Compare commits

..
Author SHA1 Message Date
orchestrator-agent e903e5e526 Remediate feature-review HIGH: report the Supply run outcome after Push to Calendar (#141)
CI / ci (push) Successful in 5m35s
CI / ci (pull_request) Successful in 4m56s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 14:50:38 -04:00
orchestrator-agent 680706df83 Build the Supply archive screen at /plan/shopping/archive (Feature 17) (#139)
CI / ci (push) Successful in 6m3s
CI / ci (pull_request) Successful in 5m1s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 13:02:19 -04:00
orchestrator-agent de75b1a883 Offline toggle queue for the Supply run (Feature 17) (#138)
CI / ci (push) Failing after 4m23s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 11:48:05 -04:00
orchestrator-agent 1a781bfdb8 Move the deployment runbook out of the repository into CTXD (#137)
CI / ci (push) Successful in 4m51s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 10:02:40 -04:00
orchestrator-agent 5d7906ae0d Build the Supply run screen at /plan/shopping (Feature 17) (#136)
CI / ci (push) Successful in 5m1s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 09:49:26 -04:00
orchestrator-agent 428e87674e Freeze ended weeks into the Supply archive and serve the archive API (Feature 17) (#135)
CI / ci (push) Successful in 5m19s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 08:07:39 -04:00
orchestrator-agent 1cbd215286 archive_title skill: title Supply runs on generation (Feature 17) (#134)
CI / ci (push) Successful in 5m5s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 06:57:03 -04:00
40 changed files with 5220 additions and 1594 deletions
+9 -55
View File
@@ -11,61 +11,14 @@ single Go service with the React PWA embedded in the binary.
> `/health`, embedded PWA skeleton, Makefile, Dockerfile, CI). The planned
> application is described in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## Quick start
## Running it locally
Requirements: Go 1.25 and Node.js `^22.22.2 || ^24.15.0 || >=26.0.0` — that
is, 22.22.2 or later on the 22 line, 24.15.0 or later on the 24 line, or 26.0.0
or later. Node.js 23, 24.0–24.14 and 25 are not supported: this is the
`engines` range of jsdom in the committed lockfile, the strictest of all the
web dependencies.
```sh
(cd web && npm ci)
mkdir -p ~/.mealquest-dev # outside the repository: it holds a secret
chmod 700 ~/.mealquest-dev
head -c 32 /dev/urandom | base64 > ~/.mealquest-dev/secret_key
chmod 600 ~/.mealquest-dev/secret_key
export MEALQUEST_HTTP_ADDR=:8080
export MEALQUEST_PUBLIC_URL=http://localhost:5173
export MEALQUEST_DATA_DIR="$HOME/.mealquest-dev"
export MEALQUEST_SHUTDOWN_TIMEOUT=10s
export MEALQUEST_SECRET_KEY_FILE="$HOME/.mealquest-dev/secret_key"
# Authentik OAuth2/OpenID provider for MealQuest (redirect URI:
# $MEALQUEST_PUBLIC_URL/auth/callback). Save its client secret to the file.
export MEALQUEST_OIDC_ISSUER=https://auth.example.com/application/o/mealquest/
export MEALQUEST_OIDC_CLIENT_ID=mealquest
export MEALQUEST_OIDC_CLIENT_SECRET_FILE="$HOME/.mealquest-dev/oidc_client_secret"
export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# Optional: sessions end after MEALQUEST_SESSION_IDLE unused (default 720h)
# and always MEALQUEST_SESSION_MAX after sign-in (default 4320h).
# export MEALQUEST_SESSION_IDLE=720h
# export MEALQUEST_SESSION_MAX=4320h
# Optional: comma-separated IPs/CIDRs of reverse proxies whose
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
# trusts no peer, which is right for local development.
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
# Optional: a SearXNG instance the Settings screen suggests as the recipe
# search URL until one is saved.
# export MEALQUEST_SEARXNG_URL=http://localhost:8888
export MEALQUEST_DEFAULT_TZ=America/New_York
make dev
```
`make dev` runs the Go server on `:8080` and the Vite dev server on `:5173`
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
http://localhost:5173. Configuration is environment-only and fails fast:
every variable above is required except the commented-out session
lifetimes (when set they must be positive, with MAX at least IDLE),
trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL
(an absolute `http://` or `https://` URL without credentials, a query or a
fragment) and `MEALQUEST_DEFAULT_TZ`, and
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
endpoints the issuer advertises must be `https://`; plain `http://` is
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
`MEALQUEST_DEFAULT_TZ` is the IANA zone the household settings are created
with on first boot: until those settings exist, boot fails unless it is a
valid IANA zone. Later boots ignore it, set or not (change the zone in
household settings instead).
The developer setup (prerequisites, Authentik and Google clients, the
`MEALQUEST_*` environment, `make dev` and the optional SearXNG sidecar) is
runbook information and lives in CTXD: project `mealquest`, file
`RUNBOOKS.MD`, section "Local development". Configuration is read only from
the environment, and the service refuses to start, naming every missing or
invalid variable.
## Common commands
@@ -78,7 +31,8 @@ household settings instead).
## Where to read next
- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, conventions.
- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, the quality
floor every change must pass, repository layout, conventions.
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — architecture of record.
- [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md) — product requirements.
- [design/MealQuest UI Designs/design_handoff_mealquest/README.md](design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md) — UI handoff.
+18 -23
View File
@@ -1,20 +1,15 @@
# MealQuest production environment. Copy to deploy/.env (never committed)
# and replace the placeholders. The service fails fast: a missing or invalid
# required variable stops it at boot with the variable named.
# MealQuest production environment: the template for deploy/.env (never
# committed). The service fails fast: a missing or invalid required variable
# stops it at boot with the variable named. Creating deploy/.env: CTXD
# project mealquest, RUNBOOKS.MD "Stack and first deploy" > "3. Environment
# file".
#
# Secrets are never set here. They live in deploy/secrets/ (never committed),
# one value per file, owned by the container user and readable only by it:
#
# mkdir -p deploy/secrets && chmod 700 deploy/secrets
# head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key
# printf '%s\n' '<Authentik client secret>' > deploy/secrets/oidc_client_secret
# printf '%s\n' '<Google OAuth client secret>' > deploy/secrets/google_client_secret
# sudo chown 10001:10001 deploy/secrets/* && sudo chmod 600 deploy/secrets/*
#
# SearXNG's secret key is the exception: compose reads it itself, so it
# stays owned by you (mode 0600). Create it after the chown above:
#
# (umask 077; printf 'SEARXNG_SECRET=%s\n' "$(openssl rand -hex 32)" > deploy/secrets/searxng.env)
# one value per file, owned by the container user 10001:10001 with mode
# 0600: secret_key, oidc_client_secret and google_client_secret. SearXNG's
# searxng.env is the exception: compose reads it itself, so it is owned by
# the host account that runs compose, mode 0600. Creating them: CTXD project
# mealquest, RUNBOOKS.MD "Stack and first deploy" > "4. Secrets".
#
# deploy/compose.yaml mounts them at /run/secrets/<name> and points the
# *_FILE variables at them.
@@ -42,8 +37,7 @@ MEALQUEST_TRUSTED_PROXY=10.60.1.33
# IANA zone the household settings are created with on first boot. Required
# until those settings exist; ignored afterwards (change the zone in
# household settings instead). Confirm the household's zone before the first
# deploy.
# household settings instead).
MEALQUEST_DEFAULT_TZ=America/New_York
# --- Authentik OIDC ----------------------------------------------------------
@@ -57,10 +51,10 @@ MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# --- Google Calendar (Push to Calendar) ------------------------------------
# Client ID of the Google OAuth web client (deploy/RUNBOOK.md "Google
# Calendar OAuth client"). Required: the service will not start without it
# and the client secret file. Its authorized redirect URI is
# <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
# Client ID of the Google OAuth web client (CTXD project mealquest,
# RUNBOOKS.MD "Google Calendar OAuth client"). Required: the service will
# not start without it and the client secret file. Its authorized redirect
# URI is <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
# Optional session lifetimes. A session ends after IDLE unused and always MAX
@@ -82,13 +76,14 @@ MEALQUEST_SESSION_MAX=4320h
# The SearXNG recipe-search sidecar on the compose network. The Settings
# screen pre-fills it as the SearXNG URL until one is saved; the saved
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
# See deploy/RUNBOOK.md "Recipe search (SearXNG)".
# See CTXD project mealquest, RUNBOOKS.MD "Recipe search (SearXNG)".
# --- Planned; not read by the service yet -------------------------------------
#
# Added to deploy/compose.yaml and this file by the features that use them.
#
# Web Push (Feature 19). Generate the key pair with cmd/vapidgen.
# Web Push (Feature 19). Key pair: CTXD project mealquest, RUNBOOKS.MD
# "VAPID keys (Web Push)".
# MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
# MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
-1417
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -1,8 +1,7 @@
# Development override: publishes SearXNG on the dev machine's loopback so
# it can be queried directly, e.g.
#
# docker compose -f deploy/compose.yaml -f deploy/compose.dev.yaml up -d searxng
# curl 'http://127.0.0.1:8888/search?q=chicken+tikka+masala+recipe&format=json'
# Development override layered on deploy/compose.yaml: publishes SearXNG
# on the dev machine's loopback (127.0.0.1:8888) so it can be queried
# directly. Using it: CTXD project mealquest, RUNBOOKS.MD "Local
# development".
#
# Loopback only, never a LAN address. Never used in production: the
# systemd unit starts deploy/compose.yaml alone.
+10 -12
View File
@@ -1,18 +1,16 @@
# Production stack for MealQuest on 10.60.1.14 (D-018). Build on the host,
# then start through systemd:
#
# docker compose -f deploy/compose.yaml build
# sudo systemctl restart mealquest
# Production stack for MealQuest on 10.60.1.14 (D-018). Building, deploying
# and operating it: CTXD project mealquest, RUNBOOKS.MD "Stack unit" and
# "Routine deploy and rollback".
#
# The services use restart: "no" on purpose: Docker must not start them
# before the DOCKER-USER firewall is applied. deploy/systemd/mealquest.service
# owns start and stop and requires mealquest-firewall.service first.
#
# Configuration comes from deploy/.env (copy deploy/.env.example) and secrets
# from deploy/secrets/; neither is committed. Compose bind-mounts file secrets
# with their host owner and mode, so each file in deploy/secrets/ must be
# owned by the container user 10001:10001 with mode 0600 (except
# searxng.env, read by compose itself; see the searxng service).
# Configuration comes from deploy/.env (template: deploy/.env.example) and
# secrets from deploy/secrets/; neither is committed. Compose bind-mounts
# file secrets with their host owner and mode, so each file in
# deploy/secrets/ must be owned by the container user 10001:10001 with mode
# 0600 (except searxng.env, read by compose itself; see the searxng service).
name: mealquest
services:
@@ -65,8 +63,8 @@ services:
# the app's own user (owner of the volume) with a read-only root
# filesystem, no privilege escalation and no Linux capabilities.
#
# Restore: follow "Restore the database" in deploy/RUNBOOK.md (stop the
# stack, move the database with its -wal and -shm aside, restore, check).
# Restoring the database: CTXD project mealquest, RUNBOOKS.MD "Restore
# the database".
litestream:
image: litestream/litestream:0.5.17
user: "10001:10001"
+3 -2
View File
@@ -5,8 +5,9 @@
# are one-way: a failure here fails this unit visibly and never fails
# docker.service.
#
# Start MealQuest only after this unit has succeeded:
# systemctl start mealquest-firewall && docker compose up -d
# mealquest.service requires this unit and starts the stack only after it
# has succeeded. Installing and operating both: CTXD project mealquest,
# RUNBOOKS.MD "Host firewall" and "Stack unit".
[Unit]
Description=Restrict MealQuest port 8090 to Traefik (DOCKER-USER)
After=docker.service
@@ -1,8 +1,7 @@
# Runs the nightly MealQuest media backup (/usr/local/sbin/mealquest-media-backup).
# Triggered by mealquest-media-backup.timer; run by hand with
# systemctl start mealquest-media-backup
# and read the result with
# journalctl -u mealquest-media-backup
# Triggered by mealquest-media-backup.timer; output goes to the journal.
# Installing it and running it by hand: CTXD project mealquest,
# RUNBOOKS.MD "Media — nightly rsync".
#
# The script runs as the app's user (10001, host user "mealquest") with no
# capabilities. systemd, as root, only bind-mounts the data volume's root
+3 -2
View File
@@ -15,8 +15,9 @@
# swapping the path afterwards cannot redirect it. Symlinks inside the
# directory are copied as links (-a), never followed.
#
# The media directory is created at deploy time (deploy/RUNBOOK.md). If it
# is missing the job fails; it is never treated as "nothing to back up".
# The media directory is created at deploy time (CTXD project mealquest,
# RUNBOOKS.MD "Media — nightly rsync"). If it is missing the job fails; it
# is never treated as "nothing to back up".
#
# rsync mirrors into .../mealquest-backups/media/ only: the target ends in
# a slash, so --delete removes files from that directory and nothing
+39
View File
@@ -490,6 +490,31 @@ automatic** (D-021).
generation: the list stays untitled (the UI shows the week label), the
failure is logged as a warning and recorded in `ai_runs`, the job fails
`title_failed`, and the week's next generation tries again.
`shopping.Freeze` titles a list still untitled once more before
freezing it (see Supply archive), through the same skill, scope and
conditional write.
## Supply archive (Feature 17)
`shopping.Freeze(weekStart)` moves a week's Supply run into the Supply
archive: `status` becomes `archived` and `frozen_at` is set, after which
generating the list or changing an item is 409 `archived`. It freezes only
a week that has ended in the household's time zone (its Monday is before
the Monday of the household's current week), so a week not yet ended is
`ErrWeekNotEnded` and a week with no list `ErrListNotFound`; freezing a
frozen list does nothing. A list still untitled is titled first with
`archive_title` from the week's plan, as `Generate` titles it; a title
that cannot be written is logged and the list is frozen untitled, when its
week label stands in for good. Titling and freezing are ordered by a
per-service `RWMutex`: a `title_supply_run` job holds it shared across
reading the list, the `archive_title` run and the title write, and `Freeze`
holds it exclusively from reading the list to freezing it. So a freeze
waits for a job in flight to store its title or fail for good; when the
job failed, the freeze finds the list still untitled and makes its own
`archive_title` attempt before freezing. A job that starts during a freeze
waits for it, then finds the list titled (that title stands) or frozen
untitled (the job fails `title_failed`), and stores nothing. The
`week_rollover` schedule is to call `Freeze` for last week.
## Recipe grounding
@@ -790,6 +815,20 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
needed, needed_count, total_count}` with the list's counts after the
change. 404 for an unknown item, 409 `archived` when the list is in the
Supply archive, 400 `invalid_request` for a bad body.
- `GET /api/v1/shopping/archive?by=week|month|year` returns the Supply
archive `{by, periods: [{key, heading, posts: [{weekStart, label,
title, dinnerCount, itemCount, onHandCount, dinners, current}]}]}`: the
Supply run of every week up to the household's current week, newest
first, grouped by the Monday's ISO week (`key` "2026-W41", `heading` the
week label), month ("2026-10", "October 2026") or year ("2026"). The
Monday is a household-local date, so the week of Monday 28 December
is in December. `label` is the week label ("Week 41 · Oct 5 – 11"; the
UI sets it in capitals), `title` null while untitled, `dinners` the
list's dinners in day order and `dinnerCount` their number,
`itemCount` all items, `onHandCount` the items marked "have it", and
`current` true for the household's current week. 400 `invalid_request`
for any other `by`. A frozen list is read through `GET
/api/v1/shopping/{weekStart}` with `status` `archived`.
## Frontend
+6
View File
@@ -87,6 +87,9 @@ type Querier interface {
// Records a pending or running batch as done or failed; a batch already
// finished is left as it is (0 rows).
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
// Freezes list id into the Supply archive at frozen_at, unless it is
// frozen already.
FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error)
// Returns sql.ErrNoRows until the row exists.
GetAISettings(ctx context.Context) (AiSetting, error)
// The job of kind queued or running for the week starting week_start,
@@ -184,6 +187,9 @@ type Querier interface {
// The items of list list_id with their group's dinner, in list order: by
// group, then by item.
ListShoppingItems(ctx context.Context, listID string) ([]ListShoppingItemsRow, error)
// The Supply runs of the weeks starting on or before week_start, newest
// week first.
ListShoppingListsThrough(ctx context.Context, weekStart string) ([]ShoppingList, error)
// The ids of the dinners with status quest_log planned on date, on a plan
// of any status, that have no Rotation check for date yet.
ListTriedCandidates(ctx context.Context, date string) ([]string, error)
+63
View File
@@ -112,6 +112,28 @@ func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error {
return err
}
const freezeShoppingList = `-- name: FreezeShoppingList :execrows
UPDATE shopping_lists
SET status = 'archived', frozen_at = ?, updated_at = ?
WHERE id = ? AND frozen_at IS NULL
`
type FreezeShoppingListParams struct {
FrozenAt sql.NullString
UpdatedAt string
ID string
}
// Freezes list id into the Supply archive at frozen_at, unless it is
// frozen already.
func (q *Queries) FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error) {
result, err := q.db.ExecContext(ctx, freezeShoppingList, arg.FrozenAt, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getShoppingItemList = `-- name: GetShoppingItemList :one
SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at
FROM shopping_items
@@ -299,6 +321,47 @@ func (q *Queries) ListShoppingItems(ctx context.Context, listID string) ([]ListS
return items, nil
}
const listShoppingListsThrough = `-- name: ListShoppingListsThrough :many
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists
WHERE week_start <= ?
ORDER BY week_start DESC
`
// The Supply runs of the weeks starting on or before week_start, newest
// week first.
func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string) ([]ShoppingList, error) {
rows, err := q.db.QueryContext(ctx, listShoppingListsThrough, weekStart)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ShoppingList
for rows.Next() {
var i ShoppingList
if err := rows.Scan(
&i.ID,
&i.WeekStart,
&i.PlanID,
&i.Title,
&i.Status,
&i.SentToChatAt,
&i.FrozenAt,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const moveShoppingGroup = `-- name: MoveShoppingGroup :exec
UPDATE shopping_groups
SET position = ?, updated_at = ?
+2 -1
View File
@@ -37,6 +37,7 @@ type plansEnv struct {
h http.Handler
st *store.Store
svc *plan.Service
shop *shopping.Service
runner *jobs.Runner
questLog string
// google is the fake Google the plan service pushes to; it starts
@@ -127,7 +128,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
t.Fatal(err)
}
}
return plansEnv{h: h, st: st, svc: svc, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
return plansEnv{h: h, st: st, svc: svc, shop: shop, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
google: google, gcal: googleSvc, logs: logs}
}
+3 -1
View File
@@ -49,7 +49,8 @@ type RouteMounter interface {
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
// POST /api/v1/dinners/{id}/rotation-review, and POST
// /api/v1/plans/{weekStart}/push-calendar with shopSvc, which makes the
// pushed week's Supply run; shopSvc serves GET /api/v1/shopping/{weekStart},
// pushed week's Supply run; shopSvc serves GET /api/v1/shopping/archive,
// GET /api/v1/shopping/{weekStart},
// POST /api/v1/shopping/{weekStart}/generate and PATCH
// /api/v1/shopping/items/{id}; googleSvc serves GET
// /api/v1/integrations/google and its /connect, /callback and /calendars,
@@ -142,6 +143,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc))
apiV1.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc, shopSvc))
apiV1.Get("/shopping/archive", getSupplyArchive(logger, shopSvc))
apiV1.Get("/shopping/{weekStart}", getSupplyRun(logger, shopSvc))
apiV1.Post("/shopping/{weekStart}/generate", postSupplyRunGenerate(logger, shopSvc))
apiV1.Patch("/shopping/items/{id}", patchShoppingItem(logger, shopSvc))
+64
View File
@@ -143,6 +143,70 @@ func patchShoppingItem(logger *slog.Logger, svc *shopping.Service) http.HandlerF
}
}
// supplyArchive is the GET /shopping/archive answer: the Supply archive's
// periods, newest first, as grouped by by.
type supplyArchive struct {
By string `json:"by"`
Periods []archivePeriod `json:"periods"`
}
// archivePeriod is one heading of the Supply archive and its posts,
// newest first.
type archivePeriod struct {
Key string `json:"key"`
Heading string `json:"heading"`
Posts []archivePost `json:"posts"`
}
// archivePost is one week's Supply run in the Supply archive. label is
// the week label, which stands in for title while it is null, and
// onHandCount counts the items marked "have it".
type archivePost struct {
WeekStart string `json:"weekStart"`
Label string `json:"label"`
Title *string `json:"title"`
DinnerCount int `json:"dinnerCount"`
ItemCount int `json:"itemCount"`
OnHandCount int `json:"onHandCount"`
Dinners []string `json:"dinners"`
Current bool `json:"current"`
}
// getSupplyArchive answers GET /shopping/archive?by=week|month|year with
// the Supply archive grouped by that period.
func getSupplyArchive(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
by := r.URL.Query().Get("by")
periods, err := svc.Archive(r.Context(), by)
if errors.Is(err, shopping.ErrInvalidPeriod) {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", shopping.ErrInvalidPeriod.Error())
return
}
if err != nil {
writeShoppingError(w, r, logger, "read Supply archive", err)
return
}
out := supplyArchive{By: by, Periods: make([]archivePeriod, len(periods))}
for i, p := range periods {
posts := make([]archivePost, len(p.Posts))
for j, post := range p.Posts {
posts[j] = archivePost{
WeekStart: post.WeekStart,
Label: post.Label,
Title: post.Title,
DinnerCount: len(post.Dinners),
ItemCount: post.Items,
OnHandCount: post.OnHand,
Dinners: post.Dinners,
Current: post.Current,
}
}
out.Periods[i] = archivePeriod{Key: p.Key, Heading: p.Heading, Posts: posts}
}
writeJSON(w, r, logger, http.StatusOK, out)
}
}
// supplyRunOf is l, the Supply run of the week starting week, as the API
// shows it.
func supplyRunOf(week store.LocalDate, l shopping.List) (supplyRun, error) {
+153
View File
@@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
"slices"
@@ -11,9 +13,12 @@ import (
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
@@ -634,3 +639,151 @@ func TestPushPlanLeavesArchivedSupplyRun(t *testing.T) {
t.Errorf("archived Supply run after push = %v, want unchanged", l.render())
}
}
// archiveJSON is a GET /shopping/archive answer decoded with every field
// the API promises.
type archiveJSON struct {
By string `json:"by"`
Periods []struct {
Key string `json:"key"`
Heading string `json:"heading"`
Posts []struct {
WeekStart string `json:"weekStart"`
Label string `json:"label"`
Title *string `json:"title"`
DinnerCount int `json:"dinnerCount"`
ItemCount int `json:"itemCount"`
OnHandCount int `json:"onHandCount"`
Dinners []string `json:"dinners"`
Current bool `json:"current"`
} `json:"posts"`
} `json:"periods"`
}
// weekSupplyRun plans a new dinner named name, grounded in a recipe
// listing ings, on the Monday of the week starting monday, and generates
// the week's Supply run.
func (e plansEnv) weekSupplyRun(t *testing.T, monday store.LocalDate, name string, ings ...shopIng) supplyRunJSON {
t.Helper()
q := db.New(e.st.Writer)
now := store.Now().String()
d, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "regular", Origin: "regular",
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
planID := store.NewID()
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: planID, WeekStart: monday.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: monday.String(),
DinnerID: sql.NullString{String: d.ID, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
e.groundDay(t, monday.String(), ings...)
var l supplyRunJSON
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+monday.String()+"/generate", "", &l); status != http.StatusOK {
t.Fatalf("generate %s = %d %v", monday, status, body)
}
return l
}
// TestSupplyArchive freezes last week's Supply run and reads the Supply
// archive: posts newest first with exactly the promised fields, this
// week's marked current. The frozen list is served read-only.
func TestSupplyArchive(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
// The fixture's plan is for a fixed week; this test plans weeks
// around today instead.
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM plan_days; DELETE FROM week_plans`); err != nil {
t.Fatal(err)
}
zone, err := household.LoadZone("America/New_York") // the env's household zone
if err != nil {
t.Fatal(err)
}
thisWeek := household.WeekStart(household.Today(time.Now(), zone))
y, m, d := thisWeek.Date()
lastWeek := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
last := e.weekSupplyRun(t, lastWeek, "Beef Stew", shopIng{"stewing beef", "2", "lb"}, shopIng{"carrots", "4", ""}, shopIng{"onion", "1", ""})
onion := last.itemID(t, "onion")
if status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+onion, `{"needed":false}`, nil); status != http.StatusOK {
t.Fatalf("PATCH = %d %v", status, body)
}
e.weekSupplyRun(t, thisWeek, "Lemon Chicken", shopIng{"chicken thighs", "6", ""}, shopIng{"lemon", "1", ""})
if err := e.shop.Freeze(t.Context(), lastWeek); err != nil {
t.Fatal(err)
}
if err := e.shop.Freeze(t.Context(), thisWeek); !errors.Is(err, shopping.ErrWeekNotEnded) {
t.Fatalf("Freeze this week = %v, want ErrWeekNotEnded", err)
}
var got archiveJSON
status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got)
if status != http.StatusOK {
t.Fatalf("GET archive = %d %v", status, raw)
}
if k := keys(raw); !reflect.DeepEqual(k, []string{"by", "periods"}) {
t.Errorf("archive fields = %v", k)
}
period := raw["periods"].([]any)[0].(map[string]any)
if k := keys(period); !reflect.DeepEqual(k, []string{"heading", "key", "posts"}) {
t.Errorf("period fields = %v", k)
}
wantPost := []string{"current", "dinnerCount", "dinners", "itemCount", "label", "onHandCount", "title", "weekStart"}
if k := keys(period["posts"].([]any)[0].(map[string]any)); !reflect.DeepEqual(k, wantPost) {
t.Errorf("post fields = %v, want %v", k, wantPost)
}
var posts []string
for _, p := range got.Periods {
if len(p.Posts) != 1 || p.Key != household.PeriodKey(mustLocalDate(t, p.Posts[0].WeekStart)) || p.Heading != p.Posts[0].Label {
t.Errorf("period %+v, want one post under its week", p)
}
for _, post := range p.Posts {
label, err := household.WeekLabel(mustLocalDate(t, post.WeekStart))
if err != nil {
t.Fatal(err)
}
if post.Label != label || post.Title != nil {
t.Errorf("post %s: label %q title %v, want %q and null", post.WeekStart, post.Label, post.Title, label)
}
posts = append(posts, fmt.Sprintf("%s %d dinners %d items %d on hand %v current=%t",
post.WeekStart, post.DinnerCount, post.ItemCount, post.OnHandCount, post.Dinners, post.Current))
}
}
want := []string{
thisWeek.String() + " 1 dinners 2 items 0 on hand [Lemon Chicken] current=true",
lastWeek.String() + " 1 dinners 3 items 1 on hand [Beef Stew] current=false",
}
if got.By != "week" || !reflect.DeepEqual(posts, want) {
t.Errorf("archive by %s =\n %s\nwant\n %s", got.By, strings.Join(posts, "\n "), strings.Join(want, "\n "))
}
for _, q := range []string{"", "?by=", "?by=day", "?by=MONTH"} {
status, body := callShopping(t, e, http.MethodGet, shoppingPath+"archive"+q, "", nil)
if status != http.StatusBadRequest || errorCode(body) != "invalid_request" {
t.Errorf("GET archive%s = %d %v, want 400 invalid_request", q, status, body)
}
}
status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+last.itemID(t, "carrots"), `{"needed":false}`, nil)
if status != http.StatusConflict || errorCode(body) != "archived" {
t.Errorf("PATCH after Freeze = %d %v, want 409 archived", status, body)
}
var frozen supplyRunJSON
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+lastWeek.String(), "", &frozen); status != http.StatusOK {
t.Fatalf("GET frozen list = %d %v", status, body)
}
wantLines := []string{
"Monday " + lastWeek.String() + " Beef Stew: stewing beef [2 lb] needed",
"Monday " + lastWeek.String() + " Beef Stew: carrots [4] needed",
"Monday " + lastWeek.String() + " Beef Stew: onion [1] have it",
}
if frozen.Status != "archived" || frozen.NeededCount != 2 || frozen.TotalCount != 3 || !reflect.DeepEqual(frozen.render(), wantLines) {
t.Errorf("frozen list = %s %d/%d %v, want archived 2/3 %v", frozen.Status, frozen.NeededCount, frozen.TotalCount, frozen.render(), wantLines)
}
}
+232
View File
@@ -0,0 +1,232 @@
package shopping
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// Archive groupings: the period Archive groups its posts by.
const (
ByWeek = "week"
ByMonth = "month"
ByYear = "year"
)
// Freeze moves the Supply run of the week starting weekStart into the
// Supply archive: its status becomes archived and frozen_at is set, after
// which it is read-only (Generate and SetNeeded are ErrFrozen). Freezing a
// frozen list does nothing and is no error.
//
// The week must have ended in the household's time zone: a week not yet
// ended is ErrWeekNotEnded, and a week with no Supply run ErrListNotFound.
//
// A list still untitled is titled first, with archive_title and the same
// conditional write as a KindTitleSupplyRun job, from the week's plan as
// Generate titles it, so its post in the Supply archive gets a title when
// the AI can write one. Titling never fails the freeze: a title that
// cannot be written is logged and the list is frozen untitled, when its
// week label stands in for the title for good. Freeze first waits for the
// title jobs in flight to store their titles or fail, and holds off new
// ones until the list is frozen, so a title a job's archive_title run
// wrote is stored before the freeze, and the freeze runs archive_title
// only when no job is running it. A job that runs after the freeze finds
// the list titled, or frozen untitled, and stores nothing.
func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error {
if _, err := household.WeekDays(weekStart); err != nil {
return fmt.Errorf("shopping: freeze: %w", err)
}
q := db.New(s.st.Writer)
thisWeek, err := s.thisWeek(ctx, q)
if err != nil {
return err
}
if weekStart.String() >= thisWeek.String() {
return fmt.Errorf("shopping: freeze %s: %w", weekStart, ErrWeekNotEnded)
}
s.titling.Lock()
defer s.titling.Unlock()
list, err := q.GetShoppingList(ctx, weekStart.String())
if errors.Is(err, sql.ErrNoRows) {
return ErrListNotFound
}
if err != nil {
return fmt.Errorf("shopping: freeze: read list: %w", err)
}
if frozen(list) {
return nil
}
if !list.Title.Valid {
p, err := freezeTitleJob(ctx, q, list)
if err != nil {
return err
}
if _, err := s.storeTitle(ctx, p); err != nil {
s.logger.WarnContext(ctx, "Supply run frozen untitled; the Supply archive shows its week label",
"week", list.WeekStart, "list", list.ID, "error", err)
}
}
// A list frozen meanwhile is left as it is: the freeze is done.
now := store.Now().String()
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
FrozenAt: sql.NullString{String: now, Valid: true}, UpdatedAt: now, ID: list.ID,
}); err != nil {
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
}
return nil
}
// freezeTitleJob is the title job of list as Generate would enqueue it:
// the week's plan revision and its dinners in day order.
func freezeTitleJob(ctx context.Context, q *db.Queries, list db.ShoppingList) (TitleJob, error) {
plan, err := q.GetWeekPlan(ctx, list.WeekStart)
if err != nil {
return TitleJob{}, fmt.Errorf("shopping: freeze: read plan: %w", err)
}
days, err := q.ListPlanDays(ctx, plan.ID)
if err != nil {
return TitleJob{}, fmt.Errorf("shopping: freeze: read plan days: %w", err)
}
names := make([]string, len(days))
for i, d := range days {
names[i] = d.Dinner.Name
}
return TitleJob{ListID: list.ID, WeekStart: list.WeekStart, Revision: plan.Revision, Dinners: names}, nil
}
// thisWeek returns the Monday of the household's current week, in its
// time zone, by the service's clock.
func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate, error) {
settings, err := q.GetHouseholdSettings(ctx)
if err != nil {
return store.LocalDate{}, fmt.Errorf("shopping: read household settings: %w", err)
}
zone, err := household.LoadZone(settings.Timezone)
if err != nil {
return store.LocalDate{}, fmt.Errorf("shopping: household settings: %w", err)
}
return household.WeekStart(household.Today(s.now(), zone)), nil
}
// Period is one heading of the Supply archive and its posts, newest first.
type Period struct {
// Key identifies the period: the ISO week ("2026-W41"), the month
// ("2026-10") or the year ("2026").
Key string
// Heading is the period as shown: the week label, the month and year
// ("October 2026") or the year ("2026").
Heading string
Posts []Post
}
// Post is one Supply run in the Supply archive.
type Post struct {
WeekStart string
// Label is the week label, such as "Week 41 · Oct 5 – 11".
Label string
// Title is nil while the list is untitled, when Label stands in.
Title *string
// Dinners are the names of the list's dinners, in day order.
Dinners []string
// Items is how many items the list has, and OnHand how many of them
// the household marked "have it".
Items, OnHand int
// Current is true for the household's current week.
Current bool
}
// Archive returns the Supply archive: the Supply run of every week up to
// and including the household's current week, newest first, grouped by
// by, which is ByWeek, ByMonth or ByYear (else ErrInvalidPeriod). A
// week's month and year are those of its Monday, a household-local date,
// so a week belongs to one period whatever the server's zone; the week
// starting Monday 2026-12-28 is in December 2026.
func (s *Service) Archive(ctx context.Context, by string) (out []Period, err error) {
// period returns the key and heading of the period holding p, whose
// week starts on Monday monday.
var period func(monday store.LocalDate, p Post) (string, string)
switch by {
case ByWeek:
period = func(monday store.LocalDate, p Post) (string, string) {
return household.PeriodKey(monday), p.Label
}
case ByMonth:
period = func(monday store.LocalDate, _ Post) (string, string) {
y, m, _ := monday.Date()
return fmt.Sprintf("%04d-%02d", y, int(m)), fmt.Sprintf("%s %d", m, y)
}
case ByYear:
period = func(monday store.LocalDate, _ Post) (string, string) {
y, _, _ := monday.Date()
return strconv.Itoa(y), strconv.Itoa(y)
}
default:
return nil, fmt.Errorf("shopping: archive by %q: %w", by, ErrInvalidPeriod)
}
tx, err := s.st.Reader.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("shopping: begin read: %w", err)
}
defer func() {
err = errors.Join(err, tx.Rollback())
}()
q := db.New(tx)
thisWeek, err := s.thisWeek(ctx, q)
if err != nil {
return nil, err
}
lists, err := q.ListShoppingListsThrough(ctx, thisWeek.String())
if err != nil {
return nil, fmt.Errorf("shopping: read Supply runs: %w", err)
}
out = []Period{}
for _, row := range lists {
monday, err := store.ParseLocalDate(row.WeekStart)
if err != nil {
return nil, fmt.Errorf("shopping: Supply run %s week: %w", row.ID, err)
}
l, err := readList(ctx, q, row)
if err != nil {
return nil, err
}
p, err := postOf(monday, l, monday == thisWeek)
if err != nil {
return nil, err
}
k, heading := period(monday, p)
if n := len(out); n == 0 || out[n-1].Key != k {
out = append(out, Period{Key: k, Heading: heading})
}
out[len(out)-1].Posts = append(out[len(out)-1].Posts, p)
}
return out, nil
}
// postOf is l, the Supply run of the week starting monday, as a post in
// the Supply archive.
func postOf(monday store.LocalDate, l List, current bool) (Post, error) {
label, err := household.WeekLabel(monday)
if err != nil {
return Post{}, fmt.Errorf("shopping: Supply run %s: %w", l.ID, err)
}
dinners := make([]string, len(l.Groups))
for i, g := range l.Groups {
dinners[i] = g.DinnerName
}
c := l.Counts()
return Post{
WeekStart: l.WeekStart,
Label: label,
Title: l.Title,
Dinners: dinners,
Items: c.Total,
OnHand: c.Total - c.Needed,
Current: current,
}, nil
}
+477
View File
@@ -0,0 +1,477 @@
package shopping_test
import (
"context"
"database/sql"
"errors"
"reflect"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// zoned makes zone the household's time zone and at the instant the
// service's clock reads.
func (e *env) zoned(t *testing.T, zone, at string) {
t.Helper()
if err := household.SeedSettings(t.Context(), e.st, zone); err != nil {
t.Fatal(err)
}
e.at(t, at)
}
// at sets the service's clock to the RFC 3339 instant at.
func (e *env) at(t *testing.T, at string) {
t.Helper()
now, err := time.Parse(time.RFC3339, at)
if err != nil {
t.Fatal(err)
}
shopping.SetClock(e.svc, func() time.Time { return now })
}
// frozenState returns the week's list's status and frozen_at; frozen_at is
// "" while NULL.
func (e *env) frozenState(t *testing.T, monday store.LocalDate) (string, string) {
t.Helper()
var status string
var frozenAt sql.NullString
if err := e.st.Reader.QueryRow(`SELECT status, frozen_at FROM shopping_lists WHERE week_start = ?`, monday.String()).Scan(&status, &frozenAt); err != nil {
t.Fatal(err)
}
return status, frozenAt.String
}
// weekList plans the dinners named names on the days of the week starting
// monday, in order, and generates its Supply run.
func (e *env) weekList(t *testing.T, monday string, names ...string) shopping.List {
t.Helper()
start := mustDate(monday)
days, err := household.WeekDays(start)
if err != nil {
t.Fatal(err)
}
q := db.New(e.st.Writer)
now := store.Now().String()
planID := store.NewID()
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: planID, WeekStart: monday, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
for i, name := range names {
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{
ID: store.NewID(), PlanID: planID, Date: days[i].String(),
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
}
g, err := e.svc.Generate(t.Context(), start)
if err != nil {
t.Fatal(err)
}
return g.List
}
// endedWeek is the env with the tests' week generated, in New York, on
// the Monday after it, so the week has ended.
func endedWeek(t *testing.T) (*env, shopping.List) {
t.Helper()
e := newEnv(t)
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
e.plan(t, "Beef Chili")
return e, e.generate(t).List
}
// TestFreeze freezes an ended week's list: it is archived with frozen_at,
// read back read-only, and freezing it again changes nothing.
func TestFreeze(t *testing.T) {
e, l := endedWeek(t)
if err := e.svc.Freeze(t.Context(), week); err != nil {
t.Fatal(err)
}
status, frozenAt := e.frozenState(t, week)
if status != shopping.StatusArchived || frozenAt == "" {
t.Fatalf("after Freeze: status %q, frozen_at %q; want archived and set", status, frozenAt)
}
got, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if got.Status != shopping.StatusArchived {
t.Errorf("read back status = %q, want archived", got.Status)
}
wantLines(t, "read back", lines(got), []string{
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
"0 2026-10-12 Beef Chili: 1 onion [1] needed",
})
time.Sleep(2 * time.Millisecond) // a second freeze would stamp a later frozen_at
if err := e.svc.Freeze(t.Context(), week); err != nil {
t.Fatalf("second Freeze = %v, want no error", err)
}
if s, again := e.frozenState(t, week); s != shopping.StatusArchived || again != frozenAt {
t.Errorf("after second Freeze: status %q, frozen_at %q; want archived, %q kept", s, again, frozenAt)
}
if _, err := e.svc.SetNeeded(t.Context(), l.Groups[0].Items[1].ID, false); !errors.Is(err, shopping.ErrFrozen) {
t.Errorf("SetNeeded after Freeze = %v, want ErrFrozen", err)
}
if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrFrozen) {
t.Errorf("Generate after Freeze = %v, want ErrFrozen", err)
}
wantLines(t, "stored list", e.stored(t), []string{
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
"0 2026-10-12 Beef Chili: 1 onion [1] needed",
})
}
// TestFreezeWeekNotEnded: a week has not ended until Monday comes in the
// household's zone. At 23:30 on the Sunday in Los Angeles it is already
// Monday in UTC, and the list is still not frozen; at 00:05 on the Monday
// it is.
func TestFreezeWeekNotEnded(t *testing.T) {
e := newEnv(t)
e.zoned(t, "America/Los_Angeles", "2026-10-19T06:30:00Z") // Sun 18 Oct 23:30 PDT
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Beef Chili")
e.generate(t)
if err := e.svc.Freeze(t.Context(), week); !errors.Is(err, shopping.ErrWeekNotEnded) {
t.Fatalf("Freeze on the Sunday = %v, want ErrWeekNotEnded", err)
}
if status, frozenAt := e.frozenState(t, week); status != shopping.StatusCurrent || frozenAt != "" {
t.Fatalf("after refused Freeze: status %q, frozen_at %q; want current, unset", status, frozenAt)
}
if err := e.svc.Freeze(t.Context(), mustDate("2026-10-19")); !errors.Is(err, shopping.ErrWeekNotEnded) {
t.Errorf("Freeze of a later week = %v, want ErrWeekNotEnded", err)
}
e.at(t, "2026-10-19T07:05:00Z") // Mon 19 Oct 00:05 PDT
if err := e.svc.Freeze(t.Context(), week); err != nil {
t.Fatalf("Freeze on the Monday = %v", err)
}
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
t.Errorf("status = %q, want archived", status)
}
}
func TestFreezeWeekWithoutList(t *testing.T) {
e := newEnv(t)
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
if err := e.svc.Freeze(t.Context(), week); !errors.Is(err, shopping.ErrListNotFound) {
t.Fatalf("Freeze = %v, want ErrListNotFound", err)
}
if err := e.svc.Freeze(t.Context(), mustDate("2026-10-13")); err == nil {
t.Fatal("Freeze of a Tuesday succeeded")
}
}
// TestFreezeTitlesUntitledList: a list still untitled when its week is
// frozen is titled by archive_title first, from the plan's dinners, and
// frozen with that title. Its title job, still queued, runs after the
// freeze, finds the title and succeeds without asking the model again.
func TestFreezeTitlesUntitledList(t *testing.T) {
e := newEnv(t)
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.dinner(t, "Fish Tacos")
e.plan(t, "Fish Tacos", "Beef Chili")
g := e.generate(t) // the runner is not started: its title job waits
e.model.QueueGenerate(titled("Tacos then Chili"))
if err := e.svc.Freeze(t.Context(), week); err != nil {
t.Fatal(err)
}
wantTitle(t, "stored title", e.storedTitle(t), "Tacos then Chili")
if status, frozenAt := e.frozenState(t, week); status != shopping.StatusArchived || frozenAt == "" {
t.Errorf("status %q, frozen_at %q; want archived and set", status, frozenAt)
}
reqs := e.model.Requests()
if len(reqs) != 1 {
t.Fatalf("%d model calls, want 1", len(reqs))
}
if want := "Title the week whose dinners are, in day order from Monday:\n- Fish Tacos\n- Beef Chili"; reqs[0].Messages[0].Content != want {
t.Errorf("user message = %q, want %q", reqs[0].Messages[0].Content, want)
}
e.start(t)
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
if n := len(e.model.Requests()); n != 1 {
t.Errorf("%d model calls, want 1: the job finds the list titled", n)
}
wantTitle(t, "stored title after the job", e.storedTitle(t), "Tacos then Chili")
}
// pauseTitle makes the env's first archive_title run pause once it has
// returned, before its title is stored, until the returned release is
// called or the test ends; paused is closed as it pauses.
func (e *env) pauseTitle(t *testing.T) (paused <-chan struct{}, release func()) {
t.Helper()
p, r := make(chan struct{}), make(chan struct{})
var first atomic.Bool
e.afterTitle = func() {
if first.CompareAndSwap(false, true) {
close(p)
select {
case <-r:
case <-t.Context().Done():
}
}
}
var released sync.Once
return p, func() { released.Do(func() { close(r) }) }
}
// waitClosed fails t unless c is closed within 10 seconds.
func waitClosed(t *testing.T, what string, c <-chan struct{}) {
t.Helper()
select {
case <-c:
case <-time.After(10 * time.Second):
t.Fatalf("timed out waiting for %s", what)
}
}
// TestFreezeWaitsForTitleJob: a freeze that starts while a title job's
// archive_title run has returned but its title is not yet stored waits for
// the job to store it, freezes the list with that title, and asks the
// model nothing itself.
func TestFreezeWaitsForTitleJob(t *testing.T) {
e := newEnv(t)
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Beef Chili")
e.model.QueueGenerate(titled("Chili Night"))
paused, release := e.pauseTitle(t)
e.start(t)
g := e.generate(t)
waitClosed(t, "the title job's archive_title run", paused)
froze := make(chan error, 1)
go func() { froze <- e.svc.Freeze(context.Background(), week) }()
var err error
select {
case err = <-froze:
t.Errorf("Freeze = %v before the title job stored its title, want it to wait", err)
release()
case <-time.After(time.Second):
release()
err = <-froze
}
if err != nil {
t.Fatal(err)
}
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
t.Errorf("status = %q, want archived", status)
}
if n := len(e.model.Requests()); n != 1 {
t.Errorf("%d model calls, want 1: the freeze waits for the job's title", n)
}
}
// TestTitleJobWaitsForFreeze: a title job that starts while a freeze's
// archive_title run has returned but its title is not yet stored waits for
// the freeze, then finds the list titled and asks the model nothing.
func TestTitleJobWaitsForFreeze(t *testing.T) {
e := newEnv(t)
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Beef Chili")
e.model.QueueGenerate(titled("Chili Night"))
g := e.generate(t) // the runner is not started: its title job waits
paused, release := e.pauseTitle(t)
froze := make(chan error, 1)
go func() { froze <- e.svc.Freeze(context.Background(), week) }()
waitClosed(t, "the freeze's archive_title run", paused)
e.start(t)
deadline := time.Now().Add(10 * time.Second)
for {
job, err := e.runner.Get(t.Context(), *g.TitleJobID)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusRunning {
break
}
if job.Status != jobs.StatusQueued || time.Now().After(deadline) {
t.Fatalf("title job %s, want it running while the freeze is paused", job.Status)
}
time.Sleep(5 * time.Millisecond)
}
release()
if err := <-froze; err != nil {
t.Fatal(err)
}
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
t.Errorf("status = %q, want archived", status)
}
if n := len(e.model.Requests()); n != 1 {
t.Errorf("%d model calls, want 1: the job waits for the freeze's title", n)
}
}
// TestFreezeUntitledWhenTitleFails: when archive_title cannot title the
// list, it is frozen untitled all the same, the failure is logged, and the
// Supply archive shows its week label for good.
func TestFreezeUntitledWhenTitleFails(t *testing.T) {
e, _ := endedWeek(t) // nothing queued on the model: archive_title fails
if err := e.svc.Freeze(t.Context(), week); err != nil {
t.Fatal(err)
}
wantUntitled(t, "stored title", e.storedTitle(t))
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
t.Errorf("status = %q, want archived", status)
}
if !strings.Contains(e.log.String(), "Supply run frozen untitled") {
t.Errorf("log lacks the failure:\n%s", e.log.String())
}
periods, err := e.svc.Archive(t.Context(), shopping.ByWeek)
if err != nil {
t.Fatal(err)
}
if len(periods) != 1 || len(periods[0].Posts) != 1 || periods[0].Posts[0].Title != nil ||
periods[0].Posts[0].Label != "Week 42 · Oct 12 – 18" {
t.Errorf("archive = %+v, want the untitled week under its label", periods)
}
}
// TestFreezeKeepsTitle: a titled list is frozen with its title and the
// model is not asked again.
func TestFreezeKeepsTitle(t *testing.T) {
e, _ := endedWeek(t)
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili Night'`); err != nil {
t.Fatal(err)
}
if err := e.svc.Freeze(t.Context(), week); err != nil {
t.Fatal(err)
}
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
if n := len(e.model.Requests()); n != 0 {
t.Errorf("%d model calls, want none", n)
}
}
// postLine renders p as "weekStart label | title | dinners | items/on hand
// | current".
func postLine(p shopping.Post) string {
title := "-"
if p.Title != nil {
title = *p.Title
}
cur := ""
if p.Current {
cur = " | current"
}
return p.WeekStart + " " + p.Label + " | " + title + " | " + strings.Join(p.Dinners, ", ") + " | " +
strconv.Itoa(p.Items) + " items, " + strconv.Itoa(p.OnHand) + " on hand" + cur
}
// periodLines renders periods one line per heading and post.
func periodLines(periods []shopping.Period) []string {
var out []string
for _, p := range periods {
out = append(out, "# "+p.Key+" "+p.Heading)
for _, post := range p.Posts {
out = append(out, postLine(post))
}
}
return out
}
// TestArchiveGroups reads the Supply archive across a month and a year
// boundary in Auckland, where it is already Monday 4 January 2027 while
// UTC still reads Sunday 3 January: that week is current, the next one is
// not yet in the archive, and each week falls in the month and year of
// its Monday.
func TestArchiveGroups(t *testing.T) {
e := newEnv(t)
e.zoned(t, "Pacific/Auckland", "2027-01-03T12:00:00Z") // Mon 4 Jan 2027 01:00 NZDT
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}, ing{"kidney beans", "2", "cans"})
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"lime", "2", ""})
e.dinner(t, "Fish Tacos") // no recipe: no group, not counted
e.dinner(t, "Pot Roast", ing{"carrots", "4", ""})
nov := e.weekList(t, "2026-11-30", "Pot Roast") // Nov 30 – Dec 6
e.weekList(t, "2026-12-07", "Pad Thai", "Fish Tacos")
dec := e.weekList(t, "2026-12-28", "Beef Chili", "Fish Tacos", "Pad Thai") // ISO 2026-W53, into January
jan := e.weekList(t, "2027-01-04", "Pad Thai", "Beef Chili")
e.weekList(t, "2027-01-11", "Pot Roast") // next week: not in the archive yet
e.haveIt(t, dec, "Beef Chili", "onion")
e.haveIt(t, dec, "Pad Thai", "lime")
e.haveIt(t, jan, "Pad Thai", "lime")
e.haveIt(t, nov, "Pot Roast", "carrots")
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili, Noodles & Co' WHERE week_start = '2026-12-28'`); err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2027-01-03T11:05:00.000Z' WHERE week_start < '2027-01-04'`); err != nil {
t.Fatal(err)
}
posts := map[string]string{
"jan": "2027-01-04 Week 1 · Jan 4 – 10 | - | Pad Thai, Beef Chili | 5 items, 1 on hand | current",
"dec": "2026-12-28 Week 53 · Dec 28 – Jan 3 | Chili, Noodles & Co | Beef Chili, Pad Thai | 5 items, 2 on hand",
"d07": "2026-12-07 Week 50 · Dec 7 – 13 | - | Pad Thai | 2 items, 0 on hand",
"nov": "2026-11-30 Week 49 · Nov 30 – Dec 6 | - | Pot Roast | 1 items, 1 on hand",
}
for _, tc := range []struct {
by string
want []string
}{
{shopping.ByWeek, []string{
"# 2027-W01 Week 1 · Jan 4 – 10", posts["jan"],
"# 2026-W53 Week 53 · Dec 28 – Jan 3", posts["dec"],
"# 2026-W50 Week 50 · Dec 7 – 13", posts["d07"],
"# 2026-W49 Week 49 · Nov 30 – Dec 6", posts["nov"],
}},
{shopping.ByMonth, []string{
"# 2027-01 January 2027", posts["jan"],
"# 2026-12 December 2026", posts["dec"], posts["d07"],
"# 2026-11 November 2026", posts["nov"],
}},
{shopping.ByYear, []string{
"# 2027 2027", posts["jan"],
"# 2026 2026", posts["dec"], posts["d07"], posts["nov"],
}},
} {
got, err := e.svc.Archive(t.Context(), tc.by)
if err != nil {
t.Fatalf("Archive(%s): %v", tc.by, err)
}
if lines := periodLines(got); !reflect.DeepEqual(lines, tc.want) {
t.Errorf("Archive(%s) =\n %s\nwant\n %s", tc.by, strings.Join(lines, "\n "), strings.Join(tc.want, "\n "))
}
}
}
func TestArchiveEmptyAndInvalidPeriod(t *testing.T) {
e := newEnv(t)
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
got, err := e.svc.Archive(t.Context(), shopping.ByMonth)
if err != nil || got == nil || len(got) != 0 {
t.Errorf("Archive of no Supply runs = %#v, %v; want empty", got, err)
}
for _, by := range []string{"", "day", "Week", "months"} {
if _, err := e.svc.Archive(t.Context(), by); !errors.Is(err, shopping.ErrInvalidPeriod) {
t.Errorf("Archive(%q) = %v, want ErrInvalidPeriod", by, err)
}
}
}
+6
View File
@@ -0,0 +1,6 @@
package shopping
import "time"
// SetClock makes now the clock s ends and dates weeks on.
func SetClock(s *Service, now func() time.Time) { s.now = now }
+42 -6
View File
@@ -8,6 +8,10 @@
// (see Generate). The one model call is the Supply run's title, written by
// archive_title in a KindTitleSupplyRun job when the list is generated
// untitled (see Generate).
//
// Freeze moves a week's Supply run into the Supply archive once its week
// has ended, and Archive reads the archive back as posts grouped by week,
// month or year.
package shopping
import (
@@ -19,6 +23,8 @@ import (
"fmt"
"log/slog"
"strings"
"sync"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
@@ -70,6 +76,14 @@ var ErrListNotFound = errors.New("the week has no Supply run")
// ErrItemNotFound is a change to an item on no Supply run.
var ErrItemNotFound = errors.New("no Supply run has that item")
// ErrWeekNotEnded is a freeze of a Supply run whose week has not ended in
// the household's time zone.
var ErrWeekNotEnded = errors.New("the week has not ended")
// ErrInvalidPeriod is an Archive grouping other than ByWeek, ByMonth and
// ByYear.
var ErrInvalidPeriod = errors.New("by must be week, month or year")
// Titler runs archive_title on in; scope names the week, its plan revision
// and its dinners.
// In production it is skills.TitleArchive with the household's
@@ -82,6 +96,14 @@ type Service struct {
title Titler
titleJob *jobs.Kind[TitleJob]
logger *slog.Logger
// now is the clock weeks are ended and current on; tests replace it.
now func() time.Time
// titling orders the title writes against Freeze. A title job holds it
// shared from reading the list to storing its title, and Freeze holds
// it exclusively from reading the list to freezing it, so a freeze
// waits for the title jobs in flight to store their titles or fail,
// and no title job runs archive_title while a freeze is under way.
titling sync.RWMutex
}
// NewService returns a service over st that titles Supply runs with title
@@ -98,7 +120,7 @@ func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog
case logger == nil:
return nil, errors.New("shopping: nil logger")
}
s := &Service{st: st, title: title, logger: logger}
s := &Service{st: st, title: title, logger: logger, now: time.Now}
kind, err := jobs.Register(runner, KindTitleSupplyRun, s.runTitleJob, ReasonTitleFailed)
if err != nil {
return nil, err
@@ -345,10 +367,19 @@ func (s *Service) runTitleJob(ctx context.Context, p TitleJob, _ func(titleResul
return titleResult{ListID: p.ListID, Title: title}, nil
}
// writeTitle runs archive_title for p's Supply run and stores its title.
// When the list was titled by another job, before this one ran or
// meanwhile, that title stands and is returned.
// writeTitle runs archive_title for p's Supply run and stores its title,
// as storeTitle does, while no freeze is under way: it waits for one to
// end first, and a freeze waits for it to end.
func (s *Service) writeTitle(ctx context.Context, p TitleJob) (string, error) {
s.titling.RLock()
defer s.titling.RUnlock()
return s.storeTitle(ctx, p)
}
// storeTitle runs archive_title for p's Supply run and stores its title.
// When the list was titled by another job, before this one ran or
// meanwhile, that title stands and is returned. The caller holds titling.
func (s *Service) storeTitle(ctx context.Context, p TitleJob) (string, error) {
q := db.New(s.st.Writer)
title, err := s.currentTitle(ctx, q, p)
if err != nil || title != "" {
@@ -423,13 +454,18 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, e
if err != nil {
return List{}, fmt.Errorf("shopping: read list: %w", err)
}
return readList(ctx, q, list)
}
// readList reads the groups and items of list through q.
func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, error) {
rows, err := q.ListShoppingGroupDinners(ctx, list.ID)
if err != nil {
return List{}, fmt.Errorf("shopping: read groups: %w", err)
return List{}, fmt.Errorf("shopping: read groups of %s: %w", list.WeekStart, err)
}
items, err := q.ListShoppingItems(ctx, list.ID)
if err != nil {
return List{}, fmt.Errorf("shopping: read items: %w", err)
return List{}, fmt.Errorf("shopping: read items of %s: %w", list.WeekStart, err)
}
byGroup := make(map[string][]Item, len(rows))
for _, r := range items {
+8 -1
View File
@@ -54,6 +54,9 @@ type env struct {
model *llm.Fake
// beforeTitle, when set, runs as archive_title is asked for a title.
beforeTitle func()
// afterTitle, when set, runs once archive_title has returned, before
// its title is stored.
afterTitle func()
// beforeModel, when set, runs as the model is sent a request.
beforeModel func(llm.Request)
// log holds what the service logged.
@@ -108,7 +111,11 @@ func newEnv(t *testing.T) *env {
if e.beforeTitle != nil {
e.beforeTitle()
}
return skills.TitleArchive(ctx, cfg, scope, in)
out, err := skills.TitleArchive(ctx, cfg, scope, in)
if e.afterTitle != nil {
e.afterTitle()
}
return out, err
}
logger := slog.New(slog.NewTextHandler(e.log, nil))
e.runner, err = jobs.NewRunner(st, logger, 2)
+14
View File
@@ -88,3 +88,17 @@ WHERE id = ?;
UPDATE shopping_lists
SET title = ?, updated_at = ?
WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL;
-- name: FreezeShoppingList :execrows
-- Freezes list id into the Supply archive at frozen_at, unless it is
-- frozen already.
UPDATE shopping_lists
SET status = 'archived', frozen_at = ?, updated_at = ?
WHERE id = ? AND frozen_at IS NULL;
-- name: ListShoppingListsThrough :many
-- The Supply runs of the weeks starting on or before week_start, newest
-- week first.
SELECT * FROM shopping_lists
WHERE week_start <= ?
ORDER BY week_start DESC;
+38
View File
@@ -8,6 +8,7 @@ import App, { routes } from './App.tsx'
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
import { InDataRouter } from './test/dataRouter.tsx'
import { dinnerBody } from './test/dinners.ts'
import { defaultWeek } from './plan/week.ts'
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
@@ -31,6 +32,8 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
const SCREENS = {
'/onboarding': true,
'/plan': true,
'/plan/shopping': true,
'/plan/shopping/archive': true,
'/discover': true,
'/settings': true,
'/settings/ai': true,
@@ -105,6 +108,41 @@ describe('App routes', () => {
expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument()
})
it('/plan/shopping renders the Supply run inside the shell', async () => {
mockViewport(390)
const week = defaultWeek(HOUSEHOLD_SETTINGS, new Date())
stubFetchByPath({
...SIGNED_IN,
[`/api/v1/shopping/${week}`]: () =>
jsonResponse(200, {
weekStart: week,
label: 'Week 41 · Oct 5 – 11',
title: null,
status: 'current',
sent_to_chat_at: null,
needed_count: 1,
total_count: 1,
groups: [{ dinner: { id: 'd1', name: 'Beef tacos' }, date: week, day: 'Monday', items: [{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true }] }],
}),
})
renderAt('/plan/shopping')
const heading = await screen.findByRole('heading', { level: 1, name: 'Shopping list' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByRole('checkbox', { name: 'Ground beef' })).toBeChecked()
})
it('/plan/shopping/archive renders the Supply archive inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
...SIGNED_IN,
'/api/v1/shopping/archive?by=week': () => jsonResponse(200, { by: 'week', periods: [] }),
})
renderAt('/plan/shopping/archive')
const heading = await screen.findByRole('heading', { level: 1, name: 'Supply archive' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByText('Nothing archived yet')).toBeInTheDocument()
})
it('/discover renders the Side quests deck inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
+6
View File
@@ -16,6 +16,9 @@ import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
import { SettingsPage } from './settings/SettingsPage.tsx'
import { AppShell } from './shell/AppShell.tsx'
import { SupplyRunSync } from './shopping/api.ts'
import { SupplyArchivePage } from './shopping/SupplyArchivePage.tsx'
import { SupplyRunPage } from './shopping/SupplyRunPage.tsx'
// Every route the app serves, with the heading its placeholder page shows.
export const routes = [
@@ -39,6 +42,8 @@ export const routes = [
// The screens built so far; every other route shows its placeholder page.
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/plan': <PlanPage />,
'/plan/shopping': <SupplyRunPage />,
'/plan/shopping/archive': <SupplyArchivePage />,
'/discover': <DiscoverPage />,
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
@@ -86,6 +91,7 @@ export default function App({ api, serviceWorker }: AppProps) {
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>
<AuthBootstrap>
<SupplyRunSync />
<AppRoutes />
</AuthBootstrap>
<OfflinePill />
+1 -1
View File
@@ -20,7 +20,7 @@ export const meQueryKey = [ME_PATH] as const
export function AuthBootstrap({ children }: { children: ReactNode }) {
const api = useApi()
const forbidden = useSyncExternalStore(api.subscribe, api.forbidden)
const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity, networkMode: 'always' })
const me = useQuery({ queryKey: meQueryKey, queryFn: () => api.loadMe(), staleTime: Infinity, networkMode: 'always' })
// fetch rejects with a TypeError only when the network fails.
const savedPages = me.isError && me.error instanceof TypeError && api.session.verified()
const { refetch } = me
+72
View File
@@ -125,6 +125,66 @@ describe('apiFetch', () => {
expect(api.forbidden()).toBe(false)
})
it('shares a csrf refresh between requests, but one whose signal aborts stops waiting and the next refreshes afresh', async () => {
// The first refresh answers only when answerFirst is called; every other
// request answers at once.
let answerFirst!: (response: Response) => void
const mes: (AbortSignal | null | undefined)[] = []
const fetchMock = vi.fn<typeof fetch>((input, init) => {
if (String(input) === '/api/v1/me') {
mes.push(init?.signal)
if (mes.length === 2) return new Promise((resolve) => (answerFirst = resolve))
return Promise.resolve(jsonResponse(200, { ...ME, csrfToken: `token-${mes.length}` }))
}
const token = new Headers(init?.headers).get(CSRF_HEADER)
return Promise.resolve(token === 'token-1' ? errorResponse(403, 'csrf') : jsonResponse(201, { token }))
})
vi.stubGlobal('fetch', fetchMock)
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const unbounded = api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })
const timedOut = new AbortController()
const bounded = api.apiFetch('/api/v1/jobs', { method: 'POST', body: {}, signal: timedOut.signal })
await vi.waitFor(() => expect(mes).toHaveLength(2))
timedOut.abort(new DOMException('no answer in time', 'TimeoutError'))
await expect(bounded).rejects.toMatchObject({ name: 'TimeoutError' })
// A request without a signal still waits for it, so it is not aborted.
expect(mes[1]!.aborted).toBe(false)
// A later csrf 403 does not wait on the refresh still in flight.
await expect(
api.apiFetch('/api/v1/jobs', { method: 'POST', body: {}, signal: new AbortController().signal }),
).resolves.toEqual({ token: 'token-3' })
expect(mes).toHaveLength(3)
answerFirst(jsonResponse(200, { ...ME, csrfToken: 'token-2' }))
await expect(unbounded).resolves.toEqual({ token: 'token-2' })
expect(mes).toHaveLength(3)
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(5)
})
it('aborts a csrf refresh once the only request waiting for it aborts, without counting it as offline', async () => {
const mes: AbortSignal[] = []
const fetchMock = vi.fn<typeof fetch>((input, init) => {
if (String(input) !== '/api/v1/me') return Promise.resolve(errorResponse(403, 'csrf'))
if (mes.push(init!.signal!) === 1) return Promise.resolve(jsonResponse(200, ME))
return new Promise((_, reject) => init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason as Error)))
})
vi.stubGlobal('fetch', fetchMock)
const network = createNetworkStatus(window)
const api = createApiClient(fakeLocation(), network, createVerifiedSession(localStorage))
await api.loadMe()
const timedOut = new AbortController()
const bounded = api.apiFetch('/api/v1/jobs', { method: 'POST', body: {}, signal: timedOut.signal })
await vi.waitFor(() => expect(mes).toHaveLength(2))
timedOut.abort(new DOMException('no answer in time', 'TimeoutError'))
await expect(bounded).rejects.toMatchObject({ name: 'TimeoutError' })
expect(mes[1]!.aborted).toBe(true)
expect(network.offline()).toBe(false)
})
it('marks any other 403 as not in this household and notifies subscribers', async () => {
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
@@ -153,6 +213,18 @@ describe('apiFetch', () => {
})
describe('loadMe', () => {
it('aborts /me with its signal', async () => {
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>((_, init) => new Promise((_, reject) => init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason as Error)))),
)
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
const timedOut = new AbortController()
const me = api.loadMe(timedOut.signal)
timedOut.abort(new DOMException('no answer in time', 'TimeoutError'))
await expect(me).rejects.toMatchObject({ name: 'TimeoutError' })
})
it('rejects a /me response without a CSRF token', async () => {
stubFetch(jsonResponse(200, { ...ME, csrfToken: '' }))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
+59 -15
View File
@@ -41,6 +41,10 @@ export type ApiRequest = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
// Sent as JSON. A GET must not have one.
body?: unknown
// Aborts the request, including the /me refresh a csrf 403 makes it wait
// for; it then rejects with the signal's reason. No request has a deadline
// unless its caller aborts it.
signal?: AbortSignal
}
// ApiRead is a GET's decoded response body, and whether the service worker
@@ -55,8 +59,9 @@ export type ApiClient = {
// apiRead is apiFetch's GET that also says whether the body is the service
// worker's cached copy rather than the server's answer.
apiRead<T>(path: string): Promise<ApiRead<T>>
// loadMe fetches GET /api/v1/me and caches its CSRF token.
loadMe(): Promise<Me>
// loadMe fetches GET /api/v1/me and caches its CSRF token; signal aborts
// it, as it does an apiFetch.
loadMe(signal?: AbortSignal): Promise<Me>
// forbidden reports whether a request was refused because the signed-in
// account is not in this household; subscribe is notified when it changes.
forbidden(): boolean
@@ -70,15 +75,20 @@ export type ApiClient = {
session: VerifiedSession
}
// Refresh is the /me request refreshMe shares: controller aborts it, waiting
// counts the requests with a signal still waiting for it, and unbounded says
// whether one without a signal is.
type Refresh = { me: Promise<Me>; controller: AbortController; waiting: number; unbounded: boolean }
// createApiClient builds the app's API client. location is window.location in
// the app: a 401 sends it to /auth/login with the current path to return to.
export function createApiClient(location: ApiLocation, network: NetworkStatus, session: VerifiedSession): ApiClient {
let csrfToken: string | null = null
let refreshing: Promise<Me> | null = null
let refreshing: Refresh | null = null
let forbidden = false
const listeners = new Set<() => void>()
async function send(method: string, path: string, body: unknown): Promise<Response> {
async function send(method: string, path: string, body: unknown, signal: AbortSignal | undefined): Promise<Response> {
const headers: Record<string, string> = { Accept: 'application/json' }
if (body !== undefined) headers['Content-Type'] = 'application/json'
if (method !== 'GET') {
@@ -97,7 +107,7 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
}
let response: Response
try {
response = await fetch(path, { method, headers, credentials: 'same-origin', body: json })
response = await fetch(path, { method, headers, credentials: 'same-origin', body: json, signal })
} catch (error) {
// fetch rejects with a TypeError when the network fails; an abort is a
// DOMException and says nothing about the network.
@@ -112,12 +122,46 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
}
// refreshMe shares one in-flight /me request between every request that
// got a csrf 403 at the same time.
function refreshMe(): Promise<Me> {
refreshing ??= loadMe().finally(() => {
refreshing = null
// got a csrf 403 at the same time. A request whose signal aborts stops
// waiting for it at once, and it is no longer shared: the next csrf 403
// starts a fresh /me rather than wait on one that may never answer. Once
// every request with a signal waiting for it has stopped, and none without
// one waits, it is aborted.
function refreshMe(signal: AbortSignal | undefined): Promise<Me> {
if (refreshing === null) {
const controller = new AbortController()
const refresh: Refresh = { me: loadMe(controller.signal), controller, waiting: 0, unbounded: false }
const done = () => {
if (refreshing === refresh) refreshing = null
}
refresh.me.then(done, done)
refreshing = refresh
}
const refresh = refreshing
if (signal === undefined) {
refresh.unbounded = true
return refresh.me
}
refresh.waiting++
return new Promise((resolve, reject) => {
const stop = () => {
if (refreshing === refresh) refreshing = null
if (--refresh.waiting === 0 && !refresh.unbounded) refresh.controller.abort(signal.reason)
reject(signal.reason as Error)
}
if (signal.aborted) return stop()
signal.addEventListener('abort', stop, { once: true })
refresh.me.then(
(me) => {
signal.removeEventListener('abort', stop)
resolve(me)
},
(error: unknown) => {
signal.removeEventListener('abort', stop)
reject(error as Error)
},
)
})
return refreshing
}
async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> {
@@ -138,14 +182,14 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
if (method === 'GET' && request.body !== undefined) {
throw new Error(`GET ${path}: a GET request cannot carry a body`)
}
let response = await send(method, url, request.body)
let response = await send(method, url, request.body, request.signal)
if (response.status === 403) {
const error = await parseForbidden(method, path, response)
// Only a non-GET carries the token, so only it can be refused for it.
if (error.code !== 'csrf' || method === 'GET') throw error
// The session's token changed: refresh it once and retry once.
await refreshMe()
response = await send(method, url, request.body)
await refreshMe(request.signal)
response = await send(method, url, request.body, request.signal)
if (response.status === 403) throw await parseForbidden(method, path, response)
}
return { body: await decode<T>(method, path, response), cached: response.headers.has(cacheFallbackHeader) }
@@ -176,8 +220,8 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
return (await response.json()) as T
}
async function loadMe(): Promise<Me> {
const me = await apiFetch<unknown>(ME_PATH)
async function loadMe(signal?: AbortSignal): Promise<Me> {
const me = await apiFetch<unknown>(ME_PATH, { signal })
if (
!isRecord(me) ||
typeof me.name !== 'string' ||
+27 -2
View File
@@ -258,8 +258,8 @@
color: var(--color-neutral-700);
}
/* An action with nothing behind it yet (the shopping list until Feature
17): it keeps focus, so hovering, tapping or focusing it shows the
/* An action with nothing behind it yet (Send to Google Chat until Feature
19): it keeps focus, so hovering, tapping or focusing it shows the
tooltip. */
.unavailable {
position: relative;
@@ -414,6 +414,31 @@
text-align: center;
}
/* What became of the week's Supply run after a push. */
.pushed {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
text-align: center;
color: var(--color-neutral-700);
}
/* The way to the week's Supply run under the Draft board on mobile. */
.shoppingLink {
display: inline-flex;
align-self: center;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-accent-700);
text-decoration: none;
}
/* A push failure's way to the Google Calendar settings. */
.settingsLink {
color: inherit;
+442 -17
View File
@@ -1,9 +1,10 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { SupplyRunPage } from '../shopping/SupplyRunPage.tsx'
import { cacheFallbackHeader } from '../sw/routes.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
@@ -52,9 +53,33 @@ function planBody(status: string, names = NAMES) {
}
// syncedNow is the plan of the week of Oct 5 as a push answers it: synced
// at the tests' noon.
function syncedNow() {
return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z' }
// at the tests' noon, with what became of the week's Supply run, made
// unless told otherwise.
function syncedNow(supplyRun: { status: string; error: { code: string; message: string; retryable: boolean } | null } = GENERATED) {
return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z', supply_run: supplyRun }
}
// The Supply run outcomes a push answers with, as the server sends them.
const GENERATED = { status: 'generated', error: null }
const ARCHIVED = {
status: 'archived',
error: { code: 'archived', message: 'the Supply run is in the Supply archive', retryable: false },
}
const PLAN_CHANGED = {
status: 'failed',
error: {
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',
retryable: true,
},
}
const SUPPLY_RUN_FAILED = {
status: 'failed',
error: {
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',
retryable: true,
},
}
// deferred is a response the test sends when it chooses.
@@ -100,8 +125,12 @@ const lists = {
jsonResponse(200, { dinners: [dinner('d0', 'Beef tacos', 'easy', 30), dinner('r1', 'Fish tacos', 'medium', 35)] }),
}
// SUPPLY_RUN is the Supply run of a week, as GET /api/v1/shopping/{weekStart}.
const SUPPLY_RUN = /^GET \/api\/v1\/shopping\/\d{4}-\d{2}-\d{2}$/
// stubServer answers "METHOD path" requests from responders, with /me and
// the household settings; any other request fails the test.
// the household settings, and no Supply run (404) for a week given none;
// any other request fails the test.
function stubServer(responders: Record<string, Responder>) {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
@@ -110,7 +139,7 @@ function stubServer(responders: Record<string, Responder>) {
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
const respond = Object.hasOwn(all, key) ? all[key] : SUPPLY_RUN.test(key) ? () => errorResponse(404, 'not_found') : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init))
})
@@ -131,21 +160,67 @@ function planReads(fetchMock: Mock<typeof fetch>) {
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the Plan page.
async function renderPage() {
// the Plan page, over queryClient, with shopping as the Supply run screen.
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />) {
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/plan']}>
<PlanPage />
<Routes>
<Route path="/plan" element={<PlanPage />} />
<Route path="/plan/shopping" element={shopping} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
// Opened stands in for the screen a link opened, saying where it is, with
// a link back to the Plan.
function Opened() {
const { pathname, search } = useLocation()
return (
<>
<p>Opened {pathname + search}</p>
<Link to="/plan">Back to the Plan</Link>
</>
)
}
// pushesKept counts the pushes of the week of Oct 5 the mutation cache of
// queryClient keeps.
function pushesKept(queryClient: QueryClient) {
return queryClient.getMutationCache().findAll({ mutationKey: ['plans', '2026-10-05', 'push'] }).length
}
// SupplyRunScreen is the Supply run screen, with a link back to the Plan.
function SupplyRunScreen() {
return (
<>
<SupplyRunPage />
<Link to="/plan">Back to the Plan</Link>
</>
)
}
// supplyRun is the Supply run of the week of Oct 5 with needed of its 32
// items still to buy.
function supplyRun(needed: number) {
return {
weekStart: '2026-10-05',
label: 'Week 41 · Oct 5 – 11',
title: null,
status: 'current',
sent_to_chat_at: null,
needed_count: needed,
total_count: 32,
groups: [],
}
}
// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so
// the page opens on this week.
beforeEach(() => {
@@ -392,6 +467,236 @@ describe('PlanPage', () => {
expect(planReads(fetchMock)).toBe(2)
})
it("reads the week's Supply run again once a push has made it, and says it was made", async () => {
let run = () => errorResponse(404, 'not_found')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => {
run = () => jsonResponse(200, supplyRun(28))
return jsonResponse(200, syncedNow(GENERATED))
},
'GET /api/v1/shopping/2026-10-05': () => run(),
})
await renderPage()
expect(await screen.findByRole('link', { name: 'Shopping list' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
// The list made is read at once, not after the cached 404 goes stale.
expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/shopping/2026-10-05')).toHaveLength(2)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('says a Supply run in the Supply archive was left as it was', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(ARCHIVED)),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent("This week's Supply run is in the Supply archive, so it was left as it was.")
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('says no Supply run was made when the plan changed after it was pushed, reads the plan again, and pushes again on Retry', async () => {
let plan = planBody('draft')
let push = () => {
plan = planBody('changed', ['Lasagna', ...NAMES.slice(1)])
return jsonResponse(200, syncedNow(PLAN_CHANGED))
}
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
[`POST ${PUSH}`]: () => push(),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent(
'Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push it again.',
)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
// The plan edited meanwhile is the one shown, not the one pushed.
expect(await screen.findByText('Lasagna')).toBeInTheDocument()
expect(screen.getByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
expect(planReads(fetchMock)).toBe(2)
push = () => jsonResponse(200, syncedNow(GENERATED))
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`])
})
it('says the Supply run could not be made after the push, and makes the shopping list again', async () => {
let generate = () => errorResponse(500, 'internal')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => generate(),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
// Calendar has the plan, but the push is not reported a quiet success.
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
expect(screen.queryByRole('status')).not.toBeInTheDocument()
fireEvent.click(within(alert).getByRole('button', { name: 'Make the shopping list' }))
expect(
await screen.findByText("Pushed to Calendar, but this week's Supply run couldn't be made. Couldn't make the shopping list. internal message"),
).toBeInTheDocument()
generate = () => jsonResponse(200, { ...supplyRun(28), skipped: [] })
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
expect(posts(fetchMock)).toEqual([
`${PUSH} undefined`,
'/api/v1/shopping/2026-10-05/generate undefined',
'/api/v1/shopping/2026-10-05/generate undefined',
])
})
it('no longer says the Supply run could not be made once the shopping list is made, after leaving the Plan and coming back', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
// A Supply run never made is still said after leaving the Plan and
// coming back.
fireEvent.click(screen.getByRole('link', { name: 'Shopping list' }))
fireEvent.click(await screen.findByRole('link', { name: 'Back to the Plan' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
// Once made, it is not said again to be unmade.
fireEvent.click(screen.getByRole('link', { name: 'Shopping list · 28' }))
fireEvent.click(await screen.findByRole('link', { name: 'Back to the Plan' }))
expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()
})
it('still says the Supply run could not be made when making the shopping list fails after leaving the week', async () => {
const made = deferred()
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => made.response,
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
fireEvent.click(within(await screen.findByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Making the shopping list…')
// The week is left while the shopping list is being made, which fails.
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
await act(async () => made.send(errorResponse(500, 'internal')))
// Coming back, the Supply run is still said to be unmade, and can be
// made again.
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
expect(within(alert).getByRole('button', { name: 'Make the shopping list' })).toBeEnabled()
})
it('no longer says the Supply run could not be made once the shopping list is made after leaving the week', async () => {
const made = deferred()
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => made.response,
})
const queryClient = createQueryClient()
await renderPage(queryClient)
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
fireEvent.click(within(await screen.findByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Making the shopping list…')
// The push is kept while the week is left, until the list is made: it
// is then settled, not merely swept with the week.
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
expect(pushesKept(queryClient)).toBe(1)
await act(async () => made.send(jsonResponse(200, { ...supplyRun(28), skipped: [] })))
expect(pushesKept(queryClient)).toBe(0)
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()
})
it('no longer says the Supply run could not be made once the shopping list is made on the Supply run screen', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
})
const queryClient = createQueryClient()
await renderPage(queryClient, <SupplyRunScreen />)
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
// The list is made on the Supply run screen, not from the Plan's alert.
fireEvent.click(screen.getByRole('link', { name: 'Shopping list' }))
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
await waitFor(() => expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument())
expect(pushesKept(queryClient)).toBe(0)
fireEvent.click(screen.getByRole('link', { name: 'Back to the Plan' }))
expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()
})
it('still says the Supply run could not be made when making it on the Supply run screen fails', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => errorResponse(500, 'internal'),
})
const queryClient = createQueryClient()
await renderPage(queryClient, <SupplyRunScreen />)
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
fireEvent.click(screen.getByRole('link', { name: 'Shopping list' }))
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't make the shopping list.")
expect(pushesKept(queryClient)).toBe(1)
fireEvent.click(screen.getByRole('link', { name: 'Back to the Plan' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
expect(within(alert).getByRole('button', { name: 'Make the shopping list' })).toBeEnabled()
})
it('pushes nothing again until the changed plan is read and shown', async () => {
let read: () => Response | Promise<Response> = () => jsonResponse(200, planBody('draft'))
let push = () => errorResponse(409, 'plan_changed')
@@ -729,6 +1034,85 @@ describe('PlanPage', () => {
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
})
it("links to the week's Supply run under the Draft board, saying how many items are still to buy", async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, supplyRun(19)),
})
await renderPage()
const shopping = await screen.findByRole('link', { name: 'Shopping list · 19' })
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
expect(declaredStyle(shopping).color).toBe('var(--color-accent-700)')
// It follows the push button and the sync status.
const push = screen.getByRole('button', { name: 'Looks good — push to Calendar' })
expect(push.compareDocumentPosition(shopping) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
fireEvent.click(shopping)
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
it("shows a failed read of the week's shopping list with Retry, the link still opening the Supply run", async () => {
let failing = true
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(19))),
})
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message")
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
// Retry reads it again.
failing = false
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
const shopping = await screen.findByRole('link', { name: 'Shopping list · 19' })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/shopping/2026-10-05')).toHaveLength(2)
fireEvent.click(shopping)
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
it("drops the count when reading the week's shopping list again fails, showing the failure with Retry", async () => {
let failing = false
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(19))),
})
await renderPage()
expect(await screen.findByRole('link', { name: 'Shopping list · 19' })).toBeInTheDocument()
// Coming back to the page once the Supply run is stale reads it again,
// and that read fails.
failing = true
vi.setSystemTime(new Date('2026-10-07T16:01:00Z'))
act(() => {
window.dispatchEvent(new Event('visibilitychange'))
})
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message")
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
expect(screen.queryByRole('link', { name: 'Shopping list · 19' })).not.toBeInTheDocument()
// Retry reads it again, and the count is back.
failing = false
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByRole('link', { name: 'Shopping list · 19' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('links to the Supply run with no count while the week has no shopping list', async () => {
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) })
await renderPage()
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
await waitFor(() => expect(fetchMock.mock.calls.map(([input]) => String(input))).toContain('/api/v1/shopping/2026-10-05'))
const shopping = screen.getByRole('link', { name: 'Shopping list' })
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
// No list is not a failure.
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
fireEvent.click(shopping)
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
it('moves between weeks with the previous and next week buttons', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
@@ -1026,11 +1410,11 @@ describe.each([
expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop')
// Actions: the shopping list unavailable, saying why; Rebuild week
// outlined and Push to Calendar primary, both working.
const shopping = screen.getByRole('button', { name: 'Shopping list' })
expect(shopping).toHaveAttribute('aria-disabled', 'true')
expect(shopping).toHaveAccessibleDescription('The shopping list arrives once the Supply run is built.')
// Actions: the shopping list, with no count while the week has no
// Supply run, and Rebuild week outlined; Push to Calendar primary.
const shopping = await screen.findByRole('link', { name: 'Shopping list' })
expect(shopping).not.toHaveAttribute('aria-disabled')
expect(shopping).not.toHaveAccessibleDescription()
const push = screen.getByRole('button', { name: 'Push to Calendar' })
expect(push).toBeEnabled()
expect(push).not.toHaveAttribute('aria-disabled')
@@ -1038,7 +1422,6 @@ describe.each([
const rebuild = screen.getByRole('button', { name: 'Rebuild week' })
expect(rebuild).toBeEnabled()
expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)')
fireEvent.click(shopping)
expect(posts(fetchMock)).toEqual([])
// Mobile's own pieces are not shown.
expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument()
@@ -1098,6 +1481,48 @@ describe.each([
expect(status).toHaveTextContent('Synced to Google Calendar 1 hr ago')
})
it("shows a failed read of the week's shopping list with Retry, the action still opening the Supply run", async () => {
let failing = true
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(28))),
...lists,
})
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message")
expect(screen.getByText('Spaghetti bolognese')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
failing = false
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
const shopping = await screen.findByRole('link', { name: 'Shopping list · 28' })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
fireEvent.click(shopping)
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
it("opens the week's Supply run from the Shopping list action, saying how many items are still to buy", async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, supplyRun(28)),
...lists,
})
await renderPage()
const shopping = await screen.findByRole('link', { name: 'Shopping list · 28' })
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
expect(declaredStyle(shopping).borderColor).toBe('var(--color-neutral-300)')
// Another week's action opens that week's, which has no list yet.
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-12')
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
fireEvent.click(await screen.findByRole('link', { name: 'Shopping list · 28' }))
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
it('says changes to a pushed plan are not on Google Calendar', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), ...lists })
await renderPage()
+89 -17
View File
@@ -7,8 +7,10 @@ import { QUEST_LOG_KEY } from '../discover/api.ts'
import { Confirm } from '../settings/LeaveGuard.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button, type ButtonVariant } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
@@ -29,6 +31,7 @@ import {
type PlanDay,
type PlanDinner,
type PlanStatus,
type PushedSupplyRun,
type Swap,
} from './api.ts'
import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx'
@@ -79,10 +82,6 @@ const settingsFailures: Record<string, { reason: string; fix: string }> = {
},
}
// SHOPPING_HINT is why the shopping list is unavailable.
// TODO(Feature 17): open the Supply run, with its count, once it is built.
const SHOPPING_HINT = 'The shopping list arrives once the Supply run is built.'
// syncStatuses is the plan manager's line on whether the plan is on Calendar.
const syncStatuses: Record<PlanStatus, string> = {
draft: 'Not pushed to Google Calendar yet',
@@ -90,6 +89,13 @@ const syncStatuses: Record<PlanStatus, string> = {
changed: 'Changes not pushed to Google Calendar',
}
// supplyRunOutcomes says what became of the week's Supply run after a push
// that made it or left it as it was.
const supplyRunOutcomes: Record<Exclude<PushedSupplyRun['status'], 'failed'>, string> = {
generated: "Made this week's Supply run from the plan.",
archived: "This week's Supply run is in the Supply archive, so it was left as it was.",
}
// duration is minutes as the plan manager's cards say it: "45 min", "6 hr",
// "1 hr 30 min", with no-break spaces so a narrow card wraps the line
// between the method and the duration, never inside the duration.
@@ -195,6 +201,22 @@ function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone
)
}
// ShoppingList opens the week's Supply run: an outline action in the plan
// manager's header, a grape link under the Draft board on mobile. Once the
// week has a Supply run it says how many items are still to buy, "Shopping
// list · 28"; until then, while that is read, or when reading it failed, it
// says no count, and the Supply run screen offers to make the list or shows
// the failure.
function ShoppingList({ weekStart, neededCount, layout }: { weekStart: string; neededCount: number | undefined; layout: Breakpoint }) {
const className = layout === 'mobile' ? styles.shoppingLink : [buttonStyles.button, buttonStyles.outline, styles.action].join(' ')
return (
<Link to={supplyRunScreen(weekStart)} className={className}>
<Icon icon={ShoppingCart} size="inline" />
{neededCount !== undefined ? `Shopping list · ${neededCount}` : 'Shopping list'}
</Link>
)
}
// SkeletonTiles stands in for the seven days while the plan loads or the AI
// drafts it, as two columns of tiles on mobile and seven columns wider.
function SkeletonTiles({ label, layout, children }: { label: string; layout: Breakpoint; children?: string }) {
@@ -217,13 +239,16 @@ function SkeletonTiles({ label, layout, children }: { label: string; layout: Bre
// the plan is read again. Rebuilding a plan pushed to Calendar asks first,
// and sends confirm. A failed draft is shown with Retry; no plan is made up.
//
// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week, Push to
// Calendar and the unavailable Shopping list are actions in the header,
// over the plan's sync status and the 7-column grid.
// From tablet up it is the plan manager (mocks 2c, 2d): Shopping list,
// Rebuild week and Push to Calendar are actions in the header, over the
// plan's sync status and the 7-column grid. On mobile the Shopping list
// link sits under the push button.
//
// Push to Calendar, on either layout, sends the plan shown and shows it as
// synced once the server answers; a failure says why, linking to the Google
// Calendar settings when only they can fix it.
// Calendar settings when only they can fix it. What became of the week's
// Supply run is said too: made, left as it was in the Supply archive, or
// not made, with the way to make it.
function WeekBoard({
weekStart,
timezone,
@@ -243,6 +268,9 @@ function WeekBoard({
const [confirming, setConfirming] = useState(false)
const swap = useSwapDay(weekStart)
const push = usePushToCalendar(weekStart)
const run = useSupplyRun(weekStart)
// Makes the week's Supply run again after a push could not make it.
const generate = useGenerateSupplyRun(weekStart)
const pushing = push.pending
// The date of the day being swapped, while the swap sheet is open.
const [swappingDate, setSwappingDate] = useState<string | null>(null)
@@ -312,6 +340,7 @@ function WeekBoard({
if (!canPush) return
swap.dismiss()
push.reset()
generate.reset()
push.mutate()
}
@@ -323,6 +352,7 @@ function WeekBoard({
setJobId(null)
swap.dismiss()
push.reset()
generate.reset()
mutate(
{ weekStart, confirm },
{
@@ -341,8 +371,33 @@ function WeekBoard({
else start(false)
}
const retryPush = cached ? () => void plan.refetch() : pushToCalendar
// makeShoppingList makes the week's Supply run again after a push could
// not make it. Once made, here or on the Supply run screen, the push is
// settled (useGenerateSupplyRun). A failure is the mutation's state, which
// generate.error reports.
function makeShoppingList() {
generate.mutate()
}
// A push's Supply run made again on the Plan stands for the push's, which
// is settled.
const supplyRun: PushedSupplyRun | null = generate.isSuccess ? { status: 'generated', error: null } : push.supplyRun
const failure = push.error !== null ? (
<PushFailure error={push.error} cached={cached} onRetry={cached ? () => void plan.refetch() : pushToCalendar} />
<PushFailure error={push.error} cached={cached} onRetry={retryPush} />
) : supplyRun?.status === 'failed' ? (
supplyRun.error.code === 'plan_changed' ? (
<ErrorBanner onRetry={retryPush}>
Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push
it again.
</ErrorBanner>
) : (
<ErrorBanner onRetry={generate.isPending ? undefined : makeShoppingList} retryLabel="Make the shopping list">
Pushed to Calendar, but this week's Supply run couldn't be made.
{generate.isPending
? ' Making the shopping list…'
: generate.isError && ` Couldn't make the shopping list. ${generate.error.message}`}
</ErrorBanner>
)
) : swap.failed !== null ? (
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
@@ -360,11 +415,27 @@ function WeekBoard({
) : (
<ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner>
)
const pushed = supplyRun !== null && supplyRun.status !== 'failed' && (
<p role="status" className={styles.pushed}>
{supplyRunOutcomes[supplyRun.status]}
</p>
)
// A failed read of the week's Supply run is shown, with Retry, after the
// Draft board's Shopping list link or under the plan manager's board; the
// Shopping list action still opens the Supply run. The count is that of a
// successful read only: a failed refetch keeps the earlier Supply run in
// run.data, and its count is then no longer current.
const neededCount = run.isSuccess ? run.data?.needed_count : undefined
const shoppingList = <ShoppingList weekStart={weekStart} neededCount={neededCount} layout={layout} />
const supplyRunFailure = run.isError && (
<ErrorBanner onRetry={() => void run.refetch()}>Couldn't load this week's shopping list. {run.error.message}</ErrorBanner>
)
// swapIn sends the swap chosen in the sheet and closes it.
function swapIn(chosen: Swap) {
setSwappingDate(null)
push.reset()
generate.reset()
swap.mutate(chosen)
}
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
@@ -462,6 +533,8 @@ function WeekBoard({
{pushing ? 'Pushing to Calendar…' : 'Looks good — push to Calendar'}
</Button>
<SyncStatus plan={plan.data} className={styles.mobileStatus} />
{shoppingList}
{supplyRunFailure}
{sheet}
</>
)
@@ -493,10 +566,7 @@ function WeekBoard({
<div className={styles.actionBar}>
{plan.data ? <SyncStatus plan={plan.data} /> : <span className={styles.status} />}
<div className={styles.actions}>
<Unavailable hint={SHOPPING_HINT} variant="outline" below className={styles.action}>
<Icon icon={ShoppingCart} size="inline" />
Shopping list
</Unavailable>
{shoppingList}
<Button
variant="outline"
className={styles.action}
@@ -537,6 +607,8 @@ function WeekBoard({
{header}
{body}
{failure}
{pushed}
{layout !== 'mobile' && supplyRunFailure}
<Confirm
open={confirming}
title="Rebuild this week?"
@@ -600,10 +672,10 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
)
}
// Unavailable is an action with nothing behind it yet (the shopping list):
// it stays focusable, so a tap or focus shows the tooltip saying why, above
// it or, at the top of the page, below.
function Unavailable({
// Unavailable is an action with nothing behind it yet (Send to Google
// Chat): it stays focusable, so a tap or focus shows the tooltip saying
// why, above it or, at the top of the page, below.
export function Unavailable({
hint,
variant = 'primary',
below = false,
+54 -2
View File
@@ -10,6 +10,21 @@ import { planKey, usePlan, usePushToCalendar, useSwapDay, type Plan, type PushTo
const WEEK = '2026-10-05'
const PLAN = `/api/v1/plans/${WEEK}`
// GENERATED is a push answer's Supply run when the push made it.
const GENERATED = { supply_run: { status: 'generated', error: null } }
// SUPPLY_RUN_FAILED is a push answer's Supply run when it could not be made.
const SUPPLY_RUN_FAILED = {
supply_run: {
status: 'failed',
error: {
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',
retryable: true,
},
},
}
const MONDAY = `${PLAN}/days/2026-10-05`
const FRIDAY = `${PLAN}/days/2026-10-09`
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
@@ -318,7 +333,7 @@ describe('week mutations', () => {
// The push succeeds while the third week is shown: it is left behind,
// and the week's pending swap is not.
await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT })))
await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT, ...GENERATED })))
await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual([]))
expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success'])
@@ -338,6 +353,43 @@ describe('week mutations', () => {
expect(weekMutations(queryClient, LATER)).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 () => {
const nextPush = deferred()
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
[`GET /api/v1/plans/${NEXT}`]: () => jsonResponse(200, { ...planBody(), week_start: NEXT }),
[`POST /api/v1/plans/${NEXT}/push-calendar`]: () => nextPush.reply,
})
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), push: usePushToCalendar(week) }), {
wrapper,
initialProps: { week: NEXT },
})
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
// The push is pushed to Calendar while another week is shown, but its
// Supply run is not made: it stays, though it succeeded.
act(() => hook.result.current.push.mutate())
hook.rerender({ week: WEEK })
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
const synced = { ...planBody(), week_start: NEXT, status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' }
await act(async () => nextPush.send(jsonResponse(200, { ...synced, ...SUPPLY_RUN_FAILED })))
await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual(['push success']))
// Coming back mounts the week's board again, as the Plan's does.
hook.unmount()
const back = renderHook(() => usePushToCalendar(NEXT), { wrapper })
expect(back.result.current.error).toBeNull()
expect(back.result.current.supplyRun).toEqual(SUPPLY_RUN_FAILED.supply_run)
})
it('leaves behind what settles once Plan is left for another route, and keeps what is pending', async () => {
const friday = deferred()
const monday = deferred()
@@ -394,7 +446,7 @@ describe('week mutations', () => {
// The push succeeds: the plan as synced is recorded, and the push is
// left behind.
const synced = { ...planBody(), status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' }
await act(async () => push.send(jsonResponse(200, synced)))
await act(async () => push.send(jsonResponse(200, { ...synced, ...GENERATED })))
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual([]))
expect(queryClient.getQueryData<Plan>(planKey(WEEK))).toMatchObject({ status: 'synced', calendar_synced_at: new Date('2026-10-08T06:00:00Z') })
})
+120 -11
View File
@@ -5,6 +5,7 @@ import { isRecord } from '../api/errors.ts'
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
import { noteGoogleFailure } from '../settings/calendarApi.ts'
import { supplyRunKey } from '../shopping/api.ts'
export const PLANS_PATH = '/api/v1/plans'
@@ -114,6 +115,56 @@ function parsePlan(where: string, weekStart: string, body: unknown, cached: bool
}
}
// SupplyRunError is the API error a Push to Calendar's Supply run carries
// when it was not generated.
export type SupplyRunError<C extends string> = { code: C; message: string; retryable: boolean }
// PushedSupplyRun says whether a Push to Calendar made the week's Supply
// run: generated when it did; archived when the week's Supply run is in the
// Supply archive and was left as it was; or failed when it was not made.
// A failed one's error is plan_changed when the plan was edited after it
// was pushed, and pushing again makes it, or supply_run_failed when it
// could not be made, and making the shopping list again does.
export type PushedSupplyRun =
| { status: 'generated'; error: null }
| { status: 'archived'; error: SupplyRunError<'archived'> }
| { status: 'failed'; error: SupplyRunError<'plan_changed' | 'supply_run_failed'> }
// PushedPlan is the POST /api/v1/plans/{weekStart}/push-calendar answer:
// the plan as synced, and what became of the week's Supply run.
export type PushedPlan = { plan: Plan; supply_run: PushedSupplyRun }
const PUSHED_SUPPLY_RUN_STATUSES = ['generated', 'archived', 'failed'] as const
const FAILED_SUPPLY_RUN_CODES = ['plan_changed', 'supply_run_failed'] as const
function parseSupplyRunError<C extends string>(where: string, codes: readonly C[], body: unknown): SupplyRunError<C> {
if (!isRecord(body) || typeof body.message !== 'string' || typeof body.retryable !== 'boolean') {
throw new Error(`${where}: supply_run has an error that is not an API error`)
}
return { code: oneOf(where, 'supply_run.error.code', codes, body.code), message: body.message, retryable: body.retryable }
}
function parsePushedSupplyRun(where: string, body: unknown): PushedSupplyRun {
if (!isRecord(body)) throw new Error(`${where}: response has no supply_run`)
const status = oneOf(where, 'supply_run.status', PUSHED_SUPPLY_RUN_STATUSES, body.status)
switch (status) {
case 'generated':
if (body.error !== null) throw new Error(`${where}: a generated supply_run has an error`)
return { status, error: null }
case 'archived':
return { status, error: parseSupplyRunError(where, ['archived'] as const, body.error) }
case 'failed':
return { status, error: parseSupplyRunError(where, FAILED_SUPPLY_RUN_CODES, body.error) }
}
}
function parsePushedPlan(where: string, weekStart: string, body: unknown): PushedPlan {
return {
plan: parsePlan(where, weekStart, body, false),
supply_run: parsePushedSupplyRun(where, isRecord(body) ? body.supply_run : undefined),
}
}
// parseDraftResult checks a draft_week job's result, which useJob passes on
// unread, is the draft of the week starting on weekStart.
export function parseDraftResult(where: string, weekStart: string, body: unknown): DraftResult {
@@ -178,6 +229,19 @@ function sameDay(a: PlanDay, b: PlanDay): boolean {
)
}
// failed says whether a swap or push failed: a push whose Supply run was
// not made failed too, though Calendar has the plan.
function failed(mutation: Mutation): boolean {
return mutation.state.status === 'error' || unmade(mutation)
}
// unmade says whether a push succeeded but its Supply run was not made: the
// week is on Calendar with no shopping list until one is made.
function unmade(mutation: Mutation): boolean {
const data = mutation.state.data as PushedPlan | undefined
return mutation.state.status === 'success' && weekMutation(mutation)?.kind === 'push' && data?.supply_run.status === 'failed'
}
// weekMutation is the kind and week of a swap or push of a week the mutation
// cache keeps for it, or null for any other mutation.
function weekMutation(mutation: Mutation): { kind: 'swap' | 'push'; week: string } | null {
@@ -203,9 +267,13 @@ const weeksShown = new WeakMap<QueryClient, WeeksShown>()
// earlier pending one must see, stay across leaving its week and coming
// back. When one settles, the same is done, except that a week's latest,
// when it failed, stays until a week is next shown, so that coming straight
// back to it shows the failure. The cache holds at most the weeks shown's,
// those of weeks left pending, and one failure of each week whose last
// pending one settled since a week was last shown.
// back to it shows the failure. A week's latest push whose Supply run was
// 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
// making it may still be under way when the week is left. The cache holds
// at most the weeks shown's, those of weeks left pending, one push of each
// week whose Supply run was not made, and one failure of each week whose
// last pending one settled since a week was last shown.
function keepWeekMutations(queryClient: QueryClient): WeeksShown {
const kept = weeksShown.get(queryClient)
if (kept !== undefined) return kept
@@ -225,7 +293,7 @@ function keepWeekMutations(queryClient: QueryClient): WeeksShown {
if (week.some((mutation) => mutation.state.status === 'pending')) continue
const latest = week.at(-1)
for (const mutation of week) {
if (settled && mutation === latest && mutation.state.status === 'error') continue
if (mutation === latest && (unmade(mutation) || (settled && failed(mutation)))) continue
mutations.remove(mutation)
}
}
@@ -363,10 +431,12 @@ function pushKey(weekStart: string) {
}
// PushToCalendar is the push of one week, as the mutation cache holds it:
// whether it is pending, and why the latest failed, if it failed.
// whether it is pending, why the latest failed, if it failed, and what
// became of the week's Supply run, if it succeeded.
export type PushToCalendar = {
pending: boolean
error: Error | null
supplyRun: PushedSupplyRun | null
mutate: () => void
reset: () => void
}
@@ -380,10 +450,19 @@ export type PushToCalendar = {
// the one shown. One Google refused the grant for reads the Google Calendar
// connection again, which is now reconnect required.
//
// Once pushed, the server makes the week's Supply run from the plan pushed
// and answers with its outcome, which supplyRun reports: a failed one is
// never a quiet success. The week's Supply run is read again, whatever the
// outcome, so the list shown is the server's. A Supply run not made because
// the plan was edited after it was pushed reads the plan again too, as a
// push refused for that does.
//
// The week's pushes live in the mutation cache, not in the component, as its
// swaps do, so leaving the week and coming back keeps one pending. reset
// leaves settled pushes behind; it is refused while one is pending. Settled
// pushes of a week no longer shown are left behind too
// leaves settled pushes behind; it is refused while one is pending. A push
// whose Supply run was not made is left behind once the list is made
// (settleUnmadePushes). Settled pushes of a week no longer shown are left
// behind too, but for the latest when its Supply run was not made
// (keepWeekMutations).
export function usePushToCalendar(weekStart: string): PushToCalendar {
const { apiFetch } = useApi()
@@ -395,12 +474,17 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
// The week's pushes, in the order sent.
const pushes = useMutationState({
filters: { mutationKey },
select: (mutation) => ({ status: mutation.state.status, error: mutation.state.error }),
select: (mutation) => ({
mutationId: mutation.mutationId,
status: mutation.state.status,
error: mutation.state.error,
data: mutation.state.data as PushedPlan | undefined,
}),
})
const latest = pushes.at(-1)
function mutate() {
const mutation = mutations.build<Plan, Error, void, unknown>(queryClient, {
const mutation = mutations.build<PushedPlan, Error, void, unknown>(queryClient, {
mutationKey,
// Kept until reset or settled in a week not shown (keepWeekMutations):
// a push pending in a week left must still be found when it is shown
@@ -408,9 +492,15 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
gcTime: Infinity,
mutationFn: async () => {
const path = `${planPath(weekStart)}/push-calendar`
return parsePlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }), false)
return parsePushedPlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
},
onSuccess: async ({ plan, supply_run }) => {
queryClient.setQueryData<Plan>(key, plan)
void queryClient.invalidateQueries({ queryKey: supplyRunKey(weekStart) })
if (supply_run.error?.code === 'plan_changed') {
await queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' })
}
},
onSuccess: (plan) => queryClient.setQueryData<Plan>(key, plan),
onError: async (error) => {
noteGoogleFailure(queryClient, error)
if (error instanceof ApiError && error.code === 'plan_changed') {
@@ -433,7 +523,26 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
return {
pending: pushes.some((push) => push.status === 'pending'),
error: latest?.status === 'error' ? latest.error! : null,
supplyRun: latest?.status === 'success' ? latest.data!.supply_run : null,
mutate,
reset,
}
}
// settleUnmadePushes is called as the Supply run of the week starting on
// weekStart starts being made, from the Plan or the Supply run screen
// (useGenerateSupplyRun). The function it answers with is called once the
// list is made: it leaves behind the week's pushes sent before whose Supply
// run was not made, so the Plan does not say again that it is unmade, even
// if the Plan was left meanwhile. A push sent since is kept, and a list not
// made leaves them as they are.
export function settleUnmadePushes(queryClient: QueryClient, weekStart: string): () => void {
const mutations = queryClient.getMutationCache()
const mutationKey = pushKey(weekStart)
const sent = new Set(mutations.findAll({ mutationKey }))
return () => {
for (const mutation of mutations.findAll({ mutationKey })) {
if (sent.has(mutation) && unmade(mutation)) mutations.remove(mutation)
}
}
}
@@ -0,0 +1,144 @@
/* Supply archive · weekly posts (mock 1m, and the right panel of 2f). */
.archive {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 14px;
max-width: 560px;
padding-top: 8px;
}
.header {
display: flex;
align-items: center;
gap: 10px;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 400;
line-height: 1.1;
}
.periods {
align-self: flex-start;
}
.period {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 6px;
}
.heading {
margin: 0;
font-family: var(--font-heading);
font-size: 18px;
font-weight: 400;
line-height: 1.2;
}
.posts {
display: flex;
flex-direction: column;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.post {
display: flex;
flex-direction: column;
gap: 6px;
padding: 16px;
border-radius: 24px;
background: var(--color-surface);
color: var(--color-text);
text-decoration: none;
}
.post:focus-visible {
outline: 3px solid var(--color-accent);
outline-offset: 2px;
}
/* The current week's post: grape-100 with a grape-300 ring. */
.current {
background: var(--color-accent-100);
box-shadow: inset 0 0 0 2px var(--color-accent-300);
}
/* The week label as the API writes it, capitalised here. */
.kicker {
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-neutral-700);
}
.current .kicker {
color: var(--color-accent-700);
}
.postTitle {
font-family: var(--font-heading);
font-size: 18px;
line-height: 1.15;
}
.meta {
font-family: var(--font-body);
font-size: 13px;
color: var(--color-neutral-800);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
padding: 3px 10px;
border-radius: var(--radius-pill);
background: var(--color-bg);
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
}
.older {
align-self: center;
}
.skeletonPost {
height: 112px;
border-radius: 24px;
}
/* Desktop (mock 2f): the week's list at the left, the archive in a 340 px
panel at the right. */
.split {
display: flex;
gap: 24px;
align-items: flex-start;
}
.list {
flex: 1;
min-width: 0;
}
.panel {
flex: none;
box-sizing: border-box;
width: 340px;
padding-left: 24px;
border-left: 1px solid var(--color-divider);
}
+422
View File
@@ -0,0 +1,422 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import type { ArchivePost } from './api.ts'
import { PAGE, SupplyArchivePage } from './SupplyArchivePage.tsx'
import styles from './SupplyArchivePage.module.css'
const ARCHIVE = (by: string) => `/api/v1/shopping/archive?by=${by}`
// post is one Supply archive post as GET /api/v1/shopping/archive sends it.
function post(weekStart: string, label: string, overrides: Partial<ArchivePost> = {}): ArchivePost {
return {
weekStart,
label,
title: null,
dinnerCount: 7,
itemCount: 23,
onHandCount: 4,
dinners: ['Beef tacos', 'Tikka masala', 'Pizza', 'Pot roast', 'Orzo', 'Chili', 'Salmon'],
current: false,
...overrides,
}
}
// The posts of mock 1m: this week's titled post, then the weeks across the
// September–October boundary and the 2025–2026 one, the last untitled.
const WEEK_41 = post('2026-10-05', 'Week 41 · Oct 5 – 11', { title: 'Tacos, tikka & a salmon finale', current: true })
const WEEK_40 = post('2026-09-28', 'Week 40 · Sep 28 – Oct 4', { title: 'Pizza, pot roast & the great orzo trial', itemCount: 26 })
const WEEK_39 = post('2026-09-21', 'Week 39 · Sep 21 – 27', { title: 'Chili season opens', dinnerCount: 1, itemCount: 1, onHandCount: 0, dinners: ['Chili'] })
const WEEK_1 = post('2025-12-29', 'Week 1 · Dec 29 – Jan 4')
// The archive as the server groups it: by week each period is one ISO week
// headed by its label; by month and year a week belongs to the period of its
// Monday, so week 40 is September's and week 1 is 2025's.
const BY = {
week: (
[
['2026-W41', WEEK_41],
['2026-W40', WEEK_40],
['2026-W39', WEEK_39],
['2026-W01', WEEK_1],
] as const
).map(([key, p]) => ({ key, heading: p.label, posts: [p] })),
month: [
{ key: '2026-10', heading: 'October 2026', posts: [WEEK_41] },
{ key: '2026-09', heading: 'September 2026', posts: [WEEK_40, WEEK_39] },
{ key: '2025-12', heading: 'December 2025', posts: [WEEK_1] },
],
year: [
{ key: '2026', heading: '2026', posts: [WEEK_41, WEEK_40, WEEK_39] },
{ key: '2025', heading: '2025', posts: [WEEK_1] },
],
}
function archive(by: keyof typeof BY) {
return () => jsonResponse(200, { by, periods: BY[by] })
}
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
// stubServer answers "METHOD path" requests from responders, with /me and
// the household settings; any other request fails the test.
function stubServer(responders: Record<string, Responder>) {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// renderPage loads /me (for the CSRF token any write would need, so a
// missing PATCH is not for want of it) and renders the Supply archive at path.
async function renderPage(path = '/plan/shopping/archive') {
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/plan/shopping/archive" element={<SupplyArchivePage />} />
<Route path="/plan/shopping" element={<p>Supply run screen</p>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
// postCard is the post titled title, a link.
function postCard(title: string) {
return screen.getByText(title).closest('a')!
}
beforeEach(() => {
mockViewport(390)
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
})
describe('SupplyArchivePage', () => {
it('renders mock 1m by week: one post per week with its kicker, title, counts and dinner chips', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
expect(screen.getByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument()
expect(screen.getByRole('radio', { name: 'Week' })).toBeChecked()
const card = await screen.findByText('Pizza, pot roast & the great orzo trial')
const post40 = card.closest('a')!
expect(within(post40).getByText('Week 40 · Sep 28 – Oct 4')).toHaveClass(styles.kicker!)
expect(post40).toHaveTextContent('7 dinners · 26 items · 4 already on hand')
expect(within(post40).getByText('Beef tacos')).toHaveClass(styles.chip!)
expect(within(post40).getByText('Tikka masala')).toHaveClass(styles.chip!)
expect(within(post40).getByText('+5')).toHaveClass(styles.chip!)
expect(within(post40).queryByText('Pizza')).not.toBeInTheDocument()
expect(postCard('Chili season opens')).toHaveTextContent('1 dinner · 1 item · 0 already on hand')
expect(within(postCard('Chili season opens')).queryByText(/^\+/)).not.toBeInTheDocument()
expect(screen.getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([
'/plan/shopping/archive?week=2026-10-05',
'/plan/shopping/archive?week=2026-09-28',
'/plan/shopping/archive?week=2026-09-21',
'/plan/shopping/archive?week=2025-12-29',
])
})
it('renders by week the periods and headings the server sends, as sent', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
await screen.findByText('Chili season opens')
expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(BY.week.map((period) => period.heading))
expect(screen.getByRole('heading', { level: 2, name: WEEK_40.label })).toHaveClass(styles.heading!)
for (const period of BY.week) {
const section = screen.getByRole('region', { name: period.heading })
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
)
}
})
it('capitalises the week label kicker in CSS from the label as the API writes it', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
await screen.findByText('Chili season opens')
expect(within(postCard('Chili season opens')).getByText('Week 39 · Sep 21 – 27')).toHaveClass(styles.kicker!)
})
it.each([
['Month', 'month', ['October 2026', 'September 2026', 'December 2025']],
['Year', 'year', ['2026', '2025']],
] as const)('groups by %s under the headings the server sends, across the month and year boundaries', async (label, by, headings) => {
const fetchMock = stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week'), [`GET ${ARCHIVE(by)}`]: archive(by) })
await renderPage()
await screen.findByText('Chili season opens')
fireEvent.click(screen.getByRole('radio', { name: label }))
await waitFor(() => expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(headings))
expect(screen.getByRole('heading', { level: 2, name: headings[0] })).toHaveClass(styles.heading!)
expect(fetchMock.mock.calls.map(([input]) => String(input))).toContain(ARCHIVE(by))
for (const period of BY[by]) {
const section = screen.getByRole('region', { name: period.heading })
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
)
}
})
it('highlights the current week’s post, by the API’s current, and no other', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
const current = (await screen.findByText('Tacos, tikka & a salmon finale')).closest('a')!
expect(current).toHaveClass(styles.current!)
expect(within(current).getByText('Week 41 · Oct 5 – 11 · Current')).toBeInTheDocument()
for (const title of ['Pizza, pot roast & the great orzo trial', 'Chili season opens']) {
expect(postCard(title)).not.toHaveClass(styles.current!)
expect(postCard(title)).not.toHaveTextContent('Current')
}
})
it('follows current from the API, not today’s date', async () => {
const past = { ...WEEK_40, current: true }
stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'week', periods: [{ key: 'w', heading: past.label, posts: [{ ...WEEK_41, current: false }, past] }] }) })
await renderPage()
expect(await screen.findByText('Week 40 · Sep 28 – Oct 4 · Current')).toBeInTheDocument()
expect(postCard('Tacos, tikka & a salmon finale')).not.toHaveClass(styles.current!)
})
it('titles an untitled post with its week label', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
await screen.findByText('Chili season opens')
const untitled = screen.getByRole('link', { name: /Week 1 · Dec 29 – Jan 4/ })
const label = within(untitled).getAllByText('Week 1 · Dec 29 – Jan 4')
expect(label.map((el) => el.className)).toEqual([styles.kicker, styles.postTitle])
})
it(`shows ${PAGE} posts and "Load older" shows the next ${PAGE}, none twice and none skipped`, async () => {
const weeks = Array.from({ length: 2 * PAGE + 3 }, (_, i) => {
const monday = new Date(Date.UTC(2026, 9, 5 - 7 * i)).toISOString().slice(0, 10)
return post(monday, `Week of ${monday}`, { title: `Post ${i}` })
})
// By month, so the cut falls inside a period and its heading is shown once.
const periods = [
{ key: 'a', heading: 'Newer', posts: weeks.slice(0, PAGE + 2) },
{ key: 'b', heading: 'Older', posts: weeks.slice(PAGE + 2) },
]
stubServer({
[`GET ${ARCHIVE('week')}`]: archive('week'),
[`GET ${ARCHIVE('month')}`]: () => jsonResponse(200, { by: 'month', periods }),
})
await renderPage()
await screen.findByText('Chili season opens')
fireEvent.click(screen.getByRole('radio', { name: 'Month' }))
const titles = () => screen.getAllByRole('link').map((link) => within(link).getByText(/^Post /).textContent)
await screen.findByText('Post 0')
expect(titles()).toEqual(weeks.slice(0, PAGE).map((p) => p.title))
expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(['Newer'])
fireEvent.click(screen.getByRole('button', { name: 'Load older' }))
expect(titles()).toEqual(weeks.slice(0, 2 * PAGE).map((p) => p.title))
expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(['Newer', 'Older'])
fireEvent.click(screen.getByRole('button', { name: 'Load older' }))
expect(titles()).toEqual(weeks.map((p) => p.title))
expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument()
})
it('offers no "Load older" when every post is shown', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
await screen.findByText('Chili season opens')
expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument()
})
it('shows skeleton posts while the archive loads', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: () => new Promise<Response>(() => {}) })
await renderPage()
expect(screen.getByLabelText('Supply archive loading')).toHaveAttribute('aria-busy', 'true')
expect(screen.getAllByTestId('skeleton-post')).toHaveLength(3)
})
it('says so when nothing is archived yet', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'week', periods: [] }) })
await renderPage()
expect(await screen.findByText('Nothing archived yet')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument()
})
it('reports a failed load and retries it', async () => {
let reads = 0
stubServer({
[`GET ${ARCHIVE('week')}`]: () => (++reads === 1 ? errorResponse(500, 'internal') : archive('week')()),
})
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't load the Supply archive.")
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Chili season opens')).toBeInTheDocument()
expect(reads).toBe(2)
})
it('reports a response that is not the archive by the period asked for', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'month', periods: [] }) })
await renderPage()
expect(await screen.findByRole('alert')).toHaveTextContent(`GET ${ARCHIVE('week')}: response is not the Supply archive by week`)
})
it('goes back to the Supply run', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Back to the Supply run' }))
expect(await screen.findByText('Supply run screen')).toBeInTheDocument()
})
})
// The frozen Supply run of week 40, as GET /api/v1/shopping/{weekStart}
// sends it.
const FROZEN = {
weekStart: '2026-09-28',
label: 'Week 40 · Sep 28 – Oct 4',
title: 'Pizza, pot roast & the great orzo trial',
status: 'archived',
sent_to_chat_at: null,
needed_count: 1,
total_count: 2,
groups: [
{
dinner: { id: 'd1', name: 'Friday pizza' },
date: '2026-10-02',
items: [
{ id: 'i1', name: 'Pizza dough', qty: '2 balls', needed: true },
{ id: 'i2', name: 'Mozzarella', qty: '1 lb', needed: false },
],
},
],
}
// The current week's Supply run, week 41, still current: the API's archive
// includes it, highlighted.
const CURRENT = {
...FROZEN,
weekStart: '2026-10-05',
label: 'Week 41 · Oct 5 – 11',
title: 'Tacos, tikka & a salmon finale',
status: 'current',
groups: [
{
dinner: { id: 'd2', name: 'Beef tacos' },
date: '2026-10-05',
items: [
{ id: 'i3', name: 'Corn tortillas', qty: '12', needed: true },
{ id: 'i4', name: 'Limes', qty: '3', needed: false },
],
},
],
}
describe('SupplyArchivePage frozen post', () => {
it('opens a tapped post’s frozen list read-only, tagged Archived, sending no PATCH', async () => {
const fetchMock = stubServer({
[`GET ${ARCHIVE('week')}`]: archive('week'),
'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, FROZEN),
})
await renderPage()
fireEvent.click(await screen.findByText('Pizza, pot roast & the great orzo trial'))
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: /Shopping list/ })).toBeInTheDocument()
expect(screen.getByText('Pizza dough')).toBeInTheDocument()
expect(screen.queryAllByRole('checkbox')).toEqual([])
fireEvent.click(screen.getByText('Pizza dough'))
fireEvent.click(screen.getByText('Mozzarella'))
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
// The list links back to the archive.
fireEvent.click(screen.getByRole('link', { name: 'Supply archive' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument()
})
it('opens the current week’s post read-only too, tagged Archived, with no checkbox, nothing to send and no PATCH', async () => {
const fetchMock = stubServer({
[`GET ${ARCHIVE('week')}`]: archive('week'),
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
})
await renderPage()
fireEvent.click(await screen.findByText('Tacos, tikka & a salmon finale'))
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(screen.getByText("This week's list as it stands, read-only in the Supply archive.")).toBeInTheDocument()
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
expect(screen.queryAllByRole('checkbox')).toEqual([])
fireEvent.click(screen.getByText('Corn tortillas'))
fireEvent.click(screen.getByText('Limes'))
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
})
it('on desktop opens the current week’s post read-only, and with no post open shows this week’s Supply run to change', async () => {
mockViewport(1440)
const fetchMock = stubServer({
[`GET ${ARCHIVE('week')}`]: archive('week'),
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
})
const view = await renderPage('/plan/shopping/archive?week=2026-10-05')
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
expect(screen.queryAllByRole('checkbox')).toEqual([])
expect(screen.queryByText('Send to Google Chat')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Corn tortillas'))
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
view.unmount()
await renderPage()
expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeInTheDocument()
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
})
it('on desktop (mock 2f) shows the archive as a panel beside the open post’s list', async () => {
mockViewport(1440)
stubServer({
[`GET ${ARCHIVE('week')}`]: archive('week'),
'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, FROZEN),
})
await renderPage('/plan/shopping/archive?week=2026-09-28')
const panel = screen.getByRole('complementary', { name: 'Supply archive' })
expect(await within(panel).findByText('Chili season opens')).toBeInTheDocument()
expect(within(panel).queryByRole('button', { name: 'Back to the Supply run' })).not.toBeInTheDocument()
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(panel).not.toContainElement(screen.getByText('Pizza dough'))
expect(screen.queryAllByRole('checkbox')).toEqual([])
})
})
+183
View File
@@ -0,0 +1,183 @@
import { ChevronLeft } from 'lucide-react'
import { useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router'
import { useBreakpoint } from '../shell/useBreakpoint.ts'
import { Button } from '../ui/Button.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { useSupplyArchive, WEEK_PARAM, type ArchiveGroup, type ArchivePeriod, type ArchivePost } from './api.ts'
import styles from './SupplyArchivePage.module.css'
import { SUPPLY_ARCHIVE, SupplyRunPage } from './SupplyRunPage.tsx'
// PAGE is how many posts the Supply archive shows at first, and how many
// more each "Load older" shows.
export const PAGE = 8
// CHIPS is how many of a post's dinners it names; a "+N" chip counts the rest.
const CHIPS = 2
const PERIODS = [
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
{ value: 'year', label: 'Year' },
] as const satisfies readonly { value: ArchivePeriod; label: string }[]
// archivePost is the Supply archive screen opened on the post of the week
// starting on Monday weekStart.
export function archivePost(weekStart: string): string {
return `${SUPPLY_ARCHIVE}?${new URLSearchParams({ [WEEK_PARAM]: weekStart })}`
}
function plural(n: number, one: string, many: string): string {
return `${n} ${n === 1 ? one : many}`
}
// PostCard is one week's Supply run (mock 1m): the week label as its kicker,
// the AI's title — the week label while it has none — the counts and its
// first dinners as chips. The current week's post is ringed in grape. It
// opens the week's list, read-only.
function PostCard({ post }: { post: ArchivePost }) {
const rest = post.dinners.length - CHIPS
return (
<Link to={archivePost(post.weekStart)} className={[styles.post, post.current && styles.current].filter(Boolean).join(' ')}>
<span className={styles.kicker}>
{post.label}
{post.current && ' · Current'}
</span>
<span className={styles.postTitle}>{post.title ?? post.label}</span>
<span className={styles.meta}>
{plural(post.dinnerCount, 'dinner', 'dinners')} · {plural(post.itemCount, 'item', 'items')} · {post.onHandCount} already on
hand
</span>
{post.dinners.length > 0 && (
<span className={styles.chips}>
{post.dinners.slice(0, CHIPS).map((name, i) => (
<span key={i} className={styles.chip}>
{name}
</span>
))}
{rest > 0 && <span className={styles.chip}>+{rest}</span>}
</span>
)}
</Link>
)
}
function Posts({ posts }: { posts: ArchivePost[] }) {
return (
<ul className={styles.posts}>
{posts.map((post) => (
<li key={post.weekStart}>
<PostCard post={post} />
</li>
))}
</ul>
)
}
// shownGroups is groups cut to their first shown posts, newest first, without
// the periods left with none.
function shownGroups(groups: ArchiveGroup[], shown: number): ArchiveGroup[] {
const out: ArchiveGroup[] = []
let left = shown
for (const group of groups) {
if (left === 0) break
const posts = group.posts.slice(0, left)
out.push({ ...group, posts })
left -= posts.length
}
return out
}
// ArchivePosts is the Supply archive as the server groups it by by: one post
// per week, under the Bagel heading of each period the server sends — by
// week, the week's label. It shows PAGE posts and "Load older" shows PAGE
// more.
function ArchivePosts({ by }: { by: ArchivePeriod }) {
const archive = useSupplyArchive(by)
const [shown, setShown] = useState(PAGE)
if (archive.isError) {
return <ErrorBanner onRetry={() => void archive.refetch()}>Couldn't load the Supply archive. {archive.error.message}</ErrorBanner>
}
if (archive.isPending) {
return (
<div aria-busy="true" aria-label="Supply archive loading" className={styles.posts}>
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} data-testid="skeleton-post" className={styles.skeletonPost} />
))}
</div>
)
}
const total = archive.data.reduce((n, group) => n + group.posts.length, 0)
if (total === 0) {
return <EmptyState title="Nothing archived yet">Each week's Supply run becomes a post here once it's made.</EmptyState>
}
const groups = shownGroups(archive.data, shown)
return (
<>
{groups.map((group) => (
<section key={group.key} aria-label={group.heading} className={styles.period}>
<h2 className={styles.heading}>{group.heading}</h2>
<Posts posts={group.posts} />
</section>
))}
{shown < total && (
<Button variant="outline" className={styles.older} onClick={() => setShown(shown + PAGE)}>
Load older
</Button>
)}
</>
)
}
// SupplyArchive is the Supply archive (mock 1m, and the right panel of 2f):
// the Week | Month | Year control over the posts. On mobile and tablet a
// back button returns to the Supply run.
function SupplyArchive({ panel }: { panel: boolean }) {
const navigate = useNavigate()
const [by, setBy] = useState<ArchivePeriod>('week')
return (
<div className={styles.archive}>
<header className={styles.header}>
{!panel && (
<IconButton label="Back to the Supply run" onClick={() => void navigate('/plan/shopping')}>
<Icon icon={ChevronLeft} size="nav" />
</IconButton>
)}
<h1 className={styles.title}>Supply archive</h1>
</header>
<Segmented label="Group the Supply archive by" options={PERIODS} value={by} onValueChange={setBy} className={styles.periods} />
<ArchivePosts key={by} by={by} />
</div>
)
}
// SupplyArchivePage is /plan/shopping/archive. A post opens it on its week
// (?week=), whose list the Supply run screen shows read-only and tagged
// Archived, whatever its status: even the current week's post is only
// viewed here, never changed. On desktop (mock 2f) the archive is a panel at
// the right of that list, this week's editable Supply run when no post is
// open; on mobile and tablet it is the archive or, with a post open, the
// post's list.
export function SupplyArchivePage() {
const layout = useBreakpoint()
const [params] = useSearchParams()
if (layout === 'desktop') {
return (
<div className={styles.split}>
<div className={styles.list}>
<SupplyRunPage readOnly={params.has(WEEK_PARAM)} />
</div>
<aside className={styles.panel} aria-label="Supply archive">
<SupplyArchive panel />
</aside>
</div>
)
}
return params.has(WEEK_PARAM) ? <SupplyRunPage readOnly /> : <SupplyArchive panel={false} />
}
+274
View File
@@ -0,0 +1,274 @@
/* Supply run · shopping list (mocks 1l, 2b, 2f). */
.page {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 18px;
}
.mobile {
max-width: 440px;
padding-top: 8px;
}
.header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.heading {
flex: 1;
min-width: 0;
}
.kicker {
margin: 0;
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-accent-700);
}
.titleRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 30px;
font-weight: 400;
line-height: 1.1;
}
/* Still to buy / all items: at the right of the title on mobile, beside it
from tablet up, where the actions take the right. */
.count {
margin: 0 0 0 auto;
font-family: var(--font-body);
font-size: 16px;
font-weight: 700;
color: var(--color-neutral-700);
white-space: nowrap;
}
.tablet .count,
.desktop .count {
margin-left: 0;
}
.helper {
margin: 6px 0 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-text);
}
.actions {
flex: none;
display: flex;
gap: 12px;
}
.action {
min-height: 44px;
text-decoration: none;
}
/* One card per dinner: stacked on mobile, three masonry columns from
tablet up. */
.groups {
display: flex;
flex-direction: column;
gap: 12px;
}
.tablet .groups,
.desktop .groups {
display: block;
column-count: 3;
column-gap: 16px;
}
.group {
box-sizing: border-box;
padding: 16px 18px 10px;
border-radius: 24px;
background: var(--color-surface);
color: var(--color-text);
break-inside: avoid;
}
.tablet .group,
.desktop .group {
margin-bottom: 16px;
}
.groupHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.dinner {
margin: 0;
font-family: var(--font-heading);
font-size: 16px;
font-weight: 400;
line-height: 1.2;
}
.day {
flex: none;
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--color-neutral-700);
}
.items {
margin: 4px 0 0;
padding: 0;
list-style: none;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
/* The checkbox's label fills the row, so the whole row up to the quantity
is the hit area. */
.check {
flex: 1;
min-width: 0;
}
.readOnly {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: center;
gap: var(--space-3);
min-height: 44px;
}
/* An archived item's mark: the checkbox's look, not interactive. */
.box {
box-sizing: border-box;
display: grid;
place-content: center;
flex: none;
width: 22px;
height: 22px;
border: 2px solid var(--color-neutral-400);
border-radius: var(--radius-sm);
}
.item:not(.have) .box {
border-color: var(--color-accent);
background: var(--color-accent);
color: var(--on-accent);
}
.mark {
width: 14px;
height: 14px;
}
.name {
font-family: var(--font-body);
font-size: 15px;
font-weight: 500;
color: var(--color-text);
}
.have .name {
text-decoration: line-through;
color: var(--color-neutral-700);
}
/* Toggled while the server cannot be reached: the change is saved here and
sent once it can be. */
.willSync {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
text-decoration: none;
vertical-align: middle;
}
.qty {
flex: none;
font-family: var(--font-body);
font-size: 13px;
color: var(--color-neutral-700);
text-align: right;
}
.have .qty {
font-weight: 700;
}
.sent {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-accent-2-800);
}
.footer {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 14px;
}
.archiveLink {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 44px;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-accent-700);
text-decoration: none;
}
.skeletonTitle {
width: 220px;
height: 34px;
}
.skeletonGroup {
height: 160px;
border-radius: 24px;
}
.tablet .skeletonGroup,
.desktop .skeletonGroup {
margin-bottom: 16px;
break-inside: avoid;
}
+491
View File
@@ -0,0 +1,491 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import buttonStyles from '../ui/Button.module.css'
import { sentAt, SupplyRunPage } from './SupplyRunPage.tsx'
const THIS_WEEK = '/api/v1/shopping/2026-10-05'
const GENERATE = `${THIS_WEEK}/generate`
const ITEM = (id: string) => `/api/v1/shopping/items/${id}`
type Item = { id: string; name: string; qty: string | null; needed: boolean }
// The week of Oct 5's Supply run, as mock 1l starts it: Monday's tacos and
// Wednesday's tikka masala, each with one item the household has.
const GROUPS: { dinner: { id: string; name: string }; date: string; day: string; items: Item[] }[] = [
{
dinner: { id: 'd0', name: 'Beef tacos' },
date: '2026-10-05',
day: 'Monday',
items: [
{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true },
{ id: 'i2', name: 'Corn tortillas', qty: '12', needed: true },
{ id: 'i3', name: 'Taco seasoning', qty: '1 packet', needed: false },
],
},
{
dinner: { id: 'd2', name: 'Crock pot tikka masala' },
date: '2026-10-07',
day: 'Wednesday',
items: [
{ id: 'i4', name: 'Chicken thighs', qty: '2 lb', needed: true },
{ id: 'i5', name: 'Tikka masala paste', qty: '1 jar', needed: true },
{ id: 'i6', name: 'Basmati rice', qty: null, needed: false },
],
},
]
// supplyRun is the week's Supply run as GET /api/v1/shopping/{weekStart}
// sends it, counted from its items.
function supplyRun(overrides: Record<string, unknown> = {}, weekStart = '2026-10-05', label = 'Week 41 · Oct 5 – 11') {
const items = GROUPS.flatMap((group) => group.items)
return {
weekStart,
label,
title: null,
status: 'current',
sent_to_chat_at: null,
needed_count: items.filter((item) => item.needed).length,
total_count: items.length,
groups: GROUPS,
...overrides,
}
}
// deferred is a response the test sends when it chooses.
function deferred() {
let send!: (response: Response) => void
const response = new Promise<Response>((resolve) => {
send = resolve
})
return { response, send }
}
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
// stubServer answers "METHOD path" requests from responders, with /me and
// the household settings; any other request fails the test.
function stubServer(responders: Record<string, Responder>) {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// writes lists the PATCHes and POSTs sent, as "METHOD path body".
function writes(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => init?.method === 'PATCH' || init?.method === 'POST')
.map(([input, init]) => `${init!.method} ${String(input)} ${String(init?.body)}`)
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the Supply run at path.
async function renderPage(path = '/plan/shopping') {
const api = createApiClient(fakeLocation('/plan/shopping'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<MemoryRouter initialEntries={[path]}>
<SupplyRunPage />
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so
// the page opens on this week.
beforeEach(() => {
mockViewport(390)
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
})
function checkbox(name: string) {
return screen.getByRole('checkbox', { name })
}
function row(name: string) {
return screen.getByText(name).closest('li')!
}
describe('SupplyRunPage', () => {
it('renders mock 1l: kicker, title, the count to buy of all items and the helper', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
await renderPage()
const title = await screen.findByRole('heading', { level: 1, name: 'Shopping list' })
expect(declaredStyle(title).fontFamily).toBe('var(--font-heading)')
// The kicker is the week's dates, set in capitals by the stylesheet.
const kicker = screen.getByText('Supply run · Oct 5 – 11')
expect(declaredStyle(kicker).textTransform).toBe('uppercase')
expect(declaredStyle(kicker).color).toBe('var(--color-accent-700)')
expect(screen.getByText('4 / 6')).toBeInTheDocument()
expect(screen.getByText("Uncheck anything you already have — it won't be counted.")).toBeInTheDocument()
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
})
it('shows one surface card per dinner with its Bagel name and day', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
await renderPage()
const tacos = await screen.findByRole('region', { name: 'Beef tacos' })
expect(screen.getAllByRole('region')).toHaveLength(2)
expect(declaredStyle(tacos).borderRadius).toBe('24px')
expect(declaredStyle(tacos).background).toBe('var(--color-surface)')
const name = within(tacos).getByRole('heading', { level: 2, name: 'Beef tacos' })
expect(declaredStyle(name).fontFamily).toBe('var(--font-heading)')
expect(declaredStyle(name).fontSize).toBe('16px')
expect(declaredStyle(within(tacos).getByText('MON')).fontSize).toBe('11px')
expect(within(screen.getByRole('region', { name: 'Crock pot tikka masala' })).getByText('WED')).toBeInTheDocument()
})
it('checks the items to buy; one the household has is unchecked, struck through and "have it"', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
await renderPage()
expect(await screen.findByRole('checkbox', { name: 'Ground beef' })).toBeChecked()
expect(within(row('Ground beef')).getByText('1 lb')).toBeInTheDocument()
expect(declaredStyle(screen.getByText('Ground beef')).textDecoration).toBe('')
expect(checkbox('Taco seasoning')).not.toBeChecked()
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
expect(within(row('Taco seasoning')).queryByText('1 packet')).not.toBeInTheDocument()
expect(declaredStyle(screen.getByText('Taco seasoning')).textDecoration).toBe('line-through')
expect(screen.getAllByText('have it')).toHaveLength(2)
})
it('toggles an item at once, then shows the counts the server answers with', async () => {
const answer = deferred()
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`PATCH ${ITEM('i3')}`]: () => answer.response,
})
await renderPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
// At once, before the server answers: checked, no longer "have it", and counted.
expect(checkbox('Taco seasoning')).toBeChecked()
expect(within(row('Taco seasoning')).getByText('1 packet')).toBeInTheDocument()
expect(screen.getByText('5 / 6')).toBeInTheDocument()
await waitFor(() => expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`]))
const call = fetchMock.mock.calls.find(([, init]) => init?.method === 'PATCH')!
expect(new Headers(call[1]?.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
// The server's counts are the ones shown.
answer.send(jsonResponse(200, { id: 'i3', needed: true, needed_count: 5, total_count: 7 }))
expect(await screen.findByText('5 / 7')).toBeInTheDocument()
expect(checkbox('Taco seasoning')).toBeChecked()
})
it('marks an item "have it" by unchecking it', async () => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`PATCH ${ITEM('i1')}`]: () => jsonResponse(200, { id: 'i1', needed: false, needed_count: 3, total_count: 6 }),
})
await renderPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Ground beef' }))
expect(checkbox('Ground beef')).not.toBeChecked()
expect(within(row('Ground beef')).getByText('have it')).toBeInTheDocument()
expect(screen.getByText('3 / 6')).toBeInTheDocument()
await waitFor(() => expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i1')} {"needed":false}`]))
})
it('puts a refused toggle back, item and count, and says why with Retry', async () => {
const answer = deferred()
let patches = 0
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`PATCH ${ITEM('i3')}`]: () => {
patches++
return patches === 1 ? answer.response : jsonResponse(200, { id: 'i3', needed: true, needed_count: 5, total_count: 6 })
},
})
await renderPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
expect(checkbox('Taco seasoning')).toBeChecked()
expect(screen.getByText('5 / 6')).toBeInTheDocument()
answer.send(errorResponse(500, 'internal'))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('Couldn\'t mark Taco seasoning to buy. internal message')
expect(checkbox('Taco seasoning')).not.toBeChecked()
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
expect(screen.getByText('4 / 6')).toBeInTheDocument()
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(checkbox('Taco seasoning')).toBeChecked()
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
expect(screen.getByText('5 / 6')).toBeInTheDocument()
expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`, `PATCH ${ITEM('i3')} {"needed":true}`])
})
it('sends toggles one at a time and keeps the count true when one between them fails', async () => {
const first = deferred()
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`PATCH ${ITEM('i3')}`]: () => first.response,
[`PATCH ${ITEM('i6')}`]: () => jsonResponse(200, { id: 'i6', needed: true, needed_count: 5, total_count: 6 }),
})
await renderPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
fireEvent.click(checkbox('Basmati rice'))
expect(screen.getByText('6 / 6')).toBeInTheDocument()
// The second waits for the first.
await waitFor(() => expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`]))
first.send(errorResponse(500, 'internal'))
await waitFor(() => expect(writes(fetchMock)).toHaveLength(2))
await waitFor(() => expect(screen.getByText('5 / 6')).toBeInTheDocument())
expect(checkbox('Taco seasoning')).not.toBeChecked()
expect(checkbox('Basmati rice')).toBeChecked()
})
it('keeps a refused toggle shown when a later toggle of another item succeeds, until it is retried', async () => {
let patches = 0
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`PATCH ${ITEM('i3')}`]: () => {
patches++
return patches === 1 ? errorResponse(500, 'internal') : jsonResponse(200, { id: 'i3', needed: true, needed_count: 6, total_count: 6 })
},
[`PATCH ${ITEM('i6')}`]: () => jsonResponse(200, { id: 'i6', needed: true, needed_count: 5, total_count: 6 }),
})
await renderPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't mark Taco seasoning to buy. internal message")
expect(checkbox('Taco seasoning')).not.toBeChecked()
fireEvent.click(checkbox('Basmati rice'))
await waitFor(() => expect(screen.getByText('5 / 6')).toBeInTheDocument())
expect(checkbox('Basmati rice')).toBeChecked()
// The refusal is still shown: nothing has superseded it.
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. internal message")
expect(checkbox('Taco seasoning')).not.toBeChecked()
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(screen.getByText('6 / 6')).toBeInTheDocument())
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(checkbox('Taco seasoning')).toBeChecked()
})
it('reads the list again when a toggle is refused because the week was archived, and shows it read-only', async () => {
let reads = 0
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => {
reads++
return jsonResponse(200, supplyRun(reads === 1 ? {} : { status: 'archived' }))
},
[`PATCH ${ITEM('i3')}`]: () => errorResponse(409, 'archived'),
})
await renderPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(reads).toBe(2)
expect(screen.queryAllByRole('checkbox')).toEqual([])
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
expect(screen.getByText('4 / 6')).toBeInTheDocument()
expect(screen.getByText('This list is in the Supply archive, as it was when the week ended.')).toBeInTheDocument()
// It says why, with nothing to retry.
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. archived message")
expect(within(alert).queryByRole('button')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Taco seasoning'))
expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`])
})
it('shows the status line once the list was sent to Google Chat', async () => {
// Saturday Oct 10, 8:00 am in New York.
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })) })
await renderPage()
const status = await screen.findByText('Sent to Google Chat · Sat 8:00 am')
expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)')
})
it('has no status line until the list is sent', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
await renderPage()
await screen.findByText('4 / 6')
expect(screen.queryByText(/Sent to Google Chat/)).not.toBeInTheDocument()
})
it('stacks the cards on mobile, with a link to the Supply archive and no header actions', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
await renderPage()
await screen.findByText('4 / 6')
const groups = screen.getByTestId('supply-run-groups')
expect(declaredStyle(groups).flexDirection).toBe('column')
expect(declaredStyle(groups).columnCount).toBe('')
expect(screen.getByRole('link', { name: 'Supply archive' })).toHaveAttribute('href', '/plan/shopping/archive')
expect(screen.queryByRole('button', { name: 'Send to Google Chat' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Archive' })).not.toBeInTheDocument()
})
it.each([
['tablet (mock 2b)', 1180],
['desktop (mock 2f)', 1440],
])('lays the cards out in 3 masonry columns on %s, with Archive and Send to Google Chat', async (_layout, width) => {
mockViewport(width)
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })) })
await renderPage()
await screen.findByText('4 / 6')
const groups = screen.getByTestId('supply-run-groups')
expect(declaredStyle(groups).columnCount).toBe('3')
expect(declaredStyle(screen.getByRole('region', { name: 'Beef tacos' })).breakInside).toBe('avoid')
const archive = screen.getByRole('link', { name: 'Archive' })
expect(archive).toHaveAttribute('href', '/plan/shopping/archive')
expect(archive).toHaveClass(buttonStyles.outline!)
// Send to Google Chat waits for Feature 19: primary, unavailable, and
// its tooltip says so. Pressing it sends nothing.
const send = screen.getByRole('button', { name: 'Send to Google Chat' })
expect(send).toHaveClass(buttonStyles.primary!)
expect(send).toHaveAttribute('aria-disabled', 'true')
expect(send).toHaveAccessibleDescription('Sending the shopping list to Google Chat arrives in a later update.')
fireEvent.click(send)
expect(writes(fetchMock)).toEqual([])
expect(screen.getByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Supply archive' })).not.toBeInTheDocument()
})
it('renders an archived list read-only, tagged Archived', async () => {
const fetchMock = stubServer({
'GET /api/v1/shopping/2026-09-28': () =>
jsonResponse(200, supplyRun({ status: 'archived' }, '2026-09-28', 'Week 40 · Sep 28 – Oct 4')),
})
await renderPage('/plan/shopping?week=2026-09-28')
expect(await screen.findByText('Supply run · Sep 28 – Oct 4')).toBeInTheDocument()
expect(screen.getByText('Archived')).toBeInTheDocument()
expect(screen.getByText('This list is in the Supply archive, as it was when the week ended.')).toBeInTheDocument()
expect(screen.queryAllByRole('checkbox')).toEqual([])
// The marks still show what was bought and what was on hand.
expect(within(row('Ground beef')).getByText('1 lb')).toBeInTheDocument()
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
expect(declaredStyle(screen.getByText('Taco seasoning')).textDecoration).toBe('line-through')
expect(screen.getByText('4 / 6')).toBeInTheDocument()
fireEvent.click(screen.getByText('Taco seasoning'))
expect(screen.getByText('4 / 6')).toBeInTheDocument()
expect(writes(fetchMock)).toEqual([])
})
it('offers no Send to Google Chat for an archived list on tablet', async () => {
mockViewport(1180)
stubServer({
'GET /api/v1/shopping/2026-09-28': () =>
jsonResponse(200, supplyRun({ status: 'archived' }, '2026-09-28', 'Week 40 · Sep 28 – Oct 4')),
})
await renderPage('/plan/shopping?week=2026-09-28')
await screen.findByText('Archived')
expect(screen.queryAllByRole('checkbox')).toEqual([])
expect(screen.queryByRole('button', { name: 'Send to Google Chat' })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Archive' })).toBeInTheDocument()
})
it('refuses a week that is not a Monday', async () => {
const fetchMock = stubServer({})
await renderPage('/plan/shopping?week=2026-09-29')
expect(await screen.findByRole('alert')).toHaveTextContent('“2026-09-29” is not the Monday of a week, as YYYY-MM-DD.')
expect(fetchMock.mock.calls.filter(([input]) => String(input).startsWith('/api/v1/shopping'))).toEqual([])
})
it('shows skeleton cards while the list loads', async () => {
const answer = deferred()
stubServer({ [`GET ${THIS_WEEK}`]: () => answer.response })
await renderPage()
expect(await screen.findByRole('region', { name: 'Shopping list loading' })).toHaveAttribute('aria-busy', 'true')
await waitFor(() => expect(screen.getAllByTestId('skeleton-group')).toHaveLength(3))
answer.send(jsonResponse(200, supplyRun()))
expect(await screen.findByText('4 / 6')).toBeInTheDocument()
expect(screen.queryByTestId('skeleton-group')).not.toBeInTheDocument()
})
it('says when the list cannot load, and Retry reads it again', async () => {
let reads = 0
stubServer({
[`GET ${THIS_WEEK}`]: () => {
reads++
return reads === 1 ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun())
},
})
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't load the shopping list. internal message")
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('4 / 6')).toBeInTheDocument()
})
it('offers to make the list when the week has none, and shows the list made', async () => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${GENERATE}`]: () => jsonResponse(200, { ...supplyRun(), skipped: [] }),
})
await renderPage()
expect(await screen.findByRole('heading', { name: 'No shopping list yet' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByText('4 / 6')).toBeInTheDocument()
expect(writes(fetchMock)).toEqual([`POST ${GENERATE} undefined`])
})
it('says why the list could not be made', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${GENERATE}`]: () => errorResponse(404, 'not_found'),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't make the shopping list. not_found message")
expect(screen.getByRole('heading', { name: 'No shopping list yet' })).toBeInTheDocument()
})
})
describe('sentAt', () => {
it('says the day and time in the household zone', () => {
expect(sentAt(new Date('2026-10-10T12:00:00Z'), 'America/New_York')).toBe('Sat 8:00 am')
expect(sentAt(new Date('2026-10-11T03:30:00Z'), 'America/New_York')).toBe('Sat 11:30 pm')
})
})
+289
View File
@@ -0,0 +1,289 @@
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router'
import { Unavailable } from '../plan/PlanPage.tsx'
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import buttonStyles from '../ui/Button.module.css'
import { Checkbox } from '../ui/Checkbox.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import {
useGenerateSupplyRun,
useSupplyRun,
useToggles,
WEEK_PARAM,
type SupplyRunGroup,
type SupplyRunItem,
type Toggles,
} from './api.ts'
import { keep, savedSchedule, useSavedProblem } from './offline.ts'
import styles from './SupplyRunPage.module.css'
// SUPPLY_ARCHIVE is the Supply archive screen.
export const SUPPLY_ARCHIVE = '/plan/shopping/archive'
// CHAT_HINT is why Send to Google Chat is unavailable.
// TODO(Feature 19): send the Supply run to Google Chat once it is built.
const CHAT_HINT = 'Sending the shopping list to Google Chat arrives in a later update.'
// sentAt is when the Supply run was sent to Google Chat as its status line
// says it, in the household's zone: "Sat 8:00 am".
export function sentAt(at: Date, timezone: string): string {
const day = new Intl.DateTimeFormat('en-US', { weekday: 'short', timeZone: timezone }).format(at)
return `${day} ${formatClockTime(localNow(timezone, at).time)}`
}
// ItemRow is one ingredient: on a current Supply run a checkbox, checked
// (grape) while it is to buy; unchecked, the name is struck through and
// "have it" stands in for the quantity. A read-only Supply run shows the
// same marks. An item toggled while the server cannot be reached
// says it will sync.
function ItemRow({ item, toggles }: { item: SupplyRunItem; toggles: Toggles | null }) {
const name = (
<>
<span className={styles.name}>{item.name}</span>
{toggles?.willSync.has(item.id) && (
<span className={styles.willSync}>
<Icon icon={CloudUpload} size="inline" />
will sync
</span>
)}
</>
)
return (
<li className={[styles.item, !item.needed && styles.have].filter(Boolean).join(' ')}>
{toggles === null ? (
<span className={styles.readOnly}>
<span className={styles.box} aria-hidden="true">
{item.needed && <Icon icon={Check} size="inline" className={styles.mark} />}
</span>
{name}
</span>
) : (
<Checkbox
className={styles.check}
label={name}
checked={item.needed}
onChange={() => toggles.toggle({ id: item.id, needed: !item.needed })}
/>
)}
<span className={styles.qty}>{item.needed ? item.qty : 'have it'}</span>
</li>
)
}
// GroupCard is one dinner's items: its name in Bagel over its day.
function GroupCard({ group, toggles }: { group: SupplyRunGroup; toggles: Toggles | null }) {
return (
<section className={styles.group} aria-label={group.dinner.name}>
<header className={styles.groupHeader}>
<h2 className={styles.dinner}>{group.dinner.name}</h2>
<span className={styles.day}>{weekdayKicker(group.date)}</span>
</header>
<ul className={styles.items}>
{group.items.map((item) => (
<ItemRow key={item.id} item={item} toggles={toggles} />
))}
</ul>
</section>
)
}
// SentStatus is the status line once the Supply run was sent to Google Chat.
function SentStatus({ at, timezone }: { at: Date; timezone: string }) {
return (
<p className={styles.sent}>
<Icon icon={MessageSquare} size="inline" />
Sent to Google Chat · {sentAt(at, timezone)}
</p>
)
}
// ArchiveLink opens the Supply archive: an outline action from tablet up, a
// grape link on mobile.
function ArchiveLink({ layout }: { layout: Breakpoint }) {
const className = layout === 'mobile' ? styles.archiveLink : [buttonStyles.button, buttonStyles.outline, styles.action].join(' ')
return (
<Link to={SUPPLY_ARCHIVE} className={className}>
<Icon icon={Archive} size="inline" />
{layout === 'mobile' ? 'Supply archive' : 'Archive'}
</Link>
)
}
// SupplyRunList is a week's Supply run (mocks 1l, 2b, 2f): the kicker with
// the week's dates, the title with the count still to buy of all items, and
// one card per dinner, stacked on mobile and in three masonry columns from
// tablet up, where Archive and Send to Google Chat are actions in the
// header. On a current Supply run each item toggles at once (useToggles); a
// toggle refused puts the item back and says why. An archived one, or any
// opened from the Supply archive (readOnly), is read-only and tagged
// Archived, and a toggle it refused is not offered again.
function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
const toggles = useToggles(weekStart)
const run = toggles.shown
const viewOnly = readOnly || run.status === 'archived'
const wide = layout !== 'mobile'
const failed = toggles.failed
const failedItem = failed && run.groups.flatMap((group) => group.items).find((item) => item.id === failed.toggle.id)
return (
<div className={`${styles.page} ${styles[layout]}`}>
<header className={styles.header}>
<div className={styles.heading}>
<p className={styles.kicker}>Supply run · {weekLabel(weekStart).dates}</p>
<div className={styles.titleRow}>
<h1 className={styles.title}>Shopping list</h1>
{viewOnly && <Tag tone="grape">Archived</Tag>}
<p className={styles.count}>
{run.needed_count} / {run.total_count}
</p>
</div>
<p className={styles.helper}>
{run.status === 'archived'
? 'This list is in the Supply archive, as it was when the week ended.'
: viewOnly
? "This week's list as it stands, read-only in the Supply archive."
: "Uncheck anything you already have — it won't be counted."}
</p>
</div>
{wide && (
<div className={styles.actions}>
<ArchiveLink layout={layout} />
{!viewOnly && (
<Unavailable hint={CHAT_HINT} below className={styles.action}>
<Icon icon={MessageSquare} size="inline" />
Send to Google Chat
</Unavailable>
)}
</div>
)}
</header>
{failed !== null && (
<ErrorBanner onRetry={viewOnly ? undefined : () => toggles.toggle(failed.toggle)}>
Couldn't mark {failedItem ? failedItem.name : 'an item'} {failed.toggle.needed ? 'to buy' : '“have it”'}.{' '}
{failed.error.message}
</ErrorBanner>
)}
{wide && run.sent_to_chat_at !== null && <SentStatus at={run.sent_to_chat_at} timezone={timezone} />}
{run.groups.length === 0 ? (
<EmptyState title="Nothing to buy">None of this week's dinners has a recipe with ingredients yet.</EmptyState>
) : (
<div className={styles.groups} data-testid="supply-run-groups">
{run.groups.map((group) => (
<GroupCard key={`${group.date} ${group.dinner.id}`} group={group} toggles={viewOnly ? null : toggles} />
))}
</div>
)}
{!wide && (
<footer className={styles.footer}>
{run.sent_to_chat_at !== null && <SentStatus at={run.sent_to_chat_at} timezone={timezone} />}
<ArchiveLink layout={layout} />
</footer>
)}
</div>
)
}
// SkeletonGroups stands in for the dinner cards while the Supply run loads.
function SkeletonGroups({ layout }: { layout: Breakpoint }) {
return (
<section aria-busy="true" aria-label="Shopping list loading" className={`${styles.page} ${styles[layout]}`}>
<Skeleton className={styles.skeletonTitle} />
<div className={styles.groups}>
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} data-testid="skeleton-group" className={styles.skeletonGroup} />
))}
</div>
</section>
)
}
type WeekProps = { weekStart: string; timezone: string; layout: Breakpoint; readOnly: boolean }
// WeekSupplyRun is the Supply run of the week starting on weekStart, or,
// when the week has none yet, the offer to make it from the week's plan —
// not offered read-only.
function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
const run = useSupplyRun(weekStart)
const generate = useGenerateSupplyRun(weekStart)
// Offline, the Supply run saved on this device stays shown; the offline
// pill says it is saved data.
if (run.isError && !(run.data !== undefined && run.error instanceof TypeError)) {
return <ErrorBanner onRetry={() => void run.refetch()}>Couldn't load the shopping list. {run.error.message}</ErrorBanner>
}
if (run.isPending || generate.isPending) return <SkeletonGroups layout={layout} />
if (run.data === null) {
return (
<div className={`${styles.page} ${styles[layout]}`}>
{readOnly ? (
<EmptyState title="No shopping list">This week has no Supply run in the Supply archive.</EmptyState>
) : (
<EmptyState title="No shopping list yet" action={{ label: 'Make the shopping list', onClick: () => generate.mutate() }}>
I'll gather the ingredients of this week's planned dinners into a Supply run.
</EmptyState>
)}
{generate.isError && (
<ErrorBanner onRetry={() => generate.mutate()}>Couldn't make the shopping list. {generate.error.message}</ErrorBanner>
)}
</div>
)
}
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} readOnly={readOnly} />
}
// SupplyRunPage is the Supply run (mock 1l on mobile, 2b on tablet, 2f on
// desktop) of the week the Plan opens on — next week once this week's
// plan_draft time has come, otherwise this week — or of the week chosen by
// ?week=. Opened from the Supply archive it is readOnly: tagged Archived,
// with no checkbox and nothing to make or send, whatever the week. Offline,
// where the settings cannot be read, it chooses the week by the schedule
// saved on this device.
export function SupplyRunPage({ readOnly = false }: { readOnly?: boolean }) {
const problem = useSavedProblem()
return (
<>
{problem !== null && (
<ErrorBanner>Changes to the shopping list can't be kept on this device while offline. {problem.message}</ErrorBanner>
)}
<ChosenSupplyRun readOnly={readOnly} />
</>
)
}
function ChosenSupplyRun({ readOnly }: { readOnly: boolean }) {
const layout = useBreakpoint()
const settings = useHouseholdSettings()
const [params] = useSearchParams()
const chosen = params.get(WEEK_PARAM)
// Read once, not on every render: a failed read reports a problem, which
// renders this again.
const [saved] = useState(() => keep(savedSchedule))
if (chosen !== null && !isMonday(chosen)) {
return <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner>
}
// fetch rejects with a TypeError only when the network fails.
const schedule = settings.data ?? (settings.error instanceof TypeError ? saved : null)
if (settings.isError && !schedule) {
return <ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
}
if (!schedule) return <SkeletonGroups layout={layout} />
const week = chosen ?? defaultWeek(schedule, new Date())
return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} readOnly={readOnly} />
}
// isMonday says whether date is a real YYYY-MM-DD Monday.
function isMonday(date: string): boolean {
try {
return weekOf(date) === date
} catch {
return false
}
}
+579
View File
@@ -0,0 +1,579 @@
import {
MutationObserver,
useMutation,
useMutationState,
useQuery,
useQueryClient,
type MutationObserverOptions,
type QueryClient,
} from '@tanstack/react-query'
import { useEffect, useReducer, useSyncExternalStore } from 'react'
import { ApiError, useApi, type ApiClient } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { meQueryKey } from '../AuthBootstrap.tsx'
import { oneOf } from '../dinners/api.ts'
import { settleUnmadePushes } from '../plan/api.ts'
import type { NetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS_KEY, type HouseholdSettings } from '../settings/api.ts'
import {
dequeue,
enqueue,
keep,
markWillSync,
queued,
SAVED_KEY,
saveRun,
savedRun,
saveSchedule,
synced,
willSync,
} from './offline.ts'
export const SHOPPING_PATH = '/api/v1/shopping'
// WEEK_PARAM names the search parameter choosing the week the Supply run
// screen shows, its Monday as YYYY-MM-DD: /plan/shopping?week=2026-09-28.
export const WEEK_PARAM = 'week'
// supplyRunScreen is the Supply run screen on the week starting on Monday
// weekStart.
export function supplyRunScreen(weekStart: string): string {
return `/plan/shopping?${new URLSearchParams({ [WEEK_PARAM]: weekStart })}`
}
// supplyRunPath is the Supply run of the week starting on Monday weekStart.
export function supplyRunPath(weekStart: string): string {
return `${SHOPPING_PATH}/${encodeURIComponent(weekStart)}`
}
// supplyRunKey is the query of the Supply run of the week starting on
// weekStart.
export function supplyRunKey(weekStart: string) {
return ['shopping', weekStart] as const
}
export const SUPPLY_RUN_STATUSES = ['current', 'archived'] as const
// SupplyRunStatus is current until the week ends and the Supply run is
// frozen into the Supply archive: archived, after which nothing on it
// changes.
export type SupplyRunStatus = (typeof SUPPLY_RUN_STATUSES)[number]
// SupplyRunItem is one ingredient: qty is null when the recipe gives none,
// and needed is false once it is marked "have it".
export type SupplyRunItem = { id: string; name: string; qty: string | null; needed: boolean }
// SupplyRunGroup is the items of one plan day's dinner: date is the plan
// day, household-local YYYY-MM-DD.
export type SupplyRunGroup = { dinner: { id: string; name: string }; date: string; items: SupplyRunItem[] }
// Counts is how many of a Supply run's items are still to buy (needed) of
// all of them (total).
export type Counts = { needed_count: number; total_count: number }
// SupplyRun is GET /api/v1/shopping/{weekStart}: label is the week label,
// "Week 41 · Oct 5 – 11", title the AI's title once written, and
// sent_to_chat_at when it was sent to Google Chat, if it was.
export type SupplyRun = Counts & {
weekStart: string
label: string
title: string | null
status: SupplyRunStatus
sent_to_chat_at: Date | null
groups: SupplyRunGroup[]
}
function count(where: string, field: string, value: unknown): number {
if (!Number.isInteger(value) || (value as number) < 0) throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not a count`)
return value as number
}
function parseCounts(where: string, body: Record<string, unknown>): Counts {
const counts = { needed_count: count(where, 'needed_count', body.needed_count), total_count: count(where, 'total_count', body.total_count) }
if (counts.needed_count > counts.total_count) throw new Error(`${where}: needed_count is more than total_count`)
return counts
}
function parseItem(where: string, body: unknown): SupplyRunItem {
if (
!isRecord(body) ||
typeof body.id !== 'string' ||
typeof body.name !== 'string' ||
(body.qty !== null && typeof body.qty !== 'string') ||
typeof body.needed !== 'boolean'
) {
throw new Error(`${where}: response has an item that is not a Supply run item`)
}
return { id: body.id, name: body.name, qty: body.qty, needed: body.needed }
}
function parseGroup(where: string, body: unknown): SupplyRunGroup {
if (
!isRecord(body) ||
!isRecord(body.dinner) ||
typeof body.dinner.id !== 'string' ||
typeof body.dinner.name !== 'string' ||
typeof body.date !== 'string' ||
!Array.isArray(body.items)
) {
throw new Error(`${where}: response has a group that is not a dinner's items`)
}
return {
dinner: { id: body.dinner.id, name: body.dinner.name },
date: body.date,
items: body.items.map((item) => parseItem(where, item)),
}
}
// sentAt reads sent_to_chat_at, an RFC 3339 instant or null.
function sentAt(where: string, value: unknown): Date | null {
if (value === null) return null
const at = typeof value === 'string' ? new Date(value) : null
if (at === null || Number.isNaN(at.getTime())) {
throw new Error(`${where}: sent_to_chat_at ${JSON.stringify(value)} is not an instant`)
}
return at
}
export function parseSupplyRun(where: string, weekStart: string, body: unknown): SupplyRun {
if (
!isRecord(body) ||
body.weekStart !== weekStart ||
typeof body.label !== 'string' ||
(body.title !== null && typeof body.title !== 'string') ||
!Array.isArray(body.groups)
) {
throw new Error(`${where}: response is not the Supply run of the week of ${weekStart}`)
}
return {
weekStart,
label: body.label,
title: body.title,
status: oneOf(where, 'status', SUPPLY_RUN_STATUSES, body.status),
sent_to_chat_at: sentAt(where, body.sent_to_chat_at),
...parseCounts(where, body),
groups: body.groups.map((group) => parseGroup(where, group)),
}
}
// savedSupplyRun is the week's Supply run saved on this device, or undefined.
function savedSupplyRun(weekStart: string): SupplyRun | undefined {
const saved = savedRun(weekStart)
return saved === undefined ? undefined : parseSupplyRun(`Saved ${SAVED_KEY}`, weekStart, saved)
}
// useSupplyRun is the Supply run of the week starting on Monday weekStart
// (GET /api/v1/shopping/{weekStart}), or null when the week has none (404).
// It starts from the Supply run saved on this device, if there is one, so
// the list opens offline; the saved one is always read again.
export function useSupplyRun(weekStart: string) {
const { apiRead } = useApi()
return useQuery({
queryKey: supplyRunKey(weekStart),
initialData: () => keep(() => savedSupplyRun(weekStart)),
initialDataUpdatedAt: 0,
queryFn: async (): Promise<SupplyRun | null> => {
const path = supplyRunPath(weekStart)
let body: unknown
try {
body = (await apiRead<unknown>(path)).body
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null
throw error
}
return parseSupplyRun(`GET ${path}`, weekStart, body)
},
})
}
// useGenerateSupplyRun makes the week's Supply run from its plan (POST
// /api/v1/shopping/{weekStart}/generate); the Supply run it answers with is
// the one shown. Once made, from whichever screen, a push of the week whose
// Supply run was not made is settled (settleUnmadePushes).
export function useGenerateSupplyRun(weekStart: string) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
onMutate: () => settleUnmadePushes(queryClient, weekStart),
mutationFn: async (): Promise<SupplyRun> => {
const path = `${supplyRunPath(weekStart)}/generate`
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
},
onSuccess: (run, _, settle) => {
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
settle()
},
})
}
// Toggle is one item marked to buy (needed) or "have it" (not needed).
export type Toggle = { id: string; needed: boolean }
// KeyedToggle is a toggle as sent: key tells it apart from any other toggle
// of the same item, here and in the queue saved on this device.
type KeyedToggle = Toggle & { key: string }
// toggleKey keys the toggles of the week starting on weekStart.
function toggleKey(weekStart: string) {
return ['shopping', weekStart, 'toggle'] as const
}
// TOGGLE_SCOPE is the one mutation scope of every toggle of every week, made
// here or replayed from the queue saved on this device.
const TOGGLE_SCOPE = 'shopping toggle'
// Toggles is the toggles of one week's Supply run: shown, the Supply run
// with those still pending laid over it, the items whose toggles will sync
// once the server can be reached, the latest failed toggle no later toggle
// of its item has superseded, and toggle to make another.
export type Toggles = {
shown: SupplyRun
willSync: ReadonlySet<string>
failed: { toggle: Toggle; error: Error } | null
toggle: (toggle: Toggle) => void
}
// TOGGLE_DEADLINE is how long, in milliseconds, one try of a toggle — its
// GET /api/v1/me, its PATCH and the /me refresh a csrf 403 to it makes — may
// take before it counts as the server not answering. Only toggles have a
// deadline: the API client sets none.
export const TOGGLE_DEADLINE = 10_000
// RETRY_FIRST and RETRY_CEILING bound, in milliseconds, how long a toggle
// waits while the server cannot be reached before it is tried again anyway:
// the first wait, doubled after each failed try up to the ceiling.
export const RETRY_FIRST = 1_000
export const RETRY_CEILING = 30_000
// reachable resolves once network says the server can be reached — at once
// when it says so now — or after delay milliseconds, whichever is first. The
// server coming back says nothing by itself, and the browser sends no online
// event when it never thought it was offline, so the toggle is tried again
// after delay whatever network says.
function reachable(network: NetworkStatus, delay: number): Promise<void> {
if (!network.offline()) return Promise.resolve()
return new Promise((resolve) => {
const done = () => {
clearTimeout(timer)
stop()
resolve()
}
const timer = setTimeout(done, delay)
const stop = network.subscribe(() => {
if (!network.offline()) done()
})
})
}
// toggleOptions is the mutation every toggle of the week's Supply run is,
// made on the Supply run screen (useToggles) or replayed from the queue saved
// on this device (useSupplyRunSync).
//
// Toggles are sent one at a time, in the order made, whatever their week
// (one mutation scope, TOGGLE_SCOPE), so the counts each is answered with
// include every toggle before it and none after, and a replayed toggle never
// overlaps another replayed or made here. While the server cannot be
// reached — the device is offline, or the toggle's PATCH failed with a
// network error (fetch rejected with a TypeError), or a try ran past its
// deadline (TOGGLE_DEADLINE) — a toggle waits, pending, and is tried again
// once network says the server can be reached, or after a wait that doubles
// from RETRY_FIRST up to RETRY_CEILING, whichever is first: those after it
// wait behind it, in order. A try past its deadline is aborted and marks the
// network failed, as a network error does, so the item says it will sync; the
// server may have applied the aborted PATCH, and sending it again is safe for
// the reason a replay is (see useSupplyRunSync). Any answer from the server
// settles it: a refusal (a 400, 404, 409 or any other error response) or a
// response that is not the item as toggled fails it, and it is not sent again. Before
// a toggle is sent, GET /api/v1/me has answered, so it carries the session's
// CSRF token even when the app opened offline.
//
// Every toggle is in the queue saved on this device (enqueue, by useToggles)
// until it settles, so a reload replays those the server never answered.
function toggleOptions(
weekStart: string,
api: ApiClient,
queryClient: QueryClient,
): MutationObserverOptions<Counts, Error, KeyedToggle> {
const key = supplyRunKey(weekStart)
const mutationKey = toggleKey(weekStart)
return {
mutationKey,
scope: { id: TOGGLE_SCOPE },
networkMode: 'always',
retry: false,
gcTime: Infinity,
mutationFn: async ({ key, id, needed }: KeyedToggle): Promise<Counts> => {
const path = `${SHOPPING_PATH}/items/${encodeURIComponent(id)}`
const where = `PATCH ${path}`
let body: unknown
for (let failures = 0; ; failures++) {
if (api.network.offline()) markWillSync(key)
await reachable(api.network, Math.min(RETRY_FIRST * 2 ** failures, RETRY_CEILING))
const deadline = new AbortController()
const timer = setTimeout(() => deadline.abort(new DOMException(`${where}: no answer in time`, 'TimeoutError')), TOGGLE_DEADLINE)
try {
// Each try loads /me itself, under its deadline, rather than share
// a /me already in flight that may never answer.
if (queryClient.getQueryData(meQueryKey) === undefined) {
queryClient.setQueryData(meQueryKey, await api.loadMe(deadline.signal))
}
body = await api.apiFetch<unknown>(path, { method: 'PATCH', body: { needed }, signal: deadline.signal })
break
} catch (error) {
if (deadline.signal.aborted) {
api.network.requestFailed()
continue
}
// The API client marks the network offline as fetch rejects; a
// TypeError it did not mark is not a network failure.
if (!(error instanceof TypeError) || !api.network.offline()) throw error
} finally {
clearTimeout(timer)
}
}
if (!isRecord(body) || body.id !== id || body.needed !== needed) throw new Error(`${where}: response is not item ${id} as toggled`)
return parseCounts(where, body)
},
// A read of the Supply run sent before the toggle must not land after
// its answer, over it.
onMutate: () => queryClient.cancelQueries({ queryKey: key }),
onSuccess: (counts, { id, needed }) => {
queryClient.setQueryData<SupplyRun | null>(
key,
(run) =>
run && {
...run,
...counts,
groups: run.groups.map((group) => ({
...group,
items: group.items.map((item) => (item.id === id ? { ...item, needed } : item)),
})),
},
)
},
onError: (error) => {
if (error instanceof ApiError && error.code === 'archived') void queryClient.invalidateQueries({ queryKey: key })
},
// This toggle is still pending as it settles, and those after it wait
// for it: the settled toggles of its item are the earlier ones. Settled,
// it leaves the saved queue.
onSettled: (_counts, _error, { key: settled, id }) => {
const cache = queryClient.getMutationCache()
for (const earlier of cache.findAll({ mutationKey, predicate: (m) => m.state.status !== 'pending' })) {
if ((earlier.state.variables as KeyedToggle).id === id) cache.remove(earlier)
}
keep(() => dequeue(settled))
synced(settled)
},
}
}
// useToggles marks items of the week's Supply run, already read, to buy or
// "have it" (PATCH /api/v1/shopping/items/{id}; see toggleOptions). The
// Supply run cached is the server's: a toggle's answer writes the item and
// the counts it answers with there. Until then the toggle is pending and is
// laid over the Supply run shown, so the item and the needed count change at
// once; a refused toggle just stops being pending, which puts both back.
// An item whose pending toggle was made, or waited, while the server could
// not be reached will sync: it says so until that toggle settles, even once
// the server can be reached again, and even when the screen is left and
// opened again meanwhile (markWillSync keeps it with the page, not here).
//
// The Supply run and the toggles are read straight from the query and
// mutation caches as each render begins, never from the caches' batched
// notices, which can arrive apart: a toggle is in the mutation cache as
// pending as soon as it is made, and its answer is in the query cache
// before it stops being pending. Making a toggle renders again at once, so
// the checkbox clicked never shows its old state; useSupplyRun and
// useMutationState render again on every later change.
//
// A failed toggle stays failed, and shown, until a later toggle of its item
// supersedes it, such as its Retry: a toggle of another item succeeding
// does not hide it. So that a failure is never collected unseen, toggles are
// kept (gcTime Infinity), and a settled toggle removes the earlier toggles
// of its item it supersedes. A toggle refused because the week was frozen
// into the Supply archive (409 archived), even one replayed after the device
// was offline, reads the Supply run again, which then renders read-only.
export function useToggles(weekStart: string): Toggles {
const api = useApi()
const queryClient = useQueryClient()
const key = supplyRunKey(weekStart)
const mutationKey = toggleKey(weekStart)
useSupplyRun(weekStart)
useMutationState({ filters: { mutationKey } })
const offline = useSyncExternalStore(api.network.subscribe, api.network.offline)
const [, renderAgain] = useReducer((n: number) => n + 1, 0)
const run = queryClient.getQueryData<SupplyRun | null>(key)
if (!run) throw new Error(`The week of ${weekStart} has no Supply run read to toggle items of.`)
const toggles = queryClient
.getMutationCache()
.findAll({ mutationKey })
.map((mutation) => ({ status: mutation.state.status, toggle: mutation.state.variables as KeyedToggle, error: mutation.state.error }))
const pending = toggles.filter((t) => t.status === 'pending').map((t) => t.toggle)
// Every change of the network status renders again, so each pending
// toggle seen while the server cannot be reached is marked.
if (offline) pending.forEach((t) => markWillSync(t.key))
const failed = toggles
.filter((t, i) => t.status === 'error' && !toggles.slice(i + 1).some((later) => later.toggle.id === t.toggle.id))
.at(-1)
const { mutate } = useMutation(toggleOptions(weekStart, api, queryClient))
return {
shown: shown(run, pending),
willSync: new Set(pending.filter((t) => willSync(t.key)).map((t) => t.id)),
failed: failed === undefined ? null : { toggle: { id: failed.toggle.id, needed: failed.toggle.needed }, error: failed.error! },
toggle: ({ id, needed }) => {
const queuedToggle = { key: crypto.randomUUID(), weekStart, id, needed }
keep(() => enqueue(queuedToggle))
mutate({ key: queuedToggle.key, id, needed })
renderAgain()
},
}
}
// useSupplyRunSync keeps, on this device, what the Supply run screen needs
// to open offline — the household's schedule and each Supply run the server
// sends (saveRun) — and replays, in the order made, the toggles saved in the
// queue that are not already pending here: those of a page reloaded before
// the server answered them.
//
// Replay is at-least-once, not exactly-once: a toggle leaves the saved queue
// only once its answer settles here, so one the server applied just before a
// reload is sent again. That duplicate is safe because a toggle sets needed
// to a fixed value — the same PATCH applied twice leaves the item and the
// counts as applying it once does — so no key is sent for the server to
// deduplicate by.
//
// A replayed toggle's answer is written over the Supply run saved, so the
// week's Supply run is cached from it first; it is still read again
// (updatedAt 0) when the Supply run screen opens.
export function useSupplyRunSync() {
const api = useApi()
const queryClient = useQueryClient()
useEffect(() => {
const stop = queryClient.getQueryCache().subscribe((event) => {
if (event.type !== 'updated' || event.action.type !== 'success') return
const [scope, week] = event.query.queryKey
const data: unknown = event.query.state.data
if (scope === 'shopping' && typeof week === 'string' && event.query.queryKey.length === 2) {
keep(() => saveRun(week, data as SupplyRun | null))
} else if (scope === HOUSEHOLD_SETTINGS_KEY[0] && week === HOUSEHOLD_SETTINGS_KEY[1]) {
keep(() => saveSchedule(data as HouseholdSettings))
}
})
const pending = new Set(
queryClient
.getMutationCache()
.findAll({ mutationKey: ['shopping'], predicate: (m) => m.state.status === 'pending' })
.map((m) => (m.state.variables as KeyedToggle).key),
)
for (const { key, weekStart, id, needed } of keep(queued) ?? []) {
if (pending.has(key)) continue
const runKey = supplyRunKey(weekStart)
if (queryClient.getQueryData(runKey) === undefined) {
const saved = keep(() => savedSupplyRun(weekStart))
if (saved !== undefined) queryClient.setQueryData(runKey, saved, { updatedAt: 0 })
}
// A refusal is the mutation's state, which the Supply run screen shows.
new MutationObserver(queryClient, toggleOptions(weekStart, api, queryClient)).mutate({ key, id, needed }).catch(() => {})
}
return stop
}, [api, queryClient])
}
// SupplyRunSync runs useSupplyRunSync for the whole app.
export function SupplyRunSync(): null {
useSupplyRunSync()
return null
}
// shown is run as the household sees it with the pending toggles laid over
// it: each item as its latest pending toggle marks it, and the needed count
// moved to match.
function shown(run: SupplyRun, pending: Toggle[]): SupplyRun {
const marked = new Map(pending.map((toggle) => [toggle.id, toggle.needed]))
let needed = run.needed_count
const groups = run.groups.map((group) => ({
...group,
items: group.items.map((item) => {
const mark = marked.get(item.id)
if (mark === undefined || mark === item.needed) return item
needed += mark ? 1 : -1
return { ...item, needed: mark }
}),
}))
return { ...run, needed_count: needed, groups }
}
export const ARCHIVE_PERIODS = ['week', 'month', 'year'] as const
// ArchivePeriod is what the Supply archive is grouped by.
export type ArchivePeriod = (typeof ARCHIVE_PERIODS)[number]
// ArchivePost is one week's Supply run in the Supply archive: label is the
// week label, "Week 41 · Oct 5 – 11", which stands in for title while it is
// null; onHandCount counts the items marked "have it", and dinners names the
// dinners in day order. current is true for the household's current week.
export type ArchivePost = {
weekStart: string
label: string
title: string | null
dinnerCount: number
itemCount: number
onHandCount: number
dinners: string[]
current: boolean
}
// ArchiveGroup is one period of the Supply archive — a week, a month
// ("October 2026") or a year ("2026") — and its posts, newest first.
export type ArchiveGroup = { key: string; heading: string; posts: ArchivePost[] }
function parsePost(where: string, body: unknown): ArchivePost {
if (
!isRecord(body) ||
typeof body.weekStart !== 'string' ||
typeof body.label !== 'string' ||
(body.title !== null && typeof body.title !== 'string') ||
!Array.isArray(body.dinners) ||
!body.dinners.every((name) => typeof name === 'string') ||
typeof body.current !== 'boolean'
) {
throw new Error(`${where}: response has a post that is not a Supply archive post`)
}
return {
weekStart: body.weekStart,
label: body.label,
title: body.title,
dinnerCount: count(where, 'dinnerCount', body.dinnerCount),
itemCount: count(where, 'itemCount', body.itemCount),
onHandCount: count(where, 'onHandCount', body.onHandCount),
dinners: body.dinners as string[],
current: body.current,
}
}
function parseArchiveGroup(where: string, body: unknown): ArchiveGroup {
if (!isRecord(body) || typeof body.key !== 'string' || typeof body.heading !== 'string' || !Array.isArray(body.posts)) {
throw new Error(`${where}: response has a period that is not a Supply archive period`)
}
return { key: body.key, heading: body.heading, posts: body.posts.map((post) => parsePost(where, post)) }
}
// useSupplyArchive is the Supply archive grouped by by, newest first (GET
// /api/v1/shopping/archive?by=week|month|year): the server groups it in the
// household's zone, so it is shown as answered, never grouped again here.
export function useSupplyArchive(by: ArchivePeriod) {
const { apiRead } = useApi()
return useQuery({
queryKey: ['supply archive', by] as const,
queryFn: async (): Promise<ArchiveGroup[]> => {
const path = `${SHOPPING_PATH}/archive?${new URLSearchParams({ by })}`
const where = `GET ${path}`
const { body } = await apiRead<unknown>(path)
if (!isRecord(body) || body.by !== by || !Array.isArray(body.periods)) {
throw new Error(`${where}: response is not the Supply archive by ${by}`)
}
return body.periods.map((period) => parseArchiveGroup(where, period))
},
})
}
+155
View File
@@ -0,0 +1,155 @@
import { useSyncExternalStore } from 'react'
import { isRecord } from '../api/errors.ts'
// SAVED_KEY is the localStorage key holding, on this device, what the Supply
// run screen needs to open offline — the household's schedule and the
// Supply runs last read — and the toggles made that the server has not
// answered yet, in the order they were made.
export const SAVED_KEY = 'mealquest.supply-run'
// SAVED_WEEKS is how many weeks' Supply runs are kept: this week's and next
// week's are the only ones still current.
const SAVED_WEEKS = 2
// Schedule is the part of the household settings the Supply run screen
// needs to choose the week and say times. The service worker never caches
// the settings, so it is kept here for the screen to open offline.
export type Schedule = { timezone: string; plan_draft_dow: number; plan_draft_time: string }
// QueuedToggle is a toggle the server has not answered yet: key tells it
// apart from any other toggle of the same item.
export type QueuedToggle = { key: string; weekStart: string; id: string; needed: boolean }
// Saved is the stored value: runs holds each week's Supply run as the server
// last sent it, by its Monday.
type Saved = { schedule: Schedule | null; runs: Record<string, unknown>; queue: QueuedToggle[] }
function isSchedule(value: unknown): value is Schedule {
return (
isRecord(value) &&
typeof value.timezone === 'string' &&
Number.isInteger(value.plan_draft_dow) &&
typeof value.plan_draft_time === 'string'
)
}
function isQueuedToggle(value: unknown): value is QueuedToggle {
return (
isRecord(value) &&
typeof value.key === 'string' &&
typeof value.weekStart === 'string' &&
typeof value.id === 'string' &&
typeof value.needed === 'boolean'
)
}
// read is the stored value, empty when nothing is stored. A value that is not
// one MealQuest stored is an error.
function read(): Saved {
const text = window.localStorage.getItem(SAVED_KEY)
if (text === null) return { schedule: null, runs: {}, queue: [] }
const saved: unknown = JSON.parse(text)
if (
!isRecord(saved) ||
(saved.schedule !== null && !isSchedule(saved.schedule)) ||
!isRecord(saved.runs) ||
!Array.isArray(saved.queue) ||
!saved.queue.every(isQueuedToggle)
) {
throw new Error(`The Supply run saved on this device (${SAVED_KEY}) is not one MealQuest saved.`)
}
return { schedule: saved.schedule as Schedule | null, runs: saved.runs, queue: saved.queue }
}
function write(change: (saved: Saved) => Saved) {
window.localStorage.setItem(SAVED_KEY, JSON.stringify(change(read())))
}
// savedSchedule is the household's schedule as last read, or null.
export function savedSchedule(): Schedule | null {
return read().schedule
}
export function saveSchedule({ timezone, plan_draft_dow, plan_draft_time }: Schedule) {
write((saved) => ({ ...saved, schedule: { timezone, plan_draft_dow, plan_draft_time } }))
}
// savedRun is the week's Supply run as the server last sent it, unparsed, or
// undefined when none is saved.
export function savedRun(weekStart: string): unknown {
return read().runs[weekStart]
}
// saveRun keeps the week's Supply run as the server sent it, with the latest
// weeks'. A week with none (null) or one frozen into the Supply archive is
// not kept: nothing on it can change.
export function saveRun(weekStart: string, run: { status: string } | null) {
write((saved) => {
const runs = { ...saved.runs }
delete runs[weekStart]
if (run !== null && run.status !== 'archived') runs[weekStart] = run
const latest = Object.keys(runs).sort().slice(-SAVED_WEEKS)
return { ...saved, runs: Object.fromEntries(latest.map((week) => [week, runs[week]])) }
})
}
// queued is the toggles the server has not answered, in the order made.
export function queued(): QueuedToggle[] {
return read().queue
}
export function enqueue(toggle: QueuedToggle) {
write((saved) => ({ ...saved, queue: [...saved.queue, toggle] }))
}
export function dequeue(key: string) {
write((saved) => ({ ...saved, queue: saved.queue.filter((toggle) => toggle.key !== key) }))
}
// The keys of the toggles that will sync: made, or waiting, while the server
// could not be reached, and not settled yet. They live here, with the page,
// as the toggles in the mutation cache do: the Supply run screen is left and
// opened again while a toggle is still pending, and starts with nothing of
// its own.
const syncing = new Set<string>()
export function markWillSync(key: string) {
syncing.add(key)
}
export function willSync(key: string): boolean {
return syncing.has(key)
}
export function synced(key: string) {
syncing.delete(key)
}
// The latest failure to read or write the saved Supply run, which the Supply
// run screen shows: a toggle that cannot be kept may be lost on a reload.
let problem: Error | null = null
const listeners = new Set<() => void>()
// keep runs one read or write of the saved Supply run, and returns its
// result, or undefined when it failed: the failure is reported to the Supply
// run screen. Listeners hear of it after the current render.
export function keep<T>(action: () => T): T | undefined {
try {
return action()
} catch (error) {
problem = error instanceof Error ? error : new Error(String(error))
queueMicrotask(() => listeners.forEach((listener) => listener()))
return undefined
}
}
function subscribe(listener: () => void) {
listeners.add(listener)
return () => listeners.delete(listener)
}
// useSavedProblem is the latest failure to read or write the saved Supply
// run, or null.
export function useSavedProblem(): Error | null {
return useSyncExternalStore(subscribe, () => problem)
}
+616
View File
@@ -0,0 +1,616 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock, type MockInstance } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus, type NetworkWindow } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { RETRY_FIRST, SupplyRunSync, TOGGLE_DEADLINE } from './api.ts'
import { SAVED_KEY } from './offline.ts'
import { SupplyRunPage } from './SupplyRunPage.tsx'
const THIS_WEEK = '/api/v1/shopping/2026-10-05'
const ITEM = (id: string) => `/api/v1/shopping/items/${id}`
// Monday's tacos: two items to buy and one the household has.
const ITEMS = [
{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true },
{ id: 'i2', name: 'Corn tortillas', qty: '12', needed: true },
{ id: 'i3', name: 'Taco seasoning', qty: '1 packet', needed: false },
]
// Next week's curry: one item to buy.
const NEXT_ITEMS = [{ id: 'n1', name: 'Chicken thighs', qty: '2 lb', needed: true }]
// The items of each week, by its Monday.
const WEEKS: Record<string, typeof ITEMS> = { '2026-10-05': ITEMS, '2026-10-12': NEXT_ITEMS }
function itemsOf(week: string) {
const items = WEEKS[week]
if (items === undefined) throw new Error(`no week ${week}`)
return items
}
// The browser's online flag; goOffline and goOnline change it and send the
// page's window the event a browser sends.
let onLine: boolean
// The server can be reached: false, every request fails as fetch does when
// the network fails, whatever onLine says.
let reachable: boolean
// The window of the page now open: a reload opens a new one, and the old
// page hears nothing more.
let win: NetworkWindow & EventTarget
function openWindow() {
win = Object.assign(new EventTarget(), {
navigator: {
get onLine() {
return onLine
},
},
}) as unknown as NetworkWindow & EventTarget
return win
}
function goOffline() {
onLine = false
act(() => void win.dispatchEvent(new Event('offline')))
}
function goOnline() {
onLine = true
reachable = true
act(() => void win.dispatchEvent(new Event('online')))
}
// stubServer is the server's Supply runs of this week and next: a PATCH
// marks its item and answers with its week's counts, unless refuse answers
// it instead; when hold is given, the item is marked at once but the answer
// waits for the promise hold returns, or until the request is aborted; holdMe
// holds GET /api/v1/me's answer the same way. This week's counts as archived
// once archive is called.
function stubServer(
refuse: (id: string) => Response | undefined = () => undefined,
hold?: () => Promise<void>,
holdMe?: () => Promise<void>,
) {
const needed = new Map(Object.values(WEEKS).flatMap((items) => items.map((item) => [item.id, item.needed] as const)))
let status = 'current'
// How many PATCHes are unanswered now, and the most there have been at once.
let inFlight = 0
let mostInFlight = 0
// How many GET /api/v1/me requests have been answered.
let meAnswered = 0
const counts = (week: string) => {
const marks = itemsOf(week).map((item) => needed.get(item.id)!)
return { needed_count: marks.filter(Boolean).length, total_count: marks.length }
}
const run = (week: string) => ({
weekStart: week,
label: week === '2026-10-05' ? 'Week 41 · Oct 5 – 11' : 'Week 42 · Oct 12 – 18',
title: null,
status: week === '2026-10-05' ? status : 'current',
sent_to_chat_at: null,
...counts(week),
groups: [
{
dinner: { id: week === '2026-10-05' ? 'd0' : 'd1', name: week === '2026-10-05' ? 'Beef tacos' : 'Chicken curry' },
date: week,
items: itemsOf(week).map((item) => ({ ...item, needed: needed.get(item.id)! })),
},
],
})
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
if (!onLine || !reachable) throw new TypeError('Failed to fetch')
const key = `${init?.method ?? 'GET'} ${String(input)}`
if (key === 'GET /api/v1/me') {
await answerAfter(init?.signal, holdMe)
meAnswered++
return jsonResponse(200, ME)
}
if (key === 'GET /api/v1/settings/household') return jsonResponse(200, HOUSEHOLD_SETTINGS)
if (key === `GET ${THIS_WEEK}`) return jsonResponse(200, run('2026-10-05'))
const week = Object.keys(WEEKS).find((w) => itemsOf(w).some((i) => key === `PATCH ${ITEM(i.id)}`))
if (week === undefined) throw new Error(`unexpected fetch: ${key}`)
const id = itemsOf(week).find((i) => key === `PATCH ${ITEM(i.id)}`)!.id
const refusal = refuse(id)
if (refusal !== undefined) return refusal
const body = JSON.parse(String(init?.body)) as { needed: boolean }
needed.set(id, body.needed)
const answer = jsonResponse(200, { id, needed: body.needed, ...counts(week) })
inFlight++
mostInFlight = Math.max(mostInFlight, inFlight)
try {
await answerAfter(init?.signal, hold)
} finally {
inFlight--
}
return answer
})
vi.stubGlobal('fetch', fetchMock)
return {
fetchMock,
run,
mostInFlight: () => mostInFlight,
meAnswered: () => meAnswered,
archive: () => {
status = 'archived'
},
}
}
// answerAfter resolves once the promise hold returns does, at once without
// hold, or rejects with signal's reason once it aborts.
function answerAfter(signal: AbortSignal | null | undefined, hold?: () => Promise<void>) {
return new Promise<void>((resolve, reject) => {
signal?.addEventListener('abort', () => reject(signal.reason as Error))
void (hold?.() ?? Promise.resolve()).then(resolve)
})
}
// held is a hold for stubServer whose answers wait until release is called,
// each one released in turn.
function held() {
const waiting: (() => void)[] = []
return {
hold: () => new Promise<void>((resolve) => waiting.push(resolve)),
waiting: () => waiting.length,
release: () => waiting.shift()!(),
}
}
// mes counts the GET /api/v1/me requests sent.
function mes(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls.filter(([input, init]) => String(input) === '/api/v1/me' && (init?.method ?? 'GET') === 'GET').length
}
// patches lists the PATCHes sent, as "path body".
function patches(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls.filter(([, init]) => init?.method === 'PATCH').map(([input, init]) => `${String(input)} ${String(init?.body)}`)
}
// queue is the toggles saved on this device, as "id needed".
function queue(): string[] {
const saved = localStorage.getItem(SAVED_KEY)
if (saved === null) return []
return (JSON.parse(saved) as { queue: { id: string; needed: boolean }[] }).queue.map((t) => `${t.id} ${t.needed}`)
}
// openPage opens the Supply run screen in a new window, as the app does:
// with SupplyRunSync, and with /me loaded unless the server cannot be
// reached. leave goes to another screen of the same page, and reopen comes
// back to the Supply run screen.
async function openPage() {
const api = createApiClient(fakeLocation('/plan/shopping'), createNetworkStatus(openWindow()), createVerifiedSession(localStorage))
if (onLine && reachable) await api.loadMe()
const queryClient = createQueryClient()
const app = (onScreen: boolean) => (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/plan/shopping']}>
<SupplyRunSync />
{onScreen && <SupplyRunPage />}
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>
)
const view = render(app(true))
return { ...view, leave: () => view.rerender(app(false)), reopen: () => view.rerender(app(true)) }
}
// reload ends the page open and its timers, as a reload does: a toggle it
// was waiting to try again is never tried, by it, again.
function reload(page: { unmount: () => void }) {
page.unmount()
vi.clearAllTimers()
}
async function advance(ms: number) {
await act(() => vi.advanceTimersByTimeAsync(ms))
}
// The page's setTimeout, watched: retrySecondTry waits for a timer to be set.
let timers: MockInstance<typeof setTimeout>
// retrySecondTry jumps past the wait before a toggle's second try — RETRY_FIRST
// doubled by its failed first try — once that wait's timer is set, so the jump
// never lands before the timer it is meant to fire.
async function retrySecondTry() {
await waitFor(() => expect(timers).toHaveBeenCalledWith(expect.any(Function), 2 * RETRY_FIRST))
await advance(2 * RETRY_FIRST)
}
function checkbox(name: string) {
return screen.getByRole('checkbox', { name: new RegExp(`^${name}`) })
}
function row(name: string) {
return screen.getByText(name).closest('li')!
}
beforeEach(() => {
onLine = true
reachable = true
mockViewport(390)
// setTimeout moves with real time, and a test can jump past a deadline or
// a wait (advance).
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'], shouldAdvanceTime: true })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
timers = vi.spyOn(globalThis, 'setTimeout')
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
})
describe('offline toggle queue', () => {
it('applies toggles made offline at once, saves them in order and sends them in that order once online', async () => {
const { fetchMock } = stubServer()
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Taco seasoning'))
fireEvent.click(checkbox('Ground beef'))
fireEvent.click(checkbox('Taco seasoning'))
// Applied here, counted, marked to sync and saved in order; nothing sent.
expect(checkbox('Taco seasoning')).not.toBeChecked()
expect(checkbox('Ground beef')).not.toBeChecked()
expect(screen.getByText('1 / 3')).toBeInTheDocument()
expect(within(row('Taco seasoning')).getByText('will sync')).toBeInTheDocument()
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(within(row('Corn tortillas')).queryByText('will sync')).not.toBeInTheDocument()
expect(queue()).toEqual(['i3 true', 'i1 false', 'i3 false'])
expect(patches(fetchMock)).toEqual([])
goOnline()
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i1')} {"needed":false}`,
`${ITEM('i3')} {"needed":false}`,
])
expect(screen.getByText('1 / 3')).toBeInTheDocument()
expect(checkbox('Taco seasoning')).not.toBeChecked()
expect(checkbox('Ground beef')).not.toBeChecked()
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('queues a toggle whose PATCH fails with a network error and sends it again once back online', async () => {
const { fetchMock } = stubServer()
await openPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
await waitFor(() => expect(queue()).toEqual([]))
expect(screen.getByText('3 / 3')).toBeInTheDocument()
// The browser still says online, but the server cannot be reached.
reachable = false
fireEvent.click(checkbox('Corn tortillas'))
expect(await within(row('Corn tortillas')).findByText('will sync')).toBeInTheDocument()
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i2')} {"needed":false}`])
expect(checkbox('Corn tortillas')).not.toBeChecked()
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(queue()).toEqual(['i2 false'])
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
goOnline()
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i2')} {"needed":false}`, `${ITEM('i2')} {"needed":false}`])
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
})
it('opens the saved list after a reload offline and replays the saved toggles in order once online', async () => {
const { fetchMock } = stubServer()
const first = await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Taco seasoning'))
fireEvent.click(checkbox('Ground beef'))
expect(queue()).toEqual(['i3 true', 'i1 false'])
// Reload while offline: the settings, /me and the list cannot be read.
reload(first)
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
expect(checkbox('Taco seasoning')).toBeChecked()
expect(checkbox('Ground beef')).not.toBeChecked()
expect(within(row('Taco seasoning')).getByText('will sync')).toBeInTheDocument()
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(patches(fetchMock)).toEqual([])
goOnline()
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`])
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(checkbox('Taco seasoning')).toBeChecked()
expect(checkbox('Ground beef')).not.toBeChecked()
})
it('discards a queued toggle the server refuses because the list was archived, puts the count back and says so', async () => {
const server = stubServer(() => errorResponse(409, 'archived'))
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Taco seasoning'))
expect(screen.getByText('3 / 3')).toBeInTheDocument()
server.archive()
goOnline()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. archived message")
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
expect(queue()).toEqual([])
// Sent once, never again.
goOnline()
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`]))
})
it('does not queue a toggle the server refuses: it is put back and says why', async () => {
const server = stubServer(() => errorResponse(400, 'invalid'))
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Ground beef'))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't mark Ground beef “have it”. invalid message")
expect(checkbox('Ground beef')).toBeChecked()
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
expect(queue()).toEqual([])
goOffline()
goOnline()
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i1')} {"needed":false}`]))
})
})
describe('offline toggle queue, replay', () => {
it('replays a queue spanning two weeks strictly in order, one PATCH at a time, a toggle made meanwhile after it', async () => {
const answers = held()
const server = stubServer(undefined, answers.hold)
const queued = [
{ key: 'k1', weekStart: '2026-10-05', id: 'i3', needed: true },
{ key: 'k2', weekStart: '2026-10-12', id: 'n1', needed: false },
{ key: 'k3', weekStart: '2026-10-05', id: 'i1', needed: false },
]
const runs = { '2026-10-05': server.run('2026-10-05'), '2026-10-12': server.run('2026-10-12') }
localStorage.setItem(SAVED_KEY, JSON.stringify({ schedule: null, runs, queue: queued }))
await openPage()
await waitFor(() => expect(answers.waiting()).toBe(1))
fireEvent.click(await screen.findByRole('checkbox', { name: 'Corn tortillas' }))
for (let sent = 1; sent <= 4; sent++) {
await waitFor(() => expect(patches(server.fetchMock)).toHaveLength(sent))
// The next one is not sent before this one is answered.
await new Promise((resolve) => setTimeout(resolve, 20))
expect(patches(server.fetchMock)).toHaveLength(sent)
act(() => answers.release())
}
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
`${ITEM('n1')} {"needed":false}`,
`${ITEM('i1')} {"needed":false}`,
`${ITEM('i2')} {"needed":false}`,
])
expect(server.mostInFlight()).toBe(1)
await waitFor(() => expect(screen.getByText('1 / 3')).toBeInTheDocument())
})
it('replays at least once: a toggle the server applied before a reload is sent again and leaves the item and count the same', async () => {
const answers = held()
const server = stubServer(undefined, answers.hold)
const first = await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
// The server applied it; the page reloads before its answer arrives.
await waitFor(() => expect(answers.waiting()).toBe(1))
expect(queue()).toEqual(['i3 true'])
reload(first)
act(() => answers.release())
await openPage()
await waitFor(() => expect(answers.waiting()).toBe(1))
act(() => answers.release())
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`])
await waitFor(() => expect(screen.getByText('3 / 3')).toBeInTheDocument())
expect(checkbox('Taco seasoning')).toBeChecked()
expect(server.run('2026-10-05')).toMatchObject({ needed_count: 3, total_count: 3 })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('keeps "will sync" on a queued toggle until its delayed replay is answered', async () => {
const answers = held()
stubServer(undefined, answers.hold)
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Ground beef'))
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
goOnline()
await waitFor(() => expect(answers.waiting()).toBe(1))
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(queue()).toEqual(['i1 false'])
act(() => answers.release())
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(queue()).toEqual([])
expect(screen.getByText('1 / 3')).toBeInTheDocument()
})
it('aborts a PATCH that never answers at its deadline, keeps it pending and tries it again before the toggles after it', async () => {
let tries = 0
// The first PATCH never answers; every later one answers at once.
const server = stubServer(undefined, () => (++tries === 1 ? new Promise<void>(() => {}) : Promise.resolve()))
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
await waitFor(() => expect(patches(server.fetchMock)).toHaveLength(1))
fireEvent.click(checkbox('Ground beef'))
expect(queue()).toEqual(['i3 true', 'i1 false'])
// Past the deadline the hung PATCH is aborted; both still wait, in order.
await advance(TOGGLE_DEADLINE)
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument()
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(patches(server.fetchMock)).toHaveLength(1)
expect(queue()).toEqual(['i3 true', 'i1 false'])
await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i1')} {"needed":false}`,
])
expect(server.mostInFlight()).toBe(1)
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('abandons a GET /api/v1/me that never answers at its deadline and loads it afresh before the toggles after it', async () => {
let tries = 0
// The page loaded /me; the first toggle loads it again, and that /me never
// answers. Every later /me answers at once.
const server = stubServer(undefined, undefined, () => (++tries === 2 ? new Promise<void>(() => {}) : Promise.resolve()))
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
await waitFor(() => expect(tries).toBe(2))
fireEvent.click(checkbox('Ground beef'))
expect(queue()).toEqual(['i3 true', 'i1 false'])
// Past the deadline the hung /me is abandoned; both still wait, in order.
await advance(TOGGLE_DEADLINE)
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument()
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(patches(server.fetchMock)).toEqual([])
// The next try sends a fresh /me, which the server answers.
await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([]))
expect(mes(server.fetchMock)).toBe(3)
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`])
expect(server.mostInFlight()).toBe(1)
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('abandons the /me refresh of a csrf 403 that never answers at its deadline and refreshes afresh on the next try', async () => {
let tries = 0
// The session's token changed: each PATCH is refused for it until a /me
// after the first refusal has answered. The first such refresh never
// answers.
let refusedAt: number | null = null
const server = stubServer(
() => {
if (refusedAt !== null && server.meAnswered() > refusedAt) return undefined
refusedAt ??= server.meAnswered()
return errorResponse(403, 'csrf')
},
undefined,
() => (refusedAt !== null && ++tries === 1 ? new Promise<void>(() => {}) : Promise.resolve()),
)
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
await waitFor(() => expect(tries).toBe(1))
fireEvent.click(checkbox('Ground beef'))
expect(patches(server.fetchMock)).toHaveLength(1)
// Past the deadline the hung refresh is abandoned; both still wait, in order.
await advance(TOGGLE_DEADLINE)
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument()
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(queue()).toEqual(['i3 true', 'i1 false'])
// The next try is refused again and sends a fresh refresh, which answers.
await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i1')} {"needed":false}`,
])
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('tries a toggle again once the server is back, with no online event from the browser', async () => {
const { fetchMock } = stubServer()
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
// The browser says online throughout; the server cannot be reached, then can.
reachable = false
fireEvent.click(checkbox('Corn tortillas'))
expect(await within(row('Corn tortillas')).findByText('will sync')).toBeInTheDocument()
reachable = true
expect(patches(fetchMock)).toEqual([])
// Nothing says the server is back: only the wait's timer tries it again.
await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([]))
// The first try failed at GET /api/v1/me, before its PATCH.
expect(patches(fetchMock)).toEqual([`${ITEM('i2')} {"needed":false}`])
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('1 / 3')).toBeInTheDocument()
})
it('keeps "will sync" when the Supply run screen is left and opened again while the replay is pending', async () => {
const answers = held()
stubServer(undefined, answers.hold)
const page = await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Ground beef'))
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
page.leave()
goOnline()
await waitFor(() => expect(answers.waiting()).toBe(1))
page.reopen()
expect(await within(row('Ground beef')).findByText('will sync')).toBeInTheDocument()
expect(queue()).toEqual(['i1 false'])
act(() => answers.release())
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(queue()).toEqual([])
expect(screen.getByText('1 / 3')).toBeInTheDocument()
})
it('says the saved list is unreadable when it is corrupt offline, without rendering again and again', async () => {
stubServer()
onLine = false
localStorage.setItem(SAVED_KEY, '{corrupt')
const getItem = vi.spyOn(Storage.prototype, 'getItem')
await openPage()
expect(await screen.findByText(/Couldn't load the household settings/)).toBeInTheDocument()
expect(screen.getByText(/can't be kept on this device while offline/)).toBeInTheDocument()
const reads = getItem.mock.calls.filter(([key]) => key === SAVED_KEY).length
await new Promise((resolve) => setTimeout(resolve, 50))
expect(getItem.mock.calls.filter(([key]) => key === SAVED_KEY)).toHaveLength(reads)
})
})