From 1862f450db872dc25c420e4d9dcfffbc4bc16c9a Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Wed, 7 Oct 2026 16:53:23 -0400 Subject: [PATCH] Feature 14: Weekly Plan Builder (#121) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- cmd/server/main.go | 23 +- docs/ARCHITECTURE.md | 62 +- internal/ai/harness/context.go | 10 +- internal/ai/skills/draft_week.go | 328 ++++ internal/ai/skills/draft_week_test.go | 324 ++++ internal/ai/skills/prompts/checksums.txt | 1 + internal/ai/skills/prompts/draft_week.v1.tmpl | 21 + internal/ai/skills/suggest_side_quests.go | 5 +- .../testdata/draft_week_valid.system.golden | 80 + .../testdata/draft_week_valid.user.golden | 1 + internal/db/household_context.sql.go | 22 +- internal/db/models.go | 2 + internal/db/plans.sql.go | 355 ++++ internal/db/querier.go | 69 +- internal/db/rotation_reviews.sql.go | 257 +++ internal/db/suggestions.sql.go | 24 +- internal/jobs/jobs.go | 38 +- internal/jobs/jobs_test.go | 36 + internal/plan/draft.go | 229 +++ internal/plan/plan.go | 445 +++++ internal/plan/plan_test.go | 946 +++++++++++ internal/plan/rotation.go | 223 +++ internal/plan/rotation_test.go | 379 +++++ internal/server/aisettings_test.go | 2 +- internal/server/dinners_test.go | 2 +- internal/server/jobs.go | 6 +- internal/server/login_test.go | 2 +- internal/server/media_test.go | 2 +- internal/server/origin_test.go | 2 +- internal/server/plans.go | 137 ++ internal/server/plans_test.go | 326 ++++ internal/server/recipesearch_test.go | 2 +- internal/server/rotation.go | 87 + internal/server/rotation_test.go | 147 ++ internal/server/server.go | 16 +- internal/server/server_test.go | 46 +- internal/server/settings_test.go | 2 +- internal/server/suggestions_test.go | 2 +- internal/store/migrate_test.go | 93 +- internal/suggest/batch.go | 18 +- internal/suggest/suggest_test.go | 71 +- migrations/0013_week_plan_revision.sql | 6 + migrations/0014_rotation_review_name.sql | 43 + migrations/queries/household_context.sql | 18 +- migrations/queries/plans.sql | 86 + migrations/queries/rotation_reviews.sql | 66 + migrations/queries/suggestions.sql | 20 +- web/src/App.test.tsx | 37 +- web/src/App.tsx | 2 + web/src/api/client.test.ts | 10 + web/src/api/client.ts | 20 +- web/src/api/index.ts | 2 +- web/src/discover/QuestLogSheet.tsx | 2 +- web/src/discover/api.ts | 14 +- web/src/plan/PlanPage.module.css | 589 +++++++ web/src/plan/PlanPage.test.tsx | 1429 +++++++++++++++++ web/src/plan/PlanPage.tsx | 599 +++++++ web/src/plan/PlanPanel.module.css | 223 +++ web/src/plan/PlanPanel.tsx | 210 +++ web/src/plan/SwapSheet.module.css | 57 + web/src/plan/SwapSheet.tsx | 129 ++ web/src/plan/api.test.tsx | 205 +++ web/src/plan/api.ts | 276 ++++ web/src/plan/drag.module.css | 12 + web/src/plan/drag.tsx | 353 ++++ web/src/plan/week.test.ts | 70 + web/src/plan/week.ts | 110 ++ web/src/ui/Tag.module.css | 5 + web/src/ui/Tag.test.tsx | 5 + web/src/ui/Tag.tsx | 5 +- 70 files changed, 9332 insertions(+), 114 deletions(-) create mode 100644 internal/ai/skills/draft_week.go create mode 100644 internal/ai/skills/draft_week_test.go create mode 100644 internal/ai/skills/prompts/draft_week.v1.tmpl create mode 100644 internal/ai/skills/testdata/draft_week_valid.system.golden create mode 100644 internal/ai/skills/testdata/draft_week_valid.user.golden create mode 100644 internal/db/plans.sql.go create mode 100644 internal/db/rotation_reviews.sql.go create mode 100644 internal/plan/draft.go create mode 100644 internal/plan/plan.go create mode 100644 internal/plan/plan_test.go create mode 100644 internal/plan/rotation.go create mode 100644 internal/plan/rotation_test.go create mode 100644 internal/server/plans.go create mode 100644 internal/server/plans_test.go create mode 100644 internal/server/rotation.go create mode 100644 internal/server/rotation_test.go create mode 100644 migrations/0013_week_plan_revision.sql create mode 100644 migrations/0014_rotation_review_name.sql create mode 100644 migrations/queries/plans.sql create mode 100644 migrations/queries/rotation_reviews.sql create mode 100644 web/src/plan/PlanPage.module.css create mode 100644 web/src/plan/PlanPage.test.tsx create mode 100644 web/src/plan/PlanPage.tsx create mode 100644 web/src/plan/PlanPanel.module.css create mode 100644 web/src/plan/PlanPanel.tsx create mode 100644 web/src/plan/SwapSheet.module.css create mode 100644 web/src/plan/SwapSheet.tsx create mode 100644 web/src/plan/api.test.tsx create mode 100644 web/src/plan/api.ts create mode 100644 web/src/plan/drag.module.css create mode 100644 web/src/plan/drag.tsx create mode 100644 web/src/plan/week.test.ts create mode 100644 web/src/plan/week.ts diff --git a/cmd/server/main.go b/cmd/server/main.go index ca0834e..f5e4efb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,6 +23,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" "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/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server" @@ -112,6 +113,11 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } + planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, logger) + if err != nil { + return err + } + sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) if err != nil { return err @@ -128,7 +134,7 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, runner, photos, frontend.Assets()) + router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, runner, photos, frontend.Assets()) if err != nil { return err } @@ -253,7 +259,8 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds // registerJobKinds registers the framework's job kinds on runner. A feature // service registers its own kinds when it is built, before the runner // starts (dinners.NewService registers dinners.KindGroundDinner and -// suggest.NewService suggest.KindSuggestionBatch). +// suggest.NewService suggest.KindSuggestionBatch, plan.NewService +// plan.KindDraftWeek). func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) { builtins, err := jobs.RegisterBuiltins(runner) if err != nil { @@ -286,6 +293,18 @@ func sideQuests(ai *aisettings.Service, guard *harness.Guard) suggest.Suggester } } +// weekDrafter runs draft_week with the AI settings current at each run, +// sharing guard. +func weekDrafter(ai *aisettings.Service, guard *harness.Guard) plan.Drafter { + return func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) { + cfg, err := ai.HarnessConfig(ctx, guard) + if err != nil { + return skills.Week{}, err + } + return skills.DraftWeek(ctx, cfg, scope, in) + } +} + // logListening logs the startup line. Only the public URL's origin is logged // so no credential-bearing URL component can reach the log. func logListening(logger *slog.Logger, cfg config.Config) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b566ef0..ffee938 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -99,9 +99,9 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`). | `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised whenever a dinner is renamed, so only the latest `ground_dinner` job may write it and no candidate offered before the rename grounds it), updated_by. | | `suggestion_batches` | trigger, status, ai_run_id. | | `suggestions` | dinner_id, batch_id, name (the name the dish was suggested under, kept whatever its dinner is later renamed, so a Pass stays an immutable record every later batch dedupes against), reason, cited_regular_ids (JSON), status, decided_at. | -| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. | +| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at, revision (raised by every swap and stored draft). | | `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. | -| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), decided_at, updated_by. | +| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. | | `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. | | `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. | | `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. | @@ -334,6 +334,64 @@ automatic** (D-021). the decision's own transaction, so a decision is never recorded without the replenish it calls for; when they cannot be written, neither is the decision, and a retry decides again. +- Weekly plans (`plan.Service`). `{weekStart}` is a household-local Monday + (`YYYY-MM-DD`); any other value is 400 `invalid_week`. `GET + /api/v1/plans/{weekStart}` returns `{id, week_start, week_label, status, + ai_note, calendar_synced_at, updated_at, days}`, or 404 when the week has + no plan; `days` are the seven days Monday first, each `{date, dinner: {id, + name, photo, minutes, method, origin}, is_new, start_by}`. `is_new` means + the dinner was planned from the Quest log; `start_by` is the household's + dinner time on that date minus `minutes_total`, computed on read, for a + crock-pot dinner with known minutes, else null. `POST + /api/v1/plans/{weekStart}/draft` takes `{confirm?}` and answers 202 + `{jobId}` for a `draft_week` job, or for the one already queued or + running for the week, so a week has at most one draft in flight. A + confirmed draft while the week's job was asked for without confirm is + 409 `draft_running` and enqueues nothing. The job runs the `draft_week` skill on every Regular + and every Quest log dinner no other plan for the current or a later week + has, with the household context and last week's dinners, and stores the + week: a new plan is `draft`; a redraft keeps each day's + `calendar_event_id`, and a `synced` or `changed` plan becomes `changed`. + Drafting over a `synced` or `changed` plan needs `{"confirm": true}`, else + 409 `confirm_required`; a plan synced while an unconfirmed draft runs is + left as it is and the job fails `confirm_required`. The job reads the + plan's revision before draft_week runs; if the plan was edited (a swap, + or another draft stored) by the time the draft is stored, the plan is + left as it is and the job fails `plan_changed`. The pool is read again + in the same transaction that stores the draft; if a dinner the draft + planned has left it meanwhile (archived, `tried`, or a Quest log dinner + stored on another current or later week's plan, as by a draft of + another week), nothing is stored and the job fails `pool_changed`, + which drafting again resolves. A pool under seven + dinners fails the job `not_enough_dinners`, draft_week failing + `ai_failed`. `PUT /api/v1/plans/{weekStart}/days/{date}` takes + `{dinner_id}`, a Regular or Quest log dinner, swaps the day, recomputes + `is_new`, moves a `synced` plan to `changed`, and answers 200 with the + plan. A date outside the week is 400 `invalid_date`, an unknown dinner or + a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and + a dinner already on another day of the week 409 `dinner_already_planned`. +- Rotation check (`plan.Service`, D-021). `MarkTried(date)`, which the + `rotation_check` schedule calls for the household-local day just past, + moves every `quest_log` dinner planned on that date, on a plan of any + status, to `tried` and opens a `pending` `rotation_reviews` row for it; + a dinner already checked for that date is left as it is, so it is + idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the + pending checks whose dinner is still tried, oldest `plan_date` first, + each `{dinner: {id, name, photo}, plan_date}`. `POST + /api/v1/dinners/{id}/rotation-review` takes `{decision}`: `keep` makes + the dinner a Regular, `drop` ("Not for us") archives it and `not_made` + ("We didn't make it") returns it to the Quest log; its origin is kept. + The check is closed with `decided_at` and the session's `updated_by`, + and the answer is `{dinner, plan_date, decision, dinner_status, + decided_at, updated_by}`. Any other decision is 400 `invalid_decision`, + an unknown dinner or a dinner never checked 404 `not_found`, a dinner + whose checks are all decided (including a second decision) 409 + `no_pending_review`, and one no longer tried 409 `dinner_not_tried`. + Keep is the only path from tried to Regular. A drop records the + dinner's name on the check (`rotation_reviews.name`), and that name + joins the passed names (`ListPassedNames`) the Side quest dedupe and + household context use, so it is never suggested again, even after the + dinner is renamed. - `/health` — liveness. - Everything else serves the embedded PWA with SPA fallback. diff --git a/internal/ai/harness/context.go b/internal/ai/harness/context.go index eae19d6..259b7e7 100644 --- a/internal/ai/harness/context.go +++ b/internal/ai/harness/context.go @@ -15,8 +15,8 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" ) -// PassedLimit is how many of the most recently passed Side quests the -// household context holds. +// PassedLimit is how many of the most recently passed names (Side quests +// passed and dinners a Rotation check dropped) the household context holds. const PassedLimit = 60 // PlanWeeks is how many ISO weeks of plans, ending with the current week, @@ -34,8 +34,8 @@ type HouseholdContext struct { Regulars []RegularDinner // QuestLog holds the Quest log dinner names, in name order. QuestLog []string - // Passed holds the names of the most recently passed Side quests, - // oldest first. + // Passed holds the most recently passed names, oldest first: Side + // quests passed and dinners a Rotation check dropped ("Not for us"). Passed []string // Plans are the planned dinners of the last PlanWeeks weeks, oldest // first. @@ -137,7 +137,7 @@ func readContext(ctx context.Context, st *store.Store, now time.Time) (h Househo h.QuestLog = append(h.QuestLog, d.Name) } - passed, err := q.ListRecentDeclinedNames(ctx, PassedLimit) + passed, err := q.ListRecentPassedNames(ctx, PassedLimit) if err != nil { return h, fmt.Errorf("read passed Side quests: %w", err) } diff --git a/internal/ai/skills/draft_week.go b/internal/ai/skills/draft_week.go new file mode 100644 index 0000000..b529842 --- /dev/null +++ b/internal/ai/skills/draft_week.go @@ -0,0 +1,328 @@ +package skills + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + "time" + "unicode/utf8" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + // Aliased: the package tests name a fixture household. + hh "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// NoteLimit is the most characters a drafted week's note may have. +const NoteLimit = 160 + +// WeekLength is how many dinners a drafted week holds, one a day from +// Monday to Sunday. +const WeekLength = 7 + +// Candidate kinds: a Regular or a Quest log dinner. +const ( + KindRegular = "regular" + KindQuestLog = "quest_log" +) + +// WeekRequest is what draft_week reads. +type WeekRequest struct { + // Context is the household context (see harness.BuildContext). + Context harness.HouseholdContext + // WeekStart is the Monday of the week to draft, household-local. + WeekStart store.LocalDate + // Pool holds the dinners the week may be drafted from. It must hold at + // least WeekLength dinners, each once. + Pool []Candidate + // LastWeek holds the IDs of the dinners planned in the week before + // WeekStart. They need not be in Pool. + LastWeek []string + // DinnerTime is the household's dinner time, "HH:MM", from which a + // crock-pot dinner's StartBy is computed. + DinnerTime string +} + +// Candidate is one dinner of the pool. +type Candidate struct { + ID string + Name string + // Kind is KindRegular or KindQuestLog. + Kind string + // Method is one of stovetop, oven, crockpot, grill and other, or "" + // when unknown. + Method string + // Minutes from prep to table, or 0 when unknown. + Minutes int +} + +// NotEnoughDinnersError is a pool too small to fill a week. DraftWeek +// returns it before any LLM call. +type NotEnoughDinnersError struct { + // Pool is how many dinners the pool holds. + Pool int +} + +func (e *NotEnoughDinnersError) Error() string { + return fmt.Sprintf("not enough dinners: the pool holds %d, a week needs %d", e.Pool, WeekLength) +} + +// Week is draft_week's output. +type Week struct { + // Days are the week's dinners, Monday to Sunday. + Days []WeekDay `json:"days"` + // Note is a short word on the week, at most NoteLimit characters. It + // may be empty. + Note string `json:"note"` + // Warnings say what the draft did that the rules discourage but allow, + // such as repeating last week's dinners. It is never part of the + // model's reply. + Warnings []string `json:"-"` +} + +// WeekDay is one day's dinner. +type WeekDay struct { + // Date is the household-local date, YYYY-MM-DD. + Date string `json:"date"` + // DinnerID is the ID of a dinner of the pool. + DinnerID string `json:"dinner_id"` + // IsNew is true for a Quest log dinner. It is computed from the pool, + // never part of the model's reply. + IsNew bool `json:"-"` + // StartBy is when a crock-pot dinner must start to be ready at the + // household's dinner time; it is zero for any other dinner and for a + // crock-pot dinner whose minutes are unknown. It is computed, never + // part of the model's reply. + StartBy time.Time `json:"-"` +} + +// DraftWeek runs draft_week v1 on in and returns a week of seven dinners +// from the pool, one a day from Monday to Sunday, none twice. A pool of +// fewer than WeekLength dinners fails with *NotEnoughDinnersError before +// any LLM call, and any other invalid request fails before it too. scope +// names the week (see harness.Run). +func DraftWeek(ctx context.Context, cfg harness.Config, scope string, in WeekRequest) (Week, error) { + s, err := newDraftWeek(in) + if err != nil { + return Week{}, err + } + return harness.Run(ctx, cfg, s, scope, in) +} + +// draftWeek is the skill. It holds its input, the week's dates and the +// pool by ID because validation and post-processing compare the output +// with them. +type draftWeek struct { + in WeekRequest + dates [WeekLength]store.LocalDate + pool map[string]Candidate + zone *time.Location +} + +var _ harness.Skill[WeekRequest, Week] = draftWeek{} + +// newDraftWeek checks in and returns the skill for it. +func newDraftWeek(in WeekRequest) (draftWeek, error) { + pool := map[string]Candidate{} + for i, c := range in.Pool { + if err := c.check(); err != nil { + return draftWeek{}, fmt.Errorf("draft_week: pool dinner %d: %w", i+1, err) + } + if _, dup := pool[c.ID]; dup { + return draftWeek{}, fmt.Errorf("draft_week: pool dinner %d: id %q is in the pool twice", i+1, c.ID) + } + pool[c.ID] = c + } + if len(pool) < WeekLength { + return draftWeek{}, &NotEnoughDinnersError{Pool: len(pool)} + } + dates, err := hh.WeekDays(in.WeekStart) + if err != nil { + return draftWeek{}, fmt.Errorf("draft_week: %w", err) + } + zone, err := hh.LoadZone(in.Context.Timezone) + if err != nil { + return draftWeek{}, fmt.Errorf("draft_week: %w", err) + } + // The dinner time is checked here so a bad one fails before the LLM + // call rather than in PostProcess. + if _, err := hh.LocalTime(zone, in.WeekStart, in.DinnerTime); err != nil { + return draftWeek{}, fmt.Errorf("draft_week: dinner time: %w", err) + } + return draftWeek{in: in, dates: dates, pool: pool, zone: zone}, nil +} + +// check reports a candidate the pool cannot hold. +func (c Candidate) check() error { + switch { + case strings.TrimSpace(c.ID) == "" || strings.ContainsAny(c.ID, "\r\n"): + return fmt.Errorf("id %q is empty or has a line break", c.ID) + case strings.TrimSpace(c.Name) == "": + return fmt.Errorf("%s has an empty name", c.ID) + case c.Kind != KindRegular && c.Kind != KindQuestLog: + return fmt.Errorf("%s kind %q is not %s or %s", c.ID, c.Kind, KindRegular, KindQuestLog) + case c.Method != "" && !slices.Contains(methods, c.Method): + return fmt.Errorf("%s method %q is not one of %s", c.ID, c.Method, strings.Join(methods, ", ")) + case c.Minutes < 0: + return fmt.Errorf("%s minutes %d is negative", c.ID, c.Minutes) + } + return nil +} + +func (draftWeek) Name() string { return "draft_week" } + +func (draftWeek) Version() int { return 1 } + +func (draftWeek) Timeout() time.Duration { return harness.DefaultTimeout } + +// weekPrompt is the template data of draft_week.v1.tmpl. +type weekPrompt struct { + // Days are the week's dates, Monday to Sunday, e.g. "Mon 2026-10-12". + Days []string +} + +func (s draftWeek) PromptInput(in WeekRequest) (harness.PromptInput, error) { + var days []string + for _, d := range s.dates { + y, m, day := d.Date() + days = append(days, time.Date(y, m, day, 0, 0, 0, 0, time.UTC).Format("Mon")+" "+d.String()) + } + var pool []string + for _, c := range in.Pool { + pool = append(pool, c.line(slices.Contains(in.LastWeek, c.ID))) + } + sections := append(in.Context.Sections(), harness.ContextSection{Title: "Candidate pool", Lines: pool}) + return harness.PromptInput{Data: weekPrompt{Days: days}, Context: sections}, nil +} + +// line renders c as `id "ID": Name (Regular, method, N min, planned last +// week)`, leaving out what is unknown. +func (c Candidate) line(lastWeek bool) string { + attrs := []string{"Regular"} + if c.Kind == KindQuestLog { + attrs = []string{"Quest log"} + } + if c.Method != "" { + attrs = append(attrs, c.Method) + } + if c.Minutes > 0 { + attrs = append(attrs, fmt.Sprintf("%d min", c.Minutes)) + } + if lastWeek { + attrs = append(attrs, "planned last week") + } + return fmt.Sprintf("id %q: %s (%s)", c.ID, oneLine(c.Name), strings.Join(attrs, ", ")) +} + +func (draftWeek) Schema() llm.JSONSchema { + return llm.JSONSchema{Name: "draft_week", Schema: json.RawMessage(`{ + "type": "object", + "properties": { + "days": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": {"type": "string"}, + "dinner_id": {"type": "string"} + }, + "required": ["date", "dinner_id"], + "additionalProperties": false + } + }, + "note": {"type": "string"} + }, + "required": ["days", "note"], + "additionalProperties": false + }`)} +} + +// UnmarshalJSON decodes a day, requiring each of its fields as the schema +// does. +func (d *WeekDay) UnmarshalJSON(data []byte) error { + if err := requireFields(data, nil, "date", "dinner_id"); err != nil { + return fmt.Errorf("day %s", err) + } + type plain WeekDay + return json.Unmarshal(data, (*plain)(d)) +} + +// UnmarshalJSON decodes the output, requiring each of its fields as the +// schema does. +func (w *Week) UnmarshalJSON(data []byte) error { + if err := requireFields(data, nil, "days", "note"); err != nil { + return fmt.Errorf("output %s", err) + } + type plain Week + return json.Unmarshal(data, (*plain)(w)) +} + +// Validate finds every broken rule: a week that is not exactly the seven +// dates Monday to Sunday in order, a dinner not in the pool or planned +// twice, or a note over NoteLimit characters. +func (s draftWeek) Validate(out Week) []string { + var problems []string + if n := len(out.Days); n != WeekLength { + problems = append(problems, fmt.Sprintf("days has %d entries; give exactly %d, one for each date from %s to %s", + n, WeekLength, s.dates[0], s.dates[WeekLength-1])) + } + first := map[string]int{} // dinner ID -> first day planning it + for i, d := range out.Days { + if i < WeekLength && d.Date != s.dates[i].String() { + problems = append(problems, fmt.Sprintf("day %d date %q is not %s; give the dates %s to %s in order", + i+1, d.Date, s.dates[i], s.dates[0], s.dates[WeekLength-1])) + } + c, ok := s.pool[d.DinnerID] + if !ok { + problems = append(problems, fmt.Sprintf("day %d dinner_id %q is not in the candidate pool; use only ids from it", i+1, d.DinnerID)) + continue + } + if j, dup := first[d.DinnerID]; dup { + problems = append(problems, fmt.Sprintf("day %d dinner_id %q (%s) is also on day %d; plan each dinner at most once", + i+1, d.DinnerID, oneLine(c.Name), j+1)) + continue + } + first[d.DinnerID] = i + } + if n := utf8.RuneCountInString(out.Note); n > NoteLimit { + problems = append(problems, fmt.Sprintf("note has %d characters; keep it to at most %d", n, NoteLimit)) + } + return problems +} + +// PostProcess tidies the note, marks each Quest log dinner new, computes +// each crock-pot dinner's StartBy, and warns when the week repeats more of +// last week's dinners than the pool made necessary. +func (s draftWeek) PostProcess(out Week) (Week, error) { + result := Week{Note: oneLine(out.Note)} + var repeats []string + for i, d := range out.Days { + c := s.pool[d.DinnerID] + day := WeekDay{Date: d.Date, DinnerID: d.DinnerID, IsNew: c.Kind == KindQuestLog} + if c.Method == "crockpot" && c.Minutes > 0 { + dinner, err := hh.LocalTime(s.zone, s.dates[i], s.in.DinnerTime) + if err != nil { + return Week{}, err + } + day.StartBy = dinner.Add(-time.Duration(c.Minutes) * time.Minute) + } + if slices.Contains(s.in.LastWeek, d.DinnerID) { + repeats = append(repeats, oneLine(c.Name)) + } + result.Days = append(result.Days, day) + } + fresh := 0 + for id := range s.pool { + if !slices.Contains(s.in.LastWeek, id) { + fresh++ + } + } + if allowed := max(0, WeekLength-fresh); len(repeats) > allowed { + result.Warnings = append(result.Warnings, fmt.Sprintf("repeats %d of last week's dinners (%s) though the pool needed at most %d", + len(repeats), strings.Join(repeats, ", "), allowed)) + } + return result, nil +} diff --git a/internal/ai/skills/draft_week_test.go b/internal/ai/skills/draft_week_test.go new file mode 100644 index 0000000..f138148 --- /dev/null +++ b/internal/ai/skills/draft_week_test.go @@ -0,0 +1,324 @@ +package skills + +import ( + "context" + "errors" + "fmt" + "reflect" + "slices" + "strings" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" +) + +// weekOf asks for the week of Monday 2026-10-12 from a pool of eight, two +// of them planned last week. +var weekOf = WeekRequest{ + Context: harness.HouseholdContext{ + Today: date(2026, 10, 8), + Timezone: "America/New_York", + Regulars: []harness.RegularDinner{ + {Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240}, + {Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30, Favorite: true}, + }, + QuestLog: []string{"Pad Thai"}, + Passed: []string{}, + Plans: []harness.PlannedDinner{ + {Date: date(2026, 10, 5), Name: "Chicken Fajitas"}, + {Date: date(2026, 10, 6), Name: "Fish Tacos"}, + }, + }, + WeekStart: date(2026, 10, 12), + Pool: []Candidate{ + {ID: "chili", Name: "Beef Chili", Kind: KindRegular, Method: "crockpot", Minutes: 240}, + {ID: "fajitas", Name: "Chicken Fajitas", Kind: KindRegular, Method: "stovetop", Minutes: 30}, + {ID: "bolognese", Name: "Spaghetti Bolognese", Kind: KindRegular, Method: "stovetop", Minutes: 45}, + {ID: "salmon", Name: "Sheet Pan Salmon", Kind: KindRegular, Method: "oven"}, + {ID: "tacos", Name: "Fish Tacos", Kind: KindRegular, Minutes: 25}, + {ID: "padthai", Name: "Pad Thai", Kind: KindQuestLog, Method: "stovetop", Minutes: 35}, + {ID: "roast", Name: "Pot Roast", Kind: KindRegular, Method: "crockpot"}, + {ID: "shawarma", Name: "Chicken Shawarma Bowls", Kind: KindQuestLog, Method: "oven", Minutes: 40}, + }, + LastWeek: []string{"fajitas", "tacos", "gone"}, + DinnerTime: "17:30", +} + +// weekDates are the dates of weekOf, Monday to Sunday. +var weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"} + +// week renders a reply planning ids on dates, in order. +func week(note string, dates []string, ids ...string) string { + var days []string + for i, id := range ids { + days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], id)) + } + return fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note) +} + +// clean plans no dinner of last week. +var clean = []string{"chili", "padthai", "bolognese", "salmon", "shawarma", "roast", "fajitas"} + +// wantWeek is the output for a reply planning ids on weekDates with note. +func wantWeek(note string, ids ...string) Week { + newYork, err := time.LoadLocation("America/New_York") + if err != nil { + panic(err) + } + out := Week{Note: note} + for i, id := range ids { + day := WeekDay{Date: weekDates[i], DinnerID: id, IsNew: id == "padthai" || id == "shawarma"} + if id == "chili" { + y, m, d := date(2026, 10, 12+i).Date() + day.StartBy = time.Date(y, m, d, 13, 30, 0, 0, newYork) + } + out.Days = append(out.Days, day) + } + return out +} + +// draft runs DraftWeek on in with f and checks f was called exactly calls +// times, each at the household temperature with the draft_week schema. +func draft(t *testing.T, f *llm.Fake, in WeekRequest, calls int) (Week, []llm.Request, error) { + t.Helper() + out, err := DraftWeek(context.Background(), testConfig(t, f), "2026-W42", in) + reqs := f.Requests() + if len(reqs) != calls { + t.Fatalf("Generate called %d times, want %d", len(reqs), calls) + } + if g, _, _ := f.Remaining(); g != 0 { + t.Fatalf("%d scripted Generate results unused", g) + } + for i, req := range reqs { + if req.Temperature == nil || *req.Temperature != 0.7 { + t.Fatalf("request %d temperature = %v, want 0.7", i+1, req.Temperature) + } + if req.Schema == nil || req.Schema.Name != "draft_week" { + t.Fatalf("request %d schema = %v, want draft_week", i+1, req.Schema) + } + } + return out, reqs, err +} + +func assertWeek(t *testing.T, got, want Week) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Fatalf("output = %+v\nwant %+v", got, want) + } +} + +func TestDraftWeekValid(t *testing.T) { + var f llm.Fake + f.QueueGenerate(reply(week(" A cosy week,\nwith Pad Thai midweek. ", weekDates, clean...))) + out, reqs, err := draft(t, &f, weekOf, 1) + if err != nil { + t.Fatal(err) + } + assertWeek(t, out, wantWeek("A cosy week, with Pad Thai midweek.", clean...)) + if !out.Days[0].StartBy.Equal(time.Date(2026, 10, 12, 17, 30, 0, 0, time.UTC)) { + t.Errorf("Beef Chili StartBy = %v, want 13:30 EDT", out.Days[0].StartBy) + } + + system, user := reqs[0].System, reqs[0].Messages[0].Content + golden(t, "draft_week_valid.system.golden", system) + golden(t, "draft_week_valid.user.golden", user) + for _, part := range []string{ + "## Household context", + "### Candidate pool", + `- id "chili": Beef Chili (Regular, crockpot, 240 min)`, + `- id "fajitas": Chicken Fajitas (Regular, stovetop, 30 min, planned last week)`, + `- id "salmon": Sheet Pan Salmon (Regular, oven)`, + `- id "tacos": Fish Tacos (Regular, 25 min, planned last week)`, + `- id "padthai": Pad Thai (Quest log, stovetop, 35 min)`, + "## Output contract", + `"dinner_id"`, + } { + if !strings.Contains(system, part) { + t.Errorf("system prompt lacks %q", part) + } + } + if strings.Index(system, "### Candidate pool") > strings.Index(system, "## Output contract") { + t.Error("the output contract is not after the candidate pool") + } + want := "Draft the week of Mon 2026-10-12, Tue 2026-10-13, Wed 2026-10-14, Thu 2026-10-15, Fri 2026-10-16, Sat 2026-10-17, Sun 2026-10-18." + if user != want { + t.Errorf("user message = %q, want %q", user, want) + } +} + +// repaired checks a first reply is repaired once, quoting it and the +// problems wanted. +func repaired(t *testing.T, first string, want ...string) { + t.Helper() + var f llm.Fake + f.QueueGenerate(reply(first), reply(week("Fixed.", weekDates, clean...))) + out, reqs, err := draft(t, &f, weekOf, 2) + if err != nil { + t.Fatal(err) + } + assertWeek(t, out, wantWeek("Fixed.", clean...)) + repair := reqs[1].Messages + if len(repair) != 3 || repair[1].Content != first { + t.Fatalf("repair turn does not quote the first reply: %+v", repair) + } + for _, w := range want { + if !strings.Contains(repair[2].Content, w) { + t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, w) + } + } +} + +func TestDraftWeekUnknownIDRepaired(t *testing.T) { + ids := slices.Clone(clean) + ids[2] = "lasagna" + repaired(t, week("A week.", weekDates, ids...), + `day 3 dinner_id "lasagna" is not in the candidate pool; use only ids from it`) +} + +func TestDraftWeekDuplicateRepaired(t *testing.T) { + ids := slices.Clone(clean) + ids[5] = "chili" + repaired(t, week("A week.", weekDates, ids...), + `day 6 dinner_id "chili" (Beef Chili) is also on day 1; plan each dinner at most once`) +} + +func TestDraftWeekWrongDatesTwiceFails(t *testing.T) { + sundayFirst := append([]string{"2026-10-11"}, weekDates[:6]...) + var f llm.Fake + f.QueueGenerate(reply(week("A week.", sundayFirst, clean...)), reply(week("A week.", sundayFirst, clean...))) + out, _, err := draft(t, &f, weekOf, 2) + var bad *harness.BadOutputError + if !errors.As(err, &bad) || !errors.Is(err, llm.ErrBadOutput) { + t.Fatalf("err = %v, want a BadOutputError", err) + } + if !slices.Contains(bad.Repair, `day 1 date "2026-10-11" is not 2026-10-12; give the dates 2026-10-12 to 2026-10-18 in order`) { + t.Fatalf("repair problems = %q", bad.Repair) + } + if out.Days != nil { + t.Fatalf("output = %+v, want none", out) + } +} + +func TestDraftWeekNotEnoughDinners(t *testing.T) { + small := weekOf + small.Pool = slices.Clone(weekOf.Pool[:6]) + var f llm.Fake + _, _, err := draft(t, &f, small, 0) + var short *NotEnoughDinnersError + if !errors.As(err, &short) || short.Pool != 6 { + t.Fatalf("err = %v, want a NotEnoughDinnersError for a pool of 6", err) + } + if err.Error() != "not enough dinners: the pool holds 6, a week needs 7" { + t.Fatalf("err = %q", err) + } +} + +func TestDraftWeekLastWeekRepeats(t *testing.T) { + // The pool has six dinners not planned last week, so the week needs one + // of last week's two and may plan it without a warning. + one := []string{"chili", "padthai", "bolognese", "salmon", "shawarma", "roast", "fajitas"} + both := []string{"chili", "padthai", "bolognese", "tacos", "shawarma", "roast", "fajitas"} + for name, tc := range map[string]struct { + ids []string + warn []string + }{ + "needed repeat": {one, nil}, + "avoidable both": {both, []string{"repeats 2 of last week's dinners (Fish Tacos, Chicken Fajitas) though the pool needed at most 1"}}, + } { + t.Run(name, func(t *testing.T) { + var f llm.Fake + f.QueueGenerate(reply(week("A week.", weekDates, tc.ids...))) + out, _, err := draft(t, &f, weekOf, 1) + if err != nil { + t.Fatal(err) + } + want := wantWeek("A week.", tc.ids...) + want.Warnings = tc.warn + assertWeek(t, out, want) + }) + } +} + +func TestWeekValidate(t *testing.T) { + s, err := newDraftWeek(weekOf) + if err != nil { + t.Fatal(err) + } + reply := func(note string, ids ...string) Week { + var w Week + if err := w.UnmarshalJSON([]byte(week(note, weekDates, ids...))); err != nil { + t.Fatal(err) + } + return w + } + if p := s.Validate(reply(strings.Repeat("é", NoteLimit), clean...)); len(p) != 0 { + t.Fatalf("valid output has problems: %q", p) + } + for name, tc := range map[string]struct { + out Week + want []string + }{ + "empty": {Week{}, []string{"days has 0 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18"}}, + "six days": {reply("", clean[:6]...), []string{ + "days has 6 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18", + }}, + "eight days": { + Week{Days: append(reply("", clean...).Days, WeekDay{Date: "2026-10-19", DinnerID: "tacos"})}, + []string{"days has 8 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18"}, + }, + "long note": {reply(strings.Repeat("a", NoteLimit+1), clean...), []string{"note has 161 characters; keep it to at most 160"}}, + "dates out of order": { + Week{Days: []WeekDay{ + {weekDates[1], "chili", false, time.Time{}}, {weekDates[0], "padthai", false, time.Time{}}, + {weekDates[2], "bolognese", false, time.Time{}}, {weekDates[3], "salmon", false, time.Time{}}, + {weekDates[4], "shawarma", false, time.Time{}}, {weekDates[5], "roast", false, time.Time{}}, + {weekDates[6], "fajitas", false, time.Time{}}, + }}, + []string{ + `day 1 date "2026-10-13" is not 2026-10-12; give the dates 2026-10-12 to 2026-10-18 in order`, + `day 2 date "2026-10-12" is not 2026-10-13; give the dates 2026-10-12 to 2026-10-18 in order`, + }, + }, + } { + t.Run(name, func(t *testing.T) { + if got := s.Validate(tc.out); !slices.Equal(got, tc.want) { + t.Fatalf("problems =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(tc.want, "\n")) + } + }) + } +} + +func TestDraftWeekRequestRejected(t *testing.T) { + with := func(change func(*WeekRequest)) WeekRequest { + in := weekOf + in.Pool = slices.Clone(weekOf.Pool) + change(&in) + return in + } + for name, tc := range map[string]struct { + in WeekRequest + want string + }{ + "not a Monday": {with(func(in *WeekRequest) { in.WeekStart = date(2026, 10, 13) }), + "draft_week: household: week start 2026-10-13 is a Tuesday, want Monday"}, + "bad kind": {with(func(in *WeekRequest) { in.Pool[0].Kind = "suggestion" }), + `draft_week: pool dinner 1: chili kind "suggestion" is not regular or quest_log`}, + "bad method": {with(func(in *WeekRequest) { in.Pool[1].Method = "air fryer" }), + `draft_week: pool dinner 2: fajitas method "air fryer" is not one of stovetop, oven, crockpot, grill, other`}, + "empty id": {with(func(in *WeekRequest) { in.Pool[2].ID = " " }), + `draft_week: pool dinner 3: id " " is empty or has a line break`}, + "id twice": {with(func(in *WeekRequest) { in.Pool[3].ID = "chili" }), + `draft_week: pool dinner 4: id "chili" is in the pool twice`}, + "bad dinner time": {with(func(in *WeekRequest) { in.DinnerTime = "5:30pm" }), + `draft_week: dinner time: household: invalid time "5:30pm": want HH:MM`}, + } { + t.Run(name, func(t *testing.T) { + var f llm.Fake + if _, _, err := draft(t, &f, tc.in, 0); err == nil || err.Error() != tc.want { + t.Fatalf("err = %v, want %q", err, tc.want) + } + }) + } +} diff --git a/internal/ai/skills/prompts/checksums.txt b/internal/ai/skills/prompts/checksums.txt index baf7690..79ec5ec 100644 --- a/internal/ai/skills/prompts/checksums.txt +++ b/internal/ai/skills/prompts/checksums.txt @@ -4,3 +4,4 @@ echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a +draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32 diff --git a/internal/ai/skills/prompts/draft_week.v1.tmpl b/internal/ai/skills/prompts/draft_week.v1.tmpl new file mode 100644 index 0000000..ed4bb2e --- /dev/null +++ b/internal/ai/skills/prompts/draft_week.v1.tmpl @@ -0,0 +1,21 @@ +{{/* draft_week drafts the household's weekly plan: one dinner a day, Monday to Sunday, from a candidate pool. */}} +{{define "instructions" -}} +You draft the household's dinner plan for one week, Monday to Sunday: one dinner for each day, chosen from the candidate pool in the household context. + +Rules: +- Give exactly 7 days, one for each date of the week, in order from Monday to Sunday, each date written as YYYY-MM-DD. +- Use only dinners from the candidate pool, each by its id exactly as the pool writes it. +- Plan each dinner at most once in the week. +- Avoid the dinners marked "planned last week" whenever the pool has enough others. +- Mix the week: vary cuisines and methods, put quick dinners (a crockpot or 30 minutes or less) on weeknights, and include a Quest log dinner when the pool has one. + +Give: +- days: the 7 days, each with date and dinner_id. +- note: one sentence of at most 160 characters about the week. + +One valid day, for a pool with a dinner whose id is "d1" (an example only, not about this household): +{"date": "2026-01-05", "dinner_id": "d1"} +{{- end}} +{{define "user" -}} +Draft the week of {{range $i, $d := .Days}}{{if $i}}, {{end}}{{$d}}{{end}}. +{{- end}} diff --git a/internal/ai/skills/suggest_side_quests.go b/internal/ai/skills/suggest_side_quests.go index ac10af7..e35d0a2 100644 --- a/internal/ai/skills/suggest_side_quests.go +++ b/internal/ai/skills/suggest_side_quests.go @@ -30,8 +30,9 @@ type SideQuestsRequest struct { Needed int // Pending holds the names of the Side quests not yet tried or passed. Pending []string - // Declined holds the name of every passed Side quest, not only the - // recent ones Context holds. + // Declined holds every passed name — each passed Side quest and each + // dinner a Rotation check dropped — not only the recent ones Context + // holds. Declined []string } diff --git a/internal/ai/skills/testdata/draft_week_valid.system.golden b/internal/ai/skills/testdata/draft_week_valid.system.golden new file mode 100644 index 0000000..12229c1 --- /dev/null +++ b/internal/ai/skills/testdata/draft_week_valid.system.golden @@ -0,0 +1,80 @@ +## Instructions +You draft the household's dinner plan for one week, Monday to Sunday: one dinner for each day, chosen from the candidate pool in the household context. + +Rules: +- Give exactly 7 days, one for each date of the week, in order from Monday to Sunday, each date written as YYYY-MM-DD. +- Use only dinners from the candidate pool, each by its id exactly as the pool writes it. +- Plan each dinner at most once in the week. +- Avoid the dinners marked "planned last week" whenever the pool has enough others. +- Mix the week: vary cuisines and methods, put quick dinners (a crockpot or 30 minutes or less) on weeknights, and include a Quest log dinner when the pool has one. + +Give: +- days: the 7 days, each with date and dinner_id. +- note: one sentence of at most 160 characters about the week. + +One valid day, for a pool with a dinner whose id is "d1" (an example only, not about this household): +{"date": "2026-01-05", "dinner_id": "d1"} + +## Household context +### Today +- Thursday 2026-10-08 (America/New_York) + +### Regulars +- Beef Chili (Tex-Mex, crockpot, 240 min) +- Chicken Fajitas (Tex-Mex, stovetop, 30 min, favorite) + +### Quest log +- Pad Thai + +### Passed, oldest first +(none) + +### Recent plans +- 2026-10-05 Mon: Chicken Fajitas +- 2026-10-06 Tue: Fish Tacos + +### Candidate pool +- id "chili": Beef Chili (Regular, crockpot, 240 min) +- id "fajitas": Chicken Fajitas (Regular, stovetop, 30 min, planned last week) +- id "bolognese": Spaghetti Bolognese (Regular, stovetop, 45 min) +- id "salmon": Sheet Pan Salmon (Regular, oven) +- id "tacos": Fish Tacos (Regular, 25 min, planned last week) +- id "padthai": Pad Thai (Quest log, stovetop, 35 min) +- id "roast": Pot Roast (Regular, crockpot) +- id "shawarma": Chicken Shawarma Bowls (Quest log, oven, 40 min) + +## Output contract +This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "draft_week" below, and nothing else: no prose and no Markdown code fences. + +{ + "type": "object", + "properties": { + "days": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "dinner_id": { + "type": "string" + } + }, + "required": [ + "date", + "dinner_id" + ], + "additionalProperties": false + } + }, + "note": { + "type": "string" + } + }, + "required": [ + "days", + "note" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/internal/ai/skills/testdata/draft_week_valid.user.golden b/internal/ai/skills/testdata/draft_week_valid.user.golden new file mode 100644 index 0000000..9e14ded --- /dev/null +++ b/internal/ai/skills/testdata/draft_week_valid.user.golden @@ -0,0 +1 @@ +Draft the week of Mon 2026-10-12, Tue 2026-10-13, Wed 2026-10-14, Thu 2026-10-15, Fri 2026-10-16, Sat 2026-10-17, Sun 2026-10-18. \ No newline at end of file diff --git a/internal/db/household_context.sql.go b/internal/db/household_context.sql.go index e26740a..e03efdf 100644 --- a/internal/db/household_context.sql.go +++ b/internal/db/household_context.sql.go @@ -52,20 +52,26 @@ func (q *Queries) ListPlannedDinners(ctx context.Context, arg ListPlannedDinners return items, nil } -const listRecentDeclinedNames = `-- name: ListRecentDeclinedNames :many +const listRecentPassedNames = `-- name: ListRecentPassedNames :many -SELECT name -FROM suggestions -WHERE status = 'declined' +SELECT name FROM ( + SELECT name, decided_at, id + FROM suggestions + WHERE status = 'declined' + UNION ALL + SELECT name, decided_at, id + FROM rotation_reviews + WHERE decision = 'drop' +) ORDER BY decided_at DESC, id DESC LIMIT ? ` // Reads for the AI household context block (internal/ai/harness). -// The names the most recently declined suggestions were suggested under, -// newest first; renaming their dinners does not change them. -func (q *Queries) ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error) { - rows, err := q.db.QueryContext(ctx, listRecentDeclinedNames, limit) +// The most recent names the household turned down, newest first, as +// ListPassedNames has them. +func (q *Queries) ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error) { + rows, err := q.db.QueryContext(ctx, listRecentPassedNames, limit) if err != nil { return nil, err } diff --git a/internal/db/models.go b/internal/db/models.go index a75716a..9cdf5d0 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -228,6 +228,7 @@ type RotationReview struct { PlanDate string Status string Decision sql.NullString + Name sql.NullString DecidedAt sql.NullString UpdatedBy sql.NullString CreatedAt string @@ -316,4 +317,5 @@ type WeekPlan struct { CalendarSyncedAt sql.NullString CreatedAt string UpdatedAt string + Revision int64 } diff --git a/internal/db/plans.sql.go b/internal/db/plans.sql.go new file mode 100644 index 0000000..bf97ec5 --- /dev/null +++ b/internal/db/plans.sql.go @@ -0,0 +1,355 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: plans.sql + +package db + +import ( + "context" + "database/sql" +) + +const createWeekPlan = `-- name: CreateWeekPlan :exec +INSERT INTO week_plans (id, week_start, status, ai_note, created_at, updated_at) +VALUES (?, ?, 'draft', ?, ?, ?) +` + +type CreateWeekPlanParams struct { + ID string + WeekStart string + AiNote sql.NullString + CreatedAt string + UpdatedAt string +} + +func (q *Queries) CreateWeekPlan(ctx context.Context, arg CreateWeekPlanParams) error { + _, err := q.db.ExecContext(ctx, createWeekPlan, + arg.ID, + arg.WeekStart, + arg.AiNote, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const getActiveDraftJob = `-- name: GetActiveDraftJob :one +SELECT id, CAST(json_extract(payload, '$.confirm') = 1 AS INTEGER) AS confirmed +FROM jobs +WHERE kind = ?1 + AND status IN ('queued', 'running') + AND json_extract(payload, '$.week_start') = ?2 +ORDER BY id +LIMIT 1 +` + +type GetActiveDraftJobParams struct { + Kind string + WeekStart string +} + +type GetActiveDraftJobRow struct { + ID string + Confirmed int64 +} + +// The job of kind queued or running for the week starting week_start, +// with whether its payload has confirm. StartDraft enqueues a draft only +// when there is none, so there is at most one. Returns sql.ErrNoRows when +// there is none. +func (q *Queries) GetActiveDraftJob(ctx context.Context, arg GetActiveDraftJobParams) (GetActiveDraftJobRow, error) { + row := q.db.QueryRowContext(ctx, getActiveDraftJob, arg.Kind, arg.WeekStart) + var i GetActiveDraftJobRow + err := row.Scan(&i.ID, &i.Confirmed) + return i, err +} + +const getWeekPlan = `-- name: GetWeekPlan :one +SELECT id, week_start, status, ai_note, ai_run_id, calendar_synced_at, created_at, updated_at, revision FROM week_plans +WHERE week_start = ? +` + +// The plan of the week starting week_start. Returns sql.ErrNoRows when +// there is none. +func (q *Queries) GetWeekPlan(ctx context.Context, weekStart string) (WeekPlan, error) { + row := q.db.QueryRowContext(ctx, getWeekPlan, weekStart) + var i WeekPlan + err := row.Scan( + &i.ID, + &i.WeekStart, + &i.Status, + &i.AiNote, + &i.AiRunID, + &i.CalendarSyncedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.Revision, + ) + return i, err +} + +const listDraftPool = `-- name: ListDraftPool :many +SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners +WHERE status = 'regular' + OR (status = 'quest_log' AND NOT EXISTS ( + SELECT 1 FROM plan_days + JOIN week_plans ON week_plans.id = plan_days.plan_id + WHERE plan_days.dinner_id = dinners.id + AND week_plans.week_start >= ?1 + AND week_plans.week_start <> ?2 + )) +ORDER BY normalized_name, id +` + +type ListDraftPoolParams struct { + FromWeekStart string + WeekStart string +} + +// The dinners a draft of the week starting week_start may plan: every +// Regular, and every Quest log dinner no other plan for the week starting +// from_week_start or later has on any day. +func (q *Queries) ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([]Dinner, error) { + rows, err := q.db.QueryContext(ctx, listDraftPool, arg.FromWeekStart, arg.WeekStart) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Dinner + for rows.Next() { + var i Dinner + if err := rows.Scan( + &i.ID, + &i.Name, + &i.NormalizedName, + &i.Status, + &i.Origin, + &i.Method, + &i.MinutesTotal, + &i.Difficulty, + &i.Cuisine, + &i.IsFavorite, + &i.RecipeID, + &i.PhotoID, + &i.GroundingStatus, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.RecipeUrl, + &i.GroundingError, + &i.GroundingRevision, + ); 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 listPlanDays = `-- name: ListPlanDays :many +SELECT plan_days.date, plan_days.is_new, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision +FROM plan_days +JOIN dinners ON dinners.id = plan_days.dinner_id +WHERE plan_days.plan_id = ? +ORDER BY plan_days.date +` + +type ListPlanDaysRow struct { + Date string + IsNew int64 + Dinner Dinner +} + +// The days of plan plan_id with their dinners, Monday first. A day with +// no dinner is left out. +func (q *Queries) ListPlanDays(ctx context.Context, planID string) ([]ListPlanDaysRow, error) { + rows, err := q.db.QueryContext(ctx, listPlanDays, planID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListPlanDaysRow + for rows.Next() { + var i ListPlanDaysRow + if err := rows.Scan( + &i.Date, + &i.IsNew, + &i.Dinner.ID, + &i.Dinner.Name, + &i.Dinner.NormalizedName, + &i.Dinner.Status, + &i.Dinner.Origin, + &i.Dinner.Method, + &i.Dinner.MinutesTotal, + &i.Dinner.Difficulty, + &i.Dinner.Cuisine, + &i.Dinner.IsFavorite, + &i.Dinner.RecipeID, + &i.Dinner.PhotoID, + &i.Dinner.GroundingStatus, + &i.Dinner.UpdatedBy, + &i.Dinner.CreatedAt, + &i.Dinner.UpdatedAt, + &i.Dinner.RecipeUrl, + &i.Dinner.GroundingError, + &i.Dinner.GroundingRevision, + ); 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 listWeekPlanDinnerIDs = `-- name: ListWeekPlanDinnerIDs :many +SELECT plan_days.dinner_id +FROM plan_days +JOIN week_plans ON week_plans.id = plan_days.plan_id +WHERE week_plans.week_start = ? AND plan_days.dinner_id IS NOT NULL +ORDER BY plan_days.date +` + +// The ids of the dinners planned in the week starting week_start, Monday +// first. +func (q *Queries) ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) ([]sql.NullString, error) { + rows, err := q.db.QueryContext(ctx, listWeekPlanDinnerIDs, weekStart) + if err != nil { + return nil, err + } + defer rows.Close() + var items []sql.NullString + for rows.Next() { + var dinner_id sql.NullString + if err := rows.Scan(&dinner_id); err != nil { + return nil, err + } + items = append(items, dinner_id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markWeekPlanEdited = `-- name: MarkWeekPlanEdited :exec +UPDATE week_plans +SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END, + revision = revision + 1, + updated_at = ? +WHERE id = ? +` + +type MarkWeekPlanEditedParams struct { + UpdatedAt string + ID string +} + +// Records an edit of plan id, raising its revision: a synced plan becomes +// changed, any other keeps its status. +func (q *Queries) MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error { + _, err := q.db.ExecContext(ctx, markWeekPlanEdited, arg.UpdatedAt, arg.ID) + return err +} + +const putPlanDay = `-- name: PutPlanDay :exec +INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (plan_id, date) DO UPDATE +SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, updated_at = excluded.updated_at +` + +type PutPlanDayParams struct { + ID string + PlanID string + Date string + DinnerID sql.NullString + IsNew int64 + CreatedAt string + UpdatedAt string +} + +// Plans dinner_id on date of plan_id, keeping the day's calendar event id +// when the day exists. +func (q *Queries) PutPlanDay(ctx context.Context, arg PutPlanDayParams) error { + _, err := q.db.ExecContext(ctx, putPlanDay, + arg.ID, + arg.PlanID, + arg.Date, + arg.DinnerID, + arg.IsNew, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const redraftWeekPlan = `-- name: RedraftWeekPlan :exec +UPDATE week_plans +SET status = ?, ai_note = ?, revision = revision + 1, updated_at = ? +WHERE id = ? +` + +type RedraftWeekPlanParams struct { + Status string + AiNote sql.NullString + UpdatedAt string + ID string +} + +// Records a new draft over plan id: its status and note, raising its +// revision. +func (q *Queries) RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error { + _, err := q.db.ExecContext(ctx, redraftWeekPlan, + arg.Status, + arg.AiNote, + arg.UpdatedAt, + arg.ID, + ) + return err +} + +const swapPlanDay = `-- name: SwapPlanDay :execrows +UPDATE plan_days +SET dinner_id = ?, is_new = ?, updated_at = ? +WHERE plan_id = ? AND date = ? +` + +type SwapPlanDayParams struct { + DinnerID sql.NullString + IsNew int64 + UpdatedAt string + PlanID string + Date string +} + +// Plans dinner_id on an existing day of plan_id; a date the plan does not +// have is left as it is (0 rows). +func (q *Queries) SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error) { + result, err := q.db.ExecContext(ctx, swapPlanDay, + arg.DinnerID, + arg.IsNew, + arg.UpdatedAt, + arg.PlanID, + arg.Date, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/db/querier.go b/internal/db/querier.go index da18d8e..88053ef 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -25,6 +25,8 @@ type Querier interface { CountDeck(ctx context.Context) (int64, error) // How many Regulars the household has; archived dinners are not counted. CountRegularDinners(ctx context.Context) (int64, error) + // How many Rotation checks dinner_id has had, pending or decided. + CountRotationReviews(ctx context.Context, dinnerID string) (int64, error) CreateAIRun(ctx context.Context, arg CreateAIRunParams) error CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error) CreateJob(ctx context.Context, arg CreateJobParams) error @@ -34,10 +36,16 @@ type Querier interface { CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error) CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error + CreateRotationReview(ctx context.Context, arg CreateRotationReviewParams) error // name is the name the dish is suggested under, kept whatever its dinner is // later renamed. CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error) + CreateWeekPlan(ctx context.Context, arg CreateWeekPlanParams) error + // Records the household's decision on a pending Rotation check, with the + // dinner's name for a drop and NULL otherwise; one already decided is left + // as it is (0 rows). + DecideRotationReview(ctx context.Context, arg DecideRotationReviewParams) (int64, error) // Records the household's decision on a pending suggestion; one already // decided is left as it is (0 rows). DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error) @@ -67,6 +75,11 @@ type Querier interface { FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (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, + // with whether its payload has confirm. StartDraft enqueues a draft only + // when there is none, so there is at most one. Returns sql.ErrNoRows when + // there is none. + GetActiveDraftJob(ctx context.Context, arg GetActiveDraftJobParams) (GetActiveDraftJobRow, error) // The batch still pending or running, oldest first. Returns sql.ErrNoRows // when there is none. GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error) @@ -81,6 +94,10 @@ type Querier interface { // The newest batch, whatever its status. Returns sql.ErrNoRows when there // is none. GetLatestSuggestionBatch(ctx context.Context) (SuggestionBatch, error) + // The pending Rotation check of dinner_id. MarkTried makes one only for a + // dinner in the Quest log, which it moves to tried, so a dinner has at + // most one. Returns sql.ErrNoRows when there is none. + GetPendingRotationReview(ctx context.Context, dinnerID string) (RotationReview, error) GetPhoto(ctx context.Context, id string) (Photo, error) // Finds stored image bytes by content hash. The schema does not make sha256 // unique, so the earliest-created (lowest id) match is returned. @@ -93,6 +110,9 @@ type Querier interface { GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error) // The id of the job that runs batch batch_id. GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error) + // The plan of the week starting week_start. Returns sql.ErrNoRows when + // there is none. + GetWeekPlan(ctx context.Context, weekStart string) (WeekPlan, error) // Records a dinner's grounding by hand (grounded or manual) and clears any // grounding_error and pasted recipe_url: the recipe chosen replaces the // link. It writes only an unarchived dinner still as it was observed @@ -112,37 +132,74 @@ type Querier interface { // The pending suggestions whose dinner is still suggested, oldest first, // with their dinners. ListDeck(ctx context.Context) ([]ListDeckRow, error) - // The name every declined suggestion was suggested under, oldest decision - // first; renaming its dinner does not change it. - ListDeclinedSuggestionNames(ctx context.Context) ([]string, error) ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error) + // The dinners a draft of the week starting week_start may plan: every + // Regular, and every Quest log dinner no other plan for the week starting + // from_week_start or later has on any day. + ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([]Dinner, error) + // Every name the household turned down, oldest decision first: the name + // each declined suggestion was suggested under, which renaming its dinner + // does not change, and the name each dinner a Rotation check dropped + // ("Not for us") had when dropped, which renaming it does not change. + ListPassedNames(ctx context.Context) ([]string, error) + // The pending Rotation checks whose dinner is still tried, oldest plan + // date first, with their dinners. + ListPendingRotationReviews(ctx context.Context) ([]ListPendingRotationReviewsRow, error) + // The days of plan plan_id with their dinners, Monday first. A day with + // no dinner is left out. + ListPlanDays(ctx context.Context, planID string) ([]ListPlanDaysRow, error) // The planned dinners dated from from_date up to but not including // to_date, oldest first. Days with no dinner are left out. ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error) // Reads for the AI household context block (internal/ai/harness). - // The names the most recently declined suggestions were suggested under, - // newest first; renaming their dinners does not change them. - ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error) + // The most recent names the household turned down, newest first, as + // ListPassedNames has them. + ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error) ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error) ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, 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) // Every dinner a new or renamed dinner must not repeat, with the // source_url of the recipe it is grounded in (NULL while it has none). ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error) // The Quest log: dinners with status quest_log that no plan for the week // starting from_week_start or later has on any day. ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStart string) ([]Dinner, error) + // The ids of the dinners planned in the week starting week_start, Monday + // first. + ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) ([]sql.NullString, error) + // Moves a dinner still in the Quest log to tried; a dinner with any other + // status is left as it is (0 rows). + MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error) + // Records an edit of plan id, raising its revision: a synced plan becomes + // changed, any other keeps its status. + MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error // Moves a dinner still suggested to status (quest_log or archived), // recording updated_by; a dinner no longer suggested is left as it is // (0 rows). MoveSuggestedDinner(ctx context.Context, arg MoveSuggestedDinnerParams) (int64, error) + // Moves a dinner still tried to status (regular, archived or quest_log) + // by a Rotation check decision, recording updated_by; its origin is kept. + // A dinner no longer tried is left as it is (0 rows). + MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error) // Stores a partial result of a running job. PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error) + // Plans dinner_id on date of plan_id, keeping the day's calendar event id + // when the day exists. + PutPlanDay(ctx context.Context, arg PutPlanDayParams) error + // Records a new draft over plan id: its status and note, raising its + // revision. + RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error // An unknown id returns sql.ErrNoRows. SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) // Marks a pending batch running; a batch in any other status is left as // it is (0 rows). StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error) SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error) + // Plans dinner_id on an existing day of plan_id; a date the plan does not + // have is left as it is (0 rows). + SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error) // An unknown id returns sql.ErrNoRows. ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error) // An unknown id returns sql.ErrNoRows. diff --git a/internal/db/rotation_reviews.sql.go b/internal/db/rotation_reviews.sql.go new file mode 100644 index 0000000..bd819f0 --- /dev/null +++ b/internal/db/rotation_reviews.sql.go @@ -0,0 +1,257 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: rotation_reviews.sql + +package db + +import ( + "context" + "database/sql" +) + +const countRotationReviews = `-- name: CountRotationReviews :one +SELECT COUNT(*) FROM rotation_reviews WHERE dinner_id = ? +` + +// How many Rotation checks dinner_id has had, pending or decided. +func (q *Queries) CountRotationReviews(ctx context.Context, dinnerID string) (int64, error) { + row := q.db.QueryRowContext(ctx, countRotationReviews, dinnerID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createRotationReview = `-- name: CreateRotationReview :exec +INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, created_at, updated_at) +VALUES (?, ?, ?, 'pending', ?, ?) +` + +type CreateRotationReviewParams struct { + ID string + DinnerID string + PlanDate string + CreatedAt string + UpdatedAt string +} + +func (q *Queries) CreateRotationReview(ctx context.Context, arg CreateRotationReviewParams) error { + _, err := q.db.ExecContext(ctx, createRotationReview, + arg.ID, + arg.DinnerID, + arg.PlanDate, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const decideRotationReview = `-- name: DecideRotationReview :execrows +UPDATE rotation_reviews +SET status = 'decided', + decision = ?1, + name = ?2, + decided_at = ?3, + updated_by = ?4, + updated_at = ?3 +WHERE id = ?5 AND status = 'pending' +` + +type DecideRotationReviewParams struct { + Decision sql.NullString + Name sql.NullString + Now sql.NullString + UpdatedBy sql.NullString + ID string +} + +// Records the household's decision on a pending Rotation check, with the +// dinner's name for a drop and NULL otherwise; one already decided is left +// as it is (0 rows). +func (q *Queries) DecideRotationReview(ctx context.Context, arg DecideRotationReviewParams) (int64, error) { + result, err := q.db.ExecContext(ctx, decideRotationReview, + arg.Decision, + arg.Name, + arg.Now, + arg.UpdatedBy, + arg.ID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getPendingRotationReview = `-- name: GetPendingRotationReview :one +SELECT id, dinner_id, plan_date, status, decision, name, decided_at, updated_by, created_at, updated_at FROM rotation_reviews +WHERE dinner_id = ? AND status = 'pending' +` + +// The pending Rotation check of dinner_id. MarkTried makes one only for a +// dinner in the Quest log, which it moves to tried, so a dinner has at +// most one. Returns sql.ErrNoRows when there is none. +func (q *Queries) GetPendingRotationReview(ctx context.Context, dinnerID string) (RotationReview, error) { + row := q.db.QueryRowContext(ctx, getPendingRotationReview, dinnerID) + var i RotationReview + err := row.Scan( + &i.ID, + &i.DinnerID, + &i.PlanDate, + &i.Status, + &i.Decision, + &i.Name, + &i.DecidedAt, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listPendingRotationReviews = `-- name: ListPendingRotationReviews :many +SELECT rotation_reviews.plan_date, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision +FROM rotation_reviews +JOIN dinners ON dinners.id = rotation_reviews.dinner_id +WHERE rotation_reviews.status = 'pending' AND dinners.status = 'tried' +ORDER BY rotation_reviews.plan_date, rotation_reviews.id +` + +type ListPendingRotationReviewsRow struct { + PlanDate string + Dinner Dinner +} + +// The pending Rotation checks whose dinner is still tried, oldest plan +// date first, with their dinners. +func (q *Queries) ListPendingRotationReviews(ctx context.Context) ([]ListPendingRotationReviewsRow, error) { + rows, err := q.db.QueryContext(ctx, listPendingRotationReviews) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListPendingRotationReviewsRow + for rows.Next() { + var i ListPendingRotationReviewsRow + if err := rows.Scan( + &i.PlanDate, + &i.Dinner.ID, + &i.Dinner.Name, + &i.Dinner.NormalizedName, + &i.Dinner.Status, + &i.Dinner.Origin, + &i.Dinner.Method, + &i.Dinner.MinutesTotal, + &i.Dinner.Difficulty, + &i.Dinner.Cuisine, + &i.Dinner.IsFavorite, + &i.Dinner.RecipeID, + &i.Dinner.PhotoID, + &i.Dinner.GroundingStatus, + &i.Dinner.UpdatedBy, + &i.Dinner.CreatedAt, + &i.Dinner.UpdatedAt, + &i.Dinner.RecipeUrl, + &i.Dinner.GroundingError, + &i.Dinner.GroundingRevision, + ); 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 listTriedCandidates = `-- name: ListTriedCandidates :many +SELECT DISTINCT dinners.id +FROM plan_days +JOIN dinners ON dinners.id = plan_days.dinner_id +WHERE plan_days.date = ?1 + AND dinners.status = 'quest_log' + AND NOT EXISTS ( + SELECT 1 FROM rotation_reviews + WHERE rotation_reviews.dinner_id = dinners.id + AND rotation_reviews.plan_date = plan_days.date + ) +ORDER BY dinners.id +` + +// 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. +func (q *Queries) ListTriedCandidates(ctx context.Context, date string) ([]string, error) { + rows, err := q.db.QueryContext(ctx, listTriedCandidates, date) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markDinnerTried = `-- name: MarkDinnerTried :execrows +UPDATE dinners +SET status = 'tried', updated_at = ? +WHERE id = ? AND status = 'quest_log' +` + +type MarkDinnerTriedParams struct { + UpdatedAt string + ID string +} + +// Moves a dinner still in the Quest log to tried; a dinner with any other +// status is left as it is (0 rows). +func (q *Queries) MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error) { + result, err := q.db.ExecContext(ctx, markDinnerTried, arg.UpdatedAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const moveTriedDinner = `-- name: MoveTriedDinner :execrows +UPDATE dinners +SET status = ?1, updated_by = ?2, updated_at = ?3 +WHERE id = ?4 AND status = 'tried' +` + +type MoveTriedDinnerParams struct { + Status string + UpdatedBy sql.NullString + UpdatedAt string + ID string +} + +// Moves a dinner still tried to status (regular, archived or quest_log) +// by a Rotation check decision, recording updated_by; its origin is kept. +// A dinner no longer tried is left as it is (0 rows). +func (q *Queries) MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error) { + result, err := q.db.ExecContext(ctx, moveTriedDinner, + arg.Status, + arg.UpdatedBy, + arg.UpdatedAt, + arg.ID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/db/suggestions.sql.go b/internal/db/suggestions.sql.go index 3829c5a..e7f12d7 100644 --- a/internal/db/suggestions.sql.go +++ b/internal/db/suggestions.sql.go @@ -348,17 +348,25 @@ func (q *Queries) ListDeck(ctx context.Context) ([]ListDeckRow, error) { return items, nil } -const listDeclinedSuggestionNames = `-- name: ListDeclinedSuggestionNames :many -SELECT name -FROM suggestions -WHERE status = 'declined' +const listPassedNames = `-- name: ListPassedNames :many +SELECT name FROM ( + SELECT name, decided_at, id + FROM suggestions + WHERE status = 'declined' + UNION ALL + SELECT name, decided_at, id + FROM rotation_reviews + WHERE decision = 'drop' +) ORDER BY decided_at, id ` -// The name every declined suggestion was suggested under, oldest decision -// first; renaming its dinner does not change it. -func (q *Queries) ListDeclinedSuggestionNames(ctx context.Context) ([]string, error) { - rows, err := q.db.QueryContext(ctx, listDeclinedSuggestionNames) +// Every name the household turned down, oldest decision first: the name +// each declined suggestion was suggested under, which renaming its dinner +// does not change, and the name each dinner a Rotation check dropped +// ("Not for us") had when dropped, which renaming it does not change. +func (q *Queries) ListPassedNames(ctx context.Context) ([]string, error) { + rows, err := q.db.QueryContext(ctx, listPassedNames) if err != nil { return nil, err } diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go index 12cb947..b98e61a 100644 --- a/internal/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -88,6 +88,10 @@ var ErrNotFound = errors.New("jobs: job not found") // registered handler. var ErrUnknownKind = errors.New("jobs: unknown job kind") +// ErrServiceOnly is returned by Runner.Enqueue for a kind made ServiceOnly, +// whose jobs only its own service enqueues. +var ErrServiceOnly = errors.New("jobs: job kind is enqueued only by its service") + // ErrInvalidPayload is returned by Runner.Enqueue for a payload that does // not decode into the kind's payload type. var ErrInvalidPayload = errors.New("jobs: payload does not decode") @@ -130,11 +134,13 @@ type Job struct { // never quotes an error. type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string) -// handler is a registered kind: run runs a job, and check reports whether -// a payload decodes into the kind's payload type. +// handler is a registered kind: run runs a job, check reports whether a +// payload decodes into the kind's payload type, and serviceOnly is set when +// Runner.Enqueue refuses the kind. type handler struct { - run runFunc - check func(payload []byte) bool + run runFunc + check func(payload []byte) bool + serviceOnly bool } // Runner owns the job handlers and the worker pool. @@ -286,15 +292,30 @@ func (k *Kind[P]) EnqueueTx(ctx context.Context, tx *sql.Tx, payload P) (string, return createJob(ctx, db.New(tx), k.name, data) } +// ServiceOnly makes Runner.Enqueue refuse the kind with ErrServiceOnly, so +// its jobs are enqueued only through k, by the service that checks what +// must hold before one starts. It must be called before Start. +func (k *Kind[P]) ServiceOnly() error { + k.r.mu.Lock() + defer k.r.mu.Unlock() + if k.r.started { + return fmt.Errorf("jobs: service-only %q: runner already started", k.name) + } + h := k.r.handlers[k.name] + h.serviceOnly = true + k.r.handlers[k.name] = h + return nil +} + // Wake tells an idle worker to look for a job, as Enqueue does itself: // call it after committing the transaction a job was enqueued in. func (k *Kind[P]) Wake() { k.r.wakeOne() } // Enqueue stores a queued job of kind with its JSON payload and returns its // id, as Kind.Enqueue does for a kind known at compile time. It returns -// ErrUnknownKind for a kind with no registered handler and -// ErrInvalidPayload for a payload that does not decode into the kind's -// payload type; neither stores a job. +// ErrUnknownKind for a kind with no registered handler, ErrServiceOnly for +// a ServiceOnly kind and ErrInvalidPayload for a payload that does not +// decode into the kind's payload type; none stores a job. func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessage) (string, error) { r.mu.Lock() h, ok := r.handlers[kind] @@ -302,6 +323,9 @@ func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessa if !ok { return "", ErrUnknownKind } + if h.serviceOnly { + return "", ErrServiceOnly + } if !h.check(payload) { return "", ErrInvalidPayload } diff --git a/internal/jobs/jobs_test.go b/internal/jobs/jobs_test.go index ece6b13..ee7e152 100644 --- a/internal/jobs/jobs_test.go +++ b/internal/jobs/jobs_test.go @@ -736,3 +736,39 @@ func TestRunnerEnqueueByKindName(t *testing.T) { t.Errorf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result) } } + +func TestServiceOnlyKind(t *testing.T) { + t.Parallel() + st := migratedStore(t) + r := newRunner(t, st, 1) + k := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) { + return countResult{Done: p.Steps}, nil + }) + if err := k.ServiceOnly(); err != nil { + t.Fatal(err) + } + ctx := context.Background() + + if _, err := r.Enqueue(ctx, "count", json.RawMessage(`{"steps":3}`)); !errors.Is(err, ErrServiceOnly) { + t.Errorf("Runner.Enqueue error = %v, want ErrServiceOnly", err) + } + var n int + if err := st.Reader.QueryRow("SELECT COUNT(*) FROM jobs").Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("refused enqueue stored %d jobs, want 0", n) + } + + id, err := k.Enqueue(ctx, countPayload{Steps: 3}) + if err != nil { + t.Fatal(err) + } + start(t, r) + if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded { + t.Errorf("job enqueued through its Kind = %+v, want succeeded", job) + } + if err := k.ServiceOnly(); err == nil { + t.Error("ServiceOnly after Start succeeded, want an error") + } +} diff --git a/internal/plan/draft.go b/internal/plan/draft.go new file mode 100644 index 0000000..5ef6cbd --- /dev/null +++ b/internal/plan/draft.go @@ -0,0 +1,229 @@ +package plan + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "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/dinners" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// DraftJob is the KindDraftWeek payload: the week to draft, and whether +// the household confirmed drafting over a synced or changed plan. +type DraftJob struct { + WeekStart string `json:"week_start"` + Confirm bool `json:"confirm"` +} + +// draftResult is the KindDraftWeek result: the week drafted and its plan. +type draftResult struct { + WeekStart string `json:"week_start"` + PlanID string `json:"plan_id"` +} + +func (r draftResult) PublicJSON() ([]byte, error) { return json.Marshal(r) } + +// runDraft drafts the week p.WeekStart: draft_week plans it from the +// Regulars and the Quest log dinners no other current or later plan has, +// given the household context and last week's dinners, and the draft is +// stored as the week's plan if the plan is still as it was before +// draft_week ran and every dinner it planned is still in the pool. A pool +// under seven dinners fails with ReasonNotEnoughDinners and draft_week +// failing with ReasonAIFailed; no week is made up instead. +func (s *Service) runDraft(ctx context.Context, p DraftJob, _ func(draftResult) error) (draftResult, error) { + weekStart, err := ParseWeekStart(p.WeekStart) + if err != nil { + return draftResult{}, fmt.Errorf("plan: draft: %w", err) + } + base, err := s.revision(ctx, weekStart) + if err != nil { + return draftResult{}, err + } + in, err := s.request(ctx, weekStart) + if err != nil { + return draftResult{}, err + } + week, err := s.draft(ctx, household.PeriodKey(weekStart), in) + if err != nil { + if ctx.Err() != nil { + return draftResult{}, ctx.Err() + } + var few *skills.NotEnoughDinnersError + if errors.As(err, &few) { + return draftResult{}, &jobs.Failure{Code: ReasonNotEnoughDinners} + } + // Only the code is logged: the error may quote a model's response. + s.logger.InfoContext(ctx, "week draft failed", "week_start", p.WeekStart, "reason", ReasonAIFailed) + return draftResult{}, &jobs.Failure{Code: ReasonAIFailed} + } + for _, w := range week.Warnings { + s.logger.InfoContext(ctx, "week draft warning", "week_start", p.WeekStart, "warning", w) + } + planID, err := s.storeDraft(ctx, weekStart, household.WeekStart(in.Context.Today), p.Confirm, base, week) + if err != nil { + return draftResult{}, err + } + return draftResult{WeekStart: p.WeekStart, PlanID: planID}, nil +} + +// noPlan is the revision of a week with no plan. +const noPlan = -1 + +// revision returns the revision of the plan of the week starting +// weekStart, or noPlan when the week has none. +func (s *Service) revision(ctx context.Context, weekStart store.LocalDate) (int64, error) { + cur, err := db.New(s.st.Reader).GetWeekPlan(ctx, weekStart.String()) + if errors.Is(err, sql.ErrNoRows) { + return noPlan, nil + } + if err != nil { + return 0, fmt.Errorf("plan: read week %s: %w", weekStart, err) + } + return cur.Revision, nil +} + +// request reads what draft_week is given for the week starting weekStart: +// the household context, the pool, last week's dinners and the dinner +// time. +func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skills.WeekRequest, error) { + hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget) + if err != nil { + return skills.WeekRequest{}, fmt.Errorf("plan: %w", err) + } + q := db.New(s.st.Reader) + settings, err := q.GetHouseholdSettings(ctx) + if err != nil { + return skills.WeekRequest{}, fmt.Errorf("plan: read household settings: %w", err) + } + thisWeek := household.WeekStart(hctx.Today) + rows, err := q.ListDraftPool(ctx, db.ListDraftPoolParams{FromWeekStart: thisWeek.String(), WeekStart: weekStart.String()}) + if err != nil { + return skills.WeekRequest{}, fmt.Errorf("plan: list draft pool: %w", err) + } + pool := make([]skills.Candidate, len(rows)) + for i, d := range rows { + kind := skills.KindRegular + if d.Status == dinners.StatusQuestLog { + kind = skills.KindQuestLog + } + pool[i] = skills.Candidate{ID: d.ID, Name: d.Name, Kind: kind, Method: d.Method.String, Minutes: int(d.MinutesTotal.Int64)} + } + last, err := q.ListWeekPlanDinnerIDs(ctx, addDays(weekStart, -7).String()) + if err != nil { + return skills.WeekRequest{}, fmt.Errorf("plan: list last week's dinners: %w", err) + } + lastWeek := []string{} + for _, id := range last { + lastWeek = append(lastWeek, id.String) + } + return skills.WeekRequest{ + Context: hctx, + WeekStart: weekStart, + Pool: pool, + LastWeek: lastWeek, + DinnerTime: settings.DinnerTime, + }, nil +} + +// storeDraft stores week as the plan of the week starting weekStart in one +// transaction and returns the plan's id. A new plan is draft. A draft +// replaces the days of an existing plan, keeping each day's calendar +// event id; a draft plan stays draft and a synced or changed one becomes +// changed, since Calendar no longer shows it. A plan synced since the +// draft was asked for without confirm is left as it is, failing with +// ReasonConfirmRequired, and so is a plan no longer at revision base (the +// revision before draft_week ran, or noPlan), failing with +// ReasonPlanChanged. The pool is read again, as of thisWeek like the pool +// draft_week was given: a day whose dinner is no longer in it, or is in it +// as a Regular rather than the Quest log or the other way round, leaves +// everything as it is, failing with ReasonPoolChanged. +func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) { + var planID string + err := s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + rows, err := q.ListDraftPool(ctx, db.ListDraftPoolParams{FromWeekStart: thisWeek.String(), WeekStart: weekStart.String()}) + if err != nil { + return fmt.Errorf("plan: list draft pool: %w", err) + } + pool := make(map[string]string, len(rows)) + for _, d := range rows { + pool[d.ID] = d.Status + } + for _, d := range week.Days { + status, ok := pool[d.DinnerID] + if !ok || (status == dinners.StatusQuestLog) != d.IsNew { + return &jobs.Failure{Code: ReasonPoolChanged} + } + } + now := store.Now().String() + note := sql.NullString{String: week.Note, Valid: week.Note != ""} + cur, err := q.GetWeekPlan(ctx, weekStart.String()) + switch { + case errors.Is(err, sql.ErrNoRows) && base != noPlan: + return &jobs.Failure{Code: ReasonPlanChanged} + case errors.Is(err, sql.ErrNoRows): + planID = store.NewID() + if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ + ID: planID, + WeekStart: weekStart.String(), + AiNote: note, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + return fmt.Errorf("plan: create week %s: %w", weekStart, err) + } + case err != nil: + return fmt.Errorf("plan: read week %s: %w", weekStart, err) + case cur.Status != StatusDraft && !confirm: + return &jobs.Failure{Code: ReasonConfirmRequired} + case cur.Revision != base: + return &jobs.Failure{Code: ReasonPlanChanged} + default: + planID = cur.ID + status := StatusDraft + if cur.Status != StatusDraft { + status = StatusChanged + } + if err := q.RedraftWeekPlan(ctx, db.RedraftWeekPlanParams{Status: status, AiNote: note, UpdatedAt: now, ID: cur.ID}); err != nil { + return fmt.Errorf("plan: redraft %s: %w", cur.ID, err) + } + } + for _, d := range week.Days { + isNew := int64(0) + if d.IsNew { + isNew = 1 + } + if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ + ID: store.NewID(), + PlanID: planID, + Date: d.Date, + DinnerID: sql.NullString{String: d.DinnerID, Valid: true}, + IsNew: isNew, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + return fmt.Errorf("plan: put %s of %s: %w", d.Date, planID, err) + } + } + return nil + }) + if err != nil { + return "", err + } + return planID, nil +} + +// addDays returns d plus n days. +func addDays(d store.LocalDate, n int) store.LocalDate { + y, m, day := d.Date() + return store.LocalDateOf(time.Date(y, m, day+n, 0, 0, 0, 0, time.UTC)) +} diff --git a/internal/plan/plan.go b/internal/plan/plan.go new file mode 100644 index 0000000..15291d5 --- /dev/null +++ b/internal/plan/plan.go @@ -0,0 +1,445 @@ +// Package plan keeps the household's weekly plans: the Draft board the AI +// drafts from the Regulars and the Quest log, and the swaps the household +// makes on it. A draft runs as a job: draft_week plans seven dinners, one +// a day from Monday to Sunday, and the job stores them as the week's plan, +// replacing any draft before it unless the household edited the plan +// while draft_week ran. A plan is draft until Push to Calendar +// syncs it (Feature 16); an edit of a synced plan makes it changed. +// +// The Rotation check follows a plan day: MarkTried moves the Quest log +// dinners planned on it to tried, and the household answers "Keep it in +// rotation?" for each (D-021). Only their keep makes a dinner a Regular. +package plan + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "slices" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// KindDraftWeek is the job that drafts a week's plan. +const KindDraftWeek = "draft_week" + +// Plan statuses (the week_plans.status column). +const ( + StatusDraft = "draft" + StatusSynced = "synced" + StatusChanged = "changed" +) + +// Reasons a draft failed, stored as the KindDraftWeek job's failure code. +// Any other failure, such as the database's, is the runner's +// internal_error. +const ( + // ReasonNotEnoughDinners: the Regulars and Quest log hold fewer than + // seven dinners to draft from. + ReasonNotEnoughDinners = "not_enough_dinners" + // ReasonAIFailed: draft_week failed; no week is made up instead. + ReasonAIFailed = "ai_failed" + // ReasonConfirmRequired: the plan was synced after the draft was + // asked for without confirm, so the draft does not replace it. + ReasonConfirmRequired = "confirm_required" + // ReasonPlanChanged: the plan was edited while draft_week ran, so the + // draft does not replace the edit. + ReasonPlanChanged = "plan_changed" + // ReasonPoolChanged: a dinner the draft planned left the pool while + // draft_week ran (archived, tried, or its Quest log dinner planned on + // another week), so the draft is not stored; drafting again uses the + // pool as it is now. + ReasonPoolChanged = "pool_changed" +) + +// Reasons are every draft failure reason. +var Reasons = []string{ReasonNotEnoughDinners, ReasonAIFailed, ReasonConfirmRequired, ReasonPlanChanged, ReasonPoolChanged} + +// ContextBudget is the character budget of the household context +// draft_week is given (see harness.BuildContext). +const ContextBudget = 16000 + +// plannable are the dinner statuses a day may be swapped to. +var plannable = []string{dinners.StatusRegular, dinners.StatusQuestLog} + +// ErrNotFound is returned for a week with no plan. +var ErrNotFound = errors.New("plan not found") + +// ErrDinnerNotFound is a swap to an unknown dinner. +var ErrDinnerNotFound = errors.New("dinner not found") + +// ErrDinnerAlreadyPlanned is a swap to a dinner already on another day of +// the week. +var ErrDinnerAlreadyPlanned = errors.New("the dinner is already on another day of this week") + +// ErrConfirmRequired is a draft over a synced or changed plan asked for +// without confirm. +var ErrConfirmRequired = errors.New("the plan was pushed to Calendar; drafting over it needs confirm") + +// ErrDraftRunning is a confirmed draft asked for while a draft of the week +// asked for without confirm is queued or running. +var ErrDraftRunning = errors.New("a draft of the week without confirm is already running; ask again when it finishes") + +// InvalidWeekError is a week start that is not a Monday date. +type InvalidWeekError struct { + Value string +} + +func (e *InvalidWeekError) Error() string { + return fmt.Sprintf("week start %q is not a Monday date (YYYY-MM-DD)", e.Value) +} + +// InvalidDayError is a swap of a date outside the plan's week. +type InvalidDayError struct { + Value string +} + +func (e *InvalidDayError) Error() string { + return fmt.Sprintf("date %q is not a day of the week (YYYY-MM-DD)", e.Value) +} + +// DinnerNotPlannableError is a swap to a dinner that is neither a Regular +// nor in the Quest log. +type DinnerNotPlannableError struct { + Status string +} + +func (e *DinnerNotPlannableError) Error() string { + return fmt.Sprintf("the dinner is %s; only Regulars and Quest log dinners can be planned", e.Status) +} + +// ParseWeekStart parses s, a household-local Monday as YYYY-MM-DD. +func ParseWeekStart(s string) (store.LocalDate, error) { + d, err := store.ParseLocalDate(s) + if err != nil { + return store.LocalDate{}, &InvalidWeekError{Value: s} + } + if _, err := household.WeekDays(d); err != nil { + return store.LocalDate{}, &InvalidWeekError{Value: s} + } + return d, nil +} + +// Drafter runs draft_week on in; scope names the week. In production it is +// skills.DraftWeek with the household's harness.Config. +type Drafter func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) + +// Service drafts, reads and edits weekly plans. +type Service struct { + st *store.Store + draft Drafter + logger *slog.Logger + draftJob *jobs.Kind[DraftJob] +} + +// NewService returns a service over st that drafts with draft and +// registers the KindDraftWeek job on runner, which must not have started +// yet. The kind is service-only: StartDraft, which keeps one draft in +// flight per week, is the only way to enqueue it. +func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, logger *slog.Logger) (*Service, error) { + switch { + case st == nil: + return nil, errors.New("plan: nil store") + case draft == nil: + return nil, errors.New("plan: nil drafter") + case runner == nil: + return nil, errors.New("plan: nil job runner") + case logger == nil: + return nil, errors.New("plan: nil logger") + } + s := &Service{st: st, draft: draft, logger: logger} + kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...) + if err != nil { + return nil, err + } + if err := kind.ServiceOnly(); err != nil { + return nil, err + } + s.draftJob = kind + return s, nil +} + +// Plan is a week's plan as the API shows it. +type Plan struct { + ID string `json:"id"` + WeekStart string `json:"week_start"` + // WeekLabel is e.g. "Week 41 · Oct 5 – 11". + WeekLabel string `json:"week_label"` + Status string `json:"status"` + AINote *string `json:"ai_note"` + CalendarSyncedAt *string `json:"calendar_synced_at"` + Days []Day `json:"days"` + UpdatedAt string `json:"updated_at"` +} + +// Day is one day of a plan. +type Day struct { + // Date is household-local, YYYY-MM-DD. + Date string `json:"date"` + Dinner Dinner `json:"dinner"` + // IsNew is true for a dinner planned from the Quest log. + IsNew bool `json:"is_new"` + // StartBy is when a crock-pot dinner must start to be ready at the + // household's dinner time; null for any other dinner and for a + // crock-pot dinner whose minutes are unknown. + StartBy *time.Time `json:"start_by"` +} + +// Dinner is a planned dinner as a plan day shows it. +type Dinner struct { + ID string `json:"id"` + Name string `json:"name"` + Photo *string `json:"photo"` + Minutes *int64 `json:"minutes"` + Method *string `json:"method"` + Origin string `json:"origin"` +} + +// Get returns the plan of the week starting weekStart, or ErrNotFound. +// The plan and the household settings come from one read transaction. +func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (p Plan, err error) { + tx, err := s.st.Reader.BeginTx(ctx, nil) + if err != nil { + return Plan{}, fmt.Errorf("plan: begin read: %w", err) + } + defer func() { + err = errors.Join(err, tx.Rollback()) + }() + return readPlan(ctx, db.New(tx), weekStart) +} + +// readPlan reads the plan of the week starting weekStart through q, with +// each day's start_by from the household's dinner time and zone. +func readPlan(ctx context.Context, q *db.Queries, weekStart store.LocalDate) (Plan, error) { + label, err := household.WeekLabel(weekStart) + if err != nil { + return Plan{}, fmt.Errorf("plan: %w", err) + } + row, err := q.GetWeekPlan(ctx, weekStart.String()) + if errors.Is(err, sql.ErrNoRows) { + return Plan{}, ErrNotFound + } + if err != nil { + return Plan{}, fmt.Errorf("plan: read week %s: %w", weekStart, err) + } + settings, err := q.GetHouseholdSettings(ctx) + if err != nil { + return Plan{}, fmt.Errorf("plan: read household settings: %w", err) + } + zone, err := household.LoadZone(settings.Timezone) + if err != nil { + return Plan{}, fmt.Errorf("plan: household settings: %w", err) + } + rows, err := q.ListPlanDays(ctx, row.ID) + if err != nil { + return Plan{}, fmt.Errorf("plan: read days of %s: %w", row.ID, err) + } + dates, err := household.WeekDays(weekStart) + if err != nil { + return Plan{}, fmt.Errorf("plan: %w", err) + } + if len(rows) != len(dates) { + return Plan{}, fmt.Errorf("plan: week %s has %d planned days, want %d", weekStart, len(rows), len(dates)) + } + p := Plan{ + ID: row.ID, + WeekStart: row.WeekStart, + WeekLabel: label, + Status: row.Status, + AINote: nullString(row.AiNote), + CalendarSyncedAt: nullString(row.CalendarSyncedAt), + Days: make([]Day, len(rows)), + UpdatedAt: row.UpdatedAt, + } + for i, r := range rows { + if r.Date != dates[i].String() { + return Plan{}, fmt.Errorf("plan: week %s day %d is %s, want %s", weekStart, i+1, r.Date, dates[i]) + } + startBy, err := startBy(zone, dates[i], settings.DinnerTime, r.Dinner) + if err != nil { + return Plan{}, fmt.Errorf("plan: start by of %s: %w", r.Date, err) + } + p.Days[i] = Day{Date: r.Date, Dinner: fromDinner(r.Dinner), IsNew: r.IsNew == 1, StartBy: startBy} + } + return p, nil +} + +// startBy returns when crock-pot dinner d must start on date to be ready at +// dinnerTime in zone, or nil for any other dinner and for a crock-pot +// dinner whose minutes are unknown. +func startBy(zone *time.Location, date store.LocalDate, dinnerTime string, d db.Dinner) (*time.Time, error) { + if !d.Method.Valid || d.Method.String != "crockpot" || !d.MinutesTotal.Valid || d.MinutesTotal.Int64 <= 0 { + return nil, nil + } + at, err := household.LocalTime(zone, date, dinnerTime) + if err != nil { + return nil, err + } + at = at.Add(-time.Duration(d.MinutesTotal.Int64) * time.Minute) + return &at, nil +} + +// Draft is a draft asked for: the job drafting the week, and whether this +// call enqueued it rather than found one already queued or running. +type Draft struct { + JobID string `json:"jobId"` + Created bool `json:"-"` +} + +// StartDraft enqueues the KindDraftWeek job drafting the week starting +// weekStart, unless one is already queued or running for it, which it +// returns instead with Created false, so a week has at most one draft in +// flight. A confirmed draft joins only a confirmed job: while the week's +// job was asked for without confirm, it fails with ErrDraftRunning and +// enqueues nothing. Drafting over a synced or changed plan needs confirm, +// else it fails with ErrConfirmRequired and enqueues nothing. +func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, confirm bool) (Draft, error) { + if _, err := household.WeekDays(weekStart); err != nil { + return Draft{}, &InvalidWeekError{Value: weekStart.String()} + } + var d Draft + err := s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + cur, err := q.GetWeekPlan(ctx, weekStart.String()) + switch { + case errors.Is(err, sql.ErrNoRows): + case err != nil: + return fmt.Errorf("plan: read week %s: %w", weekStart, err) + case cur.Status != StatusDraft && !confirm: + return ErrConfirmRequired + } + active, err := q.GetActiveDraftJob(ctx, db.GetActiveDraftJobParams{Kind: KindDraftWeek, WeekStart: weekStart.String()}) + switch { + case errors.Is(err, sql.ErrNoRows): + case err != nil: + return fmt.Errorf("plan: read draft job of %s: %w", weekStart, err) + case confirm && active.Confirmed != 1: + return ErrDraftRunning + default: + d = Draft{JobID: active.ID} + return nil + } + id, err := s.draftJob.EnqueueTx(ctx, tx, DraftJob{WeekStart: weekStart.String(), Confirm: confirm}) + if err != nil { + return fmt.Errorf("plan: enqueue draft of %s: %w", weekStart, err) + } + d = Draft{JobID: id, Created: true} + return nil + }) + if err != nil { + return Draft{}, err + } + if d.Created { + s.draftJob.Wake() + } + return d, nil +} + +// Swap plans dinnerID on date of the week starting weekStart, marking the +// day new when the dinner is in the Quest log. Swapping a synced plan +// makes it changed. The week must have a plan (else ErrNotFound), date +// must be one of its days (else *InvalidDayError), the dinner must be a +// Regular or in the Quest log (else ErrDinnerNotFound or +// *DinnerNotPlannableError), and it must not be on another day of the +// week (else ErrDinnerAlreadyPlanned). It returns the plan as swapped. +func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, dinnerID string) (Plan, error) { + dates, err := household.WeekDays(weekStart) + if err != nil { + return Plan{}, &InvalidWeekError{Value: weekStart.String()} + } + day, err := store.ParseLocalDate(date) + if err != nil || !slices.Contains(dates[:], day) { + return Plan{}, &InvalidDayError{Value: date} + } + var p Plan + err = s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + cur, err := q.GetWeekPlan(ctx, weekStart.String()) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return fmt.Errorf("plan: read week %s: %w", weekStart, err) + } + d, err := q.GetDinner(ctx, dinnerID) + if errors.Is(err, sql.ErrNoRows) { + return ErrDinnerNotFound + } + if err != nil { + return fmt.Errorf("plan: read dinner %s: %w", dinnerID, err) + } + if !slices.Contains(plannable, d.Status) { + return &DinnerNotPlannableError{Status: d.Status} + } + days, err := q.ListPlanDays(ctx, cur.ID) + if err != nil { + return fmt.Errorf("plan: read days of %s: %w", cur.ID, err) + } + if slices.ContainsFunc(days, func(r db.ListPlanDaysRow) bool { return r.Date != day.String() && r.Dinner.ID == d.ID }) { + return ErrDinnerAlreadyPlanned + } + now := store.Now().String() + n, err := q.SwapPlanDay(ctx, db.SwapPlanDayParams{ + DinnerID: sql.NullString{String: d.ID, Valid: true}, + IsNew: isNew(d.Status), + UpdatedAt: now, + PlanID: cur.ID, + Date: day.String(), + }) + if err != nil { + return fmt.Errorf("plan: swap %s of %s: %w", day, cur.ID, err) + } + if n != 1 { + return fmt.Errorf("plan: %s has no day %s", cur.ID, day) + } + if err := q.MarkWeekPlanEdited(ctx, db.MarkWeekPlanEditedParams{UpdatedAt: now, ID: cur.ID}); err != nil { + return fmt.Errorf("plan: mark %s edited: %w", cur.ID, err) + } + p, err = readPlan(ctx, q, weekStart) + return err + }) + if err != nil { + return Plan{}, err + } + return p, nil +} + +// isNew is plan_days.is_new for a dinner with status: 1 for the Quest log. +func isNew(status string) int64 { + if status == dinners.StatusQuestLog { + return 1 + } + return 0 +} + +func fromDinner(d db.Dinner) Dinner { + out := Dinner{ + ID: d.ID, + Name: d.Name, + Method: nullString(d.Method), + Origin: d.Origin, + } + if d.MinutesTotal.Valid { + out.Minutes = &d.MinutesTotal.Int64 + } + if d.PhotoID.Valid { + u := dinners.PhotoURL(d.PhotoID.String) + out.Photo = &u + } + return out +} + +func nullString(s sql.NullString) *string { + if !s.Valid { + return nil + } + return &s.String +} diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go new file mode 100644 index 0000000..8795f70 --- /dev/null +++ b/internal/plan/plan_test.go @@ -0,0 +1,946 @@ +package plan_test + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "slices" + "strings" + "sync" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// week is the Monday the tests draft, and weekDates its days. +var ( + week = mustDate("2026-10-12") + weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"} +) + +func mustDate(s string) store.LocalDate { + d, err := store.ParseLocalDate(s) + if err != nil { + panic(err) + } + return d +} + +type env struct { + svc *plan.Service + st *store.Store + runner *jobs.Runner + model *llm.Fake + // ids are the seeded dinners' ids by name. + ids map[string]string + + mu sync.Mutex + inputs []skills.WeekRequest +} + +// newEnv returns a plan service over a migrated store in zone +// America/New_York with dinner time 17:30, the Regulars below and Quest +// log dinners Pad Thai and Shawarma Bowls, and an unstarted job runner +// with two workers. It drafts through the real harness and draft_week with +// the scripted e.model. +func newEnv(t *testing.T) *env { + t.Helper() + ctx := t.Context() + st, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil { + t.Fatal(err) + } + e := &env{st: st, model: &llm.Fake{}, ids: map[string]string{}} + for _, d := range []struct { + name, status, method string + minutes int64 + }{ + {"Beef Chili", "regular", "crockpot", 240}, + {"Pot Roast", "regular", "crockpot", 0}, + {"Chicken Fajitas", "regular", "stovetop", 30}, + {"Spaghetti Bolognese", "regular", "stovetop", 45}, + {"Sheet Pan Salmon", "regular", "oven", 35}, + {"Pad Thai", "quest_log", "stovetop", 35}, + {"Shawarma Bowls", "quest_log", "oven", 40}, + } { + e.ids[d.name] = e.dinner(t, d.name, d.status, d.method, d.minutes) + } + guard, err := harness.NewGuard(harness.DefaultMaxCalls) + if err != nil { + t.Fatal(err) + } + cfg := harness.Config{ + Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7}, + NewProvider: func(llm.Settings, string) (llm.Provider, error) { return e.model, nil }, + Store: st, + Guard: guard, + } + drafter := func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) { + e.mu.Lock() + e.inputs = append(e.inputs, in) + e.mu.Unlock() + return skills.DraftWeek(ctx, cfg, scope, in) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + // Two workers, as cmd/server runs, so drafts of a week could overlap. + e.runner, err = jobs.NewRunner(st, logger, 2) + if err != nil { + t.Fatal(err) + } + e.svc, err = plan.NewService(st, drafter, e.runner, logger) + if err != nil { + t.Fatal(err) + } + return e +} + +// input returns the request of the ith draft_week run. +func (e *env) input(i int) skills.WeekRequest { + e.mu.Lock() + defer e.mu.Unlock() + return e.inputs[i] +} + +// start starts the env's job runner, stopping it when the test ends. +func (e *env) start(t *testing.T) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + if err := e.runner.Start(ctx); err != nil { + cancel() + t.Fatal(err) + } + t.Cleanup(func() { + cancel() + e.runner.Wait() + }) +} + +// dinner stores a dinner and returns its id; minutes 0 is unknown. +func (e *env) dinner(t *testing.T, name, status, method string, minutes int64) string { + t.Helper() + now := store.Now().String() + origin := "regular" + if status != "regular" { + origin = "side_quest" + } + d, err := db.New(e.st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{ + ID: store.NewID(), + Name: name, + NormalizedName: harness.Normalize(name), + Status: status, + Origin: origin, + Method: sql.NullString{String: method, Valid: method != ""}, + MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0}, + GroundingStatus: "manual", + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + t.Fatal(err) + } + return d.ID +} + +// reply scripts one draft_week reply planning the dinners named names on +// weekDates, in order. +func (e *env) reply(note string, names ...string) { + e.model.QueueGenerate(e.weekReply(note, names...)) +} + +// weekReply is the draft_week reply planning the dinners named names on +// weekDates, in order. +func (e *env) weekReply(note string, names ...string) llm.GenerateResult { + return e.weekReplyOn(weekDates, note, names...) +} + +// weekReplyOn is the draft_week reply planning the dinners named names on +// dates, in order. +func (e *env) weekReplyOn(dates []string, note string, names ...string) llm.GenerateResult { + var days []string + for i, name := range names { + days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], e.ids[name])) + } + js := fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note) + return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}} +} + +// fullWeek plans every seeded dinner, Beef Chili on Monday and Pot Roast +// on Tuesday. +var fullWeek = []string{"Beef Chili", "Pot Roast", "Pad Thai", "Chicken Fajitas", "Shawarma Bowls", "Spaghetti Bolognese", "Sheet Pan Salmon"} + +// waitJob waits for job id to finish and returns it. +func (e *env) waitJob(t *testing.T, id string) jobs.Job { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + job, err := e.runner.Get(t.Context(), id) + if err != nil { + t.Fatal(err) + } + if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed { + return job + } + if time.Now().After(deadline) { + t.Fatalf("job %s still %s", id, job.Status) + } + time.Sleep(5 * time.Millisecond) + } +} + +// draft asks for a draft of week with confirm and waits for its job. +func (e *env) draft(t *testing.T, confirm bool) jobs.Job { + t.Helper() + d, err := e.svc.StartDraft(t.Context(), week, confirm) + if err != nil { + t.Fatal(err) + } + if !d.Created { + t.Fatalf("draft job %s was already queued", d.JobID) + } + return e.waitJob(t, d.JobID) +} + +// mustDraft drafts week with confirm, requiring the job to succeed, and +// returns the plan. +func (e *env) mustDraft(t *testing.T, confirm bool) plan.Plan { + t.Helper() + job := e.draft(t, confirm) + if job.Status != jobs.StatusSucceeded { + t.Fatalf("draft job %s, error %v", job.Status, *job.Error) + } + p, err := e.svc.Get(t.Context(), week) + if err != nil { + t.Fatal(err) + } + var res struct { + WeekStart string `json:"week_start"` + PlanID string `json:"plan_id"` + } + if err := json.Unmarshal(job.Result, &res); err != nil { + t.Fatal(err) + } + if res.WeekStart != week.String() || res.PlanID != p.ID { + t.Fatalf("draft result = %+v, want week %s plan %s", res, week, p.ID) + } + return p +} + +// setStatus sets the plan of week to status, as Push to Calendar would. +func (e *env) setStatus(t *testing.T, status string) { + t.Helper() + if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE week_plans SET status = ?, calendar_synced_at = ? WHERE week_start = ?`, + status, store.Now().String(), week.String()); err != nil { + t.Fatal(err) + } +} + +func dayNames(p plan.Plan) []string { + var names []string + for _, d := range p.Days { + names = append(names, d.Dinner.Name) + } + return names +} + +func TestDraftCreatesPlan(t *testing.T) { + e := newEnv(t) + e.start(t) + e.reply("Five Regulars and two from your Quest log.", fullWeek...) + + p := e.mustDraft(t, false) + + if p.Status != plan.StatusDraft || p.WeekStart != "2026-10-12" || p.WeekLabel != "Week 42 · Oct 12 – 18" { + t.Errorf("plan = %s %s %q, want draft 2026-10-12 \"Week 42 · Oct 12 – 18\"", p.Status, p.WeekStart, p.WeekLabel) + } + if p.AINote == nil || *p.AINote != "Five Regulars and two from your Quest log." { + t.Errorf("ai_note = %v", p.AINote) + } + if p.CalendarSyncedAt != nil { + t.Errorf("calendar_synced_at = %v, want null", *p.CalendarSyncedAt) + } + if got := dayNames(p); !slices.Equal(got, fullWeek) { + t.Errorf("days = %v, want %v", got, fullWeek) + } + for i, d := range p.Days { + if d.Date != weekDates[i] { + t.Errorf("day %d date = %s, want %s", i, d.Date, weekDates[i]) + } + wantNew := d.Dinner.Name == "Pad Thai" || d.Dinner.Name == "Shawarma Bowls" + if d.IsNew != wantNew { + t.Errorf("%s is_new = %v, want %v", d.Dinner.Name, d.IsNew, wantNew) + } + if d.Dinner.ID != e.ids[d.Dinner.Name] { + t.Errorf("%s id = %s", d.Dinner.Name, d.Dinner.ID) + } + } + chili := p.Days[0] + if chili.Dinner.Method == nil || *chili.Dinner.Method != "crockpot" || chili.Dinner.Minutes == nil || *chili.Dinner.Minutes != 240 || chili.Dinner.Origin != "regular" { + t.Errorf("Beef Chili dinner = %+v", chili.Dinner) + } + + // The pool is every Regular and Quest log dinner; last week has no + // plan. + in := e.input(0) + var pool []string + for _, c := range in.Pool { + pool = append(pool, c.Name+"/"+c.Kind) + } + slices.Sort(pool) + want := []string{"Beef Chili/regular", "Chicken Fajitas/regular", "Pad Thai/quest_log", "Pot Roast/regular", + "Shawarma Bowls/quest_log", "Sheet Pan Salmon/regular", "Spaghetti Bolognese/regular"} + if !slices.Equal(pool, want) { + t.Errorf("pool = %v, want %v", pool, want) + } + if in.WeekStart != week || in.DinnerTime != "17:30" || len(in.LastWeek) != 0 { + t.Errorf("request week %s dinner time %s last week %v", in.WeekStart, in.DinnerTime, in.LastWeek) + } +} + +func TestStartByIsDinnerTimeMinusMinutesForCrockPot(t *testing.T) { + e := newEnv(t) + e.start(t) + e.reply("", fullWeek...) + p := e.mustDraft(t, false) + + newYork, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatal(err) + } + // Beef Chili: crock pot, 240 minutes, dinner at 17:30 EDT. + if got, want := p.Days[0].StartBy, time.Date(2026, 10, 12, 13, 30, 0, 0, newYork); got == nil || !got.Equal(want) { + t.Errorf("Beef Chili start_by = %v, want %v", got, want) + } + // Pot Roast is a crock-pot dinner with no minutes; the rest are not + // crock-pot dinners. + for _, d := range p.Days[1:] { + if d.StartBy != nil { + t.Errorf("%s start_by = %v, want null", d.Dinner.Name, *d.StartBy) + } + } + if p.AINote != nil { + t.Errorf("ai_note = %q, want null for an empty note", *p.AINote) + } + + // start_by follows the household's dinner time. + dinnerTime := "18:45" + if _, err := household.NewSettingsService(e.st).Update(t.Context(), household.SettingsPatch{DinnerTime: &dinnerTime}, "member-1"); err != nil { + t.Fatal(err) + } + p, err = e.svc.Get(t.Context(), week) + if err != nil { + t.Fatal(err) + } + if got, want := p.Days[0].StartBy, time.Date(2026, 10, 12, 14, 45, 0, 0, newYork); got == nil || !got.Equal(want) { + t.Errorf("Beef Chili start_by = %v, want %v", got, want) + } + if b, err := json.Marshal(p.Days[0]); err != nil || !strings.Contains(string(b), `"start_by":"2026-10-12T14:45:00-04:00"`) { + t.Errorf("day JSON = %s, %v; want start_by in the household zone", b, err) + } +} + +func TestStartByAcrossDaylightSavingTimeEnd(t *testing.T) { + e := newEnv(t) + // New York leaves daylight saving time at 02:00 on Sunday 2026-11-01. + // Beef Chili (240 minutes) is planned Monday to Saturday and a 20-hour + // crock-pot dinner on Sunday, so its start crosses the change. + stew := e.dinner(t, "Overnight Stew", "regular", "crockpot", 1200) + q := db.New(e.st.Writer) + now := store.Now().String() + id := store.NewID() + dst := mustDate("2026-10-26") + if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: dst.String(), CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + dates := []string{"2026-10-26", "2026-10-27", "2026-10-28", "2026-10-29", "2026-10-30", "2026-10-31", "2026-11-01"} + for i, date := range dates { + dinner := e.ids["Beef Chili"] + if i == 6 { + dinner = stew + } + if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: date, + DinnerID: sql.NullString{String: dinner, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + } + p, err := e.svc.Get(t.Context(), dst) + if err != nil { + t.Fatal(err) + } + // Saturday's dinner is 17:30 EDT; Sunday's is 17:30 EST, 22:30 UTC, and + // twenty hours before it is 22:30 EDT on Saturday. + for _, tc := range []struct { + day int + want string + }{ + {5, `"2026-10-31T13:30:00-04:00"`}, + {6, `"2026-10-31T22:30:00-04:00"`}, + } { + b, err := json.Marshal(p.Days[tc.day].StartBy) + if err != nil || string(b) != tc.want { + t.Errorf("%s start_by = %s, %v; want %s", dates[tc.day], b, err, tc.want) + } + } + // Swapped to Short Ribs (240 minutes), Sunday starts at 13:30 EST. + ribs := e.dinner(t, "Short Ribs", "regular", "crockpot", 240) + if _, err := e.svc.Swap(t.Context(), dst, "2026-11-01", ribs); err != nil { + t.Fatal(err) + } + if p, err = e.svc.Get(t.Context(), dst); err != nil { + t.Fatal(err) + } + if b, err := json.Marshal(p.Days[6].StartBy); err != nil || string(b) != `"2026-11-01T13:30:00-05:00"` { + t.Errorf("Sunday Short Ribs start_by = %s, %v; want 13:30 EST", b, err) + } +} + +func TestDraftGivesLastWeek(t *testing.T) { + e := newEnv(t) + e.start(t) + last := mustDate("2026-10-05") + q := db.New(e.st.Writer) + now := store.Now().String() + id := store.NewID() + if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: last.String(), CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: "2026-10-06", + DinnerID: sql.NullString{String: e.ids["Chicken Fajitas"], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + e.reply("", fullWeek...) + e.mustDraft(t, false) + if got := e.input(0).LastWeek; !slices.Equal(got, []string{e.ids["Chicken Fajitas"]}) { + t.Errorf("last week = %v, want Chicken Fajitas", got) + } +} + +func TestDraftPoolLeavesOutQuestLogPlannedOnAnotherWeek(t *testing.T) { + e := newEnv(t) + e.start(t) + // Pad Thai is on a later week's plan, so the Quest log no longer + // offers it; an eighth dinner keeps the pool at seven. + q := db.New(e.st.Writer) + now := store.Now().String() + later := store.NewID() + if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: later, WeekStart: "2099-01-05", CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: later, Date: "2099-01-05", + DinnerID: sql.NullString{String: e.ids["Pad Thai"], Valid: true}, IsNew: 1, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "", 0) + names := []string{"Beef Chili", "Pot Roast", "Fish Tacos", "Chicken Fajitas", "Shawarma Bowls", "Spaghetti Bolognese", "Sheet Pan Salmon"} + e.reply("", names...) + p := e.mustDraft(t, false) + if got := dayNames(p); !slices.Equal(got, names) { + t.Errorf("days = %v", got) + } + for _, c := range e.input(0).Pool { + if c.Name == "Pad Thai" { + t.Errorf("pool has Pad Thai, planned on a later week") + } + } + // A redraft of the week keeps offering its own Quest log dinners. + e.reply("", names...) + e.mustDraft(t, false) + if !slices.ContainsFunc(e.input(1).Pool, func(c skills.Candidate) bool { return c.Name == "Shawarma Bowls" }) { + t.Errorf("redraft pool left out Shawarma Bowls, planned on this week") + } +} + +func TestDraftFailsWhenPoolDinnerLeavesWhileDrafting(t *testing.T) { + for _, status := range []string{"archived", "tried"} { + t.Run(status, func(t *testing.T) { + e := newEnv(t) + e.start(t) + // Pad Thai leaves the Quest log while draft_week plans it. + started, release := make(chan struct{}), make(chan struct{}) + r := e.weekReply("", fullWeek...) + r.Started, r.Release = started, release + e.model.QueueGenerate(r) + d, err := e.svc.StartDraft(t.Context(), week, false) + if err != nil { + t.Fatal(err) + } + <-started + if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = ? WHERE id = ?`, status, e.ids["Pad Thai"]); err != nil { + t.Fatal(err) + } + close(release) + job := e.waitJob(t, d.JobID) + if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged { + t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged) + } + if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) { + t.Errorf("Get = %v, want ErrNotFound: no partial plan", err) + } + }) + } +} + +func TestDraftsOfTwoWeeksDoNotShareAQuestLogDinner(t *testing.T) { + e := newEnv(t) + e.start(t) + // This week's draft plans Pad Thai; while draft_week runs, the other + // worker drafts a later week with Pad Thai too and stores it first. + started, release := make(chan struct{}), make(chan struct{}) + r := e.weekReply("", fullWeek...) + r.Started, r.Release = started, release + e.model.QueueGenerate(r) + d, err := e.svc.StartDraft(t.Context(), week, false) + if err != nil { + t.Fatal(err) + } + <-started + later := mustDate("2099-01-05") + laterDates := []string{"2099-01-05", "2099-01-06", "2099-01-07", "2099-01-08", "2099-01-09", "2099-01-10", "2099-01-11"} + e.model.QueueGenerate(e.weekReplyOn(laterDates, "", fullWeek...)) + other, err := e.svc.StartDraft(t.Context(), later, false) + if err != nil { + t.Fatal(err) + } + if job := e.waitJob(t, other.JobID); job.Status != jobs.StatusSucceeded { + t.Fatalf("later week's draft %s %v", job.Status, job.Error) + } + close(release) + job := e.waitJob(t, d.JobID) + if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged { + t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged) + } + if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) { + t.Errorf("Get = %v, want ErrNotFound: no partial plan", err) + } +} + +func TestDraftFailsWithNotEnoughDinners(t *testing.T) { + e := newEnv(t) + e.start(t) + if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.ids["Pad Thai"]); err != nil { + t.Fatal(err) + } + job := e.draft(t, false) + if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonNotEnoughDinners { + t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonNotEnoughDinners) + } + if n := len(e.model.Requests()); n != 0 { + t.Errorf("model called %d times", n) + } + if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) { + t.Errorf("Get = %v, want ErrNotFound", err) + } +} + +func TestDraftFailsWhenAIFails(t *testing.T) { + e := newEnv(t) + e.start(t) + e.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")}) + job := e.draft(t, false) + if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonAIFailed { + t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonAIFailed) + } + if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) { + t.Errorf("Get = %v, want ErrNotFound", err) + } +} + +func TestStartDraftIsSingleFlight(t *testing.T) { + e := newEnv(t) + first, err := e.svc.StartDraft(t.Context(), week, false) + if err != nil { + t.Fatal(err) + } + again, err := e.svc.StartDraft(t.Context(), week, false) + if err != nil { + t.Fatal(err) + } + if !first.Created || again.Created || again.JobID != first.JobID { + t.Fatalf("drafts = %+v then %+v, want the second to join the first", first, again) + } + other, err := e.svc.StartDraft(t.Context(), mustDate("2026-10-19"), false) + if err != nil { + t.Fatal(err) + } + if !other.Created || other.JobID == first.JobID { + t.Errorf("another week's draft = %+v, want its own job", other) + } +} + +func TestSimultaneousStartDraftsShareOneJob(t *testing.T) { + for _, confirmFirst := range []bool{false, true} { + t.Run(fmt.Sprintf("first confirm %v", confirmFirst), func(t *testing.T) { + e := newEnv(t) + first, err := e.svc.StartDraft(t.Context(), week, confirmFirst) + if err != nil { + t.Fatal(err) + } + // Twenty simultaneous drafts, half of them confirmed: every + // one joins the queued job, except a confirmed one while the + // job is unconfirmed, which is refused. + const n = 20 + drafts := make([]plan.Draft, n) + errs := make([]error, n) + var wg sync.WaitGroup + for i := range n { + wg.Go(func() { + drafts[i], errs[i] = e.svc.StartDraft(context.Background(), week, i%2 == 1) + }) + } + wg.Wait() + for i, d := range drafts { + if i%2 == 1 && !confirmFirst { + if !errors.Is(errs[i], plan.ErrDraftRunning) { + t.Errorf("confirmed draft %d = %+v, %v, want ErrDraftRunning", i, d, errs[i]) + } + continue + } + if errs[i] != nil || d.JobID != first.JobID || d.Created { + t.Errorf("draft %d = %+v, %v, want it to join %s", i, d, errs[i], first.JobID) + } + } + var queued int + if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil { + t.Fatal(err) + } + if queued != 1 { + t.Errorf("%d draft jobs, want 1", queued) + } + }) + } +} + +func TestConfirmedDraftWhileUnconfirmedDraftRuns(t *testing.T) { + e := newEnv(t) + e.start(t) + e.reply("first", fullWeek...) + e.mustDraft(t, false) + + // An unconfirmed redraft is running when the plan is synced; the + // household then confirms a redraft. With a second worker idle, a + // second job would draft alongside the first, so the confirmed draft + // is refused and only the first job runs draft_week. + reversed := slices.Clone(fullWeek) + slices.Reverse(reversed) + started, release := make(chan struct{}), make(chan struct{}) + r := e.weekReply("unconfirmed", fullWeek...) + r.Started, r.Release = started, release + e.model.QueueGenerate(r) + e.reply("concurrent", reversed...) + unconfirmed, err := e.svc.StartDraft(t.Context(), week, false) + if err != nil { + t.Fatal(err) + } + <-started + e.setStatus(t, plan.StatusSynced) + if d, err := e.svc.StartDraft(t.Context(), week, true); !errors.Is(err, plan.ErrDraftRunning) { + t.Fatalf("confirmed draft = %+v, %v, want ErrDraftRunning", d, err) + } + close(release) + if job := e.waitJob(t, unconfirmed.JobID); job.Status != jobs.StatusFailed || *job.Error != plan.ReasonConfirmRequired { + t.Errorf("unconfirmed job %s %v, want failed %s", job.Status, job.Error, plan.ReasonConfirmRequired) + } + var queued int + if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil { + t.Fatal(err) + } + if queued != 2 { + t.Errorf("%d draft jobs, want the first and the unconfirmed redraft", queued) + } + p, err := e.svc.Get(t.Context(), week) + if err != nil { + t.Fatal(err) + } + if p.Status != plan.StatusSynced || *p.AINote != "first" || !slices.Equal(dayNames(p), fullWeek) { + t.Errorf("plan = %s %q %v, want the synced first draft", p.Status, *p.AINote, dayNames(p)) + } + + // Asked again once the unconfirmed job has finished, the confirmed + // draft replaces the plan. + if p := e.mustDraft(t, true); p.Status != plan.StatusChanged || *p.AINote != "concurrent" || !slices.Equal(dayNames(p), reversed) { + t.Errorf("plan = %s %q %v, want the confirmed draft, changed", p.Status, *p.AINote, dayNames(p)) + } +} + +func TestDraftFailsWhenPlanSwappedWhileDrafting(t *testing.T) { + e := newEnv(t) + e.start(t) + e.reply("first", fullWeek...) + e.mustDraft(t, false) + + // The household swaps Wednesday while draft_week runs; the draft must + // not erase the swap. + reversed := slices.Clone(fullWeek) + slices.Reverse(reversed) + started, release := make(chan struct{}), make(chan struct{}) + r := e.weekReply("second", reversed...) + r.Started, r.Release = started, release + e.model.QueueGenerate(r) + d, err := e.svc.StartDraft(t.Context(), week, false) + if err != nil { + t.Fatal(err) + } + <-started + e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25) + if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"]); err != nil { + t.Fatal(err) + } + close(release) + job := e.waitJob(t, d.JobID) + if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPlanChanged { + t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPlanChanged) + } + p, err := e.svc.Get(t.Context(), week) + if err != nil { + t.Fatal(err) + } + want := slices.Clone(fullWeek) + want[2] = "Fish Tacos" + if *p.AINote != "first" || !slices.Equal(dayNames(p), want) { + t.Errorf("plan = %q %v, want the first draft with the swap %v", *p.AINote, dayNames(p), want) + } + + // A draft after the swap replaces the plan. + e.reply("third", reversed...) + if p := e.mustDraft(t, false); *p.AINote != "third" || !slices.Equal(dayNames(p), reversed) { + t.Errorf("redraft after the swap = %q %v, want the third draft", *p.AINote, dayNames(p)) + } +} + +func TestRedraftOverDraftNeedsNoConfirm(t *testing.T) { + e := newEnv(t) + e.start(t) + e.reply("first", fullWeek...) + first := e.mustDraft(t, false) + reversed := slices.Clone(fullWeek) + slices.Reverse(reversed) + e.reply("second", reversed...) + second := e.mustDraft(t, false) + if second.ID != first.ID || second.Status != plan.StatusDraft || *second.AINote != "second" { + t.Errorf("redraft = %s %s %v, want plan %s draft \"second\"", second.ID, second.Status, *second.AINote, first.ID) + } + if got := dayNames(second); !slices.Equal(got, reversed) { + t.Errorf("days = %v, want %v", got, reversed) + } +} + +func TestDraftOverSyncedOrChangedPlanNeedsConfirm(t *testing.T) { + for _, status := range []string{plan.StatusSynced, plan.StatusChanged} { + t.Run(status, func(t *testing.T) { + e := newEnv(t) + e.start(t) + e.reply("first", fullWeek...) + first := e.mustDraft(t, false) + e.setStatus(t, status) + // Push to Calendar records each day's event id. + if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE plan_days SET calendar_event_id = 'ev-' || date`); err != nil { + t.Fatal(err) + } + + if _, err := e.svc.StartDraft(t.Context(), week, false); !errors.Is(err, plan.ErrConfirmRequired) { + t.Fatalf("StartDraft without confirm = %v, want ErrConfirmRequired", err) + } + var queued int + if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil { + t.Fatal(err) + } + if queued != 1 { + t.Errorf("%d draft jobs, want only the first", queued) + } + + reversed := slices.Clone(fullWeek) + slices.Reverse(reversed) + e.reply("second", reversed...) + p := e.mustDraft(t, true) + if p.ID != first.ID || p.Status != plan.StatusChanged || p.CalendarSyncedAt == nil { + t.Errorf("confirmed redraft = %s %s synced %v, want plan %s changed, still synced once", p.ID, p.Status, p.CalendarSyncedAt, first.ID) + } + if got := dayNames(p); !slices.Equal(got, reversed) { + t.Errorf("days = %v, want %v", got, reversed) + } + var events int + if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM plan_days WHERE calendar_event_id = 'ev-' || date`).Scan(&events); err != nil { + t.Fatal(err) + } + if events != 7 { + t.Errorf("%d days kept their calendar event id, want 7", events) + } + }) + } +} + +func TestDraftFailsWhenPlanSyncedAfterAskingWithoutConfirm(t *testing.T) { + e := newEnv(t) + e.start(t) + e.reply("first", fullWeek...) + e.mustDraft(t, false) + + // The redraft is asked for while the plan is draft; the plan is synced + // while draft_week runs, so the draft must not replace it. + reversed := slices.Clone(fullWeek) + slices.Reverse(reversed) + started, release := make(chan struct{}), make(chan struct{}) + r := e.weekReply("second", reversed...) + r.Started, r.Release = started, release + e.model.QueueGenerate(r) + d, err := e.svc.StartDraft(t.Context(), week, false) + if err != nil { + t.Fatal(err) + } + <-started + e.setStatus(t, plan.StatusSynced) + close(release) + job := e.waitJob(t, d.JobID) + if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonConfirmRequired { + t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonConfirmRequired) + } + p, err := e.svc.Get(t.Context(), week) + if err != nil { + t.Fatal(err) + } + if p.Status != plan.StatusSynced || *p.AINote != "first" || !slices.Equal(dayNames(p), fullWeek) { + t.Errorf("plan = %s %q %v, want the synced first draft", p.Status, *p.AINote, dayNames(p)) + } +} + +func TestParseWeekStart(t *testing.T) { + if d, err := plan.ParseWeekStart("2026-10-12"); err != nil || d != week { + t.Errorf("ParseWeekStart(Monday) = %v, %v", d, err) + } + // 2026-02-30 and 2027-02-29 would be Mondays were they dates. + for _, s := range []string{"2026-10-13", "2026-10-18", "2026-10-5", "2026-02-30", "2027-02-29", "2026-13-05", "next-week", ""} { + var invalid *plan.InvalidWeekError + if _, err := plan.ParseWeekStart(s); !errors.As(err, &invalid) { + t.Errorf("ParseWeekStart(%q) = %v, want *InvalidWeekError", s, err) + } + } +} + +func TestSwapDay(t *testing.T) { + e := newEnv(t) + e.start(t) + e.reply("", fullWeek...) + e.mustDraft(t, false) + e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25) + e.ids["Ramen"] = e.dinner(t, "Ramen", "quest_log", "crockpot", 300) + + // A Quest log dinner is new, and a crock-pot one gets its start_by. + p, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Ramen"]) + if err != nil { + t.Fatal(err) + } + day := p.Days[2] + if day.Dinner.Name != "Ramen" || !day.IsNew || day.StartBy == nil || day.StartBy.Format("15:04") != "12:30" { + t.Errorf("Wednesday = %+v, want Ramen, new, start by 12:30", day) + } + if p.Status != plan.StatusDraft { + t.Errorf("status = %s, want a swapped draft to stay draft", p.Status) + } + + // A Regular in place of a Quest log dinner is not new. + p, err = e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"]) + if err != nil { + t.Fatal(err) + } + if day := p.Days[2]; day.Dinner.Name != "Fish Tacos" || day.IsNew || day.StartBy != nil { + t.Errorf("Wednesday = %+v, want Fish Tacos, not new, no start_by", day) + } + want := slices.Clone(fullWeek) + want[2] = "Fish Tacos" + if got := dayNames(p); !slices.Equal(got, want) { + t.Errorf("days = %v, want %v", got, want) + } + got, err := e.svc.Get(t.Context(), week) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(dayNames(got), want) { + t.Errorf("Get days = %v, want %v", dayNames(got), want) + } +} + +func TestSwapStatusTransitions(t *testing.T) { + for _, tc := range []struct{ from, want string }{ + {plan.StatusDraft, plan.StatusDraft}, + {plan.StatusSynced, plan.StatusChanged}, + {plan.StatusChanged, plan.StatusChanged}, + } { + t.Run(tc.from, func(t *testing.T) { + e := newEnv(t) + e.start(t) + e.reply("", fullWeek...) + e.mustDraft(t, false) + e.setStatus(t, tc.from) + fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25) + p, err := e.svc.Swap(t.Context(), week, "2026-10-12", fishTacos) + if err != nil { + t.Fatal(err) + } + if p.Status != tc.want { + t.Errorf("status after swap = %s, want %s", p.Status, tc.want) + } + }) + } +} + +func TestSwapRefusals(t *testing.T) { + e := newEnv(t) + e.start(t) + e.reply("", fullWeek...) + e.mustDraft(t, false) + e.setStatus(t, plan.StatusSynced) + suggested := e.dinner(t, "Laksa", "suggested", "stovetop", 30) + archived := e.dinner(t, "Old Stew", "archived", "", 0) + + var notPlannable *plan.DinnerNotPlannableError + var badDay *plan.InvalidDayError + for _, tc := range []struct { + name string + week store.LocalDate + date, id string + wantError func(error) bool + }{ + {"no plan", mustDate("2026-10-19"), "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.Is(err, plan.ErrNotFound) }}, + {"unknown dinner", week, "2026-10-12", "no-such-dinner", func(err error) bool { return errors.Is(err, plan.ErrDinnerNotFound) }}, + {"dinner on another day", week, "2026-10-12", e.ids["Pot Roast"], func(err error) bool { return errors.Is(err, plan.ErrDinnerAlreadyPlanned) }}, + {"suggested dinner", week, "2026-10-12", suggested, func(err error) bool { return errors.As(err, ¬Plannable) }}, + {"archived dinner", week, "2026-10-12", archived, func(err error) bool { return errors.As(err, ¬Plannable) }}, + {"day of another week", week, "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }}, + {"not a date", week, "monday", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }}, + {"impossible date", week, "2026-10-32", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }}, + } { + if _, err := e.svc.Swap(t.Context(), tc.week, tc.date, tc.id); !tc.wantError(err) { + t.Errorf("%s: Swap = %v", tc.name, err) + } + } + p, err := e.svc.Get(t.Context(), week) + if err != nil { + t.Fatal(err) + } + if p.Status != plan.StatusSynced || !slices.Equal(dayNames(p), fullWeek) { + t.Errorf("plan = %s %v, want it unchanged by refused swaps", p.Status, dayNames(p)) + } +} diff --git a/internal/plan/rotation.go b/internal/plan/rotation.go new file mode 100644 index 0000000..129987e --- /dev/null +++ b/internal/plan/rotation.go @@ -0,0 +1,223 @@ +package plan + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// Rotation check decisions (the rotation_reviews.decision column), the +// household's answer to "Keep it in rotation?" (D-021). +const ( + // DecisionKeep makes the tried dinner a Regular. + DecisionKeep = "keep" + // DecisionDrop ("Not for us") archives it; its name stays among the + // passed names, so it is never suggested again. + DecisionDrop = "drop" + // DecisionNotMade ("We didn't make it") returns it to the Quest log. + DecisionNotMade = "not_made" +) + +// decisionStatus is the status each decision moves a tried dinner to. Keep +// is the only way a tried dinner becomes a Regular: nothing automatic +// promotes one (D-021). +var decisionStatus = map[string]string{ + DecisionKeep: dinners.StatusRegular, + DecisionDrop: dinners.StatusArchived, + DecisionNotMade: dinners.StatusQuestLog, +} + +// ErrReviewNotFound is a decision on a dinner that has never had a +// Rotation check: it was never tried. +var ErrReviewNotFound = errors.New("the dinner has no Rotation check") + +// ErrNoPendingReview is a decision on a dinner whose Rotation checks are +// all decided, such as a second decision. +var ErrNoPendingReview = errors.New("the dinner has no pending Rotation check") + +// ErrDinnerNotTried is a decision on a pending Rotation check whose dinner +// is no longer tried, such as one archived meanwhile. +var ErrDinnerNotTried = errors.New("the dinner is no longer tried") + +// InvalidDecisionError is a decision that is not keep, drop or not_made. +type InvalidDecisionError struct { + Value string +} + +func (e *InvalidDecisionError) Error() string { + return fmt.Sprintf("decision %q is not one of keep, drop, not_made", e.Value) +} + +// ReviewDinner is the dinner of a Rotation check. +type ReviewDinner struct { + ID string `json:"id"` + Name string `json:"name"` + Photo *string `json:"photo"` +} + +// Review is a pending Rotation check: the dinner tried and the plan date +// it was planned on. +type Review struct { + Dinner ReviewDinner `json:"dinner"` + // PlanDate is household-local, YYYY-MM-DD. + PlanDate string `json:"plan_date"` +} + +// Decided is a Rotation check as decided. +type Decided struct { + Dinner ReviewDinner `json:"dinner"` + PlanDate string `json:"plan_date"` + Decision string `json:"decision"` + // DinnerStatus is the status the decision moved the dinner to. + DinnerStatus string `json:"dinner_status"` + DecidedAt string `json:"decided_at"` + UpdatedBy string `json:"updated_by"` +} + +// MarkTried moves every dinner in the Quest log planned on date, the +// household-local day just past, on a plan of any status, to tried and +// opens a pending Rotation check of it for date. It is idempotent: a +// dinner already checked for date is left as it is, so a later run never +// moves one returned to the Quest log ("We didn't make it") again. It +// returns how many dinners it moved. +func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, error) { + var n int + err := s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + ids, err := q.ListTriedCandidates(ctx, date.String()) + if err != nil { + return fmt.Errorf("plan: list Quest log dinners planned on %s: %w", date, err) + } + now := store.Now().String() + for _, id := range ids { + moved, err := q.MarkDinnerTried(ctx, db.MarkDinnerTriedParams{UpdatedAt: now, ID: id}) + if err != nil { + return fmt.Errorf("plan: mark %s tried: %w", id, err) + } + if moved != 1 { + return fmt.Errorf("plan: dinner %s left the Quest log while marking it tried", id) + } + if err := q.CreateRotationReview(ctx, db.CreateRotationReviewParams{ + ID: store.NewID(), DinnerID: id, PlanDate: date.String(), CreatedAt: now, UpdatedAt: now, + }); err != nil { + return fmt.Errorf("plan: open Rotation check of %s for %s: %w", id, date, err) + } + } + n = len(ids) + return nil + }) + if err != nil { + return 0, err + } + return n, nil +} + +// PendingReviews returns the pending Rotation checks, oldest plan date +// first. +func (s *Service) PendingReviews(ctx context.Context) ([]Review, error) { + rows, err := db.New(s.st.Reader).ListPendingRotationReviews(ctx) + if err != nil { + return nil, fmt.Errorf("plan: list pending Rotation checks: %w", err) + } + out := make([]Review, len(rows)) + for i, r := range rows { + out[i] = Review{Dinner: reviewDinner(r.Dinner), PlanDate: r.PlanDate} + } + return out, nil +} + +// DecideReview applies decision to the pending Rotation check of dinnerID +// and closes it, recording updatedBy: keep makes the dinner a Regular, +// drop archives it and not_made returns it to the Quest log; its origin is +// kept; a drop records the dinner's name on the check, so renaming the +// dinner later never takes it off the passed names. An unknown dinner is +// ErrDinnerNotFound, a dinner never checked ErrReviewNotFound, one whose +// checks are all decided (such as a second decision) ErrNoPendingReview, +// and one no longer tried ErrDinnerNotTried; none of them changes anything. +func (s *Service) DecideReview(ctx context.Context, dinnerID, decision, updatedBy string) (Decided, error) { + status, ok := decisionStatus[decision] + if !ok { + return Decided{}, &InvalidDecisionError{Value: decision} + } + if updatedBy == "" { + return Decided{}, errors.New("plan: decide Rotation check: no member to record as updated_by") + } + var out Decided + err := s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + d, err := q.GetDinner(ctx, dinnerID) + if errors.Is(err, sql.ErrNoRows) { + return ErrDinnerNotFound + } + if err != nil { + return fmt.Errorf("plan: read dinner %s: %w", dinnerID, err) + } + review, err := q.GetPendingRotationReview(ctx, d.ID) + if errors.Is(err, sql.ErrNoRows) { + checks, err := q.CountRotationReviews(ctx, d.ID) + if err != nil { + return fmt.Errorf("plan: count Rotation checks of %s: %w", d.ID, err) + } + if checks == 0 { + return ErrReviewNotFound + } + return ErrNoPendingReview + } + if err != nil { + return fmt.Errorf("plan: read Rotation check of %s: %w", d.ID, err) + } + now := store.Now().String() + moved, err := q.MoveTriedDinner(ctx, db.MoveTriedDinnerParams{ + Status: status, + UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, + UpdatedAt: now, + ID: d.ID, + }) + if err != nil { + return fmt.Errorf("plan: move %s to %s: %w", d.ID, status, err) + } + if moved != 1 { + return ErrDinnerNotTried + } + closed, err := q.DecideRotationReview(ctx, db.DecideRotationReviewParams{ + Decision: sql.NullString{String: decision, Valid: true}, + Name: sql.NullString{String: d.Name, Valid: decision == DecisionDrop}, + Now: sql.NullString{String: now, Valid: true}, + UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, + ID: review.ID, + }) + if err != nil { + return fmt.Errorf("plan: close Rotation check %s: %w", review.ID, err) + } + if closed != 1 { + return fmt.Errorf("plan: Rotation check %s was decided while deciding it", review.ID) + } + out = Decided{ + Dinner: reviewDinner(d), + PlanDate: review.PlanDate, + Decision: decision, + DinnerStatus: status, + DecidedAt: now, + UpdatedBy: updatedBy, + } + return nil + }) + if err != nil { + return Decided{}, err + } + return out, nil +} + +func reviewDinner(d db.Dinner) ReviewDinner { + out := ReviewDinner{ID: d.ID, Name: d.Name} + if d.PhotoID.Valid { + u := dinners.PhotoURL(d.PhotoID.String) + out.Photo = &u + } + return out +} diff --git a/internal/plan/rotation_test.go b/internal/plan/rotation_test.go new file mode 100644 index 0000000..f5b1f03 --- /dev/null +++ b/internal/plan/rotation_test.go @@ -0,0 +1,379 @@ +package plan_test + +import ( + "database/sql" + "errors" + "io/fs" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +const reviewer = "member-1" + +// Pad Thai and Shawarma Bowls, the seeded Quest log dinners, are planned +// on these days of fullWeek. +var ( + padThaiDay = mustDate("2026-10-14") + shawarmaDay = mustDate("2026-10-16") +) + +// putWeek stores fullWeek as the plan of week with status, as a draft and +// Push to Calendar would. +func (e *env) putWeek(t *testing.T, status string) { + t.Helper() + q := db.New(e.st.Writer) + now := store.Now().String() + id := store.NewID() + if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: week.String(), CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + for i, name := range fullWeek { + if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: weekDates[i], + DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + } + e.setStatus(t, status) +} + +func (e *env) dinnerRow(t *testing.T, name string) db.Dinner { + t.Helper() + d, err := db.New(e.st.Reader).GetDinner(t.Context(), e.ids[name]) + if err != nil { + t.Fatal(err) + } + return d +} + +// review is a rotation_reviews row as stored. +type review struct { + planDate, status string + decision, name, decidedAt, updatedBy sql.NullString +} + +func (e *env) reviews(t *testing.T, name string) []review { + t.Helper() + rows, err := e.st.Reader.QueryContext(t.Context(), + `SELECT plan_date, status, decision, name, decided_at, updated_by FROM rotation_reviews WHERE dinner_id = ? ORDER BY plan_date`, e.ids[name]) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []review + for rows.Next() { + var r review + if err := rows.Scan(&r.planDate, &r.status, &r.decision, &r.name, &r.decidedAt, &r.updatedBy); err != nil { + t.Fatal(err) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +} + +func (e *env) markTried(t *testing.T, date store.LocalDate, want int) { + t.Helper() + n, err := e.svc.MarkTried(t.Context(), date) + if err != nil { + t.Fatal(err) + } + if n != want { + t.Fatalf("MarkTried(%s) = %d, want %d", date, n, want) + } +} + +func TestMarkTried(t *testing.T) { + e := newEnv(t) + // Any plan status: a plan pushed to Calendar and edited still counts. + e.putWeek(t, plan.StatusChanged) + + e.markTried(t, padThaiDay, 1) + if got := e.dinnerRow(t, "Pad Thai").Status; got != "tried" { + t.Errorf("Pad Thai status = %s, want tried", got) + } + got := e.reviews(t, "Pad Thai") + if len(got) != 1 || got[0].planDate != padThaiDay.String() || got[0].status != "pending" || + got[0].decision.Valid || got[0].name.Valid || got[0].decidedAt.Valid || got[0].updatedBy.Valid { + t.Fatalf("Pad Thai reviews = %+v, want one pending for %s", got, padThaiDay) + } + if got := e.dinnerRow(t, "Shawarma Bowls").Status; got != "quest_log" { + t.Errorf("Shawarma Bowls, planned on another day, status = %s, want quest_log", got) + } + + // Idempotent: a second run moves nothing and opens no second check. + e.markTried(t, padThaiDay, 0) + if got := e.reviews(t, "Pad Thai"); len(got) != 1 || got[0].status != "pending" { + t.Errorf("Pad Thai reviews after a second run = %+v, want the one pending", got) + } + + // A day planning a Regular, or with no plan, moves nothing. + e.markTried(t, week, 0) + e.markTried(t, mustDate("2026-10-20"), 0) + if got := e.dinnerRow(t, "Beef Chili").Status; got != "regular" { + t.Errorf("Beef Chili status = %s, want regular", got) + } + if got := e.reviews(t, "Beef Chili"); len(got) != 0 { + t.Errorf("Beef Chili reviews = %+v, want none", got) + } +} + +// A dinner returned to the Quest log ("We didn't make it") is not marked +// tried again for the same day. +func TestMarkTriedAfterNotMade(t *testing.T) { + e := newEnv(t) + e.putWeek(t, plan.StatusDraft) + e.markTried(t, padThaiDay, 1) + if _, err := e.svc.DecideReview(t.Context(), e.ids["Pad Thai"], plan.DecisionNotMade, reviewer); err != nil { + t.Fatal(err) + } + e.markTried(t, padThaiDay, 0) + if got := e.dinnerRow(t, "Pad Thai").Status; got != "quest_log" { + t.Errorf("Pad Thai status = %s, want quest_log", got) + } + if got := e.reviews(t, "Pad Thai"); len(got) != 1 || got[0].status != "decided" { + t.Errorf("Pad Thai reviews = %+v, want the one decided", got) + } +} + +func TestPendingReviews(t *testing.T) { + e := newEnv(t) + e.putWeek(t, plan.StatusDraft) + got, err := e.svc.PendingReviews(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("pending before MarkTried = %+v, want none", got) + } + + e.markTried(t, shawarmaDay, 1) + e.markTried(t, padThaiDay, 1) + got, err = e.svc.PendingReviews(t.Context()) + if err != nil { + t.Fatal(err) + } + want := []plan.Review{ + {Dinner: plan.ReviewDinner{ID: e.ids["Pad Thai"], Name: "Pad Thai"}, PlanDate: padThaiDay.String()}, + {Dinner: plan.ReviewDinner{ID: e.ids["Shawarma Bowls"], Name: "Shawarma Bowls"}, PlanDate: shawarmaDay.String()}, + } + if !slices.Equal(got, want) { + t.Errorf("pending = %+v, want %+v", got, want) + } +} + +func TestDecideReview(t *testing.T) { + for _, tc := range []struct { + decision, status string + }{ + {plan.DecisionKeep, "regular"}, + {plan.DecisionDrop, "archived"}, + {plan.DecisionNotMade, "quest_log"}, + } { + t.Run(tc.decision, func(t *testing.T) { + e := newEnv(t) + e.putWeek(t, plan.StatusDraft) + e.markTried(t, padThaiDay, 1) + + d, err := e.svc.DecideReview(t.Context(), e.ids["Pad Thai"], tc.decision, reviewer) + if err != nil { + t.Fatal(err) + } + if d.Dinner.ID != e.ids["Pad Thai"] || d.Dinner.Name != "Pad Thai" || d.PlanDate != padThaiDay.String() || + d.Decision != tc.decision || d.DinnerStatus != tc.status || d.DecidedAt == "" || d.UpdatedBy != reviewer { + t.Errorf("decided = %+v", d) + } + row := e.dinnerRow(t, "Pad Thai") + if row.Status != tc.status || row.Origin != "side_quest" || row.Name != "Pad Thai" || row.UpdatedBy.String != reviewer { + t.Errorf("Pad Thai = status %s origin %s name %s updated_by %v, want %s side_quest Pad Thai %s", + row.Status, row.Origin, row.Name, row.UpdatedBy, tc.status, reviewer) + } + // Only a drop records the dinner's name. + wantPassed := tc.decision == plan.DecisionDrop + wantName := sql.NullString{} + if wantPassed { + wantName = sql.NullString{String: "Pad Thai", Valid: true} + } + got := e.reviews(t, "Pad Thai") + if len(got) != 1 || got[0].status != "decided" || got[0].decision.String != tc.decision || + got[0].decidedAt.String != d.DecidedAt || got[0].updatedBy.String != reviewer || + got[0].name != wantName { + t.Errorf("reviews = %+v, want one decided %s by %s at %s, named only by a drop", got, tc.decision, reviewer, d.DecidedAt) + } + if pending, err := e.svc.PendingReviews(t.Context()); err != nil || len(pending) != 0 { + t.Errorf("pending after deciding = %+v, %v, want none", pending, err) + } + + // A second decision, of any kind, changes nothing. + for _, again := range []string{plan.DecisionKeep, plan.DecisionDrop, plan.DecisionNotMade} { + if _, err := e.svc.DecideReview(t.Context(), e.ids["Pad Thai"], again, reviewer); !errors.Is(err, plan.ErrNoPendingReview) { + t.Errorf("second decision %s: %v, want ErrNoPendingReview", again, err) + } + } + if got := e.dinnerRow(t, "Pad Thai").Status; got != tc.status { + t.Errorf("status after a second decision = %s, want %s", got, tc.status) + } + + passed, err := db.New(e.st.Reader).ListPassedNames(t.Context()) + if err != nil { + t.Fatal(err) + } + recent, err := db.New(e.st.Reader).ListRecentPassedNames(t.Context(), 10) + if err != nil { + t.Fatal(err) + } + if slices.Contains(passed, "Pad Thai") != wantPassed || slices.Contains(recent, "Pad Thai") != wantPassed { + t.Errorf("passed names = %v, recent %v; want Pad Thai among them: %t", passed, recent, wantPassed) + } + }) + } +} + +func TestDecideReviewRefused(t *testing.T) { + e := newEnv(t) + e.putWeek(t, plan.StatusDraft) + e.markTried(t, padThaiDay, 1) + ctx := t.Context() + + var invalid *plan.InvalidDecisionError + if _, err := e.svc.DecideReview(ctx, e.ids["Pad Thai"], "promote", reviewer); !errors.As(err, &invalid) { + t.Errorf("decision promote: %v, want *InvalidDecisionError", err) + } + if _, err := e.svc.DecideReview(ctx, "nope", plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrDinnerNotFound) { + t.Errorf("unknown dinner: %v, want ErrDinnerNotFound", err) + } + // Never tried: Shawarma Bowls is in the Quest log with no check. + if _, err := e.svc.DecideReview(ctx, e.ids["Shawarma Bowls"], plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrReviewNotFound) { + t.Errorf("dinner never tried: %v, want ErrReviewNotFound", err) + } + if got := e.dinnerRow(t, "Shawarma Bowls").Status; got != "quest_log" { + t.Errorf("Shawarma Bowls status = %s, want quest_log", got) + } + if _, err := e.svc.DecideReview(ctx, e.ids["Pad Thai"], plan.DecisionKeep, ""); err == nil { + t.Error("decision with no member succeeded") + } + + // A tried dinner archived meanwhile is neither kept nor listed. + if _, err := e.st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.ids["Pad Thai"]); err != nil { + t.Fatal(err) + } + if _, err := e.svc.DecideReview(ctx, e.ids["Pad Thai"], plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrDinnerNotTried) { + t.Errorf("archived dinner: %v, want ErrDinnerNotTried", err) + } + if got := e.dinnerRow(t, "Pad Thai").Status; got != "archived" { + t.Errorf("Pad Thai status = %s, want archived", got) + } + if got := e.reviews(t, "Pad Thai"); len(got) != 1 || got[0].status != "pending" { + t.Errorf("Pad Thai reviews = %+v, want the one still pending", got) + } + if pending, err := e.svc.PendingReviews(ctx); err != nil || len(pending) != 0 { + t.Errorf("pending = %+v, %v, want none for an archived dinner", pending, err) + } +} + +// TestOnlyKeepPromotesToRegular guards D-021: no code path but the Rotation +// check's keep may make an existing dinner a Regular. Every query writing +// dinners.status and every non-test use of the regular status must be one +// of those reviewed below; a new one fails here until it is reviewed. +func TestOnlyKeepPromotesToRegular(t *testing.T) { + // Each query writing dinners.status, and why it cannot promote. + statusWriters := map[string]string{ + "CreateDinner": "inserts a new dinner; only CreateRegular inserts one as a Regular", + "UpdateDinner": "dinners.Service.Update writes the status it read back", + "ArchiveDinner": "sets archived", + "MoveSuggestedDinner": "Try it / Pass move a suggested dinner to quest_log or archived", + "MarkDinnerTried": "sets tried", + "MoveTriedDinner": "DecideReview's decisionStatus: only keep maps to regular", + } + files, err := filepath.Glob("../../migrations/queries/*.sql") + if err != nil || len(files) == 0 { + t.Fatalf("no query files: %v", err) + } + writes := regexp.MustCompile(`(?is)^\s*(INSERT\s+INTO\s+dinners\b|UPDATE\s+dinners\s+SET\b.*\bstatus\s*=)`) + nameRe := regexp.MustCompile(`^\s*(\w+)`) + found := map[string]bool{} + for _, f := range files { + src, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for _, stmt := range strings.Split(string(src), "-- name:")[1:] { + name := nameRe.FindStringSubmatch(stmt)[1] + var body []string + for _, line := range strings.Split(stmt, "\n")[1:] { + if !strings.HasPrefix(strings.TrimSpace(line), "--") { + body = append(body, line) + } + } + sqlText := strings.Join(body, "\n") + if !writes.MatchString(sqlText) { + continue + } + found[name] = true + if _, ok := statusWriters[name]; !ok { + t.Errorf("%s: query %s writes dinners.status; review it against D-021 and list it here", f, name) + } + if regexp.MustCompile(`(?i)\bstatus\s*=\s*'regular'`).MatchString(sqlText) { + t.Errorf("%s: query %s sets status regular outright", f, name) + } + } + } + for name := range statusWriters { + if !found[name] { + t.Errorf("reviewed query %s no longer writes dinners.status; drop it from the list", name) + } + } + + // Each non-test Go file naming the regular status, and how often. + uses := map[string]int{ + "dinners/dinners.go": 5, // the constant (2), listable, CreateRegular's status and origin + "plan/plan.go": 1, // plannable + "plan/rotation.go": 1, // decisionStatus[DecisionKeep] + "suggest/batch.go": 1, // reads which dinners are Regulars to cite + "ai/harness/context.go": 1, // reads the Regulars + "ai/skills/draft_week.go": 1, // KindRegular, a planned day's label + } + regular := regexp.MustCompile(`StatusRegular\b|"regular"`) + got := map[string]int{} + err = filepath.WalkDir("..", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() && d.Name() == "db" { + return filepath.SkipDir // generated; the queries are checked above + } + if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + src, err := os.ReadFile(path) + if err != nil { + return err + } + if n := len(regular.FindAllIndex(src, -1)); n > 0 { + got[filepath.ToSlash(strings.TrimPrefix(path, ".."+string(filepath.Separator)))] = n + } + return nil + }) + if err != nil { + t.Fatal(err) + } + for path, n := range got { + if uses[path] != n { + t.Errorf("%s names the regular status %d times, reviewed %d; review it against D-021", path, n, uses[path]) + } + } + for path, n := range uses { + if got[path] == 0 { + t.Errorf("%s no longer names the regular status (reviewed %d); update the list", path, n) + } + } +} diff --git a/internal/server/aisettings_test.go b/internal/server/aisettings_test.go index bc4dd39..89832dd 100644 --- a/internal/server/aisettings_test.go +++ b/internal/server/aisettings_test.go @@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), - newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go index e0c71a7..fb978c8 100644 --- a/internal/server/dinners_test.go +++ b/internal/server/dinners_test.go @@ -92,7 +92,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv { t.Fatal(err) } e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - svc, newTestSuggest(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + svc, newTestSuggest(t), newTestPlan(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/jobs.go b/internal/server/jobs.go index 9f33d16..5ca5fba 100644 --- a/internal/server/jobs.go +++ b/internal/server/jobs.go @@ -30,7 +30,8 @@ type jobCreated struct { } // postJob serves POST /api/v1/jobs: it enqueues a job of a registered kind -// and returns 202 with its id, which GET /api/v1/jobs/{id} reports on. +// that is not service-only and returns 202 with its id, which +// GET /api/v1/jobs/{id} reports on. func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { req, err := decodeJobRequest(http.MaxBytesReader(w, r.Body, maxJobBody)) @@ -43,6 +44,9 @@ func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc { case errors.Is(err, jobs.ErrUnknownKind): writeError(w, r, logger, http.StatusBadRequest, "unknown_kind", "unknown job kind") return + case errors.Is(err, jobs.ErrServiceOnly): + writeError(w, r, logger, http.StatusBadRequest, "service_only_kind", "job kind is started only by its own endpoint") + return case errors.Is(err, jobs.ErrInvalidPayload): writeError(w, r, logger, http.StatusBadRequest, "invalid_payload", "payload does not match the job kind") return diff --git a/internal/server/login_test.go b/internal/server/login_test.go index 24f6a37..16d854e 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler { t.Fatal(err) } r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/media_test.go b/internal/server/media_test.go index c2ce862..dad2c2d 100644 --- a/internal/server/media_test.go +++ b/internal/server/media_test.go @@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), + r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index 7242e74..8c3f22b 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler { } logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil))) r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/plans.go b/internal/server/plans.go new file mode 100644 index 0000000..b15d345 --- /dev/null +++ b/internal/server/plans.go @@ -0,0 +1,137 @@ +package server + +import ( + "errors" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// maxPlanBody bounds a POST /plans/{weekStart}/draft or PUT +// /plans/{weekStart}/days/{date} body. +const maxPlanBody = 4 << 10 + +// planDraft is the POST /plans/{weekStart}/draft body. confirm is needed +// to draft over a plan pushed to Calendar. +type planDraft struct { + Confirm *bool `json:"confirm"` +} + +// planDaySwap is the PUT /plans/{weekStart}/days/{date} body. +type planDaySwap struct { + DinnerID *string `json:"dinner_id"` +} + +// getPlan answers GET /plans/{weekStart} with the week's plan. +func getPlan(logger *slog.Logger, svc *plan.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + week, ok := weekStartOrFail(w, r, logger) + if !ok { + return + } + p, err := svc.Get(r.Context(), week) + if err != nil { + writePlanError(w, r, logger, "read plan", err) + return + } + writeJSON(w, r, logger, http.StatusOK, p) + } +} + +// postPlanDraft enqueues the draft of the week and answers 202 with the +// jobId that GET /jobs/{id} reports on, without waiting for the job. When +// a draft of the week is already queued or running, it answers 202 with +// that job instead, or 409 when this draft is confirmed and that one is +// not. +func postPlanDraft(logger *slog.Logger, svc *plan.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + week, ok := weekStartOrFail(w, r, logger) + if !ok { + return + } + body, err := decodePatch[planDraft](http.MaxBytesReader(w, r.Body, maxPlanBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + d, err := svc.StartDraft(r.Context(), week, body.Confirm != nil && *body.Confirm) + if err != nil { + writePlanError(w, r, logger, "start plan draft", err) + return + } + writeJSON(w, r, logger, http.StatusAccepted, d) + } +} + +// putPlanDay swaps the dinner of one day of the week's plan and answers +// with the plan as swapped. +func putPlanDay(logger *slog.Logger, svc *plan.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + week, ok := weekStartOrFail(w, r, logger) + if !ok { + return + } + body, err := decodePatch[planDaySwap](http.MaxBytesReader(w, r.Body, maxPlanBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if body.DinnerID == nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "dinner_id: required") + return + } + p, err := svc.Swap(r.Context(), week, chi.URLParam(r, "date"), *body.DinnerID) + if err != nil { + writePlanError(w, r, logger, "swap plan day", err) + return + } + writeJSON(w, r, logger, http.StatusOK, p) + } +} + +// weekStartOrFail parses the {weekStart} path parameter, or answers 400 +// when it is not a Monday date. +func weekStartOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (store.LocalDate, bool) { + week, err := plan.ParseWeekStart(chi.URLParam(r, "weekStart")) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_week", err.Error()) + return store.LocalDate{}, false + } + return week, true +} + +// writePlanError maps a plan.Service error to its API error: a week with +// no plan or an unknown dinner is 404, a date outside the week is 400, and +// drafting over a synced plan without confirm, a confirmed draft while an +// unconfirmed one runs, planning a dinner that is neither a Regular nor in +// the Quest log, or one already on another day of the week, is 409. +func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) { + var day *plan.InvalidDayError + var week *plan.InvalidWeekError + var notPlannable *plan.DinnerNotPlannableError + switch { + case errors.As(err, &day): + writeError(w, r, logger, http.StatusBadRequest, "invalid_date", day.Error()) + case errors.As(err, &week): + writeError(w, r, logger, http.StatusBadRequest, "invalid_week", week.Error()) + case errors.As(err, ¬Plannable): + writeError(w, r, logger, http.StatusConflict, "dinner_not_plannable", notPlannable.Error()) + case errors.Is(err, plan.ErrDinnerAlreadyPlanned): + writeError(w, r, logger, http.StatusConflict, "dinner_already_planned", err.Error()) + case errors.Is(err, plan.ErrConfirmRequired): + writeError(w, r, logger, http.StatusConflict, "confirm_required", err.Error()) + case errors.Is(err, plan.ErrDraftRunning): + writeError(w, r, logger, http.StatusConflict, "draft_running", err.Error()) + case errors.Is(err, plan.ErrNotFound): + writeError(w, r, logger, http.StatusNotFound, "not_found", "plan not found") + case errors.Is(err, plan.ErrDinnerNotFound): + writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found") + default: + logger.ErrorContext(r.Context(), action, "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + } +} diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go new file mode 100644 index 0000000..9f6142c --- /dev/null +++ b/internal/server/plans_test.go @@ -0,0 +1,326 @@ +package server + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "testing/fstest" + + "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/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +const plansPath = "/api/v1/plans/" + +// plansEnv is a router whose plan service has an unstarted job runner, so +// drafts stay queued, over a store holding a draft plan of the week of +// 2026-10-12: Beef Chili (crock pot, 240 minutes) on Monday and a Regular +// on every other day. questLog is the id of a Quest log dinner on no plan. +type plansEnv struct { + h http.Handler + st *store.Store + svc *plan.Service + questLog string +} + +func newPlansRouter(t *testing.T) plansEnv { + t.Helper() + st := newTestStore(t) + ctx := t.Context() + if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + runner, err := jobs.NewRunner(st, logger, 1) + if err != nil { + t.Fatal(err) + } + fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { + return skills.Week{}, errors.New("no model in this test") + } + svc, err := plan.NewService(st, fail, runner, logger) + if err != nil { + t.Fatal(err) + } + h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), + newTestDinners(t), newTestSuggest(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + if err != nil { + t.Fatal(err) + } + + q := db.New(st.Writer) + now := store.Now().String() + dinner := func(name, status, method string, minutes int64) string { + d, err := q.CreateDinner(ctx, db.CreateDinnerParams{ + ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: status, Origin: "regular", + Method: sql.NullString{String: method, Valid: method != ""}, + MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0}, + GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatal(err) + } + return d.ID + } + planID := store.NewID() + if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: "2026-10-12", + AiNote: sql.NullString{String: "Mostly Regulars.", Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + for i, date := range []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"} { + id := dinner("Regular "+date, "regular", "stovetop", 30) + if i == 0 { + id = dinner("Beef Chili", "regular", "crockpot", 240) + } + if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date, + DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + } + return plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35)} +} + +func (e plansEnv) setStatus(t *testing.T, status string) { + t.Helper() + if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE week_plans SET status = ?`, status); err != nil { + t.Fatal(err) + } +} + +func TestGetPlan(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + + rec, body := callDinners(t, e.h, http.MethodGet, plansPath+"2026-10-12", "") + if rec.Code != http.StatusOK { + t.Fatalf("GET plan = %d: %s", rec.Code, rec.Body) + } + if body["status"] != "draft" || body["week_start"] != "2026-10-12" || body["week_label"] != "Week 42 · Oct 12 – 18" || + body["ai_note"] != "Mostly Regulars." || body["calendar_synced_at"] != nil { + t.Errorf("plan = %v", body) + } + days, _ := body["days"].([]any) + if len(days) != 7 { + t.Fatalf("days = %v, want 7", body["days"]) + } + monday := days[0].(map[string]any) + if monday["date"] != "2026-10-12" || monday["is_new"] != false || monday["start_by"] != "2026-10-12T13:30:00-04:00" { + t.Errorf("Monday = %v", monday) + } + d := monday["dinner"].(map[string]any) + for _, key := range []string{"id", "name", "photo", "minutes", "method", "origin"} { + if _, ok := d[key]; !ok { + t.Errorf("dinner has no %s: %v", key, d) + } + } + if d["name"] != "Beef Chili" || d["minutes"] != 240.0 || d["method"] != "crockpot" || d["origin"] != "regular" || d["photo"] != nil { + t.Errorf("Monday dinner = %v", d) + } + if tuesday := days[1].(map[string]any); tuesday["start_by"] != nil { + t.Errorf("Tuesday start_by = %v, want null for a stovetop dinner", tuesday["start_by"]) + } + + rec, body = callDinners(t, e.h, http.MethodGet, plansPath+"2026-10-19", "") + if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" { + t.Errorf("GET week with no plan = %d %v, want 404 not_found", rec.Code, body) + } +} + +func TestPlanWeekStartMustBeMonday(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + + for _, tc := range []struct{ method, target, body string }{ + {http.MethodGet, plansPath + "2026-10-13", ""}, + {http.MethodGet, plansPath + "not-a-date", ""}, + {http.MethodGet, plansPath + "2026-02-30", ""}, + {http.MethodPost, plansPath + "2027-02-29/draft", "{}"}, + {http.MethodPost, plansPath + "2026-10-18/draft", "{}"}, + {http.MethodPut, plansPath + "2026-10-14/days/2026-10-14", `{"dinner_id":"` + e.questLog + `"}`}, + } { + rec, body := callDinners(t, e.h, tc.method, tc.target, tc.body) + if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_week" { + t.Errorf("%s %s = %d %v, want 400 invalid_week", tc.method, tc.target, rec.Code, body) + } + } +} + +func TestPostPlanDraft(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + + rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", "{}") + if rec.Code != http.StatusAccepted { + t.Fatalf("POST draft = %d: %s", rec.Code, rec.Body) + } + jobID, _ := body["jobId"].(string) + if jobID == "" || len(body) != 1 { + t.Fatalf("POST draft = %v, want only a jobId", body) + } + rec, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+jobID, "") + if rec.Code != http.StatusOK || job["kind"] != plan.KindDraftWeek || job["status"] != "queued" { + t.Errorf("GET job = %d %v, want a queued %s job", rec.Code, job, plan.KindDraftWeek) + } + // A second draft of the week joins the queued one. + rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":false}`) + if rec.Code != http.StatusAccepted || body["jobId"] != jobID { + t.Errorf("second POST draft = %d %v, want 202 with job %s", rec.Code, body, jobID) + } + // A week with no plan needs no confirm. + rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-19/draft", "{}") + if rec.Code != http.StatusAccepted || body["jobId"] == jobID { + t.Errorf("POST draft of a new week = %d %v, want 202 with its own job", rec.Code, body) + } + + // A confirmed draft does not join the unconfirmed one and enqueues + // nothing. + rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":true}`) + if rec.Code != http.StatusConflict || errorCode(body) != "draft_running" { + t.Errorf("confirmed POST draft = %d %v, want 409 draft_running", rec.Code, body) + } + var queued int + if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil { + t.Fatal(err) + } + if queued != 2 { + t.Errorf("%d draft jobs, want one per week", queued) + } + + rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":"yes"}`) + if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" { + t.Errorf("POST draft with a bad confirm = %d %v, want 400", rec.Code, body) + } +} + +// TestDraftWeekNotEnqueuedThroughJobs checks POST /api/v1/jobs refuses a +// draft_week job, so a confirmed draft cannot run beside the week's +// unconfirmed one: POST /plans/{weekStart}/draft is the only way to start +// a draft. +func TestDraftWeekNotEnqueuedThroughJobs(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + + rec, _ := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", "{}") + if rec.Code != http.StatusAccepted { + t.Fatalf("POST draft = %d: %s", rec.Code, rec.Body) + } + rec, body := callDinners(t, e.h, http.MethodPost, "/api/v1/jobs", + `{"kind":"`+plan.KindDraftWeek+`","payload":{"week_start":"2026-10-12","confirm":true}}`) + if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" { + t.Errorf("POST job %s = %d %v, want 400 service_only_kind", plan.KindDraftWeek, rec.Code, body) + } + var queued int + if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil { + t.Fatal(err) + } + if queued != 1 { + t.Errorf("%d draft jobs, want only the one POST draft enqueued", queued) + } +} + +func TestSimultaneousPostPlanDraftsShareOneJob(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + + const n = 10 + recs := make([]*httptest.ResponseRecorder, n) + var wg sync.WaitGroup + for i := range n { + wg.Go(func() { + recs[i] = httptest.NewRecorder() + e.h.ServeHTTP(recs[i], signedIn(http.MethodPost, plansPath+"2026-10-12/draft", strings.NewReader("{}"))) + }) + } + wg.Wait() + jobIDs := map[any]int{} + for i, rec := range recs { + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || rec.Code != http.StatusAccepted { + t.Fatalf("POST draft %d = %d %s, want 202", i, rec.Code, rec.Body) + } + jobIDs[body["jobId"]]++ + } + var queued int + if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil { + t.Fatal(err) + } + if len(jobIDs) != 1 || jobIDs[nil] != 0 || queued != 1 { + t.Errorf("jobIds = %v, %d draft jobs; want one jobId for all %d drafts", jobIDs, queued, n) + } +} + +func TestPostPlanDraftOverSyncedPlanNeedsConfirm(t *testing.T) { + t.Parallel() + for _, status := range []string{"synced", "changed"} { + e := newPlansRouter(t) + e.setStatus(t, status) + for _, b := range []string{"{}", `{"confirm":false}`} { + rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", b) + if rec.Code != http.StatusConflict || errorCode(body) != "confirm_required" { + t.Errorf("%s plan: POST draft %s = %d %v, want 409 confirm_required", status, b, rec.Code, body) + } + } + rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":true}`) + if rec.Code != http.StatusAccepted || body["jobId"] == nil { + t.Errorf("%s plan: POST draft with confirm = %d %v, want 202", status, rec.Code, body) + } + } +} + +func TestPutPlanDay(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.setStatus(t, "synced") + + rec, body := callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-14", `{"dinner_id":"`+e.questLog+`"}`) + if rec.Code != http.StatusOK { + t.Fatalf("PUT day = %d: %s", rec.Code, rec.Body) + } + if body["status"] != "changed" { + t.Errorf("status = %v, want a swapped synced plan changed", body["status"]) + } + wednesday := body["days"].([]any)[2].(map[string]any) + if wednesday["date"] != "2026-10-14" || wednesday["is_new"] != true || wednesday["dinner"].(map[string]any)["name"] != "Pad Thai" { + t.Errorf("Wednesday = %v, want Pad Thai, new", wednesday) + } + + for _, tc := range []struct { + target, body string + status int + code string + }{ + {"2026-10-12/days/2026-10-14", `{}`, http.StatusBadRequest, "invalid_request"}, + {"2026-10-12/days/2026-10-14", `{"dinner_id":7}`, http.StatusBadRequest, "invalid_request"}, + {"2026-10-12/days/2026-10-14", `{"dinner_id":"x","extra":1}`, http.StatusBadRequest, "invalid_request"}, + {"2026-10-12/days/2026-10-20", `{"dinner_id":"` + e.questLog + `"}`, http.StatusBadRequest, "invalid_date"}, + {"2026-10-12/days/2026-10-32", `{"dinner_id":"` + e.questLog + `"}`, http.StatusBadRequest, "invalid_date"}, + {"2026-10-12/days/2026-10-14", `{"dinner_id":"no-such-dinner"}`, http.StatusNotFound, "not_found"}, + {"2026-10-19/days/2026-10-20", `{"dinner_id":"` + e.questLog + `"}`, http.StatusNotFound, "not_found"}, + {"2026-10-12/days/2026-10-15", `{"dinner_id":"` + e.questLog + `"}`, http.StatusConflict, "dinner_already_planned"}, + } { + rec, body := callDinners(t, e.h, http.MethodPut, plansPath+tc.target, tc.body) + if rec.Code != tc.status || errorCode(body) != tc.code { + t.Errorf("PUT %s %s = %d %v, want %d %s", tc.target, tc.body, rec.Code, body, tc.status, tc.code) + } + } + if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.questLog); err != nil { + t.Fatal(err) + } + rec, body = callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-15", `{"dinner_id":"`+e.questLog+`"}`) + if rec.Code != http.StatusConflict || errorCode(body) != "dinner_not_plannable" { + t.Errorf("PUT archived dinner = %d %v, want 409 dinner_not_plannable", rec.Code, body) + } +} diff --git a/internal/server/recipesearch_test.go b/internal/server/recipesearch_test.go index 5e20f89..9931bb6 100644 --- a/internal/server/recipesearch_test.go +++ b/internal/server/recipesearch_test.go @@ -22,7 +22,7 @@ const ( func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/rotation.go b/internal/server/rotation.go new file mode 100644 index 0000000..a9f6232 --- /dev/null +++ b/internal/server/rotation.go @@ -0,0 +1,87 @@ +package server + +import ( + "errors" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" +) + +// maxRotationReviewBody bounds a POST /dinners/{id}/rotation-review body. +const maxRotationReviewBody = 1 << 10 + +// rotationDecision is the POST /dinners/{id}/rotation-review body: +// keep, drop ("Not for us") or not_made ("We didn't make it"). +type rotationDecision struct { + Decision *string `json:"decision"` +} + +type rotationReviewList struct { + Reviews []plan.Review `json:"reviews"` +} + +// getRotationReviews answers GET /rotation-reviews with the pending +// Rotation checks, oldest plan date first. +func getRotationReviews(logger *slog.Logger, svc *plan.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + list, err := svc.PendingReviews(r.Context()) + if err != nil { + writeRotationError(w, r, logger, "list Rotation checks", err) + return + } + writeJSON(w, r, logger, http.StatusOK, rotationReviewList{Reviews: list}) + } +} + +// postRotationReview applies the household's answer to "Keep it in +// rotation?" for a tried dinner, recording the session's actor as +// updated_by, and answers with the Rotation check as decided. +func postRotationReview(logger *slog.Logger, svc *plan.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + actor, ok := actorOrFail(w, r, logger) + if !ok { + return + } + body, err := decodePatch[rotationDecision](http.MaxBytesReader(w, r.Body, maxRotationReviewBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if body.Decision == nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "decision: required") + return + } + d, err := svc.DecideReview(r.Context(), chi.URLParam(r, "id"), *body.Decision, actor.Subject) + if err != nil { + writeRotationError(w, r, logger, "decide Rotation check", err) + return + } + writeJSON(w, r, logger, http.StatusOK, d) + } +} + +// writeRotationError maps a Rotation check error to its API error: a +// decision other than keep, drop or not_made is 400, an unknown dinner or +// a dinner never checked 404, and a dinner whose checks are all decided +// (such as a second decision) or no longer tried 409. +func writeRotationError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) { + var invalid *plan.InvalidDecisionError + switch { + case errors.As(err, &invalid): + writeError(w, r, logger, http.StatusBadRequest, "invalid_decision", invalid.Error()) + case errors.Is(err, plan.ErrDinnerNotFound): + writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found") + case errors.Is(err, plan.ErrReviewNotFound): + writeError(w, r, logger, http.StatusNotFound, "not_found", "rotation check not found") + case errors.Is(err, plan.ErrNoPendingReview): + writeError(w, r, logger, http.StatusConflict, "no_pending_review", err.Error()) + case errors.Is(err, plan.ErrDinnerNotTried): + writeError(w, r, logger, http.StatusConflict, "dinner_not_tried", err.Error()) + default: + logger.ErrorContext(r.Context(), action, "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + } +} diff --git a/internal/server/rotation_test.go b/internal/server/rotation_test.go new file mode 100644 index 0000000..05e4c15 --- /dev/null +++ b/internal/server/rotation_test.go @@ -0,0 +1,147 @@ +package server + +import ( + "net/http" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +const rotationReviewsPath = "/api/v1/rotation-reviews" + +// triedPadThai plans the Quest log dinner Pad Thai on Wednesday 2026-10-14 +// and marks the day tried, so Pad Thai has a pending Rotation check. +func triedPadThai(t *testing.T, e plansEnv) { + t.Helper() + rec, _ := callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-14", `{"dinner_id":"`+e.questLog+`"}`) + if rec.Code != http.StatusOK { + t.Fatalf("swap Pad Thai in = %d: %s", rec.Code, rec.Body) + } + day, err := store.ParseLocalDate("2026-10-14") + if err != nil { + t.Fatal(err) + } + if n, err := e.svc.MarkTried(t.Context(), day); err != nil || n != 1 { + t.Fatalf("MarkTried = %d, %v, want 1", n, err) + } +} + +func TestGetRotationReviews(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + + rec, body := callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "") + if rec.Code != http.StatusOK { + t.Fatalf("GET = %d: %s", rec.Code, rec.Body) + } + if reviews, ok := body["reviews"].([]any); !ok || len(reviews) != 0 { + t.Errorf("GET before MarkTried = %v, want an empty reviews list", body) + } + + triedPadThai(t, e) + rec, body = callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "") + if rec.Code != http.StatusOK { + t.Fatalf("GET = %d: %s", rec.Code, rec.Body) + } + reviews, _ := body["reviews"].([]any) + if len(reviews) != 1 { + t.Fatalf("reviews = %v, want one", body) + } + r := reviews[0].(map[string]any) + d, _ := r["dinner"].(map[string]any) + if len(r) != 2 || r["plan_date"] != "2026-10-14" || len(d) != 3 || + d["id"] != e.questLog || d["name"] != "Pad Thai" || d["photo"] != nil { + t.Errorf("review = %v, want {dinner {id, name, photo}, plan_date}", r) + } +} + +func TestPostRotationReview(t *testing.T) { + t.Parallel() + for _, tc := range []struct{ decision, status string }{ + {"keep", "regular"}, + {"drop", "archived"}, + {"not_made", "quest_log"}, + } { + t.Run(tc.decision, func(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + triedPadThai(t, e) + target := dinnersPath + "/" + e.questLog + "/rotation-review" + + rec, body := callDinners(t, e.h, http.MethodPost, target, `{"decision":"`+tc.decision+`"}`) + if rec.Code != http.StatusOK { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + if body["decision"] != tc.decision || body["dinner_status"] != tc.status || body["plan_date"] != "2026-10-14" || + body["updated_by"] != testSession.Subject || body["decided_at"] == "" { + t.Errorf("decided = %v", body) + } + var status, origin, updatedBy string + if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status, origin, updated_by FROM dinners WHERE id = ?`, e.questLog). + Scan(&status, &origin, &updatedBy); err != nil { + t.Fatal(err) + } + if status != tc.status || origin != "regular" || updatedBy != testSession.Subject { + t.Errorf("dinner = %s %s %s, want %s regular %s", status, origin, updatedBy, tc.status, testSession.Subject) + } + + rec, body = callDinners(t, e.h, http.MethodPost, target, `{"decision":"keep"}`) + if rec.Code != http.StatusConflict || errorCode(body) != "no_pending_review" { + t.Errorf("second decision = %d %v, want 409 no_pending_review", rec.Code, body) + } + rec, body = callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "") + if reviews, _ := body["reviews"].([]any); rec.Code != http.StatusOK || len(reviews) != 0 { + t.Errorf("GET after deciding = %d %v, want no reviews", rec.Code, body) + } + }) + } +} + +// A dinner that has never had a Rotation check is 404; a second decision +// on one already decided is 409 (TestPostRotationReview). +func TestPostRotationReviewNeverChecked(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+e.questLog+"/rotation-review", `{"decision":"keep"}`) + if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" { + t.Errorf("never checked = %d %v, want 404 not_found", rec.Code, body) + } + var status string + if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status FROM dinners WHERE id = ?`, e.questLog).Scan(&status); err != nil { + t.Fatal(err) + } + if status != "quest_log" { + t.Errorf("status after a refused decision = %s, want quest_log", status) + } +} + +func TestPostRotationReviewRefused(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + triedPadThai(t, e) + target := dinnersPath + "/" + e.questLog + "/rotation-review" + + for _, tc := range []struct { + name, target, body string + code int + errCode string + }{ + {"no decision", target, `{}`, http.StatusBadRequest, "invalid_request"}, + {"null decision", target, `{"decision":null}`, http.StatusBadRequest, "invalid_request"}, + {"unknown field", target, `{"decision":"keep","status":"regular"}`, http.StatusBadRequest, "invalid_request"}, + {"unknown decision", target, `{"decision":"promote"}`, http.StatusBadRequest, "invalid_decision"}, + {"unknown dinner", dinnersPath + "/nope/rotation-review", `{"decision":"keep"}`, http.StatusNotFound, "not_found"}, + } { + rec, body := callDinners(t, e.h, http.MethodPost, tc.target, tc.body) + if rec.Code != tc.code || errorCode(body) != tc.errCode { + t.Errorf("%s = %d %v, want %d %s", tc.name, rec.Code, body, tc.code, tc.errCode) + } + } + var status string + if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status FROM dinners WHERE id = ?`, e.questLog).Scan(&status); err != nil { + t.Fatal(err) + } + if status != "tried" { + t.Errorf("status after refused decisions = %s, want tried", status) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index c81b05e..5fbceb6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -16,6 +16,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" "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/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" ) @@ -41,12 +42,15 @@ type RouteMounter interface { // /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground; // suggestSvc serves POST /api/v1/suggestions/batches, GET // /api/v1/suggestions/deck and POST /api/v1/suggestions/{id}/accept and -// /decline; runner serves +// /decline; planSvc serves GET /api/v1/plans/{weekStart}, POST +// /api/v1/plans/{weekStart}/draft, PUT +// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and +// POST /api/v1/dinners/{id}/rotation-review; runner serves // POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves // GET /media/photos/{id}-{size}.jpg; assets is the web build // served for every other GET. /health, /auth and the web build are public. // Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader. -func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { +func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { if settings == nil { return nil, errors.New("server: nil household settings service") } @@ -62,6 +66,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a if suggestSvc == nil { return nil, errors.New("server: nil suggestions service") } + if planSvc == nil { + return nil, errors.New("server: nil plan service") + } if runner == nil { return nil, errors.New("server: nil job runner") } @@ -116,6 +123,11 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Get("/suggestions/deck", getSuggestionDeck(logger, suggestSvc)) apiV1.Post("/suggestions/{id}/accept", postSuggestionDecision(logger, suggestSvc, true)) apiV1.Post("/suggestions/{id}/decline", postSuggestionDecision(logger, suggestSvc, false)) + apiV1.Get("/plans/{weekStart}", getPlan(logger, planSvc)) + apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc)) + apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc)) + apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc)) + apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc)) apiV1.Post("/jobs", postJob(logger, runner)) apiV1.Get("/jobs/{id}", getJob(logger, runner)) media.Get("/photos/{name}", getPhoto(logger, photos)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 05a3807..a7499a3 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -23,6 +23,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" @@ -49,7 +50,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting "index.html": {Data: []byte(fixtureIndex)}, "assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, } - r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), runner, newTestPhotos(t), assets) + r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), runner, newTestPhotos(t), assets) if err != nil { t.Fatal(err) } @@ -185,6 +186,26 @@ func newTestSuggest(t *testing.T) *suggest.Service { return svc } +// newTestPlan returns a plan service over a migrated temp database, with +// its own unstarted job runner and a drafter that always fails. +func newTestPlan(t *testing.T) *plan.Service { + t.Helper() + st := newTestStore(t) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + runner, err := jobs.NewRunner(st, logger, 1) + if err != nil { + t.Fatal(err) + } + fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { + return skills.Week{}, errors.New("no model in this test") + } + svc, err := plan.NewService(st, fail, runner, logger) + if err != nil { + t.Fatal(err) + } + return svc +} + // newTestBox returns a secret box under a random key. func newTestBox(t *testing.T) *secretbox.Box { t.Helper() @@ -216,7 +237,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil AI settings service") } } @@ -225,7 +246,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil recipe search settings service") } } @@ -234,7 +255,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted nil photo files") } } @@ -243,7 +264,7 @@ func TestNewRouterRequiresSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil settings service") } } @@ -252,7 +273,7 @@ func TestNewRouterRequiresDinners(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil dinners service") } } @@ -261,16 +282,25 @@ func TestNewRouterRequiresSuggestions(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil suggestions service") } } +func TestNewRouterRequiresPlan(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + t.Fatal("NewRouter accepted a nil plan service") + } +} + func TestNewRouterRequiresJobRunner(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil job runner") } } diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 6586253..09f1be7 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st), - newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/suggestions_test.go b/internal/server/suggestions_test.go index e50448b..a0c6549 100644 --- a/internal/server/suggestions_test.go +++ b/internal/server/suggestions_test.go @@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - newTestDinners(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), svc, newTestPlan(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index 4952f3b..5533fdc 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) { if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { t.Fatal(err) } - if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name" { - t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name", got) + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name", got) } } @@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) { if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { t.Fatal(err) } - if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name" { - t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name", got) + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name", got) } if !tableExists(t, s, "dinners") { t.Error("table from 0001_init was not created") @@ -633,3 +633,88 @@ func TestMigrateSuggestionName(t *testing.T) { t.Error("suggestions_copy left behind") } } + +// TestMigrateRotationReviewName upgrades a database at 0013 holding a +// pending, a kept and a dropped Rotation check and checks 0014 keeps all +// three, gives only the drop its dinner's name, and refuses a drop with no +// name or a name on any other check. +func TestMigrateRotationReviewName(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + before := fstest.MapFS{} + for _, name := range []string{ + "0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql", "0003_jobs_status.sql", + "0004_ai_provider_presets.sql", "0005_ai_provider_xai.sql", "0006_ai_runs_status.sql", "0007_recipe_grounding.sql", + "0008_dinner_recipe_url.sql", "0009_dinner_grounding_error.sql", "0010_dinner_grounding_revision.sql", + "0011_ground_dinner_job_revision.sql", "0012_suggestion_name.sql", "0013_week_plan_revision.sql", + } { + body, err := fs.ReadFile(migrations.FS, name) + if err != nil { + t.Fatal(err) + } + before[name] = &fstest.MapFile{Data: body} + } + if err := Migrate(ctx, s.Writer, before); err != nil { + t.Fatal(err) + } + for _, stmt := range []string{ + `INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at) + VALUES ('dinner-1', 'Pho', 'pho', 'tried', 'side_quest', 0, 'grounded', 'c', 'u'), + ('dinner-2', 'Ramen', 'ramen', 'regular', 'side_quest', 0, 'grounded', 'c', 'u'), + ('dinner-3', 'Laksa', 'laksa', 'archived', 'side_quest', 0, 'grounded', 'c', 'u')`, + `INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, decision, decided_at, updated_by, created_at, updated_at) + VALUES ('review-1', 'dinner-1', '2026-10-12', 'pending', NULL, NULL, NULL, 'c1', 'u1'), + ('review-2', 'dinner-2', '2026-10-13', 'decided', 'keep', 'd2', 'member-1', 'c2', 'u2'), + ('review-3', 'dinner-3', '2026-10-14', 'decided', 'drop', 'd3', 'member-1', 'c3', 'u3')`, + } { + if _, err := s.Writer.Exec(stmt); err != nil { + t.Fatalf("seed: %v", err) + } + } + + if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + + rows, err := s.Reader.Query(`SELECT id, dinner_id, plan_date, status, COALESCE(decision, ''), COALESCE(name, ''), + COALESCE(decided_at, ''), COALESCE(updated_by, ''), created_at, updated_at + FROM rotation_reviews ORDER BY id`) + if err != nil { + t.Fatal(err) + } + var got [][]string + for rows.Next() { + r := make([]string, 10) + if err := rows.Scan(&r[0], &r[1], &r[2], &r[3], &r[4], &r[5], &r[6], &r[7], &r[8], &r[9]); err != nil { + t.Fatal(err) + } + got = append(got, r) + } + if err := rows.Close(); err != nil { + t.Fatal(err) + } + want := [][]string{ + {"review-1", "dinner-1", "2026-10-12", "pending", "", "", "", "", "c1", "u1"}, + {"review-2", "dinner-2", "2026-10-13", "decided", "keep", "", "d2", "member-1", "c2", "u2"}, + {"review-3", "dinner-3", "2026-10-14", "decided", "drop", "Laksa", "d3", "member-1", "c3", "u3"}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("rotation_reviews after 0014 =\n %v\nwant\n %v", got, want) + } + for _, tc := range []struct{ name, stmt, want string }{ + {"drop with no name", `UPDATE rotation_reviews SET name = NULL WHERE id = 'review-3'`, "CHECK constraint failed"}, + {"empty name", `UPDATE rotation_reviews SET name = '' WHERE id = 'review-3'`, "CHECK constraint failed"}, + {"name on a keep", `UPDATE rotation_reviews SET name = 'Ramen' WHERE id = 'review-2'`, "CHECK constraint failed"}, + {"name on a pending check", `UPDATE rotation_reviews SET name = 'Pho' WHERE id = 'review-1'`, "CHECK constraint failed"}, + {"unknown dinner", `UPDATE rotation_reviews SET dinner_id = 'nope' WHERE id = 'review-1'`, "FOREIGN KEY constraint failed"}, + {"second check of a dinner and date", `INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, created_at, updated_at) + VALUES ('review-4', 'dinner-1', '2026-10-12', 'pending', 'c', 'u')`, "UNIQUE constraint failed"}, + } { + if _, err := s.Writer.Exec(tc.stmt); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: error %v, want %s", tc.name, err, tc.want) + } + } + if tableExists(t, s, "rotation_reviews_copy") { + t.Error("rotation_reviews_copy left behind") + } +} diff --git a/internal/suggest/batch.go b/internal/suggest/batch.go index 00aeccb..51ee427 100644 --- a/internal/suggest/batch.go +++ b/internal/suggest/batch.go @@ -43,7 +43,7 @@ type batchResult struct { func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) } // runBatch runs batch p.BatchID: it asks suggest_side_quests for BatchSize -// Side quests given the household context, the deck and every declined +// Side quests given the household context, the deck and every passed // name, then grounds them in the model's order, at most the number still // wanted at a time, until BatchSize are kept or none are left. A Side quest // no recipe grounds is dropped; any other grounding failure fails the @@ -132,7 +132,7 @@ func (s *Service) fill(ctx context.Context, batchID string, publish func(batchRe } // request reads what suggest_side_quests is given: the household context, -// the deck's names and every declined name. A household with no Regulars +// the deck's names and every passed name (ListPassedNames). A household with no Regulars // fails with ReasonNoRegulars. func (s *Service) request(ctx context.Context) (skills.SideQuestsRequest, error) { hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget) @@ -151,9 +151,9 @@ func (s *Service) request(ctx context.Context) (skills.SideQuestsRequest, error) for _, row := range deck { pending = append(pending, row.Dinner.Name) } - declined, err := q.ListDeclinedSuggestionNames(ctx) + declined, err := q.ListPassedNames(ctx) if err != nil { - return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list declined names: %w", err) + return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list passed names: %w", err) } return skills.SideQuestsRequest{Context: hctx, Needed: BatchSize, Pending: pending, Declined: declined}, nil } @@ -201,9 +201,9 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou // suggestion citing its Regulars by id, all in one transaction, and // returns the suggestion ids. A Side quest is left out when, since // suggest_side_quests ran, a dinner it repeats was added, a suggestion it -// repeats passed, or a Regular it cites renamed or archived. Declined -// names are read in the same transaction as the store, so a Pass committed -// before it is never missed. +// repeats passed or a Rotation check dropped, or a Regular it cites renamed +// or archived. Passed names are read in the same transaction as the store, +// so a Pass or drop committed before it is never missed. func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, error) { ids := []string{} err := s.st.WithTx(ctx, func(tx *sql.Tx) error { @@ -212,9 +212,9 @@ func (s *Service) store(ctx context.Context, batchID string, quests []groundedQu if err != nil { return fmt.Errorf("suggest: list dinners for dedupe: %w", err) } - names, err := q.ListDeclinedSuggestionNames(ctx) + names, err := q.ListPassedNames(ctx) if err != nil { - return fmt.Errorf("suggest: list declined names for dedupe: %w", err) + return fmt.Errorf("suggest: list passed names for dedupe: %w", err) } regulars := map[string][]string{} for _, row := range existing { diff --git a/internal/suggest/suggest_test.go b/internal/suggest/suggest_test.go index 915b789..469579a 100644 --- a/internal/suggest/suggest_test.go +++ b/internal/suggest/suggest_test.go @@ -21,6 +21,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" @@ -646,7 +647,7 @@ func TestDeclineIsIdempotent(t *testing.T) { if got := e.dinnerStatus(t, s.DinnerID); got != "archived" { t.Errorf("dinner status after refused accept = %s, want archived", got) } - names, err := db.New(e.st.Reader).ListDeclinedSuggestionNames(t.Context()) + names, err := db.New(e.st.Reader).ListPassedNames(t.Context()) if err != nil { t.Fatal(err) } @@ -912,6 +913,74 @@ func TestLaterBatchNeverResuggestsRenamedPass(t *testing.T) { } } +// TestLaterBatchNeverResuggestsRenamedDrop drops a tried dinner at its +// Rotation check ("Not for us"), renames its archived dinner, and checks +// the next batch still refuses the name it was dropped under. +func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) { + e := newEnv(t) + ctx := t.Context() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger) + if err != nil { + t.Fatal(err) + } + fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { + return skills.Week{}, errors.New("no draft in this test") + } + planSvc, err := plan.NewService(e.st, fail, e.runner, logger) + if err != nil { + t.Fatal(err) + } + id := e.dinner(t, "Pad Thai", "quest_log") + q := db.New(e.st.Writer) + now := store.Now().String() + planID := store.NewID() + if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: "2026-10-12", CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: "2026-10-14", + DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + day, err := store.ParseLocalDate("2026-10-14") + if err != nil { + t.Fatal(err) + } + if n, err := planSvc.MarkTried(ctx, day); err != nil || n != 1 { + t.Fatalf("MarkTried = %d, %v, want 1", n, err) + } + if _, err := planSvc.DecideReview(ctx, id, plan.DecisionDrop, member); err != nil { + t.Fatal(err) + } + renamed := "Peanut Noodles" + if _, err := dinnerSvc.Update(ctx, id, dinners.Patch{Name: &renamed}, member); err != nil { + t.Fatal(err) + } + + // The respelled dropped dish would ground, so only the passed names + // keep it out of the deck. + e.recipe(t, "pad thai!") + e.recipe(t, "Beef Ragu") + e.reply( + sideQuest("pad thai!", "Noodles, like your Chicken Fajitas.", "Chicken Fajitas"), + sideQuest("Beef Ragu", "Slow beef like your Beef Chili.", "Beef Chili"), + ) + e.start(t) + res := e.runBatch(t) + if len(res.SuggestionIDs) != 1 || res.Dropped != 1 { + t.Errorf("batch result = %+v, want 1 kept and the dropped dish dropped", res) + } + if got := e.deckNames(t); !slices.Equal(got, []string{"Beef Ragu"}) { + t.Errorf("deck = %v, want [Beef Ragu]", got) + } + e.mu.Lock() + in := e.inputs[0] + e.mu.Unlock() + if !slices.Equal(in.Declined, []string{"Pad Thai"}) || !slices.Equal(in.Context.Passed, []string{"Pad Thai"}) { + t.Errorf("batch declined %v, passed %v; want the name it was dropped under in both", in.Declined, in.Context.Passed) + } +} + // TestDecisionNeverCommitsWithoutItsReplenish makes the replenish batch's // job fail to store: the decision must not be recorded either, so the // retry decides again and starts the replenish. diff --git a/migrations/0013_week_plan_revision.sql b/migrations/0013_week_plan_revision.sql new file mode 100644 index 0000000..733caeb --- /dev/null +++ b/migrations/0013_week_plan_revision.sql @@ -0,0 +1,6 @@ +-- 0013_week_plan_revision: every edit of a week's plan — a swap or a +-- stored draft — raises its revision. A draft_week job reads the revision +-- before draft_week runs and stores its week only while the plan still +-- has it, so a draft never overwrites an edit made while the AI ran. + +ALTER TABLE week_plans ADD COLUMN revision INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/0014_rotation_review_name.sql b/migrations/0014_rotation_review_name.sql new file mode 100644 index 0000000..b1fd14c --- /dev/null +++ b/migrations/0014_rotation_review_name.sql @@ -0,0 +1,43 @@ +-- 0014_rotation_review_name: a Rotation check decided drop ("Not for us") +-- keeps the name its dinner had when the household dropped it. The dinner +-- can be renamed, even once archived, so the passed names every later +-- batch refuses are read from here, never from the dinner, as 0012 does +-- for a Pass. Only a drop has a name. A check dropped before this takes +-- its dinner's name now. +-- SQLite cannot add a column with a CHECK that existing drops would fail +-- before they are filled in, so the table is rebuilt as 0012 does: copy +-- the rows aside, drop the table, create it again, copy the rows back and +-- drop the copy. Nothing references rotation_reviews. + +CREATE TABLE rotation_reviews_copy AS SELECT * FROM rotation_reviews; + +DROP TABLE rotation_reviews; + +CREATE TABLE rotation_reviews ( + id TEXT NOT NULL PRIMARY KEY, + dinner_id TEXT NOT NULL REFERENCES dinners (id), + plan_date TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'decided')), + decision TEXT CHECK (decision IN ('keep', 'drop', 'not_made')), + name TEXT CHECK (name <> ''), -- the dinner's name when dropped + decided_at TEXT, + updated_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (dinner_id, plan_date), + CHECK ((decision IS 'drop') = (name IS NOT NULL)) +); + +INSERT INTO rotation_reviews ( + id, dinner_id, plan_date, status, decision, name, decided_at, updated_by, created_at, updated_at +) +SELECT + rotation_reviews_copy.id, rotation_reviews_copy.dinner_id, rotation_reviews_copy.plan_date, + rotation_reviews_copy.status, rotation_reviews_copy.decision, + CASE WHEN rotation_reviews_copy.decision = 'drop' THEN dinners.name END, + rotation_reviews_copy.decided_at, rotation_reviews_copy.updated_by, + rotation_reviews_copy.created_at, rotation_reviews_copy.updated_at +FROM rotation_reviews_copy +JOIN dinners ON dinners.id = rotation_reviews_copy.dinner_id; + +DROP TABLE rotation_reviews_copy; diff --git a/migrations/queries/household_context.sql b/migrations/queries/household_context.sql index fbea66d..38f5c3c 100644 --- a/migrations/queries/household_context.sql +++ b/migrations/queries/household_context.sql @@ -1,11 +1,17 @@ -- Reads for the AI household context block (internal/ai/harness). --- name: ListRecentDeclinedNames :many --- The names the most recently declined suggestions were suggested under, --- newest first; renaming their dinners does not change them. -SELECT name -FROM suggestions -WHERE status = 'declined' +-- name: ListRecentPassedNames :many +-- The most recent names the household turned down, newest first, as +-- ListPassedNames has them. +SELECT name FROM ( + SELECT name, decided_at, id + FROM suggestions + WHERE status = 'declined' + UNION ALL + SELECT name, decided_at, id + FROM rotation_reviews + WHERE decision = 'drop' +) ORDER BY decided_at DESC, id DESC LIMIT ?; diff --git a/migrations/queries/plans.sql b/migrations/queries/plans.sql new file mode 100644 index 0000000..c64d7f0 --- /dev/null +++ b/migrations/queries/plans.sql @@ -0,0 +1,86 @@ +-- name: GetWeekPlan :one +-- The plan of the week starting week_start. Returns sql.ErrNoRows when +-- there is none. +SELECT * FROM week_plans +WHERE week_start = ?; + +-- name: ListPlanDays :many +-- The days of plan plan_id with their dinners, Monday first. A day with +-- no dinner is left out. +SELECT plan_days.date, plan_days.is_new, sqlc.embed(dinners) +FROM plan_days +JOIN dinners ON dinners.id = plan_days.dinner_id +WHERE plan_days.plan_id = ? +ORDER BY plan_days.date; + +-- name: CreateWeekPlan :exec +INSERT INTO week_plans (id, week_start, status, ai_note, created_at, updated_at) +VALUES (?, ?, 'draft', ?, ?, ?); + +-- name: RedraftWeekPlan :exec +-- Records a new draft over plan id: its status and note, raising its +-- revision. +UPDATE week_plans +SET status = ?, ai_note = ?, revision = revision + 1, updated_at = ? +WHERE id = ?; + +-- name: PutPlanDay :exec +-- Plans dinner_id on date of plan_id, keeping the day's calendar event id +-- when the day exists. +INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (plan_id, date) DO UPDATE +SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, updated_at = excluded.updated_at; + +-- name: SwapPlanDay :execrows +-- Plans dinner_id on an existing day of plan_id; a date the plan does not +-- have is left as it is (0 rows). +UPDATE plan_days +SET dinner_id = ?, is_new = ?, updated_at = ? +WHERE plan_id = ? AND date = ?; + +-- name: MarkWeekPlanEdited :exec +-- Records an edit of plan id, raising its revision: a synced plan becomes +-- changed, any other keeps its status. +UPDATE week_plans +SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END, + revision = revision + 1, + updated_at = ? +WHERE id = ?; + +-- name: ListDraftPool :many +-- The dinners a draft of the week starting week_start may plan: every +-- Regular, and every Quest log dinner no other plan for the week starting +-- from_week_start or later has on any day. +SELECT * FROM dinners +WHERE status = 'regular' + OR (status = 'quest_log' AND NOT EXISTS ( + SELECT 1 FROM plan_days + JOIN week_plans ON week_plans.id = plan_days.plan_id + WHERE plan_days.dinner_id = dinners.id + AND week_plans.week_start >= sqlc.arg(from_week_start) + AND week_plans.week_start <> sqlc.arg(week_start) + )) +ORDER BY normalized_name, id; + +-- name: ListWeekPlanDinnerIDs :many +-- The ids of the dinners planned in the week starting week_start, Monday +-- first. +SELECT plan_days.dinner_id +FROM plan_days +JOIN week_plans ON week_plans.id = plan_days.plan_id +WHERE week_plans.week_start = ? AND plan_days.dinner_id IS NOT NULL +ORDER BY plan_days.date; + +-- name: GetActiveDraftJob :one +-- The job of kind queued or running for the week starting week_start, +-- with whether its payload has confirm. StartDraft enqueues a draft only +-- when there is none, so there is at most one. Returns sql.ErrNoRows when +-- there is none. +SELECT id, CAST(json_extract(payload, '$.confirm') = 1 AS INTEGER) AS confirmed +FROM jobs +WHERE kind = sqlc.arg(kind) + AND status IN ('queued', 'running') + AND json_extract(payload, '$.week_start') = sqlc.arg(week_start) +ORDER BY id +LIMIT 1; diff --git a/migrations/queries/rotation_reviews.sql b/migrations/queries/rotation_reviews.sql new file mode 100644 index 0000000..2d857d2 --- /dev/null +++ b/migrations/queries/rotation_reviews.sql @@ -0,0 +1,66 @@ +-- name: ListTriedCandidates :many +-- 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. +SELECT DISTINCT dinners.id +FROM plan_days +JOIN dinners ON dinners.id = plan_days.dinner_id +WHERE plan_days.date = sqlc.arg(date) + AND dinners.status = 'quest_log' + AND NOT EXISTS ( + SELECT 1 FROM rotation_reviews + WHERE rotation_reviews.dinner_id = dinners.id + AND rotation_reviews.plan_date = plan_days.date + ) +ORDER BY dinners.id; + +-- name: MarkDinnerTried :execrows +-- Moves a dinner still in the Quest log to tried; a dinner with any other +-- status is left as it is (0 rows). +UPDATE dinners +SET status = 'tried', updated_at = ? +WHERE id = ? AND status = 'quest_log'; + +-- name: CreateRotationReview :exec +INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, created_at, updated_at) +VALUES (?, ?, ?, 'pending', ?, ?); + +-- name: ListPendingRotationReviews :many +-- The pending Rotation checks whose dinner is still tried, oldest plan +-- date first, with their dinners. +SELECT rotation_reviews.plan_date, sqlc.embed(dinners) +FROM rotation_reviews +JOIN dinners ON dinners.id = rotation_reviews.dinner_id +WHERE rotation_reviews.status = 'pending' AND dinners.status = 'tried' +ORDER BY rotation_reviews.plan_date, rotation_reviews.id; + +-- name: GetPendingRotationReview :one +-- The pending Rotation check of dinner_id. MarkTried makes one only for a +-- dinner in the Quest log, which it moves to tried, so a dinner has at +-- most one. Returns sql.ErrNoRows when there is none. +SELECT * FROM rotation_reviews +WHERE dinner_id = ? AND status = 'pending'; + +-- name: CountRotationReviews :one +-- How many Rotation checks dinner_id has had, pending or decided. +SELECT COUNT(*) FROM rotation_reviews WHERE dinner_id = ?; + +-- name: DecideRotationReview :execrows +-- Records the household's decision on a pending Rotation check, with the +-- dinner's name for a drop and NULL otherwise; one already decided is left +-- as it is (0 rows). +UPDATE rotation_reviews +SET status = 'decided', + decision = sqlc.arg(decision), + name = sqlc.narg(name), + decided_at = sqlc.arg(now), + updated_by = sqlc.arg(updated_by), + updated_at = sqlc.arg(now) +WHERE id = sqlc.arg(id) AND status = 'pending'; + +-- name: MoveTriedDinner :execrows +-- Moves a dinner still tried to status (regular, archived or quest_log) +-- by a Rotation check decision, recording updated_by; its origin is kept. +-- A dinner no longer tried is left as it is (0 rows). +UPDATE dinners +SET status = sqlc.arg(status), updated_by = sqlc.arg(updated_by), updated_at = sqlc.arg(updated_at) +WHERE id = sqlc.arg(id) AND status = 'tried'; diff --git a/migrations/queries/suggestions.sql b/migrations/queries/suggestions.sql index 2d81299..1068622 100644 --- a/migrations/queries/suggestions.sql +++ b/migrations/queries/suggestions.sql @@ -86,12 +86,20 @@ FROM suggestions JOIN dinners ON dinners.id = suggestions.dinner_id WHERE suggestions.status = 'pending' AND dinners.status = 'suggested'; --- name: ListDeclinedSuggestionNames :many --- The name every declined suggestion was suggested under, oldest decision --- first; renaming its dinner does not change it. -SELECT name -FROM suggestions -WHERE status = 'declined' +-- name: ListPassedNames :many +-- Every name the household turned down, oldest decision first: the name +-- each declined suggestion was suggested under, which renaming its dinner +-- does not change, and the name each dinner a Rotation check dropped +-- ("Not for us") had when dropped, which renaming it does not change. +SELECT name FROM ( + SELECT name, decided_at, id + FROM suggestions + WHERE status = 'declined' + UNION ALL + SELECT name, decided_at, id + FROM rotation_reviews + WHERE decision = 'drop' +) ORDER BY decided_at, id; -- name: DecideSuggestion :execrows diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 1d2a4d9..abcaadf 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -30,6 +30,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) { // The routes that render a built screen rather than a placeholder page. const SCREENS = { '/onboarding': true, + '/plan': true, '/discover': true, '/settings': true, '/settings/ai': true, @@ -165,19 +166,19 @@ describe('App first-run redirect', () => { it('opens the routes once onboarding is finished', async () => { mockViewport(390) stubFetchByPath(SIGNED_IN) - renderAt('/plan') - expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + renderAt('/quick') + expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() }) it('reports a household settings failure with a retry instead of rendering the routes', async () => { mockViewport(390) stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS)) - renderAt('/plan') + renderAt('/quick') expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message') expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() screen.getByRole('button', { name: 'Retry' }).click() - expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() }) }) @@ -185,12 +186,12 @@ describe('App bootstrap', () => { it('shows the skeleton until /me loads, then the routes', async () => { mockViewport(390) const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS)) - renderAt('/plan') + renderAt('/quick') expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true') expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() - expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument() expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' }) }) @@ -258,7 +259,7 @@ describe('App offline bootstrap', () => { }) it.each([ - ['/plan', 'Plan'], + ['/quick', 'Quick dinners'], ['/dinner/01J9ZK', 'Dinner'], ])('an offline reload of %s after an online visit opens the saved page', async (path, title) => { await visitOnline() @@ -282,7 +283,7 @@ describe('App offline bootstrap', () => { () => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))), ).mockRejectedValue(new TypeError('Failed to fetch')), ) - renderAt('/plan') + renderAt('/quick') await vi.waitFor(() => expect(failMe).toBeDefined()) onLine = false act(() => { @@ -290,7 +291,7 @@ describe('App offline bootstrap', () => { }) act(() => failMe()) - expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument() expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data') expect(screen.queryByRole('alert')).not.toBeInTheDocument() @@ -299,15 +300,15 @@ describe('App offline bootstrap', () => { it('opens the saved pages when the server is unreachable but the browser reports online', async () => { await visitOnline() vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) - renderAt('/plan') + renderAt('/quick') - expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data') }) it('reports the failure offline when this device never verified a session', async () => { goOffline() - renderAt('/plan') + renderAt('/quick') expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch') expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() @@ -316,12 +317,12 @@ describe('App offline bootstrap', () => { it('reports the failure offline after the session was refused', async () => { await visitOnline() stubFetch(errorResponse(401, 'unauthenticated')) - const { location, unmount } = renderAt('/plan') + const { location, unmount } = renderAt('/quick') await vi.waitFor(() => expect(location.assign).toHaveBeenCalled()) unmount() goOffline() - renderAt('/plan') + renderAt('/quick') expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch') expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() }) @@ -329,13 +330,13 @@ describe('App offline bootstrap', () => { it('loads /me again on reconnect and refuses writes until it has', async () => { await visitOnline() goOffline() - const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage)) render( - + , ) - expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/) const fetchMock = stubFetchByPath(SIGNED_IN) @@ -350,6 +351,6 @@ describe('App offline bootstrap', () => { ]), ) await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull()) - expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() }) }) diff --git a/web/src/App.tsx b/web/src/App.tsx index 4a2d297..da3b58a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,6 +7,7 @@ import { DiscoverPage } from './discover/DiscoverPage.tsx' import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx' import { OnboardingPage } from './onboarding/OnboardingPage.tsx' import { PlaceholderPage } from './pages/PlaceholderPage.tsx' +import { PlanPage } from './plan/PlanPage.tsx' import { OfflinePill } from './pwa/OfflinePill.tsx' import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx' import { AISettingsPage } from './settings/AISettingsPage.tsx' @@ -36,6 +37,7 @@ export const routes = [ // The screens built so far; every other route shows its placeholder page. const screens: Partial> = { + '/plan': , '/discover': , '/settings': , '/settings/ai': , diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index c9bdb84..15390e7 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -225,4 +225,14 @@ describe('network status', () => { await expect(api.apiFetch('/api/v1/recipes/01J9ZR')).resolves.toEqual({ id: '01J9ZR' }) expect(api.network.offline()).toBe(false) }) + + it('says whether a read is the service worker\'s cached copy', async () => { + const cached = jsonResponse(200, { id: '01J9ZR' }) + cached.headers.set(cacheFallbackHeader, '1') + stubFetch(cached, jsonResponse(200, { id: '01J9ZR' })) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) + + await expect(api.apiRead('/api/v1/recipes/01J9ZR')).resolves.toEqual({ body: { id: '01J9ZR' }, cached: true }) + await expect(api.apiRead('/api/v1/recipes/01J9ZR')).resolves.toEqual({ body: { id: '01J9ZR' }, cached: false }) + }) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index cbfc0f0..1fc884d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -43,11 +43,18 @@ export type ApiRequest = { body?: unknown } +// ApiRead is a GET's decoded response body, and whether the service worker +// answered it from its cache because the server could not be reached in time. +export type ApiRead = { body: T; cached: boolean } + export type ApiClient = { // apiFetch sends a same-origin JSON request to path, which must be under // /api/v1/, and resolves with the decoded response body, or rejects with an // ApiError. apiFetch(path: string, request?: ApiRequest): Promise + // apiRead is apiFetch's GET that also says whether the body is the service + // worker's cached copy rather than the server's answer. + apiRead(path: string): Promise> // loadMe fetches GET /api/v1/me and caches its CSRF token. loadMe(): Promise // forbidden reports whether a request was refused because the signed-in @@ -114,6 +121,16 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s } async function apiFetch(path: string, request: ApiRequest = {}): Promise { + return (await exchange(path, request)).body + } + + function apiRead(path: string): Promise> { + return exchange(path, {}) + } + + // exchange sends request to path and decodes the response, noting whether + // it is the service worker's cached copy. + async function exchange(path: string, request: ApiRequest): Promise> { const method = request.method ?? 'GET' const url = apiURL(location.origin, method, path) // fetch refuses a GET with a body by rejecting with a TypeError, the same @@ -131,7 +148,7 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s response = await send(method, url, request.body) if (response.status === 403) throw await parseForbidden(method, path, response) } - return decode(method, path, response) + return { body: await decode(method, path, response), cached: response.headers.has(cacheFallbackHeader) } } // parseForbidden reads a 403. Any API error other than csrf means the @@ -179,6 +196,7 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s return { apiFetch, + apiRead, loadMe, forbidden: () => forbidden, subscribe(listener) { diff --git a/web/src/api/index.ts b/web/src/api/index.ts index cae3e1f..2f59f3a 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -1,5 +1,5 @@ export { ApiProvider, useApi } from './ApiContext.tsx' -export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiLocation, type ApiRequest, type Me } from './client.ts' +export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiRead, type ApiLocation, type ApiRequest, type Me } from './client.ts' export { ApiError } from './errors.ts' export { createQueryClient, shouldRetry } from './queryClient.ts' export { createVerifiedSession, VERIFIED_SESSION_KEY, type VerifiedSession } from './verifiedSession.ts' diff --git a/web/src/discover/QuestLogSheet.tsx b/web/src/discover/QuestLogSheet.tsx index cafdfa7..9ccd8b6 100644 --- a/web/src/discover/QuestLogSheet.tsx +++ b/web/src/discover/QuestLogSheet.tsx @@ -20,7 +20,7 @@ export type QuestLogSheetProps = { // details is a Quest-log dinner's "difficulty · minutes" line, or null when // neither is known. -function details(dinner: QuestLogDinner): string | null { +export function details(dinner: QuestLogDinner): string | null { const parts = [ dinner.difficulty !== null && DIFFICULTY_LABEL[dinner.difficulty], dinner.minutes_total !== null && `${dinner.minutes_total} min`, diff --git a/web/src/discover/api.ts b/web/src/discover/api.ts index ea8d070..c65726f 100644 --- a/web/src/discover/api.ts +++ b/web/src/discover/api.ts @@ -51,8 +51,9 @@ export const BATCH_JOB_KIND = 'suggestion_batch' export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number } // QuestLogDinner is a dinner in the Quest log, as the Quest log sheet lists -// it: photo, name, difficulty and minutes. -export type QuestLogDinner = Pick +// it: photo, name, difficulty and minutes, with the method a swap carries +// onto the plan. +export type QuestLogDinner = Pick // DECISION_STATUS is the status a suggestion has once each choice is saved. const DECISION_STATUS: Record = { try: 'accepted', pass: 'declined' } @@ -127,8 +128,8 @@ export function useQuestLog() { const body = await apiFetch(QUEST_LOG_PATH) if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`) return body.dinners.map((dinner) => { - const { id, name, photo_id, difficulty, minutes_total } = parseDinner(where, dinner) - return { id, name, photo_id, difficulty, minutes_total } + const { id, name, photo_id, difficulty, method, minutes_total } = parseDinner(where, dinner) + return { id, name, photo_id, difficulty, method, minutes_total } }) }, }) @@ -250,13 +251,14 @@ export function useDecide() { queryClient.setQueryData(QUEST_LOG_KEY, (dinners) => dinners === undefined || dinners.some((d) => d.id === quest.dinner_id) ? dinners - : // The deck does not say the minutes; the Quest log read after the - // decision does. + : // The deck does not say the method or minutes; the Quest log + // read after the decision does. dinners.concat({ id: quest.dinner_id, name: quest.name, photo_id: quest.photo_id, difficulty: quest.difficulty, + method: null, minutes_total: null, }), ) diff --git a/web/src/plan/PlanPage.module.css b/web/src/plan/PlanPage.module.css new file mode 100644 index 0000000..10af1ef --- /dev/null +++ b/web/src/plan/PlanPage.module.css @@ -0,0 +1,589 @@ +/* Plan · Draft board (mock 1f) and plan manager (mocks 2c, 2d). */ +.page { + box-sizing: border-box; +} + +.board { + box-sizing: border-box; + min-width: 0; + display: flex; + flex-direction: column; + gap: 18px; +} + +.mobilePage { + max-width: 440px; + padding-top: 8px; +} + +/* The grid fills the screen below the header: the main area's 32 px top and + bottom padding (AppShell) is taken back off the viewport height. */ +.tabletPage .board { + min-height: calc(100dvh - 64px); +} + +/* Desktop: the board beside the 290 px right panel, which runs the full + height of the screen to its right edge over the main area's padding. */ +.desktopPage { + display: grid; + grid-template-columns: minmax(0, 1fr) 290px; + min-height: 100dvh; + margin: -32px -36px -32px 0; +} + +.desktopPage .board { + padding: 32px 36px 32px 0; +} + +.header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; +} + +.heading { + min-width: 0; +} + +.kicker { + margin: 0; + font-family: var(--font-body); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-accent-700); +} + +.titleRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 26px; + font-weight: 400; + line-height: 1.1; +} + +.weekNav { + flex: none; + display: flex; + gap: 8px; +} + +/* The assistant: a 32 px grape "Q" avatar beside its note. */ +.assistant { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.avatar { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-pill); + background: var(--color-accent); + color: var(--on-accent); + font-family: var(--font-heading); + font-size: 16px; + line-height: 1; +} + +.bubble { + margin: 0; + padding: 12px 14px; + border-radius: 4px 18px 18px 18px; + background: var(--color-surface); + font-family: var(--font-body); + font-size: 14px; + line-height: 1.45; +} + +/* Two columns of 86 px day tiles; the 8th cell is "+ Rebuild week". */ +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.tile { + box-sizing: border-box; + display: flex; + min-height: 86px; + border-radius: 22px; + background: var(--color-surface); + color: var(--color-text); +} + +/* The whole tile is the button that opens the swap sheet. */ +.tileButton { + box-sizing: border-box; + flex: 1; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: stretch; + gap: 6px; + padding: 12px; + border: 0; + border-radius: inherit; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +/* A Quest-log pick. */ +.newTile { + background: var(--color-accent-2-300); +} + +.dayKicker { + display: block; + margin: 0; + font-family: var(--font-body); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-neutral-700); +} + +.newTile .dayKicker { + color: var(--color-accent-2-900); +} + +.dinner { + display: block; + margin: 0; + font-family: var(--font-heading); + font-size: 15px; + font-weight: 400; + line-height: 1.15; + overflow-wrap: anywhere; +} + +.startBy { + display: block; + margin: 4px 0 0; + font-family: var(--font-body); + font-size: 11px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.newTile .startBy { + color: var(--color-accent-2-900); +} + +/* The day being swapped, while the swap sheet is open: the page background + ringed in grape, its name muted. */ +.swapping { + background: var(--color-bg); + box-shadow: inset 0 0 0 2px var(--color-accent); +} + +.swapping .dayKicker { + color: var(--color-accent-700); +} + +.swapping .dinner, +.swapping .startBy { + color: var(--color-neutral-600); +} + +.rebuildCell { + display: flex; +} + +.rebuild { + box-sizing: border-box; + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 86px; + padding: 12px; + border: 2px dashed var(--color-neutral-400); + border-radius: 22px; + background: transparent; + color: var(--color-neutral-700); + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + cursor: pointer; +} + +.rebuild:hover:not(:disabled) { + background: var(--color-neutral-100); +} + +.rebuild:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.loading { + display: flex; + flex-direction: column; + gap: 12px; +} + +.skeletonTile { + height: 86px; + border-radius: 22px; +} + +.drafting { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + text-align: center; + color: var(--color-neutral-700); +} + +/* An action with nothing behind it yet (Push to Calendar until Feature 16, + the shopping list until Feature 17): it keeps focus, so hovering, tapping + or focusing it shows the tooltip. */ +.unavailable { + position: relative; + display: flex; + flex-direction: column; +} + +.pushButton { + min-height: 50px; +} + +.unavailable [aria-disabled='true'] { + opacity: 0.45; + cursor: not-allowed; +} + +/* Unavailable, it neither darkens nor presses. */ +.unavailable .primary[aria-disabled='true']:hover, +.unavailable .primary[aria-disabled='true']:active { + background: var(--color-accent); + transform: none; +} + +.unavailable .outline[aria-disabled='true']:hover, +.unavailable .outline[aria-disabled='true']:active { + background: transparent; + transform: none; +} + +.hint { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + width: max-content; + max-width: 100%; + padding: 8px 12px; + border-radius: var(--radius-md); + background: var(--color-text); + color: var(--color-bg); + font-family: var(--font-body); + font-size: 13px; + line-height: 1.4; + text-align: center; + box-shadow: var(--shadow-md); + visibility: hidden; + pointer-events: none; +} + +/* In the plan manager's header the tooltip opens below, at the action's + right edge. */ +.below .hint { + top: calc(100% + 8px); + bottom: auto; + left: auto; + right: 0; + transform: none; + width: 240px; + max-width: none; + z-index: 1; +} + +.unavailable:hover .hint, +.unavailable [aria-disabled='true']:focus + .hint { + visibility: visible; +} + +/* The plan manager's header: kicker and title beside the week buttons, over + the sync status and the actions. */ +.wideHeader { + display: flex; + flex-direction: column; + gap: 14px; +} + +.titleBar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.wideTitle { + margin: 4px 0 0; + font-family: var(--font-heading); + font-size: 40px; + font-weight: 400; + line-height: 1.1; +} + +.tabletPage .wideTitle { + font-size: 36px; +} + +.wideHeader .kicker { + font-size: 12px; +} + +.wideHeader .weekNav { + align-items: center; + gap: 12px; +} + +.thisWeek { + min-height: 44px; + padding: 10px 16px; + border: 0; + border-radius: var(--radius-pill); + background: var(--color-surface); + color: var(--color-text); + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + cursor: pointer; +} + +.thisWeek:hover { + background: var(--color-neutral-100); +} + +.actionBar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} + +/* The status keeps to one line; the actions wrap under it when the row is + too narrow for both. */ +.status { + flex: 1 0 auto; + white-space: nowrap; + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.synced { + color: var(--color-accent-2-800); +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.action { + min-height: 44px; + padding: 0 18px; + white-space: nowrap; +} + +/* The week: seven columns, each a day label over its card. A column is + never narrower than 112 px, which holds a dinner's words whole and the + tag beside the swap icon; where seven of those do not fit (tablet's + narrow end, desktop beside the panel) the week scrolls sideways. Its 8 px + padding, taken back by the margin, keeps the swap icons' hit boxes and + focus rings inside the scroll area. */ +.week { + flex: 1; + display: grid; + grid-template-columns: repeat(7, minmax(112px, 1fr)); + gap: 12px; + margin: -8px; + padding: 8px; + overflow-x: auto; + list-style: none; +} + +.column { + min-width: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.dayLabel { + display: flex; + align-items: baseline; + gap: 6px; + margin: 0; + padding: 0 4px; +} + +.dayName { + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.dayNumber { + font-family: var(--font-heading); + font-size: 22px; + color: var(--color-text); +} + +.card { + position: relative; + box-sizing: border-box; + flex: 1; + display: flex; + flex-direction: column; + gap: 9px; + padding: 12px; + border-radius: 26px; + background: var(--color-surface); + color: var(--color-text); +} + +.tablet .card { + gap: 8px; + padding: 10px; + border-radius: 24px; +} + +/* A Side quest's card. */ +.sideQuestCard { + background: var(--color-accent-2-200); +} + +.cardPhoto { + width: 100%; + height: 110px; +} + +.tablet .cardPhoto { + height: 96px; +} + +.cardName { + margin: 0; + font-family: var(--font-heading); + font-size: 17px; + line-height: 1.15; + overflow-wrap: break-word; +} + +.tablet .cardName { + font-size: 16px; +} + +.cardMinutes, +.cardStartBy { + margin: 0; + font-family: var(--font-body); + font-size: 12px; + color: var(--color-neutral-800); +} + +.cardStartBy { + font-weight: 700; +} + +.sideQuestCard .cardMinutes, +.sideQuestCard .cardStartBy { + color: var(--color-accent-2-900); +} + +.cardFooter { + margin-top: auto; + display: flex; + align-items: center; + gap: 4px; +} + +/* The day a dragged dinner drops on: a dashed 2.5 px grape border on + grape-100 over the card, saying which dinner it replaces. */ +.dropTarget { + position: absolute; + inset: 0; + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 4px; + padding: 12px; + border: 2.5px dashed var(--color-accent); + border-radius: inherit; + background: var(--color-accent-100); + text-align: center; +} + +.dropTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 16px; + color: var(--color-accent-700); +} + +.dropReplaces { + margin: 0; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); + overflow-wrap: break-word; +} + +/* The swap icon sits bare on the card, beside the tag: its 44 px hit box + keeps the 16 px footprint of its icon, reaching over the card's padding + and the tag. */ +.cardFooter .swapButton { + margin: -14px; +} + +.cardFooter .cardTag { + margin-right: auto; + padding: 3px 8px; +} + +.swapButton > span { + background: transparent; + color: var(--color-neutral-700); +} + +.skeletonCard { + height: 300px; + border-radius: 26px; +} diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx new file mode 100644 index 0000000..9e48c68 --- /dev/null +++ b/web/src/plan/PlanPage.test.tsx @@ -0,0 +1,1429 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { cacheFallbackHeader } from '../sw/routes.ts' +import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' +import { declaredStyle } from '../test/cssRules.ts' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { mockViewport } from '../test/matchMedia.ts' +import { ago, cooking, duration, PlanPage } from './PlanPage.tsx' + +const THIS_WEEK = '/api/v1/plans/2026-10-05' +const NEXT_WEEK = '/api/v1/plans/2026-10-12' +const PREVIOUS_WEEK = '/api/v1/plans/2026-09-28' +const DRAFT = `${THIS_WEEK}/draft` +const JOB = '/api/v1/jobs/j1' + +const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11'] +const NAMES = ['Beef tacos', 'Spaghetti bolognese', 'Crock pot tikka masala', 'Chicken stir-fry', 'Pizza night', 'Korean beef bowls', 'Sheet-pan salmon'] + +// planBody is the plan of the week of Oct 5 as GET /api/v1/plans/{weekStart} +// sends it: Wednesday's crock-pot tikka masala and Saturday's bowls are +// Quest-log picks, and the tikka masala must start by 1:30 pm New York time +// to be ready for 5:30 pm dinner. Monday's tacos have a stored photo. A plan +// pushed to Calendar was pushed two minutes before the tests' noon. +function planBody(status: string, names = NAMES) { + return { + id: 'plan-1', + week_start: '2026-10-05', + week_label: 'Week 41 · Oct 5 – 11', + status, + ai_note: 'I mixed 5 of your regulars with 2 from your Quest log. Tap any day to swap it.', + calendar_synced_at: status === 'draft' ? null : '2026-10-07T15:58:00Z', + days: DAYS.map((date, i) => ({ + date, + dinner: { + id: `d${i}`, + name: names[i], + photo: i === 0 ? '/media/photos/tacos-400.jpg' : null, + minutes: i === 2 ? 240 : 30, + method: i === 2 ? 'crockpot' : 'stovetop', + origin: i === 2 || i === 5 ? 'side_quest' : 'regular', + }, + is_new: i === 2 || i === 5, + start_by: i === 2 ? '2026-10-07T13:30:00-04:00' : null, + })), + updated_at: '2026-10-04T15:00:00Z', + } +} + +// draftJob is job j1 drafting the week of Oct 5 into plan planId: a +// redraft keeps the plan's id. +function draftJob(status: string, error: string | null = null, planId = 'plan-1') { + const result = status === 'succeeded' ? { week_start: '2026-10-05', plan_id: planId } : null + return { id: 'j1', kind: 'draft_week', status, result, error } +} + +type Responder = (init: RequestInit | undefined) => Response | Promise + +const QUEST_LOG = '/api/v1/dinners?status=quest_log' +const REGULARS = '/api/v1/dinners?status=regular' + +// dinner is a dinner as the dinners API lists it. +function dinner(id: string, name: string, difficulty: string | null, minutes: number | null) { + return { + id, + name, + method: 'stovetop', + minutes_total: minutes, + difficulty, + is_favorite: false, + photo_url: null, + grounding_status: 'grounded', + } +} + +// The Quest log has lemon orzo; the Regulars are Monday's tacos, already +// this week, and fish tacos. +const lists = { + [`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: [dinner('q1', 'Lemon orzo', 'easy', 25)] }), + [`GET ${REGULARS}`]: () => + jsonResponse(200, { dinners: [dinner('d0', 'Beef tacos', 'easy', 30), dinner('r1', 'Fish tacos', 'medium', 35)] }), +} + +// stubServer answers "METHOD path" requests from responders, with /me and +// the household settings; any other request fails the test. +function stubServer(responders: Record) { + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + 'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), + ...responders, + } + const fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + return Promise.resolve(respond(init)) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// posts lists the POSTs sent, as "path body". +function posts(fetchMock: Mock) { + return fetchMock.mock.calls + .filter(([, init]) => init?.method === 'POST') + .map(([input, init]) => `${String(input)} ${String(init?.body)}`) +} + +// planReads counts the reads of this week's plan. +function planReads(fetchMock: Mock) { + return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === THIS_WEEK).length +} + +// renderPage loads /me (for the CSRF token every write needs) and renders +// the Plan page. +async function renderPage() { + const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + return render( + + + + + + + , + ) +} + +// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so +// the page opens on this week. +beforeEach(() => { + mockViewport(390) + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-10-07T16:00:00Z')) +}) + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + Reflect.deleteProperty(window, 'matchMedia') +}) + +function tile(kicker: string) { + return screen.getByText(kicker).closest('li')! +} + +describe('PlanPage', () => { + it('renders mock 1f for a draft: title, Draft tag, the assistant note and the 2-column grid', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) }) + await renderPage() + + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + const title = screen.getByRole('heading', { level: 1, name: 'Oct 5 – 11' }) + expect(declaredStyle(title).fontSize).toBe('26px') + expect(screen.getByText('Week 41')).toBeInTheDocument() + const tag = screen.getByText('Draft') + expect(declaredStyle(tag).background).toBe('var(--color-accent-100)') + expect(declaredStyle(tag).color).toBe('var(--color-accent-800)') + + // The assistant: a 32 px grape "Q" avatar beside the plan's ai_note. + const avatar = screen.getByText('Q') + expect(declaredStyle(avatar).width).toBe('32px') + expect(declaredStyle(avatar).background).toBe('var(--color-accent)') + expect(screen.getByText('I mixed 5 of your regulars with 2 from your Quest log. Tap any day to swap it.')).toBeInTheDocument() + + // Seven day tiles, Monday to Sunday, then the "+ Rebuild week" tile. + const grid = screen.getByRole('list', { name: 'Dinners this week' }) + expect(declaredStyle(grid).gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))') + const items = within(grid).getAllByRole('listitem') + expect(items).toHaveLength(8) + expect(items.slice(0, 7).map((item) => item.querySelector('button')!.firstChild!.textContent)).toEqual([ + 'MON', + 'TUE', + 'WED · NEW', + 'THU', + 'FRI', + 'SAT · NEW', + 'SUN', + ]) + expect(within(items[7]!).getByRole('button', { name: 'Rebuild week' })).toBeInTheDocument() + const monday = tile('MON') + expect(declaredStyle(monday).minHeight).toBe('86px') + expect(declaredStyle(monday).borderRadius).toBe('22px') + expect(declaredStyle(monday).background).toBe('var(--color-surface)') + expect(declaredStyle(within(monday).getByText('Beef tacos')).fontFamily).toBe('var(--font-heading)') + expect(declaredStyle(within(monday).getByText('Beef tacos')).fontSize).toBe('15px') + }) + + it('highlights Quest-log picks in teal with the NEW kicker', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) }) + await renderPage() + + await screen.findByText('WED · NEW') + const wednesday = tile('WED · NEW') + expect(declaredStyle(wednesday).background).toBe('var(--color-accent-2-300)') + expect(declaredStyle(within(wednesday).getByText('WED · NEW')).color).toBe('var(--color-accent-2-900)') + expect(declaredStyle(tile('SAT · NEW')).background).toBe('var(--color-accent-2-300)') + // A Regular stays on the surface, with no NEW. + expect(declaredStyle(tile('THU')).background).toBe('var(--color-surface)') + expect(screen.queryByText('THU · NEW')).not.toBeInTheDocument() + }) + + it('shows a crock-pot dinner its start-by time in the household zone, and no other dinner one', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) }) + await renderPage() + + expect(await screen.findByText('Start by 1:30 pm')).toBeInTheDocument() + expect(within(tile('WED · NEW')).getByText('Start by 1:30 pm')).toBeInTheDocument() + expect(screen.getAllByText(/^Start by/)).toHaveLength(1) + }) + + it.each([ + ['synced', 'Synced', 'var(--color-accent-2-200)'], + ['changed', 'Changes not pushed', 'var(--color-accent-100)'], + ])('tags a %s plan "%s"', async (status, label, background) => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody(status)) }) + await renderPage() + + const tag = await screen.findByText(label) + expect(declaredStyle(tag).background).toBe(background) + expect(screen.queryByText('Draft')).not.toBeInTheDocument() + }) + + it('offers Push to Calendar unavailable, with a tooltip saying why', async () => { + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) }) + await renderPage() + + const push = await screen.findByRole('button', { name: 'Looks good — push to Calendar' }) + expect(push).toHaveAttribute('aria-disabled', 'true') + expect(push).toHaveAccessibleDescription('Push to Calendar arrives once Google Calendar can be connected.') + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Push to Calendar arrives once Google Calendar can be connected.') + expect(declaredStyle(push).minHeight).toBe('50px') + fireEvent.click(push) + expect(posts(fetchMock)).toEqual([]) + }) + + it('rebuilds a draft at once, showing skeleton tiles until the new plan is read', async () => { + let plan = planBody('draft') + let job = draftJob('running') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan), + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, job), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Rebuild week' })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])) + expect(await screen.findByRole('region', { name: 'Drafting this week' })).toHaveAttribute('aria-busy', 'true') + expect(screen.getAllByTestId('skeleton-tile')).toHaveLength(7) + expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument() + + plan = planBody('draft', ['Lasagna', ...NAMES.slice(1)]) + job = draftJob('succeeded') + expect(await screen.findByText('Lasagna', {}, { timeout: 3000 })).toBeInTheDocument() + expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument() + }) + + it.each(['synced', 'changed'])('asks before rebuilding a %s plan, and sends confirm', async (status) => { + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody(status)), + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, draftJob('running')), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Rebuild week' })) + const dialog = await screen.findByRole('dialog', { name: 'Rebuild this week?' }) + expect(dialog).toHaveTextContent('already pushed to Calendar') + fireEvent.click(within(dialog).getByRole('button', { name: 'Keep this plan' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(posts(fetchMock)).toEqual([]) + + fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' })) + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Rebuild week' })) + await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {"confirm":true}`])) + expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument() + }) + + it('offers "Draft this week" with no plan, and shows skeleton tiles while it drafts', async () => { + let plan: unknown = null + let job = draftJob('queued') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => (plan === null ? errorResponse(404, 'not_found') : jsonResponse(200, plan)), + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, job), + }) + await renderPage() + + expect(await screen.findByRole('heading', { name: 'Nothing planned yet' })).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument() + expect(screen.queryByText('Draft')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Draft this week' })) + expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument() + expect(screen.getAllByTestId('skeleton-tile')).toHaveLength(7) + expect(screen.getByText('Drafting your week…')).toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${DRAFT} {}`]) + + plan = planBody('draft') + job = draftJob('succeeded') + expect(await screen.findByText('Beef tacos', {}, { timeout: 3000 })).toBeInTheDocument() + expect(screen.getByText('Draft')).toBeInTheDocument() + }) + + it('shows a failed draft with Retry and makes up no plan', async () => { + let job = draftJob('failed', 'ai_failed') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'), + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, job), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Draft this week' })) + const alert = await screen.findByRole('alert', {}, { timeout: 3000 }) + expect(alert).toHaveTextContent("Couldn't draft this week. The AI assistant couldn't draft it.") + expect(screen.getByRole('heading', { name: 'Nothing planned yet' })).toBeInTheDocument() + expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument() + + job = draftJob('running') + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`, `${DRAFT} {}`])) + expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it.each([ + ['the week has no plan', null, 'The week has no plan, not draft plan-1.'], + ['the week has another plan', { ...planBody('draft'), id: 'plan-9' }, "The week's plan is not draft plan-1."], + ])('shows a succeeded draft with Retry when %s once read, and Retry reads the plan again', async (_, landed, message) => { + let plan: unknown = null + let job = draftJob('running') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => (plan === null ? errorResponse(404, 'not_found') : jsonResponse(200, plan)), + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, job), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Draft this week' })) + expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument() + plan = landed + job = draftJob('succeeded') + const alert = await screen.findByRole('alert', {}, { timeout: 3000 }) + expect(alert).toHaveTextContent(`Couldn't read the draft. ${message}`) + expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument() + + // Retry reads the plan again rather than drafting again: the read is + // still not the draft. + let reads = planReads(fetchMock) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(planReads(fetchMock)).toBe(reads + 1)) + expect(await screen.findByRole('alert')).toHaveTextContent(`Couldn't read the draft. ${message}`) + expect(posts(fetchMock)).toEqual([`${DRAFT} {}`]) + + // The next read is the draft, from the server: it lands. + plan = planBody('draft') + reads = planReads(fetchMock) + fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + expect(planReads(fetchMock)).toBe(reads + 1) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${DRAFT} {}`]) + }) + + it("doesn't land a succeeded draft on the plan cached before it, and lands it once the server is read", async () => { + let plan = planBody('draft') + let cached = false + let job = draftJob('running') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => { + const response = jsonResponse(200, plan) + if (cached) response.headers.set(cacheFallbackHeader, '1') + return response + }, + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, job), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Rebuild week' })) + expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument() + // The draft succeeds, but the server can't be reached: the service + // worker answers with the plan cached before the rebuild, same id. + cached = true + job = draftJob('succeeded') + const alert = await screen.findByRole('alert', {}, { timeout: 3000 }) + expect(alert).toHaveTextContent("Couldn't read the draft. The plan came from this device's cache, not the server") + expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument() + expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument() + + // Retry reads the plan again and drafts nothing: still the cache, so the + // draft stays unread. + const reads = planReads(fetchMock) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(planReads(fetchMock)).toBe(reads + 1)) + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't read the draft. The plan came from this device's cache, not the server") + expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${DRAFT} {}`]) + + // The server is back: the next read of the plan is the draft. + plan = planBody('draft', ['Lasagna', ...NAMES.slice(1)]) + cached = false + vi.setSystemTime(new Date('2026-10-07T16:01:00Z')) + act(() => { + window.dispatchEvent(new Event('visibilitychange')) + }) + expect(await screen.findByText('Lasagna')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument() + }) + + it('moves between weeks with the previous and next week buttons', async () => { + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + }) + await renderPage() + + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument() + expect(screen.getByText('Week 42')).toBeInTheDocument() + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument() + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + }) + + it("opens on next week once this week's plan_draft time has come", async () => { + vi.setSystemTime(new Date('2026-10-09T13:00:00Z')) // Friday 09:00 in New York + stubServer({ [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found') }) + await renderPage() + + expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument() + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + }) + + it("reports a plan that can't be read, with Retry", async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => errorResponse(500, 'internal') }) + await renderPage() + + expect(await screen.findByRole('alert', {}, { timeout: 3000 })).toHaveTextContent("Couldn't load this week's plan. internal message") + }) + + describe('swap sheet', () => { + const FRIDAY = `${THIS_WEEK}/days/2026-10-09` + + // swappedBody is the plan as the server answers a swap of Friday to + // lemon orzo, a Quest-log pick. + function swappedBody() { + const body = planBody('draft') + body.days[4] = { ...body.days[4]!, dinner: { ...body.days[4]!.dinner, id: 'q1', name: 'Lemon orzo' }, is_new: true } + return body + } + + // puts lists the PUTs sent, as "path body". + function puts(fetchMock: Mock) { + return fetchMock.mock.calls + .filter(([, init]) => init?.method === 'PUT') + .map(([input, init]) => `${String(input)} ${String(init?.body)}`) + } + + async function openFriday() { + fireEvent.click(within(await waitFor(() => tile('FRI'))).getByRole('button')) + return screen.findByRole('dialog', { name: 'Swap Friday' }) + } + + it('opens on a tapped day, puts it in the swapping state, and closes again', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) + await renderPage() + + const sheet = await openFriday() + expect(within(sheet).getByText('Replaces Pizza night')).toBeInTheDocument() + + // The tile: page background, a 2 px grape inset ring, the SWAPPING… + // kicker and its name muted. + const friday = tile('FRI · SWAPPING…') + expect(declaredStyle(friday).background).toBe('var(--color-bg)') + expect(declaredStyle(friday).boxShadow).toBe('inset 0 0 0 2px var(--color-accent)') + expect(declaredStyle(within(friday).getByText('FRI · SWAPPING…')).color).toBe('var(--color-accent-700)') + expect(declaredStyle(within(friday).getByText('Pizza night')).color).toBe('var(--color-neutral-600)') + + // "Quest log · N | Regulars · N", on the Quest log first. + expect(await within(sheet).findByRole('radio', { name: 'Quest log · 1', checked: true })).toBeInTheDocument() + expect(await within(sheet).findByRole('radio', { name: 'Regulars · 2' })).toBeInTheDocument() + const row = within(within(sheet).getByRole('list', { name: 'Quest log' })).getByRole('button', { name: /Lemon orzo/ }) + expect(row).toHaveTextContent('Easy · 25 min') + const photo = within(sheet).getByRole('img', { hidden: true, name: 'Lemon orzo' }) + expect(declaredStyle(photo).width).toBe('48px') + + fireEvent.keyDown(sheet, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(tile('FRI')).toBeInTheDocument() + expect(screen.queryByText('FRI · SWAPPING…')).not.toBeInTheDocument() + }) + + it('swaps the day at once, closing the sheet, and lands the plan the server answers', async () => { + let answer!: (response: Response) => void + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...lists, + [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (answer = resolve)), + }) + await renderPage() + + const sheet = await openFriday() + fireEvent.click(await within(sheet).findByRole('button', { name: /Lemon orzo/ })) + + // Optimistic: the sheet is closed and Friday is the Quest-log pick, NEW. + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await screen.findByText('FRI · NEW') + expect(within(tile('FRI · NEW')).getByText('Lemon orzo')).toBeInTheDocument() + expect(declaredStyle(tile('FRI · NEW')).background).toBe('var(--color-accent-2-300)') + await waitFor(() => expect(puts(fetchMock)).toEqual([`${FRIDAY} {"dinner_id":"q1"}`])) + + await act(async () => answer(jsonResponse(200, swappedBody()))) + expect(within(tile('FRI · NEW')).getByText('Lemon orzo')).toBeInTheDocument() + expect(screen.queryByText('Pizza night')).not.toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + // Once the swap has settled the sheet opens again, and reads the Quest + // log again: the server leaves a dinner on the plan out of it. + await waitFor(() => { + fireEvent.click(within(tile('FRI · NEW')).getByRole('button')) + expect(screen.getByRole('dialog', { name: 'Swap Friday' })).toHaveTextContent('Replaces Lemon orzo') + }) + await waitFor(() => expect(fetchMock.mock.calls.filter(([input]) => String(input) === QUEST_LOG)).toHaveLength(2)) + }) + + it('takes the NEW highlight off a day swapped to a Regular', async () => { + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...lists, + [`PUT ${THIS_WEEK}/days/2026-10-07`]: () => { + const body = planBody('draft') + body.days[2] = { ...body.days[2]!, dinner: { ...body.days[2]!.dinner, id: 'r1', name: 'Fish tacos' }, is_new: false, start_by: null } + return jsonResponse(200, body) + }, + }) + await renderPage() + + fireEvent.click(within(await waitFor(() => tile('WED · NEW'))).getByRole('button')) + const sheet = await screen.findByRole('dialog', { name: 'Swap Wednesday' }) + fireEvent.click(await within(sheet).findByRole('radio', { name: 'Regulars · 2' })) + fireEvent.click(within(sheet).getByRole('button', { name: /Fish tacos/ })) + + await screen.findByText('Fish tacos') + expect(within(tile('WED')).getByText('Fish tacos')).toBeInTheDocument() + expect(declaredStyle(tile('WED')).background).toBe('var(--color-surface)') + await waitFor(() => expect(puts(fetchMock)).toEqual([`${THIS_WEEK}/days/2026-10-07 {"dinner_id":"r1"}`])) + expect(screen.queryByText('Start by 1:30 pm')).not.toBeInTheDocument() + }) + + it('puts the day back and shows the failure, with Retry, when the swap is refused', async () => { + let refuse = true + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...lists, + [`PUT ${FRIDAY}`]: () => (refuse ? errorResponse(409, 'dinner_not_plannable') : jsonResponse(200, swappedBody())), + }) + await renderPage() + + const sheet = await openFriday() + fireEvent.click(await within(sheet).findByRole('button', { name: /Lemon orzo/ })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message") + expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument() + expect(screen.queryByText('Lemon orzo')).not.toBeInTheDocument() + + refuse = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Lemon orzo')).toBeInTheDocument() + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + expect(puts(fetchMock)).toHaveLength(2) + }) + + it('marks dinners already this week, which cannot be chosen', async () => { + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) + await renderPage() + + const sheet = await openFriday() + fireEvent.click(await within(sheet).findByRole('radio', { name: 'Regulars · 2' })) + const regulars = within(sheet).getByRole('list', { name: 'Regulars' }) + const [tacos, fish] = within(regulars).getAllByRole('listitem') + expect(within(tacos!).getByText('Already this week')).toBeInTheDocument() + expect(within(tacos!).queryByRole('button')).not.toBeInTheDocument() + expect(within(fish!).queryByText('Already this week')).not.toBeInTheDocument() + expect(within(fish!).getByRole('button', { name: /Fish tacos/ })).toBeInTheDocument() + + fireEvent.click(within(tacos!).getByText('Beef tacos')) + expect(screen.getByRole('dialog', { name: 'Swap Friday' })).toBeInTheDocument() + expect(puts(fetchMock)).toEqual([]) + }) + + // leaveAndReturn goes to next week and back, remounting this week's board. + async function leaveAndReturn() { + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + await screen.findByText('FRI · NEW') + } + + it('sends one swap at a time even after leaving the week and coming back', async () => { + let answer!: (response: Response) => void + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + ...lists, + [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (answer = resolve)), + }) + await renderPage() + + fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ })) + await waitFor(() => expect(puts(fetchMock)).toHaveLength(1)) + await leaveAndReturn() + + // Friday's swap is still pending: Monday does not open. + fireEvent.click(within(tile('MON')).getByRole('button')) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled() + + await act(async () => answer(jsonResponse(200, swappedBody()))) + await waitFor(() => expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeEnabled()) + fireEvent.click(within(tile('MON')).getByRole('button')) + expect(screen.getByRole('dialog', { name: 'Swap Monday' })).toBeInTheDocument() + expect(puts(fetchMock)).toHaveLength(1) + }) + + it('shows the failure of a swap that ended while the week was left', async () => { + let refuse!: () => void + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + ...lists, + [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (refuse = () => resolve(errorResponse(409, 'dinner_not_plannable')))), + }) + await renderPage() + + fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ })) + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + await act(async () => refuse()) + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message") + expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument() + }) + + it('waits for a pending swap before Rebuild week, keeping its failure', async () => { + let refuse!: () => void + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...lists, + [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (refuse = () => resolve(errorResponse(409, 'dinner_not_plannable')))), + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, draftJob('running')), + }) + await renderPage() + + fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ })) + await screen.findByText('FRI · NEW') + const rebuild = screen.getByRole('button', { name: 'Rebuild week' }) + expect(rebuild).toBeDisabled() + fireEvent.click(rebuild) + expect(posts(fetchMock)).toEqual([]) + + await act(async () => refuse()) + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message") + expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument() + + // Once it has failed, Rebuild week drafts, leaving the failure behind. + fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' })) + await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])) + expect(await screen.findByText('Drafting your week…')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + }) +}) + +describe.each([ + { width: 768, layout: 'tablet', photoHeight: '96px', nameSize: '16px' }, + { width: 1180, layout: 'tablet', photoHeight: '96px', nameSize: '16px' }, + { width: 1440, layout: 'desktop', photoHeight: '110px', nameSize: '17px' }, +])('PlanPage at $width px (mocks 2c, 2d)', ({ width, layout, photoHeight, nameSize }) => { + beforeEach(() => mockViewport(width)) + + // column is the grid's column of a day, by its name: "Monday". + function column(day: string) { + return within(screen.getByRole('list', { name: 'Dinners this week' })).getByRole('listitem', { name: day }) + } + // card is the card in a day's column, holding its dinner. + function card(day: string, dinnerName: string) { + return within(column(day)).getByText(dinnerName).parentElement! + } + + it('renders the plan manager: header, actions, sync status and the 7-column grid', async () => { + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) + await renderPage() + + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + expect(screen.getByText('Week 41 · Plan')).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Previous week' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop') + + // Actions: the shopping list and Push to Calendar unavailable, saying + // why; Rebuild week outlined and working. + const shopping = screen.getByRole('button', { name: 'Shopping list' }) + expect(shopping).toHaveAttribute('aria-disabled', 'true') + expect(shopping).toHaveAccessibleDescription('The shopping list arrives once the Supply run is built.') + const push = screen.getByRole('button', { name: 'Push to Calendar' }) + expect(push).toHaveAttribute('aria-disabled', 'true') + expect(push).toHaveAccessibleDescription('Push to Calendar arrives once Google Calendar can be connected.') + expect(declaredStyle(push).background).toBe('var(--color-accent)') + const rebuild = screen.getByRole('button', { name: 'Rebuild week' }) + expect(rebuild).toBeEnabled() + expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)') + fireEvent.click(shopping) + fireEvent.click(push) + expect(posts(fetchMock)).toEqual([]) + // Mobile's own pieces are not shown. + expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument() + expect(screen.queryByText('Draft')).not.toBeInTheDocument() + + expect(screen.getByText('Not pushed to Google Calendar yet')).toBeInTheDocument() + + // Seven columns, Monday to Sunday, 12 px apart. + const grid = screen.getByRole('list', { name: 'Dinners this week' }) + expect(declaredStyle(grid).gridTemplateColumns).toBe('repeat(7, minmax(112px, 1fr))') + expect(declaredStyle(grid).gap).toBe('12px') + expect(within(grid).getAllByRole('listitem').map((li) => li.getAttribute('aria-label'))).toEqual([ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]) + + // Monday: day label, the washed photo, the name in Bagel, method and + // minutes, and the Regular tag, on the surface. + expect(column('Monday')).toHaveTextContent(/^MON5/) + const monday = card('Monday', 'Beef tacos') + expect(declaredStyle(monday).background).toBe('var(--color-surface)') + const photo = within(monday).getByRole('img', { hidden: true, name: 'Beef tacos' }) + expect(photo).toHaveAttribute('src', '/media/photos/tacos-400.jpg') + expect(declaredStyle(photo).height).toBe(photoHeight) + const name = within(monday).getByText('Beef tacos') + expect(declaredStyle(name).fontFamily).toBe('var(--font-heading)') + expect(declaredStyle(name).fontSize).toBe(nameSize) + // Testing Library reads the duration's no-break spaces as spaces. + expect(within(monday).getByText('Stovetop · 30 min')).toBeInTheDocument() + expect(declaredStyle(within(monday).getByText('Regular')).background).toBe('var(--color-neutral-100)') + expect(within(monday).queryByText(/Start by/)).not.toBeInTheDocument() + + // Wednesday: a crock-pot Side quest on teal-200, with its start-by time. + const wednesday = card('Wednesday', 'Crock pot tikka masala') + expect(declaredStyle(wednesday).background).toBe('var(--color-accent-2-200)') + expect(within(wednesday).getByText('Crock pot · 4 hr')).toBeInTheDocument() + expect(within(wednesday).getByText('Start by 1:30 pm')).toBeInTheDocument() + expect(within(wednesday).getByText('Side quest')).toBeInTheDocument() + expect(within(column('Wednesday')).getByRole('button', { name: 'Swap Wednesday' })).toBeInTheDocument() + }) + + it('says a synced plan is on Google Calendar, and how long ago it was pushed', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'] }) + vi.setSystemTime(new Date('2026-10-07T16:00:00Z')) + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('synced')), ...lists }) + await renderPage() + + const status = await screen.findByText('Synced to Google Calendar 2 min ago') + expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)') + // The time is kept current. + act(() => vi.advanceTimersByTime(58 * 60_000)) + expect(status).toHaveTextContent('Synced to Google Calendar 1 hr ago') + }) + + it('says changes to a pushed plan are not on Google Calendar', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), ...lists }) + await renderPage() + + expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument() + }) + + if (layout === 'desktop') { + it('shows the Quest log and Regulars in the 290 px right panel', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) + await renderPage() + + const panel = screen.getByRole('complementary', { name: 'Quest log and Regulars' }) + expect(declaredStyle(panel).borderLeft).toBe('1px solid var(--color-divider)') + expect(declaredStyle(panel.parentElement!).gridTemplateColumns).toBe('minmax(0, 1fr) 290px') + expect(await within(panel).findByRole('radio', { name: 'Quest log · 1', checked: true })).toBeInTheDocument() + expect(await within(panel).findByRole('radio', { name: 'Regulars · 2' })).toBeInTheDocument() + expect(within(panel).getByText('Drag a dinner onto a day to swap it in.')).toBeInTheDocument() + + const orzo = within(within(panel).getByRole('list', { name: 'Quest log' })).getByRole('listitem') + expect(within(orzo).getByText('Lemon orzo').nextElementSibling).toHaveTextContent('Easy') + expect(declaredStyle(within(orzo).getByRole('img', { hidden: true, name: 'Lemon orzo' })).width).toBe('48px') + // The 6-dot grip, last in the row's button. + expect(within(orzo).getByRole('button').lastElementChild!.children).toHaveLength(6) + + fireEvent.click(within(panel).getByRole('radio', { name: 'Regulars · 2' })) + const regulars = within(within(panel).getByRole('list', { name: 'Regulars' })).getAllByRole('listitem') + expect(regulars).toHaveLength(2) + expect(within(regulars[0]!).getByText('Beef tacos').nextElementSibling).toHaveTextContent('Easy') + expect(within(regulars[1]!).getByText('Fish tacos').nextElementSibling).toHaveTextContent('Medium') + + expect(within(panel).getByRole('link', { name: 'Swipe more side quests' })).toHaveAttribute('href', '/discover') + expect(screen.queryByRole('region', { name: 'Quest log' })).not.toBeInTheDocument() + }) + } else { + it('shows the Quest log as a strip of chips under the grid', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) + await renderPage() + + const strip = screen.getByRole('region', { name: 'Quest log' }) + expect(await within(strip).findByRole('heading', { name: 'Quest log · 1' })).toBeInTheDocument() + expect(within(strip).getByText('Drag a dinner onto a day to swap it in.')).toBeInTheDocument() + const chip = within(within(strip).getByRole('list', { name: 'Quest log dinners' })).getByRole('listitem') + expect(chip).toHaveTextContent('Lemon orzo') + expect(declaredStyle(within(chip).getByRole('img', { hidden: true, name: 'Lemon orzo' })).width).toBe('28px') + // The strip follows the grid. + const grid = await screen.findByRole('list', { name: 'Dinners this week' }) + expect(grid.compareDocumentPosition(strip) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(screen.queryByRole('complementary')).not.toBeInTheDocument() + }) + } + + it('moves between weeks with the previous and next week buttons', async () => { + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + [`GET ${PREVIOUS_WEEK}`]: () => errorResponse(404, 'not_found'), + ...lists, + }) + await renderPage() + + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument() + expect(screen.getByText('Week 42 · Plan')).toBeInTheDocument() + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Sep 28 – Oct 4' })).toBeInTheDocument() + expect(screen.getByText('Week 40 · Plan')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument() + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + }) + + if (layout === 'desktop') { + it('comes back to this week with the "This week" pill', async () => { + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + ...lists, + }) + await renderPage() + + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + const thisWeek = screen.getByRole('button', { name: 'This week' }) + expect(declaredStyle(thisWeek).background).toBe('var(--color-surface)') + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'This week' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument() + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + }) + + it("comes back to this week even when the plan opened on next week's", async () => { + vi.setSystemTime(new Date('2026-10-09T13:00:00Z')) // Friday 09:00 in New York + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + ...lists, + }) + await renderPage() + + expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'This week' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument() + }) + } + + it("opens the swap sheet from a day's swap icon and swaps the dinner in", async () => { + const FRIDAY = `${THIS_WEEK}/days/2026-10-09` + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...lists, + [`PUT ${FRIDAY}`]: () => new Promise(() => undefined), + }) + await renderPage() + + fireEvent.click(within(await waitFor(() => column('Friday'))).getByRole('button', { name: 'Swap Friday' })) + const sheet = await screen.findByRole('dialog', { name: 'Swap Friday' }) + expect(within(sheet).getByText('Replaces Pizza night')).toBeInTheDocument() + // The card being swapped is ringed in grape. + expect(declaredStyle(card('Friday', 'Pizza night')).boxShadow).toBe('inset 0 0 0 2px var(--color-accent)') + + fireEvent.keyDown(sheet, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(declaredStyle(card('Friday', 'Pizza night')).boxShadow).toBe('') + + fireEvent.click(within(column('Friday')).getByRole('button', { name: 'Swap Friday' })) + const again = await screen.findByRole('dialog', { name: 'Swap Friday' }) + fireEvent.click(await within(again).findByRole('button', { name: /Lemon orzo/ })) + + // Optimistic: the sheet is closed and Friday is the Side quest, on teal. + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + const friday = await waitFor(() => card('Friday', 'Lemon orzo')) + expect(within(friday).getByText('Side quest')).toBeInTheDocument() + expect(within(friday).getByText('Stovetop · 25 min')).toBeInTheDocument() + expect(declaredStyle(friday).background).toBe('var(--color-accent-2-200)') + await waitFor(() => expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT')).toHaveLength(1)) + // Rebuild week waits for the swap. + expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled() + }) + + it.each(['synced', 'changed'])('asks before rebuilding a %s plan, and sends confirm', async (status) => { + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody(status)), + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, draftJob('running')), + ...lists, + }) + await renderPage() + + await screen.findByText('Beef tacos') + fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' })) + const dialog = await screen.findByRole('dialog', { name: 'Rebuild this week?' }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Keep this plan' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(posts(fetchMock)).toEqual([]) + + fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' })) + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Rebuild week' })) + await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {"confirm":true}`])) + expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument() + expect(screen.getAllByTestId('skeleton-tile')).toHaveLength(7) + expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled() + }) + + it('rebuilds a draft at once', async () => { + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, draftJob('running')), + ...lists, + }) + await renderPage() + + await screen.findByText('Beef tacos') + fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])) + }) + it('reads the Quest log again once a draft finishes, dropping the dinners it planned', async () => { + let plan = planBody('draft') + let job = draftJob('running') + let questLog = [dinner('q1', 'Lemon orzo', 'easy', 25)] + stubServer({ + ...lists, + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan), + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, job), + [`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: questLog }), + }) + await renderPage() + + const list = await screen.findByRole('list', { name: layout === 'desktop' ? 'Quest log' : 'Quest log dinners' }) + expect(await within(list).findByText('Lemon orzo')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' })) + expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument() + + plan = planBody('draft', ['Lemon orzo', ...NAMES.slice(1)]) + questLog = [] + job = draftJob('succeeded') + const week = await screen.findByRole('list', { name: 'Dinners this week' }, { timeout: 3000 }) + expect(await within(week).findByText('Lemon orzo', {}, { timeout: 3000 })).toBeInTheDocument() + await waitFor(() => expect(within(list).queryByText('Lemon orzo')).not.toBeInTheDocument()) + }) +}) + +// Dragging a dinner from desktop's panel or tablet's strip onto a day swaps +// it in, by pointer or keyboard. +describe.each([ + { width: 1180, layout: 'tablet' }, + { width: 1440, layout: 'desktop' }, +])('PlanPage drag and drop at $width px', ({ width, layout }) => { + const FRIDAY = `${THIS_WEEK}/days/2026-10-09` + let capture: Mock<(pointerId: number) => void> + // The element document.elementFromPoint finds under the pointer. + let hovered: Element | null = null + + // jsdom has no pointer capture and no layout to hit-test. + beforeEach(() => { + mockViewport(width) + capture = vi.fn() + Element.prototype.setPointerCapture = capture + hovered = null + document.elementFromPoint = () => hovered + }) + + afterEach(() => { + Reflect.deleteProperty(Element.prototype, 'setPointerCapture') + Reflect.deleteProperty(document, 'elementFromPoint') + }) + + // The Quest log has lemon orzo and miso salmon. + const dragLists = { + ...lists, + [`GET ${QUEST_LOG}`]: () => + jsonResponse(200, { dinners: [dinner('q1', 'Lemon orzo', 'easy', 25), dinner('q2', 'Miso salmon', 'medium', 30)] }), + } + + // swappedBody is the plan as the server answers a swap of Friday to lemon + // orzo, a Quest-log pick. + function swappedBody() { + const body = planBody('draft') + body.days[4] = { ...body.days[4]!, dinner: { ...body.days[4]!.dinner, id: 'q1', name: 'Lemon orzo' }, is_new: true } + return body + } + + function puts(fetchMock: Mock) { + return fetchMock.mock.calls + .filter(([, init]) => init?.method === 'PUT') + .map(([input, init]) => `${String(input)} ${String(init?.body)}`) + } + + function column(day: string) { + return within(screen.getByRole('list', { name: 'Dinners this week' })).getByRole('listitem', { name: day }) + } + + // quest is the Quest-log dinner's draggable: a row of the panel on + // desktop, a chip of the strip on tablet. + async function quest(name: string) { + const list = await screen.findByRole('list', { name: layout === 'desktop' ? 'Quest log' : 'Quest log dinners' }) + return within(list).getByRole('button', { name: new RegExp(name) }) + } + + const announcer = () => screen.getByTestId('drag-announcer') + const float = () => screen.queryByTestId('drag-float') + + // press, move and release drive pointer 3 on the draggable: down at + // (100, 100), moved over an element (or none) at (160, 140) or the given + // point, and let go there. + function press(draggable: Element) { + fireEvent.pointerDown(draggable, { pointerId: 3, isPrimary: true, button: 0, clientX: 100, clientY: 100 }) + } + function move(draggable: Element, over: Element | null, [clientX, clientY] = [160, 140]) { + hovered = over + fireEvent.pointerMove(draggable, { pointerId: 3, isPrimary: true, clientX, clientY }) + } + function release(draggable: Element) { + fireEvent.pointerUp(draggable, { pointerId: 3, isPrimary: true, button: 0, clientX: 160, clientY: 140 }) + } + + it('drops a dinner dragged onto a day, swapping it in at once and landing the plan the server answers', async () => { + let answer!: (response: Response) => void + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...dragLists, + [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (answer = resolve)), + }) + await renderPage() + await screen.findByText('Pizza night') + const orzo = await quest('Lemon orzo') + expect(declaredStyle(orzo).getPropertyValue('touch-action')).toBe('none') + expect(orzo).toHaveAccessibleDescription( + 'Press Enter or Space to pick it up, the arrow keys to choose a day, Enter to drop it on that day and Escape to cancel.', + ) + + press(orzo) + expect(capture).toHaveBeenCalledExactlyOnceWith(3) + expect(capture.mock.contexts[0]).toBe(orzo) + // A press alone is no drag. + expect(float()).not.toBeInTheDocument() + + // Over Friday: the source a dashed ghost at 55%, its copy floating with + // the large shadow at -3deg, and Friday the drop target. + move(orzo, within(column('Friday')).getByText('Pizza night')) + expect(declaredStyle(orzo).opacity).toBe('0.55') + expect(declaredStyle(orzo).borderStyle).toBe('dashed') + const copy = float()! + expect(copy).toHaveTextContent('Lemon orzo') + expect(copy).toHaveAttribute('aria-hidden', 'true') + expect(declaredStyle(copy).boxShadow).toBe('var(--shadow-lg)') + expect(declaredStyle(copy).transform).toBe('rotate(-3deg)') + expect(declaredStyle(copy).pointerEvents).toBe('none') + // Offset 48 px below right of the pointer at (160, 140), clear of the + // day's text under it. + expect(copy.style.left).toBe('208px') + expect(copy.style.top).toBe('188px') + expect(announcer()).toHaveTextContent('Picked up Lemon orzo.') + const target = within(column('Friday')).getByText('Drop to swap').parentElement! + expect(target).toHaveTextContent('Drop to swapReplaces Pizza night') + expect(declaredStyle(target).border).toBe('2.5px dashed var(--color-accent)') + expect(declaredStyle(target).background).toBe('var(--color-accent-100)') + expect(within(column('Thursday')).queryByText('Drop to swap')).not.toBeInTheDocument() + + // Moving on to Thursday moves the target. + move(orzo, column('Thursday')) + expect(within(column('Friday')).queryByText('Drop to swap')).not.toBeInTheDocument() + expect(within(column('Thursday')).getByText('Replaces Chicken stir-fry')).toBeInTheDocument() + + move(orzo, within(column('Friday')).getByRole('button', { name: 'Swap Friday' })) + release(orzo) + expect(float()).not.toBeInTheDocument() + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + expect(declaredStyle(orzo).opacity).toBe('') + expect(announcer()).toHaveTextContent('Dropped Lemon orzo on Friday, replacing Pizza night.') + + // Optimistic: Friday is the Side quest at once. + const friday = await waitFor(() => within(column('Friday')).getByText('Lemon orzo').parentElement!) + expect(within(friday).getByText('Side quest')).toBeInTheDocument() + await waitFor(() => expect(puts(fetchMock)).toEqual([`${FRIDAY} {"dinner_id":"q1"}`])) + + await act(async () => answer(jsonResponse(200, swappedBody()))) + expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument() + expect(screen.queryByText('Pizza night')).not.toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(puts(fetchMock)).toHaveLength(1) + }) + + it('flips the floating copy to the left of and above the pointer where it would leave the viewport', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...dragLists }) + await renderPage() + await screen.findByText('Pizza night') + const orzo = await quest('Lemon orzo') + vi.spyOn(orzo, 'getBoundingClientRect').mockReturnValue(DOMRect.fromRect({ x: 90, y: 90, width: 240, height: 60 })) + + press(orzo) + const [x, y] = [window.innerWidth - 100, window.innerHeight - 40] + move(orzo, document.body, [x, y]) + const copy = float()! + expect(copy.style.width).toBe('240px') + expect(copy.style.left).toBe(`${x - 48 - 240}px`) + expect(copy.style.top).toBe(`${y - 48 - 60}px`) + release(orzo) + }) + + it('cancels a drag let go over no day, or cancelled, with no swap', async () => { + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...dragLists }) + await renderPage() + await screen.findByText('Pizza night') + const orzo = await quest('Lemon orzo') + + press(orzo) + move(orzo, column('Friday')) + expect(within(column('Friday')).getByText('Drop to swap')).toBeInTheDocument() + // Off the grid, onto the page around it. + move(orzo, document.body) + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + expect(float()).toBeInTheDocument() + release(orzo) + expect(float()).not.toBeInTheDocument() + expect(declaredStyle(orzo).opacity).toBe('') + expect(announcer()).toHaveTextContent('Cancelled. Lemon orzo was not swapped in.') + + // A cancelled pointer, over a day, is no drop either. + press(orzo) + move(orzo, column('Monday')) + expect(within(column('Monday')).getByText('Replaces Beef tacos')).toBeInTheDocument() + fireEvent.pointerCancel(orzo, { pointerId: 3, isPrimary: true }) + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + expect(float()).not.toBeInTheDocument() + + // Nor is a drag Escape ends. + press(orzo) + move(orzo, column('Monday')) + fireEvent.keyDown(orzo, { key: 'Escape' }) + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + release(orzo) + + expect(within(column('Friday')).getByText('Pizza night')).toBeInTheDocument() + expect(within(column('Monday')).getByText('Beef tacos')).toBeInTheDocument() + expect(puts(fetchMock)).toEqual([]) + }) + + it('puts the day back and shows the failure when the dropped swap is refused', async () => { + let refuse!: () => void + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...dragLists, + [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (refuse = () => resolve(errorResponse(409, 'dinner_not_plannable')))), + }) + await renderPage() + await screen.findByText('Pizza night') + const orzo = await quest('Lemon orzo') + + press(orzo) + move(orzo, column('Friday')) + release(orzo) + await waitFor(() => expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument()) + await waitFor(() => expect(puts(fetchMock)).toHaveLength(1)) + + // While the swap is pending no other drag starts, by pointer or keyboard. + const salmon = await quest('Miso salmon') + expect(salmon).toHaveAttribute('aria-disabled', 'true') + press(salmon) + move(salmon, column('Monday')) + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + expect(float()).not.toBeInTheDocument() + release(salmon) + fireEvent.keyDown(salmon, { key: 'Enter' }) + expect(announcer()).toHaveTextContent('A swap is still being saved. Pick Miso salmon up once it is done.') + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + + await act(async () => refuse()) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message") + expect(within(column('Friday')).getByText('Pizza night')).toBeInTheDocument() + expect(within(column('Friday')).queryByText('Lemon orzo')).not.toBeInTheDocument() + expect(puts(fetchMock)).toHaveLength(1) + await waitFor(() => expect(salmon).not.toHaveAttribute('aria-disabled')) + }) + + it('swaps a dinner in with the keyboard, announcing each step', async () => { + let answer!: (response: Response) => void + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...dragLists, + [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (answer = resolve)), + }) + await renderPage() + await screen.findByText('Pizza night') + const orzo = await quest('Lemon orzo') + expect(announcer()).toHaveAttribute('aria-live', 'polite') + + // Enter picks it up onto Monday. + orzo.focus() + fireEvent.keyDown(orzo, { key: 'Enter' }) + expect(announcer()).toHaveTextContent( + 'Picked up Lemon orzo. Monday: replaces Beef tacos. Use the arrow keys to choose a day, Enter to drop, Escape to cancel.', + ) + expect(declaredStyle(orzo).opacity).toBe('0.55') + expect(within(column('Monday')).getByText('Replaces Beef tacos')).toBeInTheDocument() + // No pointer, so no floating copy. + expect(float()).not.toBeInTheDocument() + + // The arrow keys choose the day, going no further than Monday. + fireEvent.keyDown(orzo, { key: 'ArrowLeft' }) + expect(within(column('Monday')).getByText('Drop to swap')).toBeInTheDocument() + fireEvent.keyDown(orzo, { key: 'ArrowRight' }) + expect(announcer()).toHaveTextContent('Tuesday: replaces Spaghetti bolognese.') + expect(within(column('Monday')).queryByText('Drop to swap')).not.toBeInTheDocument() + fireEvent.keyDown(orzo, { key: 'ArrowDown' }) + fireEvent.keyDown(orzo, { key: 'ArrowRight' }) + fireEvent.keyDown(orzo, { key: 'ArrowRight' }) + fireEvent.keyDown(orzo, { key: 'ArrowRight' }) + fireEvent.keyDown(orzo, { key: 'ArrowUp' }) + expect(announcer()).toHaveTextContent('Friday: replaces Pizza night.') + expect(within(column('Friday')).getByText('Replaces Pizza night')).toBeInTheDocument() + expect(puts(fetchMock)).toEqual([]) + + // Enter drops it, swapping it in at once; focus moves to the day. + fireEvent.keyDown(orzo, { key: 'Enter' }) + expect(announcer()).toHaveTextContent('Dropped Lemon orzo on Friday, replacing Pizza night.') + expect(screen.getByRole('button', { name: 'Swap Friday' })).toHaveFocus() + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + await waitFor(() => expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument()) + await waitFor(() => expect(puts(fetchMock)).toEqual([`${FRIDAY} {"dinner_id":"q1"}`])) + await act(async () => answer(jsonResponse(200, swappedBody()))) + expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument() + expect(puts(fetchMock)).toHaveLength(1) + }) + + it('cancels a keyboard drag with Escape, or by leaving it', async () => { + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...dragLists }) + await renderPage() + await screen.findByText('Pizza night') + const orzo = await quest('Lemon orzo') + + // Space picks it up too, but does not drop it. + orzo.focus() + fireEvent.keyDown(orzo, { key: ' ' }) + fireEvent.keyDown(orzo, { key: 'ArrowRight' }) + fireEvent.keyDown(orzo, { key: ' ' }) + expect(within(column('Tuesday')).getByText('Drop to swap')).toBeInTheDocument() + fireEvent.keyDown(orzo, { key: 'Escape' }) + expect(announcer()).toHaveTextContent('Cancelled. Lemon orzo was not swapped in.') + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + expect(declaredStyle(orzo).opacity).toBe('') + expect(orzo).toHaveFocus() + + // The arrow keys do nothing until it is picked up again; Enter does. + fireEvent.keyDown(orzo, { key: 'ArrowRight' }) + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + fireEvent.keyDown(orzo, { key: 'Enter' }) + expect(within(column('Monday')).getByText('Drop to swap')).toBeInTheDocument() + // Tabbing away cancels. + fireEvent.blur(orzo) + expect(announcer()).toHaveTextContent('Cancelled. Lemon orzo was not swapped in.') + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + + expect(within(column('Monday')).getByText('Beef tacos')).toBeInTheDocument() + expect(puts(fetchMock)).toEqual([]) + }) + + if (layout === 'desktop') { + it('does not pick up a Regular already this week', async () => { + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...dragLists, + [`PUT ${THIS_WEEK}/days/2026-10-07`]: () => new Promise(() => undefined), + }) + await renderPage() + await screen.findByText('Pizza night') + fireEvent.click(await screen.findByRole('radio', { name: 'Regulars · 2' })) + const regulars = screen.getByRole('list', { name: 'Regulars' }) + const tacos = within(regulars).getByRole('button', { name: /Beef tacos/ }) + expect(tacos).toHaveAttribute('aria-disabled', 'true') + fireEvent.keyDown(tacos, { key: 'Enter' }) + expect(announcer()).toHaveTextContent('Beef tacos is already this week.') + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + + // A Regular not this week is dragged in as one, not NEW. + const fish = within(regulars).getByRole('button', { name: /Fish tacos/ }) + expect(fish).not.toHaveAttribute('aria-disabled') + press(fish) + move(fish, column('Wednesday')) + release(fish) + await waitFor(() => expect(within(column('Wednesday')).getByText('Fish tacos').parentElement).toHaveTextContent('Regular')) + await waitFor(() => expect(puts(fetchMock)).toEqual([`${THIS_WEEK}/days/2026-10-07 {"dinner_id":"r1"}`])) + }) + } +}) + +// Mobile's Draft board has no drag: its tiles open the swap sheet. +describe('PlanPage drag and drop on mobile', () => { + it('has no draggables, drop targets or drag announcements', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) + const { container } = await renderPage() + + await screen.findByText('Pizza night') + expect(container.querySelector('[data-drop-date]')).toBeNull() + expect(screen.queryByTestId('drag-announcer')).not.toBeInTheDocument() + expect(screen.queryByText('Drag a dinner onto a day to swap it in.')).not.toBeInTheDocument() + }) +}) + +// At tablet's narrow end seven usable columns do not fit beside the rail: +// the week keeps its columns usable and scrolls sideways. +describe('PlanPage at 768 px', () => { + beforeEach(() => mockViewport(768)) + + it('keeps each day column usable and scrolls the week sideways', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) + await renderPage() + + expect(await screen.findByText('Spaghetti bolognese')).toBeInTheDocument() + const grid = screen.getByRole('list', { name: 'Dinners this week' }) + // Columns no narrower than 112 px; the week scrolls when seven do not + // fit, its padding keeping the swap icons' hit boxes inside. + expect(declaredStyle(grid).gridTemplateColumns).toBe('repeat(7, minmax(112px, 1fr))') + expect(declaredStyle(grid).overflowX).toBe('auto') + expect(declaredStyle(grid).padding).toBe('8px') + expect(declaredStyle(grid).margin).toBe('-8px') + + const tuesday = within(grid).getByRole('listitem', { name: 'Tuesday' }) + // The name wraps between words, only breaking a word wider than the card. + const name = within(tuesday).getByText('Spaghetti bolognese') + expect(declaredStyle(name).overflowWrap).toBe('break-word') + expect(declaredStyle(name).hyphens).toBe('') + // The swap icon stays beside the tag. + const footer = within(tuesday).getByRole('button', { name: 'Swap Tuesday' }).parentElement! + expect(within(footer).getByText('Regular')).toBeInTheDocument() + expect(declaredStyle(footer).display).toBe('flex') + expect(declaredStyle(footer).flexWrap).toBe('') + // Every piece of the card is still there. + expect(within(tuesday).getByRole('img', { hidden: true, name: 'Spaghetti bolognese' })).toBeInTheDocument() + expect(within(tuesday).getByText('Stovetop · 30 min')).toBeInTheDocument() + expect(within(within(grid).getByRole('listitem', { name: 'Wednesday' })).getByText('Start by 1:30 pm')).toBeInTheDocument() + }) +}) + +describe('duration', () => { + it.each([ + [25, '25\u00a0min'], + [60, '1\u00a0hr'], + [90, '1\u00a0hr\u00a030\u00a0min'], + [360, '6\u00a0hr'], + ])('says %i minutes as %j', (minutes, said) => { + expect(duration(minutes)).toBe(said) + }) +}) + +describe('cooking', () => { + const dinner = { id: 'd1', name: 'Tikka masala', photo_id: undefined } + it.each([ + [{ method: 'crockpot' as const, minutes: 360 }, 'Crock pot · 6\u00a0hr'], + [{ method: 'crockpot' as const, minutes: null }, 'Crock pot'], + [{ method: null, minutes: 45 }, '45\u00a0min'], + [{ method: null, minutes: null }, null], + ])('says %o as %j', (known, said) => { + expect(cooking({ ...dinner, ...known })).toBe(said) + }) +}) + +describe('ago', () => { + const now = new Date('2026-10-07T16:00:00Z') + it.each([ + ['2026-10-07T15:59:30Z', 'just now'], + ['2026-10-07T15:58:00Z', '2 min ago'], + ['2026-10-07T13:00:00Z', '3 hr ago'], + ['2026-10-06T15:00:00Z', '1 day ago'], + ['2026-10-04T15:00:00Z', '3 days ago'], + ])('says %s as %j', (at, said) => { + expect(ago(new Date(at), now)).toBe(said) + }) +}) diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx new file mode 100644 index 0000000..3867949 --- /dev/null +++ b/web/src/plan/PlanPage.tsx @@ -0,0 +1,599 @@ +import { useQueryClient } from '@tanstack/react-query' +import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react' +import { useEffect, useId, useMemo, useState, type ReactNode } from 'react' +import { useJob } from '../api/index.ts' +import { QUEST_LOG_KEY } from '../discover/api.ts' +import { Confirm } from '../settings/LeaveGuard.tsx' +import { methodLabels } from '../settings/RegularsSettingsPage.tsx' +import { formatClockTime, useHouseholdSettings } from '../settings/api.ts' +import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts' +import { Button, type ButtonVariant } from '../ui/Button.tsx' +import { EmptyState } from '../ui/EmptyState.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { IconButton } from '../ui/IconButton.tsx' +import { Photo } from '../ui/Photo.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { Tag } from '../ui/Tag.tsx' +import { + DRAFT_JOB_KIND, + parseDraftResult, + planKey, + usePlan, + useStartDraft, + useSwapDay, + type DraftResult, + type Plan, + type PlanDay, + type PlanDinner, + type PlanStatus, + type Swap, +} from './api.ts' +import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx' +import { PlanPanel, QuestLogStrip } from './PlanPanel.tsx' +import { SwapSheet } from './SwapSheet.tsx' +import { + addDays, + dayOfMonth, + defaultWeek, + localNow, + weekdayKicker, + weekdayName, + weekLabel, + weekStart as weekOf, +} from './week.ts' +import styles from './PlanPage.module.css' + +// statusTags is the tag beside the week's title for each plan status. +const statusTags: Record = { + draft: { label: 'Draft', tone: 'grape' }, + synced: { label: 'Synced', tone: 'teal' }, + changed: { label: 'Changes not pushed', tone: 'grape' }, +} + +// draftFailures says why a draft_week job failed, by its failure code. Any +// other code is shown as it is. +const draftFailures: Record = { + not_enough_dinners: 'There are fewer than seven Regulars and Quest-log dinners to draft from.', + ai_failed: "The AI assistant couldn't draft it.", + confirm_required: 'The plan was pushed to Calendar while it was drafting.', + plan_changed: 'The plan was edited while it was drafting.', + pool_changed: 'A dinner it picked was archived, tried or planned for another week while it was drafting.', + internal_error: 'Something went wrong on the server.', +} + +// PUSH_HINT is why Push to Calendar is unavailable. +// TODO(Feature 16): connect Push to Calendar once Google Calendar is. +const PUSH_HINT = 'Push to Calendar arrives once Google Calendar can be connected.' + +// SHOPPING_HINT is why the shopping list is unavailable. +// TODO(Feature 17): open the Supply run, with its count, once it is built. +const SHOPPING_HINT = 'The shopping list arrives once the Supply run is built.' + +// syncStatuses is the plan manager's line on whether the plan is on Calendar. +const syncStatuses: Record = { + draft: 'Not pushed to Google Calendar yet', + synced: 'Synced to Google Calendar', + changed: 'Changes not pushed to Google Calendar', +} + +// duration is minutes as the plan manager's cards say it: "45 min", "6 hr", +// "1 hr 30 min", with no-break spaces so a narrow card wraps the line +// between the method and the duration, never inside the duration. +export function duration(minutes: number): string { + const hours = Math.floor(minutes / 60) + const rest = minutes % 60 + if (hours === 0) return `${rest}\u00a0min` + return rest === 0 ? `${hours}\u00a0hr` : `${hours}\u00a0hr\u00a0${rest}\u00a0min` +} + +// cooking is a card's "method · duration" line, "Crock pot · 6 hr", with +// whichever of the two is known, or null when neither is. +export function cooking(dinner: PlanDinner): string | null { + const parts = [dinner.method && methodLabels[dinner.method], dinner.minutes !== null && duration(dinner.minutes)].filter( + (part): part is string => typeof part === 'string', + ) + return parts.length === 0 ? null : parts.join(' · ') +} + +// ago is how long before now the instant at was: "just now", "2 min ago", +// "3 hr ago", "2 days ago". +export function ago(at: Date, now: Date): string { + const minutes = Math.floor((now.getTime() - at.getTime()) / 60_000) + if (minutes < 1) return 'just now' + if (minutes < 60) return `${minutes} min ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours} hr ago` + const days = Math.floor(hours / 24) + return days === 1 ? '1 day ago' : `${days} days ago` +} + +// pushedToCalendar says whether drafting over a plan of this status needs +// confirm: it was pushed to Calendar. +function pushedToCalendar(status: PlanStatus): boolean { + return status !== 'draft' +} + +// DayTile is one day of the Draft board: a Regular on the surface, a +// Quest-log pick in teal with its "NEW" kicker, and a crock-pot dinner's +// start-by time in the household's zone. Tapping it opens the swap sheet; +// while that is open the tile is swapping: on the page background, ringed in +// grape, its name muted. +function DayTile({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) { + const kicker = weekdayKicker(day.date) + const classes = [styles.tile, day.is_new && styles.newTile, swapping && styles.swapping].filter(Boolean).join(' ') + return ( +
  • + +
  • + ) +} + +// DayColumn is one day of the plan manager's 7-column grid: its day label +// over a card with the dinner's washed photo, name, method and time, a +// crock-pot dinner's start-by time in the household's zone, and its Regular +// or Side quest tag, a Side quest's card in teal. The swap icon opens the swap +// sheet; while that is open the card is swapping, ringed in grape. A dinner +// dragged over the day drops on it: the card is swapping, covered by a +// dashed grape "Drop to swap" saying which dinner it replaces. +function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) { + const { dinner } = day + const time = cooking(dinner) + const target = useDropTarget(day.date) !== null + const classes = [styles.card, day.is_new && styles.sideQuestCard, (swapping || target) && styles.swapping].filter(Boolean).join(' ') + return ( +
  • + +
    + {/* The card's name already says what the photo shows. */} + +

    {dinner.name}

    + {time !== null &&

    {time}

    } + {day.start_by !== null && ( +

    Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}

    + )} +
    + {day.is_new ? 'Side quest' : 'Regular'} + + + +
    + {target && ( +
    +

    Drop to swap

    +

    Replaces {dinner.name}

    +
    + )} +
    +
  • + ) +} + +// SkeletonTiles stands in for the seven days while the plan loads or the AI +// drafts it, as two columns of tiles on mobile and seven columns wider. +function SkeletonTiles({ label, layout, children }: { label: string; layout: Breakpoint; children?: string }) { + const wide = layout !== 'mobile' + return ( +
    +
    + {Array.from({ length: 7 }, (_, i) => ( + + ))} +
    + {children !== undefined &&

    {children}

    } +
    + ) +} + +// WeekBoard is the Draft board of the week starting on weekStart. Drafting +// it — the empty state's "Draft this week" or the "+ Rebuild week" tile — +// starts the draft_week job and shows skeleton tiles until the job ends and +// the plan is read again. Rebuilding a plan pushed to Calendar asks first, +// and sends confirm. A failed draft is shown with Retry; no plan is made up. +// +// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week and the +// unavailable Shopping list and Push to Calendar are actions in the header, +// over the plan's sync status and the 7-column grid. +function WeekBoard({ + weekStart, + timezone, + layout, + onWeek, +}: { + weekStart: string + timezone: string + layout: Breakpoint + onWeek: (weekStart: string) => void +}) { + const queryClient = useQueryClient() + const plan = usePlan(weekStart) + const startDraft = useStartDraft() + const [jobId, setJobId] = useState(null) + const job = useJob(DRAFT_JOB_KIND, jobId) + const [confirming, setConfirming] = useState(false) + const swap = useSwapDay(weekStart) + // The date of the day being swapped, while the swap sheet is open. + const [swappingDate, setSwappingDate] = useState(null) + const label = weekLabel(weekStart) + + // The draft's result, once the job has succeeded, or why it cannot be read. + const result = useMemo((): { draft: DraftResult | null; error: Error | null } => { + if (job.status !== 'succeeded') return { draft: null, error: null } + try { + return { draft: parseDraftResult(`GET /api/v1/jobs/${jobId}`, weekStart, job.result), error: null } + } catch (error) { + return { draft: null, error: error as Error } + } + }, [job.status, job.result, jobId, weekStart]) + + // Once the job ends the plan is read again: a succeeded draft is followed + // until that read lands; a failed one stays shown, and may have met a + // plan changed meanwhile. The Quest log is read again too, as after a + // swap: the draft may have planned some of it. + const [readJobId, setReadJobId] = useState(null) + const ended = job.status === 'succeeded' || job.status === 'failed' + useEffect(() => { + if (!ended) return + let active = true + void queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }) + void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }).then(() => { + if (active) setReadJobId(jobId) + }) + return () => { + active = false + } + }, [queryClient, ended, jobId, weekStart]) + + // The plan read shows once it is the one drafted, read from the server; no + // plan, another one, or a copy cached before the draft (a redraft keeps the + // plan's id) is an error rather than an empty or stale board. Its Retry + // reads the plan again, keeping the job: a later read from the server that + // is the draft still lands it. + const { draft } = result + const read = draft !== null && readJobId === jobId && plan.isSuccess + const landed = read && plan.data?.id === draft.plan_id && !plan.data.cached + const unreadError = + read && !landed + ? new Error( + plan.data === null + ? `The week has no plan, not draft ${draft.plan_id}.` + : plan.data.id !== draft.plan_id + ? `The week's plan is not draft ${draft.plan_id}.` + : `The plan came from this device's cache, not the server, so it may not be draft ${draft.plan_id} yet.`, + ) + : null + const resultError = result.error ?? unreadError + useEffect(() => { + if (landed) setJobId(null) + }, [landed]) + + const drafting = startDraft.isPending || (jobId !== null && job.status !== 'failed' && resultError === null) + + const { mutate } = startDraft + function start(confirm: boolean) { + setConfirming(false) + // A draft asked for again leaves the last one's failure, and a failed + // swap's, behind. + setJobId(null) + swap.dismiss() + mutate( + { weekStart, confirm }, + { + onSuccess: setJobId, + // A plan pushed to Calendar meanwhile needs confirm: read it again. + onError: () => void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }), + }, + ) + } + // rebuild drafts the week, asking first over a plan pushed to Calendar. + // It waits for a swap of the week still pending, which may yet fail: the + // Rebuild week tile is disabled meanwhile. + function rebuild() { + if (swap.pending) return + if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true) + else start(false) + } + + const failure = swap.failed !== null ? ( + swapIn(swap.failed!.swap)}> + Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message} + + ) : startDraft.isError ? ( + Couldn't start drafting this week. {startDraft.error.message} + ) : result.error !== null ? ( + Couldn't read the draft. {result.error.message} + ) : unreadError !== null ? ( + void plan.refetch()}>Couldn't read the draft. {unreadError.message} + ) : job.error === null ? null : job.status === 'failed' ? ( + + Couldn't draft this week. {draftFailures[job.error.message] ?? job.error.message} + + ) : ( + Couldn't check on the draft. {job.error.message} + ) + + // swapIn sends the swap chosen in the sheet and closes it. + function swapIn(chosen: Swap) { + setSwappingDate(null) + swap.mutate(chosen) + } + // dropIn swaps in a dinner dragged onto a day. A drag is not picked up, + // nor dropped, while a swap of the week is pending. + function dropIn(chosen: Swap) { + swap.dismiss() + swapIn(chosen) + } + // openSwap opens the swap sheet on a day. One swap of the week is sent at + // a time, even across leaving the week and coming back: the plan the + // server answers it with must not overwrite another still pending. + function openSwap(date: string) { + if (swap.pending) return + swap.dismiss() + setSwappingDate(date) + } + + let body + if (plan.isError) { + body = void plan.refetch()}>Couldn't load this week's plan. {plan.error.message} + } else if (drafting) { + body = ( + + Drafting your week… + + ) + } else if (resultError !== null) { + // An unread draft shows no board: the plan read is not the draft. + body = null + } else if (plan.isPending) { + body = + } else if (plan.data === null) { + body = ( + + I'll draft seven dinners from your Regulars and Quest log. + + ) + } else { + const { ai_note, days } = plan.data + const swappingDay = days.find((day) => day.date === swappingDate) + const sheet = swappingDay !== undefined && ( + day.dinner.id))} + onSwap={swapIn} + onClose={() => setSwappingDate(null)} + /> + ) + body = + layout !== 'mobile' ? ( + <> + +
      + {days.map((day) => ( + openSwap(day.date)} + /> + ))} +
    + {sheet} + + ) : ( + <> + {ai_note !== null && ( +
    + +

    {ai_note}

    +
    + )} +
      + {days.map((day) => ( + openSwap(day.date)} + /> + ))} +
    • + +
    • +
    + + Looks good — push to Calendar + + {sheet} + + ) + } + + const tag = plan.data ? statusTags[plan.data.status] : null + const header = + layout !== 'mobile' ? ( +
    +
    +
    +

    {label.week} · Plan

    +

    {label.dates}

    +
    +
    + onWeek(addDays(weekStart, -7))}> + + + {layout === 'desktop' && ( + + )} + onWeek(addDays(weekStart, 7))}> + + +
    +
    +
    + {plan.data ? : } +
    + + + Shopping list + + + + + Push to Calendar + +
    +
    +
    + ) : ( +
    +
    +

    {label.week}

    +
    +

    {label.dates}

    + {tag !== null && {tag.label}} +
    +
    +
    + onWeek(addDays(weekStart, -7))}> + + + onWeek(addDays(weekStart, 7))}> + + +
    +
    + ) + return ( + <> + {header} + {body} + {failure} + setConfirming(false)} + onConfirm={() => start(true)} + > + This week is already pushed to Calendar. The AI assistant drafts a new plan in its place. + + + ) +} + +// SyncStatus is the plan manager's status line: whether the plan is on +// Google Calendar and, once synced, how long ago it was pushed, kept current +// minute by minute. +function SyncStatus({ plan }: { plan: Plan }) { + const { status, calendar_synced_at: syncedAt } = plan + const [now, setNow] = useState(() => new Date()) + useEffect(() => { + const timer = setInterval(() => setNow(new Date()), 60_000) + return () => clearInterval(timer) + }, []) + return ( +

    + {status === 'synced' && } + {syncStatuses[status]} + {status === 'synced' && syncedAt !== null && ` ${ago(syncedAt, now)}`} +

    + ) +} + +// Unavailable is an action with nothing behind it yet (Push to Calendar, the +// shopping list): it stays focusable, so a tap or focus shows the tooltip +// saying why, above it or, at the top of the page, below. +function Unavailable({ + hint, + variant = 'primary', + below = false, + className, + children, +}: { + hint: string + variant?: ButtonVariant + below?: boolean + className?: string + children: ReactNode +}) { + const hintId = useId() + return ( +
    + + + {hint} + +
    + ) +} + +// PlanPage is the Draft board on the week chosen with the previous / next +// week buttons: mock 1f on mobile; from tablet up the plan manager, with the +// Quest log as a strip under the grid on tablet (2c) and the Quest log and +// Regulars in a right panel on desktop (2d). It opens on next week once this +// week's plan_draft time has come in the household's zone, otherwise on this +// week. The board keeps its place in the tree across layouts, so crossing a +// breakpoint keeps a draft being followed. From tablet up a dinner is +// dragged from the strip or the panel onto a day of the grid to swap it in. +export function PlanPage() { + const layout = useBreakpoint() + const settings = useHouseholdSettings() + const [week, setWeek] = useState(null) + if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date())) + + let content + if (settings.isError) { + content = ( + void settings.refetch()}>Couldn't load the household settings. {settings.error.message} + ) + } else if (week === null || !settings.data) { + content = + } else { + content = + } + return ( + +
    +
    + {content} + {layout === 'tablet' && } +
    + {layout === 'desktop' && } + {layout !== 'mobile' && } +
    +
    + ) +} diff --git a/web/src/plan/PlanPanel.module.css b/web/src/plan/PlanPanel.module.css new file mode 100644 index 0000000..99eea60 --- /dev/null +++ b/web/src/plan/PlanPanel.module.css @@ -0,0 +1,223 @@ +/* Desktop's right panel (mock 2d) and tablet's Quest-log strip (mock 2c). */ +.panel { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 16px; + padding: 32px 22px; + border-left: 1px solid var(--color-divider); +} + +.hint { + margin: 0; + font-family: var(--font-body); + font-size: 13px; + color: var(--color-neutral-800); +} + +.rows { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 12px; +} + +.row { + box-sizing: border-box; + width: 100%; + display: flex; + align-items: center; + gap: 12px; + padding: 10px; + border-radius: 22px; + background: var(--color-surface); + text-align: left; +} + +/* The 48 px washed photo; Photo applies the washed filter. The placeholder's + "dish photo" caption does not fit, so only its stripes show. */ +.rowPhoto { + width: 48px; + height: 48px; + font-size: 0; +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + font-family: var(--font-body); +} + +.rowName { + font-size: 14px; + font-weight: 700; + overflow-wrap: anywhere; +} + +.rowMeta { + font-size: 12px; + color: var(--color-neutral-700); +} + +/* Two columns of three 4 px dots. */ +.grip { + flex: none; + display: grid; + grid-template-columns: 4px 4px; + gap: 3px; +} + +.dot { + width: 4px; + height: 4px; + border-radius: var(--radius-pill); + background: var(--color-neutral-500); +} + +.more { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 44px; + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + color: var(--color-accent-700); + text-decoration: none; +} + +.more:hover { + text-decoration: underline; +} + +.loading { + display: flex; + flex-direction: column; + gap: 12px; +} + +.skeleton { + height: 68px; + border-radius: 22px; +} + +.empty { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.45; + color: var(--color-neutral-700); +} + +/* Tablet: the Quest log as chips on a surface card under the grid. */ +.strip { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 18px; + border-radius: var(--radius-lg); + background: var(--color-surface); +} + +.stripHeader { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; +} + +.stripTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 18px; + font-weight: 400; +} + +.stripHint { + margin: 0; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.chips { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.chip { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 14px 5px 5px; + border-radius: var(--radius-pill); + background: var(--color-bg); + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; +} + +/* As the row's photo, at 28 px. */ +.chipPhoto { + width: 28px; + height: 28px; + font-size: 0; +} + +/* A row or a chip is a button dragged onto a day: by a pointer (no + scrolling or text selection under it) or with the keyboard. Its 2 px + border is clear until it is the ghost, inside its padding. */ +.draggable { + margin: 0; + border: 2px solid transparent; + font: inherit; + color: var(--color-text); + cursor: grab; + touch-action: none; + user-select: none; +} + +.row.draggable { + padding: 8px; +} + +.chip.draggable { + padding: 3px 12px 3px 3px; +} + +/* A dinner that cannot be picked up now: no grid to drop on, a swap + pending, or already this week. */ +.draggable[aria-disabled='true'] { + cursor: default; +} + +/* The row or chip being dragged: a dashed ghost at 55% opacity. */ +.ghost { + border-style: dashed; + border-color: var(--color-neutral-500); + background: transparent; + opacity: 0.55; +} + +/* The dragged copy floating below right of the pointer: lifted with the + large shadow and tilted -3deg. It lets the pointer through to the day + under it. */ +.floating { + position: fixed; + z-index: 10; + box-sizing: border-box; + margin: 0; + box-shadow: var(--shadow-lg); + transform: rotate(-3deg); + pointer-events: none; + cursor: grabbing; +} diff --git a/web/src/plan/PlanPanel.tsx b/web/src/plan/PlanPanel.tsx new file mode 100644 index 0000000..8bf035d --- /dev/null +++ b/web/src/plan/PlanPanel.tsx @@ -0,0 +1,210 @@ +import { Compass } from 'lucide-react' +import { useId, useState, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { Link } from 'react-router' +import { useRegulars } from '../dinners/api.ts' +import { useQuestLog, type QuestLogDinner } from '../discover/api.ts' +import { DIFFICULTY_LABEL } from '../discover/DeckCard.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { Photo } from '../ui/Photo.tsx' +import { Segmented } from '../ui/Segmented.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { useDraggable, type Float } from './drag.tsx' +import { emptyLists, planDinner, sourceNames, type Source } from './SwapSheet.tsx' +import styles from './PlanPanel.module.css' + +// DRAG_HINT says how a dinner is swapped in from the panel or the strip. +const DRAG_HINT = 'Drag a dinner onto a day to swap it in.' + +// KEYS_HINT says how a dinner is swapped in with the keyboard. +const KEYS_HINT = 'Press Enter or Space to pick it up, the arrow keys to choose a day, Enter to drop it on that day and Escape to cancel.' + +type List = ReturnType + +// ListBody is a list of the panel or the strip once it has loaded, or why it +// has not: its error with Retry, skeletons while loading, or that it is +// empty. +function ListBody({ source, list, children }: { source: Source; list: List; children: (dinners: QuestLogDinner[]) => ReactNode }) { + if (list.isError) { + return ( + void list.refetch()}> + Couldn't load your {sourceNames[source]}. {list.error.message} + + ) + } + if (list.isPending) { + return ( +
    + {Array.from({ length: 3 }, (_, i) => ( + + ))} +
    + ) + } + if (list.data.length === 0) return

    {emptyLists[source]}

    + return children(list.data) +} + +// Grip is the 6-dot drag handle of a panel row. +function Grip() { + return ( +