Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 0942e65392 Replace a held Side quest made ineligible before a batch's final store
CI / ci (push) Successful in 9m38s
CI / ci (pull_request) Successful in 8m21s
A notifying (scheduled or manual) suggestion batch held each round's
grounded Side quests and only checked them at its single final store, so
a household change during grounding could drop a held card after the
fill loop had stopped, leaving the batch short of BatchSize with spare
candidates unused (MQ-172-3). The batch now rechecks every held Side
quest after each round with the same eligibility check the store uses
(eligible, extracted from store), so a later round grounds a spare in
its place. The single-transaction store and once-only new_suggestions
are unchanged.

Narrow ARCHITECTURE.md's "a failed scheduled or manual batch has stored
nothing" to failures before the final store, and state that a job that
fails recording the batch's end keeps its Side quests and
new_suggestions (MQ-172-4).

Maestro task 244f9834-faa8-4bb8-b5e6-43677dec0cc0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:53:58 -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
22 changed files with 931 additions and 186 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
+20 -6
View File
@@ -319,15 +319,22 @@ automatic** (D-021).
suggestion with the name it is suggested under, the reason and suggestion with the name it is suggested under, the reason and
`cited_regular_ids` — unless, since the job read its input, a dinner `cited_regular_ids` — unless, since the job read its input, a dinner
it repeats was added, a suggestion it repeats passed, or a Regular it it repeats was added, a suggestion it repeats passed, or a Regular it
cites renamed or archived (checked in the storing transaction). The job cites renamed or archived (checked in the storing transaction). A
notifying batch also rechecks the Side quests it holds after each round
and lets go of those no longer eligible, so a later round grounds a
spare in place of each; only a change committed between the last
recheck and the store can leave it short of 5 with spares unused. The job
publishes `{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`, publishes `{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
`grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the `grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the
skill or `normalize_recipe` failed; no Side quest is made up instead, skill or `normalize_recipe` failed; no Side quest is made up instead,
D-017), `search_not_configured` or `search_failed`, and the batch is set D-017), `search_not_configured` or `search_failed`, and the batch is set
`failed`. A failed `scheduled` or `manual` batch has stored nothing; a `failed`. A `scheduled` or `manual` batch that fails before its final
store (the AI, grounding, or the store itself) has stored nothing; a
failed `replenish` batch's Side quests stored before the failure stay failed `replenish` batch's Side quests stored before the failure stay
in the deck. If in the deck. If
recording the batch's end fails, its job fails; the job runner retries recording the batch's end fails, its job fails, even after the final
store committed: a notifying batch's Side quests and their
`new_suggestions` then stay; the job runner retries
a job's final status write with backoff until it lands, each attempt a job's final status write with backoff until it lands, each attempt
bounded at 5 seconds; at shutdown an attempt that fails or times out is bounded at 5 seconds; at shutdown an attempt that fails or times out is
not retried, and the next start fails the job as interrupted. Recording not retried, and the next start fails the job as interrupted. Recording
@@ -1120,7 +1127,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 +1153,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
+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 -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",
+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})
} }
} }
+72 -10
View File
@@ -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
+35
View File
@@ -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())
}
}
+85 -29
View File
@@ -54,7 +54,8 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// no recipe grounds is dropped; any other grounding failure fails the // no recipe grounds is dropped; any other grounding failure fails the
// batch, as does suggest_side_quests failing, with no Side quest made up // batch, as does suggest_side_quests failing, with no Side quest made up
// instead. The batch ends done or failed. When recording the batch's end // instead. The batch ends done or failed. When recording the batch's end
// fails, so does the job; when it times out at shutdown, the job is left // fails, so does the job, with what the batch stored, and any
// new_suggestions it emitted, kept; when it times out at shutdown, the job is left
// running and the batch with it, for the next start to fail. // running and the batch with it, for the next start to fail.
// //
// When a batch stores its Side quests depends on whether it notifies (see // When a batch stores its Side quests depends on whether it notifies (see
@@ -64,7 +65,12 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// Side quest stored. So the household is told of exactly what the batch // Side quest stored. So the household is told of exactly what the batch
// added, once; and a batch that fails at any round, including a later // added, once; and a batch that fails at any round, including a later
// round's normalize_recipe, stores nothing and sends nothing, leaving no // round's normalize_recipe, stores nothing and sends nothing, leaving no
// Side quest in the deck that was never announced. A replenish batch, // Side quest in the deck that was never announced. After each round it
// rechecks every Side quest it holds (see eligible) and lets go of those
// the household's changes made ineligible, so a later round grounds a
// spare Side quest in place of each; only a change committed between the
// last recheck and the store can still leave the batch short of
// BatchSize while spare Side quests remain. A replenish batch,
// which tops up the deck as the household swipes and never notifies, // which tops up the deck as the household swipes and never notifies,
// stores each round's Side quests before the next round starts, so the // stores each round's Side quests before the next round starts, so the
// deck fills as it goes; those stored before a failure stay in the deck. // deck fills as it goes; those stored before a failure stay in the deck.
@@ -146,7 +152,13 @@ func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publis
} }
res.Dropped += len(round) - len(grounded) res.Dropped += len(round) - len(grounded)
if note != nil { if note != nil {
held = append(held, grounded...) // Recheck every held Side quest, not just this round's, so
// one the household's changes made ineligible while this
// round grounded is replaced by a later round, not dropped by
// the store once no round is left to replace it.
if held, err = s.recheck(ctx, &res, append(held, grounded...)); err != nil {
return batchResult{}, err
}
} else if err := s.storeInto(ctx, &res, nil, grounded); err != nil { } else if err := s.storeInto(ctx, &res, nil, grounded); err != nil {
return batchResult{}, err return batchResult{}, err
} }
@@ -163,6 +175,22 @@ func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publis
return res, nil return res, nil
} }
// recheck returns, in order, those of a notifying batch's held Side
// quests that store would still keep (see eligible), adding those left
// out to res.Dropped.
func (s *Service) recheck(ctx context.Context, res *batchResult, held []groundedQuest) ([]groundedQuest, error) {
kept, err := s.eligible(ctx, db.New(s.st.Reader), res.BatchID, held)
if err != nil {
return nil, err
}
res.Dropped += len(held) - len(kept)
out := make([]groundedQuest, len(kept))
for i, k := range kept {
out[i] = k.groundedQuest
}
return out, nil
}
// storeInto stores quests for res's batch with note (see store) and adds // storeInto stores quests for res's batch with note (see store) and adds
// the suggestions stored, and the Side quests left out, to res. // the suggestions stored, and the Side quests left out, to res.
func (s *Service) storeInto(ctx context.Context, res *batchResult, note *notice, quests []groundedQuest) error { func (s *Service) storeInto(ctx context.Context, res *batchResult, note *notice, quests []groundedQuest) error {
@@ -277,11 +305,11 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
// store stores each grounded Side quest of batchID as a dinner with status // store stores each grounded Side quest of batchID as a dinner with status
// suggested and origin side_quest, grounded in its recipe, and a pending // suggested and origin side_quest, grounded in its recipe, and a pending
// suggestion citing its Regulars by id, all in one transaction, and // suggestion citing its Regulars by id, all in one transaction, and
// returns the suggestion ids. A Side quest is left out when, since // returns the suggestion ids. A Side quest eligible leaves out is not
// suggest_side_quests ran, a dinner it repeats was added, a suggestion it // stored: since suggest_side_quests ran, a dinner it repeats was added, a
// repeats passed or a Rotation check dropped, or a Regular it cites renamed // suggestion it repeats passed or a Rotation check dropped, or a Regular
// or archived. Passed names are read in the same transaction as the store, // it cites renamed or archived. eligible reads in the same transaction as
// so a Pass or drop committed before it is never missed. // the store, so a Pass or drop committed before it is never missed.
// //
// When note is not nil and a Side quest is stored, new_suggestions keyed by // When note is not nil and a Side quest is stored, new_suggestions keyed by
// note, counting and naming those stored, is emitted in the same // note, counting and naming those stored, is emitted in the same
@@ -295,28 +323,13 @@ func (s *Service) store(ctx context.Context, batchID string, note *notice, quest
var stored []string var stored []string
err := s.st.WithTx(ctx, func(tx *sql.Tx) error { err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx) q := db.New(tx)
existing, err := q.ListUnarchivedDinners(ctx) kept, err := s.eligible(ctx, q, batchID, quests)
if err != nil { if err != nil {
return fmt.Errorf("suggest: list dinners for dedupe: %w", err) return err
} }
names, err := q.ListPassedNames(ctx) for _, k := range kept {
if err != nil { gq := k.groundedQuest
return fmt.Errorf("suggest: list passed names for dedupe: %w", err) citedJSON, err := json.Marshal(k.cited)
}
regulars := map[string][]string{}
for _, row := range existing {
names = append(names, row.Dinner.Name)
if row.Dinner.Status == dinners.StatusRegular {
regulars[row.Dinner.Name] = append(regulars[row.Dinner.Name], row.Dinner.ID)
}
}
for _, gq := range quests {
cited, ok := citedIDs(gq.quest.CitedRegulars, regulars)
if !ok || repeats(gq.quest.Name, names) {
s.logger.InfoContext(ctx, "suggested dinner dropped: changed while grounding", "batch_id", batchID)
continue
}
citedJSON, err := json.Marshal(cited)
if err != nil { if err != nil {
return fmt.Errorf("suggest: encode cited Regulars: %w", err) return fmt.Errorf("suggest: encode cited Regulars: %w", err)
} }
@@ -354,7 +367,6 @@ func (s *Service) store(ctx context.Context, batchID string, note *notice, quest
}); err != nil { }); err != nil {
return fmt.Errorf("suggest: create suggestion: %w", err) return fmt.Errorf("suggest: create suggestion: %w", err)
} }
names = append(names, d.Name)
ids = append(ids, id) ids = append(ids, id)
stored = append(stored, d.Name) stored = append(stored, d.Name)
} }
@@ -372,6 +384,50 @@ func (s *Service) store(ctx context.Context, batchID string, note *notice, quest
return ids, nil return ids, nil
} }
// eligibleQuest is a Side quest store would keep, with the ids of the
// Regulars it cites.
type eligibleQuest struct {
groundedQuest
cited []string
}
// eligible returns, in order, those of batchID's quests that q's dinners
// and passed names still allow, each with the ids of the Regulars it
// cites. A Side quest is left out when a dinner it repeats is unarchived,
// a suggestion it repeats passed or a Rotation check dropped, a Regular it
// cites is renamed or archived, or an earlier Side quest of quests kept
// repeats it. store checks with this in its transaction; a notifying
// batch checks the Side quests it holds with it after each round (see
// fill).
func (s *Service) eligible(ctx context.Context, q *db.Queries, batchID string, quests []groundedQuest) ([]eligibleQuest, error) {
existing, err := q.ListUnarchivedDinners(ctx)
if err != nil {
return nil, fmt.Errorf("suggest: list dinners for dedupe: %w", err)
}
names, err := q.ListPassedNames(ctx)
if err != nil {
return nil, fmt.Errorf("suggest: list passed names for dedupe: %w", err)
}
regulars := map[string][]string{}
for _, row := range existing {
names = append(names, row.Dinner.Name)
if row.Dinner.Status == dinners.StatusRegular {
regulars[row.Dinner.Name] = append(regulars[row.Dinner.Name], row.Dinner.ID)
}
}
var kept []eligibleQuest
for _, gq := range quests {
cited, ok := citedIDs(gq.quest.CitedRegulars, regulars)
if !ok || repeats(gq.quest.Name, names) {
s.logger.InfoContext(ctx, "suggested dinner dropped: changed while grounding", "batch_id", batchID)
continue
}
names = append(names, gq.quest.Name)
kept = append(kept, eligibleQuest{groundedQuest: gq, cited: cited})
}
return kept, nil
}
// citedIDs returns the ids of the Regulars named cited, false when one // citedIDs returns the ids of the Regulars named cited, false when one
// names no Regular or more than one. // names no Regular or more than one.
func citedIDs(cited []string, regulars map[string][]string) ([]string, bool) { func citedIDs(cited []string, regulars map[string][]string) ([]string, bool) {
+61
View File
@@ -660,6 +660,67 @@ func TestBatchKeepsAtMostBatchSize(t *testing.T) {
} }
} }
// TestBatchReplacesSideQuestMadeIneligibleWhileHeld: a notifying batch
// holds its first round's Side quests until its last round is done; when
// the household adds a dinner one of them repeats while that round grounds,
// the batch lets it go and grounds a spare Side quest in its place, so it
// still stores BatchSize and announces them once.
func TestBatchReplacesSideQuestMadeIneligibleWhileHeld(t *testing.T) {
for _, trigger := range []string{suggest.TriggerScheduled, suggest.TriggerManual} {
t.Run(trigger, func(t *testing.T) {
e := newEnv(t)
e.start(t)
var quests, want []string
for i := range suggest.BatchSize + 1 {
name := fmt.Sprintf("Fajita Dish %c", 'A'+i)
e.recipe(t, name)
quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas"))
if i != 0 {
want = append(want, name)
}
}
e.reply(quests...)
// The first round's last search waits, so the household adds a
// dinner repeating Fajita Dish A while that round grounds.
last := fmt.Sprintf("Fajita Dish %c", 'A'+suggest.BatchSize-1)
gate := make(chan struct{})
e.search.Gates = map[string]chan struct{}{last: gate}
b, key := e.startBatch(t, trigger)
deadline := time.Now().Add(10 * time.Second)
for !slices.Contains(e.search.Queries(), last) {
if time.Now().After(deadline) {
close(gate)
t.Fatalf("search for %s never ran", last)
}
time.Sleep(5 * time.Millisecond)
}
e.dinner(t, "Fajita Dish A", "regular")
close(gate)
job := e.waitJob(t, b.JobID)
if job.Status != jobs.StatusSucceeded {
t.Fatalf("batch job %s, error %v", job.Status, job.Error)
}
var res batchResult
if err := json.Unmarshal(job.Result, &res); err != nil {
t.Fatal(err)
}
if len(res.SuggestionIDs) != suggest.BatchSize || res.Dropped != 1 {
t.Errorf("batch result = %+v, want %d stored and 1 dropped", res, suggest.BatchSize)
}
if got := e.deckNames(t); !slices.Equal(got, want) {
t.Errorf("deck = %v, want %v", got, want)
}
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
title := fmt.Sprintf("%d new Side quests are in", suggest.BatchSize)
if len(rows) != 1 || rows[0].Key != key || rows[0].Payload.Title != title {
t.Errorf("new_suggestions rows = %+v, want one keyed %s titled %q", rows, key, title)
}
})
}
}
func TestDeckIsOldestFirst(t *testing.T) { func TestDeckIsOldestFirst(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.start(t) e.start(t)
+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),