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 <noreply@anthropic.com>
This commit is contained in:
@@ -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). |
|
| `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). |
|
| `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. |
|
| `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_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 dinner/day; item name, qty, needed (bool), position. |
|
| `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. |
|
| `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). |
|
| `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. |
|
| `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. |
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ type ShoppingGroup struct {
|
|||||||
ID string
|
ID string
|
||||||
ListID string
|
ListID string
|
||||||
DinnerID string
|
DinnerID string
|
||||||
|
Date string
|
||||||
Position int64
|
Position int64
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
@@ -278,7 +279,7 @@ type ShoppingList struct {
|
|||||||
ID string
|
ID string
|
||||||
WeekStart string
|
WeekStart string
|
||||||
PlanID string
|
PlanID string
|
||||||
Title string
|
Title sql.NullString
|
||||||
Status string
|
Status string
|
||||||
SentToChatAt sql.NullString
|
SentToChatAt sql.NullString
|
||||||
FrozenAt sql.NullString
|
FrozenAt sql.NullString
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ type Querier interface {
|
|||||||
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
||||||
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
||||||
CreateRotationReview(ctx context.Context, arg CreateRotationReviewParams) 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
|
// name is the name the dish is suggested under, kept whatever its dinner is
|
||||||
// later renamed.
|
// later renamed.
|
||||||
CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error
|
CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error
|
||||||
@@ -59,6 +64,8 @@ type Querier interface {
|
|||||||
DeleteIntegrationsGoogle(ctx context.Context) error
|
DeleteIntegrationsGoogle(ctx context.Context) error
|
||||||
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||||
DeleteRecipeSteps(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)
|
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
|
||||||
// Fails every unarchived dinner still pending whose grounding job for its
|
// Fails every unarchived dinner still pending whose grounding job for its
|
||||||
// current grounding_revision is no longer queued or running: the process
|
// 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)
|
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
|
||||||
// Returns sql.ErrNoRows until the row exists.
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
|
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.
|
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
|
||||||
GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error)
|
GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error)
|
||||||
// The id of the job that runs batch batch_id.
|
// 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)
|
ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error)
|
||||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, 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
|
// 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.
|
// of any status, that have no Rotation check for date yet.
|
||||||
ListTriedCandidates(ctx context.Context, date string) ([]string, error)
|
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
|
// becomes synced. A plan edited since (another revision) is left as it is
|
||||||
// (0 rows).
|
// (0 rows).
|
||||||
MarkWeekPlanSynced(ctx context.Context, arg MarkWeekPlanSyncedParams) (int64, error)
|
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),
|
// Moves a dinner still suggested to status (quest_log or archived),
|
||||||
// recording updated_by; a dinner no longer suggested is left as it is
|
// recording updated_by; a dinner no longer suggested is left as it is
|
||||||
// (0 rows).
|
// (0 rows).
|
||||||
@@ -208,6 +227,8 @@ type Querier interface {
|
|||||||
// Records a new draft over plan id: its status and note, raising its
|
// Records a new draft over plan id: its status and note, raising its
|
||||||
// revision.
|
// revision.
|
||||||
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
|
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.
|
// An unknown id returns sql.ErrNoRows.
|
||||||
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
||||||
// Sets the chosen calendar; affects no row when nothing is connected.
|
// Sets the chosen calendar; affects no row when nothing is connected.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -68,6 +68,13 @@ const (
|
|||||||
manualSourcePrefix = "mealquest:manual:"
|
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
|
// GroundTimeout bounds grounding a candidate or link by hand, which runs in
|
||||||
// the request: the page fetch (recipes.Timeout), its photo download and
|
// the request: the page fetch (recipes.Timeout), its photo download and
|
||||||
// normalize_recipe (harness.DefaultTimeout), with some slack.
|
// normalize_recipe (harness.DefaultTimeout), with some slack.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
|||||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||||
t.Fatal(err)
|
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" {
|
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", got)
|
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 {
|
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||||
t.Fatal(err)
|
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" {
|
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", got)
|
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") {
|
if !tableExists(t, s, "dinners") {
|
||||||
t.Error("table from 0001_init was not created")
|
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)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ var repoParents = map[string]repoRow{
|
|||||||
}},
|
}},
|
||||||
"shopping_groups": {"shopping_groups", map[string]any{
|
"shopping_groups": {"shopping_groups", map[string]any{
|
||||||
"id": "group-1", "list_id": "list-1", "dinner_id": "dinner-1",
|
"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,
|
"title": "Supply run", "status": "current", "sent_to_chat_at": nil, "frozen_at": nil,
|
||||||
"created_at": repoTime, "updated_at": repoTime,
|
"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"},
|
parents: []string{"dinners", "week_plans", "shopping_lists"},
|
||||||
row: repoRow{"shopping_groups", map[string]any{
|
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,
|
"created_at": repoTime, "updated_at": repoTime,
|
||||||
}},
|
}},
|
||||||
update: map[string]any{"position": int64(3)},
|
update: map[string]any{"position": int64(3)},
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ func testRow(table string, i int) map[string]any {
|
|||||||
case "shopping_groups":
|
case "shopping_groups":
|
||||||
r["list_id"] = rowID("shopping_lists", 0)
|
r["list_id"] = rowID("shopping_lists", 0)
|
||||||
r["dinner_id"] = rowID("dinners", 0)
|
r["dinner_id"] = rowID("dinners", 0)
|
||||||
|
r["date"] = fmt.Sprintf("2026-01-%02d", i+5)
|
||||||
r["position"] = i
|
r["position"] = i
|
||||||
case "shopping_items":
|
case "shopping_items":
|
||||||
r["group_id"] = rowID("shopping_groups", 0)
|
r["group_id"] = rowID("shopping_groups", 0)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 = ?;
|
||||||
Reference in New Issue
Block a user