Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 d917a40fa2 Settle a push's unmade Supply run however the shopping list is made
CI / ci (push) Successful in 4m57s
CI / ci (pull_request) Successful in 4m59s
Only the Plan's own "Make the shopping list" settled a push whose Supply run
was not made. Making the list on the Supply run screen, through the same
generate endpoint, left the push in the mutation cache, so coming back to the
Plan still said the Supply run couldn't be made and offered to make it again,
although the list existed (F17-R3).

The settlement now lives in useGenerateSupplyRun, which both screens use:
onMutate notes the week's pushes sent so far (settleUnmadePushes) and
onSuccess removes those whose Supply run was not made. A failed attempt
leaves them shown. usePushToCalendar's settle, the Plan-only path, is gone.

Tests: the list made on the Supply run screen is no longer said to be unmade
on the Plan, a failure there still is, and the success-after-navigation test
now checks the push is kept while the week is left and removed once made, so
settlement is observed apart from the week sweep.

Maestro task e75db0f0-817f-4eef-84b2-266e793c018a.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:36:25 -04:00
dev_agentandClaude Opus 5.5 445750cecb Keep a push's unmade Supply run across week navigation
CI / ci (push) Successful in 5m15s
CI / ci (pull_request) Successful in 5m1s
A push whose Supply run was not made was swept from the mutation cache as
soon as another week was shown. If the household pressed "Make the shopping
list" and moved to another week while it was being made, and making it then
failed, coming back showed neither the failure nor a way to make the list:
the week was left on Calendar with no shopping list (F17-R2).

keepWeekMutations now keeps a week's latest push whose Supply run was not
made whatever week is shown. It goes only when the list is made (settle), or
when a push, draft or swap of that week sends a new push or resets it; an
earlier push replaced by a later one is still swept as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:14:52 -04:00
dev_agentandClaude Opus 5.5 c589c02cdc Settle a push's failed Supply run once the shopping list is made
CI / ci (push) Successful in 4m53s
CI / ci (pull_request) Successful in 4m57s
A push whose Supply run could not be made stays in the mutation cache, so
leaving the Plan still shows the failure on coming back. Making the
shopping list from that failure was only remembered while the Plan stayed
mounted: after visiting the Supply run and coming back, the Plan said again
that the list could not be made and offered to make it (F17-R1).

usePushToCalendar now has settle, which removes the push shown from the
mutation cache. The Plan calls it once the generate request succeeds,
through mutateAsync, so it is settled even if the Plan was left while the
list was being made. A push never followed by a made list is kept as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:54:32 -04:00
dev_agentandClaude Opus 5.5 9616941b73 Report the Supply run outcome after Push to Calendar
CI / ci (push) Successful in 5m3s
CI / ci (pull_request) Successful in 4m57s
The push client parsed only the plan from POST /plans/{weekStart}/push-calendar
and dropped supply_run, so a Supply run that was not made was shown as a
successful push (F17-01). Parse supply_run as the server sends it and tell the
household every outcome on the Plan: made, left as it was in the Supply
archive, or not made, with its recovery: push again for plan_changed (reading
the edited plan again first), or make the shopping list again through the
existing generate endpoint for supply_run_failed. A push whose Supply run
failed stays in the mutation cache like a failed push, so leaving the week
before it settles still shows it on coming back.

Read the week's Supply run again when the push answers, using the Supply run
screen's supplyRunKey, so a cached 404 or the earlier list is not shown for
the freshness window (F17-02).

Maestro task e75db0f0-817f-4eef-84b2-266e793c018a.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:36:13 -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
26 changed files with 2667 additions and 1650 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 > `/health`, embedded PWA skeleton, Makefile, Dockerfile, CI). The planned
> application is described in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). > 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 The developer setup (prerequisites, Authentik and Google clients, the
is, 22.22.2 or later on the 22 line, 24.15.0 or later on the 24 line, or 26.0.0 `MEALQUEST_*` environment, `make dev` and the optional SearXNG sidecar) is
or later. Node.js 23, 24.0–24.14 and 25 are not supported: this is the runbook information and lives in CTXD: project `mealquest`, file
`engines` range of jsdom in the committed lockfile, the strictest of all the `RUNBOOKS.MD`, section "Local development". Configuration is read only from
web dependencies. the environment, and the service refuses to start, naming every missing or
invalid variable.
```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).
## Common commands ## Common commands
@@ -78,7 +31,8 @@ household settings instead).
## Where to read next ## 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. - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — architecture of record.
- [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md) — product requirements. - [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. - [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) # MealQuest production environment: the template for deploy/.env (never
# and replace the placeholders. The service fails fast: a missing or invalid # committed). The service fails fast: a missing or invalid required variable
# required variable stops it at boot with the variable named. # 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), # 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: # 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
# mkdir -p deploy/secrets && chmod 700 deploy/secrets # searxng.env is the exception: compose reads it itself, so it is owned by
# head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key # the host account that runs compose, mode 0600. Creating them: CTXD project
# printf '%s\n' '<Authentik client secret>' > deploy/secrets/oidc_client_secret # mealquest, RUNBOOKS.MD "Stack and first deploy" > "4. Secrets".
# 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)
# #
# deploy/compose.yaml mounts them at /run/secrets/<name> and points the # deploy/compose.yaml mounts them at /run/secrets/<name> and points the
# *_FILE variables at them. # *_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 # IANA zone the household settings are created with on first boot. Required
# until those settings exist; ignored afterwards (change the zone in # until those settings exist; ignored afterwards (change the zone in
# household settings instead). Confirm the household's zone before the first # household settings instead).
# deploy.
MEALQUEST_DEFAULT_TZ=America/New_York MEALQUEST_DEFAULT_TZ=America/New_York
# --- Authentik OIDC ---------------------------------------------------------- # --- Authentik OIDC ----------------------------------------------------------
@@ -57,10 +51,10 @@ MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# --- Google Calendar (Push to Calendar) ------------------------------------ # --- Google Calendar (Push to Calendar) ------------------------------------
# Client ID of the Google OAuth web client (deploy/RUNBOOK.md "Google # Client ID of the Google OAuth web client (CTXD project mealquest,
# Calendar OAuth client"). Required: the service will not start without it # RUNBOOKS.MD "Google Calendar OAuth client"). Required: the service will
# and the client secret file. Its authorized redirect URI is # not start without it and the client secret file. Its authorized redirect
# <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback. # URI is <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
# Optional session lifetimes. A session ends after IDLE unused and always MAX # 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 # 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 # 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. # 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 ------------------------------------- # --- Planned; not read by the service yet -------------------------------------
# #
# Added to deploy/compose.yaml and this file by the features that use them. # 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_PUBLIC_KEY=<base64url VAPID public key>
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key # MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
# MEALQUEST_VAPID_SUBJECT=mailto:<owner address> # 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 # Development override layered on deploy/compose.yaml: publishes SearXNG
# it can be queried directly, e.g. # 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
# docker compose -f deploy/compose.yaml -f deploy/compose.dev.yaml up -d searxng # development".
# curl 'http://127.0.0.1:8888/search?q=chicken+tikka+masala+recipe&format=json'
# #
# Loopback only, never a LAN address. Never used in production: the # Loopback only, never a LAN address. Never used in production: the
# systemd unit starts deploy/compose.yaml alone. # 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, # Production stack for MealQuest on 10.60.1.14 (D-018). Building, deploying
# then start through systemd: # and operating it: CTXD project mealquest, RUNBOOKS.MD "Stack unit" and
# # "Routine deploy and rollback".
# docker compose -f deploy/compose.yaml build
# sudo systemctl restart mealquest
# #
# The services use restart: "no" on purpose: Docker must not start them # The services use restart: "no" on purpose: Docker must not start them
# before the DOCKER-USER firewall is applied. deploy/systemd/mealquest.service # before the DOCKER-USER firewall is applied. deploy/systemd/mealquest.service
# owns start and stop and requires mealquest-firewall.service first. # owns start and stop and requires mealquest-firewall.service first.
# #
# Configuration comes from deploy/.env (copy deploy/.env.example) and secrets # Configuration comes from deploy/.env (template: deploy/.env.example) and
# from deploy/secrets/; neither is committed. Compose bind-mounts file secrets # secrets from deploy/secrets/; neither is committed. Compose bind-mounts
# with their host owner and mode, so each file in deploy/secrets/ must be # file secrets with their host owner and mode, so each file in
# owned by the container user 10001:10001 with mode 0600 (except # deploy/secrets/ must be owned by the container user 10001:10001 with mode
# searxng.env, read by compose itself; see the searxng service). # 0600 (except searxng.env, read by compose itself; see the searxng service).
name: mealquest name: mealquest
services: services:
@@ -65,8 +63,8 @@ services:
# the app's own user (owner of the volume) with a read-only root # the app's own user (owner of the volume) with a read-only root
# filesystem, no privilege escalation and no Linux capabilities. # filesystem, no privilege escalation and no Linux capabilities.
# #
# Restore: follow "Restore the database" in deploy/RUNBOOK.md (stop the # Restoring the database: CTXD project mealquest, RUNBOOKS.MD "Restore
# stack, move the database with its -wal and -shm aside, restore, check). # the database".
litestream: litestream:
image: litestream/litestream:0.5.17 image: litestream/litestream:0.5.17
user: "10001:10001" user: "10001:10001"
+3 -2
View File
@@ -5,8 +5,9 @@
# are one-way: a failure here fails this unit visibly and never fails # are one-way: a failure here fails this unit visibly and never fails
# docker.service. # docker.service.
# #
# Start MealQuest only after this unit has succeeded: # mealquest.service requires this unit and starts the stack only after it
# systemctl start mealquest-firewall && docker compose up -d # has succeeded. Installing and operating both: CTXD project mealquest,
# RUNBOOKS.MD "Host firewall" and "Stack unit".
[Unit] [Unit]
Description=Restrict MealQuest port 8090 to Traefik (DOCKER-USER) Description=Restrict MealQuest port 8090 to Traefik (DOCKER-USER)
After=docker.service After=docker.service
@@ -1,8 +1,7 @@
# Runs the nightly MealQuest media backup (/usr/local/sbin/mealquest-media-backup). # Runs the nightly MealQuest media backup (/usr/local/sbin/mealquest-media-backup).
# Triggered by mealquest-media-backup.timer; run by hand with # Triggered by mealquest-media-backup.timer; output goes to the journal.
# systemctl start mealquest-media-backup # Installing it and running it by hand: CTXD project mealquest,
# and read the result with # RUNBOOKS.MD "Media — nightly rsync".
# journalctl -u mealquest-media-backup
# #
# The script runs as the app's user (10001, host user "mealquest") with no # 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 # 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 # swapping the path afterwards cannot redirect it. Symlinks inside the
# directory are copied as links (-a), never followed. # directory are copied as links (-a), never followed.
# #
# The media directory is created at deploy time (deploy/RUNBOOK.md). If it # The media directory is created at deploy time (CTXD project mealquest,
# is missing the job fails; it is never treated as "nothing to back up". # 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 # rsync mirrors into .../mealquest-backups/media/ only: the target ends in
# a slash, so --delete removes files from that directory and nothing # a slash, so --delete removes files from that directory and nothing
+13
View File
@@ -33,6 +33,7 @@ const SCREENS = {
'/onboarding': true, '/onboarding': true,
'/plan': true, '/plan': true,
'/plan/shopping': true, '/plan/shopping': true,
'/plan/shopping/archive': true,
'/discover': true, '/discover': true,
'/settings': true, '/settings': true,
'/settings/ai': true, '/settings/ai': true,
@@ -130,6 +131,18 @@ describe('App routes', () => {
expect(await screen.findByRole('checkbox', { name: 'Ground beef' })).toBeChecked() 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 () => { it('/discover renders the Side quests deck inside the shell', async () => {
mockViewport(390) mockViewport(390)
stubFetchByPath({ stubFetchByPath({
+4
View File
@@ -16,6 +16,8 @@ import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx' import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
import { SettingsPage } from './settings/SettingsPage.tsx' import { SettingsPage } from './settings/SettingsPage.tsx'
import { AppShell } from './shell/AppShell.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' import { SupplyRunPage } from './shopping/SupplyRunPage.tsx'
// Every route the app serves, with the heading its placeholder page shows. // Every route the app serves, with the heading its placeholder page shows.
@@ -41,6 +43,7 @@ export const routes = [
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = { const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/plan': <PlanPage />, '/plan': <PlanPage />,
'/plan/shopping': <SupplyRunPage />, '/plan/shopping': <SupplyRunPage />,
'/plan/shopping/archive': <SupplyArchivePage />,
'/discover': <DiscoverPage />, '/discover': <DiscoverPage />,
'/settings': <SettingsPage />, '/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />, '/settings/ai': <AISettingsPage />,
@@ -88,6 +91,7 @@ export default function App({ api, serviceWorker }: AppProps) {
<ApiProvider client={api}> <ApiProvider client={api}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthBootstrap> <AuthBootstrap>
<SupplyRunSync />
<AppRoutes /> <AppRoutes />
</AuthBootstrap> </AuthBootstrap>
<OfflinePill /> <OfflinePill />
+1 -1
View File
@@ -20,7 +20,7 @@ export const meQueryKey = [ME_PATH] as const
export function AuthBootstrap({ children }: { children: ReactNode }) { export function AuthBootstrap({ children }: { children: ReactNode }) {
const api = useApi() const api = useApi()
const forbidden = useSyncExternalStore(api.subscribe, api.forbidden) 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. // fetch rejects with a TypeError only when the network fails.
const savedPages = me.isError && me.error instanceof TypeError && api.session.verified() const savedPages = me.isError && me.error instanceof TypeError && api.session.verified()
const { refetch } = me const { refetch } = me
+72
View File
@@ -125,6 +125,66 @@ describe('apiFetch', () => {
expect(api.forbidden()).toBe(false) 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 () => { it('marks any other 403 as not in this household and notifies subscribers', async () => {
const fetchMock = stubFetch(errorResponse(403, 'forbidden')) const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
@@ -153,6 +213,18 @@ describe('apiFetch', () => {
}) })
describe('loadMe', () => { 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 () => { it('rejects a /me response without a CSRF token', async () => {
stubFetch(jsonResponse(200, { ...ME, csrfToken: '' })) stubFetch(jsonResponse(200, { ...ME, csrfToken: '' }))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) 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' method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
// Sent as JSON. A GET must not have one. // Sent as JSON. A GET must not have one.
body?: unknown 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 // 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 // apiRead is apiFetch's GET that also says whether the body is the service
// worker's cached copy rather than the server's answer. // worker's cached copy rather than the server's answer.
apiRead<T>(path: string): Promise<ApiRead<T>> apiRead<T>(path: string): Promise<ApiRead<T>>
// loadMe fetches GET /api/v1/me and caches its CSRF token. // loadMe fetches GET /api/v1/me and caches its CSRF token; signal aborts
loadMe(): Promise<Me> // it, as it does an apiFetch.
loadMe(signal?: AbortSignal): Promise<Me>
// forbidden reports whether a request was refused because the signed-in // forbidden reports whether a request was refused because the signed-in
// account is not in this household; subscribe is notified when it changes. // account is not in this household; subscribe is notified when it changes.
forbidden(): boolean forbidden(): boolean
@@ -70,15 +75,20 @@ export type ApiClient = {
session: VerifiedSession 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 // 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. // 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 { export function createApiClient(location: ApiLocation, network: NetworkStatus, session: VerifiedSession): ApiClient {
let csrfToken: string | null = null let csrfToken: string | null = null
let refreshing: Promise<Me> | null = null let refreshing: Refresh | null = null
let forbidden = false let forbidden = false
const listeners = new Set<() => void>() 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' } const headers: Record<string, string> = { Accept: 'application/json' }
if (body !== undefined) headers['Content-Type'] = 'application/json' if (body !== undefined) headers['Content-Type'] = 'application/json'
if (method !== 'GET') { if (method !== 'GET') {
@@ -97,7 +107,7 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
} }
let response: Response let response: Response
try { 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) { } catch (error) {
// fetch rejects with a TypeError when the network fails; an abort is a // fetch rejects with a TypeError when the network fails; an abort is a
// DOMException and says nothing about the network. // 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 // refreshMe shares one in-flight /me request between every request that
// got a csrf 403 at the same time. // got a csrf 403 at the same time. A request whose signal aborts stops
function refreshMe(): Promise<Me> { // waiting for it at once, and it is no longer shared: the next csrf 403
refreshing ??= loadMe().finally(() => { // starts a fresh /me rather than wait on one that may never answer. Once
refreshing = null // 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> { 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) { if (method === 'GET' && request.body !== undefined) {
throw new Error(`GET ${path}: a GET request cannot carry a body`) 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) { if (response.status === 403) {
const error = await parseForbidden(method, path, response) const error = await parseForbidden(method, path, response)
// Only a non-GET carries the token, so only it can be refused for it. // Only a non-GET carries the token, so only it can be refused for it.
if (error.code !== 'csrf' || method === 'GET') throw error if (error.code !== 'csrf' || method === 'GET') throw error
// The session's token changed: refresh it once and retry once. // The session's token changed: refresh it once and retry once.
await refreshMe() await refreshMe(request.signal)
response = await send(method, url, request.body) response = await send(method, url, request.body, request.signal)
if (response.status === 403) throw await parseForbidden(method, path, response) if (response.status === 403) throw await parseForbidden(method, path, response)
} }
return { body: await decode<T>(method, path, response), cached: response.headers.has(cacheFallbackHeader) } 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 return (await response.json()) as T
} }
async function loadMe(): Promise<Me> { async function loadMe(signal?: AbortSignal): Promise<Me> {
const me = await apiFetch<unknown>(ME_PATH) const me = await apiFetch<unknown>(ME_PATH, { signal })
if ( if (
!isRecord(me) || !isRecord(me) ||
typeof me.name !== 'string' || typeof me.name !== 'string' ||
+10
View File
@@ -414,6 +414,16 @@
text-align: center; 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. */ /* The way to the week's Supply run under the Draft board on mobile. */
.shoppingLink { .shoppingLink {
display: inline-flex; display: inline-flex;
+288 -11
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 { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter, Route, Routes, useLocation } from 'react-router' import { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { SupplyRunPage } from '../shopping/SupplyRunPage.tsx'
import { cacheFallbackHeader } from '../sw/routes.ts' import { cacheFallbackHeader } from '../sw/routes.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts' 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 // syncedNow is the plan of the week of Oct 5 as a push answers it: synced
// at the tests' noon. // at the tests' noon, with what became of the week's Supply run, made
function syncedNow() { // unless told otherwise.
return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z' } 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. // deferred is a response the test sends when it chooses.
@@ -135,17 +160,17 @@ function planReads(fetchMock: Mock<typeof fetch>) {
} }
// renderPage loads /me (for the CSRF token every write needs) and renders // renderPage loads /me (for the CSRF token every write needs) and renders
// the Plan page. // the Plan page, over queryClient, with shopping as the Supply run screen.
async function renderPage() { async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />) {
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe() await api.loadMe()
return render( return render(
<ApiProvider client={api}> <ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}> <QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/plan']}> <MemoryRouter initialEntries={['/plan']}>
<Routes> <Routes>
<Route path="/plan" element={<PlanPage />} /> <Route path="/plan" element={<PlanPage />} />
<Route path="/plan/shopping" element={<Opened />} /> <Route path="/plan/shopping" element={shopping} />
</Routes> </Routes>
</MemoryRouter> </MemoryRouter>
</QueryClientProvider> </QueryClientProvider>
@@ -153,10 +178,32 @@ async function renderPage() {
) )
} }
// Opened stands in for the screen a link opened, saying where it is. // Opened stands in for the screen a link opened, saying where it is, with
// a link back to the Plan.
function Opened() { function Opened() {
const { pathname, search } = useLocation() const { pathname, search } = useLocation()
return <p>Opened {pathname + search}</p> 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 // supplyRun is the Supply run of the week of Oct 5 with needed of its 32
@@ -420,6 +467,236 @@ describe('PlanPage', () => {
expect(planReads(fetchMock)).toBe(2) 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 () => { it('pushes nothing again until the changed plan is read and shown', async () => {
let read: () => Response | Promise<Response> = () => jsonResponse(200, planBody('draft')) let read: () => Response | Promise<Response> = () => jsonResponse(200, planBody('draft'))
let push = () => errorResponse(409, 'plan_changed') let push = () => errorResponse(409, 'plan_changed')
+49 -3
View File
@@ -7,7 +7,7 @@ import { QUEST_LOG_KEY } from '../discover/api.ts'
import { Confirm } from '../settings/LeaveGuard.tsx' import { Confirm } from '../settings/LeaveGuard.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.tsx' import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts' import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { supplyRunScreen, useSupplyRun } from '../shopping/api.ts' import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts' import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button, type ButtonVariant } from '../ui/Button.tsx' import { Button, type ButtonVariant } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css' import buttonStyles from '../ui/Button.module.css'
@@ -31,6 +31,7 @@ import {
type PlanDay, type PlanDay,
type PlanDinner, type PlanDinner,
type PlanStatus, type PlanStatus,
type PushedSupplyRun,
type Swap, type Swap,
} from './api.ts' } from './api.ts'
import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx' import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx'
@@ -88,6 +89,13 @@ const syncStatuses: Record<PlanStatus, string> = {
changed: 'Changes not pushed to Google Calendar', 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", // 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 // "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. // between the method and the duration, never inside the duration.
@@ -238,7 +246,9 @@ function SkeletonTiles({ label, layout, children }: { label: string; layout: Bre
// //
// Push to Calendar, on either layout, sends the plan shown and shows it as // 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 // 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({ function WeekBoard({
weekStart, weekStart,
timezone, timezone,
@@ -259,6 +269,8 @@ function WeekBoard({
const swap = useSwapDay(weekStart) const swap = useSwapDay(weekStart)
const push = usePushToCalendar(weekStart) const push = usePushToCalendar(weekStart)
const run = useSupplyRun(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 const pushing = push.pending
// The date of the day being swapped, while the swap sheet is open. // The date of the day being swapped, while the swap sheet is open.
const [swappingDate, setSwappingDate] = useState<string | null>(null) const [swappingDate, setSwappingDate] = useState<string | null>(null)
@@ -328,6 +340,7 @@ function WeekBoard({
if (!canPush) return if (!canPush) return
swap.dismiss() swap.dismiss()
push.reset() push.reset()
generate.reset()
push.mutate() push.mutate()
} }
@@ -339,6 +352,7 @@ function WeekBoard({
setJobId(null) setJobId(null)
swap.dismiss() swap.dismiss()
push.reset() push.reset()
generate.reset()
mutate( mutate(
{ weekStart, confirm }, { weekStart, confirm },
{ {
@@ -357,8 +371,33 @@ function WeekBoard({
else start(false) 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 ? ( 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 ? ( ) : swap.failed !== null ? (
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}> <ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message} Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
@@ -376,6 +415,11 @@ function WeekBoard({
) : ( ) : (
<ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner> <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 // 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 // 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 // Shopping list action still opens the Supply run. The count is that of a
@@ -391,6 +435,7 @@ function WeekBoard({
function swapIn(chosen: Swap) { function swapIn(chosen: Swap) {
setSwappingDate(null) setSwappingDate(null)
push.reset() push.reset()
generate.reset()
swap.mutate(chosen) swap.mutate(chosen)
} }
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up, // dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
@@ -562,6 +607,7 @@ function WeekBoard({
{header} {header}
{body} {body}
{failure} {failure}
{pushed}
{layout !== 'mobile' && supplyRunFailure} {layout !== 'mobile' && supplyRunFailure}
<Confirm <Confirm
open={confirming} open={confirming}
+54 -2
View File
@@ -10,6 +10,21 @@ import { planKey, usePlan, usePushToCalendar, useSwapDay, type Plan, type PushTo
const WEEK = '2026-10-05' const WEEK = '2026-10-05'
const PLAN = `/api/v1/plans/${WEEK}` 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 MONDAY = `${PLAN}/days/2026-10-05`
const FRIDAY = `${PLAN}/days/2026-10-09` 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'] 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, // The push succeeds while the third week is shown: it is left behind,
// and the week's pending swap is not. // 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([])) await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual([]))
expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success']) expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success'])
@@ -338,6 +353,43 @@ describe('week mutations', () => {
expect(weekMutations(queryClient, LATER)).toEqual([]) 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 () => { it('leaves behind what settles once Plan is left for another route, and keeps what is pending', async () => {
const friday = deferred() const friday = deferred()
const monday = deferred() const monday = deferred()
@@ -394,7 +446,7 @@ describe('week mutations', () => {
// The push succeeds: the plan as synced is recorded, and the push is // The push succeeds: the plan as synced is recorded, and the push is
// left behind. // left behind.
const synced = { ...planBody(), status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' } 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([])) 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') }) 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 { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts' import { QUEST_LOG_KEY } from '../discover/api.ts'
import { noteGoogleFailure } from '../settings/calendarApi.ts' import { noteGoogleFailure } from '../settings/calendarApi.ts'
import { supplyRunKey } from '../shopping/api.ts'
export const PLANS_PATH = '/api/v1/plans' 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 // parseDraftResult checks a draft_week job's result, which useJob passes on
// unread, is the draft of the week starting on weekStart. // unread, is the draft of the week starting on weekStart.
export function parseDraftResult(where: string, weekStart: string, body: unknown): DraftResult { 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 // 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. // cache keeps for it, or null for any other mutation.
function weekMutation(mutation: Mutation): { kind: 'swap' | 'push'; week: string } | null { 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 // 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, // 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 // 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, // back to it shows the failure. A week's latest push whose Supply run was
// those of weeks left pending, and one failure of each week whose last // not made stays whatever is shown: the week has no shopping list until one
// pending one settled since a week was last shown. // 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 { function keepWeekMutations(queryClient: QueryClient): WeeksShown {
const kept = weeksShown.get(queryClient) const kept = weeksShown.get(queryClient)
if (kept !== undefined) return kept if (kept !== undefined) return kept
@@ -225,7 +293,7 @@ function keepWeekMutations(queryClient: QueryClient): WeeksShown {
if (week.some((mutation) => mutation.state.status === 'pending')) continue if (week.some((mutation) => mutation.state.status === 'pending')) continue
const latest = week.at(-1) const latest = week.at(-1)
for (const mutation of week) { for (const mutation of week) {
if (settled && mutation === latest && mutation.state.status === 'error') continue if (mutation === latest && (unmade(mutation) || (settled && failed(mutation)))) continue
mutations.remove(mutation) mutations.remove(mutation)
} }
} }
@@ -363,10 +431,12 @@ function pushKey(weekStart: string) {
} }
// PushToCalendar is the push of one week, as the mutation cache holds it: // 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 = { export type PushToCalendar = {
pending: boolean pending: boolean
error: Error | null error: Error | null
supplyRun: PushedSupplyRun | null
mutate: () => void mutate: () => void
reset: () => void reset: () => void
} }
@@ -380,10 +450,19 @@ export type PushToCalendar = {
// the one shown. One Google refused the grant for reads the Google Calendar // the one shown. One Google refused the grant for reads the Google Calendar
// connection again, which is now reconnect required. // 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 // 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 // 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 // leaves settled pushes behind; it is refused while one is pending. A push
// pushes of a week no longer shown are left behind too // 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). // (keepWeekMutations).
export function usePushToCalendar(weekStart: string): PushToCalendar { export function usePushToCalendar(weekStart: string): PushToCalendar {
const { apiFetch } = useApi() const { apiFetch } = useApi()
@@ -395,12 +474,17 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
// The week's pushes, in the order sent. // The week's pushes, in the order sent.
const pushes = useMutationState({ const pushes = useMutationState({
filters: { mutationKey }, 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) const latest = pushes.at(-1)
function mutate() { function mutate() {
const mutation = mutations.build<Plan, Error, void, unknown>(queryClient, { const mutation = mutations.build<PushedPlan, Error, void, unknown>(queryClient, {
mutationKey, mutationKey,
// Kept until reset or settled in a week not shown (keepWeekMutations): // 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 // 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, gcTime: Infinity,
mutationFn: async () => { mutationFn: async () => {
const path = `${planPath(weekStart)}/push-calendar` 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) => { onError: async (error) => {
noteGoogleFailure(queryClient, error) noteGoogleFailure(queryClient, error)
if (error instanceof ApiError && error.code === 'plan_changed') { if (error instanceof ApiError && error.code === 'plan_changed') {
@@ -433,7 +523,26 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
return { return {
pending: pushes.some((push) => push.status === 'pending'), pending: pushes.some((push) => push.status === 'pending'),
error: latest?.status === 'error' ? latest.error! : null, error: latest?.status === 'error' ? latest.error! : null,
supplyRun: latest?.status === 'success' ? latest.data!.supply_run : null,
mutate, mutate,
reset, 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} />
}
+15
View File
@@ -200,6 +200,21 @@
color: var(--color-neutral-700); 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 { .qty {
flex: none; flex: none;
font-family: var(--font-body); font-family: var(--font-body);
+72 -27
View File
@@ -1,4 +1,5 @@
import { Archive, Check, MessageSquare } from 'lucide-react' import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router' import { Link, useSearchParams } from 'react-router'
import { Unavailable } from '../plan/PlanPage.tsx' import { Unavailable } from '../plan/PlanPage.tsx'
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts' import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
@@ -20,6 +21,7 @@ import {
type SupplyRunItem, type SupplyRunItem,
type Toggles, type Toggles,
} from './api.ts' } from './api.ts'
import { keep, savedSchedule, useSavedProblem } from './offline.ts'
import styles from './SupplyRunPage.module.css' import styles from './SupplyRunPage.module.css'
// SUPPLY_ARCHIVE is the Supply archive screen. // SUPPLY_ARCHIVE is the Supply archive screen.
@@ -38,10 +40,21 @@ export function sentAt(at: Date, timezone: string): string {
// ItemRow is one ingredient: on a current Supply run a checkbox, checked // 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 // (grape) while it is to buy; unchecked, the name is struck through and
// "have it" stands in for the quantity. An archived Supply run shows the // "have it" stands in for the quantity. A read-only Supply run shows the
// same marks, read-only. // same marks. An item toggled while the server cannot be reached
// says it will sync.
function ItemRow({ item, toggles }: { item: SupplyRunItem; toggles: Toggles | null }) { function ItemRow({ item, toggles }: { item: SupplyRunItem; toggles: Toggles | null }) {
const name = <span className={styles.name}>{item.name}</span> 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 ( return (
<li className={[styles.item, !item.needed && styles.have].filter(Boolean).join(' ')}> <li className={[styles.item, !item.needed && styles.have].filter(Boolean).join(' ')}>
{toggles === null ? ( {toggles === null ? (
@@ -108,12 +121,13 @@ function ArchiveLink({ layout }: { layout: Breakpoint }) {
// one card per dinner, stacked on mobile and in three masonry columns from // 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 // 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 // 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 is // toggle refused puts the item back and says why. An archived one, or any
// read-only, and a toggle it refused is not offered again. // opened from the Supply archive (readOnly), is read-only and tagged
function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; timezone: string; layout: Breakpoint }) { // Archived, and a toggle it refused is not offered again.
function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
const toggles = useToggles(weekStart) const toggles = useToggles(weekStart)
const run = toggles.shown const run = toggles.shown
const archived = run.status === 'archived' const viewOnly = readOnly || run.status === 'archived'
const wide = layout !== 'mobile' const wide = layout !== 'mobile'
const failed = toggles.failed const failed = toggles.failed
const failedItem = failed && run.groups.flatMap((group) => group.items).find((item) => item.id === failed.toggle.id) const failedItem = failed && run.groups.flatMap((group) => group.items).find((item) => item.id === failed.toggle.id)
@@ -125,21 +139,23 @@ function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; tim
<p className={styles.kicker}>Supply run · {weekLabel(weekStart).dates}</p> <p className={styles.kicker}>Supply run · {weekLabel(weekStart).dates}</p>
<div className={styles.titleRow}> <div className={styles.titleRow}>
<h1 className={styles.title}>Shopping list</h1> <h1 className={styles.title}>Shopping list</h1>
{archived && <Tag tone="grape">Archived</Tag>} {viewOnly && <Tag tone="grape">Archived</Tag>}
<p className={styles.count}> <p className={styles.count}>
{run.needed_count} / {run.total_count} {run.needed_count} / {run.total_count}
</p> </p>
</div> </div>
<p className={styles.helper}> <p className={styles.helper}>
{archived {run.status === 'archived'
? 'This list is in the Supply archive, as it was when the week ended.' ? 'This list is in the Supply archive, as it was when the week ended.'
: "Uncheck anything you already have — it won't be counted."} : 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> </p>
</div> </div>
{wide && ( {wide && (
<div className={styles.actions}> <div className={styles.actions}>
<ArchiveLink layout={layout} /> <ArchiveLink layout={layout} />
{!archived && ( {!viewOnly && (
<Unavailable hint={CHAT_HINT} below className={styles.action}> <Unavailable hint={CHAT_HINT} below className={styles.action}>
<Icon icon={MessageSquare} size="inline" /> <Icon icon={MessageSquare} size="inline" />
Send to Google Chat Send to Google Chat
@@ -149,7 +165,7 @@ function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; tim
)} )}
</header> </header>
{failed !== null && ( {failed !== null && (
<ErrorBanner onRetry={archived ? undefined : () => toggles.toggle(failed.toggle)}> <ErrorBanner onRetry={viewOnly ? undefined : () => toggles.toggle(failed.toggle)}>
Couldn't mark {failedItem ? failedItem.name : 'an item'} {failed.toggle.needed ? 'to buy' : '“have it”'}.{' '} Couldn't mark {failedItem ? failedItem.name : 'an item'} {failed.toggle.needed ? 'to buy' : '“have it”'}.{' '}
{failed.error.message} {failed.error.message}
</ErrorBanner> </ErrorBanner>
@@ -160,7 +176,7 @@ function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; tim
) : ( ) : (
<div className={styles.groups} data-testid="supply-run-groups"> <div className={styles.groups} data-testid="supply-run-groups">
{run.groups.map((group) => ( {run.groups.map((group) => (
<GroupCard key={`${group.date} ${group.dinner.id}`} group={group} toggles={archived ? null : toggles} /> <GroupCard key={`${group.date} ${group.dinner.id}`} group={group} toggles={viewOnly ? null : toggles} />
))} ))}
</div> </div>
)} )}
@@ -188,50 +204,79 @@ function SkeletonGroups({ layout }: { layout: Breakpoint }) {
) )
} }
type WeekProps = { weekStart: string; timezone: string; layout: Breakpoint; readOnly: boolean }
// WeekSupplyRun is the Supply run of the week starting on weekStart, or, // 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. // when the week has none yet, the offer to make it from the week's plan —
function WeekSupplyRun({ weekStart, timezone, layout }: { weekStart: string; timezone: string; layout: Breakpoint }) { // not offered read-only.
function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
const run = useSupplyRun(weekStart) const run = useSupplyRun(weekStart)
const generate = useGenerateSupplyRun(weekStart) const generate = useGenerateSupplyRun(weekStart)
if (run.isError) { // 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> 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.isPending || generate.isPending) return <SkeletonGroups layout={layout} />
if (run.data === null) { if (run.data === null) {
return ( return (
<div className={`${styles.page} ${styles[layout]}`}> <div className={`${styles.page} ${styles[layout]}`}>
<EmptyState title="No shopping list yet" action={{ label: 'Make the shopping list', onClick: () => generate.mutate() }}> {readOnly ? (
I'll gather the ingredients of this week's planned dinners into a Supply run. <EmptyState title="No shopping list">This week has no Supply run in the Supply archive.</EmptyState>
</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 && ( {generate.isError && (
<ErrorBanner onRetry={() => generate.mutate()}>Couldn't make the shopping list. {generate.error.message}</ErrorBanner> <ErrorBanner onRetry={() => generate.mutate()}>Couldn't make the shopping list. {generate.error.message}</ErrorBanner>
)} )}
</div> </div>
) )
} }
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} /> return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} readOnly={readOnly} />
} }
// SupplyRunPage is the Supply run (mock 1l on mobile, 2b on tablet, 2f on // 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 // 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 // plan_draft time has come, otherwise this week — or of the week chosen by
// ?week=, such as a week in the Supply archive. // ?week=. Opened from the Supply archive it is readOnly: tagged Archived,
export function SupplyRunPage() { // 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 layout = useBreakpoint()
const settings = useHouseholdSettings() const settings = useHouseholdSettings()
const [params] = useSearchParams() const [params] = useSearchParams()
const chosen = params.get(WEEK_PARAM) 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)) { if (chosen !== null && !isMonday(chosen)) {
return <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner> return <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner>
} }
if (settings.isError) { // 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> return <ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
} }
if (!settings.data) return <SkeletonGroups layout={layout} /> if (!schedule) return <SkeletonGroups layout={layout} />
const week = chosen ?? defaultWeek(settings.data, new Date()) const week = chosen ?? defaultWeek(schedule, new Date())
return <WeekSupplyRun key={week} weekStart={week} timezone={settings.data.timezone} layout={layout} /> return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} readOnly={readOnly} />
} }
// isMonday says whether date is a real YYYY-MM-DD Monday. // isMonday says whether date is a real YYYY-MM-DD Monday.
+340 -60
View File
@@ -1,8 +1,33 @@
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query' import {
import { useReducer } from 'react' MutationObserver,
import { ApiError, useApi } from '../api/index.ts' 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 { isRecord } from '../api/errors.ts'
import { meQueryKey } from '../AuthBootstrap.tsx'
import { oneOf } from '../dinners/api.ts' 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' export const SHOPPING_PATH = '/api/v1/shopping'
@@ -131,12 +156,22 @@ export function parseSupplyRun(where: string, weekStart: string, body: unknown):
} }
} }
// 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 // 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). // (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) { export function useSupplyRun(weekStart: string) {
const { apiRead } = useApi() const { apiRead } = useApi()
return useQuery({ return useQuery({
queryKey: supplyRunKey(weekStart), queryKey: supplyRunKey(weekStart),
initialData: () => keep(() => savedSupplyRun(weekStart)),
initialDataUpdatedAt: 0,
queryFn: async (): Promise<SupplyRun | null> => { queryFn: async (): Promise<SupplyRun | null> => {
const path = supplyRunPath(weekStart) const path = supplyRunPath(weekStart)
let body: unknown let body: unknown
@@ -153,91 +188,149 @@ export function useSupplyRun(weekStart: string) {
// useGenerateSupplyRun makes the week's Supply run from its plan (POST // useGenerateSupplyRun makes the week's Supply run from its plan (POST
// /api/v1/shopping/{weekStart}/generate); the Supply run it answers with is // /api/v1/shopping/{weekStart}/generate); the Supply run it answers with is
// the one shown. // 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) { export function useGenerateSupplyRun(weekStart: string) {
const { apiFetch } = useApi() const { apiFetch } = useApi()
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
onMutate: () => settleUnmadePushes(queryClient, weekStart),
mutationFn: async (): Promise<SupplyRun> => { mutationFn: async (): Promise<SupplyRun> => {
const path = `${supplyRunPath(weekStart)}/generate` const path = `${supplyRunPath(weekStart)}/generate`
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' })) return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
}, },
onSuccess: (run) => queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run), onSuccess: (run, _, settle) => {
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
settle()
},
}) })
} }
// Toggle is one item marked to buy (needed) or "have it" (not needed). // Toggle is one item marked to buy (needed) or "have it" (not needed).
export type Toggle = { id: string; needed: boolean } 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. // toggleKey keys the toggles of the week starting on weekStart.
function toggleKey(weekStart: string) { function toggleKey(weekStart: string) {
return ['shopping', weekStart, 'toggle'] as const 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 // Toggles is the toggles of one week's Supply run: shown, the Supply run
// with those still pending laid over it, the latest failed toggle no later // with those still pending laid over it, the items whose toggles will sync
// toggle of its item has superseded, and toggle to make another. // 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 = { export type Toggles = {
shown: SupplyRun shown: SupplyRun
willSync: ReadonlySet<string>
failed: { toggle: Toggle; error: Error } | null failed: { toggle: Toggle; error: Error } | null
toggle: (toggle: Toggle) => void toggle: (toggle: Toggle) => void
} }
// useToggles marks items of the week's Supply run, already read, to buy or // TOGGLE_DEADLINE is how long, in milliseconds, one try of a toggle — its
// "have it" (PATCH /api/v1/shopping/items/{id}). The Supply run cached is // GET /api/v1/me, its PATCH and the /me refresh a csrf 403 to it makes — may
// the server's: a toggle's answer writes the item and the counts it answers // take before it counts as the server not answering. Only toggles have a
// with there. Until then the toggle is pending and is laid over the Supply // deadline: the API client sets none.
// run shown, so the item and the needed count change at once; a refused export const TOGGLE_DEADLINE = 10_000
// toggle just stops being pending, which puts both back.
// 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).
// //
// The Supply run and the toggles are read straight from the query and // Toggles are sent one at a time, in the order made, whatever their week
// mutation caches as each render begins, never from the caches' batched // (one mutation scope, TOGGLE_SCOPE), so the counts each is answered with
// notices, which can arrive apart: a toggle is in the mutation cache as // include every toggle before it and none after, and a replayed toggle never
// pending as soon as it is made, and its answer is in the query cache // overlaps another replayed or made here. While the server cannot be
// before it stops being pending. Making a toggle renders again at once, so // reached — the device is offline, or the toggle's PATCH failed with a
// the checkbox clicked never shows its old state; useSupplyRun and // network error (fetch rejected with a TypeError), or a try ran past its
// useMutationState render again on every later change. // 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.
// //
// The week's toggles are sent one at a time, in the order made (one // Every toggle is in the queue saved on this device (enqueue, by useToggles)
// mutation scope), so the counts each is answered with include every toggle // until it settles, so a reload replays those the server never answered.
// before it and none after. Each is sent once, and not held back while the function toggleOptions(
// device is offline (networkMode always): an item never shows a state for weekStart: string,
// longer than it takes the server to refuse it. api: ApiClient,
// queryClient: QueryClient,
// A failed toggle stays failed, and shown, until a later toggle of its item ): MutationObserverOptions<Counts, Error, KeyedToggle> {
// 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) reads the Supply run again, which
// then renders read-only.
export function useToggles(weekStart: string): Toggles {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
const key = supplyRunKey(weekStart) const key = supplyRunKey(weekStart)
const mutationKey = toggleKey(weekStart) const mutationKey = toggleKey(weekStart)
useSupplyRun(weekStart) return {
useMutationState({ filters: { mutationKey } })
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 Toggle, error: mutation.state.error }))
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({
mutationKey, mutationKey,
scope: { id: mutationKey.join(' ') }, scope: { id: TOGGLE_SCOPE },
networkMode: 'always', networkMode: 'always',
retry: false, retry: false,
gcTime: Infinity, gcTime: Infinity,
mutationFn: async ({ id, needed }: Toggle): Promise<Counts> => { mutationFn: async ({ key, id, needed }: KeyedToggle): Promise<Counts> => {
const path = `${SHOPPING_PATH}/items/${encodeURIComponent(id)}` const path = `${SHOPPING_PATH}/items/${encodeURIComponent(id)}`
const where = `PATCH ${path}` const where = `PATCH ${path}`
const body = await apiFetch<unknown>(path, { method: 'PATCH', body: { needed } }) 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`) if (!isRecord(body) || body.id !== id || body.needed !== needed) throw new Error(`${where}: response is not item ${id} as toggled`)
return parseCounts(where, body) return parseCounts(where, body)
}, },
@@ -262,24 +355,137 @@ export function useToggles(weekStart: string): Toggles {
if (error instanceof ApiError && error.code === 'archived') void queryClient.invalidateQueries({ queryKey: key }) 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 // 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. // for it: the settled toggles of its item are the earlier ones. Settled,
onSettled: (_counts, _error, { id }) => { // it leaves the saved queue.
onSettled: (_counts, _error, { key: settled, id }) => {
const cache = queryClient.getMutationCache() const cache = queryClient.getMutationCache()
for (const earlier of cache.findAll({ mutationKey, predicate: (m) => m.state.status !== 'pending' })) { for (const earlier of cache.findAll({ mutationKey, predicate: (m) => m.state.status !== 'pending' })) {
if ((earlier.state.variables as Toggle).id === id) cache.remove(earlier) 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 { return {
shown: shown(run, toggles.filter((t) => t.status === 'pending').map((t) => t.toggle)), shown: shown(run, pending),
failed: failed === undefined ? null : { toggle: failed.toggle, error: failed.error! }, willSync: new Set(pending.filter((t) => willSync(t.key)).map((t) => t.id)),
toggle: (toggle) => { failed: failed === undefined ? null : { toggle: { id: failed.toggle.id, needed: failed.toggle.needed }, error: failed.error! },
mutate(toggle) toggle: ({ id, needed }) => {
const queuedToggle = { key: crypto.randomUUID(), weekStart, id, needed }
keep(() => enqueue(queuedToggle))
mutate({ key: queuedToggle.key, id, needed })
renderAgain() 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 // 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 // it: each item as its latest pending toggle marks it, and the needed count
// moved to match. // moved to match.
@@ -297,3 +503,77 @@ function shown(run: SupplyRun, pending: Toggle[]): SupplyRun {
})) }))
return { ...run, needed_count: needed, groups } 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)
})
})