Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cd5ae3d8f | ||
|
|
01cd9a8741 | ||
|
|
3a4991cc8e | ||
|
|
5637bf872e | ||
|
|
f7ebc85862 | ||
|
|
9d91686c35 | ||
|
|
fe318bf982 | ||
|
|
c2a63192a6 |
+23
-10
@@ -95,8 +95,8 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
|
||||
| --- | --- |
|
||||
| `household_settings` | One row: timezone, dinner_time (17:30), reminder_time (16:30), review_time (09:00), plan_draft day/time (Fri 09:00), suggestions day/time (Sun 08:00), chat_autosend day/time (Sat 08:00), onboarding_completed_at, updates_seen_at. |
|
||||
| `recipes` | A real recipe page: source_url (unique), site_name, title, total/prep/cook minutes (the page's JSON-LD), yield, raw_jsonld, fetched_at; and its grounding: photo_id, method, difficulty, minutes_total (normalized prep to table). |
|
||||
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
|
||||
| `recipe_steps` | recipe_id, position, text. |
|
||||
| `recipe_ingredients` | recipe_id, position (unique per recipe: the recipe order), raw_text, name, qty, unit, note. |
|
||||
| `recipe_steps` | recipe_id, position (unique per recipe: the recipe order), text. |
|
||||
| `photos` | source_url, local_path, width, height, sha256, fetched_at. |
|
||||
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised whenever a dinner is renamed, so only the latest `ground_dinner` job may write it and no candidate offered before the rename grounds it), updated_by. |
|
||||
| `suggestion_batches` | trigger, status, ai_run_id. |
|
||||
@@ -105,7 +105,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
|
||||
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). |
|
||||
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. |
|
||||
| `shopping_lists` | week_start (unique), plan_id, title (the AI-written post title; NULL until written or when the AI fails, when the week label is shown instead; never empty), status (`current` / `archived`), sent_to_chat_at, chat_send_unconfirmed_at (a send to Google Chat recorded as begun before posting and cleared once its outcome is known; left set when it may or may not have posted), frozen_at. |
|
||||
| `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by `shopping.Generate` from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged. |
|
||||
| `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date), frozen_dinner_name (the dinner's name when its list was frozen into the Supply archive, kept whatever it is later renamed; NULL while the list is current, which shows its dinners' names now; never empty). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by `shopping.Generate` from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged. |
|
||||
| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
|
||||
| `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url (the SearXNG instance), api_key_ct and api_key_last4 (Brave). |
|
||||
| `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. |
|
||||
@@ -121,6 +121,16 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
|
||||
`recipes` holds a fetched recipe page; `dinners` holds the household's meal
|
||||
record and its lifecycle, optionally linked to a recipe and a photo.
|
||||
|
||||
Migration `0018_recipe_item_position_unique` makes position unique per
|
||||
recipe and fails, so the service refuses to start, on a database already
|
||||
holding a duplicate. Before deploying it, check the database; both queries
|
||||
must return no rows:
|
||||
|
||||
```sql
|
||||
SELECT recipe_id, position FROM recipe_ingredients GROUP BY 1, 2 HAVING count(*) > 1;
|
||||
SELECT recipe_id, position FROM recipe_steps GROUP BY 1, 2 HAVING count(*) > 1;
|
||||
```
|
||||
|
||||
## Dinner lifecycle
|
||||
|
||||
```
|
||||
@@ -215,11 +225,10 @@ automatic** (D-021).
|
||||
raw_text}]` and `steps` the step texts, both in recipe position order and
|
||||
empty (`[]`) with no recipe, as for a dinner whose grounding is
|
||||
`pending` or `failed`; a recipe entered by hand has only `raw_text`.
|
||||
Two items at one position leave recipe order unknown, so such a recipe
|
||||
is 500 rather than shown out of order. `source` is `{site_name, url}`
|
||||
of the page the recipe was fetched from, null for a manual recipe or
|
||||
none, as `dinners.SourceOf` gives it — the one rule for every fetched
|
||||
recipe's source, also used by idea results and recipe candidates. `url`
|
||||
`source` is `{site_name, url}` of the page the recipe was fetched from,
|
||||
null for a manual recipe or none, as `dinners.SourceOf` gives it — the
|
||||
one rule for every fetched recipe's source, also used by idea results
|
||||
and recipe candidates. `url`
|
||||
is the page's link only when it is free of secrets (https, no user,
|
||||
query or fragment), else null. `site_name` is the name the page gives
|
||||
its site (publisher or `og:site_name`), else the link's host without
|
||||
@@ -644,7 +653,10 @@ automatic** (D-021).
|
||||
|
||||
`shopping.Freeze(weekStart)` moves a week's Supply run into the Supply
|
||||
archive: `status` becomes `archived` and `frozen_at` is set, after which
|
||||
generating the list or changing an item is 409 `archived`. It freezes only
|
||||
generating the list or changing an item is 409 `archived`. In the same
|
||||
transaction each group keeps its dinner's name as `frozen_dinner_name`, so
|
||||
the frozen list and its post show the names they had when frozen, whatever
|
||||
a dinner is renamed later. It freezes only
|
||||
a week that has ended in the household's time zone (its Monday is before
|
||||
the Monday of the household's current week), so a week not yet ended is
|
||||
`ErrWeekNotEnded` and a week with no list `ErrListNotFound`; freezing a
|
||||
@@ -1196,7 +1208,8 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
Monday is a household-local date, so the week of Monday 28 December
|
||||
is in December. `label` is the week label ("Week 41 · Oct 5 – 11"; the
|
||||
UI sets it in capitals), `title` null while untitled, `dinners` the
|
||||
list's dinners in day order and `dinnerCount` their number,
|
||||
list's dinners in day order (as named when the list was frozen; the
|
||||
current week's as named now) and `dinnerCount` their number,
|
||||
`itemCount` all items, `onHandCount` the items marked "have it", and
|
||||
`current` true for the household's current week. 400 `invalid_request`
|
||||
for any other `by`. A frozen list is read through `GET
|
||||
|
||||
@@ -255,13 +255,14 @@ type Session struct {
|
||||
}
|
||||
|
||||
type ShoppingGroup struct {
|
||||
ID string
|
||||
ListID string
|
||||
DinnerID string
|
||||
Date string
|
||||
Position int64
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
ID string
|
||||
ListID string
|
||||
DinnerID string
|
||||
Date string
|
||||
Position int64
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
FrozenDinnerName sql.NullString
|
||||
}
|
||||
|
||||
type ShoppingItem struct {
|
||||
|
||||
@@ -107,6 +107,9 @@ type Querier interface {
|
||||
// Records a pending or running batch as done or failed; a batch already
|
||||
// finished is left as it is (0 rows).
|
||||
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
|
||||
// Keeps the name each dinner of list list_id has now as its group's
|
||||
// frozen_dinner_name, as the list is frozen into the Supply archive.
|
||||
FreezeShoppingGroupNames(ctx context.Context, listID string) error
|
||||
// Freezes list id into the Supply archive at frozen_at, unless it is
|
||||
// frozen already.
|
||||
FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error)
|
||||
@@ -228,7 +231,8 @@ type Querier interface {
|
||||
ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error)
|
||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||
// The groups of list list_id with their dinner's name, in list order.
|
||||
// The groups of list list_id with their dinner's name now, in list order.
|
||||
// A group of a frozen list shows its frozen_dinner_name instead.
|
||||
ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error)
|
||||
// The groups of list list_id, in list order.
|
||||
ListShoppingGroups(ctx context.Context, listID string) ([]ShoppingGroup, error)
|
||||
|
||||
@@ -220,7 +220,7 @@ func (q *Queries) GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (R
|
||||
const listRecipeIngredients = `-- name: ListRecipeIngredients :many
|
||||
SELECT id, recipe_id, position, raw_text, name, qty, unit, note, created_at, updated_at FROM recipe_ingredients
|
||||
WHERE recipe_id = ?
|
||||
ORDER BY position, id
|
||||
ORDER BY position
|
||||
`
|
||||
|
||||
func (q *Queries) ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error) {
|
||||
@@ -260,7 +260,7 @@ func (q *Queries) ListRecipeIngredients(ctx context.Context, recipeID string) ([
|
||||
const listRecipeSteps = `-- name: ListRecipeSteps :many
|
||||
SELECT id, recipe_id, position, text, created_at, updated_at FROM recipe_steps
|
||||
WHERE recipe_id = ?
|
||||
ORDER BY position, id
|
||||
ORDER BY position
|
||||
`
|
||||
|
||||
func (q *Queries) ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error) {
|
||||
|
||||
@@ -155,6 +155,19 @@ func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const freezeShoppingGroupNames = `-- name: FreezeShoppingGroupNames :exec
|
||||
UPDATE shopping_groups
|
||||
SET frozen_dinner_name = (SELECT dinners.name FROM dinners WHERE dinners.id = shopping_groups.dinner_id)
|
||||
WHERE list_id = ?
|
||||
`
|
||||
|
||||
// Keeps the name each dinner of list list_id has now as its group's
|
||||
// frozen_dinner_name, as the list is frozen into the Supply archive.
|
||||
func (q *Queries) FreezeShoppingGroupNames(ctx context.Context, listID string) error {
|
||||
_, err := q.db.ExecContext(ctx, freezeShoppingGroupNames, listID)
|
||||
return err
|
||||
}
|
||||
|
||||
const freezeShoppingList = `-- name: FreezeShoppingList :execrows
|
||||
UPDATE shopping_lists
|
||||
SET status = 'archived', frozen_at = ?, updated_at = ?
|
||||
@@ -235,7 +248,7 @@ func (q *Queries) GetShoppingList(ctx context.Context, weekStart string) (Shoppi
|
||||
}
|
||||
|
||||
const listShoppingGroupDinners = `-- name: ListShoppingGroupDinners :many
|
||||
SELECT shopping_groups.id, shopping_groups.list_id, shopping_groups.dinner_id, shopping_groups.date, shopping_groups.position, shopping_groups.created_at, shopping_groups.updated_at, dinners.name AS dinner_name
|
||||
SELECT shopping_groups.id, shopping_groups.list_id, shopping_groups.dinner_id, shopping_groups.date, shopping_groups.position, shopping_groups.created_at, shopping_groups.updated_at, shopping_groups.frozen_dinner_name, dinners.name AS dinner_name
|
||||
FROM shopping_groups
|
||||
JOIN dinners ON dinners.id = shopping_groups.dinner_id
|
||||
WHERE shopping_groups.list_id = ?
|
||||
@@ -247,7 +260,8 @@ type ListShoppingGroupDinnersRow struct {
|
||||
DinnerName string
|
||||
}
|
||||
|
||||
// The groups of list list_id with their dinner's name, in list order.
|
||||
// The groups of list list_id with their dinner's name now, in list order.
|
||||
// A group of a frozen list shows its frozen_dinner_name instead.
|
||||
func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listShoppingGroupDinners, listID)
|
||||
if err != nil {
|
||||
@@ -265,6 +279,7 @@ func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) (
|
||||
&i.ShoppingGroup.Position,
|
||||
&i.ShoppingGroup.CreatedAt,
|
||||
&i.ShoppingGroup.UpdatedAt,
|
||||
&i.ShoppingGroup.FrozenDinnerName,
|
||||
&i.DinnerName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -281,7 +296,7 @@ func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) (
|
||||
}
|
||||
|
||||
const listShoppingGroups = `-- name: ListShoppingGroups :many
|
||||
SELECT id, list_id, dinner_id, date, position, created_at, updated_at FROM shopping_groups
|
||||
SELECT id, list_id, dinner_id, date, position, created_at, updated_at, frozen_dinner_name FROM shopping_groups
|
||||
WHERE list_id = ?
|
||||
ORDER BY position, id
|
||||
`
|
||||
@@ -304,6 +319,7 @@ func (q *Queries) ListShoppingGroups(ctx context.Context, listID string) ([]Shop
|
||||
&i.Position,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FrozenDinnerName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -157,10 +157,7 @@ func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ingredients: %w", err)
|
||||
}
|
||||
for i, ing := range ings {
|
||||
if i > 0 && ing.Position == ings[i-1].Position {
|
||||
return duplicatePosition("ingredient", ing.Position)
|
||||
}
|
||||
for _, ing := range ings {
|
||||
d.Ingredients = append(d.Ingredients, Ingredient{
|
||||
Name: nullString(ing.Name),
|
||||
Qty: nullString(ing.Qty),
|
||||
@@ -173,10 +170,7 @@ func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("steps: %w", err)
|
||||
}
|
||||
for i, step := range steps {
|
||||
if i > 0 && step.Position == steps[i-1].Position {
|
||||
return duplicatePosition("step", step.Position)
|
||||
}
|
||||
for _, step := range steps {
|
||||
d.Steps = append(d.Steps, step.Text)
|
||||
}
|
||||
return nil
|
||||
@@ -280,11 +274,3 @@ func browserHost(u *url.URL) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// duplicatePosition is the error for two recipe items of kind at position.
|
||||
// Position is the only record of recipe order, and the id that breaks the
|
||||
// tie says nothing about it, so a recipe with a duplicate is refused rather
|
||||
// than shown in an order nobody can trust.
|
||||
func duplicatePosition(kind string, position int64) error {
|
||||
return fmt.Errorf("two %ss at position %d: recipe order is unknown", kind, position)
|
||||
}
|
||||
|
||||
@@ -72,8 +72,8 @@ func TestGetDinnerGroundedSideQuest(t *testing.T) {
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
`UPDATE dinners SET status = 'quest_log', origin = 'side_quest', is_favorite = 1 WHERE id = ?`,
|
||||
`UPDATE recipe_ingredients SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
|
||||
`UPDATE recipe_steps SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
|
||||
`UPDATE recipe_ingredients SET position = 9 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
|
||||
`UPDATE recipe_steps SET position = 9 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
|
||||
} {
|
||||
if _, err := e.st.Writer.Exec(stmt, id); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -492,25 +492,3 @@ func TestGetDinnerWhyPickedIsCreatingSuggestion(t *testing.T) {
|
||||
t.Errorf("why_picked = %v, want %q", got.WhyPicked, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDinnerDuplicatePositionFails: a recipe with two ingredients or two
|
||||
// steps at one position has no order anyone can trust, so its detail is a
|
||||
// 500 rather than the recipe in some order.
|
||||
func TestGetDinnerDuplicatePositionFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
for _, table := range []string{"recipe_ingredients", "recipe_steps"} {
|
||||
id := createDinner(t, e.h, `{"name":"Family chili `+table+`"}`)
|
||||
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
|
||||
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if _, err := e.st.Writer.Exec(`UPDATE `+table+` SET position = 0 WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
|
||||
if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "Simmer.") || strings.Contains(rec.Body.String(), "1 lb beef") {
|
||||
t.Errorf("GET with duplicate %s positions = %d: %s, want 500 and no recipe", table, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc,
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinnersOn(t, st), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -168,7 +168,12 @@ func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
|
||||
// with its own unstarted job runner and a grounder that finds nothing.
|
||||
func newTestDinners(t *testing.T) *dinners.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
return newTestDinnersOn(t, newTestStore(t))
|
||||
}
|
||||
|
||||
// newTestDinnersOn is newTestDinners over st.
|
||||
func newTestDinnersOn(t *testing.T, st *store.Store) *dinners.Service {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
|
||||
@@ -222,8 +222,9 @@ type archivePeriod struct {
|
||||
}
|
||||
|
||||
// archivePost is one week's Supply run in the Supply archive. label is
|
||||
// the week label, which stands in for title while it is null, and
|
||||
// onHandCount counts the items marked "have it".
|
||||
// the week label, which stands in for title while it is null,
|
||||
// onHandCount counts the items marked "have it", and frozen is false
|
||||
// while the list can still be changed from the Supply run.
|
||||
type archivePost struct {
|
||||
WeekStart string `json:"weekStart"`
|
||||
Label string `json:"label"`
|
||||
@@ -233,6 +234,7 @@ type archivePost struct {
|
||||
OnHandCount int `json:"onHandCount"`
|
||||
Dinners []string `json:"dinners"`
|
||||
Current bool `json:"current"`
|
||||
Frozen bool `json:"frozen"`
|
||||
}
|
||||
|
||||
// getSupplyArchive answers GET /shopping/archive?by=week|month|year with
|
||||
@@ -262,6 +264,7 @@ func getSupplyArchive(logger *slog.Logger, svc *shopping.Service) http.HandlerFu
|
||||
OnHandCount: post.OnHand,
|
||||
Dinners: post.Dinners,
|
||||
Current: post.Current,
|
||||
Frozen: post.Frozen,
|
||||
}
|
||||
}
|
||||
out.Periods[i] = archivePeriod{Key: p.Key, Heading: p.Heading, Posts: posts}
|
||||
|
||||
@@ -75,7 +75,7 @@ func (e plansEnv) groundWeek(t *testing.T) {
|
||||
func (e plansEnv) archive(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(),
|
||||
`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -720,6 +720,7 @@ type archiveJSON struct {
|
||||
OnHandCount int `json:"onHandCount"`
|
||||
Dinners []string `json:"dinners"`
|
||||
Current bool `json:"current"`
|
||||
Frozen bool `json:"frozen"`
|
||||
} `json:"posts"`
|
||||
} `json:"periods"`
|
||||
}
|
||||
@@ -798,7 +799,7 @@ func TestSupplyArchive(t *testing.T) {
|
||||
if k := keys(period); !reflect.DeepEqual(k, []string{"heading", "key", "posts"}) {
|
||||
t.Errorf("period fields = %v", k)
|
||||
}
|
||||
wantPost := []string{"current", "dinnerCount", "dinners", "itemCount", "label", "onHandCount", "title", "weekStart"}
|
||||
wantPost := []string{"current", "dinnerCount", "dinners", "frozen", "itemCount", "label", "onHandCount", "title", "weekStart"}
|
||||
if k := keys(period["posts"].([]any)[0].(map[string]any)); !reflect.DeepEqual(k, wantPost) {
|
||||
t.Errorf("post fields = %v, want %v", k, wantPost)
|
||||
}
|
||||
@@ -815,13 +816,13 @@ func TestSupplyArchive(t *testing.T) {
|
||||
if post.Label != label || post.Title != nil {
|
||||
t.Errorf("post %s: label %q title %v, want %q and null", post.WeekStart, post.Label, post.Title, label)
|
||||
}
|
||||
posts = append(posts, fmt.Sprintf("%s %d dinners %d items %d on hand %v current=%t",
|
||||
post.WeekStart, post.DinnerCount, post.ItemCount, post.OnHandCount, post.Dinners, post.Current))
|
||||
posts = append(posts, fmt.Sprintf("%s %d dinners %d items %d on hand %v current=%t frozen=%t",
|
||||
post.WeekStart, post.DinnerCount, post.ItemCount, post.OnHandCount, post.Dinners, post.Current, post.Frozen))
|
||||
}
|
||||
}
|
||||
want := []string{
|
||||
thisWeek.String() + " 1 dinners 2 items 0 on hand [Lemon Chicken] current=true",
|
||||
lastWeek.String() + " 1 dinners 3 items 1 on hand [Beef Stew] current=false",
|
||||
thisWeek.String() + " 1 dinners 2 items 0 on hand [Lemon Chicken] current=true frozen=false",
|
||||
lastWeek.String() + " 1 dinners 3 items 1 on hand [Beef Stew] current=false frozen=true",
|
||||
}
|
||||
if got.By != "week" || !reflect.DeepEqual(posts, want) {
|
||||
t.Errorf("archive by %s =\n %s\nwant\n %s", got.By, strings.Join(posts, "\n "), strings.Join(want, "\n "))
|
||||
@@ -852,6 +853,137 @@ func TestSupplyArchive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupplyArchiveUnfrozenPastWeek: a past week's Supply run that
|
||||
// week_rollover has not frozen is in the Supply archive as not frozen for
|
||||
// as long as the Supply run route can still change it, and as frozen once
|
||||
// it cannot.
|
||||
func TestSupplyArchiveUnfrozenPastWeek(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM plan_days; DELETE FROM week_plans`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := household.LoadZone("America/New_York") // the env's household zone
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
thisWeek := household.WeekStart(household.Today(time.Now(), zone))
|
||||
y, m, d := thisWeek.Date()
|
||||
lastWeek := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
|
||||
last := e.weekSupplyRun(t, lastWeek, "Beef Stew", shopIng{"stewing beef", "2", "lb"}, shopIng{"carrots", "4", ""})
|
||||
|
||||
// post is last week's post in the Supply archive.
|
||||
post := func() (frozen bool) {
|
||||
t.Helper()
|
||||
var got archiveJSON
|
||||
if status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got); status != http.StatusOK {
|
||||
t.Fatalf("GET archive = %d %v", status, raw)
|
||||
}
|
||||
for _, p := range got.Periods {
|
||||
for _, post := range p.Posts {
|
||||
if post.WeekStart == lastWeek.String() {
|
||||
if post.Current {
|
||||
t.Errorf("last week's post is current")
|
||||
}
|
||||
return post.Frozen
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("archive %+v has no post for %s", got, lastWeek)
|
||||
return false
|
||||
}
|
||||
// change marks carrots and reports whether the Supply run route took it.
|
||||
change := func(needed bool) bool {
|
||||
t.Helper()
|
||||
status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+last.itemID(t, "carrots"), fmt.Sprintf(`{"needed":%t}`, needed), nil)
|
||||
switch {
|
||||
case status == http.StatusOK:
|
||||
return true
|
||||
case status == http.StatusConflict && errorCode(body) == "archived":
|
||||
return false
|
||||
}
|
||||
t.Fatalf("PATCH = %d %v, want 200 or 409 archived", status, body)
|
||||
return false
|
||||
}
|
||||
|
||||
if frozen, changed := post(), change(false); frozen || !changed {
|
||||
t.Errorf("unfrozen past week: post frozen=%t, PATCH changed=%t; want a post not frozen for a list that changes", frozen, changed)
|
||||
}
|
||||
if err := e.shop.Freeze(t.Context(), lastWeek); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if frozen, changed := post(), change(true); !frozen || changed {
|
||||
t.Errorf("frozen past week: post frozen=%t, PATCH changed=%t; want a frozen post for a list that cannot change", frozen, changed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupplyArchiveKeepsFrozenNames freezes last week's Supply run, then
|
||||
// renames its dinner and this week's through the dinners API: the frozen
|
||||
// post and list keep the name the dinner had when frozen, while this
|
||||
// week's list, not frozen, shows the new name.
|
||||
func TestSupplyArchiveKeepsFrozenNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM plan_days; DELETE FROM week_plans`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := household.LoadZone("America/New_York") // the env's household zone
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
thisWeek := household.WeekStart(household.Today(time.Now(), zone))
|
||||
y, m, d := thisWeek.Date()
|
||||
lastWeek := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
last := e.weekSupplyRun(t, lastWeek, "Beef Stew", shopIng{"stewing beef", "2", "lb"})
|
||||
current := e.weekSupplyRun(t, thisWeek, "Lemon Chicken", shopIng{"lemon", "1", ""})
|
||||
if err := e.shop.Freeze(t.Context(), lastWeek); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for id, name := range map[string]string{
|
||||
last.Groups[0].Dinner.ID: "Sunday Beef Stew",
|
||||
current.Groups[0].Dinner.ID: "Lemon Garlic Chicken",
|
||||
} {
|
||||
if status, body := callShopping(t, e, http.MethodPatch, dinnersPath+"/"+id, `{"name":"`+name+`"}`, nil); status != http.StatusOK {
|
||||
t.Fatalf("PATCH dinner %s = %d %v", id, status, body)
|
||||
}
|
||||
}
|
||||
|
||||
var got archiveJSON
|
||||
if status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got); status != http.StatusOK {
|
||||
t.Fatalf("GET archive = %d %v", status, raw)
|
||||
}
|
||||
var posts []string
|
||||
for _, p := range got.Periods {
|
||||
for _, post := range p.Posts {
|
||||
posts = append(posts, fmt.Sprintf("%s %v current=%t", post.WeekStart, post.Dinners, post.Current))
|
||||
}
|
||||
}
|
||||
want := []string{
|
||||
thisWeek.String() + " [Lemon Garlic Chicken] current=true",
|
||||
lastWeek.String() + " [Beef Stew] current=false",
|
||||
}
|
||||
if !reflect.DeepEqual(posts, want) {
|
||||
t.Errorf("archive after rename =\n %s\nwant\n %s", strings.Join(posts, "\n "), strings.Join(want, "\n "))
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
week store.LocalDate
|
||||
want string
|
||||
}{
|
||||
{lastWeek, "Monday " + lastWeek.String() + " Beef Stew: stewing beef [2 lb] needed"},
|
||||
{thisWeek, "Monday " + thisWeek.String() + " Lemon Garlic Chicken: lemon [1] needed"},
|
||||
} {
|
||||
var l supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+tc.week.String(), "", &l); status != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d %v", tc.week, status, body)
|
||||
}
|
||||
if got := l.render(); !reflect.DeepEqual(got, []string{tc.want}) {
|
||||
t.Errorf("list %s after rename = %v, want [%s]", tc.week, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupplyRunSendChat posts the week's Supply run to Google Chat once,
|
||||
// records sent_to_chat_at, answers a second send with the status as it
|
||||
// is, and posts again only with resend.
|
||||
|
||||
@@ -22,7 +22,9 @@ const (
|
||||
|
||||
// Freeze moves the Supply run of the week starting weekStart into the
|
||||
// Supply archive: its status becomes archived and frozen_at is set, after
|
||||
// which it is read-only (Generate and SetNeeded are ErrFrozen). Freezing a
|
||||
// which it is read-only (Generate and SetNeeded are ErrFrozen). The list
|
||||
// keeps the names its dinners have as it is frozen, so its post in the
|
||||
// Supply archive shows them whatever a dinner is renamed later. Freezing a
|
||||
// frozen list does nothing and is no error.
|
||||
//
|
||||
// The week must have ended in the household's time zone: a week not yet
|
||||
@@ -79,14 +81,25 @@ func (s *Service) freeze(ctx context.Context, weekStart store.LocalDate, now tim
|
||||
"week", list.WeekStart, "list", list.ID, "error", err)
|
||||
}
|
||||
}
|
||||
// A list frozen meanwhile is left as it is: the freeze is done.
|
||||
// A list frozen meanwhile is left as it is, its names as they were
|
||||
// frozen: the freeze is done.
|
||||
stamp := store.Now().String()
|
||||
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
|
||||
FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
|
||||
}
|
||||
return nil
|
||||
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
n, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
|
||||
FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := q.FreezeShoppingGroupNames(ctx, list.ID); err != nil {
|
||||
return fmt.Errorf("shopping: freeze %s: keep dinner names: %w", weekStart, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// freezeTitleJob is the title job of list as Generate would enqueue it:
|
||||
@@ -145,13 +158,19 @@ type Post struct {
|
||||
Label string
|
||||
// Title is nil while the list is untitled, when Label stands in.
|
||||
Title *string
|
||||
// Dinners are the names of the list's dinners, in day order.
|
||||
// Dinners are the names of the list's dinners, in day order: as they
|
||||
// were when the list was frozen, or as they are now while it is not.
|
||||
Dinners []string
|
||||
// Items is how many items the list has, and OnHand how many of them
|
||||
// the household marked "have it".
|
||||
Items, OnHand int
|
||||
// Current is true for the household's current week.
|
||||
Current bool
|
||||
// Frozen is true once the list is frozen into the Supply archive and
|
||||
// can no longer change. A list not yet frozen — the current week's, or
|
||||
// a past week's that week_rollover has not frozen — can still be
|
||||
// changed from the Supply run.
|
||||
Frozen bool
|
||||
}
|
||||
|
||||
// Archive returns the Supply archive: the Supply run of every week up to
|
||||
@@ -208,7 +227,7 @@ func (s *Service) Archive(ctx context.Context, by string) (out []Period, err err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := postOf(monday, l, monday == thisWeek)
|
||||
p, err := postOf(monday, l, monday == thisWeek, frozen(row))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -223,7 +242,7 @@ func (s *Service) Archive(ctx context.Context, by string) (out []Period, err err
|
||||
|
||||
// postOf is l, the Supply run of the week starting monday, as a post in
|
||||
// the Supply archive.
|
||||
func postOf(monday store.LocalDate, l List, current bool) (Post, error) {
|
||||
func postOf(monday store.LocalDate, l List, current, isFrozen bool) (Post, error) {
|
||||
label, err := household.WeekLabel(monday)
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("shopping: Supply run %s: %w", l.ID, err)
|
||||
@@ -241,5 +260,6 @@ func postOf(monday store.LocalDate, l List, current bool) (Post, error) {
|
||||
Items: c.Total,
|
||||
OnHand: c.Total - c.Needed,
|
||||
Current: current,
|
||||
Frozen: isFrozen,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -381,6 +381,9 @@ func postLine(p shopping.Post) string {
|
||||
if p.Current {
|
||||
cur = " | current"
|
||||
}
|
||||
if !p.Frozen {
|
||||
cur += " | open"
|
||||
}
|
||||
return p.WeekStart + " " + p.Label + " | " + title + " | " + strings.Join(p.Dinners, ", ") + " | " +
|
||||
strconv.Itoa(p.Items) + " items, " + strconv.Itoa(p.OnHand) + " on hand" + cur
|
||||
}
|
||||
@@ -422,12 +425,12 @@ func TestArchiveGroups(t *testing.T) {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili, Noodles & Co' WHERE week_start = '2026-12-28'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2027-01-03T11:05:00.000Z' WHERE week_start < '2027-01-04'`); err != nil {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2027-01-03T11:05:00.000Z' WHERE week_start < '2027-01-04'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
posts := map[string]string{
|
||||
"jan": "2027-01-04 Week 1 · Jan 4 – 10 | - | Pad Thai, Beef Chili | 5 items, 1 on hand | current",
|
||||
"jan": "2027-01-04 Week 1 · Jan 4 – 10 | - | Pad Thai, Beef Chili | 5 items, 1 on hand | current | open",
|
||||
"dec": "2026-12-28 Week 53 · Dec 28 – Jan 3 | Chili, Noodles & Co | Beef Chili, Pad Thai | 5 items, 2 on hand",
|
||||
"d07": "2026-12-07 Week 50 · Dec 7 – 13 | - | Pad Thai | 2 items, 0 on hand",
|
||||
"nov": "2026-11-30 Week 49 · Nov 30 – Dec 6 | - | Pot Roast | 1 items, 1 on hand",
|
||||
|
||||
@@ -152,7 +152,7 @@ func TestSendToChatFailuresRecordNothing(t *testing.T) {
|
||||
t.Errorf("after failed sends sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want both unset", l.SentToChatAt, l.ChatSendUnconfirmedAt, err)
|
||||
}
|
||||
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
posts := len(e.chat.Posts())
|
||||
|
||||
@@ -492,7 +492,9 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, e
|
||||
return readList(ctx, q, list)
|
||||
}
|
||||
|
||||
// readList reads the groups and items of list through q.
|
||||
// readList reads the groups and items of list through q. A group of a
|
||||
// frozen list is named as its dinner was when the list was frozen; one
|
||||
// with no such name is an error.
|
||||
func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, error) {
|
||||
rows, err := q.ListShoppingGroupDinners(ctx, list.ID)
|
||||
if err != nil {
|
||||
@@ -512,8 +514,15 @@ func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, e
|
||||
groups := make([]Group, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
g := r.ShoppingGroup
|
||||
name := r.DinnerName
|
||||
if frozen(list) {
|
||||
if !g.FrozenDinnerName.Valid {
|
||||
return List{}, fmt.Errorf("shopping: group %s of frozen list %s has no frozen dinner name", g.ID, list.WeekStart)
|
||||
}
|
||||
name = g.FrozenDinnerName.String
|
||||
}
|
||||
groups = append(groups, Group{
|
||||
ID: g.ID, DinnerID: g.DinnerID, DinnerName: r.DinnerName, Date: g.Date, Position: int(g.Position),
|
||||
ID: g.ID, DinnerID: g.DinnerID, DinnerName: name, Date: g.Date, Position: int(g.Position),
|
||||
Items: byGroup[g.ID],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -692,7 +692,7 @@ func TestGenerateRefusesFrozenList(t *testing.T) {
|
||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.generate(t)
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.swap(t, "2026-10-12", "Pad Thai")
|
||||
@@ -834,7 +834,7 @@ func TestSetNeededRefusesFrozenList(t *testing.T) {
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
l := e.generate(t).List
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -239,7 +239,7 @@ func TestGenerateTitleNotStoredOnFrozenList(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||||
e.beforeTitle = func() {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,8 +177,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0020_site_name_from_page" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0020_site_name_from_page", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_recipe_item_position_unique,0019_shopping_group_frozen_name,0020_site_name_from_page" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_recipe_item_position_unique,0019_shopping_group_frozen_name,0020_site_name_from_page", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,8 +198,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0020_site_name_from_page" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0020_site_name_from_page", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_recipe_item_position_unique,0019_shopping_group_frozen_name,0020_site_name_from_page" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_recipe_item_position_unique,0019_shopping_group_frozen_name,0020_site_name_from_page", got)
|
||||
}
|
||||
if !tableExists(t, s, "dinners") {
|
||||
t.Error("table from 0001_init was not created")
|
||||
@@ -924,6 +924,149 @@ func TestMigrateShoppingListChatSendUnconfirmed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedRecipeItems inserts two recipes, each with an ingredient and a step
|
||||
// at position 0, then the extra statements.
|
||||
func seedRecipeItems(t *testing.T, s *Store, extra ...string) {
|
||||
t.Helper()
|
||||
stmts := []string{
|
||||
`INSERT INTO recipes (id, source_url, title, raw_jsonld, fetched_at, created_at, updated_at)
|
||||
VALUES ('recipe-1', 'https://example.com/chili', 'Chili', '{}', 'f', 'c', 'u')`,
|
||||
`INSERT INTO recipes (id, source_url, title, raw_jsonld, fetched_at, created_at, updated_at)
|
||||
VALUES ('recipe-2', 'https://example.com/curry', 'Curry', '{}', 'f', 'c', 'u')`,
|
||||
`INSERT INTO recipe_ingredients (id, recipe_id, position, raw_text, created_at, updated_at)
|
||||
VALUES ('ing-1', 'recipe-1', 0, '1 onion', 'c', 'u'), ('ing-2', 'recipe-2', 0, '1 lime', 'c', 'u')`,
|
||||
`INSERT INTO recipe_steps (id, recipe_id, position, text, created_at, updated_at)
|
||||
VALUES ('step-1', 'recipe-1', 0, 'Chop.', 'c', 'u'), ('step-2', 'recipe-2', 0, 'Squeeze.', 'c', 'u')`,
|
||||
}
|
||||
for _, stmt := range append(stmts, extra...) {
|
||||
if _, err := s.Writer.Exec(stmt); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateRecipeItemPositionUnique applies 0018 over two recipes with
|
||||
// items at the same position: each recipe keeps its items, and a second
|
||||
// ingredient or step at a position its recipe already uses is refused.
|
||||
func TestMigrateRecipeItemPositionUnique(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
migrateBefore(t, s, "0018")
|
||||
seedRecipeItems(t, s)
|
||||
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got string
|
||||
if err := s.Reader.QueryRowContext(ctx, `SELECT
|
||||
(SELECT group_concat(id || '@' || recipe_id || ':' || position, ',') FROM (SELECT * FROM recipe_ingredients ORDER BY id)) || ' / ' ||
|
||||
(SELECT group_concat(id || '@' || recipe_id || ':' || position, ',') FROM (SELECT * FROM recipe_steps ORDER BY id))`).Scan(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := "ing-1@recipe-1:0,ing-2@recipe-2:0 / step-1@recipe-1:0,step-2@recipe-2:0"; got != want {
|
||||
t.Errorf("recipe items after 0018 = %s, want %s", got, want)
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ name, stmt string }{
|
||||
{"second ingredient at a position", `INSERT INTO recipe_ingredients (id, recipe_id, position, raw_text, created_at, updated_at)
|
||||
VALUES ('ing-3', 'recipe-1', 0, '2 onions', 'c', 'u')`},
|
||||
{"second step at a position", `INSERT INTO recipe_steps (id, recipe_id, position, text, created_at, updated_at)
|
||||
VALUES ('step-3', 'recipe-1', 0, 'Dice.', 'c', 'u')`},
|
||||
{"ingredient moved onto another's position", `UPDATE recipe_ingredients SET recipe_id = 'recipe-1' WHERE id = 'ing-2'`},
|
||||
{"step moved onto another's position", `UPDATE recipe_steps SET recipe_id = 'recipe-1' WHERE id = 'step-2'`},
|
||||
} {
|
||||
if _, err := s.Writer.Exec(tc.stmt); err == nil || !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
t.Errorf("%s: error %v, want UNIQUE constraint failed", tc.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateRecipeItemPositionUniqueRefusesDuplicate applies 0018 over a
|
||||
// recipe already holding two ingredients, or two steps, at one position:
|
||||
// its order is unknown, so the migration fails and is not recorded.
|
||||
func TestMigrateRecipeItemPositionUniqueRefusesDuplicate(t *testing.T) {
|
||||
for _, tc := range []struct{ table, stmt string }{
|
||||
{"recipe_ingredients", `INSERT INTO recipe_ingredients (id, recipe_id, position, raw_text, created_at, updated_at)
|
||||
VALUES ('ing-3', 'recipe-1', 0, '2 onions', 'c', 'u')`},
|
||||
{"recipe_steps", `INSERT INTO recipe_steps (id, recipe_id, position, text, created_at, updated_at)
|
||||
VALUES ('step-3', 'recipe-1', 0, 'Dice.', 'c', 'u')`},
|
||||
} {
|
||||
t.Run(tc.table, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
migrateBefore(t, s, "0018")
|
||||
seedRecipeItems(t, s, tc.stmt)
|
||||
|
||||
err := Migrate(ctx, s.Writer, migrations.FS)
|
||||
if err == nil || !strings.Contains(err.Error(), "UNIQUE constraint failed: "+tc.table+".recipe_id, "+tc.table+".position") {
|
||||
t.Fatalf("Migrate = %v, want the duplicate position refused", err)
|
||||
}
|
||||
var n int
|
||||
if err := s.Reader.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations WHERE version = '0018_recipe_item_position_unique'`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("0018 recorded as applied")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateShoppingGroupFrozenName applies 0019 over a frozen list and
|
||||
// a current one: the frozen list's group takes its dinner's name now, the
|
||||
// current list's group has none, and a frozen name is never empty.
|
||||
func TestMigrateShoppingGroupFrozenName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
migrateBefore(t, s, "0019")
|
||||
for _, stmt := range []string{
|
||||
`INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at)
|
||||
VALUES ('dinner-1', 'Beef Stew', 'beef stew', 'regular', 'regular', 0, 'manual', 'c', 'u'),
|
||||
('dinner-2', 'Lemon Chicken', 'lemon chicken', 'regular', 'regular', 0, 'manual', 'c', 'u')`,
|
||||
`INSERT INTO week_plans (id, week_start, status, created_at, updated_at)
|
||||
VALUES ('plan-1', '2026-10-05', 'synced', 'c', 'u'),
|
||||
('plan-2', '2026-10-12', 'synced', 'c', 'u')`,
|
||||
`INSERT INTO shopping_lists (id, week_start, plan_id, status, frozen_at, created_at, updated_at)
|
||||
VALUES ('list-1', '2026-10-05', 'plan-1', 'archived', 'f', 'c', 'u'),
|
||||
('list-2', '2026-10-12', 'plan-2', 'current', NULL, 'c', 'u')`,
|
||||
`INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
|
||||
VALUES ('group-1', 'list-1', 'dinner-1', '2026-10-05', 0, 'c', 'u'),
|
||||
('group-2', 'list-2', 'dinner-2', '2026-10-12', 0, 'c', 'u')`,
|
||||
} {
|
||||
if _, err := s.Writer.Exec(stmt); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := s.Reader.QueryContext(ctx, `SELECT id || '|' || COALESCE(frozen_dinner_name, 'NULL') || '|' || updated_at FROM shopping_groups ORDER BY id`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var got []string
|
||||
for rows.Next() {
|
||||
var row string
|
||||
if err := rows.Scan(&row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"group-1|Beef Stew|u", "group-2|NULL|u"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("shopping groups after 0019 = %v, want %v", got, want)
|
||||
}
|
||||
if _, err := s.Writer.Exec(`UPDATE shopping_groups SET frozen_dinner_name = '' WHERE id = 'group-1'`); err == nil || !strings.Contains(err.Error(), "CHECK constraint failed") {
|
||||
t.Errorf("empty frozen name: error %v, want CHECK constraint failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateSiteNameFromPage applies 0020 over recipes and idea job
|
||||
// results stored when a page naming no site was stored under its host: a
|
||||
// stored name that could be a host — one with no space that is dotted, an
|
||||
|
||||
@@ -408,6 +408,8 @@ func TestSchemaUniqueConstraintsRejectDuplicates(t *testing.T) {
|
||||
{"notification_outbox", []string{"event_key", "channel"}},
|
||||
{"schedule_runs", []string{"schedule", "period_key"}},
|
||||
{"notification_prefs", []string{"event"}},
|
||||
{"recipe_ingredients", []string{"recipe_id", "position"}},
|
||||
{"recipe_steps", []string{"recipe_id", "position"}},
|
||||
}
|
||||
s := seededStore(t)
|
||||
for _, c := range cases {
|
||||
@@ -445,6 +447,8 @@ func TestSchemaCompositeUniqueAllowsPartialOverlap(t *testing.T) {
|
||||
{"same plan_date, different dinner", "rotation_reviews", "plan_date", "dinners", map[string]any{"dinner_id": rowID("dinners", 1)}},
|
||||
{"same event_key, different channel", "notification_outbox", "event_key", "", map[string]any{"channel": "chat"}},
|
||||
{"same period_key, different schedule", "schedule_runs", "period_key", "", map[string]any{"schedule": "week_rollover"}},
|
||||
{"same position, different recipe", "recipe_ingredients", "position", "recipes", map[string]any{"recipe_id": rowID("recipes", 1)}},
|
||||
{"same position, different recipe", "recipe_steps", "position", "recipes", map[string]any{"recipe_id": rowID("recipes", 1)}},
|
||||
}
|
||||
s := seededStore(t)
|
||||
for _, c := range cases {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 0018_recipe_item_position_unique: position is the only record of recipe
|
||||
-- order, so a recipe never has two ingredients or two steps at one
|
||||
-- position. Writers replace a recipe's items whole, numbered from 0, and
|
||||
-- the detail read orders by position alone. A database already holding a
|
||||
-- duplicate fails this migration rather than keep an order nobody can
|
||||
-- trust.
|
||||
|
||||
CREATE UNIQUE INDEX recipe_ingredients_recipe_position ON recipe_ingredients (recipe_id, position);
|
||||
CREATE UNIQUE INDEX recipe_steps_recipe_position ON recipe_steps (recipe_id, position);
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 0019_shopping_group_frozen_name: a Supply run frozen into the Supply
|
||||
-- archive keeps the names its dinners had when it was frozen. A dinner can
|
||||
-- be renamed at any time, so a frozen list reads each group's dinner name
|
||||
-- from frozen_dinner_name, never from the dinner, as 0012 does for a Pass.
|
||||
-- Only a group of a frozen list has one; a current list reads its dinners'
|
||||
-- names live. A list frozen before this takes its dinners' names now.
|
||||
|
||||
ALTER TABLE shopping_groups ADD COLUMN frozen_dinner_name TEXT CHECK (frozen_dinner_name <> '');
|
||||
|
||||
UPDATE shopping_groups
|
||||
SET frozen_dinner_name = (SELECT dinners.name FROM dinners WHERE dinners.id = shopping_groups.dinner_id)
|
||||
WHERE list_id IN (
|
||||
SELECT id FROM shopping_lists WHERE status = 'archived' OR frozen_at IS NOT NULL
|
||||
);
|
||||
@@ -48,7 +48,7 @@ INSERT INTO recipe_ingredients (
|
||||
-- name: ListRecipeIngredients :many
|
||||
SELECT * FROM recipe_ingredients
|
||||
WHERE recipe_id = ?
|
||||
ORDER BY position, id;
|
||||
ORDER BY position;
|
||||
|
||||
-- name: DeleteRecipeIngredients :exec
|
||||
DELETE FROM recipe_ingredients
|
||||
@@ -64,7 +64,7 @@ INSERT INTO recipe_steps (
|
||||
-- name: ListRecipeSteps :many
|
||||
SELECT * FROM recipe_steps
|
||||
WHERE recipe_id = ?
|
||||
ORDER BY position, id;
|
||||
ORDER BY position;
|
||||
|
||||
-- name: DeleteRecipeSteps :exec
|
||||
DELETE FROM recipe_steps
|
||||
|
||||
@@ -60,7 +60,8 @@ DELETE FROM shopping_items
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: ListShoppingGroupDinners :many
|
||||
-- The groups of list list_id with their dinner's name, in list order.
|
||||
-- The groups of list list_id with their dinner's name now, in list order.
|
||||
-- A group of a frozen list shows its frozen_dinner_name instead.
|
||||
SELECT sqlc.embed(shopping_groups), dinners.name AS dinner_name
|
||||
FROM shopping_groups
|
||||
JOIN dinners ON dinners.id = shopping_groups.dinner_id
|
||||
@@ -117,6 +118,13 @@ UPDATE shopping_lists
|
||||
SET status = 'archived', frozen_at = ?, updated_at = ?
|
||||
WHERE id = ? AND frozen_at IS NULL;
|
||||
|
||||
-- name: FreezeShoppingGroupNames :exec
|
||||
-- Keeps the name each dinner of list list_id has now as its group's
|
||||
-- frozen_dinner_name, as the list is frozen into the Supply archive.
|
||||
UPDATE shopping_groups
|
||||
SET frozen_dinner_name = (SELECT dinners.name FROM dinners WHERE dinners.id = shopping_groups.dinner_id)
|
||||
WHERE list_id = ?;
|
||||
|
||||
-- name: ListShoppingListsThrough :many
|
||||
-- The Supply runs of the weeks starting on or before week_start, newest
|
||||
-- week first.
|
||||
|
||||
@@ -237,6 +237,21 @@ function supplyRun(needed: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// makesSupplyRun answers POST /api/v1/shopping/2026-10-05/generate with
|
||||
// generate and, as the server does, GET of the week's Supply run with the
|
||||
// one made once generate has made it: until then the week has none.
|
||||
function makesSupplyRun(generate: () => Response): Record<string, Responder> {
|
||||
let made = false
|
||||
return {
|
||||
'GET /api/v1/shopping/2026-10-05': () => (made ? jsonResponse(200, supplyRun(28)) : errorResponse(404, 'not_found')),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => {
|
||||
const response = generate()
|
||||
made ||= response.ok
|
||||
return response
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so
|
||||
// the page opens on this week.
|
||||
beforeEach(() => {
|
||||
@@ -553,7 +568,7 @@ describe('PlanPage', () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => generate(),
|
||||
...makesSupplyRun(() => generate()),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
@@ -585,7 +600,7 @@ describe('PlanPage', () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
|
||||
...makesSupplyRun(() => jsonResponse(200, { ...supplyRun(28), skipped: [] })),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
@@ -670,7 +685,7 @@ describe('PlanPage', () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
|
||||
...makesSupplyRun(() => jsonResponse(200, { ...supplyRun(28), skipped: [] })),
|
||||
})
|
||||
const queryClient = createQueryClient()
|
||||
await renderPage(queryClient, <SupplyRunScreen />)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { mockViewport } from '../test/matchMedia.ts'
|
||||
import type { ArchivePost } from './api.ts'
|
||||
import { PAGE, SupplyArchivePage } from './SupplyArchivePage.tsx'
|
||||
import styles from './SupplyArchivePage.module.css'
|
||||
import { SupplyRunPage } from './SupplyRunPage.tsx'
|
||||
|
||||
const ARCHIVE = (by: string) => `/api/v1/shopping/archive?by=${by}`
|
||||
|
||||
@@ -24,13 +25,14 @@ function post(weekStart: string, label: string, overrides: Partial<ArchivePost>
|
||||
onHandCount: 4,
|
||||
dinners: ['Beef tacos', 'Tikka masala', 'Pizza', 'Pot roast', 'Orzo', 'Chili', 'Salmon'],
|
||||
current: false,
|
||||
frozen: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// The posts of mock 1m: this week's titled post, then the weeks across the
|
||||
// September–October boundary and the 2025–2026 one, the last untitled.
|
||||
const WEEK_41 = post('2026-10-05', 'Week 41 · Oct 5 – 11', { title: 'Tacos, tikka & a salmon finale', current: true })
|
||||
const WEEK_41 = post('2026-10-05', 'Week 41 · Oct 5 – 11', { title: 'Tacos, tikka & a salmon finale', current: true, frozen: false })
|
||||
const WEEK_40 = post('2026-09-28', 'Week 40 · Sep 28 – Oct 4', { title: 'Pizza, pot roast & the great orzo trial', itemCount: 26 })
|
||||
const WEEK_39 = post('2026-09-21', 'Week 39 · Sep 21 – 27', { title: 'Chili season opens', dinnerCount: 1, itemCount: 1, onHandCount: 0, dinners: ['Chili'] })
|
||||
const WEEK_1 = post('2025-12-29', 'Week 1 · Dec 29 – Jan 4')
|
||||
@@ -83,8 +85,9 @@ function stubServer(responders: Record<string, Responder>) {
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token any write would need, so a
|
||||
// missing PATCH is not for want of it) and renders the Supply archive at path.
|
||||
async function renderPage(path = '/plan/shopping/archive') {
|
||||
// missing PATCH is not for want of it) and renders the Supply archive at path,
|
||||
// with supplyRun at the Supply run's route.
|
||||
async function renderPage(path = '/plan/shopping/archive', supplyRun = <p>Supply run screen</p>) {
|
||||
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
@@ -93,7 +96,7 @@ async function renderPage(path = '/plan/shopping/archive') {
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="/plan/shopping/archive" element={<SupplyArchivePage />} />
|
||||
<Route path="/plan/shopping" element={<p>Supply run screen</p>} />
|
||||
<Route path="/plan/shopping" element={supplyRun} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
@@ -137,7 +140,7 @@ describe('SupplyArchivePage', () => {
|
||||
expect(within(postCard('Chili season opens')).queryByText(/^\+/)).not.toBeInTheDocument()
|
||||
|
||||
expect(screen.getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([
|
||||
'/plan/shopping/archive?week=2026-10-05',
|
||||
'/plan/shopping?week=2026-10-05',
|
||||
'/plan/shopping/archive?week=2026-09-28',
|
||||
'/plan/shopping/archive?week=2026-09-21',
|
||||
'/plan/shopping/archive?week=2025-12-29',
|
||||
@@ -154,7 +157,7 @@ describe('SupplyArchivePage', () => {
|
||||
for (const period of BY.week) {
|
||||
const section = screen.getByRole('region', { name: period.heading })
|
||||
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
|
||||
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
|
||||
period.posts.map((p) => (p.frozen ? `/plan/shopping/archive?week=${p.weekStart}` : `/plan/shopping?week=${p.weekStart}`)),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -182,7 +185,7 @@ describe('SupplyArchivePage', () => {
|
||||
for (const period of BY[by]) {
|
||||
const section = screen.getByRole('region', { name: period.heading })
|
||||
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
|
||||
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
|
||||
period.posts.map((p) => (p.frozen ? `/plan/shopping/archive?week=${p.weekStart}` : `/plan/shopping?week=${p.weekStart}`)),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -366,38 +369,66 @@ describe('SupplyArchivePage frozen post', () => {
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the current week’s post read-only too, tagged Archived, with no checkbox, nothing to send and no PATCH', async () => {
|
||||
const fetchMock = stubServer({
|
||||
it('opens the current week’s post, not yet frozen, in the Supply run, where it can still change', async () => {
|
||||
stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: archive('week'),
|
||||
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
|
||||
})
|
||||
await renderPage()
|
||||
await renderPage('/plan/shopping/archive', <SupplyRunPage />)
|
||||
|
||||
fireEvent.click(await screen.findByText('Tacos, tikka & a salmon finale'))
|
||||
|
||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
||||
expect(screen.getByText("This week's list as it stands, read-only in the Supply archive.")).toBeInTheDocument()
|
||||
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
fireEvent.click(screen.getByText('Corn tortillas'))
|
||||
fireEvent.click(screen.getByText('Limes'))
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
|
||||
expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeEnabled()
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('on desktop opens the current week’s post read-only, and with no post open shows this week’s Supply run to change', async () => {
|
||||
it('marks a past week’s post not yet frozen Still open and opens it in the Supply run, where it can still change', async () => {
|
||||
const open = { ...WEEK_40, frozen: false }
|
||||
stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'week', periods: [{ key: '2026-W40', heading: open.label, posts: [open] }] }),
|
||||
'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, { ...FROZEN, status: 'current' }),
|
||||
})
|
||||
await renderPage('/plan/shopping/archive', <SupplyRunPage />)
|
||||
|
||||
await screen.findByText('Pizza, pot roast & the great orzo trial')
|
||||
const card = postCard('Pizza, pot roast & the great orzo trial')
|
||||
expect(within(card).getByText('Week 40 · Sep 28 – Oct 4 · Still open')).toHaveClass(styles.kicker!)
|
||||
expect(card).not.toHaveClass(styles.current!)
|
||||
expect(card).toHaveAttribute('href', '/plan/shopping?week=2026-09-28')
|
||||
|
||||
fireEvent.click(card)
|
||||
|
||||
expect(await screen.findByRole('checkbox', { name: 'Pizza dough' })).toBeEnabled()
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks no frozen post Still open', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
|
||||
await renderPage()
|
||||
await screen.findByText('Chili season opens')
|
||||
expect(screen.queryByText(/Still open/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens a saved link to a past week’s list not yet frozen in the editable Supply run, not read-only', async () => {
|
||||
stubServer({ 'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, { ...FROZEN, status: 'current' }) })
|
||||
await renderPage('/plan/shopping/archive?week=2026-09-28', <SupplyRunPage />)
|
||||
|
||||
expect(await screen.findByRole('checkbox', { name: 'Pizza dough' })).toBeEnabled()
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
expect(screen.getByText("Uncheck anything you already have — it won't be counted.")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('on desktop opens a saved link to the current week’s list, not yet frozen, in the editable Supply run, as with no post open', async () => {
|
||||
mockViewport(1440)
|
||||
const fetchMock = stubServer({
|
||||
stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: archive('week'),
|
||||
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
|
||||
})
|
||||
const view = await renderPage('/plan/shopping/archive?week=2026-10-05')
|
||||
const view = await renderPage('/plan/shopping/archive?week=2026-10-05', <SupplyRunPage />)
|
||||
|
||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
||||
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
expect(screen.queryByText('Send to Google Chat')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Corn tortillas'))
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
|
||||
expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeEnabled()
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('complementary', { name: 'Supply archive' })).not.toBeInTheDocument()
|
||||
view.unmount()
|
||||
|
||||
await renderPage()
|
||||
|
||||
@@ -9,9 +9,9 @@ import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Segmented } from '../ui/Segmented.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { useSupplyArchive, WEEK_PARAM, type ArchiveGroup, type ArchivePeriod, type ArchivePost } from './api.ts'
|
||||
import { supplyRunScreen, useSupplyArchive, WEEK_PARAM, type ArchiveGroup, type ArchivePeriod, type ArchivePost } from './api.ts'
|
||||
import styles from './SupplyArchivePage.module.css'
|
||||
import { SUPPLY_ARCHIVE, SupplyRunPage } from './SupplyRunPage.tsx'
|
||||
import { SUPPLY_ARCHIVE, SUPPLY_RUN, SupplyRunPage } from './SupplyRunPage.tsx'
|
||||
|
||||
// PAGE is how many posts the Supply archive shows at first, and how many
|
||||
// more each "Load older" shows.
|
||||
@@ -38,15 +38,20 @@ function plural(n: number, one: string, many: string): string {
|
||||
|
||||
// PostCard is one week's Supply run (mock 1m): the week label as its kicker,
|
||||
// the AI's title — the week label while it has none — the counts and its
|
||||
// first dinners as chips. The current week's post is ringed in grape. It
|
||||
// opens the week's list, read-only.
|
||||
// first dinners as chips. The current week's post is ringed in grape. A
|
||||
// frozen post opens the week's list here, read-only; a post not yet frozen —
|
||||
// this week's, or a past week's the week rollover has not frozen — is marked
|
||||
// Current or Still open and opens the Supply run, where it can still change.
|
||||
function PostCard({ post }: { post: ArchivePost }) {
|
||||
const rest = post.dinners.length - CHIPS
|
||||
return (
|
||||
<Link to={archivePost(post.weekStart)} className={[styles.post, post.current && styles.current].filter(Boolean).join(' ')}>
|
||||
<Link
|
||||
to={post.frozen ? archivePost(post.weekStart) : supplyRunScreen(post.weekStart)}
|
||||
className={[styles.post, post.current && styles.current].filter(Boolean).join(' ')}
|
||||
>
|
||||
<span className={styles.kicker}>
|
||||
{post.label}
|
||||
{post.current && ' · Current'}
|
||||
{post.current ? ' · Current' : !post.frozen && ' · Still open'}
|
||||
</span>
|
||||
<span className={styles.postTitle}>{post.title ?? post.label}</span>
|
||||
<span className={styles.meta}>
|
||||
@@ -145,7 +150,7 @@ function SupplyArchive({ panel }: { panel: boolean }) {
|
||||
<div className={styles.archive}>
|
||||
<header className={styles.header}>
|
||||
{!panel && (
|
||||
<IconButton label="Back to the Supply run" onClick={() => void navigate('/plan/shopping')}>
|
||||
<IconButton label="Back to the Supply run" onClick={() => void navigate(SUPPLY_RUN)}>
|
||||
<Icon icon={ChevronLeft} size="nav" />
|
||||
</IconButton>
|
||||
)}
|
||||
@@ -157,10 +162,10 @@ function SupplyArchive({ panel }: { panel: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
// SupplyArchivePage is /plan/shopping/archive. A post opens it on its week
|
||||
// (?week=), whose list the Supply run screen shows read-only and tagged
|
||||
// Archived, whatever its status: even the current week's post is only
|
||||
// viewed here, never changed. On desktop (mock 2f) the archive is a panel at
|
||||
// SupplyArchivePage is /plan/shopping/archive. A frozen post opens it on its
|
||||
// week (?week=), whose list the Supply run screen shows read-only and tagged
|
||||
// Archived; a post not yet frozen opens the Supply run instead, as does a
|
||||
// ?week= visit to a list not yet frozen. On desktop (mock 2f) the archive is a panel at
|
||||
// the right of that list, this week's editable Supply run when no post is
|
||||
// open; on mobile and tablet it is the archive or, with a post open, the
|
||||
// post's list.
|
||||
|
||||
@@ -99,12 +99,12 @@ function writes(fetchMock: Mock<typeof fetch>) {
|
||||
|
||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||
// the Supply run at path.
|
||||
async function renderPage(path = '/plan/shopping') {
|
||||
async function renderPage(path = '/plan/shopping', queryClient = createQueryClient()) {
|
||||
const api = createApiClient(fakeLocation('/plan/shopping'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<SupplyRunPage />
|
||||
</MemoryRouter>
|
||||
@@ -563,9 +563,13 @@ describe('SupplyRunPage', () => {
|
||||
})
|
||||
|
||||
it('offers to make the list when the week has none, and shows the list made', async () => {
|
||||
let made = false
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
[`POST ${GENERATE}`]: () => jsonResponse(200, { ...supplyRun(), skipped: [] }),
|
||||
[`GET ${THIS_WEEK}`]: () => (made ? jsonResponse(200, supplyRun()) : errorResponse(404, 'not_found')),
|
||||
[`POST ${GENERATE}`]: () => {
|
||||
made = true
|
||||
return jsonResponse(200, { ...supplyRun(), skipped: [] })
|
||||
},
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
@@ -575,6 +579,50 @@ describe('SupplyRunPage', () => {
|
||||
expect(writes(fetchMock)).toEqual([`POST ${GENERATE} undefined`])
|
||||
})
|
||||
|
||||
it('keeps the list made when a read sent before it was made answers after it', async () => {
|
||||
const queryClient = createQueryClient()
|
||||
const made = deferred()
|
||||
const late = deferred()
|
||||
const again = deferred()
|
||||
let reads = 0
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => {
|
||||
reads++
|
||||
if (reads === 1) return errorResponse(404, 'not_found')
|
||||
return reads === 2 ? late.response : again.response
|
||||
},
|
||||
[`POST ${GENERATE}`]: () => made.response,
|
||||
})
|
||||
await renderPage('/plan/shopping', queryClient)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
|
||||
// A read of the week sent while the list is being made, so from before it.
|
||||
void queryClient.invalidateQueries({ queryKey: ['shopping', '2026-10-05'] })
|
||||
await waitFor(() => expect(reads).toBe(2))
|
||||
made.send(jsonResponse(200, { ...supplyRun(), skipped: [] }))
|
||||
expect(await screen.findByText('4 / 6')).toBeInTheDocument()
|
||||
// The list made is written, and the week read again from the server.
|
||||
await waitFor(() => expect(reads).toBe(3))
|
||||
|
||||
// The read from before the list was made answers only now.
|
||||
late.send(errorResponse(404, 'not_found'))
|
||||
await late.response
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(screen.getByText('4 / 6')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'No shopping list yet' })).not.toBeInTheDocument()
|
||||
|
||||
// The read sent after it was made is the one shown: by then the
|
||||
// household has the ground beef.
|
||||
const groups = GROUPS.map((group, g) => ({
|
||||
...group,
|
||||
items: group.items.map((item, i) => (g === 0 && i === 0 ? { ...item, needed: false } : item)),
|
||||
}))
|
||||
again.send(jsonResponse(200, supplyRun({ needed_count: 3, groups })))
|
||||
expect(await screen.findByText('3 / 6')).toBeInTheDocument()
|
||||
expect(reads).toBe(3)
|
||||
expect(writes(fetchMock)).toEqual([`POST ${GENERATE} undefined`])
|
||||
})
|
||||
|
||||
it('says why the list could not be made', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router'
|
||||
import { Link, Navigate, useSearchParams } from 'react-router'
|
||||
import { defaultWeek, isMonday, localNow, weekdayKicker, weekLabel } from '../plan/week.ts'
|
||||
import { ApiError } from '../api/errors.ts'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
@@ -15,6 +15,7 @@ import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import {
|
||||
useGenerateSupplyRun,
|
||||
supplyRunScreen,
|
||||
useSendToChat,
|
||||
useSupplyRun,
|
||||
useToggles,
|
||||
@@ -26,6 +27,9 @@ import {
|
||||
import { keep, savedSchedule, useSavedProblem } from './offline.ts'
|
||||
import styles from './SupplyRunPage.module.css'
|
||||
|
||||
// SUPPLY_RUN is the Supply run screen.
|
||||
export const SUPPLY_RUN = '/plan/shopping'
|
||||
|
||||
// SUPPLY_ARCHIVE is the Supply archive screen.
|
||||
export const SUPPLY_ARCHIVE = '/plan/shopping/archive'
|
||||
|
||||
@@ -143,10 +147,10 @@ function ArchiveLink({ layout }: { layout: Breakpoint }) {
|
||||
// tablet up, where Archive and Send to Google Chat are actions in the
|
||||
// header; a send that fails says why, and one whose outcome is not known
|
||||
// offers no retry and leaves the status line saying so. On a current Supply run each item toggles at once (useToggles); a
|
||||
// toggle refused puts the item back and says why. An archived one, or any
|
||||
// opened from the Supply archive (readOnly), is read-only and tagged
|
||||
// Archived, and a toggle it refused is not offered again.
|
||||
function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
||||
// toggle refused puts the item back and says why. An archived one is
|
||||
// read-only and tagged Archived, and a toggle it refused is not offered
|
||||
// again.
|
||||
function SupplyRunList({ weekStart, timezone, layout }: Omit<WeekProps, 'readOnly'>) {
|
||||
const toggles = useToggles(weekStart)
|
||||
const send = useSendToChat(weekStart)
|
||||
const run = toggles.shown
|
||||
@@ -157,7 +161,7 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
||||
<UnconfirmedStatus at={run.chat_send_unconfirmed_at} timezone={timezone} />
|
||||
)
|
||||
const sendUnconfirmed = send.error instanceof ApiError && send.error.code === 'chat_unconfirmed'
|
||||
const viewOnly = readOnly || run.status === 'archived'
|
||||
const viewOnly = run.status === 'archived'
|
||||
const wide = layout !== 'mobile'
|
||||
const failed = toggles.failed
|
||||
const failedItem = failed && run.groups.flatMap((group) => group.items).find((item) => item.id === failed.toggle.id)
|
||||
@@ -175,11 +179,9 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
||||
</p>
|
||||
</div>
|
||||
<p className={styles.helper}>
|
||||
{run.status === 'archived'
|
||||
{viewOnly
|
||||
? 'This list is in the Supply archive, as it was when the week ended.'
|
||||
: viewOnly
|
||||
? "This week's list as it stands, read-only in the Supply archive."
|
||||
: "Uncheck anything you already have — it won't be counted."}
|
||||
: "Uncheck anything you already have — it won't be counted."}
|
||||
</p>
|
||||
</div>
|
||||
{wide && (
|
||||
@@ -240,7 +242,8 @@ type WeekProps = { weekStart: string; timezone: string; layout: Breakpoint; read
|
||||
|
||||
// WeekSupplyRun is the Supply run of the week starting on weekStart, or,
|
||||
// when the week has none yet, the offer to make it from the week's plan —
|
||||
// not offered read-only.
|
||||
// not offered read-only. Read-only, a Supply run not yet frozen opens the
|
||||
// editable Supply run screen instead, where it can still change.
|
||||
function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
||||
const run = useSupplyRun(weekStart)
|
||||
const generate = useGenerateSupplyRun(weekStart)
|
||||
@@ -267,14 +270,16 @@ function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} readOnly={readOnly} />
|
||||
if (readOnly && run.data.status !== 'archived') return <Navigate to={supplyRunScreen(weekStart)} replace />
|
||||
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} />
|
||||
}
|
||||
|
||||
// SupplyRunPage is the Supply run (mock 1l on mobile, 2b on tablet, 2f on
|
||||
// desktop) of the week the Plan opens on — next week once this week's
|
||||
// plan_draft time has come, otherwise this week — or of the week chosen by
|
||||
// ?week=. Opened from the Supply archive it is readOnly: tagged Archived,
|
||||
// with no checkbox and nothing to make or send, whatever the week. Offline,
|
||||
// ?week=. Opened from the Supply archive it is readOnly: a frozen Supply run
|
||||
// is tagged Archived, with no checkbox and nothing to make or send; one not
|
||||
// yet frozen opens the editable Supply run screen instead. Offline,
|
||||
// where the settings cannot be read, it chooses the week by the schedule
|
||||
// saved on this device.
|
||||
export function SupplyRunPage({ readOnly = false }: { readOnly?: boolean }) {
|
||||
|
||||
+17
-4
@@ -194,18 +194,26 @@ export function useSupplyRun(weekStart: string) {
|
||||
// /api/v1/shopping/{weekStart}/generate); the Supply run it answers with is
|
||||
// the one shown. Once made, from whichever screen, a push of the week whose
|
||||
// Supply run was not made is settled (settleUnmadePushes).
|
||||
//
|
||||
// A read of the week's Supply run still in flight as the answer arrives may
|
||||
// have been sent before the Supply run was made: it is cancelled before the
|
||||
// answer is written, so it cannot land over it, and the week is then read
|
||||
// again from the server.
|
||||
export function useGenerateSupplyRun(weekStart: string) {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
const key = supplyRunKey(weekStart)
|
||||
return useMutation({
|
||||
onMutate: () => settleUnmadePushes(queryClient, weekStart),
|
||||
mutationFn: async (): Promise<SupplyRun> => {
|
||||
const path = `${supplyRunPath(weekStart)}/generate`
|
||||
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
|
||||
},
|
||||
onSuccess: (run, _, settle) => {
|
||||
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
|
||||
onSuccess: async (run, _, settle) => {
|
||||
await queryClient.cancelQueries({ queryKey: key })
|
||||
queryClient.setQueryData<SupplyRun | null>(key, run)
|
||||
settle()
|
||||
void queryClient.invalidateQueries({ queryKey: key })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -537,7 +545,9 @@ export type ArchivePeriod = (typeof ARCHIVE_PERIODS)[number]
|
||||
// ArchivePost is one week's Supply run in the Supply archive: label is the
|
||||
// week label, "Week 41 · Oct 5 – 11", which stands in for title while it is
|
||||
// null; onHandCount counts the items marked "have it", and dinners names the
|
||||
// dinners in day order. current is true for the household's current week.
|
||||
// dinners in day order. current is true for the household's current week,
|
||||
// and frozen once the list is frozen into the Supply archive: until then it
|
||||
// can still be changed from the Supply run.
|
||||
export type ArchivePost = {
|
||||
weekStart: string
|
||||
label: string
|
||||
@@ -547,6 +557,7 @@ export type ArchivePost = {
|
||||
onHandCount: number
|
||||
dinners: string[]
|
||||
current: boolean
|
||||
frozen: boolean
|
||||
}
|
||||
|
||||
// ArchiveGroup is one period of the Supply archive — a week, a month
|
||||
@@ -561,7 +572,8 @@ function parsePost(where: string, body: unknown): ArchivePost {
|
||||
(body.title !== null && typeof body.title !== 'string') ||
|
||||
!Array.isArray(body.dinners) ||
|
||||
!body.dinners.every((name) => typeof name === 'string') ||
|
||||
typeof body.current !== 'boolean'
|
||||
typeof body.current !== 'boolean' ||
|
||||
typeof body.frozen !== 'boolean'
|
||||
) {
|
||||
throw new Error(`${where}: response has a post that is not a Supply archive post`)
|
||||
}
|
||||
@@ -574,6 +586,7 @@ function parsePost(where: string, body: unknown): ArchivePost {
|
||||
onHandCount: count(where, 'onHandCount', body.onHandCount),
|
||||
dinners: body.dinners as string[],
|
||||
current: body.current,
|
||||
frozen: body.frozen,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user