From a7a56623e5b3a8d4feee753a7ab816f94f8af6ba Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 10 Oct 2026 12:51:54 -0400 Subject: [PATCH 1/2] Add day-of and crock-pot start-by reminders (Feature 20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register plan.Service.ReminderSchedules as the day_of_reminder schedule: daily at the household reminder_time (period YYYY-MM-DD) it emits day_of_reminder:YYYY-MM-DD, "Tonight: {dinner} · Recipe →" linking /dinner/{id}; for a crock-pot dinner whose start_by is earlier than reminder_time it also fires 15 minutes before start_by (period start:YYYY-MM-DD) and emits day_of_reminder:start:YYYY-MM-DD, "Start the crock pot by {time}". A date whose week has no plan emits nothing. The scheduler gains DailyAt rules, whose fire time per date is looked up from data on every pass (a failed lookup is retried every ClaimRetry), and a Schedule Key that prefixes period keys so two schedules of one name claim different periods. Maestro task 30963b62-2803-4879-82ff-c351cc6628cf. Co-Authored-By: Claude Opus 5.5 --- cmd/server/main.go | 5 + docs/ARCHITECTURE.md | 29 ++- internal/chat/messages.go | 25 +- internal/chat/messages_test.go | 17 +- internal/plan/plan.go | 23 +- internal/plan/plan_test.go | 4 +- internal/plan/reminder.go | 150 ++++++++++++ internal/scheduler/day_of_reminder_test.go | 267 +++++++++++++++++++++ internal/scheduler/scheduler.go | 214 +++++++++++++---- 9 files changed, 660 insertions(+), 74 deletions(-) create mode 100644 internal/plan/reminder.go create mode 100644 internal/scheduler/day_of_reminder_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 969bdfd..bbdabee 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -198,6 +198,11 @@ func run(cfg config.Config, logger *slog.Logger) error { 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..35d17cb 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,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 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, 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 `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 `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/plan/plan.go b/internal/plan/plan.go index 9cbb60a..0edbaf1 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -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, -// 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 +245,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 diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index f4166d0..7931dd1 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -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..fcb8817 --- /dev/null +++ b/internal/scheduler/day_of_reminder_test.go @@ -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()) +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 9f8acee..ebe77e9 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -44,6 +44,17 @@ // 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, 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 // // The scheduler subscribes to the household settings and records each change @@ -59,6 +70,7 @@ import ( "errors" "fmt" "log/slog" + "strings" "sync" "time" @@ -102,8 +114,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 +123,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 +148,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 +176,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 +204,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 +218,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) @@ -240,19 +283,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) @@ -346,7 +392,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 +442,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 +474,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 +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 // 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 +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 -// 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 +614,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. -- 2.55.0 From 765a36fa97990f6e3b3b4a834de7e9be6b097fa6 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 10 Oct 2026 14:55:07 -0400 Subject: [PATCH 2/2] Wake the scheduler on plan and dinner changes (MQ-177-01) A swap, stored draft, or dinner method/minutes change (edit or grounding) now calls Scheduler.Wake once committed, so a crock-pot dinner swapped in for tonight is reminded at start_by minus 15 minutes rather than at the next hourly pass. Adds a fake-clock test that swaps in a crock-pot Quest-log dinner at 09:01 and asserts the start reminder at 09:15, once. Co-Authored-By: Claude Opus 5.5 --- cmd/server/main.go | 13 ++--- docs/ARCHITECTURE.md | 11 ++-- internal/dinners/dinners.go | 19 +++++-- internal/dinners/grounding.go | 3 ++ internal/dinners/grounding_test.go | 2 +- internal/plan/draft.go | 1 + internal/plan/plan.go | 13 +++-- internal/plan/plan_test.go | 2 +- internal/scheduler/day_of_reminder_test.go | 61 ++++++++++++++++++++++ internal/scheduler/plan_draft_test.go | 18 ++++++- internal/scheduler/scheduler.go | 28 +++++++--- internal/scheduler/scheduler_test.go | 20 ++++++- internal/server/dinners_test.go | 2 +- internal/server/ideaactions_test.go | 4 +- internal/server/plans_test.go | 2 +- internal/server/server_test.go | 4 +- internal/shopping/shopping_test.go | 2 +- internal/suggest/suggest_test.go | 6 +-- 18 files changed, 172 insertions(+), 39 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index bbdabee..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,10 +193,6 @@ 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 } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 35d17cb..895bd43 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -832,9 +832,14 @@ 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`. +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. 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 0edbaf1..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 @@ -469,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 7931dd1..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) } diff --git a/internal/scheduler/day_of_reminder_test.go b/internal/scheduler/day_of_reminder_test.go index fcb8817..f2084e4 100644 --- a/internal/scheduler/day_of_reminder_test.go +++ b/internal/scheduler/day_of_reminder_test.go @@ -8,6 +8,8 @@ import ( "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" @@ -265,3 +267,62 @@ func TestDailyAtLookupFailureIsRetried(t *testing.T) { 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 ebe77e9..c39d436 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -50,10 +50,11 @@ // 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. +// 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 // @@ -242,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 @@ -356,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 } } 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) } -- 2.55.0