Rotation check schedule (Feature 20)
CI / ci (push) Successful in 8m10s
CI / ci (pull_request) Successful in 7m22s

Register rotation_check: daily at review_time, claimed as
rotation_check:YYYY-MM-DD, it calls plan.MarkTried for yesterday.
MarkTried now emits rotation_review:{dinnerId}:{planDate} ("How was
{dinner}? Keep it in rotation?", URL /) for each Rotation check it
opens, in the same transaction. Nothing promotes a dinner to Regular.

Task: 58095ae5-ed4b-4494-b8f0-206c4c0a3f1a

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 16:59:20 -04:00
co-authored by Claude Opus 5.5
parent 54a55bf5c3
commit fdd694d1ef
7 changed files with 286 additions and 30 deletions
+1 -1
View File
@@ -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 {
+8 -8
View File
@@ -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),
}
}
+5 -5
View File
@@ -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
+46 -5
View File
@@ -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
}
+204
View File
@@ -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"})
}