diff --git a/cmd/server/main.go b/cmd/server/main.go index 969bdfd..081e0e6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -130,7 +130,12 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - dinnerSvc, err := dinners.NewService(db, grounder, runner, logger) + // The scheduler is made before the services whose changes wake it. + sched, err := scheduler.New(db, settings, logger) + if err != nil { + return err + } + dinnerSvc, err := dinners.NewService(db, grounder, runner, sched.Wake, logger) if err != nil { return err } @@ -152,7 +157,7 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, cfg.PublicURL, logger) + planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, sched.Wake, cfg.PublicURL, logger) if err != nil { return err } @@ -188,16 +193,17 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - sched, err := scheduler.New(db, settings, logger) - if err != nil { - return err - } if err := sched.Register(suggestSvc.WeeklySchedule()); err != nil { return err } if err := sched.Register(planSvc.DraftSchedule()); err != nil { 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()) if err != nil { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e0d9b0a..895bd43 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -782,7 +782,7 @@ purge are fixed and not configurable. | `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`). | | `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. | | `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). | @@ -790,7 +790,7 @@ purge are fixed and not configurable. Runs are recorded in `schedule_runs` and executed through `jobs`. `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` 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 @@ -822,6 +822,24 @@ 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 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. +`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. A committed change that +can move a `start_by` calls `Scheduler.Wake` (passed to `plan.NewService` +and `dinners.NewService` as `wake`): a plan swap or stored draft, and a +dinner's method or minutes changed by edit or grounding. The next pass +comes at once, so a crock-pot dinner swapped in for tonight is still +reminded 15 minutes before its `start_by`, not at the next hourly timer +(`maxWait`); a lookup that fails is logged and repeated every +`ClaimRetry`. Fake-clock tests of a schedule live in `internal/scheduler`, where `SetClock` is visible to tests. @@ -857,9 +875,15 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where notifies. This is the only place `new_suggestions` is emitted; the `suggestions_weekly` schedule does not emit it itself. - `day_of_reminder:{date}` (URL `/dinner/{id}`) through - `plan.Service.EmitDayOfReminder(date)`, which names the dinner planned - on the date and emits nothing for a week with no plan. The scheduler - calls it at `reminder_time` (Feature 20). + `plan.Service.EmitDayOfReminder(date)`, "Tonight: {dinner} · Recipe →", + naming the dinner planned on the date; it emits nothing for a week + 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`. - Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming webhook. diff --git a/internal/chat/messages.go b/internal/chat/messages.go index b358616..4af7922 100644 --- a/internal/chat/messages.go +++ b/internal/chat/messages.go @@ -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 -// 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 { return notify.Event{ Type: notify.DayOfReminder, 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, URL: "/dinner/" + dinnerID, 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 -// 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 { text := p.Title + if p.Body == "" && strings.HasSuffix(text, "→") { + return text + } if p.Body != "" { if endsSentence(text) { text += " " diff --git a/internal/chat/messages_test.go b/internal/chat/messages_test.go index 44becb9..de4be3e 100644 --- a/internal/chat/messages_test.go +++ b/internal/chat/messages_test.go @@ -2,6 +2,7 @@ package chat import ( "testing" + "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" "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", url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8", week: "2026-10-12", - title: "Tonight's dinner", - body: "Tacos", - chatT: "Tonight's dinner: Tacos. Open the recipe →", + title: "Tonight: Tacos · Recipe →", + body: "", + 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", diff --git a/internal/dinners/dinners.go b/internal/dinners/dinners.go index 02d96bd..45308e5 100644 --- a/internal/dinners/dinners.go +++ b/internal/dinners/dinners.go @@ -138,6 +138,9 @@ type Service struct { grounder *recipes.Grounder logger *slog.Logger groundJob *jobs.Kind[GroundJob] + // wake is called after every committed change to a dinner's method or + // minutes, which can move its crock-pot start reminder. + wake func() // groundingsMu guards groundings: the KindGroundDinner jobs running, // by dinner id, so archiving or renaming a dinner can stop them. @@ -157,10 +160,11 @@ type Service struct { offerSearches map[string]map[*offerSearch]struct{} } -// NewService returns a service over st that grounds dinners with grounder -// and registers the KindGroundDinner job on runner, which must not have -// started yet. -func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, logger *slog.Logger) (*Service, error) { +// NewService returns a service over st that grounds dinners with grounder, +// calls wake (the scheduler's Wake) after every committed change to a +// dinner's method or minutes, and registers the KindGroundDinner job on +// runner, which must not have started yet. +func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, wake func(), logger *slog.Logger) (*Service, error) { switch { case st == nil: return nil, errors.New("dinners: nil store") @@ -168,10 +172,12 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner return nil, errors.New("dinners: nil grounder") case runner == nil: return nil, errors.New("dinners: nil job runner") + case wake == nil: + return nil, errors.New("dinners: nil wake") case logger == nil: return nil, errors.New("dinners: nil logger") } - s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]*offerSet), offerSearches: make(map[string]map[*offerSearch]struct{})} + s := &Service{st: st, grounder: grounder, wake: wake, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]*offerSet), offerSearches: make(map[string]map[*offerSearch]struct{})} rand.Read(s.offerKey) // never fails: it crashes the program instead kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...) if err != nil { @@ -502,6 +508,9 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri if err != nil { return Dinner{}, err } + if p.Method != nil || p.MinutesTotal != nil { + s.wake() + } if renamed { updateCommitted() s.clearOffers(row.ID, row.GroundingRevision) diff --git a/internal/dinners/grounding.go b/internal/dinners/grounding.go index 101a72e..93aeee2 100644 --- a/internal/dinners/grounding.go +++ b/internal/dinners/grounding.go @@ -196,6 +196,7 @@ func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundRe if n == 0 { return s.currentGrounding(ctx, row.ID) } + s.wake() return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil } @@ -577,6 +578,7 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string if err != nil { return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err) } + s.wake() s.clearOffers(cur.ID, math.MaxInt64) return fromRow(row), nil } @@ -700,6 +702,7 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u if err != nil { return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err) } + s.wake() s.clearOffers(row.ID, math.MaxInt64) return fromRow(row), nil } diff --git a/internal/dinners/grounding_test.go b/internal/dinners/grounding_test.go index fa17cb2..11fb4c0 100644 --- a/internal/dinners/grounding_test.go +++ b/internal/dinners/grounding_test.go @@ -104,7 +104,7 @@ func newUnstartedEnvOn(t *testing.T, st *store.Store, dataDir string) *env { if err != nil { t.Fatal(err) } - svc, err := dinners.NewService(st, g, runner, logger) + svc, err := dinners.NewService(st, g, runner, func() {}, logger) if err != nil { t.Fatal(err) } diff --git a/internal/plan/draft.go b/internal/plan/draft.go index 4b9bfbd..6bb1271 100644 --- a/internal/plan/draft.go +++ b/internal/plan/draft.go @@ -234,6 +234,7 @@ func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.Loca return "", err } s.notifier.Wake() + s.wake() return planID, nil } diff --git a/internal/plan/plan.go b/internal/plan/plan.go index 9cbb60a..7e66458 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -141,6 +141,9 @@ type Service struct { draft Drafter cal Calendar notifier *notify.Service + // wake is called after every committed change to a plan's days, which + // can move a crock-pot dinner's start reminder. + wake func() logger *slog.Logger draftJob *jobs.Kind[DraftJob] // publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links @@ -150,11 +153,12 @@ type Service struct { // NewService returns a service over st that drafts with draft, pushes to // cal with links under publicURL, tells the household through notifier, -// which must be over st, and registers the KindDraftWeek job on +// which must be over st, calls wake (the scheduler's Wake) after every +// committed change to a plan's days, and registers the KindDraftWeek job on // runner, which must not have started yet. The kind is service-only: // StartDraft and StartScheduledDraft, which keep one draft in flight per // week, are the only way to enqueue it. -func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, publicURL *url.URL, logger *slog.Logger) (*Service, error) { +func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, wake func(), publicURL *url.URL, logger *slog.Logger) (*Service, error) { switch { case st == nil: return nil, errors.New("plan: nil store") @@ -166,12 +170,14 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calenda return nil, errors.New("plan: nil calendar") case notifier == nil: return nil, errors.New("plan: nil notifier") + case wake == nil: + return nil, errors.New("plan: nil wake") case publicURL == nil: return nil, errors.New("plan: nil public URL") case logger == nil: return nil, errors.New("plan: nil logger") } - s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, logger: logger, publicURL: publicURL} + s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, wake: wake, logger: logger, publicURL: publicURL} kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...) if err != nil { return nil, err @@ -233,10 +239,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, -// naming the dinner planned on it, and reports whether it did: a date whose -// week has no plan emits nothing. The scheduler calls it at the -// household's reminder_time (Feature 20); emitting a date again stores no -// second notification. +// "Tonight: {dinner} · Recipe →" for the dinner planned on it, and reports +// whether it did: a date whose week has no plan emits nothing. The +// day_of_reminder schedule calls it at the household's reminder_time +// (ReminderSchedules); emitting a date again stores no second notification. func (s *Service) EmitDayOfReminder(ctx context.Context, date store.LocalDate) (bool, error) { p, err := s.Get(ctx, household.WeekStart(date)) if errors.Is(err, ErrNotFound) { @@ -245,15 +251,14 @@ func (s *Service) EmitDayOfReminder(ctx context.Context, date store.LocalDate) ( if err != nil { return false, err } - for _, d := range p.Days { - if d.Date == date.String() { - 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 true, nil - } + d, err := p.day(date) + if err != nil { + return false, err } - return false, fmt.Errorf("plan: week %s has no day %s", p.WeekStart, date) + 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 true, nil } // readPlan reads the plan of the week starting weekStart through q, with @@ -470,6 +475,7 @@ func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, din if err != nil { return Plan{}, err } + s.wake() return p, nil } diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index f4166d0..89decf6 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -146,7 +146,7 @@ func newEnv(t *testing.T) *env { if err != nil { t.Fatal(err) } - e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), publicURL, logger) + e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), func() {}, publicURL, logger) if err != nil { t.Fatal(err) } @@ -428,8 +428,8 @@ func TestEmitDayOfReminder(t *testing.T) { t.Fatalf("day_of_reminder rows = %+v, want one on push", rows) } r := rows[0] - if r.Key != "day_of_reminder:2026-10-14" || r.Channel != notify.ChannelPush || r.Payload.Title != "Tonight's dinner" || - r.Payload.Body != "Pad Thai" || r.Payload.URL != "/dinner/"+e.ids["Pad Thai"] || r.Payload.Week != "2026-10-12" { + if r.Key != "day_of_reminder:2026-10-14" || r.Channel != notify.ChannelPush || r.Payload.Title != "Tonight: Pad Thai · Recipe →" || + 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) } } diff --git a/internal/plan/reminder.go b/internal/plan/reminder.go new file mode 100644 index 0000000..2680eae --- /dev/null +++ b/internal/plan/reminder.go @@ -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) +} diff --git a/internal/scheduler/day_of_reminder_test.go b/internal/scheduler/day_of_reminder_test.go new file mode 100644 index 0000000..f2084e4 --- /dev/null +++ b/internal/scheduler/day_of_reminder_test.go @@ -0,0 +1,328 @@ +package scheduler_test + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "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/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()) +} + +// questLog stores name as a Quest-log dinner, a crock-pot dinner of +// minutes, and returns its id. It is not a Regular. +func (p *planEnv) questLog(name string, minutes int) string { + p.e.t.Helper() + now := store.Now().String() + d, err := db.New(p.e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{ + ID: store.NewID(), + Name: name, + NormalizedName: harness.Normalize(name), + Status: "quest_log", + Origin: "side_quest", + GroundingStatus: "manual", + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + p.e.t.Fatal(err) + } + p.ids[name] = d.ID + p.crockPot(name, minutes) + return d.ID +} + +// TestStartReminderAfterSwapFiresOnTime: swapping a crock-pot dinner in +// for tonight wakes the scheduler, so its start reminder fires at start_by +// minus 15 minutes, 09:15, and not at the next hourly check, 10:00. At +// 09:00 tonight is Beef Chili, not a crock-pot dinner, and the scheduler's +// next timer is 10:00; at 09:01 the household swaps in an eight-hour +// crock-pot Quest-log dinner, start by 09:30. Each reminder fires once. +func TestStartReminderAfterSwapFiresOnTime(t *testing.T) { + e := newEnv(t) + p := e.wakingPlans(e.pool()) + stew := p.questLog("Crock Pot Stew", 480) // start by 09:30 + p.planWeek(w42) + + r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...) + r.runUntil(at(2026, 10, 12, 9, 0)) + if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 12, 10, 0)) { + t.Fatalf("next wake at 09:00 = %s, want the hourly 10:00", next) + } + + r.runUntil(at(2026, 10, 12, 9, 1)) + if _, err := p.svc.Swap(context.Background(), w42, "2026-10-12", stew); err != nil { + t.Fatal(err) + } + r.changed() + 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", "Crock Pot Stew", "Crock Pot Stew") + + 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"}) + p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Crock Pot Stew · Recipe →", "", "Crock Pot Stew") + wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13", "start:2026-10-12"}) +} diff --git a/internal/scheduler/plan_draft_test.go b/internal/scheduler/plan_draft_test.go index ad59e47..8696487 100644 --- a/internal/scheduler/plan_draft_test.go +++ b/internal/scheduler/plan_draft_test.go @@ -81,7 +81,23 @@ func (c noCalendar) PushEvents(context.Context, []gcal.Event, func() error) erro // plans builds a plan service drafting from the dinners ids over e with a // started job runner, which stop stops (and the test's end, if not before). +// Its changes do not wake the scheduler: a draft job's wake would add a +// pass the fake clock's waits do not expect. func (e *env) plans(ids map[string]string) *planEnv { + e.t.Helper() + return e.newPlans(ids, func() {}) +} + +// wakingPlans is plans whose changes wake the scheduler, as in the server. +// A test using it changes plans only from its own goroutine and waits for +// the wake's pass with changed. +func (e *env) wakingPlans(ids map[string]string) *planEnv { + e.t.Helper() + return e.newPlans(ids, e.wake) +} + +// newPlans is plans whose service calls wake after a plan change. +func (e *env) newPlans(ids map[string]string, wake func()) *planEnv { e.t.Helper() p := &planEnv{e: e, model: &llm.Fake{}, ids: ids, logs: &syncBuffer{}} guard, err := harness.NewGuard(harness.DefaultMaxCalls) @@ -102,7 +118,7 @@ func (e *env) plans(ids map[string]string) *planEnv { if err != nil { e.t.Fatal(err) } - p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), publicURL, logger) + p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), wake, publicURL, logger) if err != nil { e.t.Fatal(err) } diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 9f8acee..c39d436 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -44,6 +44,18 @@ // occurrence. Either way the period key is that local date's, so the // 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, and whoever changes that data calls Wake once the change is +// committed, so the next pass comes at once and a fire time the change +// moved fires on time rather than up to maxWait late; 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 // // The scheduler subscribes to the household settings and records each change @@ -59,6 +71,7 @@ import ( "errors" "fmt" "log/slog" + "strings" "sync" "time" @@ -102,8 +115,8 @@ const ClaimRetry = time.Minute const maxWait = time.Hour // 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 -// or WeeklyForNextWeek. +// day, or on Weekday only when Weekly is set, or every day at the instant +// At gives. Build one with Daily, Weekly, WeeklyForNextWeek or DailyAt. type Rule struct { Weekly bool Weekday time.Weekday @@ -111,8 +124,15 @@ type Rule struct { // NextWeek keys a weekly period by the ISO week after the fire date, // for work done ahead for the coming week. 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. func Daily(hhmm string) Rule { return Rule{Time: hhmm} @@ -129,6 +149,12 @@ func WeeklyForNextWeek(day time.Weekday, hhmm string) Rule { 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 // 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") @@ -151,6 +177,12 @@ func (r Rule) validate() error { if !r.Weekly && (r.Weekday != time.Sunday || r.NextWeek) { 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. _, err := household.LocalTime(time.UTC, store.LocalDateOf(time.Unix(0, 0).UTC()), r.Time) return err @@ -173,6 +205,10 @@ type Period struct { type Schedule struct { // Name is one of the schedule name constants. 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 // again after every settings change. Rule func(settings household.Settings) Rule @@ -183,6 +219,14 @@ type Schedule struct { 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. type Settings interface { Get(ctx context.Context) (household.Settings, error) @@ -199,8 +243,9 @@ type Scheduler struct { now func() time.Time after func(d time.Duration) <-chan time.Time - // wake holds one token when the settings changed since the loop last - // looked; pending is the changes to apply, oldest first. + // wake holds one token when the settings or the data a DailyAt rule + // reads changed since the loop last looked; pending is the settings + // changes to apply, oldest first. wake chan struct{} pendingMu sync.Mutex pending []change @@ -240,19 +285,22 @@ func (s *Scheduler) Register(sch Schedule) error { return fmt.Errorf("scheduler: register: unknown schedule %q", sch.Name) } 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 { - 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() defer s.mu.Unlock() 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 { - if have.Name == sch.Name { - return fmt.Errorf("scheduler: register %s: already registered", sch.Name) + if have.Name == sch.Name && have.Key == sch.Key { + return fmt.Errorf("scheduler: register %s: already registered", sch.label()) } } s.schedules = append(s.schedules, sch) @@ -310,9 +358,21 @@ func (s *Scheduler) changed(settings household.Settings) { s.pendingMu.Lock() s.pending = append(s.pending, change{at: s.now(), settings: settings}) s.pendingMu.Unlock() + s.signal() +} + +// Wake makes the scheduler look up its fire times again now rather than at +// its next timer. Call it after committing a change to the data a DailyAt +// rule reads, such as tonight's planned dinner. It never blocks. +func (s *Scheduler) Wake() { + s.signal() +} + +// signal leaves one token in wake for the loop. +func (s *Scheduler) signal() { select { case s.wake <- struct{}{}: - default: // the loop already has a token and reads pending then + default: // the loop already has a token and looks again then } } @@ -346,7 +406,7 @@ func compile(schedules []Schedule, settings household.Settings) (*plan, error) { for i, sch := range schedules { rule := sch.Rule(settings) 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} } @@ -396,7 +456,12 @@ func (s *Scheduler) loop(ctx context.Context, p *plan) { case len(retry) > 0: timeout = s.after(ClaimRetry) 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)) } } @@ -423,41 +488,67 @@ func (s *Scheduler) compileChange(ctx context.Context, settings household.Settin 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 { - it item - loc *time.Location - at time.Time + it item + loc *time.Location + 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 // 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 { var failed []target for _, it := range p.items { if ctx.Err() != nil { return failed } - at, due := fireBefore(it.rule, p.loc, until) - if !due || !at.After(done) { - continue - } - if t := (target{it: it, loc: p.loc, at: at}); !s.handle(ctx, t, now) { + if t, ok := s.fireItem(ctx, it, p.loc, done, until, now); !ok { failed = append(failed, t) } } return failed } -// retry handles targets again at now and returns those whose claim still -// failed. +// fireItem handles it's most recent fire time at or before until if it is +// 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 { var failed []target for i, t := range targets { if ctx.Err() != nil { 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) { failed = append(failed, t) } @@ -469,9 +560,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 // when the claim failed and the period is still unclaimed. func (s *Scheduler) handle(ctx context.Context, t target, now time.Time) bool { - it, at := t.it, t.at - date := household.Today(at, t.loc) + it, at, date := t.it, t.at, t.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 { period.Week = household.WeekStart(addDays(date, 7)) } else { @@ -515,16 +608,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 -// when there is no schedule. -func (p *plan) next(now time.Time) (time.Time, bool) { +// when there is none. +func (p *plan) next(ctx context.Context, now time.Time) (time.Time, bool, error) { var next time.Time for _, it := range p.items { - at, ok := fireAfter(it.rule, p.loc, now) - if ok && (next.IsZero() || at.Before(next)) { - next = at + f, ok, err := fireAfter(ctx, it.rule, p.loc, now) + if err != nil { + 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 @@ -532,46 +628,80 @@ func (p *plan) next(now time.Time) (time.Time, bool) { // each side. 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. -func fireBefore(r Rule, loc *time.Location, t time.Time) (time.Time, bool) { - var best time.Time - for _, at := range fireTimes(r, loc, t) { - if !at.After(t) && (best.IsZero() || at.After(best)) { - best = at +func fireBefore(ctx context.Context, r Rule, loc *time.Location, t time.Time) (fireTime, bool, error) { + fs, err := fireTimes(ctx, r, loc, t) + if err != nil { + return fireTime{}, false, err + } + 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. -func fireAfter(r Rule, loc *time.Location, t time.Time) (time.Time, bool) { - var best time.Time - for _, at := range fireTimes(r, loc, t) { - if at.After(t) && (best.IsZero() || at.Before(best)) { - best = at +func fireAfter(ctx context.Context, r Rule, loc *time.Location, t time.Time) (fireTime, bool, error) { + fs, err := fireTimes(ctx, r, loc, t) + if err != nil { + return fireTime{}, false, err + } + 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 -// of t's. A rule that fails to resolve was validated at compile, so it -// cannot. -func fireTimes(r Rule, loc *time.Location, t time.Time) []time.Time { +// fireTimes returns r's fire times for the local dates within window days +// of t's, or atWindow days for a DailyAt rule. A Time that fails to +// resolve was validated at compile, so it cannot; a DailyAt lookup that +// fails is an error. +func fireTimes(ctx context.Context, r Rule, loc *time.Location, t time.Time) ([]fireTime, error) { today := household.Today(t, loc) - var out []time.Time - for i := -window; i <= window; i++ { + span := window + if r.At != nil { + span = atWindow + } + var out []fireTime + for i := -span; i <= span; i++ { d := addDays(today, i) if r.Weekly && weekday(d) != r.Weekday { 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) if err != nil { 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. diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index 27af79a..3e288ee 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -7,6 +7,7 @@ import ( "log/slog" "slices" "sync" + "sync/atomic" "testing" "time" @@ -59,10 +60,13 @@ func (c *fakeClock) Now() time.Time { return c.now } +// After returns a timer that fires d from now. The scheduler waits on only +// its latest timer, so this one replaces any still pending: a timer the +// scheduler gave up on when a wake came first never fires. func (c *fakeClock) After(d time.Duration) <-chan time.Time { c.mu.Lock() ch := make(chan time.Time, 1) - c.timers = append(c.timers, fakeTimer{deadline: c.now.Add(d), ch: ch}) + c.timers = []fakeTimer{{deadline: c.now.Add(d), ch: ch}} c.mu.Unlock() c.waits <- struct{}{} return ch @@ -134,6 +138,16 @@ type env struct { t *testing.T st *store.Store settings *household.SettingsService + // sched is the scheduler start last started, which wake wakes. + sched atomic.Pointer[scheduler.Scheduler] +} + +// wake wakes the scheduler start last started, as the services' wake does +// in the server; before the first start it does nothing. +func (e *env) wake() { + if s := e.sched.Load(); s != nil { + s.Wake() + } } func newEnv(t *testing.T) *env { @@ -214,6 +228,7 @@ func (e *env) start(now time.Time, schedules ...scheduler.Schedule) *running { cancel() e.t.Fatal(err) } + e.sched.Store(s) var once sync.Once stop := func() { once.Do(func() { @@ -255,7 +270,8 @@ func (r *running) runUntil(to time.Time) { } } -// changed waits for the scheduler to apply a settings change. +// changed waits for the scheduler to apply a settings change, or to look +// again after a wake. func (r *running) changed() { r.t.Helper() r.idle() diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go index 237a256..f0ce5c7 100644 --- a/internal/server/dinners_test.go +++ b/internal/server/dinners_test.go @@ -93,7 +93,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv { if err != nil { t.Fatal(err) } - svc, err := dinners.NewService(st, g, runner, logger) + svc, err := dinners.NewService(st, g, runner, func() {}, logger) if err != nil { t.Fatal(err) } diff --git a/internal/server/ideaactions_test.go b/internal/server/ideaactions_test.go index 238156a..535a22b 100644 --- a/internal/server/ideaactions_test.go +++ b/internal/server/ideaactions_test.go @@ -65,7 +65,7 @@ func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *reci if err != nil { t.Fatal(err) } - dinnerSvc, err := dinners.NewService(st, g, runner, logger) + dinnerSvc, err := dinners.NewService(st, g, runner, func() {}, logger) if err != nil { t.Fatal(err) } @@ -73,7 +73,7 @@ func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *reci t.Error("draft_week ran: an idea action must never draft a plan") return skills.Week{}, context.Canceled } - planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger) + planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger) if err != nil { t.Fatal(err) } diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go index 4b7224d..c73341f 100644 --- a/internal/server/plans_test.go +++ b/internal/server/plans_test.go @@ -93,7 +93,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv { t.Fatal(err) } notifier := notifytest.New(t, st) - svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, pub, logger) + svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, func() {}, pub, logger) if err != nil { t.Fatal(err) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index e6c75ae..7cb8d27 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -166,7 +166,7 @@ func newTestDinners(t *testing.T) *dinners.Service { t.Fatal(err) } g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{}) - svc, err := dinners.NewService(st, g, runner, logger) + svc, err := dinners.NewService(st, g, runner, func() {}, logger) if err != nil { t.Fatal(err) } @@ -232,7 +232,7 @@ func newTestPlan(t *testing.T) *plan.Service { fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { return skills.Week{}, errors.New("no model in this test") } - svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger) + svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger) if err != nil { t.Fatal(err) } diff --git a/internal/shopping/shopping_test.go b/internal/shopping/shopping_test.go index 64640ad..9f359b6 100644 --- a/internal/shopping/shopping_test.go +++ b/internal/shopping/shopping_test.go @@ -516,7 +516,7 @@ func TestGenerateManualRecipe(t *testing.T) { if err != nil { t.Fatal(err) } - dsvc, err := dinners.NewService(e.st, grounder, runner, logger) + dsvc, err := dinners.NewService(e.st, grounder, runner, func() {}, logger) if err != nil { t.Fatal(err) } diff --git a/internal/suggest/suggest_test.go b/internal/suggest/suggest_test.go index 2caa7cf..6c0052c 100644 --- a/internal/suggest/suggest_test.go +++ b/internal/suggest/suggest_test.go @@ -1102,7 +1102,7 @@ func TestBatchNeverStoresDishPassedWhileRunning(t *testing.T) { func TestLaterBatchNeverResuggestsRenamedPass(t *testing.T) { e := newEnv(t) // runner not started: the replenish batch waits for the rename logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger) + dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, func() {}, logger) if err != nil { t.Fatal(err) } @@ -1150,14 +1150,14 @@ func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) { e := newEnv(t) ctx := t.Context() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger) + dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, func() {}, logger) if err != nil { t.Fatal(err) } fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { return skills.Week{}, errors.New("no draft in this test") } - planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), &url.URL{Scheme: "https", Host: "meals.example.com"}, logger) + planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), func() {}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger) if err != nil { t.Fatal(err) }