Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d917a40fa2 | ||
|
|
445750cecb | ||
|
|
c589c02cdc | ||
|
|
9616941b73 | ||
|
|
680706df83 | ||
|
|
de75b1a883 | ||
|
|
1a781bfdb8 |
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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' ||
|
||||||
|
|||||||
@@ -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
@@ -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')
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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} />
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user