diff --git a/cmd/server/main.go b/cmd/server/main.go index c472419..0ca16d6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -31,6 +31,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" @@ -130,7 +131,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 +158,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 } @@ -193,17 +199,46 @@ func run(cfg config.Config, logger *slog.Logger) error { 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 + } + } + if err := sched.Register(planSvc.RotationCheckSchedule()); err != nil { + return err + } + if err := sched.Register(shopSvc.ChatAutosendSchedule()); err != nil { + return err + } + if err := sched.Register(shopSvc.RolloverSchedule()); err != nil { + return err + } + purge, err := harness.PurgeSchedule(db, logger) + if err != nil { + return err + } + if err := sched.Register(purge); 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, homeSvc, runner, photos, frontend.Assets()) if err != nil { return err } // Start fails the jobs a previous process left running before any - // worker runs. The job workers and the notification worker stop, and - // are waited for, before the database closes. + // worker runs. The job workers, the notification worker and the + // scheduler stop, and are waited for, before the database closes. workersCtx, stopWorkers := context.WithCancel(ctx) defer func() { stopWorkers() + sched.Wait() runner.Wait() notifier.Wait() }() @@ -219,6 +254,11 @@ func run(cfg config.Config, logger *slog.Logger) error { if _, err := dinnerSvc.FailInterruptedGroundings(ctx); err != nil { return err } + // The scheduler starts last: its catch-up may enqueue jobs and + // notifications, so their workers must already run. + if err := sched.Start(workersCtx); err != nil { + return err + } srv := &http.Server{ Addr: cfg.HTTPAddr, Handler: router, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e4f55e5..0472353 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -309,20 +309,24 @@ automatic** (D-021). `suggest_side_quests` for 5 Side quests (scope: the batch id), then grounds the survivors in the model's order with `Grounder.GroundMany`, as many as are still wanted at a time, until 5 are kept or none are - left. A Side quest no recipe grounds is dropped; each round's grounded - ones are stored before the next — a dinner with status `suggested`, - origin `side_quest`, grounding_status `grounded` with the recipe's - recipe_id, photo_id, method, minutes_total and difficulty and the - model's cuisine, and a `pending` suggestion with the name it is - suggested under, the reason and `cited_regular_ids` — unless, since the - job read its input, a dinner it repeats was added, a suggestion it - repeats passed, or a Regular it cites renamed or archived (checked in - the storing transaction). The job publishes - `{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`, + left. A Side quest no recipe grounds is dropped. A `replenish` batch + stores each round's grounded ones before the next; a `scheduled` or + `manual` batch, which notifies, holds them and stores them all in one + transaction once its last round is done. Each is stored as a dinner + with status `suggested`, origin `side_quest`, grounding_status + `grounded` with the recipe's recipe_id, photo_id, method, + minutes_total and difficulty and the model's cuisine, and a `pending` + suggestion with the name it is suggested under, the reason and + `cited_regular_ids` — unless, since the job read its input, a dinner + it repeats was added, a suggestion it repeats passed, or a Regular it + cites renamed or archived (checked in the storing transaction). The job + publishes `{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`, `grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the skill or `normalize_recipe` failed; no Side quest is made up instead, D-017), `search_not_configured` or `search_failed`, and the batch is set - `failed`; Side quests stored before the failure stay in the deck. If + `failed`. A failed `scheduled` or `manual` batch has stored nothing; a + failed `replenish` batch's Side quests stored before the failure stay + in the deck. If recording the batch's end fails, its job fails; the job runner retries a job's final status write with backoff until it lands, each attempt bounded at 5 seconds; at shutdown an attempt that fails or times out is @@ -480,15 +484,13 @@ automatic** (D-021). `changed`, and answers 200 with the plan. A date outside the week is 400 `invalid_date`, an unknown dinner or a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and a dinner already on another day of the week 409 `dinner_already_planned`. -- Rotation check (`plan.Service`, D-021). This release provides the - `MarkTried` service method and the rotation review read and decide APIs - below. The `rotation_check` schedule that is to call `MarkTried` for the - household-local day just past is planned (Feature 20, the scheduler) and - not wired yet: nothing in production calls `MarkTried`, so no Rotation - check opens automatically. `MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any - status, to `tried` and opens a `pending` `rotation_reviews` row for it; - a dinner already checked for that date is left as it is, so it is - idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the +- Rotation check (`plan.Service`, D-021). The `rotation_check` schedule + calls `MarkTried` for the household-local day just past (see Scheduler). + `MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any + status, to `tried`, opens a `pending` `rotation_reviews` row for it and + emits its `rotation_review`, in one transaction; + a dinner already checked for that date is left as it is and not + notified again, so it is idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the pending checks whose dinner is still tried, oldest `plan_date` first, each `{dinner: {id, name, photo}, plan_date}`. `POST /api/v1/dinners/{id}/rotation-review` takes `{decision}`: `keep` makes @@ -533,8 +535,8 @@ automatic** (D-021). received. `harness.Purge` deletes rows older than `harness.RunRetention` (30 days), clearing the `suggestion_batches` and `week_plans` references first. The - harness does not call it: the scheduler's daily `ai_runs` purge - (Feature 20) must call `harness.Purge` with `now - RunRetention`. + daily `ai_runs_purge` schedule (`harness.PurgeSchedule`) calls it with + its fire time less `RunRetention`. - **Concurrency guards** (`harness.Guard`, one per process): `harness.Run` takes a scope key, and concurrent runs of one skill with one scope share a single in-flight run and its result, error included (single-flight); runs @@ -649,7 +651,8 @@ job failed, the freeze finds the list still untitled and makes its own `archive_title` attempt before freezing. A job that starts during a freeze waits for it, then finds the list titled (that title stands) or frozen untitled (the job fails `title_failed`), and stores nothing. The -`week_rollover` schedule is to call `Freeze` for last week. +`week_rollover` schedule (`shopping.Service.RolloverSchedule`) calls +`Freeze` for last week, judging the week ended by its fire time. ## Recipe grounding @@ -775,16 +778,92 @@ purge are fixed and not configurable. | Schedule | When | Does | | --- | --- | --- | -| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests. | -| `plan_draft` | Friday 09:00 | AI drafts next week's plan. | -| `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. | -| `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. | -| `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). | +| `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 coming week's Supply run to Google Chat (`shopping.Service.ChatAutosendSchedule`). | +| `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 | Marks yesterday's Quest log dinners tried and asks "Keep it in rotation?" (`plan.Service.RotationCheckSchedule`). | +| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive (`shopping.Service.RolloverSchedule`). | +| `ai_runs_purge` | Daily 03:00 (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (`harness.PurgeSchedule`). | 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`, 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 +fire date for a weekly one, or of the week after for `WeeklyForNextWeek` +(`plan_draft`). Inserting `schedule_runs(schedule, period_key)` is the claim; +a period already claimed is skipped, so a period runs at most once and a +failed run is not retried. A claim that fails is not skipped: the pass is +repeated every `scheduler.ClaimRetry` until the claim succeeds. A settings +change is recorded with the instant it was saved, and fire times up to that +instant follow the old rule and later ones the new, however late the +scheduler applies it. At boot, and whenever the scheduler wakes late, +every unclaimed fire time within 24 hours (`scheduler.CatchUpWindow`) is +processed oldest first, each running once. A most recent fire time that +passed longer ago is claimed without running (logged as missed) so it +never runs later; older ones are not looked at. DST +follows `household.LocalTime`: a skipped time fires later by the gap, a +repeated time fires once, at its first occurrence. + +Each schedule is built by the service that does its work, as a +`scheduler.Schedule` value, and registered in `cmd/server` before the +scheduler starts: `suggest.Service.WeeklySchedule()` is `suggestions_weekly`. +Its run calls `StartScheduledBatch(period.Week)`, which starts the same +batch and job as `POST /api/v1/suggestions/batches` (`StartBatch(manual)`), +with the `scheduled` trigger, and returns at once; the batch runs as a job. When a +batch is already pending or running the period starts none and logs it. +`plan.Service.DraftSchedule()` is `plan_draft`, keyed by the ISO week +after the fire date (`scheduler.WeeklyForNextWeek`). Its run calls +`StartScheduledDraft(period.Week)`, which enqueues the same `draft_week` +job as `POST /api/v1/plans/{weekStart}/draft` without confirm, so a stored +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`. +`shopping.Service.ChatAutosendSchedule()` is `shopping_chat_autosend`, +`WeeklyForNextWeek(chat_autosend_dow, chat_autosend_time)`, so its period +names the coming week. Its run calls `Autosend(period.Week)`, which posts +with `SendToChat(week, false)`, the household's own send, only when a +webhook is stored, `list_ready`'s Chat channel is on +(`notify.Service.ChannelOn`) and the week has a Supply run neither sent +nor with a send unconfirmed; otherwise it posts nothing and logs why, +with the key `list_ready:chat:{weekStart}`. It never resends; a send that +fails is the period's error. `shopping.Service.RolloverSchedule()` is +`week_rollover`, `Weekly(Monday, 00:05)`; its run freezes the week before +the fire date's, and a week with no Supply run freezes nothing and logs +it. `plan.Service.RotationCheckSchedule()` is `rotation_check`, +`Daily(review_time)`, claimed as `rotation_check:YYYY-MM-DD` of the fire +date (`Schedule.PeriodKey`). Its run calls `MarkTried` for the day before +the fire date, which emits one `rotation_review` per Rotation check it +opens; it never makes a dinner a Regular, and a day with no Quest log +dinner planned opens none and logs the count. +`harness.PurgeSchedule(store, logger)` is `ai_runs_purge`, +`Daily(03:00)`, deleting rows created more than 30 days before the fire +time. +Fake-clock tests of a schedule live in `internal/scheduler`, where +`SetClock` is visible to tests. + ## Notifications - Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`, @@ -793,8 +872,9 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. - Where each event is emitted, with its idempotency key. Each is stored by `notify.EmitTx` in the transaction of the change it is about, so the change and its notification commit together or not at all: - - `plan_ready:{weekStart}` (URL `/plan?week={weekStart}`) when a draft job stores the - AI's draft of a week. Only the first draft of a week notifies: a + - `plan_ready:{weekStart}` (URL `/plan?week={weekStart}`) when a draft job, asked for + by the API or the `plan_draft` schedule, stores the AI's draft of a + week. Only the first draft of a week notifies: a redraft (Rebuild week) has the same key, and a manual swap never drafts. Its title is "Next week's plan is ready" (or "This week's", or "The plan for the week of Oct 26") and its body the drafted @@ -802,16 +882,33 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. - `list_ready:{weekStart}` (URL `/plan/shopping?week={weekStart}`) when a week's Supply run is first created, by `POST /shopping/{weekStart}/generate` or after Push to Calendar; remaking it does not notify. - - `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or - `manual` suggestion batch first adds Side quests to the deck, in the - transaction storing them, so they are announced even when a later - round or recording the batch's end fails; it names that round's Side - quests, and a later round's emit stores nothing. A `replenish` batch - never notifies. + - `new_suggestions:{YYYY-Www}` for a `scheduled` batch, the ISO week it + was scheduled for (carried in its job, so a batch caught up late is + still keyed by its own week), or `new_suggestions:{batchId}` for a + `manual` one (URL `/discover`), once, when the batch's last round is + done, in the one transaction that stores all its Side quests, so they + and their notification commit together or not at all. Its title + counts every Side quest the batch stored ("5 new Side quests are in") + and its body names them. A batch that fails at any round, as when + `suggest_side_quests` or a later round's `normalize_recipe` fails (the + failure is in `ai_runs`), stores no Side quest and sends nothing, so + no stored Side quest goes unannounced. A `replenish` batch never + 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`. + - `rotation_review:{dinnerId}:{planDate}` (URL `/`, where the Home card + asks) through `plan.Service.MarkTried(planDate)`, "How was {dinner}? + Keep it in rotation?", once per Rotation check it opens. The + `rotation_check` schedule calls it at `review_time` for yesterday. - Delivery goes through `notification_outbox`. - Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming webhook. @@ -1068,7 +1165,7 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. request. 404 when the week has no Supply run, 409 `archived` when it is in the Supply archive, 400 `invalid_request` for a bad body. `shopping.SendToChat(weekStart, resend)` is what - `shopping_chat_autosend` calls (Feature 20). + `shopping_chat_autosend` calls, through `Autosend`. - `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required boolean; it sets the flag, it does not flip it) and answers `{id, needed, needed_count, total_count}` with the list's counts after the diff --git a/internal/ai/harness/runs.go b/internal/ai/harness/runs.go index 5bf2e86..270aba4 100644 --- a/internal/ai/harness/runs.go +++ b/internal/ai/harness/runs.go @@ -34,8 +34,8 @@ const ( StatusProviderError RunStatus = "provider_error" ) -// RunRetention is how long ai_runs rows are kept. The daily purge calls -// Purge with time.Now().Add(-RunRetention). +// RunRetention is how long ai_runs rows are kept. The daily purge, +// PurgeSchedule, calls Purge with its fire time less RunRetention. const RunRetention = 30 * 24 * time.Hour // runSaveTimeout bounds writing the ai_runs row of a run that has failed, @@ -46,9 +46,6 @@ var runSaveTimeout = 5 * time.Second // Purge deletes the ai_runs rows created before olderThan and returns how // many it deleted. The suggestion_batches and week_plans references to // those rows are cleared first, in the same transaction. -// -// Nothing in this package calls Purge: the scheduler's daily ai_runs purge -// (Feature 20) must call it with time.Now().Add(-RunRetention). func Purge(ctx context.Context, st *store.Store, olderThan time.Time) (int64, error) { if olderThan.IsZero() { return 0, errors.New("harness: purge ai_runs: zero cutoff") diff --git a/internal/ai/harness/schedule.go b/internal/ai/harness/schedule.go new file mode 100644 index 0000000..9bd5c80 --- /dev/null +++ b/internal/ai/harness/schedule.go @@ -0,0 +1,46 @@ +package harness + +import ( + "context" + "errors" + "log/slog" + "time" + + "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" +) + +// PurgeTime is when, every day in the household zone, ai_runs_purge runs. +// It is fixed, not a setting. +const PurgeTime = "03:00" + +// PurgeSchedule is the ai_runs_purge schedule over st: every day at +// PurgeTime it deletes the ai_runs rows created more than RunRetention +// before the fire time with Purge, its period scheduler.Daily keyed +// ai_runs_purge:YYYY-MM-DD. A purge caught up late deletes no row younger +// than RunRetention; one that fails is the period's error and is not +// retried, and the next day's purge deletes what it left. +func PurgeSchedule(st *store.Store, logger *slog.Logger) (scheduler.Schedule, error) { + if st == nil { + return scheduler.Schedule{}, errors.New("harness: purge schedule: nil store") + } + if logger == nil { + return scheduler.Schedule{}, errors.New("harness: purge schedule: nil logger") + } + return scheduler.Schedule{ + Name: scheduler.AIRunsPurge, + Rule: func(household.Settings) scheduler.Rule { + return scheduler.Daily(PurgeTime) + }, + Run: func(ctx context.Context, p scheduler.Period) error { + cutoff := p.FireAt.Add(-RunRetention) + n, err := Purge(ctx, st, cutoff) + if err != nil { + return err + } + logger.InfoContext(ctx, "ai_runs purged", "period", p.Key, "deleted", n, "created_before", cutoff.UTC().Format(time.RFC3339)) + return nil + }, + }, nil +} diff --git a/internal/chat/chat_test.go b/internal/chat/chat_test.go index aa9b804..f3c0d15 100644 --- a/internal/chat/chat_test.go +++ b/internal/chat/chat_test.go @@ -308,7 +308,7 @@ func TestSendThreadsByWeek(t *testing.T) { ListReady(week, 19, 7), DayOfReminder(mustDate(t, "2026-10-18"), "01J9DINNER", "Tacos"), NewSuggestions("01J9BATCH", week, []string{"Pho"}), - RotationReview("01J9DINNER", "Pho", week), + RotationReview("01J9DINNER", "Pho", mustDate(t, "2026-10-14")), } for _, ev := range events { if err := svc.Send(ctx, notify.Message{ID: "n1", EventKey: ev.Key, Channel: notify.ChannelChat, Payload: payloadOf(ev), Attempt: 1}); err != nil { diff --git a/internal/chat/messages.go b/internal/chat/messages.go index 1ab7a40..b29a2cd 100644 --- a/internal/chat/messages.go +++ b/internal/chat/messages.go @@ -66,42 +66,59 @@ 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), } } -// NewSuggestions is the new_suggestions event of suggestion batch batchID, -// made in the week starting on week, naming its Side quests. -func NewSuggestions(batchID string, week store.LocalDate, names []string) notify.Event { +// NewSuggestions is the new_suggestions event keyed by id, the ISO week +// ("2026-W41") of a scheduled suggestion batch or the batch id of a manual +// one, of the week starting on week: how many new Side quests there are, +// and their names. +func NewSuggestions(id string, week store.LocalDate, names []string) notify.Event { return notify.Event{ Type: notify.NewSuggestions, - Key: string(notify.NewSuggestions) + ":" + batchID, - Title: "New Side quests are in", + Key: string(notify.NewSuggestions) + ":" + id, + Title: count(len(names), "new Side quest is in", "new Side quests are in"), Body: nameList(names), URL: "/discover", Week: week, } } -// RotationReview is the rotation_review event asking whether the dinner -// dinnerID, named name and tried in the week starting on week, becomes a -// Regular. -func RotationReview(dinnerID, name string, week store.LocalDate) notify.Event { +// RotationReview is the rotation_review event, keyed +// rotation_review:{dinnerID}:{planDate}, of the Rotation check of the +// dinner dinnerID, named name and tried on planDate: "How was Pho? Keep it +// in rotation?". It opens Home, where the Rotation check card asks. +func RotationReview(dinnerID, name string, planDate store.LocalDate) notify.Event { return notify.Event{ Type: notify.RotationReview, - Key: string(notify.RotationReview) + ":" + dinnerID, - Title: "Keep it in rotation?", - Body: "You tried " + name + ". Make it a Regular?", + Key: string(notify.RotationReview) + ":" + dinnerID + ":" + planDate.String(), + Title: "How was " + name + "? Keep it in rotation?", URL: "/", - Week: week, + Week: household.WeekStart(planDate), } } @@ -172,9 +189,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 20468d6..6eaf87e 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", @@ -110,19 +121,29 @@ func TestMessages(t *testing.T) { key: "new_suggestions:01J9BATCH", url: "/discover", week: "2026-10-12", - title: "New Side quests are in", + title: "5 new Side quests are in", body: "Lemon orzo, Bibimbap, Shakshuka + 2 more", - chatT: "New Side quests are in: Lemon orzo, Bibimbap, Shakshuka + 2 more. Open Discover →", + chatT: "5 new Side quests are in: Lemon orzo, Bibimbap, Shakshuka + 2 more. Open Discover →", + }, + { + name: "new suggestions of a scheduled week", + ev: NewSuggestions("2026-W42", week, []string{"Pho"}), + key: "new_suggestions:2026-W42", + url: "/discover", + week: "2026-10-12", + title: "1 new Side quest is in", + body: "Pho", + chatT: "1 new Side quest is in: Pho. Open Discover →", }, { name: "rotation review", - ev: RotationReview("01J9DINNER", "Lemon orzo", week), - key: "rotation_review:01J9DINNER", + ev: RotationReview("01J9DINNER", "Lemon orzo", mustDate(t, "2026-10-14")), + key: "rotation_review:01J9DINNER:2026-10-14", url: "/", week: "2026-10-12", - title: "Keep it in rotation?", - body: "You tried Lemon orzo. Make it a Regular?", - chatT: "Keep it in rotation? You tried Lemon orzo. Make it a Regular? Open MealQuest →", + title: "How was Lemon orzo? Keep it in rotation?", + body: "", + chatT: "How was Lemon orzo? Keep it in rotation? Open MealQuest →", }, } { ev := tc.ev diff --git a/internal/db/querier.go b/internal/db/querier.go index 6a11650..d1623b1 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -24,6 +24,9 @@ type Querier interface { // Marks the oldest queued job running and returns it. Returns sql.ErrNoRows // when no job is queued. ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error) + // Claims one period of a schedule. The insert is the claim: 1 row means + // this caller owns the period, 0 rows means it was already claimed. + ClaimScheduleRun(ctx context.Context, arg ClaimScheduleRunParams) (int64, error) // Clears every day's calendar event id: none is on the chosen calendar. ClearCalendarEvents(ctx context.Context, updatedAt string) error // Records that Google refuses the stored grant: clears the refresh token and diff --git a/internal/db/schedule_runs.sql.go b/internal/db/schedule_runs.sql.go new file mode 100644 index 0000000..0370485 --- /dev/null +++ b/internal/db/schedule_runs.sql.go @@ -0,0 +1,38 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: schedule_runs.sql + +package db + +import ( + "context" +) + +const claimScheduleRun = `-- name: ClaimScheduleRun :execrows +INSERT INTO schedule_runs (id, schedule, period_key, created_at) +VALUES (?, ?, ?, ?) +ON CONFLICT (schedule, period_key) DO NOTHING +` + +type ClaimScheduleRunParams struct { + ID string + Schedule string + PeriodKey string + CreatedAt string +} + +// Claims one period of a schedule. The insert is the claim: 1 row means +// this caller owns the period, 0 rows means it was already claimed. +func (q *Queries) ClaimScheduleRun(ctx context.Context, arg ClaimScheduleRunParams) (int64, error) { + result, err := q.db.ExecContext(ctx, claimScheduleRun, + arg.ID, + arg.Schedule, + arg.PeriodKey, + arg.CreatedAt, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} 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/home/home_test.go b/internal/home/home_test.go index d606d15..a158d2b 100644 --- a/internal/home/home_test.go +++ b/internal/home/home_test.go @@ -94,7 +94,7 @@ func newEnv(t *testing.T) *env { draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { return skills.Week{}, errors.New("no model in this test") } - plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, pub, logger) + plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger) if err != nil { t.Fatal(err) } diff --git a/internal/notify/notify.go b/internal/notify/notify.go index bc4503b..38dc0fa 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -27,6 +27,7 @@ import ( "fmt" "log/slog" "net/url" + "slices" "strings" "sync" "time" @@ -293,6 +294,19 @@ func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels fun return nil } +// ChannelOn reports whether the preference of event type ev switches +// channel ch on. A type with no preference row is an error. +func (s *Service) ChannelOn(ctx context.Context, ev EventType, ch Channel) (bool, error) { + pref, err := db.New(s.st.Reader).GetNotificationPref(ctx, string(ev)) + if errors.Is(err, sql.ErrNoRows) { + return false, fmt.Errorf("notify: no notification preference for %s", ev) + } + if err != nil { + return false, fmt.Errorf("notify: read notification preference for %s: %w", ev, err) + } + return slices.Contains(enabledChannels(pref), ch), nil +} + // enabledChannels returns the channels pref switches on. func enabledChannels(pref db.NotificationPref) []Channel { var chs []Channel 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 6f4a415..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, which keeps one draft in flight per week, is 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) { +// 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, 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 @@ -342,10 +347,27 @@ type Draft struct { // enqueues nothing. Drafting over a synced or changed plan needs confirm, // else it fails with ErrConfirmRequired and enqueues nothing. func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, confirm bool) (Draft, error) { + d, _, err := s.startDraft(ctx, weekStart, confirm, false) + return d, err +} + +// StartScheduledDraft is StartDraft, without confirm, for the plan_draft +// schedule: it enqueues the same KindDraftWeek job, or returns the one +// queued or running, only while the week starting weekStart has no plan. +// A week that has a plan, in any status, is left as it is: nothing is +// enqueued and planned is true. +func (s *Service) StartScheduledDraft(ctx context.Context, weekStart store.LocalDate) (d Draft, planned bool, err error) { + return s.startDraft(ctx, weekStart, false, true) +} + +// startDraft is StartDraft, and when onlyUnplanned is set it enqueues +// nothing for a week that has a plan and reports planned true. +func (s *Service) startDraft(ctx context.Context, weekStart store.LocalDate, confirm, onlyUnplanned bool) (Draft, bool, error) { if _, err := household.WeekDays(weekStart); err != nil { - return Draft{}, &InvalidWeekError{Value: weekStart.String()} + return Draft{}, false, &InvalidWeekError{Value: weekStart.String()} } var d Draft + planned := false err := s.st.WithTx(ctx, func(tx *sql.Tx) error { q := db.New(tx) cur, err := q.GetWeekPlan(ctx, weekStart.String()) @@ -353,6 +375,9 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con case errors.Is(err, sql.ErrNoRows): case err != nil: return fmt.Errorf("plan: read week %s: %w", weekStart, err) + case onlyUnplanned: + planned = true + return nil case cur.Status != StatusDraft && !confirm: return ErrConfirmRequired } @@ -375,12 +400,12 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con return nil }) if err != nil { - return Draft{}, err + return Draft{}, false, err } if d.Created { s.draftJob.Wake() } - return d, nil + return d, planned, nil } // Swap plans dinnerID on date of the week starting weekStart, marking the @@ -450,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 2ea0b38..4b1f557 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/plan/rotation.go b/internal/plan/rotation.go index 129987e..bb80d26 100644 --- a/internal/plan/rotation.go +++ b/internal/plan/rotation.go @@ -6,8 +6,11 @@ import ( "errors" "fmt" + "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/dinners" + "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" ) @@ -79,12 +82,40 @@ type Decided struct { UpdatedBy string `json:"updated_by"` } +// RotationCheckSchedule is the rotation_check schedule: every day at the +// household's review_time it runs MarkTried for the day before, so each +// Quest log dinner planned yesterday is tried and the household is asked +// "Keep it in rotation?". Its period is the fire date, claimed as +// rotation_check:YYYY-MM-DD. It never makes a dinner a Regular. +func (s *Service) RotationCheckSchedule() scheduler.Schedule { + return scheduler.Schedule{ + Name: scheduler.RotationCheck, + Rule: func(settings household.Settings) scheduler.Rule { + return scheduler.Daily(settings.ReviewTime) + }, + PeriodKey: func(p scheduler.Period) string { + return scheduler.RotationCheck + ":" + p.Key + }, + Run: func(ctx context.Context, p scheduler.Period) error { + yesterday := addDays(p.Date, -1) + n, err := s.MarkTried(ctx, yesterday) + if err != nil { + return err + } + s.logger.InfoContext(ctx, "Rotation check: Quest log dinners marked tried", + "period", p.Key, "plan_date", yesterday.String(), "tried", n) + return nil + }, + } +} + // MarkTried moves every dinner in the Quest log planned on date, the -// household-local day just past, on a plan of any status, to tried and -// opens a pending Rotation check of it for date. It is idempotent: a -// dinner already checked for date is left as it is, so a later run never -// moves one returned to the Quest log ("We didn't make it") again. It -// returns how many dinners it moved. +// household-local day just past, on a plan of any status, to tried, opens +// a pending Rotation check of it for date and emits its rotation_review +// (chat.RotationReview), all in one transaction. It is idempotent: a +// dinner already checked for date is left as it is and not notified of +// again, so a later run never moves one returned to the Quest log ("We +// didn't make it") again. It returns how many dinners it moved. func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, error) { var n int err := s.st.WithTx(ctx, func(tx *sql.Tx) error { @@ -95,6 +126,10 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err } now := store.Now().String() for _, id := range ids { + d, err := q.GetDinner(ctx, id) + if err != nil { + return fmt.Errorf("plan: read dinner %s: %w", id, err) + } moved, err := q.MarkDinnerTried(ctx, db.MarkDinnerTriedParams{UpdatedAt: now, ID: id}) if err != nil { return fmt.Errorf("plan: mark %s tried: %w", id, err) @@ -107,6 +142,9 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err }); err != nil { return fmt.Errorf("plan: open Rotation check of %s for %s: %w", id, date, err) } + if err := s.notifier.EmitTx(ctx, tx, chat.RotationReview(id, d.Name, date)); err != nil { + return fmt.Errorf("plan: %w", err) + } } n = len(ids) return nil @@ -114,6 +152,9 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err if err != nil { return 0, err } + if n > 0 { + s.notifier.Wake() + } return n, nil } diff --git a/internal/plan/schedule.go b/internal/plan/schedule.go new file mode 100644 index 0000000..527f814 --- /dev/null +++ b/internal/plan/schedule.go @@ -0,0 +1,42 @@ +package plan + +import ( + "context" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler" +) + +// DraftSchedule is the plan_draft schedule: at the household's +// plan_draft_dow and plan_draft_time it drafts the week after the fire +// date's, its period scheduler.WeeklyForNextWeek keys plan_draft:YYYY-Www +// for that week. The draft is the KindDraftWeek job a manual draft runs +// (StartScheduledDraft), so a stored draft emits plan_ready keyed +// plan_ready:{weekStart} and a draft that fails, as when the AI fails, +// stores no plan and emits nothing. A week that already has a plan is left +// as it is, and so is a draft of it already queued or running; either way +// the period says so. +func (s *Service) DraftSchedule() scheduler.Schedule { + return scheduler.Schedule{ + Name: scheduler.PlanDraft, + Rule: func(settings household.Settings) scheduler.Rule { + return scheduler.WeeklyForNextWeek(time.Weekday(settings.PlanDraftDow), settings.PlanDraftTime) + }, + Run: func(ctx context.Context, p scheduler.Period) error { + d, planned, err := s.StartScheduledDraft(ctx, p.Week) + if err != nil { + return err + } + switch { + case planned: + s.logger.InfoContext(ctx, "scheduled week draft not started: the week already has a plan", + "period", p.Key, "week_start", p.Week.String()) + case !d.Created: + s.logger.WarnContext(ctx, "scheduled week draft not started: a draft of the week is already under way", + "period", p.Key, "week_start", p.Week.String(), "job_id", d.JobID) + } + return nil + }, + } +} 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/export_test.go b/internal/scheduler/export_test.go new file mode 100644 index 0000000..38c8057 --- /dev/null +++ b/internal/scheduler/export_test.go @@ -0,0 +1,9 @@ +package scheduler + +import "time" + +// SetClock makes now the clock and after the timer s fires by. +func SetClock(s *Scheduler, now func() time.Time, after func(time.Duration) <-chan time.Time) { + s.now = now + s.after = after +} diff --git a/internal/scheduler/plan_draft_test.go b/internal/scheduler/plan_draft_test.go new file mode 100644 index 0000000..8696487 --- /dev/null +++ b/internal/scheduler/plan_draft_test.go @@ -0,0 +1,409 @@ +package scheduler_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/url" + "strings" + "sync" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "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/plan" + "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 plan_draft schedule on the fake clock +// against the real plan service, job runner, harness, draft_week and +// notification outbox, with the AI faked. + +// planEnv is a plan service over a scheduler env's store, the "process" a +// scheduler runs it in. model answers draft_week. +type planEnv struct { + e *env + svc *plan.Service + model *llm.Fake + ids map[string]string + logs *syncBuffer + stop func() +} + +// poolNames are the seven Regulars pool stores, enough for a week. +var poolNames = []string{"Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Tacos", "Pancakes"} + +// pool stores poolNames as Regulars and returns their ids by name. +func (e *env) pool() map[string]string { + e.t.Helper() + ids := map[string]string{} + for _, name := range poolNames { + now := store.Now().String() + d, err := db.New(e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{ + ID: store.NewID(), + Name: name, + NormalizedName: harness.Normalize(name), + Status: "regular", + Origin: "regular", + GroundingStatus: "manual", + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + e.t.Fatal(err) + } + ids[name] = d.ID + } + return ids +} + +// publicURL is MEALQUEST_PUBLIC_URL in these tests. +var publicURL = &url.URL{Scheme: "https", Host: "meals.example.com"} + +// noCalendar is a Calendar the schedule must never push to. +type noCalendar struct{ t *testing.T } + +func (c noCalendar) PushEvents(context.Context, []gcal.Event, func() error) error { + c.t.Error("plan_draft pushed to Calendar") + return errors.New("no calendar") +} + +// 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) + if err != nil { + e.t.Fatal(err) + } + cfg := harness.Config{ + Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7}, + NewProvider: func(llm.Settings, string) (llm.Provider, error) { return p.model, nil }, + Store: e.st, + Guard: guard, + } + drafter := func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) { + return skills.DraftWeek(ctx, cfg, scope, in) + } + logger := slog.New(slog.NewTextHandler(p.logs, nil)) + runner, err := jobs.NewRunner(e.st, logger, 1) + 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), wake, publicURL, logger) + if err != nil { + e.t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + if err := runner.Start(ctx); err != nil { + cancel() + e.t.Fatal(err) + } + var once sync.Once + p.stop = func() { + once.Do(func() { + cancel() + runner.Wait() + }) + } + e.t.Cleanup(p.stop) + return p +} + +// reply scripts the next draft_week answer: poolNames on the days of the +// week starting weekStart, Monday to Sunday. +func (p *planEnv) reply(weekStart store.LocalDate) { + p.e.t.Helper() + dates, err := household.WeekDays(weekStart) + if err != nil { + p.e.t.Fatal(err) + } + var days []string + for i, name := range poolNames { + days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], p.ids[name])) + } + js := fmt.Sprintf(`{"days":[%s],"note":""}`, strings.Join(days, ",")) + p.model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}) +} + +// draftJob is a draft_week jobs row. +type draftJob struct { + Status, Error string +} + +// drafts waits until no draft_week job is queued or running and returns +// them all, oldest first. +func (e *env) drafts() []draftJob { + e.t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + rows, err := e.st.Reader.Query(`SELECT status, COALESCE(error, '') FROM jobs WHERE kind = ? ORDER BY created_at, id`, plan.KindDraftWeek) + if err != nil { + e.t.Fatal(err) + } + var out []draftJob + busy := false + for rows.Next() { + var j draftJob + if err := rows.Scan(&j.Status, &j.Error); err != nil { + e.t.Fatal(err) + } + busy = busy || j.Status == string(jobs.StatusQueued) || j.Status == string(jobs.StatusRunning) + out = append(out, j) + } + if err := rows.Err(); err != nil { + e.t.Fatal(err) + } + rows.Close() + if !busy { + return out + } + if time.Now().After(deadline) { + e.t.Fatalf("draft jobs still under way: %+v", out) + } + time.Sleep(5 * time.Millisecond) + } +} + +// wantDrafts fails unless the draft_week jobs are got. +func wantDrafts(t *testing.T, what string, got []draftJob, want ...draftJob) { + t.Helper() + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("%s: draft jobs = %+v, want %+v", what, got, want) + } +} + +// weekPlans returns the week_start of every plan, oldest first. +func (e *env) weekPlans() []string { + e.t.Helper() + rows, err := e.st.Reader.Query(`SELECT week_start FROM week_plans ORDER BY week_start`) + if err != nil { + e.t.Fatal(err) + } + defer rows.Close() + var out []string + for rows.Next() { + var w string + if err := rows.Scan(&w); err != nil { + e.t.Fatal(err) + } + out = append(out, w) + } + if err := rows.Err(); err != nil { + e.t.Fatal(err) + } + return out +} + +// planReady returns the distinct plan_ready keys stored, oldest first. +func (e *env) planReady() []string { + e.t.Helper() + var keys []string + for _, r := range notifytest.Rows(e.t, e.st, notify.PlanReady) { + if len(keys) == 0 || keys[len(keys)-1] != r.Key { + keys = append(keys, r.Key) + } + } + return keys +} + +var ( + succeeded = draftJob{Status: string(jobs.StatusSucceeded)} + aiFailed = draftJob{Status: string(jobs.StatusFailed), Error: plan.ReasonAIFailed} + // w43 is the Monday of 2026-W43, the week after Friday Oct 16. + w43 = mustLocalDate("2026-10-19") + w44 = mustLocalDate("2026-10-26") +) + +func mustLocalDate(s string) store.LocalDate { + d, err := store.ParseLocalDate(s) + if err != nil { + panic(err) + } + return d +} + +// TestPlanDraftDraftsNextWeek: at the seeded Friday 09:00 the schedule +// drafts the next ISO week through the plan service's draft job, period +// plan_draft:YYYY-Www of that week, and the draft emits plan_ready keyed +// by its Monday once. +func TestPlanDraftDraftsNextWeek(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + p.reply(w43) + p.reply(w44) + + // Wednesday Oct 14: the Friday Oct 9 09:00 fire is days ago, missed. + r := e.start(at(2026, 10, 14, 12, 0), p.svc.DraftSchedule()) + r.runUntil(at(2026, 10, 16, 8, 59)) + wantDrafts(t, "before Friday 09:00", e.drafts()) + wantKeys(t, "plans before Friday 09:00", e.weekPlans(), nil) + + r.runUntil(at(2026, 10, 16, 9, 0)) + wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded) + wantKeys(t, "plans at Friday 09:00", e.weekPlans(), []string{"2026-10-19"}) + wantKeys(t, "plan_ready of W43", e.planReady(), []string{"plan_ready:2026-10-19"}) + got, err := p.svc.Get(context.Background(), w43) + if err != nil { + t.Fatal(err) + } + if len(got.Days) != 7 || got.Days[0].Dinner.Name != "Beef Chili" || got.Status != plan.StatusDraft { + t.Errorf("plan of W43 = %+v, want the drafted week", got) + } + + r.runUntil(at(2026, 10, 23, 9, 0)) + wantDrafts(t, "W44", e.drafts(), succeeded, succeeded) + wantKeys(t, "plans", e.weekPlans(), []string{"2026-10-19", "2026-10-26"}) + wantKeys(t, "plan_ready of W43 and W44", e.planReady(), []string{"plan_ready:2026-10-19", "plan_ready:2026-10-26"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43", "2026-W44"}) +} + +// TestPlanDraftLeavesAnExistingPlan: when next week already has a plan, +// here one the household drafted by hand, the period drafts nothing, +// notifies no one and says so. +func TestPlanDraftLeavesAnExistingPlan(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + p.reply(w43) + p.reply(w43) // a second draft would succeed too + if _, err := p.svc.StartDraft(context.Background(), w43, false); err != nil { + t.Fatal(err) + } + wantDrafts(t, "manual draft", e.drafts(), succeeded) + wantKeys(t, "plan_ready of the manual draft", e.planReady(), []string{"plan_ready:2026-10-19"}) + before, err := p.svc.Get(context.Background(), w43) + if err != nil { + t.Fatal(err) + } + + r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule()) + r.runUntil(at(2026, 10, 16, 9, 0)) + wantDrafts(t, "with a plan", e.drafts(), succeeded) + wantKeys(t, "plan_ready with a plan", e.planReady(), []string{"plan_ready:2026-10-19"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"}) + after, err := p.svc.Get(context.Background(), w43) + if err != nil { + t.Fatal(err) + } + if fmt.Sprint(after) != fmt.Sprint(before) { + t.Errorf("plan of W43 = %+v, want it unchanged from %+v", after, before) + } + if logs := p.logs.String(); !strings.Contains(logs, "the week already has a plan") || !strings.Contains(logs, "period=2026-W43") { + t.Errorf("logs = %s, want the skipped period logged", logs) + } +} + +// TestPlanDraftNoDuplicateAfterRestart: a restart after the week was +// drafted, still within the catch-up window, drafts nothing more and sends +// no second plan_ready: the period is already claimed. +func TestPlanDraftNoDuplicateAfterRestart(t *testing.T) { + e := newEnv(t) + ids := e.pool() + p := e.plans(ids) + p.reply(w43) + r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule()) + r.runUntil(at(2026, 10, 16, 9, 0)) + wantDrafts(t, "first process", e.drafts(), succeeded) + r.stop() + p.stop() + + // The restarted process has a new job runner, plan service and + // scheduler; it would answer a second draft if one were started. + p2 := e.plans(ids) + p2.reply(w43) + r2 := e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule()) + r2.runUntil(at(2026, 10, 23, 8, 59)) + wantDrafts(t, "after restart", e.drafts(), succeeded) + wantKeys(t, "plans after restart", e.weekPlans(), []string{"2026-10-19"}) + wantKeys(t, "plan_ready after restart", e.planReady(), []string{"plan_ready:2026-10-19"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"}) +} + +// TestPlanDraftAIFailureEmitsNothing: when draft_week fails the draft job +// fails as ai_failed with the failure recorded in ai_runs, no plan is made +// up and no one is notified; a restart within the catch-up window does not +// draft the claimed period again. +func TestPlanDraftAIFailureEmitsNothing(t *testing.T) { + e := newEnv(t) + ids := e.pool() + p := e.plans(ids) + p.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")}) + + r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule()) + r.runUntil(at(2026, 10, 16, 9, 0)) + wantDrafts(t, "after the AI failed", e.drafts(), aiFailed) + wantKeys(t, "plans after the AI failed", e.weekPlans(), nil) + wantKeys(t, "plan_ready after the AI failed", e.planReady(), nil) + wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"}) + + var status, msg string + if err := e.st.Reader.QueryRow(`SELECT status, COALESCE(error, '') FROM ai_runs WHERE skill = 'draft_week'`).Scan(&status, &msg); err != nil { + t.Fatalf("ai_runs row of draft_week: %v", err) + } + if status != string(harness.StatusProviderError) || msg == "" { + t.Errorf("ai_runs draft_week = %s %q, want %s with its message", status, msg, harness.StatusProviderError) + } + if logs := p.logs.String(); !strings.Contains(logs, "week draft failed") || !strings.Contains(logs, "reason="+plan.ReasonAIFailed) { + t.Errorf("logs = %s, want the draft failure with reason %s", logs, plan.ReasonAIFailed) + } + + r.stop() + p.stop() + p2 := e.plans(ids) + p2.reply(w43) + e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule()) + wantDrafts(t, "after restart", e.drafts(), aiFailed) + wantKeys(t, "plan_ready after restart", e.planReady(), nil) +} + +// TestPlanDraftFollowsSettingsWithoutRestart: a plan_draft_dow and +// plan_draft_time change takes effect in the running scheduler; the old +// Friday 09:00 no longer fires and the new Wednesday 18:30 drafts the next +// week. +func TestPlanDraftFollowsSettingsWithoutRestart(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + p.reply(w43) + + r := e.start(at(2026, 10, 12, 12, 0), p.svc.DraftSchedule()) // Monday + e.update(household.SettingsPatch{PlanDraftDow: ptr(3), PlanDraftTime: ptr("18:30")}) + r.changed() + r.runUntil(at(2026, 10, 14, 18, 29)) // Wednesday + wantDrafts(t, "before Wednesday 18:30", e.drafts()) + r.runUntil(at(2026, 10, 14, 18, 30)) + wantDrafts(t, "at Wednesday 18:30", e.drafts(), succeeded) + wantKeys(t, "plan_ready", e.planReady(), []string{"plan_ready:2026-10-19"}) + + // Friday 09:00, the old fire time, drafts nothing more. + r.runUntil(at(2026, 10, 16, 9, 0)) + wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded) + wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"}) +} diff --git a/internal/scheduler/rotation_check_test.go b/internal/scheduler/rotation_check_test.go new file mode 100644 index 0000000..d4075d4 --- /dev/null +++ b/internal/scheduler/rotation_check_test.go @@ -0,0 +1,204 @@ +package scheduler_test + +import ( + "context" + "testing" + + "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" +) + +// The tests in this file run plan's rotation_check schedule on the fake +// clock against the real plan service and notification outbox. The plan of +// the week of Monday Oct 12 (w42) has poolNames, all Regulars, Monday to +// Sunday; a test that needs a Quest-log dinner swaps Lemon Orzo in for +// Monday. The household's review time is the seeded 09:00, in Chicago. + +// orzoDay is the Monday Lemon Orzo is planned on. +var orzoDay = mustLocalDate("2026-10-12") + +// orzoWeek plans w42 with the Quest-log dinner Lemon Orzo on Monday in +// place of Beef Chili, and returns its id. +func (p *planEnv) orzoWeek() string { + p.e.t.Helper() + id := p.questLog("Lemon Orzo", 30) + p.planWeek(w42) + if _, err := p.svc.Swap(context.Background(), w42, orzoDay.String(), id); err != nil { + p.e.t.Fatal(err) + } + return id +} + +// rotationReviews returns the keys of the rotation_review notifications +// stored, oldest first. The seeded preference sends rotation_review to push +// only, so there is one row per event. +func (e *env) rotationReviews() []string { + e.t.Helper() + var keys []string + for _, r := range notifytest.Rows(e.t, e.st, notify.RotationReview) { + if r.Channel != notify.ChannelPush { + e.t.Fatalf("rotation_review row %+v, want push only", r) + } + keys = append(keys, r.Key) + } + return keys +} + +// status returns the status of the dinner name. +func (p *planEnv) status(name string) string { + p.e.t.Helper() + var s string + if err := p.e.st.Reader.QueryRow(`SELECT status FROM dinners WHERE id = ?`, p.ids[name]).Scan(&s); err != nil { + p.e.t.Fatal(err) + } + return s +} + +// pendingReviews returns the plan dates of the pending Rotation checks of +// the dinner name. +func (p *planEnv) pendingReviews(name string) []string { + p.e.t.Helper() + rows, err := p.e.st.Reader.Query(`SELECT plan_date FROM rotation_reviews WHERE dinner_id = ? AND status = 'pending' ORDER BY plan_date`, p.ids[name]) + if err != nil { + p.e.t.Fatal(err) + } + defer rows.Close() + var out []string + for rows.Next() { + var d string + if err := rows.Scan(&d); err != nil { + p.e.t.Fatal(err) + } + out = append(out, d) + } + if err := rows.Err(); err != nil { + p.e.t.Fatal(err) + } + return out +} + +// wantOrzoTried fails unless Lemon Orzo, id, is tried with one pending +// Rotation check for orzoDay and exactly one rotation_review asking about +// it, which opens Home. +func (p *planEnv) wantOrzoTried(id string) { + p.e.t.Helper() + if got := p.status("Lemon Orzo"); got != "tried" { + p.e.t.Errorf("Lemon Orzo status = %s, want tried", got) + } + wantKeys(p.e.t, "Lemon Orzo pending Rotation checks", p.pendingReviews("Lemon Orzo"), []string{orzoDay.String()}) + key := "rotation_review:" + id + ":" + orzoDay.String() + wantKeys(p.e.t, "rotation_review", p.e.rotationReviews(), []string{key}) + got := notifytest.Rows(p.e.t, p.e.st, notify.RotationReview)[0].Payload + want := notify.Payload{Event: notify.RotationReview, Title: "How was Lemon Orzo? Keep it in rotation?", URL: "/", Tag: key, Week: w42.String()} + if got != want { + p.e.t.Errorf("rotation_review = %+v, want %+v", got, want) + } +} + +// TestRotationCheckMarksQuestLogDinnerTried: at review_time 09:00 the day +// after a Quest-log dinner was planned, the schedule, period +// rotation_check:YYYY-MM-DD, marks it tried, opens its Rotation check and +// emits one rotation_review. It never makes the dinner a Regular. +func TestRotationCheckMarksQuestLogDinnerTried(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + id := p.orzoWeek() + + // Tuesday 08:00: the catch-up of Monday 09:00 checks Sunday, unplanned. + r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule()) + r.runUntil(at(2026, 10, 13, 8, 59)) + wantKeys(t, "before 09:00", e.rotationReviews(), nil) + if got := p.status("Lemon Orzo"); got != "quest_log" { + t.Fatalf("Lemon Orzo status before 09:00 = %s, want quest_log", got) + } + + r.runUntil(at(2026, 10, 13, 9, 0)) + p.wantOrzoTried(id) + + // Days later, still tried: only the household's keep makes a Regular. + r.runUntil(at(2026, 10, 16, 12, 0)) + p.wantOrzoTried(id) + wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{ + "rotation_check:2026-10-12", "rotation_check:2026-10-13", "rotation_check:2026-10-14", + "rotation_check:2026-10-15", "rotation_check:2026-10-16", + }) +} + +// TestRotationCheckLeavesRegulars: a day planning only Regulars marks +// nothing tried, opens no Rotation check and emits nothing. +func TestRotationCheckLeavesRegulars(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + p.planWeek(w42) + + r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule()) + r.runUntil(at(2026, 10, 13, 9, 0)) + wantKeys(t, "rotation_review", e.rotationReviews(), nil) + if got := p.status("Beef Chili"); got != "regular" { + t.Errorf("Beef Chili status = %s, want regular", got) + } + wantKeys(t, "Beef Chili pending Rotation checks", p.pendingReviews("Beef Chili"), nil) + wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"}) +} + +// TestRotationCheckNoDuplicateSameDay: a restart the same day finds the +// period claimed, and MarkTried run again for the same day finds the +// Rotation check open, so neither opens a second check or notification. +func TestRotationCheckNoDuplicateSameDay(t *testing.T) { + e := newEnv(t) + ids := e.pool() + p := e.plans(ids) + id := p.orzoWeek() + + r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule()) + r.runUntil(at(2026, 10, 13, 9, 0)) + p.wantOrzoTried(id) + r.stop() + p.stop() + + p2 := e.plans(ids) + r2 := e.start(at(2026, 10, 13, 9, 30), p2.svc.RotationCheckSchedule()) + r2.runUntil(at(2026, 10, 13, 23, 59)) + p2.wantOrzoTried(id) + + if n, err := p2.svc.MarkTried(context.Background(), orzoDay); err != nil || n != 0 { + t.Fatalf("MarkTried(%s) again = %d, %v, want 0", orzoDay, n, err) + } + p2.wantOrzoTried(id) + wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"}) +} + +// TestRotationCheckCatchUpAtBoot: started 23 hours after a missed 09:00, +// the scheduler runs that day's Rotation check once at boot. +func TestRotationCheckCatchUpAtBoot(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + id := p.orzoWeek() + + e.start(at(2026, 10, 14, 8, 0), p.svc.RotationCheckSchedule()) + p.wantOrzoTried(id) + wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-13"}) +} + +// TestRotationCheckFollowsReviewTimeWithoutRestart: a review_time change +// takes effect in the running scheduler. Moved from 09:00 to 10:30, the +// Rotation check does not run at 09:00 and runs at 10:30. +func TestRotationCheckFollowsReviewTimeWithoutRestart(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + id := p.orzoWeek() + + r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule()) + e.update(household.SettingsPatch{ReviewTime: ptr("10:30")}) + r.changed() + r.runUntil(at(2026, 10, 13, 10, 29)) + wantKeys(t, "before 10:30", e.rotationReviews(), nil) + if got := p.status("Lemon Orzo"); got != "quest_log" { + t.Fatalf("Lemon Orzo status before 10:30 = %s, want quest_log", got) + } + r.runUntil(at(2026, 10, 13, 10, 30)) + p.wantOrzoTried(id) + wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"}) +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..a37eaf5 --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,746 @@ +// Package scheduler runs the household's timed work: each registered +// schedule fires once per period at a household-local time read from the +// household settings. +// +// # Periods and the claim +// +// Every fire time belongs to one period, named by a period key derived in +// the household zone from the schedule's cadence (see Rule.PeriodKey): the +// local date "2026-10-09" for a daily rule, and the ISO week "2026-W41" of +// the fire date, or of the week after it, for a weekly rule. Before running, +// the scheduler inserts schedule_runs(schedule, period_key); the insert is +// the claim, and a period already claimed, by an earlier run, an earlier +// process or another instance, is skipped. A period therefore runs at most +// once: a Run that fails is logged and its period is not retried. +// +// # Catch-up +// +// When the scheduler starts, and whenever it wakes late, it handles, oldest +// first, every fire time of each schedule that passed within CatchUpWindow, +// and the most recent fire time even when it passed longer ago. Each +// unclaimed period within CatchUpWindow runs once. A most recent one that +// passed longer ago is claimed without running and logged as missed, so it +// can never run later, not after a restart and not after a settings change +// moves the fire time. Other fire times older than CatchUpWindow are never +// looked at. +// +// # Claim failures +// +// A claim that fails (the database refused the insert) is never skipped: +// the scheduler logs it and keeps that period's fire time, and every +// ClaimRetry claims that same period again until the claim succeeds. Only +// then does it apply the catch-up rule to it, running it if its fire time +// is within CatchUpWindow of the successful claim and recording it as +// missed otherwise, and only then does it go on to later fire times, even +// when one has passed meanwhile. Each period is claimed by its own fire +// time, so the later period runs once as well. +// +// # Time zone and DST +// +// Fire times are wall-clock times in the household zone, never the process +// zone, turned into instants by household.LocalTime. Its rule is the +// scheduler's DST rule: a time that does not exist on a spring-forward day +// fires later by the length of the gap (02:30 fires at 03:30 local), and a +// time that occurs twice on a fall-back day fires once, at its first +// 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 +// with the instant it was saved. A change takes effect at once without a +// restart: fire times up to that instant follow the old settings and later +// ones the new, even when the scheduler gets to the change late. So moving +// a fire time to earlier the same day does not fire the passed time, and a +// new fire time that was still ahead when the change was saved fires. +package scheduler + +import ( + "context" + "errors" + "fmt" + "log/slog" + "slices" + "strings" + "sync" + "time" + + "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/store" +) + +// Schedule names, as the schedule_runs.schedule CHECK constraint allows. +const ( + SuggestionsWeekly = "suggestions_weekly" + PlanDraft = "plan_draft" + ShoppingChatAutosend = "shopping_chat_autosend" + DayOfReminder = "day_of_reminder" + RotationCheck = "rotation_check" + WeekRollover = "week_rollover" + AIRunsPurge = "ai_runs_purge" +) + +// names are the schedule names Register accepts. +var names = map[string]bool{ + SuggestionsWeekly: true, + PlanDraft: true, + ShoppingChatAutosend: true, + DayOfReminder: true, + RotationCheck: true, + WeekRollover: true, + AIRunsPurge: true, +} + +// CatchUpWindow is how long after its fire time a period that has not run +// still runs. A period found later than that is recorded as missed. +const CatchUpWindow = 24 * time.Hour + +// ClaimRetry is how long the scheduler waits before repeating a pass in +// which a claim failed. +const ClaimRetry = time.Minute + +// maxWait bounds one sleep, so the scheduler checks the wall clock at least +// this often even if the host was suspended or its clock stepped. +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, or every day at the instant +// At gives. Build one with Daily, Weekly, WeeklyForNextWeek or DailyAt. +type Rule struct { + Weekly bool + Weekday time.Weekday + Time string + // 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} +} + +// Weekly fires on day at hhmm; its period is the ISO week of the fire date. +func Weekly(day time.Weekday, hhmm string) Rule { + return Rule{Weekly: true, Weekday: day, Time: hhmm} +} + +// WeeklyForNextWeek fires on day at hhmm; its period is the ISO week after +// the fire date's. +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") +// when NextWeek is set. It panics if fireDate is the zero LocalDate. +func (r Rule) PeriodKey(fireDate store.LocalDate) string { + if !r.Weekly { + return fireDate.String() + } + if r.NextWeek { + return household.PeriodKey(addDays(fireDate, 7)) + } + return household.PeriodKey(fireDate) +} + +// validate reports whether r can fire. +func (r Rule) validate() error { + if r.Weekly && (r.Weekday < time.Sunday || r.Weekday > time.Saturday) { + return fmt.Errorf("invalid weekday %d (want 0-6, Sunday = 0)", r.Weekday) + } + 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 +} + +// Period is one period of a schedule, as given to its Run. +type Period struct { + // Key is the period key claimed in schedule_runs. + Key string + // FireAt is the instant the period fired, in the household zone. + FireAt time.Time + // Date is the household-local date of FireAt. + Date store.LocalDate + // Week is the ISO Monday of the week Key names; for a daily rule, of + // the week containing Date. + Week store.LocalDate +} + +// Schedule is registered work that runs once per period. +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 + // PeriodKey, when set, gives the period key claimed for p in place of + // the rule's, such as "list_ready:chat:2026-10-19" for the week p.Week; + // p.Key is the rule's key. It must give each period its own key. A + // schedule sets Key or PeriodKey, not both. + PeriodKey func(p Period) string + // Rule returns when the schedule fires under settings. It is called + // again after every settings change. + Rule func(settings household.Settings) Rule + // Run does the period's work. It runs on the scheduler's goroutine, + // one schedule at a time, so it must return promptly: long work is + // enqueued as a job. ctx is cancelled when the scheduler stops. An + // error is logged; the period stays claimed and is not retried. + 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) + Subscribe(fn func(household.Settings)) (unsubscribe func()) +} + +// Scheduler runs the registered schedules. +type Scheduler struct { + st *store.Store + settings Settings + logger *slog.Logger + + // now is the clock and after the timer; tests replace both. + now func() time.Time + after func(d time.Duration) <-chan time.Time + + // 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 + + mu sync.Mutex + schedules []Schedule + started bool + + wg sync.WaitGroup +} + +// New returns a scheduler over st reading the household settings from +// settings. +func New(st *store.Store, settings Settings, logger *slog.Logger) (*Scheduler, error) { + if st == nil { + return nil, errors.New("scheduler: nil store") + } + if settings == nil { + return nil, errors.New("scheduler: nil settings") + } + if logger == nil { + return nil, errors.New("scheduler: nil logger") + } + return &Scheduler{ + st: st, + settings: settings, + logger: logger, + now: time.Now, + after: time.After, + wake: make(chan struct{}, 1), + }, nil +} + +// Register adds sch. It must be called before Start, once per name. +func (s *Scheduler) Register(sch Schedule) error { + if !names[sch.Name] { + return fmt.Errorf("scheduler: register: unknown schedule %q", sch.Name) + } + if sch.Rule == nil { + return fmt.Errorf("scheduler: register %s: nil rule", sch.label()) + } + if sch.Run == nil { + 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()) + } + if sch.Key != "" && sch.PeriodKey != nil { + return fmt.Errorf("scheduler: register %s: both key and period key", sch.label()) + } + s.mu.Lock() + defer s.mu.Unlock() + if s.started { + return fmt.Errorf("scheduler: register %s: already started", sch.label()) + } + for _, have := range s.schedules { + 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) + return nil +} + +// Start reads the settings and starts the scheduler, which first catches +// up on each schedule's recent periods and then fires schedules as +// their times come. Settings whose zone or rules are invalid are an error +// and nothing starts. The scheduler stops when ctx is cancelled; Wait waits +// for it. +func (s *Scheduler) Start(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.started { + return errors.New("scheduler: already started") + } + // Subscribing first means a change saved after the read below still + // wakes the loop. + unsubscribe := s.settings.Subscribe(s.changed) + settings, err := s.settings.Get(ctx) + if err != nil { + unsubscribe() + return fmt.Errorf("scheduler: %w", err) + } + p, err := compile(s.schedules, settings) + if err != nil { + unsubscribe() + return err + } + s.started = true + s.wg.Add(1) + go func() { + defer s.wg.Done() + defer unsubscribe() + s.loop(ctx, p) + }() + return nil +} + +// Wait blocks until the scheduler has stopped. +func (s *Scheduler) Wait() { + s.wg.Wait() +} + +// change is a settings change and the instant it was saved. +type change struct { + at time.Time + settings household.Settings +} + +// changed is the settings subscriber. It runs on the updating goroutine, so +// it only records the change and wakes the loop. +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 looks again then + } +} + +// takeChanges returns and clears the pending changes. +func (s *Scheduler) takeChanges() []change { + s.pendingMu.Lock() + defer s.pendingMu.Unlock() + out := s.pending + s.pending = nil + return out +} + +// plan is the schedules compiled against one version of the settings. +type plan struct { + loc *time.Location + items []item +} + +type item struct { + sch Schedule + rule Rule +} + +// compile builds the plan for settings. +func compile(schedules []Schedule, settings household.Settings) (*plan, error) { + loc, err := household.LoadZone(settings.Timezone) + if err != nil { + return nil, fmt.Errorf("scheduler: household zone: %w", err) + } + p := &plan{loc: loc, items: make([]item, len(schedules))} + for i, sch := range schedules { + rule := sch.Rule(settings) + if err := rule.validate(); err != nil { + return nil, fmt.Errorf("scheduler: %s: %w", sch.label(), err) + } + p.items[i] = item{sch: sch, rule: rule} + } + return p, nil +} + +// loop fires schedules until ctx is cancelled. done is the instant up to +// which fire times have been handled; it starts at the zero time so the +// first pass catches up. retry is the periods whose claim failed, each +// with its own fire time. Each pass first claims retry again; while any +// claim there still fails, the pass stops and is repeated after +// ClaimRetry. Otherwise it applies the pending settings changes in order, +// handling fire times up to a change under the plan it replaces, then +// handles fire times up to now under the current plan, stopping after the +// first stretch in which a claim failed. +func (s *Scheduler) loop(ctx context.Context, p *plan) { + var done time.Time + var pending []change + var retry []target + for { + now := s.now() + pending = append(pending, s.takeChanges()...) + retry = s.retry(ctx, retry, now) + for len(retry) == 0 && len(pending) > 0 { + c := pending[0] + if c.at.After(done) { + if p != nil { + retry = s.fire(ctx, p, done, c.at, now) + } + done = c.at + } + p = s.compileChange(ctx, c.settings) + pending = pending[1:] + } + if len(retry) == 0 { + if p != nil { + retry = s.fire(ctx, p, done, now, now) + } + done = now + } + if ctx.Err() != nil { + return + } + + var timeout <-chan time.Time + switch { + case len(retry) > 0: + timeout = s.after(ClaimRetry) + case p != nil: + 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)) + } + } + select { + case <-ctx.Done(): + return + case <-timeout: + case <-s.wake: + } + } +} + +// compileChange returns the plan for changed settings, or nil when they +// are unusable. +func (s *Scheduler) compileChange(ctx context.Context, settings household.Settings) *plan { + p, err := compile(s.schedules, settings) + if err != nil { + // Settings are validated before they are saved, so this is a + // bug; firing at a guessed time would be worse than not firing + // until the settings are fixed. + s.logger.ErrorContext(ctx, "scheduler: settings unusable; no schedule fires until they change", "error", err) + return nil + } + return p +} + +// target is one schedule's periods still to handle, oldest first, each +// named by its fire time and the household-local date of its period. A +// target whose fire times could not be looked up has lookup set instead, +// with the stretch (done, until] to look them up in again. +type target struct { + it item + loc *time.Location + fires []fireTime + + lookup bool + done, until time.Time +} + +// fire handles, for every schedule, its catch-up fire times after done and +// at or before until; now is the current instant. It returns the +// 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 + } + if t, ok := s.fireItem(ctx, it, p.loc, done, until, now); !ok { + failed = append(failed, t) + } + } + return failed +} + +// fireItem handles, oldest first, it's fire times after done and at or +// before until that passed within CatchUpWindow of now, and the most recent +// of them even when it passed longer ago. It reports false, with the target +// to handle again, when the fire times could not be looked up or a period's +// claim failed. +func (s *Scheduler) fireItem(ctx context.Context, it item, loc *time.Location, done, until, now time.Time) (target, bool) { + fs, err := fireBetween(ctx, it.rule, loc, done, 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 + } + i := 0 + for i < len(fs)-1 && now.Sub(fs[i].at) > CatchUpWindow { + i++ + } + return s.handleAll(ctx, target{it: it, loc: loc, fires: fs[i:]}, now) +} + +// handleAll handles the periods of t in order, stopping at the first whose +// claim failed. It reports false, with that period and those after it, +// when one did. +func (s *Scheduler) handleAll(ctx context.Context, t target, now time.Time) (target, bool) { + for i, f := range t.fires { + if !s.handle(ctx, t.it, f, now) { + t.fires = t.fires[i:] + return t, false + } + } + return target{}, true +} + +// 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 again, ok := s.handleAll(ctx, t, now); !ok { + failed = append(failed, again) + } + } + return failed +} + +// handle claims the period of it's fire time f and runs it, or records it +// as missed when f 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, it item, f fireTime, now time.Time) bool { + at, date := f.at, f.date + period := Period{Key: it.rule.PeriodKey(date), FireAt: at, Date: date} + if it.rule.NextWeek { + period.Week = household.WeekStart(addDays(date, 7)) + } else { + period.Week = household.WeekStart(date) + } + switch { + case it.sch.Key != "": + period.Key = it.sch.Key + ":" + period.Key + case it.sch.PeriodKey != nil: + period.Key = it.sch.PeriodKey(period) + } + log := s.logger.With("schedule", it.sch.Name, "period", period.Key, "fire_at", at.Format(time.RFC3339)) + + claimed, err := s.claim(ctx, it.sch.Name, period.Key, now) + if err != nil { + log.ErrorContext(ctx, "scheduler: claim period failed; retrying", "error", err, "retry_in", ClaimRetry.String()) + return false + } + if !claimed { + return true + } + if late := now.Sub(at); late > CatchUpWindow { + log.WarnContext(ctx, "scheduler: period missed; recorded without running", "late", late.String()) + return true + } + if err := it.sch.Run(ctx, period); err != nil { + log.ErrorContext(ctx, "scheduler: run failed", "error", err) + return true + } + log.InfoContext(ctx, "scheduler: ran") + return true +} + +// claim inserts schedule_runs(name, key) and reports whether this call +// inserted it. +func (s *Scheduler) claim(ctx context.Context, name, key string, now time.Time) (bool, error) { + n, err := db.New(s.st.Writer).ClaimScheduleRun(ctx, db.ClaimScheduleRunParams{ + ID: store.NewID(), + Schedule: name, + PeriodKey: key, + CreatedAt: store.Timestamp{Time: now.UTC().Truncate(time.Millisecond)}.String(), + }) + if err != nil { + return false, err + } + return n == 1, nil +} + +// next returns the earliest fire time of any schedule after now, or false +// 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 { + 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(), nil +} + +// window is how many days either side of a date fireBetween and fireAfter +// search: more than a week, so a weekly rule always has a fire time on +// each side. +const window = 8 + +// atWindow is how many days either side of a date fireBetween 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 +} + +// fireBetween returns the fire times of r in zone loc after from and at or +// before t, oldest first. +func fireBetween(ctx context.Context, r Rule, loc *time.Location, from, t time.Time) ([]fireTime, error) { + fs, err := fireTimes(ctx, r, loc, t) + if err != nil { + return nil, err + } + var out []fireTime + for _, f := range fs { + if f.at.After(from) && !f.at.After(t) { + out = append(out, f) + } + } + slices.SortFunc(out, func(a, b fireTime) int { return a.at.Compare(b.at) }) + return out, nil +} + +// fireAfter returns the earliest fire time of r in zone loc after t. +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.at.IsZero(), nil +} + +// 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) + 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, fireTime{at: at, date: d}) + } + return out, nil +} + +// addDays returns d plus n calendar days. +func addDays(d store.LocalDate, n int) store.LocalDate { + y, m, day := d.Date() + return store.LocalDateOf(time.Date(y, m, day+n, 0, 0, 0, 0, time.UTC)) +} + +// weekday returns the day of the week of d. +func weekday(d store.LocalDate) time.Weekday { + y, m, day := d.Date() + return time.Date(y, m, day, 0, 0, 0, 0, time.UTC).Weekday() +} diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go new file mode 100644 index 0000000..4edf8b3 --- /dev/null +++ b/internal/scheduler/scheduler_test.go @@ -0,0 +1,805 @@ +package scheduler_test + +import ( + "context" + "errors" + "io" + "log/slog" + "slices" + "sync" + "sync/atomic" + "testing" + "time" + + "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" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +const zone = "America/Chicago" + +// chicago is the household zone of every test. +var chicago = mustZone(zone) + +func mustZone(name string) *time.Location { + loc, err := household.LoadZone(name) + if err != nil { + panic(err) + } + return loc +} + +// at returns the instant clocks in Chicago read the given time. +func at(year int, month time.Month, day, hour, minute int) time.Time { + return time.Date(year, month, day, hour, minute, 0, 0, chicago) +} + +// fakeClock is a clock whose time moves only when the test moves it. +type fakeClock struct { + mu sync.Mutex + now time.Time + timers []fakeTimer + // waits receives one value per After call, so the test knows the + // scheduler finished a pass and is waiting. + waits chan struct{} +} + +type fakeTimer struct { + deadline time.Time + ch chan time.Time +} + +func newFakeClock(now time.Time) *fakeClock { + return &fakeClock{now: now, waits: make(chan struct{}, 100)} +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + 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 = []fakeTimer{{deadline: c.now.Add(d), ch: ch}} + c.mu.Unlock() + c.waits <- struct{}{} + return ch +} + +// set moves the clock to t and fires the timers due by then, reporting +// whether any fired. +func (c *fakeClock) set(t time.Time) bool { + c.mu.Lock() + defer c.mu.Unlock() + c.now = t + fired := false + kept := c.timers[:0] + for _, tm := range c.timers { + if tm.deadline.After(t) { + kept = append(kept, tm) + continue + } + tm.ch <- t + fired = true + } + c.timers = kept + return fired +} + +// nextDeadline returns the earliest pending timer deadline. +func (c *fakeClock) nextDeadline() (time.Time, bool) { + c.mu.Lock() + defer c.mu.Unlock() + var next time.Time + for _, tm := range c.timers { + if next.IsZero() || tm.deadline.Before(next) { + next = tm.deadline + } + } + return next, !next.IsZero() +} + +// recorder records the periods a schedule ran. +type recorder struct { + mu sync.Mutex + runs []scheduler.Period + err error +} + +func (r *recorder) run(_ context.Context, p scheduler.Period) error { + r.mu.Lock() + defer r.mu.Unlock() + r.runs = append(r.runs, p) + return r.err +} + +func (r *recorder) periods() []scheduler.Period { + r.mu.Lock() + defer r.mu.Unlock() + return slices.Clone(r.runs) +} + +func (r *recorder) keys() []string { + var keys []string + for _, p := range r.periods() { + keys = append(keys, p.Key) + } + return keys +} + +// env is a migrated database with household settings in Chicago. +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 { + t.Helper() + ctx := context.Background() + st, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + if err := household.SeedSettings(ctx, st, zone); err != nil { + t.Fatal(err) + } + return &env{t: t, st: st, settings: household.NewSettingsService(st)} +} + +// update changes the household settings. +func (e *env) update(patch household.SettingsPatch) { + e.t.Helper() + if _, err := e.settings.Update(context.Background(), patch, "member"); err != nil { + e.t.Fatal(err) + } +} + +// claimed returns the period keys schedule_runs holds for name. +func (e *env) claimed(name string) []string { + e.t.Helper() + rows, err := e.st.Reader.Query(`SELECT period_key FROM schedule_runs WHERE schedule = ? ORDER BY period_key`, name) + if err != nil { + e.t.Fatal(err) + } + defer rows.Close() + var keys []string + for rows.Next() { + var k string + if err := rows.Scan(&k); err != nil { + e.t.Fatal(err) + } + keys = append(keys, k) + } + if err := rows.Err(); err != nil { + e.t.Fatal(err) + } + return keys +} + +// running is a started scheduler on a fake clock. +type running struct { + t *testing.T + clock *fakeClock + stop func() +} + +// start starts a scheduler on e at now with the schedules, and stops it +// when the test ends. It returns once the first pass, the catch-up, is done. +func (e *env) start(now time.Time, schedules ...scheduler.Schedule) *running { + e.t.Helper() + s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + e.t.Fatal(err) + } + clock := newFakeClock(now) + scheduler.SetClock(s, clock.Now, clock.After) + for _, sch := range schedules { + if err := s.Register(sch); err != nil { + e.t.Fatal(err) + } + } + ctx, cancel := context.WithCancel(context.Background()) + if err := s.Start(ctx); err != nil { + cancel() + e.t.Fatal(err) + } + e.sched.Store(s) + var once sync.Once + stop := func() { + once.Do(func() { + cancel() + s.Wait() + }) + } + e.t.Cleanup(stop) + r := &running{t: e.t, clock: clock, stop: stop} + r.idle() + return r +} + +// idle waits until the scheduler finished a pass and is waiting. +func (r *running) idle() { + r.t.Helper() + select { + case <-r.clock.waits: + case <-time.After(10 * time.Second): + r.t.Fatal("scheduler did not finish its pass") + } +} + +// runUntil moves the clock to to, stopping at every instant the scheduler +// asked to wake at on the way, as a real clock would. +func (r *running) runUntil(to time.Time) { + r.t.Helper() + for { + next, ok := r.clock.nextDeadline() + if !ok || next.After(to) { + if r.clock.set(to) { + r.idle() + } + return + } + if r.clock.set(next) { + r.idle() + } + } +} + +// 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() +} + +func reminder(rec *recorder) scheduler.Schedule { + return scheduler.Schedule{ + Name: scheduler.DayOfReminder, + Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReminderTime) }, + Run: rec.run, + } +} + +func suggestions(rec *recorder) scheduler.Schedule { + return scheduler.Schedule{ + Name: scheduler.SuggestionsWeekly, + Rule: func(s household.Settings) scheduler.Rule { + return scheduler.Weekly(time.Weekday(s.SuggestionsDow), s.SuggestionsTime) + }, + Run: rec.run, + } +} + +func planDraft(rec *recorder) scheduler.Schedule { + return scheduler.Schedule{ + Name: scheduler.PlanDraft, + Rule: func(s household.Settings) scheduler.Rule { + return scheduler.WeeklyForNextWeek(time.Weekday(s.PlanDraftDow), s.PlanDraftTime) + }, + Run: rec.run, + } +} + +func ptr[T any](v T) *T { return &v } + +func wantKeys(t *testing.T, what string, got, want []string) { + t.Helper() + if !slices.Equal(got, want) { + t.Fatalf("%s = %q, want %q", what, got, want) + } +} + +func TestFiresOncePerPeriod(t *testing.T) { + e := newEnv(t) + var rec recorder + // Seeded reminder time is 16:30; boot just after it on Oct 6 runs it. + r := e.start(at(2026, 10, 6, 16, 31), reminder(&rec)) + wantKeys(t, "after boot", rec.keys(), []string{"2026-10-06"}) + + r.runUntil(at(2026, 10, 7, 16, 29)) + wantKeys(t, "before the fire time", rec.keys(), []string{"2026-10-06"}) + r.runUntil(at(2026, 10, 9, 23, 59)) + wantKeys(t, "after three days", rec.keys(), []string{"2026-10-06", "2026-10-07", "2026-10-08", "2026-10-09"}) + + for i, p := range rec.periods()[1:] { + want := at(2026, 10, 7+i, 16, 30) + if !p.FireAt.Equal(want) { + t.Errorf("run %s fired at %s, want %s", p.Key, p.FireAt, want) + } + if p.FireAt.Location().String() != zone { + t.Errorf("run %s fired in zone %s, want %s", p.Key, p.FireAt.Location(), zone) + } + } + wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), rec.keys()) +} + +func TestRestartMidPeriodDoesNotRerun(t *testing.T) { + e := newEnv(t) + var first recorder + r := e.start(at(2026, 10, 7, 12, 0), suggestions(&first)) // Wednesday + // The seeded Sunday 08:00 of Oct 4 is days ago: missed, not run. + wantKeys(t, "first boot", first.keys(), nil) + r.runUntil(at(2026, 10, 11, 9, 0)) + wantKeys(t, "first process", first.keys(), []string{"2026-W41"}) + r.stop() + + // The restart is an hour after the Sunday 08:00 fire, mid-period. + var second recorder + r2 := e.start(at(2026, 10, 11, 9, 0), suggestions(&second)) + wantKeys(t, "after restart", second.keys(), nil) + r2.runUntil(at(2026, 10, 18, 7, 59)) + wantKeys(t, "rest of the period", second.keys(), nil) + r2.runUntil(at(2026, 10, 18, 8, 0)) + wantKeys(t, "next period", second.keys(), []string{"2026-W42"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41", "2026-W42"}) +} + +func TestCatchUpWithin24Hours(t *testing.T) { + e := newEnv(t) + var rec recorder + // Sunday Oct 11 08:00 passed 23h59m before boot. + e.start(at(2026, 10, 12, 7, 59), suggestions(&rec)) + wantKeys(t, "catch-up", rec.keys(), []string{"2026-W41"}) + if p := rec.periods()[0]; !p.FireAt.Equal(at(2026, 10, 11, 8, 0)) { + t.Errorf("caught-up period fired at %s, want its fire time", p.FireAt) + } +} + +func TestNoCatchUpBeyond24Hours(t *testing.T) { + e := newEnv(t) + var rec recorder + // Sunday Oct 11 08:00 passed 24h01m before boot. + r := e.start(at(2026, 10, 12, 8, 1), suggestions(&rec)) + wantKeys(t, "boot", rec.keys(), nil) + // The missed period is recorded, so it never runs later. + wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W41"}) + + // Moving the fire time to Sunday 23:00 puts that week's fire time + // within 24 h of now. The change itself never fires a passed time... + e.update(household.SettingsPatch{SuggestionsTime: ptr("23:00")}) + r.changed() + wantKeys(t, "after a settings change", rec.keys(), nil) + r.stop() + + // ...and the restart's catch-up finds the period already recorded. + var again recorder + r2 := e.start(at(2026, 10, 12, 8, 2), suggestions(&again)) + wantKeys(t, "after restart", again.keys(), nil) + r2.runUntil(at(2026, 10, 18, 23, 0)) + wantKeys(t, "next period", again.keys(), []string{"2026-W42"}) +} + +func TestCatchUpOnlyMostRecentPeriod(t *testing.T) { + e := newEnv(t) + var rec recorder + // Days of downtime: only Oct 9 16:30, 30 minutes ago, runs. + e.start(at(2026, 10, 9, 17, 0), reminder(&rec)) + wantKeys(t, "catch-up", rec.keys(), []string{"2026-10-09"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-09"}) +} + +func TestCatchUpEveryPeriodWithin24HoursAcrossSpringForward(t *testing.T) { + // Clocks in Chicago jump 02:00 CST → 03:00 CDT on Sunday 2026-03-08, + // so 01:30 on Mar 8 and 01:30 on Mar 9 are only 23 hours apart. + e := newEnv(t) + e.update(household.SettingsPatch{ReviewTime: ptr("01:30")}) + var rec recorder + review := scheduler.Schedule{ + Name: scheduler.RotationCheck, + Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) }, + Run: rec.run, + } + // Boot at Mar 9 01:35: Mar 9 01:30 passed 5 minutes ago and Mar 8 + // 01:30 23h05m ago, so both run, oldest first; Mar 7 01:30, 47h05m + // ago, is not looked at. + r := e.start(at(2026, 3, 9, 1, 35), review) + wantKeys(t, "catch-up", rec.keys(), []string{"2026-03-08", "2026-03-09"}) + wantFired(t, rec.periods(), + time.Date(2026, 3, 8, 7, 30, 0, 0, time.UTC), // 01:30 CST + time.Date(2026, 3, 9, 6, 30, 0, 0, time.UTC), // 01:30 CDT + ) + wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"2026-03-08", "2026-03-09"}) + r.stop() + + // A restart within the window finds both periods claimed. + var again recorder + review.Run = again.run + r2 := e.start(at(2026, 3, 9, 1, 40), review) + r2.runUntil(at(2026, 3, 9, 23, 0)) + wantKeys(t, "after restart", again.keys(), nil) +} + +func TestSpringForward(t *testing.T) { + // Clocks in Chicago jump 02:00 CST → 03:00 CDT on Sunday 2026-03-08. + e := newEnv(t) + e.update(household.SettingsPatch{ReminderTime: ptr("08:00"), ReviewTime: ptr("02:30")}) + var daily, skipped recorder + review := scheduler.Schedule{ + Name: scheduler.RotationCheck, + Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) }, + Run: skipped.run, + } + r := e.start(at(2026, 3, 7, 9, 0), reminder(&daily), review) + r.runUntil(at(2026, 3, 9, 12, 0)) + + wantKeys(t, "08:00 runs", daily.keys(), []string{"2026-03-07", "2026-03-08", "2026-03-09"}) + wantFired(t, daily.periods(), + time.Date(2026, 3, 7, 14, 0, 0, 0, time.UTC), // 08:00 CST + time.Date(2026, 3, 8, 13, 0, 0, 0, time.UTC), // 08:00 CDT + time.Date(2026, 3, 9, 13, 0, 0, 0, time.UTC), + ) + + // 02:30 does not exist on Mar 8: it fires once, an hour later at + // 03:30 CDT, keyed by Mar 8. + wantKeys(t, "02:30 runs", skipped.keys(), []string{"2026-03-07", "2026-03-08", "2026-03-09"}) + wantFired(t, skipped.periods(), + time.Date(2026, 3, 7, 8, 30, 0, 0, time.UTC), // 02:30 CST + time.Date(2026, 3, 8, 8, 30, 0, 0, time.UTC), // 03:30 CDT + time.Date(2026, 3, 9, 7, 30, 0, 0, time.UTC), // 02:30 CDT + ) + if got := skipped.periods()[1].FireAt.Format("15:04 MST"); got != "03:30 CDT" { + t.Errorf("skipped 02:30 fired at %s local, want 03:30 CDT", got) + } +} + +func TestFallBack(t *testing.T) { + // Clocks in Chicago go back 02:00 CDT → 01:00 CST on Sunday 2026-11-01. + e := newEnv(t) + e.update(household.SettingsPatch{ReminderTime: ptr("08:00"), ReviewTime: ptr("01:30")}) + var daily, repeated recorder + review := scheduler.Schedule{ + Name: scheduler.RotationCheck, + Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) }, + Run: repeated.run, + } + r := e.start(at(2026, 10, 31, 9, 0), reminder(&daily), review) + r.runUntil(at(2026, 11, 2, 12, 0)) + + wantKeys(t, "08:00 runs", daily.keys(), []string{"2026-10-31", "2026-11-01", "2026-11-02"}) + wantFired(t, daily.periods(), + time.Date(2026, 10, 31, 13, 0, 0, 0, time.UTC), // 08:00 CDT + time.Date(2026, 11, 1, 14, 0, 0, 0, time.UTC), // 08:00 CST + time.Date(2026, 11, 2, 14, 0, 0, 0, time.UTC), + ) + + // 01:30 happens twice on Nov 1: it fires once, at the first (CDT). + wantKeys(t, "01:30 runs", repeated.keys(), []string{"2026-10-31", "2026-11-01", "2026-11-02"}) + wantFired(t, repeated.periods(), + time.Date(2026, 10, 31, 6, 30, 0, 0, time.UTC), // 01:30 CDT + time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC), // first 01:30, CDT + time.Date(2026, 11, 2, 7, 30, 0, 0, time.UTC), // 01:30 CST + ) +} + +func TestFallBackSecondOccurrenceDoesNotRerun(t *testing.T) { + e := newEnv(t) + e.update(household.SettingsPatch{ReviewTime: ptr("01:30")}) + var rec recorder + review := scheduler.Schedule{ + Name: scheduler.RotationCheck, + Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) }, + Run: rec.run, + } + // Boot between the two 01:30s of Nov 1: the first ran in an earlier + // process, so the second is not a new period. + first := time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC) + r := e.start(first.Add(-time.Minute), review) + r.runUntil(first) + wantKeys(t, "first 01:30", rec.keys(), []string{"2026-10-31", "2026-11-01"}) + r.stop() + + var again recorder + review.Run = again.run + r2 := e.start(first.Add(30*time.Minute), review) + r2.runUntil(first.Add(2 * time.Hour)) // past the second 01:30, CST + wantKeys(t, "second 01:30", again.keys(), nil) +} + +func wantFired(t *testing.T, got []scheduler.Period, want ...time.Time) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%d runs, want %d", len(got), len(want)) + } + for i := range want { + if !got[i].FireAt.Equal(want[i]) { + t.Errorf("run %s fired at %s (%s), want %s", got[i].Key, got[i].FireAt.UTC(), got[i].FireAt, want[i]) + } + } +} + +func TestISOWeek53Boundary(t *testing.T) { + e := newEnv(t) + var sugg, draft recorder + // Suggestions Sunday 08:00 keys the ISO week ending that Sunday; the + // plan draft Friday 09:00 keys the week after. + r := e.start(at(2026, 12, 20, 9, 0), suggestions(&sugg), planDraft(&draft)) + r.runUntil(at(2027, 1, 11, 0, 0)) + + wantKeys(t, "suggestions", sugg.keys(), []string{"2026-W51", "2026-W52", "2026-W53", "2027-W01"}) + // Sunday 2027-01-03 is the last day of 2026-W53. + if p := sugg.periods()[2]; p.Date.String() != "2027-01-03" || p.Week.String() != "2026-12-28" { + t.Errorf("2026-W53 suggestions ran on %s for week %s, want 2027-01-03 for 2026-12-28", p.Date, p.Week) + } + // Friday Dec 18, two days before boot, was missed. + wantKeys(t, "plan drafts", draft.keys(), []string{"2026-W53", "2027-W01", "2027-W02"}) + // Friday 2026-12-25 drafts 2026-W53; Friday 2027-01-01 drafts 2027-W01. + if p := draft.periods()[0]; p.Date.String() != "2026-12-25" || p.Week.String() != "2026-12-28" { + t.Errorf("2026-W53 draft ran on %s for week %s, want 2026-12-25 for 2026-12-28", p.Date, p.Week) + } + if p := draft.periods()[1]; p.Date.String() != "2027-01-01" || p.Week.String() != "2027-01-04" { + t.Errorf("2027-W01 draft ran on %s for week %s, want 2027-01-01 for 2027-01-04", p.Date, p.Week) + } +} + +func TestPeriodKey(t *testing.T) { + date := func(s string) store.LocalDate { + d, err := store.ParseLocalDate(s) + if err != nil { + t.Fatal(err) + } + return d + } + tests := []struct { + rule scheduler.Rule + date string + want string + }{ + {scheduler.Daily("16:30"), "2026-12-31", "2026-12-31"}, + {scheduler.Weekly(time.Monday, "00:00"), "2026-12-28", "2026-W53"}, + {scheduler.Weekly(time.Sunday, "08:00"), "2027-01-03", "2026-W53"}, + {scheduler.Weekly(time.Monday, "00:00"), "2027-01-04", "2027-W01"}, + {scheduler.WeeklyForNextWeek(time.Friday, "09:00"), "2026-12-25", "2026-W53"}, + {scheduler.WeeklyForNextWeek(time.Friday, "09:00"), "2027-01-01", "2027-W01"}, + } + for _, tt := range tests { + if got := tt.rule.PeriodKey(date(tt.date)); got != tt.want { + t.Errorf("%+v.PeriodKey(%s) = %s, want %s", tt.rule, tt.date, got, tt.want) + } + } +} + +func TestSettingsChangeMovesNextFire(t *testing.T) { + e := newEnv(t) + var rec recorder + r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec)) + wantKeys(t, "boot", rec.keys(), []string{"2026-10-06"}) + + // Earlier but still ahead: fires at the new time, not the old. + e.update(household.SettingsPatch{ReminderTime: ptr("13:00")}) + r.changed() + if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 7, 13, 0)) { + t.Errorf("next wake %s, want the new 13:00", next) + } + r.runUntil(at(2026, 10, 7, 13, 0)) + wantKeys(t, "at the new time", rec.keys(), []string{"2026-10-06", "2026-10-07"}) + r.runUntil(at(2026, 10, 7, 23, 0)) + wantKeys(t, "at the old time", rec.keys(), []string{"2026-10-06", "2026-10-07"}) + + // Moved to a time already passed today: nothing fires now, and + // tomorrow fires at the new time. + r.runUntil(at(2026, 10, 8, 12, 0)) + e.update(household.SettingsPatch{ReminderTime: ptr("11:00")}) + r.changed() + wantKeys(t, "moved into the past", rec.keys(), []string{"2026-10-06", "2026-10-07"}) + r.runUntil(at(2026, 10, 9, 10, 59)) + wantKeys(t, "before the new time", rec.keys(), []string{"2026-10-06", "2026-10-07"}) + r.runUntil(at(2026, 10, 9, 11, 0)) + wantKeys(t, "the new time", rec.keys(), []string{"2026-10-06", "2026-10-07", "2026-10-09"}) +} + +// A change saved at 12:00 moving the 16:30 reminder to 12:01 must fire it +// even when the scheduler only gets to the change at 12:02, after the new +// fire time has passed. +func TestSettingsChangeAppliedLateStillFires(t *testing.T) { + e := newEnv(t) + var rec recorder + // blocker holds the scheduler in a Run at 12:00 on 2026-10-07 while + // the settings change and the clock moves on. + started := make(chan struct{}) + release := make(chan struct{}) + blocker := scheduler.Schedule{ + Name: scheduler.RotationCheck, + Rule: func(household.Settings) scheduler.Rule { return scheduler.Daily("12:00") }, + Run: func(_ context.Context, p scheduler.Period) error { + if p.Key == "2026-10-07" { + close(started) + <-release + } + return nil + }, + } + r := e.start(at(2026, 10, 7, 11, 0), reminder(&rec), blocker) + wantKeys(t, "boot", rec.keys(), []string{"2026-10-06"}) + + r.clock.set(at(2026, 10, 7, 12, 0)) + <-started + e.update(household.SettingsPatch{ReminderTime: ptr("12:01")}) + r.clock.set(at(2026, 10, 7, 12, 2)) + close(release) + r.idle() // the pass the blocker held + r.idle() // the pass that applies the change + wantKeys(t, "after the late change", rec.keys(), []string{"2026-10-06", "2026-10-07"}) + + r.runUntil(at(2026, 10, 7, 23, 0)) + wantKeys(t, "end of day", rec.keys(), []string{"2026-10-06", "2026-10-07"}) + wantKeys(t, "claims", e.claimed(scheduler.DayOfReminder), []string{"2026-10-06", "2026-10-07"}) +} + +// failClaims makes every claim fail until recover is called. +func (e *env) failClaims() (recover func()) { + e.t.Helper() + for _, q := range []string{ + `CREATE TABLE fail_claims (on_ INTEGER NOT NULL)`, + `INSERT INTO fail_claims VALUES (1)`, + `CREATE TRIGGER fail_claim BEFORE INSERT ON schedule_runs + WHEN (SELECT on_ FROM fail_claims) = 1 + BEGIN SELECT RAISE(ABORT, 'injected claim failure'); END`, + } { + if _, err := e.st.Writer.Exec(q); err != nil { + e.t.Fatal(err) + } + } + return func() { + e.t.Helper() + if _, err := e.st.Writer.Exec(`UPDATE fail_claims SET on_ = 0`); err != nil { + e.t.Fatal(err) + } + } +} + +// A claim that fails is retried, not skipped, and the period then runs once. +func TestClaimFailureIsRetried(t *testing.T) { + e := newEnv(t) + recoverClaims := e.failClaims() + var rec recorder + r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec)) + wantKeys(t, "boot with failing claims", rec.keys(), nil) + wantKeys(t, "claims while failing", e.claimed(scheduler.DayOfReminder), nil) + if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 7, 12, 0).Add(scheduler.ClaimRetry)) { + t.Errorf("next wake %s, want a retry after %s", next, scheduler.ClaimRetry) + } + + recoverClaims() + r.runUntil(at(2026, 10, 7, 12, 0).Add(scheduler.ClaimRetry)) + wantKeys(t, "after the retry", rec.keys(), []string{"2026-10-06"}) + r.runUntil(at(2026, 10, 7, 23, 0)) + wantKeys(t, "end of day", rec.keys(), []string{"2026-10-06", "2026-10-07"}) + wantKeys(t, "claims", e.claimed(scheduler.DayOfReminder), []string{"2026-10-06", "2026-10-07"}) +} + +// A claim that keeps failing past the next daily fire is retried for its +// own period, not the most recent one: that period, now more than +// CatchUpWindow old, is recorded as missed, and the later period runs once. +func TestClaimFailureRecoversAfterNextFire(t *testing.T) { + e := newEnv(t) + recoverClaims := e.failClaims() + var rec recorder + // The 7 October 16:30 reminder is due and its claim fails, past the + // 8 October 16:30 fire. + r := e.start(at(2026, 10, 7, 16, 45), reminder(&rec)) + r.runUntil(at(2026, 10, 8, 17, 0)) + wantKeys(t, "runs while failing", rec.keys(), nil) + wantKeys(t, "claims while failing", e.claimed(scheduler.DayOfReminder), nil) + + // Claims recover 24h31m after the 7 October fire. + recoverClaims() + r.runUntil(at(2026, 10, 8, 17, 1)) + wantKeys(t, "after recovery", rec.keys(), []string{"2026-10-08"}) + wantKeys(t, "claims after recovery", e.claimed(scheduler.DayOfReminder), []string{"2026-10-07", "2026-10-08"}) + + r.runUntil(at(2026, 10, 9, 16, 0)) + wantKeys(t, "next day", rec.keys(), []string{"2026-10-08"}) +} + +func TestSettingsChangeTimezone(t *testing.T) { + e := newEnv(t) + var rec recorder + r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec)) + e.update(household.SettingsPatch{Timezone: ptr("America/New_York")}) + r.changed() + // 16:30 in New York is 15:30 in Chicago. + r.runUntil(at(2026, 10, 7, 15, 30)) + wantKeys(t, "after the zone change", rec.keys(), []string{"2026-10-06", "2026-10-07"}) + if got := rec.periods()[1].FireAt.Location().String(); got != "America/New_York" { + t.Errorf("fired in zone %s, want America/New_York", got) + } +} + +func TestRunErrorKeepsPeriodClaimed(t *testing.T) { + e := newEnv(t) + rec := recorder{err: errors.New("boom")} + r := e.start(at(2026, 10, 7, 16, 0), reminder(&rec)) + r.runUntil(at(2026, 10, 7, 23, 0)) + wantKeys(t, "runs", rec.keys(), []string{"2026-10-06", "2026-10-07"}) + r.stop() + var again recorder + e.start(at(2026, 10, 7, 23, 0), reminder(&again)) + wantKeys(t, "after restart", again.keys(), nil) +} + +func TestRegister(t *testing.T) { + e := newEnv(t) + s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + var rec recorder + bad := []scheduler.Schedule{ + {Name: "hourly", Rule: reminder(&rec).Rule, Run: rec.run}, + {Name: scheduler.DayOfReminder, Run: rec.run}, + {Name: scheduler.DayOfReminder, Rule: reminder(&rec).Rule}, + {Name: scheduler.DayOfReminder, Key: "start", PeriodKey: func(p scheduler.Period) string { return p.Key }, Rule: reminder(&rec).Rule, Run: rec.run}, + } + for _, sch := range bad { + if err := s.Register(sch); err == nil { + t.Errorf("Register(%q) accepted an invalid schedule", sch.Name) + } + } + if err := s.Register(reminder(&rec)); err != nil { + t.Fatal(err) + } + if err := s.Register(reminder(&rec)); err == nil { + t.Error("Register accepted a second schedule with the same name") + } +} + +func TestStartRejectsInvalidRule(t *testing.T) { + e := newEnv(t) + s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + var rec recorder + if err := s.Register(scheduler.Schedule{ + Name: scheduler.WeekRollover, + Rule: func(household.Settings) scheduler.Rule { return scheduler.Weekly(7, "00:00") }, + Run: rec.run, + }); err != nil { + t.Fatal(err) + } + if err := s.Start(context.Background()); err == nil { + s.Wait() + t.Fatal("Start accepted weekday 7") + } +} + +func TestStopEndsTheLoop(t *testing.T) { + e := newEnv(t) + var rec recorder + r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec)) + done := make(chan struct{}) + go func() { + r.stop() + close(done) + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("scheduler did not stop") + } +} diff --git a/internal/scheduler/suggestions_test.go b/internal/scheduler/suggestions_test.go new file mode 100644 index 0000000..c40e1c8 --- /dev/null +++ b/internal/scheduler/suggestions_test.go @@ -0,0 +1,370 @@ +package scheduler_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" + "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/jobs" + "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/recipes" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" +) + +// The tests in this file run suggest's suggestions_weekly schedule on the +// fake clock against the real suggestion service, job runner, harness and +// notification outbox, with the AI and recipe sources faked. + +// suggestEnv is a suggestion service over a scheduler env's store, the +// "process" a scheduler runs it in. side answers suggest_side_quests and +// model normalize_recipe while grounding. +type suggestEnv struct { + e *env + svc *suggest.Service + side *llm.Fake + model *llm.Fake + search *recipestest.Search + pages *recipestest.Pages + images *recipestest.Images + logs *syncBuffer + stop func() +} + +// syncBuffer collects log output written by concurrent goroutines. +type syncBuffer struct { + mu sync.Mutex + buf strings.Builder +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// regulars stores the Regulars Beef Chili and Chicken Fajitas, so a batch +// has Regulars to suggest from. +func (e *env) regulars() { + e.t.Helper() + for _, name := range []string{"Beef Chili", "Chicken Fajitas"} { + now := store.Now().String() + if _, err := db.New(e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{ + ID: store.NewID(), + Name: name, + NormalizedName: harness.Normalize(name), + Status: "regular", + Origin: "regular", + GroundingStatus: "manual", + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + e.t.Fatal(err) + } + } +} + +// suggestions builds a suggestion service over e with a started job +// runner, which stop stops (and the test's end, if not before). +func (e *env) suggestions() *suggestEnv { + e.t.Helper() + s := &suggestEnv{ + e: e, + side: &llm.Fake{}, + model: &llm.Fake{}, + search: &recipestest.Search{Results: map[string][]recipes.Candidate{}}, + pages: &recipestest.Pages{Bodies: map[string][]byte{}}, + images: &recipestest.Images{JPEGs: map[string][]byte{}}, + logs: &syncBuffer{}, + } + g := recipestest.NewGrounder(e.t, e.st, e.t.TempDir(), s.search, s.pages, s.images, s.model) + guard, err := harness.NewGuard(harness.DefaultMaxCalls) + if err != nil { + e.t.Fatal(err) + } + cfg := harness.Config{ + Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7}, + NewProvider: func(llm.Settings, string) (llm.Provider, error) { return s.side, nil }, + Store: e.st, + Guard: guard, + } + suggester := func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) { + return skills.SuggestSideQuests(ctx, cfg, scope, in) + } + logger := slog.New(slog.NewTextHandler(s.logs, nil)) + runner, err := jobs.NewRunner(e.st, logger, 1) + if err != nil { + e.t.Fatal(err) + } + s.svc, err = suggest.NewService(e.st, g, suggester, runner, notifytest.New(e.t, e.st), logger) + if err != nil { + e.t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + if err := runner.Start(ctx); err != nil { + cancel() + e.t.Fatal(err) + } + var once sync.Once + s.stop = func() { + once.Do(func() { + cancel() + runner.Wait() + }) + } + e.t.Cleanup(s.stop) + return s +} + +// week scripts the next batch: suggest_side_quests answers with the +// Side quests named names, each grounded by its own recipe. +func (s *suggestEnv) week(names ...string) { + s.e.t.Helper() + var quests []string + for _, name := range names { + slug := strings.ReplaceAll(strings.ToLower(name), " ", "-") + page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg" + s.search.Results[name] = []recipes.Candidate{{URL: page}} + s.pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 35) + s.images.JPEGs[photo] = recipestest.JPEG(s.e.t, 400, 320, uint8(len(s.images.JPEGs)+1)) + s.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 35)) + quests = append(quests, fmt.Sprintf(`{"name":%q,"cuisine":"Fusion","method":"stovetop","est_minutes":35,"difficulty":"easy","reason":"Like your Beef Chili.","cited_regulars":["Beef Chili"],"search_query":%q}`, name, name)) + } + js := `{"suggestions":[` + strings.Join(quests, ",") + `]}` + s.side.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}) +} + +// batch is a suggestion_batches row. +type batch struct { + ID, Trigger, Status string +} + +// batches returns every suggestion batch, oldest first. +func (e *env) batches() []batch { + e.t.Helper() + rows, err := e.st.Reader.Query(`SELECT id, trigger, status FROM suggestion_batches ORDER BY created_at, id`) + if err != nil { + e.t.Fatal(err) + } + defer rows.Close() + var out []batch + for rows.Next() { + var b batch + if err := rows.Scan(&b.ID, &b.Trigger, &b.Status); err != nil { + e.t.Fatal(err) + } + out = append(out, b) + } + if err := rows.Err(); err != nil { + e.t.Fatal(err) + } + return out +} + +// settled waits until no batch is pending or running and returns the +// batches. +func (e *env) settled() []batch { + e.t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + bs := e.batches() + busy := false + for _, b := range bs { + busy = busy || b.Status == suggest.BatchPending || b.Status == suggest.BatchRunning + } + if !busy { + return bs + } + if time.Now().After(deadline) { + e.t.Fatalf("batches still under way: %+v", bs) + } + time.Sleep(5 * time.Millisecond) + } +} + +// wantBatches fails unless the batches are scheduled with statuses want. +func wantBatches(t *testing.T, what string, got []batch, want ...string) { + t.Helper() + ok := len(got) == len(want) + for i := 0; ok && i < len(got); i++ { + ok = got[i].Trigger == suggest.TriggerScheduled && got[i].Status == want[i] + } + if !ok { + t.Fatalf("%s: batches = %+v, want scheduled %q", what, got, want) + } +} + +// notified returns the new_suggestions keys stored, oldest first, and +// reports an error for any whose title is not the one titles gives it. +func (e *env) notified(titles ...string) []string { + e.t.Helper() + var keys []string + rows := notifytest.Rows(e.t, e.st, notify.NewSuggestions) + for i, r := range rows { + keys = append(keys, r.Key) + if i < len(titles) && r.Payload.Title != titles[i] { + e.t.Errorf("new_suggestions %s title = %q, want %q", r.Key, r.Payload.Title, titles[i]) + } + } + return keys +} + +// TestSuggestionsWeeklyOneBatchAndNotificationPerWeek: at the seeded +// Sunday 08:00 the schedule starts one scheduled batch a week, period +// suggestions_weekly:YYYY-Www, and each tells the household once, with +// new_suggestions:YYYY-Www counting its new Side quests. +func TestSuggestionsWeeklyOneBatchAndNotificationPerWeek(t *testing.T) { + e := newEnv(t) + e.regulars() + s := e.suggestions() + s.week("Chili Verde", "Chicken Shawarma Bowls") + s.week("Lamb Kofta", "Black Bean Tostadas", "Pork Carnitas") + + // Wednesday Oct 7: the Sunday Oct 4 08:00 fire is days ago, missed. + r := e.start(at(2026, 10, 7, 12, 0), s.svc.WeeklySchedule()) + r.runUntil(at(2026, 10, 11, 7, 59)) + wantBatches(t, "before Sunday 08:00", e.settled()) + wantKeys(t, "new_suggestions before Sunday 08:00", e.notified(), nil) + + r.runUntil(at(2026, 10, 11, 8, 0)) + wantBatches(t, "at Sunday 08:00", e.settled(), suggest.BatchDone) + wantKeys(t, "new_suggestions of W41", e.notified("2 new Side quests are in"), []string{"new_suggestions:2026-W41"}) + + r.runUntil(at(2026, 10, 18, 7, 59)) + wantBatches(t, "rest of W41", e.settled(), suggest.BatchDone) + r.runUntil(at(2026, 10, 18, 9, 0)) + wantBatches(t, "W42", e.settled(), suggest.BatchDone, suggest.BatchDone) + wantKeys(t, "new_suggestions of W41 and W42", e.notified("2 new Side quests are in", "3 new Side quests are in"), + []string{"new_suggestions:2026-W41", "new_suggestions:2026-W42"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41", "2026-W42"}) +} + +// TestSuggestionsWeeklyFollowsSettings: the schedule fires at the +// household's suggestions_dow and suggestions_time. +func TestSuggestionsWeeklyFollowsSettings(t *testing.T) { + e := newEnv(t) + e.regulars() + e.update(household.SettingsPatch{SuggestionsDow: ptr(3), SuggestionsTime: ptr("18:30")}) + s := e.suggestions() + s.week("Chili Verde") + + r := e.start(at(2026, 10, 12, 12, 0), s.svc.WeeklySchedule()) // Monday + r.runUntil(at(2026, 10, 14, 18, 29)) // Wednesday + wantBatches(t, "before Wednesday 18:30", e.settled()) + r.runUntil(at(2026, 10, 14, 18, 30)) + wantBatches(t, "at Wednesday 18:30", e.settled(), suggest.BatchDone) + wantKeys(t, "new_suggestions", e.notified("1 new Side quest is in"), []string{"new_suggestions:2026-W42"}) +} + +// TestSuggestionsWeeklyAIFailureSendsNothing: when suggest_side_quests +// fails the batch fails with the failure logged and recorded in ai_runs, +// and the household is not notified. +func TestSuggestionsWeeklyAIFailureSendsNothing(t *testing.T) { + e := newEnv(t) + e.regulars() + s := e.suggestions() + s.side.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")}) + + r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule()) + r.runUntil(at(2026, 10, 11, 8, 0)) + wantBatches(t, "after the AI failed", e.settled(), suggest.BatchFailed) + wantKeys(t, "new_suggestions after the AI failed", e.notified(), nil) + // W40 is the boot's missed period, recorded without running. + wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41"}) + + var status, msg string + if err := e.st.Reader.QueryRow(`SELECT status, COALESCE(error, '') FROM ai_runs WHERE skill = 'suggest_side_quests'`).Scan(&status, &msg); err != nil { + t.Fatalf("ai_runs row of suggest_side_quests: %v", err) + } + if status != string(harness.StatusProviderError) || msg == "" { + t.Errorf("ai_runs suggest_side_quests = %s %q, want %s with its message", status, msg, harness.StatusProviderError) + } + if logs := s.logs.String(); !strings.Contains(logs, "suggestion batch failed") || !strings.Contains(logs, "reason="+suggest.ReasonAIFailed) { + t.Errorf("logs = %s, want the batch failure with reason %s", logs, suggest.ReasonAIFailed) + } +} + +// TestSuggestionsWeeklyNoDuplicateAfterRestart: a restart after the week's +// batch ran, still within the catch-up window, starts no second batch and +// sends no second notification: the period is already claimed. +func TestSuggestionsWeeklyNoDuplicateAfterRestart(t *testing.T) { + e := newEnv(t) + e.regulars() + s := e.suggestions() + s.week("Chili Verde", "Chicken Shawarma Bowls") + r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule()) + r.runUntil(at(2026, 10, 11, 8, 0)) + wantBatches(t, "first process", e.settled(), suggest.BatchDone) + r.stop() + s.stop() + + // The restarted process has a new job runner, suggestion service and + // scheduler; it would answer a second batch if one were started. + s2 := e.suggestions() + s2.week("Lamb Kofta") + r2 := e.start(at(2026, 10, 11, 9, 0), s2.svc.WeeklySchedule()) + r2.runUntil(at(2026, 10, 18, 7, 59)) + wantBatches(t, "after restart", e.settled(), suggest.BatchDone) + wantKeys(t, "new_suggestions after restart", e.notified(), []string{"new_suggestions:2026-W41"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41"}) +} + +// TestSuggestionsWeeklyCatchUpAfterTheWeekEnds: a Sunday fire caught up on +// Monday, in the next ISO week, still runs W41's batch keyed by W41, and +// the next Sunday's batch notifies for W42. +func TestSuggestionsWeeklyCatchUpAfterTheWeekEnds(t *testing.T) { + e := newEnv(t) + e.regulars() + s := e.suggestions() + s.week("Chili Verde") + s.week("Lamb Kofta") + + r := e.start(at(2026, 10, 12, 7, 0), s.svc.WeeklySchedule()) // Monday, 23h late + wantBatches(t, "after catch-up", e.settled(), suggest.BatchDone) + wantKeys(t, "new_suggestions after catch-up", e.notified(), []string{"new_suggestions:2026-W41"}) + r.runUntil(at(2026, 10, 18, 8, 0)) + wantBatches(t, "W42", e.settled(), suggest.BatchDone, suggest.BatchDone) + wantKeys(t, "new_suggestions", e.notified(), []string{"new_suggestions:2026-W41", "new_suggestions:2026-W42"}) +} + +// TestSuggestionsWeeklyStartsNoBatchWhileOneIsUnderWay: a period that +// fires while a batch is pending starts no second one and says so. +func TestSuggestionsWeeklyStartsNoBatchWhileOneIsUnderWay(t *testing.T) { + e := newEnv(t) + e.regulars() + s := e.suggestions() + s.stop() // the manual batch stays pending + manual, err := s.svc.StartBatch(context.Background(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + + r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule()) + r.runUntil(at(2026, 10, 11, 8, 0)) + if bs := e.batches(); len(bs) != 1 || bs[0].ID != manual.ID { + t.Fatalf("batches = %+v, want only the manual batch %s", bs, manual.ID) + } + if logs := s.logs.String(); !strings.Contains(logs, "scheduled suggestion batch not started") || !strings.Contains(logs, "period=2026-W41") { + t.Errorf("logs = %s, want the skipped period logged", logs) + } +} diff --git a/internal/scheduler/weekly_automation_test.go b/internal/scheduler/weekly_automation_test.go new file mode 100644 index 0000000..993d376 --- /dev/null +++ b/internal/scheduler/weekly_automation_test.go @@ -0,0 +1,421 @@ +package scheduler_test + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "errors" + "log/slog" + "strings" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest" + "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/jobs" + "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/secretbox" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// The tests in this file run shopping's shopping_chat_autosend and +// week_rollover schedules, and harness's ai_runs_purge, on the fake clock +// against the real shopping service, Google Chat client (posting to a fake +// webhook), notification preferences and ai_runs purge. The seeded +// autosend is Saturday 08:00 in Chicago. + +// w41 is the Monday of 2026-W41. +var w41 = mustLocalDate("2026-10-05") + +// shopEnv is a shopping service over a scheduler env's store. Its job +// runner never starts, so Generate's title jobs stay queued and every +// Supply run stays untitled until Freeze titles it, which fails. +type shopEnv struct { + e *env + svc *shopping.Service + chat *chattest.Chat + cs *chat.Service + plans *planEnv + logs *syncBuffer +} + +// shopping builds a shopping service over e, with Regulars to plan from. +func (e *env) shopping() *shopEnv { + e.t.Helper() + s := &shopEnv{e: e, chat: chattest.New(e.t), plans: e.plans(e.pool()), logs: &syncBuffer{}} + key := make([]byte, secretbox.KeySize) + rand.Read(key) + box, err := secretbox.New(base64.StdEncoding.EncodeToString(key)) + if err != nil { + e.t.Fatal(err) + } + s.cs, err = chat.NewService(e.st, box, publicURL, s.chat.Transport()) + if err != nil { + e.t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(s.logs, nil)) + runner, err := jobs.NewRunner(e.st, logger, 1) + if err != nil { + e.t.Fatal(err) + } + noTitle := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) { + return skills.ArchiveTitle{}, errors.New("no AI in this test") + } + s.svc, err = shopping.NewService(e.st, noTitle, runner, notifytest.New(e.t, e.st), s.cs, logger) + if err != nil { + e.t.Fatal(err) + } + return s +} + +// list plans the week starting weekStart and generates its Supply run. +func (s *shopEnv) list(weekStart store.LocalDate) { + s.e.t.Helper() + s.plans.planWeek(weekStart) + if _, err := s.svc.Generate(context.Background(), weekStart); err != nil { + s.e.t.Fatal(err) + } +} + +// webhook stores the fake Google Chat webhook. +func (s *shopEnv) webhook() { + s.e.t.Helper() + if _, err := s.cs.Put(context.Background(), chattest.Webhook, "Family"); err != nil { + s.e.t.Fatal(err) + } +} + +// get returns the Supply run of the week starting weekStart. +func (s *shopEnv) get(weekStart store.LocalDate) shopping.List { + s.e.t.Helper() + l, err := s.svc.Get(context.Background(), weekStart) + if err != nil { + s.e.t.Fatal(err) + } + return l +} + +// wantLogged fails unless the service logged want and logged no error. +func (s *shopEnv) wantLogged(want string) { + s.e.t.Helper() + if logs := s.logs.String(); !strings.Contains(logs, want) || strings.Contains(logs, "level=ERROR") { + s.e.t.Errorf("logs = %s, want %q and no error", logs, want) + } +} + +// TestChatAutosendSendsComingWeek: at the seeded Saturday 08:00 the +// schedule posts the coming week's Supply run, W43's, to Google Chat +// through SendToChat, in that week's thread, and records sent_to_chat_at; +// its period is claimed in schedule_runs as list_ready:chat:2026-10-19, +// the coming week's start. It posts once. +func TestChatAutosendSendsComingWeek(t *testing.T) { + e := newEnv(t) + s := e.shopping() + s.list(w43) + s.webhook() + + // Saturday 07:00: the previous Saturday's period is past the + // catch-up window, so it is recorded as missed. + r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule()) + r.runUntil(at(2026, 10, 17, 7, 59)) + if n := len(s.chat.Posts()); n != 0 || s.get(w43).SentToChatAt != nil { + t.Fatalf("before 08:00: %d posts, sent_to_chat_at %v, want none", n, s.get(w43).SentToChatAt) + } + + r.runUntil(at(2026, 10, 17, 8, 0)) + posts := s.chat.Posts() + if len(posts) != 1 { + t.Fatalf("at 08:00: %d posts, want 1", len(posts)) + } + if !strings.HasPrefix(posts[0].Text(), "Supply run · Week 43 · Oct 19 – 25") { + t.Errorf("posted %q, want the W43 Supply run", posts[0].Text()) + } + if got := posts[0].Query.Get("threadKey"); got != chat.ThreadKey(w43) { + t.Errorf("posted in thread %q, want %q", got, chat.ThreadKey(w43)) + } + if s.get(w43).SentToChatAt == nil { + t.Error("sent_to_chat_at not recorded") + } + s.wantLogged("Supply run sent to Google Chat") + s.wantLogged("period=list_ready:chat:2026-10-19") + + r.runUntil(at(2026, 10, 24, 7, 0)) + if n := len(s.chat.Posts()); n != 1 { + t.Errorf("a week on: %d posts, want 1", n) + } + wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"}) +} + +// TestChatAutosendFollowsSettings: chat_autosend_dow and +// chat_autosend_time move the send without a restart, here to Friday +// 18:00, still for the coming week. +func TestChatAutosendFollowsSettings(t *testing.T) { + e := newEnv(t) + s := e.shopping() + s.list(w43) + s.webhook() + + r := e.start(at(2026, 10, 16, 12, 0), s.svc.ChatAutosendSchedule()) + e.update(household.SettingsPatch{ChatAutosendDow: ptr(5), ChatAutosendTime: ptr("18:00")}) + r.changed() + r.runUntil(at(2026, 10, 16, 17, 59)) + if n := len(s.chat.Posts()); n != 0 { + t.Fatalf("before Friday 18:00: %d posts, want 0", n) + } + r.runUntil(at(2026, 10, 16, 18, 0)) + if n := len(s.chat.Posts()); n != 1 || s.get(w43).SentToChatAt == nil { + t.Fatalf("at Friday 18:00: %d posts, sent_to_chat_at %v, want 1 and set", n, s.get(w43).SentToChatAt) + } + r.runUntil(at(2026, 10, 17, 9, 0)) + if n := len(s.chat.Posts()); n != 1 { + t.Errorf("Saturday: %d posts, want still 1", n) + } +} + +// TestChatAutosendSkips: the schedule posts nothing, records no send and +// fails nothing when the coming week's Supply run was already sent, when +// the week has no Supply run, when list_ready is switched off for Google +// Chat, and when no webhook is stored. Each period is still claimed and +// the reason logged. +func TestChatAutosendSkips(t *testing.T) { + cases := []struct { + name string + setup func(t *testing.T, s *shopEnv) + // posts is how many posts the setup itself made. + posts int + reason string + }{ + { + name: "already sent", + setup: func(t *testing.T, s *shopEnv) { + s.list(w43) + s.webhook() + if _, err := s.svc.SendToChat(context.Background(), w43, false); err != nil { + t.Fatal(err) + } + }, + posts: 1, + reason: shopping.AutosendAlreadySent, + }, + { + name: "no list", + setup: func(t *testing.T, s *shopEnv) { + s.list(w42) + s.webhook() + }, + reason: shopping.AutosendNoList, + }, + { + name: "chat off for list_ready", + setup: func(t *testing.T, s *shopEnv) { + s.list(w43) + s.webhook() + if _, err := s.e.st.Writer.Exec(`UPDATE notification_prefs SET chat = 0 WHERE event = 'list_ready'`); err != nil { + t.Fatal(err) + } + }, + reason: shopping.AutosendChatOff, + }, + { + name: "no webhook", + setup: func(t *testing.T, s *shopEnv) { + s.list(w43) + }, + reason: shopping.AutosendNotConfigured, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + e := newEnv(t) + s := e.shopping() + c.setup(t, s) + var sentBefore *string + if l, err := s.svc.Get(context.Background(), w43); err == nil { + sentBefore = l.SentToChatAt + } + + r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule()) + r.runUntil(at(2026, 10, 17, 9, 0)) + if n := len(s.chat.Posts()); n != c.posts { + t.Errorf("posts = %d, want %d", n, c.posts) + } + if l, err := s.svc.Get(context.Background(), w43); err == nil && !equalPtr(l.SentToChatAt, sentBefore) { + t.Errorf("sent_to_chat_at = %v, want %v", l.SentToChatAt, sentBefore) + } + s.wantLogged("Supply run not sent to Google Chat: " + c.reason) + wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"}) + }) + } +} + +// TestChatAutosendFailureSurfaces: a send Google Chat refuses is the +// period's error, logged as such; the period stays claimed and is not +// retried, and sent_to_chat_at is not recorded. +func TestChatAutosendFailureSurfaces(t *testing.T) { + e := newEnv(t) + s := e.shopping() + s.list(w43) + s.webhook() + s.chat.SetStatus(500) + + var logs syncBuffer + sched, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(&logs, nil))) + if err != nil { + t.Fatal(err) + } + clock := newFakeClock(at(2026, 10, 17, 7, 0)) + scheduler.SetClock(sched, clock.Now, clock.After) + if err := sched.Register(s.svc.ChatAutosendSchedule()); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(func() { + cancel() + sched.Wait() + }) + if err := sched.Start(ctx); err != nil { + t.Fatal(err) + } + r := &running{t: t, clock: clock, stop: cancel} + r.idle() + r.runUntil(at(2026, 10, 17, 9, 0)) + + if n := len(s.chat.Posts()); n != 1 { + t.Errorf("posts = %d, want the one refused", n) + } + if s.get(w43).SentToChatAt != nil { + t.Error("sent_to_chat_at recorded for a refused send") + } + if got := logs.String(); !strings.Contains(got, "level=ERROR") || !strings.Contains(got, "scheduler: run failed") || !strings.Contains(got, "schedule=shopping_chat_autosend") { + t.Errorf("scheduler logs = %s, want the refused send logged as a failed run", got) + } + wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"}) +} + +func equalPtr(a, b *string) bool { + return (a == nil && b == nil) || (a != nil && b != nil && *a == *b) +} + +// TestWeekRolloverFreezesPreviousWeek: at Monday 00:05 the schedule +// freezes exactly the week that has just ended into the Supply archive, +// not the week before it nor the new week; the next Monday freezes the +// next week; a Monday whose previous week has no Supply run freezes +// nothing and fails nothing. Its periods are week_rollover:YYYY-Www of the +// Monday. +func TestWeekRolloverFreezesPreviousWeek(t *testing.T) { + e := newEnv(t) + s := e.shopping() + s.list(w41) + s.list(w42) + s.list(w43) + + status := func() []string { + return []string{s.get(w41).Status, s.get(w42).Status, s.get(w43).Status} + } + cur, arc := shopping.StatusCurrent, shopping.StatusArchived + + // Sunday 23:00: last Monday's rollover is past the catch-up window and + // recorded as missed, so W41 is never frozen by it. + r := e.start(at(2026, 10, 18, 23, 0), s.svc.RolloverSchedule()) + r.runUntil(at(2026, 10, 19, 0, 4)) + wantKeys(t, "before Monday 00:05", status(), []string{cur, cur, cur}) + + r.runUntil(at(2026, 10, 19, 0, 5)) + wantKeys(t, "at Monday 00:05", status(), []string{cur, arc, cur}) + if s.get(w42).Title != nil { + t.Errorf("W42 title = %q, want frozen untitled", *s.get(w42).Title) + } + if _, err := s.svc.Generate(context.Background(), w42); !errors.Is(err, shopping.ErrFrozen) { + t.Errorf("generate W42 after the rollover: %v, want ErrFrozen", err) + } + + r.runUntil(at(2026, 10, 26, 0, 5)) + wantKeys(t, "next Monday", status(), []string{cur, arc, arc}) + + r.runUntil(at(2026, 11, 2, 0, 5)) + s.wantLogged("week rollover froze nothing: the week has no Supply run") + wantKeys(t, "schedule_runs", e.claimed(scheduler.WeekRollover), []string{"2026-W42", "2026-W43", "2026-W44", "2026-W45"}) +} + +// aiRun stores an ai_runs row created at created and returns its id. +func (e *env) aiRun(created time.Time) string { + e.t.Helper() + id := store.NewID() + ts := store.Timestamp{Time: created.UTC()}.String() + if err := db.New(e.st.Writer).CreateAIRun(context.Background(), db.CreateAIRunParams{ + ID: id, Skill: "draft_week", SkillVersion: "1", Provider: "anthropic", Model: "claude-test", + Temperature: 0.7, Status: "ok", RequestRedacted: "{}", ResponseRedacted: sql.NullString{String: "{}", Valid: true}, + CreatedAt: ts, UpdatedAt: ts, + }); err != nil { + e.t.Fatal(err) + } + return id +} + +// aiRuns returns the ids of the ai_runs rows, oldest first. +func (e *env) aiRuns() []string { + e.t.Helper() + rows, err := e.st.Reader.Query(`SELECT id FROM ai_runs ORDER BY created_at`) + if err != nil { + e.t.Fatal(err) + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + e.t.Fatal(err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + e.t.Fatal(err) + } + return ids +} + +// TestAIRunsPurgeDaily: every day at 03:00 the schedule deletes the +// ai_runs rows created more than 30 days before, and no younger row; its +// periods are ai_runs_purge:YYYY-MM-DD. +func TestAIRunsPurgeDaily(t *testing.T) { + e := newEnv(t) + e.aiRun(at(2026, 9, 10, 12, 0)) // older than Sep 11 03:00 + edge := e.aiRun(at(2026, 9, 12, 2, 59)) + young := e.aiRun(at(2026, 9, 12, 3, 1)) + recent := e.aiRun(at(2026, 10, 11, 12, 0)) + + sch, err := harness.PurgeSchedule(e.st, slog.New(slog.NewTextHandler(&syncBuffer{}, nil))) + if err != nil { + t.Fatal(err) + } + // Monday 02:00: the catch-up of Sunday 03:00 purges before Sep 11 03:00. + r := e.start(at(2026, 10, 12, 2, 0), sch) + wantKeys(t, "after the catch-up", e.aiRuns(), []string{edge, young, recent}) + + r.runUntil(at(2026, 10, 12, 2, 59)) + wantKeys(t, "before 03:00", e.aiRuns(), []string{edge, young, recent}) + r.runUntil(at(2026, 10, 12, 3, 0)) + wantKeys(t, "at 03:00", e.aiRuns(), []string{young, recent}) + r.runUntil(at(2026, 10, 13, 3, 0)) + wantKeys(t, "next day", e.aiRuns(), []string{recent}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.AIRunsPurge), []string{"2026-10-11", "2026-10-12", "2026-10-13"}) +} + +// TestPurgeScheduleRejectsNil: the purge schedule needs a store and a +// logger. +func TestPurgeScheduleRejectsNil(t *testing.T) { + e := newEnv(t) + if _, err := harness.PurgeSchedule(nil, slog.Default()); err == nil { + t.Error("nil store accepted") + } + if _, err := harness.PurgeSchedule(e.st, nil); err == nil { + t.Error("nil logger accepted") + } +} diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go index 4a13e76..cf7e40d 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 c6dbf4c..5bc40bc 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 9910272..5da305e 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 6a1769d..402b925 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -174,7 +174,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) } @@ -240,7 +240,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) } @@ -303,7 +303,7 @@ func newTestHomeOn(t *testing.T, st *store.Store) *home.Service { if err != nil { t.Fatal(err) } - planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, pub, logger) + planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger) if err != nil { t.Fatal(err) } diff --git a/internal/shopping/archive.go b/internal/shopping/archive.go index c87ad63..a24d2dc 100644 --- a/internal/shopping/archive.go +++ b/internal/shopping/archive.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strconv" + "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" @@ -39,11 +40,17 @@ const ( // only when no job is running it. A job that runs after the freeze finds // the list titled, or frozen untitled, and stores nothing. func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error { + return s.freeze(ctx, weekStart, s.now()) +} + +// freeze is Freeze with the week ended by instant now rather than by the +// service's clock. +func (s *Service) freeze(ctx context.Context, weekStart store.LocalDate, now time.Time) error { if _, err := household.WeekDays(weekStart); err != nil { return fmt.Errorf("shopping: freeze: %w", err) } q := db.New(s.st.Writer) - thisWeek, err := s.thisWeek(ctx, q) + thisWeek, err := weekAt(ctx, q, now) if err != nil { return err } @@ -73,9 +80,9 @@ func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error { } } // A list frozen meanwhile is left as it is: the freeze is done. - now := store.Now().String() + stamp := store.Now().String() if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{ - FrozenAt: sql.NullString{String: now, Valid: true}, UpdatedAt: now, ID: list.ID, + FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID, }); err != nil { return fmt.Errorf("shopping: freeze %s: %w", weekStart, err) } @@ -103,6 +110,12 @@ func freezeTitleJob(ctx context.Context, q *db.Queries, list db.ShoppingList) (T // thisWeek returns the Monday of the household's current week, in its // time zone, by the service's clock. func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate, error) { + return weekAt(ctx, q, s.now()) +} + +// weekAt returns the Monday of the household's week at instant t, in its +// time zone. +func weekAt(ctx context.Context, q *db.Queries, t time.Time) (store.LocalDate, error) { settings, err := q.GetHouseholdSettings(ctx) if err != nil { return store.LocalDate{}, fmt.Errorf("shopping: read household settings: %w", err) @@ -111,7 +124,7 @@ func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate, if err != nil { return store.LocalDate{}, fmt.Errorf("shopping: household settings: %w", err) } - return household.WeekStart(household.Today(s.now(), zone)), nil + return household.WeekStart(household.Today(t, zone)), nil } // Period is one heading of the Supply archive and its posts, newest first. diff --git a/internal/shopping/schedule.go b/internal/shopping/schedule.go new file mode 100644 index 0000000..73182fa --- /dev/null +++ b/internal/shopping/schedule.go @@ -0,0 +1,133 @@ +package shopping + +import ( + "context" + "errors" + "fmt" + "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/scheduler" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// RolloverTime is when, every Monday in the household zone, week_rollover +// freezes the week that has just ended. It is fixed, not a setting. +const RolloverTime = "00:05" + +// Reasons Autosend posts nothing. +const ( + AutosendNotConfigured = "no Google Chat webhook is configured" + AutosendChatOff = "list_ready is switched off for Google Chat" + AutosendNoList = "the week has no Supply run" + AutosendAlreadySent = "the Supply run was already sent to Google Chat" + AutosendUnconfirmed = "a send of the Supply run to Google Chat is unconfirmed" +) + +// AutosendKey identifies the automatic send of the Supply run of the week +// starting weekStart, list_ready:chat:YYYY-MM-DD: it is the +// shopping_chat_autosend period key claimed in schedule_runs. The send +// itself is recorded in the list's sent_to_chat_at. +func AutosendKey(weekStart store.LocalDate) string { + return string(notify.ListReady) + ":" + string(notify.ChannelChat) + ":" + weekStart.String() +} + +// ChatAutosendSchedule is the shopping_chat_autosend schedule: at the +// household's chat_autosend_dow and chat_autosend_time it sends the Supply +// run of the coming week, the week after the fire date's, to Google Chat +// with Autosend. Its period is that week, scheduler.WeeklyForNextWeek's, +// claimed under AutosendKey of the week's Monday. A send that fails is the +// period's error and is not retried; the household can still send it from +// the Supply run. +func (s *Service) ChatAutosendSchedule() scheduler.Schedule { + return scheduler.Schedule{ + Name: scheduler.ShoppingChatAutosend, + Rule: func(settings household.Settings) scheduler.Rule { + return scheduler.WeeklyForNextWeek(time.Weekday(settings.ChatAutosendDow), settings.ChatAutosendTime) + }, + PeriodKey: func(p scheduler.Period) string { + return AutosendKey(p.Week) + }, + Run: func(ctx context.Context, p scheduler.Period) error { + skipped, err := s.Autosend(ctx, p.Week) + if err != nil { + return err + } + log := s.logger.With("period", p.Key, "week_start", p.Week.String()) + if skipped != "" { + log.InfoContext(ctx, "Supply run not sent to Google Chat: "+skipped) + return nil + } + log.InfoContext(ctx, "Supply run sent to Google Chat") + return nil + }, + } +} + +// Autosend sends the Supply run of the week starting weekStart to Google +// Chat with SendToChat, as the household's send does, when a webhook is +// stored, list_ready's Google Chat channel is switched on, and the week has +// a Supply run not yet sent and with no send unconfirmed. Otherwise it +// posts nothing and returns which of those reasons stopped it; it never +// resends. A send that fails is SendToChat's error. +func (s *Service) Autosend(ctx context.Context, weekStart store.LocalDate) (skipped string, err error) { + settings, err := s.chat.Get(ctx) + if err != nil { + return "", fmt.Errorf("shopping: autosend: %w", err) + } + if settings.URLMasked == nil { + return AutosendNotConfigured, nil + } + on, err := s.notifier.ChannelOn(ctx, notify.ListReady, notify.ChannelChat) + if err != nil { + return "", fmt.Errorf("shopping: autosend: %w", err) + } + if !on { + return AutosendChatOff, nil + } + l, err := s.Get(ctx, weekStart) + if errors.Is(err, ErrListNotFound) { + return AutosendNoList, nil + } + if err != nil { + return "", err + } + switch { + case l.SentToChatAt != nil: + return AutosendAlreadySent, nil + case l.ChatSendUnconfirmedAt != nil: + return AutosendUnconfirmed, nil + } + if _, err := s.SendToChat(ctx, weekStart, false); err != nil { + return "", err + } + return "", nil +} + +// RolloverSchedule is the week_rollover schedule: every Monday at +// RolloverTime it freezes the Supply run of the week before the fire +// date's into the Supply archive with Freeze, the week ending by the fire +// time. Its period scheduler.Weekly keys week_rollover:YYYY-Www for the +// fire date's week. A week with no Supply run has nothing to freeze, and +// the period says so; a freeze that fails is the period's error and is not +// retried. +func (s *Service) RolloverSchedule() scheduler.Schedule { + return scheduler.Schedule{ + Name: scheduler.WeekRollover, + Rule: func(household.Settings) scheduler.Rule { + return scheduler.Weekly(time.Monday, RolloverTime) + }, + Run: func(ctx context.Context, p scheduler.Period) error { + y, m, d := p.Week.Date() + last := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC)) + err := s.freeze(ctx, last, p.FireAt) + if errors.Is(err, ErrListNotFound) { + s.logger.InfoContext(ctx, "week rollover froze nothing: the week has no Supply run", + "period", p.Key, "week_start", last.String()) + return nil + } + return 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/batch.go b/internal/suggest/batch.go index 00f26b0..a6e130c 100644 --- a/internal/suggest/batch.go +++ b/internal/suggest/batch.go @@ -22,6 +22,9 @@ import ( // BatchJob is the KindSuggestionBatch payload: the batch to run. type BatchJob struct { BatchID string `json:"batch_id"` + // Week is the YYYY-MM-DD Monday of the ISO week a scheduled batch is + // for, which keys its new_suggestions; empty for any other trigger. + Week string `json:"week,omitempty"` } // Batch stages, as the job's result reports them. @@ -50,19 +53,22 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) } // wanted at a time, until BatchSize are kept or none are left. A Side quest // no recipe grounds is dropped; any other grounding failure fails the // batch, as does suggest_side_quests failing, with no Side quest made up -// instead. Each round's grounded Side quests are stored before the next -// round starts, so the deck fills as the batch goes. The batch ends done -// or failed; Side quests stored before a failure stay in the deck. When -// recording the batch's end fails, so does the job; when it times out at -// shutdown, the job is left running and the batch with it, for the next -// start to fail. +// instead. The batch ends done or failed. When recording the batch's end +// fails, so does the job; when it times out at shutdown, the job is left +// running and the batch with it, for the next start to fail. // -// A scheduled or manual batch emits new_suggestions, keyed by the batch, -// in the transaction that first adds Side quests to the deck, so the -// household is told of every Side quest stored, whether or not the batch -// then ends done: a later round, or recording the batch's end, may fail. -// A replenish batch, which tops up the deck as the household swipes, never -// notifies. See store. +// When a batch stores its Side quests depends on whether it notifies (see +// noticeOf). A scheduled or manual batch notifies: it holds every round's +// grounded Side quests and, once the last round is done, stores them all +// in one transaction with one new_suggestions counting and naming every +// Side quest stored. So the household is told of exactly what the batch +// added, once; and a batch that fails at any round, including a later +// round's normalize_recipe, stores nothing and sends nothing, leaving no +// Side quest in the deck that was never announced. A replenish batch, +// which tops up the deck as the household swipes and never notifies, +// stores each round's Side quests before the next round starts, so the +// deck fills as it goes; those stored before a failure stay in the deck. +// See store. func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) { batch, err := db.New(s.st.Reader).GetSuggestionBatch(ctx, p.BatchID) if err != nil { @@ -78,7 +84,7 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe if n == 0 { return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID) } - res, err := s.fill(ctx, p.BatchID, batch.Trigger != TriggerReplenish, publish) + res, err := s.fill(ctx, batch.Trigger, p, publish) if err == nil { if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil { return batchResult{}, err @@ -98,10 +104,12 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe return batchResult{}, errors.Join(err, finishErr) } -// fill does runBatch's work, returning a *jobs.Failure for a failure the -// household may see. When notifies is true, store emits new_suggestions -// with the Side quests it adds. -func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publish func(batchResult) error) (batchResult, error) { +// fill does runBatch's work for job of a batch with trigger, returning a +// *jobs.Failure for a failure the household may see. A batch with a notice +// is stored once, when its last round is done; any other batch round by +// round. +func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publish func(batchResult) error) (batchResult, error) { + batchID := job.BatchID res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}} if err := publish(res); err != nil { return batchResult{}, err @@ -110,6 +118,10 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi if err != nil { return batchResult{}, err } + note, err := noticeOf(trigger, job, in.Context.Today) + if err != nil { + return batchResult{}, err + } out, err := s.suggest(ctx, batchID, in) if err != nil { if ctx.Err() != nil { @@ -122,34 +134,81 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi if err := publish(res); err != nil { return batchResult{}, err } - var week *store.LocalDate - if notifies { - w := household.WeekStart(in.Context.Today) - week = &w - } left := out.Suggestions - for len(res.SuggestionIDs) < BatchSize && len(left) > 0 { - round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))] + // held are a notifying batch's grounded Side quests, not yet stored. + var held []groundedQuest + for len(res.SuggestionIDs)+len(held) < BatchSize && len(left) > 0 { + round := left[:min(BatchSize-len(res.SuggestionIDs)-len(held), len(left))] left = left[len(round):] grounded, err := s.ground(ctx, round) if err != nil { return batchResult{}, err } res.Dropped += len(round) - len(grounded) - ids, err := s.store(ctx, batchID, week, grounded) - if err != nil { + if note != nil { + held = append(held, grounded...) + } else if err := s.storeInto(ctx, &res, nil, grounded); err != nil { return batchResult{}, err } - res.Dropped += len(grounded) - len(ids) - res.SuggestionIDs = append(res.SuggestionIDs, ids...) if err := publish(res); err != nil { return batchResult{}, err } } + if note != nil { + if err := s.storeInto(ctx, &res, note, held); err != nil { + return batchResult{}, err + } + } res.Stage = StageDone return res, nil } +// storeInto stores quests for res's batch with note (see store) and adds +// the suggestions stored, and the Side quests left out, to res. +func (s *Service) storeInto(ctx context.Context, res *batchResult, note *notice, quests []groundedQuest) error { + ids, err := s.store(ctx, res.BatchID, note, quests) + if err != nil { + return err + } + res.Dropped += len(quests) - len(ids) + res.SuggestionIDs = append(res.SuggestionIDs, ids...) + return nil +} + +// notice is how a batch's new_suggestions is keyed: by id, as +// new_suggestions:{id}, for the week starting on week. +type notice struct { + id string + week store.LocalDate +} + +// noticeOf returns the notice of job, run for a batch with trigger on +// household-local date today, or nil for a replenish batch, which never +// notifies. A scheduled batch is keyed by the ISO week it was scheduled +// for ("2026-W41"), not the week it happens to run in, so each week's +// schedule tells the household once even when its batch runs late; a +// manual batch is keyed by its batch id, for the week of today. A job +// whose week does not fit its trigger is an error. +func noticeOf(trigger string, job BatchJob, today store.LocalDate) (*notice, error) { + if trigger != TriggerScheduled { + if job.Week != "" { + return nil, fmt.Errorf("suggest: %s batch %s has a week", trigger, job.BatchID) + } + if trigger == TriggerReplenish { + return nil, nil + } + return ¬ice{id: job.BatchID, week: household.WeekStart(today)}, nil + } + week, err := store.ParseLocalDate(job.Week) + if err != nil { + return nil, fmt.Errorf("suggest: scheduled batch %s week: %w", job.BatchID, err) + } + if household.WeekStart(week) != week { + return nil, fmt.Errorf("suggest: scheduled batch %s week %s is not a Monday", job.BatchID, week) + } + return ¬ice{id: household.PeriodKey(week), week: week}, nil +} + // request reads what suggest_side_quests is given: the household context, // the deck's names and every passed name (ListPassedNames). A household with no Regulars // fails with ReasonNoRegulars. @@ -224,13 +283,14 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou // or archived. Passed names are read in the same transaction as the store, // so a Pass or drop committed before it is never missed. // -// When week is not nil and a Side quest is stored, new_suggestions:{batchID} -// naming those stored is emitted in the same transaction, for the week -// starting week, so the Side quests and their notification commit together +// When note is not nil and a Side quest is stored, new_suggestions keyed by +// note, counting and naming those stored, is emitted in the same +// transaction, so the Side quests and their notification commit together // or not at all: a notification that cannot be stored fails the store, and -// the batch with it. The event's key makes the first round to store Side -// quests the one that notifies; a later round's emit stores nothing. -func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDate, quests []groundedQuest) ([]string, error) { +// the batch with it. A notifying batch calls store once, with all its +// Side quests, so the count is the batch's (see runBatch). This is the +// only place new_suggestions is emitted. +func (s *Service) store(ctx context.Context, batchID string, note *notice, quests []groundedQuest) ([]string, error) { ids := []string{} var stored []string err := s.st.WithTx(ctx, func(tx *sql.Tx) error { @@ -298,15 +358,15 @@ func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDa ids = append(ids, id) stored = append(stored, d.Name) } - if week == nil || len(stored) == 0 { + if note == nil || len(stored) == 0 { return nil } - return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(batchID, *week, stored)) + return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(note.id, note.week, stored)) }) if err != nil { return nil, err } - if week != nil && len(stored) > 0 { + if note != nil && len(stored) > 0 { s.notifier.Wake() } return ids, nil diff --git a/internal/suggest/schedule.go b/internal/suggest/schedule.go new file mode 100644 index 0000000..ca20b22 --- /dev/null +++ b/internal/suggest/schedule.go @@ -0,0 +1,36 @@ +package suggest + +import ( + "context" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler" +) + +// WeeklySchedule is the suggestions_weekly schedule: at the household's +// suggestions_dow and suggestions_time it starts a scheduled batch for the +// ISO week of the fire date, its period scheduler.Weekly keys +// suggestions_weekly:YYYY-Www. The batch tells the household of the Side +// quests it adds with new_suggestions:YYYY-Www for that week, once; a batch +// that fails before adding any, as when the AI fails, sends nothing. When a +// batch is already pending or running the period starts none, and says so. +func (s *Service) WeeklySchedule() scheduler.Schedule { + return scheduler.Schedule{ + Name: scheduler.SuggestionsWeekly, + Rule: func(settings household.Settings) scheduler.Rule { + return scheduler.Weekly(time.Weekday(settings.SuggestionsDow), settings.SuggestionsTime) + }, + Run: func(ctx context.Context, p scheduler.Period) error { + b, err := s.StartScheduledBatch(ctx, p.Week) + if err != nil { + return err + } + if !b.Created { + s.logger.WarnContext(ctx, "scheduled suggestion batch not started: a batch is already under way", + "period", p.Key, "batch_id", b.ID, "trigger", b.Trigger) + } + return nil + }, + } +} diff --git a/internal/suggest/suggest.go b/internal/suggest/suggest.go index 23fa9d9..2aa6c81 100644 --- a/internal/suggest/suggest.go +++ b/internal/suggest/suggest.go @@ -19,6 +19,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" @@ -154,17 +155,35 @@ type Batch struct { Created bool `json:"-"` } -// StartBatch creates a batch with trigger and enqueues the +// StartBatch creates a manual or replenish batch and enqueues the // KindSuggestionBatch job that runs it, unless a batch is already pending -// or running, which it returns instead with Created false. +// or running, which it returns instead with Created false. A scheduled +// batch is started by StartScheduledBatch. func (s *Service) StartBatch(ctx context.Context, trigger string) (Batch, error) { + if trigger == TriggerScheduled { + return Batch{}, errors.New("suggest: a scheduled batch is started by StartScheduledBatch") + } if !slices.Contains(triggers, trigger) { return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", trigger) } + return s.start(ctx, trigger, "") +} + +// StartScheduledBatch is StartBatch for a scheduled batch for the ISO week +// starting on Monday week, which keys its new_suggestions (see noticeOf). +func (s *Service) StartScheduledBatch(ctx context.Context, week store.LocalDate) (Batch, error) { + if week.IsZero() || household.WeekStart(week) != week { + return Batch{}, fmt.Errorf("suggest: scheduled batch week %s is not a Monday", week) + } + return s.start(ctx, TriggerScheduled, week.String()) +} + +// start does StartBatch's work for a batch with trigger and the job week. +func (s *Service) start(ctx context.Context, trigger, week string) (Batch, error) { var b Batch err := s.st.WithTx(ctx, func(tx *sql.Tx) error { var err error - b, err = s.startBatch(ctx, tx, trigger) + b, err = s.startBatch(ctx, tx, trigger, week) return err }) if err != nil { @@ -228,10 +247,11 @@ func failedBatch(ctx context.Context, q *db.Queries) (*Batch, error) { return &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil } -// startBatch does StartBatch's work in tx: the batch and the job that runs -// it are committed together, so a batch is never seen without its job. The -// caller wakes the job runner once tx has committed and Created is true. -func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) { +// startBatch does StartBatch's work in tx, for a batch with trigger whose +// job has week (BatchJob.Week): the batch and the job that runs it are +// committed together, so a batch is never seen without its job. The caller +// wakes the job runner once tx has committed and Created is true. +func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger, week string) (Batch, error) { q := db.New(tx) cur, err := s.activeBatch(ctx, q) if err != nil { @@ -250,7 +270,7 @@ func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (B if err != nil { return Batch{}, fmt.Errorf("suggest: create batch: %w", err) } - jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID}) + jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID, Week: week}) if err != nil { return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err) } @@ -439,7 +459,7 @@ func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, update if left >= ReplenishBelow { return nil } - b, err := s.startBatch(ctx, tx, TriggerReplenish) + b, err := s.startBatch(ctx, tx, TriggerReplenish, "") if err != nil { return err } diff --git a/internal/suggest/suggest_test.go b/internal/suggest/suggest_test.go index 4a0eea7..6c0052c 100644 --- a/internal/suggest/suggest_test.go +++ b/internal/suggest/suggest_test.go @@ -300,6 +300,45 @@ func (e *env) runBatch(t *testing.T) batchResult { return res } +// scheduledWeek is the week the tests' scheduled batches are for: the +// Monday of 2026-W41. +var scheduledWeek = mustDate("2026-10-05") + +func mustDate(s string) store.LocalDate { + d, err := store.ParseLocalDate(s) + if err != nil { + panic(err) + } + return d +} + +func mustZone(t *testing.T, name string) *time.Location { + t.Helper() + loc, err := household.LoadZone(name) + if err != nil { + t.Fatal(err) + } + return loc +} + +// startBatch starts a batch with trigger, a scheduled one for +// scheduledWeek, and returns it with the key its new_suggestions has. +func (e *env) startBatch(t *testing.T, trigger string) (suggest.Batch, string) { + t.Helper() + if trigger == suggest.TriggerScheduled { + b, err := e.svc.StartScheduledBatch(t.Context(), scheduledWeek) + if err != nil { + t.Fatal(err) + } + return b, "new_suggestions:2026-W41" + } + b, err := e.svc.StartBatch(t.Context(), trigger) + if err != nil { + t.Fatal(err) + } + return b, "new_suggestions:" + b.ID +} + func (e *env) batchStatus(t *testing.T, id string) string { t.Helper() var status string @@ -404,8 +443,9 @@ func TestBatchStoresGroundedSideQuests(t *testing.T) { } // TestBatchEmitsNewSuggestions: a scheduled or manual batch that adds Side -// quests stores new_suggestions keyed by the batch, naming them; a -// replenish batch, or one that adds none, stores nothing. +// quests stores new_suggestions counting and naming them, keyed by the +// scheduled ISO week or the manual batch, for that week or the current +// one; a replenish batch, or one that adds none, stores nothing. func TestBatchEmitsNewSuggestions(t *testing.T) { for _, tc := range []struct { trigger string @@ -428,10 +468,7 @@ func TestBatchEmitsNewSuggestions(t *testing.T) { sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"), sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"), ) - b, err := e.svc.StartBatch(t.Context(), tc.trigger) - if err != nil { - t.Fatal(err) - } + b, key := e.startBatch(t, tc.trigger) if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded { t.Fatalf("batch job %s", job.Status) } @@ -446,64 +483,70 @@ func TestBatchEmitsNewSuggestions(t *testing.T) { t.Fatalf("new_suggestions rows = %+v, want one on push", rows) } r := rows[0] - if r.Key != "new_suggestions:"+b.ID || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" || - r.Payload.Title != "New Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week == "" { - t.Errorf("new_suggestions row = %+v, want key new_suggestions:%s naming the Side quests, /discover", r, b.ID) + week := household.WeekStart(store.LocalDateOf(time.Now().In(mustZone(t, "America/New_York")))).String() + if tc.trigger == suggest.TriggerScheduled { + week = scheduledWeek.String() + } + if r.Key != key || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" || + r.Payload.Title != "2 new Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week != week { + t.Errorf("new_suggestions row = %+v, want key %s counting and naming the Side quests, week %s, /discover", r, key, week) } }) } } -// TestBatchNotifiesAsSideQuestsAreStored: new_suggestions is stored with -// the first round to add Side quests, so a batch whose later round fails -// still tells the household of those in the deck, once; a later round that -// succeeds adds no second notification; a replenish batch notifies nothing. -func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) { +// twoRoundBatch scripts a suggest_side_quests reply of BatchSize+1 Side +// quests, Fajita Dish A to F, whose first round grounds all but B, so a +// second round grounds F. Unless lastGrounds, normalize_recipe fails for +// F. +func (e *env) twoRoundBatch(t *testing.T, lastGrounds bool) { + t.Helper() + var quests []string + for i := range suggest.BatchSize + 1 { + name := fmt.Sprintf("Fajita Dish %c", 'A'+i) + e.search.Results[name] = nil + switch { + case i == suggest.BatchSize && !lastGrounds: + // A page is found, but no normalize_recipe reply is scripted + // for it, so the AI call grounding it fails. + slug := strings.ReplaceAll(strings.ToLower(name), " ", "-") + page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg" + e.search.Results[name] = []recipes.Candidate{{URL: page}} + e.pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 35) + e.images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(e.images.JPEGs)+1)) + case i != 1: + e.recipe(t, name) + } + quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas")) + } + e.reply(quests...) +} + +// TestBatchNotifiesOnceWithEverySideQuestStored: a batch that fills across +// two grounding rounds stores new_suggestions once, when it completes, +// counting and naming every Side quest it stored, not just the first +// round's; a replenish batch notifies nothing. +func TestBatchNotifiesOnceWithEverySideQuestStored(t *testing.T) { for _, tc := range []struct { - trigger string - lastFails bool - notify bool + trigger string + notify bool }{ - {suggest.TriggerScheduled, true, true}, - {suggest.TriggerManual, true, true}, - {suggest.TriggerManual, false, true}, - {suggest.TriggerReplenish, true, false}, + {suggest.TriggerScheduled, true}, + {suggest.TriggerManual, true}, + {suggest.TriggerReplenish, false}, } { - t.Run(fmt.Sprintf("%s last round fails %v", tc.trigger, tc.lastFails), func(t *testing.T) { + t.Run(tc.trigger, func(t *testing.T) { e := newEnv(t) e.start(t) - // The first round grounds BatchSize Side quests but B; the - // second round grounds F. - var quests []string - for i := range suggest.BatchSize + 1 { - name := fmt.Sprintf("Fajita Dish %c", 'A'+i) - e.search.Results[name] = nil - if i != 1 { - e.recipe(t, name) - } - quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas")) - } - e.reply(quests...) - if tc.lastFails { - if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_f BEFORE INSERT ON dinners WHEN NEW.name = 'Fajita Dish F' BEGIN SELECT RAISE(ABORT, 'F refused'); END`); err != nil { - t.Fatal(err) - } - } + e.twoRoundBatch(t, true) - b, err := e.svc.StartBatch(t.Context(), tc.trigger) - if err != nil { - t.Fatal(err) + b, key := e.startBatch(t, tc.trigger) + if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded { + t.Fatalf("batch job %s, want succeeded", job.Status) } - job := e.waitJob(t, b.JobID) - want, deck := jobs.StatusSucceeded, suggest.BatchSize - if tc.lastFails { - want, deck = jobs.StatusFailed, suggest.BatchSize-1 - } - if job.Status != want { - t.Fatalf("batch job %s, want %s", job.Status, want) - } - if got := e.deckNames(t); len(got) != deck { - t.Fatalf("deck = %v, want %d Side quests", got, deck) + deck := e.deckNames(t) + if want := []string{"Fajita Dish A", "Fajita Dish C", "Fajita Dish D", "Fajita Dish E", "Fajita Dish F"}; !slices.Equal(deck, want) { + t.Fatalf("deck = %v, want %v", deck, want) } rows := notifytest.Rows(t, e.st, notify.NewSuggestions) if !tc.notify { @@ -512,9 +555,47 @@ func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) { } return } - if len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || - rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 1 more" { - t.Errorf("new_suggestions rows = %+v, want one keyed by batch %s naming the first round", rows, b.ID) + want := fmt.Sprintf("%d new Side quests are in", len(deck)) + if len(rows) != 1 || rows[0].Key != key || rows[0].Payload.Title != want || + rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 2 more" { + t.Errorf("new_suggestions rows = %+v, want one keyed %s titled %q", rows, key, want) + } + }) + } +} + +// TestBatchFailingInALaterRoundStoresNothing: when normalize_recipe fails +// in a batch's second grounding round, a scheduled or manual batch stores +// none of the first round's Side quests and sends no new_suggestions, so +// no Side quest is left in the deck unannounced; a replenish batch, which +// never notifies, keeps the first round's in the deck. +func TestBatchFailingInALaterRoundStoresNothing(t *testing.T) { + for _, tc := range []struct { + trigger string + deck int + }{ + {suggest.TriggerScheduled, 0}, + {suggest.TriggerManual, 0}, + {suggest.TriggerReplenish, suggest.BatchSize - 1}, + } { + t.Run(tc.trigger, func(t *testing.T) { + e := newEnv(t) + e.start(t) + e.twoRoundBatch(t, false) + + b, _ := e.startBatch(t, tc.trigger) + job := e.waitJob(t, b.JobID) + if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != suggest.ReasonAIFailed { + t.Fatalf("batch job %s, error %+v, want failed %s", job.Status, job.Error, suggest.ReasonAIFailed) + } + if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed { + t.Errorf("batch status = %s, want failed", got) + } + if got := e.deckNames(t); len(got) != tc.deck { + t.Errorf("deck = %v, want %d Side quests", got, tc.deck) + } + if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 0 { + t.Errorf("new_suggestions rows = %+v, want none", rows) } }) } @@ -1021,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) } @@ -1069,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) } @@ -1299,7 +1380,7 @@ func TestBatchJobFailsWhenRecordingItsEndFails(t *testing.T) { t.Errorf("batch status = %s with its job failed, want failed", got) } // Its Side quests stay in the deck, and the household was told of them. - if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || rows[0].Payload.Body != "Chili Verde" { + if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || rows[0].Payload.Title != "1 new Side quest is in" || rows[0].Payload.Body != "Chili Verde" { t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want one naming Chili Verde", rows) } } @@ -1474,6 +1555,47 @@ func TestStartBatchRefusesUnknownTrigger(t *testing.T) { } } +// TestScheduledBatchNeedsItsWeek: a scheduled batch is started only by +// StartScheduledBatch, for a Monday. +func TestScheduledBatchNeedsItsWeek(t *testing.T) { + e := newEnv(t) + if _, err := e.svc.StartBatch(t.Context(), suggest.TriggerScheduled); err == nil { + t.Error("StartBatch started a scheduled batch with no week") + } + for _, week := range []store.LocalDate{{}, mustDate("2026-10-06")} { + if _, err := e.svc.StartScheduledBatch(t.Context(), week); err == nil { + t.Errorf("StartScheduledBatch(%q) accepted a week that is not a Monday", week) + } + } + if n := e.activeBatches(t); n != 0 { + t.Errorf("%d active batches, want none", n) + } +} + +// TestScheduledBatchKeyedByItsWeekWhenRunLate: a scheduled batch's +// new_suggestions is keyed by the week it was scheduled for, not the week +// it runs in, so a batch caught up after its week ended still tells the +// household of that week's Side quests and leaves the next week's key free. +func TestScheduledBatchKeyedByItsWeekWhenRunLate(t *testing.T) { + e := newEnv(t) + e.start(t) + e.recipe(t, "Chili Verde") + e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili")) + // A week long past: the batch runs today, weeks later. + b, err := e.svc.StartScheduledBatch(t.Context(), mustDate("2026-01-05")) + if err != nil { + t.Fatal(err) + } + if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded { + t.Fatalf("batch job %s", job.Status) + } + rows := notifytest.Rows(t, e.st, notify.NewSuggestions) + if len(rows) != 1 || rows[0].Key != "new_suggestions:2026-W02" || rows[0].Payload.Week != "2026-01-05" || + rows[0].Payload.Title != "1 new Side quest is in" { + t.Errorf("new_suggestions rows = %+v, want one keyed new_suggestions:2026-W02 for 2026-01-05", rows) + } +} + // noCalendar is a plan.Calendar for tests that never push. type noCalendar struct{} diff --git a/migrations/queries/schedule_runs.sql b/migrations/queries/schedule_runs.sql new file mode 100644 index 0000000..e0b3a93 --- /dev/null +++ b/migrations/queries/schedule_runs.sql @@ -0,0 +1,6 @@ +-- name: ClaimScheduleRun :execrows +-- Claims one period of a schedule. The insert is the claim: 1 row means +-- this caller owns the period, 0 rows means it was already claimed. +INSERT INTO schedule_runs (id, schedule, period_key, created_at) +VALUES (?, ?, ?, ?) +ON CONFLICT (schedule, period_key) DO NOTHING;