Merge pull request 'Release: Feature 17 (Shopping List and Supply Archive)' (#142) from release/2026-10-08b into main
CI / ci (push) Successful in 4m50s

Reviewed-on: #142
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
This commit was merged in pull request #142.
This commit is contained in:
2026-10-08 18:15:48 -04:00
67 changed files with 9252 additions and 1666 deletions
+9 -55
View File
@@ -11,61 +11,14 @@ single Go service with the React PWA embedded in the binary.
> `/health`, embedded PWA skeleton, Makefile, Dockerfile, CI). The planned > `/health`, embedded PWA skeleton, Makefile, Dockerfile, CI). The planned
> application is described in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). > application is described in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## Quick start ## Running it locally
Requirements: Go 1.25 and Node.js `^22.22.2 || ^24.15.0 || >=26.0.0` — that The developer setup (prerequisites, Authentik and Google clients, the
is, 22.22.2 or later on the 22 line, 24.15.0 or later on the 24 line, or 26.0.0 `MEALQUEST_*` environment, `make dev` and the optional SearXNG sidecar) is
or later. Node.js 23, 24.0–24.14 and 25 are not supported: this is the runbook information and lives in CTXD: project `mealquest`, file
`engines` range of jsdom in the committed lockfile, the strictest of all the `RUNBOOKS.MD`, section "Local development". Configuration is read only from
web dependencies. the environment, and the service refuses to start, naming every missing or
invalid variable.
```sh
(cd web && npm ci)
mkdir -p ~/.mealquest-dev # outside the repository: it holds a secret
chmod 700 ~/.mealquest-dev
head -c 32 /dev/urandom | base64 > ~/.mealquest-dev/secret_key
chmod 600 ~/.mealquest-dev/secret_key
export MEALQUEST_HTTP_ADDR=:8080
export MEALQUEST_PUBLIC_URL=http://localhost:5173
export MEALQUEST_DATA_DIR="$HOME/.mealquest-dev"
export MEALQUEST_SHUTDOWN_TIMEOUT=10s
export MEALQUEST_SECRET_KEY_FILE="$HOME/.mealquest-dev/secret_key"
# Authentik OAuth2/OpenID provider for MealQuest (redirect URI:
# $MEALQUEST_PUBLIC_URL/auth/callback). Save its client secret to the file.
export MEALQUEST_OIDC_ISSUER=https://auth.example.com/application/o/mealquest/
export MEALQUEST_OIDC_CLIENT_ID=mealquest
export MEALQUEST_OIDC_CLIENT_SECRET_FILE="$HOME/.mealquest-dev/oidc_client_secret"
export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# Optional: sessions end after MEALQUEST_SESSION_IDLE unused (default 720h)
# and always MEALQUEST_SESSION_MAX after sign-in (default 4320h).
# export MEALQUEST_SESSION_IDLE=720h
# export MEALQUEST_SESSION_MAX=4320h
# Optional: comma-separated IPs/CIDRs of reverse proxies whose
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
# trusts no peer, which is right for local development.
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
# Optional: a SearXNG instance the Settings screen suggests as the recipe
# search URL until one is saved.
# export MEALQUEST_SEARXNG_URL=http://localhost:8888
export MEALQUEST_DEFAULT_TZ=America/New_York
make dev
```
`make dev` runs the Go server on `:8080` and the Vite dev server on `:5173`
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
http://localhost:5173. Configuration is environment-only and fails fast:
every variable above is required except the commented-out session
lifetimes (when set they must be positive, with MAX at least IDLE),
trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL
(an absolute `http://` or `https://` URL without credentials, a query or a
fragment) and `MEALQUEST_DEFAULT_TZ`, and
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
endpoints the issuer advertises must be `https://`; plain `http://` is
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
`MEALQUEST_DEFAULT_TZ` is the IANA zone the household settings are created
with on first boot: until those settings exist, boot fails unless it is a
valid IANA zone. Later boots ignore it, set or not (change the zone in
household settings instead).
## Common commands ## Common commands
@@ -78,7 +31,8 @@ household settings instead).
## Where to read next ## Where to read next
- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, conventions. - [AGENTS.md](AGENTS.md) — house rules, approved dependencies, the quality
floor every change must pass, repository layout, conventions.
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — architecture of record. - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — architecture of record.
- [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md) — product requirements. - [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md) — product requirements.
- [design/MealQuest UI Designs/design_handoff_mealquest/README.md](design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md) — UI handoff. - [design/MealQuest UI Designs/design_handoff_mealquest/README.md](design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md) — UI handoff.
+20 -2
View File
@@ -28,6 +28,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
@@ -132,6 +133,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err return err
} }
shopSvc, err := shopping.NewService(db, archiveTitler(ai, guard), runner, logger)
if err != nil {
return err
}
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil { if err != nil {
return err return err
@@ -148,7 +154,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err return err
} }
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, googleSvc, runner, photos, frontend.Assets()) router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, runner, photos, frontend.Assets())
if err != nil { if err != nil {
return err return err
} }
@@ -274,7 +280,7 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
// service registers its own kinds when it is built, before the runner // service registers its own kinds when it is built, before the runner
// starts (dinners.NewService registers dinners.KindGroundDinner and // starts (dinners.NewService registers dinners.KindGroundDinner and
// suggest.NewService suggest.KindSuggestionBatch, plan.NewService // suggest.NewService suggest.KindSuggestionBatch, plan.NewService
// plan.KindDraftWeek). // plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun).
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) { func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
builtins, err := jobs.RegisterBuiltins(runner) builtins, err := jobs.RegisterBuiltins(runner)
if err != nil { if err != nil {
@@ -319,6 +325,18 @@ func weekDrafter(ai *aisettings.Service, guard *harness.Guard) plan.Drafter {
} }
} }
// archiveTitler runs archive_title with the AI settings current at each
// run, sharing guard.
func archiveTitler(ai *aisettings.Service, guard *harness.Guard) shopping.Titler {
return func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
cfg, err := ai.HarnessConfig(ctx, guard)
if err != nil {
return skills.ArchiveTitle{}, err
}
return skills.TitleArchive(ctx, cfg, scope, in)
}
}
// logListening logs the startup line. Only the public URL's origin is logged // logListening logs the startup line. Only the public URL's origin is logged
// so no credential-bearing URL component can reach the log. // so no credential-bearing URL component can reach the log.
func logListening(logger *slog.Logger, cfg config.Config) { func logListening(logger *slog.Logger, cfg config.Config) {
+18 -23
View File
@@ -1,20 +1,15 @@
# MealQuest production environment. Copy to deploy/.env (never committed) # MealQuest production environment: the template for deploy/.env (never
# and replace the placeholders. The service fails fast: a missing or invalid # committed). The service fails fast: a missing or invalid required variable
# required variable stops it at boot with the variable named. # stops it at boot with the variable named. Creating deploy/.env: CTXD
# project mealquest, RUNBOOKS.MD "Stack and first deploy" > "3. Environment
# file".
# #
# Secrets are never set here. They live in deploy/secrets/ (never committed), # Secrets are never set here. They live in deploy/secrets/ (never committed),
# one value per file, owned by the container user and readable only by it: # one value per file, owned by the container user 10001:10001 with mode
# # 0600: secret_key, oidc_client_secret and google_client_secret. SearXNG's
# mkdir -p deploy/secrets && chmod 700 deploy/secrets # searxng.env is the exception: compose reads it itself, so it is owned by
# head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key # the host account that runs compose, mode 0600. Creating them: CTXD project
# printf '%s\n' '<Authentik client secret>' > deploy/secrets/oidc_client_secret # mealquest, RUNBOOKS.MD "Stack and first deploy" > "4. Secrets".
# printf '%s\n' '<Google OAuth client secret>' > deploy/secrets/google_client_secret
# sudo chown 10001:10001 deploy/secrets/* && sudo chmod 600 deploy/secrets/*
#
# SearXNG's secret key is the exception: compose reads it itself, so it
# stays owned by you (mode 0600). Create it after the chown above:
#
# (umask 077; printf 'SEARXNG_SECRET=%s\n' "$(openssl rand -hex 32)" > deploy/secrets/searxng.env)
# #
# deploy/compose.yaml mounts them at /run/secrets/<name> and points the # deploy/compose.yaml mounts them at /run/secrets/<name> and points the
# *_FILE variables at them. # *_FILE variables at them.
@@ -42,8 +37,7 @@ MEALQUEST_TRUSTED_PROXY=10.60.1.33
# IANA zone the household settings are created with on first boot. Required # IANA zone the household settings are created with on first boot. Required
# until those settings exist; ignored afterwards (change the zone in # until those settings exist; ignored afterwards (change the zone in
# household settings instead). Confirm the household's zone before the first # household settings instead).
# deploy.
MEALQUEST_DEFAULT_TZ=America/New_York MEALQUEST_DEFAULT_TZ=America/New_York
# --- Authentik OIDC ---------------------------------------------------------- # --- Authentik OIDC ----------------------------------------------------------
@@ -57,10 +51,10 @@ MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# --- Google Calendar (Push to Calendar) ------------------------------------ # --- Google Calendar (Push to Calendar) ------------------------------------
# Client ID of the Google OAuth web client (deploy/RUNBOOK.md "Google # Client ID of the Google OAuth web client (CTXD project mealquest,
# Calendar OAuth client"). Required: the service will not start without it # RUNBOOKS.MD "Google Calendar OAuth client"). Required: the service will
# and the client secret file. Its authorized redirect URI is # not start without it and the client secret file. Its authorized redirect
# <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback. # URI is <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
# Optional session lifetimes. A session ends after IDLE unused and always MAX # Optional session lifetimes. A session ends after IDLE unused and always MAX
@@ -82,13 +76,14 @@ MEALQUEST_SESSION_MAX=4320h
# The SearXNG recipe-search sidecar on the compose network. The Settings # The SearXNG recipe-search sidecar on the compose network. The Settings
# screen pre-fills it as the SearXNG URL until one is saved; the saved # screen pre-fills it as the SearXNG URL until one is saved; the saved
# provider and base URL come from PATCH /api/v1/settings/recipe-search. # provider and base URL come from PATCH /api/v1/settings/recipe-search.
# See deploy/RUNBOOK.md "Recipe search (SearXNG)". # See CTXD project mealquest, RUNBOOKS.MD "Recipe search (SearXNG)".
# --- Planned; not read by the service yet ------------------------------------- # --- Planned; not read by the service yet -------------------------------------
# #
# Added to deploy/compose.yaml and this file by the features that use them. # Added to deploy/compose.yaml and this file by the features that use them.
# #
# Web Push (Feature 19). Generate the key pair with cmd/vapidgen. # Web Push (Feature 19). Key pair: CTXD project mealquest, RUNBOOKS.MD
# "VAPID keys (Web Push)".
# MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key> # MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key # MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
# MEALQUEST_VAPID_SUBJECT=mailto:<owner address> # MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
-1417
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -1,8 +1,7 @@
# Development override: publishes SearXNG on the dev machine's loopback so # Development override layered on deploy/compose.yaml: publishes SearXNG
# it can be queried directly, e.g. # on the dev machine's loopback (127.0.0.1:8888) so it can be queried
# # directly. Using it: CTXD project mealquest, RUNBOOKS.MD "Local
# docker compose -f deploy/compose.yaml -f deploy/compose.dev.yaml up -d searxng # development".
# curl 'http://127.0.0.1:8888/search?q=chicken+tikka+masala+recipe&format=json'
# #
# Loopback only, never a LAN address. Never used in production: the # Loopback only, never a LAN address. Never used in production: the
# systemd unit starts deploy/compose.yaml alone. # systemd unit starts deploy/compose.yaml alone.
+10 -12
View File
@@ -1,18 +1,16 @@
# Production stack for MealQuest on 10.60.1.14 (D-018). Build on the host, # Production stack for MealQuest on 10.60.1.14 (D-018). Building, deploying
# then start through systemd: # and operating it: CTXD project mealquest, RUNBOOKS.MD "Stack unit" and
# # "Routine deploy and rollback".
# docker compose -f deploy/compose.yaml build
# sudo systemctl restart mealquest
# #
# The services use restart: "no" on purpose: Docker must not start them # The services use restart: "no" on purpose: Docker must not start them
# before the DOCKER-USER firewall is applied. deploy/systemd/mealquest.service # before the DOCKER-USER firewall is applied. deploy/systemd/mealquest.service
# owns start and stop and requires mealquest-firewall.service first. # owns start and stop and requires mealquest-firewall.service first.
# #
# Configuration comes from deploy/.env (copy deploy/.env.example) and secrets # Configuration comes from deploy/.env (template: deploy/.env.example) and
# from deploy/secrets/; neither is committed. Compose bind-mounts file secrets # secrets from deploy/secrets/; neither is committed. Compose bind-mounts
# with their host owner and mode, so each file in deploy/secrets/ must be # file secrets with their host owner and mode, so each file in
# owned by the container user 10001:10001 with mode 0600 (except # deploy/secrets/ must be owned by the container user 10001:10001 with mode
# searxng.env, read by compose itself; see the searxng service). # 0600 (except searxng.env, read by compose itself; see the searxng service).
name: mealquest name: mealquest
services: services:
@@ -65,8 +63,8 @@ services:
# the app's own user (owner of the volume) with a read-only root # the app's own user (owner of the volume) with a read-only root
# filesystem, no privilege escalation and no Linux capabilities. # filesystem, no privilege escalation and no Linux capabilities.
# #
# Restore: follow "Restore the database" in deploy/RUNBOOK.md (stop the # Restoring the database: CTXD project mealquest, RUNBOOKS.MD "Restore
# stack, move the database with its -wal and -shm aside, restore, check). # the database".
litestream: litestream:
image: litestream/litestream:0.5.17 image: litestream/litestream:0.5.17
user: "10001:10001" user: "10001:10001"
+3 -2
View File
@@ -5,8 +5,9 @@
# are one-way: a failure here fails this unit visibly and never fails # are one-way: a failure here fails this unit visibly and never fails
# docker.service. # docker.service.
# #
# Start MealQuest only after this unit has succeeded: # mealquest.service requires this unit and starts the stack only after it
# systemctl start mealquest-firewall && docker compose up -d # has succeeded. Installing and operating both: CTXD project mealquest,
# RUNBOOKS.MD "Host firewall" and "Stack unit".
[Unit] [Unit]
Description=Restrict MealQuest port 8090 to Traefik (DOCKER-USER) Description=Restrict MealQuest port 8090 to Traefik (DOCKER-USER)
After=docker.service After=docker.service
@@ -1,8 +1,7 @@
# Runs the nightly MealQuest media backup (/usr/local/sbin/mealquest-media-backup). # Runs the nightly MealQuest media backup (/usr/local/sbin/mealquest-media-backup).
# Triggered by mealquest-media-backup.timer; run by hand with # Triggered by mealquest-media-backup.timer; output goes to the journal.
# systemctl start mealquest-media-backup # Installing it and running it by hand: CTXD project mealquest,
# and read the result with # RUNBOOKS.MD "Media — nightly rsync".
# journalctl -u mealquest-media-backup
# #
# The script runs as the app's user (10001, host user "mealquest") with no # The script runs as the app's user (10001, host user "mealquest") with no
# capabilities. systemd, as root, only bind-mounts the data volume's root # capabilities. systemd, as root, only bind-mounts the data volume's root
+3 -2
View File
@@ -15,8 +15,9 @@
# swapping the path afterwards cannot redirect it. Symlinks inside the # swapping the path afterwards cannot redirect it. Symlinks inside the
# directory are copied as links (-a), never followed. # directory are copied as links (-a), never followed.
# #
# The media directory is created at deploy time (deploy/RUNBOOK.md). If it # The media directory is created at deploy time (CTXD project mealquest,
# is missing the job fails; it is never treated as "nothing to back up". # RUNBOOKS.MD "Media — nightly rsync"). If it is missing the job fails; it
# is never treated as "nothing to back up".
# #
# rsync mirrors into .../mealquest-backups/media/ only: the target ends in # rsync mirrors into .../mealquest-backups/media/ only: the target ends in
# a slash, so --delete removes files from that directory and nothing # a slash, so --delete removes files from that directory and nothing
+93 -5
View File
@@ -102,8 +102,8 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at, revision (raised by every swap and stored draft). | | `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at, revision (raised by every swap and stored draft). |
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). | | `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). |
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. | | `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. |
| `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. | | `shopping_lists` | week_start (unique), plan_id, title (the AI-written post title; NULL until written or when the AI fails, when the week label is shown instead; never empty), status (`current` / `archived`), sent_to_chat_at, frozen_at. |
| `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. | | `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by `shopping.Generate` from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged. |
| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. | | `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
| `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url (the SearXNG instance), api_key_ct and api_key_last4 (Brave). | | `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url (the SearXNG instance), api_key_ct and api_key_last4 (Brave). |
| `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. | | `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. |
@@ -470,6 +470,51 @@ automatic** (D-021).
not a Regular; and one whose reason is over 180 characters or names none not a Regular; and one whose reason is over 180 characters or names none
of its cited Regulars. Cited names are rewritten as the household writes of its cited Regulars. Cited names are rewritten as the household writes
them. Grounding each survivor in a recipe is the caller's next step. them. Grounding each survivor in a recipe is the caller's next step.
- **Supply run titles** (`skills.TitleArchive`, skill `archive_title`
v1): from the names of the week's planned dinners in day order, writes
`{title}`, the Supply run's post title in the Supply archive, at the
household temperature with no household context. A title must be 1–48
characters on one line, with no emoji and no surrounding quotation
marks; a reply breaking a rule gets the one repair turn, never a
truncation. `shopping.Generate` (and `GeneratePushed`), on storing a
list that is still untitled — a new list, or one a failed title left
untitled — enqueues a `title_supply_run` job in the generation's
transaction and returns without waiting for it, so neither Push to
Calendar nor `POST /shopping/{weekStart}/generate` waits for a model
call; the list reads untitled until the job stores its title. The job's
scope is `<weekStart>@<plan revision>/<sha256 of the dinner names in
order>`, so runs share a title only for the same names (a renamed dinner
changes the names, not the revision). It writes `shopping_lists.title`
only while the list is untitled and not frozen. A title is written once;
a remake keeps it and enqueues no job. A failed title never fails the
generation: the list stays untitled (the UI shows the week label), the
failure is logged as a warning and recorded in `ai_runs`, the job fails
`title_failed`, and the week's next generation tries again.
`shopping.Freeze` titles a list still untitled once more before
freezing it (see Supply archive), through the same skill, scope and
conditional write.
## Supply archive (Feature 17)
`shopping.Freeze(weekStart)` moves a week's Supply run into the Supply
archive: `status` becomes `archived` and `frozen_at` is set, after which
generating the list or changing an item is 409 `archived`. It freezes only
a week that has ended in the household's time zone (its Monday is before
the Monday of the household's current week), so a week not yet ended is
`ErrWeekNotEnded` and a week with no list `ErrListNotFound`; freezing a
frozen list does nothing. A list still untitled is titled first with
`archive_title` from the week's plan, as `Generate` titles it; a title
that cannot be written is logged and the list is frozen untitled, when its
week label stands in for good. Titling and freezing are ordered by a
per-service `RWMutex`: a `title_supply_run` job holds it shared across
reading the list, the `archive_title` run and the title write, and `Freeze`
holds it exclusively from reading the list to freezing it. So a freeze
waits for a job in flight to store its title or fail for good; when the
job failed, the freeze finds the list still untitled and makes its own
`archive_title` attempt before freezing. A job that starts during a freeze
waits for it, then finds the list titled (that title stands) or frozen
untitled (the job fails `title_failed`), and stores nothing. The
`week_rollover` schedule is to call `Freeze` for last week.
## Recipe grounding ## Recipe grounding
@@ -738,9 +783,52 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
failures), 404 when the week failures), 404 when the week
has no plan, 400 `invalid_week`. The push holds the account lock has no plan, 400 `invalid_week`. The push holds the account lock
across its Google requests and its record, like listing and choosing. across its Google requests and its record, like listing and choosing.
- The week's Supply run (Feature 17) is made from a pushed plan: a - The week's Supply run (Feature 17) is made from a pushed plan: after
successful `Push` returns that plan, which is where Feature 17 hooks a successful `Push`, the handler runs `shopping.GeneratePushed` for
in. Nothing here generates a shopping list. the week at the plan revision `Push` pushed, and the 200 answer
carries `supply_run: {status, error}`. `status` is `generated`;
`archived` when the week's Supply run is in the Supply archive and was
left as it was; or `failed` — error `plan_changed` (retryable, logged
as a warning) when the plan was edited between the push and
generation, so no Supply run is made from a plan Calendar does not
show and pushing again makes it; or error `supply_run_failed`
(retryable, logged as an error) when it could not be made, and
`POST /api/v1/shopping/{weekStart}/generate` makes it again.
Generation checks the revision inside its own transaction, and never
fails or undoes the push — Calendar already shows the plan.
## Supply run API (Feature 17)
- `GET /api/v1/shopping/{weekStart}` returns `{weekStart, label, title,
status, sent_to_chat_at, needed_count, total_count, groups: [{dinner:
{id, name}, date, day, items: [{id, name, qty, needed}]}]}`: `label` is
the week label (shown while `title` is null), `day` the plan day's
weekday ("Monday"), `qty` null when the recipe gives none,
`needed_count` the items still needed and `total_count` all items. 404
when the week has no Supply run, 400 `invalid_week`.
- `POST /api/v1/shopping/{weekStart}/generate` makes or remakes the list
through `shopping.Generate` and answers with the same shape plus
`skipped: [{date, dinner_id, dinner_name, reason}]`. 404 when the week
has no plan, 409 `archived` when its list is in the Supply archive.
- `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required
boolean; it sets the flag, it does not flip it) and answers `{id,
needed, needed_count, total_count}` with the list's counts after the
change. 404 for an unknown item, 409 `archived` when the list is in the
Supply archive, 400 `invalid_request` for a bad body.
- `GET /api/v1/shopping/archive?by=week|month|year` returns the Supply
archive `{by, periods: [{key, heading, posts: [{weekStart, label,
title, dinnerCount, itemCount, onHandCount, dinners, current}]}]}`: the
Supply run of every week up to the household's current week, newest
first, grouped by the Monday's ISO week (`key` "2026-W41", `heading` the
week label), month ("2026-10", "October 2026") or year ("2026"). The
Monday is a household-local date, so the week of Monday 28 December
is in December. `label` is the week label ("Week 41 · Oct 5 – 11"; the
UI sets it in capitals), `title` null while untitled, `dinners` the
list's dinners in day order and `dinnerCount` their number,
`itemCount` all items, `onHandCount` the items marked "have it", and
`current` true for the household's current week. 400 `invalid_request`
for any other `by`. A frozen list is read through `GET
/api/v1/shopping/{weekStart}` with `status` `archived`.
## Frontend ## Frontend
+148
View File
@@ -0,0 +1,148 @@
package skills
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// TitleLimit is the most characters a Supply run's title may have.
const TitleLimit = 48
// ArchiveTitleRequest is what archive_title reads.
type ArchiveTitleRequest struct {
// Dinners are the names of the week's planned dinners in day order,
// Monday first. It must hold at least one, none blank.
Dinners []string
}
// ArchiveTitle is archive_title's output.
type ArchiveTitle struct {
// Title is the Supply run's post title in the Supply archive: 1 to
// TitleLimit characters on one line, with no emoji and no surrounding
// quotation marks.
Title string `json:"title"`
}
// TitleArchive runs archive_title v1 on in and returns the title of the
// week's Supply run, written from its dinners. A request with no dinners,
// or a blank one, fails before any LLM call. scope names the week and its
// dinners (see harness.Run).
func TitleArchive(ctx context.Context, cfg harness.Config, scope string, in ArchiveTitleRequest) (ArchiveTitle, error) {
return harness.Run(ctx, cfg, archiveTitle{}, scope, in)
}
// archiveTitle is the skill.
type archiveTitle struct{}
var _ harness.Skill[ArchiveTitleRequest, ArchiveTitle] = archiveTitle{}
func (archiveTitle) Name() string { return "archive_title" }
func (archiveTitle) Version() int { return 1 }
func (archiveTitle) Timeout() time.Duration { return harness.DefaultTimeout }
// archiveTitlePrompt is the template data of archive_title.v1.tmpl.
type archiveTitlePrompt struct {
// Dinners are the week's dinner names in day order, each on one line.
Dinners []string
}
func (archiveTitle) PromptInput(in ArchiveTitleRequest) (harness.PromptInput, error) {
if len(in.Dinners) == 0 {
return harness.PromptInput{}, errors.New("the week has no dinners to title it from")
}
var p archiveTitlePrompt
for i, name := range in.Dinners {
name = oneLine(name)
if name == "" {
return harness.PromptInput{}, fmt.Errorf("dinner %d has a blank name", i+1)
}
p.Dinners = append(p.Dinners, name)
}
return harness.PromptInput{Data: p}, nil
}
func (archiveTitle) Schema() llm.JSONSchema {
return llm.JSONSchema{Name: "archive_title", Schema: json.RawMessage(`{
"type": "object",
"properties": {
"title": {"type": "string"}
},
"required": ["title"],
"additionalProperties": false
}`)}
}
// UnmarshalJSON decodes the output, requiring its field as the schema
// does.
func (t *ArchiveTitle) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "title"); err != nil {
return fmt.Errorf("output %s", err)
}
type plain ArchiveTitle
return json.Unmarshal(data, (*plain)(t))
}
// quotes are the quotation marks a title may not start or end with.
const quotes = `"'` + "`" + "“”‘’„‚«»‹›「」『』"
// Validate finds every broken rule of the title as PostProcess will store
// it, on one line: empty, over TitleLimit characters, holding an emoji, or
// starting or ending with a quotation mark.
func (archiveTitle) Validate(out ArchiveTitle) []string {
var problems []string
title := oneLine(out.Title)
switch n := utf8.RuneCountInString(title); {
case n == 0:
problems = append(problems, fmt.Sprintf("title is empty; give a title of 1 to %d characters", TitleLimit))
case n > TitleLimit:
problems = append(problems, fmt.Sprintf("title %q has %d characters; shorten it to at most %d", title, n, TitleLimit))
}
for _, r := range title {
if isEmoji(r) {
problems = append(problems, fmt.Sprintf("title %q has an emoji (%U); use words only", title, r))
break
}
}
if first, _ := utf8.DecodeRuneInString(title); title != "" && strings.ContainsRune(quotes, first) {
problems = append(problems, fmt.Sprintf("title %q starts with a quotation mark; give the title without quotation marks around it", title))
} else if last, _ := utf8.DecodeLastRuneInString(title); title != "" && strings.ContainsRune(quotes, last) {
problems = append(problems, fmt.Sprintf("title %q ends with a quotation mark; give the title without quotation marks around it", title))
}
return problems
}
// isEmoji reports whether r is an emoji or a part of one: a pictograph,
// a symbol from the emoji blocks, a regional indicator, a keycap, a skin
// tone, an emoji variation selector or the zero-width joiner.
func isEmoji(r rune) bool {
switch {
case r >= 0x1F000 && r <= 0x1FAFF: // tiles, cards, enclosed, pictographs, emoticons, transport, supplemental symbols
return true
case r >= 0x2600 && r <= 0x27BF: // miscellaneous symbols and dingbats
return true
case r >= 0x2B00 && r <= 0x2BFF: // arrows and stars such as ⭐ and ⬛
return true
case r >= 0x2300 && r <= 0x23FF: // technical symbols such as ⌚ and ⏰
return true
case r >= 0xE0020 && r <= 0xE007F: // tag sequences of flags
return true
case r == 0x200D, r == 0x20E3, r == 0xFE0F: // joiner, keycap, emoji presentation
return true
}
return false
}
// PostProcess puts the title on one line, collapsing its white space.
func (archiveTitle) PostProcess(out ArchiveTitle) (ArchiveTitle, error) {
return ArchiveTitle{Title: oneLine(out.Title)}, nil
}
+170
View File
@@ -0,0 +1,170 @@
package skills
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// titleWeek is a week of seven dinners, Monday first.
var titleWeek = ArchiveTitleRequest{Dinners: []string{
"Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Pad Thai", "Fish Tacos",
}}
func titleReply(title string) llm.GenerateResult {
return reply(fmt.Sprintf(`{"title":%q}`, title))
}
// title runs TitleArchive on in with f and checks f was called exactly
// calls times, each at the household temperature with the archive_title
// schema.
func title(t *testing.T, f *llm.Fake, in ArchiveTitleRequest, calls int) (ArchiveTitle, []llm.Request, error) {
t.Helper()
out, err := TitleArchive(context.Background(), testConfig(t, f), "2026-10-12", in)
reqs := f.Requests()
if len(reqs) != calls {
t.Fatalf("Generate called %d times, want %d", len(reqs), calls)
}
if g, _, _ := f.Remaining(); g != 0 {
t.Fatalf("%d scripted Generate results unused", g)
}
for i, req := range reqs {
if req.Temperature == nil || *req.Temperature != 0.7 {
t.Fatalf("request %d temperature = %v, want 0.7", i+1, req.Temperature)
}
if req.Schema == nil || req.Schema.Name != "archive_title" {
t.Fatalf("request %d schema = %v, want archive_title", i+1, req.Schema)
}
}
return out, reqs, err
}
func TestArchiveTitleValid(t *testing.T) {
var f llm.Fake
f.QueueGenerate(titleReply(" Slow Simmers\nand Taco Night "))
out, reqs, err := title(t, &f, titleWeek, 1)
if err != nil {
t.Fatal(err)
}
if out.Title != "Slow Simmers and Taco Night" {
t.Fatalf("title = %q", out.Title)
}
system, user := reqs[0].System, reqs[0].Messages[0].Content
golden(t, "archive_title_valid.system.golden", system)
golden(t, "archive_title_valid.user.golden", user)
want := "Title the week whose dinners are, in day order from Monday:\n" +
"- Beef Chili\n- Pot Roast\n- Chicken Fajitas\n- Spaghetti Bolognese\n- Sheet Pan Salmon\n- Pad Thai\n- Fish Tacos"
if user != want {
t.Errorf("user message = %q, want %q", user, want)
}
if !strings.Contains(system, "## Output contract") || !strings.Contains(system, `"title"`) {
t.Error("system prompt lacks the output contract")
}
}
// titleRepaired checks a first reply is repaired once, quoting it and the
// problem wanted.
func titleRepaired(t *testing.T, first, want string) {
t.Helper()
var f llm.Fake
f.QueueGenerate(titleReply(first), titleReply("Chili, Roast and Taco Night"))
out, reqs, err := title(t, &f, titleWeek, 2)
if err != nil {
t.Fatal(err)
}
if out.Title != "Chili, Roast and Taco Night" {
t.Fatalf("title = %q", out.Title)
}
repair := reqs[1].Messages
if len(repair) != 3 || repair[1].Content != fmt.Sprintf(`{"title":%q}`, first) {
t.Fatalf("repair turn does not quote the first reply: %+v", repair)
}
if !strings.Contains(repair[2].Content, want) {
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, want)
}
}
func TestArchiveTitleTooLongRepaired(t *testing.T) {
long := "A Week of Slow Simmers, Sizzling Fajitas and Taco Night" // 55 characters
titleRepaired(t, long, fmt.Sprintf("title %q has 55 characters; shorten it to at most 48", long))
}
func TestArchiveTitleEmojiRepaired(t *testing.T) {
titleRepaired(t, "Taco Night 🌮", `title "Taco Night 🌮" has an emoji (U+1F32E); use words only`)
}
func TestArchiveTitleQuotedRepaired(t *testing.T) {
titleRepaired(t, `"Taco Night"`, `title "\"Taco Night\"" starts with a quotation mark; give the title without quotation marks around it`)
}
func TestArchiveTitleEmojiTwiceFails(t *testing.T) {
var f llm.Fake
f.QueueGenerate(titleReply("Chili Week 🔥"), titleReply("Chili Week ❤️"))
out, _, err := title(t, &f, titleWeek, 2)
var bad *harness.BadOutputError
if !errors.As(err, &bad) || !errors.Is(err, llm.ErrBadOutput) {
t.Fatalf("err = %v, want a BadOutputError", err)
}
if !slices.Equal(bad.Repair, []string{`title "Chili Week ❤️" has an emoji (U+2764); use words only`}) {
t.Fatalf("repair problems = %q", bad.Repair)
}
if out.Title != "" {
t.Fatalf("title = %q, want none", out.Title)
}
}
func TestArchiveTitleRules(t *testing.T) {
s := archiveTitle{}
for _, tc := range []struct {
title string
want []string
}{
{"Taco Night", nil},
{strings.Repeat("a", TitleLimit), nil},
{"Crème Brûlée & Co.", nil},
{"Fajitas — Then Tacos", nil},
{"Mom's Pot Roast Week", nil},
{" Spaced out ", nil},
{"", []string{"title is empty; give a title of 1 to 48 characters"}},
{" \n ", []string{"title is empty; give a title of 1 to 48 characters"}},
{strings.Repeat("é", TitleLimit+1), []string{fmt.Sprintf("title %q has 49 characters; shorten it to at most 48", strings.Repeat("é", TitleLimit+1))}},
{"Hot ☕ Week", []string{`title "Hot ☕ Week" has an emoji (U+2615); use words only`}},
{"Star ⭐ Week", []string{`title "Star ⭐ Week" has an emoji (U+2B50); use words only`}},
{"Go 🇮🇹", []string{`title "Go 🇮🇹" has an emoji (U+1F1EE); use words only`}},
{"‘Taco Night’", []string{`title "‘Taco Night’" starts with a quotation mark; give the title without quotation marks around it`}},
{"Taco Night”", []string{`title "Taco Night”" ends with a quotation mark; give the title without quotation marks around it`}},
{"«Pasta» 🍝 " + strings.Repeat("x", 45), []string{
fmt.Sprintf("title %q has 55 characters; shorten it to at most 48", "«Pasta» 🍝 "+strings.Repeat("x", 45)),
fmt.Sprintf("title %q has an emoji (U+1F35D); use words only", "«Pasta» 🍝 "+strings.Repeat("x", 45)),
fmt.Sprintf("title %q starts with a quotation mark; give the title without quotation marks around it", "«Pasta» 🍝 "+strings.Repeat("x", 45)),
}},
} {
if got := s.Validate(ArchiveTitle{Title: tc.title}); !slices.Equal(got, tc.want) {
t.Errorf("Validate(%q) =\n%q\nwant\n%q", tc.title, got, tc.want)
}
}
}
func TestArchiveTitleBadRequestFailsBeforeCall(t *testing.T) {
for name, tc := range map[string]struct {
in ArchiveTitleRequest
want string
}{
"no dinners": {ArchiveTitleRequest{}, "the week has no dinners to title it from"},
"blank dinner": {ArchiveTitleRequest{Dinners: []string{"Pad Thai", " \n"}}, "dinner 2 has a blank name"},
} {
t.Run(name, func(t *testing.T) {
var f llm.Fake
_, _, err := title(t, &f, tc.in, 0)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want one containing %q", err, tc.want)
}
})
}
}
@@ -0,0 +1,22 @@
{{/* archive_title titles a week's Supply run for the Supply archive, from the week's dinners. */}}
{{define "instructions" -}}
You title the household's Supply run, the week's shopping list, as it is filed in the Supply archive: a blog-style history of the household's weeks of dinners. Write one short, warm, playful post title for the week, drawn from its dinners.
Rules:
- At most 48 characters, on one line.
- Words only: no emoji.
- No quotation marks around the title.
- Do not name a date or week number; the archive shows those.
Give:
- title: the title.
One valid reply, for a week of Beef Chili, Pot Roast and Fish Tacos (an example only, not about this household):
{"title": "Slow Simmers and Taco Night"}
{{- end}}
{{define "user" -}}
Title the week whose dinners are, in day order from Monday:
{{- range .Dinners}}
- {{.}}
{{- end}}
{{- end}}
+1
View File
@@ -5,3 +5,4 @@ echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce
normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32 draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32
archive_title v1 b4f30562a8614dac09a69d512dc361883b917ee4228a0ce3b4583c7fd3f6101a
@@ -0,0 +1,33 @@
## Instructions
You title the household's Supply run, the week's shopping list, as it is filed in the Supply archive: a blog-style history of the household's weeks of dinners. Write one short, warm, playful post title for the week, drawn from its dinners.
Rules:
- At most 48 characters, on one line.
- Words only: no emoji.
- No quotation marks around the title.
- Do not name a date or week number; the archive shows those.
Give:
- title: the title.
One valid reply, for a week of Beef Chili, Pot Roast and Fish Tacos (an example only, not about this household):
{"title": "Slow Simmers and Taco Night"}
## Household context
No household context.
## Output contract
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "archive_title" below, and nothing else: no prose and no Markdown code fences.
{
"type": "object",
"properties": {
"title": {
"type": "string"
}
},
"required": [
"title"
],
"additionalProperties": false
}
@@ -0,0 +1,8 @@
Title the week whose dinners are, in day order from Monday:
- Beef Chili
- Pot Roast
- Chicken Fajitas
- Spaghetti Bolognese
- Sheet Pan Salmon
- Pad Thai
- Fish Tacos
+2 -1
View File
@@ -258,6 +258,7 @@ type ShoppingGroup struct {
ID string ID string
ListID string ListID string
DinnerID string DinnerID string
Date string
Position int64 Position int64
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
@@ -278,7 +279,7 @@ type ShoppingList struct {
ID string ID string
WeekStart string WeekStart string
PlanID string PlanID string
Title string Title sql.NullString
Status string Status string
SentToChatAt sql.NullString SentToChatAt sql.NullString
FrozenAt sql.NullString FrozenAt sql.NullString
+37
View File
@@ -43,6 +43,11 @@ type Querier interface {
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
CreateRotationReview(ctx context.Context, arg CreateRotationReviewParams) error CreateRotationReview(ctx context.Context, arg CreateRotationReviewParams) error
CreateShoppingGroup(ctx context.Context, arg CreateShoppingGroupParams) error
// A new item is needed until the household marks it "have it".
CreateShoppingItem(ctx context.Context, arg CreateShoppingItemParams) error
// A new, untitled Supply run: the title is written later, by the AI.
CreateShoppingList(ctx context.Context, arg CreateShoppingListParams) error
// name is the name the dish is suggested under, kept whatever its dinner is // name is the name the dish is suggested under, kept whatever its dinner is
// later renamed. // later renamed.
CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error
@@ -59,6 +64,8 @@ type Querier interface {
DeleteIntegrationsGoogle(ctx context.Context) error DeleteIntegrationsGoogle(ctx context.Context) error
DeleteRecipeIngredients(ctx context.Context, recipeID string) error DeleteRecipeIngredients(ctx context.Context, recipeID string) error
DeleteRecipeSteps(ctx context.Context, recipeID string) error DeleteRecipeSteps(ctx context.Context, recipeID string) error
DeleteShoppingGroup(ctx context.Context, id string) error
DeleteShoppingItem(ctx context.Context, id string) error
FailJob(ctx context.Context, arg FailJobParams) (int64, error) FailJob(ctx context.Context, arg FailJobParams) (int64, error)
// Fails every unarchived dinner still pending whose grounding job for its // Fails every unarchived dinner still pending whose grounding job for its
// current grounding_revision is no longer queued or running: the process // current grounding_revision is no longer queued or running: the process
@@ -80,6 +87,9 @@ type Querier interface {
// Records a pending or running batch as done or failed; a batch already // Records a pending or running batch as done or failed; a batch already
// finished is left as it is (0 rows). // finished is left as it is (0 rows).
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error) FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
// Freezes list id into the Supply archive at frozen_at, unless it is
// frozen already.
FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error)
// Returns sql.ErrNoRows until the row exists. // Returns sql.ErrNoRows until the row exists.
GetAISettings(ctx context.Context) (AiSetting, error) GetAISettings(ctx context.Context) (AiSetting, error)
// The job of kind queued or running for the week starting week_start, // The job of kind queued or running for the week starting week_start,
@@ -113,6 +123,12 @@ type Querier interface {
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error) GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
// Returns sql.ErrNoRows until the row exists. // Returns sql.ErrNoRows until the row exists.
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
// The Supply run holding item id. Returns sql.ErrNoRows when there is no
// such item.
GetShoppingItemList(ctx context.Context, id string) (GetShoppingItemListRow, error)
// The Supply run of the week starting week_start. Returns sql.ErrNoRows
// when there is none.
GetShoppingList(ctx context.Context, weekStart string) (ShoppingList, error)
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id. // A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error) GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error)
// The id of the job that runs batch batch_id. // The id of the job that runs batch batch_id.
@@ -164,6 +180,16 @@ type Querier interface {
ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error) ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error)
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error) ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error) ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
// The groups of list list_id with their dinner's name, in list order.
ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error)
// The groups of list list_id, in list order.
ListShoppingGroups(ctx context.Context, listID string) ([]ShoppingGroup, error)
// The items of list list_id with their group's dinner, in list order: by
// group, then by item.
ListShoppingItems(ctx context.Context, listID string) ([]ListShoppingItemsRow, error)
// The Supply runs of the weeks starting on or before week_start, newest
// week first.
ListShoppingListsThrough(ctx context.Context, weekStart string) ([]ShoppingList, error)
// The ids of the dinners with status quest_log planned on date, on a plan // The ids of the dinners with status quest_log planned on date, on a plan
// of any status, that have no Rotation check for date yet. // of any status, that have no Rotation check for date yet.
ListTriedCandidates(ctx context.Context, date string) ([]string, error) ListTriedCandidates(ctx context.Context, date string) ([]string, error)
@@ -191,6 +217,10 @@ type Querier interface {
// becomes synced. A plan edited since (another revision) is left as it is // becomes synced. A plan edited since (another revision) is left as it is
// (0 rows). // (0 rows).
MarkWeekPlanSynced(ctx context.Context, arg MarkWeekPlanSyncedParams) (int64, error) MarkWeekPlanSynced(ctx context.Context, arg MarkWeekPlanSyncedParams) (int64, error)
MoveShoppingGroup(ctx context.Context, arg MoveShoppingGroupParams) error
// Places item id in group group_id at position with quantity qty; its
// name and needed flag are kept.
MoveShoppingItem(ctx context.Context, arg MoveShoppingItemParams) error
// Moves a dinner still suggested to status (quest_log or archived), // Moves a dinner still suggested to status (quest_log or archived),
// recording updated_by; a dinner no longer suggested is left as it is // recording updated_by; a dinner no longer suggested is left as it is
// (0 rows). // (0 rows).
@@ -208,6 +238,8 @@ type Querier interface {
// Records a new draft over plan id: its status and note, raising its // Records a new draft over plan id: its status and note, raising its
// revision. // revision.
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
// Records a generation of list id from plan plan_id.
RegenerateShoppingList(ctx context.Context, arg RegenerateShoppingListParams) error
// An unknown id returns sql.ErrNoRows. // An unknown id returns sql.ErrNoRows.
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
// Sets the chosen calendar; affects no row when nothing is connected. // Sets the chosen calendar; affects no row when nothing is connected.
@@ -215,6 +247,11 @@ type Querier interface {
// Records that the event calendar_event_id on Calendar shows date of // Records that the event calendar_event_id on Calendar shows date of
// plan_id as planned. // plan_id as planned.
SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error) SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error)
// Marks item id needed (1) or "have it" (0).
SetShoppingItemNeeded(ctx context.Context, arg SetShoppingItemNeededParams) error
// Titles list id, unless it has a title already or is frozen into the
// Supply archive.
SetShoppingListTitle(ctx context.Context, arg SetShoppingListTitleParams) (int64, error)
// Marks a pending batch running; a batch in any other status is left as // Marks a pending batch running; a batch in any other status is left as
// it is (0 rows). // it is (0 rows).
StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error) StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error)
+465
View File
@@ -0,0 +1,465 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: shopping.sql
package db
import (
"context"
"database/sql"
)
const createShoppingGroup = `-- name: CreateShoppingGroup :exec
INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`
type CreateShoppingGroupParams struct {
ID string
ListID string
DinnerID string
Date string
Position int64
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateShoppingGroup(ctx context.Context, arg CreateShoppingGroupParams) error {
_, err := q.db.ExecContext(ctx, createShoppingGroup,
arg.ID,
arg.ListID,
arg.DinnerID,
arg.Date,
arg.Position,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const createShoppingItem = `-- name: CreateShoppingItem :exec
INSERT INTO shopping_items (id, group_id, name, qty, needed, position, created_at, updated_at)
VALUES (?, ?, ?, ?, 1, ?, ?, ?)
`
type CreateShoppingItemParams struct {
ID string
GroupID string
Name string
Qty sql.NullString
Position int64
CreatedAt string
UpdatedAt string
}
// A new item is needed until the household marks it "have it".
func (q *Queries) CreateShoppingItem(ctx context.Context, arg CreateShoppingItemParams) error {
_, err := q.db.ExecContext(ctx, createShoppingItem,
arg.ID,
arg.GroupID,
arg.Name,
arg.Qty,
arg.Position,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const createShoppingList = `-- name: CreateShoppingList :exec
INSERT INTO shopping_lists (id, week_start, plan_id, status, created_at, updated_at)
VALUES (?, ?, ?, 'current', ?, ?)
`
type CreateShoppingListParams struct {
ID string
WeekStart string
PlanID string
CreatedAt string
UpdatedAt string
}
// A new, untitled Supply run: the title is written later, by the AI.
func (q *Queries) CreateShoppingList(ctx context.Context, arg CreateShoppingListParams) error {
_, err := q.db.ExecContext(ctx, createShoppingList,
arg.ID,
arg.WeekStart,
arg.PlanID,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const deleteShoppingGroup = `-- name: DeleteShoppingGroup :exec
DELETE FROM shopping_groups
WHERE id = ?
`
func (q *Queries) DeleteShoppingGroup(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deleteShoppingGroup, id)
return err
}
const deleteShoppingItem = `-- name: DeleteShoppingItem :exec
DELETE FROM shopping_items
WHERE id = ?
`
func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deleteShoppingItem, id)
return err
}
const freezeShoppingList = `-- name: FreezeShoppingList :execrows
UPDATE shopping_lists
SET status = 'archived', frozen_at = ?, updated_at = ?
WHERE id = ? AND frozen_at IS NULL
`
type FreezeShoppingListParams struct {
FrozenAt sql.NullString
UpdatedAt string
ID string
}
// Freezes list id into the Supply archive at frozen_at, unless it is
// frozen already.
func (q *Queries) FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error) {
result, err := q.db.ExecContext(ctx, freezeShoppingList, arg.FrozenAt, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getShoppingItemList = `-- name: GetShoppingItemList :one
SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at
FROM shopping_items
JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id
JOIN shopping_lists ON shopping_lists.id = shopping_groups.list_id
WHERE shopping_items.id = ?
`
type GetShoppingItemListRow struct {
ShoppingList ShoppingList
}
// The Supply run holding item id. Returns sql.ErrNoRows when there is no
// such item.
func (q *Queries) GetShoppingItemList(ctx context.Context, id string) (GetShoppingItemListRow, error) {
row := q.db.QueryRowContext(ctx, getShoppingItemList, id)
var i GetShoppingItemListRow
err := row.Scan(
&i.ShoppingList.ID,
&i.ShoppingList.WeekStart,
&i.ShoppingList.PlanID,
&i.ShoppingList.Title,
&i.ShoppingList.Status,
&i.ShoppingList.SentToChatAt,
&i.ShoppingList.FrozenAt,
&i.ShoppingList.CreatedAt,
&i.ShoppingList.UpdatedAt,
)
return i, err
}
const getShoppingList = `-- name: GetShoppingList :one
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists
WHERE week_start = ?
`
// The Supply run of the week starting week_start. Returns sql.ErrNoRows
// when there is none.
func (q *Queries) GetShoppingList(ctx context.Context, weekStart string) (ShoppingList, error) {
row := q.db.QueryRowContext(ctx, getShoppingList, weekStart)
var i ShoppingList
err := row.Scan(
&i.ID,
&i.WeekStart,
&i.PlanID,
&i.Title,
&i.Status,
&i.SentToChatAt,
&i.FrozenAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listShoppingGroupDinners = `-- name: ListShoppingGroupDinners :many
SELECT shopping_groups.id, shopping_groups.list_id, shopping_groups.dinner_id, shopping_groups.date, shopping_groups.position, shopping_groups.created_at, shopping_groups.updated_at, dinners.name AS dinner_name
FROM shopping_groups
JOIN dinners ON dinners.id = shopping_groups.dinner_id
WHERE shopping_groups.list_id = ?
ORDER BY shopping_groups.position, shopping_groups.id
`
type ListShoppingGroupDinnersRow struct {
ShoppingGroup ShoppingGroup
DinnerName string
}
// The groups of list list_id with their dinner's name, in list order.
func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error) {
rows, err := q.db.QueryContext(ctx, listShoppingGroupDinners, listID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListShoppingGroupDinnersRow
for rows.Next() {
var i ListShoppingGroupDinnersRow
if err := rows.Scan(
&i.ShoppingGroup.ID,
&i.ShoppingGroup.ListID,
&i.ShoppingGroup.DinnerID,
&i.ShoppingGroup.Date,
&i.ShoppingGroup.Position,
&i.ShoppingGroup.CreatedAt,
&i.ShoppingGroup.UpdatedAt,
&i.DinnerName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listShoppingGroups = `-- name: ListShoppingGroups :many
SELECT id, list_id, dinner_id, date, position, created_at, updated_at FROM shopping_groups
WHERE list_id = ?
ORDER BY position, id
`
// The groups of list list_id, in list order.
func (q *Queries) ListShoppingGroups(ctx context.Context, listID string) ([]ShoppingGroup, error) {
rows, err := q.db.QueryContext(ctx, listShoppingGroups, listID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ShoppingGroup
for rows.Next() {
var i ShoppingGroup
if err := rows.Scan(
&i.ID,
&i.ListID,
&i.DinnerID,
&i.Date,
&i.Position,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listShoppingItems = `-- name: ListShoppingItems :many
SELECT shopping_items.id, shopping_items.group_id, shopping_items.name, shopping_items.qty, shopping_items.needed, shopping_items.position, shopping_items.created_at, shopping_items.updated_at, shopping_groups.dinner_id
FROM shopping_items
JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id
WHERE shopping_groups.list_id = ?
ORDER BY shopping_groups.position, shopping_items.position, shopping_items.id
`
type ListShoppingItemsRow struct {
ShoppingItem ShoppingItem
DinnerID string
}
// The items of list list_id with their group's dinner, in list order: by
// group, then by item.
func (q *Queries) ListShoppingItems(ctx context.Context, listID string) ([]ListShoppingItemsRow, error) {
rows, err := q.db.QueryContext(ctx, listShoppingItems, listID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListShoppingItemsRow
for rows.Next() {
var i ListShoppingItemsRow
if err := rows.Scan(
&i.ShoppingItem.ID,
&i.ShoppingItem.GroupID,
&i.ShoppingItem.Name,
&i.ShoppingItem.Qty,
&i.ShoppingItem.Needed,
&i.ShoppingItem.Position,
&i.ShoppingItem.CreatedAt,
&i.ShoppingItem.UpdatedAt,
&i.DinnerID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listShoppingListsThrough = `-- name: ListShoppingListsThrough :many
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists
WHERE week_start <= ?
ORDER BY week_start DESC
`
// The Supply runs of the weeks starting on or before week_start, newest
// week first.
func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string) ([]ShoppingList, error) {
rows, err := q.db.QueryContext(ctx, listShoppingListsThrough, weekStart)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ShoppingList
for rows.Next() {
var i ShoppingList
if err := rows.Scan(
&i.ID,
&i.WeekStart,
&i.PlanID,
&i.Title,
&i.Status,
&i.SentToChatAt,
&i.FrozenAt,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const moveShoppingGroup = `-- name: MoveShoppingGroup :exec
UPDATE shopping_groups
SET position = ?, updated_at = ?
WHERE id = ?
`
type MoveShoppingGroupParams struct {
Position int64
UpdatedAt string
ID string
}
func (q *Queries) MoveShoppingGroup(ctx context.Context, arg MoveShoppingGroupParams) error {
_, err := q.db.ExecContext(ctx, moveShoppingGroup, arg.Position, arg.UpdatedAt, arg.ID)
return err
}
const moveShoppingItem = `-- name: MoveShoppingItem :exec
UPDATE shopping_items
SET group_id = ?, qty = ?, position = ?, updated_at = ?
WHERE id = ?
`
type MoveShoppingItemParams struct {
GroupID string
Qty sql.NullString
Position int64
UpdatedAt string
ID string
}
// Places item id in group group_id at position with quantity qty; its
// name and needed flag are kept.
func (q *Queries) MoveShoppingItem(ctx context.Context, arg MoveShoppingItemParams) error {
_, err := q.db.ExecContext(ctx, moveShoppingItem,
arg.GroupID,
arg.Qty,
arg.Position,
arg.UpdatedAt,
arg.ID,
)
return err
}
const regenerateShoppingList = `-- name: RegenerateShoppingList :exec
UPDATE shopping_lists
SET plan_id = ?, updated_at = ?
WHERE id = ?
`
type RegenerateShoppingListParams struct {
PlanID string
UpdatedAt string
ID string
}
// Records a generation of list id from plan plan_id.
func (q *Queries) RegenerateShoppingList(ctx context.Context, arg RegenerateShoppingListParams) error {
_, err := q.db.ExecContext(ctx, regenerateShoppingList, arg.PlanID, arg.UpdatedAt, arg.ID)
return err
}
const setShoppingItemNeeded = `-- name: SetShoppingItemNeeded :exec
UPDATE shopping_items
SET needed = ?, updated_at = ?
WHERE id = ?
`
type SetShoppingItemNeededParams struct {
Needed int64
UpdatedAt string
ID string
}
// Marks item id needed (1) or "have it" (0).
func (q *Queries) SetShoppingItemNeeded(ctx context.Context, arg SetShoppingItemNeededParams) error {
_, err := q.db.ExecContext(ctx, setShoppingItemNeeded, arg.Needed, arg.UpdatedAt, arg.ID)
return err
}
const setShoppingListTitle = `-- name: SetShoppingListTitle :execrows
UPDATE shopping_lists
SET title = ?, updated_at = ?
WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL
`
type SetShoppingListTitleParams struct {
Title sql.NullString
UpdatedAt string
ID string
}
// Titles list id, unless it has a title already or is frozen into the
// Supply archive.
func (q *Queries) SetShoppingListTitle(ctx context.Context, arg SetShoppingListTitleParams) (int64, error) {
result, err := q.db.ExecContext(ctx, setShoppingListTitle, arg.Title, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+7
View File
@@ -68,6 +68,13 @@ const (
manualSourcePrefix = "mealquest:manual:" manualSourcePrefix = "mealquest:manual:"
) )
// IsManualRecipe reports whether sourceURL, a recipes.source_url, is that
// of a recipe entered by hand. Its ingredients are stored as typed, in
// raw_text only: no model normalizes them, so name, qty and unit are NULL.
func IsManualRecipe(sourceURL string) bool {
return strings.HasPrefix(sourceURL, manualSourcePrefix)
}
// GroundTimeout bounds grounding a candidate or link by hand, which runs in // GroundTimeout bounds grounding a candidate or link by hand, which runs in
// the request: the page fetch (recipes.Timeout), its photo download and // the request: the page fetch (recipes.Timeout), its photo download and
// normalize_recipe (harness.DefaultTimeout), with some slack. // normalize_recipe (harness.DefaultTimeout), with some slack.
+7 -7
View File
@@ -84,20 +84,20 @@ type pushDay struct {
// //
// A successful Push is the household confirming the week's plan, which is // A successful Push is the household confirming the week's plan, which is
// what the week's Supply run (Feature 17) is made from; the returned plan // what the week's Supply run (Feature 17) is made from; the returned plan
// is the one pushed. // is the one pushed, and revision is its revision as pushed, so the Supply
func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (Plan, error) { // run can be made from that plan and no later edit of it.
func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (p Plan, revision int64, err error) {
if _, err := household.WeekDays(weekStart); err != nil { if _, err := household.WeekDays(weekStart); err != nil {
return Plan{}, &InvalidWeekError{Value: weekStart.String()} return Plan{}, 0, &InvalidWeekError{Value: weekStart.String()}
} }
planID, revision, days, err := s.pushDays(ctx, weekStart) planID, revision, days, err := s.pushDays(ctx, weekStart)
if err != nil { if err != nil {
return Plan{}, err return Plan{}, 0, err
} }
events := make([]gcal.Event, len(days)) events := make([]gcal.Event, len(days))
for i, d := range days { for i, d := range days {
events[i] = d.event events[i] = d.event
} }
var p Plan
record := func() error { record := func() error {
return s.st.WithTx(ctx, func(tx *sql.Tx) error { return s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx) q := db.New(tx)
@@ -133,9 +133,9 @@ func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (Plan, er
}) })
} }
if err := s.cal.PushEvents(ctx, events, record); err != nil { if err := s.cal.PushEvents(ctx, events, record); err != nil {
return Plan{}, fmt.Errorf("plan: push %s to Calendar: %w", weekStart, err) return Plan{}, 0, fmt.Errorf("plan: push %s to Calendar: %w", weekStart, err)
} }
return p, nil return p, revision, nil
} }
// pushDays reads, in one read transaction, the plan of the week starting // pushDays reads, in one read transaction, the plan of the week starting
+31 -31
View File
@@ -135,7 +135,7 @@ func TestPushCreatesOneEventPerDay(t *testing.T) {
e.connectCalendar(t) e.connectCalendar(t)
dates := e.storePlan(t, week, fullWeek...) dates := e.storePlan(t, week, fullWeek...)
p, err := e.svc.Push(t.Context(), week) p, _, err := e.svc.Push(t.Context(), week)
if err != nil { if err != nil {
t.Fatalf("Push: %v", err) t.Fatalf("Push: %v", err)
} }
@@ -197,7 +197,7 @@ func TestPushAgainUpdatesOnly(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.connectCalendar(t) e.connectCalendar(t)
e.storePlan(t, week, fullWeek...) e.storePlan(t, week, fullWeek...)
if _, err := e.svc.Push(t.Context(), week); err != nil { if _, _, err := e.svc.Push(t.Context(), week); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// The household deletes Monday's event in Google Calendar; Google keeps // The household deletes Monday's event in Google Calendar; Google keeps
@@ -206,7 +206,7 @@ func TestPushAgainUpdatesOnly(t *testing.T) {
e.google.CancelEvent(dinnersCal.ID, monday) e.google.CancelEvent(dinnersCal.ID, monday)
e.google.ResetEventWrites() e.google.ResetEventWrites()
p, err := e.svc.Push(t.Context(), week) p, _, err := e.svc.Push(t.Context(), week)
if err != nil { if err != nil {
t.Fatalf("second Push: %v", err) t.Fatalf("second Push: %v", err)
} }
@@ -229,7 +229,7 @@ func TestSwapThenPushUpdatesThatDay(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.connectCalendar(t) e.connectCalendar(t)
e.storePlan(t, week, fullWeek...) e.storePlan(t, week, fullWeek...)
if _, err := e.svc.Push(t.Context(), week); err != nil { if _, _, err := e.svc.Push(t.Context(), week); err != nil {
t.Fatal(err) t.Fatal(err)
} }
before := e.google.Events(dinnersCal.ID) before := e.google.Events(dinnersCal.ID)
@@ -247,7 +247,7 @@ func TestSwapThenPushUpdatesThatDay(t *testing.T) {
t.Errorf("swapped day kept event id %q", id) t.Errorf("swapped day kept event id %q", id)
} }
p, err := e.svc.Push(t.Context(), week) p, _, err := e.svc.Push(t.Context(), week)
if err != nil { if err != nil {
t.Fatalf("Push: %v", err) t.Fatalf("Push: %v", err)
} }
@@ -296,7 +296,7 @@ func TestPushAcrossDaylightSavingTime(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.connectCalendar(t) e.connectCalendar(t)
dates := e.storePlan(t, tc.week, fullWeek...) dates := e.storePlan(t, tc.week, fullWeek...)
if _, err := e.svc.Push(t.Context(), tc.week); err != nil { if _, _, err := e.svc.Push(t.Context(), tc.week); err != nil {
t.Fatalf("Push: %v", err) t.Fatalf("Push: %v", err)
} }
events := e.google.Events(dinnersCal.ID) events := e.google.Events(dinnersCal.ID)
@@ -330,12 +330,12 @@ func TestPushAcrossDaylightSavingTime(t *testing.T) {
func TestPushNeedsAChosenCalendar(t *testing.T) { func TestPushNeedsAChosenCalendar(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.storePlan(t, week, fullWeek...) e.storePlan(t, week, fullWeek...)
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNotConnected) { if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNotConnected) {
t.Errorf("Push with nothing connected = %v, want ErrNotConnected", err) t.Errorf("Push with nothing connected = %v, want ErrNotConnected", err)
} }
e.google.SetCalendars(0, dinnersCal) e.google.SetCalendars(0, dinnersCal)
e.google.Connect(t, e.gcal) e.google.Connect(t, e.gcal)
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) { if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) {
t.Errorf("Push with no calendar chosen = %v, want ErrNoCalendar", err) t.Errorf("Push with no calendar chosen = %v, want ErrNoCalendar", err)
} }
if inserts, updates := e.google.EventWrites(); inserts+updates != 0 { if inserts, updates := e.google.EventWrites(); inserts+updates != 0 {
@@ -348,7 +348,7 @@ func TestPushWithoutAPlan(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.connectCalendar(t) e.connectCalendar(t)
before := e.google.TokenCalls() before := e.google.TokenCalls()
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, plan.ErrNotFound) { if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Push = %v, want ErrNotFound", err) t.Errorf("Push = %v, want ErrNotFound", err)
} }
if n := e.google.TokenCalls() - before; n != 0 { if n := e.google.TokenCalls() - before; n != 0 {
@@ -363,7 +363,7 @@ func TestPushPartialFailureLeavesPlanUnsynced(t *testing.T) {
// Google fails the fourth event on every try. // Google fails the fourth event on every try.
e.google.FailEventWrites(4, 3, http.StatusServiceUnavailable) e.google.FailEventWrites(4, 3, http.StatusServiceUnavailable)
_, err := e.svc.Push(t.Context(), week) _, _, err := e.svc.Push(t.Context(), week)
var calendar *gcal.CalendarError var calendar *gcal.CalendarError
if !errors.As(err, &calendar) || !calendar.Retryable() { if !errors.As(err, &calendar) || !calendar.Retryable() {
t.Fatalf("Push = %v, want a retryable *gcal.CalendarError", err) t.Fatalf("Push = %v, want a retryable *gcal.CalendarError", err)
@@ -377,7 +377,7 @@ func TestPushPartialFailureLeavesPlanUnsynced(t *testing.T) {
// Pushing again writes every day: the three already there are updated, // Pushing again writes every day: the three already there are updated,
// not added twice. // not added twice.
e.google.ResetEventWrites() e.google.ResetEventWrites()
p, err := e.svc.Push(t.Context(), week) p, _, err := e.svc.Push(t.Context(), week)
if err != nil { if err != nil {
t.Fatalf("retried Push: %v", err) t.Fatalf("retried Push: %v", err)
} }
@@ -393,7 +393,7 @@ func TestPushPartialFailureKeepsAChangedPlanChanged(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.connectCalendar(t) e.connectCalendar(t)
e.storePlan(t, week, fullWeek...) e.storePlan(t, week, fullWeek...)
first, err := e.svc.Push(t.Context(), week) first, _, err := e.svc.Push(t.Context(), week)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -403,7 +403,7 @@ func TestPushPartialFailureKeepsAChangedPlanChanged(t *testing.T) {
} }
} }
e.google.FailEventWrite(2, http.StatusBadRequest) e.google.FailEventWrite(2, http.StatusBadRequest)
if _, err := e.svc.Push(t.Context(), week); err == nil { if _, _, err := e.svc.Push(t.Context(), week); err == nil {
t.Fatal("Push succeeded, want the second event to fail") t.Fatal("Push succeeded, want the second event to fail")
} }
p, err := e.svc.Get(t.Context(), week) p, err := e.svc.Get(t.Context(), week)
@@ -431,7 +431,7 @@ func TestPushToStaleCalendar(t *testing.T) {
e.connectCalendar(t) e.connectCalendar(t)
e.storePlan(t, week, fullWeek...) e.storePlan(t, week, fullWeek...)
edit(e.google) edit(e.google)
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrCalendarUnavailable) { if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrCalendarUnavailable) {
t.Errorf("Push = %v, want ErrCalendarUnavailable", err) t.Errorf("Push = %v, want ErrCalendarUnavailable", err)
} }
e.assertUnsynced(t, week, plan.StatusDraft) e.assertUnsynced(t, week, plan.StatusDraft)
@@ -444,7 +444,7 @@ func TestPushGrantNoLongerAccepted(t *testing.T) {
e.connectCalendar(t) e.connectCalendar(t)
e.storePlan(t, week, fullWeek...) e.storePlan(t, week, fullWeek...)
e.google.RevokeGrant() e.google.RevokeGrant()
_, err := e.svc.Push(t.Context(), week) _, _, err := e.svc.Push(t.Context(), week)
if !errors.Is(err, gcal.ErrReconnect) { if !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Push = %v, want ErrReconnect", err) t.Fatalf("Push = %v, want ErrReconnect", err)
} }
@@ -479,12 +479,12 @@ func TestRefusedGrantKeepsPushedWeeks(t *testing.T) {
dates := map[store.LocalDate][]string{} dates := map[store.LocalDate][]string{}
for _, w := range pushedWeeks { for _, w := range pushedWeeks {
dates[w] = e.storePlan(t, w, fullWeek...) dates[w] = e.storePlan(t, w, fullWeek...)
if _, err := e.svc.Push(t.Context(), w); err != nil { if _, _, err := e.svc.Push(t.Context(), w); err != nil {
t.Fatalf("Push %s: %v", w, err) t.Fatalf("Push %s: %v", w, err)
} }
} }
e.google.RevokeGrant() e.google.RevokeGrant()
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) { if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Push = %v, want ErrReconnect", err) t.Fatalf("Push = %v, want ErrReconnect", err)
} }
e.assertRefused(t) e.assertRefused(t)
@@ -497,7 +497,7 @@ func TestRefusedGrantKeepsPushedWeeks(t *testing.T) {
// The refused token is not offered to Google again. // The refused token is not offered to Google again.
before := e.google.TokenCalls() before := e.google.TokenCalls()
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) { if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Push again = %v, want ErrReconnect", err) t.Fatalf("Push again = %v, want ErrReconnect", err)
} }
if n := e.google.TokenCalls() - before; n != 0 { if n := e.google.TokenCalls() - before; n != 0 {
@@ -506,7 +506,7 @@ func TestRefusedGrantKeepsPushedWeeks(t *testing.T) {
e.google.Connect(t, e.gcal) e.google.Connect(t, e.gcal)
e.google.ResetEventWrites() e.google.ResetEventWrites()
p, err := e.svc.Push(t.Context(), week) p, _, err := e.svc.Push(t.Context(), week)
if err != nil || p.Status != plan.StatusSynced { if err != nil || p.Status != plan.StatusSynced {
t.Fatalf("Push after connecting again = %s, %v; want synced", p.Status, err) t.Fatalf("Push after connecting again = %s, %v; want synced", p.Status, err)
} }
@@ -523,7 +523,7 @@ func TestDisconnectAfterRefusedGrant(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.connectCalendar(t) e.connectCalendar(t)
e.storePlan(t, week, fullWeek...) e.storePlan(t, week, fullWeek...)
if _, err := e.svc.Push(t.Context(), week); err != nil { if _, _, err := e.svc.Push(t.Context(), week); err != nil {
t.Fatal(err) t.Fatal(err)
} }
e.google.RevokeGrant() e.google.RevokeGrant()
@@ -551,7 +551,7 @@ func TestPushFailsWhenPlanSwappedWhilePushing(t *testing.T) {
defer release() defer release()
pushed := make(chan error, 1) pushed := make(chan error, 1)
go func() { go func() {
_, err := e.svc.Push(t.Context(), week) _, _, err := e.svc.Push(t.Context(), week)
pushed <- err pushed <- err
}() }()
<-arrived <-arrived
@@ -596,7 +596,7 @@ func TestPushAfterChoosingAnotherCalendarWritesTheWholeWeek(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.connectCalendar(t) e.connectCalendar(t)
dates := e.storePlan(t, week, fullWeek...) dates := e.storePlan(t, week, fullWeek...)
first, err := e.svc.Push(t.Context(), week) first, _, err := e.svc.Push(t.Context(), week)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -640,14 +640,14 @@ func TestPushAfterChoosingAnotherCalendarWritesTheWholeWeek(t *testing.T) {
// A push that fails part way leaves the plan changed. // A push that fails part way leaves the plan changed.
e.google.ResetEventWrites() e.google.ResetEventWrites()
e.google.FailEventWrite(4, http.StatusBadRequest) e.google.FailEventWrite(4, http.StatusBadRequest)
if _, err := e.svc.Push(t.Context(), week); err == nil { if _, _, err := e.svc.Push(t.Context(), week); err == nil {
t.Fatal("Push succeeded, want the fourth event to fail") t.Fatal("Push succeeded, want the fourth event to fail")
} }
if p, err := e.svc.Get(t.Context(), week); err != nil || p.Status != plan.StatusChanged { if p, err := e.svc.Get(t.Context(), week); err != nil || p.Status != plan.StatusChanged {
t.Errorf("plan after a failed push = %s, %v; want changed", p.Status, err) t.Errorf("plan after a failed push = %s, %v; want changed", p.Status, err)
} }
p, err = e.svc.Push(t.Context(), week) p, _, err = e.svc.Push(t.Context(), week)
if err != nil { if err != nil {
t.Fatalf("Push: %v", err) t.Fatalf("Push: %v", err)
} }
@@ -678,7 +678,7 @@ func TestChoosingAnotherCalendarWaitsForAPush(t *testing.T) {
defer release() defer release()
pushed := make(chan error, 1) pushed := make(chan error, 1)
go func() { go func() {
_, err := e.svc.Push(t.Context(), week) _, _, err := e.svc.Push(t.Context(), week)
pushed <- err pushed <- err
}() }()
<-arrived <-arrived
@@ -712,7 +712,7 @@ func TestChoosingAnotherCalendarWaitsForAPush(t *testing.T) {
} }
} }
if p, err = e.svc.Push(t.Context(), week); err != nil || p.Status != plan.StatusSynced { if p, _, err = e.svc.Push(t.Context(), week); err != nil || p.Status != plan.StatusSynced {
t.Fatalf("Push = %s, %v; want synced", p.Status, err) t.Fatalf("Push = %s, %v; want synced", p.Status, err)
} }
e.assertWeekOn(t, kitchenCal.ID, week, dates, fullWeek...) e.assertWeekOn(t, kitchenCal.ID, week, dates, fullWeek...)
@@ -746,7 +746,7 @@ func TestDisconnectForgetsEveryPushedWeek(t *testing.T) {
dates := map[store.LocalDate][]string{} dates := map[store.LocalDate][]string{}
for _, w := range pushedWeeks { for _, w := range pushedWeeks {
dates[w] = e.storePlan(t, w, fullWeek...) dates[w] = e.storePlan(t, w, fullWeek...)
if _, err := e.svc.Push(t.Context(), w); err != nil { if _, _, err := e.svc.Push(t.Context(), w); err != nil {
t.Fatalf("Push %s: %v", w, err) t.Fatalf("Push %s: %v", w, err)
} }
} }
@@ -764,7 +764,7 @@ func TestDisconnectForgetsEveryPushedWeek(t *testing.T) {
// Connecting again chooses no calendar, so nothing is pushed until one // Connecting again chooses no calendar, so nothing is pushed until one
// is chosen; then a push writes the whole week to it. // is chosen; then a push writes the whole week to it.
e.google.Connect(t, e.gcal) e.google.Connect(t, e.gcal)
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) { if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) {
t.Fatalf("Push after connecting again = %v, want ErrNoCalendar", err) t.Fatalf("Push after connecting again = %v, want ErrNoCalendar", err)
} }
e.google.SetCalendars(0, dinnersCal, kitchenCal) e.google.SetCalendars(0, dinnersCal, kitchenCal)
@@ -773,7 +773,7 @@ func TestDisconnectForgetsEveryPushedWeek(t *testing.T) {
} }
for _, w := range pushedWeeks { for _, w := range pushedWeeks {
e.assertForgotten(t, w, "after connecting again") e.assertForgotten(t, w, "after connecting again")
p, err := e.svc.Push(t.Context(), w) p, _, err := e.svc.Push(t.Context(), w)
if err != nil || p.Status != plan.StatusSynced { if err != nil || p.Status != plan.StatusSynced {
t.Fatalf("Push %s = %s, %v; want synced", w, p.Status, err) t.Fatalf("Push %s = %s, %v; want synced", w, p.Status, err)
} }
@@ -789,7 +789,7 @@ func TestDisconnectRefusedKeepsPushedWeeks(t *testing.T) {
dates := map[store.LocalDate][]string{} dates := map[store.LocalDate][]string{}
for _, w := range pushedWeeks { for _, w := range pushedWeeks {
dates[w] = e.storePlan(t, w, fullWeek...) dates[w] = e.storePlan(t, w, fullWeek...)
if _, err := e.svc.Push(t.Context(), w); err != nil { if _, _, err := e.svc.Push(t.Context(), w); err != nil {
t.Fatalf("Push %s: %v", w, err) t.Fatalf("Push %s: %v", w, err)
} }
} }
@@ -816,7 +816,7 @@ func TestDisconnectWaitsForAPush(t *testing.T) {
defer release() defer release()
pushed := make(chan error, 1) pushed := make(chan error, 1)
go func() { go func() {
_, err := e.svc.Push(t.Context(), week) _, _, err := e.svc.Push(t.Context(), week)
pushed <- err pushed <- err
}() }()
<-arrived <-arrived
+1 -1
View File
@@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler {
t.Helper() t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t),
newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+1 -1
View File
@@ -92,7 +92,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
t.Fatal(err) t.Fatal(err)
} }
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
svc, newTestSuggest(t), newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+1 -1
View File
@@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv {
} }
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug})) logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), newTestSuggest(t), newTestPlan(t), svc, newTestRunner(t), newTestPhotos(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestRunner(t), newTestPhotos(t),
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+1 -1
View File
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
t.Fatal(err) t.Fatal(err)
} }
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t), r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+1 -1
View File
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
t.Fatal(err) t.Fatal(err)
} }
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t),
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+1 -1
View File
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
} }
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil))) logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t), r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+58 -4
View File
@@ -1,6 +1,7 @@
package server package server
import ( import (
"context"
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -9,6 +10,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
) )
@@ -94,20 +96,72 @@ func putPlanDay(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
} }
} }
// pushedPlan is the POST /plans/{weekStart}/push-calendar answer: the
// plan as synced, and what became of the week's Supply run.
type pushedPlan struct {
plan.Plan
SupplyRun pushedSupplyRun `json:"supply_run"`
}
// pushedSupplyRun says whether a Push to Calendar made the week's Supply
// run. status is 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 Supply run's error is plan_changed when the plan
// was edited after it was pushed, so the Supply run was not made from a
// plan Calendar does not show and pushing again makes it; or
// supply_run_failed when it could not be made, and POST
// /shopping/{weekStart}/generate makes it again. error is null when status
// is generated.
type pushedSupplyRun struct {
Status string `json:"status"`
Error *apiError `json:"error"`
}
// postPlanPush pushes the week's plan to the chosen Google calendar // postPlanPush pushes the week's plan to the chosen Google calendar
// (Push to Calendar) and answers with the plan as synced. // (Push to Calendar) and, once the push is recorded, makes the week's
func postPlanPush(logger *slog.Logger, svc *plan.Service) http.HandlerFunc { // Supply run from the plan pushed. It answers with the plan as synced and
// the Supply run's outcome.
//
// The Supply run is made after the push, never as part of it: Calendar
// already shows the plan, so a Supply run that cannot be made does not
// undo or fail the push. The answer is still 200 with supply_run failed,
// and the failure is logged, so nothing claims a Supply run that was not
// made. It is made only from the plan as pushed: one edited between the
// push and the Supply run is not made into a Supply run.
func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger) week, ok := weekStartOrFail(w, r, logger)
if !ok { if !ok {
return return
} }
p, err := svc.Push(r.Context(), week) p, revision, err := svc.Push(r.Context(), week)
if err != nil { if err != nil {
writePlanPushError(w, r, logger, err) writePlanPushError(w, r, logger, err)
return return
} }
writeJSON(w, r, logger, http.StatusOK, p) // The push is done: make the Supply run even if the client has
// gone.
ctx := context.WithoutCancel(r.Context())
run := pushedSupplyRun{Status: "generated"}
if _, err := shop.GeneratePushed(ctx, week, revision); errors.Is(err, shopping.ErrFrozen) {
logger.InfoContext(ctx, "Supply run in the Supply archive left as it was after Push to Calendar", "week", week.String())
run = pushedSupplyRun{Status: "archived", Error: &apiError{Code: "archived", Message: err.Error()}}
} else if errors.Is(err, shopping.ErrPlanChanged) {
logger.WarnContext(ctx, "plan edited after Push to Calendar; Supply run not made", "week", week.String())
run = pushedSupplyRun{Status: "failed", Error: &apiError{
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,
}}
} else if err != nil {
logger.ErrorContext(ctx, "generate Supply run after Push to Calendar", "week", week.String(), "error", err)
run = pushedSupplyRun{Status: "failed", Error: &apiError{
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,
}}
}
writeJSON(w, r, logger, http.StatusOK, pushedPlan{Plan: p, SupplyRun: run})
} }
} }
+16 -2
View File
@@ -23,6 +23,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
) )
@@ -36,6 +37,8 @@ type plansEnv struct {
h http.Handler h http.Handler
st *store.Store st *store.Store
svc *plan.Service svc *plan.Service
shop *shopping.Service
runner *jobs.Runner
questLog string questLog string
// google is the fake Google the plan service pushes to; it starts // google is the fake Google the plan service pushes to; it starts
// unconnected. // unconnected.
@@ -46,6 +49,13 @@ type plansEnv struct {
} }
func newPlansRouter(t *testing.T) plansEnv { func newPlansRouter(t *testing.T) plansEnv {
t.Helper()
return newPlansRouterTitled(t, untitled)
}
// newPlansRouterTitled is newPlansRouter whose Supply runs are titled by
// title.
func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
t.Helper() t.Helper()
st := newTestStore(t) st := newTestStore(t)
ctx := t.Context() ctx := t.Context()
@@ -79,8 +89,12 @@ func newPlansRouter(t *testing.T) plansEnv {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
shop, err := shopping.NewService(st, title, runner, logger)
if err != nil {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), newTestSuggest(t), svc, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -114,7 +128,7 @@ func newPlansRouter(t *testing.T) plansEnv {
t.Fatal(err) t.Fatal(err)
} }
} }
return plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35), return plansEnv{h: h, st: st, svc: svc, shop: shop, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
google: google, gcal: googleSvc, logs: logs} google: google, gcal: googleSvc, logs: logs}
} }
+1 -1
View File
@@ -22,7 +22,7 @@ const (
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler { func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
t.Helper() t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+16 -3
View File
@@ -19,6 +19,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
) )
@@ -46,7 +47,12 @@ type RouteMounter interface {
// /decline; planSvc serves GET /api/v1/plans/{weekStart}, POST // /decline; planSvc serves GET /api/v1/plans/{weekStart}, POST
// /api/v1/plans/{weekStart}/draft, PUT // /api/v1/plans/{weekStart}/draft, PUT
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and // /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
// POST /api/v1/dinners/{id}/rotation-review; googleSvc serves GET // POST /api/v1/dinners/{id}/rotation-review, and POST
// /api/v1/plans/{weekStart}/push-calendar with shopSvc, which makes the
// pushed week's Supply run; shopSvc serves GET /api/v1/shopping/archive,
// GET /api/v1/shopping/{weekStart},
// POST /api/v1/shopping/{weekStart}/generate and PATCH
// /api/v1/shopping/items/{id}; googleSvc serves GET
// /api/v1/integrations/google and its /connect, /callback and /calendars, // /api/v1/integrations/google and its /connect, /callback and /calendars,
// PUT /api/v1/integrations/google/calendar and DELETE // PUT /api/v1/integrations/google/calendar and DELETE
// /api/v1/integrations/google; runner serves // /api/v1/integrations/google; runner serves
@@ -54,7 +60,7 @@ type RouteMounter interface {
// GET /media/photos/{id}-{size}.jpg; assets is the web build // GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public. // served for every other GET. /health, /auth and the web build are public.
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader. // Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, googleSvc *gcal.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
if settings == nil { if settings == nil {
return nil, errors.New("server: nil household settings service") return nil, errors.New("server: nil household settings service")
} }
@@ -73,6 +79,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
if planSvc == nil { if planSvc == nil {
return nil, errors.New("server: nil plan service") return nil, errors.New("server: nil plan service")
} }
if shopSvc == nil {
return nil, errors.New("server: nil shopping service")
}
if googleSvc == nil { if googleSvc == nil {
return nil, errors.New("server: nil Google Calendar service") return nil, errors.New("server: nil Google Calendar service")
} }
@@ -133,7 +142,11 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Get("/plans/{weekStart}", getPlan(logger, planSvc)) apiV1.Get("/plans/{weekStart}", getPlan(logger, planSvc))
apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc)) apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc)) apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc))
apiV1.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc)) apiV1.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc, shopSvc))
apiV1.Get("/shopping/archive", getSupplyArchive(logger, shopSvc))
apiV1.Get("/shopping/{weekStart}", getSupplyRun(logger, shopSvc))
apiV1.Post("/shopping/{weekStart}/generate", postSupplyRunGenerate(logger, shopSvc))
apiV1.Patch("/shopping/items/{id}", patchShoppingItem(logger, shopSvc))
apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc)) apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc))
apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc)) apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc))
// connect and callback are top-level browser navigations, so they are // connect and callback are top-level browser navigations, so they are
+43 -10
View File
@@ -30,6 +30,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
@@ -53,7 +54,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
"index.html": {Data: []byte(fixtureIndex)}, "index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, "assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
} }
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), assets) r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), assets)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -209,6 +210,29 @@ func newTestPlan(t *testing.T) *plan.Service {
return svc return svc
} }
// newTestShopping returns a shopping service over a migrated temp
// database, whose Supply runs stay untitled.
func newTestShopping(t *testing.T) *shopping.Service {
t.Helper()
st := newTestStore(t)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
svc, err := shopping.NewService(st, untitled, runner, logger)
if err != nil {
t.Fatal(err)
}
return svc
}
// untitled is a shopping.Titler with no model: every Supply run it is
// asked to title stays untitled.
func untitled(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
return skills.ArchiveTitle{}, errors.New("no model in this test")
}
// newTestBox returns a secret box under a random key. // newTestBox returns a secret box under a random key.
func newTestBox(t *testing.T) *secretbox.Box { func newTestBox(t *testing.T) *secretbox.Box {
t.Helper() t.Helper()
@@ -263,7 +287,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
t.Parallel() t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil AI settings service") t.Fatal("NewRouter accepted a nil AI settings service")
} }
} }
@@ -272,7 +296,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
t.Parallel() t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil recipe search settings service") t.Fatal("NewRouter accepted a nil recipe search settings service")
} }
} }
@@ -281,7 +305,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
t.Parallel() t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted nil photo files") t.Fatal("NewRouter accepted nil photo files")
} }
} }
@@ -290,7 +314,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel() t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service") t.Fatal("NewRouter accepted a nil settings service")
} }
} }
@@ -299,7 +323,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
t.Parallel() t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil dinners service") t.Fatal("NewRouter accepted a nil dinners service")
} }
} }
@@ -308,7 +332,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
t.Parallel() t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil suggestions service") t.Fatal("NewRouter accepted a nil suggestions service")
} }
} }
@@ -317,16 +341,25 @@ func TestNewRouterRequiresPlan(t *testing.T) {
t.Parallel() t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil plan service") t.Fatal("NewRouter accepted a nil plan service")
} }
} }
func TestNewRouterRequiresShopping(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil shopping service")
}
}
func TestNewRouterRequiresGoogle(t *testing.T) { func TestNewRouterRequiresGoogle(t *testing.T) {
t.Parallel() t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil Google Calendar service") t.Fatal("NewRouter accepted a nil Google Calendar service")
} }
} }
@@ -335,7 +368,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
t.Parallel() t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner") t.Fatal("NewRouter accepted a nil job runner")
} }
} }
+1 -1
View File
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
} }
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st), router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+262
View File
@@ -0,0 +1,262 @@
package server
import (
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// maxShoppingItemBody bounds a PATCH /shopping/items/{id} body.
const maxShoppingItemBody = 1 << 10
// supplyRun is a Supply run as the API shows it. label is the week label,
// which stands in for title until the AI writes one.
type supplyRun struct {
WeekStart string `json:"weekStart"`
Label string `json:"label"`
Title *string `json:"title"`
Status string `json:"status"`
SentToChatAt *string `json:"sent_to_chat_at"`
shopping.Counts
Groups []supplyRunGroup `json:"groups"`
}
// supplyRunGroup is the items of one plan day's dinner. day is the plan
// day's weekday, such as "Monday".
type supplyRunGroup struct {
Dinner supplyRunDinner `json:"dinner"`
Date string `json:"date"`
Day string `json:"day"`
Items []supplyRunItem `json:"items"`
}
type supplyRunDinner struct {
ID string `json:"id"`
Name string `json:"name"`
}
// supplyRunItem is one ingredient to buy; qty is null when the recipe
// gives none, and needed is false once it is marked "have it".
type supplyRunItem struct {
ID string `json:"id"`
Name string `json:"name"`
Qty *string `json:"qty"`
Needed bool `json:"needed"`
}
// generatedSupplyRun is the POST /shopping/{weekStart}/generate answer:
// the Supply run as made, and the plan days with nothing to shop for.
type generatedSupplyRun struct {
supplyRun
Skipped []shopping.SkippedDay `json:"skipped"`
}
// shoppingItemPatch is the PATCH /shopping/items/{id} body.
type shoppingItemPatch struct {
Needed *bool `json:"needed"`
}
// toggledItem is the PATCH /shopping/items/{id} answer: the item's flag
// and its Supply run's counts after the change.
type toggledItem struct {
ID string `json:"id"`
Needed bool `json:"needed"`
shopping.Counts
}
// getSupplyRun answers GET /shopping/{weekStart} with the week's Supply
// run.
func getSupplyRun(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
if !ok {
return
}
l, err := svc.Get(r.Context(), week)
if err != nil {
writeShoppingError(w, r, logger, "read Supply run", err)
return
}
out, err := supplyRunOf(week, l)
if err != nil {
writeShoppingError(w, r, logger, "read Supply run", err)
return
}
writeJSON(w, r, logger, http.StatusOK, out)
}
}
// postSupplyRunGenerate makes, or remakes, the week's Supply run from its
// plan and answers with it and the plan days it skipped.
func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
if !ok {
return
}
g, err := svc.Generate(r.Context(), week)
if err != nil {
writeShoppingError(w, r, logger, "generate Supply run", err)
return
}
out, err := supplyRunOf(week, g.List)
if err != nil {
writeShoppingError(w, r, logger, "generate Supply run", err)
return
}
skipped := g.Skipped
if skipped == nil {
skipped = []shopping.SkippedDay{}
}
writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped})
}
}
// patchShoppingItem marks a Supply run item needed or "have it" and
// answers with its Supply run's new counts.
func patchShoppingItem(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := decodePatch[shoppingItemPatch](http.MaxBytesReader(w, r.Body, maxShoppingItemBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if body.Needed == nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "needed: required")
return
}
id := chi.URLParam(r, "id")
c, err := svc.SetNeeded(r.Context(), id, *body.Needed)
if err != nil {
writeShoppingError(w, r, logger, "mark Supply run item", err)
return
}
writeJSON(w, r, logger, http.StatusOK, toggledItem{ID: id, Needed: *body.Needed, Counts: c})
}
}
// supplyArchive is the GET /shopping/archive answer: the Supply archive's
// periods, newest first, as grouped by by.
type supplyArchive struct {
By string `json:"by"`
Periods []archivePeriod `json:"periods"`
}
// archivePeriod is one heading of the Supply archive and its posts,
// newest first.
type archivePeriod struct {
Key string `json:"key"`
Heading string `json:"heading"`
Posts []archivePost `json:"posts"`
}
// archivePost is one week's Supply run in the Supply archive. label is
// the week label, which stands in for title while it is null, and
// onHandCount counts the items marked "have it".
type archivePost struct {
WeekStart string `json:"weekStart"`
Label string `json:"label"`
Title *string `json:"title"`
DinnerCount int `json:"dinnerCount"`
ItemCount int `json:"itemCount"`
OnHandCount int `json:"onHandCount"`
Dinners []string `json:"dinners"`
Current bool `json:"current"`
}
// getSupplyArchive answers GET /shopping/archive?by=week|month|year with
// the Supply archive grouped by that period.
func getSupplyArchive(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
by := r.URL.Query().Get("by")
periods, err := svc.Archive(r.Context(), by)
if errors.Is(err, shopping.ErrInvalidPeriod) {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", shopping.ErrInvalidPeriod.Error())
return
}
if err != nil {
writeShoppingError(w, r, logger, "read Supply archive", err)
return
}
out := supplyArchive{By: by, Periods: make([]archivePeriod, len(periods))}
for i, p := range periods {
posts := make([]archivePost, len(p.Posts))
for j, post := range p.Posts {
posts[j] = archivePost{
WeekStart: post.WeekStart,
Label: post.Label,
Title: post.Title,
DinnerCount: len(post.Dinners),
ItemCount: post.Items,
OnHandCount: post.OnHand,
Dinners: post.Dinners,
Current: post.Current,
}
}
out.Periods[i] = archivePeriod{Key: p.Key, Heading: p.Heading, Posts: posts}
}
writeJSON(w, r, logger, http.StatusOK, out)
}
}
// supplyRunOf is l, the Supply run of the week starting week, as the API
// shows it.
func supplyRunOf(week store.LocalDate, l shopping.List) (supplyRun, error) {
label, err := household.WeekLabel(week)
if err != nil {
return supplyRun{}, err
}
groups := make([]supplyRunGroup, len(l.Groups))
for i, g := range l.Groups {
date, err := time.Parse(time.DateOnly, g.Date)
if err != nil {
return supplyRun{}, fmt.Errorf("group %s date: %w", g.ID, err)
}
items := make([]supplyRunItem, len(g.Items))
for j, it := range g.Items {
items[j] = supplyRunItem{ID: it.ID, Name: it.Name, Qty: it.Qty, Needed: it.Needed}
}
groups[i] = supplyRunGroup{
Dinner: supplyRunDinner{ID: g.DinnerID, Name: g.DinnerName},
Date: g.Date,
Day: date.Weekday().String(),
Items: items,
}
}
return supplyRun{
WeekStart: l.WeekStart,
Label: label,
Title: l.Title,
Status: l.Status,
SentToChatAt: l.SentToChatAt,
Counts: l.Counts(),
Groups: groups,
}, nil
}
// writeShoppingError maps a shopping.Service error to its API error: a
// week with no Supply run or no plan, or an unknown item, is 404, and a
// change to a Supply run in the Supply archive is 409 archived.
func writeShoppingError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
switch {
case errors.Is(err, shopping.ErrListNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "Supply run not found")
case errors.Is(err, shopping.ErrItemNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "Supply run item not found")
case errors.Is(err, shopping.ErrPlanNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "plan not found")
case errors.Is(err, shopping.ErrFrozen):
writeError(w, r, logger, http.StatusConflict, "archived", err.Error())
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
+789
View File
@@ -0,0 +1,789 @@
package server
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
"slices"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const shoppingPath = "/api/v1/shopping/"
// shopIng is a recipe ingredient as recipe grounding stores it: an empty
// qty or unit is NULL.
type shopIng struct {
name, qty, unit string
}
// groundDay grounds the dinner planned on date of the plans env's week in
// a fetched recipe listing ings.
func (e plansEnv) groundDay(t *testing.T, date string, ings ...shopIng) {
t.Helper()
ctx := t.Context()
q := db.New(e.st.Writer)
now := store.Now().String()
r, err := q.UpsertRecipe(ctx, db.UpsertRecipeParams{
ID: store.NewID(), SourceUrl: "https://recipes.example/" + date, Title: "Recipe of " + date,
RawJsonld: "{}", FetchedAt: now, CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
for i, in := range ings {
if err := q.CreateRecipeIngredient(ctx, db.CreateRecipeIngredientParams{
ID: store.NewID(), RecipeID: r.ID, Position: int64(i),
RawText: strings.TrimSpace(in.qty + " " + in.unit + " " + in.name),
Name: sql.NullString{String: in.name, Valid: true},
Qty: sql.NullString{String: in.qty, Valid: in.qty != ""},
Unit: sql.NullString{String: in.unit, Valid: in.unit != ""},
CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
}
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE dinners SET recipe_id = ?, grounding_status = 'grounded'
WHERE id = (SELECT dinner_id FROM plan_days WHERE date = ?)`, r.ID, date); err != nil {
t.Fatal(err)
}
}
// groundWeek grounds Monday's Beef Chili and Tuesday's Regular: five items,
// one with no quantity.
func (e plansEnv) groundWeek(t *testing.T) {
t.Helper()
e.groundDay(t, "2026-10-12", shopIng{"ground beef", "1", "lb"}, shopIng{"kidney beans", "2", "cans"}, shopIng{"onion", "1", ""})
e.groundDay(t, "2026-10-13", shopIng{"spaghetti", "500", "g"}, shopIng{"basil", "", ""})
}
// archive freezes the week's Supply run into the Supply archive.
func (e plansEnv) archive(t *testing.T) {
t.Helper()
if _, err := e.st.Writer.ExecContext(t.Context(),
`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
t.Fatal(err)
}
}
// supplyRunJSON is a Supply run answer decoded with every field the API
// promises.
type supplyRunJSON struct {
WeekStart string `json:"weekStart"`
Label string `json:"label"`
Title *string `json:"title"`
Status string `json:"status"`
SentToChatAt *string `json:"sent_to_chat_at"`
NeededCount int `json:"needed_count"`
TotalCount int `json:"total_count"`
Groups []struct {
Dinner struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"dinner"`
Date string `json:"date"`
Day string `json:"day"`
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Qty *string `json:"qty"`
Needed bool `json:"needed"`
} `json:"items"`
} `json:"groups"`
}
// render is l one item a line as "day date dinner: name [qty] needed|have
// it".
func (l supplyRunJSON) render() []string {
var out []string
for _, g := range l.Groups {
for _, it := range g.Items {
qty := "null"
if it.Qty != nil {
qty = *it.Qty
}
mark := "needed"
if !it.Needed {
mark = "have it"
}
out = append(out, g.Day+" "+g.Date+" "+g.Dinner.Name+": "+it.Name+" ["+qty+"] "+mark)
}
}
return out
}
// itemID is the id of the item named name.
func (l supplyRunJSON) itemID(t *testing.T, name string) string {
t.Helper()
for _, g := range l.Groups {
for _, it := range g.Items {
if it.Name == name {
return it.ID
}
}
}
t.Fatalf("no item %q", name)
return ""
}
// callShopping makes a request and decodes a 200 answer into out.
func callShopping(t *testing.T, e plansEnv, method, target, body string, out any) (int, map[string]any) {
t.Helper()
rec, got := callDinners(t, e.h, method, target, body)
if rec.Code == http.StatusOK && out != nil {
if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil {
t.Fatal(err)
}
}
return rec.Code, got
}
func keys(m map[string]any) []string {
var out []string
for k := range m {
out = append(out, k)
}
slices.Sort(out)
return out
}
var weekLines = []string{
"Monday 2026-10-12 Beef Chili: ground beef [1 lb] needed",
"Monday 2026-10-12 Beef Chili: kidney beans [2 cans] needed",
"Monday 2026-10-12 Beef Chili: onion [1] needed",
"Tuesday 2026-10-13 Regular 2026-10-13: spaghetti [500 g] needed",
"Tuesday 2026-10-13 Regular 2026-10-13: basil [null] needed",
}
// TestSupplyRunShape generates the week's Supply run and reads it back:
// both answers carry exactly the promised fields.
func TestSupplyRunShape(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", nil); status != http.StatusNotFound || errorCode(body) != "not_found" {
t.Fatalf("GET before generating = %d %v, want 404 not_found", status, body)
}
var made supplyRunJSON
status, raw := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &made)
if status != http.StatusOK {
t.Fatalf("generate = %d %v", status, raw)
}
wantTop := []string{"groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "total_count", "weekStart"}
if got := keys(raw); !reflect.DeepEqual(got, wantTop) {
t.Errorf("generate fields = %v, want %v", got, wantTop)
}
if skipped, _ := raw["skipped"].([]any); len(skipped) != 5 {
t.Errorf("skipped = %v, want the five days without a recipe", raw["skipped"])
}
var got supplyRunJSON
status, raw = callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &got)
if status != http.StatusOK {
t.Fatalf("GET = %d %v", status, raw)
}
if k := keys(raw); !reflect.DeepEqual(k, slices.DeleteFunc(slices.Clone(wantTop), func(s string) bool { return s == "skipped" })) {
t.Errorf("GET fields = %v", k)
}
group := raw["groups"].([]any)[0].(map[string]any)
if k := keys(group); !reflect.DeepEqual(k, []string{"date", "day", "dinner", "items"}) {
t.Errorf("group fields = %v", k)
}
if k := keys(group["dinner"].(map[string]any)); !reflect.DeepEqual(k, []string{"id", "name"}) {
t.Errorf("dinner fields = %v", k)
}
if k := keys(group["items"].([]any)[0].(map[string]any)); !reflect.DeepEqual(k, []string{"id", "name", "needed", "qty"}) {
t.Errorf("item fields = %v", k)
}
if !reflect.DeepEqual(got, made) {
t.Errorf("GET = %+v, want what generate made %+v", got, made)
}
if got.WeekStart != "2026-10-12" || got.Label != "Week 42 · Oct 12 – 18" || got.Title != nil ||
got.Status != "current" || got.SentToChatAt != nil || got.NeededCount != 5 || got.TotalCount != 5 {
t.Errorf("GET = %+v", got)
}
if !reflect.DeepEqual(got.render(), weekLines) {
t.Errorf("items =\n %s\nwant\n %s", strings.Join(got.render(), "\n "), strings.Join(weekLines, "\n "))
}
if got.Groups[0].Dinner.ID == "" || got.Groups[0].Items[0].ID == "" {
t.Errorf("ids missing: %+v", got.Groups[0])
}
}
// TestShoppingItemToggle marks items "have it" and needed again: each
// answer carries the item and the new counts, and the list read back
// agrees.
func TestShoppingItemToggle(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
var l supplyRunJSON
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l); status != http.StatusOK {
t.Fatalf("generate = %d %v", status, body)
}
onion, basil := l.itemID(t, "onion"), l.itemID(t, "basil")
for _, step := range []struct {
id string
needed bool
neededCount int
wantAfterGet string
}{
{onion, false, 4, "Monday 2026-10-12 Beef Chili: onion [1] have it"},
{onion, false, 4, "Monday 2026-10-12 Beef Chili: onion [1] have it"}, // sets, never flips
{basil, false, 3, "Tuesday 2026-10-13 Regular 2026-10-13: basil [null] have it"},
{onion, true, 4, "Monday 2026-10-12 Beef Chili: onion [1] needed"},
} {
body := `{"needed":false}`
if step.needed {
body = `{"needed":true}`
}
status, got := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+step.id, body, nil)
want := map[string]any{"id": step.id, "needed": step.needed, "needed_count": float64(step.neededCount), "total_count": float64(5)}
if status != http.StatusOK || !reflect.DeepEqual(got, want) {
t.Fatalf("PATCH %s = %d %v, want %v", body, status, got, want)
}
var after supplyRunJSON
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &after)
if after.NeededCount != step.neededCount || after.TotalCount != 5 || !slices.Contains(after.render(), step.wantAfterGet) {
t.Errorf("GET after PATCH %s = %d/%d %v", body, after.NeededCount, after.TotalCount, after.render())
}
}
}
func TestShoppingItemToggleErrors(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
var l supplyRunJSON
callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l)
onion := l.itemID(t, "onion")
for _, tc := range []struct {
name, id, body string
status int
code string
}{
{"no needed", onion, `{}`, http.StatusBadRequest, "invalid_request"},
{"needed null", onion, `{"needed":null}`, http.StatusBadRequest, "invalid_request"},
{"needed not a bool", onion, `{"needed":"no"}`, http.StatusBadRequest, "invalid_request"},
{"unknown field", onion, `{"needed":false,"qty":"2"}`, http.StatusBadRequest, "invalid_request"},
{"unknown item", store.NewID(), `{"needed":false}`, http.StatusNotFound, "not_found"},
} {
status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+tc.id, tc.body, nil)
if status != tc.status || errorCode(body) != tc.code {
t.Errorf("%s: PATCH = %d %v, want %d %s", tc.name, status, body, tc.status, tc.code)
}
}
e.archive(t)
status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+onion, `{"needed":false}`, nil)
if status != http.StatusConflict || errorCode(body) != "archived" {
t.Errorf("PATCH of an archived list = %d %v, want 409 archived", status, body)
}
var after supplyRunJSON
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &after)
if after.Status != "archived" || !reflect.DeepEqual(after.render(), weekLines) || after.NeededCount != 5 {
t.Errorf("archived list after PATCH = %s %v, want unchanged", after.Status, after.render())
}
}
// TestSupplyRunRegenerate remakes the list after a swap: the swapped-out
// dinner's group goes, and what the household marked "have it" stays.
func TestSupplyRunRegenerate(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
var l supplyRunJSON
callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l)
onion := l.itemID(t, "onion")
callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+onion, `{"needed":false}`, nil)
if _, err := e.svc.Swap(t.Context(), mustLocalDate(t, "2026-10-12"), "2026-10-13", e.questLog); err != nil {
t.Fatal(err)
}
var again supplyRunJSON
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &again); status != http.StatusOK {
t.Fatalf("generate again = %d %v", status, body)
}
want := []string{
"Monday 2026-10-12 Beef Chili: ground beef [1 lb] needed",
"Monday 2026-10-12 Beef Chili: kidney beans [2 cans] needed",
"Monday 2026-10-12 Beef Chili: onion [1] have it",
}
if !reflect.DeepEqual(again.render(), want) || again.NeededCount != 2 || again.TotalCount != 3 {
t.Errorf("remade = %d/%d %v, want 2/3 %v", again.NeededCount, again.TotalCount, again.render(), want)
}
if again.itemID(t, "onion") != onion {
t.Error("onion has a new id after the remake")
}
}
func TestSupplyRunErrors(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
for _, tc := range []struct {
name, method, path string
status int
code string
}{
{"GET not a Monday", http.MethodGet, "2026-10-13", http.StatusBadRequest, "invalid_week"},
{"GET no list", http.MethodGet, "2026-10-19", http.StatusNotFound, "not_found"},
{"generate not a Monday", http.MethodPost, "2026-10-13/generate", http.StatusBadRequest, "invalid_week"},
{"generate no plan", http.MethodPost, "2026-10-19/generate", http.StatusNotFound, "not_found"},
} {
status, body := callShopping(t, e, tc.method, shoppingPath+tc.path, "", nil)
if status != tc.status || errorCode(body) != tc.code {
t.Errorf("%s = %d %v, want %d %s", tc.name, status, body, tc.status, tc.code)
}
}
callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
e.archive(t)
status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
if status != http.StatusConflict || errorCode(body) != "archived" {
t.Errorf("generate of an archived list = %d %v, want 409 archived", status, body)
}
}
// TestPushPlanMakesSupplyRun: a Push to Calendar makes the week's Supply
// run from the plan pushed.
func TestPushPlanMakesSupplyRun(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
e.connectCalendar(t, true)
rec, body := e.push(t, "2026-10-12")
if rec.Code != http.StatusOK || body["status"] != "synced" {
t.Fatalf("push = %d %s", rec.Code, rec.Body)
}
if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) {
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
}
var l supplyRunJSON
if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l); status != http.StatusOK {
t.Fatalf("GET after push = %d %v, want the Supply run", status, got)
}
if !reflect.DeepEqual(l.render(), weekLines) {
t.Errorf("Supply run after push = %v", l.render())
}
}
// heldTitler is a shopping.Titler that reports each call on entered and
// answers title only once release is closed.
type heldTitler struct {
entered chan struct{}
release chan struct{}
}
func newHeldTitler() heldTitler {
return heldTitler{entered: make(chan struct{}, 4), release: make(chan struct{})}
}
func (h heldTitler) title(ctx context.Context, _ string, _ skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
h.entered <- struct{}{}
select {
case <-h.release:
return skills.ArchiveTitle{Title: "Chili and Friends"}, nil
case <-ctx.Done():
return skills.ArchiveTitle{}, ctx.Err()
}
}
// startRunner starts e's job runner, stopping it when the test ends. e's
// logs must not be read while it runs.
func (e plansEnv) startRunner(t *testing.T) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
if err := e.runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
e.runner.Wait()
})
}
// waitEntered waits for the title call to begin.
func (h heldTitler) waitEntered(t *testing.T) {
t.Helper()
select {
case <-h.entered:
case <-time.After(10 * time.Second):
t.Fatal("archive_title was never called")
}
}
// waitTitled waits for the week's Supply run to have a title through GET
// and returns it.
func (e plansEnv) waitTitled(t *testing.T) string {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
var l supplyRunJSON
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
if l.Title != nil {
return *l.Title
}
if time.Now().After(deadline) {
t.Fatal("the Supply run was never titled")
}
time.Sleep(5 * time.Millisecond)
}
}
// TestPushPlanDoesNotWaitForTitle: Push to Calendar answers while the
// Supply run's title is still being written; the list reads untitled until
// the title job stores it.
func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
t.Parallel()
held := newHeldTitler()
e := newPlansRouterTitled(t, held.title)
e.groundWeek(t)
e.connectCalendar(t, true)
e.startRunner(t)
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/push-calendar", "")
if rec.Code != http.StatusOK || body["status"] != "synced" {
t.Fatalf("push = %d %s", rec.Code, rec.Body)
}
if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) {
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
}
held.waitEntered(t)
var l supplyRunJSON
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
if l.Title != nil {
t.Errorf("title = %q while archive_title is still running, want null", *l.Title)
}
close(held.release)
if got := e.waitTitled(t); got != "Chili and Friends" {
t.Errorf("title = %q, want the title job's", got)
}
}
// TestSupplyRunGenerateDoesNotWaitForTitle: POST generate answers with the
// list untitled while its title is still being written.
func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
t.Parallel()
held := newHeldTitler()
e := newPlansRouterTitled(t, held.title)
e.groundWeek(t)
e.startRunner(t)
var l supplyRunJSON
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l); status != http.StatusOK {
t.Fatalf("generate = %d %v", status, body)
}
if l.Title != nil {
t.Errorf("title = %q while archive_title is still running, want null", *l.Title)
}
if !reflect.DeepEqual(l.render(), weekLines) {
t.Errorf("generated = %v, want %v", l.render(), weekLines)
}
held.waitEntered(t)
close(held.release)
if got := e.waitTitled(t); got != "Chili and Friends" {
t.Errorf("title = %q, want the title job's", got)
}
}
// TestFailedPushMakesNoSupplyRun: a push Calendar refused makes no Supply
// run.
func TestFailedPushMakesNoSupplyRun(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
if rec, _ := e.push(t, "2026-10-12"); rec.Code != http.StatusConflict {
t.Fatalf("push unconnected = %d %s", rec.Code, rec.Body)
}
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", nil); status != http.StatusNotFound {
t.Errorf("GET after a failed push = %d %v, want 404", status, body)
}
}
// TestPushPlanSupplyRunFails: when the Supply run cannot be made after the
// push, the push stands (200, synced, on Calendar), the answer says the
// Supply run failed, the failure is logged as an error, and no Supply run
// is claimed.
func TestPushPlanSupplyRunFails(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE recipe_ingredients SET name = NULL WHERE name = 'onion'`); err != nil {
t.Fatal(err)
}
e.connectCalendar(t, true)
rec, body := e.push(t, "2026-10-12")
if rec.Code != http.StatusOK || body["status"] != "synced" {
t.Fatalf("push = %d %s, want 200 synced", rec.Code, rec.Body)
}
want := map[string]any{"status": "failed", "error": map[string]any{
"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,
}}
if !reflect.DeepEqual(body["supply_run"], want) {
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
}
if n := len(e.google.Events(pushCalendar.ID)); n != 7 {
t.Errorf("calendar has %d events, want 7", n)
}
p, err := e.svc.Get(t.Context(), mustLocalDate(t, "2026-10-12"))
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusSynced {
t.Errorf("plan = %s after the push, want synced", p.Status)
}
if !strings.Contains(e.logs.String(), `"level":"ERROR","msg":"generate Supply run after Push to Calendar"`) ||
!strings.Contains(e.logs.String(), "has no normalized name") {
t.Errorf("logs = %s, want the failure logged as an error", e.logs)
}
if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", nil); status != http.StatusNotFound {
t.Errorf("GET after the failure = %d %v, want 404", status, got)
}
}
// TestPushPlanEditedBeforeSupplyRun: a plan edit landing after the push
// is recorded but before the Supply run is made does not make the Supply
// run from the edited plan, which Calendar does not show. The push stands
// (200, on Calendar) and the answer says the Supply run was not made
// because the plan changed.
func TestPushPlanEditedBeforeSupplyRun(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
e.connectCalendar(t, true)
// Once the push records Sunday's event, the household swaps Tuesday to
// Pad Thai, as a swap does: committed before the Supply run is made.
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER edit_after_push
AFTER UPDATE OF calendar_event_id ON plan_days
WHEN NEW.date = '2026-10-18' AND NEW.calendar_event_id IS NOT NULL
BEGIN
UPDATE plan_days SET dinner_id = '`+e.questLog+`', is_new = 1, calendar_event_id = NULL WHERE date = '2026-10-13';
UPDATE week_plans SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END, revision = revision + 1;
END`); err != nil {
t.Fatal(err)
}
rec, body := e.push(t, "2026-10-12")
if rec.Code != http.StatusOK {
t.Fatalf("push = %d %s, want 200", rec.Code, rec.Body)
}
want := map[string]any{"status": "failed", "error": map[string]any{
"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,
}}
if !reflect.DeepEqual(body["supply_run"], want) {
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
}
if n := len(e.google.Events(pushCalendar.ID)); n != 7 {
t.Errorf("calendar has %d events, want 7", n)
}
if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", nil); status != http.StatusNotFound {
t.Errorf("GET after the edit = %d %v, want 404: no Supply run from the edited plan", status, got)
}
if !strings.Contains(e.logs.String(), `"level":"WARN","msg":"plan edited after Push to Calendar; Supply run not made"`) {
t.Errorf("logs = %s, want the edit logged as a warning", e.logs)
}
}
// TestPushPlanLeavesArchivedSupplyRun: pushing a week whose Supply run is
// in the Supply archive leaves it as it was and says so.
func TestPushPlanLeavesArchivedSupplyRun(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
e.archive(t)
if _, err := e.svc.Swap(t.Context(), mustLocalDate(t, "2026-10-12"), "2026-10-13", e.questLog); err != nil {
t.Fatal(err)
}
e.connectCalendar(t, true)
rec, body := e.push(t, "2026-10-12")
if rec.Code != http.StatusOK || body["status"] != "synced" {
t.Fatalf("push = %d %s", rec.Code, rec.Body)
}
run, _ := body["supply_run"].(map[string]any)
if apiErr, _ := run["error"].(map[string]any); run["status"] != "archived" || apiErr["code"] != "archived" {
t.Errorf("supply_run = %v, want archived", body["supply_run"])
}
var l supplyRunJSON
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
if !reflect.DeepEqual(l.render(), weekLines) {
t.Errorf("archived Supply run after push = %v, want unchanged", l.render())
}
}
// archiveJSON is a GET /shopping/archive answer decoded with every field
// the API promises.
type archiveJSON struct {
By string `json:"by"`
Periods []struct {
Key string `json:"key"`
Heading string `json:"heading"`
Posts []struct {
WeekStart string `json:"weekStart"`
Label string `json:"label"`
Title *string `json:"title"`
DinnerCount int `json:"dinnerCount"`
ItemCount int `json:"itemCount"`
OnHandCount int `json:"onHandCount"`
Dinners []string `json:"dinners"`
Current bool `json:"current"`
} `json:"posts"`
} `json:"periods"`
}
// weekSupplyRun plans a new dinner named name, grounded in a recipe
// listing ings, on the Monday of the week starting monday, and generates
// the week's Supply run.
func (e plansEnv) weekSupplyRun(t *testing.T, monday store.LocalDate, name string, ings ...shopIng) supplyRunJSON {
t.Helper()
q := db.New(e.st.Writer)
now := store.Now().String()
d, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "regular", Origin: "regular",
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
planID := store.NewID()
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: planID, WeekStart: monday.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: monday.String(),
DinnerID: sql.NullString{String: d.ID, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
e.groundDay(t, monday.String(), ings...)
var l supplyRunJSON
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+monday.String()+"/generate", "", &l); status != http.StatusOK {
t.Fatalf("generate %s = %d %v", monday, status, body)
}
return l
}
// TestSupplyArchive freezes last week's Supply run and reads the Supply
// archive: posts newest first with exactly the promised fields, this
// week's marked current. The frozen list is served read-only.
func TestSupplyArchive(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
// The fixture's plan is for a fixed week; this test plans weeks
// around today instead.
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM plan_days; DELETE FROM week_plans`); err != nil {
t.Fatal(err)
}
zone, err := household.LoadZone("America/New_York") // the env's household zone
if err != nil {
t.Fatal(err)
}
thisWeek := household.WeekStart(household.Today(time.Now(), zone))
y, m, d := thisWeek.Date()
lastWeek := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
last := e.weekSupplyRun(t, lastWeek, "Beef Stew", shopIng{"stewing beef", "2", "lb"}, shopIng{"carrots", "4", ""}, shopIng{"onion", "1", ""})
onion := last.itemID(t, "onion")
if status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+onion, `{"needed":false}`, nil); status != http.StatusOK {
t.Fatalf("PATCH = %d %v", status, body)
}
e.weekSupplyRun(t, thisWeek, "Lemon Chicken", shopIng{"chicken thighs", "6", ""}, shopIng{"lemon", "1", ""})
if err := e.shop.Freeze(t.Context(), lastWeek); err != nil {
t.Fatal(err)
}
if err := e.shop.Freeze(t.Context(), thisWeek); !errors.Is(err, shopping.ErrWeekNotEnded) {
t.Fatalf("Freeze this week = %v, want ErrWeekNotEnded", err)
}
var got archiveJSON
status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got)
if status != http.StatusOK {
t.Fatalf("GET archive = %d %v", status, raw)
}
if k := keys(raw); !reflect.DeepEqual(k, []string{"by", "periods"}) {
t.Errorf("archive fields = %v", k)
}
period := raw["periods"].([]any)[0].(map[string]any)
if k := keys(period); !reflect.DeepEqual(k, []string{"heading", "key", "posts"}) {
t.Errorf("period fields = %v", k)
}
wantPost := []string{"current", "dinnerCount", "dinners", "itemCount", "label", "onHandCount", "title", "weekStart"}
if k := keys(period["posts"].([]any)[0].(map[string]any)); !reflect.DeepEqual(k, wantPost) {
t.Errorf("post fields = %v, want %v", k, wantPost)
}
var posts []string
for _, p := range got.Periods {
if len(p.Posts) != 1 || p.Key != household.PeriodKey(mustLocalDate(t, p.Posts[0].WeekStart)) || p.Heading != p.Posts[0].Label {
t.Errorf("period %+v, want one post under its week", p)
}
for _, post := range p.Posts {
label, err := household.WeekLabel(mustLocalDate(t, post.WeekStart))
if err != nil {
t.Fatal(err)
}
if post.Label != label || post.Title != nil {
t.Errorf("post %s: label %q title %v, want %q and null", post.WeekStart, post.Label, post.Title, label)
}
posts = append(posts, fmt.Sprintf("%s %d dinners %d items %d on hand %v current=%t",
post.WeekStart, post.DinnerCount, post.ItemCount, post.OnHandCount, post.Dinners, post.Current))
}
}
want := []string{
thisWeek.String() + " 1 dinners 2 items 0 on hand [Lemon Chicken] current=true",
lastWeek.String() + " 1 dinners 3 items 1 on hand [Beef Stew] current=false",
}
if got.By != "week" || !reflect.DeepEqual(posts, want) {
t.Errorf("archive by %s =\n %s\nwant\n %s", got.By, strings.Join(posts, "\n "), strings.Join(want, "\n "))
}
for _, q := range []string{"", "?by=", "?by=day", "?by=MONTH"} {
status, body := callShopping(t, e, http.MethodGet, shoppingPath+"archive"+q, "", nil)
if status != http.StatusBadRequest || errorCode(body) != "invalid_request" {
t.Errorf("GET archive%s = %d %v, want 400 invalid_request", q, status, body)
}
}
status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+last.itemID(t, "carrots"), `{"needed":false}`, nil)
if status != http.StatusConflict || errorCode(body) != "archived" {
t.Errorf("PATCH after Freeze = %d %v, want 409 archived", status, body)
}
var frozen supplyRunJSON
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+lastWeek.String(), "", &frozen); status != http.StatusOK {
t.Fatalf("GET frozen list = %d %v", status, body)
}
wantLines := []string{
"Monday " + lastWeek.String() + " Beef Stew: stewing beef [2 lb] needed",
"Monday " + lastWeek.String() + " Beef Stew: carrots [4] needed",
"Monday " + lastWeek.String() + " Beef Stew: onion [1] have it",
}
if frozen.Status != "archived" || frozen.NeededCount != 2 || frozen.TotalCount != 3 || !reflect.DeepEqual(frozen.render(), wantLines) {
t.Errorf("frozen list = %s %d/%d %v, want archived 2/3 %v", frozen.Status, frozen.NeededCount, frozen.TotalCount, frozen.render(), wantLines)
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
t.Fatal(err) t.Fatal(err)
} }
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), svc, newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+232
View File
@@ -0,0 +1,232 @@
package shopping
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// Archive groupings: the period Archive groups its posts by.
const (
ByWeek = "week"
ByMonth = "month"
ByYear = "year"
)
// Freeze moves the Supply run of the week starting weekStart into the
// Supply archive: its status becomes archived and frozen_at is set, after
// which it is read-only (Generate and SetNeeded are ErrFrozen). Freezing a
// frozen list does nothing and is no error.
//
// The week must have ended in the household's time zone: a week not yet
// ended is ErrWeekNotEnded, and a week with no Supply run ErrListNotFound.
//
// A list still untitled is titled first, with archive_title and the same
// conditional write as a KindTitleSupplyRun job, from the week's plan as
// Generate titles it, so its post in the Supply archive gets a title when
// the AI can write one. Titling never fails the freeze: a title that
// cannot be written is logged and the list is frozen untitled, when its
// week label stands in for the title for good. Freeze first waits for the
// title jobs in flight to store their titles or fail, and holds off new
// ones until the list is frozen, so a title a job's archive_title run
// wrote is stored before the freeze, and the freeze runs archive_title
// only when no job is running it. A job that runs after the freeze finds
// the list titled, or frozen untitled, and stores nothing.
func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error {
if _, err := household.WeekDays(weekStart); err != nil {
return fmt.Errorf("shopping: freeze: %w", err)
}
q := db.New(s.st.Writer)
thisWeek, err := s.thisWeek(ctx, q)
if err != nil {
return err
}
if weekStart.String() >= thisWeek.String() {
return fmt.Errorf("shopping: freeze %s: %w", weekStart, ErrWeekNotEnded)
}
s.titling.Lock()
defer s.titling.Unlock()
list, err := q.GetShoppingList(ctx, weekStart.String())
if errors.Is(err, sql.ErrNoRows) {
return ErrListNotFound
}
if err != nil {
return fmt.Errorf("shopping: freeze: read list: %w", err)
}
if frozen(list) {
return nil
}
if !list.Title.Valid {
p, err := freezeTitleJob(ctx, q, list)
if err != nil {
return err
}
if _, err := s.storeTitle(ctx, p); err != nil {
s.logger.WarnContext(ctx, "Supply run frozen untitled; the Supply archive shows its week label",
"week", list.WeekStart, "list", list.ID, "error", err)
}
}
// A list frozen meanwhile is left as it is: the freeze is done.
now := store.Now().String()
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
FrozenAt: sql.NullString{String: now, Valid: true}, UpdatedAt: now, ID: list.ID,
}); err != nil {
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
}
return nil
}
// freezeTitleJob is the title job of list as Generate would enqueue it:
// the week's plan revision and its dinners in day order.
func freezeTitleJob(ctx context.Context, q *db.Queries, list db.ShoppingList) (TitleJob, error) {
plan, err := q.GetWeekPlan(ctx, list.WeekStart)
if err != nil {
return TitleJob{}, fmt.Errorf("shopping: freeze: read plan: %w", err)
}
days, err := q.ListPlanDays(ctx, plan.ID)
if err != nil {
return TitleJob{}, fmt.Errorf("shopping: freeze: read plan days: %w", err)
}
names := make([]string, len(days))
for i, d := range days {
names[i] = d.Dinner.Name
}
return TitleJob{ListID: list.ID, WeekStart: list.WeekStart, Revision: plan.Revision, Dinners: names}, nil
}
// thisWeek returns the Monday of the household's current week, in its
// time zone, by the service's clock.
func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate, error) {
settings, err := q.GetHouseholdSettings(ctx)
if err != nil {
return store.LocalDate{}, fmt.Errorf("shopping: read household settings: %w", err)
}
zone, err := household.LoadZone(settings.Timezone)
if err != nil {
return store.LocalDate{}, fmt.Errorf("shopping: household settings: %w", err)
}
return household.WeekStart(household.Today(s.now(), zone)), nil
}
// Period is one heading of the Supply archive and its posts, newest first.
type Period struct {
// Key identifies the period: the ISO week ("2026-W41"), the month
// ("2026-10") or the year ("2026").
Key string
// Heading is the period as shown: the week label, the month and year
// ("October 2026") or the year ("2026").
Heading string
Posts []Post
}
// Post is one Supply run in the Supply archive.
type Post struct {
WeekStart string
// Label is the week label, such as "Week 41 · Oct 5 – 11".
Label string
// Title is nil while the list is untitled, when Label stands in.
Title *string
// Dinners are the names of the list's dinners, in day order.
Dinners []string
// Items is how many items the list has, and OnHand how many of them
// the household marked "have it".
Items, OnHand int
// Current is true for the household's current week.
Current bool
}
// Archive returns the Supply archive: the Supply run of every week up to
// and including the household's current week, newest first, grouped by
// by, which is ByWeek, ByMonth or ByYear (else ErrInvalidPeriod). A
// week's month and year are those of its Monday, a household-local date,
// so a week belongs to one period whatever the server's zone; the week
// starting Monday 2026-12-28 is in December 2026.
func (s *Service) Archive(ctx context.Context, by string) (out []Period, err error) {
// period returns the key and heading of the period holding p, whose
// week starts on Monday monday.
var period func(monday store.LocalDate, p Post) (string, string)
switch by {
case ByWeek:
period = func(monday store.LocalDate, p Post) (string, string) {
return household.PeriodKey(monday), p.Label
}
case ByMonth:
period = func(monday store.LocalDate, _ Post) (string, string) {
y, m, _ := monday.Date()
return fmt.Sprintf("%04d-%02d", y, int(m)), fmt.Sprintf("%s %d", m, y)
}
case ByYear:
period = func(monday store.LocalDate, _ Post) (string, string) {
y, _, _ := monday.Date()
return strconv.Itoa(y), strconv.Itoa(y)
}
default:
return nil, fmt.Errorf("shopping: archive by %q: %w", by, ErrInvalidPeriod)
}
tx, err := s.st.Reader.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("shopping: begin read: %w", err)
}
defer func() {
err = errors.Join(err, tx.Rollback())
}()
q := db.New(tx)
thisWeek, err := s.thisWeek(ctx, q)
if err != nil {
return nil, err
}
lists, err := q.ListShoppingListsThrough(ctx, thisWeek.String())
if err != nil {
return nil, fmt.Errorf("shopping: read Supply runs: %w", err)
}
out = []Period{}
for _, row := range lists {
monday, err := store.ParseLocalDate(row.WeekStart)
if err != nil {
return nil, fmt.Errorf("shopping: Supply run %s week: %w", row.ID, err)
}
l, err := readList(ctx, q, row)
if err != nil {
return nil, err
}
p, err := postOf(monday, l, monday == thisWeek)
if err != nil {
return nil, err
}
k, heading := period(monday, p)
if n := len(out); n == 0 || out[n-1].Key != k {
out = append(out, Period{Key: k, Heading: heading})
}
out[len(out)-1].Posts = append(out[len(out)-1].Posts, p)
}
return out, nil
}
// postOf is l, the Supply run of the week starting monday, as a post in
// the Supply archive.
func postOf(monday store.LocalDate, l List, current bool) (Post, error) {
label, err := household.WeekLabel(monday)
if err != nil {
return Post{}, fmt.Errorf("shopping: Supply run %s: %w", l.ID, err)
}
dinners := make([]string, len(l.Groups))
for i, g := range l.Groups {
dinners[i] = g.DinnerName
}
c := l.Counts()
return Post{
WeekStart: l.WeekStart,
Label: label,
Title: l.Title,
Dinners: dinners,
Items: c.Total,
OnHand: c.Total - c.Needed,
Current: current,
}, nil
}
+477
View File
@@ -0,0 +1,477 @@
package shopping_test
import (
"context"
"database/sql"
"errors"
"reflect"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// zoned makes zone the household's time zone and at the instant the
// service's clock reads.
func (e *env) zoned(t *testing.T, zone, at string) {
t.Helper()
if err := household.SeedSettings(t.Context(), e.st, zone); err != nil {
t.Fatal(err)
}
e.at(t, at)
}
// at sets the service's clock to the RFC 3339 instant at.
func (e *env) at(t *testing.T, at string) {
t.Helper()
now, err := time.Parse(time.RFC3339, at)
if err != nil {
t.Fatal(err)
}
shopping.SetClock(e.svc, func() time.Time { return now })
}
// frozenState returns the week's list's status and frozen_at; frozen_at is
// "" while NULL.
func (e *env) frozenState(t *testing.T, monday store.LocalDate) (string, string) {
t.Helper()
var status string
var frozenAt sql.NullString
if err := e.st.Reader.QueryRow(`SELECT status, frozen_at FROM shopping_lists WHERE week_start = ?`, monday.String()).Scan(&status, &frozenAt); err != nil {
t.Fatal(err)
}
return status, frozenAt.String
}
// weekList plans the dinners named names on the days of the week starting
// monday, in order, and generates its Supply run.
func (e *env) weekList(t *testing.T, monday string, names ...string) shopping.List {
t.Helper()
start := mustDate(monday)
days, err := household.WeekDays(start)
if err != nil {
t.Fatal(err)
}
q := db.New(e.st.Writer)
now := store.Now().String()
planID := store.NewID()
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: planID, WeekStart: monday, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
for i, name := range names {
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{
ID: store.NewID(), PlanID: planID, Date: days[i].String(),
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
}
g, err := e.svc.Generate(t.Context(), start)
if err != nil {
t.Fatal(err)
}
return g.List
}
// endedWeek is the env with the tests' week generated, in New York, on
// the Monday after it, so the week has ended.
func endedWeek(t *testing.T) (*env, shopping.List) {
t.Helper()
e := newEnv(t)
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
e.plan(t, "Beef Chili")
return e, e.generate(t).List
}
// TestFreeze freezes an ended week's list: it is archived with frozen_at,
// read back read-only, and freezing it again changes nothing.
func TestFreeze(t *testing.T) {
e, l := endedWeek(t)
if err := e.svc.Freeze(t.Context(), week); err != nil {
t.Fatal(err)
}
status, frozenAt := e.frozenState(t, week)
if status != shopping.StatusArchived || frozenAt == "" {
t.Fatalf("after Freeze: status %q, frozen_at %q; want archived and set", status, frozenAt)
}
got, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if got.Status != shopping.StatusArchived {
t.Errorf("read back status = %q, want archived", got.Status)
}
wantLines(t, "read back", lines(got), []string{
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
"0 2026-10-12 Beef Chili: 1 onion [1] needed",
})
time.Sleep(2 * time.Millisecond) // a second freeze would stamp a later frozen_at
if err := e.svc.Freeze(t.Context(), week); err != nil {
t.Fatalf("second Freeze = %v, want no error", err)
}
if s, again := e.frozenState(t, week); s != shopping.StatusArchived || again != frozenAt {
t.Errorf("after second Freeze: status %q, frozen_at %q; want archived, %q kept", s, again, frozenAt)
}
if _, err := e.svc.SetNeeded(t.Context(), l.Groups[0].Items[1].ID, false); !errors.Is(err, shopping.ErrFrozen) {
t.Errorf("SetNeeded after Freeze = %v, want ErrFrozen", err)
}
if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrFrozen) {
t.Errorf("Generate after Freeze = %v, want ErrFrozen", err)
}
wantLines(t, "stored list", e.stored(t), []string{
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
"0 2026-10-12 Beef Chili: 1 onion [1] needed",
})
}
// TestFreezeWeekNotEnded: a week has not ended until Monday comes in the
// household's zone. At 23:30 on the Sunday in Los Angeles it is already
// Monday in UTC, and the list is still not frozen; at 00:05 on the Monday
// it is.
func TestFreezeWeekNotEnded(t *testing.T) {
e := newEnv(t)
e.zoned(t, "America/Los_Angeles", "2026-10-19T06:30:00Z") // Sun 18 Oct 23:30 PDT
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Beef Chili")
e.generate(t)
if err := e.svc.Freeze(t.Context(), week); !errors.Is(err, shopping.ErrWeekNotEnded) {
t.Fatalf("Freeze on the Sunday = %v, want ErrWeekNotEnded", err)
}
if status, frozenAt := e.frozenState(t, week); status != shopping.StatusCurrent || frozenAt != "" {
t.Fatalf("after refused Freeze: status %q, frozen_at %q; want current, unset", status, frozenAt)
}
if err := e.svc.Freeze(t.Context(), mustDate("2026-10-19")); !errors.Is(err, shopping.ErrWeekNotEnded) {
t.Errorf("Freeze of a later week = %v, want ErrWeekNotEnded", err)
}
e.at(t, "2026-10-19T07:05:00Z") // Mon 19 Oct 00:05 PDT
if err := e.svc.Freeze(t.Context(), week); err != nil {
t.Fatalf("Freeze on the Monday = %v", err)
}
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
t.Errorf("status = %q, want archived", status)
}
}
func TestFreezeWeekWithoutList(t *testing.T) {
e := newEnv(t)
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
if err := e.svc.Freeze(t.Context(), week); !errors.Is(err, shopping.ErrListNotFound) {
t.Fatalf("Freeze = %v, want ErrListNotFound", err)
}
if err := e.svc.Freeze(t.Context(), mustDate("2026-10-13")); err == nil {
t.Fatal("Freeze of a Tuesday succeeded")
}
}
// TestFreezeTitlesUntitledList: a list still untitled when its week is
// frozen is titled by archive_title first, from the plan's dinners, and
// frozen with that title. Its title job, still queued, runs after the
// freeze, finds the title and succeeds without asking the model again.
func TestFreezeTitlesUntitledList(t *testing.T) {
e := newEnv(t)
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.dinner(t, "Fish Tacos")
e.plan(t, "Fish Tacos", "Beef Chili")
g := e.generate(t) // the runner is not started: its title job waits
e.model.QueueGenerate(titled("Tacos then Chili"))
if err := e.svc.Freeze(t.Context(), week); err != nil {
t.Fatal(err)
}
wantTitle(t, "stored title", e.storedTitle(t), "Tacos then Chili")
if status, frozenAt := e.frozenState(t, week); status != shopping.StatusArchived || frozenAt == "" {
t.Errorf("status %q, frozen_at %q; want archived and set", status, frozenAt)
}
reqs := e.model.Requests()
if len(reqs) != 1 {
t.Fatalf("%d model calls, want 1", len(reqs))
}
if want := "Title the week whose dinners are, in day order from Monday:\n- Fish Tacos\n- Beef Chili"; reqs[0].Messages[0].Content != want {
t.Errorf("user message = %q, want %q", reqs[0].Messages[0].Content, want)
}
e.start(t)
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
if n := len(e.model.Requests()); n != 1 {
t.Errorf("%d model calls, want 1: the job finds the list titled", n)
}
wantTitle(t, "stored title after the job", e.storedTitle(t), "Tacos then Chili")
}
// pauseTitle makes the env's first archive_title run pause once it has
// returned, before its title is stored, until the returned release is
// called or the test ends; paused is closed as it pauses.
func (e *env) pauseTitle(t *testing.T) (paused <-chan struct{}, release func()) {
t.Helper()
p, r := make(chan struct{}), make(chan struct{})
var first atomic.Bool
e.afterTitle = func() {
if first.CompareAndSwap(false, true) {
close(p)
select {
case <-r:
case <-t.Context().Done():
}
}
}
var released sync.Once
return p, func() { released.Do(func() { close(r) }) }
}
// waitClosed fails t unless c is closed within 10 seconds.
func waitClosed(t *testing.T, what string, c <-chan struct{}) {
t.Helper()
select {
case <-c:
case <-time.After(10 * time.Second):
t.Fatalf("timed out waiting for %s", what)
}
}
// TestFreezeWaitsForTitleJob: a freeze that starts while a title job's
// archive_title run has returned but its title is not yet stored waits for
// the job to store it, freezes the list with that title, and asks the
// model nothing itself.
func TestFreezeWaitsForTitleJob(t *testing.T) {
e := newEnv(t)
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Beef Chili")
e.model.QueueGenerate(titled("Chili Night"))
paused, release := e.pauseTitle(t)
e.start(t)
g := e.generate(t)
waitClosed(t, "the title job's archive_title run", paused)
froze := make(chan error, 1)
go func() { froze <- e.svc.Freeze(context.Background(), week) }()
var err error
select {
case err = <-froze:
t.Errorf("Freeze = %v before the title job stored its title, want it to wait", err)
release()
case <-time.After(time.Second):
release()
err = <-froze
}
if err != nil {
t.Fatal(err)
}
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
t.Errorf("status = %q, want archived", status)
}
if n := len(e.model.Requests()); n != 1 {
t.Errorf("%d model calls, want 1: the freeze waits for the job's title", n)
}
}
// TestTitleJobWaitsForFreeze: a title job that starts while a freeze's
// archive_title run has returned but its title is not yet stored waits for
// the freeze, then finds the list titled and asks the model nothing.
func TestTitleJobWaitsForFreeze(t *testing.T) {
e := newEnv(t)
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Beef Chili")
e.model.QueueGenerate(titled("Chili Night"))
g := e.generate(t) // the runner is not started: its title job waits
paused, release := e.pauseTitle(t)
froze := make(chan error, 1)
go func() { froze <- e.svc.Freeze(context.Background(), week) }()
waitClosed(t, "the freeze's archive_title run", paused)
e.start(t)
deadline := time.Now().Add(10 * time.Second)
for {
job, err := e.runner.Get(t.Context(), *g.TitleJobID)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusRunning {
break
}
if job.Status != jobs.StatusQueued || time.Now().After(deadline) {
t.Fatalf("title job %s, want it running while the freeze is paused", job.Status)
}
time.Sleep(5 * time.Millisecond)
}
release()
if err := <-froze; err != nil {
t.Fatal(err)
}
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
t.Errorf("status = %q, want archived", status)
}
if n := len(e.model.Requests()); n != 1 {
t.Errorf("%d model calls, want 1: the job waits for the freeze's title", n)
}
}
// TestFreezeUntitledWhenTitleFails: when archive_title cannot title the
// list, it is frozen untitled all the same, the failure is logged, and the
// Supply archive shows its week label for good.
func TestFreezeUntitledWhenTitleFails(t *testing.T) {
e, _ := endedWeek(t) // nothing queued on the model: archive_title fails
if err := e.svc.Freeze(t.Context(), week); err != nil {
t.Fatal(err)
}
wantUntitled(t, "stored title", e.storedTitle(t))
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
t.Errorf("status = %q, want archived", status)
}
if !strings.Contains(e.log.String(), "Supply run frozen untitled") {
t.Errorf("log lacks the failure:\n%s", e.log.String())
}
periods, err := e.svc.Archive(t.Context(), shopping.ByWeek)
if err != nil {
t.Fatal(err)
}
if len(periods) != 1 || len(periods[0].Posts) != 1 || periods[0].Posts[0].Title != nil ||
periods[0].Posts[0].Label != "Week 42 · Oct 12 – 18" {
t.Errorf("archive = %+v, want the untitled week under its label", periods)
}
}
// TestFreezeKeepsTitle: a titled list is frozen with its title and the
// model is not asked again.
func TestFreezeKeepsTitle(t *testing.T) {
e, _ := endedWeek(t)
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili Night'`); err != nil {
t.Fatal(err)
}
if err := e.svc.Freeze(t.Context(), week); err != nil {
t.Fatal(err)
}
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
if n := len(e.model.Requests()); n != 0 {
t.Errorf("%d model calls, want none", n)
}
}
// postLine renders p as "weekStart label | title | dinners | items/on hand
// | current".
func postLine(p shopping.Post) string {
title := "-"
if p.Title != nil {
title = *p.Title
}
cur := ""
if p.Current {
cur = " | current"
}
return p.WeekStart + " " + p.Label + " | " + title + " | " + strings.Join(p.Dinners, ", ") + " | " +
strconv.Itoa(p.Items) + " items, " + strconv.Itoa(p.OnHand) + " on hand" + cur
}
// periodLines renders periods one line per heading and post.
func periodLines(periods []shopping.Period) []string {
var out []string
for _, p := range periods {
out = append(out, "# "+p.Key+" "+p.Heading)
for _, post := range p.Posts {
out = append(out, postLine(post))
}
}
return out
}
// TestArchiveGroups reads the Supply archive across a month and a year
// boundary in Auckland, where it is already Monday 4 January 2027 while
// UTC still reads Sunday 3 January: that week is current, the next one is
// not yet in the archive, and each week falls in the month and year of
// its Monday.
func TestArchiveGroups(t *testing.T) {
e := newEnv(t)
e.zoned(t, "Pacific/Auckland", "2027-01-03T12:00:00Z") // Mon 4 Jan 2027 01:00 NZDT
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}, ing{"kidney beans", "2", "cans"})
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"lime", "2", ""})
e.dinner(t, "Fish Tacos") // no recipe: no group, not counted
e.dinner(t, "Pot Roast", ing{"carrots", "4", ""})
nov := e.weekList(t, "2026-11-30", "Pot Roast") // Nov 30 – Dec 6
e.weekList(t, "2026-12-07", "Pad Thai", "Fish Tacos")
dec := e.weekList(t, "2026-12-28", "Beef Chili", "Fish Tacos", "Pad Thai") // ISO 2026-W53, into January
jan := e.weekList(t, "2027-01-04", "Pad Thai", "Beef Chili")
e.weekList(t, "2027-01-11", "Pot Roast") // next week: not in the archive yet
e.haveIt(t, dec, "Beef Chili", "onion")
e.haveIt(t, dec, "Pad Thai", "lime")
e.haveIt(t, jan, "Pad Thai", "lime")
e.haveIt(t, nov, "Pot Roast", "carrots")
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili, Noodles & Co' WHERE week_start = '2026-12-28'`); err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2027-01-03T11:05:00.000Z' WHERE week_start < '2027-01-04'`); err != nil {
t.Fatal(err)
}
posts := map[string]string{
"jan": "2027-01-04 Week 1 · Jan 4 – 10 | - | Pad Thai, Beef Chili | 5 items, 1 on hand | current",
"dec": "2026-12-28 Week 53 · Dec 28 – Jan 3 | Chili, Noodles & Co | Beef Chili, Pad Thai | 5 items, 2 on hand",
"d07": "2026-12-07 Week 50 · Dec 7 – 13 | - | Pad Thai | 2 items, 0 on hand",
"nov": "2026-11-30 Week 49 · Nov 30 – Dec 6 | - | Pot Roast | 1 items, 1 on hand",
}
for _, tc := range []struct {
by string
want []string
}{
{shopping.ByWeek, []string{
"# 2027-W01 Week 1 · Jan 4 – 10", posts["jan"],
"# 2026-W53 Week 53 · Dec 28 – Jan 3", posts["dec"],
"# 2026-W50 Week 50 · Dec 7 – 13", posts["d07"],
"# 2026-W49 Week 49 · Nov 30 – Dec 6", posts["nov"],
}},
{shopping.ByMonth, []string{
"# 2027-01 January 2027", posts["jan"],
"# 2026-12 December 2026", posts["dec"], posts["d07"],
"# 2026-11 November 2026", posts["nov"],
}},
{shopping.ByYear, []string{
"# 2027 2027", posts["jan"],
"# 2026 2026", posts["dec"], posts["d07"], posts["nov"],
}},
} {
got, err := e.svc.Archive(t.Context(), tc.by)
if err != nil {
t.Fatalf("Archive(%s): %v", tc.by, err)
}
if lines := periodLines(got); !reflect.DeepEqual(lines, tc.want) {
t.Errorf("Archive(%s) =\n %s\nwant\n %s", tc.by, strings.Join(lines, "\n "), strings.Join(tc.want, "\n "))
}
}
}
func TestArchiveEmptyAndInvalidPeriod(t *testing.T) {
e := newEnv(t)
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
got, err := e.svc.Archive(t.Context(), shopping.ByMonth)
if err != nil || got == nil || len(got) != 0 {
t.Errorf("Archive of no Supply runs = %#v, %v; want empty", got, err)
}
for _, by := range []string{"", "day", "Week", "months"} {
if _, err := e.svc.Archive(t.Context(), by); !errors.Is(err, shopping.ErrInvalidPeriod) {
t.Errorf("Archive(%q) = %v, want ErrInvalidPeriod", by, err)
}
}
}
+6
View File
@@ -0,0 +1,6 @@
package shopping
import "time"
// SetClock makes now the clock s ends and dates weeks on.
func SetClock(s *Service, now func() time.Time) { s.now = now }
+732
View File
@@ -0,0 +1,732 @@
// Package shopping keeps the household's Supply runs: each week's
// shopping list, generated from the week's plan and grouped by dinner, and
// the Supply archive they are frozen into.
//
// Generate makes a week's Supply run from its plan, and remakes it after
// the plan changes. It is deterministic and runs no model: an item is one
// ingredient of the dinner's recipe exactly as recipe grounding stored it
// (see Generate). The one model call is the Supply run's title, written by
// archive_title in a KindTitleSupplyRun job when the list is generated
// untitled (see Generate).
//
// Freeze moves a week's Supply run into the Supply archive once its week
// has ended, and Archive reads the archive back as posts grouped by week,
// month or year.
package shopping
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// List statuses (the shopping_lists.status column).
const (
StatusCurrent = "current"
StatusArchived = "archived"
)
// Reasons a plan day has no group on its Supply run.
const (
// SkipNoRecipe: the day's dinner has no recipe, as while its grounding
// is pending or after it failed.
SkipNoRecipe = "no_recipe"
// SkipNoIngredients: the day's dinner's recipe lists no ingredients.
SkipNoIngredients = "no_ingredients"
)
// KindTitleSupplyRun is the job kind that titles a Supply run with
// archive_title. Only Generate and GeneratePushed enqueue it.
const KindTitleSupplyRun = "title_supply_run"
// ReasonTitleFailed is the failure code of a KindTitleSupplyRun job that
// stored no title: archive_title failed, or the Supply run was frozen or
// replaced first. The Supply run stays untitled.
const ReasonTitleFailed = "title_failed"
// ErrPlanNotFound is a generation for a week with no plan.
var ErrPlanNotFound = errors.New("the week has no plan")
// ErrPlanChanged is a generation from a pushed plan the household has
// edited since it was pushed: the Supply run would not match Calendar, so
// none is made, and pushing the plan again makes it.
var ErrPlanChanged = errors.New("the plan changed after it was pushed to Calendar; push again")
// ErrFrozen is a generation of, or a change to an item of, a Supply run
// frozen into the Supply archive, which is read-only.
var ErrFrozen = errors.New("the week's Supply run is in the Supply archive and can no longer change")
// ErrListNotFound is a read of a week with no Supply run.
var ErrListNotFound = errors.New("the week has no Supply run")
// ErrItemNotFound is a change to an item on no Supply run.
var ErrItemNotFound = errors.New("no Supply run has that item")
// ErrWeekNotEnded is a freeze of a Supply run whose week has not ended in
// the household's time zone.
var ErrWeekNotEnded = errors.New("the week has not ended")
// ErrInvalidPeriod is an Archive grouping other than ByWeek, ByMonth and
// ByYear.
var ErrInvalidPeriod = errors.New("by must be week, month or year")
// Titler runs archive_title on in; scope names the week, its plan revision
// and its dinners.
// In production it is skills.TitleArchive with the household's
// harness.Config.
type Titler func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error)
// Service generates Supply runs.
type Service struct {
st *store.Store
title Titler
titleJob *jobs.Kind[TitleJob]
logger *slog.Logger
// now is the clock weeks are ended and current on; tests replace it.
now func() time.Time
// titling orders the title writes against Freeze. A title job holds it
// shared from reading the list to storing its title, and Freeze holds
// it exclusively from reading the list to freezing it, so a freeze
// waits for the title jobs in flight to store their titles or fail,
// and no title job runs archive_title while a freeze is under way.
titling sync.RWMutex
}
// NewService returns a service over st that titles Supply runs with title
// in KindTitleSupplyRun jobs, which it registers on runner, and logs to
// logger. runner must not have started yet.
func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("shopping: nil store")
case title == nil:
return nil, errors.New("shopping: nil titler")
case runner == nil:
return nil, errors.New("shopping: nil job runner")
case logger == nil:
return nil, errors.New("shopping: nil logger")
}
s := &Service{st: st, title: title, logger: logger, now: time.Now}
kind, err := jobs.Register(runner, KindTitleSupplyRun, s.runTitleJob, ReasonTitleFailed)
if err != nil {
return nil, err
}
if err := kind.ServiceOnly(); err != nil {
return nil, err
}
s.titleJob = kind
return s, nil
}
// List is a Supply run.
type List struct {
ID string `json:"id"`
// WeekStart is the Monday of the list's week, YYYY-MM-DD.
WeekStart string `json:"week_start"`
PlanID string `json:"plan_id"`
// Title is the AI-written post title; nil until it is written, when
// the week label stands in for it.
Title *string `json:"title"`
Status string `json:"status"`
// SentToChatAt is when the list was sent to Google Chat; nil until it
// is.
SentToChatAt *string `json:"sent_to_chat_at"`
Groups []Group `json:"groups"`
}
// Counts are how many items of a Supply run are still needed and how many
// it has in all.
type Counts struct {
Needed int `json:"needed_count"`
Total int `json:"total_count"`
}
// Counts counts l's items.
func (l List) Counts() Counts {
var c Counts
for _, g := range l.Groups {
for _, it := range g.Items {
c.Total++
if it.Needed {
c.Needed++
}
}
}
return c
}
// Group is the items of one plan day's dinner.
type Group struct {
ID string `json:"id"`
DinnerID string `json:"dinner_id"`
DinnerName string `json:"dinner_name"`
// Date is the plan day, household-local YYYY-MM-DD.
Date string `json:"date"`
Position int `json:"position"`
Items []Item `json:"items"`
}
// Item is one ingredient to buy.
type Item struct {
ID string `json:"id"`
Name string `json:"name"`
// Qty is the quantity as shown, such as "2 cups" or "500 g"; nil when
// the recipe gives none.
Qty *string `json:"qty"`
// Needed is false once the household marks the item "have it".
Needed bool `json:"needed"`
Position int `json:"position"`
}
// SkippedDay is a plan day with no group on the list, and why.
type SkippedDay struct {
Date string `json:"date"`
DinnerID string `json:"dinner_id"`
DinnerName string `json:"dinner_name"`
// Reason is SkipNoRecipe or SkipNoIngredients.
Reason string `json:"reason"`
}
// Generated is the outcome of Generate.
type Generated struct {
List List `json:"list"`
// Skipped are the plan days whose dinner has no ingredients to shop
// for, by date.
Skipped []SkippedDay `json:"skipped"`
// TitleJobID is the KindTitleSupplyRun job titling the list; nil when
// the list already has its title.
TitleJobID *string `json:"title_job_id"`
}
// Generate makes the Supply run of the week starting weekStart from the
// week's plan, creating it untitled the first time and remaking it after
// that, in one transaction.
//
// The list has one group per plan day with a dinner, by date, holding one
// item per ingredient of the dinner's recipe, by the ingredient's
// position. A fetched recipe's ingredients are normalized when it is
// grounded (normalize_recipe): the item is the ingredient's name, and its
// quantity is qty and unit joined by a space ("2 cups"), either alone when
// the other is missing, or nil when both are. A recipe entered by hand is
// stored as typed, unnormalized: each item is its line as typed, with no
// quantity. A day whose dinner has no recipe or whose recipe has no
// ingredients gets no group and is reported in Skipped.
//
// Remaking keeps what the household marked: an item is the same item as
// before when its dinner and name are the same, wherever the dinner and
// the ingredient now are, so it keeps its id and needed flag. When a
// dinner lists a name more than once, its first such item matches the
// first one before, the second the second, and so on. Any other item is
// new and needed; an item no longer on the plan is removed, and with it a
// group no longer on the plan.
//
// A list still untitled once it is stored, as a new list always is, gets a
// KindTitleSupplyRun job, enqueued in the same transaction, that titles it
// with archive_title from the names of the plan's dinners in day order.
// Generate returns without waiting for the job: the list is returned
// untitled, with the job's id. A title is written once: a remake keeps it
// and enqueues no job. Titling never fails the generation: a title that
// cannot be written leaves the list untitled, the failure is logged and
// the job failed with ReasonTitleFailed, and the next generation of the
// week, Generate or GeneratePushed, tries again.
//
// A week with no plan is ErrPlanNotFound; a list frozen into the Supply
// archive is ErrFrozen. A fetched recipe's ingredient without a name is an
// error: grounding always names one.
func (s *Service) Generate(ctx context.Context, weekStart store.LocalDate) (Generated, error) {
return s.generate(ctx, weekStart, func(db.WeekPlan) error { return nil })
}
// GeneratePushed makes the Supply run of the week starting weekStart as
// Generate does, from the plan a Push to Calendar pushed at revision. A
// plan edited since (another revision) is ErrPlanChanged, and the Supply
// run is left as it was.
func (s *Service) GeneratePushed(ctx context.Context, weekStart store.LocalDate, revision int64) (Generated, error) {
return s.generate(ctx, weekStart, func(plan db.WeekPlan) error {
if plan.Revision != revision {
return ErrPlanChanged
}
return nil
})
}
// generate makes the Supply run of the week starting weekStart from the
// week's plan, as Generate says, once check accepts that plan, in the same
// transaction.
func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check func(db.WeekPlan) error) (Generated, error) {
if _, err := household.WeekDays(weekStart); err != nil {
return Generated{}, fmt.Errorf("shopping: generate: %w", err)
}
var out Generated
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
plan, err := q.GetWeekPlan(ctx, weekStart.String())
if errors.Is(err, sql.ErrNoRows) {
return ErrPlanNotFound
}
if err != nil {
return fmt.Errorf("shopping: read plan: %w", err)
}
if err := check(plan); err != nil {
return err
}
want, skipped, dinnerNames, err := wanted(ctx, q, plan.ID)
if err != nil {
return err
}
now := store.Now().String()
list, err := q.GetShoppingList(ctx, weekStart.String())
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("shopping: read list: %w", err)
}
switch {
case err != nil: // no list yet
list = db.ShoppingList{ID: store.NewID(), WeekStart: weekStart.String(), PlanID: plan.ID, Status: StatusCurrent}
err = q.CreateShoppingList(ctx, db.CreateShoppingListParams{
ID: list.ID, WeekStart: list.WeekStart, PlanID: list.PlanID, CreatedAt: now, UpdatedAt: now,
})
case frozen(list):
return ErrFrozen
default:
list.PlanID = plan.ID
err = q.RegenerateShoppingList(ctx, db.RegenerateShoppingListParams{PlanID: plan.ID, UpdatedAt: now, ID: list.ID})
}
if err != nil {
return fmt.Errorf("shopping: store list: %w", err)
}
groups, err := reconcile(ctx, q, list.ID, want, now)
if err != nil {
return err
}
out = Generated{List: listOf(list, groups), Skipped: skipped}
if list.Title.Valid {
return nil
}
id, err := s.titleJob.EnqueueTx(ctx, tx, TitleJob{
ListID: list.ID, WeekStart: list.WeekStart, Revision: plan.Revision, Dinners: dinnerNames,
})
if err != nil {
return fmt.Errorf("shopping: enqueue title: %w", err)
}
out.TitleJobID = &id
return nil
})
if err != nil {
return Generated{}, err
}
if out.TitleJobID != nil {
s.titleJob.Wake()
}
return out, nil
}
// TitleJob is a KindTitleSupplyRun job's payload: the untitled Supply run
// ListID of the week WeekStart, made from the plan at Revision, whose
// dinners in day order are Dinners.
type TitleJob struct {
ListID string `json:"list_id"`
WeekStart string `json:"week_start"`
Revision int64 `json:"revision"`
Dinners []string `json:"dinners"`
}
// titleResult is a KindTitleSupplyRun job's result: the Supply run and the
// title it has.
type titleResult struct {
ListID string `json:"list_id"`
Title string `json:"title"`
}
// PublicJSON returns the Supply run's id and title.
func (r titleResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// runTitleJob titles the Supply run of p with archive_title and stores the
// title. A title that cannot be stored leaves the list untitled: the
// failure is logged and the job fails with ReasonTitleFailed.
func (s *Service) runTitleJob(ctx context.Context, p TitleJob, _ func(titleResult) error) (titleResult, error) {
title, err := s.writeTitle(ctx, p)
if err != nil {
s.logger.WarnContext(ctx, "Supply run left untitled; its next generation titles it again",
"week", p.WeekStart, "list", p.ListID, "error", err)
return titleResult{}, &jobs.Failure{Code: ReasonTitleFailed}
}
return titleResult{ListID: p.ListID, Title: title}, nil
}
// writeTitle runs archive_title for p's Supply run and stores its title,
// as storeTitle does, while no freeze is under way: it waits for one to
// end first, and a freeze waits for it to end.
func (s *Service) writeTitle(ctx context.Context, p TitleJob) (string, error) {
s.titling.RLock()
defer s.titling.RUnlock()
return s.storeTitle(ctx, p)
}
// storeTitle runs archive_title for p's Supply run and stores its title.
// When the list was titled by another job, before this one ran or
// meanwhile, that title stands and is returned. The caller holds titling.
func (s *Service) storeTitle(ctx context.Context, p TitleJob) (string, error) {
q := db.New(s.st.Writer)
title, err := s.currentTitle(ctx, q, p)
if err != nil || title != "" {
return title, err
}
out, err := s.title(ctx, titleScope(p), skills.ArchiveTitleRequest{Dinners: p.Dinners})
if err != nil {
return "", fmt.Errorf("shopping: title: %w", err)
}
write, cancel := jobs.FinalWriteContext(ctx)
defer cancel()
n, err := q.SetShoppingListTitle(write, db.SetShoppingListTitleParams{
Title: sql.NullString{String: out.Title, Valid: true}, UpdatedAt: store.Now().String(), ID: p.ListID,
})
if err != nil {
return "", fmt.Errorf("shopping: store title: %w", err)
}
if n == 1 {
return out.Title, nil
}
title, err = s.currentTitle(write, q, p)
if err == nil && title == "" {
err = errors.New("shopping: store title: the Supply run is still untitled")
}
return title, err
}
// currentTitle returns the title of p's Supply run, or "" while it is
// untitled. A list frozen or replaced untitled is an error: it can no
// longer be titled.
func (s *Service) currentTitle(ctx context.Context, q *db.Queries, p TitleJob) (string, error) {
list, err := q.GetShoppingList(ctx, p.WeekStart)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("shopping: title: read list: %w", err)
}
switch {
case err == nil && list.ID == p.ListID && list.Title.Valid:
return list.Title.String, nil
case err != nil || list.ID != p.ListID || frozen(list):
return "", errors.New("shopping: store title: the Supply run was frozen or replaced before its title was stored")
}
return "", nil
}
// titleScope is the archive_title scope of p: its week and plan revision,
// and a fingerprint of its dinners in day order, so runs share a title only
// when they title the same dinner names. A renamed dinner changes the
// names but not the plan revision.
func titleScope(p TitleJob) string {
names, _ := json.Marshal(p.Dinners) // a []string always encodes
return fmt.Sprintf("%s@%d/%x", p.WeekStart, p.Revision, sha256.Sum256(names))
}
// Get returns the Supply run of the week starting weekStart, as stored: a
// week with none is ErrListNotFound.
func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, err error) {
if _, err := household.WeekDays(weekStart); err != nil {
return List{}, fmt.Errorf("shopping: get: %w", err)
}
tx, err := s.st.Reader.BeginTx(ctx, nil)
if err != nil {
return List{}, fmt.Errorf("shopping: begin read: %w", err)
}
defer func() {
err = errors.Join(err, tx.Rollback())
}()
q := db.New(tx)
list, err := q.GetShoppingList(ctx, weekStart.String())
if errors.Is(err, sql.ErrNoRows) {
return List{}, ErrListNotFound
}
if err != nil {
return List{}, fmt.Errorf("shopping: read list: %w", err)
}
return readList(ctx, q, list)
}
// readList reads the groups and items of list through q.
func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, error) {
rows, err := q.ListShoppingGroupDinners(ctx, list.ID)
if err != nil {
return List{}, fmt.Errorf("shopping: read groups of %s: %w", list.WeekStart, err)
}
items, err := q.ListShoppingItems(ctx, list.ID)
if err != nil {
return List{}, fmt.Errorf("shopping: read items of %s: %w", list.WeekStart, err)
}
byGroup := make(map[string][]Item, len(rows))
for _, r := range items {
it := r.ShoppingItem
byGroup[it.GroupID] = append(byGroup[it.GroupID], Item{
ID: it.ID, Name: it.Name, Qty: nullString(it.Qty), Needed: it.Needed == 1, Position: int(it.Position),
})
}
groups := make([]Group, 0, len(rows))
for _, r := range rows {
g := r.ShoppingGroup
groups = append(groups, Group{
ID: g.ID, DinnerID: g.DinnerID, DinnerName: r.DinnerName, Date: g.Date, Position: int(g.Position),
Items: byGroup[g.ID],
})
}
return listOf(list, groups), nil
}
// SetNeeded marks item itemID needed, or "have it" when needed is false,
// and returns its Supply run's counts after the change. An item on no
// Supply run is ErrItemNotFound; one on a list frozen into the Supply
// archive is ErrFrozen, and is left as it was.
func (s *Service) SetNeeded(ctx context.Context, itemID string, needed bool) (Counts, error) {
var c Counts
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
row, err := q.GetShoppingItemList(ctx, itemID)
if errors.Is(err, sql.ErrNoRows) {
return ErrItemNotFound
}
if err != nil {
return fmt.Errorf("shopping: read list of item %s: %w", itemID, err)
}
if frozen(row.ShoppingList) {
return ErrFrozen
}
flag := int64(0)
if needed {
flag = 1
}
if err := q.SetShoppingItemNeeded(ctx, db.SetShoppingItemNeededParams{
Needed: flag, UpdatedAt: store.Now().String(), ID: itemID,
}); err != nil {
return fmt.Errorf("shopping: mark item %s: %w", itemID, err)
}
items, err := q.ListShoppingItems(ctx, row.ShoppingList.ID)
if err != nil {
return fmt.Errorf("shopping: read items: %w", err)
}
for _, r := range items {
c.Total++
if r.ShoppingItem.Needed == 1 {
c.Needed++
}
}
return nil
})
if err != nil {
return Counts{}, err
}
return c, nil
}
// frozen reports whether list is frozen into the Supply archive.
func frozen(list db.ShoppingList) bool {
return list.FrozenAt.Valid || list.Status == StatusArchived
}
// listOf is list with groups.
func listOf(list db.ShoppingList, groups []Group) List {
return List{
ID: list.ID,
WeekStart: list.WeekStart,
PlanID: list.PlanID,
Title: nullString(list.Title),
Status: list.Status,
SentToChatAt: nullString(list.SentToChatAt),
Groups: groups,
}
}
// wantGroup is a group the plan asks for, before it is stored.
type wantGroup struct {
date, dinnerID, dinnerName string
items []wantItem
}
type wantItem struct {
name string
qty sql.NullString
}
// wanted reads plan planID's days and their dinners' ingredients: the
// groups the list must have, by date, the days that get none, and the
// names of the plan's dinners in day order.
func wanted(ctx context.Context, q *db.Queries, planID string) ([]wantGroup, []SkippedDay, []string, error) {
days, err := q.ListPlanDays(ctx, planID)
if err != nil {
return nil, nil, nil, fmt.Errorf("shopping: read plan days: %w", err)
}
var (
groups []wantGroup
skipped []SkippedDay
names []string
)
for _, day := range days {
d := day.Dinner
names = append(names, d.Name)
skip := SkippedDay{Date: day.Date, DinnerID: d.ID, DinnerName: d.Name}
if !d.RecipeID.Valid {
skip.Reason = SkipNoRecipe
skipped = append(skipped, skip)
continue
}
recipe, err := q.GetRecipe(ctx, d.RecipeID.String)
if err != nil {
return nil, nil, nil, fmt.Errorf("shopping: read recipe %s of dinner %s: %w", d.RecipeID.String, d.ID, err)
}
ings, err := q.ListRecipeIngredients(ctx, recipe.ID)
if err != nil {
return nil, nil, nil, fmt.Errorf("shopping: read ingredients of recipe %s: %w", recipe.ID, err)
}
if len(ings) == 0 {
skip.Reason = SkipNoIngredients
skipped = append(skipped, skip)
continue
}
manual := dinners.IsManualRecipe(recipe.SourceUrl)
g := wantGroup{date: day.Date, dinnerID: d.ID, dinnerName: d.Name}
for _, ing := range ings {
it, err := item(ing, manual)
if err != nil {
return nil, nil, nil, fmt.Errorf("shopping: recipe %s: %w", recipe.ID, err)
}
g.items = append(g.items, it)
}
groups = append(groups, g)
}
return groups, skipped, names, nil
}
// item is the item for ingredient ing of a recipe entered by hand
// (manual) or fetched.
func item(ing db.RecipeIngredient, manual bool) (wantItem, error) {
if manual {
return wantItem{name: ing.RawText}, nil
}
if !ing.Name.Valid || ing.Name.String == "" {
return wantItem{}, fmt.Errorf("ingredient %d (%q) has no normalized name", ing.Position+1, ing.RawText)
}
var parts []string
for _, p := range []sql.NullString{ing.Qty, ing.Unit} {
if p.Valid && p.String != "" {
parts = append(parts, p.String)
}
}
qty := strings.Join(parts, " ")
return wantItem{name: ing.Name.String, qty: sql.NullString{String: qty, Valid: qty != ""}}, nil
}
// itemKey is what makes an item the same item across a remake.
type itemKey struct {
dinnerID, name string
}
// groupKey is what makes a group the same group across a remake.
type groupKey struct {
date, dinnerID string
}
// reconcile makes the groups and items of list listID those of want, as
// Generate says, and returns them.
func reconcile(ctx context.Context, q *db.Queries, listID string, want []wantGroup, now string) ([]Group, error) {
oldGroups, err := q.ListShoppingGroups(ctx, listID)
if err != nil {
return nil, fmt.Errorf("shopping: read groups: %w", err)
}
oldItems, err := q.ListShoppingItems(ctx, listID)
if err != nil {
return nil, fmt.Errorf("shopping: read items: %w", err)
}
groupsByKey := make(map[groupKey]db.ShoppingGroup, len(oldGroups))
for _, g := range oldGroups {
groupsByKey[groupKey{g.Date, g.DinnerID}] = g
}
// itemsByKey holds each key's items in list order, so the nth item a
// dinner lists under a name matches its nth one before.
itemsByKey := make(map[itemKey][]db.ShoppingItem)
for _, r := range oldItems {
k := itemKey{r.DinnerID, r.ShoppingItem.Name}
itemsByKey[k] = append(itemsByKey[k], r.ShoppingItem)
}
kept := make(map[string]bool) // ids of the groups and items kept
groups := make([]Group, 0, len(want))
for gi, w := range want {
g := Group{DinnerID: w.dinnerID, DinnerName: w.dinnerName, Date: w.date, Position: gi}
if old, ok := groupsByKey[groupKey{w.date, w.dinnerID}]; ok {
g.ID = old.ID
kept[old.ID] = true
if err := q.MoveShoppingGroup(ctx, db.MoveShoppingGroupParams{Position: int64(gi), UpdatedAt: now, ID: g.ID}); err != nil {
return nil, fmt.Errorf("shopping: move group %s: %w", g.ID, err)
}
} else {
g.ID = store.NewID()
if err := q.CreateShoppingGroup(ctx, db.CreateShoppingGroupParams{
ID: g.ID, ListID: listID, DinnerID: w.dinnerID, Date: w.date, Position: int64(gi),
CreatedAt: now, UpdatedAt: now,
}); err != nil {
return nil, fmt.Errorf("shopping: store group of %s: %w", w.date, err)
}
}
for ii, wi := range w.items {
it := Item{Name: wi.name, Qty: nullString(wi.qty), Needed: true, Position: ii}
k := itemKey{w.dinnerID, wi.name}
if same := itemsByKey[k]; len(same) > 0 {
old := same[0]
itemsByKey[k] = same[1:]
it.ID, it.Needed = old.ID, old.Needed == 1
kept[old.ID] = true
if err := q.MoveShoppingItem(ctx, db.MoveShoppingItemParams{
GroupID: g.ID, Qty: wi.qty, Position: int64(ii), UpdatedAt: now, ID: it.ID,
}); err != nil {
return nil, fmt.Errorf("shopping: move item %s: %w", it.ID, err)
}
} else {
it.ID = store.NewID()
if err := q.CreateShoppingItem(ctx, db.CreateShoppingItemParams{
ID: it.ID, GroupID: g.ID, Name: wi.name, Qty: wi.qty, Position: int64(ii),
CreatedAt: now, UpdatedAt: now,
}); err != nil {
return nil, fmt.Errorf("shopping: store item %q of %s: %w", wi.name, w.date, err)
}
}
g.Items = append(g.Items, it)
}
groups = append(groups, g)
}
// Items first: a group is removed only once it is empty.
for _, r := range oldItems {
if !kept[r.ShoppingItem.ID] {
if err := q.DeleteShoppingItem(ctx, r.ShoppingItem.ID); err != nil {
return nil, fmt.Errorf("shopping: remove item %s: %w", r.ShoppingItem.ID, err)
}
}
}
for _, g := range oldGroups {
if !kept[g.ID] {
if err := q.DeleteShoppingGroup(ctx, g.ID); err != nil {
return nil, fmt.Errorf("shopping: remove group %s: %w", g.ID, err)
}
}
}
return groups, nil
}
func nullString(s sql.NullString) *string {
if !s.Valid {
return nil
}
return &s.String
}
+823
View File
@@ -0,0 +1,823 @@
package shopping_test
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"io"
"log/slog"
"reflect"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// week is the Monday the tests plan, and weekDates its days.
var (
week = mustDate("2026-10-12")
weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
)
func mustDate(s string) store.LocalDate {
d, err := store.ParseLocalDate(s)
if err != nil {
panic(err)
}
return d
}
type env struct {
svc *shopping.Service
st *store.Store
dataDir string
planID string
// ids are the dinners' ids by name.
ids map[string]string
// runner runs the title jobs, two at a time, once start starts it.
runner *jobs.Runner
// model answers archive_title, which titles the Supply runs; with
// nothing queued it fails, and the lists stay untitled.
model *llm.Fake
// beforeTitle, when set, runs as archive_title is asked for a title.
beforeTitle func()
// afterTitle, when set, runs once archive_title has returned, before
// its title is stored.
afterTitle func()
// beforeModel, when set, runs as the model is sent a request.
beforeModel func(llm.Request)
// log holds what the service logged.
log *syncBuffer
}
// syncBuffer is a bytes.Buffer safe for concurrent use.
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
func newEnv(t *testing.T) *env {
t.Helper()
dataDir := t.TempDir()
st, err := store.Open(t.Context(), dataDir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
e := &env{st: st, dataDir: dataDir, ids: map[string]string{}, model: &llm.Fake{}, log: &syncBuffer{}}
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
t.Fatal(err)
}
cfg := harness.Config{
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return hookedModel{e}, nil },
Store: st,
Guard: guard,
}
title := func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
if e.beforeTitle != nil {
e.beforeTitle()
}
out, err := skills.TitleArchive(ctx, cfg, scope, in)
if e.afterTitle != nil {
e.afterTitle()
}
return out, err
}
logger := slog.New(slog.NewTextHandler(e.log, nil))
e.runner, err = jobs.NewRunner(st, logger, 2)
if err != nil {
t.Fatal(err)
}
e.svc, err = shopping.NewService(st, title, e.runner, logger)
if err != nil {
t.Fatal(err)
}
return e
}
// hookedModel is the env's model, running beforeModel first.
type hookedModel struct{ e *env }
func (m hookedModel) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
if m.e.beforeModel != nil {
m.e.beforeModel(req)
}
return m.e.model.Generate(ctx, req)
}
func (m hookedModel) ListModels(ctx context.Context) ([]llm.Model, error) {
return m.e.model.ListModels(ctx)
}
func (m hookedModel) Verify(ctx context.Context) error { return m.e.model.Verify(ctx) }
// start starts the env's job runner, stopping it when the test ends.
func (e *env) start(t *testing.T) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
if err := e.runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
e.runner.Wait()
})
}
// waitTitle waits for g's title job to finish and returns it.
func (e *env) waitTitle(t *testing.T, g shopping.Generated) jobs.Job {
t.Helper()
if g.TitleJobID == nil {
t.Fatal("generation enqueued no title job")
}
deadline := time.Now().Add(10 * time.Second)
for {
job, err := e.runner.Get(t.Context(), *g.TitleJobID)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
return job
}
if time.Now().After(deadline) {
t.Fatalf("title job %s still %s", job.ID, job.Status)
}
time.Sleep(5 * time.Millisecond)
}
}
// ing is a recipe ingredient as recipe grounding stores it: an empty
// qty or unit is NULL.
type ing struct {
name, qty, unit string
}
// dinner stores a Regular named name and returns its id. With ings it is
// grounded in a fetched recipe listing them; with none it has no recipe.
func (e *env) dinner(t *testing.T, name string, ings ...ing) string {
t.Helper()
q := db.New(e.st.Writer)
now := store.Now().String()
var recipeID sql.NullString
status := "pending"
if ings != nil {
recipeID = e.recipe(t, name, ings)
status = "grounded"
}
d, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: "regular",
Origin: "regular",
RecipeID: recipeID,
GroundingStatus: status,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
e.ids[name] = d.ID
return d.ID
}
// recipe stores a fetched recipe titled title with ings, as
// recipes.Grounder stores a normalized page, and returns its id.
func (e *env) recipe(t *testing.T, title string, ings []ing) sql.NullString {
t.Helper()
q := db.New(e.st.Writer)
now := store.Now().String()
r, err := q.UpsertRecipe(t.Context(), db.UpsertRecipeParams{
ID: store.NewID(),
SourceUrl: "https://recipes.example/" + strings.ReplaceAll(strings.ToLower(title), " ", "-"),
Title: title,
RawJsonld: "{}",
FetchedAt: now,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
e.setIngredients(t, r.ID, ings)
return sql.NullString{String: r.ID, Valid: true}
}
// setIngredients replaces recipe id's ingredients with ings, as a
// regrounding does.
func (e *env) setIngredients(t *testing.T, id string, ings []ing) {
t.Helper()
q := db.New(e.st.Writer)
if err := q.DeleteRecipeIngredients(t.Context(), id); err != nil {
t.Fatal(err)
}
now := store.Now().String()
for i, in := range ings {
qtyUnit := strings.TrimSpace(in.qty + " " + in.unit)
if err := q.CreateRecipeIngredient(t.Context(), db.CreateRecipeIngredientParams{
ID: store.NewID(),
RecipeID: id,
Position: int64(i),
RawText: strings.TrimSpace(qtyUnit + " " + in.name),
Name: sql.NullString{String: in.name, Valid: true},
Qty: sql.NullString{String: in.qty, Valid: in.qty != ""},
Unit: sql.NullString{String: in.unit, Valid: in.unit != ""},
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
}
}
// recipeOf returns the id of dinner name's recipe.
func (e *env) recipeOf(t *testing.T, name string) string {
t.Helper()
d, err := db.New(e.st.Reader).GetDinner(t.Context(), e.ids[name])
if err != nil {
t.Fatal(err)
}
return d.RecipeID.String
}
// plan stores the week's plan with the dinners named names on weekDates,
// in order.
func (e *env) plan(t *testing.T, names ...string) {
t.Helper()
q := db.New(e.st.Writer)
now := store.Now().String()
e.planID = store.NewID()
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: e.planID, WeekStart: week.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
for i, name := range names {
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{
ID: store.NewID(), PlanID: e.planID, Date: weekDates[i],
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
}
}
// swap plans the dinner named name on date, as a swap on the Draft board
// does.
func (e *env) swap(t *testing.T, date, name string) {
t.Helper()
n, err := db.New(e.st.Writer).SwapPlanDay(t.Context(), db.SwapPlanDayParams{
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, UpdatedAt: store.Now().String(),
PlanID: e.planID, Date: date,
})
if err != nil || n != 1 {
t.Fatalf("swap %s to %s: %d rows, %v", date, name, n, err)
}
}
// haveIt marks the item named name of dinner's group "have it".
func (e *env) haveIt(t *testing.T, l shopping.List, dinner, name string) string {
t.Helper()
for _, g := range l.Groups {
for _, it := range g.Items {
if g.DinnerName == dinner && it.Name == name {
if _, err := e.st.Writer.Exec(`UPDATE shopping_items SET needed = 0 WHERE id = ?`, it.ID); err != nil {
t.Fatal(err)
}
return it.ID
}
}
}
t.Fatalf("no item %q of %s", name, dinner)
return ""
}
func (e *env) generate(t *testing.T) shopping.Generated {
t.Helper()
g, err := e.svc.Generate(t.Context(), week)
if err != nil {
t.Fatal(err)
}
return g
}
// lines renders l one item a line as "position date dinner: position
// name [qty] needed|have it".
func lines(l shopping.List) []string {
var out []string
for _, g := range l.Groups {
for _, it := range g.Items {
qty := "-"
if it.Qty != nil {
qty = *it.Qty
}
mark := "needed"
if !it.Needed {
mark = "have it"
}
out = append(out, fmt.Sprintf("%d %s %s: %d %s [%s] %s", g.Position, g.Date, g.DinnerName, it.Position, it.Name, qty, mark))
}
}
return out
}
// stored renders the stored list of the week as lines does, so a test
// checks the database holds what Generate returned.
func (e *env) stored(t *testing.T) []string {
t.Helper()
rows, err := e.st.Reader.Query(`SELECT g.position, g.date, d.name, i.position, i.name, COALESCE(i.qty, '-'), i.needed
FROM shopping_lists l
JOIN shopping_groups g ON g.list_id = l.id
JOIN dinners d ON d.id = g.dinner_id
JOIN shopping_items i ON i.group_id = g.id
WHERE l.week_start = ?
ORDER BY g.position, i.position`, week.String())
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []string
for rows.Next() {
var (
gpos, ipos, needed int
date, dinner, name string
qty string
)
if err := rows.Scan(&gpos, &date, &dinner, &ipos, &name, &qty, &needed); err != nil {
t.Fatal(err)
}
mark := "needed"
if needed == 0 {
mark = "have it"
}
out = append(out, fmt.Sprintf("%d %s %s: %d %s [%s] %s", gpos, date, dinner, ipos, name, qty, mark))
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return out
}
// count returns the number of rows in table.
func (e *env) count(t *testing.T, table string) int {
t.Helper()
var n int
if err := e.st.Reader.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
func wantLines(t *testing.T, what string, got, want []string) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Errorf("%s =\n %s\nwant\n %s", what, strings.Join(got, "\n "), strings.Join(want, "\n "))
}
}
func TestGenerateSevenDayPlan(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"kidney beans", "2", "cans"}, ing{"onion", "1", ""})
e.dinner(t, "Pot Roast", ing{"chuck roast", "3", "lb"}, ing{"carrots", "4", ""})
e.dinner(t, "Chicken Fajitas", ing{"chicken breast", "500", "g"}, ing{"bell peppers", "3", ""}, ing{"tortillas", "8", ""})
e.dinner(t, "Spaghetti Bolognese", ing{"spaghetti", "1", "lb"}, ing{"crushed tomatoes", "28", "oz"})
e.dinner(t, "Sheet Pan Salmon", ing{"salmon fillets", "4", ""}, ing{"lemon", "1", ""}, ing{"dill", "", "sprig"})
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"bean sprouts", "2", "cups"}, ing{"salt", "", ""})
e.dinner(t, "Shawarma Bowls", ing{"chicken thighs", "2", "lb"}, ing{"pita", "4", ""})
e.plan(t, "Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Pad Thai", "Shawarma Bowls")
g := e.generate(t)
want := []string{
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
"0 2026-10-12 Beef Chili: 1 kidney beans [2 cans] needed",
"0 2026-10-12 Beef Chili: 2 onion [1] needed",
"1 2026-10-13 Pot Roast: 0 chuck roast [3 lb] needed",
"1 2026-10-13 Pot Roast: 1 carrots [4] needed",
"2 2026-10-14 Chicken Fajitas: 0 chicken breast [500 g] needed",
"2 2026-10-14 Chicken Fajitas: 1 bell peppers [3] needed",
"2 2026-10-14 Chicken Fajitas: 2 tortillas [8] needed",
"3 2026-10-15 Spaghetti Bolognese: 0 spaghetti [1 lb] needed",
"3 2026-10-15 Spaghetti Bolognese: 1 crushed tomatoes [28 oz] needed",
"4 2026-10-16 Sheet Pan Salmon: 0 salmon fillets [4] needed",
"4 2026-10-16 Sheet Pan Salmon: 1 lemon [1] needed",
"4 2026-10-16 Sheet Pan Salmon: 2 dill [sprig] needed",
"5 2026-10-17 Pad Thai: 0 rice noodles [8 oz] needed",
"5 2026-10-17 Pad Thai: 1 bean sprouts [2 cups] needed",
"5 2026-10-17 Pad Thai: 2 salt [-] needed",
"6 2026-10-18 Shawarma Bowls: 0 chicken thighs [2 lb] needed",
"6 2026-10-18 Shawarma Bowls: 1 pita [4] needed",
}
wantLines(t, "list", lines(g.List), want)
wantLines(t, "stored list", e.stored(t), want)
if len(g.Skipped) != 0 {
t.Errorf("skipped = %+v, want none", g.Skipped)
}
for _, grp := range g.List.Groups {
if grp.DinnerID != e.ids[grp.DinnerName] {
t.Errorf("group %s dinner id = %s, want %s", grp.DinnerName, grp.DinnerID, e.ids[grp.DinnerName])
}
}
if g.List.WeekStart != "2026-10-12" || g.List.PlanID != e.planID || g.List.Status != shopping.StatusCurrent || g.List.Title != nil {
t.Errorf("list = %+v, want the week's current, untitled list of plan %s", g.List, e.planID)
}
var title sql.NullString
if err := e.st.Reader.QueryRow(`SELECT title FROM shopping_lists WHERE id = ?`, g.List.ID).Scan(&title); err != nil {
t.Fatal(err)
}
if title.Valid {
t.Errorf("stored title = %q, want NULL until the AI titles the list", title.String)
}
// Generating again from the same plan changes nothing: same rows, same
// ids, no extra rows. The list is still untitled, so it gets another
// title job.
again := e.generate(t)
if again.TitleJobID == nil || g.TitleJobID == nil || *again.TitleJobID == *g.TitleJobID {
t.Errorf("title jobs = %v then %v, want one per generation of the untitled list", deref(g.TitleJobID), deref(again.TitleJobID))
}
again.TitleJobID = g.TitleJobID
if !reflect.DeepEqual(again, g) {
t.Errorf("second generation =\n %+v\nwant the first\n %+v", again, g)
}
if n := e.count(t, "shopping_lists"); n != 1 {
t.Errorf("%d lists, want 1", n)
}
if n := e.count(t, "shopping_items"); n != len(want) {
t.Errorf("%d items stored, want %d", n, len(want))
}
}
// TestGenerateManualRecipe grounds a dinner with a recipe typed in, through
// dinners.Service as the household does: its lines are stored unnormalized,
// so each is an item as typed, with no quantity.
func TestGenerateManualRecipe(t *testing.T) {
e := newEnv(t)
model := &llm.Fake{}
grounder := recipestest.NewGrounder(t, e.st, e.dataDir, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, model)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(e.st, logger, 1)
if err != nil {
t.Fatal(err)
}
dsvc, err := dinners.NewService(e.st, grounder, runner, logger)
if err != nil {
t.Fatal(err)
}
e.dinner(t, "Grandma's Goulash")
minutes, method := 45, "stovetop"
if _, err := dsvc.Ground(t.Context(), e.ids["Grandma's Goulash"], dinners.Fix{Manual: &dinners.ManualRecipe{
Ingredients: []string{"2 lb beef chuck", "1 onion, diced", "paprika to taste"},
Steps: []string{"Brown the beef."},
Minutes: &minutes,
Method: &method,
}}, "member-1"); err != nil {
t.Fatal(err)
}
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Grandma's Goulash", "Beef Chili")
g := e.generate(t)
want := []string{
"0 2026-10-12 Grandma's Goulash: 0 2 lb beef chuck [-] needed",
"0 2026-10-12 Grandma's Goulash: 1 1 onion, diced [-] needed",
"0 2026-10-12 Grandma's Goulash: 2 paprika to taste [-] needed",
"1 2026-10-13 Beef Chili: 0 ground beef [1 lb] needed",
}
wantLines(t, "list", lines(g.List), want)
wantLines(t, "stored list", e.stored(t), want)
if len(g.Skipped) != 0 {
t.Errorf("skipped = %+v, want none", g.Skipped)
}
if reqs := model.Requests(); len(reqs) != 0 {
t.Errorf("%d model requests, want none", len(reqs))
}
}
// TestGenerateSkipsDinnersWithoutIngredients plans a dinner with no recipe
// (its grounding pending) and one whose recipe lists no ingredients: each
// day gets no group and is reported with why.
func TestGenerateSkipsDinnersWithoutIngredients(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Mystery Stew")
e.dinner(t, "Toast", []ing{}...) // a recipe with no ingredients
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Mystery Stew", "Toast", "Beef Chili")
g := e.generate(t)
want := []string{"0 2026-10-14 Beef Chili: 0 ground beef [1 lb] needed"}
wantLines(t, "list", lines(g.List), want)
wantLines(t, "stored list", e.stored(t), want)
wantSkipped := []shopping.SkippedDay{
{Date: "2026-10-12", DinnerID: e.ids["Mystery Stew"], DinnerName: "Mystery Stew", Reason: shopping.SkipNoRecipe},
{Date: "2026-10-13", DinnerID: e.ids["Toast"], DinnerName: "Toast", Reason: shopping.SkipNoIngredients},
}
if !reflect.DeepEqual(g.Skipped, wantSkipped) {
t.Errorf("skipped = %+v, want %+v", g.Skipped, wantSkipped)
}
if n := e.count(t, "shopping_groups"); n != 1 {
t.Errorf("%d groups stored, want 1: a skipped day gets no empty group", n)
}
}
// TestGenerateAfterSwapKeepsHaveIt swaps a day to a new dinner and moves a
// dinner to another day, and regrounds a dinner so its ingredients change
// order and quantity: an item whose dinner and name are unchanged keeps
// its id and "have it" wherever it now is; the rest is added as needed or
// removed.
func TestGenerateAfterSwapKeepsHaveIt(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}, ing{"kidney beans", "2", "cans"})
e.dinner(t, "Chicken Fajitas", ing{"chicken breast", "500", "g"}, ing{"tortillas", "8", ""})
e.dinner(t, "Sheet Pan Salmon", ing{"salmon fillets", "4", ""}, ing{"lemon", "1", ""})
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"lime", "2", ""})
e.plan(t, "Beef Chili", "Chicken Fajitas", "Sheet Pan Salmon")
first := e.generate(t).List
onion := e.haveIt(t, first, "Beef Chili", "onion")
tortillas := e.haveIt(t, first, "Chicken Fajitas", "tortillas")
lemon := e.haveIt(t, first, "Sheet Pan Salmon", "lemon")
// Tuesday becomes Pad Thai and Fajitas moves to Wednesday, dropping
// the Salmon; the Chili is regrounded: onion first, no beans, a
// pepper added and more beef.
e.swap(t, "2026-10-13", "Pad Thai")
e.swap(t, "2026-10-14", "Chicken Fajitas")
e.setIngredients(t, e.recipeOf(t, "Beef Chili"), []ing{{"onion", "2", ""}, {"ground beef", "2", "lb"}, {"jalapeño", "1", ""}})
g := e.generate(t)
want := []string{
"0 2026-10-12 Beef Chili: 0 onion [2] have it",
"0 2026-10-12 Beef Chili: 1 ground beef [2 lb] needed",
"0 2026-10-12 Beef Chili: 2 jalapeño [1] needed",
"1 2026-10-13 Pad Thai: 0 rice noodles [8 oz] needed",
"1 2026-10-13 Pad Thai: 1 lime [2] needed",
"2 2026-10-14 Chicken Fajitas: 0 chicken breast [500 g] needed",
"2 2026-10-14 Chicken Fajitas: 1 tortillas [8] have it",
}
wantLines(t, "list", lines(g.List), want)
wantLines(t, "stored list", e.stored(t), want)
ids := map[string]string{}
for _, grp := range g.List.Groups {
for _, it := range grp.Items {
ids[grp.DinnerName+"/"+it.Name] = it.ID
}
}
if ids["Beef Chili/onion"] != onion || ids["Chicken Fajitas/tortillas"] != tortillas {
t.Errorf("kept items have ids %s and %s, want %s and %s", ids["Beef Chili/onion"], ids["Chicken Fajitas/tortillas"], onion, tortillas)
}
var n int
if err := e.st.Reader.QueryRow(`SELECT count(*) FROM shopping_items WHERE id = ?`, lemon).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Error("the Salmon's lemon is still stored after the Salmon left the plan")
}
if got := e.count(t, "shopping_items"); got != len(want) {
t.Errorf("%d items stored, want %d", got, len(want))
}
if got := e.count(t, "shopping_groups"); got != 3 {
t.Errorf("%d groups stored, want 3", got)
}
if g.List.ID != first.ID {
t.Errorf("list id = %s, want the first generation's %s", g.List.ID, first.ID)
}
}
// TestGenerateMatchesRepeatedNamesInOrder lists a name twice in one
// dinner: the second "salt" marked "have it" stays the second.
func TestGenerateMatchesRepeatedNamesInOrder(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Pasta", ing{"salt", "1", "tbsp"}, ing{"spaghetti", "1", "lb"}, ing{"salt", "", "pinch"})
e.plan(t, "Pasta")
first := e.generate(t).List
second := first.Groups[0].Items[2].ID
if _, err := e.st.Writer.Exec(`UPDATE shopping_items SET needed = 0 WHERE id = ?`, second); err != nil {
t.Fatal(err)
}
e.setIngredients(t, e.recipeOf(t, "Pasta"), []ing{{"spaghetti", "1", "lb"}, {"salt", "1", "tbsp"}, {"salt", "", "pinch"}})
g := e.generate(t)
wantLines(t, "list", lines(g.List), []string{
"0 2026-10-12 Pasta: 0 spaghetti [1 lb] needed",
"0 2026-10-12 Pasta: 1 salt [1 tbsp] needed",
"0 2026-10-12 Pasta: 2 salt [pinch] have it",
})
if id := g.List.Groups[0].Items[2].ID; id != second {
t.Errorf("second salt id = %s, want %s", id, second)
}
}
func TestGenerateWeekWithoutPlan(t *testing.T) {
e := newEnv(t)
if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrPlanNotFound) {
t.Fatalf("Generate = %v, want ErrPlanNotFound", err)
}
if n := e.count(t, "shopping_lists"); n != 0 {
t.Errorf("%d lists stored, want none", n)
}
}
func TestGenerateRefusesNonMonday(t *testing.T) {
e := newEnv(t)
if _, err := e.svc.Generate(t.Context(), mustDate("2026-10-13")); err == nil {
t.Fatal("Generate of a Tuesday succeeded")
}
}
// TestGenerateRefusesFrozenList leaves a list frozen into the Supply
// archive as it is.
func TestGenerateRefusesFrozenList(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
e.plan(t, "Beef Chili")
e.generate(t)
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
t.Fatal(err)
}
e.swap(t, "2026-10-12", "Pad Thai")
if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrFrozen) {
t.Fatalf("Generate = %v, want ErrFrozen", err)
}
wantLines(t, "stored list", e.stored(t), []string{"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed"})
}
// TestGeneratePushedRefusesEditedPlan makes the Supply run of a pushed
// plan at the revision pushed, then edits the plan: the pushed revision no
// longer makes one, and the Supply run is left as it was.
func TestGeneratePushedRefusesEditedPlan(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
e.plan(t, "Beef Chili")
q := db.New(e.st.Writer)
pushed, err := q.GetWeekPlan(t.Context(), week.String())
if err != nil {
t.Fatal(err)
}
if _, err := e.svc.GeneratePushed(t.Context(), week, pushed.Revision); err != nil {
t.Fatalf("GeneratePushed at the pushed revision: %v", err)
}
e.swap(t, "2026-10-12", "Pad Thai")
if err := q.MarkWeekPlanEdited(t.Context(), db.MarkWeekPlanEditedParams{UpdatedAt: store.Now().String(), ID: e.planID}); err != nil {
t.Fatal(err)
}
if _, err := e.svc.GeneratePushed(t.Context(), week, pushed.Revision); !errors.Is(err, shopping.ErrPlanChanged) {
t.Fatalf("GeneratePushed after an edit = %v, want ErrPlanChanged", err)
}
wantLines(t, "stored list", e.stored(t), []string{"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed"})
}
// TestGenerateRefusesUnnamedIngredient: grounding always names a fetched
// recipe's ingredient, so one without a name is an error, and nothing is
// stored.
func TestGenerateRefusesUnnamedIngredient(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
e.plan(t, "Beef Chili")
if _, err := e.st.Writer.Exec(`UPDATE recipe_ingredients SET name = NULL WHERE position = 1`); err != nil {
t.Fatal(err)
}
_, err := e.svc.Generate(t.Context(), week)
if err == nil || !strings.Contains(err.Error(), `ingredient 2 ("1 onion") has no normalized name`) {
t.Fatalf("Generate = %v, want the unnamed ingredient refused", err)
}
if n := e.count(t, "shopping_lists"); n != 0 {
t.Errorf("%d lists stored, want none", n)
}
}
// TestGetReadsStoredList reads back what Generate stored, with what the
// household marked, and counts it.
func TestGetReadsStoredList(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
e.dinner(t, "Pot Roast", ing{"carrots", "4", ""})
e.plan(t, "Beef Chili", "Pot Roast")
made := e.generate(t).List
e.haveIt(t, made, "Beef Chili", "onion")
got, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
wantLines(t, "read list", lines(got), []string{
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
"0 2026-10-12 Beef Chili: 1 onion [1] have it",
"1 2026-10-13 Pot Roast: 0 carrots [4] needed",
})
if got.ID != made.ID || got.WeekStart != week.String() || got.Status != shopping.StatusCurrent || got.Title != nil || got.SentToChatAt != nil {
t.Errorf("read list = %+v", got)
}
if c := got.Counts(); c != (shopping.Counts{Needed: 2, Total: 3}) {
t.Errorf("Counts = %+v, want 2 of 3", c)
}
}
func TestGetWeekWithoutList(t *testing.T) {
e := newEnv(t)
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, shopping.ErrListNotFound) {
t.Fatalf("Get = %v, want ErrListNotFound", err)
}
if _, err := e.svc.Get(t.Context(), mustDate("2026-10-13")); err == nil {
t.Fatal("Get of a Tuesday succeeded")
}
}
// TestSetNeeded marks items and returns the counts after each change.
func TestSetNeeded(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}, ing{"garlic", "2", "cloves"})
e.plan(t, "Beef Chili")
l := e.generate(t).List
onion, garlic := l.Groups[0].Items[1].ID, l.Groups[0].Items[2].ID
for _, step := range []struct {
id string
needed bool
want shopping.Counts
}{
{onion, false, shopping.Counts{Needed: 2, Total: 3}},
{onion, false, shopping.Counts{Needed: 2, Total: 3}},
{garlic, false, shopping.Counts{Needed: 1, Total: 3}},
{onion, true, shopping.Counts{Needed: 2, Total: 3}},
} {
c, err := e.svc.SetNeeded(t.Context(), step.id, step.needed)
if err != nil {
t.Fatal(err)
}
if c != step.want {
t.Errorf("SetNeeded(%s, %v) = %+v, want %+v", step.id, step.needed, c, step.want)
}
}
wantLines(t, "stored list", e.stored(t), []string{
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
"0 2026-10-12 Beef Chili: 1 onion [1] needed",
"0 2026-10-12 Beef Chili: 2 garlic [2 cloves] have it",
})
}
func TestSetNeededUnknownItem(t *testing.T) {
e := newEnv(t)
if _, err := e.svc.SetNeeded(t.Context(), store.NewID(), false); !errors.Is(err, shopping.ErrItemNotFound) {
t.Fatalf("SetNeeded = %v, want ErrItemNotFound", err)
}
}
// TestSetNeededRefusesFrozenList leaves an item of a list in the Supply
// archive as it was.
func TestSetNeededRefusesFrozenList(t *testing.T) {
e := newEnv(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Beef Chili")
l := e.generate(t).List
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
t.Fatal(err)
}
if _, err := e.svc.SetNeeded(t.Context(), l.Groups[0].Items[0].ID, false); !errors.Is(err, shopping.ErrFrozen) {
t.Fatalf("SetNeeded = %v, want ErrFrozen", err)
}
wantLines(t, "stored list", e.stored(t), []string{"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed"})
}
+313
View File
@@ -0,0 +1,313 @@
package shopping_test
import (
"database/sql"
"encoding/json"
"fmt"
"slices"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
)
// titled is archive_title's reply giving title.
func titled(title string) llm.GenerateResult {
js := fmt.Sprintf(`{"title":%q}`, title)
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
}
// titleWeek stores three dinners, one with no recipe, and plans them on
// the week's first three days; its job runner is started.
func titleWeek(t *testing.T) *env {
t.Helper()
e := newEnv(t)
e.start(t)
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.dinner(t, "Fish Tacos")
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
e.plan(t, "Pad Thai", "Fish Tacos", "Beef Chili")
return e
}
// storedTitle returns the week's list's stored title; nil when NULL.
func (e *env) storedTitle(t *testing.T) *string {
t.Helper()
var title sql.NullString
if err := e.st.Reader.QueryRow(`SELECT title FROM shopping_lists WHERE week_start = ?`, week.String()).Scan(&title); err != nil {
t.Fatal(err)
}
if !title.Valid {
return nil
}
return &title.String
}
// runStatuses returns the status of every archive_title run recorded in
// ai_runs, oldest first.
func (e *env) runStatuses(t *testing.T) []string {
t.Helper()
rows, err := e.st.Reader.Query(`SELECT status FROM ai_runs WHERE skill = 'archive_title' ORDER BY created_at, rowid`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
t.Fatal(err)
}
out = append(out, s)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return out
}
func wantTitle(t *testing.T, what string, got *string, want string) {
t.Helper()
if got == nil || *got != want {
t.Errorf("%s = %v, want %q", what, deref(got), want)
}
}
func wantUntitled(t *testing.T, what string, got *string) {
t.Helper()
if got != nil {
t.Errorf("%s = %q, want none", what, *got)
}
}
func deref(s *string) any {
if s == nil {
return nil
}
return *s
}
// wantJob fails t unless job ended with status and, when failed, code.
func wantJob(t *testing.T, job jobs.Job, status jobs.Status, code string) {
t.Helper()
if job.Status != status || (code != "" && (job.Error == nil || *job.Error != code)) {
t.Errorf("title job = %s %v, want %s %s", job.Status, deref(job.Error), status, code)
}
}
// TestGenerateTitlesNewList: a new list is returned untitled with a title
// job, which titles it by archive_title from the plan's dinners in day
// order, a dinner with no group included, and stores the title.
func TestGenerateTitlesNewList(t *testing.T) {
e := titleWeek(t)
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
g := e.generate(t)
wantUntitled(t, "returned title", g.List.Title)
job := e.waitTitle(t, g)
wantJob(t, job, jobs.StatusSucceeded, "")
if want := `{"list_id":"` + g.List.ID + `","title":"Noodles, Tacos and Chili"}`; string(job.Result) != want {
t.Errorf("job result = %s, want %s", job.Result, want)
}
wantTitle(t, "stored title", e.storedTitle(t), "Noodles, Tacos and Chili")
got, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
wantTitle(t, "title read back", got.Title, "Noodles, Tacos and Chili")
reqs := e.model.Requests()
if len(reqs) != 1 {
t.Fatalf("%d model calls, want 1", len(reqs))
}
want := "Title the week whose dinners are, in day order from Monday:\n- Pad Thai\n- Fish Tacos\n- Beef Chili"
if user := reqs[0].Messages[0].Content; user != want {
t.Errorf("user message = %q, want %q", user, want)
}
if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusOK) {
t.Errorf("archive_title runs = %q, want one ok", s)
}
}
// TestGenerateTitleTooLongRepaired: a title over 48 characters is repaired
// through the repair turn, and the repaired title is stored, not a
// truncation of the first.
func TestGenerateTitleTooLongRepaired(t *testing.T) {
e := titleWeek(t)
long := "Pad Thai Monday, Fish Taco Tuesday and Chili Wednesday"
e.model.QueueGenerate(titled(long), titled("Noodles, Tacos and Chili"))
wantJob(t, e.waitTitle(t, e.generate(t)), jobs.StatusSucceeded, "")
wantTitle(t, "stored title", e.storedTitle(t), "Noodles, Tacos and Chili")
reqs := e.model.Requests()
if len(reqs) != 2 || !strings.Contains(reqs[1].Messages[2].Content, "has 54 characters; shorten it to at most 48") {
t.Fatalf("model calls = %+v, want a repair turn asking for a shorter title", reqs)
}
if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusRepaired) {
t.Errorf("archive_title runs = %q, want one repaired", s)
}
}
// TestGenerateTitleFailureKeepsList: archive_title giving an emoji twice
// fails, and the list is still made, whole and untitled, with the failure
// logged, recorded in ai_runs and failing the title job.
func TestGenerateTitleFailureKeepsList(t *testing.T) {
e := titleWeek(t)
e.model.QueueGenerate(titled("Taco Week 🌮"), titled("Taco Week 🌮"))
g, err := e.svc.Generate(t.Context(), week)
if err != nil {
t.Fatalf("Generate = %v, want the list made though its title failed", err)
}
want := []string{
"0 2026-10-12 Pad Thai: 0 rice noodles [8 oz] needed",
"1 2026-10-14 Beef Chili: 0 ground beef [1 lb] needed",
}
wantLines(t, "list", lines(g.List), want)
wantLines(t, "stored list", e.stored(t), want)
if len(g.Skipped) != 1 || g.Skipped[0].DinnerName != "Fish Tacos" {
t.Errorf("skipped = %+v, want Fish Tacos", g.Skipped)
}
wantJob(t, e.waitTitle(t, g), jobs.StatusFailed, shopping.ReasonTitleFailed)
wantUntitled(t, "returned title", g.List.Title)
wantUntitled(t, "stored title", e.storedTitle(t))
if g.List.Status != shopping.StatusCurrent {
t.Errorf("status = %q, want current", g.List.Status)
}
log := e.log.String()
for _, part := range []string{"level=WARN", "Supply run left untitled", "week=2026-10-12", "has an emoji (U+1F32E)"} {
if !strings.Contains(log, part) {
t.Errorf("log lacks %q:\n%s", part, log)
}
}
if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusBadOutput) {
t.Errorf("archive_title runs = %q, want one bad_output", s)
}
}
// TestGenerateRetriesTitle: a list left untitled is titled by the week's
// next generation, Generate or GeneratePushed; once titled, a remake keeps
// its title and asks for none.
func TestGenerateRetriesTitle(t *testing.T) {
e := titleWeek(t)
e.model.QueueGenerate(llm.GenerateResult{Err: fmt.Errorf("%w: model down", llm.ErrProvider)})
first := e.generate(t)
wantJob(t, e.waitTitle(t, first), jobs.StatusFailed, shopping.ReasonTitleFailed)
wantUntitled(t, "first title", e.storedTitle(t))
if !strings.Contains(e.log.String(), "model down") {
t.Errorf("log lacks the failure:\n%s", e.log.String())
}
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
plan, err := db.New(e.st.Reader).GetWeekPlan(t.Context(), week.String())
if err != nil {
t.Fatal(err)
}
second, err := e.svc.GeneratePushed(t.Context(), week, plan.Revision)
if err != nil {
t.Fatal(err)
}
if second.List.ID != first.List.ID {
t.Fatalf("second generation made list %s, want list %s again", second.List.ID, first.List.ID)
}
wantJob(t, e.waitTitle(t, second), jobs.StatusSucceeded, "")
wantTitle(t, "retried title", e.storedTitle(t), "Noodles, Tacos and Chili")
e.swap(t, "2026-10-12", "Beef Chili")
third := e.generate(t)
wantTitle(t, "title after a remake", third.List.Title, "Noodles, Tacos and Chili")
if third.TitleJobID != nil {
t.Errorf("a remake of a titled list enqueued title job %s", *third.TitleJobID)
}
if n := len(e.model.Requests()); n != 2 {
t.Errorf("%d model calls, want 2: a titled list is not titled again", n)
}
}
// TestGenerateTitleNotStoredOnFrozenList: a list frozen into the Supply
// archive while its title is written keeps no title, and the failure is
// logged.
func TestGenerateTitleNotStoredOnFrozenList(t *testing.T) {
e := titleWeek(t)
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
e.beforeTitle = func() {
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
t.Error(err)
}
}
wantJob(t, e.waitTitle(t, e.generate(t)), jobs.StatusFailed, shopping.ReasonTitleFailed)
wantUntitled(t, "stored title", e.storedTitle(t))
if !strings.Contains(e.log.String(), "the Supply run was frozen or replaced before its title was stored") {
t.Errorf("log lacks the failure:\n%s", e.log.String())
}
}
// TestGenerateTitleOverlapAcrossRename: a dinner renamed between two
// generations of an untitled list changes the names to title but not the
// plan revision. The two title jobs, running at once, each send the model
// their own names; neither is given the other's title.
func TestGenerateTitleOverlapAcrossRename(t *testing.T) {
e := titleWeek(t)
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"), titled("Drunken Noodles Week"))
// Each model call waits for the other, so the runs overlap.
var arrived sync.WaitGroup
arrived.Add(2)
e.beforeModel = func(llm.Request) {
arrived.Done()
done := make(chan struct{})
go func() { arrived.Wait(); close(done) }()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Error("the title runs did not overlap: one model call never came")
}
}
revision := func() int64 {
p, err := db.New(e.st.Reader).GetWeekPlan(t.Context(), week.String())
if err != nil {
t.Fatal(err)
}
return p.Revision
}
before := revision()
first := e.generate(t)
if _, err := e.st.Writer.Exec(`UPDATE dinners SET name = 'Drunken Noodles', normalized_name = 'drunken noodles' WHERE id = ?`, e.ids["Pad Thai"]); err != nil {
t.Fatal(err)
}
second := e.generate(t)
if revision() != before {
t.Fatalf("plan revision moved from %d to %d: the rename must leave it", before, revision())
}
wantJob(t, e.waitTitle(t, first), jobs.StatusSucceeded, "")
wantJob(t, e.waitTitle(t, second), jobs.StatusSucceeded, "")
var users []string
for _, r := range e.model.Requests() {
users = append(users, r.Messages[0].Content)
}
slices.Sort(users)
want := []string{
"Title the week whose dinners are, in day order from Monday:\n- Drunken Noodles\n- Fish Tacos\n- Beef Chili",
"Title the week whose dinners are, in day order from Monday:\n- Pad Thai\n- Fish Tacos\n- Beef Chili",
}
if !slices.Equal(users, want) {
t.Errorf("model was sent %q, want each generation's own names %q", users, want)
}
if s := e.runStatuses(t); len(s) != 2 {
t.Errorf("archive_title runs = %q, want two", s)
}
if got := e.storedTitle(t); got == nil || (*got != "Noodles, Tacos and Chili" && *got != "Drunken Noodles Week") {
t.Errorf("stored title = %v, want one of the two titles", deref(got))
}
}
+132 -4
View File
@@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required" { if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required", got) t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled", got)
} }
} }
@@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required" { if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required", got) t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled", got)
} }
if !tableExists(t, s, "dinners") { if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created") t.Error("table from 0001_init was not created")
@@ -761,3 +761,131 @@ func TestMigrateGoogleReconnectKeepsConnection(t *testing.T) {
t.Errorf("connection after 0015 = %s, want %s", got, row) t.Errorf("connection after 0015 = %s, want %s", got, row)
} }
} }
// migrateBefore applies every migration whose name sorts before prefix.
func migrateBefore(t *testing.T, s *Store, prefix string) {
t.Helper()
before := fstest.MapFS{}
matches, err := fs.Glob(migrations.FS, "*.sql")
if err != nil {
t.Fatal(err)
}
for _, name := range matches {
if name >= prefix {
continue
}
body, err := fs.ReadFile(migrations.FS, name)
if err != nil {
t.Fatal(err)
}
before[name] = &fstest.MapFile{Data: body}
}
if err := Migrate(context.Background(), s.Writer, before); err != nil {
t.Fatal(err)
}
}
// seedShopping stores two dinners, a plan with dinner-1 on 2026-10-13
// and dinner-2 on 2026-10-14, and a titled list with a group of
// groupDinner holding an item marked "have it".
func seedShopping(t *testing.T, s *Store, groupDinner string) {
t.Helper()
for _, stmt := range []string{
`INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at)
VALUES ('dinner-1', 'Pho', 'pho', 'regular', 'regular', 0, 'grounded', 'c', 'u'),
('dinner-2', 'Ramen', 'ramen', 'regular', 'regular', 0, 'grounded', 'c', 'u'),
('dinner-3', 'Laksa', 'laksa', 'regular', 'regular', 0, 'grounded', 'c', 'u')`,
`INSERT INTO week_plans (id, week_start, status, created_at, updated_at)
VALUES ('plan-1', '2026-10-12', 'synced', 'c', 'u')`,
`INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at)
VALUES ('day-1', 'plan-1', '2026-10-13', 'dinner-1', 0, 'c', 'u'),
('day-2', 'plan-1', '2026-10-14', 'dinner-2', 0, 'c', 'u')`,
`INSERT INTO shopping_lists (id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at)
VALUES ('list-1', '2026-10-12', 'plan-1', 'Noodle week', 'current', 's', NULL, 'c1', 'u1')`,
`INSERT INTO shopping_groups (id, list_id, dinner_id, position, created_at, updated_at)
VALUES ('group-1', 'list-1', '` + groupDinner + `', 0, 'c2', 'u2')`,
`INSERT INTO shopping_items (id, group_id, name, qty, needed, position, created_at, updated_at)
VALUES ('item-1', 'group-1', 'rice noodles', '200 g', 0, 0, 'c3', 'u3')`,
} {
if _, err := s.Writer.Exec(stmt); err != nil {
t.Fatalf("seed: %v", err)
}
}
}
// TestMigrateShoppingListUntitled applies 0016, which rebuilds the
// shopping tables: every row survives, a group takes its dinner's plan
// day, and a list may then be untitled but never have an empty title.
func TestMigrateShoppingListUntitled(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
migrateBefore(t, s, "0016")
seedShopping(t, s, "dinner-2")
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
var got string
if err := s.Reader.QueryRowContext(ctx, `SELECT
l.id || '|' || l.week_start || '|' || l.plan_id || '|' || l.title || '|' || l.status || '|' ||
l.sent_to_chat_at || '|' || COALESCE(l.frozen_at, '') || '|' || l.created_at || '|' || l.updated_at || ' / ' ||
g.id || '|' || g.list_id || '|' || g.dinner_id || '|' || g.date || '|' || g.position || '|' || g.created_at || '|' || g.updated_at || ' / ' ||
i.id || '|' || i.group_id || '|' || i.name || '|' || i.qty || '|' || i.needed || '|' || i.position || '|' || i.created_at || '|' || i.updated_at
FROM shopping_lists l JOIN shopping_groups g ON g.list_id = l.id JOIN shopping_items i ON i.group_id = g.id`).Scan(&got); err != nil {
t.Fatal(err)
}
const want = `list-1|2026-10-12|plan-1|Noodle week|current|s||c1|u1 / ` +
`group-1|list-1|dinner-2|2026-10-14|0|c2|u2 / ` +
`item-1|group-1|rice noodles|200 g|0|0|c3|u3`
if got != want {
t.Errorf("shopping rows after 0016 =\n %s\nwant\n %s", got, want)
}
if _, err := s.Writer.Exec(`UPDATE shopping_lists SET title = NULL WHERE id = 'list-1'`); err != nil {
t.Errorf("untitled list refused: %v", err)
}
for _, tc := range []struct{ name, stmt, want string }{
{"empty title", `UPDATE shopping_lists SET title = '' WHERE id = 'list-1'`, "CHECK constraint failed"},
{"group with no date", `UPDATE shopping_groups SET date = NULL WHERE id = 'group-1'`, "NOT NULL constraint failed"},
{"group of an unknown list", `UPDATE shopping_groups SET list_id = 'nope' WHERE id = 'group-1'`, "FOREIGN KEY constraint failed"},
{"item of an unknown group", `UPDATE shopping_items SET group_id = 'nope' WHERE id = 'item-1'`, "FOREIGN KEY constraint failed"},
{"needed not a boolean", `UPDATE shopping_items SET needed = 2 WHERE id = 'item-1'`, "CHECK constraint failed"},
{"second list of a week", `INSERT INTO shopping_lists (id, week_start, plan_id, status, created_at, updated_at)
VALUES ('list-2', '2026-10-12', 'plan-1', 'current', 'c', 'u')`, "UNIQUE constraint failed"},
} {
if _, err := s.Writer.Exec(tc.stmt); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: error %v, want %s", tc.name, err, tc.want)
}
}
for _, table := range []string{"shopping_lists_copy", "shopping_groups_copy", "shopping_items_copy"} {
if tableExists(t, s, table) {
t.Errorf("%s left behind", table)
}
}
}
// TestMigrateShoppingListUntitledRefusesUndatedGroup applies 0016 over a
// group whose dinner is not on its list's plan: it has no plan day, so the
// migration fails and leaves the database as it was.
func TestMigrateShoppingListUntitledRefusesUndatedGroup(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
migrateBefore(t, s, "0016")
seedShopping(t, s, "dinner-3")
err := Migrate(ctx, s.Writer, migrations.FS)
if err == nil || !strings.Contains(err.Error(), "NOT NULL constraint failed: shopping_groups.date") {
t.Fatalf("Migrate = %v, want the undated group refused", err)
}
var n int
if err := s.Reader.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations WHERE version = '0016_shopping_list_untitled'`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Error("0016 recorded as applied")
}
if tableExists(t, s, "shopping_groups_copy") {
t.Error("shopping_groups_copy left behind")
}
}
+3 -3
View File
@@ -43,7 +43,7 @@ var repoParents = map[string]repoRow{
}}, }},
"shopping_groups": {"shopping_groups", map[string]any{ "shopping_groups": {"shopping_groups", map[string]any{
"id": "group-1", "list_id": "list-1", "dinner_id": "dinner-1", "id": "group-1", "list_id": "list-1", "dinner_id": "dinner-1",
"position": int64(0), "created_at": repoTime, "updated_at": repoTime, "date": "2026-10-05", "position": int64(0), "created_at": repoTime, "updated_at": repoTime,
}}, }},
} }
@@ -108,12 +108,12 @@ func TestRepositoryInsertReadUpdate(t *testing.T) {
"title": "Supply run", "status": "current", "sent_to_chat_at": nil, "frozen_at": nil, "title": "Supply run", "status": "current", "sent_to_chat_at": nil, "frozen_at": nil,
"created_at": repoTime, "updated_at": repoTime, "created_at": repoTime, "updated_at": repoTime,
}}, }},
update: map[string]any{"status": "archived", "frozen_at": "2026-10-12T00:00:00Z"}, update: map[string]any{"title": nil, "status": "archived", "frozen_at": "2026-10-12T00:00:00Z"},
}, },
{ {
parents: []string{"dinners", "week_plans", "shopping_lists"}, parents: []string{"dinners", "week_plans", "shopping_lists"},
row: repoRow{"shopping_groups", map[string]any{ row: repoRow{"shopping_groups", map[string]any{
"id": "group-2", "list_id": "list-1", "dinner_id": "dinner-1", "position": int64(0), "id": "group-2", "list_id": "list-1", "dinner_id": "dinner-1", "date": "2026-10-06", "position": int64(0),
"created_at": repoTime, "updated_at": repoTime, "created_at": repoTime, "updated_at": repoTime,
}}, }},
update: map[string]any{"position": int64(3)}, update: map[string]any{"position": int64(3)},
+1
View File
@@ -136,6 +136,7 @@ func testRow(table string, i int) map[string]any {
case "shopping_groups": case "shopping_groups":
r["list_id"] = rowID("shopping_lists", 0) r["list_id"] = rowID("shopping_lists", 0)
r["dinner_id"] = rowID("dinners", 0) r["dinner_id"] = rowID("dinners", 0)
r["date"] = fmt.Sprintf("2026-01-%02d", i+5)
r["position"] = i r["position"] = i
case "shopping_items": case "shopping_items":
r["group_id"] = rowID("shopping_groups", 0) r["group_id"] = rowID("shopping_groups", 0)
@@ -0,0 +1,81 @@
-- 0016_shopping_list_untitled: a Supply run is generated from the plan
-- before it has a title, and stays untitled when the AI cannot write one,
-- so shopping_lists.title becomes nullable; an untitled list shows its
-- week label instead. A title is never empty. Each group also keeps the
-- plan day it shops for, so a list read back, or frozen into the Supply
-- archive, says which day each dinner was planned on.
-- SQLite cannot drop a NOT NULL constraint or add a NOT NULL column
-- without a default, so the three shopping tables are rebuilt as 0012
-- does: copy the rows aside, drop the tables children first, create them
-- again, copy the rows back parents first and drop the copies. A group
-- takes the date its dinner has on its list's plan; a group whose dinner
-- is not on that plan has no date and fails the migration. Nothing else
-- references the shopping tables.
CREATE TABLE shopping_items_copy AS SELECT * FROM shopping_items;
CREATE TABLE shopping_groups_copy AS SELECT * FROM shopping_groups;
CREATE TABLE shopping_lists_copy AS SELECT * FROM shopping_lists;
DROP TABLE shopping_items;
DROP TABLE shopping_groups;
DROP TABLE shopping_lists;
CREATE TABLE shopping_lists (
id TEXT NOT NULL PRIMARY KEY,
week_start TEXT NOT NULL UNIQUE,
plan_id TEXT NOT NULL REFERENCES week_plans (id),
title TEXT CHECK (title <> ''), -- NULL until the AI titles it
status TEXT NOT NULL CHECK (status IN ('current', 'archived')),
sent_to_chat_at TEXT,
frozen_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE shopping_groups (
id TEXT NOT NULL PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES shopping_lists (id),
dinner_id TEXT NOT NULL REFERENCES dinners (id),
date TEXT NOT NULL, -- the plan day, household-local YYYY-MM-DD
position INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE shopping_items (
id TEXT NOT NULL PRIMARY KEY,
group_id TEXT NOT NULL REFERENCES shopping_groups (id),
name TEXT NOT NULL,
qty TEXT,
needed INTEGER NOT NULL DEFAULT 1 CHECK (needed IN (0, 1)),
position INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO shopping_lists (
id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at
)
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at
FROM shopping_lists_copy;
INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
SELECT
shopping_groups_copy.id, shopping_groups_copy.list_id, shopping_groups_copy.dinner_id,
(
SELECT MIN(plan_days.date)
FROM plan_days
JOIN shopping_lists_copy ON shopping_lists_copy.plan_id = plan_days.plan_id
WHERE shopping_lists_copy.id = shopping_groups_copy.list_id
AND plan_days.dinner_id = shopping_groups_copy.dinner_id
),
shopping_groups_copy.position, shopping_groups_copy.created_at, shopping_groups_copy.updated_at
FROM shopping_groups_copy;
INSERT INTO shopping_items (id, group_id, name, qty, needed, position, created_at, updated_at)
SELECT id, group_id, name, qty, needed, position, created_at, updated_at
FROM shopping_items_copy;
DROP TABLE shopping_items_copy;
DROP TABLE shopping_groups_copy;
DROP TABLE shopping_lists_copy;
+104
View File
@@ -0,0 +1,104 @@
-- name: GetShoppingList :one
-- The Supply run of the week starting week_start. Returns sql.ErrNoRows
-- when there is none.
SELECT * FROM shopping_lists
WHERE week_start = ?;
-- name: CreateShoppingList :exec
-- A new, untitled Supply run: the title is written later, by the AI.
INSERT INTO shopping_lists (id, week_start, plan_id, status, created_at, updated_at)
VALUES (?, ?, ?, 'current', ?, ?);
-- name: RegenerateShoppingList :exec
-- Records a generation of list id from plan plan_id.
UPDATE shopping_lists
SET plan_id = ?, updated_at = ?
WHERE id = ?;
-- name: ListShoppingGroups :many
-- The groups of list list_id, in list order.
SELECT * FROM shopping_groups
WHERE list_id = ?
ORDER BY position, id;
-- name: ListShoppingItems :many
-- The items of list list_id with their group's dinner, in list order: by
-- group, then by item.
SELECT sqlc.embed(shopping_items), shopping_groups.dinner_id
FROM shopping_items
JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id
WHERE shopping_groups.list_id = ?
ORDER BY shopping_groups.position, shopping_items.position, shopping_items.id;
-- name: CreateShoppingGroup :exec
INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?);
-- name: MoveShoppingGroup :exec
UPDATE shopping_groups
SET position = ?, updated_at = ?
WHERE id = ?;
-- name: DeleteShoppingGroup :exec
DELETE FROM shopping_groups
WHERE id = ?;
-- name: CreateShoppingItem :exec
-- A new item is needed until the household marks it "have it".
INSERT INTO shopping_items (id, group_id, name, qty, needed, position, created_at, updated_at)
VALUES (?, ?, ?, ?, 1, ?, ?, ?);
-- name: MoveShoppingItem :exec
-- Places item id in group group_id at position with quantity qty; its
-- name and needed flag are kept.
UPDATE shopping_items
SET group_id = ?, qty = ?, position = ?, updated_at = ?
WHERE id = ?;
-- name: DeleteShoppingItem :exec
DELETE FROM shopping_items
WHERE id = ?;
-- name: ListShoppingGroupDinners :many
-- The groups of list list_id with their dinner's name, in list order.
SELECT sqlc.embed(shopping_groups), dinners.name AS dinner_name
FROM shopping_groups
JOIN dinners ON dinners.id = shopping_groups.dinner_id
WHERE shopping_groups.list_id = ?
ORDER BY shopping_groups.position, shopping_groups.id;
-- name: GetShoppingItemList :one
-- The Supply run holding item id. Returns sql.ErrNoRows when there is no
-- such item.
SELECT sqlc.embed(shopping_lists)
FROM shopping_items
JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id
JOIN shopping_lists ON shopping_lists.id = shopping_groups.list_id
WHERE shopping_items.id = ?;
-- name: SetShoppingItemNeeded :exec
-- Marks item id needed (1) or "have it" (0).
UPDATE shopping_items
SET needed = ?, updated_at = ?
WHERE id = ?;
-- name: SetShoppingListTitle :execrows
-- Titles list id, unless it has a title already or is frozen into the
-- Supply archive.
UPDATE shopping_lists
SET title = ?, updated_at = ?
WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL;
-- name: FreezeShoppingList :execrows
-- Freezes list id into the Supply archive at frozen_at, unless it is
-- frozen already.
UPDATE shopping_lists
SET status = 'archived', frozen_at = ?, updated_at = ?
WHERE id = ? AND frozen_at IS NULL;
-- name: ListShoppingListsThrough :many
-- The Supply runs of the weeks starting on or before week_start, newest
-- week first.
SELECT * FROM shopping_lists
WHERE week_start <= ?
ORDER BY week_start DESC;
+38
View File
@@ -8,6 +8,7 @@ import App, { routes } from './App.tsx'
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx' import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
import { InDataRouter } from './test/dataRouter.tsx' import { InDataRouter } from './test/dataRouter.tsx'
import { dinnerBody } from './test/dinners.ts' import { dinnerBody } from './test/dinners.ts'
import { defaultWeek } from './plan/week.ts'
afterEach(() => { afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia') Reflect.deleteProperty(window, 'matchMedia')
@@ -31,6 +32,8 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
const SCREENS = { const SCREENS = {
'/onboarding': true, '/onboarding': true,
'/plan': true, '/plan': true,
'/plan/shopping': true,
'/plan/shopping/archive': true,
'/discover': true, '/discover': true,
'/settings': true, '/settings': true,
'/settings/ai': true, '/settings/ai': true,
@@ -105,6 +108,41 @@ describe('App routes', () => {
expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument() expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument()
}) })
it('/plan/shopping renders the Supply run inside the shell', async () => {
mockViewport(390)
const week = defaultWeek(HOUSEHOLD_SETTINGS, new Date())
stubFetchByPath({
...SIGNED_IN,
[`/api/v1/shopping/${week}`]: () =>
jsonResponse(200, {
weekStart: week,
label: 'Week 41 · Oct 5 – 11',
title: null,
status: 'current',
sent_to_chat_at: null,
needed_count: 1,
total_count: 1,
groups: [{ dinner: { id: 'd1', name: 'Beef tacos' }, date: week, day: 'Monday', items: [{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true }] }],
}),
})
renderAt('/plan/shopping')
const heading = await screen.findByRole('heading', { level: 1, name: 'Shopping list' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByRole('checkbox', { name: 'Ground beef' })).toBeChecked()
})
it('/plan/shopping/archive renders the Supply archive inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
...SIGNED_IN,
'/api/v1/shopping/archive?by=week': () => jsonResponse(200, { by: 'week', periods: [] }),
})
renderAt('/plan/shopping/archive')
const heading = await screen.findByRole('heading', { level: 1, name: 'Supply archive' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByText('Nothing archived yet')).toBeInTheDocument()
})
it('/discover renders the Side quests deck inside the shell', async () => { it('/discover renders the Side quests deck inside the shell', async () => {
mockViewport(390) mockViewport(390)
stubFetchByPath({ stubFetchByPath({
+6
View File
@@ -16,6 +16,9 @@ import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx' import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
import { SettingsPage } from './settings/SettingsPage.tsx' import { SettingsPage } from './settings/SettingsPage.tsx'
import { AppShell } from './shell/AppShell.tsx' import { AppShell } from './shell/AppShell.tsx'
import { SupplyRunSync } from './shopping/api.ts'
import { SupplyArchivePage } from './shopping/SupplyArchivePage.tsx'
import { SupplyRunPage } from './shopping/SupplyRunPage.tsx'
// Every route the app serves, with the heading its placeholder page shows. // Every route the app serves, with the heading its placeholder page shows.
export const routes = [ export const routes = [
@@ -39,6 +42,8 @@ export const routes = [
// The screens built so far; every other route shows its placeholder page. // The screens built so far; every other route shows its placeholder page.
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = { const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/plan': <PlanPage />, '/plan': <PlanPage />,
'/plan/shopping': <SupplyRunPage />,
'/plan/shopping/archive': <SupplyArchivePage />,
'/discover': <DiscoverPage />, '/discover': <DiscoverPage />,
'/settings': <SettingsPage />, '/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />, '/settings/ai': <AISettingsPage />,
@@ -86,6 +91,7 @@ export default function App({ api, serviceWorker }: AppProps) {
<ApiProvider client={api}> <ApiProvider client={api}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthBootstrap> <AuthBootstrap>
<SupplyRunSync />
<AppRoutes /> <AppRoutes />
</AuthBootstrap> </AuthBootstrap>
<OfflinePill /> <OfflinePill />
+1 -1
View File
@@ -20,7 +20,7 @@ export const meQueryKey = [ME_PATH] as const
export function AuthBootstrap({ children }: { children: ReactNode }) { export function AuthBootstrap({ children }: { children: ReactNode }) {
const api = useApi() const api = useApi()
const forbidden = useSyncExternalStore(api.subscribe, api.forbidden) const forbidden = useSyncExternalStore(api.subscribe, api.forbidden)
const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity, networkMode: 'always' }) const me = useQuery({ queryKey: meQueryKey, queryFn: () => api.loadMe(), staleTime: Infinity, networkMode: 'always' })
// fetch rejects with a TypeError only when the network fails. // fetch rejects with a TypeError only when the network fails.
const savedPages = me.isError && me.error instanceof TypeError && api.session.verified() const savedPages = me.isError && me.error instanceof TypeError && api.session.verified()
const { refetch } = me const { refetch } = me
+72
View File
@@ -125,6 +125,66 @@ describe('apiFetch', () => {
expect(api.forbidden()).toBe(false) expect(api.forbidden()).toBe(false)
}) })
it('shares a csrf refresh between requests, but one whose signal aborts stops waiting and the next refreshes afresh', async () => {
// The first refresh answers only when answerFirst is called; every other
// request answers at once.
let answerFirst!: (response: Response) => void
const mes: (AbortSignal | null | undefined)[] = []
const fetchMock = vi.fn<typeof fetch>((input, init) => {
if (String(input) === '/api/v1/me') {
mes.push(init?.signal)
if (mes.length === 2) return new Promise((resolve) => (answerFirst = resolve))
return Promise.resolve(jsonResponse(200, { ...ME, csrfToken: `token-${mes.length}` }))
}
const token = new Headers(init?.headers).get(CSRF_HEADER)
return Promise.resolve(token === 'token-1' ? errorResponse(403, 'csrf') : jsonResponse(201, { token }))
})
vi.stubGlobal('fetch', fetchMock)
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const unbounded = api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })
const timedOut = new AbortController()
const bounded = api.apiFetch('/api/v1/jobs', { method: 'POST', body: {}, signal: timedOut.signal })
await vi.waitFor(() => expect(mes).toHaveLength(2))
timedOut.abort(new DOMException('no answer in time', 'TimeoutError'))
await expect(bounded).rejects.toMatchObject({ name: 'TimeoutError' })
// A request without a signal still waits for it, so it is not aborted.
expect(mes[1]!.aborted).toBe(false)
// A later csrf 403 does not wait on the refresh still in flight.
await expect(
api.apiFetch('/api/v1/jobs', { method: 'POST', body: {}, signal: new AbortController().signal }),
).resolves.toEqual({ token: 'token-3' })
expect(mes).toHaveLength(3)
answerFirst(jsonResponse(200, { ...ME, csrfToken: 'token-2' }))
await expect(unbounded).resolves.toEqual({ token: 'token-2' })
expect(mes).toHaveLength(3)
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(5)
})
it('aborts a csrf refresh once the only request waiting for it aborts, without counting it as offline', async () => {
const mes: AbortSignal[] = []
const fetchMock = vi.fn<typeof fetch>((input, init) => {
if (String(input) !== '/api/v1/me') return Promise.resolve(errorResponse(403, 'csrf'))
if (mes.push(init!.signal!) === 1) return Promise.resolve(jsonResponse(200, ME))
return new Promise((_, reject) => init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason as Error)))
})
vi.stubGlobal('fetch', fetchMock)
const network = createNetworkStatus(window)
const api = createApiClient(fakeLocation(), network, createVerifiedSession(localStorage))
await api.loadMe()
const timedOut = new AbortController()
const bounded = api.apiFetch('/api/v1/jobs', { method: 'POST', body: {}, signal: timedOut.signal })
await vi.waitFor(() => expect(mes).toHaveLength(2))
timedOut.abort(new DOMException('no answer in time', 'TimeoutError'))
await expect(bounded).rejects.toMatchObject({ name: 'TimeoutError' })
expect(mes[1]!.aborted).toBe(true)
expect(network.offline()).toBe(false)
})
it('marks any other 403 as not in this household and notifies subscribers', async () => { it('marks any other 403 as not in this household and notifies subscribers', async () => {
const fetchMock = stubFetch(errorResponse(403, 'forbidden')) const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
@@ -153,6 +213,18 @@ describe('apiFetch', () => {
}) })
describe('loadMe', () => { describe('loadMe', () => {
it('aborts /me with its signal', async () => {
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>((_, init) => new Promise((_, reject) => init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason as Error)))),
)
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
const timedOut = new AbortController()
const me = api.loadMe(timedOut.signal)
timedOut.abort(new DOMException('no answer in time', 'TimeoutError'))
await expect(me).rejects.toMatchObject({ name: 'TimeoutError' })
})
it('rejects a /me response without a CSRF token', async () => { it('rejects a /me response without a CSRF token', async () => {
stubFetch(jsonResponse(200, { ...ME, csrfToken: '' })) stubFetch(jsonResponse(200, { ...ME, csrfToken: '' }))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
+59 -15
View File
@@ -41,6 +41,10 @@ export type ApiRequest = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
// Sent as JSON. A GET must not have one. // Sent as JSON. A GET must not have one.
body?: unknown body?: unknown
// Aborts the request, including the /me refresh a csrf 403 makes it wait
// for; it then rejects with the signal's reason. No request has a deadline
// unless its caller aborts it.
signal?: AbortSignal
} }
// ApiRead is a GET's decoded response body, and whether the service worker // ApiRead is a GET's decoded response body, and whether the service worker
@@ -55,8 +59,9 @@ export type ApiClient = {
// apiRead is apiFetch's GET that also says whether the body is the service // apiRead is apiFetch's GET that also says whether the body is the service
// worker's cached copy rather than the server's answer. // worker's cached copy rather than the server's answer.
apiRead<T>(path: string): Promise<ApiRead<T>> apiRead<T>(path: string): Promise<ApiRead<T>>
// loadMe fetches GET /api/v1/me and caches its CSRF token. // loadMe fetches GET /api/v1/me and caches its CSRF token; signal aborts
loadMe(): Promise<Me> // it, as it does an apiFetch.
loadMe(signal?: AbortSignal): Promise<Me>
// forbidden reports whether a request was refused because the signed-in // forbidden reports whether a request was refused because the signed-in
// account is not in this household; subscribe is notified when it changes. // account is not in this household; subscribe is notified when it changes.
forbidden(): boolean forbidden(): boolean
@@ -70,15 +75,20 @@ export type ApiClient = {
session: VerifiedSession session: VerifiedSession
} }
// Refresh is the /me request refreshMe shares: controller aborts it, waiting
// counts the requests with a signal still waiting for it, and unbounded says
// whether one without a signal is.
type Refresh = { me: Promise<Me>; controller: AbortController; waiting: number; unbounded: boolean }
// createApiClient builds the app's API client. location is window.location in // createApiClient builds the app's API client. location is window.location in
// the app: a 401 sends it to /auth/login with the current path to return to. // the app: a 401 sends it to /auth/login with the current path to return to.
export function createApiClient(location: ApiLocation, network: NetworkStatus, session: VerifiedSession): ApiClient { export function createApiClient(location: ApiLocation, network: NetworkStatus, session: VerifiedSession): ApiClient {
let csrfToken: string | null = null let csrfToken: string | null = null
let refreshing: Promise<Me> | null = null let refreshing: Refresh | null = null
let forbidden = false let forbidden = false
const listeners = new Set<() => void>() const listeners = new Set<() => void>()
async function send(method: string, path: string, body: unknown): Promise<Response> { async function send(method: string, path: string, body: unknown, signal: AbortSignal | undefined): Promise<Response> {
const headers: Record<string, string> = { Accept: 'application/json' } const headers: Record<string, string> = { Accept: 'application/json' }
if (body !== undefined) headers['Content-Type'] = 'application/json' if (body !== undefined) headers['Content-Type'] = 'application/json'
if (method !== 'GET') { if (method !== 'GET') {
@@ -97,7 +107,7 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
} }
let response: Response let response: Response
try { try {
response = await fetch(path, { method, headers, credentials: 'same-origin', body: json }) response = await fetch(path, { method, headers, credentials: 'same-origin', body: json, signal })
} catch (error) { } catch (error) {
// fetch rejects with a TypeError when the network fails; an abort is a // fetch rejects with a TypeError when the network fails; an abort is a
// DOMException and says nothing about the network. // DOMException and says nothing about the network.
@@ -112,12 +122,46 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
} }
// refreshMe shares one in-flight /me request between every request that // refreshMe shares one in-flight /me request between every request that
// got a csrf 403 at the same time. // got a csrf 403 at the same time. A request whose signal aborts stops
function refreshMe(): Promise<Me> { // waiting for it at once, and it is no longer shared: the next csrf 403
refreshing ??= loadMe().finally(() => { // starts a fresh /me rather than wait on one that may never answer. Once
refreshing = null // every request with a signal waiting for it has stopped, and none without
// one waits, it is aborted.
function refreshMe(signal: AbortSignal | undefined): Promise<Me> {
if (refreshing === null) {
const controller = new AbortController()
const refresh: Refresh = { me: loadMe(controller.signal), controller, waiting: 0, unbounded: false }
const done = () => {
if (refreshing === refresh) refreshing = null
}
refresh.me.then(done, done)
refreshing = refresh
}
const refresh = refreshing
if (signal === undefined) {
refresh.unbounded = true
return refresh.me
}
refresh.waiting++
return new Promise((resolve, reject) => {
const stop = () => {
if (refreshing === refresh) refreshing = null
if (--refresh.waiting === 0 && !refresh.unbounded) refresh.controller.abort(signal.reason)
reject(signal.reason as Error)
}
if (signal.aborted) return stop()
signal.addEventListener('abort', stop, { once: true })
refresh.me.then(
(me) => {
signal.removeEventListener('abort', stop)
resolve(me)
},
(error: unknown) => {
signal.removeEventListener('abort', stop)
reject(error as Error)
},
)
}) })
return refreshing
} }
async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> { async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> {
@@ -138,14 +182,14 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
if (method === 'GET' && request.body !== undefined) { if (method === 'GET' && request.body !== undefined) {
throw new Error(`GET ${path}: a GET request cannot carry a body`) throw new Error(`GET ${path}: a GET request cannot carry a body`)
} }
let response = await send(method, url, request.body) let response = await send(method, url, request.body, request.signal)
if (response.status === 403) { if (response.status === 403) {
const error = await parseForbidden(method, path, response) const error = await parseForbidden(method, path, response)
// Only a non-GET carries the token, so only it can be refused for it. // Only a non-GET carries the token, so only it can be refused for it.
if (error.code !== 'csrf' || method === 'GET') throw error if (error.code !== 'csrf' || method === 'GET') throw error
// The session's token changed: refresh it once and retry once. // The session's token changed: refresh it once and retry once.
await refreshMe() await refreshMe(request.signal)
response = await send(method, url, request.body) response = await send(method, url, request.body, request.signal)
if (response.status === 403) throw await parseForbidden(method, path, response) if (response.status === 403) throw await parseForbidden(method, path, response)
} }
return { body: await decode<T>(method, path, response), cached: response.headers.has(cacheFallbackHeader) } return { body: await decode<T>(method, path, response), cached: response.headers.has(cacheFallbackHeader) }
@@ -176,8 +220,8 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
return (await response.json()) as T return (await response.json()) as T
} }
async function loadMe(): Promise<Me> { async function loadMe(signal?: AbortSignal): Promise<Me> {
const me = await apiFetch<unknown>(ME_PATH) const me = await apiFetch<unknown>(ME_PATH, { signal })
if ( if (
!isRecord(me) || !isRecord(me) ||
typeof me.name !== 'string' || typeof me.name !== 'string' ||
+27 -2
View File
@@ -258,8 +258,8 @@
color: var(--color-neutral-700); color: var(--color-neutral-700);
} }
/* An action with nothing behind it yet (the shopping list until Feature /* An action with nothing behind it yet (Send to Google Chat until Feature
17): it keeps focus, so hovering, tapping or focusing it shows the 19): it keeps focus, so hovering, tapping or focusing it shows the
tooltip. */ tooltip. */
.unavailable { .unavailable {
position: relative; position: relative;
@@ -414,6 +414,31 @@
text-align: center; text-align: center;
} }
/* What became of the week's Supply run after a push. */
.pushed {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
text-align: center;
color: var(--color-neutral-700);
}
/* The way to the week's Supply run under the Draft board on mobile. */
.shoppingLink {
display: inline-flex;
align-self: center;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-accent-700);
text-decoration: none;
}
/* A push failure's way to the Google Calendar settings. */ /* A push failure's way to the Google Calendar settings. */
.settingsLink { .settingsLink {
color: inherit; color: inherit;
+442 -17
View File
@@ -1,9 +1,10 @@
import { QueryClientProvider } from '@tanstack/react-query' import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router' import { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { SupplyRunPage } from '../shopping/SupplyRunPage.tsx'
import { cacheFallbackHeader } from '../sw/routes.ts' import { cacheFallbackHeader } from '../sw/routes.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts' import { declaredStyle } from '../test/cssRules.ts'
@@ -52,9 +53,33 @@ function planBody(status: string, names = NAMES) {
} }
// syncedNow is the plan of the week of Oct 5 as a push answers it: synced // syncedNow is the plan of the week of Oct 5 as a push answers it: synced
// at the tests' noon. // at the tests' noon, with what became of the week's Supply run, made
function syncedNow() { // unless told otherwise.
return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z' } function syncedNow(supplyRun: { status: string; error: { code: string; message: string; retryable: boolean } | null } = GENERATED) {
return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z', supply_run: supplyRun }
}
// The Supply run outcomes a push answers with, as the server sends them.
const GENERATED = { status: 'generated', error: null }
const ARCHIVED = {
status: 'archived',
error: { code: 'archived', message: 'the Supply run is in the Supply archive', retryable: false },
}
const PLAN_CHANGED = {
status: 'failed',
error: {
code: 'plan_changed',
message: 'the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again',
retryable: true,
},
}
const SUPPLY_RUN_FAILED = {
status: 'failed',
error: {
code: 'supply_run_failed',
message: 'the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run',
retryable: true,
},
} }
// deferred is a response the test sends when it chooses. // deferred is a response the test sends when it chooses.
@@ -100,8 +125,12 @@ const lists = {
jsonResponse(200, { dinners: [dinner('d0', 'Beef tacos', 'easy', 30), dinner('r1', 'Fish tacos', 'medium', 35)] }), jsonResponse(200, { dinners: [dinner('d0', 'Beef tacos', 'easy', 30), dinner('r1', 'Fish tacos', 'medium', 35)] }),
} }
// SUPPLY_RUN is the Supply run of a week, as GET /api/v1/shopping/{weekStart}.
const SUPPLY_RUN = /^GET \/api\/v1\/shopping\/\d{4}-\d{2}-\d{2}$/
// stubServer answers "METHOD path" requests from responders, with /me and // stubServer answers "METHOD path" requests from responders, with /me and
// the household settings; any other request fails the test. // the household settings, and no Supply run (404) for a week given none;
// any other request fails the test.
function stubServer(responders: Record<string, Responder>) { function stubServer(responders: Record<string, Responder>) {
const all: Record<string, Responder> = { const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME), 'GET /api/v1/me': () => jsonResponse(200, ME),
@@ -110,7 +139,7 @@ function stubServer(responders: Record<string, Responder>) {
} }
const fetchMock = vi.fn<typeof fetch>((input, init) => { const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}` const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined const respond = Object.hasOwn(all, key) ? all[key] : SUPPLY_RUN.test(key) ? () => errorResponse(404, 'not_found') : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init)) return Promise.resolve(respond(init))
}) })
@@ -131,21 +160,67 @@ function planReads(fetchMock: Mock<typeof fetch>) {
} }
// renderPage loads /me (for the CSRF token every write needs) and renders // renderPage loads /me (for the CSRF token every write needs) and renders
// the Plan page. // the Plan page, over queryClient, with shopping as the Supply run screen.
async function renderPage() { async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />) {
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe() await api.loadMe()
return render( return render(
<ApiProvider client={api}> <ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}> <QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/plan']}> <MemoryRouter initialEntries={['/plan']}>
<PlanPage /> <Routes>
<Route path="/plan" element={<PlanPage />} />
<Route path="/plan/shopping" element={shopping} />
</Routes>
</MemoryRouter> </MemoryRouter>
</QueryClientProvider> </QueryClientProvider>
</ApiProvider>, </ApiProvider>,
) )
} }
// Opened stands in for the screen a link opened, saying where it is, with
// a link back to the Plan.
function Opened() {
const { pathname, search } = useLocation()
return (
<>
<p>Opened {pathname + search}</p>
<Link to="/plan">Back to the Plan</Link>
</>
)
}
// pushesKept counts the pushes of the week of Oct 5 the mutation cache of
// queryClient keeps.
function pushesKept(queryClient: QueryClient) {
return queryClient.getMutationCache().findAll({ mutationKey: ['plans', '2026-10-05', 'push'] }).length
}
// SupplyRunScreen is the Supply run screen, with a link back to the Plan.
function SupplyRunScreen() {
return (
<>
<SupplyRunPage />
<Link to="/plan">Back to the Plan</Link>
</>
)
}
// supplyRun is the Supply run of the week of Oct 5 with needed of its 32
// items still to buy.
function supplyRun(needed: number) {
return {
weekStart: '2026-10-05',
label: 'Week 41 · Oct 5 – 11',
title: null,
status: 'current',
sent_to_chat_at: null,
needed_count: needed,
total_count: 32,
groups: [],
}
}
// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so // Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so
// the page opens on this week. // the page opens on this week.
beforeEach(() => { beforeEach(() => {
@@ -392,6 +467,236 @@ describe('PlanPage', () => {
expect(planReads(fetchMock)).toBe(2) expect(planReads(fetchMock)).toBe(2)
}) })
it("reads the week's Supply run again once a push has made it, and says it was made", async () => {
let run = () => errorResponse(404, 'not_found')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => {
run = () => jsonResponse(200, supplyRun(28))
return jsonResponse(200, syncedNow(GENERATED))
},
'GET /api/v1/shopping/2026-10-05': () => run(),
})
await renderPage()
expect(await screen.findByRole('link', { name: 'Shopping list' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
// The list made is read at once, not after the cached 404 goes stale.
expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/shopping/2026-10-05')).toHaveLength(2)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('says a Supply run in the Supply archive was left as it was', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(ARCHIVED)),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent("This week's Supply run is in the Supply archive, so it was left as it was.")
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('says no Supply run was made when the plan changed after it was pushed, reads the plan again, and pushes again on Retry', async () => {
let plan = planBody('draft')
let push = () => {
plan = planBody('changed', ['Lasagna', ...NAMES.slice(1)])
return jsonResponse(200, syncedNow(PLAN_CHANGED))
}
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
[`POST ${PUSH}`]: () => push(),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent(
'Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push it again.',
)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
// The plan edited meanwhile is the one shown, not the one pushed.
expect(await screen.findByText('Lasagna')).toBeInTheDocument()
expect(screen.getByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
expect(planReads(fetchMock)).toBe(2)
push = () => jsonResponse(200, syncedNow(GENERATED))
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`])
})
it('says the Supply run could not be made after the push, and makes the shopping list again', async () => {
let generate = () => errorResponse(500, 'internal')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => generate(),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
// Calendar has the plan, but the push is not reported a quiet success.
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
expect(screen.queryByRole('status')).not.toBeInTheDocument()
fireEvent.click(within(alert).getByRole('button', { name: 'Make the shopping list' }))
expect(
await screen.findByText("Pushed to Calendar, but this week's Supply run couldn't be made. Couldn't make the shopping list. internal message"),
).toBeInTheDocument()
generate = () => jsonResponse(200, { ...supplyRun(28), skipped: [] })
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
expect(posts(fetchMock)).toEqual([
`${PUSH} undefined`,
'/api/v1/shopping/2026-10-05/generate undefined',
'/api/v1/shopping/2026-10-05/generate undefined',
])
})
it('no longer says the Supply run could not be made once the shopping list is made, after leaving the Plan and coming back', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
// A Supply run never made is still said after leaving the Plan and
// coming back.
fireEvent.click(screen.getByRole('link', { name: 'Shopping list' }))
fireEvent.click(await screen.findByRole('link', { name: 'Back to the Plan' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
// Once made, it is not said again to be unmade.
fireEvent.click(screen.getByRole('link', { name: 'Shopping list · 28' }))
fireEvent.click(await screen.findByRole('link', { name: 'Back to the Plan' }))
expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()
})
it('still says the Supply run could not be made when making the shopping list fails after leaving the week', async () => {
const made = deferred()
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => made.response,
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
fireEvent.click(within(await screen.findByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Making the shopping list…')
// The week is left while the shopping list is being made, which fails.
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
await act(async () => made.send(errorResponse(500, 'internal')))
// Coming back, the Supply run is still said to be unmade, and can be
// made again.
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
expect(within(alert).getByRole('button', { name: 'Make the shopping list' })).toBeEnabled()
})
it('no longer says the Supply run could not be made once the shopping list is made after leaving the week', async () => {
const made = deferred()
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => made.response,
})
const queryClient = createQueryClient()
await renderPage(queryClient)
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
fireEvent.click(within(await screen.findByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Making the shopping list…')
// The push is kept while the week is left, until the list is made: it
// is then settled, not merely swept with the week.
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
expect(pushesKept(queryClient)).toBe(1)
await act(async () => made.send(jsonResponse(200, { ...supplyRun(28), skipped: [] })))
expect(pushesKept(queryClient)).toBe(0)
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()
})
it('no longer says the Supply run could not be made once the shopping list is made on the Supply run screen', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
})
const queryClient = createQueryClient()
await renderPage(queryClient, <SupplyRunScreen />)
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
// The list is made on the Supply run screen, not from the Plan's alert.
fireEvent.click(screen.getByRole('link', { name: 'Shopping list' }))
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
await waitFor(() => expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument())
expect(pushesKept(queryClient)).toBe(0)
fireEvent.click(screen.getByRole('link', { name: 'Back to the Plan' }))
expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()
})
it('still says the Supply run could not be made when making it on the Supply run screen fails', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => errorResponse(500, 'internal'),
})
const queryClient = createQueryClient()
await renderPage(queryClient, <SupplyRunScreen />)
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
fireEvent.click(screen.getByRole('link', { name: 'Shopping list' }))
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't make the shopping list.")
expect(pushesKept(queryClient)).toBe(1)
fireEvent.click(screen.getByRole('link', { name: 'Back to the Plan' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
expect(within(alert).getByRole('button', { name: 'Make the shopping list' })).toBeEnabled()
})
it('pushes nothing again until the changed plan is read and shown', async () => { it('pushes nothing again until the changed plan is read and shown', async () => {
let read: () => Response | Promise<Response> = () => jsonResponse(200, planBody('draft')) let read: () => Response | Promise<Response> = () => jsonResponse(200, planBody('draft'))
let push = () => errorResponse(409, 'plan_changed') let push = () => errorResponse(409, 'plan_changed')
@@ -729,6 +1034,85 @@ describe('PlanPage', () => {
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument() expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
}) })
it("links to the week's Supply run under the Draft board, saying how many items are still to buy", async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, supplyRun(19)),
})
await renderPage()
const shopping = await screen.findByRole('link', { name: 'Shopping list · 19' })
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
expect(declaredStyle(shopping).color).toBe('var(--color-accent-700)')
// It follows the push button and the sync status.
const push = screen.getByRole('button', { name: 'Looks good — push to Calendar' })
expect(push.compareDocumentPosition(shopping) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
fireEvent.click(shopping)
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
it("shows a failed read of the week's shopping list with Retry, the link still opening the Supply run", async () => {
let failing = true
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(19))),
})
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message")
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
// Retry reads it again.
failing = false
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
const shopping = await screen.findByRole('link', { name: 'Shopping list · 19' })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/shopping/2026-10-05')).toHaveLength(2)
fireEvent.click(shopping)
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
it("drops the count when reading the week's shopping list again fails, showing the failure with Retry", async () => {
let failing = false
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(19))),
})
await renderPage()
expect(await screen.findByRole('link', { name: 'Shopping list · 19' })).toBeInTheDocument()
// Coming back to the page once the Supply run is stale reads it again,
// and that read fails.
failing = true
vi.setSystemTime(new Date('2026-10-07T16:01:00Z'))
act(() => {
window.dispatchEvent(new Event('visibilitychange'))
})
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message")
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
expect(screen.queryByRole('link', { name: 'Shopping list · 19' })).not.toBeInTheDocument()
// Retry reads it again, and the count is back.
failing = false
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByRole('link', { name: 'Shopping list · 19' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('links to the Supply run with no count while the week has no shopping list', async () => {
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) })
await renderPage()
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
await waitFor(() => expect(fetchMock.mock.calls.map(([input]) => String(input))).toContain('/api/v1/shopping/2026-10-05'))
const shopping = screen.getByRole('link', { name: 'Shopping list' })
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
// No list is not a failure.
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
fireEvent.click(shopping)
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
it('moves between weeks with the previous and next week buttons', async () => { it('moves between weeks with the previous and next week buttons', async () => {
stubServer({ stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
@@ -1026,11 +1410,11 @@ describe.each([
expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop') expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop')
// Actions: the shopping list unavailable, saying why; Rebuild week // Actions: the shopping list, with no count while the week has no
// outlined and Push to Calendar primary, both working. // Supply run, and Rebuild week outlined; Push to Calendar primary.
const shopping = screen.getByRole('button', { name: 'Shopping list' }) const shopping = await screen.findByRole('link', { name: 'Shopping list' })
expect(shopping).toHaveAttribute('aria-disabled', 'true') expect(shopping).not.toHaveAttribute('aria-disabled')
expect(shopping).toHaveAccessibleDescription('The shopping list arrives once the Supply run is built.') expect(shopping).not.toHaveAccessibleDescription()
const push = screen.getByRole('button', { name: 'Push to Calendar' }) const push = screen.getByRole('button', { name: 'Push to Calendar' })
expect(push).toBeEnabled() expect(push).toBeEnabled()
expect(push).not.toHaveAttribute('aria-disabled') expect(push).not.toHaveAttribute('aria-disabled')
@@ -1038,7 +1422,6 @@ describe.each([
const rebuild = screen.getByRole('button', { name: 'Rebuild week' }) const rebuild = screen.getByRole('button', { name: 'Rebuild week' })
expect(rebuild).toBeEnabled() expect(rebuild).toBeEnabled()
expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)') expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)')
fireEvent.click(shopping)
expect(posts(fetchMock)).toEqual([]) expect(posts(fetchMock)).toEqual([])
// Mobile's own pieces are not shown. // Mobile's own pieces are not shown.
expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument()
@@ -1098,6 +1481,48 @@ describe.each([
expect(status).toHaveTextContent('Synced to Google Calendar 1 hr ago') expect(status).toHaveTextContent('Synced to Google Calendar 1 hr ago')
}) })
it("shows a failed read of the week's shopping list with Retry, the action still opening the Supply run", async () => {
let failing = true
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(28))),
...lists,
})
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message")
expect(screen.getByText('Spaghetti bolognese')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
failing = false
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
const shopping = await screen.findByRole('link', { name: 'Shopping list · 28' })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
fireEvent.click(shopping)
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
it("opens the week's Supply run from the Shopping list action, saying how many items are still to buy", async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, supplyRun(28)),
...lists,
})
await renderPage()
const shopping = await screen.findByRole('link', { name: 'Shopping list · 28' })
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
expect(declaredStyle(shopping).borderColor).toBe('var(--color-neutral-300)')
// Another week's action opens that week's, which has no list yet.
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-12')
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
fireEvent.click(await screen.findByRole('link', { name: 'Shopping list · 28' }))
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
it('says changes to a pushed plan are not on Google Calendar', async () => { it('says changes to a pushed plan are not on Google Calendar', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), ...lists }) stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), ...lists })
await renderPage() await renderPage()
+89 -17
View File
@@ -7,8 +7,10 @@ import { QUEST_LOG_KEY } from '../discover/api.ts'
import { Confirm } from '../settings/LeaveGuard.tsx' import { Confirm } from '../settings/LeaveGuard.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.tsx' import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts' import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts' import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button, type ButtonVariant } from '../ui/Button.tsx' import { Button, type ButtonVariant } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
import { EmptyState } from '../ui/EmptyState.tsx' import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx' import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx' import { Icon } from '../ui/Icon.tsx'
@@ -29,6 +31,7 @@ import {
type PlanDay, type PlanDay,
type PlanDinner, type PlanDinner,
type PlanStatus, type PlanStatus,
type PushedSupplyRun,
type Swap, type Swap,
} from './api.ts' } from './api.ts'
import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx' import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx'
@@ -79,10 +82,6 @@ const settingsFailures: Record<string, { reason: string; fix: string }> = {
}, },
} }
// SHOPPING_HINT is why the shopping list is unavailable.
// TODO(Feature 17): open the Supply run, with its count, once it is built.
const SHOPPING_HINT = 'The shopping list arrives once the Supply run is built.'
// syncStatuses is the plan manager's line on whether the plan is on Calendar. // syncStatuses is the plan manager's line on whether the plan is on Calendar.
const syncStatuses: Record<PlanStatus, string> = { const syncStatuses: Record<PlanStatus, string> = {
draft: 'Not pushed to Google Calendar yet', draft: 'Not pushed to Google Calendar yet',
@@ -90,6 +89,13 @@ const syncStatuses: Record<PlanStatus, string> = {
changed: 'Changes not pushed to Google Calendar', changed: 'Changes not pushed to Google Calendar',
} }
// supplyRunOutcomes says what became of the week's Supply run after a push
// that made it or left it as it was.
const supplyRunOutcomes: Record<Exclude<PushedSupplyRun['status'], 'failed'>, string> = {
generated: "Made this week's Supply run from the plan.",
archived: "This week's Supply run is in the Supply archive, so it was left as it was.",
}
// duration is minutes as the plan manager's cards say it: "45 min", "6 hr", // duration is minutes as the plan manager's cards say it: "45 min", "6 hr",
// "1 hr 30 min", with no-break spaces so a narrow card wraps the line // "1 hr 30 min", with no-break spaces so a narrow card wraps the line
// between the method and the duration, never inside the duration. // between the method and the duration, never inside the duration.
@@ -195,6 +201,22 @@ function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone
) )
} }
// ShoppingList opens the week's Supply run: an outline action in the plan
// manager's header, a grape link under the Draft board on mobile. Once the
// week has a Supply run it says how many items are still to buy, "Shopping
// list · 28"; until then, while that is read, or when reading it failed, it
// says no count, and the Supply run screen offers to make the list or shows
// the failure.
function ShoppingList({ weekStart, neededCount, layout }: { weekStart: string; neededCount: number | undefined; layout: Breakpoint }) {
const className = layout === 'mobile' ? styles.shoppingLink : [buttonStyles.button, buttonStyles.outline, styles.action].join(' ')
return (
<Link to={supplyRunScreen(weekStart)} className={className}>
<Icon icon={ShoppingCart} size="inline" />
{neededCount !== undefined ? `Shopping list · ${neededCount}` : 'Shopping list'}
</Link>
)
}
// SkeletonTiles stands in for the seven days while the plan loads or the AI // SkeletonTiles stands in for the seven days while the plan loads or the AI
// drafts it, as two columns of tiles on mobile and seven columns wider. // drafts it, as two columns of tiles on mobile and seven columns wider.
function SkeletonTiles({ label, layout, children }: { label: string; layout: Breakpoint; children?: string }) { function SkeletonTiles({ label, layout, children }: { label: string; layout: Breakpoint; children?: string }) {
@@ -217,13 +239,16 @@ function SkeletonTiles({ label, layout, children }: { label: string; layout: Bre
// the plan is read again. Rebuilding a plan pushed to Calendar asks first, // the plan is read again. Rebuilding a plan pushed to Calendar asks first,
// and sends confirm. A failed draft is shown with Retry; no plan is made up. // and sends confirm. A failed draft is shown with Retry; no plan is made up.
// //
// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week, Push to // From tablet up it is the plan manager (mocks 2c, 2d): Shopping list,
// Calendar and the unavailable Shopping list are actions in the header, // Rebuild week and Push to Calendar are actions in the header, over the
// over the plan's sync status and the 7-column grid. // plan's sync status and the 7-column grid. On mobile the Shopping list
// link sits under the push button.
// //
// Push to Calendar, on either layout, sends the plan shown and shows it as // Push to Calendar, on either layout, sends the plan shown and shows it as
// synced once the server answers; a failure says why, linking to the Google // synced once the server answers; a failure says why, linking to the Google
// Calendar settings when only they can fix it. // Calendar settings when only they can fix it. What became of the week's
// Supply run is said too: made, left as it was in the Supply archive, or
// not made, with the way to make it.
function WeekBoard({ function WeekBoard({
weekStart, weekStart,
timezone, timezone,
@@ -243,6 +268,9 @@ function WeekBoard({
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const swap = useSwapDay(weekStart) const swap = useSwapDay(weekStart)
const push = usePushToCalendar(weekStart) const push = usePushToCalendar(weekStart)
const run = useSupplyRun(weekStart)
// Makes the week's Supply run again after a push could not make it.
const generate = useGenerateSupplyRun(weekStart)
const pushing = push.pending const pushing = push.pending
// The date of the day being swapped, while the swap sheet is open. // The date of the day being swapped, while the swap sheet is open.
const [swappingDate, setSwappingDate] = useState<string | null>(null) const [swappingDate, setSwappingDate] = useState<string | null>(null)
@@ -312,6 +340,7 @@ function WeekBoard({
if (!canPush) return if (!canPush) return
swap.dismiss() swap.dismiss()
push.reset() push.reset()
generate.reset()
push.mutate() push.mutate()
} }
@@ -323,6 +352,7 @@ function WeekBoard({
setJobId(null) setJobId(null)
swap.dismiss() swap.dismiss()
push.reset() push.reset()
generate.reset()
mutate( mutate(
{ weekStart, confirm }, { weekStart, confirm },
{ {
@@ -341,8 +371,33 @@ function WeekBoard({
else start(false) else start(false)
} }
const retryPush = cached ? () => void plan.refetch() : pushToCalendar
// makeShoppingList makes the week's Supply run again after a push could
// not make it. Once made, here or on the Supply run screen, the push is
// settled (useGenerateSupplyRun). A failure is the mutation's state, which
// generate.error reports.
function makeShoppingList() {
generate.mutate()
}
// A push's Supply run made again on the Plan stands for the push's, which
// is settled.
const supplyRun: PushedSupplyRun | null = generate.isSuccess ? { status: 'generated', error: null } : push.supplyRun
const failure = push.error !== null ? ( const failure = push.error !== null ? (
<PushFailure error={push.error} cached={cached} onRetry={cached ? () => void plan.refetch() : pushToCalendar} /> <PushFailure error={push.error} cached={cached} onRetry={retryPush} />
) : supplyRun?.status === 'failed' ? (
supplyRun.error.code === 'plan_changed' ? (
<ErrorBanner onRetry={retryPush}>
Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push
it again.
</ErrorBanner>
) : (
<ErrorBanner onRetry={generate.isPending ? undefined : makeShoppingList} retryLabel="Make the shopping list">
Pushed to Calendar, but this week's Supply run couldn't be made.
{generate.isPending
? ' Making the shopping list…'
: generate.isError && ` Couldn't make the shopping list. ${generate.error.message}`}
</ErrorBanner>
)
) : swap.failed !== null ? ( ) : swap.failed !== null ? (
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}> <ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message} Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
@@ -360,11 +415,27 @@ function WeekBoard({
) : ( ) : (
<ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner> <ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner>
) )
const pushed = supplyRun !== null && supplyRun.status !== 'failed' && (
<p role="status" className={styles.pushed}>
{supplyRunOutcomes[supplyRun.status]}
</p>
)
// A failed read of the week's Supply run is shown, with Retry, after the
// Draft board's Shopping list link or under the plan manager's board; the
// Shopping list action still opens the Supply run. The count is that of a
// successful read only: a failed refetch keeps the earlier Supply run in
// run.data, and its count is then no longer current.
const neededCount = run.isSuccess ? run.data?.needed_count : undefined
const shoppingList = <ShoppingList weekStart={weekStart} neededCount={neededCount} layout={layout} />
const supplyRunFailure = run.isError && (
<ErrorBanner onRetry={() => void run.refetch()}>Couldn't load this week's shopping list. {run.error.message}</ErrorBanner>
)
// swapIn sends the swap chosen in the sheet and closes it. // swapIn sends the swap chosen in the sheet and closes it.
function swapIn(chosen: Swap) { function swapIn(chosen: Swap) {
setSwappingDate(null) setSwappingDate(null)
push.reset() push.reset()
generate.reset()
swap.mutate(chosen) swap.mutate(chosen)
} }
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up, // dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
@@ -462,6 +533,8 @@ function WeekBoard({
{pushing ? 'Pushing to Calendar…' : 'Looks good — push to Calendar'} {pushing ? 'Pushing to Calendar…' : 'Looks good — push to Calendar'}
</Button> </Button>
<SyncStatus plan={plan.data} className={styles.mobileStatus} /> <SyncStatus plan={plan.data} className={styles.mobileStatus} />
{shoppingList}
{supplyRunFailure}
{sheet} {sheet}
</> </>
) )
@@ -493,10 +566,7 @@ function WeekBoard({
<div className={styles.actionBar}> <div className={styles.actionBar}>
{plan.data ? <SyncStatus plan={plan.data} /> : <span className={styles.status} />} {plan.data ? <SyncStatus plan={plan.data} /> : <span className={styles.status} />}
<div className={styles.actions}> <div className={styles.actions}>
<Unavailable hint={SHOPPING_HINT} variant="outline" below className={styles.action}> {shoppingList}
<Icon icon={ShoppingCart} size="inline" />
Shopping list
</Unavailable>
<Button <Button
variant="outline" variant="outline"
className={styles.action} className={styles.action}
@@ -537,6 +607,8 @@ function WeekBoard({
{header} {header}
{body} {body}
{failure} {failure}
{pushed}
{layout !== 'mobile' && supplyRunFailure}
<Confirm <Confirm
open={confirming} open={confirming}
title="Rebuild this week?" title="Rebuild this week?"
@@ -600,10 +672,10 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
) )
} }
// Unavailable is an action with nothing behind it yet (the shopping list): // Unavailable is an action with nothing behind it yet (Send to Google
// it stays focusable, so a tap or focus shows the tooltip saying why, above // Chat): it stays focusable, so a tap or focus shows the tooltip saying
// it or, at the top of the page, below. // why, above it or, at the top of the page, below.
function Unavailable({ export function Unavailable({
hint, hint,
variant = 'primary', variant = 'primary',
below = false, below = false,
+54 -2
View File
@@ -10,6 +10,21 @@ import { planKey, usePlan, usePushToCalendar, useSwapDay, type Plan, type PushTo
const WEEK = '2026-10-05' const WEEK = '2026-10-05'
const PLAN = `/api/v1/plans/${WEEK}` const PLAN = `/api/v1/plans/${WEEK}`
// GENERATED is a push answer's Supply run when the push made it.
const GENERATED = { supply_run: { status: 'generated', error: null } }
// SUPPLY_RUN_FAILED is a push answer's Supply run when it could not be made.
const SUPPLY_RUN_FAILED = {
supply_run: {
status: 'failed',
error: {
code: 'supply_run_failed',
message: 'the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run',
retryable: true,
},
},
}
const MONDAY = `${PLAN}/days/2026-10-05` const MONDAY = `${PLAN}/days/2026-10-05`
const FRIDAY = `${PLAN}/days/2026-10-09` const FRIDAY = `${PLAN}/days/2026-10-09`
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11'] const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
@@ -318,7 +333,7 @@ describe('week mutations', () => {
// The push succeeds while the third week is shown: it is left behind, // The push succeeds while the third week is shown: it is left behind,
// and the week's pending swap is not. // and the week's pending swap is not.
await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT }))) await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT, ...GENERATED })))
await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual([])) await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual([]))
expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success']) expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success'])
@@ -338,6 +353,43 @@ describe('week mutations', () => {
expect(weekMutations(queryClient, LATER)).toEqual([]) expect(weekMutations(queryClient, LATER)).toEqual([])
}) })
it('keeps a push whose Supply run was not made, settled in a week no longer shown, to be shown on coming back', async () => {
const nextPush = deferred()
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
[`GET /api/v1/plans/${NEXT}`]: () => jsonResponse(200, { ...planBody(), week_start: NEXT }),
[`POST /api/v1/plans/${NEXT}/push-calendar`]: () => nextPush.reply,
})
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const queryClient = createQueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ApiProvider>
)
const hook = renderHook(({ week }) => ({ plan: usePlan(week), push: usePushToCalendar(week) }), {
wrapper,
initialProps: { week: NEXT },
})
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
// The push is pushed to Calendar while another week is shown, but its
// Supply run is not made: it stays, though it succeeded.
act(() => hook.result.current.push.mutate())
hook.rerender({ week: WEEK })
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
const synced = { ...planBody(), week_start: NEXT, status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' }
await act(async () => nextPush.send(jsonResponse(200, { ...synced, ...SUPPLY_RUN_FAILED })))
await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual(['push success']))
// Coming back mounts the week's board again, as the Plan's does.
hook.unmount()
const back = renderHook(() => usePushToCalendar(NEXT), { wrapper })
expect(back.result.current.error).toBeNull()
expect(back.result.current.supplyRun).toEqual(SUPPLY_RUN_FAILED.supply_run)
})
it('leaves behind what settles once Plan is left for another route, and keeps what is pending', async () => { it('leaves behind what settles once Plan is left for another route, and keeps what is pending', async () => {
const friday = deferred() const friday = deferred()
const monday = deferred() const monday = deferred()
@@ -394,7 +446,7 @@ describe('week mutations', () => {
// The push succeeds: the plan as synced is recorded, and the push is // The push succeeds: the plan as synced is recorded, and the push is
// left behind. // left behind.
const synced = { ...planBody(), status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' } const synced = { ...planBody(), status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' }
await act(async () => push.send(jsonResponse(200, synced))) await act(async () => push.send(jsonResponse(200, { ...synced, ...GENERATED })))
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual([])) await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual([]))
expect(queryClient.getQueryData<Plan>(planKey(WEEK))).toMatchObject({ status: 'synced', calendar_synced_at: new Date('2026-10-08T06:00:00Z') }) expect(queryClient.getQueryData<Plan>(planKey(WEEK))).toMatchObject({ status: 'synced', calendar_synced_at: new Date('2026-10-08T06:00:00Z') })
}) })
+120 -11
View File
@@ -5,6 +5,7 @@ import { isRecord } from '../api/errors.ts'
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts' import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts' import { QUEST_LOG_KEY } from '../discover/api.ts'
import { noteGoogleFailure } from '../settings/calendarApi.ts' import { noteGoogleFailure } from '../settings/calendarApi.ts'
import { supplyRunKey } from '../shopping/api.ts'
export const PLANS_PATH = '/api/v1/plans' export const PLANS_PATH = '/api/v1/plans'
@@ -114,6 +115,56 @@ function parsePlan(where: string, weekStart: string, body: unknown, cached: bool
} }
} }
// SupplyRunError is the API error a Push to Calendar's Supply run carries
// when it was not generated.
export type SupplyRunError<C extends string> = { code: C; message: string; retryable: boolean }
// PushedSupplyRun says whether a Push to Calendar made the week's Supply
// run: generated when it did; archived when the week's Supply run is in the
// Supply archive and was left as it was; or failed when it was not made.
// A failed one's error is plan_changed when the plan was edited after it
// was pushed, and pushing again makes it, or supply_run_failed when it
// could not be made, and making the shopping list again does.
export type PushedSupplyRun =
| { status: 'generated'; error: null }
| { status: 'archived'; error: SupplyRunError<'archived'> }
| { status: 'failed'; error: SupplyRunError<'plan_changed' | 'supply_run_failed'> }
// PushedPlan is the POST /api/v1/plans/{weekStart}/push-calendar answer:
// the plan as synced, and what became of the week's Supply run.
export type PushedPlan = { plan: Plan; supply_run: PushedSupplyRun }
const PUSHED_SUPPLY_RUN_STATUSES = ['generated', 'archived', 'failed'] as const
const FAILED_SUPPLY_RUN_CODES = ['plan_changed', 'supply_run_failed'] as const
function parseSupplyRunError<C extends string>(where: string, codes: readonly C[], body: unknown): SupplyRunError<C> {
if (!isRecord(body) || typeof body.message !== 'string' || typeof body.retryable !== 'boolean') {
throw new Error(`${where}: supply_run has an error that is not an API error`)
}
return { code: oneOf(where, 'supply_run.error.code', codes, body.code), message: body.message, retryable: body.retryable }
}
function parsePushedSupplyRun(where: string, body: unknown): PushedSupplyRun {
if (!isRecord(body)) throw new Error(`${where}: response has no supply_run`)
const status = oneOf(where, 'supply_run.status', PUSHED_SUPPLY_RUN_STATUSES, body.status)
switch (status) {
case 'generated':
if (body.error !== null) throw new Error(`${where}: a generated supply_run has an error`)
return { status, error: null }
case 'archived':
return { status, error: parseSupplyRunError(where, ['archived'] as const, body.error) }
case 'failed':
return { status, error: parseSupplyRunError(where, FAILED_SUPPLY_RUN_CODES, body.error) }
}
}
function parsePushedPlan(where: string, weekStart: string, body: unknown): PushedPlan {
return {
plan: parsePlan(where, weekStart, body, false),
supply_run: parsePushedSupplyRun(where, isRecord(body) ? body.supply_run : undefined),
}
}
// parseDraftResult checks a draft_week job's result, which useJob passes on // parseDraftResult checks a draft_week job's result, which useJob passes on
// unread, is the draft of the week starting on weekStart. // unread, is the draft of the week starting on weekStart.
export function parseDraftResult(where: string, weekStart: string, body: unknown): DraftResult { export function parseDraftResult(where: string, weekStart: string, body: unknown): DraftResult {
@@ -178,6 +229,19 @@ function sameDay(a: PlanDay, b: PlanDay): boolean {
) )
} }
// failed says whether a swap or push failed: a push whose Supply run was
// not made failed too, though Calendar has the plan.
function failed(mutation: Mutation): boolean {
return mutation.state.status === 'error' || unmade(mutation)
}
// unmade says whether a push succeeded but its Supply run was not made: the
// week is on Calendar with no shopping list until one is made.
function unmade(mutation: Mutation): boolean {
const data = mutation.state.data as PushedPlan | undefined
return mutation.state.status === 'success' && weekMutation(mutation)?.kind === 'push' && data?.supply_run.status === 'failed'
}
// weekMutation is the kind and week of a swap or push of a week the mutation // weekMutation is the kind and week of a swap or push of a week the mutation
// cache keeps for it, or null for any other mutation. // cache keeps for it, or null for any other mutation.
function weekMutation(mutation: Mutation): { kind: 'swap' | 'push'; week: string } | null { function weekMutation(mutation: Mutation): { kind: 'swap' | 'push'; week: string } | null {
@@ -203,9 +267,13 @@ const weeksShown = new WeakMap<QueryClient, WeeksShown>()
// earlier pending one must see, stay across leaving its week and coming // earlier pending one must see, stay across leaving its week and coming
// back. When one settles, the same is done, except that a week's latest, // back. When one settles, the same is done, except that a week's latest,
// when it failed, stays until a week is next shown, so that coming straight // when it failed, stays until a week is next shown, so that coming straight
// back to it shows the failure. The cache holds at most the weeks shown's, // back to it shows the failure. A week's latest push whose Supply run was
// those of weeks left pending, and one failure of each week whose last // not made stays whatever is shown: the week has no shopping list until one
// pending one settled since a week was last shown. // is made, which settles the push, or a push sent since replaces it, and
// making it may still be under way when the week is left. The cache holds
// at most the weeks shown's, those of weeks left pending, one push of each
// week whose Supply run was not made, and one failure of each week whose
// last pending one settled since a week was last shown.
function keepWeekMutations(queryClient: QueryClient): WeeksShown { function keepWeekMutations(queryClient: QueryClient): WeeksShown {
const kept = weeksShown.get(queryClient) const kept = weeksShown.get(queryClient)
if (kept !== undefined) return kept if (kept !== undefined) return kept
@@ -225,7 +293,7 @@ function keepWeekMutations(queryClient: QueryClient): WeeksShown {
if (week.some((mutation) => mutation.state.status === 'pending')) continue if (week.some((mutation) => mutation.state.status === 'pending')) continue
const latest = week.at(-1) const latest = week.at(-1)
for (const mutation of week) { for (const mutation of week) {
if (settled && mutation === latest && mutation.state.status === 'error') continue if (mutation === latest && (unmade(mutation) || (settled && failed(mutation)))) continue
mutations.remove(mutation) mutations.remove(mutation)
} }
} }
@@ -363,10 +431,12 @@ function pushKey(weekStart: string) {
} }
// PushToCalendar is the push of one week, as the mutation cache holds it: // PushToCalendar is the push of one week, as the mutation cache holds it:
// whether it is pending, and why the latest failed, if it failed. // whether it is pending, why the latest failed, if it failed, and what
// became of the week's Supply run, if it succeeded.
export type PushToCalendar = { export type PushToCalendar = {
pending: boolean pending: boolean
error: Error | null error: Error | null
supplyRun: PushedSupplyRun | null
mutate: () => void mutate: () => void
reset: () => void reset: () => void
} }
@@ -380,10 +450,19 @@ export type PushToCalendar = {
// the one shown. One Google refused the grant for reads the Google Calendar // the one shown. One Google refused the grant for reads the Google Calendar
// connection again, which is now reconnect required. // connection again, which is now reconnect required.
// //
// Once pushed, the server makes the week's Supply run from the plan pushed
// and answers with its outcome, which supplyRun reports: a failed one is
// never a quiet success. The week's Supply run is read again, whatever the
// outcome, so the list shown is the server's. A Supply run not made because
// the plan was edited after it was pushed reads the plan again too, as a
// push refused for that does.
//
// The week's pushes live in the mutation cache, not in the component, as its // The week's pushes live in the mutation cache, not in the component, as its
// swaps do, so leaving the week and coming back keeps one pending. reset // swaps do, so leaving the week and coming back keeps one pending. reset
// leaves settled pushes behind; it is refused while one is pending. Settled // leaves settled pushes behind; it is refused while one is pending. A push
// pushes of a week no longer shown are left behind too // whose Supply run was not made is left behind once the list is made
// (settleUnmadePushes). Settled pushes of a week no longer shown are left
// behind too, but for the latest when its Supply run was not made
// (keepWeekMutations). // (keepWeekMutations).
export function usePushToCalendar(weekStart: string): PushToCalendar { export function usePushToCalendar(weekStart: string): PushToCalendar {
const { apiFetch } = useApi() const { apiFetch } = useApi()
@@ -395,12 +474,17 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
// The week's pushes, in the order sent. // The week's pushes, in the order sent.
const pushes = useMutationState({ const pushes = useMutationState({
filters: { mutationKey }, filters: { mutationKey },
select: (mutation) => ({ status: mutation.state.status, error: mutation.state.error }), select: (mutation) => ({
mutationId: mutation.mutationId,
status: mutation.state.status,
error: mutation.state.error,
data: mutation.state.data as PushedPlan | undefined,
}),
}) })
const latest = pushes.at(-1) const latest = pushes.at(-1)
function mutate() { function mutate() {
const mutation = mutations.build<Plan, Error, void, unknown>(queryClient, { const mutation = mutations.build<PushedPlan, Error, void, unknown>(queryClient, {
mutationKey, mutationKey,
// Kept until reset or settled in a week not shown (keepWeekMutations): // Kept until reset or settled in a week not shown (keepWeekMutations):
// a push pending in a week left must still be found when it is shown // a push pending in a week left must still be found when it is shown
@@ -408,9 +492,15 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
gcTime: Infinity, gcTime: Infinity,
mutationFn: async () => { mutationFn: async () => {
const path = `${planPath(weekStart)}/push-calendar` const path = `${planPath(weekStart)}/push-calendar`
return parsePlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }), false) return parsePushedPlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
},
onSuccess: async ({ plan, supply_run }) => {
queryClient.setQueryData<Plan>(key, plan)
void queryClient.invalidateQueries({ queryKey: supplyRunKey(weekStart) })
if (supply_run.error?.code === 'plan_changed') {
await queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' })
}
}, },
onSuccess: (plan) => queryClient.setQueryData<Plan>(key, plan),
onError: async (error) => { onError: async (error) => {
noteGoogleFailure(queryClient, error) noteGoogleFailure(queryClient, error)
if (error instanceof ApiError && error.code === 'plan_changed') { if (error instanceof ApiError && error.code === 'plan_changed') {
@@ -433,7 +523,26 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
return { return {
pending: pushes.some((push) => push.status === 'pending'), pending: pushes.some((push) => push.status === 'pending'),
error: latest?.status === 'error' ? latest.error! : null, error: latest?.status === 'error' ? latest.error! : null,
supplyRun: latest?.status === 'success' ? latest.data!.supply_run : null,
mutate, mutate,
reset, reset,
} }
} }
// settleUnmadePushes is called as the Supply run of the week starting on
// weekStart starts being made, from the Plan or the Supply run screen
// (useGenerateSupplyRun). The function it answers with is called once the
// list is made: it leaves behind the week's pushes sent before whose Supply
// run was not made, so the Plan does not say again that it is unmade, even
// if the Plan was left meanwhile. A push sent since is kept, and a list not
// made leaves them as they are.
export function settleUnmadePushes(queryClient: QueryClient, weekStart: string): () => void {
const mutations = queryClient.getMutationCache()
const mutationKey = pushKey(weekStart)
const sent = new Set(mutations.findAll({ mutationKey }))
return () => {
for (const mutation of mutations.findAll({ mutationKey })) {
if (sent.has(mutation) && unmade(mutation)) mutations.remove(mutation)
}
}
}
@@ -0,0 +1,144 @@
/* Supply archive · weekly posts (mock 1m, and the right panel of 2f). */
.archive {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 14px;
max-width: 560px;
padding-top: 8px;
}
.header {
display: flex;
align-items: center;
gap: 10px;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 400;
line-height: 1.1;
}
.periods {
align-self: flex-start;
}
.period {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 6px;
}
.heading {
margin: 0;
font-family: var(--font-heading);
font-size: 18px;
font-weight: 400;
line-height: 1.2;
}
.posts {
display: flex;
flex-direction: column;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.post {
display: flex;
flex-direction: column;
gap: 6px;
padding: 16px;
border-radius: 24px;
background: var(--color-surface);
color: var(--color-text);
text-decoration: none;
}
.post:focus-visible {
outline: 3px solid var(--color-accent);
outline-offset: 2px;
}
/* The current week's post: grape-100 with a grape-300 ring. */
.current {
background: var(--color-accent-100);
box-shadow: inset 0 0 0 2px var(--color-accent-300);
}
/* The week label as the API writes it, capitalised here. */
.kicker {
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-neutral-700);
}
.current .kicker {
color: var(--color-accent-700);
}
.postTitle {
font-family: var(--font-heading);
font-size: 18px;
line-height: 1.15;
}
.meta {
font-family: var(--font-body);
font-size: 13px;
color: var(--color-neutral-800);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
padding: 3px 10px;
border-radius: var(--radius-pill);
background: var(--color-bg);
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
}
.older {
align-self: center;
}
.skeletonPost {
height: 112px;
border-radius: 24px;
}
/* Desktop (mock 2f): the week's list at the left, the archive in a 340 px
panel at the right. */
.split {
display: flex;
gap: 24px;
align-items: flex-start;
}
.list {
flex: 1;
min-width: 0;
}
.panel {
flex: none;
box-sizing: border-box;
width: 340px;
padding-left: 24px;
border-left: 1px solid var(--color-divider);
}
+422
View File
@@ -0,0 +1,422 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import type { ArchivePost } from './api.ts'
import { PAGE, SupplyArchivePage } from './SupplyArchivePage.tsx'
import styles from './SupplyArchivePage.module.css'
const ARCHIVE = (by: string) => `/api/v1/shopping/archive?by=${by}`
// post is one Supply archive post as GET /api/v1/shopping/archive sends it.
function post(weekStart: string, label: string, overrides: Partial<ArchivePost> = {}): ArchivePost {
return {
weekStart,
label,
title: null,
dinnerCount: 7,
itemCount: 23,
onHandCount: 4,
dinners: ['Beef tacos', 'Tikka masala', 'Pizza', 'Pot roast', 'Orzo', 'Chili', 'Salmon'],
current: false,
...overrides,
}
}
// The posts of mock 1m: this week's titled post, then the weeks across the
// September–October boundary and the 2025–2026 one, the last untitled.
const WEEK_41 = post('2026-10-05', 'Week 41 · Oct 5 – 11', { title: 'Tacos, tikka & a salmon finale', current: true })
const WEEK_40 = post('2026-09-28', 'Week 40 · Sep 28 – Oct 4', { title: 'Pizza, pot roast & the great orzo trial', itemCount: 26 })
const WEEK_39 = post('2026-09-21', 'Week 39 · Sep 21 – 27', { title: 'Chili season opens', dinnerCount: 1, itemCount: 1, onHandCount: 0, dinners: ['Chili'] })
const WEEK_1 = post('2025-12-29', 'Week 1 · Dec 29 – Jan 4')
// The archive as the server groups it: by week each period is one ISO week
// headed by its label; by month and year a week belongs to the period of its
// Monday, so week 40 is September's and week 1 is 2025's.
const BY = {
week: (
[
['2026-W41', WEEK_41],
['2026-W40', WEEK_40],
['2026-W39', WEEK_39],
['2026-W01', WEEK_1],
] as const
).map(([key, p]) => ({ key, heading: p.label, posts: [p] })),
month: [
{ key: '2026-10', heading: 'October 2026', posts: [WEEK_41] },
{ key: '2026-09', heading: 'September 2026', posts: [WEEK_40, WEEK_39] },
{ key: '2025-12', heading: 'December 2025', posts: [WEEK_1] },
],
year: [
{ key: '2026', heading: '2026', posts: [WEEK_41, WEEK_40, WEEK_39] },
{ key: '2025', heading: '2025', posts: [WEEK_1] },
],
}
function archive(by: keyof typeof BY) {
return () => jsonResponse(200, { by, periods: BY[by] })
}
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
// stubServer answers "METHOD path" requests from responders, with /me and
// the household settings; any other request fails the test.
function stubServer(responders: Record<string, Responder>) {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// renderPage loads /me (for the CSRF token any write would need, so a
// missing PATCH is not for want of it) and renders the Supply archive at path.
async function renderPage(path = '/plan/shopping/archive') {
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/plan/shopping/archive" element={<SupplyArchivePage />} />
<Route path="/plan/shopping" element={<p>Supply run screen</p>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
// postCard is the post titled title, a link.
function postCard(title: string) {
return screen.getByText(title).closest('a')!
}
beforeEach(() => {
mockViewport(390)
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
})
describe('SupplyArchivePage', () => {
it('renders mock 1m by week: one post per week with its kicker, title, counts and dinner chips', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
expect(screen.getByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument()
expect(screen.getByRole('radio', { name: 'Week' })).toBeChecked()
const card = await screen.findByText('Pizza, pot roast & the great orzo trial')
const post40 = card.closest('a')!
expect(within(post40).getByText('Week 40 · Sep 28 – Oct 4')).toHaveClass(styles.kicker!)
expect(post40).toHaveTextContent('7 dinners · 26 items · 4 already on hand')
expect(within(post40).getByText('Beef tacos')).toHaveClass(styles.chip!)
expect(within(post40).getByText('Tikka masala')).toHaveClass(styles.chip!)
expect(within(post40).getByText('+5')).toHaveClass(styles.chip!)
expect(within(post40).queryByText('Pizza')).not.toBeInTheDocument()
expect(postCard('Chili season opens')).toHaveTextContent('1 dinner · 1 item · 0 already on hand')
expect(within(postCard('Chili season opens')).queryByText(/^\+/)).not.toBeInTheDocument()
expect(screen.getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([
'/plan/shopping/archive?week=2026-10-05',
'/plan/shopping/archive?week=2026-09-28',
'/plan/shopping/archive?week=2026-09-21',
'/plan/shopping/archive?week=2025-12-29',
])
})
it('renders by week the periods and headings the server sends, as sent', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
await screen.findByText('Chili season opens')
expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(BY.week.map((period) => period.heading))
expect(screen.getByRole('heading', { level: 2, name: WEEK_40.label })).toHaveClass(styles.heading!)
for (const period of BY.week) {
const section = screen.getByRole('region', { name: period.heading })
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
)
}
})
it('capitalises the week label kicker in CSS from the label as the API writes it', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
await screen.findByText('Chili season opens')
expect(within(postCard('Chili season opens')).getByText('Week 39 · Sep 21 – 27')).toHaveClass(styles.kicker!)
})
it.each([
['Month', 'month', ['October 2026', 'September 2026', 'December 2025']],
['Year', 'year', ['2026', '2025']],
] as const)('groups by %s under the headings the server sends, across the month and year boundaries', async (label, by, headings) => {
const fetchMock = stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week'), [`GET ${ARCHIVE(by)}`]: archive(by) })
await renderPage()
await screen.findByText('Chili season opens')
fireEvent.click(screen.getByRole('radio', { name: label }))
await waitFor(() => expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(headings))
expect(screen.getByRole('heading', { level: 2, name: headings[0] })).toHaveClass(styles.heading!)
expect(fetchMock.mock.calls.map(([input]) => String(input))).toContain(ARCHIVE(by))
for (const period of BY[by]) {
const section = screen.getByRole('region', { name: period.heading })
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
)
}
})
it('highlights the current week’s post, by the API’s current, and no other', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
const current = (await screen.findByText('Tacos, tikka & a salmon finale')).closest('a')!
expect(current).toHaveClass(styles.current!)
expect(within(current).getByText('Week 41 · Oct 5 – 11 · Current')).toBeInTheDocument()
for (const title of ['Pizza, pot roast & the great orzo trial', 'Chili season opens']) {
expect(postCard(title)).not.toHaveClass(styles.current!)
expect(postCard(title)).not.toHaveTextContent('Current')
}
})
it('follows current from the API, not today’s date', async () => {
const past = { ...WEEK_40, current: true }
stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'week', periods: [{ key: 'w', heading: past.label, posts: [{ ...WEEK_41, current: false }, past] }] }) })
await renderPage()
expect(await screen.findByText('Week 40 · Sep 28 – Oct 4 · Current')).toBeInTheDocument()
expect(postCard('Tacos, tikka & a salmon finale')).not.toHaveClass(styles.current!)
})
it('titles an untitled post with its week label', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
await screen.findByText('Chili season opens')
const untitled = screen.getByRole('link', { name: /Week 1 · Dec 29 – Jan 4/ })
const label = within(untitled).getAllByText('Week 1 · Dec 29 – Jan 4')
expect(label.map((el) => el.className)).toEqual([styles.kicker, styles.postTitle])
})
it(`shows ${PAGE} posts and "Load older" shows the next ${PAGE}, none twice and none skipped`, async () => {
const weeks = Array.from({ length: 2 * PAGE + 3 }, (_, i) => {
const monday = new Date(Date.UTC(2026, 9, 5 - 7 * i)).toISOString().slice(0, 10)
return post(monday, `Week of ${monday}`, { title: `Post ${i}` })
})
// By month, so the cut falls inside a period and its heading is shown once.
const periods = [
{ key: 'a', heading: 'Newer', posts: weeks.slice(0, PAGE + 2) },
{ key: 'b', heading: 'Older', posts: weeks.slice(PAGE + 2) },
]
stubServer({
[`GET ${ARCHIVE('week')}`]: archive('week'),
[`GET ${ARCHIVE('month')}`]: () => jsonResponse(200, { by: 'month', periods }),
})
await renderPage()
await screen.findByText('Chili season opens')
fireEvent.click(screen.getByRole('radio', { name: 'Month' }))
const titles = () => screen.getAllByRole('link').map((link) => within(link).getByText(/^Post /).textContent)
await screen.findByText('Post 0')
expect(titles()).toEqual(weeks.slice(0, PAGE).map((p) => p.title))
expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(['Newer'])
fireEvent.click(screen.getByRole('button', { name: 'Load older' }))
expect(titles()).toEqual(weeks.slice(0, 2 * PAGE).map((p) => p.title))
expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(['Newer', 'Older'])
fireEvent.click(screen.getByRole('button', { name: 'Load older' }))
expect(titles()).toEqual(weeks.map((p) => p.title))
expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument()
})
it('offers no "Load older" when every post is shown', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
await screen.findByText('Chili season opens')
expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument()
})
it('shows skeleton posts while the archive loads', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: () => new Promise<Response>(() => {}) })
await renderPage()
expect(screen.getByLabelText('Supply archive loading')).toHaveAttribute('aria-busy', 'true')
expect(screen.getAllByTestId('skeleton-post')).toHaveLength(3)
})
it('says so when nothing is archived yet', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'week', periods: [] }) })
await renderPage()
expect(await screen.findByText('Nothing archived yet')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument()
})
it('reports a failed load and retries it', async () => {
let reads = 0
stubServer({
[`GET ${ARCHIVE('week')}`]: () => (++reads === 1 ? errorResponse(500, 'internal') : archive('week')()),
})
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't load the Supply archive.")
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Chili season opens')).toBeInTheDocument()
expect(reads).toBe(2)
})
it('reports a response that is not the archive by the period asked for', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'month', periods: [] }) })
await renderPage()
expect(await screen.findByRole('alert')).toHaveTextContent(`GET ${ARCHIVE('week')}: response is not the Supply archive by week`)
})
it('goes back to the Supply run', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Back to the Supply run' }))
expect(await screen.findByText('Supply run screen')).toBeInTheDocument()
})
})
// The frozen Supply run of week 40, as GET /api/v1/shopping/{weekStart}
// sends it.
const FROZEN = {
weekStart: '2026-09-28',
label: 'Week 40 · Sep 28 – Oct 4',
title: 'Pizza, pot roast & the great orzo trial',
status: 'archived',
sent_to_chat_at: null,
needed_count: 1,
total_count: 2,
groups: [
{
dinner: { id: 'd1', name: 'Friday pizza' },
date: '2026-10-02',
items: [
{ id: 'i1', name: 'Pizza dough', qty: '2 balls', needed: true },
{ id: 'i2', name: 'Mozzarella', qty: '1 lb', needed: false },
],
},
],
}
// The current week's Supply run, week 41, still current: the API's archive
// includes it, highlighted.
const CURRENT = {
...FROZEN,
weekStart: '2026-10-05',
label: 'Week 41 · Oct 5 – 11',
title: 'Tacos, tikka & a salmon finale',
status: 'current',
groups: [
{
dinner: { id: 'd2', name: 'Beef tacos' },
date: '2026-10-05',
items: [
{ id: 'i3', name: 'Corn tortillas', qty: '12', needed: true },
{ id: 'i4', name: 'Limes', qty: '3', needed: false },
],
},
],
}
describe('SupplyArchivePage frozen post', () => {
it('opens a tapped post’s frozen list read-only, tagged Archived, sending no PATCH', async () => {
const fetchMock = stubServer({
[`GET ${ARCHIVE('week')}`]: archive('week'),
'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, FROZEN),
})
await renderPage()
fireEvent.click(await screen.findByText('Pizza, pot roast & the great orzo trial'))
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: /Shopping list/ })).toBeInTheDocument()
expect(screen.getByText('Pizza dough')).toBeInTheDocument()
expect(screen.queryAllByRole('checkbox')).toEqual([])
fireEvent.click(screen.getByText('Pizza dough'))
fireEvent.click(screen.getByText('Mozzarella'))
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
// The list links back to the archive.
fireEvent.click(screen.getByRole('link', { name: 'Supply archive' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument()
})
it('opens the current week’s post read-only too, tagged Archived, with no checkbox, nothing to send and no PATCH', async () => {
const fetchMock = stubServer({
[`GET ${ARCHIVE('week')}`]: archive('week'),
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
})
await renderPage()
fireEvent.click(await screen.findByText('Tacos, tikka & a salmon finale'))
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(screen.getByText("This week's list as it stands, read-only in the Supply archive.")).toBeInTheDocument()
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
expect(screen.queryAllByRole('checkbox')).toEqual([])
fireEvent.click(screen.getByText('Corn tortillas'))
fireEvent.click(screen.getByText('Limes'))
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
})
it('on desktop opens the current week’s post read-only, and with no post open shows this week’s Supply run to change', async () => {
mockViewport(1440)
const fetchMock = stubServer({
[`GET ${ARCHIVE('week')}`]: archive('week'),
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
})
const view = await renderPage('/plan/shopping/archive?week=2026-10-05')
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
expect(screen.queryAllByRole('checkbox')).toEqual([])
expect(screen.queryByText('Send to Google Chat')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Corn tortillas'))
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
view.unmount()
await renderPage()
expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeInTheDocument()
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
})
it('on desktop (mock 2f) shows the archive as a panel beside the open post’s list', async () => {
mockViewport(1440)
stubServer({
[`GET ${ARCHIVE('week')}`]: archive('week'),
'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, FROZEN),
})
await renderPage('/plan/shopping/archive?week=2026-09-28')
const panel = screen.getByRole('complementary', { name: 'Supply archive' })
expect(await within(panel).findByText('Chili season opens')).toBeInTheDocument()
expect(within(panel).queryByRole('button', { name: 'Back to the Supply run' })).not.toBeInTheDocument()
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(panel).not.toContainElement(screen.getByText('Pizza dough'))
expect(screen.queryAllByRole('checkbox')).toEqual([])
})
})
+183
View File
@@ -0,0 +1,183 @@
import { ChevronLeft } from 'lucide-react'
import { useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router'
import { useBreakpoint } from '../shell/useBreakpoint.ts'
import { Button } from '../ui/Button.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { useSupplyArchive, WEEK_PARAM, type ArchiveGroup, type ArchivePeriod, type ArchivePost } from './api.ts'
import styles from './SupplyArchivePage.module.css'
import { SUPPLY_ARCHIVE, SupplyRunPage } from './SupplyRunPage.tsx'
// PAGE is how many posts the Supply archive shows at first, and how many
// more each "Load older" shows.
export const PAGE = 8
// CHIPS is how many of a post's dinners it names; a "+N" chip counts the rest.
const CHIPS = 2
const PERIODS = [
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
{ value: 'year', label: 'Year' },
] as const satisfies readonly { value: ArchivePeriod; label: string }[]
// archivePost is the Supply archive screen opened on the post of the week
// starting on Monday weekStart.
export function archivePost(weekStart: string): string {
return `${SUPPLY_ARCHIVE}?${new URLSearchParams({ [WEEK_PARAM]: weekStart })}`
}
function plural(n: number, one: string, many: string): string {
return `${n} ${n === 1 ? one : many}`
}
// PostCard is one week's Supply run (mock 1m): the week label as its kicker,
// the AI's title — the week label while it has none — the counts and its
// first dinners as chips. The current week's post is ringed in grape. It
// opens the week's list, read-only.
function PostCard({ post }: { post: ArchivePost }) {
const rest = post.dinners.length - CHIPS
return (
<Link to={archivePost(post.weekStart)} className={[styles.post, post.current && styles.current].filter(Boolean).join(' ')}>
<span className={styles.kicker}>
{post.label}
{post.current && ' · Current'}
</span>
<span className={styles.postTitle}>{post.title ?? post.label}</span>
<span className={styles.meta}>
{plural(post.dinnerCount, 'dinner', 'dinners')} · {plural(post.itemCount, 'item', 'items')} · {post.onHandCount} already on
hand
</span>
{post.dinners.length > 0 && (
<span className={styles.chips}>
{post.dinners.slice(0, CHIPS).map((name, i) => (
<span key={i} className={styles.chip}>
{name}
</span>
))}
{rest > 0 && <span className={styles.chip}>+{rest}</span>}
</span>
)}
</Link>
)
}
function Posts({ posts }: { posts: ArchivePost[] }) {
return (
<ul className={styles.posts}>
{posts.map((post) => (
<li key={post.weekStart}>
<PostCard post={post} />
</li>
))}
</ul>
)
}
// shownGroups is groups cut to their first shown posts, newest first, without
// the periods left with none.
function shownGroups(groups: ArchiveGroup[], shown: number): ArchiveGroup[] {
const out: ArchiveGroup[] = []
let left = shown
for (const group of groups) {
if (left === 0) break
const posts = group.posts.slice(0, left)
out.push({ ...group, posts })
left -= posts.length
}
return out
}
// ArchivePosts is the Supply archive as the server groups it by by: one post
// per week, under the Bagel heading of each period the server sends — by
// week, the week's label. It shows PAGE posts and "Load older" shows PAGE
// more.
function ArchivePosts({ by }: { by: ArchivePeriod }) {
const archive = useSupplyArchive(by)
const [shown, setShown] = useState(PAGE)
if (archive.isError) {
return <ErrorBanner onRetry={() => void archive.refetch()}>Couldn't load the Supply archive. {archive.error.message}</ErrorBanner>
}
if (archive.isPending) {
return (
<div aria-busy="true" aria-label="Supply archive loading" className={styles.posts}>
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} data-testid="skeleton-post" className={styles.skeletonPost} />
))}
</div>
)
}
const total = archive.data.reduce((n, group) => n + group.posts.length, 0)
if (total === 0) {
return <EmptyState title="Nothing archived yet">Each week's Supply run becomes a post here once it's made.</EmptyState>
}
const groups = shownGroups(archive.data, shown)
return (
<>
{groups.map((group) => (
<section key={group.key} aria-label={group.heading} className={styles.period}>
<h2 className={styles.heading}>{group.heading}</h2>
<Posts posts={group.posts} />
</section>
))}
{shown < total && (
<Button variant="outline" className={styles.older} onClick={() => setShown(shown + PAGE)}>
Load older
</Button>
)}
</>
)
}
// SupplyArchive is the Supply archive (mock 1m, and the right panel of 2f):
// the Week | Month | Year control over the posts. On mobile and tablet a
// back button returns to the Supply run.
function SupplyArchive({ panel }: { panel: boolean }) {
const navigate = useNavigate()
const [by, setBy] = useState<ArchivePeriod>('week')
return (
<div className={styles.archive}>
<header className={styles.header}>
{!panel && (
<IconButton label="Back to the Supply run" onClick={() => void navigate('/plan/shopping')}>
<Icon icon={ChevronLeft} size="nav" />
</IconButton>
)}
<h1 className={styles.title}>Supply archive</h1>
</header>
<Segmented label="Group the Supply archive by" options={PERIODS} value={by} onValueChange={setBy} className={styles.periods} />
<ArchivePosts key={by} by={by} />
</div>
)
}
// SupplyArchivePage is /plan/shopping/archive. A post opens it on its week
// (?week=), whose list the Supply run screen shows read-only and tagged
// Archived, whatever its status: even the current week's post is only
// viewed here, never changed. On desktop (mock 2f) the archive is a panel at
// the right of that list, this week's editable Supply run when no post is
// open; on mobile and tablet it is the archive or, with a post open, the
// post's list.
export function SupplyArchivePage() {
const layout = useBreakpoint()
const [params] = useSearchParams()
if (layout === 'desktop') {
return (
<div className={styles.split}>
<div className={styles.list}>
<SupplyRunPage readOnly={params.has(WEEK_PARAM)} />
</div>
<aside className={styles.panel} aria-label="Supply archive">
<SupplyArchive panel />
</aside>
</div>
)
}
return params.has(WEEK_PARAM) ? <SupplyRunPage readOnly /> : <SupplyArchive panel={false} />
}
+274
View File
@@ -0,0 +1,274 @@
/* Supply run · shopping list (mocks 1l, 2b, 2f). */
.page {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 18px;
}
.mobile {
max-width: 440px;
padding-top: 8px;
}
.header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.heading {
flex: 1;
min-width: 0;
}
.kicker {
margin: 0;
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-accent-700);
}
.titleRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 30px;
font-weight: 400;
line-height: 1.1;
}
/* Still to buy / all items: at the right of the title on mobile, beside it
from tablet up, where the actions take the right. */
.count {
margin: 0 0 0 auto;
font-family: var(--font-body);
font-size: 16px;
font-weight: 700;
color: var(--color-neutral-700);
white-space: nowrap;
}
.tablet .count,
.desktop .count {
margin-left: 0;
}
.helper {
margin: 6px 0 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-text);
}
.actions {
flex: none;
display: flex;
gap: 12px;
}
.action {
min-height: 44px;
text-decoration: none;
}
/* One card per dinner: stacked on mobile, three masonry columns from
tablet up. */
.groups {
display: flex;
flex-direction: column;
gap: 12px;
}
.tablet .groups,
.desktop .groups {
display: block;
column-count: 3;
column-gap: 16px;
}
.group {
box-sizing: border-box;
padding: 16px 18px 10px;
border-radius: 24px;
background: var(--color-surface);
color: var(--color-text);
break-inside: avoid;
}
.tablet .group,
.desktop .group {
margin-bottom: 16px;
}
.groupHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.dinner {
margin: 0;
font-family: var(--font-heading);
font-size: 16px;
font-weight: 400;
line-height: 1.2;
}
.day {
flex: none;
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--color-neutral-700);
}
.items {
margin: 4px 0 0;
padding: 0;
list-style: none;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
/* The checkbox's label fills the row, so the whole row up to the quantity
is the hit area. */
.check {
flex: 1;
min-width: 0;
}
.readOnly {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: center;
gap: var(--space-3);
min-height: 44px;
}
/* An archived item's mark: the checkbox's look, not interactive. */
.box {
box-sizing: border-box;
display: grid;
place-content: center;
flex: none;
width: 22px;
height: 22px;
border: 2px solid var(--color-neutral-400);
border-radius: var(--radius-sm);
}
.item:not(.have) .box {
border-color: var(--color-accent);
background: var(--color-accent);
color: var(--on-accent);
}
.mark {
width: 14px;
height: 14px;
}
.name {
font-family: var(--font-body);
font-size: 15px;
font-weight: 500;
color: var(--color-text);
}
.have .name {
text-decoration: line-through;
color: var(--color-neutral-700);
}
/* Toggled while the server cannot be reached: the change is saved here and
sent once it can be. */
.willSync {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
text-decoration: none;
vertical-align: middle;
}
.qty {
flex: none;
font-family: var(--font-body);
font-size: 13px;
color: var(--color-neutral-700);
text-align: right;
}
.have .qty {
font-weight: 700;
}
.sent {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-accent-2-800);
}
.footer {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 14px;
}
.archiveLink {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 44px;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-accent-700);
text-decoration: none;
}
.skeletonTitle {
width: 220px;
height: 34px;
}
.skeletonGroup {
height: 160px;
border-radius: 24px;
}
.tablet .skeletonGroup,
.desktop .skeletonGroup {
margin-bottom: 16px;
break-inside: avoid;
}
+491
View File
@@ -0,0 +1,491 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import buttonStyles from '../ui/Button.module.css'
import { sentAt, SupplyRunPage } from './SupplyRunPage.tsx'
const THIS_WEEK = '/api/v1/shopping/2026-10-05'
const GENERATE = `${THIS_WEEK}/generate`
const ITEM = (id: string) => `/api/v1/shopping/items/${id}`
type Item = { id: string; name: string; qty: string | null; needed: boolean }
// The week of Oct 5's Supply run, as mock 1l starts it: Monday's tacos and
// Wednesday's tikka masala, each with one item the household has.
const GROUPS: { dinner: { id: string; name: string }; date: string; day: string; items: Item[] }[] = [
{
dinner: { id: 'd0', name: 'Beef tacos' },
date: '2026-10-05',
day: 'Monday',
items: [
{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true },
{ id: 'i2', name: 'Corn tortillas', qty: '12', needed: true },
{ id: 'i3', name: 'Taco seasoning', qty: '1 packet', needed: false },
],
},
{
dinner: { id: 'd2', name: 'Crock pot tikka masala' },
date: '2026-10-07',
day: 'Wednesday',
items: [
{ id: 'i4', name: 'Chicken thighs', qty: '2 lb', needed: true },
{ id: 'i5', name: 'Tikka masala paste', qty: '1 jar', needed: true },
{ id: 'i6', name: 'Basmati rice', qty: null, needed: false },
],
},
]
// supplyRun is the week's Supply run as GET /api/v1/shopping/{weekStart}
// sends it, counted from its items.
function supplyRun(overrides: Record<string, unknown> = {}, weekStart = '2026-10-05', label = 'Week 41 · Oct 5 – 11') {
const items = GROUPS.flatMap((group) => group.items)
return {
weekStart,
label,
title: null,
status: 'current',
sent_to_chat_at: null,
needed_count: items.filter((item) => item.needed).length,
total_count: items.length,
groups: GROUPS,
...overrides,
}
}
// deferred is a response the test sends when it chooses.
function deferred() {
let send!: (response: Response) => void
const response = new Promise<Response>((resolve) => {
send = resolve
})
return { response, send }
}
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
// stubServer answers "METHOD path" requests from responders, with /me and
// the household settings; any other request fails the test.
function stubServer(responders: Record<string, Responder>) {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// writes lists the PATCHes and POSTs sent, as "METHOD path body".
function writes(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => init?.method === 'PATCH' || init?.method === 'POST')
.map(([input, init]) => `${init!.method} ${String(input)} ${String(init?.body)}`)
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the Supply run at path.
async function renderPage(path = '/plan/shopping') {
const api = createApiClient(fakeLocation('/plan/shopping'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<MemoryRouter initialEntries={[path]}>
<SupplyRunPage />
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so
// the page opens on this week.
beforeEach(() => {
mockViewport(390)
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
})
function checkbox(name: string) {
return screen.getByRole('checkbox', { name })
}
function row(name: string) {
return screen.getByText(name).closest('li')!
}
describe('SupplyRunPage', () => {
it('renders mock 1l: kicker, title, the count to buy of all items and the helper', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
await renderPage()
const title = await screen.findByRole('heading', { level: 1, name: 'Shopping list' })
expect(declaredStyle(title).fontFamily).toBe('var(--font-heading)')
// The kicker is the week's dates, set in capitals by the stylesheet.
const kicker = screen.getByText('Supply run · Oct 5 – 11')
expect(declaredStyle(kicker).textTransform).toBe('uppercase')
expect(declaredStyle(kicker).color).toBe('var(--color-accent-700)')
expect(screen.getByText('4 / 6')).toBeInTheDocument()
expect(screen.getByText("Uncheck anything you already have — it won't be counted.")).toBeInTheDocument()
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
})
it('shows one surface card per dinner with its Bagel name and day', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
await renderPage()
const tacos = await screen.findByRole('region', { name: 'Beef tacos' })
expect(screen.getAllByRole('region')).toHaveLength(2)
expect(declaredStyle(tacos).borderRadius).toBe('24px')
expect(declaredStyle(tacos).background).toBe('var(--color-surface)')
const name = within(tacos).getByRole('heading', { level: 2, name: 'Beef tacos' })
expect(declaredStyle(name).fontFamily).toBe('var(--font-heading)')
expect(declaredStyle(name).fontSize).toBe('16px')
expect(declaredStyle(within(tacos).getByText('MON')).fontSize).toBe('11px')
expect(within(screen.getByRole('region', { name: 'Crock pot tikka masala' })).getByText('WED')).toBeInTheDocument()
})
it('checks the items to buy; one the household has is unchecked, struck through and "have it"', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
await renderPage()
expect(await screen.findByRole('checkbox', { name: 'Ground beef' })).toBeChecked()
expect(within(row('Ground beef')).getByText('1 lb')).toBeInTheDocument()
expect(declaredStyle(screen.getByText('Ground beef')).textDecoration).toBe('')
expect(checkbox('Taco seasoning')).not.toBeChecked()
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
expect(within(row('Taco seasoning')).queryByText('1 packet')).not.toBeInTheDocument()
expect(declaredStyle(screen.getByText('Taco seasoning')).textDecoration).toBe('line-through')
expect(screen.getAllByText('have it')).toHaveLength(2)
})
it('toggles an item at once, then shows the counts the server answers with', async () => {
const answer = deferred()
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`PATCH ${ITEM('i3')}`]: () => answer.response,
})
await renderPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
// At once, before the server answers: checked, no longer "have it", and counted.
expect(checkbox('Taco seasoning')).toBeChecked()
expect(within(row('Taco seasoning')).getByText('1 packet')).toBeInTheDocument()
expect(screen.getByText('5 / 6')).toBeInTheDocument()
await waitFor(() => expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`]))
const call = fetchMock.mock.calls.find(([, init]) => init?.method === 'PATCH')!
expect(new Headers(call[1]?.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
// The server's counts are the ones shown.
answer.send(jsonResponse(200, { id: 'i3', needed: true, needed_count: 5, total_count: 7 }))
expect(await screen.findByText('5 / 7')).toBeInTheDocument()
expect(checkbox('Taco seasoning')).toBeChecked()
})
it('marks an item "have it" by unchecking it', async () => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`PATCH ${ITEM('i1')}`]: () => jsonResponse(200, { id: 'i1', needed: false, needed_count: 3, total_count: 6 }),
})
await renderPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Ground beef' }))
expect(checkbox('Ground beef')).not.toBeChecked()
expect(within(row('Ground beef')).getByText('have it')).toBeInTheDocument()
expect(screen.getByText('3 / 6')).toBeInTheDocument()
await waitFor(() => expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i1')} {"needed":false}`]))
})
it('puts a refused toggle back, item and count, and says why with Retry', async () => {
const answer = deferred()
let patches = 0
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`PATCH ${ITEM('i3')}`]: () => {
patches++
return patches === 1 ? answer.response : jsonResponse(200, { id: 'i3', needed: true, needed_count: 5, total_count: 6 })
},
})
await renderPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
expect(checkbox('Taco seasoning')).toBeChecked()
expect(screen.getByText('5 / 6')).toBeInTheDocument()
answer.send(errorResponse(500, 'internal'))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('Couldn\'t mark Taco seasoning to buy. internal message')
expect(checkbox('Taco seasoning')).not.toBeChecked()
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
expect(screen.getByText('4 / 6')).toBeInTheDocument()
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(checkbox('Taco seasoning')).toBeChecked()
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
expect(screen.getByText('5 / 6')).toBeInTheDocument()
expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`, `PATCH ${ITEM('i3')} {"needed":true}`])
})
it('sends toggles one at a time and keeps the count true when one between them fails', async () => {
const first = deferred()
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`PATCH ${ITEM('i3')}`]: () => first.response,
[`PATCH ${ITEM('i6')}`]: () => jsonResponse(200, { id: 'i6', needed: true, needed_count: 5, total_count: 6 }),
})
await renderPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
fireEvent.click(checkbox('Basmati rice'))
expect(screen.getByText('6 / 6')).toBeInTheDocument()
// The second waits for the first.
await waitFor(() => expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`]))
first.send(errorResponse(500, 'internal'))
await waitFor(() => expect(writes(fetchMock)).toHaveLength(2))
await waitFor(() => expect(screen.getByText('5 / 6')).toBeInTheDocument())
expect(checkbox('Taco seasoning')).not.toBeChecked()
expect(checkbox('Basmati rice')).toBeChecked()
})
it('keeps a refused toggle shown when a later toggle of another item succeeds, until it is retried', async () => {
let patches = 0
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`PATCH ${ITEM('i3')}`]: () => {
patches++
return patches === 1 ? errorResponse(500, 'internal') : jsonResponse(200, { id: 'i3', needed: true, needed_count: 6, total_count: 6 })
},
[`PATCH ${ITEM('i6')}`]: () => jsonResponse(200, { id: 'i6', needed: true, needed_count: 5, total_count: 6 }),
})
await renderPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't mark Taco seasoning to buy. internal message")
expect(checkbox('Taco seasoning')).not.toBeChecked()
fireEvent.click(checkbox('Basmati rice'))
await waitFor(() => expect(screen.getByText('5 / 6')).toBeInTheDocument())
expect(checkbox('Basmati rice')).toBeChecked()
// The refusal is still shown: nothing has superseded it.
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. internal message")
expect(checkbox('Taco seasoning')).not.toBeChecked()
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(screen.getByText('6 / 6')).toBeInTheDocument())
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(checkbox('Taco seasoning')).toBeChecked()
})
it('reads the list again when a toggle is refused because the week was archived, and shows it read-only', async () => {
let reads = 0
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => {
reads++
return jsonResponse(200, supplyRun(reads === 1 ? {} : { status: 'archived' }))
},
[`PATCH ${ITEM('i3')}`]: () => errorResponse(409, 'archived'),
})
await renderPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(reads).toBe(2)
expect(screen.queryAllByRole('checkbox')).toEqual([])
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
expect(screen.getByText('4 / 6')).toBeInTheDocument()
expect(screen.getByText('This list is in the Supply archive, as it was when the week ended.')).toBeInTheDocument()
// It says why, with nothing to retry.
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. archived message")
expect(within(alert).queryByRole('button')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Taco seasoning'))
expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`])
})
it('shows the status line once the list was sent to Google Chat', async () => {
// Saturday Oct 10, 8:00 am in New York.
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })) })
await renderPage()
const status = await screen.findByText('Sent to Google Chat · Sat 8:00 am')
expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)')
})
it('has no status line until the list is sent', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
await renderPage()
await screen.findByText('4 / 6')
expect(screen.queryByText(/Sent to Google Chat/)).not.toBeInTheDocument()
})
it('stacks the cards on mobile, with a link to the Supply archive and no header actions', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
await renderPage()
await screen.findByText('4 / 6')
const groups = screen.getByTestId('supply-run-groups')
expect(declaredStyle(groups).flexDirection).toBe('column')
expect(declaredStyle(groups).columnCount).toBe('')
expect(screen.getByRole('link', { name: 'Supply archive' })).toHaveAttribute('href', '/plan/shopping/archive')
expect(screen.queryByRole('button', { name: 'Send to Google Chat' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Archive' })).not.toBeInTheDocument()
})
it.each([
['tablet (mock 2b)', 1180],
['desktop (mock 2f)', 1440],
])('lays the cards out in 3 masonry columns on %s, with Archive and Send to Google Chat', async (_layout, width) => {
mockViewport(width)
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })) })
await renderPage()
await screen.findByText('4 / 6')
const groups = screen.getByTestId('supply-run-groups')
expect(declaredStyle(groups).columnCount).toBe('3')
expect(declaredStyle(screen.getByRole('region', { name: 'Beef tacos' })).breakInside).toBe('avoid')
const archive = screen.getByRole('link', { name: 'Archive' })
expect(archive).toHaveAttribute('href', '/plan/shopping/archive')
expect(archive).toHaveClass(buttonStyles.outline!)
// Send to Google Chat waits for Feature 19: primary, unavailable, and
// its tooltip says so. Pressing it sends nothing.
const send = screen.getByRole('button', { name: 'Send to Google Chat' })
expect(send).toHaveClass(buttonStyles.primary!)
expect(send).toHaveAttribute('aria-disabled', 'true')
expect(send).toHaveAccessibleDescription('Sending the shopping list to Google Chat arrives in a later update.')
fireEvent.click(send)
expect(writes(fetchMock)).toEqual([])
expect(screen.getByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Supply archive' })).not.toBeInTheDocument()
})
it('renders an archived list read-only, tagged Archived', async () => {
const fetchMock = stubServer({
'GET /api/v1/shopping/2026-09-28': () =>
jsonResponse(200, supplyRun({ status: 'archived' }, '2026-09-28', 'Week 40 · Sep 28 – Oct 4')),
})
await renderPage('/plan/shopping?week=2026-09-28')
expect(await screen.findByText('Supply run · Sep 28 – Oct 4')).toBeInTheDocument()
expect(screen.getByText('Archived')).toBeInTheDocument()
expect(screen.getByText('This list is in the Supply archive, as it was when the week ended.')).toBeInTheDocument()
expect(screen.queryAllByRole('checkbox')).toEqual([])
// The marks still show what was bought and what was on hand.
expect(within(row('Ground beef')).getByText('1 lb')).toBeInTheDocument()
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
expect(declaredStyle(screen.getByText('Taco seasoning')).textDecoration).toBe('line-through')
expect(screen.getByText('4 / 6')).toBeInTheDocument()
fireEvent.click(screen.getByText('Taco seasoning'))
expect(screen.getByText('4 / 6')).toBeInTheDocument()
expect(writes(fetchMock)).toEqual([])
})
it('offers no Send to Google Chat for an archived list on tablet', async () => {
mockViewport(1180)
stubServer({
'GET /api/v1/shopping/2026-09-28': () =>
jsonResponse(200, supplyRun({ status: 'archived' }, '2026-09-28', 'Week 40 · Sep 28 – Oct 4')),
})
await renderPage('/plan/shopping?week=2026-09-28')
await screen.findByText('Archived')
expect(screen.queryAllByRole('checkbox')).toEqual([])
expect(screen.queryByRole('button', { name: 'Send to Google Chat' })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Archive' })).toBeInTheDocument()
})
it('refuses a week that is not a Monday', async () => {
const fetchMock = stubServer({})
await renderPage('/plan/shopping?week=2026-09-29')
expect(await screen.findByRole('alert')).toHaveTextContent('“2026-09-29” is not the Monday of a week, as YYYY-MM-DD.')
expect(fetchMock.mock.calls.filter(([input]) => String(input).startsWith('/api/v1/shopping'))).toEqual([])
})
it('shows skeleton cards while the list loads', async () => {
const answer = deferred()
stubServer({ [`GET ${THIS_WEEK}`]: () => answer.response })
await renderPage()
expect(await screen.findByRole('region', { name: 'Shopping list loading' })).toHaveAttribute('aria-busy', 'true')
await waitFor(() => expect(screen.getAllByTestId('skeleton-group')).toHaveLength(3))
answer.send(jsonResponse(200, supplyRun()))
expect(await screen.findByText('4 / 6')).toBeInTheDocument()
expect(screen.queryByTestId('skeleton-group')).not.toBeInTheDocument()
})
it('says when the list cannot load, and Retry reads it again', async () => {
let reads = 0
stubServer({
[`GET ${THIS_WEEK}`]: () => {
reads++
return reads === 1 ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun())
},
})
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't load the shopping list. internal message")
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('4 / 6')).toBeInTheDocument()
})
it('offers to make the list when the week has none, and shows the list made', async () => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${GENERATE}`]: () => jsonResponse(200, { ...supplyRun(), skipped: [] }),
})
await renderPage()
expect(await screen.findByRole('heading', { name: 'No shopping list yet' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByText('4 / 6')).toBeInTheDocument()
expect(writes(fetchMock)).toEqual([`POST ${GENERATE} undefined`])
})
it('says why the list could not be made', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${GENERATE}`]: () => errorResponse(404, 'not_found'),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't make the shopping list. not_found message")
expect(screen.getByRole('heading', { name: 'No shopping list yet' })).toBeInTheDocument()
})
})
describe('sentAt', () => {
it('says the day and time in the household zone', () => {
expect(sentAt(new Date('2026-10-10T12:00:00Z'), 'America/New_York')).toBe('Sat 8:00 am')
expect(sentAt(new Date('2026-10-11T03:30:00Z'), 'America/New_York')).toBe('Sat 11:30 pm')
})
})
+289
View File
@@ -0,0 +1,289 @@
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router'
import { Unavailable } from '../plan/PlanPage.tsx'
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import buttonStyles from '../ui/Button.module.css'
import { Checkbox } from '../ui/Checkbox.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import {
useGenerateSupplyRun,
useSupplyRun,
useToggles,
WEEK_PARAM,
type SupplyRunGroup,
type SupplyRunItem,
type Toggles,
} from './api.ts'
import { keep, savedSchedule, useSavedProblem } from './offline.ts'
import styles from './SupplyRunPage.module.css'
// SUPPLY_ARCHIVE is the Supply archive screen.
export const SUPPLY_ARCHIVE = '/plan/shopping/archive'
// CHAT_HINT is why Send to Google Chat is unavailable.
// TODO(Feature 19): send the Supply run to Google Chat once it is built.
const CHAT_HINT = 'Sending the shopping list to Google Chat arrives in a later update.'
// sentAt is when the Supply run was sent to Google Chat as its status line
// says it, in the household's zone: "Sat 8:00 am".
export function sentAt(at: Date, timezone: string): string {
const day = new Intl.DateTimeFormat('en-US', { weekday: 'short', timeZone: timezone }).format(at)
return `${day} ${formatClockTime(localNow(timezone, at).time)}`
}
// ItemRow is one ingredient: on a current Supply run a checkbox, checked
// (grape) while it is to buy; unchecked, the name is struck through and
// "have it" stands in for the quantity. A read-only Supply run shows the
// same marks. An item toggled while the server cannot be reached
// says it will sync.
function ItemRow({ item, toggles }: { item: SupplyRunItem; toggles: Toggles | null }) {
const name = (
<>
<span className={styles.name}>{item.name}</span>
{toggles?.willSync.has(item.id) && (
<span className={styles.willSync}>
<Icon icon={CloudUpload} size="inline" />
will sync
</span>
)}
</>
)
return (
<li className={[styles.item, !item.needed && styles.have].filter(Boolean).join(' ')}>
{toggles === null ? (
<span className={styles.readOnly}>
<span className={styles.box} aria-hidden="true">
{item.needed && <Icon icon={Check} size="inline" className={styles.mark} />}
</span>
{name}
</span>
) : (
<Checkbox
className={styles.check}
label={name}
checked={item.needed}
onChange={() => toggles.toggle({ id: item.id, needed: !item.needed })}
/>
)}
<span className={styles.qty}>{item.needed ? item.qty : 'have it'}</span>
</li>
)
}
// GroupCard is one dinner's items: its name in Bagel over its day.
function GroupCard({ group, toggles }: { group: SupplyRunGroup; toggles: Toggles | null }) {
return (
<section className={styles.group} aria-label={group.dinner.name}>
<header className={styles.groupHeader}>
<h2 className={styles.dinner}>{group.dinner.name}</h2>
<span className={styles.day}>{weekdayKicker(group.date)}</span>
</header>
<ul className={styles.items}>
{group.items.map((item) => (
<ItemRow key={item.id} item={item} toggles={toggles} />
))}
</ul>
</section>
)
}
// SentStatus is the status line once the Supply run was sent to Google Chat.
function SentStatus({ at, timezone }: { at: Date; timezone: string }) {
return (
<p className={styles.sent}>
<Icon icon={MessageSquare} size="inline" />
Sent to Google Chat · {sentAt(at, timezone)}
</p>
)
}
// ArchiveLink opens the Supply archive: an outline action from tablet up, a
// grape link on mobile.
function ArchiveLink({ layout }: { layout: Breakpoint }) {
const className = layout === 'mobile' ? styles.archiveLink : [buttonStyles.button, buttonStyles.outline, styles.action].join(' ')
return (
<Link to={SUPPLY_ARCHIVE} className={className}>
<Icon icon={Archive} size="inline" />
{layout === 'mobile' ? 'Supply archive' : 'Archive'}
</Link>
)
}
// SupplyRunList is a week's Supply run (mocks 1l, 2b, 2f): the kicker with
// the week's dates, the title with the count still to buy of all items, and
// one card per dinner, stacked on mobile and in three masonry columns from
// tablet up, where Archive and Send to Google Chat are actions in the
// header. On a current Supply run each item toggles at once (useToggles); a
// toggle refused puts the item back and says why. An archived one, or any
// opened from the Supply archive (readOnly), is read-only and tagged
// Archived, and a toggle it refused is not offered again.
function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
const toggles = useToggles(weekStart)
const run = toggles.shown
const viewOnly = readOnly || run.status === 'archived'
const wide = layout !== 'mobile'
const failed = toggles.failed
const failedItem = failed && run.groups.flatMap((group) => group.items).find((item) => item.id === failed.toggle.id)
return (
<div className={`${styles.page} ${styles[layout]}`}>
<header className={styles.header}>
<div className={styles.heading}>
<p className={styles.kicker}>Supply run · {weekLabel(weekStart).dates}</p>
<div className={styles.titleRow}>
<h1 className={styles.title}>Shopping list</h1>
{viewOnly && <Tag tone="grape">Archived</Tag>}
<p className={styles.count}>
{run.needed_count} / {run.total_count}
</p>
</div>
<p className={styles.helper}>
{run.status === 'archived'
? 'This list is in the Supply archive, as it was when the week ended.'
: viewOnly
? "This week's list as it stands, read-only in the Supply archive."
: "Uncheck anything you already have — it won't be counted."}
</p>
</div>
{wide && (
<div className={styles.actions}>
<ArchiveLink layout={layout} />
{!viewOnly && (
<Unavailable hint={CHAT_HINT} below className={styles.action}>
<Icon icon={MessageSquare} size="inline" />
Send to Google Chat
</Unavailable>
)}
</div>
)}
</header>
{failed !== null && (
<ErrorBanner onRetry={viewOnly ? undefined : () => toggles.toggle(failed.toggle)}>
Couldn't mark {failedItem ? failedItem.name : 'an item'} {failed.toggle.needed ? 'to buy' : '“have it”'}.{' '}
{failed.error.message}
</ErrorBanner>
)}
{wide && run.sent_to_chat_at !== null && <SentStatus at={run.sent_to_chat_at} timezone={timezone} />}
{run.groups.length === 0 ? (
<EmptyState title="Nothing to buy">None of this week's dinners has a recipe with ingredients yet.</EmptyState>
) : (
<div className={styles.groups} data-testid="supply-run-groups">
{run.groups.map((group) => (
<GroupCard key={`${group.date} ${group.dinner.id}`} group={group} toggles={viewOnly ? null : toggles} />
))}
</div>
)}
{!wide && (
<footer className={styles.footer}>
{run.sent_to_chat_at !== null && <SentStatus at={run.sent_to_chat_at} timezone={timezone} />}
<ArchiveLink layout={layout} />
</footer>
)}
</div>
)
}
// SkeletonGroups stands in for the dinner cards while the Supply run loads.
function SkeletonGroups({ layout }: { layout: Breakpoint }) {
return (
<section aria-busy="true" aria-label="Shopping list loading" className={`${styles.page} ${styles[layout]}`}>
<Skeleton className={styles.skeletonTitle} />
<div className={styles.groups}>
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} data-testid="skeleton-group" className={styles.skeletonGroup} />
))}
</div>
</section>
)
}
type WeekProps = { weekStart: string; timezone: string; layout: Breakpoint; readOnly: boolean }
// WeekSupplyRun is the Supply run of the week starting on weekStart, or,
// when the week has none yet, the offer to make it from the week's plan —
// not offered read-only.
function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
const run = useSupplyRun(weekStart)
const generate = useGenerateSupplyRun(weekStart)
// Offline, the Supply run saved on this device stays shown; the offline
// pill says it is saved data.
if (run.isError && !(run.data !== undefined && run.error instanceof TypeError)) {
return <ErrorBanner onRetry={() => void run.refetch()}>Couldn't load the shopping list. {run.error.message}</ErrorBanner>
}
if (run.isPending || generate.isPending) return <SkeletonGroups layout={layout} />
if (run.data === null) {
return (
<div className={`${styles.page} ${styles[layout]}`}>
{readOnly ? (
<EmptyState title="No shopping list">This week has no Supply run in the Supply archive.</EmptyState>
) : (
<EmptyState title="No shopping list yet" action={{ label: 'Make the shopping list', onClick: () => generate.mutate() }}>
I'll gather the ingredients of this week's planned dinners into a Supply run.
</EmptyState>
)}
{generate.isError && (
<ErrorBanner onRetry={() => generate.mutate()}>Couldn't make the shopping list. {generate.error.message}</ErrorBanner>
)}
</div>
)
}
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} readOnly={readOnly} />
}
// SupplyRunPage is the Supply run (mock 1l on mobile, 2b on tablet, 2f on
// desktop) of the week the Plan opens on — next week once this week's
// plan_draft time has come, otherwise this week — or of the week chosen by
// ?week=. Opened from the Supply archive it is readOnly: tagged Archived,
// with no checkbox and nothing to make or send, whatever the week. Offline,
// where the settings cannot be read, it chooses the week by the schedule
// saved on this device.
export function SupplyRunPage({ readOnly = false }: { readOnly?: boolean }) {
const problem = useSavedProblem()
return (
<>
{problem !== null && (
<ErrorBanner>Changes to the shopping list can't be kept on this device while offline. {problem.message}</ErrorBanner>
)}
<ChosenSupplyRun readOnly={readOnly} />
</>
)
}
function ChosenSupplyRun({ readOnly }: { readOnly: boolean }) {
const layout = useBreakpoint()
const settings = useHouseholdSettings()
const [params] = useSearchParams()
const chosen = params.get(WEEK_PARAM)
// Read once, not on every render: a failed read reports a problem, which
// renders this again.
const [saved] = useState(() => keep(savedSchedule))
if (chosen !== null && !isMonday(chosen)) {
return <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner>
}
// fetch rejects with a TypeError only when the network fails.
const schedule = settings.data ?? (settings.error instanceof TypeError ? saved : null)
if (settings.isError && !schedule) {
return <ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
}
if (!schedule) return <SkeletonGroups layout={layout} />
const week = chosen ?? defaultWeek(schedule, new Date())
return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} readOnly={readOnly} />
}
// isMonday says whether date is a real YYYY-MM-DD Monday.
function isMonday(date: string): boolean {
try {
return weekOf(date) === date
} catch {
return false
}
}
+579
View File
@@ -0,0 +1,579 @@
import {
MutationObserver,
useMutation,
useMutationState,
useQuery,
useQueryClient,
type MutationObserverOptions,
type QueryClient,
} from '@tanstack/react-query'
import { useEffect, useReducer, useSyncExternalStore } from 'react'
import { ApiError, useApi, type ApiClient } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { meQueryKey } from '../AuthBootstrap.tsx'
import { oneOf } from '../dinners/api.ts'
import { settleUnmadePushes } from '../plan/api.ts'
import type { NetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS_KEY, type HouseholdSettings } from '../settings/api.ts'
import {
dequeue,
enqueue,
keep,
markWillSync,
queued,
SAVED_KEY,
saveRun,
savedRun,
saveSchedule,
synced,
willSync,
} from './offline.ts'
export const SHOPPING_PATH = '/api/v1/shopping'
// WEEK_PARAM names the search parameter choosing the week the Supply run
// screen shows, its Monday as YYYY-MM-DD: /plan/shopping?week=2026-09-28.
export const WEEK_PARAM = 'week'
// supplyRunScreen is the Supply run screen on the week starting on Monday
// weekStart.
export function supplyRunScreen(weekStart: string): string {
return `/plan/shopping?${new URLSearchParams({ [WEEK_PARAM]: weekStart })}`
}
// supplyRunPath is the Supply run of the week starting on Monday weekStart.
export function supplyRunPath(weekStart: string): string {
return `${SHOPPING_PATH}/${encodeURIComponent(weekStart)}`
}
// supplyRunKey is the query of the Supply run of the week starting on
// weekStart.
export function supplyRunKey(weekStart: string) {
return ['shopping', weekStart] as const
}
export const SUPPLY_RUN_STATUSES = ['current', 'archived'] as const
// SupplyRunStatus is current until the week ends and the Supply run is
// frozen into the Supply archive: archived, after which nothing on it
// changes.
export type SupplyRunStatus = (typeof SUPPLY_RUN_STATUSES)[number]
// SupplyRunItem is one ingredient: qty is null when the recipe gives none,
// and needed is false once it is marked "have it".
export type SupplyRunItem = { id: string; name: string; qty: string | null; needed: boolean }
// SupplyRunGroup is the items of one plan day's dinner: date is the plan
// day, household-local YYYY-MM-DD.
export type SupplyRunGroup = { dinner: { id: string; name: string }; date: string; items: SupplyRunItem[] }
// Counts is how many of a Supply run's items are still to buy (needed) of
// all of them (total).
export type Counts = { needed_count: number; total_count: number }
// SupplyRun is GET /api/v1/shopping/{weekStart}: label is the week label,
// "Week 41 · Oct 5 – 11", title the AI's title once written, and
// sent_to_chat_at when it was sent to Google Chat, if it was.
export type SupplyRun = Counts & {
weekStart: string
label: string
title: string | null
status: SupplyRunStatus
sent_to_chat_at: Date | null
groups: SupplyRunGroup[]
}
function count(where: string, field: string, value: unknown): number {
if (!Number.isInteger(value) || (value as number) < 0) throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not a count`)
return value as number
}
function parseCounts(where: string, body: Record<string, unknown>): Counts {
const counts = { needed_count: count(where, 'needed_count', body.needed_count), total_count: count(where, 'total_count', body.total_count) }
if (counts.needed_count > counts.total_count) throw new Error(`${where}: needed_count is more than total_count`)
return counts
}
function parseItem(where: string, body: unknown): SupplyRunItem {
if (
!isRecord(body) ||
typeof body.id !== 'string' ||
typeof body.name !== 'string' ||
(body.qty !== null && typeof body.qty !== 'string') ||
typeof body.needed !== 'boolean'
) {
throw new Error(`${where}: response has an item that is not a Supply run item`)
}
return { id: body.id, name: body.name, qty: body.qty, needed: body.needed }
}
function parseGroup(where: string, body: unknown): SupplyRunGroup {
if (
!isRecord(body) ||
!isRecord(body.dinner) ||
typeof body.dinner.id !== 'string' ||
typeof body.dinner.name !== 'string' ||
typeof body.date !== 'string' ||
!Array.isArray(body.items)
) {
throw new Error(`${where}: response has a group that is not a dinner's items`)
}
return {
dinner: { id: body.dinner.id, name: body.dinner.name },
date: body.date,
items: body.items.map((item) => parseItem(where, item)),
}
}
// sentAt reads sent_to_chat_at, an RFC 3339 instant or null.
function sentAt(where: string, value: unknown): Date | null {
if (value === null) return null
const at = typeof value === 'string' ? new Date(value) : null
if (at === null || Number.isNaN(at.getTime())) {
throw new Error(`${where}: sent_to_chat_at ${JSON.stringify(value)} is not an instant`)
}
return at
}
export function parseSupplyRun(where: string, weekStart: string, body: unknown): SupplyRun {
if (
!isRecord(body) ||
body.weekStart !== weekStart ||
typeof body.label !== 'string' ||
(body.title !== null && typeof body.title !== 'string') ||
!Array.isArray(body.groups)
) {
throw new Error(`${where}: response is not the Supply run of the week of ${weekStart}`)
}
return {
weekStart,
label: body.label,
title: body.title,
status: oneOf(where, 'status', SUPPLY_RUN_STATUSES, body.status),
sent_to_chat_at: sentAt(where, body.sent_to_chat_at),
...parseCounts(where, body),
groups: body.groups.map((group) => parseGroup(where, group)),
}
}
// savedSupplyRun is the week's Supply run saved on this device, or undefined.
function savedSupplyRun(weekStart: string): SupplyRun | undefined {
const saved = savedRun(weekStart)
return saved === undefined ? undefined : parseSupplyRun(`Saved ${SAVED_KEY}`, weekStart, saved)
}
// useSupplyRun is the Supply run of the week starting on Monday weekStart
// (GET /api/v1/shopping/{weekStart}), or null when the week has none (404).
// It starts from the Supply run saved on this device, if there is one, so
// the list opens offline; the saved one is always read again.
export function useSupplyRun(weekStart: string) {
const { apiRead } = useApi()
return useQuery({
queryKey: supplyRunKey(weekStart),
initialData: () => keep(() => savedSupplyRun(weekStart)),
initialDataUpdatedAt: 0,
queryFn: async (): Promise<SupplyRun | null> => {
const path = supplyRunPath(weekStart)
let body: unknown
try {
body = (await apiRead<unknown>(path)).body
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null
throw error
}
return parseSupplyRun(`GET ${path}`, weekStart, body)
},
})
}
// useGenerateSupplyRun makes the week's Supply run from its plan (POST
// /api/v1/shopping/{weekStart}/generate); the Supply run it answers with is
// the one shown. Once made, from whichever screen, a push of the week whose
// Supply run was not made is settled (settleUnmadePushes).
export function useGenerateSupplyRun(weekStart: string) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
onMutate: () => settleUnmadePushes(queryClient, weekStart),
mutationFn: async (): Promise<SupplyRun> => {
const path = `${supplyRunPath(weekStart)}/generate`
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
},
onSuccess: (run, _, settle) => {
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
settle()
},
})
}
// Toggle is one item marked to buy (needed) or "have it" (not needed).
export type Toggle = { id: string; needed: boolean }
// KeyedToggle is a toggle as sent: key tells it apart from any other toggle
// of the same item, here and in the queue saved on this device.
type KeyedToggle = Toggle & { key: string }
// toggleKey keys the toggles of the week starting on weekStart.
function toggleKey(weekStart: string) {
return ['shopping', weekStart, 'toggle'] as const
}
// TOGGLE_SCOPE is the one mutation scope of every toggle of every week, made
// here or replayed from the queue saved on this device.
const TOGGLE_SCOPE = 'shopping toggle'
// Toggles is the toggles of one week's Supply run: shown, the Supply run
// with those still pending laid over it, the items whose toggles will sync
// once the server can be reached, the latest failed toggle no later toggle
// of its item has superseded, and toggle to make another.
export type Toggles = {
shown: SupplyRun
willSync: ReadonlySet<string>
failed: { toggle: Toggle; error: Error } | null
toggle: (toggle: Toggle) => void
}
// TOGGLE_DEADLINE is how long, in milliseconds, one try of a toggle — its
// GET /api/v1/me, its PATCH and the /me refresh a csrf 403 to it makes — may
// take before it counts as the server not answering. Only toggles have a
// deadline: the API client sets none.
export const TOGGLE_DEADLINE = 10_000
// RETRY_FIRST and RETRY_CEILING bound, in milliseconds, how long a toggle
// waits while the server cannot be reached before it is tried again anyway:
// the first wait, doubled after each failed try up to the ceiling.
export const RETRY_FIRST = 1_000
export const RETRY_CEILING = 30_000
// reachable resolves once network says the server can be reached — at once
// when it says so now — or after delay milliseconds, whichever is first. The
// server coming back says nothing by itself, and the browser sends no online
// event when it never thought it was offline, so the toggle is tried again
// after delay whatever network says.
function reachable(network: NetworkStatus, delay: number): Promise<void> {
if (!network.offline()) return Promise.resolve()
return new Promise((resolve) => {
const done = () => {
clearTimeout(timer)
stop()
resolve()
}
const timer = setTimeout(done, delay)
const stop = network.subscribe(() => {
if (!network.offline()) done()
})
})
}
// toggleOptions is the mutation every toggle of the week's Supply run is,
// made on the Supply run screen (useToggles) or replayed from the queue saved
// on this device (useSupplyRunSync).
//
// Toggles are sent one at a time, in the order made, whatever their week
// (one mutation scope, TOGGLE_SCOPE), so the counts each is answered with
// include every toggle before it and none after, and a replayed toggle never
// overlaps another replayed or made here. While the server cannot be
// reached — the device is offline, or the toggle's PATCH failed with a
// network error (fetch rejected with a TypeError), or a try ran past its
// deadline (TOGGLE_DEADLINE) — a toggle waits, pending, and is tried again
// once network says the server can be reached, or after a wait that doubles
// from RETRY_FIRST up to RETRY_CEILING, whichever is first: those after it
// wait behind it, in order. A try past its deadline is aborted and marks the
// network failed, as a network error does, so the item says it will sync; the
// server may have applied the aborted PATCH, and sending it again is safe for
// the reason a replay is (see useSupplyRunSync). Any answer from the server
// settles it: a refusal (a 400, 404, 409 or any other error response) or a
// response that is not the item as toggled fails it, and it is not sent again. Before
// a toggle is sent, GET /api/v1/me has answered, so it carries the session's
// CSRF token even when the app opened offline.
//
// Every toggle is in the queue saved on this device (enqueue, by useToggles)
// until it settles, so a reload replays those the server never answered.
function toggleOptions(
weekStart: string,
api: ApiClient,
queryClient: QueryClient,
): MutationObserverOptions<Counts, Error, KeyedToggle> {
const key = supplyRunKey(weekStart)
const mutationKey = toggleKey(weekStart)
return {
mutationKey,
scope: { id: TOGGLE_SCOPE },
networkMode: 'always',
retry: false,
gcTime: Infinity,
mutationFn: async ({ key, id, needed }: KeyedToggle): Promise<Counts> => {
const path = `${SHOPPING_PATH}/items/${encodeURIComponent(id)}`
const where = `PATCH ${path}`
let body: unknown
for (let failures = 0; ; failures++) {
if (api.network.offline()) markWillSync(key)
await reachable(api.network, Math.min(RETRY_FIRST * 2 ** failures, RETRY_CEILING))
const deadline = new AbortController()
const timer = setTimeout(() => deadline.abort(new DOMException(`${where}: no answer in time`, 'TimeoutError')), TOGGLE_DEADLINE)
try {
// Each try loads /me itself, under its deadline, rather than share
// a /me already in flight that may never answer.
if (queryClient.getQueryData(meQueryKey) === undefined) {
queryClient.setQueryData(meQueryKey, await api.loadMe(deadline.signal))
}
body = await api.apiFetch<unknown>(path, { method: 'PATCH', body: { needed }, signal: deadline.signal })
break
} catch (error) {
if (deadline.signal.aborted) {
api.network.requestFailed()
continue
}
// The API client marks the network offline as fetch rejects; a
// TypeError it did not mark is not a network failure.
if (!(error instanceof TypeError) || !api.network.offline()) throw error
} finally {
clearTimeout(timer)
}
}
if (!isRecord(body) || body.id !== id || body.needed !== needed) throw new Error(`${where}: response is not item ${id} as toggled`)
return parseCounts(where, body)
},
// A read of the Supply run sent before the toggle must not land after
// its answer, over it.
onMutate: () => queryClient.cancelQueries({ queryKey: key }),
onSuccess: (counts, { id, needed }) => {
queryClient.setQueryData<SupplyRun | null>(
key,
(run) =>
run && {
...run,
...counts,
groups: run.groups.map((group) => ({
...group,
items: group.items.map((item) => (item.id === id ? { ...item, needed } : item)),
})),
},
)
},
onError: (error) => {
if (error instanceof ApiError && error.code === 'archived') void queryClient.invalidateQueries({ queryKey: key })
},
// This toggle is still pending as it settles, and those after it wait
// for it: the settled toggles of its item are the earlier ones. Settled,
// it leaves the saved queue.
onSettled: (_counts, _error, { key: settled, id }) => {
const cache = queryClient.getMutationCache()
for (const earlier of cache.findAll({ mutationKey, predicate: (m) => m.state.status !== 'pending' })) {
if ((earlier.state.variables as KeyedToggle).id === id) cache.remove(earlier)
}
keep(() => dequeue(settled))
synced(settled)
},
}
}
// useToggles marks items of the week's Supply run, already read, to buy or
// "have it" (PATCH /api/v1/shopping/items/{id}; see toggleOptions). The
// Supply run cached is the server's: a toggle's answer writes the item and
// the counts it answers with there. Until then the toggle is pending and is
// laid over the Supply run shown, so the item and the needed count change at
// once; a refused toggle just stops being pending, which puts both back.
// An item whose pending toggle was made, or waited, while the server could
// not be reached will sync: it says so until that toggle settles, even once
// the server can be reached again, and even when the screen is left and
// opened again meanwhile (markWillSync keeps it with the page, not here).
//
// The Supply run and the toggles are read straight from the query and
// mutation caches as each render begins, never from the caches' batched
// notices, which can arrive apart: a toggle is in the mutation cache as
// pending as soon as it is made, and its answer is in the query cache
// before it stops being pending. Making a toggle renders again at once, so
// the checkbox clicked never shows its old state; useSupplyRun and
// useMutationState render again on every later change.
//
// A failed toggle stays failed, and shown, until a later toggle of its item
// supersedes it, such as its Retry: a toggle of another item succeeding
// does not hide it. So that a failure is never collected unseen, toggles are
// kept (gcTime Infinity), and a settled toggle removes the earlier toggles
// of its item it supersedes. A toggle refused because the week was frozen
// into the Supply archive (409 archived), even one replayed after the device
// was offline, reads the Supply run again, which then renders read-only.
export function useToggles(weekStart: string): Toggles {
const api = useApi()
const queryClient = useQueryClient()
const key = supplyRunKey(weekStart)
const mutationKey = toggleKey(weekStart)
useSupplyRun(weekStart)
useMutationState({ filters: { mutationKey } })
const offline = useSyncExternalStore(api.network.subscribe, api.network.offline)
const [, renderAgain] = useReducer((n: number) => n + 1, 0)
const run = queryClient.getQueryData<SupplyRun | null>(key)
if (!run) throw new Error(`The week of ${weekStart} has no Supply run read to toggle items of.`)
const toggles = queryClient
.getMutationCache()
.findAll({ mutationKey })
.map((mutation) => ({ status: mutation.state.status, toggle: mutation.state.variables as KeyedToggle, error: mutation.state.error }))
const pending = toggles.filter((t) => t.status === 'pending').map((t) => t.toggle)
// Every change of the network status renders again, so each pending
// toggle seen while the server cannot be reached is marked.
if (offline) pending.forEach((t) => markWillSync(t.key))
const failed = toggles
.filter((t, i) => t.status === 'error' && !toggles.slice(i + 1).some((later) => later.toggle.id === t.toggle.id))
.at(-1)
const { mutate } = useMutation(toggleOptions(weekStart, api, queryClient))
return {
shown: shown(run, pending),
willSync: new Set(pending.filter((t) => willSync(t.key)).map((t) => t.id)),
failed: failed === undefined ? null : { toggle: { id: failed.toggle.id, needed: failed.toggle.needed }, error: failed.error! },
toggle: ({ id, needed }) => {
const queuedToggle = { key: crypto.randomUUID(), weekStart, id, needed }
keep(() => enqueue(queuedToggle))
mutate({ key: queuedToggle.key, id, needed })
renderAgain()
},
}
}
// useSupplyRunSync keeps, on this device, what the Supply run screen needs
// to open offline — the household's schedule and each Supply run the server
// sends (saveRun) — and replays, in the order made, the toggles saved in the
// queue that are not already pending here: those of a page reloaded before
// the server answered them.
//
// Replay is at-least-once, not exactly-once: a toggle leaves the saved queue
// only once its answer settles here, so one the server applied just before a
// reload is sent again. That duplicate is safe because a toggle sets needed
// to a fixed value — the same PATCH applied twice leaves the item and the
// counts as applying it once does — so no key is sent for the server to
// deduplicate by.
//
// A replayed toggle's answer is written over the Supply run saved, so the
// week's Supply run is cached from it first; it is still read again
// (updatedAt 0) when the Supply run screen opens.
export function useSupplyRunSync() {
const api = useApi()
const queryClient = useQueryClient()
useEffect(() => {
const stop = queryClient.getQueryCache().subscribe((event) => {
if (event.type !== 'updated' || event.action.type !== 'success') return
const [scope, week] = event.query.queryKey
const data: unknown = event.query.state.data
if (scope === 'shopping' && typeof week === 'string' && event.query.queryKey.length === 2) {
keep(() => saveRun(week, data as SupplyRun | null))
} else if (scope === HOUSEHOLD_SETTINGS_KEY[0] && week === HOUSEHOLD_SETTINGS_KEY[1]) {
keep(() => saveSchedule(data as HouseholdSettings))
}
})
const pending = new Set(
queryClient
.getMutationCache()
.findAll({ mutationKey: ['shopping'], predicate: (m) => m.state.status === 'pending' })
.map((m) => (m.state.variables as KeyedToggle).key),
)
for (const { key, weekStart, id, needed } of keep(queued) ?? []) {
if (pending.has(key)) continue
const runKey = supplyRunKey(weekStart)
if (queryClient.getQueryData(runKey) === undefined) {
const saved = keep(() => savedSupplyRun(weekStart))
if (saved !== undefined) queryClient.setQueryData(runKey, saved, { updatedAt: 0 })
}
// A refusal is the mutation's state, which the Supply run screen shows.
new MutationObserver(queryClient, toggleOptions(weekStart, api, queryClient)).mutate({ key, id, needed }).catch(() => {})
}
return stop
}, [api, queryClient])
}
// SupplyRunSync runs useSupplyRunSync for the whole app.
export function SupplyRunSync(): null {
useSupplyRunSync()
return null
}
// shown is run as the household sees it with the pending toggles laid over
// it: each item as its latest pending toggle marks it, and the needed count
// moved to match.
function shown(run: SupplyRun, pending: Toggle[]): SupplyRun {
const marked = new Map(pending.map((toggle) => [toggle.id, toggle.needed]))
let needed = run.needed_count
const groups = run.groups.map((group) => ({
...group,
items: group.items.map((item) => {
const mark = marked.get(item.id)
if (mark === undefined || mark === item.needed) return item
needed += mark ? 1 : -1
return { ...item, needed: mark }
}),
}))
return { ...run, needed_count: needed, groups }
}
export const ARCHIVE_PERIODS = ['week', 'month', 'year'] as const
// ArchivePeriod is what the Supply archive is grouped by.
export type ArchivePeriod = (typeof ARCHIVE_PERIODS)[number]
// ArchivePost is one week's Supply run in the Supply archive: label is the
// week label, "Week 41 · Oct 5 – 11", which stands in for title while it is
// null; onHandCount counts the items marked "have it", and dinners names the
// dinners in day order. current is true for the household's current week.
export type ArchivePost = {
weekStart: string
label: string
title: string | null
dinnerCount: number
itemCount: number
onHandCount: number
dinners: string[]
current: boolean
}
// ArchiveGroup is one period of the Supply archive — a week, a month
// ("October 2026") or a year ("2026") — and its posts, newest first.
export type ArchiveGroup = { key: string; heading: string; posts: ArchivePost[] }
function parsePost(where: string, body: unknown): ArchivePost {
if (
!isRecord(body) ||
typeof body.weekStart !== 'string' ||
typeof body.label !== 'string' ||
(body.title !== null && typeof body.title !== 'string') ||
!Array.isArray(body.dinners) ||
!body.dinners.every((name) => typeof name === 'string') ||
typeof body.current !== 'boolean'
) {
throw new Error(`${where}: response has a post that is not a Supply archive post`)
}
return {
weekStart: body.weekStart,
label: body.label,
title: body.title,
dinnerCount: count(where, 'dinnerCount', body.dinnerCount),
itemCount: count(where, 'itemCount', body.itemCount),
onHandCount: count(where, 'onHandCount', body.onHandCount),
dinners: body.dinners as string[],
current: body.current,
}
}
function parseArchiveGroup(where: string, body: unknown): ArchiveGroup {
if (!isRecord(body) || typeof body.key !== 'string' || typeof body.heading !== 'string' || !Array.isArray(body.posts)) {
throw new Error(`${where}: response has a period that is not a Supply archive period`)
}
return { key: body.key, heading: body.heading, posts: body.posts.map((post) => parsePost(where, post)) }
}
// useSupplyArchive is the Supply archive grouped by by, newest first (GET
// /api/v1/shopping/archive?by=week|month|year): the server groups it in the
// household's zone, so it is shown as answered, never grouped again here.
export function useSupplyArchive(by: ArchivePeriod) {
const { apiRead } = useApi()
return useQuery({
queryKey: ['supply archive', by] as const,
queryFn: async (): Promise<ArchiveGroup[]> => {
const path = `${SHOPPING_PATH}/archive?${new URLSearchParams({ by })}`
const where = `GET ${path}`
const { body } = await apiRead<unknown>(path)
if (!isRecord(body) || body.by !== by || !Array.isArray(body.periods)) {
throw new Error(`${where}: response is not the Supply archive by ${by}`)
}
return body.periods.map((period) => parseArchiveGroup(where, period))
},
})
}
+155
View File
@@ -0,0 +1,155 @@
import { useSyncExternalStore } from 'react'
import { isRecord } from '../api/errors.ts'
// SAVED_KEY is the localStorage key holding, on this device, what the Supply
// run screen needs to open offline — the household's schedule and the
// Supply runs last read — and the toggles made that the server has not
// answered yet, in the order they were made.
export const SAVED_KEY = 'mealquest.supply-run'
// SAVED_WEEKS is how many weeks' Supply runs are kept: this week's and next
// week's are the only ones still current.
const SAVED_WEEKS = 2
// Schedule is the part of the household settings the Supply run screen
// needs to choose the week and say times. The service worker never caches
// the settings, so it is kept here for the screen to open offline.
export type Schedule = { timezone: string; plan_draft_dow: number; plan_draft_time: string }
// QueuedToggle is a toggle the server has not answered yet: key tells it
// apart from any other toggle of the same item.
export type QueuedToggle = { key: string; weekStart: string; id: string; needed: boolean }
// Saved is the stored value: runs holds each week's Supply run as the server
// last sent it, by its Monday.
type Saved = { schedule: Schedule | null; runs: Record<string, unknown>; queue: QueuedToggle[] }
function isSchedule(value: unknown): value is Schedule {
return (
isRecord(value) &&
typeof value.timezone === 'string' &&
Number.isInteger(value.plan_draft_dow) &&
typeof value.plan_draft_time === 'string'
)
}
function isQueuedToggle(value: unknown): value is QueuedToggle {
return (
isRecord(value) &&
typeof value.key === 'string' &&
typeof value.weekStart === 'string' &&
typeof value.id === 'string' &&
typeof value.needed === 'boolean'
)
}
// read is the stored value, empty when nothing is stored. A value that is not
// one MealQuest stored is an error.
function read(): Saved {
const text = window.localStorage.getItem(SAVED_KEY)
if (text === null) return { schedule: null, runs: {}, queue: [] }
const saved: unknown = JSON.parse(text)
if (
!isRecord(saved) ||
(saved.schedule !== null && !isSchedule(saved.schedule)) ||
!isRecord(saved.runs) ||
!Array.isArray(saved.queue) ||
!saved.queue.every(isQueuedToggle)
) {
throw new Error(`The Supply run saved on this device (${SAVED_KEY}) is not one MealQuest saved.`)
}
return { schedule: saved.schedule as Schedule | null, runs: saved.runs, queue: saved.queue }
}
function write(change: (saved: Saved) => Saved) {
window.localStorage.setItem(SAVED_KEY, JSON.stringify(change(read())))
}
// savedSchedule is the household's schedule as last read, or null.
export function savedSchedule(): Schedule | null {
return read().schedule
}
export function saveSchedule({ timezone, plan_draft_dow, plan_draft_time }: Schedule) {
write((saved) => ({ ...saved, schedule: { timezone, plan_draft_dow, plan_draft_time } }))
}
// savedRun is the week's Supply run as the server last sent it, unparsed, or
// undefined when none is saved.
export function savedRun(weekStart: string): unknown {
return read().runs[weekStart]
}
// saveRun keeps the week's Supply run as the server sent it, with the latest
// weeks'. A week with none (null) or one frozen into the Supply archive is
// not kept: nothing on it can change.
export function saveRun(weekStart: string, run: { status: string } | null) {
write((saved) => {
const runs = { ...saved.runs }
delete runs[weekStart]
if (run !== null && run.status !== 'archived') runs[weekStart] = run
const latest = Object.keys(runs).sort().slice(-SAVED_WEEKS)
return { ...saved, runs: Object.fromEntries(latest.map((week) => [week, runs[week]])) }
})
}
// queued is the toggles the server has not answered, in the order made.
export function queued(): QueuedToggle[] {
return read().queue
}
export function enqueue(toggle: QueuedToggle) {
write((saved) => ({ ...saved, queue: [...saved.queue, toggle] }))
}
export function dequeue(key: string) {
write((saved) => ({ ...saved, queue: saved.queue.filter((toggle) => toggle.key !== key) }))
}
// The keys of the toggles that will sync: made, or waiting, while the server
// could not be reached, and not settled yet. They live here, with the page,
// as the toggles in the mutation cache do: the Supply run screen is left and
// opened again while a toggle is still pending, and starts with nothing of
// its own.
const syncing = new Set<string>()
export function markWillSync(key: string) {
syncing.add(key)
}
export function willSync(key: string): boolean {
return syncing.has(key)
}
export function synced(key: string) {
syncing.delete(key)
}
// The latest failure to read or write the saved Supply run, which the Supply
// run screen shows: a toggle that cannot be kept may be lost on a reload.
let problem: Error | null = null
const listeners = new Set<() => void>()
// keep runs one read or write of the saved Supply run, and returns its
// result, or undefined when it failed: the failure is reported to the Supply
// run screen. Listeners hear of it after the current render.
export function keep<T>(action: () => T): T | undefined {
try {
return action()
} catch (error) {
problem = error instanceof Error ? error : new Error(String(error))
queueMicrotask(() => listeners.forEach((listener) => listener()))
return undefined
}
}
function subscribe(listener: () => void) {
listeners.add(listener)
return () => listeners.delete(listener)
}
// useSavedProblem is the latest failure to read or write the saved Supply
// run, or null.
export function useSavedProblem(): Error | null {
return useSyncExternalStore(subscribe, () => problem)
}
+616
View File
@@ -0,0 +1,616 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock, type MockInstance } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus, type NetworkWindow } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { RETRY_FIRST, SupplyRunSync, TOGGLE_DEADLINE } from './api.ts'
import { SAVED_KEY } from './offline.ts'
import { SupplyRunPage } from './SupplyRunPage.tsx'
const THIS_WEEK = '/api/v1/shopping/2026-10-05'
const ITEM = (id: string) => `/api/v1/shopping/items/${id}`
// Monday's tacos: two items to buy and one the household has.
const ITEMS = [
{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true },
{ id: 'i2', name: 'Corn tortillas', qty: '12', needed: true },
{ id: 'i3', name: 'Taco seasoning', qty: '1 packet', needed: false },
]
// Next week's curry: one item to buy.
const NEXT_ITEMS = [{ id: 'n1', name: 'Chicken thighs', qty: '2 lb', needed: true }]
// The items of each week, by its Monday.
const WEEKS: Record<string, typeof ITEMS> = { '2026-10-05': ITEMS, '2026-10-12': NEXT_ITEMS }
function itemsOf(week: string) {
const items = WEEKS[week]
if (items === undefined) throw new Error(`no week ${week}`)
return items
}
// The browser's online flag; goOffline and goOnline change it and send the
// page's window the event a browser sends.
let onLine: boolean
// The server can be reached: false, every request fails as fetch does when
// the network fails, whatever onLine says.
let reachable: boolean
// The window of the page now open: a reload opens a new one, and the old
// page hears nothing more.
let win: NetworkWindow & EventTarget
function openWindow() {
win = Object.assign(new EventTarget(), {
navigator: {
get onLine() {
return onLine
},
},
}) as unknown as NetworkWindow & EventTarget
return win
}
function goOffline() {
onLine = false
act(() => void win.dispatchEvent(new Event('offline')))
}
function goOnline() {
onLine = true
reachable = true
act(() => void win.dispatchEvent(new Event('online')))
}
// stubServer is the server's Supply runs of this week and next: a PATCH
// marks its item and answers with its week's counts, unless refuse answers
// it instead; when hold is given, the item is marked at once but the answer
// waits for the promise hold returns, or until the request is aborted; holdMe
// holds GET /api/v1/me's answer the same way. This week's counts as archived
// once archive is called.
function stubServer(
refuse: (id: string) => Response | undefined = () => undefined,
hold?: () => Promise<void>,
holdMe?: () => Promise<void>,
) {
const needed = new Map(Object.values(WEEKS).flatMap((items) => items.map((item) => [item.id, item.needed] as const)))
let status = 'current'
// How many PATCHes are unanswered now, and the most there have been at once.
let inFlight = 0
let mostInFlight = 0
// How many GET /api/v1/me requests have been answered.
let meAnswered = 0
const counts = (week: string) => {
const marks = itemsOf(week).map((item) => needed.get(item.id)!)
return { needed_count: marks.filter(Boolean).length, total_count: marks.length }
}
const run = (week: string) => ({
weekStart: week,
label: week === '2026-10-05' ? 'Week 41 · Oct 5 – 11' : 'Week 42 · Oct 12 – 18',
title: null,
status: week === '2026-10-05' ? status : 'current',
sent_to_chat_at: null,
...counts(week),
groups: [
{
dinner: { id: week === '2026-10-05' ? 'd0' : 'd1', name: week === '2026-10-05' ? 'Beef tacos' : 'Chicken curry' },
date: week,
items: itemsOf(week).map((item) => ({ ...item, needed: needed.get(item.id)! })),
},
],
})
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
if (!onLine || !reachable) throw new TypeError('Failed to fetch')
const key = `${init?.method ?? 'GET'} ${String(input)}`
if (key === 'GET /api/v1/me') {
await answerAfter(init?.signal, holdMe)
meAnswered++
return jsonResponse(200, ME)
}
if (key === 'GET /api/v1/settings/household') return jsonResponse(200, HOUSEHOLD_SETTINGS)
if (key === `GET ${THIS_WEEK}`) return jsonResponse(200, run('2026-10-05'))
const week = Object.keys(WEEKS).find((w) => itemsOf(w).some((i) => key === `PATCH ${ITEM(i.id)}`))
if (week === undefined) throw new Error(`unexpected fetch: ${key}`)
const id = itemsOf(week).find((i) => key === `PATCH ${ITEM(i.id)}`)!.id
const refusal = refuse(id)
if (refusal !== undefined) return refusal
const body = JSON.parse(String(init?.body)) as { needed: boolean }
needed.set(id, body.needed)
const answer = jsonResponse(200, { id, needed: body.needed, ...counts(week) })
inFlight++
mostInFlight = Math.max(mostInFlight, inFlight)
try {
await answerAfter(init?.signal, hold)
} finally {
inFlight--
}
return answer
})
vi.stubGlobal('fetch', fetchMock)
return {
fetchMock,
run,
mostInFlight: () => mostInFlight,
meAnswered: () => meAnswered,
archive: () => {
status = 'archived'
},
}
}
// answerAfter resolves once the promise hold returns does, at once without
// hold, or rejects with signal's reason once it aborts.
function answerAfter(signal: AbortSignal | null | undefined, hold?: () => Promise<void>) {
return new Promise<void>((resolve, reject) => {
signal?.addEventListener('abort', () => reject(signal.reason as Error))
void (hold?.() ?? Promise.resolve()).then(resolve)
})
}
// held is a hold for stubServer whose answers wait until release is called,
// each one released in turn.
function held() {
const waiting: (() => void)[] = []
return {
hold: () => new Promise<void>((resolve) => waiting.push(resolve)),
waiting: () => waiting.length,
release: () => waiting.shift()!(),
}
}
// mes counts the GET /api/v1/me requests sent.
function mes(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls.filter(([input, init]) => String(input) === '/api/v1/me' && (init?.method ?? 'GET') === 'GET').length
}
// patches lists the PATCHes sent, as "path body".
function patches(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls.filter(([, init]) => init?.method === 'PATCH').map(([input, init]) => `${String(input)} ${String(init?.body)}`)
}
// queue is the toggles saved on this device, as "id needed".
function queue(): string[] {
const saved = localStorage.getItem(SAVED_KEY)
if (saved === null) return []
return (JSON.parse(saved) as { queue: { id: string; needed: boolean }[] }).queue.map((t) => `${t.id} ${t.needed}`)
}
// openPage opens the Supply run screen in a new window, as the app does:
// with SupplyRunSync, and with /me loaded unless the server cannot be
// reached. leave goes to another screen of the same page, and reopen comes
// back to the Supply run screen.
async function openPage() {
const api = createApiClient(fakeLocation('/plan/shopping'), createNetworkStatus(openWindow()), createVerifiedSession(localStorage))
if (onLine && reachable) await api.loadMe()
const queryClient = createQueryClient()
const app = (onScreen: boolean) => (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/plan/shopping']}>
<SupplyRunSync />
{onScreen && <SupplyRunPage />}
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>
)
const view = render(app(true))
return { ...view, leave: () => view.rerender(app(false)), reopen: () => view.rerender(app(true)) }
}
// reload ends the page open and its timers, as a reload does: a toggle it
// was waiting to try again is never tried, by it, again.
function reload(page: { unmount: () => void }) {
page.unmount()
vi.clearAllTimers()
}
async function advance(ms: number) {
await act(() => vi.advanceTimersByTimeAsync(ms))
}
// The page's setTimeout, watched: retrySecondTry waits for a timer to be set.
let timers: MockInstance<typeof setTimeout>
// retrySecondTry jumps past the wait before a toggle's second try — RETRY_FIRST
// doubled by its failed first try — once that wait's timer is set, so the jump
// never lands before the timer it is meant to fire.
async function retrySecondTry() {
await waitFor(() => expect(timers).toHaveBeenCalledWith(expect.any(Function), 2 * RETRY_FIRST))
await advance(2 * RETRY_FIRST)
}
function checkbox(name: string) {
return screen.getByRole('checkbox', { name: new RegExp(`^${name}`) })
}
function row(name: string) {
return screen.getByText(name).closest('li')!
}
beforeEach(() => {
onLine = true
reachable = true
mockViewport(390)
// setTimeout moves with real time, and a test can jump past a deadline or
// a wait (advance).
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'], shouldAdvanceTime: true })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
timers = vi.spyOn(globalThis, 'setTimeout')
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
})
describe('offline toggle queue', () => {
it('applies toggles made offline at once, saves them in order and sends them in that order once online', async () => {
const { fetchMock } = stubServer()
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Taco seasoning'))
fireEvent.click(checkbox('Ground beef'))
fireEvent.click(checkbox('Taco seasoning'))
// Applied here, counted, marked to sync and saved in order; nothing sent.
expect(checkbox('Taco seasoning')).not.toBeChecked()
expect(checkbox('Ground beef')).not.toBeChecked()
expect(screen.getByText('1 / 3')).toBeInTheDocument()
expect(within(row('Taco seasoning')).getByText('will sync')).toBeInTheDocument()
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(within(row('Corn tortillas')).queryByText('will sync')).not.toBeInTheDocument()
expect(queue()).toEqual(['i3 true', 'i1 false', 'i3 false'])
expect(patches(fetchMock)).toEqual([])
goOnline()
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i1')} {"needed":false}`,
`${ITEM('i3')} {"needed":false}`,
])
expect(screen.getByText('1 / 3')).toBeInTheDocument()
expect(checkbox('Taco seasoning')).not.toBeChecked()
expect(checkbox('Ground beef')).not.toBeChecked()
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('queues a toggle whose PATCH fails with a network error and sends it again once back online', async () => {
const { fetchMock } = stubServer()
await openPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
await waitFor(() => expect(queue()).toEqual([]))
expect(screen.getByText('3 / 3')).toBeInTheDocument()
// The browser still says online, but the server cannot be reached.
reachable = false
fireEvent.click(checkbox('Corn tortillas'))
expect(await within(row('Corn tortillas')).findByText('will sync')).toBeInTheDocument()
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i2')} {"needed":false}`])
expect(checkbox('Corn tortillas')).not.toBeChecked()
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(queue()).toEqual(['i2 false'])
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
goOnline()
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i2')} {"needed":false}`, `${ITEM('i2')} {"needed":false}`])
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
})
it('opens the saved list after a reload offline and replays the saved toggles in order once online', async () => {
const { fetchMock } = stubServer()
const first = await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Taco seasoning'))
fireEvent.click(checkbox('Ground beef'))
expect(queue()).toEqual(['i3 true', 'i1 false'])
// Reload while offline: the settings, /me and the list cannot be read.
reload(first)
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
expect(checkbox('Taco seasoning')).toBeChecked()
expect(checkbox('Ground beef')).not.toBeChecked()
expect(within(row('Taco seasoning')).getByText('will sync')).toBeInTheDocument()
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(patches(fetchMock)).toEqual([])
goOnline()
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`])
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(checkbox('Taco seasoning')).toBeChecked()
expect(checkbox('Ground beef')).not.toBeChecked()
})
it('discards a queued toggle the server refuses because the list was archived, puts the count back and says so', async () => {
const server = stubServer(() => errorResponse(409, 'archived'))
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Taco seasoning'))
expect(screen.getByText('3 / 3')).toBeInTheDocument()
server.archive()
goOnline()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. archived message")
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
expect(queue()).toEqual([])
// Sent once, never again.
goOnline()
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`]))
})
it('does not queue a toggle the server refuses: it is put back and says why', async () => {
const server = stubServer(() => errorResponse(400, 'invalid'))
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Ground beef'))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't mark Ground beef “have it”. invalid message")
expect(checkbox('Ground beef')).toBeChecked()
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
expect(queue()).toEqual([])
goOffline()
goOnline()
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i1')} {"needed":false}`]))
})
})
describe('offline toggle queue, replay', () => {
it('replays a queue spanning two weeks strictly in order, one PATCH at a time, a toggle made meanwhile after it', async () => {
const answers = held()
const server = stubServer(undefined, answers.hold)
const queued = [
{ key: 'k1', weekStart: '2026-10-05', id: 'i3', needed: true },
{ key: 'k2', weekStart: '2026-10-12', id: 'n1', needed: false },
{ key: 'k3', weekStart: '2026-10-05', id: 'i1', needed: false },
]
const runs = { '2026-10-05': server.run('2026-10-05'), '2026-10-12': server.run('2026-10-12') }
localStorage.setItem(SAVED_KEY, JSON.stringify({ schedule: null, runs, queue: queued }))
await openPage()
await waitFor(() => expect(answers.waiting()).toBe(1))
fireEvent.click(await screen.findByRole('checkbox', { name: 'Corn tortillas' }))
for (let sent = 1; sent <= 4; sent++) {
await waitFor(() => expect(patches(server.fetchMock)).toHaveLength(sent))
// The next one is not sent before this one is answered.
await new Promise((resolve) => setTimeout(resolve, 20))
expect(patches(server.fetchMock)).toHaveLength(sent)
act(() => answers.release())
}
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
`${ITEM('n1')} {"needed":false}`,
`${ITEM('i1')} {"needed":false}`,
`${ITEM('i2')} {"needed":false}`,
])
expect(server.mostInFlight()).toBe(1)
await waitFor(() => expect(screen.getByText('1 / 3')).toBeInTheDocument())
})
it('replays at least once: a toggle the server applied before a reload is sent again and leaves the item and count the same', async () => {
const answers = held()
const server = stubServer(undefined, answers.hold)
const first = await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
// The server applied it; the page reloads before its answer arrives.
await waitFor(() => expect(answers.waiting()).toBe(1))
expect(queue()).toEqual(['i3 true'])
reload(first)
act(() => answers.release())
await openPage()
await waitFor(() => expect(answers.waiting()).toBe(1))
act(() => answers.release())
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`])
await waitFor(() => expect(screen.getByText('3 / 3')).toBeInTheDocument())
expect(checkbox('Taco seasoning')).toBeChecked()
expect(server.run('2026-10-05')).toMatchObject({ needed_count: 3, total_count: 3 })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('keeps "will sync" on a queued toggle until its delayed replay is answered', async () => {
const answers = held()
stubServer(undefined, answers.hold)
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Ground beef'))
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
goOnline()
await waitFor(() => expect(answers.waiting()).toBe(1))
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(queue()).toEqual(['i1 false'])
act(() => answers.release())
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(queue()).toEqual([])
expect(screen.getByText('1 / 3')).toBeInTheDocument()
})
it('aborts a PATCH that never answers at its deadline, keeps it pending and tries it again before the toggles after it', async () => {
let tries = 0
// The first PATCH never answers; every later one answers at once.
const server = stubServer(undefined, () => (++tries === 1 ? new Promise<void>(() => {}) : Promise.resolve()))
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
await waitFor(() => expect(patches(server.fetchMock)).toHaveLength(1))
fireEvent.click(checkbox('Ground beef'))
expect(queue()).toEqual(['i3 true', 'i1 false'])
// Past the deadline the hung PATCH is aborted; both still wait, in order.
await advance(TOGGLE_DEADLINE)
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument()
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(patches(server.fetchMock)).toHaveLength(1)
expect(queue()).toEqual(['i3 true', 'i1 false'])
await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i1')} {"needed":false}`,
])
expect(server.mostInFlight()).toBe(1)
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('abandons a GET /api/v1/me that never answers at its deadline and loads it afresh before the toggles after it', async () => {
let tries = 0
// The page loaded /me; the first toggle loads it again, and that /me never
// answers. Every later /me answers at once.
const server = stubServer(undefined, undefined, () => (++tries === 2 ? new Promise<void>(() => {}) : Promise.resolve()))
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
await waitFor(() => expect(tries).toBe(2))
fireEvent.click(checkbox('Ground beef'))
expect(queue()).toEqual(['i3 true', 'i1 false'])
// Past the deadline the hung /me is abandoned; both still wait, in order.
await advance(TOGGLE_DEADLINE)
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument()
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(patches(server.fetchMock)).toEqual([])
// The next try sends a fresh /me, which the server answers.
await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([]))
expect(mes(server.fetchMock)).toBe(3)
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`])
expect(server.mostInFlight()).toBe(1)
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('abandons the /me refresh of a csrf 403 that never answers at its deadline and refreshes afresh on the next try', async () => {
let tries = 0
// The session's token changed: each PATCH is refused for it until a /me
// after the first refusal has answered. The first such refresh never
// answers.
let refusedAt: number | null = null
const server = stubServer(
() => {
if (refusedAt !== null && server.meAnswered() > refusedAt) return undefined
refusedAt ??= server.meAnswered()
return errorResponse(403, 'csrf')
},
undefined,
() => (refusedAt !== null && ++tries === 1 ? new Promise<void>(() => {}) : Promise.resolve()),
)
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
await waitFor(() => expect(tries).toBe(1))
fireEvent.click(checkbox('Ground beef'))
expect(patches(server.fetchMock)).toHaveLength(1)
// Past the deadline the hung refresh is abandoned; both still wait, in order.
await advance(TOGGLE_DEADLINE)
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument()
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(queue()).toEqual(['i3 true', 'i1 false'])
// The next try is refused again and sends a fresh refresh, which answers.
await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i1')} {"needed":false}`,
])
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('tries a toggle again once the server is back, with no online event from the browser', async () => {
const { fetchMock } = stubServer()
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
// The browser says online throughout; the server cannot be reached, then can.
reachable = false
fireEvent.click(checkbox('Corn tortillas'))
expect(await within(row('Corn tortillas')).findByText('will sync')).toBeInTheDocument()
reachable = true
expect(patches(fetchMock)).toEqual([])
// Nothing says the server is back: only the wait's timer tries it again.
await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([]))
// The first try failed at GET /api/v1/me, before its PATCH.
expect(patches(fetchMock)).toEqual([`${ITEM('i2')} {"needed":false}`])
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('1 / 3')).toBeInTheDocument()
})
it('keeps "will sync" when the Supply run screen is left and opened again while the replay is pending', async () => {
const answers = held()
stubServer(undefined, answers.hold)
const page = await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Ground beef'))
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
page.leave()
goOnline()
await waitFor(() => expect(answers.waiting()).toBe(1))
page.reopen()
expect(await within(row('Ground beef')).findByText('will sync')).toBeInTheDocument()
expect(queue()).toEqual(['i1 false'])
act(() => answers.release())
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(queue()).toEqual([])
expect(screen.getByText('1 / 3')).toBeInTheDocument()
})
it('says the saved list is unreadable when it is corrupt offline, without rendering again and again', async () => {
stubServer()
onLine = false
localStorage.setItem(SAVED_KEY, '{corrupt')
const getItem = vi.spyOn(Storage.prototype, 'getItem')
await openPage()
expect(await screen.findByText(/Couldn't load the household settings/)).toBeInTheDocument()
expect(screen.getByText(/can't be kept on this device while offline/)).toBeInTheDocument()
const reads = getItem.mock.calls.filter(([key]) => key === SAVED_KEY).length
await new Promise((resolve) => setTimeout(resolve, 50))
expect(getItem.mock.calls.filter(([key]) => key === SAVED_KEY)).toHaveLength(reads)
})
})