Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 86389be2ae Show the Kitchen reading kicker for a planned day already past
CI / ci (push) Successful in 4m55s
CI / ci (pull_request) Successful in 4m59s
The kicker only looked at plan days from today on, so a dinner this week's
plan held only on a past day showed no kicker. It now names the first planned
day from today on, or the latest past one when every planned day is past.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:44:34 -04:00
dev_agentandClaude Opus 5.5 522f74ec40 Add tablet Kitchen reading mode (2a) (Feature 15)
CI / ci (push) Successful in 5m3s
CI / ci (pull_request) Successful in 5m0s
From 768 px, /dinner/:id renders mock 2a instead of the phone sheet:
a header with back, the planned day as "{Weekday, Mon D} · Side
quest|Regular" (absent when this or next week's plan does not have the
dinner from today on), the 36 px title, a Keep screen on switch and an
A/A/A text-size control remembered per device in localStorage. Below,
the Ingredients card with 22 px checkboxes and "N of M set out" beside
the steps, one current at a time, moved by Previous / Next step and the
arrow keys (not while a text field has focus). Checked ingredients and
the current step are local to the visit.

The Keep screen on switch only holds its state for now: useKeepScreenOn
is the seam for the Wake Lock task (c7da10dc).

Maestro task de9f49a8-a6d0-41f4-9f75-8d7243546f3b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:39:41 -04:00
orchestrator-agent c454c76816 Add to this week and favorite (Feature 15) (#147)
CI / ci (push) Successful in 5m14s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 21:31:21 -04:00
orchestrator-agent 8068d06ff0 Mobile recipe detail (1k) (Feature 15) (#145)
CI / ci (push) Successful in 5m12s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 20:36:47 -04:00
orchestrator-agent d8ce8d8072 Dinner detail API (Feature 15) (#143)
CI / ci (push) Successful in 5m1s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 19:46:31 -04:00
orchestrator-agent cb451baed4 Feature 17: Shopping List and Supply Archive (#140)
CI / ci (push) Successful in 5m45s
CI / ci (pull_request) Successful in 5m23s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 15:16:51 -04:00
52 changed files with 5156 additions and 1622 deletions
+9 -55
View File
@@ -11,61 +11,14 @@ single Go service with the React PWA embedded in the binary.
> `/health`, embedded PWA skeleton, Makefile, Dockerfile, CI). The planned
> application is described in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## Quick start
## Running it locally
Requirements: Go 1.25 and Node.js `^22.22.2 || ^24.15.0 || >=26.0.0` — that
is, 22.22.2 or later on the 22 line, 24.15.0 or later on the 24 line, or 26.0.0
or later. Node.js 23, 24.0–24.14 and 25 are not supported: this is the
`engines` range of jsdom in the committed lockfile, the strictest of all the
web dependencies.
```sh
(cd web && npm ci)
mkdir -p ~/.mealquest-dev # outside the repository: it holds a secret
chmod 700 ~/.mealquest-dev
head -c 32 /dev/urandom | base64 > ~/.mealquest-dev/secret_key
chmod 600 ~/.mealquest-dev/secret_key
export MEALQUEST_HTTP_ADDR=:8080
export MEALQUEST_PUBLIC_URL=http://localhost:5173
export MEALQUEST_DATA_DIR="$HOME/.mealquest-dev"
export MEALQUEST_SHUTDOWN_TIMEOUT=10s
export MEALQUEST_SECRET_KEY_FILE="$HOME/.mealquest-dev/secret_key"
# Authentik OAuth2/OpenID provider for MealQuest (redirect URI:
# $MEALQUEST_PUBLIC_URL/auth/callback). Save its client secret to the file.
export MEALQUEST_OIDC_ISSUER=https://auth.example.com/application/o/mealquest/
export MEALQUEST_OIDC_CLIENT_ID=mealquest
export MEALQUEST_OIDC_CLIENT_SECRET_FILE="$HOME/.mealquest-dev/oidc_client_secret"
export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# Optional: sessions end after MEALQUEST_SESSION_IDLE unused (default 720h)
# and always MEALQUEST_SESSION_MAX after sign-in (default 4320h).
# export MEALQUEST_SESSION_IDLE=720h
# export MEALQUEST_SESSION_MAX=4320h
# Optional: comma-separated IPs/CIDRs of reverse proxies whose
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
# trusts no peer, which is right for local development.
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
# Optional: a SearXNG instance the Settings screen suggests as the recipe
# search URL until one is saved.
# export MEALQUEST_SEARXNG_URL=http://localhost:8888
export MEALQUEST_DEFAULT_TZ=America/New_York
make dev
```
`make dev` runs the Go server on `:8080` and the Vite dev server on `:5173`
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
http://localhost:5173. Configuration is environment-only and fails fast:
every variable above is required except the commented-out session
lifetimes (when set they must be positive, with MAX at least IDLE),
trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL
(an absolute `http://` or `https://` URL without credentials, a query or a
fragment) and `MEALQUEST_DEFAULT_TZ`, and
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
endpoints the issuer advertises must be `https://`; plain `http://` is
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
`MEALQUEST_DEFAULT_TZ` is the IANA zone the household settings are created
with on first boot: until those settings exist, boot fails unless it is a
valid IANA zone. Later boots ignore it, set or not (change the zone in
household settings instead).
The developer setup (prerequisites, Authentik and Google clients, the
`MEALQUEST_*` environment, `make dev` and the optional SearXNG sidecar) is
runbook information and lives in CTXD: project `mealquest`, file
`RUNBOOKS.MD`, section "Local development". Configuration is read only from
the environment, and the service refuses to start, naming every missing or
invalid variable.
## Common commands
@@ -78,7 +31,8 @@ household settings instead).
## Where to read next
- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, conventions.
- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, the quality
floor every change must pass, repository layout, conventions.
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — architecture of record.
- [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md) — product requirements.
- [design/MealQuest UI Designs/design_handoff_mealquest/README.md](design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md) — UI handoff.
+18 -23
View File
@@ -1,20 +1,15 @@
# MealQuest production environment. Copy to deploy/.env (never committed)
# and replace the placeholders. The service fails fast: a missing or invalid
# required variable stops it at boot with the variable named.
# MealQuest production environment: the template for deploy/.env (never
# committed). The service fails fast: a missing or invalid required variable
# stops it at boot with the variable named. Creating deploy/.env: CTXD
# project mealquest, RUNBOOKS.MD "Stack and first deploy" > "3. Environment
# file".
#
# Secrets are never set here. They live in deploy/secrets/ (never committed),
# one value per file, owned by the container user and readable only by it:
#
# mkdir -p deploy/secrets && chmod 700 deploy/secrets
# head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key
# printf '%s\n' '<Authentik client secret>' > deploy/secrets/oidc_client_secret
# printf '%s\n' '<Google OAuth client secret>' > deploy/secrets/google_client_secret
# sudo chown 10001:10001 deploy/secrets/* && sudo chmod 600 deploy/secrets/*
#
# SearXNG's secret key is the exception: compose reads it itself, so it
# stays owned by you (mode 0600). Create it after the chown above:
#
# (umask 077; printf 'SEARXNG_SECRET=%s\n' "$(openssl rand -hex 32)" > deploy/secrets/searxng.env)
# one value per file, owned by the container user 10001:10001 with mode
# 0600: secret_key, oidc_client_secret and google_client_secret. SearXNG's
# searxng.env is the exception: compose reads it itself, so it is owned by
# the host account that runs compose, mode 0600. Creating them: CTXD project
# mealquest, RUNBOOKS.MD "Stack and first deploy" > "4. Secrets".
#
# deploy/compose.yaml mounts them at /run/secrets/<name> and points the
# *_FILE variables at them.
@@ -42,8 +37,7 @@ MEALQUEST_TRUSTED_PROXY=10.60.1.33
# IANA zone the household settings are created with on first boot. Required
# until those settings exist; ignored afterwards (change the zone in
# household settings instead). Confirm the household's zone before the first
# deploy.
# household settings instead).
MEALQUEST_DEFAULT_TZ=America/New_York
# --- Authentik OIDC ----------------------------------------------------------
@@ -57,10 +51,10 @@ MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# --- Google Calendar (Push to Calendar) ------------------------------------
# Client ID of the Google OAuth web client (deploy/RUNBOOK.md "Google
# Calendar OAuth client"). Required: the service will not start without it
# and the client secret file. Its authorized redirect URI is
# <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
# Client ID of the Google OAuth web client (CTXD project mealquest,
# RUNBOOKS.MD "Google Calendar OAuth client"). Required: the service will
# not start without it and the client secret file. Its authorized redirect
# URI is <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
# Optional session lifetimes. A session ends after IDLE unused and always MAX
@@ -82,13 +76,14 @@ MEALQUEST_SESSION_MAX=4320h
# The SearXNG recipe-search sidecar on the compose network. The Settings
# screen pre-fills it as the SearXNG URL until one is saved; the saved
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
# See deploy/RUNBOOK.md "Recipe search (SearXNG)".
# See CTXD project mealquest, RUNBOOKS.MD "Recipe search (SearXNG)".
# --- Planned; not read by the service yet -------------------------------------
#
# Added to deploy/compose.yaml and this file by the features that use them.
#
# Web Push (Feature 19). Generate the key pair with cmd/vapidgen.
# Web Push (Feature 19). Key pair: CTXD project mealquest, RUNBOOKS.MD
# "VAPID keys (Web Push)".
# MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
# MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
-1417
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -1,8 +1,7 @@
# Development override: publishes SearXNG on the dev machine's loopback so
# it can be queried directly, e.g.
#
# docker compose -f deploy/compose.yaml -f deploy/compose.dev.yaml up -d searxng
# curl 'http://127.0.0.1:8888/search?q=chicken+tikka+masala+recipe&format=json'
# Development override layered on deploy/compose.yaml: publishes SearXNG
# on the dev machine's loopback (127.0.0.1:8888) so it can be queried
# directly. Using it: CTXD project mealquest, RUNBOOKS.MD "Local
# development".
#
# Loopback only, never a LAN address. Never used in production: the
# systemd unit starts deploy/compose.yaml alone.
+10 -12
View File
@@ -1,18 +1,16 @@
# Production stack for MealQuest on 10.60.1.14 (D-018). Build on the host,
# then start through systemd:
#
# docker compose -f deploy/compose.yaml build
# sudo systemctl restart mealquest
# Production stack for MealQuest on 10.60.1.14 (D-018). Building, deploying
# and operating it: CTXD project mealquest, RUNBOOKS.MD "Stack unit" and
# "Routine deploy and rollback".
#
# The services use restart: "no" on purpose: Docker must not start them
# before the DOCKER-USER firewall is applied. deploy/systemd/mealquest.service
# owns start and stop and requires mealquest-firewall.service first.
#
# Configuration comes from deploy/.env (copy deploy/.env.example) and secrets
# from deploy/secrets/; neither is committed. Compose bind-mounts file secrets
# with their host owner and mode, so each file in deploy/secrets/ must be
# owned by the container user 10001:10001 with mode 0600 (except
# searxng.env, read by compose itself; see the searxng service).
# Configuration comes from deploy/.env (template: deploy/.env.example) and
# secrets from deploy/secrets/; neither is committed. Compose bind-mounts
# file secrets with their host owner and mode, so each file in
# deploy/secrets/ must be owned by the container user 10001:10001 with mode
# 0600 (except searxng.env, read by compose itself; see the searxng service).
name: mealquest
services:
@@ -65,8 +63,8 @@ services:
# the app's own user (owner of the volume) with a read-only root
# filesystem, no privilege escalation and no Linux capabilities.
#
# Restore: follow "Restore the database" in deploy/RUNBOOK.md (stop the
# stack, move the database with its -wal and -shm aside, restore, check).
# Restoring the database: CTXD project mealquest, RUNBOOKS.MD "Restore
# the database".
litestream:
image: litestream/litestream:0.5.17
user: "10001:10001"
+3 -2
View File
@@ -5,8 +5,9 @@
# are one-way: a failure here fails this unit visibly and never fails
# docker.service.
#
# Start MealQuest only after this unit has succeeded:
# systemctl start mealquest-firewall && docker compose up -d
# mealquest.service requires this unit and starts the stack only after it
# has succeeded. Installing and operating both: CTXD project mealquest,
# RUNBOOKS.MD "Host firewall" and "Stack unit".
[Unit]
Description=Restrict MealQuest port 8090 to Traefik (DOCKER-USER)
After=docker.service
@@ -1,8 +1,7 @@
# Runs the nightly MealQuest media backup (/usr/local/sbin/mealquest-media-backup).
# Triggered by mealquest-media-backup.timer; run by hand with
# systemctl start mealquest-media-backup
# and read the result with
# journalctl -u mealquest-media-backup
# Triggered by mealquest-media-backup.timer; output goes to the journal.
# Installing it and running it by hand: CTXD project mealquest,
# RUNBOOKS.MD "Media — nightly rsync".
#
# The script runs as the app's user (10001, host user "mealquest") with no
# capabilities. systemd, as root, only bind-mounts the data volume's root
+3 -2
View File
@@ -15,8 +15,9 @@
# swapping the path afterwards cannot redirect it. Symlinks inside the
# directory are copied as links (-a), never followed.
#
# The media directory is created at deploy time (deploy/RUNBOOK.md). If it
# is missing the job fails; it is never treated as "nothing to back up".
# The media directory is created at deploy time (CTXD project mealquest,
# RUNBOOKS.MD "Media — nightly rsync"). If it is missing the job fails; it
# is never treated as "nothing to back up".
#
# rsync mirrors into .../mealquest-backups/media/ only: the target ends in
# a slash, so --delete removes files from that directory and nothing
+24
View File
@@ -204,6 +204,30 @@ automatic** (D-021).
sets status `archived` and keeps the row; `POST
/api/v1/dinners/{id}/favorite` toggles `is_favorite`. Every write records
the session's subject as `updated_by`; an unknown id is 404.
`GET /api/v1/dinners/{id}` returns the dinner with its recipe, read in
one transaction: `{id, name, status, origin, method, minutes_total,
difficulty, cuisine, is_favorite, photo_urls, ingredients, steps,
source, grounding_status, grounding_error, why_picked}`. `photo_urls` is
`{"400": url, "1200": url}` (`/media/photos/{photo_id}-{size}.jpg`) or
null with no photo; `ingredients` is `[{name, qty, unit, note,
raw_text}]` and `steps` the step texts, both in recipe position order and
empty (`[]`) with no recipe, as for a dinner whose grounding is
`pending` or `failed`; a recipe entered by hand has only `raw_text`.
Two items at one position leave recipe order unknown, so such a recipe
is 500 rather than shown out of order. `source` is `{site_name}` of the
page the recipe was fetched from, null for a manual recipe or none; it
has no `url`, as the page's link may carry a personal token.
`site_name` is the name the page gives its site (publisher or
`og:site_name`) and null otherwise: a page naming no site is stored
under its host, which may be the pasted link's — a token in a
subdomain — or a redirect's, so any stored name that could be a host
(the link's host, an IP address or a dotted host name, even a real
name like "Food.com") is withheld. The detail screen shows "Recipe
from {site}" only when `site_name` is not null. `why_picked` is the reason of the
suggestion that created the dinner — its pending or accepted one, as
declining a suggestion archives its dinner — null for one no suggestion
created; a dinner with more than one such suggestion is 500, as nothing
records which created it. An unknown or archived dinner is 404.
- Dinner grounding. `POST /api/v1/dinners` also enqueues a `ground_dinner`
job and answers with its `grounding_job_id` without waiting for it
(`GET /api/v1/jobs/{id}` reports it); if the job cannot be enqueued it
+7
View File
@@ -103,6 +103,13 @@ type Querier interface {
// Returns sql.ErrNoRows until the row exists.
GetChatSettings(ctx context.Context) (ChatSetting, error)
GetDinner(ctx context.Context, id string) (Dinner, error)
// The reason of each suggestion of dinner_id that can have created it,
// none for a dinner no suggestion created. A suggestion is only ever
// created with a new dinner, and declining one archives its dinner, so a
// live dinner's creating suggestion is pending (the dinner still
// suggested) or accepted; a declined one did not create it. More than one
// row means nothing records which did.
GetDinnerSuggestionReasons(ctx context.Context, dinnerID string) ([]string, error)
// Returns sql.ErrNoRows until the row exists.
GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error)
// Returns sql.ErrNoRows until the row exists.
+35
View File
@@ -187,6 +187,41 @@ func (q *Queries) GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch
return i, err
}
const getDinnerSuggestionReasons = `-- name: GetDinnerSuggestionReasons :many
SELECT reason FROM suggestions
WHERE dinner_id = ? AND status IN ('pending', 'accepted')
ORDER BY id
`
// The reason of each suggestion of dinner_id that can have created it,
// none for a dinner no suggestion created. A suggestion is only ever
// created with a new dinner, and declining one archives its dinner, so a
// live dinner's creating suggestion is pending (the dinner still
// suggested) or accepted; a declined one did not create it. More than one
// row means nothing records which did.
func (q *Queries) GetDinnerSuggestionReasons(ctx context.Context, dinnerID string) ([]string, error) {
rows, err := q.db.QueryContext(ctx, getDinnerSuggestionReasons, dinnerID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var reason string
if err := rows.Scan(&reason); err != nil {
return nil, err
}
items = append(items, reason)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getLatestSuggestionBatch = `-- name: GetLatestSuggestionBatch :one
SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches
ORDER BY id DESC
+218
View File
@@ -0,0 +1,218 @@
package dinners
import (
"context"
"database/sql"
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
)
// Detail is a dinner with its recipe, as the recipe detail screen and
// Kitchen reading mode show it.
type Detail struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Origin string `json:"origin"`
Method *string `json:"method"`
MinutesTotal *int64 `json:"minutes_total"`
Difficulty *string `json:"difficulty"`
Cuisine *string `json:"cuisine"`
IsFavorite bool `json:"is_favorite"`
// PhotoURLs is the cached photo at each stored width, keyed by width
// ("400", "1200"); null when the dinner has no photo.
PhotoURLs map[string]string `json:"photo_urls"`
// Ingredients and Steps are the recipe's, in recipe order; empty when
// the dinner has no recipe.
Ingredients []Ingredient `json:"ingredients"`
Steps []string `json:"steps"`
// Source is the site of the recipe page the dinner is grounded in;
// null for a recipe entered by hand or a dinner with no recipe.
Source *Source `json:"source"`
GroundingStatus string `json:"grounding_status"`
// GroundingError is why grounding failed, one of the Reason codes;
// null unless GroundingStatus is failed.
GroundingError *string `json:"grounding_error"`
// WhyPicked is the reason of the suggestion that created the dinner
// (see GetDinnerSuggestionReasons); null for a dinner no suggestion
// created.
WhyPicked *string `json:"why_picked"`
}
// Ingredient is one recipe line. RawText is the line as the recipe gives
// it; Name, Qty, Unit and Note are normalize_recipe's reading of it, each
// null when it has none, and Name, Qty and Unit always null for a recipe
// entered by hand (see IsManualRecipe).
type Ingredient struct {
Name *string `json:"name"`
Qty *string `json:"qty"`
Unit *string `json:"unit"`
Note *string `json:"note"`
RawText string `json:"raw_text"`
}
// Source is the site a recipe was fetched from. It has no url: the page's
// link, pasted or offered as a candidate, may carry a personal token, and
// no stored link can be shown to be free of one, so like a dinner's
// recipe_url and a candidate's page it never leaves the server. SiteName
// is the name the page itself gives its site, and null when it gives none
// or the stored name could have come from a link (see pageSiteName), so a
// client shows "Recipe from {site}" only when SiteName is not null.
type Source struct {
SiteName *string `json:"site_name"`
}
// Get returns dinner id with its recipe and the reason it was suggested,
// read in one read transaction. An unknown or archived dinner is
// ErrNotFound: an archived dinner has no detail to show.
func (s *Service) Get(ctx context.Context, id string) (d Detail, err error) {
tx, err := s.st.Reader.BeginTx(ctx, nil)
if err != nil {
return Detail{}, fmt.Errorf("dinners: begin read: %w", err)
}
defer func() {
err = errors.Join(err, tx.Rollback())
}()
q := db.New(tx)
row, err := q.GetDinner(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return Detail{}, ErrNotFound
}
if err != nil {
return Detail{}, fmt.Errorf("dinners: read %s: %w", id, err)
}
if row.Status == StatusArchived {
return Detail{}, ErrNotFound
}
d = Detail{
ID: row.ID,
Name: row.Name,
Status: row.Status,
Origin: row.Origin,
Method: nullString(row.Method),
Difficulty: nullString(row.Difficulty),
Cuisine: nullString(row.Cuisine),
IsFavorite: row.IsFavorite == 1,
Ingredients: []Ingredient{},
Steps: []string{},
GroundingStatus: row.GroundingStatus,
GroundingError: nullString(row.GroundingError),
}
if row.MinutesTotal.Valid {
d.MinutesTotal = &row.MinutesTotal.Int64
}
if row.PhotoID.Valid {
d.PhotoURLs = make(map[string]string, len(recipes.PhotoSizes))
for _, size := range recipes.PhotoSizes {
d.PhotoURLs[strconv.Itoa(size)] = photoURL(row.PhotoID.String, size)
}
}
if (row.GroundingStatus == GroundingGrounded || row.GroundingStatus == GroundingManual) && !row.RecipeID.Valid {
return Detail{}, fmt.Errorf("dinners: %s is %s with no recipe", id, row.GroundingStatus)
}
if row.RecipeID.Valid {
if err := readRecipe(ctx, q, row.RecipeID.String, &d); err != nil {
return Detail{}, fmt.Errorf("dinners: read recipe of %s: %w", id, err)
}
}
reasons, err := q.GetDinnerSuggestionReasons(ctx, id)
if err != nil {
return Detail{}, fmt.Errorf("dinners: read suggestion reason of %s: %w", id, err)
}
switch len(reasons) {
case 0:
case 1:
d.WhyPicked = &reasons[0]
default:
// Nothing records which of them created the dinner, and any
// one picked would be a guess.
return Detail{}, fmt.Errorf("dinners: %s has %d suggestions that could have created it", id, len(reasons))
}
return d, nil
}
// readRecipe sets d's Source, Ingredients and Steps from recipe recipeID.
func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail) error {
rec, err := q.GetRecipe(ctx, recipeID)
if err != nil {
return fmt.Errorf("recipe %s: %w", recipeID, err)
}
if !IsManualRecipe(rec.SourceUrl) {
site, err := pageSiteName(rec.SiteName, rec.SourceUrl)
if err != nil {
return err
}
d.Source = &Source{SiteName: site}
}
ings, err := q.ListRecipeIngredients(ctx, recipeID)
if err != nil {
return fmt.Errorf("ingredients: %w", err)
}
for i, ing := range ings {
if i > 0 && ing.Position == ings[i-1].Position {
return duplicatePosition("ingredient", ing.Position)
}
d.Ingredients = append(d.Ingredients, Ingredient{
Name: nullString(ing.Name),
Qty: nullString(ing.Qty),
Unit: nullString(ing.Unit),
Note: nullString(ing.Note),
RawText: ing.RawText,
})
}
steps, err := q.ListRecipeSteps(ctx, recipeID)
if err != nil {
return fmt.Errorf("steps: %w", err)
}
for i, step := range steps {
if i > 0 && step.Position == steps[i-1].Position {
return duplicatePosition("step", step.Position)
}
d.Steps = append(d.Steps, step.Text)
}
return nil
}
// pageSiteName is the stored site name of a recipe fetched from sourceURL
// when it is one the page gave, else nil. A page that names no site is
// stored under recipes.HostSiteName of the URL it was served from: the
// pasted link's host, which may hold a personal token (a private
// subdomain), or that of a page the link redirected to, which is not
// stored. Nothing records which a stored name is, so any name that could
// be a host — the link's own host, an IP address, or a dotted host name —
// is treated as one and withheld. A site that names itself as a domain,
// such as "Food.com", loses its attribution with them: a recipe with no
// attribution is better than one that leaks a link.
func pageSiteName(stored sql.NullString, sourceURL string) (*string, error) {
if !stored.Valid {
return nil, nil
}
link, err := url.Parse(sourceURL)
if err != nil {
// Not wrapped: url.Parse's error quotes the link.
return nil, errors.New("source url does not parse")
}
name := stored.String
if strings.EqualFold(name, recipes.HostSiteName(link)) || net.ParseIP(name) != nil {
return nil, nil
}
if u, err := url.Parse("https://" + name + "/"); err == nil && u.Host == name && strings.Contains(name, ".") {
return nil, nil
}
return &name, nil
}
// duplicatePosition is the error for two recipe items of kind at position.
// Position is the only record of recipe order, and the id that breaks the
// tie says nothing about it, so a recipe with a duplicate is refused rather
// than shown in an order nobody can trust.
func duplicatePosition(kind string, position int64) error {
return fmt.Errorf("two %ss at position %d: recipe order is unknown", kind, position)
}
+6 -1
View File
@@ -572,7 +572,12 @@ func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeUR
// PhotoURL is where the API serves a list-sized copy of photo id.
func PhotoURL(id string) string {
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, photoListWidth)
return photoURL(id, photoListWidth)
}
// photoURL is where the API serves photo id at stored width size.
func photoURL(id string, size int) string {
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, size)
}
func fromRow(r db.Dinner) Dinner {
+44
View File
@@ -1,6 +1,7 @@
package dinners_test
import (
"database/sql"
"encoding/json"
"errors"
"strings"
@@ -74,3 +75,46 @@ func TestCreateRegularNeverNamesFromLink(t *testing.T) {
}
check("duplicate error", dup.Error())
}
// TestPageSiteNameWithholdsHosts: a stored site name that could be a host —
// the link's own, in any case and without "www.", an IP address or a dotted
// host name — is withheld; a name the page gave, dotless or with spaces, is
// kept.
func TestPageSiteNameWithholdsHosts(t *testing.T) {
t.Parallel()
const link = "https://www.TokenHost/recipes/stew"
for _, tc := range []struct {
stored string
valid bool
want *string
}{
{"", false, nil},
{"tokenhost", true, nil},
{"TOKENHOST", true, nil},
{"private-token.example.com", true, nil},
{"Food.com", true, nil},
{"203.0.113.7", true, nil},
{"2001:db8::1", true, nil},
{"Delish", true, ptr("Delish")},
{"Bon Appétit", true, ptr("Bon Appétit")},
{"Mr. Food Test Kitchen", true, ptr("Mr. Food Test Kitchen")},
} {
got, err := dinners.PageSiteName(sql.NullString{String: tc.stored, Valid: tc.valid}, link)
if err != nil {
t.Fatalf("PageSiteName(%q): %v", tc.stored, err)
}
if (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) {
t.Errorf("PageSiteName(%q) = %v, want %v", tc.stored, deref(got), deref(tc.want))
}
}
if _, err := dinners.PageSiteName(sql.NullString{String: "Delish", Valid: true}, "https://secret-token\x7f.example/%zz"); err == nil || strings.Contains(err.Error(), "secret-token") {
t.Errorf("PageSiteName of an unparseable link: err = %v, want one not quoting the link", err)
}
}
func deref(s *string) any {
if s == nil {
return nil
}
return *s
}
+3
View File
@@ -71,3 +71,6 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error)
r, err := s.runGroundJob(ctx, p, nil)
return r.GroundingStatus, err
}
// PageSiteName is pageSiteName.
var PageSiteName = pageSiteName
+8 -2
View File
@@ -29,7 +29,7 @@ type Recipe struct {
// Title is the recipe's name.
Title string
// SiteName is the publisher's name, else the page's og:site_name, else
// its host without a leading "www.".
// HostSiteName of the page's URL.
SiteName string
// ImageURL is the largest image, absolute; empty when the recipe has
// none.
@@ -232,7 +232,13 @@ func siteName(publisher any, g graph, ogSiteName string, base *url.URL) (string,
if ogSiteName != "" {
return ogSiteName, nil
}
return strings.TrimPrefix(base.Hostname(), "www."), nil
return HostSiteName(base), nil
}
// HostSiteName is the site name of a page that names none: its host
// without a leading "www.".
func HostSiteName(u *url.URL) string {
return strings.TrimPrefix(u.Hostname(), "www.")
}
// imageURL picks the largest of the recipe's images by width × height;
+404
View File
@@ -0,0 +1,404 @@
package server
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// getDetail answers GET /dinners/{id} for id, failing unless it is 200, and
// returns the body both raw (to tell null from absent) and decoded.
func getDetail(t *testing.T, h http.Handler, id string) (map[string]any, dinners.Detail) {
t.Helper()
rec, raw := callDinners(t, h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET dinner = %d: %s", rec.Code, rec.Body)
}
var d dinners.Detail
dec := json.NewDecoder(rec.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&d); err != nil {
t.Fatalf("decode detail: %v", err)
}
return raw, d
}
// wantNull fails unless each key is present in raw and null.
func wantNull(t *testing.T, raw map[string]any, keys ...string) {
t.Helper()
for _, k := range keys {
if v, ok := raw[k]; !ok || v != nil {
t.Errorf("%s = %v (present %v), want null", k, v, ok)
}
}
}
// TestGetDinnerGroundedSideQuest: a Side quest grounded in a fetched page
// shows its photo at both stored sizes, its recipe's ingredients and steps
// in recipe order (not row order), its source site and the reason it was
// suggested.
func TestGetDinnerGroundedSideQuest(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
e.start(t)
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "succeeded" {
t.Fatalf("grounding job = %v", job)
}
id := created["id"].(string)
// Make it an accepted Side quest with the suggestion that created it,
// and reverse its recipe's positions so row order disagrees with
// recipe order.
var recipeID, photoID string
if err := e.st.Reader.QueryRow(`SELECT recipe_id, photo_id FROM dinners WHERE id = ?`, id).Scan(&recipeID, &photoID); err != nil {
t.Fatal(err)
}
for _, stmt := range []string{
`UPDATE dinners SET status = 'quest_log', origin = 'side_quest', is_favorite = 1 WHERE id = ?`,
`UPDATE recipe_ingredients SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
`UPDATE recipe_steps SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
} {
if _, err := e.st.Writer.Exec(stmt, id); err != nil {
t.Fatal(err)
}
}
q := db.New(e.st.Writer)
now := store.Now().String()
batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
if err != nil {
t.Fatal(err)
}
const reason = "Like your Tikka masala, but quicker."
if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{
ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: "Chicken curry",
Reason: reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
_, got := getDetail(t, e.h, id)
str := func(s string) *string { return &s }
minutes := int64(40)
want := dinners.Detail{
ID: id,
Name: "Chicken curry",
Status: "quest_log",
Origin: "side_quest",
Method: str("stovetop"),
MinutesTotal: &minutes,
Difficulty: str("easy"),
IsFavorite: true,
PhotoURLs: map[string]string{
"400": "/media/photos/" + photoID + "-400.jpg",
"1200": "/media/photos/" + photoID + "-1200.jpg",
},
Ingredients: []dinners.Ingredient{
{Name: str("garlic"), Qty: str("2"), Unit: str("cloves"), Note: str("minced"), RawText: "2 cloves garlic, minced"},
{Name: str("onion"), Qty: str("1"), RawText: "1 onion"},
},
Steps: []string{"Fry with the garlic.", "Chop the onion."},
Source: &dinners.Source{SiteName: str("Curry House")},
GroundingStatus: "grounded",
WhyPicked: str(reason),
}
if !reflect.DeepEqual(got, want) {
gj, _ := json.Marshal(got)
wj, _ := json.Marshal(want)
t.Errorf("detail =\n %s\nwant\n %s", gj, wj)
}
// Each photo URL is served.
for _, u := range got.PhotoURLs {
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, signedIn(http.MethodGet, u, nil))
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/jpeg" {
t.Errorf("GET %s = %d %s", u, rec.Code, rec.Header().Get("Content-Type"))
}
}
}
// TestGetDinnerManualRegular: a Regular added by hand and given a recipe
// typed in has no photo, no source and no reason, and its ingredients are
// the typed lines with nothing normalized.
func TestGetDinnerManualRegular(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Family chili"}`)
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body)
}
raw, got := getDetail(t, e.h, id)
wantNull(t, raw, "photo_urls", "source", "why_picked", "difficulty", "grounding_error")
str := func(s string) *string { return &s }
minutes := int64(240)
want := dinners.Detail{
ID: id,
Name: "Family chili",
Status: "regular",
Origin: "regular",
Method: str("crockpot"),
MinutesTotal: &minutes,
Ingredients: []dinners.Ingredient{{RawText: "1 lb beef"}, {RawText: "1 can beans"}},
Steps: []string{"Brown.", "Simmer."},
GroundingStatus: "manual",
}
if !reflect.DeepEqual(got, want) {
gj, _ := json.Marshal(got)
wj, _ := json.Marshal(want)
t.Errorf("detail =\n %s\nwant\n %s", gj, wj)
}
}
// TestGetDinnerFailedGrounding: a dinner whose grounding failed shows its
// real state — failed with its reason, no photo, no source and empty
// ingredients and steps — never a pretend recipe.
func TestGetDinnerFailedGrounding(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
e.start(t)
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Nothing matches this"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "failed" {
t.Fatalf("grounding job = %v, want failed", job)
}
raw, got := getDetail(t, e.h, created["id"].(string))
wantNull(t, raw, "photo_urls", "source", "why_picked", "method", "minutes_total", "difficulty")
if ings, ok := raw["ingredients"].([]any); !ok || len(ings) != 0 {
t.Errorf("ingredients = %v, want []", raw["ingredients"])
}
if steps, ok := raw["steps"].([]any); !ok || len(steps) != 0 {
t.Errorf("steps = %v, want []", raw["steps"])
}
if got.GroundingStatus != "failed" || got.GroundingError == nil || *got.GroundingError != dinners.ReasonNoRecipe ||
got.Name != "Nothing matches this" || got.Status != "regular" || got.Origin != "regular" {
t.Errorf("detail = %+v, want the failed Regular with no_recipe_found", got)
}
}
// TestGetDinnerNotFound: an unknown dinner and an archived one are both
// 404, and the detail needs a session like every /api/v1 route.
func TestGetDinnerNotFound(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Old favourite"}`)
if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body)
}
for _, target := range []string{id, store.NewID()} {
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+target, "")
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("GET %s = %d %v, want 404 not_found", target, rec.Code, body)
}
}
live := createDinner(t, e.h, `{"name":"Taco night"}`)
req := httptest.NewRequest(http.MethodGet, dinnersPath+"/"+live, nil)
if rec, body := serve(t, e.h, req); rec.Code != http.StatusUnauthorized || errorCode(body) != "unauthenticated" {
t.Errorf("GET without a session = %d %v, want 401", rec.Code, body)
}
}
// TestGetDinnerSourceHidesTokenURL: a recipe grounded in a pasted link that
// carries a token in its path or query shows its site name, but never the
// link or any part of it.
func TestGetDinnerSourceHidesTokenURL(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
const (
pathToken = "path-token-abc123"
queryToken = "query-token-def456"
pathPage = "https://secret-path.example/u/" + pathToken + "/chicken-curry"
queryPage = "https://secret-query.example/curry?access_token=" + queryToken
)
e.pages.Bodies[pathPage] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
e.pages.Bodies[queryPage] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25)
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
for _, tc := range []struct{ dish, page, site string }{
{"Chicken curry", pathPage, "Curry House"},
{"Weeknight curry", queryPage, "B Kitchen"},
} {
id := createDinner(t, e.h, `{"name":"`+tc.dish+`"}`) // runner not started: stays pending
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+tc.page+`"}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground %s = %d: %s", tc.page, rec.Code, rec.Body)
}
var stored string
if err := e.st.Reader.QueryRow(`SELECT source_url FROM recipes WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored != tc.page {
t.Fatalf("stored source_url = %q, want %q", stored, tc.page)
}
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET dinner = %d: %s", rec.Code, rec.Body)
}
for _, secret := range []string{tc.page, "secret-path.example", "secret-query.example", pathToken, queryToken, "access_token", `"url"`} {
if strings.Contains(rec.Body.String(), secret) {
t.Errorf("detail of %s contains %q: %s", tc.dish, secret, rec.Body)
}
}
raw, _ := getDetail(t, e.h, id)
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": tc.site}) {
t.Errorf("source of %s = %v, want only site_name %q", tc.dish, raw["source"], tc.site)
}
}
}
// TestGetDinnerSiteNameNeverFromLink: a page that names no site is stored
// under its host, so a recipe grounded in a pasted link whose host carries a
// token has site_name null and nothing from the link in its detail; a page
// that names its site keeps that name, even on a token-bearing host; and a
// stored name shaped like another host, as a redirect leaves, is withheld.
func TestGetDinnerSiteNameNeverFromLink(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
const (
bareToken = "private-token-abc123"
namedToken = "named-token-def456"
bare = "https://" + bareToken + ".example.com/recipe"
named = "https://www." + namedToken + ".example.com/recipe"
)
e.pages.Bodies[bare] = recipestest.RecipePage("Chicken Curry", "", "https://a.example/curry.jpg", 40)
e.pages.Bodies[named] = recipestest.RecipePage("Weeknight Curry", "Curry House", "https://b.example/curry.jpg", 25)
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
ground := func(dish, page string) (id, recipeID string) {
t.Helper()
id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+page+`"}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground %s = %d: %s", page, rec.Code, rec.Body)
}
if err := e.st.Reader.QueryRow(`SELECT recipe_id FROM dinners WHERE id = ?`, id).Scan(&recipeID); err != nil {
t.Fatal(err)
}
return id, recipeID
}
storedSite := func(recipeID string) string {
t.Helper()
var site string
if err := e.st.Reader.QueryRow(`SELECT site_name FROM recipes WHERE id = ?`, recipeID).Scan(&site); err != nil {
t.Fatal(err)
}
return site
}
check := func(what, id string, leaks []string, want any) {
t.Helper()
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d: %s", what, rec.Code, rec.Body)
}
for _, leak := range leaks {
if strings.Contains(strings.ToLower(rec.Body.String()), leak) {
t.Errorf("detail of %s contains %q: %s", what, leak, rec.Body)
}
}
raw, _ := getDetail(t, e.h, id)
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": want}) {
t.Errorf("source of %s = %v, want only site_name %v", what, raw["source"], want)
}
}
bareID, bareRecipe := ground("Chicken curry", bare)
if got := storedSite(bareRecipe); got != bareToken+".example.com" {
t.Fatalf("stored site_name = %q, want the link's host", got)
}
check("a page naming no site", bareID, []string{bare, bareToken, "example.com", `"url"`}, nil)
namedID, namedRecipe := ground("Weeknight curry", named)
if got := storedSite(namedRecipe); got != "Curry House" {
t.Fatalf("stored site_name = %q, want the page's", got)
}
check("a page naming its site", namedID, []string{named, namedToken, "example.com", `"url"`}, "Curry House")
const redirectHost = "redirect-token-ghi789.example.net"
if _, err := e.st.Writer.Exec(`UPDATE recipes SET site_name = ? WHERE id = ?`, redirectHost, namedRecipe); err != nil {
t.Fatal(err)
}
check("a page named by a redirect's host", namedID, []string{redirectHost, "redirect-token", namedToken}, nil)
}
// TestGetDinnerWhyPickedIsCreatingSuggestion: why_picked is the reason of
// the suggestion that created the dinner, never that of an earlier
// suggestion row for it that was declined.
func TestGetDinnerWhyPickedIsCreatingSuggestion(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
if _, err := e.st.Writer.Exec(`UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, id); err != nil {
t.Fatal(err)
}
q := db.New(e.st.Writer)
now := store.Now().String()
batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
if err != nil {
t.Fatal(err)
}
const reason = "Like your Tikka masala, but quicker."
// The declined row is created first, so it is the earliest by both
// created_at and id.
for _, s := range []struct{ reason, status string }{
{"Passed on last week.", "declined"},
{reason, "accepted"},
} {
sid := store.NewID()
if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{
ID: sid, DinnerID: id, BatchID: batch.ID, Name: "Chicken curry",
Reason: s.reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
if n, err := q.DecideSuggestion(t.Context(), db.DecideSuggestionParams{
Status: s.status, Now: sql.NullString{String: now, Valid: true}, ID: sid,
}); err != nil || n != 1 {
t.Fatalf("decide %s = %d, %v", s.status, n, err)
}
}
if _, got := getDetail(t, e.h, id); got.WhyPicked == nil || *got.WhyPicked != reason {
t.Errorf("why_picked = %v, want %q", got.WhyPicked, reason)
}
}
// TestGetDinnerDuplicatePositionFails: a recipe with two ingredients or two
// steps at one position has no order anyone can trust, so its detail is a
// 500 rather than the recipe in some order.
func TestGetDinnerDuplicatePositionFails(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
for _, table := range []string{"recipe_ingredients", "recipe_steps"} {
id := createDinner(t, e.h, `{"name":"Family chili `+table+`"}`)
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body)
}
if _, err := e.st.Writer.Exec(`UPDATE `+table+` SET position = 0 WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id); err != nil {
t.Fatal(err)
}
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "Simmer.") || strings.Contains(rec.Body.String(), "1 lb beef") {
t.Errorf("GET with duplicate %s positions = %d: %s, want 500 and no recipe", table, rec.Code, rec.Body)
}
}
}
+18
View File
@@ -70,6 +70,24 @@ func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
}
}
// getDinner answers GET /dinners/{id} with the dinner, its recipe and the
// reason it was suggested. An archived dinner is 404, like an unknown one,
// not a 200 with its status: it has no detail to show. source never has the
// recipe page's link, and source.site_name is null unless the page itself
// named its site — a name that could be a host, the link's or a redirect's,
// is withheld (dinners.Source) — so the detail screen shows "Recipe from
// {site}" only when site_name is not null.
func getDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
d, err := svc.Get(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeDinnerError(w, r, logger, "get dinner", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// postDinner adds a Regular, recording the session's actor as updated_by,
// and answers 201 with it and its grounding_job_id without waiting for the
// job. When the job cannot be enqueued it still answers 201, with the dinner
+8 -2
View File
@@ -86,13 +86,19 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
t.Fatal(err)
}
e.runner = runner
g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model)
// The router serves the photos the grounder caches.
dataDir := t.TempDir()
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.model)
photos, err := recipes.NewPhotoCache(e.images, st, dataDir)
if err != nil {
t.Fatal(err)
}
svc, err := dinners.NewService(st, g, runner, logger)
if err != nil {
t.Fatal(err)
}
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+2 -1
View File
@@ -39,7 +39,7 @@ type RouteMounter interface {
// /media; settings serves /api/v1/settings/household; search serves
// /api/v1/settings/recipe-search and its /test; ai serves
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE
// dinnerSvc serves GET and POST /api/v1/dinners, GET, PATCH and DELETE
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
// suggestSvc serves POST /api/v1/suggestions/batches, GET
@@ -130,6 +130,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc))
apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc))
apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc))
apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc))
+11
View File
@@ -116,3 +116,14 @@ WHERE id = sqlc.arg(id) AND status = 'pending';
UPDATE dinners
SET status = sqlc.arg(status), updated_by = sqlc.arg(updated_by), updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id) AND status = 'suggested';
-- name: GetDinnerSuggestionReasons :many
-- The reason of each suggestion of dinner_id that can have created it,
-- none for a dinner no suggestion created. A suggestion is only ever
-- created with a new dinner, and declining one archives its dinner, so a
-- live dinner's creating suggestion is pending (the dinner still
-- suggested) or accepted; a declined one did not create it. More than one
-- row means nothing records which did.
SELECT reason FROM suggestions
WHERE dinner_id = ? AND status IN ('pending', 'accepted')
ORDER BY id;
+15 -1
View File
@@ -33,7 +33,9 @@ const SCREENS = {
'/onboarding': true,
'/plan': true,
'/plan/shopping': true,
'/plan/shopping/archive': true,
'/discover': true,
'/dinner/:id': true,
'/settings': true,
'/settings/ai': true,
'/settings/household': true,
@@ -130,6 +132,18 @@ describe('App routes', () => {
expect(await screen.findByRole('checkbox', { name: 'Ground beef' })).toBeChecked()
})
it('/plan/shopping/archive renders the Supply archive inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
...SIGNED_IN,
'/api/v1/shopping/archive?by=week': () => jsonResponse(200, { by: 'week', periods: [] }),
})
renderAt('/plan/shopping/archive')
const heading = await screen.findByRole('heading', { level: 1, name: 'Supply archive' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByText('Nothing archived yet')).toBeInTheDocument()
})
it('/discover renders the Side quests deck inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
@@ -286,7 +300,7 @@ describe('App offline bootstrap', () => {
it.each([
['/quick', 'Quick dinners'],
['/dinner/01J9ZK', 'Dinner'],
['/cook-from-pantry', 'Cook from what I have'],
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
await visitOnline()
goOffline()
+4
View File
@@ -3,6 +3,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
import { Route, Routes } from 'react-router'
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
import { AuthBootstrap } from './AuthBootstrap.tsx'
import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx'
import { DiscoverPage } from './discover/DiscoverPage.tsx'
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
@@ -17,6 +18,7 @@ import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
import { SettingsPage } from './settings/SettingsPage.tsx'
import { AppShell } from './shell/AppShell.tsx'
import { SupplyRunSync } from './shopping/api.ts'
import { SupplyArchivePage } from './shopping/SupplyArchivePage.tsx'
import { SupplyRunPage } from './shopping/SupplyRunPage.tsx'
// Every route the app serves, with the heading its placeholder page shows.
@@ -42,7 +44,9 @@ export const routes = [
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/plan': <PlanPage />,
'/plan/shopping': <SupplyRunPage />,
'/plan/shopping/archive': <SupplyArchivePage />,
'/discover': <DiscoverPage />,
'/dinner/:id': <DinnerDetailPage />,
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
'/settings/household': <HouseholdSettingsPage />,
@@ -0,0 +1,7 @@
/* A week with no plan: why there's no day, over the link to draft it. */
.noPlan {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 14px;
}
+173
View File
@@ -0,0 +1,173 @@
import { useId, useState } from 'react'
import { Link } from 'react-router'
import { usePlan, useWeekSwap, type PlanDinner, type Swap } from '../plan/api.ts'
import { cooking } from '../plan/PlanPage.tsx'
import swapStyles from '../plan/SwapSheet.module.css'
import { addDays, dayOfMonth, localNow, weekdayName, weekLabel, weekStart as weekOf } from '../plan/week.ts'
import { useHouseholdSettings } from '../settings/api.ts'
import buttonStyles from '../ui/Button.module.css'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { ListRow } from '../ui/ListRow.tsx'
import { Photo } from '../ui/Photo.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import type { DinnerDetail } from './detail.ts'
import styles from './AddToWeekSheet.module.css'
// PLAN is the Draft board, where a week with no plan is drafted.
export const PLAN = '/plan'
// Which is the week a dinner is added to: the household's current week or
// the one after.
export type Which = 'this' | 'next'
export const weekNames: Record<Which, string> = { this: 'this week', next: 'next week' }
// Added is a swap sent from the sheet: the week it went to and the swap.
export type Added = { week: string; which: Which; swap: Swap }
// planDinner is the dinner as it is planned on a day.
function planDinner(dinner: DinnerDetail): PlanDinner {
return { id: dinner.id, name: dinner.name, photo_id: dinner.photo_id, minutes: dinner.minutes_total, method: dinner.method }
}
type WeekDaysProps = {
dinner: DinnerDetail
week: string
which: Which
onAdd: (added: Added) => void
}
// WeekDays is the seven days of the week starting on week, each with its
// dinner; choosing one swaps the dinner in as the Draft board swaps a day
// (useWeekSwap). A week with no plan offers to draft it on the Draft board:
// nothing here makes a plan up. A dinner already on the week can't be added
// again, nor can one while a swap of the week is still being saved or the
// week is being pushed to Calendar.
function WeekDays({ dinner, week, which, onAdd }: WeekDaysProps) {
const plan = usePlan(week)
const { swap, push, refused, swapIn } = useWeekSwap(week)
if (plan.isError) {
return <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load {weekNames[which]}'s plan. {plan.error.message}</ErrorBanner>
}
if (plan.isPending) {
return (
<div aria-busy="true" aria-label="Plan loading" className={swapStyles.rows}>
{Array.from({ length: 7 }, (_, i) => (
<Skeleton key={i} className={swapStyles.skeletonRow} />
))}
</div>
)
}
if (plan.data === null) {
return (
<div className={styles.noPlan}>
<p className={swapStyles.empty}>There's no plan for {weekNames[which]} yet, so there's no day to add it to.</p>
<Link to={PLAN} className={[buttonStyles.button, buttonStyles.primary].join(' ')}>
Draft {weekNames[which]} first
</Link>
</div>
)
}
const days = plan.data.days
const planned = days.find((day) => day.dinner.id === dinner.id)
const choosable = planned === undefined && !refused
function add(date: string) {
const chosen: Swap = { date, dinner: planDinner(dinner), fromQuestLog: dinner.status === 'quest_log' }
swapIn(chosen)
onAdd({ week, which, swap: chosen })
}
return (
<>
{planned !== undefined ? (
<p className={swapStyles.empty}>
{dinner.name} is already on {weekdayName(planned.date)} {weekNames[which]}.
</p>
) : swap.pending ? (
<p className={swapStyles.empty}>A change to {weekNames[which]} is still being saved.</p>
) : (
push.pending && <p className={swapStyles.empty}>The plan for {weekNames[which]} is being pushed to Calendar.</p>
)}
<ul aria-label={`Days of ${weekNames[which]}`} className={swapStyles.rows}>
{days.map((day) => (
<li key={day.date}>
<ListRow
className={choosable ? undefined : swapStyles.already}
leading={
// The row's dinner names what the photo shows.
<span aria-hidden="true">
<Photo id={day.dinner.photo_id} name={day.dinner.name} radius="var(--radius-md)" sizes="48px" className={swapStyles.photo} />
</span>
}
title={`${weekdayName(day.date)} ${dayOfMonth(day.date)}`}
meta={[day.dinner.name, cooking(day.dinner)].filter(Boolean).join(' · ')}
onClick={choosable ? () => add(day.date) : undefined}
trailing={day === planned && <Tag>This dinner</Tag>}
/>
</li>
))}
</ul>
</>
)
}
export type AddToWeekSheetProps = {
dinner: DinnerDetail
onAdd: (added: Added) => void
onClose: () => void
}
// AddToWeekSheet is the sheet Add to this week opens: the seven days of the
// household's current week, Monday to Sunday in its timezone, each with its
// dinner, and a toggle to next week. Choosing a day replaces its dinner.
export function AddToWeekSheet({ dinner, onAdd, onClose }: AddToWeekSheetProps) {
const titleId = useId()
const settings = useHouseholdSettings()
const [which, setWhich] = useState<Which>('this')
let body
if (settings.isError) {
body = (
<ErrorBanner onRetry={() => void settings.refetch()}>
Couldn't load the household settings. {settings.error.message}
</ErrorBanner>
)
} else if (settings.isPending) {
body = <Skeleton className={swapStyles.skeletonRow} />
} else {
const thisWeek = weekOf(localNow(settings.data.timezone, new Date()).date)
const week = which === 'this' ? thisWeek : addDays(thisWeek, 7)
body = (
<>
<p className={swapStyles.replaces}>
{weekLabel(week).dates} · replaces the day's dinner
</p>
<Segmented
label="Week"
options={[
{ value: 'this', label: 'This week' },
{ value: 'next', label: 'Next week' },
]}
value={which}
onValueChange={setWhich}
/>
<WeekDays key={week} dinner={dinner} week={week} which={which} onAdd={onAdd} />
</>
)
}
return (
<Sheet open onClose={onClose} aria-labelledby={titleId}>
<div className={swapStyles.sheet}>
<h2 id={titleId} className={swapStyles.title}>
Add {dinner.name}
</h2>
{body}
</div>
</Sheet>
)
}
@@ -0,0 +1,406 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { PlanPage } from '../plan/PlanPage.tsx'
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
const DINNER = '/api/v1/dinners/d1'
const FAVORITE = `${DINNER}/favorite`
const THIS_WEEK = '/api/v1/plans/2026-10-05'
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
const THURSDAY = `${THIS_WEEK}/days/2026-10-08`
// detail is the Korean beef bowls, a Quest-log Side quest, as GET
// /api/v1/dinners/{id} sends it.
function detail(overrides: Record<string, unknown> = {}) {
return {
id: 'd1',
name: 'Korean beef bowls',
status: 'quest_log',
origin: 'suggested',
method: 'stovetop',
minutes_total: 30,
difficulty: 'easy',
cuisine: 'korean',
is_favorite: false,
photo_urls: null,
ingredients: [{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }],
steps: ['Brown the beef with the sauce.'],
source: null,
grounding_status: 'grounded',
grounding_error: null,
why_picked: null,
...overrides,
}
}
// listed is the bowls as the dinners list, and so the favorite toggle,
// answers: no recipe.
function listed(is_favorite: boolean) {
return {
id: 'd1',
name: 'Korean beef bowls',
method: 'stovetop',
minutes_total: 30,
difficulty: 'easy',
is_favorite,
photo_url: null,
grounding_status: 'grounded',
}
}
const THIS_NAMES = ['Beef tacos', 'Spaghetti bolognese', 'Lemon orzo', 'Chicken stir-fry', 'Pizza night', 'Fish tacos', 'Sheet-pan salmon']
const NEXT_NAMES = ['Chili', 'Pad thai', 'Burgers', 'Ramen', 'Pizza night', 'Pot roast', 'Fried rice']
// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it, with
// dinner ids p0 to p6 unless ids says otherwise.
function planBody(weekStart: string, names: string[], ids = names.map((_, i) => `p${i}`)) {
const start = new Date(`${weekStart}T00:00:00Z`).getTime()
return {
id: `plan-${weekStart}`,
week_start: weekStart,
status: 'draft',
ai_note: null,
calendar_synced_at: null as string | null,
days: names.map((name, i) => ({
date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
dinner: { id: ids[i], name, photo: null, minutes: 30, method: 'stovetop' },
is_new: false,
start_by: null,
})),
}
}
// THURSDAY_SWAPPED is this week's plan as the swap of Thursday answers it.
function thursdaySwapped() {
const ids = THIS_NAMES.map((_, i) => (i === 3 ? 'd1' : `p${i}`))
const names = THIS_NAMES.map((name, i) => (i === 3 ? 'Korean beef bowls' : name))
const plan = planBody('2026-10-05', names, ids)
plan.days[3]!.is_new = true
return plan
}
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
// stubServer answers "METHOD path" requests from responders, with /me, the
// household settings and the dinner; 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),
[`GET ${DINNER}`]: () => jsonResponse(200, detail()),
...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
}
// sent lists the requests sent with method, as "path body".
function sent(fetchMock: Mock<typeof fetch>, method: string) {
return fetchMock.mock.calls
.filter(([, init]) => init?.method === method)
.map(([input, init]) => `${String(input)} ${String(init?.body)}`)
}
function reads(fetchMock: Mock<typeof fetch>, path: string) {
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === path).length
}
// deferred is a response the test sends when it chooses.
function deferred() {
let send!: (response: Response) => void
const response = new Promise<Response>((resolve) => {
send = resolve
})
return { response, send }
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the bowls' detail screen, with plan for the Plan: a probe unless told
// otherwise. It answers with the router.
async function renderPage(plan = <h1>Plan probe</h1>) {
const api = createApiClient(fakeLocation('/dinner/d1'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const router = createMemoryRouter(
[
{ path: '/dinner/:id', element: <DinnerDetailPage /> },
{ path: '/plan', element: plan },
],
{ initialEntries: ['/dinner/d1'] },
)
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<RouterProvider router={router} />
</QueryClientProvider>
</ApiProvider>,
)
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
return router
}
// openSheet opens Add to this week and waits for the week's days.
async function openSheet(listName = 'Days of this week') {
fireEvent.click(screen.getByRole('button', { name: 'Add to this week' }))
const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' })
const days = await within(sheet).findByRole('list', { name: listName })
return { sheet, days }
}
// dayRows is each day's row text, as "Monday 5 · Beef tacos · Stovetop · 30 min",
// with plain spaces.
function dayRows(days: HTMLElement) {
return within(days)
.getAllByRole('listitem')
.map((row) => [...row.querySelectorAll('span')].filter((span) => span.children.length === 0 && !span.closest('[aria-hidden="true"]')).map((span) => span.textContent).join(' · ').replace(/\s/g, ' '))
}
beforeEach(() => {
mockViewport(390)
// Wednesday Oct 7, noon in New York.
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
Reflect.deleteProperty(window, 'matchMedia')
vi.unstubAllGlobals()
})
describe('Add to this week', () => {
it('lists the seven days of this week with their dinners, and next week by the toggle', async () => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
[`GET ${NEXT_WEEK}`]: () => jsonResponse(200, planBody('2026-10-12', NEXT_NAMES)),
})
await renderPage()
// Nothing about the week is read until the sheet opens.
expect(reads(fetchMock, THIS_WEEK)).toBe(0)
const { sheet, days } = await openSheet()
expect(within(sheet).getByText("Oct 5 – 11 · replaces the day's dinner")).toBeInTheDocument()
expect(dayRows(days)).toEqual([
'Monday 5 · Beef tacos · Stovetop · 30 min',
'Tuesday 6 · Spaghetti bolognese · Stovetop · 30 min',
'Wednesday 7 · Lemon orzo · Stovetop · 30 min',
'Thursday 8 · Chicken stir-fry · Stovetop · 30 min',
'Friday 9 · Pizza night · Stovetop · 30 min',
'Saturday 10 · Fish tacos · Stovetop · 30 min',
'Sunday 11 · Sheet-pan salmon · Stovetop · 30 min',
])
expect(within(days).getAllByRole('button')).toHaveLength(7)
fireEvent.click(within(sheet).getByRole('radio', { name: 'Next week' }))
const next = await within(sheet).findByRole('list', { name: 'Days of next week' })
expect(within(sheet).getByText("Oct 12 – 18 · replaces the day's dinner")).toBeInTheDocument()
expect(dayRows(next)[0]).toBe('Monday 12 · Chili · Stovetop · 30 min')
expect(dayRows(next)[6]).toBe('Sunday 18 · Fried rice · Stovetop · 30 min')
fireEvent.click(within(sheet).getByRole('radio', { name: 'This week' }))
expect(await within(sheet).findByRole('list', { name: 'Days of this week' })).toBeInTheDocument()
})
it("takes this week in the household's timezone, not the device's UTC date", async () => {
// Sunday Oct 11, 10 pm in New York, already Monday Oct 12 in UTC.
vi.setSystemTime(new Date('2026-10-12T02:00:00Z'))
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)) })
await renderPage()
const { days } = await openSheet()
expect(dayRows(days)[6]).toBe('Sunday 11 · Sheet-pan salmon · Stovetop · 30 min')
expect(reads(fetchMock, THIS_WEEK)).toBe(1)
expect(reads(fetchMock, NEXT_WEEK)).toBe(0)
})
it('swaps the day chosen through the plan API, confirms it and reads the plan again', async () => {
const swap = deferred()
let plan = planBody('2026-10-05', THIS_NAMES)
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
[`PUT ${THURSDAY}`]: () => swap.response,
})
await renderPage()
const { days } = await openSheet()
fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await waitFor(() => expect(sent(fetchMock, 'PUT')).toHaveLength(1))
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`])
expect(screen.getByRole('status')).toHaveTextContent('Adding to Thursday…')
plan = thursdaySwapped()
await act(async () => swap.send(jsonResponse(200, plan)))
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
// The plan query is invalidated: the week is read again from the server.
await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(2))
// The sheet opened again shows the plan as swapped.
const again = await openSheet()
expect(dayRows(again.days)[3]).toBe('Thursday 8 · Korean beef bowls · Stovetop · 30 min · This dinner')
expect(within(again.sheet).getByText('Korean beef bowls is already on Thursday this week.')).toBeInTheDocument()
expect(within(again.days).queryAllByRole('button')).toHaveLength(0)
})
it('offers to draft a week with no plan on the Plan, and swaps nothing', async () => {
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found') })
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Add to this week' }))
const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' })
const draft = await within(sheet).findByRole('link', { name: 'Draft this week first' })
expect(draft).toHaveAttribute('href', '/plan')
expect(within(sheet).getByText("There's no plan for this week yet, so there's no day to add it to.")).toBeInTheDocument()
expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
fireEvent.click(draft)
expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument()
expect(sent(fetchMock, 'PUT')).toEqual([])
})
it('shows a refused swap with the error, puts the day back and retries it', async () => {
let refuse = true
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
[`PUT ${THURSDAY}`]: () => (refuse ? errorResponse(500, 'internal') : jsonResponse(200, thursdaySwapped())),
})
await renderPage()
const { days } = await openSheet()
fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't add it to Thursday this week. internal message")
const reopened = await openSheet()
expect(dayRows(reopened.days)[3]).toBe('Thursday 8 · Chicken stir-fry · Stovetop · 30 min')
fireEvent.keyDown(reopened.sheet, { key: 'Escape' })
refuse = false
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`, `${THURSDAY} {"dinner_id":"d1"}`])
})
it("leaves a push's Supply run outcome behind on the Plan, as a swap on the Draft board does", async () => {
let plan = planBody('2026-10-05', THIS_NAMES)
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
[`POST ${THIS_WEEK}/push-calendar`]: () => {
plan = { ...plan, status: 'synced', calendar_synced_at: '2026-10-07T16:00:00Z' }
return jsonResponse(200, { ...plan, supply_run: { status: 'generated', error: null } })
},
[`PUT ${THURSDAY}`]: () => {
plan = { ...thursdaySwapped(), status: 'changed', calendar_synced_at: '2026-10-07T16:00:00Z' }
return jsonResponse(200, plan)
},
'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'),
})
const router = await renderPage(<PlanPage />)
// The week is pushed on the Plan, which says its Supply run was made.
await act(() => router.navigate('/plan'))
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
// Coming back from the dinner with nothing swapped, it still says so.
await act(() => router.navigate('/dinner/d1'))
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
await act(() => router.navigate('/plan'))
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
// Thursday is swapped from the dinner's Add to this week.
await act(() => router.navigate('/dinner/d1'))
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
const { days } = await openSheet()
fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
// The Plan shows the swapped plan, not changed since its push, and no
// longer says the earlier plan's Supply run was made.
await act(() => router.navigate('/plan'))
expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
expect(screen.getByText('Korean beef bowls')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.queryByText("Made this week's Supply run from the plan.")).not.toBeInTheDocument()
expect(sent(fetchMock, 'POST')).toEqual([`${THIS_WEEK}/push-calendar undefined`])
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`])
})
it('refuses a swap while the week is being pushed to Calendar', async () => {
const push = deferred()
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
[`POST ${THIS_WEEK}/push-calendar`]: () => push.response,
'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'),
})
const router = await renderPage(<PlanPage />)
await act(() => router.navigate('/plan'))
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1))
await act(() => router.navigate('/dinner/d1'))
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
const { sheet, days } = await openSheet()
expect(within(sheet).getByText('The plan for this week is being pushed to Calendar.')).toBeInTheDocument()
expect(within(days).queryAllByRole('button')).toHaveLength(0)
expect(sent(fetchMock, 'PUT')).toEqual([])
})
})
describe('the favorite heart', () => {
it('fills at once, keeps the recipe and takes the server answer', async () => {
const favorite = deferred()
const fetchMock = stubServer({ [`POST ${FAVORITE}`]: () => favorite.response })
await renderPage()
const heart = screen.getByRole('button', { name: 'Favorite' })
expect(heart).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(heart)
await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'true'))
expect(sent(fetchMock, 'POST')).toEqual([`${FAVORITE} undefined`])
await act(async () => favorite.send(jsonResponse(200, listed(true))))
await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1))
expect(heart).toHaveAttribute('aria-pressed', 'true')
// The list-shaped answer did not replace the detail: the recipe stays.
expect(screen.getByText('Ground beef')).toBeInTheDocument()
expect(screen.getByText('Brown the beef with the sauce.')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('empties again with the error when the server refuses, and retries', async () => {
const refused = deferred()
let refuse = true
const fetchMock = stubServer({
[`GET ${DINNER}`]: () => jsonResponse(200, detail({ is_favorite: true })),
[`POST ${FAVORITE}`]: () => (refuse ? refused.response : jsonResponse(200, listed(false))),
})
await renderPage()
const heart = screen.getByRole('button', { name: 'Favorite' })
expect(heart).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(heart)
await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false'))
await act(async () => refused.send(errorResponse(500, 'internal')))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't unfavorite Korean beef bowls. internal message")
expect(heart).toHaveAttribute('aria-pressed', 'true')
refuse = false
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false'))
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
expect(sent(fetchMock, 'POST')).toHaveLength(2)
})
})
+540
View File
@@ -0,0 +1,540 @@
/* Mock 1k on mobile: the photo and the sheet run full-bleed past the
shell's 20 px side padding. From tablet up the page is Kitchen reading
mode (mock 2a, below). The page is at least as tall as the shell's scroll
area (a stretched flex item, so its height resolves the percentage) and
the sheet, or the reading view, grows into any room left, so the action
bar rests at the bottom of a short recipe. */
.page {
display: flex;
flex-direction: column;
min-height: 100%;
font-family: var(--font-body);
color: var(--color-text);
}
/* Kitchen reading mode is no wider than the handoff's tablet frame. */
.tablet,
.desktop {
width: 100%;
max-width: 1180px;
margin: 0 auto;
}
.hero {
position: relative;
height: 250px;
}
.mobile .hero,
.mobile .skeletonHero {
margin: 0 -20px;
}
/* Without a dinner to show, the back button sits alone above the message. */
.backRow {
padding-top: calc(16px + env(safe-area-inset-top));
}
.backRow .back {
position: static;
}
.photo {
width: 100%;
height: 100%;
}
.back {
position: absolute;
top: calc(16px + env(safe-area-inset-top));
left: 20px;
}
/* The sheet overlaps the photo by 28 px. */
.sheet {
position: relative;
flex-grow: 1;
margin-top: -28px;
display: flex;
flex-direction: column;
gap: 10px;
padding: 22px 20px 0;
border-radius: 32px 32px 0 0;
background: var(--color-bg);
}
.mobile .sheet {
margin-left: -20px;
margin-right: -20px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 28px;
font-weight: 400;
line-height: 1.05;
}
.why {
margin: 0;
padding: 12px 14px;
border-radius: 18px;
background: var(--color-accent-2-100);
color: var(--color-accent-2-900);
font-size: 13px;
line-height: 1.45;
}
.section {
display: flex;
flex-direction: column;
margin-top: 6px;
}
.sectionTitle {
margin: 0 0 4px;
font-family: var(--font-heading);
font-size: 18px;
font-weight: 400;
line-height: 1.15;
}
.ingredients,
.steps {
margin: 0;
padding: 0;
list-style: none;
font-size: 14px;
line-height: 1.45;
}
.ingredient {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 7px 0;
border-bottom: 1px solid var(--color-divider);
}
.ingredient:last-child {
border-bottom: 0;
}
.note,
.qty {
color: var(--color-neutral-700);
}
.qty {
flex: none;
text-align: right;
}
.step {
display: flex;
gap: 12px;
padding: 8px 0;
}
.number {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-pill);
background: var(--color-accent-100);
color: var(--color-accent-800);
font-size: 12px;
font-weight: 700;
}
.source {
margin: 4px 0 0;
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.source a {
color: inherit;
}
.missing {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
margin: 6px 0 0;
padding: 12px 14px;
border-radius: 18px;
background: var(--color-neutral-100);
color: var(--color-neutral-800);
font-size: 13px;
line-height: 1.45;
}
.missing p {
margin: 0;
}
/* A 44 px tall tap target for the link. */
.fix {
display: inline-flex;
align-items: center;
min-height: 44px;
color: var(--color-accent-700);
font-weight: 700;
}
/* At the bottom of the page, which fills the scroll area, and held at the
bottom of the screen over a long recipe. Sticky offsets count from inside
the scroll area's padding, which on mobile already keeps clear of the nav
bar. */
.bar {
position: sticky;
bottom: 0;
z-index: 1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 24px;
padding: 10px 0;
background: var(--color-bg);
}
.add {
flex: 1;
min-height: 52px;
font-size: 16px;
}
/* What became of an add or a favorite: a full row above the buttons. */
.status {
order: -1;
flex-basis: 100%;
}
.added {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 600;
color: var(--color-accent-2-800);
}
.filled {
color: var(--color-accent);
fill: currentColor;
}
.skeletonHero {
height: 250px;
border-radius: 0;
}
.skeletonTitle {
height: 32px;
width: 70%;
}
/* Mock 2a, Kitchen reading mode: the header over two columns, the
ingredients card (400 px) and the steps. Ingredient and step text scale
with the text size chosen: the handoff's sizes are medium. */
.reading {
flex-grow: 1;
display: flex;
flex-direction: column;
gap: 22px;
}
.small {
--reading-scale: 0.85;
}
.medium {
--reading-scale: 1;
}
.large {
--reading-scale: 1.25;
}
.readingHeader {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 18px;
}
.readingHeader .back {
position: static;
flex: none;
}
.heading {
flex: 1 1 240px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.kicker {
margin: 0;
font-size: 13px;
font-weight: 700;
color: var(--color-neutral-700);
}
.readingTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 36px;
font-weight: 400;
line-height: 1.05;
}
.keepOn {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 700;
}
/* The A / A / A control: an outlined pill, the size chosen in grape, each
A drawn at the size it stands for. */
.readingHeader .textSize {
gap: 0;
padding: 0;
border: 1px solid var(--color-divider);
background: transparent;
overflow: hidden;
}
.readingHeader .textSize > * {
padding: 0 14px;
border-radius: 0;
color: var(--color-text);
}
.readingHeader .textSize > [aria-checked="true"] {
background: var(--color-accent);
color: var(--on-accent);
box-shadow: none;
}
.readingHeader .textSize > :nth-child(1) {
font-size: 13px;
}
.readingHeader .textSize > :nth-child(2) {
font-size: 17px;
}
.readingHeader .textSize > :nth-child(3) {
font-size: 21px;
}
.columns {
flex-grow: 1;
display: grid;
grid-template-columns: 400px minmax(0, 1fr);
gap: 28px;
}
/* Below a 1024 px viewport the shell leaves too little beside a 400 px
card for the steps, so the columns share the width instead. */
@media (max-width: 1023px) {
.columns {
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
}
}
.oneColumn {
flex-grow: 1;
display: flex;
flex-direction: column;
}
.columns .ingredientsCard {
display: flex;
flex-direction: column;
gap: 6px;
padding: 22px;
border-radius: 30px;
}
.cardHead {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-bottom: 6px;
}
.readingSectionTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
line-height: 1.15;
}
.setOutCount {
flex: none;
white-space: nowrap;
font-size: 13px;
font-weight: 700;
color: var(--color-neutral-700);
}
.checklist {
margin: 0;
padding: 0;
list-style: none;
}
.checkRow {
display: flex;
align-items: center;
gap: 14px;
padding: 4px 0;
border-bottom: 1px solid var(--color-divider);
}
.checkRow .check {
flex: 1;
min-width: 0;
gap: 14px;
}
.checkName {
font-size: calc(18px * var(--reading-scale));
font-weight: 400;
line-height: 1.3;
}
.setOut {
color: var(--color-neutral-600);
text-decoration: line-through;
}
.checkQty {
flex: none;
text-align: right;
font-size: calc(15px * var(--reading-scale));
color: var(--color-neutral-700);
}
.method {
flex-grow: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.intro {
display: flex;
align-items: center;
gap: 16px;
}
.introPhoto {
flex: 1;
min-width: 0;
height: 96px;
}
.introWhy {
flex: 1.6;
padding: 14px 18px;
border-radius: 22px;
font-size: 14px;
}
.readingSteps {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 4px;
}
.stepList {
display: flex;
flex-direction: column;
gap: 14px;
margin: 0;
padding: 0;
list-style: none;
}
.readingStep {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 6px 18px;
border-radius: 26px;
font-size: calc(18px * var(--reading-scale));
line-height: 1.45;
}
.stepCircle {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--radius-pill);
background: var(--color-surface);
font-family: var(--font-heading);
font-size: 18px;
}
.stepText {
padding-top: 6px;
}
.stepDone {
color: var(--color-neutral-600);
}
.stepCurrent {
padding: 18px;
background: var(--color-accent-100);
box-shadow: inset 0 0 0 2px var(--color-accent-300);
font-size: calc(22px * var(--reading-scale));
}
.stepCurrent .stepCircle {
background: var(--color-accent);
color: var(--on-accent);
}
/* The action bar rests at the bottom of the steps column, with Previous
and Next step at its right. */
.reading .bar {
margin-top: auto;
}
.reading .add {
flex: none;
}
.stepButtons {
display: flex;
gap: 10px;
margin-left: auto;
}
+442
View File
@@ -0,0 +1,442 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { AppShell } from '../shell/AppShell.tsx'
import { cacheFallbackHeader } from '../sw/routes.ts'
import { ApiProviders } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { errorResponse, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { GROUNDING_POLL_MS } from './api.ts'
import { DinnerDetailPage, REGULAR_DINNERS } from './DinnerDetailPage.tsx'
const DINNER = '/api/v1/dinners/d1'
// detail is a dinner as GET /api/v1/dinners/{id} sends it: mock 1k's
// Korean beef bowls, a Side quest grounded in a page that named its site.
function detail(overrides: Record<string, unknown> = {}) {
return {
id: 'd1',
name: 'Korean beef bowls',
status: 'quest_log',
origin: 'suggested',
method: 'stovetop',
minutes_total: 30,
difficulty: 'easy',
cuisine: 'korean',
is_favorite: false,
photo_urls: { '400': '/media/photos/p1-400.jpg', '1200': '/media/photos/p1-1200.jpg' },
ingredients: [
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
{ name: 'Soy sauce', qty: '¼', unit: 'cup', note: null, raw_text: '¼ cup soy sauce' },
{ name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' },
],
steps: ['Cook the rice.', 'Brown the beef with the sauce.', 'Serve over rice with the onions.'],
source: { site_name: 'Serious Eats' },
grounding_status: 'grounded',
grounding_error: null,
why_picked: 'close cousin of your Beef tacos and Chicken stir-fry.',
...overrides,
}
}
// A recipe the household typed in: only raw_text, no source.
const MANUAL = {
status: 'regular',
origin: 'regular',
photo_urls: null,
ingredients: [
{ name: null, qty: null, unit: null, note: null, raw_text: '2 lb chuck roast' },
{ name: null, qty: null, unit: null, note: null, raw_text: 'A splash of red wine' },
],
steps: ['Sear the roast.', 'Slow cook 8 hours.'],
source: null,
grounding_status: 'manual',
why_picked: null,
}
// A Regular whose recipe could not be found: no recipe at all.
const FAILED = {
status: 'regular',
origin: 'regular',
photo_urls: null,
minutes_total: null,
difficulty: null,
ingredients: [],
steps: [],
source: null,
grounding_status: 'failed',
grounding_error: 'no_recipe_found',
why_picked: null,
}
// renderAt renders the detail screen at the last of entries, inside the
// app shell's scroll area as App routes it, with probes for where its links
// and back button go.
function renderAt(entries: string[]) {
const router = createMemoryRouter(
[
{ element: <AppShell />, children: [{ path: '/dinner/:id', element: <DinnerDetailPage /> }] },
{ path: '/', element: <h1>Home probe</h1> },
{ path: '/plan', element: <h1>Plan probe</h1> },
{ path: REGULAR_DINNERS, element: <h1>Regular dinners probe</h1> },
],
{ initialEntries: entries, initialIndex: entries.length - 1 },
)
render(
<ApiProviders>
<RouterProvider router={router} />
</ApiProviders>,
)
return router
}
function serve(body: unknown) {
return stubFetchByPath({ [DINNER]: () => jsonResponse(200, body) })
}
beforeEach(() => {
mockViewport(390)
})
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
vi.unstubAllGlobals()
})
describe('DinnerDetailPage', () => {
describe('a grounded Side quest', () => {
it('shows its photo washed and full-bleed, tags, title and why it was picked', async () => {
serve(detail())
renderAt(['/dinner/d1'])
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
const photo = screen.getByRole('img', { name: 'Korean beef bowls' })
expect(photo).toHaveAttribute('src', '/media/photos/p1-400.jpg')
expect(photo).toHaveAttribute('srcset', '/media/photos/p1-400.jpg 400w, /media/photos/p1-1200.jpg 1200w')
expect(declaredStyle(photo.parentElement!).height).toBe('250px')
expect(declaredStyle(photo.parentElement!).margin).toBe('0px -20px')
expect(screen.getByText('Side quest')).toBeInTheDocument()
expect(screen.getByText('Easy')).toBeInTheDocument()
expect(screen.getByText('30 min')).toBeInTheDocument()
expect(screen.queryByText('Regular')).not.toBeInTheDocument()
expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent(
'Why it was picked: close cousin of your Beef tacos and Chicken stir-fry.',
)
})
it('lists ingredients in recipe order with quantities on the right, then numbered steps', async () => {
serve(detail())
renderAt(['/dinner/d1'])
expect(await screen.findByRole('heading', { level: 2, name: 'Ingredients' })).toBeInTheDocument()
const rows = within(screen.getAllByRole('list')[0]!).getAllByRole('listitem')
expect(rows.map((row) => Array.from(row.children, (cell) => cell.textContent))).toEqual([
['Ground beef', '1 lb'],
['Jasmine rice', '2 cups'],
['Soy sauce', '¼ cup'],
['Green onions, sliced', '1 bunch'],
])
const qty = rows[0]!.lastElementChild!
expect(declaredStyle(qty).textAlign).toBe('right')
expect(declaredStyle(rows[0]!).justifyContent).toBe('space-between')
expect(declaredStyle(rows[0]!).borderBottom).toBe('1px solid var(--color-divider)')
expect(screen.getByRole('heading', { level: 2, name: 'Steps' })).toBeInTheDocument()
const steps = screen.getAllByRole('list')[1]!
expect(steps.tagName).toBe('OL')
expect(within(steps).getAllByRole('listitem').map((step) => step.textContent)).toEqual([
'1Cook the rice.',
'2Brown the beef with the sauce.',
'3Serve over rice with the onions.',
])
})
it('credits the site the recipe came from, with no link to it', async () => {
serve(detail())
renderAt(['/dinner/d1'])
const credit = await screen.findByText('Recipe from Serious Eats')
expect(credit.closest('a')).toBeNull()
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
})
it('links the credit to the recipe in a new tab once the server sends a safe url', async () => {
serve(detail({ source: { site_name: 'Serious Eats', url: 'https://www.seriouseats.com/korean-beef-bowls' } }))
renderAt(['/dinner/d1'])
const credit = await screen.findByRole('link', { name: 'Recipe from Serious Eats' })
expect(credit).toHaveAttribute('href', 'https://www.seriouseats.com/korean-beef-bowls')
expect(credit).toHaveAttribute('target', '_blank')
expect(credit).toHaveAttribute('rel', 'noopener noreferrer')
})
it.each([
['http', 'http://www.seriouseats.com/korean-beef-bowls'],
['a password', 'https://user:secret@www.seriouseats.com/korean-beef-bowls'],
['a query', 'https://www.seriouseats.com/korean-beef-bowls?token=abc'],
['a fragment', 'https://www.seriouseats.com/korean-beef-bowls#token=abc'],
])('refuses a source url with %s', async (_, url) => {
serve(detail({ source: { site_name: 'Serious Eats', url } }))
renderAt(['/dinner/d1'])
expect(await screen.findByRole('alert')).toHaveTextContent('source url is not an https link')
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
})
it('leaves the credit out when the server names no site', async () => {
serve(detail({ source: { site_name: null } }))
renderAt(['/dinner/d1'])
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument()
})
it('holds Add to this week and the heart at the bottom', async () => {
serve(detail({ is_favorite: true }))
renderAt(['/dinner/d1'])
const add = await screen.findByRole('button', { name: 'Add to this week' })
expect(add).toBeEnabled()
const heart = screen.getByRole('button', { name: 'Favorite' })
expect(heart).toBeEnabled()
expect(heart).toHaveAttribute('aria-pressed', 'true')
const bar = heart.parentElement!
expect(bar).toContainElement(add)
expect(declaredStyle(bar).position).toBe('sticky')
expect(declaredStyle(bar).bottom).toBe('0px')
})
})
// jsdom does no layout, so no test here can measure where the bar lands.
// This pins the CSS chain that puts it at the bottom of the screen on a
// short recipe: the shell's scroll area is a full-height flex item, the
// page fills it as a column, the sheet takes the room left and the bar is
// the page's last child. Drop any link and the bar sits right after the
// content again, and this fails.
it('fills the scroll area so the bar rests at the bottom of a short recipe', async () => {
serve(detail(FAILED))
renderAt(['/dinner/d1'])
const bar = (await screen.findByRole('button', { name: 'Favorite' })).parentElement!
const page = bar.parentElement!
const main = page.parentElement!
const shell = main.parentElement!
expect(main.tagName).toBe('MAIN')
expect(declaredStyle(shell).height).toBe('100dvh')
expect(declaredStyle(shell).display).toBe('flex')
expect(declaredStyle(shell).alignItems).toBe('')
expect(declaredStyle(main).overflowY).toBe('auto')
expect(declaredStyle(page).minHeight).toBe('100%')
expect(declaredStyle(page).display).toBe('flex')
expect(declaredStyle(page).flexDirection).toBe('column')
expect(page.lastElementChild).toBe(bar)
const sheet = screen.getByRole('article')
expect(sheet.parentElement).toBe(page)
expect(sheet.nextElementSibling).toBe(bar)
expect(declaredStyle(sheet).flexGrow).toBe('1')
expect(declaredStyle(bar).position).toBe('sticky')
})
it('shows the striped placeholder when the dinner has no photo', async () => {
serve(detail({ photo_urls: null }))
renderAt(['/dinner/d1'])
const placeholder = await screen.findByRole('img', { name: 'Korean beef bowls' })
expect(placeholder.tagName).toBe('DIV')
expect(placeholder).toHaveTextContent('dish photo')
})
it('shows a manual Regular line by line, as typed, with no credit', async () => {
serve(detail(MANUAL))
renderAt(['/dinner/d1'])
expect(await screen.findByText('Regular')).toBeInTheDocument()
expect(screen.queryByText('Side quest')).not.toBeInTheDocument()
const [ingredients, steps] = screen.getAllByRole('list')
expect(within(ingredients!).getAllByRole('listitem').map((row) => row.firstElementChild!.textContent)).toEqual([
'2 lb chuck roast',
'A splash of red wine',
])
expect(within(steps!).getAllByRole('listitem')).toHaveLength(2)
expect(screen.queryByText(/Why it was picked/)).not.toBeInTheDocument()
expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveTextContent('dish photo')
})
it('links a dinner whose recipe was not found to Settings → Regular dinners, with no recipe', async () => {
serve(detail(FAILED))
const router = renderAt(['/dinner/d1'])
const fix = await screen.findByRole('link', { name: 'Fix recipe' })
expect(screen.getByText(/couldn't find a recipe/)).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Steps' })).not.toBeInTheDocument()
expect(screen.queryByRole('list')).not.toBeInTheDocument()
fireEvent.click(fix)
expect(await screen.findByRole('heading', { name: 'Regular dinners probe' })).toBeInTheDocument()
expect(router.state.location.pathname).toBe(REGULAR_DINNERS)
})
it('says it is still finding a recipe while grounding is pending', async () => {
serve(detail({ ...FAILED, grounding_status: 'pending', grounding_error: null }))
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Fix recipe' })).not.toBeInTheDocument()
})
describe('while the recipe is being found', () => {
const PENDING = detail({ ...FAILED, grounding_status: 'pending', grounding_error: null })
const GROUNDED = detail({ grounding_status: 'grounded' })
let onLine: boolean
let body: unknown
let fromCache: boolean
let fetchMock: Mock<typeof fetch>
const reads = () => fetchMock.mock.calls.length
const tick = (intervals = 1) => act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * intervals))
function setOnline(value: boolean) {
onLine = value
act(() => {
window.dispatchEvent(new Event(value ? 'online' : 'offline'))
})
}
beforeEach(() => {
// Only intervals are faked: TanStack Query polls with setInterval, and
// Testing Library's waits keep their real timers.
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
onLine = true
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine)
body = PENDING
fromCache = false
fetchMock = stubFetchByPath({
[DINNER]: () =>
new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json', ...(fromCache ? { [cacheFallbackHeader]: '1' } : {}) },
}),
})
})
// An offline event leaves TanStack Query's online manager, shared by
// every test, offline until an online event.
afterEach(() => {
window.dispatchEvent(new Event('online'))
vi.useRealTimers()
vi.restoreAllMocks()
})
it('reads the dinner again until the recipe lands, then stops', async () => {
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(reads()).toBe(1)
await tick()
await waitFor(() => expect(reads()).toBe(2))
body = GROUNDED
await tick()
expect(await screen.findByRole('heading', { level: 2, name: 'Ingredients' })).toBeInTheDocument()
expect(reads()).toBe(3)
await tick(3)
expect(reads()).toBe(3)
})
it('pauses while the browser is offline and resumes when it is back', async () => {
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(reads()).toBe(1)
setOnline(false)
await tick(3)
expect(reads()).toBe(1)
setOnline(true)
await tick()
await waitFor(() => expect(reads()).toBe(2))
})
it('reads a saved pending copy offline once, then waits for the connection', async () => {
onLine = false
fromCache = true
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(reads()).toBe(1)
await tick(3)
expect(reads()).toBe(1)
// The connection comes back: the saved copy is read again at once,
// and polling goes on once the server answers.
fromCache = false
setOnline(true)
await waitFor(() => expect(reads()).toBe(2))
await tick()
await waitFor(() => expect(reads()).toBe(3))
})
it('stops polling on a saved copy even while the browser says it is online', async () => {
fromCache = true
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
await tick(3)
expect(reads()).toBe(1)
})
it('stops polling when the page is left', async () => {
const router = renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(reads()).toBe(1)
await act(() => router.navigate('/'))
expect(screen.getByRole('heading', { name: 'Home probe' })).toBeInTheDocument()
await tick(3)
expect(reads()).toBe(1)
})
})
it('says an unknown or removed dinner is not found', async () => {
stubFetchByPath({ [DINNER]: () => errorResponse(404, 'not_found') })
renderAt(['/dinner/d1'])
expect(await screen.findByRole('heading', { name: 'Dinner not found' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Go Home' })).toHaveAttribute('href', '/')
})
it('reports any other failure with a retry', async () => {
stubFetchByPath({ [DINNER]: () => errorResponse(500, 'internal') })
renderAt(['/dinner/d1'])
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.")
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
})
describe('the back button', () => {
it('goes back in the app', async () => {
serve(detail())
const router = renderAt(['/plan', '/dinner/d1'])
fireEvent.click(await screen.findByRole('button', { name: 'Back' }))
expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/plan')
})
it('goes Home when the dinner was opened from a link', async () => {
serve(detail())
const router = renderAt(['/dinner/d1'])
fireEvent.click(await screen.findByRole('button', { name: 'Back' }))
expect(await screen.findByRole('heading', { name: 'Home probe' })).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/')
})
})
})
+563
View File
@@ -0,0 +1,563 @@
import { useQueryClient } from '@tanstack/react-query'
import { Check, ChevronLeft, ChevronRight, Heart } from 'lucide-react'
import { useCallback, useEffect, useId, useState, type ReactNode } from 'react'
import { Link, useLocation, useNavigate, useParams } from 'react-router'
import { ApiError } from '../api/errors.ts'
import { planKey, usePlan, useWeekSwap } from '../plan/api.ts'
import { duration } from '../plan/PlanPage.tsx'
import { addDays, dayAndDate, localNow, weekdayName, weekStart } from '../plan/week.ts'
import { useHouseholdSettings } from '../settings/api.ts'
import { useBreakpoint } from '../shell/useBreakpoint.ts'
import { Button } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
import { Card } from '../ui/Card.tsx'
import { Checkbox } from '../ui/Checkbox.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Photo } from '../ui/Photo.tsx'
import { Segmented, type SegmentedOption } from '../ui/Segmented.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import { Toggle } from '../ui/Toggle.tsx'
import { AddToWeekSheet, weekNames, type Added } from './AddToWeekSheet.tsx'
import { useToggleFavorite, type Difficulty } from './api.ts'
import { useDinnerDetail, type DinnerDetail, type Ingredient, type RecipeSource } from './detail.ts'
import styles from './DinnerDetailPage.module.css'
import { useKeepScreenOn } from './keepScreenOn.ts'
import { textSizeFailureText, useTextSize, type TextSize } from './textSize.ts'
// REGULAR_DINNERS is Settings → Regular dinners, where a recipe is fixed.
export const REGULAR_DINNERS = '/settings/regulars'
const difficultyLabels: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
// BackButton goes back in the app's history, or, opened from a link with
// no history in the app, to Home.
function BackButton() {
const navigate = useNavigate()
const location = useLocation()
// The first entry of a session has the key "default".
const back = () => (location.key === 'default' ? void navigate('/', { replace: true }) : void navigate(-1))
return (
<IconButton label="Back" tone="neutral" className={styles.back} onClick={back}>
<Icon icon={ChevronLeft} size="inline" />
</IconButton>
)
}
// quantity is an ingredient's amount as the right-hand column shows it,
// "1 lb", or empty when the recipe gives none.
function quantity(ingredient: Ingredient): string {
return [ingredient.qty, ingredient.unit].filter(Boolean).join(' ')
}
// IngredientRow is one ingredient: its name with any note, or the line as
// the recipe gives it when there is no name, and its amount on the right.
function IngredientRow({ ingredient }: { ingredient: Ingredient }) {
return (
<li className={styles.ingredient}>
<span>
{ingredient.name ?? ingredient.raw_text}
{ingredient.name !== null && ingredient.note !== null && <span className={styles.note}>, {ingredient.note}</span>}
</span>
<span className={styles.qty}>{quantity(ingredient)}</span>
</li>
)
}
// SourceCredit is "Recipe from {site}": a link opening the recipe's page in
// a new tab when the server sends a url safe to open, else plain text.
function SourceCredit({ siteName, url }: RecipeSource & { siteName: string }) {
const credit = `Recipe from ${siteName}`
return (
<p className={styles.source}>
{url === null ? (
credit
) : (
<a href={url} target="_blank" rel="noopener noreferrer">
{credit}
</a>
)}
</p>
)
}
// hasRecipe says whether the dinner's recipe was found or typed in, rather
// than still being searched for or not found.
function hasRecipe(dinner: DinnerDetail): boolean {
return dinner.grounding_status !== 'pending' && dinner.grounding_status !== 'failed'
}
// RecipeMissing is what stands in for a recipe still being searched for, or
// the link to fix one that could not be found.
function RecipeMissing({ dinner }: { dinner: DinnerDetail }) {
if (dinner.grounding_status === 'failed') {
return (
<section className={styles.missing} aria-label="No recipe">
<p>We couldn't find a recipe for this dinner, so there are no ingredients or steps yet.</p>
<Link to={REGULAR_DINNERS} className={styles.fix}>
Fix recipe
</Link>
</section>
)
}
return <p className={styles.missing}>Still finding the recipe — its ingredients and steps will show here.</p>
}
// Recipe is the dinner's ingredients and steps, or what stands in for them.
function Recipe({ dinner }: { dinner: DinnerDetail }) {
if (!hasRecipe(dinner)) return <RecipeMissing dinner={dinner} />
return (
<>
{dinner.ingredients.length > 0 && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Ingredients</h2>
<ul className={styles.ingredients}>
{dinner.ingredients.map((ingredient, i) => (
<IngredientRow key={i} ingredient={ingredient} />
))}
</ul>
</section>
)}
{dinner.steps.length > 0 && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Steps</h2>
<ol className={styles.steps}>
{dinner.steps.map((step, i) => (
<li key={i} className={styles.step}>
<span className={styles.number} aria-hidden="true">
{i + 1}
</span>
<span>{step}</span>
</li>
))}
</ol>
</section>
)}
{dinner.source !== null && dinner.source.siteName !== null && (
<SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />
)}
</>
)
}
// AddedStatus says what became of a dinner added to a day: being saved,
// added, or why it couldn't be, with Retry, which sends the same swap again
// as the Draft board would (useWeekSwap), and is not offered while that is
// refused. The week's plan stays read while it shows, so a retry finds the
// day to swap; once added, the plan is read again from the server.
function AddedStatus({ added }: { added: Added }) {
const queryClient = useQueryClient()
usePlan(added.week)
const { swap, refused, swapIn } = useWeekSwap(added.week)
const { date } = added.swap
const done = swap.succeeded === added.swap
const failed = swap.failed?.swap === added.swap ? swap.failed.error : null
useEffect(() => {
if (done) void queryClient.invalidateQueries({ queryKey: planKey(added.week) })
}, [done, queryClient, added.week])
if (failed !== null) {
return (
<div className={styles.status}>
<ErrorBanner onRetry={refused ? undefined : () => swapIn(added.swap)}>
Couldn't add it to {weekdayName(date)} {weekNames[added.which]}. {failed.message}
</ErrorBanner>
</div>
)
}
return (
<p role="status" className={[styles.status, styles.added].join(' ')}>
{done ? `Added to ${weekdayName(date)} ${weekNames[added.which]}.` : `Adding to ${weekdayName(date)}…`}
</p>
)
}
// AddToWeek is Add to this week: it opens the sheet of the week's days, and
// says what became of the dinner added.
function AddToWeek({ dinner }: { dinner: DinnerDetail }) {
const [open, setOpen] = useState(false)
const [added, setAdded] = useState<Added | null>(null)
function add(chosen: Added) {
setOpen(false)
setAdded(chosen)
}
return (
<>
{added !== null && <AddedStatus key={`${added.week} ${added.swap.date}`} added={added} />}
<Button className={styles.add} onClick={() => setOpen(true)}>
Add to this week
</Button>
{open && <AddToWeekSheet dinner={dinner} onAdd={add} onClose={() => setOpen(false)} />}
</>
)
}
// FavoriteHeart toggles whether the dinner is a favorite, at once, changing
// back with the error and Retry if the server refuses. One toggle is sent at
// a time.
function FavoriteHeart({ dinner }: { dinner: DinnerDetail }) {
const favorite = useToggleFavorite()
function toggle() {
if (!favorite.isPending) favorite.mutate({ id: dinner.id, is_favorite: dinner.is_favorite })
}
return (
<>
{favorite.isError && (
<div className={styles.status}>
<ErrorBanner onRetry={toggle}>
Couldn't {dinner.is_favorite ? 'unfavorite' : 'favorite'} {dinner.name}. {favorite.error.message}
</ErrorBanner>
</div>
)}
<IconButton label="Favorite" size={52} aria-pressed={dinner.is_favorite} onClick={toggle}>
<Icon icon={Heart} size="nav" className={dinner.is_favorite ? styles.filled : undefined} />
</IconButton>
</>
)
}
// ActionBar is the bar at the bottom of the screen, the page's last child:
// the page fills the scroll area so a short recipe still leaves it at the
// bottom, and it sticks there over a long one. It holds Add to this week
// and the favorite heart, with what became of either above them, and in
// Kitchen reading mode the step buttons after them.
function ActionBar({ dinner, children }: { dinner: DinnerDetail; children?: ReactNode }) {
return (
<div className={styles.bar}>
<AddToWeek dinner={dinner} />
<FavoriteHeart dinner={dinner} />
{children}
</div>
)
}
// Detail is a dinner as mock 1k shows it: the photo full-bleed under a sheet
// with its tags, title, why it was picked and its recipe, over the action bar.
function Detail({ dinner }: { dinner: DinnerDetail }) {
const tags = [
dinner.difficulty !== null && difficultyLabels[dinner.difficulty],
dinner.minutes_total !== null && duration(dinner.minutes_total),
].filter((tag) => tag !== false)
return (
<>
<div className={styles.hero}>
<Photo id={dinner.photo_id} name={dinner.name} radius="0" sizes="100vw" className={styles.photo} />
<BackButton />
</div>
<article className={styles.sheet}>
<div className={styles.tags}>
<Tag tone="grape">{dinner.status === 'regular' ? 'Regular' : 'Side quest'}</Tag>
{tags.map((tag) => (
<Tag key={tag} tone="neutral">
{tag}
</Tag>
))}
</div>
<h1 className={styles.title}>{dinner.name}</h1>
{dinner.why_picked !== null && (
<p className={styles.why}>
<b>Why it was picked:</b> {dinner.why_picked}
</p>
)}
<Recipe dinner={dinner} />
</article>
<ActionBar dinner={dinner} />
</>
)
}
// statusName is the dinner as the household calls it: a Regular, or a Side
// quest for any dinner not yet one.
function statusName(dinner: DinnerDetail): string {
return dinner.status === 'regular' ? 'Regular' : 'Side quest'
}
// PlannedDay is the kicker of a dinner on a plan, "Saturday, Oct 10 · Side
// quest", whenever this week's or next week's plan has it. Of the days that
// do, it is the first from today on in the household's timezone; when every
// one is already past, it is the latest of them. There is none while the
// plans are read, nor when neither plan has the dinner.
function PlannedDay({ dinner, timezone }: { dinner: DinnerDetail; timezone: string }) {
const today = localNow(timezone, new Date()).date
const thisWeek = weekStart(today)
const plans = [usePlan(thisWeek), usePlan(addDays(thisWeek, 7))]
const failed = plans.find((plan) => plan.isError)
if (failed !== undefined) {
return (
<ErrorBanner onRetry={() => plans.forEach((plan) => plan.isError && void plan.refetch())}>
Couldn't check the plan for this dinner's day. {failed.error.message}
</ErrorBanner>
)
}
const planned = plans.flatMap((plan) => plan.data?.days ?? []).filter((day) => day.dinner.id === dinner.id)
const day = planned.find((day) => day.date >= today) ?? planned.at(-1)
if (day === undefined) return null
return (
<p className={styles.kicker}>
{dayAndDate(day.date)} · {statusName(dinner)}
</p>
)
}
// Kicker is PlannedDay, once the household's timezone is read.
function Kicker({ dinner }: { dinner: DinnerDetail }) {
const settings = useHouseholdSettings()
if (settings.isError) {
return (
<ErrorBanner onRetry={() => void settings.refetch()}>
Couldn't load the household settings to find this dinner's day. {settings.error.message}
</ErrorBanner>
)
}
if (settings.isPending) return null
return <PlannedDay dinner={dinner} timezone={settings.data.timezone} />
}
const textSizes: SegmentedOption<TextSize>[] = [
{ value: 'small', label: 'A', ariaLabel: 'Small text' },
{ value: 'medium', label: 'A', ariaLabel: 'Medium text' },
{ value: 'large', label: 'A', ariaLabel: 'Large text' },
]
// ReadingHeader is Kitchen reading mode's header: back, the day the dinner
// is planned for, its title, Keep screen on and the text size.
function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: ReturnType<typeof useTextSize> }) {
const keepOnId = useId()
const [keepScreenOn, setKeepScreenOn] = useKeepScreenOn()
const { failure } = textSize
return (
<>
<header className={styles.readingHeader}>
<BackButton />
<div className={styles.heading}>
<Kicker dinner={dinner} />
<h1 className={styles.readingTitle}>{dinner.name}</h1>
</div>
<div className={styles.keepOn}>
<span id={keepOnId}>Keep screen on</span>
<Toggle checked={keepScreenOn} onCheckedChange={setKeepScreenOn} aria-labelledby={keepOnId} />
</div>
<Segmented
label="Text size"
options={textSizes}
value={textSize.size}
onValueChange={textSize.choose}
className={styles.textSize}
/>
</header>
{failure !== null && (
<ErrorBanner onRetry={textSize.retry}>
{textSizeFailureText[failure.action]}: {failure.message}
</ErrorBanner>
)}
</>
)
}
// IngredientsCard is the ingredients with a checkbox each, struck through
// once set out, and how many are. Which are set out is this visit's alone:
// it is never sent to the server and is not the Supply run's "have it".
function IngredientsCard({ ingredients }: { ingredients: Ingredient[] }) {
const titleId = useId()
const [setOut, setSetOut] = useState<ReadonlySet<number>>(() => new Set())
const count = ingredients.filter((_, i) => setOut.has(i)).length
function toggle(i: number) {
setSetOut((before) => {
const after = new Set(before)
if (!after.delete(i)) after.add(i)
return after
})
}
return (
<Card role="region" aria-labelledby={titleId} className={styles.ingredientsCard}>
<div className={styles.cardHead}>
<h2 id={titleId} className={styles.readingSectionTitle}>
Ingredients
</h2>
<span className={styles.setOutCount}>
{count} of {ingredients.length} set out
</span>
</div>
<ul className={styles.checklist}>
{ingredients.map((ingredient, i) => (
<li key={i} className={styles.checkRow}>
<Checkbox
className={styles.check}
checked={setOut.has(i)}
onChange={() => toggle(i)}
label={
<span className={[styles.checkName, setOut.has(i) && styles.setOut].filter(Boolean).join(' ')}>
{ingredient.name ?? ingredient.raw_text}
{ingredient.name !== null && ingredient.note !== null && `, ${ingredient.note}`}
</span>
}
/>
<span className={styles.checkQty}>{quantity(ingredient)}</span>
</li>
))}
</ul>
</Card>
)
}
// Steps is the recipe's steps: those before current done, muted with a
// check; current highlighted in larger text; the rest still to come.
function Steps({ steps, current }: { steps: string[]; current: number }) {
const titleId = useId()
return (
<section aria-labelledby={titleId} className={styles.readingSteps}>
<h2 id={titleId} className={styles.readingSectionTitle}>
Steps
</h2>
<ol className={styles.stepList}>
{steps.map((step, i) => (
<li
key={i}
aria-current={i === current ? 'step' : undefined}
className={[styles.readingStep, i < current ? styles.stepDone : i === current ? styles.stepCurrent : undefined]
.filter(Boolean)
.join(' ')}
>
<span className={styles.stepCircle} aria-hidden="true">
{i < current ? <Icon icon={Check} /> : i + 1}
</span>
<span className={styles.stepText}>{step}</span>
</li>
))}
</ol>
</section>
)
}
// typing says whether target is a field taking text, where the arrow keys
// move the caret and are not the screen's to take.
function typing(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
return target.isContentEditable || target.matches('textarea, select, input:not([type="checkbox"], [type="radio"])')
}
// useCurrentStep is the step being cooked, of count, from the first, with
// move, which goes back or on by a step and stops at either end. The ← and →
// keys move it too, unless a text field has focus, a sheet is open over the
// screen or the key was already taken (a segmented control's arrows).
function useCurrentStep(count: number): [number, (by: number) => void] {
const [step, setStep] = useState(0)
const last = count - 1
const move = useCallback(
(by: number) => setStep((before) => Math.max(0, Math.min(Math.min(before, last) + by, last))),
[last],
)
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
const by = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
if (by === 0 || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
if (typing(event.target) || (event.target instanceof Element && event.target.closest('[role="dialog"]') !== null)) return
event.preventDefault()
move(by)
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [move])
return [Math.max(0, Math.min(step, last)), move]
}
// KitchenReading is a dinner as mock 2a shows it on a tablet: the header,
// then the ingredients to check off beside the photo, why it was picked and
// the steps, one at a time, over the action bar with Previous and Next step.
// What is checked and the step reached last only as long as this visit.
function KitchenReading({ dinner }: { dinner: DinnerDetail }) {
const textSize = useTextSize()
const [current, move] = useCurrentStep(dinner.steps.length)
const recipe = hasRecipe(dinner)
const last = dinner.steps.length - 1
return (
<div className={[styles.reading, styles[textSize.size]].join(' ')}>
<ReadingHeader dinner={dinner} textSize={textSize} />
<div className={recipe && dinner.ingredients.length > 0 ? styles.columns : styles.oneColumn}>
{recipe && dinner.ingredients.length > 0 && <IngredientsCard ingredients={dinner.ingredients} />}
<div className={styles.method}>
<div className={styles.intro}>
<Photo id={dinner.photo_id} name={dinner.name} radius="24px" sizes="400px" className={styles.introPhoto} />
{dinner.why_picked !== null && (
<p className={[styles.why, styles.introWhy].join(' ')}>
<b>Why it was picked:</b> {dinner.why_picked}
</p>
)}
</div>
{!recipe && <RecipeMissing dinner={dinner} />}
{recipe && dinner.steps.length > 0 && <Steps steps={dinner.steps} current={current} />}
{recipe && dinner.source !== null && dinner.source.siteName !== null && (
<SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />
)}
<ActionBar dinner={dinner}>
{recipe && dinner.steps.length > 0 && (
<div className={styles.stepButtons}>
<Button variant="outline" disabled={current === 0} onClick={() => move(-1)}>
<Icon icon={ChevronLeft} />
Previous
</Button>
<Button disabled={current === last} onClick={() => move(1)}>
<Icon icon={ChevronRight} />
Next step
</Button>
</div>
)}
</ActionBar>
</div>
</div>
</div>
)
}
// DinnerDetailPage is a dinner with its recipe: on a phone (mock 1k) its
// photo, or the striped placeholder, under a sheet with Regular or Side
// quest, difficulty and time, why it was picked, the ingredients and the
// steps; from tablet up, Kitchen reading mode (mock 2a). A dinner whose
// recipe could not be found links to Settings → Regular dinners to fix it;
// an unknown or removed dinner says it is not found.
export function DinnerDetailPage() {
const { id = '' } = useParams()
const layout = useBreakpoint()
const dinner = useDinnerDetail(id)
let body
if (dinner.isError) {
body = (
<>
<div className={styles.backRow}>
<BackButton />
</div>
{dinner.error instanceof ApiError && dinner.error.status === 404 ? (
<>
<EmptyState title="Dinner not found">This dinner isn't in our kitchen — it may have been removed.</EmptyState>
<Link to="/" className={[buttonStyles.button, buttonStyles.outline].join(' ')}>
Go Home
</Link>
</>
) : (
<ErrorBanner onRetry={() => void dinner.refetch()}>Couldn't load this dinner. {dinner.error.message}</ErrorBanner>
)}
</>
)
} else if (dinner.isPending) {
body = (
<section aria-busy="true" aria-label="Dinner loading">
<Skeleton className={styles.skeletonHero} />
<div className={styles.sheet}>
<Skeleton className={styles.skeletonTitle} />
<Skeleton />
<Skeleton />
</div>
</section>
)
} else {
// Keyed by the dinner, so moving to another dinner starts its visit
// afresh: nothing checked, on the first step.
body =
layout === 'mobile' ? <Detail dinner={dinner.data} /> : <KitchenReading key={dinner.data.id} dinner={dinner.data} />
}
return <div className={`${styles.page} ${styles[layout]}`}>{body}</div>
}
+382
View File
@@ -0,0 +1,382 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { errorResponse, jsonResponse } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
import { TEXT_SIZE_KEY } from './textSize.ts'
const DINNER = '/api/v1/dinners/d1'
const SETTINGS = '/api/v1/settings/household'
const THIS_WEEK = '/api/v1/plans/2026-10-05'
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
// detail is the Korean beef bowls, a Quest-log Side quest, as GET
// /api/v1/dinners/{id} sends it.
function detail(overrides: Record<string, unknown> = {}) {
return {
id: 'd1',
name: 'Korean beef bowls',
status: 'quest_log',
origin: 'suggested',
method: 'stovetop',
minutes_total: 30,
difficulty: 'easy',
cuisine: 'korean',
is_favorite: false,
photo_urls: null,
ingredients: [
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
{ name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' },
],
steps: [
'Rinse the rice and start it cooking.',
'Brown the beef with the garlic and ginger.',
'Stir in the sauce and simmer until glossy.',
],
source: null,
grounding_status: 'grounded',
grounding_error: null,
why_picked: 'close cousin of your Beef tacos.',
...overrides,
}
}
// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it: p0 to
// p6, except the bowls (d1) on each day of bowlsOn, counting from Monday = 0.
function planBody(weekStart: string, ...bowlsOn: number[]) {
const start = new Date(`${weekStart}T00:00:00Z`).getTime()
return {
id: `plan-${weekStart}`,
week_start: weekStart,
status: 'draft',
ai_note: null,
calendar_synced_at: null,
days: Array.from({ length: 7 }, (_, i) => ({
date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
dinner:
bowlsOn.includes(i)
? { id: 'd1', name: 'Korean beef bowls', photo: null, minutes: 30, method: 'stovetop' }
: { id: `p${i}`, name: `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop' },
is_new: bowlsOn.includes(i),
start_by: null,
})),
}
}
// serve answers the screen's reads: the dinner, the household settings and
// both weeks' plans, unless responses says otherwise.
function serve(responses: Record<string, () => Response> = {}): Mock<typeof fetch> {
const all: Record<string, () => Response> = {
[DINNER]: () => jsonResponse(200, detail()),
[SETTINGS]: () => jsonResponse(200, HOUSEHOLD_SETTINGS),
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 5)),
[NEXT_WEEK]: () => errorResponse(404, 'not_found'),
...responses,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const path = String(input)
const respond = Object.hasOwn(all, path) ? all[path] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`)
return Promise.resolve(respond())
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
function reads(fetchMock: Mock<typeof fetch>, path: string) {
return fetchMock.mock.calls.filter(([input]) => String(input) === path).length
}
// renderPage renders the bowls' detail screen and waits for its title.
async function renderPage() {
const router = createMemoryRouter([{ path: '/dinner/:id', element: <DinnerDetailPage /> }], {
initialEntries: ['/dinner/d1'],
})
const view = render(
<ApiProviders>
<RouterProvider router={router} />
</ApiProviders>,
)
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
return view
}
function steps() {
return within(screen.getByRole('region', { name: 'Steps' })).getAllByRole('listitem')
}
// currentStep is the text of the step marked current.
function currentStep() {
const current = steps().filter((step) => step.getAttribute('aria-current') === 'step')
expect(current).toHaveLength(1)
return current[0]!.textContent
}
beforeEach(() => {
mockViewport(1180)
// Wednesday Oct 7, noon in New York.
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
Reflect.deleteProperty(window, 'matchMedia')
vi.unstubAllGlobals()
})
describe('Kitchen reading mode', () => {
it('is the tablet layout of the same route: header with back, title, Keep screen on and text size', async () => {
serve()
await renderPage()
const title = screen.getByRole('heading', { level: 1, name: 'Korean beef bowls' })
expect(declaredStyle(title).fontSize).toBe('36px')
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
// Not the phone's sheet: no photo hero tags.
expect(screen.queryByText('Easy')).not.toBeInTheDocument()
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
expect(keepOn).toHaveAttribute('aria-checked', 'false')
fireEvent.click(keepOn)
expect(keepOn).toHaveAttribute('aria-checked', 'true')
const sizes = screen.getByRole('radiogroup', { name: 'Text size' })
expect(within(sizes).getAllByRole('radio').map((radio) => radio.getAttribute('aria-label'))).toEqual([
'Small text',
'Medium text',
'Large text',
])
expect(within(sizes).getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true')
expect(declaredStyle(screen.getByRole('region', { name: 'Ingredients' }).parentElement!).gridTemplateColumns).toBe(
'400px minmax(0, 1fr)',
)
expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent('Why it was picked: close cousin')
})
describe('kicker', () => {
it('names the day the plan has the dinner on, and Side quest', async () => {
serve()
await renderPage()
expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument()
})
it("names a day of next week's plan, and Regular, for a Regular", async () => {
serve({
[DINNER]: () => jsonResponse(200, detail({ status: 'regular', origin: 'regular' })),
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05')),
[NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12', 0)),
})
await renderPage()
expect(await screen.findByText('Monday, Oct 12 · Regular')).toBeInTheDocument()
})
it("names a day of this week's plan already past when that is the only day", async () => {
// Today is Wednesday; the bowls were on Monday, and are on no day of next week.
serve({
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0)),
[NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12')),
})
await renderPage()
expect(await screen.findByText('Monday, Oct 5 · Side quest')).toBeInTheDocument()
})
it('names the first day from today on over a day already past', async () => {
serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1, 4, 6)) })
await renderPage()
expect(await screen.findByText('Friday, Oct 9 · Side quest')).toBeInTheDocument()
expect(screen.queryByText(/Monday|Tuesday|Sunday/)).not.toBeInTheDocument()
})
it('names the latest day already past when every day is past', async () => {
serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1)) })
await renderPage()
expect(await screen.findByText('Tuesday, Oct 6 · Side quest')).toBeInTheDocument()
expect(screen.queryByText(/Monday/)).not.toBeInTheDocument()
})
it('is absent when neither week has a plan', async () => {
const fetchMock = serve({ [THIS_WEEK]: () => errorResponse(404, 'not_found') })
await renderPage()
await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(1))
await waitFor(() => expect(reads(fetchMock, NEXT_WEEK)).toBe(1))
await act(async () => {})
expect(screen.queryByText(/· Side quest/)).not.toBeInTheDocument()
expect(screen.queryByText(/Oct/)).not.toBeInTheDocument()
})
it('says so when the plan cannot be read, with Retry', async () => {
let failing = true
serve({
[THIS_WEEK]: () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, planBody('2026-10-05', 5))),
})
await renderPage()
expect(await screen.findByText(/Couldn't check the plan for this dinner's day/)).toBeInTheDocument()
failing = false
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument()
})
})
describe('ingredients', () => {
it('checks ingredients off with 22 px checkboxes, struck through, counting those set out', async () => {
const fetchMock = serve()
await renderPage()
const card = screen.getByRole('region', { name: 'Ingredients' })
expect(within(card).getByText('0 of 3 set out')).toBeInTheDocument()
const beef = within(card).getByRole('checkbox', { name: 'Ground beef' })
expect(declaredStyle(beef).width).toBe('22px')
expect(declaredStyle(beef).height).toBe('22px')
expect(within(card).getByRole('checkbox', { name: 'Green onions, sliced' })).not.toBeChecked()
fireEvent.click(beef)
expect(beef).toBeChecked()
expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('line-through')
fireEvent.click(within(card).getByRole('checkbox', { name: 'Jasmine rice' }))
expect(within(card).getByText('2 of 3 set out')).toBeInTheDocument()
fireEvent.click(beef)
expect(beef).not.toBeChecked()
expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('')
expect(within(card).getByText('1 of 3 set out')).toBeInTheDocument()
// Local to the screen: nothing is sent.
expect(fetchMock.mock.calls.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')).toEqual([])
})
it('starts afresh on the next visit', async () => {
serve()
const view = await renderPage()
fireEvent.click(screen.getByRole('checkbox', { name: 'Ground beef' }))
fireEvent.click(screen.getByRole('button', { name: 'Next step' }))
view.unmount()
await renderPage()
expect(screen.getByText('0 of 3 set out')).toBeInTheDocument()
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
})
})
describe('steps', () => {
it('highlights the current step in 22 px text, others 18 px, and moves by Previous and Next step', async () => {
serve()
await renderPage()
const previous = screen.getByRole('button', { name: 'Previous' })
const next = screen.getByRole('button', { name: 'Next step' })
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
expect(previous).toBeDisabled()
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(22px * var(--reading-scale))')
expect(declaredStyle(steps()[0]!).background).toBe('var(--color-accent-100)')
expect(declaredStyle(steps()[0]!).boxShadow).toBe('inset 0 0 0 2px var(--color-accent-300)')
expect(declaredStyle(steps()[1]!).fontSize).toBe('calc(18px * var(--reading-scale))')
fireEvent.click(next)
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
// The step done is muted, its number a check.
expect(steps()[0]!.textContent).toBe('Rinse the rice and start it cooking.')
expect(steps()[0]!.querySelector('svg')).not.toBeNull()
expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-600)')
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(18px * var(--reading-scale))')
fireEvent.click(next)
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
expect(next).toBeDisabled()
fireEvent.click(next)
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
fireEvent.click(previous)
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
})
it('moves by the ← and → keys, stopping at either end', async () => {
serve()
await renderPage()
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
// From a checked ingredient too.
const beef = screen.getByRole('checkbox', { name: 'Ground beef' })
beef.focus()
fireEvent.keyDown(beef, { key: 'ArrowRight' })
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
})
it('leaves the keys alone in a text field, with a modifier, or in the text size control', async () => {
serve()
await renderPage()
const field = document.createElement('input')
document.body.append(field)
field.focus()
fireEvent.keyDown(field, { key: 'ArrowRight' })
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
field.remove()
fireEvent.keyDown(document.body, { key: 'ArrowRight', altKey: true })
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
const medium = screen.getByRole('radio', { name: 'Medium text' })
fireEvent.keyDown(medium, { key: 'ArrowRight' })
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
})
})
describe('text size', () => {
it('is remembered on this device', async () => {
serve()
const view = await renderPage()
fireEvent.click(screen.getByRole('radio', { name: 'Large text' }))
expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('large')
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
view.unmount()
await renderPage()
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'false')
expect(declaredStyle(screen.getByRole('heading', { level: 1 }).closest('header')!.parentElement!).getPropertyValue('--reading-scale')).toBe(
'1.25',
)
})
it('says so when the size saved is not one MealQuest saved, until one is chosen', async () => {
localStorage.setItem(TEXT_SIZE_KEY, 'huge')
serve()
await renderPage()
expect(screen.getByText(/Could not read the text size chosen on this device/)).toHaveTextContent('"huge"')
expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true')
fireEvent.click(screen.getByRole('radio', { name: 'Small text' }))
expect(screen.queryByText(/Could not read the text size/)).not.toBeInTheDocument()
expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('small')
})
})
it('shows what stands in for a recipe not found, with no step buttons', async () => {
serve({
[DINNER]: () =>
jsonResponse(200, detail({ ingredients: [], steps: [], grounding_status: 'failed', grounding_error: 'no_recipe_found' })),
})
await renderPage()
expect(screen.getByRole('link', { name: 'Fix recipe' })).toBeInTheDocument()
expect(screen.queryByRole('region', { name: 'Ingredients' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Next step' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add to this week' })).toBeInTheDocument()
})
})
+35 -14
View File
@@ -1,6 +1,7 @@
import { useIsMutating, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import type { DinnerDetail } from './detail.ts'
export const DINNERS_PATH = '/api/v1/dinners'
export const REGULARS_PATH = `${DINNERS_PATH}?status=regular`
@@ -12,7 +13,7 @@ export const GROUNDING_POLL_MS = 3000
// Methods and Difficulties are the values the dinners API allows.
export const METHODS = ['stovetop', 'oven', 'crockpot', 'grill', 'other'] as const
export const DIFFICULTIES = ['easy', 'medium', 'hard'] as const
const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const
export const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const
export type Method = (typeof METHODS)[number]
export type Difficulty = (typeof DIFFICULTIES)[number]
@@ -146,6 +147,11 @@ export function dinnerPath(id: string): string {
return `${DINNERS_PATH}/${encodeURIComponent(id)}`
}
// dinnerDetailKey is the query of dinner id with its recipe (useDinnerDetail).
export function dinnerDetailKey(id: string) {
return ['dinners', 'detail', id] as const
}
// useRegulars is the household's Regulars, in the server's order. While any
// of them is still finding its recipe it is read again every
// GROUNDING_POLL_MS, except while any change is in flight (whose settling
@@ -275,28 +281,43 @@ export function useEditRegular() {
})
}
// useToggleFavorite toggles a Regular's favorite heart (POST
// /api/v1/dinners/{id}/favorite). The heart changes at once and changes
// back if the server refuses.
// Favorite is the dinner whose heart is toggled, as it was before the toggle.
export type Favorite = { id: string; is_favorite: boolean }
// setFavorite shows dinner id's heart as is_favorite wherever it is cached:
// in the Regulars and in its detail. A cache not yet read is left unread.
function setFavorite(queryClient: QueryClient, id: string, is_favorite: boolean) {
queryClient.setQueryData<CachedRegular[]>(REGULARS_LIST_KEY, (list) =>
list?.map((r) => (r.id === id ? { ...r, is_favorite } : r)),
)
queryClient.setQueryData<DinnerDetail>(dinnerDetailKey(id), (dinner) => dinner && { ...dinner, is_favorite })
}
// useToggleFavorite toggles a dinner's favorite heart (POST
// /api/v1/dinners/{id}/favorite), a Regular's or any dinner's on its detail
// screen. The heart changes at once and changes back if the server refuses.
// The server answers with the dinner as the dinners list sends it, which
// replaces the Regular; the detail keeps its recipe and takes only the heart.
export function useToggleFavorite() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
const set = (id: string, is_favorite: boolean) =>
setRegulars(queryClient, (list) => list.map((r) => (r.id === id ? { ...r, is_favorite } : r)))
return useMutation({
mutationKey: REGULARS_MUTATION_KEY,
mutationFn: async (regular: Regular) =>
mutationFn: async (dinner: Favorite) =>
parseDinner(
`POST ${DINNERS_PATH}/{id}/favorite`,
await apiFetch<unknown>(`${dinnerPath(regular.id)}/favorite`, { method: 'POST' }),
await apiFetch<unknown>(`${dinnerPath(dinner.id)}/favorite`, { method: 'POST' }),
),
onMutate: async (regular) => {
await changeOptimistically(queryClient, (list) =>
list.map((r) => (r.id === regular.id ? { ...r, is_favorite: !regular.is_favorite } : r)),
)
onMutate: async (dinner) => {
await queryClient.cancelQueries({ queryKey: dinnerDetailKey(dinner.id) })
await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY })
setFavorite(queryClient, dinner.id, !dinner.is_favorite)
},
onSuccess: (dinner) => replace(queryClient, dinner),
onError: (_error, regular) => set(regular.id, regular.is_favorite),
onSuccess: (dinner) => {
queryClient.setQueryData<CachedRegular[]>(REGULARS_LIST_KEY, (list) => list?.map((r) => (r.id === dinner.id ? dinner : r)))
queryClient.setQueryData<DinnerDetail>(dinnerDetailKey(dinner.id), (detail) => detail && { ...detail, is_favorite: dinner.is_favorite })
},
onError: (_error, dinner) => setFavorite(queryClient, dinner.id, dinner.is_favorite),
onSettled: () => settle(queryClient),
})
}
+179
View File
@@ -0,0 +1,179 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useSyncExternalStore } from 'react'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import {
DIFFICULTIES,
dinnerDetailKey,
dinnerPath,
DINNERS_PATH,
GROUNDING_POLL_MS,
GROUNDING_STATUSES,
METHODS,
oneOf,
photoId,
type Difficulty,
type GroundingStatus,
type Method,
} from './api.ts'
// The statuses a dinner with detail can have; an archived dinner is 404.
const STATUSES = ['suggested', 'quest_log', 'tried', 'regular'] as const
export type DinnerStatus = (typeof STATUSES)[number]
// Ingredient is one recipe line. raw_text is the line as the recipe gives
// it; name, qty, unit and note are the server's reading of it, each null
// when it has none — always name, qty and unit for a recipe typed in.
export type Ingredient = {
name: string | null
qty: string | null
unit: string | null
note: string | null
raw_text: string
}
// RecipeSource is where a fetched recipe came from. siteName is the site,
// or null when there is none to name: a page that named no site, or a name
// the server withheld because it could have come from the link. url is the
// recipe's page, or null: today's API never sends the stored link, which can
// carry a token, so the credit is text until the server sends a url it has
// checked is free of secrets.
export type RecipeSource = { siteName: string | null; url: string | null }
// DinnerDetail is a dinner with its recipe (GET /api/v1/dinners/{id}).
// source is null for a recipe typed in. cached says the service worker
// answered from its saved copy because the server could not be reached.
export type DinnerDetail = {
id: string
name: string
status: DinnerStatus
method: Method | null
minutes_total: number | null
difficulty: Difficulty | null
is_favorite: boolean
photo_id: string | undefined
ingredients: Ingredient[]
steps: string[]
source: RecipeSource | null
grounding_status: GroundingStatus
why_picked: string | null
cached: boolean
}
function text(where: string, field: string, value: unknown): string {
if (typeof value !== 'string') throw new Error(`${where}: ${field} is not a string`)
return value
}
function nullableText(where: string, field: string, value: unknown): string | null {
return value === null ? null : text(where, field, value)
}
function parseIngredient(where: string, body: unknown): Ingredient {
if (!isRecord(body)) throw new Error(`${where}: ingredient is not an object`)
return {
name: nullableText(where, 'ingredient name', body.name),
qty: nullableText(where, 'ingredient qty', body.qty),
unit: nullableText(where, 'ingredient unit', body.unit),
note: nullableText(where, 'ingredient note', body.note),
raw_text: text(where, 'ingredient raw_text', body.raw_text),
}
}
// detailPhotoId is the photo id of photo_urls, which the server sends as
// both cached sizes of one photo, or null.
function detailPhotoId(where: string, value: unknown): string | undefined {
if (value === null) return undefined
if (!isRecord(value)) throw new Error(`${where}: photo_urls is not an object`)
const id = photoId(where, value['400'])
if (id === undefined || value['1200'] !== `/media/photos/${encodeURIComponent(id)}-1200.jpg`) {
throw new Error(`${where}: photo_urls is not both sizes of one stored photo`)
}
return id
}
// sourceUrl is source.url, which today's API does not send, or a link safe
// to open: https, with no user, password, query or fragment.
function sourceUrl(where: string, value: unknown): string | null {
if (value === undefined || value === null) return null
const href = text(where, 'source url', value)
let link: URL
try {
link = new URL(href)
} catch (error) {
throw new Error(`${where}: source url is not a URL`, { cause: error })
}
if (link.protocol !== 'https:' || link.username !== '' || link.password !== '' || link.search !== '' || link.hash !== '') {
throw new Error(`${where}: source url is not an https link without credentials, query or fragment`)
}
return link.href
}
function parseSource(where: string, value: unknown): RecipeSource | null {
if (value === null) return null
if (!isRecord(value)) throw new Error(`${where}: source is not an object`)
return { siteName: nullableText(where, 'source site_name', value.site_name), url: sourceUrl(where, value.url) }
}
// parseDinnerDetail checks a dinner with its recipe as the API sends it;
// cached says whether the service worker answered from its saved copy.
export function parseDinnerDetail(where: string, body: unknown, cached: boolean): DinnerDetail {
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
throw new Error(`${where}: response is not a dinner`)
}
if (typeof body.is_favorite !== 'boolean') throw new Error(`${where}: is_favorite is not a boolean`)
if (!Array.isArray(body.ingredients) || !Array.isArray(body.steps)) throw new Error(`${where}: response has no recipe lists`)
const minutes = body.minutes_total
if (minutes !== null && (typeof minutes !== 'number' || !Number.isInteger(minutes))) {
throw new Error(`${where}: minutes_total is not a whole number`)
}
return {
id: body.id,
name: body.name,
status: oneOf(where, 'status', STATUSES, body.status),
method: body.method === null ? null : oneOf(where, 'method', METHODS, body.method),
minutes_total: minutes,
difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty),
is_favorite: body.is_favorite,
photo_id: detailPhotoId(where, body.photo_urls),
ingredients: body.ingredients.map((ingredient) => parseIngredient(where, ingredient)),
steps: body.steps.map((step) => text(where, 'step', step)),
source: parseSource(where, body.source),
grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status),
why_picked: nullableText(where, 'why_picked', body.why_picked),
cached,
}
}
// useDinnerDetail is dinner id with its recipe (GET /api/v1/dinners/{id}).
// While the dinner is still finding its recipe it is read again every
// GROUNDING_POLL_MS, but only while the server can be reached: polling pauses
// while the browser is offline or the last read was the service worker's
// saved copy, which would only answer the same again, and a saved pending
// copy is read once more when the connection comes back. Polling stops on
// unmount. An unknown or archived dinner fails with the API's 404. It runs
// even while the browser reports offline, so the copy the service worker
// saved is shown, or the read fails, instead of waiting for the network.
export function useDinnerDetail(id: string) {
const { apiRead, network } = useApi()
const offline = useSyncExternalStore(network.subscribe, network.offline)
const query = useQuery({
queryKey: dinnerDetailKey(id),
queryFn: async () => {
const read = await apiRead<unknown>(dinnerPath(id))
return parseDinnerDetail(`GET ${DINNERS_PATH}/{id}`, read.body, read.cached)
},
networkMode: 'always',
refetchInterval: (query) => {
const dinner = query.state.data
return dinner?.grounding_status === 'pending' && !dinner.cached && !offline ? GROUNDING_POLL_MS : false
},
})
const { data, refetch } = query
const stalled = data?.grounding_status === 'pending' && data.cached
useEffect(() => {
if (stalled && !offline) void refetch()
}, [stalled, offline, refetch])
return query
}
+10
View File
@@ -0,0 +1,10 @@
import { useState } from 'react'
// useKeepScreenOn is Kitchen reading mode's Keep screen on switch: whether it
// is on, and a setter. It starts off on each visit.
//
// TODO(c7da10dc): hold a Screen Wake Lock while it is on. Today it is only
// the switch's state; nothing keeps the screen awake yet.
export function useKeepScreenOn(): [boolean, (on: boolean) => void] {
return useState(false)
}
+63
View File
@@ -0,0 +1,63 @@
import { useState } from 'react'
// TEXT_SIZE_KEY is the localStorage key remembering, on this device, the text
// size chosen in Kitchen reading mode.
export const TEXT_SIZE_KEY = 'mealquest.readingTextSize'
export const TEXT_SIZES = ['small', 'medium', 'large'] as const
export type TextSize = (typeof TEXT_SIZES)[number]
// DEFAULT_TEXT_SIZE is the size of a device that has never chosen one: the
// handoff's 18 px ingredients and steps.
export const DEFAULT_TEXT_SIZE: TextSize = 'medium'
// TextSizeState is the text size, and the storage failure, if any, that
// stopped us reading or saving it.
type TextSizeState = {
size: TextSize
failure: { action: 'read' | 'write'; message: string } | null
}
function isTextSize(value: string): value is TextSize {
return (TEXT_SIZES as readonly string[]).includes(value)
}
// Storage can be unavailable (blocked by the browser or privacy settings), in
// which case using it throws, and can hold a value MealQuest did not save.
// Either is reported, never treated as a remembered size.
function readTextSize(): TextSizeState {
try {
const saved = window.localStorage.getItem(TEXT_SIZE_KEY)
if (saved === null) return { size: DEFAULT_TEXT_SIZE, failure: null }
if (!isTextSize(saved)) throw new Error(`"${saved}" is not a text size MealQuest saved`)
return { size: saved, failure: null }
} catch (err) {
return { size: DEFAULT_TEXT_SIZE, failure: { action: 'read', message: String(err) } }
}
}
function writeTextSize(size: TextSize): TextSizeState {
try {
window.localStorage.setItem(TEXT_SIZE_KEY, size)
return { size, failure: null }
} catch (err) {
return { size, failure: { action: 'write', message: String(err) } }
}
}
export const textSizeFailureText = {
read: 'Could not read the text size chosen on this device',
write: 'Could not remember the text size on this device',
}
// useTextSize is Kitchen reading mode's text size on this device: the size,
// the storage failure if any, choose, which saves a size, and retry, which
// reads or saves again whatever failed. A size that could not be saved is
// still used until the screen is left.
export function useTextSize() {
const [state, setState] = useState<TextSizeState>(readTextSize)
const choose = (size: TextSize) => setState(writeTextSize(size))
const retry = () => setState(state.failure?.action === 'write' ? writeTextSize(state.size) : readTextSize())
return { ...state, choose, retry }
}
+10
View File
@@ -414,6 +414,16 @@
text-align: center;
}
/* What became of the week's Supply run after a push. */
.pushed {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
text-align: center;
color: var(--color-neutral-700);
}
/* The way to the week's Supply run under the Draft board on mobile. */
.shoppingLink {
display: inline-flex;
+288 -11
View File
@@ -1,9 +1,10 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { 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 { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { SupplyRunPage } from '../shopping/SupplyRunPage.tsx'
import { cacheFallbackHeader } from '../sw/routes.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
@@ -52,9 +53,33 @@ function planBody(status: string, names = NAMES) {
}
// syncedNow is the plan of the week of Oct 5 as a push answers it: synced
// at the tests' noon.
function syncedNow() {
return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z' }
// at the tests' noon, with what became of the week's Supply run, made
// unless told otherwise.
function syncedNow(supplyRun: { status: string; error: { code: string; message: string; retryable: boolean } | null } = GENERATED) {
return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z', supply_run: supplyRun }
}
// The Supply run outcomes a push answers with, as the server sends them.
const GENERATED = { status: 'generated', error: null }
const ARCHIVED = {
status: 'archived',
error: { code: 'archived', message: 'the Supply run is in the Supply archive', retryable: false },
}
const PLAN_CHANGED = {
status: 'failed',
error: {
code: 'plan_changed',
message: 'the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again',
retryable: true,
},
}
const SUPPLY_RUN_FAILED = {
status: 'failed',
error: {
code: 'supply_run_failed',
message: 'the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run',
retryable: true,
},
}
// deferred is a response the test sends when it chooses.
@@ -135,17 +160,17 @@ function planReads(fetchMock: Mock<typeof fetch>) {
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the Plan page.
async function renderPage() {
// the Plan page, over queryClient, with shopping as the Supply run screen.
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />) {
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/plan']}>
<Routes>
<Route path="/plan" element={<PlanPage />} />
<Route path="/plan/shopping" element={<Opened />} />
<Route path="/plan/shopping" element={shopping} />
</Routes>
</MemoryRouter>
</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() {
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
@@ -420,6 +467,236 @@ describe('PlanPage', () => {
expect(planReads(fetchMock)).toBe(2)
})
it("reads the week's Supply run again once a push has made it, and says it was made", async () => {
let run = () => errorResponse(404, 'not_found')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => {
run = () => jsonResponse(200, supplyRun(28))
return jsonResponse(200, syncedNow(GENERATED))
},
'GET /api/v1/shopping/2026-10-05': () => run(),
})
await renderPage()
expect(await screen.findByRole('link', { name: 'Shopping list' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
// The list made is read at once, not after the cached 404 goes stale.
expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/shopping/2026-10-05')).toHaveLength(2)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('says a Supply run in the Supply archive was left as it was', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(ARCHIVED)),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent("This week's Supply run is in the Supply archive, so it was left as it was.")
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('says no Supply run was made when the plan changed after it was pushed, reads the plan again, and pushes again on Retry', async () => {
let plan = planBody('draft')
let push = () => {
plan = planBody('changed', ['Lasagna', ...NAMES.slice(1)])
return jsonResponse(200, syncedNow(PLAN_CHANGED))
}
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
[`POST ${PUSH}`]: () => push(),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent(
'Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push it again.',
)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
// The plan edited meanwhile is the one shown, not the one pushed.
expect(await screen.findByText('Lasagna')).toBeInTheDocument()
expect(screen.getByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
expect(planReads(fetchMock)).toBe(2)
push = () => jsonResponse(200, syncedNow(GENERATED))
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`])
})
it('says the Supply run could not be made after the push, and makes the shopping list again', async () => {
let generate = () => errorResponse(500, 'internal')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => generate(),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
// Calendar has the plan, but the push is not reported a quiet success.
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
expect(screen.queryByRole('status')).not.toBeInTheDocument()
fireEvent.click(within(alert).getByRole('button', { name: 'Make the shopping list' }))
expect(
await screen.findByText("Pushed to Calendar, but this week's Supply run couldn't be made. Couldn't make the shopping list. internal message"),
).toBeInTheDocument()
generate = () => jsonResponse(200, { ...supplyRun(28), skipped: [] })
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
expect(posts(fetchMock)).toEqual([
`${PUSH} undefined`,
'/api/v1/shopping/2026-10-05/generate undefined',
'/api/v1/shopping/2026-10-05/generate undefined',
])
})
it('no longer says the Supply run could not be made once the shopping list is made, after leaving the Plan and coming back', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
// A Supply run never made is still said after leaving the Plan and
// coming back.
fireEvent.click(screen.getByRole('link', { name: 'Shopping list' }))
fireEvent.click(await screen.findByRole('link', { name: 'Back to the Plan' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
// Once made, it is not said again to be unmade.
fireEvent.click(screen.getByRole('link', { name: 'Shopping list · 28' }))
fireEvent.click(await screen.findByRole('link', { name: 'Back to the Plan' }))
expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()
})
it('still says the Supply run could not be made when making the shopping list fails after leaving the week', async () => {
const made = deferred()
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => made.response,
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
fireEvent.click(within(await screen.findByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Making the shopping list…')
// The week is left while the shopping list is being made, which fails.
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
await act(async () => made.send(errorResponse(500, 'internal')))
// Coming back, the Supply run is still said to be unmade, and can be
// made again.
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
expect(within(alert).getByRole('button', { name: 'Make the shopping list' })).toBeEnabled()
})
it('no longer says the Supply run could not be made once the shopping list is made after leaving the week', async () => {
const made = deferred()
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => made.response,
})
const queryClient = createQueryClient()
await renderPage(queryClient)
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
fireEvent.click(within(await screen.findByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Making the shopping list…')
// The push is kept while the week is left, until the list is made: it
// is then settled, not merely swept with the week.
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
expect(pushesKept(queryClient)).toBe(1)
await act(async () => made.send(jsonResponse(200, { ...supplyRun(28), skipped: [] })))
expect(pushesKept(queryClient)).toBe(0)
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()
})
it('no longer says the Supply run could not be made once the shopping list is made on the Supply run screen', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
})
const queryClient = createQueryClient()
await renderPage(queryClient, <SupplyRunScreen />)
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
// The list is made on the Supply run screen, not from the Plan's alert.
fireEvent.click(screen.getByRole('link', { name: 'Shopping list' }))
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
await waitFor(() => expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument())
expect(pushesKept(queryClient)).toBe(0)
fireEvent.click(screen.getByRole('link', { name: 'Back to the Plan' }))
expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()
})
it('still says the Supply run could not be made when making it on the Supply run screen fails', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => errorResponse(500, 'internal'),
})
const queryClient = createQueryClient()
await renderPage(queryClient, <SupplyRunScreen />)
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
fireEvent.click(screen.getByRole('link', { name: 'Shopping list' }))
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't make the shopping list.")
expect(pushesKept(queryClient)).toBe(1)
fireEvent.click(screen.getByRole('link', { name: 'Back to the Plan' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
expect(within(alert).getByRole('button', { name: 'Make the shopping list' })).toBeEnabled()
})
it('pushes nothing again until the changed plan is read and shown', async () => {
let read: () => Response | Promise<Response> = () => jsonResponse(200, planBody('draft'))
let push = () => errorResponse(409, 'plan_changed')
+59 -22
View File
@@ -7,7 +7,7 @@ import { QUEST_LOG_KEY } from '../discover/api.ts'
import { Confirm } from '../settings/LeaveGuard.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { supplyRunScreen, useSupplyRun } from '../shopping/api.ts'
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button, type ButtonVariant } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
@@ -23,14 +23,14 @@ import {
parseDraftResult,
planKey,
usePlan,
usePushToCalendar,
useStartDraft,
useSwapDay,
useWeekSwap,
type DraftResult,
type Plan,
type PlanDay,
type PlanDinner,
type PlanStatus,
type PushedSupplyRun,
type Swap,
} from './api.ts'
import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx'
@@ -88,6 +88,13 @@ const syncStatuses: Record<PlanStatus, string> = {
changed: 'Changes not pushed to Google Calendar',
}
// supplyRunOutcomes says what became of the week's Supply run after a push
// that made it or left it as it was.
const supplyRunOutcomes: Record<Exclude<PushedSupplyRun['status'], 'failed'>, string> = {
generated: "Made this week's Supply run from the plan.",
archived: "This week's Supply run is in the Supply archive, so it was left as it was.",
}
// duration is minutes as the plan manager's cards say it: "45 min", "6 hr",
// "1 hr 30 min", with no-break spaces so a narrow card wraps the line
// between the method and the duration, never inside the duration.
@@ -238,7 +245,9 @@ function SkeletonTiles({ label, layout, children }: { label: string; layout: Bre
//
// Push to Calendar, on either layout, sends the plan shown and shows it as
// synced once the server answers; a failure says why, linking to the Google
// Calendar settings when only they can fix it.
// Calendar settings when only they can fix it. What became of the week's
// Supply run is said too: made, left as it was in the Supply archive, or
// not made, with the way to make it.
function WeekBoard({
weekStart,
timezone,
@@ -256,9 +265,10 @@ function WeekBoard({
const [jobId, setJobId] = useState<string | null>(null)
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
const [confirming, setConfirming] = useState(false)
const swap = useSwapDay(weekStart)
const push = usePushToCalendar(weekStart)
const { swap, push, refused, swapIn: sendSwap } = useWeekSwap(weekStart)
const run = useSupplyRun(weekStart)
// Makes the week's Supply run again after a push could not make it.
const generate = useGenerateSupplyRun(weekStart)
const pushing = push.pending
// The date of the day being swapped, while the swap sheet is open.
const [swappingDate, setSwappingDate] = useState<string | null>(null)
@@ -328,6 +338,7 @@ function WeekBoard({
if (!canPush) return
swap.dismiss()
push.reset()
generate.reset()
push.mutate()
}
@@ -339,6 +350,7 @@ function WeekBoard({
setJobId(null)
swap.dismiss()
push.reset()
generate.reset()
mutate(
{ weekStart, confirm },
{
@@ -357,8 +369,33 @@ function WeekBoard({
else start(false)
}
const retryPush = cached ? () => void plan.refetch() : pushToCalendar
// makeShoppingList makes the week's Supply run again after a push could
// not make it. Once made, here or on the Supply run screen, the push is
// settled (useGenerateSupplyRun). A failure is the mutation's state, which
// generate.error reports.
function makeShoppingList() {
generate.mutate()
}
// A push's Supply run made again on the Plan stands for the push's, which
// is settled.
const supplyRun: PushedSupplyRun | null = generate.isSuccess ? { status: 'generated', error: null } : push.supplyRun
const failure = push.error !== null ? (
<PushFailure error={push.error} cached={cached} onRetry={cached ? () => void plan.refetch() : pushToCalendar} />
<PushFailure error={push.error} cached={cached} onRetry={retryPush} />
) : supplyRun?.status === 'failed' ? (
supplyRun.error.code === 'plan_changed' ? (
<ErrorBanner onRetry={retryPush}>
Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push
it again.
</ErrorBanner>
) : (
<ErrorBanner onRetry={generate.isPending ? undefined : makeShoppingList} retryLabel="Make the shopping list">
Pushed to Calendar, but this week's Supply run couldn't be made.
{generate.isPending
? ' Making the shopping list…'
: generate.isError && ` Couldn't make the shopping list. ${generate.error.message}`}
</ErrorBanner>
)
) : swap.failed !== null ? (
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
@@ -376,6 +413,11 @@ function WeekBoard({
) : (
<ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner>
)
const pushed = supplyRun !== null && supplyRun.status !== 'failed' && (
<p role="status" className={styles.pushed}>
{supplyRunOutcomes[supplyRun.status]}
</p>
)
// A failed read of the week's Supply run is shown, with Retry, after the
// Draft board's Shopping list link or under the plan manager's board; the
// Shopping list action still opens the Supply run. The count is that of a
@@ -387,24 +429,18 @@ function WeekBoard({
<ErrorBanner onRetry={() => void run.refetch()}>Couldn't load this week's shopping list. {run.error.message}</ErrorBanner>
)
// swapIn sends the swap chosen in the sheet and closes it.
// swapIn sends the swap chosen in the sheet, dropped on a day or retried
// (useWeekSwap) and closes the sheet. The Supply run made again on the
// Plan after a push is left behind with the push.
function swapIn(chosen: Swap) {
setSwappingDate(null)
push.reset()
swap.mutate(chosen)
generate.reset()
sendSwap(chosen)
}
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
// nor dropped, while a swap of the week is pending.
function dropIn(chosen: Swap) {
swap.dismiss()
swapIn(chosen)
}
// openSwap opens the swap sheet on a day. One swap of the week is sent at
// a time, even across leaving the week and coming back: the plan the
// server answers it with must not overwrite another still pending. Nor is
// one sent while the week is being pushed to Calendar.
// openSwap opens the swap sheet on a day, unless a swap is refused now
// (useWeekSwap). A drag is not picked up, nor dropped, then either.
function openSwap(date: string) {
if (swap.pending || pushing) return
if (refused) return
swap.dismiss()
setSwappingDate(date)
}
@@ -443,7 +479,7 @@ function WeekBoard({
body =
layout !== 'mobile' ? (
<>
<DropBoard days={days} pending={swap.pending || pushing} drop={dropIn} />
<DropBoard days={days} pending={refused} drop={swapIn} />
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
{days.map((day) => (
<DayColumn
@@ -562,6 +598,7 @@ function WeekBoard({
{header}
{body}
{failure}
{pushed}
{layout !== 'mobile' && supplyRunFailure}
<Confirm
open={confirming}
+54 -2
View File
@@ -10,6 +10,21 @@ import { planKey, usePlan, usePushToCalendar, useSwapDay, type Plan, type PushTo
const WEEK = '2026-10-05'
const PLAN = `/api/v1/plans/${WEEK}`
// GENERATED is a push answer's Supply run when the push made it.
const GENERATED = { supply_run: { status: 'generated', error: null } }
// SUPPLY_RUN_FAILED is a push answer's Supply run when it could not be made.
const SUPPLY_RUN_FAILED = {
supply_run: {
status: 'failed',
error: {
code: 'supply_run_failed',
message: 'the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run',
retryable: true,
},
},
}
const MONDAY = `${PLAN}/days/2026-10-05`
const FRIDAY = `${PLAN}/days/2026-10-09`
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
@@ -318,7 +333,7 @@ describe('week mutations', () => {
// The push succeeds while the third week is shown: it is left behind,
// and the week's pending swap is not.
await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT })))
await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT, ...GENERATED })))
await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual([]))
expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success'])
@@ -338,6 +353,43 @@ describe('week mutations', () => {
expect(weekMutations(queryClient, LATER)).toEqual([])
})
it('keeps a push whose Supply run was not made, settled in a week no longer shown, to be shown on coming back', async () => {
const nextPush = deferred()
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
[`GET /api/v1/plans/${NEXT}`]: () => jsonResponse(200, { ...planBody(), week_start: NEXT }),
[`POST /api/v1/plans/${NEXT}/push-calendar`]: () => nextPush.reply,
})
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const queryClient = createQueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ApiProvider>
)
const hook = renderHook(({ week }) => ({ plan: usePlan(week), push: usePushToCalendar(week) }), {
wrapper,
initialProps: { week: NEXT },
})
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
// The push is pushed to Calendar while another week is shown, but its
// Supply run is not made: it stays, though it succeeded.
act(() => hook.result.current.push.mutate())
hook.rerender({ week: WEEK })
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
const synced = { ...planBody(), week_start: NEXT, status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' }
await act(async () => nextPush.send(jsonResponse(200, { ...synced, ...SUPPLY_RUN_FAILED })))
await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual(['push success']))
// Coming back mounts the week's board again, as the Plan's does.
hook.unmount()
const back = renderHook(() => usePushToCalendar(NEXT), { wrapper })
expect(back.result.current.error).toBeNull()
expect(back.result.current.supplyRun).toEqual(SUPPLY_RUN_FAILED.supply_run)
})
it('leaves behind what settles once Plan is left for another route, and keeps what is pending', async () => {
const friday = deferred()
const monday = deferred()
@@ -394,7 +446,7 @@ describe('week mutations', () => {
// The push succeeds: the plan as synced is recorded, and the push is
// left behind.
const synced = { ...planBody(), status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' }
await act(async () => push.send(jsonResponse(200, synced)))
await act(async () => push.send(jsonResponse(200, { ...synced, ...GENERATED })))
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual([]))
expect(queryClient.getQueryData<Plan>(planKey(WEEK))).toMatchObject({ status: 'synced', calendar_synced_at: new Date('2026-10-08T06:00:00Z') })
})
+155 -12
View File
@@ -5,6 +5,7 @@ import { isRecord } from '../api/errors.ts'
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
import { noteGoogleFailure } from '../settings/calendarApi.ts'
import { supplyRunKey } from '../shopping/api.ts'
export const PLANS_PATH = '/api/v1/plans'
@@ -114,6 +115,56 @@ function parsePlan(where: string, weekStart: string, body: unknown, cached: bool
}
}
// SupplyRunError is the API error a Push to Calendar's Supply run carries
// when it was not generated.
export type SupplyRunError<C extends string> = { code: C; message: string; retryable: boolean }
// PushedSupplyRun says whether a Push to Calendar made the week's Supply
// run: generated when it did; archived when the week's Supply run is in the
// Supply archive and was left as it was; or failed when it was not made.
// A failed one's error is plan_changed when the plan was edited after it
// was pushed, and pushing again makes it, or supply_run_failed when it
// could not be made, and making the shopping list again does.
export type PushedSupplyRun =
| { status: 'generated'; error: null }
| { status: 'archived'; error: SupplyRunError<'archived'> }
| { status: 'failed'; error: SupplyRunError<'plan_changed' | 'supply_run_failed'> }
// PushedPlan is the POST /api/v1/plans/{weekStart}/push-calendar answer:
// the plan as synced, and what became of the week's Supply run.
export type PushedPlan = { plan: Plan; supply_run: PushedSupplyRun }
const PUSHED_SUPPLY_RUN_STATUSES = ['generated', 'archived', 'failed'] as const
const FAILED_SUPPLY_RUN_CODES = ['plan_changed', 'supply_run_failed'] as const
function parseSupplyRunError<C extends string>(where: string, codes: readonly C[], body: unknown): SupplyRunError<C> {
if (!isRecord(body) || typeof body.message !== 'string' || typeof body.retryable !== 'boolean') {
throw new Error(`${where}: supply_run has an error that is not an API error`)
}
return { code: oneOf(where, 'supply_run.error.code', codes, body.code), message: body.message, retryable: body.retryable }
}
function parsePushedSupplyRun(where: string, body: unknown): PushedSupplyRun {
if (!isRecord(body)) throw new Error(`${where}: response has no supply_run`)
const status = oneOf(where, 'supply_run.status', PUSHED_SUPPLY_RUN_STATUSES, body.status)
switch (status) {
case 'generated':
if (body.error !== null) throw new Error(`${where}: a generated supply_run has an error`)
return { status, error: null }
case 'archived':
return { status, error: parseSupplyRunError(where, ['archived'] as const, body.error) }
case 'failed':
return { status, error: parseSupplyRunError(where, FAILED_SUPPLY_RUN_CODES, body.error) }
}
}
function parsePushedPlan(where: string, weekStart: string, body: unknown): PushedPlan {
return {
plan: parsePlan(where, weekStart, body, false),
supply_run: parsePushedSupplyRun(where, isRecord(body) ? body.supply_run : undefined),
}
}
// parseDraftResult checks a draft_week job's result, which useJob passes on
// unread, is the draft of the week starting on weekStart.
export function parseDraftResult(where: string, weekStart: string, body: unknown): DraftResult {
@@ -178,6 +229,19 @@ function sameDay(a: PlanDay, b: PlanDay): boolean {
)
}
// failed says whether a swap or push failed: a push whose Supply run was
// not made failed too, though Calendar has the plan.
function failed(mutation: Mutation): boolean {
return mutation.state.status === 'error' || unmade(mutation)
}
// unmade says whether a push succeeded but its Supply run was not made: the
// week is on Calendar with no shopping list until one is made.
function unmade(mutation: Mutation): boolean {
const data = mutation.state.data as PushedPlan | undefined
return mutation.state.status === 'success' && weekMutation(mutation)?.kind === 'push' && data?.supply_run.status === 'failed'
}
// weekMutation is the kind and week of a swap or push of a week the mutation
// cache keeps for it, or null for any other mutation.
function weekMutation(mutation: Mutation): { kind: 'swap' | 'push'; week: string } | null {
@@ -203,9 +267,13 @@ const weeksShown = new WeakMap<QueryClient, WeeksShown>()
// earlier pending one must see, stay across leaving its week and coming
// back. When one settles, the same is done, except that a week's latest,
// when it failed, stays until a week is next shown, so that coming straight
// back to it shows the failure. The cache holds at most the weeks shown's,
// those of weeks left pending, and one failure of each week whose last
// pending one settled since a week was last shown.
// back to it shows the failure. A week's latest push whose Supply run was
// not made stays whatever is shown: the week has no shopping list until one
// is made, which settles the push, or a push sent since replaces it, and
// making it may still be under way when the week is left. The cache holds
// at most the weeks shown's, those of weeks left pending, one push of each
// week whose Supply run was not made, and one failure of each week whose
// last pending one settled since a week was last shown.
function keepWeekMutations(queryClient: QueryClient): WeeksShown {
const kept = weeksShown.get(queryClient)
if (kept !== undefined) return kept
@@ -225,7 +293,7 @@ function keepWeekMutations(queryClient: QueryClient): WeeksShown {
if (week.some((mutation) => mutation.state.status === 'pending')) continue
const latest = week.at(-1)
for (const mutation of week) {
if (settled && mutation === latest && mutation.state.status === 'error') continue
if (mutation === latest && (unmade(mutation) || (settled && failed(mutation)))) continue
mutations.remove(mutation)
}
}
@@ -261,9 +329,11 @@ function swapKey(weekStart: string) {
}
// SwapDay is the swaps of one week, as the mutation cache holds them:
// whether one is pending, and the failure of the latest, if it failed.
// whether one is pending, the latest if it succeeded, and the failure of the
// latest, if it failed.
export type SwapDay = {
pending: boolean
succeeded: Swap | null
failed: { swap: Swap; error: Error } | null
mutate: (chosen: Swap) => void
dismiss: () => void
@@ -350,6 +420,7 @@ export function useSwapDay(weekStart: string): SwapDay {
return {
pending: swaps.some((swap) => swap.status === 'pending'),
succeeded: latest?.status === 'success' ? latest.swap : null,
failed: latest?.status === 'error' ? { swap: latest.swap, error: latest.error! } : null,
mutate,
dismiss,
@@ -363,10 +434,12 @@ function pushKey(weekStart: string) {
}
// PushToCalendar is the push of one week, as the mutation cache holds it:
// whether it is pending, and why the latest failed, if it failed.
// whether it is pending, why the latest failed, if it failed, and what
// became of the week's Supply run, if it succeeded.
export type PushToCalendar = {
pending: boolean
error: Error | null
supplyRun: PushedSupplyRun | null
mutate: () => void
reset: () => void
}
@@ -380,10 +453,19 @@ export type PushToCalendar = {
// the one shown. One Google refused the grant for reads the Google Calendar
// connection again, which is now reconnect required.
//
// Once pushed, the server makes the week's Supply run from the plan pushed
// and answers with its outcome, which supplyRun reports: a failed one is
// never a quiet success. The week's Supply run is read again, whatever the
// outcome, so the list shown is the server's. A Supply run not made because
// the plan was edited after it was pushed reads the plan again too, as a
// push refused for that does.
//
// The week's pushes live in the mutation cache, not in the component, as its
// swaps do, so leaving the week and coming back keeps one pending. reset
// leaves settled pushes behind; it is refused while one is pending. Settled
// pushes of a week no longer shown are left behind too
// leaves settled pushes behind; it is refused while one is pending. A push
// whose Supply run was not made is left behind once the list is made
// (settleUnmadePushes). Settled pushes of a week no longer shown are left
// behind too, but for the latest when its Supply run was not made
// (keepWeekMutations).
export function usePushToCalendar(weekStart: string): PushToCalendar {
const { apiFetch } = useApi()
@@ -395,12 +477,17 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
// The week's pushes, in the order sent.
const pushes = useMutationState({
filters: { mutationKey },
select: (mutation) => ({ status: mutation.state.status, error: mutation.state.error }),
select: (mutation) => ({
mutationId: mutation.mutationId,
status: mutation.state.status,
error: mutation.state.error,
data: mutation.state.data as PushedPlan | undefined,
}),
})
const latest = pushes.at(-1)
function mutate() {
const mutation = mutations.build<Plan, Error, void, unknown>(queryClient, {
const mutation = mutations.build<PushedPlan, Error, void, unknown>(queryClient, {
mutationKey,
// Kept until reset or settled in a week not shown (keepWeekMutations):
// a push pending in a week left must still be found when it is shown
@@ -408,9 +495,15 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
gcTime: Infinity,
mutationFn: async () => {
const path = `${planPath(weekStart)}/push-calendar`
return parsePlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }), false)
return parsePushedPlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
},
onSuccess: async ({ plan, supply_run }) => {
queryClient.setQueryData<Plan>(key, plan)
void queryClient.invalidateQueries({ queryKey: supplyRunKey(weekStart) })
if (supply_run.error?.code === 'plan_changed') {
await queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' })
}
},
onSuccess: (plan) => queryClient.setQueryData<Plan>(key, plan),
onError: async (error) => {
noteGoogleFailure(queryClient, error)
if (error instanceof ApiError && error.code === 'plan_changed') {
@@ -433,7 +526,57 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
return {
pending: pushes.some((push) => push.status === 'pending'),
error: latest?.status === 'error' ? latest.error! : null,
supplyRun: latest?.status === 'success' ? latest.data!.supply_run : null,
mutate,
reset,
}
}
// WeekSwap is the one way a day of a week is swapped, wherever the swap is
// chosen: the week's swaps and pushes, whether a swap is refused now, and
// swapIn to send one.
export type WeekSwap = {
swap: SwapDay
push: PushToCalendar
refused: boolean
swapIn: (chosen: Swap) => void
}
// useWeekSwap swaps a day of the week starting on weekStart, from the Draft
// board or a dinner's Add to this week. One swap of the week is sent at a
// time, even across leaving the week and coming back: the plan the server
// answers it with must not overwrite another still pending. Nor is one sent
// while the week is being pushed to Calendar: refused says so, and swapIn
// then throws. A swap leaves the week's settled swaps behind, and its
// pushes: what a push said of Calendar and the Supply run was said of the
// plan before the swap, not the one swapped.
export function useWeekSwap(weekStart: string): WeekSwap {
const swap = useSwapDay(weekStart)
const push = usePushToCalendar(weekStart)
const refused = swap.pending || push.pending
function swapIn(chosen: Swap) {
if (refused) throw new Error(`The week of ${weekStart} has a swap or push pending, so ${chosen.date} cannot be swapped.`)
swap.dismiss()
push.reset()
swap.mutate(chosen)
}
return { swap, push, refused, swapIn }
}
// 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)
}
}
}
+6 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { addDays, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
import { addDays, dayAndDate, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
// SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York.
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
@@ -36,6 +36,11 @@ describe('week helpers', () => {
expect(weekdayName('2026-10-11')).toBe('Sunday')
})
it('names the day of a date in full with its month and day', () => {
expect(dayAndDate('2026-10-10')).toBe('Saturday, Oct 10')
expect(dayAndDate('2026-11-01')).toBe('Sunday, Nov 1')
})
it('reads the day of the month of a date', () => {
expect(dayOfMonth('2026-10-05')).toBe(5)
expect(dayOfMonth('2026-10-31')).toBe(31)
+7
View File
@@ -52,6 +52,13 @@ export function weekdayName(date: string): string {
return weekday.format(midnightUTC(date))
}
const longDay = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'short', day: 'numeric', timeZone: 'UTC' })
// dayAndDate is date's day in full with its month and day: "Saturday, Oct 10".
export function dayAndDate(date: string): string {
return longDay.format(midnightUTC(date))
}
// monday reads start as midnight UTC, or throws when it is not a Monday.
function monday(start: string): Date {
const t = midnightUTC(start)
@@ -0,0 +1,144 @@
/* Supply archive · weekly posts (mock 1m, and the right panel of 2f). */
.archive {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 14px;
max-width: 560px;
padding-top: 8px;
}
.header {
display: flex;
align-items: center;
gap: 10px;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 400;
line-height: 1.1;
}
.periods {
align-self: flex-start;
}
.period {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 6px;
}
.heading {
margin: 0;
font-family: var(--font-heading);
font-size: 18px;
font-weight: 400;
line-height: 1.2;
}
.posts {
display: flex;
flex-direction: column;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.post {
display: flex;
flex-direction: column;
gap: 6px;
padding: 16px;
border-radius: 24px;
background: var(--color-surface);
color: var(--color-text);
text-decoration: none;
}
.post:focus-visible {
outline: 3px solid var(--color-accent);
outline-offset: 2px;
}
/* The current week's post: grape-100 with a grape-300 ring. */
.current {
background: var(--color-accent-100);
box-shadow: inset 0 0 0 2px var(--color-accent-300);
}
/* The week label as the API writes it, capitalised here. */
.kicker {
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-neutral-700);
}
.current .kicker {
color: var(--color-accent-700);
}
.postTitle {
font-family: var(--font-heading);
font-size: 18px;
line-height: 1.15;
}
.meta {
font-family: var(--font-body);
font-size: 13px;
color: var(--color-neutral-800);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
padding: 3px 10px;
border-radius: var(--radius-pill);
background: var(--color-bg);
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
}
.older {
align-self: center;
}
.skeletonPost {
height: 112px;
border-radius: 24px;
}
/* Desktop (mock 2f): the week's list at the left, the archive in a 340 px
panel at the right. */
.split {
display: flex;
gap: 24px;
align-items: flex-start;
}
.list {
flex: 1;
min-width: 0;
}
.panel {
flex: none;
box-sizing: border-box;
width: 340px;
padding-left: 24px;
border-left: 1px solid var(--color-divider);
}
+422
View File
@@ -0,0 +1,422 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import type { ArchivePost } from './api.ts'
import { PAGE, SupplyArchivePage } from './SupplyArchivePage.tsx'
import styles from './SupplyArchivePage.module.css'
const ARCHIVE = (by: string) => `/api/v1/shopping/archive?by=${by}`
// post is one Supply archive post as GET /api/v1/shopping/archive sends it.
function post(weekStart: string, label: string, overrides: Partial<ArchivePost> = {}): ArchivePost {
return {
weekStart,
label,
title: null,
dinnerCount: 7,
itemCount: 23,
onHandCount: 4,
dinners: ['Beef tacos', 'Tikka masala', 'Pizza', 'Pot roast', 'Orzo', 'Chili', 'Salmon'],
current: false,
...overrides,
}
}
// The posts of mock 1m: this week's titled post, then the weeks across the
// September–October boundary and the 2025–2026 one, the last untitled.
const WEEK_41 = post('2026-10-05', 'Week 41 · Oct 5 – 11', { title: 'Tacos, tikka & a salmon finale', current: true })
const WEEK_40 = post('2026-09-28', 'Week 40 · Sep 28 – Oct 4', { title: 'Pizza, pot roast & the great orzo trial', itemCount: 26 })
const WEEK_39 = post('2026-09-21', 'Week 39 · Sep 21 – 27', { title: 'Chili season opens', dinnerCount: 1, itemCount: 1, onHandCount: 0, dinners: ['Chili'] })
const WEEK_1 = post('2025-12-29', 'Week 1 · Dec 29 – Jan 4')
// The archive as the server groups it: by week each period is one ISO week
// headed by its label; by month and year a week belongs to the period of its
// Monday, so week 40 is September's and week 1 is 2025's.
const BY = {
week: (
[
['2026-W41', WEEK_41],
['2026-W40', WEEK_40],
['2026-W39', WEEK_39],
['2026-W01', WEEK_1],
] as const
).map(([key, p]) => ({ key, heading: p.label, posts: [p] })),
month: [
{ key: '2026-10', heading: 'October 2026', posts: [WEEK_41] },
{ key: '2026-09', heading: 'September 2026', posts: [WEEK_40, WEEK_39] },
{ key: '2025-12', heading: 'December 2025', posts: [WEEK_1] },
],
year: [
{ key: '2026', heading: '2026', posts: [WEEK_41, WEEK_40, WEEK_39] },
{ key: '2025', heading: '2025', posts: [WEEK_1] },
],
}
function archive(by: keyof typeof BY) {
return () => jsonResponse(200, { by, periods: BY[by] })
}
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
// stubServer answers "METHOD path" requests from responders, with /me and
// the household settings; any other request fails the test.
function stubServer(responders: Record<string, Responder>) {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// renderPage loads /me (for the CSRF token any write would need, so a
// missing PATCH is not for want of it) and renders the Supply archive at path.
async function renderPage(path = '/plan/shopping/archive') {
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/plan/shopping/archive" element={<SupplyArchivePage />} />
<Route path="/plan/shopping" element={<p>Supply run screen</p>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
// postCard is the post titled title, a link.
function postCard(title: string) {
return screen.getByText(title).closest('a')!
}
beforeEach(() => {
mockViewport(390)
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
})
describe('SupplyArchivePage', () => {
it('renders mock 1m by week: one post per week with its kicker, title, counts and dinner chips', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
expect(screen.getByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument()
expect(screen.getByRole('radio', { name: 'Week' })).toBeChecked()
const card = await screen.findByText('Pizza, pot roast & the great orzo trial')
const post40 = card.closest('a')!
expect(within(post40).getByText('Week 40 · Sep 28 – Oct 4')).toHaveClass(styles.kicker!)
expect(post40).toHaveTextContent('7 dinners · 26 items · 4 already on hand')
expect(within(post40).getByText('Beef tacos')).toHaveClass(styles.chip!)
expect(within(post40).getByText('Tikka masala')).toHaveClass(styles.chip!)
expect(within(post40).getByText('+5')).toHaveClass(styles.chip!)
expect(within(post40).queryByText('Pizza')).not.toBeInTheDocument()
expect(postCard('Chili season opens')).toHaveTextContent('1 dinner · 1 item · 0 already on hand')
expect(within(postCard('Chili season opens')).queryByText(/^\+/)).not.toBeInTheDocument()
expect(screen.getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([
'/plan/shopping/archive?week=2026-10-05',
'/plan/shopping/archive?week=2026-09-28',
'/plan/shopping/archive?week=2026-09-21',
'/plan/shopping/archive?week=2025-12-29',
])
})
it('renders by week the periods and headings the server sends, as sent', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
await screen.findByText('Chili season opens')
expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(BY.week.map((period) => period.heading))
expect(screen.getByRole('heading', { level: 2, name: WEEK_40.label })).toHaveClass(styles.heading!)
for (const period of BY.week) {
const section = screen.getByRole('region', { name: period.heading })
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
)
}
})
it('capitalises the week label kicker in CSS from the label as the API writes it', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
await screen.findByText('Chili season opens')
expect(within(postCard('Chili season opens')).getByText('Week 39 · Sep 21 – 27')).toHaveClass(styles.kicker!)
})
it.each([
['Month', 'month', ['October 2026', 'September 2026', 'December 2025']],
['Year', 'year', ['2026', '2025']],
] as const)('groups by %s under the headings the server sends, across the month and year boundaries', async (label, by, headings) => {
const fetchMock = stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week'), [`GET ${ARCHIVE(by)}`]: archive(by) })
await renderPage()
await screen.findByText('Chili season opens')
fireEvent.click(screen.getByRole('radio', { name: label }))
await waitFor(() => expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(headings))
expect(screen.getByRole('heading', { level: 2, name: headings[0] })).toHaveClass(styles.heading!)
expect(fetchMock.mock.calls.map(([input]) => String(input))).toContain(ARCHIVE(by))
for (const period of BY[by]) {
const section = screen.getByRole('region', { name: period.heading })
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
)
}
})
it('highlights the current week’s post, by the API’s current, and no other', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
const current = (await screen.findByText('Tacos, tikka & a salmon finale')).closest('a')!
expect(current).toHaveClass(styles.current!)
expect(within(current).getByText('Week 41 · Oct 5 – 11 · Current')).toBeInTheDocument()
for (const title of ['Pizza, pot roast & the great orzo trial', 'Chili season opens']) {
expect(postCard(title)).not.toHaveClass(styles.current!)
expect(postCard(title)).not.toHaveTextContent('Current')
}
})
it('follows current from the API, not today’s date', async () => {
const past = { ...WEEK_40, current: true }
stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'week', periods: [{ key: 'w', heading: past.label, posts: [{ ...WEEK_41, current: false }, past] }] }) })
await renderPage()
expect(await screen.findByText('Week 40 · Sep 28 – Oct 4 · Current')).toBeInTheDocument()
expect(postCard('Tacos, tikka & a salmon finale')).not.toHaveClass(styles.current!)
})
it('titles an untitled post with its week label', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
await screen.findByText('Chili season opens')
const untitled = screen.getByRole('link', { name: /Week 1 · Dec 29 – Jan 4/ })
const label = within(untitled).getAllByText('Week 1 · Dec 29 – Jan 4')
expect(label.map((el) => el.className)).toEqual([styles.kicker, styles.postTitle])
})
it(`shows ${PAGE} posts and "Load older" shows the next ${PAGE}, none twice and none skipped`, async () => {
const weeks = Array.from({ length: 2 * PAGE + 3 }, (_, i) => {
const monday = new Date(Date.UTC(2026, 9, 5 - 7 * i)).toISOString().slice(0, 10)
return post(monday, `Week of ${monday}`, { title: `Post ${i}` })
})
// By month, so the cut falls inside a period and its heading is shown once.
const periods = [
{ key: 'a', heading: 'Newer', posts: weeks.slice(0, PAGE + 2) },
{ key: 'b', heading: 'Older', posts: weeks.slice(PAGE + 2) },
]
stubServer({
[`GET ${ARCHIVE('week')}`]: archive('week'),
[`GET ${ARCHIVE('month')}`]: () => jsonResponse(200, { by: 'month', periods }),
})
await renderPage()
await screen.findByText('Chili season opens')
fireEvent.click(screen.getByRole('radio', { name: 'Month' }))
const titles = () => screen.getAllByRole('link').map((link) => within(link).getByText(/^Post /).textContent)
await screen.findByText('Post 0')
expect(titles()).toEqual(weeks.slice(0, PAGE).map((p) => p.title))
expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(['Newer'])
fireEvent.click(screen.getByRole('button', { name: 'Load older' }))
expect(titles()).toEqual(weeks.slice(0, 2 * PAGE).map((p) => p.title))
expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(['Newer', 'Older'])
fireEvent.click(screen.getByRole('button', { name: 'Load older' }))
expect(titles()).toEqual(weeks.map((p) => p.title))
expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument()
})
it('offers no "Load older" when every post is shown', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
await screen.findByText('Chili season opens')
expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument()
})
it('shows skeleton posts while the archive loads', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: () => new Promise<Response>(() => {}) })
await renderPage()
expect(screen.getByLabelText('Supply archive loading')).toHaveAttribute('aria-busy', 'true')
expect(screen.getAllByTestId('skeleton-post')).toHaveLength(3)
})
it('says so when nothing is archived yet', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'week', periods: [] }) })
await renderPage()
expect(await screen.findByText('Nothing archived yet')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument()
})
it('reports a failed load and retries it', async () => {
let reads = 0
stubServer({
[`GET ${ARCHIVE('week')}`]: () => (++reads === 1 ? errorResponse(500, 'internal') : archive('week')()),
})
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't load the Supply archive.")
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Chili season opens')).toBeInTheDocument()
expect(reads).toBe(2)
})
it('reports a response that is not the archive by the period asked for', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'month', periods: [] }) })
await renderPage()
expect(await screen.findByRole('alert')).toHaveTextContent(`GET ${ARCHIVE('week')}: response is not the Supply archive by week`)
})
it('goes back to the Supply run', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Back to the Supply run' }))
expect(await screen.findByText('Supply run screen')).toBeInTheDocument()
})
})
// The frozen Supply run of week 40, as GET /api/v1/shopping/{weekStart}
// sends it.
const FROZEN = {
weekStart: '2026-09-28',
label: 'Week 40 · Sep 28 – Oct 4',
title: 'Pizza, pot roast & the great orzo trial',
status: 'archived',
sent_to_chat_at: null,
needed_count: 1,
total_count: 2,
groups: [
{
dinner: { id: 'd1', name: 'Friday pizza' },
date: '2026-10-02',
items: [
{ id: 'i1', name: 'Pizza dough', qty: '2 balls', needed: true },
{ id: 'i2', name: 'Mozzarella', qty: '1 lb', needed: false },
],
},
],
}
// The current week's Supply run, week 41, still current: the API's archive
// includes it, highlighted.
const CURRENT = {
...FROZEN,
weekStart: '2026-10-05',
label: 'Week 41 · Oct 5 – 11',
title: 'Tacos, tikka & a salmon finale',
status: 'current',
groups: [
{
dinner: { id: 'd2', name: 'Beef tacos' },
date: '2026-10-05',
items: [
{ id: 'i3', name: 'Corn tortillas', qty: '12', needed: true },
{ id: 'i4', name: 'Limes', qty: '3', needed: false },
],
},
],
}
describe('SupplyArchivePage frozen post', () => {
it('opens a tapped post’s frozen list read-only, tagged Archived, sending no PATCH', async () => {
const fetchMock = stubServer({
[`GET ${ARCHIVE('week')}`]: archive('week'),
'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, FROZEN),
})
await renderPage()
fireEvent.click(await screen.findByText('Pizza, pot roast & the great orzo trial'))
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: /Shopping list/ })).toBeInTheDocument()
expect(screen.getByText('Pizza dough')).toBeInTheDocument()
expect(screen.queryAllByRole('checkbox')).toEqual([])
fireEvent.click(screen.getByText('Pizza dough'))
fireEvent.click(screen.getByText('Mozzarella'))
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
// The list links back to the archive.
fireEvent.click(screen.getByRole('link', { name: 'Supply archive' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument()
})
it('opens the current week’s post read-only too, tagged Archived, with no checkbox, nothing to send and no PATCH', async () => {
const fetchMock = stubServer({
[`GET ${ARCHIVE('week')}`]: archive('week'),
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
})
await renderPage()
fireEvent.click(await screen.findByText('Tacos, tikka & a salmon finale'))
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(screen.getByText("This week's list as it stands, read-only in the Supply archive.")).toBeInTheDocument()
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
expect(screen.queryAllByRole('checkbox')).toEqual([])
fireEvent.click(screen.getByText('Corn tortillas'))
fireEvent.click(screen.getByText('Limes'))
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
})
it('on desktop opens the current week’s post read-only, and with no post open shows this week’s Supply run to change', async () => {
mockViewport(1440)
const fetchMock = stubServer({
[`GET ${ARCHIVE('week')}`]: archive('week'),
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
})
const view = await renderPage('/plan/shopping/archive?week=2026-10-05')
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
expect(screen.queryAllByRole('checkbox')).toEqual([])
expect(screen.queryByText('Send to Google Chat')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Corn tortillas'))
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
view.unmount()
await renderPage()
expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeInTheDocument()
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
})
it('on desktop (mock 2f) shows the archive as a panel beside the open post’s list', async () => {
mockViewport(1440)
stubServer({
[`GET ${ARCHIVE('week')}`]: archive('week'),
'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, FROZEN),
})
await renderPage('/plan/shopping/archive?week=2026-09-28')
const panel = screen.getByRole('complementary', { name: 'Supply archive' })
expect(await within(panel).findByText('Chili season opens')).toBeInTheDocument()
expect(within(panel).queryByRole('button', { name: 'Back to the Supply run' })).not.toBeInTheDocument()
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(panel).not.toContainElement(screen.getByText('Pizza dough'))
expect(screen.queryAllByRole('checkbox')).toEqual([])
})
})
+183
View File
@@ -0,0 +1,183 @@
import { ChevronLeft } from 'lucide-react'
import { useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router'
import { useBreakpoint } from '../shell/useBreakpoint.ts'
import { Button } from '../ui/Button.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { useSupplyArchive, WEEK_PARAM, type ArchiveGroup, type ArchivePeriod, type ArchivePost } from './api.ts'
import styles from './SupplyArchivePage.module.css'
import { SUPPLY_ARCHIVE, SupplyRunPage } from './SupplyRunPage.tsx'
// PAGE is how many posts the Supply archive shows at first, and how many
// more each "Load older" shows.
export const PAGE = 8
// CHIPS is how many of a post's dinners it names; a "+N" chip counts the rest.
const CHIPS = 2
const PERIODS = [
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
{ value: 'year', label: 'Year' },
] as const satisfies readonly { value: ArchivePeriod; label: string }[]
// archivePost is the Supply archive screen opened on the post of the week
// starting on Monday weekStart.
export function archivePost(weekStart: string): string {
return `${SUPPLY_ARCHIVE}?${new URLSearchParams({ [WEEK_PARAM]: weekStart })}`
}
function plural(n: number, one: string, many: string): string {
return `${n} ${n === 1 ? one : many}`
}
// PostCard is one week's Supply run (mock 1m): the week label as its kicker,
// the AI's title — the week label while it has none — the counts and its
// first dinners as chips. The current week's post is ringed in grape. It
// opens the week's list, read-only.
function PostCard({ post }: { post: ArchivePost }) {
const rest = post.dinners.length - CHIPS
return (
<Link to={archivePost(post.weekStart)} className={[styles.post, post.current && styles.current].filter(Boolean).join(' ')}>
<span className={styles.kicker}>
{post.label}
{post.current && ' · Current'}
</span>
<span className={styles.postTitle}>{post.title ?? post.label}</span>
<span className={styles.meta}>
{plural(post.dinnerCount, 'dinner', 'dinners')} · {plural(post.itemCount, 'item', 'items')} · {post.onHandCount} already on
hand
</span>
{post.dinners.length > 0 && (
<span className={styles.chips}>
{post.dinners.slice(0, CHIPS).map((name, i) => (
<span key={i} className={styles.chip}>
{name}
</span>
))}
{rest > 0 && <span className={styles.chip}>+{rest}</span>}
</span>
)}
</Link>
)
}
function Posts({ posts }: { posts: ArchivePost[] }) {
return (
<ul className={styles.posts}>
{posts.map((post) => (
<li key={post.weekStart}>
<PostCard post={post} />
</li>
))}
</ul>
)
}
// shownGroups is groups cut to their first shown posts, newest first, without
// the periods left with none.
function shownGroups(groups: ArchiveGroup[], shown: number): ArchiveGroup[] {
const out: ArchiveGroup[] = []
let left = shown
for (const group of groups) {
if (left === 0) break
const posts = group.posts.slice(0, left)
out.push({ ...group, posts })
left -= posts.length
}
return out
}
// ArchivePosts is the Supply archive as the server groups it by by: one post
// per week, under the Bagel heading of each period the server sends — by
// week, the week's label. It shows PAGE posts and "Load older" shows PAGE
// more.
function ArchivePosts({ by }: { by: ArchivePeriod }) {
const archive = useSupplyArchive(by)
const [shown, setShown] = useState(PAGE)
if (archive.isError) {
return <ErrorBanner onRetry={() => void archive.refetch()}>Couldn't load the Supply archive. {archive.error.message}</ErrorBanner>
}
if (archive.isPending) {
return (
<div aria-busy="true" aria-label="Supply archive loading" className={styles.posts}>
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} data-testid="skeleton-post" className={styles.skeletonPost} />
))}
</div>
)
}
const total = archive.data.reduce((n, group) => n + group.posts.length, 0)
if (total === 0) {
return <EmptyState title="Nothing archived yet">Each week's Supply run becomes a post here once it's made.</EmptyState>
}
const groups = shownGroups(archive.data, shown)
return (
<>
{groups.map((group) => (
<section key={group.key} aria-label={group.heading} className={styles.period}>
<h2 className={styles.heading}>{group.heading}</h2>
<Posts posts={group.posts} />
</section>
))}
{shown < total && (
<Button variant="outline" className={styles.older} onClick={() => setShown(shown + PAGE)}>
Load older
</Button>
)}
</>
)
}
// SupplyArchive is the Supply archive (mock 1m, and the right panel of 2f):
// the Week | Month | Year control over the posts. On mobile and tablet a
// back button returns to the Supply run.
function SupplyArchive({ panel }: { panel: boolean }) {
const navigate = useNavigate()
const [by, setBy] = useState<ArchivePeriod>('week')
return (
<div className={styles.archive}>
<header className={styles.header}>
{!panel && (
<IconButton label="Back to the Supply run" onClick={() => void navigate('/plan/shopping')}>
<Icon icon={ChevronLeft} size="nav" />
</IconButton>
)}
<h1 className={styles.title}>Supply archive</h1>
</header>
<Segmented label="Group the Supply archive by" options={PERIODS} value={by} onValueChange={setBy} className={styles.periods} />
<ArchivePosts key={by} by={by} />
</div>
)
}
// SupplyArchivePage is /plan/shopping/archive. A post opens it on its week
// (?week=), whose list the Supply run screen shows read-only and tagged
// Archived, whatever its status: even the current week's post is only
// viewed here, never changed. On desktop (mock 2f) the archive is a panel at
// the right of that list, this week's editable Supply run when no post is
// open; on mobile and tablet it is the archive or, with a post open, the
// post's list.
export function SupplyArchivePage() {
const layout = useBreakpoint()
const [params] = useSearchParams()
if (layout === 'desktop') {
return (
<div className={styles.split}>
<div className={styles.list}>
<SupplyRunPage readOnly={params.has(WEEK_PARAM)} />
</div>
<aside className={styles.panel} aria-label="Supply archive">
<SupplyArchive panel />
</aside>
</div>
)
}
return params.has(WEEK_PARAM) ? <SupplyRunPage readOnly /> : <SupplyArchive panel={false} />
}
+36 -24
View File
@@ -40,8 +40,8 @@ export function sentAt(at: Date, timezone: string): string {
// ItemRow is one ingredient: on a current Supply run a checkbox, checked
// (grape) while it is to buy; unchecked, the name is struck through and
// "have it" stands in for the quantity. An archived Supply run shows the
// same marks, read-only. An item toggled while the server cannot be reached
// "have it" stands in for the quantity. A read-only Supply run shows the
// same marks. An item toggled while the server cannot be reached
// says it will sync.
function ItemRow({ item, toggles }: { item: SupplyRunItem; toggles: Toggles | null }) {
const name = (
@@ -121,12 +121,13 @@ function ArchiveLink({ layout }: { layout: Breakpoint }) {
// one card per dinner, stacked on mobile and in three masonry columns from
// tablet up, where Archive and Send to Google Chat are actions in the
// header. On a current Supply run each item toggles at once (useToggles); a
// toggle refused puts the item back and says why. An archived one is
// read-only, and a toggle it refused is not offered again.
function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; timezone: string; layout: Breakpoint }) {
// toggle refused puts the item back and says why. An archived one, or any
// opened from the Supply archive (readOnly), is read-only and tagged
// Archived, and a toggle it refused is not offered again.
function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
const toggles = useToggles(weekStart)
const run = toggles.shown
const archived = run.status === 'archived'
const viewOnly = readOnly || run.status === 'archived'
const wide = layout !== 'mobile'
const failed = toggles.failed
const failedItem = failed && run.groups.flatMap((group) => group.items).find((item) => item.id === failed.toggle.id)
@@ -138,21 +139,23 @@ function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; tim
<p className={styles.kicker}>Supply run · {weekLabel(weekStart).dates}</p>
<div className={styles.titleRow}>
<h1 className={styles.title}>Shopping list</h1>
{archived && <Tag tone="grape">Archived</Tag>}
{viewOnly && <Tag tone="grape">Archived</Tag>}
<p className={styles.count}>
{run.needed_count} / {run.total_count}
</p>
</div>
<p className={styles.helper}>
{archived
{run.status === 'archived'
? '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>
</div>
{wide && (
<div className={styles.actions}>
<ArchiveLink layout={layout} />
{!archived && (
{!viewOnly && (
<Unavailable hint={CHAT_HINT} below className={styles.action}>
<Icon icon={MessageSquare} size="inline" />
Send to Google Chat
@@ -162,7 +165,7 @@ function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; tim
)}
</header>
{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”'}.{' '}
{failed.error.message}
</ErrorBanner>
@@ -173,7 +176,7 @@ function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; tim
) : (
<div className={styles.groups} data-testid="supply-run-groups">
{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>
)}
@@ -201,9 +204,12 @@ 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,
// 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 }) {
// when the week has none yet, the offer to make it from the week's plan —
// not offered read-only.
function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
const run = useSupplyRun(weekStart)
const generate = useGenerateSupplyRun(weekStart)
@@ -216,36 +222,42 @@ function WeekSupplyRun({ weekStart, timezone, layout }: { weekStart: string; tim
if (run.data === null) {
return (
<div className={`${styles.page} ${styles[layout]}`}>
<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>
{readOnly ? (
<EmptyState title="No shopping list">This week has no Supply run in the Supply archive.</EmptyState>
) : (
<EmptyState title="No shopping list yet" action={{ label: 'Make the shopping list', onClick: () => generate.mutate() }}>
I'll gather the ingredients of this week's planned dinners into a Supply run.
</EmptyState>
)}
{generate.isError && (
<ErrorBanner onRetry={() => generate.mutate()}>Couldn't make the shopping list. {generate.error.message}</ErrorBanner>
)}
</div>
)
}
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} />
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} readOnly={readOnly} />
}
// SupplyRunPage is the Supply run (mock 1l on mobile, 2b on tablet, 2f on
// desktop) of the week the Plan opens on — next week once this week's
// plan_draft time has come, otherwise this week — or of the week chosen by
// ?week=, such as a week in the Supply archive. Offline, where the settings
// cannot be read, it chooses the week by the schedule saved on this device.
export function SupplyRunPage() {
// ?week=. Opened from the Supply archive it is readOnly: tagged Archived,
// with no checkbox and nothing to make or send, whatever the week. Offline,
// where the settings cannot be read, it chooses the week by the schedule
// saved on this device.
export function SupplyRunPage({ readOnly = false }: { readOnly?: boolean }) {
const problem = useSavedProblem()
return (
<>
{problem !== null && (
<ErrorBanner>Changes to the shopping list can't be kept on this device while offline. {problem.message}</ErrorBanner>
)}
<ChosenSupplyRun />
<ChosenSupplyRun readOnly={readOnly} />
</>
)
}
function ChosenSupplyRun() {
function ChosenSupplyRun({ readOnly }: { readOnly: boolean }) {
const layout = useBreakpoint()
const settings = useHouseholdSettings()
const [params] = useSearchParams()
@@ -264,7 +276,7 @@ function ChosenSupplyRun() {
}
if (!schedule) return <SkeletonGroups layout={layout} />
const week = chosen ?? defaultWeek(schedule, new Date())
return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.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.
+82 -2
View File
@@ -12,6 +12,7 @@ import { ApiError, useApi, type ApiClient } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { meQueryKey } from '../AuthBootstrap.tsx'
import { oneOf } from '../dinners/api.ts'
import { settleUnmadePushes } from '../plan/api.ts'
import type { NetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS_KEY, type HouseholdSettings } from '../settings/api.ts'
import {
@@ -187,16 +188,21 @@ export function useSupplyRun(weekStart: string) {
// useGenerateSupplyRun makes the week's Supply run from its plan (POST
// /api/v1/shopping/{weekStart}/generate); the Supply run it answers with is
// the one shown.
// the one shown. Once made, from whichever screen, a push of the week whose
// Supply run was not made is settled (settleUnmadePushes).
export function useGenerateSupplyRun(weekStart: string) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
onMutate: () => settleUnmadePushes(queryClient, weekStart),
mutationFn: async (): Promise<SupplyRun> => {
const path = `${supplyRunPath(weekStart)}/generate`
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
},
onSuccess: (run) => queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run),
onSuccess: (run, _, settle) => {
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
settle()
},
})
}
@@ -497,3 +503,77 @@ function shown(run: SupplyRun, pending: Toggle[]): SupplyRun {
}))
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))
},
})
}
+19 -5
View File
@@ -1,7 +1,7 @@
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 } from 'vitest'
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'
@@ -211,6 +211,17 @@ 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}`) })
}
@@ -227,6 +238,7 @@ beforeEach(() => {
// 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(() => {
@@ -459,7 +471,7 @@ describe('offline toggle queue, replay', () => {
expect(patches(server.fetchMock)).toHaveLength(1)
expect(queue()).toEqual(['i3 true', 'i1 false'])
await advance(RETRY_FIRST)
await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
@@ -492,7 +504,7 @@ describe('offline toggle queue, replay', () => {
expect(patches(server.fetchMock)).toEqual([])
// The next try sends a fresh /me, which the server answers.
await advance(RETRY_FIRST)
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}`])
@@ -532,7 +544,7 @@ describe('offline toggle queue, replay', () => {
expect(queue()).toEqual(['i3 true', 'i1 false'])
// The next try is refused again and sends a fresh refresh, which answers.
await advance(RETRY_FIRST)
await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
@@ -555,8 +567,10 @@ describe('offline toggle queue, replay', () => {
fireEvent.click(checkbox('Corn tortillas'))
expect(await within(row('Corn tortillas')).findByText('will sync')).toBeInTheDocument()
reachable = true
expect(patches(fetchMock)).toEqual([])
await advance(RETRY_FIRST)
// 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}`])
+10
View File
@@ -24,6 +24,11 @@
margin: -2px;
}
.size52 {
width: 52px;
height: 52px;
}
.size62 {
width: 62px;
height: 62px;
@@ -56,6 +61,11 @@
height: 40px;
}
.size52 .face {
width: 52px;
height: 52px;
}
.size62 .face {
width: 62px;
height: 62px;
+7
View File
@@ -26,6 +26,13 @@ describe('IconButton', () => {
expect(hitArea(face)).toEqual({ width: 40, height: 40 })
})
it('is 52 px at the medium size', () => {
render(<IconButton label="Favorite" size={52} />)
const button = screen.getByRole('button', { name: 'Favorite' })
expectMinHitArea(button)
expect(hitArea(button)).toEqual({ width: 52, height: 52 })
})
it('is 62 px at the large size', () => {
render(<IconButton label="Try it" size={62} tone="teal" />)
const button = screen.getByRole('button', { name: 'Try it' })
+3 -2
View File
@@ -1,7 +1,7 @@
import type { ComponentPropsWithRef } from 'react'
import styles from './IconButton.module.css'
export type IconButtonSize = 40 | 62
export type IconButtonSize = 40 | 52 | 62
export type IconButtonTone = 'surface' | 'neutral' | 'accent' | 'teal'
export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'> & {
@@ -15,7 +15,8 @@ export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'
}
// Round icon button, named by its label and showing it only with showLabel.
// The 40 px size keeps a 44 px hit box (see the CSS); the 62 px size is the
// The 40 px size keeps a 44 px hit box (see the CSS); the 52 px size sits
// beside a 52 px pill button, as the recipe's heart; the 62 px size is the
// round action button with --shadow-md.
export function IconButton({
label,
+10
View File
@@ -83,6 +83,16 @@ describe('Segmented', () => {
for (const option of screen.getAllByRole('radio')) expectMinHitArea(option)
})
it('names an option by its ariaLabel when it has one', () => {
const sizes = [
{ value: 'small', label: 'A', ariaLabel: 'Small text' },
{ value: 'large', label: 'A', ariaLabel: 'Large text' },
] as const
render(<Segmented label="Text size" options={sizes} value="large" onValueChange={() => {}} />)
expect(radio('Small text')).toHaveTextContent('A')
expect(radio('Large text')).toHaveAttribute('aria-checked', 'true')
})
it('fails fast when the value is not one of the options', () => {
expect(() =>
render(<Segmented label="Archive period" options={options} value={'decade' as Period} onValueChange={() => {}} />),
+4
View File
@@ -4,6 +4,9 @@ import styles from './Segmented.module.css'
export type SegmentedOption<T extends string> = {
value: T
label: string
// Accessible name, when the visible label alone does not say what the
// option is (the A / A / A of a text size).
ariaLabel?: string
}
export type SegmentedProps<T extends string> = {
@@ -63,6 +66,7 @@ export function Segmented<T extends string>({ label, options, value, onValueChan
type="button"
role="radio"
aria-checked={checked}
aria-label={option.ariaLabel}
tabIndex={checked ? 0 : -1}
className={checked ? `${styles.option} ${styles.selected}` : styles.option}
onClick={() => select(index)}