Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 7b0a63e1b5 Fix the 0019 migration header comment (task 22f80eb6)
CI / ci (push) Successful in 8m23s
CI / ci (pull_request) Successful in 9m11s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:37:16 -04:00
dev_agentandClaude Opus 5.5 2feaced7c9 Renumber the frozen-name migration to 0019 (task 22f80eb6)
CI / ci (push) Successful in 8m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:29:32 -04:00
dev_agentandClaude Opus 5.5 a72ef1cbdd Keep dinner names frozen in Supply archive posts
CI / ci (push) Successful in 8m17s
Freezing a Supply run now records each group's dinner name in
shopping_groups.frozen_dinner_name, in the same transaction as the
freeze. A frozen list and its Supply archive post read that name, so
renaming a dinner after its week is frozen no longer rewrites the
archive; a current list still shows its dinners' names live.
Migration 0018 adds the column and fills it for lists frozen before it.

Task: 22f80eb6-cdee-4377-80ea-9b28f15a866f

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:18:54 -04:00
orchestrator-agent 791b321b71 Expose the Supply run title job and cover the replacement race (#194)
CI / ci (push) Successful in 8m9s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 08:24:51 -04:00
orchestrator-agent be41b56690 Make login OAuth state single-use (#193)
CI / ci (push) Successful in 8m6s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 02:13:34 -04:00
orchestrator-agent 3a39fc8f2c Plan-screen deferred hardening (#192)
CI / ci (push) Successful in 9m29s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 01:21:55 -04:00
orchestrator-agent d8633d56a4 Embed the Regular dinners screen in the desktop Settings tab (#191)
CI / ci (push) Successful in 8m15s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 00:54:54 -04:00
orchestrator-agent 9046b6df78 Harden make sqlc-check against a failing git status (#190)
CI / ci (push) Successful in 8m20s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 00:25:04 -04:00
33 changed files with 1009 additions and 192 deletions
+3 -2
View File
@@ -82,7 +82,8 @@ sqlc:
$(SQLC) generate $(SQLC) generate
# Fails on any difference from the committed generated code, including new # Fails on any difference from the committed generated code, including new
# or deleted files. # or deleted files. A failing git status is itself a failure: its empty
# output would otherwise read as "no drift".
sqlc-check: sqlc sqlc-check: sqlc
@drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))"; \ @drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))" || { echo "git status failed; cannot check internal/db for drift"; exit 1; }; \
if [ -n "$$drift" ]; then echo "internal/db is stale; run make sqlc and commit:"; echo "$$drift"; git diff -- $(SQLC_OUT); exit 1; fi if [ -n "$$drift" ]; then echo "internal/db is stale; run make sqlc and commit:"; echo "$$drift"; git diff -- $(SQLC_OUT); exit 1; fi
+17 -6
View File
@@ -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). | | `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 (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_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. | | `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. |
@@ -635,7 +635,10 @@ automatic** (D-021).
`shopping.Freeze(weekStart)` moves a week's Supply run into the Supply `shopping.Freeze(weekStart)` moves a week's Supply run into the Supply
archive: `status` becomes `archived` and `frozen_at` is set, after which 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 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 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 `ErrWeekNotEnded` and a week with no list `ErrListNotFound`; freezing a
@@ -1120,7 +1123,10 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
- The week's Supply run (Feature 17) is made from a pushed plan: after - The week's Supply run (Feature 17) is made from a pushed plan: after
a successful `Push`, the handler runs `shopping.GeneratePushed` for a successful `Push`, the handler runs `shopping.GeneratePushed` for
the week at the plan revision `Push` pushed, and the 200 answer the week at the plan revision `Push` pushed, and the 200 answer
carries `supply_run: {status, error}`. `status` is `generated`; carries `supply_run: {status, error, title_job_id}`. `title_job_id` is
the `title_supply_run` job titling a generated list, as for `POST
/shopping/{weekStart}/generate`; null when the list already has its
title or was not generated. `status` is `generated`;
`archived` when the week's Supply run is in the Supply archive and was `archived` when the week's Supply run is in the Supply archive and was
left as it was; or `failed` — error `plan_changed` (retryable, logged left as it was; or `failed` — error `plan_changed` (retryable, logged
as a warning) when the plan was edited between the push and as a warning) when the plan was edited between the push and
@@ -1143,8 +1149,12 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
when the week has no Supply run, 400 `invalid_week`. when the week has no Supply run, 400 `invalid_week`.
- `POST /api/v1/shopping/{weekStart}/generate` makes or remakes the list - `POST /api/v1/shopping/{weekStart}/generate` makes or remakes the list
through `shopping.Generate` and answers with the same shape plus through `shopping.Generate` and answers with the same shape plus
`skipped: [{date, dinner_id, dinner_name, reason}]`. 404 when the week `skipped: [{date, dinner_id, dinner_name, reason}]` and
has no plan, 409 `archived` when its list is in the Supply archive. `title_job_id`, the `title_supply_run` job titling the list, which
`GET /api/v1/jobs/{id}` reports on (failed `title_failed` when the title
could not be written); null when the list already has its title. 404
when the week has no plan, 409 `archived` when its list is in the Supply
archive.
- `POST /api/v1/shopping/{weekStart}/send-chat` (CSRF-checked) takes - `POST /api/v1/shopping/{weekStart}/send-chat` (CSRF-checked) takes
`{}` or `{"resend": true}` and posts the Supply run to Google Chat `{}` or `{"resend": true}` and posts the Supply run to Google Chat
through `chat.PostSupplyRun`, in the week's thread: "Supply run · " and through `chat.PostSupplyRun`, in the week's thread: "Supply run · " and
@@ -1180,7 +1190,8 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
Monday is a household-local date, so the week of Monday 28 December 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 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 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 `itemCount` all items, `onHandCount` the items marked "have it", and
`current` true for the household's current week. 400 `invalid_request` `current` true for the household's current week. 400 `invalid_request`
for any other `by`. A frozen list is read through `GET for any other `by`. A frozen list is read through `GET
+17 -2
View File
@@ -79,6 +79,7 @@ type Auth struct {
provider *oidc.Provider provider *oidc.Provider
client *http.Client client *http.Client
cookie loginCookie cookie loginCookie
pending *PendingStates
sessions Sessions sessions Sessions
allowed []string allowed []string
// endSessionURL is the provider's end_session_endpoint; logout ends // endSessionURL is the provider's end_session_endpoint; logout ends
@@ -157,6 +158,7 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
client: client, client: client,
allowed: slices.Clone(cfg.AllowedGroups), allowed: slices.Clone(cfg.AllowedGroups),
cookie: newLoginCookie(cfg.SecretKey), cookie: newLoginCookie(cfg.SecretKey),
pending: NewPendingStates(loginCookieTTL),
sessions: sessions, sessions: sessions,
endSessionURL: discovered.EndSessionURI, endSessionURL: discovered.EndSessionURI,
logger: logger, logger: logger,
@@ -250,7 +252,13 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
Verifier: oauth2.GenerateVerifier(), Verifier: oauth2.GenerateVerifier(),
Return: returnTo, Return: returnTo,
} }
if err := a.cookie.set(w, state, time.Now()); err != nil { now := time.Now()
if err := a.pending.Add(state.State, now); err != nil {
a.logger.WarnContext(r.Context(), "login refused", "error", err)
http.Error(w, "too many sign-ins in progress; try again later", http.StatusServiceUnavailable)
return
}
if err := a.cookie.set(w, state, now); err != nil {
a.logger.ErrorContext(r.Context(), "set login cookie", "error", err) a.logger.ErrorContext(r.Context(), "set login cookie", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
@@ -262,7 +270,7 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
} }
// callback completes the flow: it checks state against the login cookie, // callback completes the flow: it checks state against the login cookie,
// exchanges the code, verifies the ID token and nonce, then starts a session // consumes it, exchanges the code, verifies the ID token and nonce, then starts a session
// if the account is in an allowed group. Membership is checked on every // if the account is in an allowed group. Membership is checked on every
// login, so an account removed from the group is refused at its next one. // login, so an account removed from the group is refused at its next one.
func (a *Auth) callback(w http.ResponseWriter, r *http.Request) { func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
@@ -283,6 +291,13 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest) http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
return return
} }
// A copy of the cookie verifies until it expires, so the state is
// single-use only because it is consumed here.
if !a.pending.Consume(state.State) {
a.logger.WarnContext(ctx, "login callback state already used")
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
return
}
if q.Get("error") != "" { if q.Get("error") != "" {
// error and error_description are provider-supplied text; only the // error and error_description are provider-supplied text; only the
// fixed category is logged. // fixed category is logged.
+91
View File
@@ -497,6 +497,59 @@ func TestCallbackDefaultReturnPath(t *testing.T) {
} }
} }
// A saved copy of the login cookie still verifies until it expires, so a
// replay with a second valid code is refused only because the server
// consumed the state on first use.
func TestCallbackRefusesReplayedLoginState(t *testing.T) {
t.Parallel()
h := newHarness(t)
l := h.login(t, LoginPath)
idToken := h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))
if resp := h.callback(l, l.query.Get("state"), idToken); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("first callback status = %d, want 303", resp.StatusCode)
}
resp := h.callback(l, l.query.Get("state"), idToken)
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("replayed callback status = %d, want 400", resp.StatusCode)
}
if n := len(h.sessions.started()); n != 1 {
t.Errorf("%d sessions started, want 1", n)
}
assertLoginCookieCleared(t, resp)
}
// Pending login states live in memory, so a login started before a restart
// fails closed after it even though its cookie still verifies.
func TestCallbackRefusesLoginStartedBeforeRestart(t *testing.T) {
t.Parallel()
h := newHarness(t)
l := h.login(t, LoginPath)
restarted, err := New(context.Background(), Config{
Issuer: h.provider.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("server-secret-key"),
AllowedGroups: testAllowedGroups,
}, h.sessions, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatal(err)
}
r := chi.NewRouter()
restarted.Mount(r)
h.router = r
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("callback after restart status = %d, want 400", resp.StatusCode)
}
if n := len(h.sessions.started()); n != 0 {
t.Errorf("%d sessions started, want none", n)
}
}
func TestCallbackRejected(t *testing.T) { func TestCallbackRejected(t *testing.T) {
t.Parallel() t.Parallel()
@@ -819,6 +872,44 @@ func TestLoginCookieExpires(t *testing.T) {
} }
} }
func TestPendingStates(t *testing.T) {
t.Parallel()
p := NewPendingStates(time.Minute)
now := time.Now()
if err := p.Add("first", now); err != nil {
t.Fatal(err)
}
if !p.Consume("first") {
t.Error("Consume of a pending state = false")
}
if p.Consume("first") {
t.Error("second Consume of a state = true")
}
if p.Consume("never-added") {
t.Error("Consume of an unknown state = true")
}
for i := range maxPendingStates - 1 {
if err := p.Add(fmt.Sprint("s", i), now); err != nil {
t.Fatalf("Add %d: %v", i, err)
}
}
// "first" was consumed but has not expired, so it still counts.
if err := p.Add("over", now); !errors.Is(err, ErrTooManyPending) {
t.Errorf("Add past the bound = %v, want ErrTooManyPending", err)
}
// Once the earlier states expire, they are forgotten and a new one fits.
if err := p.Add("later", now.Add(time.Minute)); err != nil {
t.Errorf("Add after expiry: %v", err)
}
if p.Consume("s0") {
t.Error("an expired state was still pending")
}
if !p.Consume("later") {
t.Error("the state added after expiry is not pending")
}
}
func TestLoginRejectsOffOriginReturn(t *testing.T) { func TestLoginRejectsOffOriginReturn(t *testing.T) {
t.Parallel() t.Parallel()
h := newHarness(t) h := newHarness(t)
+68
View File
@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"sync"
"time" "time"
) )
@@ -153,6 +154,73 @@ func (c FlowCookie) Read(r *http.Request, v flowValue, now time.Time) error {
return nil return nil
} }
// maxPendingStates bounds each flow's pending states. The login route is
// unauthenticated, so without a bound anyone could grow the set without
// limit; one household never has close to this many flows in progress.
const maxPendingStates = 10000
// ErrTooManyPending means a flow's pending states are at maxPendingStates,
// so a new flow is refused until older ones expire.
var ErrTooManyPending = errors.New("too many sign-in flows in progress")
// PendingStates holds the state of every redirect flow started and not yet
// completed, until its flow cookie expires. A copy of a flow cookie verifies
// until it expires, so a flow is single-use only because its callback
// consumes the state here. The states live in memory — MealQuest runs as one
// process — so a flow started before a restart fails closed after it.
type PendingStates struct {
ttl time.Duration
mu sync.Mutex
states map[string]time.Time
// order is every added state in the order added. Every state is pending
// for the same ttl, so it is also expiry order and Add forgets expired
// states from its front.
order []pendingState
}
type pendingState struct {
state string
expires time.Time
}
// NewPendingStates returns an empty set whose states are pending for ttl,
// the flow cookie's lifetime.
func NewPendingStates(ttl time.Duration) *PendingStates {
return &PendingStates{ttl: ttl, states: map[string]time.Time{}}
}
// Add records state, started at now, as pending and forgets expired ones.
// It fails with ErrTooManyPending when maxPendingStates are pending.
func (p *PendingStates) Add(state string, now time.Time) error {
p.mu.Lock()
defer p.mu.Unlock()
expired := 0
for expired < len(p.order) && !now.Before(p.order[expired].expires) {
delete(p.states, p.order[expired].state)
expired++
}
p.order = p.order[expired:]
// The bound counts every unexpired state, consumed or not: a consumed
// state leaves states at once but order only when it expires.
if len(p.order) >= maxPendingStates {
return ErrTooManyPending
}
expires := now.Add(p.ttl)
p.states[state] = expires
p.order = append(p.order, pendingState{state: state, expires: expires})
return nil
}
// Consume removes state from the pending states, reporting whether it was
// there.
func (p *PendingStates) Consume(state string) bool {
p.mu.Lock()
defer p.mu.Unlock()
_, ok := p.states[state]
delete(p.states, state)
return ok
}
// returnPath validates the optional post-login return path. Empty means "/". // returnPath validates the optional post-login return path. Empty means "/".
// Only same-origin absolute paths are allowed: no scheme, host, protocol- // Only same-origin absolute paths are allowed: no scheme, host, protocol-
// relative "//" prefix, backslashes or control characters. // relative "//" prefix, backslashes or control characters.
+8 -7
View File
@@ -255,13 +255,14 @@ type Session struct {
} }
type ShoppingGroup struct { type ShoppingGroup struct {
ID string ID string
ListID string ListID string
DinnerID string DinnerID string
Date string Date string
Position int64 Position int64
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
FrozenDinnerName sql.NullString
} }
type ShoppingItem struct { type ShoppingItem struct {
+5 -1
View File
@@ -107,6 +107,9 @@ type Querier interface {
// Records a pending or running batch as done or failed; a batch already // Records a pending or running batch as done or failed; a batch already
// finished is left as it is (0 rows). // finished is left as it is (0 rows).
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error) 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 // Freezes list id into the Supply archive at frozen_at, unless it is
// frozen already. // frozen already.
FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error) FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error)
@@ -228,7 +231,8 @@ 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 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) ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error)
// The groups of list list_id, in list order. // The groups of list list_id, in list order.
ListShoppingGroups(ctx context.Context, listID string) ([]ShoppingGroup, error) ListShoppingGroups(ctx context.Context, listID string) ([]ShoppingGroup, error)
+19 -3
View File
@@ -155,6 +155,19 @@ func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error {
return err 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 const freezeShoppingList = `-- name: FreezeShoppingList :execrows
UPDATE shopping_lists UPDATE shopping_lists
SET status = 'archived', frozen_at = ?, updated_at = ? 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 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 FROM shopping_groups
JOIN dinners ON dinners.id = shopping_groups.dinner_id JOIN dinners ON dinners.id = shopping_groups.dinner_id
WHERE shopping_groups.list_id = ? WHERE shopping_groups.list_id = ?
@@ -247,7 +260,8 @@ type ListShoppingGroupDinnersRow struct {
DinnerName string 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) { func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error) {
rows, err := q.db.QueryContext(ctx, listShoppingGroupDinners, listID) rows, err := q.db.QueryContext(ctx, listShoppingGroupDinners, listID)
if err != nil { if err != nil {
@@ -265,6 +279,7 @@ func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) (
&i.ShoppingGroup.Position, &i.ShoppingGroup.Position,
&i.ShoppingGroup.CreatedAt, &i.ShoppingGroup.CreatedAt,
&i.ShoppingGroup.UpdatedAt, &i.ShoppingGroup.UpdatedAt,
&i.ShoppingGroup.FrozenDinnerName,
&i.DinnerName, &i.DinnerName,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -281,7 +296,7 @@ func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) (
} }
const listShoppingGroups = `-- name: ListShoppingGroups :many 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 = ? WHERE list_id = ?
ORDER BY position, id ORDER BY position, id
` `
@@ -304,6 +319,7 @@ func (q *Queries) ListShoppingGroups(ctx context.Context, listID string) ([]Shop
&i.Position, &i.Position,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.FrozenDinnerName,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
+8 -30
View File
@@ -137,10 +137,9 @@ type Service struct {
account sync.Mutex account sync.Mutex
// pending holds the state of every connect started and not yet // pending holds the state of every connect started and not yet
// completed, with when it expires. Complete consumes a state once, so a // completed. Complete consumes a state once, so a replayed cookie finds
// replayed cookie finds it gone. // it gone.
pendingMu sync.Mutex pending *auth.PendingStates
pending map[string]time.Time
} }
// NewService returns the service over st, sealing refresh tokens with box. // NewService returns the service over st, sealing refresh tokens with box.
@@ -175,7 +174,7 @@ func NewService(st *store.Store, box *secretbox.Box, cfg Config) (*Service, erro
cookie: auth.NewFlowCookie(connectCookieName, connectCookieKeyLabel, connectCookieTTL, cfg.SecretKey), cookie: auth.NewFlowCookie(connectCookieName, connectCookieKeyLabel, connectCookieTTL, cfg.SecretKey),
retryDelay: firstRetryDelay, retryDelay: firstRetryDelay,
now: time.Now, now: time.Now,
pending: map[string]time.Time{}, pending: auth.NewPendingStates(connectCookieTTL),
}, nil }, nil
} }
@@ -210,10 +209,12 @@ func (s *Service) Connect(w http.ResponseWriter, session auth.Session) (string,
Session: sessionBinding(session), Session: sessionBinding(session),
} }
now := s.now() now := s.now()
if err := s.pending.Add(state.State, now); err != nil {
return "", fmt.Errorf("gcal: %w", err)
}
if err := s.cookie.Set(w, &state, now); err != nil { if err := s.cookie.Set(w, &state, now); err != nil {
return "", fmt.Errorf("gcal: set connect cookie: %w", err) return "", fmt.Errorf("gcal: set connect cookie: %w", err)
} }
s.addPending(state.State, now)
return s.oauth.AuthCodeURL(state.State, return s.oauth.AuthCodeURL(state.State,
oauth2.AccessTypeOffline, oauth2.AccessTypeOffline,
oauth2.ApprovalForce, oauth2.ApprovalForce,
@@ -243,7 +244,7 @@ func (s *Service) Complete(w http.ResponseWriter, r *http.Request, session auth.
} }
// A copy of the cookie verifies until it expires, so the state is // A copy of the cookie verifies until it expires, so the state is
// single-use only because it is consumed here. // single-use only because it is consumed here.
if !s.consumePending(state.State) { if !s.pending.Consume(state.State) {
return fmt.Errorf("%w: already used", ErrBadState) return fmt.Errorf("%w: already used", ErrBadState)
} }
if q.Get("error") != "" { if q.Get("error") != "" {
@@ -276,29 +277,6 @@ func (s *Service) Complete(w http.ResponseWriter, r *http.Request, session auth.
return s.store(ctx, sealed) return s.store(ctx, sealed)
} }
// addPending records state, started at now, as pending until the connect
// cookie expires, and forgets expired ones.
func (s *Service) addPending(state string, now time.Time) {
s.pendingMu.Lock()
defer s.pendingMu.Unlock()
for k, expires := range s.pending {
if !now.Before(expires) {
delete(s.pending, k)
}
}
s.pending[state] = now.Add(connectCookieTTL)
}
// consumePending removes state from the pending states, reporting whether
// it was there.
func (s *Service) consumePending(state string) bool {
s.pendingMu.Lock()
defer s.pendingMu.Unlock()
_, ok := s.pending[state]
delete(s.pending, state)
return ok
}
// checkScopes fails with ErrScopes unless token's granted scopes include // checkScopes fails with ErrScopes unless token's granted scopes include
// every scope in Scopes. Google lets the household untick a permission on // every scope in Scopes. Google lets the household untick a permission on
// the consent screen and reports what was granted in "scope". // the consent screen and reports what was granted in "scope".
+10 -5
View File
@@ -111,10 +111,13 @@ type pushedPlan struct {
// plan Calendar does not show and pushing again makes it; or // plan Calendar does not show and pushing again makes it; or
// supply_run_failed when it could not be made, and POST // supply_run_failed when it could not be made, and POST
// /shopping/{weekStart}/generate makes it again. error is null when status // /shopping/{weekStart}/generate makes it again. error is null when status
// is generated. // is generated. title_job_id is the job titling a generated Supply run,
// which GET /jobs/{id} reports on; null when it already has its title or
// was not generated.
type pushedSupplyRun struct { type pushedSupplyRun struct {
Status string `json:"status"` Status string `json:"status"`
Error *apiError `json:"error"` Error *apiError `json:"error"`
TitleJobID *string `json:"title_job_id"`
} }
// postPlanPush pushes the week's plan to the chosen Google calendar // postPlanPush pushes the week's plan to the chosen Google calendar
@@ -143,7 +146,9 @@ func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service
// gone. // gone.
ctx := context.WithoutCancel(r.Context()) ctx := context.WithoutCancel(r.Context())
run := pushedSupplyRun{Status: "generated"} run := pushedSupplyRun{Status: "generated"}
if _, err := shop.GeneratePushed(ctx, week, revision); errors.Is(err, shopping.ErrFrozen) { if g, err := shop.GeneratePushed(ctx, week, revision); err == nil {
run.TitleJobID = g.TitleJobID
} else if errors.Is(err, shopping.ErrFrozen) {
logger.InfoContext(ctx, "Supply run in the Supply archive left as it was after Push to Calendar", "week", week.String()) logger.InfoContext(ctx, "Supply run in the Supply archive left as it was after Push to Calendar", "week", week.String())
run = pushedSupplyRun{Status: "archived", Error: &apiError{Code: "archived", Message: err.Error()}} run = pushedSupplyRun{Status: "archived", Error: &apiError{Code: "archived", Message: err.Error()}}
} else if errors.Is(err, shopping.ErrPlanChanged) { } else if errors.Is(err, shopping.ErrPlanChanged) {
@@ -153,7 +158,7 @@ func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service
Message: "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again", Message: "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again",
Retryable: true, Retryable: true,
}} }}
} else if err != nil { } else {
logger.ErrorContext(ctx, "generate Supply run after Push to Calendar", "week", week.String(), "error", err) logger.ErrorContext(ctx, "generate Supply run after Push to Calendar", "week", week.String(), "error", err)
run = pushedSupplyRun{Status: "failed", Error: &apiError{ run = pushedSupplyRun{Status: "failed", Error: &apiError{
Code: "supply_run_failed", Code: "supply_run_failed",
+1 -1
View File
@@ -107,7 +107,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
t.Fatal(err) t.Fatal(err)
} }
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc, 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+6 -1
View File
@@ -167,7 +167,12 @@ func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
// with its own unstarted job runner and a grounder that finds nothing. // with its own unstarted job runner and a grounder that finds nothing.
func newTestDinners(t *testing.T) *dinners.Service { func newTestDinners(t *testing.T) *dinners.Service {
t.Helper() 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)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1) runner, err := jobs.NewRunner(st, logger, 1)
if err != nil { if err != nil {
+7 -4
View File
@@ -60,10 +60,13 @@ type supplyRunItem struct {
} }
// generatedSupplyRun is the POST /shopping/{weekStart}/generate answer: // generatedSupplyRun is the POST /shopping/{weekStart}/generate answer:
// the Supply run as made, and the plan days with nothing to shop for. // the Supply run as made, the plan days with nothing to shop for, and the
// title_job_id of the job titling it, which GET /jobs/{id} reports on;
// null when the Supply run already has its title.
type generatedSupplyRun struct { type generatedSupplyRun struct {
supplyRun supplyRun
Skipped []shopping.SkippedDay `json:"skipped"` Skipped []shopping.SkippedDay `json:"skipped"`
TitleJobID *string `json:"title_job_id"`
} }
// sendChatRequest is the POST /shopping/{weekStart}/send-chat body: // sendChatRequest is the POST /shopping/{weekStart}/send-chat body:
@@ -108,7 +111,7 @@ func getSupplyRun(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
} }
// postSupplyRunGenerate makes, or remakes, the week's Supply run from its // postSupplyRunGenerate makes, or remakes, the week's Supply run from its
// plan and answers with it and the plan days it skipped. // plan and answers with it, the plan days it skipped and its title job.
func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc { func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger) week, ok := weekStartOrFail(w, r, logger)
@@ -129,7 +132,7 @@ func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.Hand
if skipped == nil { if skipped == nil {
skipped = []shopping.SkippedDay{} skipped = []shopping.SkippedDay{}
} }
writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped}) writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped, TitleJobID: g.TitleJobID})
} }
} }
+140 -11
View File
@@ -75,7 +75,7 @@ func (e plansEnv) groundWeek(t *testing.T) {
func (e plansEnv) archive(t *testing.T) { func (e plansEnv) archive(t *testing.T) {
t.Helper() t.Helper()
if _, err := e.st.Writer.ExecContext(t.Context(), 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) t.Fatal(err)
} }
} }
@@ -186,7 +186,7 @@ func TestSupplyRunShape(t *testing.T) {
if status != http.StatusOK { if status != http.StatusOK {
t.Fatalf("generate = %d %v", status, raw) t.Fatalf("generate = %d %v", status, raw)
} }
wantTop := []string{"chat_send_unconfirmed_at", "groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "total_count", "weekStart"} wantTop := []string{"chat_send_unconfirmed_at", "groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "title_job_id", "total_count", "weekStart"}
if got := keys(raw); !reflect.DeepEqual(got, wantTop) { if got := keys(raw); !reflect.DeepEqual(got, wantTop) {
t.Errorf("generate fields = %v, want %v", got, wantTop) t.Errorf("generate fields = %v, want %v", got, wantTop)
} }
@@ -199,7 +199,7 @@ func TestSupplyRunShape(t *testing.T) {
if status != http.StatusOK { if status != http.StatusOK {
t.Fatalf("GET = %d %v", status, raw) t.Fatalf("GET = %d %v", status, raw)
} }
if k := keys(raw); !reflect.DeepEqual(k, slices.DeleteFunc(slices.Clone(wantTop), func(s string) bool { return s == "skipped" })) { if k := keys(raw); !reflect.DeepEqual(k, slices.DeleteFunc(slices.Clone(wantTop), func(s string) bool { return s == "skipped" || s == "title_job_id" })) {
t.Errorf("GET fields = %v", k) t.Errorf("GET fields = %v", k)
} }
group := raw["groups"].([]any)[0].(map[string]any) group := raw["groups"].([]any)[0].(map[string]any)
@@ -376,8 +376,12 @@ func TestPushPlanMakesSupplyRun(t *testing.T) {
if rec.Code != http.StatusOK || body["status"] != "synced" { if rec.Code != http.StatusOK || body["status"] != "synced" {
t.Fatalf("push = %d %s", rec.Code, rec.Body) t.Fatalf("push = %d %s", rec.Code, rec.Body)
} }
if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) { run, _ := body["supply_run"].(map[string]any)
t.Errorf("supply_run = %v, want %v", body["supply_run"], want) if k := keys(run); run["status"] != "generated" || run["error"] != nil || !reflect.DeepEqual(k, []string{"error", "status", "title_job_id"}) {
t.Errorf("supply_run = %v, want generated with no error", run)
}
if job := titleJob(t, e, run["title_job_id"]); job["status"] != "queued" {
t.Errorf("title job = %v, want queued: no runner in this test", job)
} }
var l supplyRunJSON var l supplyRunJSON
if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l); status != http.StatusOK { if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l); status != http.StatusOK {
@@ -467,9 +471,11 @@ func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
if rec.Code != http.StatusOK || body["status"] != "synced" { if rec.Code != http.StatusOK || body["status"] != "synced" {
t.Fatalf("push = %d %s", rec.Code, rec.Body) t.Fatalf("push = %d %s", rec.Code, rec.Body)
} }
if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) { run, _ := body["supply_run"].(map[string]any)
t.Errorf("supply_run = %v, want %v", body["supply_run"], want) if run["status"] != "generated" || run["error"] != nil {
t.Errorf("supply_run = %v, want generated with no error", run)
} }
titleJobID, _ := run["title_job_id"].(string)
held.waitEntered(t) held.waitEntered(t)
var l supplyRunJSON var l supplyRunJSON
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l) callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
@@ -478,6 +484,7 @@ func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
} }
close(held.release) close(held.release)
wantTitleJobSucceeded(t, e, titleJobID)
if got := e.waitTitled(t); got != "Chili and Friends" { if got := e.waitTitled(t); got != "Chili and Friends" {
t.Errorf("title = %q, want the title job's", got) t.Errorf("title = %q, want the title job's", got)
} }
@@ -493,7 +500,8 @@ func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
e.startRunner(t) e.startRunner(t)
var l supplyRunJSON var l supplyRunJSON
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l); status != http.StatusOK { status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l)
if status != http.StatusOK {
t.Fatalf("generate = %d %v", status, body) t.Fatalf("generate = %d %v", status, body)
} }
if l.Title != nil { if l.Title != nil {
@@ -502,12 +510,66 @@ func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
if !reflect.DeepEqual(l.render(), weekLines) { if !reflect.DeepEqual(l.render(), weekLines) {
t.Errorf("generated = %v, want %v", l.render(), weekLines) t.Errorf("generated = %v, want %v", l.render(), weekLines)
} }
titleJobID, _ := body["title_job_id"].(string)
held.waitEntered(t) held.waitEntered(t)
close(held.release) close(held.release)
wantTitleJobSucceeded(t, e, titleJobID)
if got := e.waitTitled(t); got != "Chili and Friends" { if got := e.waitTitled(t); got != "Chili and Friends" {
t.Errorf("title = %q, want the title job's", got) t.Errorf("title = %q, want the title job's", got)
} }
if _, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil); body["title_job_id"] != nil {
t.Errorf("remake title_job_id = %v, want null: the Supply run has its title", body["title_job_id"])
}
}
// TestSupplyRunGenerateTitleJobFails: a title job that cannot title the
// Supply run is reported failed with title_failed by GET /jobs/{id}, and
// the week's next generation answers with a new title job.
func TestSupplyRunGenerateTitleJobFails(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
e.startRunner(t)
_, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
first := titleJob(t, e, body["title_job_id"])
job := waitJob(t, e.h, first["id"].(string))
if job["status"] != "failed" || job["error"] != shopping.ReasonTitleFailed {
t.Errorf("title job = %v, want failed %s", job, shopping.ReasonTitleFailed)
}
_, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
if second := titleJob(t, e, body["title_job_id"]); second["id"] == first["id"] {
t.Errorf("retry title_job_id = %v, want a new job", second["id"])
}
}
// titleJob returns the title_supply_run job id, as GET /jobs/{id} reports
// it; id must be a job id string.
func titleJob(t *testing.T, e plansEnv, id any) map[string]any {
t.Helper()
s, ok := id.(string)
if !ok || s == "" {
t.Fatalf("title_job_id = %v, want a job id", id)
}
rec, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+s, "")
if rec.Code != http.StatusOK || job["kind"] != shopping.KindTitleSupplyRun {
t.Fatalf("GET title job = %d %s, want a %s job", rec.Code, rec.Body, shopping.KindTitleSupplyRun)
}
return job
}
// wantTitleJobSucceeded waits for title job id to finish and fails t
// unless GET /jobs/{id} reports it succeeded with the title written.
func wantTitleJobSucceeded(t *testing.T, e plansEnv, id string) {
t.Helper()
titleJob(t, e, id)
job := waitJob(t, e.h, id)
result, _ := job["result"].(map[string]any)
if job["status"] != "succeeded" || result["title"] != "Chili and Friends" {
t.Errorf("title job = %v, want succeeded with the title", job)
}
} }
// TestFailedPushMakesNoSupplyRun: a push Calendar refused makes no Supply // TestFailedPushMakesNoSupplyRun: a push Calendar refused makes no Supply
@@ -546,7 +608,7 @@ func TestPushPlanSupplyRunFails(t *testing.T) {
"code": "supply_run_failed", "code": "supply_run_failed",
"message": "the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run", "message": "the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run",
"retryable": true, "retryable": true,
}} }, "title_job_id": nil}
if !reflect.DeepEqual(body["supply_run"], want) { if !reflect.DeepEqual(body["supply_run"], want) {
t.Errorf("supply_run = %v, want %v", body["supply_run"], want) t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
} }
@@ -599,7 +661,7 @@ func TestPushPlanEditedBeforeSupplyRun(t *testing.T) {
"code": "plan_changed", "code": "plan_changed",
"message": "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again", "message": "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again",
"retryable": true, "retryable": true,
}} }, "title_job_id": nil}
if !reflect.DeepEqual(body["supply_run"], want) { if !reflect.DeepEqual(body["supply_run"], want) {
t.Errorf("supply_run = %v, want %v", body["supply_run"], want) t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
} }
@@ -632,7 +694,7 @@ func TestPushPlanLeavesArchivedSupplyRun(t *testing.T) {
t.Fatalf("push = %d %s", rec.Code, rec.Body) t.Fatalf("push = %d %s", rec.Code, rec.Body)
} }
run, _ := body["supply_run"].(map[string]any) run, _ := body["supply_run"].(map[string]any)
if apiErr, _ := run["error"].(map[string]any); run["status"] != "archived" || apiErr["code"] != "archived" { if apiErr, _ := run["error"].(map[string]any); run["status"] != "archived" || apiErr["code"] != "archived" || run["title_job_id"] != nil {
t.Errorf("supply_run = %v, want archived", body["supply_run"]) t.Errorf("supply_run = %v, want archived", body["supply_run"])
} }
var l supplyRunJSON var l supplyRunJSON
@@ -790,6 +852,73 @@ func TestSupplyArchive(t *testing.T) {
} }
} }
// TestSupplyArchiveKeepsFrozenNames freezes last week's Supply run, then
// renames its dinner and this week's through the dinners API: the frozen
// post and list keep the name the dinner had when frozen, while this
// week's list, not frozen, shows the new name.
func TestSupplyArchiveKeepsFrozenNames(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM plan_days; DELETE FROM week_plans`); err != nil {
t.Fatal(err)
}
zone, err := household.LoadZone("America/New_York") // the env's household zone
if err != nil {
t.Fatal(err)
}
thisWeek := household.WeekStart(household.Today(time.Now(), zone))
y, m, d := thisWeek.Date()
lastWeek := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
last := e.weekSupplyRun(t, lastWeek, "Beef Stew", shopIng{"stewing beef", "2", "lb"})
current := e.weekSupplyRun(t, thisWeek, "Lemon Chicken", shopIng{"lemon", "1", ""})
if err := e.shop.Freeze(t.Context(), lastWeek); err != nil {
t.Fatal(err)
}
for id, name := range map[string]string{
last.Groups[0].Dinner.ID: "Sunday Beef Stew",
current.Groups[0].Dinner.ID: "Lemon Garlic Chicken",
} {
if status, body := callShopping(t, e, http.MethodPatch, dinnersPath+"/"+id, `{"name":"`+name+`"}`, nil); status != http.StatusOK {
t.Fatalf("PATCH dinner %s = %d %v", id, status, body)
}
}
var got archiveJSON
if status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got); status != http.StatusOK {
t.Fatalf("GET archive = %d %v", status, raw)
}
var posts []string
for _, p := range got.Periods {
for _, post := range p.Posts {
posts = append(posts, fmt.Sprintf("%s %v current=%t", post.WeekStart, post.Dinners, post.Current))
}
}
want := []string{
thisWeek.String() + " [Lemon Garlic Chicken] current=true",
lastWeek.String() + " [Beef Stew] current=false",
}
if !reflect.DeepEqual(posts, want) {
t.Errorf("archive after rename =\n %s\nwant\n %s", strings.Join(posts, "\n "), strings.Join(want, "\n "))
}
for _, tc := range []struct {
week store.LocalDate
want string
}{
{lastWeek, "Monday " + lastWeek.String() + " Beef Stew: stewing beef [2 lb] needed"},
{thisWeek, "Monday " + thisWeek.String() + " Lemon Garlic Chicken: lemon [1] needed"},
} {
var l supplyRunJSON
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+tc.week.String(), "", &l); status != http.StatusOK {
t.Fatalf("GET %s = %d %v", tc.week, status, body)
}
if got := l.render(); !reflect.DeepEqual(got, []string{tc.want}) {
t.Errorf("list %s after rename = %v, want [%s]", tc.week, got, tc.want)
}
}
}
// TestSupplyRunSendChat posts the week's Supply run to Google Chat once, // 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 // records sent_to_chat_at, answers a second send with the status as it
// is, and posts again only with resend. // is, and posts again only with resend.
+23 -9
View File
@@ -22,7 +22,9 @@ const (
// Freeze moves the Supply run of the week starting weekStart into the // Freeze moves the Supply run of the week starting weekStart into the
// Supply archive: its status becomes archived and frozen_at is set, after // 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. // frozen list does nothing and is no error.
// //
// The week must have ended in the household's time zone: a week not yet // 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) "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() stamp := store.Now().String()
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{ return s.st.WithTx(ctx, func(tx *sql.Tx) error {
FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID, q := db.New(tx)
}); err != nil { n, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err) FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID,
} })
return nil 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: // freezeTitleJob is the title job of list as Generate would enqueue it:
@@ -145,7 +158,8 @@ type Post struct {
Label string Label string
// Title is nil while the list is untitled, when Label stands in. // Title is nil while the list is untitled, when Label stands in.
Title *string 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 Dinners []string
// Items is how many items the list has, and OnHand how many of them // Items is how many items the list has, and OnHand how many of them
// the household marked "have it". // the household marked "have it".
+1 -1
View File
@@ -422,7 +422,7 @@ func TestArchiveGroups(t *testing.T) {
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili, Noodles & Co' WHERE week_start = '2026-12-28'`); err != nil { 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) 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) t.Fatal(err)
} }
+1 -1
View File
@@ -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) 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) t.Fatal(err)
} }
posts := len(e.chat.Posts()) posts := len(e.chat.Posts())
+11 -2
View File
@@ -492,7 +492,9 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, e
return readList(ctx, q, list) 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) { func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, error) {
rows, err := q.ListShoppingGroupDinners(ctx, list.ID) rows, err := q.ListShoppingGroupDinners(ctx, list.ID)
if err != nil { 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)) groups := make([]Group, 0, len(rows))
for _, r := range rows { for _, r := range rows {
g := r.ShoppingGroup 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{ 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], Items: byGroup[g.ID],
}) })
} }
+2 -2
View File
@@ -692,7 +692,7 @@ func TestGenerateRefusesFrozenList(t *testing.T) {
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}) e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
e.plan(t, "Beef Chili") e.plan(t, "Beef Chili")
e.generate(t) 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) t.Fatal(err)
} }
e.swap(t, "2026-10-12", "Pad Thai") 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.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Beef Chili") e.plan(t, "Beef Chili")
l := e.generate(t).List 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) t.Fatal(err)
} }
+36 -1
View File
@@ -239,7 +239,7 @@ func TestGenerateTitleNotStoredOnFrozenList(t *testing.T) {
e := titleWeek(t) e := titleWeek(t)
e.model.QueueGenerate(titled("Noodles, Tacos and Chili")) e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
e.beforeTitle = func() { 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) t.Error(err)
} }
} }
@@ -311,3 +311,38 @@ func TestGenerateTitleOverlapAcrossRename(t *testing.T) {
t.Errorf("stored title = %v, want one of the two titles", deref(got)) t.Errorf("stored title = %v, want one of the two titles", deref(got))
} }
} }
// TestGenerateTitleNotStoredOnReplacedList: a list replaced by a new
// untitled list of the same week once archive_title has answered, before
// its title is stored, keeps the replacement untitled; the old list's
// title job fails, and the failure is logged.
func TestGenerateTitleNotStoredOnReplacedList(t *testing.T) {
e := titleWeek(t)
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
e.afterTitle = func() {
for _, stmt := range []string{
`DELETE FROM shopping_items WHERE group_id IN (SELECT id FROM shopping_groups WHERE list_id IN (SELECT id FROM shopping_lists WHERE week_start = ?1))`,
`DELETE FROM shopping_groups WHERE list_id IN (SELECT id FROM shopping_lists WHERE week_start = ?1)`,
`UPDATE shopping_lists SET id = 'replacement' WHERE week_start = ?1`,
} {
if _, err := e.st.Writer.Exec(stmt, week.String()); err != nil {
t.Error(err)
}
}
}
g := e.generate(t)
wantJob(t, e.waitTitle(t, g), jobs.StatusFailed, shopping.ReasonTitleFailed)
var id string
if err := e.st.Reader.QueryRow(`SELECT id FROM shopping_lists WHERE week_start = ?`, week.String()).Scan(&id); err != nil {
t.Fatal(err)
}
if id != "replacement" || id == g.List.ID {
t.Fatalf("week's list = %s, want the replacement of %s", id, g.List.ID)
}
wantUntitled(t, "replacement's title", e.storedTitle(t))
if !strings.Contains(e.log.String(), "the Supply run was frozen or replaced before its title was stored") {
t.Errorf("log lacks the failure:\n%s", e.log.String())
}
}
+58 -4
View File
@@ -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,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" { if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0019_shopping_group_frozen_name" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", 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,0017_shopping_list_chat_send_unconfirmed,0019_shopping_group_frozen_name", got)
} }
} }
@@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { 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,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" { if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0019_shopping_group_frozen_name" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", 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,0017_shopping_list_chat_send_unconfirmed,0019_shopping_group_frozen_name", 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")
@@ -922,3 +922,57 @@ func TestMigrateShoppingListChatSendUnconfirmed(t *testing.T) {
t.Errorf("shopping list after 0017 = %s, want %s", got, want) t.Errorf("shopping list after 0017 = %s, want %s", got, want)
} }
} }
// 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)
}
}
@@ -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
);
+9 -1
View File
@@ -60,7 +60,8 @@ DELETE FROM shopping_items
WHERE id = ?; WHERE id = ?;
-- name: ListShoppingGroupDinners :many -- 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 SELECT sqlc.embed(shopping_groups), dinners.name AS dinner_name
FROM shopping_groups FROM shopping_groups
JOIN dinners ON dinners.id = shopping_groups.dinner_id JOIN dinners ON dinners.id = shopping_groups.dinner_id
@@ -117,6 +118,13 @@ UPDATE shopping_lists
SET status = 'archived', frozen_at = ?, updated_at = ? SET status = 'archived', frozen_at = ?, updated_at = ?
WHERE id = ? AND frozen_at IS NULL; 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 -- name: ListShoppingListsThrough :many
-- The Supply runs of the weeks starting on or before week_start, newest -- The Supply runs of the weeks starting on or before week_start, newest
-- week first. -- week first.
+58
View File
@@ -1392,6 +1392,64 @@ describe('PlanPage', () => {
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument() expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
}) })
it("still shows a swap's failure on coming back to the week, through another, and asks for the swap again only on Retry", async () => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
[`GET /api/v1/plans/2026-10-19`]: () => errorResponse(404, 'not_found'),
...lists,
[`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_not_plannable'),
})
await renderPage()
fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ }))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message")
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
await waitFor(() => expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-19'))
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
await waitFor(() => expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-12'))
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message")
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
expect(puts(fetchMock)).toHaveLength(1)
})
it('reads the plan again when the dinner is already on another day, and offers to choose another rather than send it again', async () => {
// Meanwhile lemon orzo was planned on Wednesday.
let read = planBody('draft')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, read),
...lists,
[`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_already_planned'),
})
await renderPage()
const sheet = await openFriday()
read = planBody('draft')
read.days[2] = { ...read.days[2]!, dinner: { ...read.days[2]!.dinner, id: 'q1', name: 'Lemon orzo' }, is_new: true }
fireEvent.click(await within(sheet).findByRole('button', { name: /Lemon orzo/ }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't swap Friday's dinner. Lemon orzo is already on another day this week.")
expect(within(alert).queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
await waitFor(() => expect(within(tile('WED · NEW')).getByText('Lemon orzo')).toBeInTheDocument())
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
// Choosing again opens Friday's sheet on the plan read, where lemon
// orzo is already this week; nothing is sent again.
fireEvent.click(within(alert).getByRole('button', { name: 'Choose another dinner' }))
const again = screen.getByRole('dialog', { name: 'Swap Friday' })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
const orzo = within(within(again).getByRole('list', { name: 'Quest log' })).getByRole('listitem')
expect(within(orzo).getByText('Already this week')).toBeInTheDocument()
expect(within(orzo).queryByRole('button')).not.toBeInTheDocument()
expect(puts(fetchMock)).toHaveLength(1)
})
it('waits for a pending swap before Rebuild week, keeping its failure', async () => { it('waits for a pending swap before Rebuild week, keeping its failure', async () => {
let refuse!: () => void let refuse!: () => void
const fetchMock = stubServer({ const fetchMock = stubServer({
+18 -3
View File
@@ -127,6 +127,12 @@ export function ago(at: Date, now: Date): string {
return days === 1 ? '1 day ago' : `${days} days ago` return days === 1 ? '1 day ago' : `${days} days ago`
} }
// alreadyPlanned says whether a swap was refused because its dinner is
// already on another day of the week: sending it again cannot succeed.
function alreadyPlanned(error: Error): boolean {
return error instanceof ApiError && error.code === 'dinner_already_planned'
}
// pushedToCalendar says whether drafting over a plan of this status needs // pushedToCalendar says whether drafting over a plan of this status needs
// confirm: it was pushed to Calendar. // confirm: it was pushed to Calendar.
function pushedToCalendar(status: PlanStatus): boolean { function pushedToCalendar(status: PlanStatus): boolean {
@@ -398,9 +404,18 @@ function WeekBoard({
</ErrorBanner> </ErrorBanner>
) )
) : swap.failed !== null ? ( ) : swap.failed !== null ? (
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}> alreadyPlanned(swap.failed.error) ? (
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message} // The plan is read again (useSwapDay): the household chooses another
</ErrorBanner> // dinner from it rather than sending the refused swap again.
<ErrorBanner onRetry={() => openSwap(swap.failed!.swap.date)} retryLabel="Choose another dinner">
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.swap.dinner.name} is already on another day
this week.
</ErrorBanner>
) : (
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
</ErrorBanner>
)
) : startDraft.isError ? ( ) : startDraft.isError ? (
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner> <ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
) : result.error !== null ? ( ) : result.error !== null ? (
+132
View File
@@ -127,6 +127,17 @@ describe('usePlan', () => {
['a method it does not know', (body: ReturnType<typeof planBody>) => (body.days[0]!.dinner.method = 'microwave'), 'method "microwave"'], ['a method it does not know', (body: ReturnType<typeof planBody>) => (body.days[0]!.dinner.method = 'microwave'), 'method "microwave"'],
['no method', (body: ReturnType<typeof planBody>) => Reflect.deleteProperty(body.days[0]!.dinner, 'method'), 'method undefined'], ['no method', (body: ReturnType<typeof planBody>) => Reflect.deleteProperty(body.days[0]!.dinner, 'method'), 'method undefined'],
['a sync time that is no instant', (body: ReturnType<typeof planBody>) => (body.calendar_synced_at = 'yesterday'), 'calendar_synced_at "yesterday"'], ['a sync time that is no instant', (body: ReturnType<typeof planBody>) => (body.calendar_synced_at = 'yesterday'), 'calendar_synced_at "yesterday"'],
// Date reads both, in this device's zone: neither says which instant.
[
'a sync time with no offset',
(body: ReturnType<typeof planBody>) => (body.calendar_synced_at = '2026-10-07T15:58:00'),
'calendar_synced_at "2026-10-07T15:58:00" is not an instant',
],
[
'a sync date with no time',
(body: ReturnType<typeof planBody>) => (body.calendar_synced_at = '2026-10-07'),
'calendar_synced_at "2026-10-07" is not an instant',
],
])('refuses a plan with %s', async (_, change, said) => { ])('refuses a plan with %s', async (_, change, said) => {
const body = planBody() const body = planBody()
change(body) change(body)
@@ -208,6 +219,72 @@ describe('useSwapDay', () => {
expect(result.current.swap.failed?.error.message).toBe('dinner_not_plannable message') expect(result.current.swap.failed?.error.message).toBe('dinner_not_plannable message')
}) })
it('leaves a day a later plan read landed, the very day the swap wrote, when the swap of it fails', async () => {
const friday = deferred()
let read = planBody()
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, read),
[`PUT ${FRIDAY}`]: () => friday.reply,
})
const { result, plan, queryClient } = await renderSwaps()
// The read finds Friday lemon orzo, field for field the day the swap
// wrote: the server has it, whatever this swap's PUT is answered with.
const swapped = { date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: null }
act(() => result.current.swap.mutate(ORZO))
await waitFor(() => expect(plan().days[4]).toEqual(swapped))
read = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })
await act(() => result.current.plan.refetch())
await act(async () => friday.send(errorResponse(500, 'internal')))
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
expect(plan().days[4]).toEqual(swapped)
expect(result.current.swap.failed?.error.message).toBe('internal message')
})
it('puts the day back when the swap of it fails with no plan read since, after a later swap of another day', async () => {
const friday = deferred()
const monday = deferred()
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
[`PUT ${FRIDAY}`]: () => friday.reply,
[`PUT ${MONDAY}`]: () => monday.reply,
})
const { result, plan, queryClient } = await renderSwaps()
act(() => result.current.swap.mutate(ORZO))
act(() => result.current.swap.mutate(FISH))
await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos'))
await act(async () => friday.send(errorResponse(500, 'internal')))
await waitFor(() => expect(plan().days[4]!.dinner.name).toBe('Dinner 4'))
expect(plan().days[0]!.dinner.name).toBe('Fish tacos')
await act(async () => monday.send(errorResponse(500, 'internal')))
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
expect(plan().days.map((day) => day.dinner.name)).toEqual(planBody().days.map((day) => day.dinner.name))
})
it('reads the plan again when the dinner is already on another day of the week, and sends the swap only once', async () => {
let read = planBody()
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, read),
[`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_already_planned'),
})
const { result, plan, queryClient } = await renderSwaps()
// Meanwhile lemon orzo was planned on Wednesday.
read = planBody({ 2: { id: 'q1', name: 'Lemon orzo', is_new: true } })
act(() => result.current.swap.mutate(ORZO))
await waitFor(() => expect(plan().days[2]!.dinner.name).toBe('Lemon orzo'))
expect(plan().days[4]!.dinner.name).toBe('Dinner 4')
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
expect(result.current.swap.failed?.error.message).toBe('dinner_already_planned message')
const sent = vi.mocked(fetch).mock.calls.map(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}`)
expect(sent.filter((call) => call === `PUT ${FRIDAY}`)).toHaveLength(1)
expect(sent.filter((call) => call === `GET ${PLAN}`)).toHaveLength(2)
})
it('refuses to dismiss a pending swap', async () => { it('refuses to dismiss a pending swap', async () => {
const friday = deferred() const friday = deferred()
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), [`PUT ${FRIDAY}`]: () => friday.reply }) stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), [`PUT ${FRIDAY}`]: () => friday.reply })
@@ -353,6 +430,61 @@ describe('week mutations', () => {
expect(weekMutations(queryClient, LATER)).toEqual([]) expect(weekMutations(queryClient, LATER)).toEqual([])
}) })
it("leaves behind a week's settled swaps but for the failure that speaks for it, which stays until its week leaves it behind", async () => {
let refuse = true
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
[`PUT ${FRIDAY}`]: () => (refuse ? errorResponse(409, 'dinner_not_plannable') : jsonResponse(200, planBody())),
[`PUT ${MONDAY}`]: () => jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } })),
[`GET /api/v1/plans/${NEXT}`]: () => errorResponse(404, 'not_found'),
[`GET /api/v1/plans/${LATER}`]: () => errorResponse(404, 'not_found'),
})
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const queryClient = createQueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ApiProvider>
)
const hook = renderHook(({ week }) => ({ plan: usePlan(week), swap: useSwapDay(week) }), {
wrapper,
initialProps: { week: WEEK },
})
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
// Two swaps settle while the week is shown: the earlier is needed for
// ordering no longer, and only the latest stays.
act(() => hook.result.current.swap.mutate(FISH))
act(() => hook.result.current.swap.mutate(ORZO))
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap error']))
// Leaving the week, through two others, keeps the failure alone.
for (const week of [NEXT, LATER, NEXT]) {
hook.rerender({ week })
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
expect(weekMutations(queryClient, WEEK)).toEqual(['swap error'])
}
// Coming back shows it; it fails again, and again one stays.
hook.rerender({ week: WEEK })
expect(hook.result.current.swap.failed?.error.message).toBe('dinner_not_plannable message')
act(() => hook.result.current.swap.dismiss())
act(() => hook.result.current.swap.mutate(ORZO))
await waitFor(() => expect(hook.result.current.swap.failed).not.toBeNull())
expect(weekMutations(queryClient, WEEK)).toEqual(['swap error'])
// A swap that succeeds is left behind once the week is.
refuse = false
act(() => hook.result.current.swap.dismiss())
act(() => hook.result.current.swap.mutate(ORZO))
await waitFor(() => expect(hook.result.current.swap.succeeded).not.toBeNull())
expect(weekMutations(queryClient, WEEK)).toEqual(['swap success'])
hook.rerender({ week: NEXT })
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
expect(weekMutations(queryClient, WEEK)).toEqual([])
})
it('keeps a push whose Supply run was not made, settled in a week no longer shown, to be shown on coming back', async () => { it('keeps a push whose Supply run was not made, settled in a week no longer shown, to be shown on coming back', async () => {
const nextPush = deferred() const nextPush = deferred()
stubServer({ stubServer({
+102 -35
View File
@@ -1,5 +1,14 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useMutation, useMutationState, useQuery, useQueryClient, type Mutation, type QueryClient } from '@tanstack/react-query' import {
useMutation,
useMutationState,
useQuery,
useQueryClient,
type Mutation,
type Query,
type QueryClient,
type QueryKey,
} from '@tanstack/react-query'
import { ApiError, useApi, type ApiRead } from '../api/index.ts' import { ApiError, useApi, type ApiRead } from '../api/index.ts'
import { isRecord } from '../api/errors.ts' import { isRecord } from '../api/errors.ts'
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts' import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
@@ -83,10 +92,16 @@ function parseDay(where: string, body: unknown): PlanDay {
return { date: body.date, dinner, is_new: body.is_new, start_by: body.start_by } return { date: body.date, dinner, is_new: body.is_new, start_by: body.start_by }
} }
// RFC3339 is an RFC 3339 instant as the server writes it: a full date and
// time, to the second or finer, and its offset, Z or ±hh:mm. A date alone,
// or a time with no offset, which Date would read in this device's zone, is
// not one.
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
// syncedAt reads calendar_synced_at, an RFC 3339 instant or null. // syncedAt reads calendar_synced_at, an RFC 3339 instant or null.
function syncedAt(where: string, value: unknown): Date | null { function syncedAt(where: string, value: unknown): Date | null {
if (value === null) return null if (value === null) return null
const at = typeof value === 'string' ? new Date(value) : null const at = typeof value === 'string' && RFC3339.test(value) ? new Date(value) : null
if (at === null || Number.isNaN(at.getTime())) { if (at === null || Number.isNaN(at.getTime())) {
throw new Error(`${where}: calendar_synced_at ${JSON.stringify(value)} is not an instant`) throw new Error(`${where}: calendar_synced_at ${JSON.stringify(value)} is not an instant`)
} }
@@ -264,38 +279,54 @@ const weeksShown = new WeakMap<QueryClient, WeeksShown>()
// time a week is shown, and is never removed, so a swap or push that settles // time a week is shown, and is never removed, so a swap or push that settles
// after its Draft board, or the whole Plan, is gone is still collected. // after its Draft board, or the whole Plan, is gone is still collected.
// //
// When a week is shown, the settled ones of every other week are removed, // A swap or push pending is kept, with every other of its kind and week:
// unless that week still has one pending: a pending one, and those an // an earlier pending one must see those sent after it, across leaving its
// earlier pending one must see, stay across leaving its week and coming // week and coming back. Once none of a kind and week is pending, only the
// back. When one settles, the same is done, except that a week's latest, // latest is needed, and the rest are removed, whenever a week is shown or
// when it failed, stays until a week is next shown, so that coming straight // one settles. The latest itself stays while its week is shown. In a week
// back to it shows the failure. A week's latest push whose Supply run was // not shown it is removed, unless:
// not made stays whatever is shown: the week has no shopping list until one //
// is made, which settles the push, or a push sent since replaces it, and // - it is a failed swap: it stays, whatever is shown, until the week's
// making it may still be under way when the week is left. The cache holds // board leaves it behind (useWeekSwap, Rebuild week, Push to Calendar),
// at most the weeks shown's, those of weeks left pending, one push of each // so coming back to the week shows the failure;
// week whose Supply run was not made, and one failure of each week whose // - it is a push whose Supply run was not made: the week has no shopping
// last pending one settled since a week was last shown. // list until one is made, which settles the push, or a push sent since
// replaces it, and making it may still be under way when the week is
// left;
// - it is a failed push that settled after its week was left: it stays
// until a week is next shown, so coming straight back shows it.
//
// So once none of its kind is pending, a week keeps at most one settled
// swap and one settled push, and a week left keeps none but those above:
// settled swaps and pushes never accumulate.
function keepWeekMutations(queryClient: QueryClient): WeeksShown { function keepWeekMutations(queryClient: QueryClient): WeeksShown {
const kept = weeksShown.get(queryClient) const kept = weeksShown.get(queryClient)
if (kept !== undefined) return kept if (kept !== undefined) return kept
const mutations = queryClient.getMutationCache() const mutations = queryClient.getMutationCache()
// How many times each week is shown at once. // How many times each week is shown at once.
const shown = new Map<string, number>() const shown = new Map<string, number>()
// keep says whether the latest of its kind and week, none of which is
// pending, stays; settled says a mutation settling swept, not a week
// shown.
function keep(mutation: Mutation, of: { kind: 'swap' | 'push'; week: string }, settled: boolean): boolean {
if (shown.has(of.week)) return true
if (of.kind === 'swap') return failed(mutation)
return unmade(mutation) || (settled && failed(mutation))
}
function sweep(settled: boolean) { function sweep(settled: boolean) {
// The mutations of each kind of each week not shown, in the order sent. // The mutations of each kind of each week, in the order sent.
const left = new Map<string, Mutation[]>() const sent = new Map<string, { of: { kind: 'swap' | 'push'; week: string }; week: Mutation[] }>()
for (const mutation of mutations.getAll()) { for (const mutation of mutations.getAll()) {
const of = weekMutation(mutation) const of = weekMutation(mutation)
if (of === null || shown.has(of.week)) continue if (of === null) continue
const id = `${of.kind} ${of.week}` const id = `${of.kind} ${of.week}`
left.set(id, [...(left.get(id) ?? []), mutation]) sent.set(id, { of, week: [...(sent.get(id)?.week ?? []), mutation] })
} }
for (const week of left.values()) { for (const { of, week } of sent.values()) {
if (week.some((mutation) => mutation.state.status === 'pending')) continue if (week.some((mutation) => mutation.state.status === 'pending')) continue
const latest = week.at(-1) const latest = week.at(-1)
for (const mutation of week) { for (const mutation of week) {
if (mutation === latest && (unmade(mutation) || (settled && failed(mutation)))) continue if (mutation === latest && keep(mutation, of, settled)) continue
mutations.remove(mutation) mutations.remove(mutation)
} }
} }
@@ -324,6 +355,30 @@ function useWeekShown(weekStart: string) {
useEffect(() => keepWeekMutations(queryClient).show(weekStart), [queryClient, weekStart]) useEffect(() => keepWeekMutations(queryClient).show(weekStart), [queryClient, weekStart])
} }
// swapWrites counts, for each plan query, its updates that were a swap's
// own optimistic write or rollback, not a plan read from the server.
const swapWrites = new WeakMap<Query, number>()
// serverReads is how many plans read from the server have landed in the
// plan query: its updates, less the swaps' own writes. A plan read lands
// whether or not it differs from the plan shown.
function serverReads(query: Query): number {
return query.state.dataUpdateCount - (swapWrites.get(query) ?? 0)
}
// writeSwap updates the plan query as a swap's own write, which serverReads
// does not count.
function writeSwap(queryClient: QueryClient, query: Query, update: (plan: Plan | null | undefined) => Plan | null | undefined) {
const before = query.state.dataUpdateCount
queryClient.setQueryData<Plan | null>(query.queryKey, update)
swapWrites.set(query, (swapWrites.get(query) ?? 0) + query.state.dataUpdateCount - before)
}
// planQuery is the plan query keyed key, if the query cache has it.
function planQuery(queryClient: QueryClient, key: QueryKey): Query | undefined {
return queryClient.getQueryCache().find({ queryKey: key, exact: true })
}
// swapKey keys the swaps of the week starting on weekStart in the mutation // swapKey keys the swaps of the week starting on weekStart in the mutation
// cache, where they outlive the Draft board that sent them. // cache, where they outlive the Draft board that sent them.
function swapKey(weekStart: string) { function swapKey(weekStart: string) {
@@ -345,9 +400,13 @@ export type SwapDay = {
// (PUT /api/v1/plans/{weekStart}/days/{date}). The day shows the dinner at // (PUT /api/v1/plans/{weekStart}/days/{date}). The day shows the dinner at
// once, NEW when it came from the Quest log, with no start-by time until the // once, NEW when it came from the Quest log, with no start-by time until the
// server answers with the plan as swapped, which then replaces it. A refused // server answers with the plan as swapped, which then replaces it. A refused
// swap puts the day back as it was, unless it has changed again meanwhile. // swap puts the day back as it was, unless it has changed again meanwhile:
// However the swap ends, the Quest log is read again: the server leaves a // another swap wrote over it, or any plan read from the server landed
// dinner on the plan out of it. // since, even one with the very day the swap wrote. A swap refused because
// the dinner is already on another day of the week (409
// dinner_already_planned) reads the plan again, so the household chooses
// again from the plan as it is. However the swap ends, the Quest log is
// read again: the server leaves a dinner on the plan out of it.
// //
// The week's swaps live in the mutation cache, not in the component, so // The week's swaps live in the mutation cache, not in the component, so
// leaving the week and coming back keeps them: pending and failed are read // leaving the week and coming back keeps them: pending and failed are read
@@ -374,7 +433,7 @@ export function useSwapDay(weekStart: string): SwapDay {
function mutate(chosen: Swap) { function mutate(chosen: Swap) {
// isLatest says whether no swap of the week was sent after this one. // isLatest says whether no swap of the week was sent after this one.
const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId) const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId)
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(queryClient, { const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay; query: Query; reads: number }>(queryClient, {
mutationKey, mutationKey,
// Kept until dismissed or settled in a week not shown // Kept until dismissed or settled in a week not shown
// (keepWeekMutations): a later swap must stay in the cache while an // (keepWeekMutations): a later swap must stay in the cache while an
@@ -387,24 +446,32 @@ export function useSwapDay(weekStart: string): SwapDay {
}, },
onMutate: async ({ date, dinner, fromQuestLog }) => { onMutate: async ({ date, dinner, fromQuestLog }) => {
await queryClient.cancelQueries({ queryKey: key }) await queryClient.cancelQueries({ queryKey: key })
const plan = queryClient.getQueryData<Plan | null>(key) const query = planQuery(queryClient, key)
const plan = query?.state.data as Plan | null | undefined
const previous = plan?.days.find((day) => day.date === date) const previous = plan?.days.find((day) => day.date === date)
if (!plan || previous === undefined) throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`) if (query === undefined || !plan || previous === undefined) {
throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`)
}
const swapped: PlanDay = { date, dinner, is_new: fromQuestLog, start_by: null } const swapped: PlanDay = { date, dinner, is_new: fromQuestLog, start_by: null }
queryClient.setQueryData<Plan>(key, { ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) }) writeSwap(queryClient, query, () => ({ ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) }))
return { previous, swapped } return { previous, swapped, query, reads: serverReads(query) }
}, },
onSuccess: (plan) => { onSuccess: (plan) => {
if (isLatest()) queryClient.setQueryData<Plan>(key, plan) if (isLatest()) queryClient.setQueryData<Plan>(key, plan)
}, },
// The day goes back only while it is still, field for field, the day // The day goes back only while it is still, field for field, the day
// this swap wrote: a later swap or plan read that replaced it is left // this swap wrote, and no plan read from the server has landed since
// alone. // (serverReads): a later swap that replaced it, or a read that did,
onError: (_error, _swap, context) => { // even with the same day, is left alone.
if (context === undefined) return onError: (error, _swap, context) => {
queryClient.setQueryData<Plan | null>(key, (plan) => if (context !== undefined && planQuery(queryClient, key) === context.query && serverReads(context.query) === context.reads) {
plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) }, writeSwap(queryClient, context.query, (plan) =>
) plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) },
)
}
if (error instanceof ApiError && error.code === 'dinner_already_planned') {
void queryClient.invalidateQueries({ queryKey: key })
}
}, },
onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }), onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
}) })
+4 -1
View File
@@ -80,7 +80,10 @@ export function RegularsSettingsPage() {
// Which sheet is open, for which Regular. // Which sheet is open, for which Regular.
type Open = { sheet: 'edit' | 'fix'; id: string } | null type Open = { sheet: 'edit' | 'fix'; id: string } | null
function RegularsManager() { // RegularsManager lists, adds, edits and fixes the recipes of the
// household's Regulars: this page's body, and the Settings "Regular
// dinners" sub-tab at tablet and desktop widths.
export function RegularsManager() {
const regulars = useRegulars() const regulars = useRegulars()
const add = useAddRegular() const add = useAddRegular()
const remove = useRemoveRegular() const remove = useRemoveRegular()
+4 -1
View File
@@ -104,7 +104,10 @@ describe('SettingsPage', () => {
expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument() expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
expect(within(row('AI assistant')).getByText('Anthropic · Claude Sonnet')).toBeInTheDocument() expect(within(row('AI assistant')).getByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument() expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
// The rotation count and the hidden Regular dinners sub-tab both read
// the Regulars.
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([ expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
REGULARS_PATH,
REGULARS_PATH, REGULARS_PATH,
CALENDAR_PATH, CALENDAR_PATH,
'/api/v1/settings/ai', '/api/v1/settings/ai',
@@ -117,7 +120,7 @@ describe('SettingsPage', () => {
}) })
it('shows the not-yet-available states while the server has no endpoint for them', async () => { it('shows the not-yet-available states while the server has no endpoint for them', async () => {
stubSettings() stubSettings({ [REGULARS_PATH]: () => errorResponse(404, 'not_found') })
renderSettings() renderSettings()
expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument() expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument()
expect( expect(
-21
View File
@@ -101,24 +101,3 @@
padding: 24px; padding: 24px;
border-radius: 30px; border-radius: 30px;
} }
.placeholder {
display: flex;
flex-direction: column;
gap: 14px;
}
.cardTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.hint {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-neutral-700);
}
+124 -10
View File
@@ -11,9 +11,11 @@ import {
HOUSEHOLD_SETTINGS, HOUSEHOLD_SETTINGS,
NOTIFICATION_SETTINGS, NOTIFICATION_SETTINGS,
RECIPE_SEARCH_SETTINGS, RECIPE_SEARCH_SETTINGS,
SETTINGS_REGULARS,
} from '../test/ApiProviders.tsx' } from '../test/ApiProviders.tsx'
import { InDataRouter } from '../test/dataRouter.tsx' import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
import { fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts' import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts' import { mockViewport } from '../test/matchMedia.ts'
import { SettingsPage } from './SettingsPage.tsx' import { SettingsPage } from './SettingsPage.tsx'
@@ -32,6 +34,7 @@ function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch>
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS), 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
'GET /api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS), 'GET /api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK), 'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK),
'GET /api/v1/dinners?status=regular': () => jsonResponse(200, SETTINGS_REGULARS),
...responders, ...responders,
} }
const fetchMock = vi.fn<typeof fetch>((input, init) => { const fetchMock = vi.fn<typeof fetch>((input, init) => {
@@ -121,7 +124,7 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
expect(tab('Regular dinners')).toHaveAttribute('aria-selected', 'true') expect(tab('Regular dinners')).toHaveAttribute('aria-selected', 'true')
expect(tab('Regular dinners')).toHaveFocus() expect(tab('Regular dinners')).toHaveFocus()
expect(panel('Regular dinners')).toBeVisible() expect(panel('Regular dinners')).toBeVisible()
expect(within(panel('Regular dinners')).getByRole('region', { name: 'Regular dinners' })).toBeInTheDocument() expect(await within(panel('Regular dinners')).findByRole('heading', { name: 'On rotation · 1' })).toBeInTheDocument()
expect(panel('Household')).not.toBeVisible() expect(panel('Household')).not.toBeVisible()
fireEvent.keyDown(tab('Regular dinners'), { key: 'End' }) fireEvent.keyDown(tab('Regular dinners'), { key: 'End' })
@@ -165,6 +168,123 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
}) })
}) })
const TACOS = dinnerBody('d1', 'Beef tacos', { method: 'stovetop', minutes_total: 40, difficulty: 'easy' })
const PIZZA = dinnerBody('d3', 'Pizza night', { grounding_status: 'failed', grounding_error: 'no_recipe_found' })
// stubRegulars answers the Regulars from server.regulars, so a write's
// responder changes what the next read sees.
function stubRegulars(regulars: DinnerBody[], responders: (server: { regulars: DinnerBody[] }) => Record<string, Responder>) {
const server = { regulars }
const fetchMock = stubAPI({
'GET /api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: server.regulars }),
...responders(server),
})
return { server, fetchMock }
}
// writes lists the requests other than GETs, as method, path and body.
function writes(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
.map(([input, init]) => [init?.method, String(input), init?.body])
}
// openRegulars shows the Regular dinners tab once its Regulars have loaded.
async function openRegulars() {
fireEvent.click(tab('Regular dinners'))
const regulars = within(panel('Regular dinners'))
await regulars.findByRole('heading', { level: 2, name: /^On rotation/ })
return regulars
}
function regularRow(name: string) {
const item = within(screen.getByRole('list', { name: 'Regulars' }))
.getAllByRole('listitem')
.find((li) => within(li).queryByText(name) !== null)
if (item === undefined) throw new Error(`no Regular row named ${name}`)
return within(item)
}
describe.each([1440, 1180])('SettingsPage Regular dinners tab at %i px (mock 2e)', (width) => {
it('lists the Regulars as /settings/regulars does, under the page title rather than its own', async () => {
stubRegulars([TACOS, PIZZA], () => ({}))
await renderAt(width)
const regulars = await openRegulars()
expect(regulars.getByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
expect(regularRow('Beef tacos').getByText('Stovetop · 40 min')).toBeInTheDocument()
expect(regularRow('Beef tacos').getByText('Recipe found')).toBeInTheDocument()
expect(regularRow('Pizza night').getByText('Needs a recipe')).toBeInTheDocument()
expect(regulars.getByRole('textbox', { name: 'Add a Regular' })).toBeInTheDocument()
expect(screen.getAllByRole('heading', { level: 1 }).map((h) => h.textContent)).toEqual(['Settings'])
expect(regulars.queryByRole('button', { name: 'Back to Settings' })).not.toBeInTheDocument()
})
it('adds a Regular by name', async () => {
const { fetchMock } = stubRegulars([TACOS], (server) => ({
'POST /api/v1/dinners': (body) => {
const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' })
server.regulars = [...server.regulars, created]
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
},
}))
await renderAt(width)
const regulars = await openRegulars()
fireEvent.change(regulars.getByRole('textbox', { name: 'Add a Regular' }), { target: { value: 'Chicken enchiladas' } })
fireEvent.click(regulars.getByRole('button', { name: 'Add Regular' }))
expect(await regulars.findByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
expect(regularRow('Chicken enchiladas').getByText('Finding recipe…')).toBeInTheDocument()
expect(writes(fetchMock)).toEqual([['POST', '/api/v1/dinners', JSON.stringify({ name: 'Chicken enchiladas' })]])
})
it('edits a Regular in its sheet', async () => {
const { fetchMock } = stubRegulars([TACOS], (server) => ({
'PATCH /api/v1/dinners/d1': (body) => {
const edited = { ...TACOS, ...(body as object) }
server.regulars = [edited]
return jsonResponse(200, edited)
},
}))
await renderAt(width)
await openRegulars()
fireEvent.click(regularRow('Beef tacos').getByRole('button', { name: /^Beef tacos/ }))
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '25' } })
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(writes(fetchMock)).toEqual([['PATCH', '/api/v1/dinners/d1', JSON.stringify({ minutes_total: 25 })]])
expect(await regularRow('Beef tacos').findByText('Stovetop · 25 min')).toBeInTheDocument()
})
it('fixes the recipe of a Regular that needs one', async () => {
const { fetchMock } = stubRegulars([PIZZA], (server) => ({
'GET /api/v1/dinners/d3/recipe-candidates': () =>
jsonResponse(200, {
candidates: [
{ candidate_id: 'https://example.com/pizza', title: 'Weeknight pizza', site_name: 'Example Kitchen', photo_url: null, minutes: 45 },
],
}),
'POST /api/v1/dinners/d3/ground': () => {
const fixed = { ...PIZZA, grounding_status: 'grounded', grounding_error: null }
server.regulars = [fixed]
return jsonResponse(200, fixed)
},
}))
await renderAt(width)
await openRegulars()
fireEvent.click(regularRow('Pizza night').getByRole('button', { name: /^Pizza night/ }))
const sheet = within(screen.getByRole('dialog', { name: 'Fix recipe' }))
fireEvent.click(await sheet.findByRole('button', { name: 'Use Weeknight pizza from Example Kitchen' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(writes(fetchMock)).toEqual([
['POST', '/api/v1/dinners/d3/ground', JSON.stringify({ candidate_id: 'https://example.com/pizza' })],
])
expect(await regularRow('Pizza night').findByText('Recipe found')).toBeInTheDocument()
})
})
describe('SettingsPage reminder time across tabs', () => { describe('SettingsPage reminder time across tabs', () => {
it('shows a reminder time saved on Notifications in the Household tab, keeping its draft', async () => { it('shows a reminder time saved on Notifications in the Household tab, keeping its draft', async () => {
const saved = { ...HOUSEHOLD_SETTINGS } const saved = { ...HOUSEHOLD_SETTINGS }
@@ -195,10 +315,7 @@ describe('SettingsPage reminder time across tabs', () => {
describe('SettingsPage across the 768 px breakpoint', () => { describe('SettingsPage across the 768 px breakpoint', () => {
it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => { it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => {
stubAPI({ stubAPI({ 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED) })
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
})
const resize = await renderAt(767) const resize = await renderAt(767)
expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai') expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai')
expect(screen.queryByRole('tablist')).not.toBeInTheDocument() expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
@@ -209,10 +326,7 @@ describe('SettingsPage across the 768 px breakpoint', () => {
}) })
it('keeps an unsaved draft and its leave guard across 1180 px, 390 px and back', async () => { it('keeps an unsaved draft and its leave guard across 1180 px, 390 px and back', async () => {
stubAPI({ stubAPI({ 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED) })
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
})
const resize = await renderAt(1180) const resize = await renderAt(1180)
fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), { fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), {
target: { value: 'A draft' }, target: { value: 'A draft' },
+2 -22
View File
@@ -1,11 +1,11 @@
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react' import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
import { useBreakpoint, useOrientation } from '../shell/useBreakpoint.ts' import { useBreakpoint, useOrientation } from '../shell/useBreakpoint.ts'
import { Card } from '../ui/Card.tsx'
import { AIAssistantCard } from './AISettingsPage.tsx' import { AIAssistantCard } from './AISettingsPage.tsx'
import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx' import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx'
import { LeaveGuard } from './LeaveGuard.tsx' import { LeaveGuard } from './LeaveGuard.tsx'
import { ChatWebhookCard, NotificationsCard } from './NotificationsPage.tsx' import { ChatWebhookCard, NotificationsCard } from './NotificationsPage.tsx'
import { RecipeSearchCard } from './RecipeSearchCard.tsx' import { RecipeSearchCard } from './RecipeSearchCard.tsx'
import { RegularsManager } from './RegularsSettingsPage.tsx'
import styles from './SettingsTabs.module.css' import styles from './SettingsTabs.module.css'
type Tab = 'household' | 'regulars' | 'notifications-ai' type Tab = 'household' | 'regulars' | 'notifications-ai'
@@ -101,13 +101,7 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
{panel('household', styles.single, <HouseholdSettingsSections onDirtyChange={setHouseholdDirty} />)} {panel('household', styles.single, <HouseholdSettingsSections onDirtyChange={setHouseholdDirty} />)}
{panel( {panel('regulars', styles.single, <RegularsManager />)}
'regulars',
styles.single,
// TODO(Feature 12): embed the Regular dinners screen here once Feature
// 12 builds it.
<Placeholder title="Regular dinners">Managing the household's Regulars arrives in a later update.</Placeholder>,
)}
{panel( {panel(
'notifications-ai', 'notifications-ai',
@@ -128,17 +122,3 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
</div> </div>
) )
} }
// Placeholder is a neutral card holding a section's place until the feature
// that owns it renders it.
function Placeholder({ title, children }: { title: string; children: ReactNode }) {
const titleId = useId()
return (
<Card className={styles.placeholder} aria-labelledby={titleId} role="region">
<h2 id={titleId} className={styles.cardTitle}>
{title}
</h2>
<p className={styles.hint}>{children}</p>
</Card>
)
}
+10 -5
View File
@@ -2,7 +2,8 @@ import { QueryClientProvider } from '@tanstack/react-query'
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { errorResponse, fakeLocation, jsonResponse } from './fetchStub.ts' import { dinnerBody } from './dinners.ts'
import { fakeLocation, jsonResponse } from './fetchStub.ts'
// AI_SETTINGS is a GET /api/v1/settings/ai body: Anthropic with a verified key. // AI_SETTINGS is a GET /api/v1/settings/ai body: Anthropic with a verified key.
export const AI_SETTINGS = { export const AI_SETTINGS = {
@@ -99,12 +100,16 @@ export const CHAT_WEBHOOK = {
last_test_status: null, last_test_status: null,
} }
// SETTINGS_REGULARS is a GET /api/v1/dinners?status=regular body: one
// Regular, its recipe found.
export const SETTINGS_REGULARS = { dinners: [dinnerBody('r1', 'Beef tacos')] }
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as // SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
// today's server does: the dinners endpoint does not exist yet, and neither // today's server does: one Regular on rotation, and neither Google Calendar
// Google Calendar nor Google Chat is connected. The home's sub-tabs stay mounted on mobile, so // nor Google Chat connected. The home's sub-tabs stay mounted on mobile, so
// it also reads the AI models and recipe search settings. // it also reads the Regulars list, the AI models and recipe search settings.
export const SETTINGS_API = { export const SETTINGS_API = {
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'), '/api/v1/dinners?status=regular': () => jsonResponse(200, SETTINGS_REGULARS),
'/api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED), '/api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
'/api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS), '/api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
'/api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK_NONE), '/api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK_NONE),