From a72ef1cbdd1bb082977613339d85dca73bde1a51 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sun, 11 Oct 2026 09:18:54 -0400 Subject: [PATCH 1/3] Keep dinner names frozen in Supply archive posts Freezing a Supply run now records each group's dinner name in shopping_groups.frozen_dinner_name, in the same transaction as the freeze. A frozen list and its Supply archive post read that name, so renaming a dinner after its week is frozen no longer rewrites the archive; a current list still shows its dinners' names live. Migration 0018 adds the column and fills it for lists frozen before it. Task: 22f80eb6-cdee-4377-80ea-9b28f15a866f Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 10 ++- internal/db/models.go | 15 ++-- internal/db/querier.go | 6 +- internal/db/shopping.sql.go | 22 +++++- internal/server/plans_test.go | 2 +- internal/server/server_test.go | 7 +- internal/server/shopping_test.go | 69 ++++++++++++++++++- internal/shopping/archive.go | 32 ++++++--- internal/shopping/archive_test.go | 2 +- internal/shopping/notify_test.go | 2 +- internal/shopping/shopping.go | 13 +++- internal/shopping/shopping_test.go | 4 +- internal/shopping/title_test.go | 2 +- internal/store/migrate_test.go | 62 +++++++++++++++-- .../0018_shopping_group_frozen_name.sql | 14 ++++ migrations/queries/shopping.sql | 10 ++- 16 files changed, 234 insertions(+), 38 deletions(-) create mode 100644 migrations/0018_shopping_group_frozen_name.sql diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 539863e..e3d62d9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -105,7 +105,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`). | `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). | | `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 (the AI-written post title; NULL until written or when the AI fails, when the week label is shown instead; never empty), status (`current` / `archived`), sent_to_chat_at, chat_send_unconfirmed_at (a send to Google Chat recorded as begun before posting and cleared once its outcome is known; left set when it may or may not have posted), frozen_at. | -| `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by `shopping.Generate` from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged. | +| `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date), frozen_dinner_name (the dinner's name when its list was frozen into the Supply archive, kept whatever it is later renamed; NULL while the list is current, which shows its dinners' names now; never empty). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by `shopping.Generate` from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged. | | `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. | | `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url (the SearXNG instance), api_key_ct and api_key_last4 (Brave). | | `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. | @@ -635,7 +635,10 @@ automatic** (D-021). `shopping.Freeze(weekStart)` moves a week's Supply run into the Supply archive: `status` becomes `archived` and `frozen_at` is set, after which -generating the list or changing an item is 409 `archived`. It freezes only +generating the list or changing an item is 409 `archived`. In the same +transaction each group keeps its dinner's name as `frozen_dinner_name`, so +the frozen list and its post show the names they had when frozen, whatever +a dinner is renamed later. It freezes only a week that has ended in the household's time zone (its Monday is before the Monday of the household's current week), so a week not yet ended is `ErrWeekNotEnded` and a week with no list `ErrListNotFound`; freezing a @@ -1187,7 +1190,8 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where Monday is a household-local date, so the week of Monday 28 December is in December. `label` is the week label ("Week 41 · Oct 5 – 11"; the UI sets it in capitals), `title` null while untitled, `dinners` the - list's dinners in day order and `dinnerCount` their number, + list's dinners in day order (as named when the list was frozen; the + current week's as named now) and `dinnerCount` their number, `itemCount` all items, `onHandCount` the items marked "have it", and `current` true for the household's current week. 400 `invalid_request` for any other `by`. A frozen list is read through `GET diff --git a/internal/db/models.go b/internal/db/models.go index d451225..be54524 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -255,13 +255,14 @@ type Session struct { } type ShoppingGroup struct { - ID string - ListID string - DinnerID string - Date string - Position int64 - CreatedAt string - UpdatedAt string + ID string + ListID string + DinnerID string + Date string + Position int64 + CreatedAt string + UpdatedAt string + FrozenDinnerName sql.NullString } type ShoppingItem struct { diff --git a/internal/db/querier.go b/internal/db/querier.go index d1623b1..d87ba6c 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -107,6 +107,9 @@ type Querier interface { // Records a pending or running batch as done or failed; a batch already // finished is left as it is (0 rows). FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error) + // Keeps the name each dinner of list list_id has now as its group's + // frozen_dinner_name, as the list is frozen into the Supply archive. + FreezeShoppingGroupNames(ctx context.Context, listID string) error // Freezes list id into the Supply archive at frozen_at, unless it is // frozen already. FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error) @@ -228,7 +231,8 @@ type Querier interface { 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 groups of list list_id with their dinner's name, in list order. + // The groups of list list_id with their dinner's name now, in list order. + // A group of a frozen list shows its frozen_dinner_name instead. ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error) // The groups of list list_id, in list order. ListShoppingGroups(ctx context.Context, listID string) ([]ShoppingGroup, error) diff --git a/internal/db/shopping.sql.go b/internal/db/shopping.sql.go index 91b1b9b..4d5caa4 100644 --- a/internal/db/shopping.sql.go +++ b/internal/db/shopping.sql.go @@ -155,6 +155,19 @@ func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error { return err } +const freezeShoppingGroupNames = `-- name: FreezeShoppingGroupNames :exec +UPDATE shopping_groups +SET frozen_dinner_name = (SELECT dinners.name FROM dinners WHERE dinners.id = shopping_groups.dinner_id) +WHERE list_id = ? +` + +// Keeps the name each dinner of list list_id has now as its group's +// frozen_dinner_name, as the list is frozen into the Supply archive. +func (q *Queries) FreezeShoppingGroupNames(ctx context.Context, listID string) error { + _, err := q.db.ExecContext(ctx, freezeShoppingGroupNames, listID) + return err +} + const freezeShoppingList = `-- name: FreezeShoppingList :execrows UPDATE shopping_lists SET status = 'archived', frozen_at = ?, updated_at = ? @@ -235,7 +248,7 @@ func (q *Queries) GetShoppingList(ctx context.Context, weekStart string) (Shoppi } const listShoppingGroupDinners = `-- name: ListShoppingGroupDinners :many -SELECT shopping_groups.id, shopping_groups.list_id, shopping_groups.dinner_id, shopping_groups.date, shopping_groups.position, shopping_groups.created_at, shopping_groups.updated_at, dinners.name AS dinner_name +SELECT shopping_groups.id, shopping_groups.list_id, shopping_groups.dinner_id, shopping_groups.date, shopping_groups.position, shopping_groups.created_at, shopping_groups.updated_at, shopping_groups.frozen_dinner_name, dinners.name AS dinner_name FROM shopping_groups JOIN dinners ON dinners.id = shopping_groups.dinner_id WHERE shopping_groups.list_id = ? @@ -247,7 +260,8 @@ type ListShoppingGroupDinnersRow struct { DinnerName string } -// The groups of list list_id with their dinner's name, in list order. +// The groups of list list_id with their dinner's name now, in list order. +// A group of a frozen list shows its frozen_dinner_name instead. func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error) { rows, err := q.db.QueryContext(ctx, listShoppingGroupDinners, listID) if err != nil { @@ -265,6 +279,7 @@ func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) ( &i.ShoppingGroup.Position, &i.ShoppingGroup.CreatedAt, &i.ShoppingGroup.UpdatedAt, + &i.ShoppingGroup.FrozenDinnerName, &i.DinnerName, ); err != nil { return nil, err @@ -281,7 +296,7 @@ func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) ( } const listShoppingGroups = `-- name: ListShoppingGroups :many -SELECT id, list_id, dinner_id, date, position, created_at, updated_at FROM shopping_groups +SELECT id, list_id, dinner_id, date, position, created_at, updated_at, frozen_dinner_name FROM shopping_groups WHERE list_id = ? ORDER BY position, id ` @@ -304,6 +319,7 @@ func (q *Queries) ListShoppingGroups(ctx context.Context, listID string) ([]Shop &i.Position, &i.CreatedAt, &i.UpdatedAt, + &i.FrozenDinnerName, ); err != nil { return nil, err } diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go index 5da305e..0c66853 100644 --- a/internal/server/plans_test.go +++ b/internal/server/plans_test.go @@ -107,7 +107,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv { t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc, - newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinnersOn(t, st), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 402b925..939faf5 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -167,7 +167,12 @@ func newTestSearch(t *testing.T) *recipes.SearchSettingsService { // with its own unstarted job runner and a grounder that finds nothing. func newTestDinners(t *testing.T) *dinners.Service { t.Helper() - st := newTestStore(t) + return newTestDinnersOn(t, newTestStore(t)) +} + +// newTestDinnersOn is newTestDinners over st. +func newTestDinnersOn(t *testing.T, st *store.Store) *dinners.Service { + t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) runner, err := jobs.NewRunner(st, logger, 1) if err != nil { diff --git a/internal/server/shopping_test.go b/internal/server/shopping_test.go index 739d44a..b8b5d5e 100644 --- a/internal/server/shopping_test.go +++ b/internal/server/shopping_test.go @@ -75,7 +75,7 @@ func (e plansEnv) groundWeek(t *testing.T) { func (e plansEnv) archive(t *testing.T) { t.Helper() if _, err := e.st.Writer.ExecContext(t.Context(), - `UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil { + `UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil { t.Fatal(err) } } @@ -852,6 +852,73 @@ func TestSupplyArchive(t *testing.T) { } } +// TestSupplyArchiveKeepsFrozenNames freezes last week's Supply run, then +// renames its dinner and this week's through the dinners API: the frozen +// post and list keep the name the dinner had when frozen, while this +// week's list, not frozen, shows the new name. +func TestSupplyArchiveKeepsFrozenNames(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM plan_days; DELETE FROM week_plans`); err != nil { + t.Fatal(err) + } + zone, err := household.LoadZone("America/New_York") // the env's household zone + if err != nil { + t.Fatal(err) + } + thisWeek := household.WeekStart(household.Today(time.Now(), zone)) + y, m, d := thisWeek.Date() + lastWeek := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC)) + + last := e.weekSupplyRun(t, lastWeek, "Beef Stew", shopIng{"stewing beef", "2", "lb"}) + current := e.weekSupplyRun(t, thisWeek, "Lemon Chicken", shopIng{"lemon", "1", ""}) + if err := e.shop.Freeze(t.Context(), lastWeek); err != nil { + t.Fatal(err) + } + for id, name := range map[string]string{ + last.Groups[0].Dinner.ID: "Sunday Beef Stew", + current.Groups[0].Dinner.ID: "Lemon Garlic Chicken", + } { + if status, body := callShopping(t, e, http.MethodPatch, dinnersPath+"/"+id, `{"name":"`+name+`"}`, nil); status != http.StatusOK { + t.Fatalf("PATCH dinner %s = %d %v", id, status, body) + } + } + + var got archiveJSON + if status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got); status != http.StatusOK { + t.Fatalf("GET archive = %d %v", status, raw) + } + var posts []string + for _, p := range got.Periods { + for _, post := range p.Posts { + posts = append(posts, fmt.Sprintf("%s %v current=%t", post.WeekStart, post.Dinners, post.Current)) + } + } + want := []string{ + thisWeek.String() + " [Lemon Garlic Chicken] current=true", + lastWeek.String() + " [Beef Stew] current=false", + } + if !reflect.DeepEqual(posts, want) { + t.Errorf("archive after rename =\n %s\nwant\n %s", strings.Join(posts, "\n "), strings.Join(want, "\n ")) + } + + for _, tc := range []struct { + week store.LocalDate + want string + }{ + {lastWeek, "Monday " + lastWeek.String() + " Beef Stew: stewing beef [2 lb] needed"}, + {thisWeek, "Monday " + thisWeek.String() + " Lemon Garlic Chicken: lemon [1] needed"}, + } { + var l supplyRunJSON + if status, body := callShopping(t, e, http.MethodGet, shoppingPath+tc.week.String(), "", &l); status != http.StatusOK { + t.Fatalf("GET %s = %d %v", tc.week, status, body) + } + if got := l.render(); !reflect.DeepEqual(got, []string{tc.want}) { + t.Errorf("list %s after rename = %v, want [%s]", tc.week, got, tc.want) + } + } +} + // TestSupplyRunSendChat posts the week's Supply run to Google Chat once, // records sent_to_chat_at, answers a second send with the status as it // is, and posts again only with resend. diff --git a/internal/shopping/archive.go b/internal/shopping/archive.go index a24d2dc..fe2895d 100644 --- a/internal/shopping/archive.go +++ b/internal/shopping/archive.go @@ -22,7 +22,9 @@ const ( // Freeze moves the Supply run of the week starting weekStart into the // Supply archive: its status becomes archived and frozen_at is set, after -// which it is read-only (Generate and SetNeeded are ErrFrozen). Freezing a +// which it is read-only (Generate and SetNeeded are ErrFrozen). The list +// keeps the names its dinners have as it is frozen, so its post in the +// Supply archive shows them whatever a dinner is renamed later. Freezing a // frozen list does nothing and is no error. // // The week must have ended in the household's time zone: a week not yet @@ -79,14 +81,25 @@ func (s *Service) freeze(ctx context.Context, weekStart store.LocalDate, now tim "week", list.WeekStart, "list", list.ID, "error", err) } } - // A list frozen meanwhile is left as it is: the freeze is done. + // A list frozen meanwhile is left as it is, its names as they were + // frozen: the freeze is done. stamp := store.Now().String() - if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{ - FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID, - }); err != nil { - return fmt.Errorf("shopping: freeze %s: %w", weekStart, err) - } - return nil + return s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + n, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{ + FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID, + }) + if err != nil { + return fmt.Errorf("shopping: freeze %s: %w", weekStart, err) + } + if n == 0 { + return nil + } + if err := q.FreezeShoppingGroupNames(ctx, list.ID); err != nil { + return fmt.Errorf("shopping: freeze %s: keep dinner names: %w", weekStart, err) + } + return nil + }) } // freezeTitleJob is the title job of list as Generate would enqueue it: @@ -145,7 +158,8 @@ type Post struct { Label string // Title is nil while the list is untitled, when Label stands in. Title *string - // Dinners are the names of the list's dinners, in day order. + // Dinners are the names of the list's dinners, in day order: as they + // were when the list was frozen, or as they are now while it is not. Dinners []string // Items is how many items the list has, and OnHand how many of them // the household marked "have it". diff --git a/internal/shopping/archive_test.go b/internal/shopping/archive_test.go index 00fff92..9ba0278 100644 --- a/internal/shopping/archive_test.go +++ b/internal/shopping/archive_test.go @@ -422,7 +422,7 @@ func TestArchiveGroups(t *testing.T) { if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili, Noodles & Co' WHERE week_start = '2026-12-28'`); err != nil { t.Fatal(err) } - if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2027-01-03T11:05:00.000Z' WHERE week_start < '2027-01-04'`); err != nil { + if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2027-01-03T11:05:00.000Z' WHERE week_start < '2027-01-04'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil { t.Fatal(err) } diff --git a/internal/shopping/notify_test.go b/internal/shopping/notify_test.go index 873f3a1..5d43c10 100644 --- a/internal/shopping/notify_test.go +++ b/internal/shopping/notify_test.go @@ -152,7 +152,7 @@ func TestSendToChatFailuresRecordNothing(t *testing.T) { t.Errorf("after failed sends sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want both unset", l.SentToChatAt, l.ChatSendUnconfirmedAt, err) } - if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil { + if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil { t.Fatal(err) } posts := len(e.chat.Posts()) diff --git a/internal/shopping/shopping.go b/internal/shopping/shopping.go index 595d321..24a1743 100644 --- a/internal/shopping/shopping.go +++ b/internal/shopping/shopping.go @@ -492,7 +492,9 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, e return readList(ctx, q, list) } -// readList reads the groups and items of list through q. +// readList reads the groups and items of list through q. A group of a +// frozen list is named as its dinner was when the list was frozen; one +// with no such name is an error. func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, error) { rows, err := q.ListShoppingGroupDinners(ctx, list.ID) if err != nil { @@ -512,8 +514,15 @@ func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, e groups := make([]Group, 0, len(rows)) for _, r := range rows { g := r.ShoppingGroup + name := r.DinnerName + if frozen(list) { + if !g.FrozenDinnerName.Valid { + return List{}, fmt.Errorf("shopping: group %s of frozen list %s has no frozen dinner name", g.ID, list.WeekStart) + } + name = g.FrozenDinnerName.String + } groups = append(groups, Group{ - ID: g.ID, DinnerID: g.DinnerID, DinnerName: r.DinnerName, Date: g.Date, Position: int(g.Position), + ID: g.ID, DinnerID: g.DinnerID, DinnerName: name, Date: g.Date, Position: int(g.Position), Items: byGroup[g.ID], }) } diff --git a/internal/shopping/shopping_test.go b/internal/shopping/shopping_test.go index 9f359b6..7fa4514 100644 --- a/internal/shopping/shopping_test.go +++ b/internal/shopping/shopping_test.go @@ -692,7 +692,7 @@ func TestGenerateRefusesFrozenList(t *testing.T) { e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}) e.plan(t, "Beef Chili") e.generate(t) - if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil { + if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil { t.Fatal(err) } e.swap(t, "2026-10-12", "Pad Thai") @@ -834,7 +834,7 @@ func TestSetNeededRefusesFrozenList(t *testing.T) { e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) e.plan(t, "Beef Chili") l := e.generate(t).List - if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil { + if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil { t.Fatal(err) } diff --git a/internal/shopping/title_test.go b/internal/shopping/title_test.go index 701bfa7..4ac4729 100644 --- a/internal/shopping/title_test.go +++ b/internal/shopping/title_test.go @@ -239,7 +239,7 @@ func TestGenerateTitleNotStoredOnFrozenList(t *testing.T) { e := titleWeek(t) e.model.QueueGenerate(titled("Noodles, Tacos and Chili")) e.beforeTitle = func() { - if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil { + if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil { t.Error(err) } } diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index bf09f0c..bd5f9b5 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,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" { - 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,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", 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,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_shopping_group_frozen_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,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_shopping_group_frozen_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,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" { - 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,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", 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,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_shopping_group_frozen_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,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_shopping_group_frozen_name", got) } if !tableExists(t, s, "dinners") { t.Error("table from 0001_init was not created") @@ -922,3 +922,57 @@ func TestMigrateShoppingListChatSendUnconfirmed(t *testing.T) { t.Errorf("shopping list after 0017 = %s, want %s", got, want) } } + +// TestMigrateShoppingGroupFrozenName applies 0018 over a frozen list and +// a current one: the frozen list's group takes its dinner's name now, the +// current list's group has none, and a frozen name is never empty. +func TestMigrateShoppingGroupFrozenName(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + migrateBefore(t, s, "0018") + for _, stmt := range []string{ + `INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at) + VALUES ('dinner-1', 'Beef Stew', 'beef stew', 'regular', 'regular', 0, 'manual', 'c', 'u'), + ('dinner-2', 'Lemon Chicken', 'lemon chicken', 'regular', 'regular', 0, 'manual', 'c', 'u')`, + `INSERT INTO week_plans (id, week_start, status, created_at, updated_at) + VALUES ('plan-1', '2026-10-05', 'synced', 'c', 'u'), + ('plan-2', '2026-10-12', 'synced', 'c', 'u')`, + `INSERT INTO shopping_lists (id, week_start, plan_id, status, frozen_at, created_at, updated_at) + VALUES ('list-1', '2026-10-05', 'plan-1', 'archived', 'f', 'c', 'u'), + ('list-2', '2026-10-12', 'plan-2', 'current', NULL, 'c', 'u')`, + `INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at) + VALUES ('group-1', 'list-1', 'dinner-1', '2026-10-05', 0, 'c', 'u'), + ('group-2', 'list-2', 'dinner-2', '2026-10-12', 0, 'c', 'u')`, + } { + 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.QueryContext(ctx, `SELECT id || '|' || COALESCE(frozen_dinner_name, 'NULL') || '|' || updated_at FROM shopping_groups ORDER BY id`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var got []string + for rows.Next() { + var row string + if err := rows.Scan(&row); err != nil { + t.Fatal(err) + } + got = append(got, row) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if want := []string{"group-1|Beef Stew|u", "group-2|NULL|u"}; strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("shopping groups after 0018 = %v, want %v", got, want) + } + if _, err := s.Writer.Exec(`UPDATE shopping_groups SET frozen_dinner_name = '' WHERE id = 'group-1'`); err == nil || !strings.Contains(err.Error(), "CHECK constraint failed") { + t.Errorf("empty frozen name: error %v, want CHECK constraint failed", err) + } +} diff --git a/migrations/0018_shopping_group_frozen_name.sql b/migrations/0018_shopping_group_frozen_name.sql new file mode 100644 index 0000000..48ec65c --- /dev/null +++ b/migrations/0018_shopping_group_frozen_name.sql @@ -0,0 +1,14 @@ +-- 0018_shopping_group_frozen_name: a Supply run frozen into the Supply +-- archive keeps the names its dinners had when it was frozen. A dinner can +-- be renamed at any time, so a frozen list reads each group's dinner name +-- from frozen_dinner_name, never from the dinner, as 0012 does for a Pass. +-- Only a group of a frozen list has one; a current list reads its dinners' +-- names live. A list frozen before this takes its dinners' names now. + +ALTER TABLE shopping_groups ADD COLUMN frozen_dinner_name TEXT CHECK (frozen_dinner_name <> ''); + +UPDATE shopping_groups +SET frozen_dinner_name = (SELECT dinners.name FROM dinners WHERE dinners.id = shopping_groups.dinner_id) +WHERE list_id IN ( + SELECT id FROM shopping_lists WHERE status = 'archived' OR frozen_at IS NOT NULL +); diff --git a/migrations/queries/shopping.sql b/migrations/queries/shopping.sql index b4ade22..e8d144d 100644 --- a/migrations/queries/shopping.sql +++ b/migrations/queries/shopping.sql @@ -60,7 +60,8 @@ DELETE FROM shopping_items WHERE id = ?; -- name: ListShoppingGroupDinners :many --- The groups of list list_id with their dinner's name, in list order. +-- The groups of list list_id with their dinner's name now, in list order. +-- A group of a frozen list shows its frozen_dinner_name instead. SELECT sqlc.embed(shopping_groups), dinners.name AS dinner_name FROM shopping_groups JOIN dinners ON dinners.id = shopping_groups.dinner_id @@ -117,6 +118,13 @@ UPDATE shopping_lists SET status = 'archived', frozen_at = ?, updated_at = ? WHERE id = ? AND frozen_at IS NULL; +-- name: FreezeShoppingGroupNames :exec +-- Keeps the name each dinner of list list_id has now as its group's +-- frozen_dinner_name, as the list is frozen into the Supply archive. +UPDATE shopping_groups +SET frozen_dinner_name = (SELECT dinners.name FROM dinners WHERE dinners.id = shopping_groups.dinner_id) +WHERE list_id = ?; + -- name: ListShoppingListsThrough :many -- The Supply runs of the weeks starting on or before week_start, newest -- week first. -- 2.55.0 From 2feaced7c92f2b28c3aad9f233bc3e53c5c04ee8 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sun, 11 Oct 2026 09:29:32 -0400 Subject: [PATCH 2/3] Renumber the frozen-name migration to 0019 (task 22f80eb6) Co-Authored-By: Claude Opus 5.5 --- internal/store/migrate_test.go | 14 +++++++------- ...ame.sql => 0019_shopping_group_frozen_name.sql} | 0 2 files changed, 7 insertions(+), 7 deletions(-) rename migrations/{0018_shopping_group_frozen_name.sql => 0019_shopping_group_frozen_name.sql} (100%) diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index bd5f9b5..460c71e 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,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_shopping_group_frozen_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,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_shopping_group_frozen_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,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0019_shopping_group_frozen_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,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0019_shopping_group_frozen_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,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_shopping_group_frozen_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,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_shopping_group_frozen_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,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0019_shopping_group_frozen_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,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0019_shopping_group_frozen_name", got) } if !tableExists(t, s, "dinners") { t.Error("table from 0001_init was not created") @@ -923,13 +923,13 @@ func TestMigrateShoppingListChatSendUnconfirmed(t *testing.T) { } } -// TestMigrateShoppingGroupFrozenName applies 0018 over a frozen list and +// TestMigrateShoppingGroupFrozenName applies 0019 over a frozen list and // a current one: the frozen list's group takes its dinner's name now, the // current list's group has none, and a frozen name is never empty. func TestMigrateShoppingGroupFrozenName(t *testing.T) { ctx := context.Background() s := openTestStore(t) - migrateBefore(t, s, "0018") + migrateBefore(t, s, "0019") for _, stmt := range []string{ `INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at) VALUES ('dinner-1', 'Beef Stew', 'beef stew', 'regular', 'regular', 0, 'manual', 'c', 'u'), @@ -970,7 +970,7 @@ func TestMigrateShoppingGroupFrozenName(t *testing.T) { t.Fatal(err) } if want := []string{"group-1|Beef Stew|u", "group-2|NULL|u"}; strings.Join(got, ",") != strings.Join(want, ",") { - t.Errorf("shopping groups after 0018 = %v, want %v", got, want) + t.Errorf("shopping groups after 0019 = %v, want %v", got, want) } if _, err := s.Writer.Exec(`UPDATE shopping_groups SET frozen_dinner_name = '' WHERE id = 'group-1'`); err == nil || !strings.Contains(err.Error(), "CHECK constraint failed") { t.Errorf("empty frozen name: error %v, want CHECK constraint failed", err) diff --git a/migrations/0018_shopping_group_frozen_name.sql b/migrations/0019_shopping_group_frozen_name.sql similarity index 100% rename from migrations/0018_shopping_group_frozen_name.sql rename to migrations/0019_shopping_group_frozen_name.sql -- 2.55.0 From 7b0a63e1b580bda0fcd61c1958e126034fb0150a Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sun, 11 Oct 2026 09:37:16 -0400 Subject: [PATCH 3/3] Fix the 0019 migration header comment (task 22f80eb6) Co-Authored-By: Claude Opus 5.5 --- migrations/0019_shopping_group_frozen_name.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/0019_shopping_group_frozen_name.sql b/migrations/0019_shopping_group_frozen_name.sql index 48ec65c..8ea136f 100644 --- a/migrations/0019_shopping_group_frozen_name.sql +++ b/migrations/0019_shopping_group_frozen_name.sql @@ -1,4 +1,4 @@ --- 0018_shopping_group_frozen_name: a Supply run frozen into the Supply +-- 0019_shopping_group_frozen_name: a Supply run frozen into the Supply -- archive keeps the names its dinners had when it was frozen. A dinner can -- be renamed at any time, so a frozen list reads each group's dinner name -- from frozen_dinner_name, never from the dinner, as 0012 does for a Pass. -- 2.55.0