diff --git a/cmd/server/main.go b/cmd/server/main.go index 1df7bc5..230a4b1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -204,6 +204,9 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } } + if err := sched.Register(planSvc.RotationCheckSchedule()); err != nil { + return err + } if err := sched.Register(shopSvc.ChatAutosendSchedule()); err != nil { return err } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2e87898..19111e8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -484,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 @@ -784,7 +782,7 @@ purge are fixed and not configurable. | `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 | Asks "Keep it in rotation?" for tried dinners. | +| `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`). | @@ -852,7 +850,13 @@ 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. `harness.PurgeSchedule(store, logger)` is `ai_runs_purge`, +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 @@ -899,6 +903,10 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where {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. diff --git a/internal/chat/chat_test.go b/internal/chat/chat_test.go index e06d9e7..836dd0c 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 4af7922..d06e16e 100644 --- a/internal/chat/messages.go +++ b/internal/chat/messages.go @@ -107,17 +107,17 @@ func NewSuggestions(id string, week store.LocalDate, names []string) notify.Even } } -// 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), } } diff --git a/internal/chat/messages_test.go b/internal/chat/messages_test.go index de4be3e..64c2261 100644 --- a/internal/chat/messages_test.go +++ b/internal/chat/messages_test.go @@ -137,13 +137,13 @@ func TestMessages(t *testing.T) { }, { 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/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/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"}) +}