From 1ca66931bb1abb020bf71f9685fae554ab1e05c1 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 03:58:18 -0400 Subject: [PATCH] Generate the week's Supply run from its plan Add internal/shopping with Generate(weekStart), one entry point for the first generation and every regeneration. It builds one group per plan day (dinner_id, date, position by date) and one item per ingredient of the dinner's recipe, in ingredient order, reusing the values recipe grounding stored: a fetched recipe's normalized name with qty and unit joined for display, or a manual recipe's line as typed with no qty. A day whose dinner has no recipe or no ingredients gets no group and is reported in Skipped. Regenerating keeps the id and needed flag of each item whose (dinner_id, name) is unchanged, matching repeated names in order; new items are needed and items no longer planned are removed. It is deterministic and makes no model call. A frozen list is refused. Migration 0016 makes shopping_lists.title nullable (never empty) so a list exists untitled until the AI titles it, and adds shopping_groups.date. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 4 +- internal/db/models.go | 3 +- internal/db/querier.go | 21 + internal/db/shopping.sql.go | 285 ++++++++++ internal/dinners/grounding.go | 7 + internal/shopping/shopping.go | 376 +++++++++++++ internal/shopping/shopping_test.go | 582 +++++++++++++++++++++ internal/store/migrate_test.go | 136 ++++- internal/store/repository_test.go | 6 +- internal/store/schema_test.go | 1 + migrations/0016_shopping_list_untitled.sql | 81 +++ migrations/queries/shopping.sql | 60 +++ 12 files changed, 1552 insertions(+), 10 deletions(-) create mode 100644 internal/db/shopping.sql.go create mode 100644 internal/shopping/shopping.go create mode 100644 internal/shopping/shopping_test.go create mode 100644 migrations/0016_shopping_list_untitled.sql create mode 100644 migrations/queries/shopping.sql diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d8b64d0..c519360 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -102,8 +102,8 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`). | `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 (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, status (`current` / `archived`), sent_to_chat_at, frozen_at. | -| `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. | +| `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, 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. | | `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. | diff --git a/internal/db/models.go b/internal/db/models.go index 9cdf5d0..464b6b0 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -258,6 +258,7 @@ type ShoppingGroup struct { ID string ListID string DinnerID string + Date string Position int64 CreatedAt string UpdatedAt string @@ -278,7 +279,7 @@ type ShoppingList struct { ID string WeekStart string PlanID string - Title string + Title sql.NullString Status string SentToChatAt sql.NullString FrozenAt sql.NullString diff --git a/internal/db/querier.go b/internal/db/querier.go index 0178e6d..c105f5b 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -43,6 +43,11 @@ type Querier interface { CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error CreateRotationReview(ctx context.Context, arg CreateRotationReviewParams) error + CreateShoppingGroup(ctx context.Context, arg CreateShoppingGroupParams) error + // A new item is needed until the household marks it "have it". + CreateShoppingItem(ctx context.Context, arg CreateShoppingItemParams) error + // A new, untitled Supply run: the title is written later, by the AI. + CreateShoppingList(ctx context.Context, arg CreateShoppingListParams) error // name is the name the dish is suggested under, kept whatever its dinner is // later renamed. CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error @@ -59,6 +64,8 @@ type Querier interface { DeleteIntegrationsGoogle(ctx context.Context) error DeleteRecipeIngredients(ctx context.Context, recipeID string) error DeleteRecipeSteps(ctx context.Context, recipeID string) error + DeleteShoppingGroup(ctx context.Context, id string) error + DeleteShoppingItem(ctx context.Context, id string) error FailJob(ctx context.Context, arg FailJobParams) (int64, error) // Fails every unarchived dinner still pending whose grounding job for its // current grounding_revision is no longer queued or running: the process @@ -113,6 +120,9 @@ type Querier interface { GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error) // Returns sql.ErrNoRows until the row exists. GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error) + // The Supply run of the week starting week_start. Returns sql.ErrNoRows + // when there is none. + GetShoppingList(ctx context.Context, weekStart string) (ShoppingList, error) // A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id. GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error) // The id of the job that runs batch batch_id. @@ -164,6 +174,11 @@ 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, in list order. + ListShoppingGroups(ctx context.Context, listID string) ([]ShoppingGroup, error) + // The items of list list_id with their group's dinner, in list order: by + // group, then by item. + ListShoppingItems(ctx context.Context, listID string) ([]ListShoppingItemsRow, error) // The 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) @@ -191,6 +206,10 @@ type Querier interface { // becomes synced. A plan edited since (another revision) is left as it is // (0 rows). MarkWeekPlanSynced(ctx context.Context, arg MarkWeekPlanSyncedParams) (int64, error) + MoveShoppingGroup(ctx context.Context, arg MoveShoppingGroupParams) error + // Places item id in group group_id at position with quantity qty; its + // name and needed flag are kept. + MoveShoppingItem(ctx context.Context, arg MoveShoppingItemParams) 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). @@ -208,6 +227,8 @@ type Querier interface { // Records a new draft over plan id: its status and note, raising its // revision. RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error + // Records a generation of list id from plan plan_id. + RegenerateShoppingList(ctx context.Context, arg RegenerateShoppingListParams) error // An unknown id returns sql.ErrNoRows. SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) // Sets the chosen calendar; affects no row when nothing is connected. diff --git a/internal/db/shopping.sql.go b/internal/db/shopping.sql.go new file mode 100644 index 0000000..ccb9046 --- /dev/null +++ b/internal/db/shopping.sql.go @@ -0,0 +1,285 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: shopping.sql + +package db + +import ( + "context" + "database/sql" +) + +const createShoppingGroup = `-- name: CreateShoppingGroup :exec +INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +` + +type CreateShoppingGroupParams struct { + ID string + ListID string + DinnerID string + Date string + Position int64 + CreatedAt string + UpdatedAt string +} + +func (q *Queries) CreateShoppingGroup(ctx context.Context, arg CreateShoppingGroupParams) error { + _, err := q.db.ExecContext(ctx, createShoppingGroup, + arg.ID, + arg.ListID, + arg.DinnerID, + arg.Date, + arg.Position, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const createShoppingItem = `-- name: CreateShoppingItem :exec +INSERT INTO shopping_items (id, group_id, name, qty, needed, position, created_at, updated_at) +VALUES (?, ?, ?, ?, 1, ?, ?, ?) +` + +type CreateShoppingItemParams struct { + ID string + GroupID string + Name string + Qty sql.NullString + Position int64 + CreatedAt string + UpdatedAt string +} + +// A new item is needed until the household marks it "have it". +func (q *Queries) CreateShoppingItem(ctx context.Context, arg CreateShoppingItemParams) error { + _, err := q.db.ExecContext(ctx, createShoppingItem, + arg.ID, + arg.GroupID, + arg.Name, + arg.Qty, + arg.Position, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const createShoppingList = `-- name: CreateShoppingList :exec +INSERT INTO shopping_lists (id, week_start, plan_id, status, created_at, updated_at) +VALUES (?, ?, ?, 'current', ?, ?) +` + +type CreateShoppingListParams struct { + ID string + WeekStart string + PlanID string + CreatedAt string + UpdatedAt string +} + +// A new, untitled Supply run: the title is written later, by the AI. +func (q *Queries) CreateShoppingList(ctx context.Context, arg CreateShoppingListParams) error { + _, err := q.db.ExecContext(ctx, createShoppingList, + arg.ID, + arg.WeekStart, + arg.PlanID, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const deleteShoppingGroup = `-- name: DeleteShoppingGroup :exec +DELETE FROM shopping_groups +WHERE id = ? +` + +func (q *Queries) DeleteShoppingGroup(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteShoppingGroup, id) + return err +} + +const deleteShoppingItem = `-- name: DeleteShoppingItem :exec +DELETE FROM shopping_items +WHERE id = ? +` + +func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteShoppingItem, id) + return err +} + +const getShoppingList = `-- name: GetShoppingList :one +SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists +WHERE week_start = ? +` + +// The Supply run of the week starting week_start. Returns sql.ErrNoRows +// when there is none. +func (q *Queries) GetShoppingList(ctx context.Context, weekStart string) (ShoppingList, error) { + row := q.db.QueryRowContext(ctx, getShoppingList, weekStart) + var i ShoppingList + err := row.Scan( + &i.ID, + &i.WeekStart, + &i.PlanID, + &i.Title, + &i.Status, + &i.SentToChatAt, + &i.FrozenAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listShoppingGroups = `-- name: ListShoppingGroups :many +SELECT id, list_id, dinner_id, date, position, created_at, updated_at FROM shopping_groups +WHERE list_id = ? +ORDER BY position, id +` + +// The groups of list list_id, in list order. +func (q *Queries) ListShoppingGroups(ctx context.Context, listID string) ([]ShoppingGroup, error) { + rows, err := q.db.QueryContext(ctx, listShoppingGroups, listID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ShoppingGroup + for rows.Next() { + var i ShoppingGroup + if err := rows.Scan( + &i.ID, + &i.ListID, + &i.DinnerID, + &i.Date, + &i.Position, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listShoppingItems = `-- name: ListShoppingItems :many +SELECT shopping_items.id, shopping_items.group_id, shopping_items.name, shopping_items.qty, shopping_items.needed, shopping_items.position, shopping_items.created_at, shopping_items.updated_at, shopping_groups.dinner_id +FROM shopping_items +JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id +WHERE shopping_groups.list_id = ? +ORDER BY shopping_groups.position, shopping_items.position, shopping_items.id +` + +type ListShoppingItemsRow struct { + ShoppingItem ShoppingItem + DinnerID string +} + +// The items of list list_id with their group's dinner, in list order: by +// group, then by item. +func (q *Queries) ListShoppingItems(ctx context.Context, listID string) ([]ListShoppingItemsRow, error) { + rows, err := q.db.QueryContext(ctx, listShoppingItems, listID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListShoppingItemsRow + for rows.Next() { + var i ListShoppingItemsRow + if err := rows.Scan( + &i.ShoppingItem.ID, + &i.ShoppingItem.GroupID, + &i.ShoppingItem.Name, + &i.ShoppingItem.Qty, + &i.ShoppingItem.Needed, + &i.ShoppingItem.Position, + &i.ShoppingItem.CreatedAt, + &i.ShoppingItem.UpdatedAt, + &i.DinnerID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const moveShoppingGroup = `-- name: MoveShoppingGroup :exec +UPDATE shopping_groups +SET position = ?, updated_at = ? +WHERE id = ? +` + +type MoveShoppingGroupParams struct { + Position int64 + UpdatedAt string + ID string +} + +func (q *Queries) MoveShoppingGroup(ctx context.Context, arg MoveShoppingGroupParams) error { + _, err := q.db.ExecContext(ctx, moveShoppingGroup, arg.Position, arg.UpdatedAt, arg.ID) + return err +} + +const moveShoppingItem = `-- name: MoveShoppingItem :exec +UPDATE shopping_items +SET group_id = ?, qty = ?, position = ?, updated_at = ? +WHERE id = ? +` + +type MoveShoppingItemParams struct { + GroupID string + Qty sql.NullString + Position int64 + UpdatedAt string + ID string +} + +// Places item id in group group_id at position with quantity qty; its +// name and needed flag are kept. +func (q *Queries) MoveShoppingItem(ctx context.Context, arg MoveShoppingItemParams) error { + _, err := q.db.ExecContext(ctx, moveShoppingItem, + arg.GroupID, + arg.Qty, + arg.Position, + arg.UpdatedAt, + arg.ID, + ) + return err +} + +const regenerateShoppingList = `-- name: RegenerateShoppingList :exec +UPDATE shopping_lists +SET plan_id = ?, updated_at = ? +WHERE id = ? +` + +type RegenerateShoppingListParams struct { + PlanID string + UpdatedAt string + ID string +} + +// Records a generation of list id from plan plan_id. +func (q *Queries) RegenerateShoppingList(ctx context.Context, arg RegenerateShoppingListParams) error { + _, err := q.db.ExecContext(ctx, regenerateShoppingList, arg.PlanID, arg.UpdatedAt, arg.ID) + return err +} diff --git a/internal/dinners/grounding.go b/internal/dinners/grounding.go index a09df3c..101a72e 100644 --- a/internal/dinners/grounding.go +++ b/internal/dinners/grounding.go @@ -68,6 +68,13 @@ const ( manualSourcePrefix = "mealquest:manual:" ) +// IsManualRecipe reports whether sourceURL, a recipes.source_url, is that +// of a recipe entered by hand. Its ingredients are stored as typed, in +// raw_text only: no model normalizes them, so name, qty and unit are NULL. +func IsManualRecipe(sourceURL string) bool { + return strings.HasPrefix(sourceURL, manualSourcePrefix) +} + // GroundTimeout bounds grounding a candidate or link by hand, which runs in // the request: the page fetch (recipes.Timeout), its photo download and // normalize_recipe (harness.DefaultTimeout), with some slack. diff --git a/internal/shopping/shopping.go b/internal/shopping/shopping.go new file mode 100644 index 0000000..96dc660 --- /dev/null +++ b/internal/shopping/shopping.go @@ -0,0 +1,376 @@ +// Package shopping keeps the household's Supply runs: each week's +// shopping list, generated from the week's plan and grouped by dinner, and +// the Supply archive they are frozen into. +// +// Generate makes a week's Supply run from its plan, and remakes it after +// the plan changes. It is deterministic and runs no model: an item is one +// ingredient of the dinner's recipe exactly as recipe grounding stored it +// (see Generate). +package shopping + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "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/store" +) + +// List statuses (the shopping_lists.status column). +const ( + StatusCurrent = "current" + StatusArchived = "archived" +) + +// Reasons a plan day has no group on its Supply run. +const ( + // SkipNoRecipe: the day's dinner has no recipe, as while its grounding + // is pending or after it failed. + SkipNoRecipe = "no_recipe" + // SkipNoIngredients: the day's dinner's recipe lists no ingredients. + SkipNoIngredients = "no_ingredients" +) + +// ErrPlanNotFound is a generation for a week with no plan. +var ErrPlanNotFound = errors.New("the week has no plan") + +// ErrFrozen is a generation for a week whose Supply run is frozen into the +// Supply archive, which is read-only. +var ErrFrozen = errors.New("the week's Supply run is in the Supply archive and can no longer change") + +// Service generates Supply runs. +type Service struct { + st *store.Store +} + +// NewService returns a service over st. +func NewService(st *store.Store) (*Service, error) { + if st == nil { + return nil, errors.New("shopping: nil store") + } + return &Service{st: st}, nil +} + +// List is a Supply run. +type List struct { + ID string `json:"id"` + // WeekStart is the Monday of the list's week, YYYY-MM-DD. + WeekStart string `json:"week_start"` + PlanID string `json:"plan_id"` + // Title is the AI-written post title; nil until it is written, when + // the week label stands in for it. + Title *string `json:"title"` + Status string `json:"status"` + Groups []Group `json:"groups"` +} + +// Group is the items of one plan day's dinner. +type Group struct { + ID string `json:"id"` + DinnerID string `json:"dinner_id"` + DinnerName string `json:"dinner_name"` + // Date is the plan day, household-local YYYY-MM-DD. + Date string `json:"date"` + Position int `json:"position"` + Items []Item `json:"items"` +} + +// Item is one ingredient to buy. +type Item struct { + ID string `json:"id"` + Name string `json:"name"` + // Qty is the quantity as shown, such as "2 cups" or "500 g"; nil when + // the recipe gives none. + Qty *string `json:"qty"` + // Needed is false once the household marks the item "have it". + Needed bool `json:"needed"` + Position int `json:"position"` +} + +// SkippedDay is a plan day with no group on the list, and why. +type SkippedDay struct { + Date string `json:"date"` + DinnerID string `json:"dinner_id"` + DinnerName string `json:"dinner_name"` + // Reason is SkipNoRecipe or SkipNoIngredients. + Reason string `json:"reason"` +} + +// Generated is the outcome of Generate. +type Generated struct { + List List `json:"list"` + // Skipped are the plan days whose dinner has no ingredients to shop + // for, by date. + Skipped []SkippedDay `json:"skipped"` +} + +// Generate makes the Supply run of the week starting weekStart from the +// week's plan, creating it untitled the first time and remaking it after +// that, in one transaction. +// +// The list has one group per plan day with a dinner, by date, holding one +// item per ingredient of the dinner's recipe, by the ingredient's +// position. A fetched recipe's ingredients are normalized when it is +// grounded (normalize_recipe): the item is the ingredient's name, and its +// quantity is qty and unit joined by a space ("2 cups"), either alone when +// the other is missing, or nil when both are. A recipe entered by hand is +// stored as typed, unnormalized: each item is its line as typed, with no +// quantity. A day whose dinner has no recipe or whose recipe has no +// ingredients gets no group and is reported in Skipped. +// +// Remaking keeps what the household marked: an item is the same item as +// before when its dinner and name are the same, wherever the dinner and +// the ingredient now are, so it keeps its id and needed flag. When a +// dinner lists a name more than once, its first such item matches the +// first one before, the second the second, and so on. Any other item is +// new and needed; an item no longer on the plan is removed, and with it a +// group no longer on the plan. +// +// A week with no plan is ErrPlanNotFound; a list frozen into the Supply +// archive is ErrFrozen. A fetched recipe's ingredient without a name is an +// error: grounding always names one. +func (s *Service) Generate(ctx context.Context, weekStart store.LocalDate) (Generated, error) { + if _, err := household.WeekDays(weekStart); err != nil { + return Generated{}, fmt.Errorf("shopping: generate: %w", err) + } + var out Generated + err := s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + plan, err := q.GetWeekPlan(ctx, weekStart.String()) + if errors.Is(err, sql.ErrNoRows) { + return ErrPlanNotFound + } + if err != nil { + return fmt.Errorf("shopping: read plan: %w", err) + } + want, skipped, err := wanted(ctx, q, plan.ID) + if err != nil { + return err + } + now := store.Now().String() + list, err := q.GetShoppingList(ctx, weekStart.String()) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("shopping: read list: %w", err) + } + switch { + case err != nil: // no list yet + list = db.ShoppingList{ID: store.NewID(), WeekStart: weekStart.String(), PlanID: plan.ID, Status: StatusCurrent} + err = q.CreateShoppingList(ctx, db.CreateShoppingListParams{ + ID: list.ID, WeekStart: list.WeekStart, PlanID: list.PlanID, CreatedAt: now, UpdatedAt: now, + }) + case list.FrozenAt.Valid || list.Status == StatusArchived: + return ErrFrozen + default: + list.PlanID = plan.ID + err = q.RegenerateShoppingList(ctx, db.RegenerateShoppingListParams{PlanID: plan.ID, UpdatedAt: now, ID: list.ID}) + } + if err != nil { + return fmt.Errorf("shopping: store list: %w", err) + } + groups, err := reconcile(ctx, q, list.ID, want, now) + if err != nil { + return err + } + out = Generated{ + List: List{ + ID: list.ID, + WeekStart: list.WeekStart, + PlanID: list.PlanID, + Title: nullString(list.Title), + Status: list.Status, + Groups: groups, + }, + Skipped: skipped, + } + return nil + }) + if err != nil { + return Generated{}, err + } + return out, nil +} + +// wantGroup is a group the plan asks for, before it is stored. +type wantGroup struct { + date, dinnerID, dinnerName string + items []wantItem +} + +type wantItem struct { + name string + qty sql.NullString +} + +// wanted reads plan planID's days and their dinners' ingredients: the +// groups the list must have, by date, and the days that get none. +func wanted(ctx context.Context, q *db.Queries, planID string) ([]wantGroup, []SkippedDay, error) { + days, err := q.ListPlanDays(ctx, planID) + if err != nil { + return nil, nil, fmt.Errorf("shopping: read plan days: %w", err) + } + var ( + groups []wantGroup + skipped []SkippedDay + ) + for _, day := range days { + d := day.Dinner + skip := SkippedDay{Date: day.Date, DinnerID: d.ID, DinnerName: d.Name} + if !d.RecipeID.Valid { + skip.Reason = SkipNoRecipe + skipped = append(skipped, skip) + continue + } + recipe, err := q.GetRecipe(ctx, d.RecipeID.String) + if err != nil { + return nil, nil, fmt.Errorf("shopping: read recipe %s of dinner %s: %w", d.RecipeID.String, d.ID, err) + } + ings, err := q.ListRecipeIngredients(ctx, recipe.ID) + if err != nil { + return nil, nil, fmt.Errorf("shopping: read ingredients of recipe %s: %w", recipe.ID, err) + } + if len(ings) == 0 { + skip.Reason = SkipNoIngredients + skipped = append(skipped, skip) + continue + } + manual := dinners.IsManualRecipe(recipe.SourceUrl) + g := wantGroup{date: day.Date, dinnerID: d.ID, dinnerName: d.Name} + for _, ing := range ings { + it, err := item(ing, manual) + if err != nil { + return nil, nil, fmt.Errorf("shopping: recipe %s: %w", recipe.ID, err) + } + g.items = append(g.items, it) + } + groups = append(groups, g) + } + return groups, skipped, nil +} + +// item is the item for ingredient ing of a recipe entered by hand +// (manual) or fetched. +func item(ing db.RecipeIngredient, manual bool) (wantItem, error) { + if manual { + return wantItem{name: ing.RawText}, nil + } + if !ing.Name.Valid || ing.Name.String == "" { + return wantItem{}, fmt.Errorf("ingredient %d (%q) has no normalized name", ing.Position+1, ing.RawText) + } + var parts []string + for _, p := range []sql.NullString{ing.Qty, ing.Unit} { + if p.Valid && p.String != "" { + parts = append(parts, p.String) + } + } + qty := strings.Join(parts, " ") + return wantItem{name: ing.Name.String, qty: sql.NullString{String: qty, Valid: qty != ""}}, nil +} + +// itemKey is what makes an item the same item across a remake. +type itemKey struct { + dinnerID, name string +} + +// groupKey is what makes a group the same group across a remake. +type groupKey struct { + date, dinnerID string +} + +// reconcile makes the groups and items of list listID those of want, as +// Generate says, and returns them. +func reconcile(ctx context.Context, q *db.Queries, listID string, want []wantGroup, now string) ([]Group, error) { + oldGroups, err := q.ListShoppingGroups(ctx, listID) + if err != nil { + return nil, fmt.Errorf("shopping: read groups: %w", err) + } + oldItems, err := q.ListShoppingItems(ctx, listID) + if err != nil { + return nil, fmt.Errorf("shopping: read items: %w", err) + } + groupsByKey := make(map[groupKey]db.ShoppingGroup, len(oldGroups)) + for _, g := range oldGroups { + groupsByKey[groupKey{g.Date, g.DinnerID}] = g + } + // itemsByKey holds each key's items in list order, so the nth item a + // dinner lists under a name matches its nth one before. + itemsByKey := make(map[itemKey][]db.ShoppingItem) + for _, r := range oldItems { + k := itemKey{r.DinnerID, r.ShoppingItem.Name} + itemsByKey[k] = append(itemsByKey[k], r.ShoppingItem) + } + kept := make(map[string]bool) // ids of the groups and items kept + + groups := make([]Group, 0, len(want)) + for gi, w := range want { + g := Group{DinnerID: w.dinnerID, DinnerName: w.dinnerName, Date: w.date, Position: gi} + if old, ok := groupsByKey[groupKey{w.date, w.dinnerID}]; ok { + g.ID = old.ID + kept[old.ID] = true + if err := q.MoveShoppingGroup(ctx, db.MoveShoppingGroupParams{Position: int64(gi), UpdatedAt: now, ID: g.ID}); err != nil { + return nil, fmt.Errorf("shopping: move group %s: %w", g.ID, err) + } + } else { + g.ID = store.NewID() + if err := q.CreateShoppingGroup(ctx, db.CreateShoppingGroupParams{ + ID: g.ID, ListID: listID, DinnerID: w.dinnerID, Date: w.date, Position: int64(gi), + CreatedAt: now, UpdatedAt: now, + }); err != nil { + return nil, fmt.Errorf("shopping: store group of %s: %w", w.date, err) + } + } + for ii, wi := range w.items { + it := Item{Name: wi.name, Qty: nullString(wi.qty), Needed: true, Position: ii} + k := itemKey{w.dinnerID, wi.name} + if same := itemsByKey[k]; len(same) > 0 { + old := same[0] + itemsByKey[k] = same[1:] + it.ID, it.Needed = old.ID, old.Needed == 1 + kept[old.ID] = true + if err := q.MoveShoppingItem(ctx, db.MoveShoppingItemParams{ + GroupID: g.ID, Qty: wi.qty, Position: int64(ii), UpdatedAt: now, ID: it.ID, + }); err != nil { + return nil, fmt.Errorf("shopping: move item %s: %w", it.ID, err) + } + } else { + it.ID = store.NewID() + if err := q.CreateShoppingItem(ctx, db.CreateShoppingItemParams{ + ID: it.ID, GroupID: g.ID, Name: wi.name, Qty: wi.qty, Position: int64(ii), + CreatedAt: now, UpdatedAt: now, + }); err != nil { + return nil, fmt.Errorf("shopping: store item %q of %s: %w", wi.name, w.date, err) + } + } + g.Items = append(g.Items, it) + } + groups = append(groups, g) + } + + // Items first: a group is removed only once it is empty. + for _, r := range oldItems { + if !kept[r.ShoppingItem.ID] { + if err := q.DeleteShoppingItem(ctx, r.ShoppingItem.ID); err != nil { + return nil, fmt.Errorf("shopping: remove item %s: %w", r.ShoppingItem.ID, err) + } + } + } + for _, g := range oldGroups { + if !kept[g.ID] { + if err := q.DeleteShoppingGroup(ctx, g.ID); err != nil { + return nil, fmt.Errorf("shopping: remove group %s: %w", g.ID, err) + } + } + } + return groups, nil +} + +func nullString(s sql.NullString) *string { + if !s.Valid { + return nil + } + return &s.String +} diff --git a/internal/shopping/shopping_test.go b/internal/shopping/shopping_test.go new file mode 100644 index 0000000..7aa5035 --- /dev/null +++ b/internal/shopping/shopping_test.go @@ -0,0 +1,582 @@ +package shopping_test + +import ( + "database/sql" + "errors" + "fmt" + "io" + "log/slog" + "reflect" + "strings" + "testing" + + "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/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// week is the Monday the tests plan, 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 *shopping.Service + st *store.Store + dataDir string + planID string + // ids are the dinners' ids by name. + ids map[string]string +} + +func newEnv(t *testing.T) *env { + t.Helper() + dataDir := t.TempDir() + st, err := store.Open(t.Context(), dataDir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + svc, err := shopping.NewService(st) + if err != nil { + t.Fatal(err) + } + return &env{svc: svc, st: st, dataDir: dataDir, ids: map[string]string{}} +} + +// ing is a recipe ingredient as recipe grounding stores it: an empty +// qty or unit is NULL. +type ing struct { + name, qty, unit string +} + +// dinner stores a Regular named name and returns its id. With ings it is +// grounded in a fetched recipe listing them; with none it has no recipe. +func (e *env) dinner(t *testing.T, name string, ings ...ing) string { + t.Helper() + q := db.New(e.st.Writer) + now := store.Now().String() + var recipeID sql.NullString + status := "pending" + if ings != nil { + recipeID = e.recipe(t, name, ings) + status = "grounded" + } + d, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{ + ID: store.NewID(), + Name: name, + NormalizedName: harness.Normalize(name), + Status: "regular", + Origin: "regular", + RecipeID: recipeID, + GroundingStatus: status, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + t.Fatal(err) + } + e.ids[name] = d.ID + return d.ID +} + +// recipe stores a fetched recipe titled title with ings, as +// recipes.Grounder stores a normalized page, and returns its id. +func (e *env) recipe(t *testing.T, title string, ings []ing) sql.NullString { + t.Helper() + q := db.New(e.st.Writer) + now := store.Now().String() + r, err := q.UpsertRecipe(t.Context(), db.UpsertRecipeParams{ + ID: store.NewID(), + SourceUrl: "https://recipes.example/" + strings.ReplaceAll(strings.ToLower(title), " ", "-"), + Title: title, + RawJsonld: "{}", + FetchedAt: now, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + t.Fatal(err) + } + e.setIngredients(t, r.ID, ings) + return sql.NullString{String: r.ID, Valid: true} +} + +// setIngredients replaces recipe id's ingredients with ings, as a +// regrounding does. +func (e *env) setIngredients(t *testing.T, id string, ings []ing) { + t.Helper() + q := db.New(e.st.Writer) + if err := q.DeleteRecipeIngredients(t.Context(), id); err != nil { + t.Fatal(err) + } + now := store.Now().String() + for i, in := range ings { + qtyUnit := strings.TrimSpace(in.qty + " " + in.unit) + if err := q.CreateRecipeIngredient(t.Context(), db.CreateRecipeIngredientParams{ + ID: store.NewID(), + RecipeID: id, + Position: int64(i), + RawText: strings.TrimSpace(qtyUnit + " " + in.name), + Name: sql.NullString{String: in.name, Valid: true}, + Qty: sql.NullString{String: in.qty, Valid: in.qty != ""}, + Unit: sql.NullString{String: in.unit, Valid: in.unit != ""}, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + } +} + +// recipeOf returns the id of dinner name's recipe. +func (e *env) recipeOf(t *testing.T, name string) string { + t.Helper() + d, err := db.New(e.st.Reader).GetDinner(t.Context(), e.ids[name]) + if err != nil { + t.Fatal(err) + } + return d.RecipeID.String +} + +// plan stores the week's plan with the dinners named names on weekDates, +// in order. +func (e *env) plan(t *testing.T, names ...string) { + t.Helper() + q := db.New(e.st.Writer) + now := store.Now().String() + e.planID = store.NewID() + if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: e.planID, WeekStart: week.String(), CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + for i, name := range names { + if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ + ID: store.NewID(), PlanID: e.planID, Date: weekDates[i], + DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + } +} + +// swap plans the dinner named name on date, as a swap on the Draft board +// does. +func (e *env) swap(t *testing.T, date, name string) { + t.Helper() + n, err := db.New(e.st.Writer).SwapPlanDay(t.Context(), db.SwapPlanDayParams{ + DinnerID: sql.NullString{String: e.ids[name], Valid: true}, UpdatedAt: store.Now().String(), + PlanID: e.planID, Date: date, + }) + if err != nil || n != 1 { + t.Fatalf("swap %s to %s: %d rows, %v", date, name, n, err) + } +} + +// haveIt marks the item named name of dinner's group "have it". +func (e *env) haveIt(t *testing.T, l shopping.List, dinner, name string) string { + t.Helper() + for _, g := range l.Groups { + for _, it := range g.Items { + if g.DinnerName == dinner && it.Name == name { + if _, err := e.st.Writer.Exec(`UPDATE shopping_items SET needed = 0 WHERE id = ?`, it.ID); err != nil { + t.Fatal(err) + } + return it.ID + } + } + } + t.Fatalf("no item %q of %s", name, dinner) + return "" +} + +func (e *env) generate(t *testing.T) shopping.Generated { + t.Helper() + g, err := e.svc.Generate(t.Context(), week) + if err != nil { + t.Fatal(err) + } + return g +} + +// lines renders l one item a line as "position date dinner: position +// name [qty] needed|have it". +func lines(l shopping.List) []string { + var out []string + for _, g := range l.Groups { + for _, it := range g.Items { + qty := "-" + if it.Qty != nil { + qty = *it.Qty + } + mark := "needed" + if !it.Needed { + mark = "have it" + } + out = append(out, fmt.Sprintf("%d %s %s: %d %s [%s] %s", g.Position, g.Date, g.DinnerName, it.Position, it.Name, qty, mark)) + } + } + return out +} + +// stored renders the stored list of the week as lines does, so a test +// checks the database holds what Generate returned. +func (e *env) stored(t *testing.T) []string { + t.Helper() + rows, err := e.st.Reader.Query(`SELECT g.position, g.date, d.name, i.position, i.name, COALESCE(i.qty, '-'), i.needed + FROM shopping_lists l + JOIN shopping_groups g ON g.list_id = l.id + JOIN dinners d ON d.id = g.dinner_id + JOIN shopping_items i ON i.group_id = g.id + WHERE l.week_start = ? + ORDER BY g.position, i.position`, week.String()) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []string + for rows.Next() { + var ( + gpos, ipos, needed int + date, dinner, name string + qty string + ) + if err := rows.Scan(&gpos, &date, &dinner, &ipos, &name, &qty, &needed); err != nil { + t.Fatal(err) + } + mark := "needed" + if needed == 0 { + mark = "have it" + } + out = append(out, fmt.Sprintf("%d %s %s: %d %s [%s] %s", gpos, date, dinner, ipos, name, qty, mark)) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +} + +// count returns the number of rows in table. +func (e *env) count(t *testing.T, table string) int { + t.Helper() + var n int + if err := e.st.Reader.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +func wantLines(t *testing.T, what string, got, want []string) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Errorf("%s =\n %s\nwant\n %s", what, strings.Join(got, "\n "), strings.Join(want, "\n ")) + } +} + +func TestGenerateSevenDayPlan(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"kidney beans", "2", "cans"}, ing{"onion", "1", ""}) + e.dinner(t, "Pot Roast", ing{"chuck roast", "3", "lb"}, ing{"carrots", "4", ""}) + e.dinner(t, "Chicken Fajitas", ing{"chicken breast", "500", "g"}, ing{"bell peppers", "3", ""}, ing{"tortillas", "8", ""}) + e.dinner(t, "Spaghetti Bolognese", ing{"spaghetti", "1", "lb"}, ing{"crushed tomatoes", "28", "oz"}) + e.dinner(t, "Sheet Pan Salmon", ing{"salmon fillets", "4", ""}, ing{"lemon", "1", ""}, ing{"dill", "", "sprig"}) + e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"bean sprouts", "2", "cups"}, ing{"salt", "", ""}) + e.dinner(t, "Shawarma Bowls", ing{"chicken thighs", "2", "lb"}, ing{"pita", "4", ""}) + e.plan(t, "Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Pad Thai", "Shawarma Bowls") + + g := e.generate(t) + + want := []string{ + "0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed", + "0 2026-10-12 Beef Chili: 1 kidney beans [2 cans] needed", + "0 2026-10-12 Beef Chili: 2 onion [1] needed", + "1 2026-10-13 Pot Roast: 0 chuck roast [3 lb] needed", + "1 2026-10-13 Pot Roast: 1 carrots [4] needed", + "2 2026-10-14 Chicken Fajitas: 0 chicken breast [500 g] needed", + "2 2026-10-14 Chicken Fajitas: 1 bell peppers [3] needed", + "2 2026-10-14 Chicken Fajitas: 2 tortillas [8] needed", + "3 2026-10-15 Spaghetti Bolognese: 0 spaghetti [1 lb] needed", + "3 2026-10-15 Spaghetti Bolognese: 1 crushed tomatoes [28 oz] needed", + "4 2026-10-16 Sheet Pan Salmon: 0 salmon fillets [4] needed", + "4 2026-10-16 Sheet Pan Salmon: 1 lemon [1] needed", + "4 2026-10-16 Sheet Pan Salmon: 2 dill [sprig] needed", + "5 2026-10-17 Pad Thai: 0 rice noodles [8 oz] needed", + "5 2026-10-17 Pad Thai: 1 bean sprouts [2 cups] needed", + "5 2026-10-17 Pad Thai: 2 salt [-] needed", + "6 2026-10-18 Shawarma Bowls: 0 chicken thighs [2 lb] needed", + "6 2026-10-18 Shawarma Bowls: 1 pita [4] needed", + } + wantLines(t, "list", lines(g.List), want) + wantLines(t, "stored list", e.stored(t), want) + if len(g.Skipped) != 0 { + t.Errorf("skipped = %+v, want none", g.Skipped) + } + for _, grp := range g.List.Groups { + if grp.DinnerID != e.ids[grp.DinnerName] { + t.Errorf("group %s dinner id = %s, want %s", grp.DinnerName, grp.DinnerID, e.ids[grp.DinnerName]) + } + } + if g.List.WeekStart != "2026-10-12" || g.List.PlanID != e.planID || g.List.Status != shopping.StatusCurrent || g.List.Title != nil { + t.Errorf("list = %+v, want the week's current, untitled list of plan %s", g.List, e.planID) + } + var title sql.NullString + if err := e.st.Reader.QueryRow(`SELECT title FROM shopping_lists WHERE id = ?`, g.List.ID).Scan(&title); err != nil { + t.Fatal(err) + } + if title.Valid { + t.Errorf("stored title = %q, want NULL until the AI titles the list", title.String) + } + + // Generating again from the same plan changes nothing: same rows, same + // ids, no extra rows. + again := e.generate(t) + if !reflect.DeepEqual(again, g) { + t.Errorf("second generation =\n %+v\nwant the first\n %+v", again, g) + } + if n := e.count(t, "shopping_lists"); n != 1 { + t.Errorf("%d lists, want 1", n) + } + if n := e.count(t, "shopping_items"); n != len(want) { + t.Errorf("%d items stored, want %d", n, len(want)) + } +} + +// TestGenerateManualRecipe grounds a dinner with a recipe typed in, through +// dinners.Service as the household does: its lines are stored unnormalized, +// so each is an item as typed, with no quantity. +func TestGenerateManualRecipe(t *testing.T) { + e := newEnv(t) + model := &llm.Fake{} + grounder := recipestest.NewGrounder(t, e.st, e.dataDir, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, model) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + runner, err := jobs.NewRunner(e.st, logger, 1) + if err != nil { + t.Fatal(err) + } + dsvc, err := dinners.NewService(e.st, grounder, runner, logger) + if err != nil { + t.Fatal(err) + } + e.dinner(t, "Grandma's Goulash") + minutes, method := 45, "stovetop" + if _, err := dsvc.Ground(t.Context(), e.ids["Grandma's Goulash"], dinners.Fix{Manual: &dinners.ManualRecipe{ + Ingredients: []string{"2 lb beef chuck", "1 onion, diced", "paprika to taste"}, + Steps: []string{"Brown the beef."}, + Minutes: &minutes, + Method: &method, + }}, "member-1"); err != nil { + t.Fatal(err) + } + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) + e.plan(t, "Grandma's Goulash", "Beef Chili") + + g := e.generate(t) + + want := []string{ + "0 2026-10-12 Grandma's Goulash: 0 2 lb beef chuck [-] needed", + "0 2026-10-12 Grandma's Goulash: 1 1 onion, diced [-] needed", + "0 2026-10-12 Grandma's Goulash: 2 paprika to taste [-] needed", + "1 2026-10-13 Beef Chili: 0 ground beef [1 lb] needed", + } + wantLines(t, "list", lines(g.List), want) + wantLines(t, "stored list", e.stored(t), want) + if len(g.Skipped) != 0 { + t.Errorf("skipped = %+v, want none", g.Skipped) + } + if reqs := model.Requests(); len(reqs) != 0 { + t.Errorf("%d model requests, want none", len(reqs)) + } +} + +// TestGenerateSkipsDinnersWithoutIngredients plans a dinner with no recipe +// (its grounding pending) and one whose recipe lists no ingredients: each +// day gets no group and is reported with why. +func TestGenerateSkipsDinnersWithoutIngredients(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Mystery Stew") + e.dinner(t, "Toast", []ing{}...) // a recipe with no ingredients + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) + e.plan(t, "Mystery Stew", "Toast", "Beef Chili") + + g := e.generate(t) + + want := []string{"0 2026-10-14 Beef Chili: 0 ground beef [1 lb] needed"} + wantLines(t, "list", lines(g.List), want) + wantLines(t, "stored list", e.stored(t), want) + wantSkipped := []shopping.SkippedDay{ + {Date: "2026-10-12", DinnerID: e.ids["Mystery Stew"], DinnerName: "Mystery Stew", Reason: shopping.SkipNoRecipe}, + {Date: "2026-10-13", DinnerID: e.ids["Toast"], DinnerName: "Toast", Reason: shopping.SkipNoIngredients}, + } + if !reflect.DeepEqual(g.Skipped, wantSkipped) { + t.Errorf("skipped = %+v, want %+v", g.Skipped, wantSkipped) + } + if n := e.count(t, "shopping_groups"); n != 1 { + t.Errorf("%d groups stored, want 1: a skipped day gets no empty group", n) + } +} + +// TestGenerateAfterSwapKeepsHaveIt swaps a day to a new dinner and moves a +// dinner to another day, and regrounds a dinner so its ingredients change +// order and quantity: an item whose dinner and name are unchanged keeps +// its id and "have it" wherever it now is; the rest is added as needed or +// removed. +func TestGenerateAfterSwapKeepsHaveIt(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}, ing{"kidney beans", "2", "cans"}) + e.dinner(t, "Chicken Fajitas", ing{"chicken breast", "500", "g"}, ing{"tortillas", "8", ""}) + e.dinner(t, "Sheet Pan Salmon", ing{"salmon fillets", "4", ""}, ing{"lemon", "1", ""}) + e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"lime", "2", ""}) + e.plan(t, "Beef Chili", "Chicken Fajitas", "Sheet Pan Salmon") + first := e.generate(t).List + onion := e.haveIt(t, first, "Beef Chili", "onion") + tortillas := e.haveIt(t, first, "Chicken Fajitas", "tortillas") + lemon := e.haveIt(t, first, "Sheet Pan Salmon", "lemon") + + // Tuesday becomes Pad Thai and Fajitas moves to Wednesday, dropping + // the Salmon; the Chili is regrounded: onion first, no beans, a + // pepper added and more beef. + e.swap(t, "2026-10-13", "Pad Thai") + e.swap(t, "2026-10-14", "Chicken Fajitas") + e.setIngredients(t, e.recipeOf(t, "Beef Chili"), []ing{{"onion", "2", ""}, {"ground beef", "2", "lb"}, {"jalapeño", "1", ""}}) + + g := e.generate(t) + + want := []string{ + "0 2026-10-12 Beef Chili: 0 onion [2] have it", + "0 2026-10-12 Beef Chili: 1 ground beef [2 lb] needed", + "0 2026-10-12 Beef Chili: 2 jalapeño [1] needed", + "1 2026-10-13 Pad Thai: 0 rice noodles [8 oz] needed", + "1 2026-10-13 Pad Thai: 1 lime [2] needed", + "2 2026-10-14 Chicken Fajitas: 0 chicken breast [500 g] needed", + "2 2026-10-14 Chicken Fajitas: 1 tortillas [8] have it", + } + wantLines(t, "list", lines(g.List), want) + wantLines(t, "stored list", e.stored(t), want) + ids := map[string]string{} + for _, grp := range g.List.Groups { + for _, it := range grp.Items { + ids[grp.DinnerName+"/"+it.Name] = it.ID + } + } + if ids["Beef Chili/onion"] != onion || ids["Chicken Fajitas/tortillas"] != tortillas { + t.Errorf("kept items have ids %s and %s, want %s and %s", ids["Beef Chili/onion"], ids["Chicken Fajitas/tortillas"], onion, tortillas) + } + var n int + if err := e.st.Reader.QueryRow(`SELECT count(*) FROM shopping_items WHERE id = ?`, lemon).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Error("the Salmon's lemon is still stored after the Salmon left the plan") + } + if got := e.count(t, "shopping_items"); got != len(want) { + t.Errorf("%d items stored, want %d", got, len(want)) + } + if got := e.count(t, "shopping_groups"); got != 3 { + t.Errorf("%d groups stored, want 3", got) + } + if g.List.ID != first.ID { + t.Errorf("list id = %s, want the first generation's %s", g.List.ID, first.ID) + } +} + +// TestGenerateMatchesRepeatedNamesInOrder lists a name twice in one +// dinner: the second "salt" marked "have it" stays the second. +func TestGenerateMatchesRepeatedNamesInOrder(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Pasta", ing{"salt", "1", "tbsp"}, ing{"spaghetti", "1", "lb"}, ing{"salt", "", "pinch"}) + e.plan(t, "Pasta") + first := e.generate(t).List + second := first.Groups[0].Items[2].ID + if _, err := e.st.Writer.Exec(`UPDATE shopping_items SET needed = 0 WHERE id = ?`, second); err != nil { + t.Fatal(err) + } + e.setIngredients(t, e.recipeOf(t, "Pasta"), []ing{{"spaghetti", "1", "lb"}, {"salt", "1", "tbsp"}, {"salt", "", "pinch"}}) + + g := e.generate(t) + + wantLines(t, "list", lines(g.List), []string{ + "0 2026-10-12 Pasta: 0 spaghetti [1 lb] needed", + "0 2026-10-12 Pasta: 1 salt [1 tbsp] needed", + "0 2026-10-12 Pasta: 2 salt [pinch] have it", + }) + if id := g.List.Groups[0].Items[2].ID; id != second { + t.Errorf("second salt id = %s, want %s", id, second) + } +} + +func TestGenerateWeekWithoutPlan(t *testing.T) { + e := newEnv(t) + if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrPlanNotFound) { + t.Fatalf("Generate = %v, want ErrPlanNotFound", err) + } + if n := e.count(t, "shopping_lists"); n != 0 { + t.Errorf("%d lists stored, want none", n) + } +} + +func TestGenerateRefusesNonMonday(t *testing.T) { + e := newEnv(t) + if _, err := e.svc.Generate(t.Context(), mustDate("2026-10-13")); err == nil { + t.Fatal("Generate of a Tuesday succeeded") + } +} + +// TestGenerateRefusesFrozenList leaves a list frozen into the Supply +// archive as it is. +func TestGenerateRefusesFrozenList(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) + 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 { + t.Fatal(err) + } + e.swap(t, "2026-10-12", "Pad Thai") + + if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrFrozen) { + t.Fatalf("Generate = %v, want ErrFrozen", err) + } + wantLines(t, "stored list", e.stored(t), []string{"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed"}) +} + +// TestGenerateRefusesUnnamedIngredient: grounding always names a fetched +// recipe's ingredient, so one without a name is an error, and nothing is +// stored. +func TestGenerateRefusesUnnamedIngredient(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}) + e.plan(t, "Beef Chili") + if _, err := e.st.Writer.Exec(`UPDATE recipe_ingredients SET name = NULL WHERE position = 1`); err != nil { + t.Fatal(err) + } + + _, err := e.svc.Generate(t.Context(), week) + if err == nil || !strings.Contains(err.Error(), `ingredient 2 ("1 onion") has no normalized name`) { + t.Fatalf("Generate = %v, want the unnamed ingredient refused", err) + } + if n := e.count(t, "shopping_lists"); n != 0 { + t.Errorf("%d lists stored, want none", n) + } +} diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index 4349f60..f674930 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" { - 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", 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" { + 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", 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" { - 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", 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" { + 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", got) } if !tableExists(t, s, "dinners") { t.Error("table from 0001_init was not created") @@ -761,3 +761,131 @@ func TestMigrateGoogleReconnectKeepsConnection(t *testing.T) { t.Errorf("connection after 0015 = %s, want %s", got, row) } } + +// migrateBefore applies every migration whose name sorts before prefix. +func migrateBefore(t *testing.T, s *Store, prefix string) { + t.Helper() + before := fstest.MapFS{} + matches, err := fs.Glob(migrations.FS, "*.sql") + if err != nil { + t.Fatal(err) + } + for _, name := range matches { + if name >= prefix { + continue + } + body, err := fs.ReadFile(migrations.FS, name) + if err != nil { + t.Fatal(err) + } + before[name] = &fstest.MapFile{Data: body} + } + if err := Migrate(context.Background(), s.Writer, before); err != nil { + t.Fatal(err) + } +} + +// seedShopping stores two dinners, a plan with dinner-1 on 2026-10-13 +// and dinner-2 on 2026-10-14, and a titled list with a group of +// groupDinner holding an item marked "have it". +func seedShopping(t *testing.T, s *Store, groupDinner string) { + t.Helper() + 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', 'regular', 'regular', 0, 'grounded', 'c', 'u'), + ('dinner-2', 'Ramen', 'ramen', 'regular', 'regular', 0, 'grounded', 'c', 'u'), + ('dinner-3', 'Laksa', 'laksa', 'regular', 'regular', 0, 'grounded', 'c', 'u')`, + `INSERT INTO week_plans (id, week_start, status, created_at, updated_at) + VALUES ('plan-1', '2026-10-12', 'synced', 'c', 'u')`, + `INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at) + VALUES ('day-1', 'plan-1', '2026-10-13', 'dinner-1', 0, 'c', 'u'), + ('day-2', 'plan-1', '2026-10-14', 'dinner-2', 0, 'c', 'u')`, + `INSERT INTO shopping_lists (id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at) + VALUES ('list-1', '2026-10-12', 'plan-1', 'Noodle week', 'current', 's', NULL, 'c1', 'u1')`, + `INSERT INTO shopping_groups (id, list_id, dinner_id, position, created_at, updated_at) + VALUES ('group-1', 'list-1', '` + groupDinner + `', 0, 'c2', 'u2')`, + `INSERT INTO shopping_items (id, group_id, name, qty, needed, position, created_at, updated_at) + VALUES ('item-1', 'group-1', 'rice noodles', '200 g', 0, 0, 'c3', 'u3')`, + } { + if _, err := s.Writer.Exec(stmt); err != nil { + t.Fatalf("seed: %v", err) + } + } +} + +// TestMigrateShoppingListUntitled applies 0016, which rebuilds the +// shopping tables: every row survives, a group takes its dinner's plan +// day, and a list may then be untitled but never have an empty title. +func TestMigrateShoppingListUntitled(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + migrateBefore(t, s, "0016") + seedShopping(t, s, "dinner-2") + + if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + + var got string + if err := s.Reader.QueryRowContext(ctx, `SELECT + l.id || '|' || l.week_start || '|' || l.plan_id || '|' || l.title || '|' || l.status || '|' || + l.sent_to_chat_at || '|' || COALESCE(l.frozen_at, '') || '|' || l.created_at || '|' || l.updated_at || ' / ' || + g.id || '|' || g.list_id || '|' || g.dinner_id || '|' || g.date || '|' || g.position || '|' || g.created_at || '|' || g.updated_at || ' / ' || + i.id || '|' || i.group_id || '|' || i.name || '|' || i.qty || '|' || i.needed || '|' || i.position || '|' || i.created_at || '|' || i.updated_at + FROM shopping_lists l JOIN shopping_groups g ON g.list_id = l.id JOIN shopping_items i ON i.group_id = g.id`).Scan(&got); err != nil { + t.Fatal(err) + } + const want = `list-1|2026-10-12|plan-1|Noodle week|current|s||c1|u1 / ` + + `group-1|list-1|dinner-2|2026-10-14|0|c2|u2 / ` + + `item-1|group-1|rice noodles|200 g|0|0|c3|u3` + if got != want { + t.Errorf("shopping rows after 0016 =\n %s\nwant\n %s", got, want) + } + + if _, err := s.Writer.Exec(`UPDATE shopping_lists SET title = NULL WHERE id = 'list-1'`); err != nil { + t.Errorf("untitled list refused: %v", err) + } + for _, tc := range []struct{ name, stmt, want string }{ + {"empty title", `UPDATE shopping_lists SET title = '' WHERE id = 'list-1'`, "CHECK constraint failed"}, + {"group with no date", `UPDATE shopping_groups SET date = NULL WHERE id = 'group-1'`, "NOT NULL constraint failed"}, + {"group of an unknown list", `UPDATE shopping_groups SET list_id = 'nope' WHERE id = 'group-1'`, "FOREIGN KEY constraint failed"}, + {"item of an unknown group", `UPDATE shopping_items SET group_id = 'nope' WHERE id = 'item-1'`, "FOREIGN KEY constraint failed"}, + {"needed not a boolean", `UPDATE shopping_items SET needed = 2 WHERE id = 'item-1'`, "CHECK constraint failed"}, + {"second list of a week", `INSERT INTO shopping_lists (id, week_start, plan_id, status, created_at, updated_at) + VALUES ('list-2', '2026-10-12', 'plan-1', 'current', '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) + } + } + for _, table := range []string{"shopping_lists_copy", "shopping_groups_copy", "shopping_items_copy"} { + if tableExists(t, s, table) { + t.Errorf("%s left behind", table) + } + } +} + +// TestMigrateShoppingListUntitledRefusesUndatedGroup applies 0016 over a +// group whose dinner is not on its list's plan: it has no plan day, so the +// migration fails and leaves the database as it was. +func TestMigrateShoppingListUntitledRefusesUndatedGroup(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + migrateBefore(t, s, "0016") + seedShopping(t, s, "dinner-3") + + err := Migrate(ctx, s.Writer, migrations.FS) + if err == nil || !strings.Contains(err.Error(), "NOT NULL constraint failed: shopping_groups.date") { + t.Fatalf("Migrate = %v, want the undated group refused", err) + } + var n int + if err := s.Reader.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations WHERE version = '0016_shopping_list_untitled'`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Error("0016 recorded as applied") + } + if tableExists(t, s, "shopping_groups_copy") { + t.Error("shopping_groups_copy left behind") + } +} diff --git a/internal/store/repository_test.go b/internal/store/repository_test.go index 58101e8..95abc51 100644 --- a/internal/store/repository_test.go +++ b/internal/store/repository_test.go @@ -43,7 +43,7 @@ var repoParents = map[string]repoRow{ }}, "shopping_groups": {"shopping_groups", map[string]any{ "id": "group-1", "list_id": "list-1", "dinner_id": "dinner-1", - "position": int64(0), "created_at": repoTime, "updated_at": repoTime, + "date": "2026-10-05", "position": int64(0), "created_at": repoTime, "updated_at": repoTime, }}, } @@ -108,12 +108,12 @@ func TestRepositoryInsertReadUpdate(t *testing.T) { "title": "Supply run", "status": "current", "sent_to_chat_at": nil, "frozen_at": nil, "created_at": repoTime, "updated_at": repoTime, }}, - update: map[string]any{"status": "archived", "frozen_at": "2026-10-12T00:00:00Z"}, + update: map[string]any{"title": nil, "status": "archived", "frozen_at": "2026-10-12T00:00:00Z"}, }, { parents: []string{"dinners", "week_plans", "shopping_lists"}, row: repoRow{"shopping_groups", map[string]any{ - "id": "group-2", "list_id": "list-1", "dinner_id": "dinner-1", "position": int64(0), + "id": "group-2", "list_id": "list-1", "dinner_id": "dinner-1", "date": "2026-10-06", "position": int64(0), "created_at": repoTime, "updated_at": repoTime, }}, update: map[string]any{"position": int64(3)}, diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go index 8750a2a..1607353 100644 --- a/internal/store/schema_test.go +++ b/internal/store/schema_test.go @@ -136,6 +136,7 @@ func testRow(table string, i int) map[string]any { case "shopping_groups": r["list_id"] = rowID("shopping_lists", 0) r["dinner_id"] = rowID("dinners", 0) + r["date"] = fmt.Sprintf("2026-01-%02d", i+5) r["position"] = i case "shopping_items": r["group_id"] = rowID("shopping_groups", 0) diff --git a/migrations/0016_shopping_list_untitled.sql b/migrations/0016_shopping_list_untitled.sql new file mode 100644 index 0000000..2c54101 --- /dev/null +++ b/migrations/0016_shopping_list_untitled.sql @@ -0,0 +1,81 @@ +-- 0016_shopping_list_untitled: a Supply run is generated from the plan +-- before it has a title, and stays untitled when the AI cannot write one, +-- so shopping_lists.title becomes nullable; an untitled list shows its +-- week label instead. A title is never empty. Each group also keeps the +-- plan day it shops for, so a list read back, or frozen into the Supply +-- archive, says which day each dinner was planned on. +-- SQLite cannot drop a NOT NULL constraint or add a NOT NULL column +-- without a default, so the three shopping tables are rebuilt as 0012 +-- does: copy the rows aside, drop the tables children first, create them +-- again, copy the rows back parents first and drop the copies. A group +-- takes the date its dinner has on its list's plan; a group whose dinner +-- is not on that plan has no date and fails the migration. Nothing else +-- references the shopping tables. + +CREATE TABLE shopping_items_copy AS SELECT * FROM shopping_items; +CREATE TABLE shopping_groups_copy AS SELECT * FROM shopping_groups; +CREATE TABLE shopping_lists_copy AS SELECT * FROM shopping_lists; + +DROP TABLE shopping_items; +DROP TABLE shopping_groups; +DROP TABLE shopping_lists; + +CREATE TABLE shopping_lists ( + id TEXT NOT NULL PRIMARY KEY, + week_start TEXT NOT NULL UNIQUE, + plan_id TEXT NOT NULL REFERENCES week_plans (id), + title TEXT CHECK (title <> ''), -- NULL until the AI titles it + status TEXT NOT NULL CHECK (status IN ('current', 'archived')), + sent_to_chat_at TEXT, + frozen_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE shopping_groups ( + id TEXT NOT NULL PRIMARY KEY, + list_id TEXT NOT NULL REFERENCES shopping_lists (id), + dinner_id TEXT NOT NULL REFERENCES dinners (id), + date TEXT NOT NULL, -- the plan day, household-local YYYY-MM-DD + position INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE shopping_items ( + id TEXT NOT NULL PRIMARY KEY, + group_id TEXT NOT NULL REFERENCES shopping_groups (id), + name TEXT NOT NULL, + qty TEXT, + needed INTEGER NOT NULL DEFAULT 1 CHECK (needed IN (0, 1)), + position INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT INTO shopping_lists ( + id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at +) +SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at +FROM shopping_lists_copy; + +INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at) +SELECT + shopping_groups_copy.id, shopping_groups_copy.list_id, shopping_groups_copy.dinner_id, + ( + SELECT MIN(plan_days.date) + FROM plan_days + JOIN shopping_lists_copy ON shopping_lists_copy.plan_id = plan_days.plan_id + WHERE shopping_lists_copy.id = shopping_groups_copy.list_id + AND plan_days.dinner_id = shopping_groups_copy.dinner_id + ), + shopping_groups_copy.position, shopping_groups_copy.created_at, shopping_groups_copy.updated_at +FROM shopping_groups_copy; + +INSERT INTO shopping_items (id, group_id, name, qty, needed, position, created_at, updated_at) +SELECT id, group_id, name, qty, needed, position, created_at, updated_at +FROM shopping_items_copy; + +DROP TABLE shopping_items_copy; +DROP TABLE shopping_groups_copy; +DROP TABLE shopping_lists_copy; diff --git a/migrations/queries/shopping.sql b/migrations/queries/shopping.sql new file mode 100644 index 0000000..aefdc7b --- /dev/null +++ b/migrations/queries/shopping.sql @@ -0,0 +1,60 @@ +-- name: GetShoppingList :one +-- The Supply run of the week starting week_start. Returns sql.ErrNoRows +-- when there is none. +SELECT * FROM shopping_lists +WHERE week_start = ?; + +-- name: CreateShoppingList :exec +-- A new, untitled Supply run: the title is written later, by the AI. +INSERT INTO shopping_lists (id, week_start, plan_id, status, created_at, updated_at) +VALUES (?, ?, ?, 'current', ?, ?); + +-- name: RegenerateShoppingList :exec +-- Records a generation of list id from plan plan_id. +UPDATE shopping_lists +SET plan_id = ?, updated_at = ? +WHERE id = ?; + +-- name: ListShoppingGroups :many +-- The groups of list list_id, in list order. +SELECT * FROM shopping_groups +WHERE list_id = ? +ORDER BY position, id; + +-- name: ListShoppingItems :many +-- The items of list list_id with their group's dinner, in list order: by +-- group, then by item. +SELECT sqlc.embed(shopping_items), shopping_groups.dinner_id +FROM shopping_items +JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id +WHERE shopping_groups.list_id = ? +ORDER BY shopping_groups.position, shopping_items.position, shopping_items.id; + +-- name: CreateShoppingGroup :exec +INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?); + +-- name: MoveShoppingGroup :exec +UPDATE shopping_groups +SET position = ?, updated_at = ? +WHERE id = ?; + +-- name: DeleteShoppingGroup :exec +DELETE FROM shopping_groups +WHERE id = ?; + +-- name: CreateShoppingItem :exec +-- A new item is needed until the household marks it "have it". +INSERT INTO shopping_items (id, group_id, name, qty, needed, position, created_at, updated_at) +VALUES (?, ?, ?, ?, 1, ?, ?, ?); + +-- name: MoveShoppingItem :exec +-- Places item id in group group_id at position with quantity qty; its +-- name and needed flag are kept. +UPDATE shopping_items +SET group_id = ?, qty = ?, position = ?, updated_at = ? +WHERE id = ?; + +-- name: DeleteShoppingItem :exec +DELETE FROM shopping_items +WHERE id = ?;