Day-of and crock-pot start-by reminders (Feature 20) #177
@@ -198,6 +198,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
if err := sched.Register(planSvc.DraftSchedule()); err != nil {
|
if err := sched.Register(planSvc.DraftSchedule()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
for _, sch := range planSvc.ReminderSchedules() {
|
||||||
|
if err := sched.Register(sch); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, runner, photos, frontend.Assets())
|
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, runner, photos, frontend.Assets())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+24
-5
@@ -782,7 +782,7 @@ purge are fixed and not configurable.
|
|||||||
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests (`suggest.Service.WeeklySchedule`). |
|
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests (`suggest.Service.WeeklySchedule`). |
|
||||||
| `plan_draft` | Friday 09:00 | AI drafts next week's plan (`plan.Service.DraftSchedule`). |
|
| `plan_draft` | Friday 09:00 | AI drafts next week's plan (`plan.Service.DraftSchedule`). |
|
||||||
| `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. |
|
| `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. |
|
||||||
| `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. |
|
| `day_of_reminder` | Daily 16:30, and 15 minutes before a crock-pot start_by earlier than that | Tonight's dinner reminder and the crock-pot start reminder (`plan.Service.ReminderSchedules`). |
|
||||||
| `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. |
|
| `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. |
|
||||||
| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive. |
|
| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive. |
|
||||||
| `ai_runs` purge | Daily (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (Feature 20). |
|
| `ai_runs` purge | Daily (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (Feature 20). |
|
||||||
@@ -790,7 +790,7 @@ purge are fixed and not configurable.
|
|||||||
Runs are recorded in `schedule_runs` and executed through `jobs`.
|
Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||||
|
|
||||||
`internal/scheduler` runs them. A schedule registers a name, a rule read
|
`internal/scheduler` runs them. A schedule registers a name, a rule read
|
||||||
from the settings (`scheduler.Daily`, `Weekly` or `WeeklyForNextWeek`) and a
|
from the settings (`scheduler.Daily`, `Weekly` or `WeeklyForNextWeek`, or `DailyAt` for a time read from data) and a
|
||||||
run function; settings changes reach it through `SettingsService.Subscribe`
|
run function; settings changes reach it through `SettingsService.Subscribe`
|
||||||
and move the next fire time without a restart. Each fire time has a period
|
and move the next fire time without a restart. Each fire time has a period
|
||||||
key (`Rule.PeriodKey`): the local date for a daily rule, the ISO week of the
|
key (`Rule.PeriodKey`): the local date for a daily rule, the ISO week of the
|
||||||
@@ -822,6 +822,19 @@ draft emits `plan_ready:{weekStart}` from the job and a failed one (e.g.
|
|||||||
`ai_failed`, recorded in `ai_runs`) emits nothing. When the week already
|
`ai_failed`, recorded in `ai_runs`) emits nothing. When the week already
|
||||||
has a plan, in any status, it enqueues nothing; when a draft of the week
|
has a plan, in any status, it enqueues nothing; when a draft of the week
|
||||||
is already queued or running it joins it. Either way it logs it.
|
is already queued or running it joins it. Either way it logs it.
|
||||||
|
`plan.Service.ReminderSchedules()` is `day_of_reminder`, two schedules
|
||||||
|
of one name. The first is `Daily(reminder_time)`, period `YYYY-MM-DD`,
|
||||||
|
and calls `EmitDayOfReminder(period.Date)`. The second has `Key` `start`,
|
||||||
|
so its periods are `start:YYYY-MM-DD`, and a `scheduler.DailyAt` rule:
|
||||||
|
for each date it reads the plan and fires 15 minutes
|
||||||
|
(`plan.StartReminderLead`) before the day's `start_by` (the same
|
||||||
|
computation the plan API shows) when the dinner is a crock-pot dinner
|
||||||
|
whose `start_by` is earlier than that day's `reminder_time`; its run
|
||||||
|
calls `EmitStartReminder(period.Date)`, which checks the same again. A
|
||||||
|
date whose week has no plan emits neither and logs it. A `DailyAt` fire
|
||||||
|
time is looked up from the data on every pass, so a plan change moves it
|
||||||
|
within an hour (`maxWait`); a lookup that fails is logged and repeated
|
||||||
|
every `ClaimRetry`.
|
||||||
Fake-clock tests of a schedule live in `internal/scheduler`, where
|
Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||||
`SetClock` is visible to tests.
|
`SetClock` is visible to tests.
|
||||||
|
|
||||||
@@ -857,9 +870,15 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
|||||||
notifies. This is the only place `new_suggestions` is emitted; the
|
notifies. This is the only place `new_suggestions` is emitted; the
|
||||||
`suggestions_weekly` schedule does not emit it itself.
|
`suggestions_weekly` schedule does not emit it itself.
|
||||||
- `day_of_reminder:{date}` (URL `/dinner/{id}`) through
|
- `day_of_reminder:{date}` (URL `/dinner/{id}`) through
|
||||||
`plan.Service.EmitDayOfReminder(date)`, which names the dinner planned
|
`plan.Service.EmitDayOfReminder(date)`, "Tonight: {dinner} · Recipe →",
|
||||||
on the date and emits nothing for a week with no plan. The scheduler
|
naming the dinner planned on the date; it emits nothing for a week
|
||||||
calls it at `reminder_time` (Feature 20).
|
with no plan. The `day_of_reminder` schedule calls it at
|
||||||
|
`reminder_time`.
|
||||||
|
- `day_of_reminder:start:{date}` (URL `/dinner/{id}`) through
|
||||||
|
`plan.Service.EmitStartReminder(date)`, "Start the crock pot by
|
||||||
|
{time}" with the dinner's name, only for a crock-pot dinner whose
|
||||||
|
`start_by` is earlier than `reminder_time`. The `day_of_reminder`
|
||||||
|
schedule calls it 15 minutes before `start_by`.
|
||||||
- Delivery goes through `notification_outbox`.
|
- Delivery goes through `notification_outbox`.
|
||||||
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
|
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
|
||||||
webhook.
|
webhook.
|
||||||
|
|||||||
@@ -65,12 +65,27 @@ func ListReady(week store.LocalDate, needed, dinners int) notify.Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DayOfReminder is the day_of_reminder event of the dinner dinnerID, named
|
// DayOfReminder is the day_of_reminder event of the dinner dinnerID, named
|
||||||
// name, planned on date.
|
// name, planned on date: "Tonight: Tacos · Recipe →", a title carrying its
|
||||||
|
// own call to action.
|
||||||
func DayOfReminder(date store.LocalDate, dinnerID, name string) notify.Event {
|
func DayOfReminder(date store.LocalDate, dinnerID, name string) notify.Event {
|
||||||
return notify.Event{
|
return notify.Event{
|
||||||
Type: notify.DayOfReminder,
|
Type: notify.DayOfReminder,
|
||||||
Key: string(notify.DayOfReminder) + ":" + date.String(),
|
Key: string(notify.DayOfReminder) + ":" + date.String(),
|
||||||
Title: "Tonight's dinner",
|
Title: "Tonight: " + name + " · Recipe →",
|
||||||
|
URL: "/dinner/" + dinnerID,
|
||||||
|
Week: household.WeekStart(date),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartReminder is the day_of_reminder event, keyed
|
||||||
|
// day_of_reminder:start:{date}, reminding the household to start the
|
||||||
|
// crock-pot dinner dinnerID, named name and planned on date, by startBy,
|
||||||
|
// a time in the household zone: "Start the crock pot by 9:30 am".
|
||||||
|
func StartReminder(date store.LocalDate, dinnerID, name string, startBy time.Time) notify.Event {
|
||||||
|
return notify.Event{
|
||||||
|
Type: notify.DayOfReminder,
|
||||||
|
Key: string(notify.DayOfReminder) + ":start:" + date.String(),
|
||||||
|
Title: "Start the crock pot by " + startBy.Format("3:04 pm"),
|
||||||
Body: name,
|
Body: name,
|
||||||
URL: "/dinner/" + dinnerID,
|
URL: "/dinner/" + dinnerID,
|
||||||
Week: household.WeekStart(date),
|
Week: household.WeekStart(date),
|
||||||
@@ -173,9 +188,13 @@ func planTitle(week, thisWeek store.LocalDate) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Text is the Google Chat message of p: its title, its body after a colon
|
// Text is the Google Chat message of p: its title, its body after a colon
|
||||||
// and the event's call to action with an arrow.
|
// and the event's call to action with an arrow. A title ending in an arrow,
|
||||||
|
// with no body, carries its own call to action and is the message as it is.
|
||||||
func Text(p notify.Payload) string {
|
func Text(p notify.Payload) string {
|
||||||
text := p.Title
|
text := p.Title
|
||||||
|
if p.Body == "" && strings.HasSuffix(text, "→") {
|
||||||
|
return text
|
||||||
|
}
|
||||||
if p.Body != "" {
|
if p.Body != "" {
|
||||||
if endsSentence(text) {
|
if endsSentence(text) {
|
||||||
text += " "
|
text += " "
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package chat
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
@@ -100,9 +101,19 @@ func TestMessages(t *testing.T) {
|
|||||||
key: "day_of_reminder:2026-10-15",
|
key: "day_of_reminder:2026-10-15",
|
||||||
url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
|
url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
|
||||||
week: "2026-10-12",
|
week: "2026-10-12",
|
||||||
title: "Tonight's dinner",
|
title: "Tonight: Tacos · Recipe →",
|
||||||
body: "Tacos",
|
body: "",
|
||||||
chatT: "Tonight's dinner: Tacos. Open the recipe →",
|
chatT: "Tonight: Tacos · Recipe →",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "crock-pot start reminder",
|
||||||
|
ev: StartReminder(mustDate(t, "2026-10-15"), "01J9Z8Y7X6W5V4T3S2R1Q0P9N8", "Beef Chili", time.Date(2026, 10, 15, 9, 30, 0, 0, time.FixedZone("CDT", -5*60*60))),
|
||||||
|
key: "day_of_reminder:start:2026-10-15",
|
||||||
|
url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
|
||||||
|
week: "2026-10-12",
|
||||||
|
title: "Start the crock pot by 9:30 am",
|
||||||
|
body: "Beef Chili",
|
||||||
|
chatT: "Start the crock pot by 9:30 am: Beef Chili. Open the recipe →",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "new suggestions",
|
name: "new suggestions",
|
||||||
|
|||||||
@@ -233,10 +233,10 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (p Plan, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// EmitDayOfReminder emits day_of_reminder for date, keyed by the date,
|
// EmitDayOfReminder emits day_of_reminder for date, keyed by the date,
|
||||||
// naming the dinner planned on it, and reports whether it did: a date whose
|
// "Tonight: {dinner} · Recipe →" for the dinner planned on it, and reports
|
||||||
// week has no plan emits nothing. The scheduler calls it at the
|
// whether it did: a date whose week has no plan emits nothing. The
|
||||||
// household's reminder_time (Feature 20); emitting a date again stores no
|
// day_of_reminder schedule calls it at the household's reminder_time
|
||||||
// second notification.
|
// (ReminderSchedules); emitting a date again stores no second notification.
|
||||||
func (s *Service) EmitDayOfReminder(ctx context.Context, date store.LocalDate) (bool, error) {
|
func (s *Service) EmitDayOfReminder(ctx context.Context, date store.LocalDate) (bool, error) {
|
||||||
p, err := s.Get(ctx, household.WeekStart(date))
|
p, err := s.Get(ctx, household.WeekStart(date))
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
@@ -245,15 +245,14 @@ func (s *Service) EmitDayOfReminder(ctx context.Context, date store.LocalDate) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
for _, d := range p.Days {
|
d, err := p.day(date)
|
||||||
if d.Date == date.String() {
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
if err := s.notifier.Emit(ctx, chat.DayOfReminder(date, d.Dinner.ID, d.Dinner.Name)); err != nil {
|
if err := s.notifier.Emit(ctx, chat.DayOfReminder(date, d.Dinner.ID, d.Dinner.Name)); err != nil {
|
||||||
return false, fmt.Errorf("plan: %w", err)
|
return false, fmt.Errorf("plan: %w", err)
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, fmt.Errorf("plan: week %s has no day %s", p.WeekStart, date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// readPlan reads the plan of the week starting weekStart through q, with
|
// readPlan reads the plan of the week starting weekStart through q, with
|
||||||
|
|||||||
@@ -428,8 +428,8 @@ func TestEmitDayOfReminder(t *testing.T) {
|
|||||||
t.Fatalf("day_of_reminder rows = %+v, want one on push", rows)
|
t.Fatalf("day_of_reminder rows = %+v, want one on push", rows)
|
||||||
}
|
}
|
||||||
r := rows[0]
|
r := rows[0]
|
||||||
if r.Key != "day_of_reminder:2026-10-14" || r.Channel != notify.ChannelPush || r.Payload.Title != "Tonight's dinner" ||
|
if r.Key != "day_of_reminder:2026-10-14" || r.Channel != notify.ChannelPush || r.Payload.Title != "Tonight: Pad Thai · Recipe →" ||
|
||||||
r.Payload.Body != "Pad Thai" || r.Payload.URL != "/dinner/"+e.ids["Pad Thai"] || r.Payload.Week != "2026-10-12" {
|
r.Payload.Body != "" || r.Payload.URL != "/dinner/"+e.ids["Pad Thai"] || r.Payload.Week != "2026-10-12" {
|
||||||
t.Errorf("day_of_reminder row = %+v", r)
|
t.Errorf("day_of_reminder row = %+v", r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package plan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartReminderLead is how long before a crock-pot dinner's start_by its
|
||||||
|
// start reminder fires.
|
||||||
|
const StartReminderLead = 15 * time.Minute
|
||||||
|
|
||||||
|
// StartReminderKey is the period key prefix of the crock-pot start
|
||||||
|
// reminder: its periods are start:YYYY-MM-DD of the day_of_reminder
|
||||||
|
// schedule.
|
||||||
|
const StartReminderKey = "start"
|
||||||
|
|
||||||
|
// ReminderSchedules are the day_of_reminder schedules. The first fires
|
||||||
|
// every day at the household's reminder_time, period YYYY-MM-DD, and emits
|
||||||
|
// day_of_reminder:YYYY-MM-DD for tonight's dinner (EmitDayOfReminder). The
|
||||||
|
// second fires StartReminderLead before the start_by of a crock-pot dinner
|
||||||
|
// whose start_by is earlier than that day's reminder_time, period
|
||||||
|
// start:YYYY-MM-DD of the dinner's date, and emits
|
||||||
|
// day_of_reminder:start:YYYY-MM-DD (EmitStartReminder). A date whose week
|
||||||
|
// has no plan emits neither.
|
||||||
|
func (s *Service) ReminderSchedules() []scheduler.Schedule {
|
||||||
|
return []scheduler.Schedule{
|
||||||
|
{
|
||||||
|
Name: scheduler.DayOfReminder,
|
||||||
|
Rule: func(settings household.Settings) scheduler.Rule {
|
||||||
|
return scheduler.Daily(settings.ReminderTime)
|
||||||
|
},
|
||||||
|
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||||
|
sent, err := s.EmitDayOfReminder(ctx, p.Date)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !sent {
|
||||||
|
s.logger.InfoContext(ctx, "day-of reminder not sent: the week has no plan",
|
||||||
|
"period", p.Key, "date", p.Date.String())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: scheduler.DayOfReminder,
|
||||||
|
Key: StartReminderKey,
|
||||||
|
Rule: func(household.Settings) scheduler.Rule {
|
||||||
|
return scheduler.DailyAt(s.startReminderAt)
|
||||||
|
},
|
||||||
|
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||||
|
sent, err := s.EmitStartReminder(ctx, p.Date)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !sent {
|
||||||
|
s.logger.InfoContext(ctx, "start reminder not sent: no crock-pot dinner to start before the reminder",
|
||||||
|
"period", p.Key, "date", p.Date.String())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmitStartReminder emits day_of_reminder:start:{date}, "Start the crock
|
||||||
|
// pot by {time}", for the crock-pot dinner planned on date, and reports
|
||||||
|
// whether it did: it emits nothing unless date's dinner has a start_by
|
||||||
|
// earlier than date's reminder_time. Emitting a date again stores no
|
||||||
|
// second notification.
|
||||||
|
func (s *Service) EmitStartReminder(ctx context.Context, date store.LocalDate) (bool, error) {
|
||||||
|
d, ok, err := s.startReminder(ctx, date)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := s.notifier.Emit(ctx, chat.StartReminder(date, d.Dinner.ID, d.Dinner.Name, *d.StartBy)); err != nil {
|
||||||
|
return false, fmt.Errorf("plan: %w", err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startReminderAt is the start reminder's scheduler.FireAt: the
|
||||||
|
// StartReminderLead before start_by of the dinner date's start reminder,
|
||||||
|
// if it has one.
|
||||||
|
func (s *Service) startReminderAt(ctx context.Context, date store.LocalDate) (time.Time, bool, error) {
|
||||||
|
d, ok, err := s.startReminder(ctx, date)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return time.Time{}, false, err
|
||||||
|
}
|
||||||
|
return d.StartBy.Add(-StartReminderLead), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startReminder returns the plan day of date when it has a start reminder:
|
||||||
|
// its dinner has a start_by (a crock-pot dinner of known minutes) earlier
|
||||||
|
// than the household's reminder_time on date. The plan and the settings
|
||||||
|
// come from one read transaction.
|
||||||
|
func (s *Service) startReminder(ctx context.Context, date store.LocalDate) (d Day, ok bool, err error) {
|
||||||
|
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return Day{}, false, fmt.Errorf("plan: begin read: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
err = errors.Join(err, tx.Rollback())
|
||||||
|
}()
|
||||||
|
q := db.New(tx)
|
||||||
|
p, err := readPlan(ctx, q, household.WeekStart(date))
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return Day{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Day{}, false, err
|
||||||
|
}
|
||||||
|
d, err = p.day(date)
|
||||||
|
if err != nil {
|
||||||
|
return Day{}, false, err
|
||||||
|
}
|
||||||
|
if d.StartBy == nil {
|
||||||
|
return Day{}, false, nil
|
||||||
|
}
|
||||||
|
settings, err := q.GetHouseholdSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Day{}, false, fmt.Errorf("plan: read household settings: %w", err)
|
||||||
|
}
|
||||||
|
zone, err := household.LoadZone(settings.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return Day{}, false, fmt.Errorf("plan: household settings: %w", err)
|
||||||
|
}
|
||||||
|
remind, err := household.LocalTime(zone, date, settings.ReminderTime)
|
||||||
|
if err != nil {
|
||||||
|
return Day{}, false, fmt.Errorf("plan: reminder time of %s: %w", date, err)
|
||||||
|
}
|
||||||
|
return d, d.StartBy.Before(remind), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// day returns p's day on date; a plan holds all seven days of its week.
|
||||||
|
func (p Plan) day(date store.LocalDate) (Day, error) {
|
||||||
|
for _, d := range p.Days {
|
||||||
|
if d.Date == date.String() {
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Day{}, fmt.Errorf("plan: week %s has no day %s", p.WeekStart, date)
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package scheduler_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The tests in this file run plan's day_of_reminder schedules on the fake
|
||||||
|
// clock against the real plan service and notification outbox. The plan of
|
||||||
|
// the week of Monday Oct 12 (w42) has poolNames Monday to Sunday: Beef
|
||||||
|
// Chili on Monday, Pot Roast on Tuesday. The household's dinner time is the
|
||||||
|
// seeded 17:30 and its reminder time the seeded 16:30, in Chicago.
|
||||||
|
|
||||||
|
// w42 is the Monday of 2026-W42.
|
||||||
|
var w42 = mustLocalDate("2026-10-12")
|
||||||
|
|
||||||
|
// planWeek drafts the plan of the week starting weekStart and waits for it.
|
||||||
|
func (p *planEnv) planWeek(weekStart store.LocalDate) {
|
||||||
|
p.e.t.Helper()
|
||||||
|
p.reply(weekStart)
|
||||||
|
if _, err := p.svc.StartDraft(context.Background(), weekStart, false); err != nil {
|
||||||
|
p.e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
if jobs := p.e.drafts(); jobs[len(jobs)-1] != succeeded {
|
||||||
|
p.e.t.Fatalf("draft of %s = %+v, want succeeded", weekStart, jobs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// crockPot makes the dinner name a crock-pot dinner of minutes.
|
||||||
|
func (p *planEnv) crockPot(name string, minutes int) {
|
||||||
|
p.e.t.Helper()
|
||||||
|
if _, err := p.e.st.Writer.Exec(`UPDATE dinners SET method = 'crockpot', minutes_total = ? WHERE id = ?`, minutes, p.ids[name]); err != nil {
|
||||||
|
p.e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reminders returns the day_of_reminder notifications stored, oldest
|
||||||
|
// first. The seeded preference sends day_of_reminder to push only, so there
|
||||||
|
// is one row per event.
|
||||||
|
func (e *env) reminders() []notifytest.Row {
|
||||||
|
e.t.Helper()
|
||||||
|
rows := notifytest.Rows(e.t, e.st, notify.DayOfReminder)
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Channel != notify.ChannelPush {
|
||||||
|
e.t.Fatalf("day_of_reminder row %+v, want push only", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// reminderKeys returns the keys of e.reminders().
|
||||||
|
func (e *env) reminderKeys() []string {
|
||||||
|
e.t.Helper()
|
||||||
|
var keys []string
|
||||||
|
for _, r := range e.reminders() {
|
||||||
|
keys = append(keys, r.Key)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantReminder fails unless the reminder keyed key was stored with title,
|
||||||
|
// body and the link to the dinner name.
|
||||||
|
func (p *planEnv) wantReminder(key, title, body, name string) {
|
||||||
|
p.e.t.Helper()
|
||||||
|
for _, r := range p.e.reminders() {
|
||||||
|
if r.Key != key {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
week := household.WeekStart(mustLocalDate(key[len(key)-len("YYYY-MM-DD"):]))
|
||||||
|
want := notify.Payload{Event: notify.DayOfReminder, Title: title, Body: body, URL: "/dinner/" + p.ids[name], Tag: key, Week: week.String()}
|
||||||
|
if r.Payload != want {
|
||||||
|
p.e.t.Errorf("reminder %s = %+v, want %+v", key, r.Payload, want)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.e.t.Errorf("no reminder %s", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDayOfReminderNormalNight: a night whose dinner is not a crock-pot
|
||||||
|
// dinner gets exactly the daily reminder, at reminder_time 16:30, keyed
|
||||||
|
// day_of_reminder:YYYY-MM-DD and linking to the dinner.
|
||||||
|
func TestDayOfReminderNormalNight(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
p := e.plans(e.pool())
|
||||||
|
p.planWeek(w42)
|
||||||
|
|
||||||
|
// Monday 08:00: the catch-up of Sunday 16:30 finds no plan for W41.
|
||||||
|
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||||
|
r.runUntil(at(2026, 10, 12, 16, 29))
|
||||||
|
wantKeys(t, "before 16:30", e.reminderKeys(), nil)
|
||||||
|
|
||||||
|
r.runUntil(at(2026, 10, 12, 16, 30))
|
||||||
|
wantKeys(t, "at 16:30", e.reminderKeys(), []string{"day_of_reminder:2026-10-12"})
|
||||||
|
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Beef Chili · Recipe →", "", "Beef Chili")
|
||||||
|
|
||||||
|
r.runUntil(at(2026, 10, 13, 23, 59))
|
||||||
|
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
|
||||||
|
p.wantReminder("day_of_reminder:2026-10-13", "Tonight: Pot Roast · Recipe →", "", "Pot Roast")
|
||||||
|
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDayOfReminderCrockPotNight: a crock-pot night whose start_by is
|
||||||
|
// earlier than reminder_time gets the start reminder at start_by minus 15
|
||||||
|
// minutes, keyed day_of_reminder:start:YYYY-MM-DD, and the daily reminder
|
||||||
|
// at reminder_time. A crock-pot night whose start_by is not earlier than
|
||||||
|
// reminder_time gets only the daily one.
|
||||||
|
func TestDayOfReminderCrockPotNight(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
p := e.plans(e.pool())
|
||||||
|
p.crockPot("Beef Chili", 480) // Monday: start by 09:30
|
||||||
|
p.crockPot("Pot Roast", 30) // Tuesday: start by 17:00, after 16:30
|
||||||
|
p.planWeek(w42)
|
||||||
|
|
||||||
|
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||||
|
r.runUntil(at(2026, 10, 12, 9, 14))
|
||||||
|
wantKeys(t, "before 09:15", e.reminderKeys(), nil)
|
||||||
|
|
||||||
|
r.runUntil(at(2026, 10, 12, 9, 15))
|
||||||
|
wantKeys(t, "at 09:15", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||||
|
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 9:30 am", "Beef Chili", "Beef Chili")
|
||||||
|
|
||||||
|
r.runUntil(at(2026, 10, 12, 16, 29))
|
||||||
|
wantKeys(t, "before 16:30", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||||
|
r.runUntil(at(2026, 10, 12, 16, 30))
|
||||||
|
wantKeys(t, "at 16:30", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
|
||||||
|
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Beef Chili · Recipe →", "", "Beef Chili")
|
||||||
|
|
||||||
|
r.runUntil(at(2026, 10, 13, 23, 59))
|
||||||
|
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
|
||||||
|
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13", "start:2026-10-12"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDayOfReminderEmptyNight: with no plan for tonight's week, here none
|
||||||
|
// at all and then only next week's, nothing is emitted and nothing fails;
|
||||||
|
// next week's crock-pot Monday is reminded on its own day.
|
||||||
|
func TestDayOfReminderEmptyNight(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
p := e.plans(e.pool())
|
||||||
|
|
||||||
|
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||||
|
r.runUntil(at(2026, 10, 13, 23, 59))
|
||||||
|
wantKeys(t, "with no plan", e.reminderKeys(), nil)
|
||||||
|
|
||||||
|
p.crockPot("Beef Chili", 480)
|
||||||
|
p.planWeek(w43)
|
||||||
|
r.runUntil(at(2026, 10, 18, 23, 59))
|
||||||
|
wantKeys(t, "with only next week's plan", e.reminderKeys(), nil)
|
||||||
|
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder),
|
||||||
|
[]string{"2026-10-11", "2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"})
|
||||||
|
if logs := p.logs.String(); !strings.Contains(logs, "day-of reminder not sent: the week has no plan") || strings.Contains(logs, "level=ERROR") {
|
||||||
|
t.Errorf("logs = %s, want the empty nights logged and no error", logs)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.runUntil(at(2026, 10, 19, 9, 15))
|
||||||
|
wantKeys(t, "next Monday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-19"})
|
||||||
|
p.wantReminder("day_of_reminder:start:2026-10-19", "Start the crock pot by 9:30 am", "Beef Chili", "Beef Chili")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDayOfReminderFollowsReminderTimeWithoutRestart: a reminder_time
|
||||||
|
// change takes effect in the running scheduler. Moved from 16:30 to 18:00,
|
||||||
|
// the daily reminder fires at 18:00, not 16:30, and a crock-pot start_by
|
||||||
|
// of 17:00, now earlier than reminder_time, gets its start reminder at
|
||||||
|
// 16:45.
|
||||||
|
func TestDayOfReminderFollowsReminderTimeWithoutRestart(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
p := e.plans(e.pool())
|
||||||
|
p.crockPot("Beef Chili", 30) // start by 17:00
|
||||||
|
p.planWeek(w42)
|
||||||
|
|
||||||
|
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||||
|
e.update(household.SettingsPatch{ReminderTime: ptr("18:00")})
|
||||||
|
r.changed()
|
||||||
|
r.runUntil(at(2026, 10, 12, 16, 44))
|
||||||
|
wantKeys(t, "before 16:45", e.reminderKeys(), nil)
|
||||||
|
r.runUntil(at(2026, 10, 12, 16, 45))
|
||||||
|
wantKeys(t, "at 16:45", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||||
|
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 5:00 pm", "Beef Chili", "Beef Chili")
|
||||||
|
r.runUntil(at(2026, 10, 12, 17, 59))
|
||||||
|
wantKeys(t, "before 18:00", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||||
|
r.runUntil(at(2026, 10, 12, 18, 0))
|
||||||
|
wantKeys(t, "at 18:00", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDayOfReminderNoDuplicateAfterRestart: restarts after each reminder
|
||||||
|
// of a crock-pot night, within the catch-up window, emit neither again:
|
||||||
|
// each period is already claimed.
|
||||||
|
func TestDayOfReminderNoDuplicateAfterRestart(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
ids := e.pool()
|
||||||
|
p := e.plans(ids)
|
||||||
|
p.crockPot("Beef Chili", 480)
|
||||||
|
p.planWeek(w42)
|
||||||
|
|
||||||
|
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||||
|
r.runUntil(at(2026, 10, 12, 9, 15))
|
||||||
|
r.stop()
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
p2 := e.plans(ids)
|
||||||
|
r2 := e.start(at(2026, 10, 12, 9, 20), p2.svc.ReminderSchedules()...)
|
||||||
|
wantKeys(t, "after the first restart", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||||
|
r2.runUntil(at(2026, 10, 12, 16, 30))
|
||||||
|
r2.stop()
|
||||||
|
p2.stop()
|
||||||
|
|
||||||
|
p3 := e.plans(ids)
|
||||||
|
r3 := e.start(at(2026, 10, 12, 16, 40), p3.svc.ReminderSchedules()...)
|
||||||
|
r3.runUntil(at(2026, 10, 13, 9, 0))
|
||||||
|
wantKeys(t, "after the second restart", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
|
||||||
|
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "start:2026-10-12"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDailyAtLookupFailureIsRetried: a DailyAt rule fires at the instant
|
||||||
|
// its lookup gives, its period keyed by the schedule's Key and the date;
|
||||||
|
// a lookup that fails fires nothing and is looked up again after
|
||||||
|
// ClaimRetry, then fires its period once.
|
||||||
|
func TestDailyAtLookupFailureIsRetried(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
var rec recorder
|
||||||
|
var mu sync.Mutex
|
||||||
|
failing := false
|
||||||
|
at915 := func(ctx context.Context, d store.LocalDate) (time.Time, bool, error) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if failing {
|
||||||
|
return time.Time{}, false, errors.New("database down")
|
||||||
|
}
|
||||||
|
y, m, day := d.Date()
|
||||||
|
return at(y, m, day, 9, 15), true, nil
|
||||||
|
}
|
||||||
|
sch := scheduler.Schedule{
|
||||||
|
Name: scheduler.DayOfReminder,
|
||||||
|
Key: "start",
|
||||||
|
Rule: func(household.Settings) scheduler.Rule { return scheduler.DailyAt(at915) },
|
||||||
|
Run: rec.run,
|
||||||
|
}
|
||||||
|
|
||||||
|
r := e.start(at(2026, 10, 12, 8, 0), sch)
|
||||||
|
wantKeys(t, "catch-up", rec.keys(), []string{"start:2026-10-11"})
|
||||||
|
mu.Lock()
|
||||||
|
failing = true
|
||||||
|
mu.Unlock()
|
||||||
|
r.runUntil(at(2026, 10, 12, 9, 16))
|
||||||
|
wantKeys(t, "while the lookup fails", rec.keys(), []string{"start:2026-10-11"})
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
failing = false
|
||||||
|
mu.Unlock()
|
||||||
|
r.runUntil(at(2026, 10, 12, 9, 17))
|
||||||
|
wantKeys(t, "after the lookup recovers", rec.keys(), []string{"start:2026-10-11", "start:2026-10-12"})
|
||||||
|
if p := rec.periods()[1]; !p.FireAt.Equal(at(2026, 10, 12, 9, 15)) || p.Date.String() != "2026-10-12" {
|
||||||
|
t.Errorf("period = %+v, want fired at 09:15 for 2026-10-12", p)
|
||||||
|
}
|
||||||
|
r.runUntil(at(2026, 10, 13, 12, 0))
|
||||||
|
wantKeys(t, "next day", rec.keys(), []string{"start:2026-10-11", "start:2026-10-12", "start:2026-10-13"})
|
||||||
|
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), rec.keys())
|
||||||
|
}
|
||||||
+162
-46
@@ -44,6 +44,17 @@
|
|||||||
// occurrence. Either way the period key is that local date's, so the
|
// occurrence. Either way the period key is that local date's, so the
|
||||||
// period still runs exactly once.
|
// period still runs exactly once.
|
||||||
//
|
//
|
||||||
|
// # Fire times from data
|
||||||
|
//
|
||||||
|
// A DailyAt rule fires at a time read from the household's data rather
|
||||||
|
// than the settings: its function gives, for each household-local date,
|
||||||
|
// the instant the date's period fires, or none. The period is that date
|
||||||
|
// even when the instant falls on another day. The scheduler asks again on
|
||||||
|
// every pass, so a change to the data moves the fire time by the next pass,
|
||||||
|
// at most maxWait after the change; the claim still runs a period at most
|
||||||
|
// once. A lookup that fails is logged and repeated every ClaimRetry, as a
|
||||||
|
// failed claim is.
|
||||||
|
//
|
||||||
// # Settings changes
|
// # Settings changes
|
||||||
//
|
//
|
||||||
// The scheduler subscribes to the household settings and records each change
|
// The scheduler subscribes to the household settings and records each change
|
||||||
@@ -59,6 +70,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -102,8 +114,8 @@ const ClaimRetry = time.Minute
|
|||||||
const maxWait = time.Hour
|
const maxWait = time.Hour
|
||||||
|
|
||||||
// Rule is when a schedule fires: at household-local Time ("HH:MM") every
|
// Rule is when a schedule fires: at household-local Time ("HH:MM") every
|
||||||
// day, or on Weekday only when Weekly is set. Build one with Daily, Weekly
|
// day, or on Weekday only when Weekly is set, or every day at the instant
|
||||||
// or WeeklyForNextWeek.
|
// At gives. Build one with Daily, Weekly, WeeklyForNextWeek or DailyAt.
|
||||||
type Rule struct {
|
type Rule struct {
|
||||||
Weekly bool
|
Weekly bool
|
||||||
Weekday time.Weekday
|
Weekday time.Weekday
|
||||||
@@ -111,8 +123,15 @@ type Rule struct {
|
|||||||
// NextWeek keys a weekly period by the ISO week after the fire date,
|
// NextWeek keys a weekly period by the ISO week after the fire date,
|
||||||
// for work done ahead for the coming week.
|
// for work done ahead for the coming week.
|
||||||
NextWeek bool
|
NextWeek bool
|
||||||
|
// At, instead of Time, gives the instant the period of a
|
||||||
|
// household-local date fires.
|
||||||
|
At FireAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FireAt gives the instant the period of household-local date fires, or
|
||||||
|
// false when that date's period does not fire.
|
||||||
|
type FireAt func(ctx context.Context, date store.LocalDate) (time.Time, bool, error)
|
||||||
|
|
||||||
// Daily fires every day at hhmm; its period is the local date.
|
// Daily fires every day at hhmm; its period is the local date.
|
||||||
func Daily(hhmm string) Rule {
|
func Daily(hhmm string) Rule {
|
||||||
return Rule{Time: hhmm}
|
return Rule{Time: hhmm}
|
||||||
@@ -129,6 +148,12 @@ func WeeklyForNextWeek(day time.Weekday, hhmm string) Rule {
|
|||||||
return Rule{Weekly: true, Weekday: day, Time: hhmm, NextWeek: true}
|
return Rule{Weekly: true, Weekday: day, Time: hhmm, NextWeek: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DailyAt fires at most once a day, at the instant at gives for the date;
|
||||||
|
// its period is that date, wherever the instant falls.
|
||||||
|
func DailyAt(at FireAt) Rule {
|
||||||
|
return Rule{At: at}
|
||||||
|
}
|
||||||
|
|
||||||
// PeriodKey returns the period key of a fire on household-local date
|
// PeriodKey returns the period key of a fire on household-local date
|
||||||
// fireDate: the date ("2026-10-09") for a daily rule, the ISO week
|
// fireDate: the date ("2026-10-09") for a daily rule, the ISO week
|
||||||
// ("2026-W53") for a weekly one, and the ISO week after that ("2027-W01")
|
// ("2026-W53") for a weekly one, and the ISO week after that ("2027-W01")
|
||||||
@@ -151,6 +176,12 @@ func (r Rule) validate() error {
|
|||||||
if !r.Weekly && (r.Weekday != time.Sunday || r.NextWeek) {
|
if !r.Weekly && (r.Weekday != time.Sunday || r.NextWeek) {
|
||||||
return errors.New("a daily rule has no weekday or next week")
|
return errors.New("a daily rule has no weekday or next week")
|
||||||
}
|
}
|
||||||
|
if r.At != nil {
|
||||||
|
if r.Weekly || r.Time != "" {
|
||||||
|
return errors.New("a DailyAt rule has no weekday or time")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
// Any date checks the time's form.
|
// Any date checks the time's form.
|
||||||
_, err := household.LocalTime(time.UTC, store.LocalDateOf(time.Unix(0, 0).UTC()), r.Time)
|
_, err := household.LocalTime(time.UTC, store.LocalDateOf(time.Unix(0, 0).UTC()), r.Time)
|
||||||
return err
|
return err
|
||||||
@@ -173,6 +204,10 @@ type Period struct {
|
|||||||
type Schedule struct {
|
type Schedule struct {
|
||||||
// Name is one of the schedule name constants.
|
// Name is one of the schedule name constants.
|
||||||
Name string
|
Name string
|
||||||
|
// Key, when set, prefixes the schedule's period keys with Key and a
|
||||||
|
// colon ("start:2026-10-14"), so two schedules of one Name claim
|
||||||
|
// different periods. Name and Key together are unique.
|
||||||
|
Key string
|
||||||
// Rule returns when the schedule fires under settings. It is called
|
// Rule returns when the schedule fires under settings. It is called
|
||||||
// again after every settings change.
|
// again after every settings change.
|
||||||
Rule func(settings household.Settings) Rule
|
Rule func(settings household.Settings) Rule
|
||||||
@@ -183,6 +218,14 @@ type Schedule struct {
|
|||||||
Run func(ctx context.Context, p Period) error
|
Run func(ctx context.Context, p Period) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// label names sch in errors and logs: its Name, and its Key if set.
|
||||||
|
func (sch Schedule) label() string {
|
||||||
|
if sch.Key == "" {
|
||||||
|
return sch.Name
|
||||||
|
}
|
||||||
|
return sch.Name + ":" + sch.Key
|
||||||
|
}
|
||||||
|
|
||||||
// Settings is the household settings source: *household.SettingsService.
|
// Settings is the household settings source: *household.SettingsService.
|
||||||
type Settings interface {
|
type Settings interface {
|
||||||
Get(ctx context.Context) (household.Settings, error)
|
Get(ctx context.Context) (household.Settings, error)
|
||||||
@@ -240,19 +283,22 @@ func (s *Scheduler) Register(sch Schedule) error {
|
|||||||
return fmt.Errorf("scheduler: register: unknown schedule %q", sch.Name)
|
return fmt.Errorf("scheduler: register: unknown schedule %q", sch.Name)
|
||||||
}
|
}
|
||||||
if sch.Rule == nil {
|
if sch.Rule == nil {
|
||||||
return fmt.Errorf("scheduler: register %s: nil rule", sch.Name)
|
return fmt.Errorf("scheduler: register %s: nil rule", sch.label())
|
||||||
}
|
}
|
||||||
if sch.Run == nil {
|
if sch.Run == nil {
|
||||||
return fmt.Errorf("scheduler: register %s: nil run", sch.Name)
|
return fmt.Errorf("scheduler: register %s: nil run", sch.label())
|
||||||
|
}
|
||||||
|
if strings.Contains(sch.Key, ":") {
|
||||||
|
return fmt.Errorf("scheduler: register %s: key has a colon", sch.label())
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if s.started {
|
if s.started {
|
||||||
return fmt.Errorf("scheduler: register %s: already started", sch.Name)
|
return fmt.Errorf("scheduler: register %s: already started", sch.label())
|
||||||
}
|
}
|
||||||
for _, have := range s.schedules {
|
for _, have := range s.schedules {
|
||||||
if have.Name == sch.Name {
|
if have.Name == sch.Name && have.Key == sch.Key {
|
||||||
return fmt.Errorf("scheduler: register %s: already registered", sch.Name)
|
return fmt.Errorf("scheduler: register %s: already registered", sch.label())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.schedules = append(s.schedules, sch)
|
s.schedules = append(s.schedules, sch)
|
||||||
@@ -346,7 +392,7 @@ func compile(schedules []Schedule, settings household.Settings) (*plan, error) {
|
|||||||
for i, sch := range schedules {
|
for i, sch := range schedules {
|
||||||
rule := sch.Rule(settings)
|
rule := sch.Rule(settings)
|
||||||
if err := rule.validate(); err != nil {
|
if err := rule.validate(); err != nil {
|
||||||
return nil, fmt.Errorf("scheduler: %s: %w", sch.Name, err)
|
return nil, fmt.Errorf("scheduler: %s: %w", sch.label(), err)
|
||||||
}
|
}
|
||||||
p.items[i] = item{sch: sch, rule: rule}
|
p.items[i] = item{sch: sch, rule: rule}
|
||||||
}
|
}
|
||||||
@@ -396,7 +442,12 @@ func (s *Scheduler) loop(ctx context.Context, p *plan) {
|
|||||||
case len(retry) > 0:
|
case len(retry) > 0:
|
||||||
timeout = s.after(ClaimRetry)
|
timeout = s.after(ClaimRetry)
|
||||||
case p != nil:
|
case p != nil:
|
||||||
if next, ok := p.next(now); ok {
|
next, ok, err := p.next(ctx, now)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
s.logger.ErrorContext(ctx, "scheduler: look up next fire time failed; retrying", "error", err, "retry_in", ClaimRetry.String())
|
||||||
|
timeout = s.after(ClaimRetry)
|
||||||
|
case ok:
|
||||||
timeout = s.after(min(next.Sub(now), maxWait))
|
timeout = s.after(min(next.Sub(now), maxWait))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -423,41 +474,67 @@ func (s *Scheduler) compileChange(ctx context.Context, settings household.Settin
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
// target is one schedule's period, named by the fire time it belongs to.
|
// target is one schedule's period, named by the fire time it belongs to
|
||||||
|
// and the household-local date of its period. A target whose fire time
|
||||||
|
// could not be looked up has lookup set instead, with the stretch
|
||||||
|
// (done, until] to look it up in again.
|
||||||
type target struct {
|
type target struct {
|
||||||
it item
|
it item
|
||||||
loc *time.Location
|
loc *time.Location
|
||||||
at time.Time
|
at time.Time
|
||||||
|
date store.LocalDate
|
||||||
|
|
||||||
|
lookup bool
|
||||||
|
done, until time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// fire handles, for every schedule, its most recent fire time at or before
|
// fire handles, for every schedule, its most recent fire time at or before
|
||||||
// until if it is after done; now is the current instant. It returns the
|
// until if it is after done; now is the current instant. It returns the
|
||||||
// targets whose claim failed, to be claimed again.
|
// targets whose lookup or claim failed, to be handled again.
|
||||||
func (s *Scheduler) fire(ctx context.Context, p *plan, done, until, now time.Time) []target {
|
func (s *Scheduler) fire(ctx context.Context, p *plan, done, until, now time.Time) []target {
|
||||||
var failed []target
|
var failed []target
|
||||||
for _, it := range p.items {
|
for _, it := range p.items {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return failed
|
return failed
|
||||||
}
|
}
|
||||||
at, due := fireBefore(it.rule, p.loc, until)
|
if t, ok := s.fireItem(ctx, it, p.loc, done, until, now); !ok {
|
||||||
if !due || !at.After(done) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if t := (target{it: it, loc: p.loc, at: at}); !s.handle(ctx, t, now) {
|
|
||||||
failed = append(failed, t)
|
failed = append(failed, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return failed
|
return failed
|
||||||
}
|
}
|
||||||
|
|
||||||
// retry handles targets again at now and returns those whose claim still
|
// fireItem handles it's most recent fire time at or before until if it is
|
||||||
// failed.
|
// after done. It reports false, with the target to handle again, when the
|
||||||
|
// fire time could not be looked up or its period's claim failed.
|
||||||
|
func (s *Scheduler) fireItem(ctx context.Context, it item, loc *time.Location, done, until, now time.Time) (target, bool) {
|
||||||
|
f, due, err := fireBefore(ctx, it.rule, loc, until)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, "scheduler: look up fire time failed; retrying",
|
||||||
|
"schedule", it.sch.label(), "error", err, "retry_in", ClaimRetry.String())
|
||||||
|
return target{it: it, loc: loc, lookup: true, done: done, until: until}, false
|
||||||
|
}
|
||||||
|
if !due || !f.at.After(done) {
|
||||||
|
return target{}, true
|
||||||
|
}
|
||||||
|
t := target{it: it, loc: loc, at: f.at, date: f.date}
|
||||||
|
return t, s.handle(ctx, t, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// retry handles targets again at now and returns those whose lookup or
|
||||||
|
// claim still failed.
|
||||||
func (s *Scheduler) retry(ctx context.Context, targets []target, now time.Time) []target {
|
func (s *Scheduler) retry(ctx context.Context, targets []target, now time.Time) []target {
|
||||||
var failed []target
|
var failed []target
|
||||||
for i, t := range targets {
|
for i, t := range targets {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return append(failed, targets[i:]...)
|
return append(failed, targets[i:]...)
|
||||||
}
|
}
|
||||||
|
if t.lookup {
|
||||||
|
if again, ok := s.fireItem(ctx, t.it, t.loc, t.done, t.until, now); !ok {
|
||||||
|
failed = append(failed, again)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !s.handle(ctx, t, now) {
|
if !s.handle(ctx, t, now) {
|
||||||
failed = append(failed, t)
|
failed = append(failed, t)
|
||||||
}
|
}
|
||||||
@@ -469,9 +546,11 @@ func (s *Scheduler) retry(ctx context.Context, targets []target, now time.Time)
|
|||||||
// its fire time is more than CatchUpWindow before now. It reports false
|
// its fire time is more than CatchUpWindow before now. It reports false
|
||||||
// when the claim failed and the period is still unclaimed.
|
// when the claim failed and the period is still unclaimed.
|
||||||
func (s *Scheduler) handle(ctx context.Context, t target, now time.Time) bool {
|
func (s *Scheduler) handle(ctx context.Context, t target, now time.Time) bool {
|
||||||
it, at := t.it, t.at
|
it, at, date := t.it, t.at, t.date
|
||||||
date := household.Today(at, t.loc)
|
|
||||||
period := Period{Key: it.rule.PeriodKey(date), FireAt: at, Date: date}
|
period := Period{Key: it.rule.PeriodKey(date), FireAt: at, Date: date}
|
||||||
|
if it.sch.Key != "" {
|
||||||
|
period.Key = it.sch.Key + ":" + period.Key
|
||||||
|
}
|
||||||
if it.rule.NextWeek {
|
if it.rule.NextWeek {
|
||||||
period.Week = household.WeekStart(addDays(date, 7))
|
period.Week = household.WeekStart(addDays(date, 7))
|
||||||
} else {
|
} else {
|
||||||
@@ -515,16 +594,19 @@ func (s *Scheduler) claim(ctx context.Context, name, key string, now time.Time)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// next returns the earliest fire time of any schedule after now, or false
|
// next returns the earliest fire time of any schedule after now, or false
|
||||||
// when there is no schedule.
|
// when there is none.
|
||||||
func (p *plan) next(now time.Time) (time.Time, bool) {
|
func (p *plan) next(ctx context.Context, now time.Time) (time.Time, bool, error) {
|
||||||
var next time.Time
|
var next time.Time
|
||||||
for _, it := range p.items {
|
for _, it := range p.items {
|
||||||
at, ok := fireAfter(it.rule, p.loc, now)
|
f, ok, err := fireAfter(ctx, it.rule, p.loc, now)
|
||||||
if ok && (next.IsZero() || at.Before(next)) {
|
if err != nil {
|
||||||
next = at
|
return time.Time{}, false, fmt.Errorf("%s: %w", it.sch.label(), err)
|
||||||
|
}
|
||||||
|
if ok && (next.IsZero() || f.at.Before(next)) {
|
||||||
|
next = f.at
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return next, !next.IsZero()
|
return next, !next.IsZero(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// window is how many days either side of a date fireBefore and fireAfter
|
// window is how many days either side of a date fireBefore and fireAfter
|
||||||
@@ -532,46 +614,80 @@ func (p *plan) next(now time.Time) (time.Time, bool) {
|
|||||||
// each side.
|
// each side.
|
||||||
const window = 8
|
const window = 8
|
||||||
|
|
||||||
|
// atWindow is how many days either side of a date fireBefore and fireAfter
|
||||||
|
// look up a DailyAt rule's fire times. A period firing further off is
|
||||||
|
// looked up on a later pass, which comes within maxWait.
|
||||||
|
const atWindow = 2
|
||||||
|
|
||||||
|
// fireTime is a fire time and the household-local date of its period.
|
||||||
|
type fireTime struct {
|
||||||
|
at time.Time
|
||||||
|
date store.LocalDate
|
||||||
|
}
|
||||||
|
|
||||||
// fireBefore returns the latest fire time of r in zone loc at or before t.
|
// fireBefore returns the latest fire time of r in zone loc at or before t.
|
||||||
func fireBefore(r Rule, loc *time.Location, t time.Time) (time.Time, bool) {
|
func fireBefore(ctx context.Context, r Rule, loc *time.Location, t time.Time) (fireTime, bool, error) {
|
||||||
var best time.Time
|
fs, err := fireTimes(ctx, r, loc, t)
|
||||||
for _, at := range fireTimes(r, loc, t) {
|
if err != nil {
|
||||||
if !at.After(t) && (best.IsZero() || at.After(best)) {
|
return fireTime{}, false, err
|
||||||
best = at
|
}
|
||||||
|
var best fireTime
|
||||||
|
for _, f := range fs {
|
||||||
|
if !f.at.After(t) && (best.at.IsZero() || f.at.After(best.at)) {
|
||||||
|
best = f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return best, !best.IsZero()
|
return best, !best.at.IsZero(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fireAfter returns the earliest fire time of r in zone loc after t.
|
// fireAfter returns the earliest fire time of r in zone loc after t.
|
||||||
func fireAfter(r Rule, loc *time.Location, t time.Time) (time.Time, bool) {
|
func fireAfter(ctx context.Context, r Rule, loc *time.Location, t time.Time) (fireTime, bool, error) {
|
||||||
var best time.Time
|
fs, err := fireTimes(ctx, r, loc, t)
|
||||||
for _, at := range fireTimes(r, loc, t) {
|
if err != nil {
|
||||||
if at.After(t) && (best.IsZero() || at.Before(best)) {
|
return fireTime{}, false, err
|
||||||
best = at
|
}
|
||||||
|
var best fireTime
|
||||||
|
for _, f := range fs {
|
||||||
|
if f.at.After(t) && (best.at.IsZero() || f.at.Before(best.at)) {
|
||||||
|
best = f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return best, !best.IsZero()
|
return best, !best.at.IsZero(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fireTimes returns r's fire times on the local dates within window days
|
// fireTimes returns r's fire times for the local dates within window days
|
||||||
// of t's. A rule that fails to resolve was validated at compile, so it
|
// of t's, or atWindow days for a DailyAt rule. A Time that fails to
|
||||||
// cannot.
|
// resolve was validated at compile, so it cannot; a DailyAt lookup that
|
||||||
func fireTimes(r Rule, loc *time.Location, t time.Time) []time.Time {
|
// fails is an error.
|
||||||
|
func fireTimes(ctx context.Context, r Rule, loc *time.Location, t time.Time) ([]fireTime, error) {
|
||||||
today := household.Today(t, loc)
|
today := household.Today(t, loc)
|
||||||
var out []time.Time
|
span := window
|
||||||
for i := -window; i <= window; i++ {
|
if r.At != nil {
|
||||||
|
span = atWindow
|
||||||
|
}
|
||||||
|
var out []fireTime
|
||||||
|
for i := -span; i <= span; i++ {
|
||||||
d := addDays(today, i)
|
d := addDays(today, i)
|
||||||
if r.Weekly && weekday(d) != r.Weekday {
|
if r.Weekly && weekday(d) != r.Weekday {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if r.At != nil {
|
||||||
|
at, ok, err := r.At(ctx, d)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fire time of %s: %w", d, err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
out = append(out, fireTime{at: at, date: d})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
at, err := household.LocalTime(loc, d, r.Time)
|
at, err := household.LocalTime(loc, d, r.Time)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(fmt.Sprintf("scheduler: validated rule %+v: %v", r, err))
|
panic(fmt.Sprintf("scheduler: validated rule %+v: %v", r, err))
|
||||||
}
|
}
|
||||||
out = append(out, at)
|
out = append(out, fireTime{at: at, date: d})
|
||||||
}
|
}
|
||||||
return out
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// addDays returns d plus n calendar days.
|
// addDays returns d plus n calendar days.
|
||||||
|
|||||||
Reference in New Issue
Block a user