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..c87ad63 --- /dev/null +++ b/internal/shopping/archive.go @@ -0,0 +1,232 @@ +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. 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) + } + 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) + } + s.titling.Lock() + defer s.titling.Unlock() + 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.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) + } + } + // 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..00fff92 --- /dev/null +++ b/internal/shopping/archive_test.go @@ -0,0 +1,477 @@ +package shopping_test + +import ( + "context" + "database/sql" + "errors" + "reflect" + "strconv" + "strings" + "sync" + "sync/atomic" + "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") +} + +// 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. +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..5a5451b 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,8 @@ import ( "fmt" "log/slog" "strings" + "sync" + "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" @@ -70,6 +76,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 +96,14 @@ 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 + // 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 @@ -98,7 +120,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 @@ -345,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 != "" { @@ -423,13 +454,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/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) 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;