From 6da5643f493a901c65d0c4544c20939a3f3d9f2c Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 07:10:06 -0400 Subject: [PATCH 1/2] Freeze ended weeks into the Supply archive and serve the archive API (Feature 17) shopping.Freeze(weekStart) archives a week's Supply run once its week has ended in the household's time zone, setting status archived and frozen_at; it is idempotent, and a week not yet ended (ErrWeekNotEnded) or with no list (ErrListNotFound) is an explicit error. A list still untitled is titled first with archive_title and the same conditional write the title_supply_run job uses; a failed title is logged and the list is frozen untitled, keeping its week label. GET /api/v1/shopping/archive?by=week|month|year returns the Supply runs up to the household's current week, newest first, grouped by the Monday's ISO week, month or year, each post with its week label, title or null, dinner, item and on-hand counts, dinner names and current flag. Any other by is 400 invalid_request. Frozen lists stay readable through GET /shopping/{weekStart} and reject item PATCHes with 409 archived. Maestro task 7ac36526-cb51-4dc7-bbad-6ebbee481942. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 33 +++ internal/db/querier.go | 6 + internal/db/shopping.sql.go | 63 ++++++ internal/server/plans_test.go | 3 +- internal/server/server.go | 4 +- internal/server/shopping.go | 64 ++++++ internal/server/shopping_test.go | 153 +++++++++++++ internal/shopping/archive.go | 228 +++++++++++++++++++ internal/shopping/archive_test.go | 359 ++++++++++++++++++++++++++++++ internal/shopping/export_test.go | 6 + internal/shopping/shopping.go | 26 ++- migrations/queries/shopping.sql | 14 ++ 12 files changed, 954 insertions(+), 5 deletions(-) create mode 100644 internal/shopping/archive.go create mode 100644 internal/shopping/archive_test.go create mode 100644 internal/shopping/export_test.go diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c3c3dac..3ebc9db 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -490,6 +490,25 @@ automatic** (D-021). generation: the list stays untitled (the UI shows the week label), the failure is logged as a warning and recorded in `ai_runs`, the job fails `title_failed`, and the week's next generation tries again. + `shopping.Freeze` titles a list still untitled once more before + freezing it (see Supply archive), through the same skill, scope and + conditional write. + +## Supply archive (Feature 17) + +`shopping.Freeze(weekStart)` moves a week's Supply run into the Supply +archive: `status` becomes `archived` and `frozen_at` is set, after which +generating the list or changing an item is 409 `archived`. It freezes only +a week that has ended in the household's time zone (its Monday is before +the Monday of the household's current week), so a week not yet ended is +`ErrWeekNotEnded` and a week with no list `ErrListNotFound`; freezing a +frozen list does nothing. A list still untitled is titled first with +`archive_title` from the week's plan, as `Generate` titles it; a title +that cannot be written is logged and the list is frozen untitled, when its +week label stands in for good. A `title_supply_run` job still in flight +with the same scope shares that run (`harness.Run`); whichever stores the +title first wins, and a job storing after the freeze stores nothing. The +`week_rollover` schedule is to call `Freeze` for last week. ## Recipe grounding @@ -790,6 +809,20 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. needed, needed_count, total_count}` with the list's counts after the change. 404 for an unknown item, 409 `archived` when the list is in the Supply archive, 400 `invalid_request` for a bad body. +- `GET /api/v1/shopping/archive?by=week|month|year` returns the Supply + archive `{by, periods: [{key, heading, posts: [{weekStart, label, + title, dinnerCount, itemCount, onHandCount, dinners, current}]}]}`: the + Supply run of every week up to the household's current week, newest + first, grouped by the Monday's ISO week (`key` "2026-W41", `heading` the + week label), month ("2026-10", "October 2026") or year ("2026"). The + Monday is a household-local date, so the week of Monday 28 December + is in December. `label` is the week label ("Week 41 · Oct 5 – 11"; the + UI sets it in capitals), `title` null while untitled, `dinners` the + list's dinners in day order and `dinnerCount` their number, + `itemCount` all items, `onHandCount` the items marked "have it", and + `current` true for the household's current week. 400 `invalid_request` + for any other `by`. A frozen list is read through `GET + /api/v1/shopping/{weekStart}` with `status` `archived`. ## Frontend diff --git a/internal/db/querier.go b/internal/db/querier.go index 3e143c4..00e2e22 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -87,6 +87,9 @@ type Querier interface { // Records a pending or running batch as done or failed; a batch already // finished is left as it is (0 rows). FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error) + // Freezes list id into the Supply archive at frozen_at, unless it is + // frozen already. + FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error) // Returns sql.ErrNoRows until the row exists. GetAISettings(ctx context.Context) (AiSetting, error) // The job of kind queued or running for the week starting week_start, @@ -184,6 +187,9 @@ type Querier interface { // The items of list list_id with their group's dinner, in list order: by // group, then by item. ListShoppingItems(ctx context.Context, listID string) ([]ListShoppingItemsRow, error) + // The Supply runs of the weeks starting on or before week_start, newest + // week first. + ListShoppingListsThrough(ctx context.Context, weekStart string) ([]ShoppingList, error) // The ids of the dinners with status quest_log planned on date, on a plan // of any status, that have no Rotation check for date yet. ListTriedCandidates(ctx context.Context, date string) ([]string, error) diff --git a/internal/db/shopping.sql.go b/internal/db/shopping.sql.go index 8652edd..55b060a 100644 --- a/internal/db/shopping.sql.go +++ b/internal/db/shopping.sql.go @@ -112,6 +112,28 @@ func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error { return err } +const freezeShoppingList = `-- name: FreezeShoppingList :execrows +UPDATE shopping_lists +SET status = 'archived', frozen_at = ?, updated_at = ? +WHERE id = ? AND frozen_at IS NULL +` + +type FreezeShoppingListParams struct { + FrozenAt sql.NullString + UpdatedAt string + ID string +} + +// Freezes list id into the Supply archive at frozen_at, unless it is +// frozen already. +func (q *Queries) FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error) { + result, err := q.db.ExecContext(ctx, freezeShoppingList, arg.FrozenAt, arg.UpdatedAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const getShoppingItemList = `-- name: GetShoppingItemList :one SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at FROM shopping_items @@ -299,6 +321,47 @@ func (q *Queries) ListShoppingItems(ctx context.Context, listID string) ([]ListS return items, nil } +const listShoppingListsThrough = `-- name: ListShoppingListsThrough :many +SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists +WHERE week_start <= ? +ORDER BY week_start DESC +` + +// The Supply runs of the weeks starting on or before week_start, newest +// week first. +func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string) ([]ShoppingList, error) { + rows, err := q.db.QueryContext(ctx, listShoppingListsThrough, weekStart) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ShoppingList + for rows.Next() { + var i ShoppingList + if err := rows.Scan( + &i.ID, + &i.WeekStart, + &i.PlanID, + &i.Title, + &i.Status, + &i.SentToChatAt, + &i.FrozenAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const moveShoppingGroup = `-- name: MoveShoppingGroup :exec UPDATE shopping_groups SET position = ?, updated_at = ? diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go index fd6d3d7..5d6ed74 100644 --- a/internal/server/plans_test.go +++ b/internal/server/plans_test.go @@ -37,6 +37,7 @@ type plansEnv struct { h http.Handler st *store.Store svc *plan.Service + shop *shopping.Service runner *jobs.Runner questLog string // google is the fake Google the plan service pushes to; it starts @@ -127,7 +128,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv { t.Fatal(err) } } - return plansEnv{h: h, st: st, svc: svc, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35), + return plansEnv{h: h, st: st, svc: svc, shop: shop, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35), google: google, gcal: googleSvc, logs: logs} } diff --git a/internal/server/server.go b/internal/server/server.go index 5c70e0b..931c1e3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -49,7 +49,8 @@ type RouteMounter interface { // /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and // POST /api/v1/dinners/{id}/rotation-review, and POST // /api/v1/plans/{weekStart}/push-calendar with shopSvc, which makes the -// pushed week's Supply run; shopSvc serves GET /api/v1/shopping/{weekStart}, +// pushed week's Supply run; shopSvc serves GET /api/v1/shopping/archive, +// GET /api/v1/shopping/{weekStart}, // POST /api/v1/shopping/{weekStart}/generate and PATCH // /api/v1/shopping/items/{id}; googleSvc serves GET // /api/v1/integrations/google and its /connect, /callback and /calendars, @@ -142,6 +143,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc)) apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc)) apiV1.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc, shopSvc)) + apiV1.Get("/shopping/archive", getSupplyArchive(logger, shopSvc)) apiV1.Get("/shopping/{weekStart}", getSupplyRun(logger, shopSvc)) apiV1.Post("/shopping/{weekStart}/generate", postSupplyRunGenerate(logger, shopSvc)) apiV1.Patch("/shopping/items/{id}", patchShoppingItem(logger, shopSvc)) diff --git a/internal/server/shopping.go b/internal/server/shopping.go index 3c3ac8e..e62ab53 100644 --- a/internal/server/shopping.go +++ b/internal/server/shopping.go @@ -143,6 +143,70 @@ func patchShoppingItem(logger *slog.Logger, svc *shopping.Service) http.HandlerF } } +// supplyArchive is the GET /shopping/archive answer: the Supply archive's +// periods, newest first, as grouped by by. +type supplyArchive struct { + By string `json:"by"` + Periods []archivePeriod `json:"periods"` +} + +// archivePeriod is one heading of the Supply archive and its posts, +// newest first. +type archivePeriod struct { + Key string `json:"key"` + Heading string `json:"heading"` + Posts []archivePost `json:"posts"` +} + +// archivePost is one week's Supply run in the Supply archive. label is +// the week label, which stands in for title while it is null, and +// onHandCount counts the items marked "have it". +type archivePost struct { + WeekStart string `json:"weekStart"` + Label string `json:"label"` + Title *string `json:"title"` + DinnerCount int `json:"dinnerCount"` + ItemCount int `json:"itemCount"` + OnHandCount int `json:"onHandCount"` + Dinners []string `json:"dinners"` + Current bool `json:"current"` +} + +// getSupplyArchive answers GET /shopping/archive?by=week|month|year with +// the Supply archive grouped by that period. +func getSupplyArchive(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + by := r.URL.Query().Get("by") + periods, err := svc.Archive(r.Context(), by) + if errors.Is(err, shopping.ErrInvalidPeriod) { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", shopping.ErrInvalidPeriod.Error()) + return + } + if err != nil { + writeShoppingError(w, r, logger, "read Supply archive", err) + return + } + out := supplyArchive{By: by, Periods: make([]archivePeriod, len(periods))} + for i, p := range periods { + posts := make([]archivePost, len(p.Posts)) + for j, post := range p.Posts { + posts[j] = archivePost{ + WeekStart: post.WeekStart, + Label: post.Label, + Title: post.Title, + DinnerCount: len(post.Dinners), + ItemCount: post.Items, + OnHandCount: post.OnHand, + Dinners: post.Dinners, + Current: post.Current, + } + } + out.Periods[i] = archivePeriod{Key: p.Key, Heading: p.Heading, Posts: posts} + } + writeJSON(w, r, logger, http.StatusOK, out) + } +} + // supplyRunOf is l, the Supply run of the week starting week, as the API // shows it. func supplyRunOf(week store.LocalDate, l shopping.List) (supplyRun, error) { diff --git a/internal/server/shopping_test.go b/internal/server/shopping_test.go index 350b100..1698821 100644 --- a/internal/server/shopping_test.go +++ b/internal/server/shopping_test.go @@ -4,6 +4,8 @@ import ( "context" "database/sql" "encoding/json" + "errors" + "fmt" "net/http" "reflect" "slices" @@ -11,9 +13,12 @@ import ( "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/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" ) @@ -634,3 +639,151 @@ func TestPushPlanLeavesArchivedSupplyRun(t *testing.T) { t.Errorf("archived Supply run after push = %v, want unchanged", l.render()) } } + +// archiveJSON is a GET /shopping/archive answer decoded with every field +// the API promises. +type archiveJSON struct { + By string `json:"by"` + Periods []struct { + Key string `json:"key"` + Heading string `json:"heading"` + Posts []struct { + WeekStart string `json:"weekStart"` + Label string `json:"label"` + Title *string `json:"title"` + DinnerCount int `json:"dinnerCount"` + ItemCount int `json:"itemCount"` + OnHandCount int `json:"onHandCount"` + Dinners []string `json:"dinners"` + Current bool `json:"current"` + } `json:"posts"` + } `json:"periods"` +} + +// weekSupplyRun plans a new dinner named name, grounded in a recipe +// listing ings, on the Monday of the week starting monday, and generates +// the week's Supply run. +func (e plansEnv) weekSupplyRun(t *testing.T, monday store.LocalDate, name string, ings ...shopIng) supplyRunJSON { + t.Helper() + q := db.New(e.st.Writer) + now := store.Now().String() + d, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{ + ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "regular", Origin: "regular", + GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatal(err) + } + planID := store.NewID() + if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: planID, WeekStart: monday.String(), CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: monday.String(), + DinnerID: sql.NullString{String: d.ID, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + e.groundDay(t, monday.String(), ings...) + var l supplyRunJSON + if status, body := callShopping(t, e, http.MethodPost, shoppingPath+monday.String()+"/generate", "", &l); status != http.StatusOK { + t.Fatalf("generate %s = %d %v", monday, status, body) + } + return l +} + +// TestSupplyArchive freezes last week's Supply run and reads the Supply +// archive: posts newest first with exactly the promised fields, this +// week's marked current. The frozen list is served read-only. +func TestSupplyArchive(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + // The fixture's plan is for a fixed week; this test plans weeks + // around today instead. + if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM plan_days; DELETE FROM week_plans`); err != nil { + t.Fatal(err) + } + zone, err := household.LoadZone("America/New_York") // the env's household zone + if err != nil { + t.Fatal(err) + } + thisWeek := household.WeekStart(household.Today(time.Now(), zone)) + y, m, d := thisWeek.Date() + lastWeek := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC)) + + last := e.weekSupplyRun(t, lastWeek, "Beef Stew", shopIng{"stewing beef", "2", "lb"}, shopIng{"carrots", "4", ""}, shopIng{"onion", "1", ""}) + onion := last.itemID(t, "onion") + if status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+onion, `{"needed":false}`, nil); status != http.StatusOK { + t.Fatalf("PATCH = %d %v", status, body) + } + e.weekSupplyRun(t, thisWeek, "Lemon Chicken", shopIng{"chicken thighs", "6", ""}, shopIng{"lemon", "1", ""}) + if err := e.shop.Freeze(t.Context(), lastWeek); err != nil { + t.Fatal(err) + } + if err := e.shop.Freeze(t.Context(), thisWeek); !errors.Is(err, shopping.ErrWeekNotEnded) { + t.Fatalf("Freeze this week = %v, want ErrWeekNotEnded", err) + } + + var got archiveJSON + status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got) + if status != http.StatusOK { + t.Fatalf("GET archive = %d %v", status, raw) + } + if k := keys(raw); !reflect.DeepEqual(k, []string{"by", "periods"}) { + t.Errorf("archive fields = %v", k) + } + period := raw["periods"].([]any)[0].(map[string]any) + if k := keys(period); !reflect.DeepEqual(k, []string{"heading", "key", "posts"}) { + t.Errorf("period fields = %v", k) + } + wantPost := []string{"current", "dinnerCount", "dinners", "itemCount", "label", "onHandCount", "title", "weekStart"} + if k := keys(period["posts"].([]any)[0].(map[string]any)); !reflect.DeepEqual(k, wantPost) { + t.Errorf("post fields = %v, want %v", k, wantPost) + } + var posts []string + for _, p := range got.Periods { + if len(p.Posts) != 1 || p.Key != household.PeriodKey(mustLocalDate(t, p.Posts[0].WeekStart)) || p.Heading != p.Posts[0].Label { + t.Errorf("period %+v, want one post under its week", p) + } + for _, post := range p.Posts { + label, err := household.WeekLabel(mustLocalDate(t, post.WeekStart)) + if err != nil { + t.Fatal(err) + } + if post.Label != label || post.Title != nil { + t.Errorf("post %s: label %q title %v, want %q and null", post.WeekStart, post.Label, post.Title, label) + } + posts = append(posts, fmt.Sprintf("%s %d dinners %d items %d on hand %v current=%t", + post.WeekStart, post.DinnerCount, post.ItemCount, post.OnHandCount, post.Dinners, post.Current)) + } + } + want := []string{ + thisWeek.String() + " 1 dinners 2 items 0 on hand [Lemon Chicken] current=true", + lastWeek.String() + " 1 dinners 3 items 1 on hand [Beef Stew] current=false", + } + if got.By != "week" || !reflect.DeepEqual(posts, want) { + t.Errorf("archive by %s =\n %s\nwant\n %s", got.By, strings.Join(posts, "\n "), strings.Join(want, "\n ")) + } + + for _, q := range []string{"", "?by=", "?by=day", "?by=MONTH"} { + status, body := callShopping(t, e, http.MethodGet, shoppingPath+"archive"+q, "", nil) + if status != http.StatusBadRequest || errorCode(body) != "invalid_request" { + t.Errorf("GET archive%s = %d %v, want 400 invalid_request", q, status, body) + } + } + + status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+last.itemID(t, "carrots"), `{"needed":false}`, nil) + if status != http.StatusConflict || errorCode(body) != "archived" { + t.Errorf("PATCH after Freeze = %d %v, want 409 archived", status, body) + } + var frozen supplyRunJSON + if status, body := callShopping(t, e, http.MethodGet, shoppingPath+lastWeek.String(), "", &frozen); status != http.StatusOK { + t.Fatalf("GET frozen list = %d %v", status, body) + } + wantLines := []string{ + "Monday " + lastWeek.String() + " Beef Stew: stewing beef [2 lb] needed", + "Monday " + lastWeek.String() + " Beef Stew: carrots [4] needed", + "Monday " + lastWeek.String() + " Beef Stew: onion [1] have it", + } + if frozen.Status != "archived" || frozen.NeededCount != 2 || frozen.TotalCount != 3 || !reflect.DeepEqual(frozen.render(), wantLines) { + t.Errorf("frozen list = %s %d/%d %v, want archived 2/3 %v", frozen.Status, frozen.NeededCount, frozen.TotalCount, frozen.render(), wantLines) + } +} diff --git a/internal/shopping/archive.go b/internal/shopping/archive.go new file mode 100644 index 0000000..ba4eb17 --- /dev/null +++ b/internal/shopping/archive.go @@ -0,0 +1,228 @@ +package shopping + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" + + "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" +) + +// Archive groupings: the period Archive groups its posts by. +const ( + ByWeek = "week" + ByMonth = "month" + ByYear = "year" +) + +// Freeze moves the Supply run of the week starting weekStart into the +// Supply archive: its status becomes archived and frozen_at is set, after +// which it is read-only (Generate and SetNeeded are ErrFrozen). Freezing a +// frozen list does nothing and is no error. +// +// The week must have ended in the household's time zone: a week not yet +// ended is ErrWeekNotEnded, and a week with no Supply run ErrListNotFound. +// +// A list still untitled is titled first, with archive_title and the same +// conditional write as a KindTitleSupplyRun job, from the week's plan as +// Generate titles it, so its post in the Supply archive gets a title when +// the AI can write one. Titling never fails the freeze: a title that +// cannot be written is logged and the list is frozen untitled, when its +// week label stands in for the title for good. A title job still in +// flight for the same plan revision and dinners shares Freeze's +// archive_title run (see harness.Run), and whichever stores the title +// first wins; a job whose write comes after the freeze stores nothing. +func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) 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) + if err != nil { + return err + } + if weekStart.String() >= thisWeek.String() { + return fmt.Errorf("shopping: freeze %s: %w", weekStart, ErrWeekNotEnded) + } + list, err := q.GetShoppingList(ctx, weekStart.String()) + if errors.Is(err, sql.ErrNoRows) { + return ErrListNotFound + } + if err != nil { + return fmt.Errorf("shopping: freeze: read list: %w", err) + } + if frozen(list) { + return nil + } + if !list.Title.Valid { + p, err := freezeTitleJob(ctx, q, list) + if err != nil { + return err + } + if _, err := s.writeTitle(ctx, p); err != nil { + s.logger.WarnContext(ctx, "Supply run frozen untitled; the Supply archive shows its week label", + "week", list.WeekStart, "list", list.ID, "error", err) + } + } + // A list frozen meanwhile is left as it is: the freeze is done. + now := store.Now().String() + if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{ + FrozenAt: sql.NullString{String: now, Valid: true}, UpdatedAt: now, ID: list.ID, + }); err != nil { + return fmt.Errorf("shopping: freeze %s: %w", weekStart, err) + } + return nil +} + +// freezeTitleJob is the title job of list as Generate would enqueue it: +// the week's plan revision and its dinners in day order. +func freezeTitleJob(ctx context.Context, q *db.Queries, list db.ShoppingList) (TitleJob, error) { + plan, err := q.GetWeekPlan(ctx, list.WeekStart) + if err != nil { + return TitleJob{}, fmt.Errorf("shopping: freeze: read plan: %w", err) + } + days, err := q.ListPlanDays(ctx, plan.ID) + if err != nil { + return TitleJob{}, fmt.Errorf("shopping: freeze: read plan days: %w", err) + } + names := make([]string, len(days)) + for i, d := range days { + names[i] = d.Dinner.Name + } + return TitleJob{ListID: list.ID, WeekStart: list.WeekStart, Revision: plan.Revision, Dinners: names}, nil +} + +// 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) { + settings, err := q.GetHouseholdSettings(ctx) + if err != nil { + return store.LocalDate{}, fmt.Errorf("shopping: read household settings: %w", err) + } + zone, err := household.LoadZone(settings.Timezone) + if err != nil { + return store.LocalDate{}, fmt.Errorf("shopping: household settings: %w", err) + } + return household.WeekStart(household.Today(s.now(), zone)), nil +} + +// Period is one heading of the Supply archive and its posts, newest first. +type Period struct { + // Key identifies the period: the ISO week ("2026-W41"), the month + // ("2026-10") or the year ("2026"). + Key string + // Heading is the period as shown: the week label, the month and year + // ("October 2026") or the year ("2026"). + Heading string + Posts []Post +} + +// Post is one Supply run in the Supply archive. +type Post struct { + WeekStart string + // Label is the week label, such as "Week 41 · Oct 5 – 11". + Label string + // Title is nil while the list is untitled, when Label stands in. + Title *string + // Dinners are the names of the list's dinners, in day order. + Dinners []string + // Items is how many items the list has, and OnHand how many of them + // the household marked "have it". + Items, OnHand int + // Current is true for the household's current week. + Current bool +} + +// Archive returns the Supply archive: the Supply run of every week up to +// and including the household's current week, newest first, grouped by +// by, which is ByWeek, ByMonth or ByYear (else ErrInvalidPeriod). A +// week's month and year are those of its Monday, a household-local date, +// so a week belongs to one period whatever the server's zone; the week +// starting Monday 2026-12-28 is in December 2026. +func (s *Service) Archive(ctx context.Context, by string) (out []Period, err error) { + // period returns the key and heading of the period holding p, whose + // week starts on Monday monday. + var period func(monday store.LocalDate, p Post) (string, string) + switch by { + case ByWeek: + period = func(monday store.LocalDate, p Post) (string, string) { + return household.PeriodKey(monday), p.Label + } + case ByMonth: + period = func(monday store.LocalDate, _ Post) (string, string) { + y, m, _ := monday.Date() + return fmt.Sprintf("%04d-%02d", y, int(m)), fmt.Sprintf("%s %d", m, y) + } + case ByYear: + period = func(monday store.LocalDate, _ Post) (string, string) { + y, _, _ := monday.Date() + return strconv.Itoa(y), strconv.Itoa(y) + } + default: + return nil, fmt.Errorf("shopping: archive by %q: %w", by, ErrInvalidPeriod) + } + tx, err := s.st.Reader.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("shopping: begin read: %w", err) + } + defer func() { + err = errors.Join(err, tx.Rollback()) + }() + q := db.New(tx) + thisWeek, err := s.thisWeek(ctx, q) + if err != nil { + return nil, err + } + lists, err := q.ListShoppingListsThrough(ctx, thisWeek.String()) + if err != nil { + return nil, fmt.Errorf("shopping: read Supply runs: %w", err) + } + out = []Period{} + for _, row := range lists { + monday, err := store.ParseLocalDate(row.WeekStart) + if err != nil { + return nil, fmt.Errorf("shopping: Supply run %s week: %w", row.ID, err) + } + l, err := readList(ctx, q, row) + if err != nil { + return nil, err + } + p, err := postOf(monday, l, monday == thisWeek) + if err != nil { + return nil, err + } + k, heading := period(monday, p) + if n := len(out); n == 0 || out[n-1].Key != k { + out = append(out, Period{Key: k, Heading: heading}) + } + out[len(out)-1].Posts = append(out[len(out)-1].Posts, p) + } + return out, nil +} + +// postOf is l, the Supply run of the week starting monday, as a post in +// the Supply archive. +func postOf(monday store.LocalDate, l List, current bool) (Post, error) { + label, err := household.WeekLabel(monday) + if err != nil { + return Post{}, fmt.Errorf("shopping: Supply run %s: %w", l.ID, err) + } + dinners := make([]string, len(l.Groups)) + for i, g := range l.Groups { + dinners[i] = g.DinnerName + } + c := l.Counts() + return Post{ + WeekStart: l.WeekStart, + Label: label, + Title: l.Title, + Dinners: dinners, + Items: c.Total, + OnHand: c.Total - c.Needed, + Current: current, + }, nil +} diff --git a/internal/shopping/archive_test.go b/internal/shopping/archive_test.go new file mode 100644 index 0000000..43f7a87 --- /dev/null +++ b/internal/shopping/archive_test.go @@ -0,0 +1,359 @@ +package shopping_test + +import ( + "database/sql" + "errors" + "reflect" + "strconv" + "strings" + "testing" + "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/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// zoned makes zone the household's time zone and at the instant the +// service's clock reads. +func (e *env) zoned(t *testing.T, zone, at string) { + t.Helper() + if err := household.SeedSettings(t.Context(), e.st, zone); err != nil { + t.Fatal(err) + } + e.at(t, at) +} + +// at sets the service's clock to the RFC 3339 instant at. +func (e *env) at(t *testing.T, at string) { + t.Helper() + now, err := time.Parse(time.RFC3339, at) + if err != nil { + t.Fatal(err) + } + shopping.SetClock(e.svc, func() time.Time { return now }) +} + +// frozenState returns the week's list's status and frozen_at; frozen_at is +// "" while NULL. +func (e *env) frozenState(t *testing.T, monday store.LocalDate) (string, string) { + t.Helper() + var status string + var frozenAt sql.NullString + if err := e.st.Reader.QueryRow(`SELECT status, frozen_at FROM shopping_lists WHERE week_start = ?`, monday.String()).Scan(&status, &frozenAt); err != nil { + t.Fatal(err) + } + return status, frozenAt.String +} + +// weekList plans the dinners named names on the days of the week starting +// monday, in order, and generates its Supply run. +func (e *env) weekList(t *testing.T, monday string, names ...string) shopping.List { + t.Helper() + start := mustDate(monday) + days, err := household.WeekDays(start) + if err != nil { + t.Fatal(err) + } + q := db.New(e.st.Writer) + now := store.Now().String() + planID := store.NewID() + if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: planID, WeekStart: monday, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + for i, name := range names { + if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ + ID: store.NewID(), PlanID: planID, Date: days[i].String(), + DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + } + g, err := e.svc.Generate(t.Context(), start) + if err != nil { + t.Fatal(err) + } + return g.List +} + +// endedWeek is the env with the tests' week generated, in New York, on +// the Monday after it, so the week has ended. +func endedWeek(t *testing.T) (*env, shopping.List) { + t.Helper() + e := newEnv(t) + e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z") + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}) + e.plan(t, "Beef Chili") + return e, e.generate(t).List +} + +// TestFreeze freezes an ended week's list: it is archived with frozen_at, +// read back read-only, and freezing it again changes nothing. +func TestFreeze(t *testing.T) { + e, l := endedWeek(t) + + if err := e.svc.Freeze(t.Context(), week); err != nil { + t.Fatal(err) + } + status, frozenAt := e.frozenState(t, week) + if status != shopping.StatusArchived || frozenAt == "" { + t.Fatalf("after Freeze: status %q, frozen_at %q; want archived and set", status, frozenAt) + } + got, err := e.svc.Get(t.Context(), week) + if err != nil { + t.Fatal(err) + } + if got.Status != shopping.StatusArchived { + t.Errorf("read back status = %q, want archived", got.Status) + } + wantLines(t, "read back", lines(got), []string{ + "0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed", + "0 2026-10-12 Beef Chili: 1 onion [1] needed", + }) + + time.Sleep(2 * time.Millisecond) // a second freeze would stamp a later frozen_at + if err := e.svc.Freeze(t.Context(), week); err != nil { + t.Fatalf("second Freeze = %v, want no error", err) + } + if s, again := e.frozenState(t, week); s != shopping.StatusArchived || again != frozenAt { + t.Errorf("after second Freeze: status %q, frozen_at %q; want archived, %q kept", s, again, frozenAt) + } + + if _, err := e.svc.SetNeeded(t.Context(), l.Groups[0].Items[1].ID, false); !errors.Is(err, shopping.ErrFrozen) { + t.Errorf("SetNeeded after Freeze = %v, want ErrFrozen", err) + } + if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrFrozen) { + t.Errorf("Generate after Freeze = %v, want ErrFrozen", err) + } + wantLines(t, "stored list", e.stored(t), []string{ + "0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed", + "0 2026-10-12 Beef Chili: 1 onion [1] needed", + }) +} + +// TestFreezeWeekNotEnded: a week has not ended until Monday comes in the +// household's zone. At 23:30 on the Sunday in Los Angeles it is already +// Monday in UTC, and the list is still not frozen; at 00:05 on the Monday +// it is. +func TestFreezeWeekNotEnded(t *testing.T) { + e := newEnv(t) + e.zoned(t, "America/Los_Angeles", "2026-10-19T06:30:00Z") // Sun 18 Oct 23:30 PDT + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) + e.plan(t, "Beef Chili") + e.generate(t) + + if err := e.svc.Freeze(t.Context(), week); !errors.Is(err, shopping.ErrWeekNotEnded) { + t.Fatalf("Freeze on the Sunday = %v, want ErrWeekNotEnded", err) + } + if status, frozenAt := e.frozenState(t, week); status != shopping.StatusCurrent || frozenAt != "" { + t.Fatalf("after refused Freeze: status %q, frozen_at %q; want current, unset", status, frozenAt) + } + if err := e.svc.Freeze(t.Context(), mustDate("2026-10-19")); !errors.Is(err, shopping.ErrWeekNotEnded) { + t.Errorf("Freeze of a later week = %v, want ErrWeekNotEnded", err) + } + + e.at(t, "2026-10-19T07:05:00Z") // Mon 19 Oct 00:05 PDT + if err := e.svc.Freeze(t.Context(), week); err != nil { + t.Fatalf("Freeze on the Monday = %v", err) + } + if status, _ := e.frozenState(t, week); status != shopping.StatusArchived { + t.Errorf("status = %q, want archived", status) + } +} + +func TestFreezeWeekWithoutList(t *testing.T) { + e := newEnv(t) + e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z") + if err := e.svc.Freeze(t.Context(), week); !errors.Is(err, shopping.ErrListNotFound) { + t.Fatalf("Freeze = %v, want ErrListNotFound", err) + } + if err := e.svc.Freeze(t.Context(), mustDate("2026-10-13")); err == nil { + t.Fatal("Freeze of a Tuesday succeeded") + } +} + +// TestFreezeTitlesUntitledList: a list still untitled when its week is +// frozen is titled by archive_title first, from the plan's dinners, and +// frozen with that title. Its title job, still queued, runs after the +// freeze, finds the title and succeeds without asking the model again. +func TestFreezeTitlesUntitledList(t *testing.T) { + e := newEnv(t) + e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z") + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) + e.dinner(t, "Fish Tacos") + e.plan(t, "Fish Tacos", "Beef Chili") + g := e.generate(t) // the runner is not started: its title job waits + e.model.QueueGenerate(titled("Tacos then Chili")) + + if err := e.svc.Freeze(t.Context(), week); err != nil { + t.Fatal(err) + } + wantTitle(t, "stored title", e.storedTitle(t), "Tacos then Chili") + if status, frozenAt := e.frozenState(t, week); status != shopping.StatusArchived || frozenAt == "" { + t.Errorf("status %q, frozen_at %q; want archived and set", status, frozenAt) + } + reqs := e.model.Requests() + if len(reqs) != 1 { + t.Fatalf("%d model calls, want 1", len(reqs)) + } + if want := "Title the week whose dinners are, in day order from Monday:\n- Fish Tacos\n- Beef Chili"; reqs[0].Messages[0].Content != want { + t.Errorf("user message = %q, want %q", reqs[0].Messages[0].Content, want) + } + + e.start(t) + wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "") + if n := len(e.model.Requests()); n != 1 { + t.Errorf("%d model calls, want 1: the job finds the list titled", n) + } + wantTitle(t, "stored title after the job", e.storedTitle(t), "Tacos then Chili") +} + +// TestFreezeUntitledWhenTitleFails: when archive_title cannot title the +// list, it is frozen untitled all the same, the failure is logged, and the +// Supply archive shows its week label for good. +func TestFreezeUntitledWhenTitleFails(t *testing.T) { + e, _ := endedWeek(t) // nothing queued on the model: archive_title fails + + if err := e.svc.Freeze(t.Context(), week); err != nil { + t.Fatal(err) + } + wantUntitled(t, "stored title", e.storedTitle(t)) + if status, _ := e.frozenState(t, week); status != shopping.StatusArchived { + t.Errorf("status = %q, want archived", status) + } + if !strings.Contains(e.log.String(), "Supply run frozen untitled") { + t.Errorf("log lacks the failure:\n%s", e.log.String()) + } + periods, err := e.svc.Archive(t.Context(), shopping.ByWeek) + if err != nil { + t.Fatal(err) + } + if len(periods) != 1 || len(periods[0].Posts) != 1 || periods[0].Posts[0].Title != nil || + periods[0].Posts[0].Label != "Week 42 · Oct 12 – 18" { + t.Errorf("archive = %+v, want the untitled week under its label", periods) + } +} + +// TestFreezeKeepsTitle: a titled list is frozen with its title and the +// model is not asked again. +func TestFreezeKeepsTitle(t *testing.T) { + e, _ := endedWeek(t) + if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili Night'`); err != nil { + t.Fatal(err) + } + if err := e.svc.Freeze(t.Context(), week); err != nil { + t.Fatal(err) + } + wantTitle(t, "stored title", e.storedTitle(t), "Chili Night") + if n := len(e.model.Requests()); n != 0 { + t.Errorf("%d model calls, want none", n) + } +} + +// postLine renders p as "weekStart label | title | dinners | items/on hand +// | current". +func postLine(p shopping.Post) string { + title := "-" + if p.Title != nil { + title = *p.Title + } + cur := "" + if p.Current { + cur = " | current" + } + return p.WeekStart + " " + p.Label + " | " + title + " | " + strings.Join(p.Dinners, ", ") + " | " + + strconv.Itoa(p.Items) + " items, " + strconv.Itoa(p.OnHand) + " on hand" + cur +} + +// periodLines renders periods one line per heading and post. +func periodLines(periods []shopping.Period) []string { + var out []string + for _, p := range periods { + out = append(out, "# "+p.Key+" "+p.Heading) + for _, post := range p.Posts { + out = append(out, postLine(post)) + } + } + return out +} + +// TestArchiveGroups reads the Supply archive across a month and a year +// boundary in Auckland, where it is already Monday 4 January 2027 while +// UTC still reads Sunday 3 January: that week is current, the next one is +// not yet in the archive, and each week falls in the month and year of +// its Monday. +func TestArchiveGroups(t *testing.T) { + e := newEnv(t) + e.zoned(t, "Pacific/Auckland", "2027-01-03T12:00:00Z") // Mon 4 Jan 2027 01:00 NZDT + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}, ing{"kidney beans", "2", "cans"}) + e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"lime", "2", ""}) + e.dinner(t, "Fish Tacos") // no recipe: no group, not counted + e.dinner(t, "Pot Roast", ing{"carrots", "4", ""}) + + nov := e.weekList(t, "2026-11-30", "Pot Roast") // Nov 30 – Dec 6 + e.weekList(t, "2026-12-07", "Pad Thai", "Fish Tacos") + dec := e.weekList(t, "2026-12-28", "Beef Chili", "Fish Tacos", "Pad Thai") // ISO 2026-W53, into January + jan := e.weekList(t, "2027-01-04", "Pad Thai", "Beef Chili") + e.weekList(t, "2027-01-11", "Pot Roast") // next week: not in the archive yet + e.haveIt(t, dec, "Beef Chili", "onion") + e.haveIt(t, dec, "Pad Thai", "lime") + e.haveIt(t, jan, "Pad Thai", "lime") + e.haveIt(t, nov, "Pot Roast", "carrots") + if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili, Noodles & Co' WHERE week_start = '2026-12-28'`); err != nil { + t.Fatal(err) + } + if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2027-01-03T11:05:00.000Z' WHERE week_start < '2027-01-04'`); err != nil { + t.Fatal(err) + } + + posts := map[string]string{ + "jan": "2027-01-04 Week 1 · Jan 4 – 10 | - | Pad Thai, Beef Chili | 5 items, 1 on hand | current", + "dec": "2026-12-28 Week 53 · Dec 28 – Jan 3 | Chili, Noodles & Co | Beef Chili, Pad Thai | 5 items, 2 on hand", + "d07": "2026-12-07 Week 50 · Dec 7 – 13 | - | Pad Thai | 2 items, 0 on hand", + "nov": "2026-11-30 Week 49 · Nov 30 – Dec 6 | - | Pot Roast | 1 items, 1 on hand", + } + for _, tc := range []struct { + by string + want []string + }{ + {shopping.ByWeek, []string{ + "# 2027-W01 Week 1 · Jan 4 – 10", posts["jan"], + "# 2026-W53 Week 53 · Dec 28 – Jan 3", posts["dec"], + "# 2026-W50 Week 50 · Dec 7 – 13", posts["d07"], + "# 2026-W49 Week 49 · Nov 30 – Dec 6", posts["nov"], + }}, + {shopping.ByMonth, []string{ + "# 2027-01 January 2027", posts["jan"], + "# 2026-12 December 2026", posts["dec"], posts["d07"], + "# 2026-11 November 2026", posts["nov"], + }}, + {shopping.ByYear, []string{ + "# 2027 2027", posts["jan"], + "# 2026 2026", posts["dec"], posts["d07"], posts["nov"], + }}, + } { + got, err := e.svc.Archive(t.Context(), tc.by) + if err != nil { + t.Fatalf("Archive(%s): %v", tc.by, err) + } + if lines := periodLines(got); !reflect.DeepEqual(lines, tc.want) { + t.Errorf("Archive(%s) =\n %s\nwant\n %s", tc.by, strings.Join(lines, "\n "), strings.Join(tc.want, "\n ")) + } + } +} + +func TestArchiveEmptyAndInvalidPeriod(t *testing.T) { + e := newEnv(t) + e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z") + got, err := e.svc.Archive(t.Context(), shopping.ByMonth) + if err != nil || got == nil || len(got) != 0 { + t.Errorf("Archive of no Supply runs = %#v, %v; want empty", got, err) + } + for _, by := range []string{"", "day", "Week", "months"} { + if _, err := e.svc.Archive(t.Context(), by); !errors.Is(err, shopping.ErrInvalidPeriod) { + t.Errorf("Archive(%q) = %v, want ErrInvalidPeriod", by, err) + } + } +} diff --git a/internal/shopping/export_test.go b/internal/shopping/export_test.go new file mode 100644 index 0000000..3718a07 --- /dev/null +++ b/internal/shopping/export_test.go @@ -0,0 +1,6 @@ +package shopping + +import "time" + +// SetClock makes now the clock s ends and dates weeks on. +func SetClock(s *Service, now func() time.Time) { s.now = now } diff --git a/internal/shopping/shopping.go b/internal/shopping/shopping.go index 1ed9097..789c832 100644 --- a/internal/shopping/shopping.go +++ b/internal/shopping/shopping.go @@ -8,6 +8,10 @@ // (see Generate). The one model call is the Supply run's title, written by // archive_title in a KindTitleSupplyRun job when the list is generated // untitled (see Generate). +// +// Freeze moves a week's Supply run into the Supply archive once its week +// has ended, and Archive reads the archive back as posts grouped by week, +// month or year. package shopping import ( @@ -19,6 +23,7 @@ import ( "fmt" "log/slog" "strings" + "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" @@ -70,6 +75,14 @@ var ErrListNotFound = errors.New("the week has no Supply run") // ErrItemNotFound is a change to an item on no Supply run. var ErrItemNotFound = errors.New("no Supply run has that item") +// ErrWeekNotEnded is a freeze of a Supply run whose week has not ended in +// the household's time zone. +var ErrWeekNotEnded = errors.New("the week has not ended") + +// ErrInvalidPeriod is an Archive grouping other than ByWeek, ByMonth and +// ByYear. +var ErrInvalidPeriod = errors.New("by must be week, month or year") + // Titler runs archive_title on in; scope names the week, its plan revision // and its dinners. // In production it is skills.TitleArchive with the household's @@ -82,6 +95,8 @@ type Service struct { title Titler titleJob *jobs.Kind[TitleJob] logger *slog.Logger + // now is the clock weeks are ended and current on; tests replace it. + now func() time.Time } // NewService returns a service over st that titles Supply runs with title @@ -98,7 +113,7 @@ func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog case logger == nil: return nil, errors.New("shopping: nil logger") } - s := &Service{st: st, title: title, logger: logger} + s := &Service{st: st, title: title, logger: logger, now: time.Now} kind, err := jobs.Register(runner, KindTitleSupplyRun, s.runTitleJob, ReasonTitleFailed) if err != nil { return nil, err @@ -423,13 +438,18 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, e if err != nil { return List{}, fmt.Errorf("shopping: read list: %w", err) } + return readList(ctx, q, list) +} + +// readList reads the groups and items of list through q. +func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, error) { rows, err := q.ListShoppingGroupDinners(ctx, list.ID) if err != nil { - return List{}, fmt.Errorf("shopping: read groups: %w", err) + return List{}, fmt.Errorf("shopping: read groups of %s: %w", list.WeekStart, err) } items, err := q.ListShoppingItems(ctx, list.ID) if err != nil { - return List{}, fmt.Errorf("shopping: read items: %w", err) + return List{}, fmt.Errorf("shopping: read items of %s: %w", list.WeekStart, err) } byGroup := make(map[string][]Item, len(rows)) for _, r := range items { diff --git a/migrations/queries/shopping.sql b/migrations/queries/shopping.sql index 76782fe..56bf1c3 100644 --- a/migrations/queries/shopping.sql +++ b/migrations/queries/shopping.sql @@ -88,3 +88,17 @@ WHERE id = ?; UPDATE shopping_lists SET title = ?, updated_at = ? WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL; + +-- name: FreezeShoppingList :execrows +-- Freezes list id into the Supply archive at frozen_at, unless it is +-- frozen already. +UPDATE shopping_lists +SET status = 'archived', frozen_at = ?, updated_at = ? +WHERE id = ? AND frozen_at IS NULL; + +-- name: ListShoppingListsThrough :many +-- The Supply runs of the weeks starting on or before week_start, newest +-- week first. +SELECT * FROM shopping_lists +WHERE week_start <= ? +ORDER BY week_start DESC; -- 2.55.0 From 292f91707e7e8e5a1d568af4956e71101df845be Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 07:46:48 -0400 Subject: [PATCH 2/2] Make Freeze wait for in-flight Supply run title writes (Feature 17) The archive_title single-flight in the harness ended before a title job stored its title, so a freeze in that gap ran a second model call and, when it failed, froze the list untitled while the job's title was then rejected. A title job now holds the service's titling lock shared from reading the list to storing its title, and Freeze holds it exclusively from reading the list to freezing it: a freeze waits for in-flight titles to be stored or fail, and no title job calls the model while a freeze runs. Adds controlled tests for both start orders, each paused after archive_title returns and before its title is stored. Co-Authored-By: Claude Opus 5.5 --- internal/shopping/archive.go | 14 ++-- internal/shopping/archive_test.go | 118 +++++++++++++++++++++++++++++ internal/shopping/shopping.go | 22 +++++- internal/shopping/shopping_test.go | 9 ++- 4 files changed, 154 insertions(+), 9 deletions(-) diff --git a/internal/shopping/archive.go b/internal/shopping/archive.go index ba4eb17..c87ad63 100644 --- a/internal/shopping/archive.go +++ b/internal/shopping/archive.go @@ -32,10 +32,12 @@ const ( // Generate titles it, so its post in the Supply archive gets a title when // the AI can write one. Titling never fails the freeze: a title that // cannot be written is logged and the list is frozen untitled, when its -// week label stands in for the title for good. A title job still in -// flight for the same plan revision and dinners shares Freeze's -// archive_title run (see harness.Run), and whichever stores the title -// first wins; a job whose write comes after the freeze stores nothing. +// week label stands in for the title for good. Freeze first waits for the +// title jobs in flight to store their titles or fail, and holds off new +// ones until the list is frozen, so a title a job's archive_title run +// wrote is stored before the freeze, and the freeze runs archive_title +// 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 { if _, err := household.WeekDays(weekStart); err != nil { return fmt.Errorf("shopping: freeze: %w", err) @@ -48,6 +50,8 @@ func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error { if weekStart.String() >= thisWeek.String() { return fmt.Errorf("shopping: freeze %s: %w", weekStart, ErrWeekNotEnded) } + s.titling.Lock() + defer s.titling.Unlock() list, err := q.GetShoppingList(ctx, weekStart.String()) if errors.Is(err, sql.ErrNoRows) { return ErrListNotFound @@ -63,7 +67,7 @@ func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error { if err != nil { return err } - if _, err := s.writeTitle(ctx, p); err != nil { + if _, err := s.storeTitle(ctx, p); err != nil { s.logger.WarnContext(ctx, "Supply run frozen untitled; the Supply archive shows its week label", "week", list.WeekStart, "list", list.ID, "error", err) } diff --git a/internal/shopping/archive_test.go b/internal/shopping/archive_test.go index 43f7a87..00fff92 100644 --- a/internal/shopping/archive_test.go +++ b/internal/shopping/archive_test.go @@ -1,11 +1,14 @@ package shopping_test import ( + "context" "database/sql" "errors" "reflect" "strconv" "strings" + "sync" + "sync/atomic" "testing" "time" @@ -210,6 +213,121 @@ func TestFreezeTitlesUntitledList(t *testing.T) { wantTitle(t, "stored title after the job", e.storedTitle(t), "Tacos then Chili") } +// pauseTitle makes the env's first archive_title run pause once it has +// returned, before its title is stored, until the returned release is +// called or the test ends; paused is closed as it pauses. +func (e *env) pauseTitle(t *testing.T) (paused <-chan struct{}, release func()) { + t.Helper() + p, r := make(chan struct{}), make(chan struct{}) + var first atomic.Bool + e.afterTitle = func() { + if first.CompareAndSwap(false, true) { + close(p) + select { + case <-r: + case <-t.Context().Done(): + } + } + } + var released sync.Once + return p, func() { released.Do(func() { close(r) }) } +} + +// waitClosed fails t unless c is closed within 10 seconds. +func waitClosed(t *testing.T, what string, c <-chan struct{}) { + t.Helper() + select { + case <-c: + case <-time.After(10 * time.Second): + t.Fatalf("timed out waiting for %s", what) + } +} + +// TestFreezeWaitsForTitleJob: a freeze that starts while a title job's +// archive_title run has returned but its title is not yet stored waits for +// the job to store it, freezes the list with that title, and asks the +// model nothing itself. +func TestFreezeWaitsForTitleJob(t *testing.T) { + e := newEnv(t) + e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z") + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) + e.plan(t, "Beef Chili") + e.model.QueueGenerate(titled("Chili Night")) + paused, release := e.pauseTitle(t) + e.start(t) + g := e.generate(t) + waitClosed(t, "the title job's archive_title run", paused) + + froze := make(chan error, 1) + go func() { froze <- e.svc.Freeze(context.Background(), week) }() + var err error + select { + case err = <-froze: + t.Errorf("Freeze = %v before the title job stored its title, want it to wait", err) + release() + case <-time.After(time.Second): + release() + err = <-froze + } + if err != nil { + t.Fatal(err) + } + + wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "") + wantTitle(t, "stored title", e.storedTitle(t), "Chili Night") + if status, _ := e.frozenState(t, week); status != shopping.StatusArchived { + t.Errorf("status = %q, want archived", status) + } + if n := len(e.model.Requests()); n != 1 { + t.Errorf("%d model calls, want 1: the freeze waits for the job's title", n) + } +} + +// TestTitleJobWaitsForFreeze: a title job that starts while a freeze's +// archive_title run has returned but its title is not yet stored waits for +// the freeze, then finds the list titled and asks the model nothing. +func TestTitleJobWaitsForFreeze(t *testing.T) { + e := newEnv(t) + e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z") + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) + e.plan(t, "Beef Chili") + e.model.QueueGenerate(titled("Chili Night")) + g := e.generate(t) // the runner is not started: its title job waits + paused, release := e.pauseTitle(t) + + froze := make(chan error, 1) + go func() { froze <- e.svc.Freeze(context.Background(), week) }() + waitClosed(t, "the freeze's archive_title run", paused) + e.start(t) + deadline := time.Now().Add(10 * time.Second) + for { + job, err := e.runner.Get(t.Context(), *g.TitleJobID) + if err != nil { + t.Fatal(err) + } + if job.Status == jobs.StatusRunning { + break + } + if job.Status != jobs.StatusQueued || time.Now().After(deadline) { + t.Fatalf("title job %s, want it running while the freeze is paused", job.Status) + } + time.Sleep(5 * time.Millisecond) + } + release() + if err := <-froze; err != nil { + t.Fatal(err) + } + + wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "") + wantTitle(t, "stored title", e.storedTitle(t), "Chili Night") + if status, _ := e.frozenState(t, week); status != shopping.StatusArchived { + t.Errorf("status = %q, want archived", status) + } + if n := len(e.model.Requests()); n != 1 { + t.Errorf("%d model calls, want 1: the job waits for the freeze's title", n) + } +} + // TestFreezeUntitledWhenTitleFails: when archive_title cannot title the // list, it is frozen untitled all the same, the failure is logged, and the // Supply archive shows its week label for good. diff --git a/internal/shopping/shopping.go b/internal/shopping/shopping.go index 789c832..5a5451b 100644 --- a/internal/shopping/shopping.go +++ b/internal/shopping/shopping.go @@ -23,6 +23,7 @@ import ( "fmt" "log/slog" "strings" + "sync" "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" @@ -97,6 +98,12 @@ type Service struct { logger *slog.Logger // now is the clock weeks are ended and current on; tests replace it. now func() time.Time + // titling orders the title writes against Freeze. A title job holds it + // shared from reading the list to storing its title, and Freeze holds + // it exclusively from reading the list to freezing it, so a freeze + // waits for the title jobs in flight to store their titles or fail, + // and no title job runs archive_title while a freeze is under way. + titling sync.RWMutex } // NewService returns a service over st that titles Supply runs with title @@ -360,10 +367,19 @@ func (s *Service) runTitleJob(ctx context.Context, p TitleJob, _ func(titleResul return titleResult{ListID: p.ListID, Title: title}, nil } -// writeTitle runs archive_title for p's Supply run and stores its title. -// When the list was titled by another job, before this one ran or -// meanwhile, that title stands and is returned. +// writeTitle runs archive_title for p's Supply run and stores its title, +// as storeTitle does, while no freeze is under way: it waits for one to +// end first, and a freeze waits for it to end. func (s *Service) writeTitle(ctx context.Context, p TitleJob) (string, error) { + s.titling.RLock() + defer s.titling.RUnlock() + return s.storeTitle(ctx, p) +} + +// storeTitle runs archive_title for p's Supply run and stores its title. +// When the list was titled by another job, before this one ran or +// meanwhile, that title stands and is returned. The caller holds titling. +func (s *Service) storeTitle(ctx context.Context, p TitleJob) (string, error) { q := db.New(s.st.Writer) title, err := s.currentTitle(ctx, q, p) if err != nil || title != "" { diff --git a/internal/shopping/shopping_test.go b/internal/shopping/shopping_test.go index a746343..a38f37e 100644 --- a/internal/shopping/shopping_test.go +++ b/internal/shopping/shopping_test.go @@ -54,6 +54,9 @@ type env struct { model *llm.Fake // beforeTitle, when set, runs as archive_title is asked for a title. beforeTitle func() + // afterTitle, when set, runs once archive_title has returned, before + // its title is stored. + afterTitle func() // beforeModel, when set, runs as the model is sent a request. beforeModel func(llm.Request) // log holds what the service logged. @@ -108,7 +111,11 @@ func newEnv(t *testing.T) *env { if e.beforeTitle != nil { e.beforeTitle() } - return skills.TitleArchive(ctx, cfg, scope, in) + out, err := skills.TitleArchive(ctx, cfg, scope, in) + if e.afterTitle != nil { + e.afterTitle() + } + return out, err } logger := slog.New(slog.NewTextHandler(e.log, nil)) e.runner, err = jobs.NewRunner(st, logger, 2) -- 2.55.0