From cb451baed4fe52b8b7ae423b49d45b0875699d8c Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Thu, 8 Oct 2026 15:16:51 -0400 Subject: [PATCH] Feature 17: Shopping List and Supply Archive (#140) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- README.md | 64 +- cmd/server/main.go | 22 +- deploy/.env.example | 41 +- deploy/RUNBOOK.md | 1417 ----------------- deploy/compose.dev.yaml | 9 +- deploy/compose.yaml | 22 +- deploy/systemd/mealquest-firewall.service | 5 +- deploy/systemd/mealquest-media-backup.service | 7 +- deploy/systemd/mealquest-media-backup.sh | 5 +- docs/ARCHITECTURE.md | 98 +- internal/ai/skills/archive_title.go | 148 ++ internal/ai/skills/archive_title_test.go | 170 ++ .../ai/skills/prompts/archive_title.v1.tmpl | 22 + internal/ai/skills/prompts/checksums.txt | 1 + .../archive_title_valid.system.golden | 33 + .../testdata/archive_title_valid.user.golden | 8 + internal/db/models.go | 3 +- internal/db/querier.go | 37 + internal/db/shopping.sql.go | 465 ++++++ internal/dinners/grounding.go | 7 + internal/plan/push.go | 14 +- internal/plan/push_test.go | 62 +- internal/server/aisettings_test.go | 2 +- internal/server/dinners_test.go | 2 +- internal/server/google_test.go | 2 +- internal/server/login_test.go | 2 +- internal/server/media_test.go | 2 +- internal/server/origin_test.go | 2 +- internal/server/plans.go | 62 +- internal/server/plans_test.go | 18 +- internal/server/recipesearch_test.go | 2 +- internal/server/server.go | 19 +- internal/server/server_test.go | 53 +- internal/server/settings_test.go | 2 +- internal/server/shopping.go | 262 +++ internal/server/shopping_test.go | 789 +++++++++ internal/server/suggestions_test.go | 2 +- internal/shopping/archive.go | 232 +++ internal/shopping/archive_test.go | 477 ++++++ internal/shopping/export_test.go | 6 + internal/shopping/shopping.go | 732 +++++++++ internal/shopping/shopping_test.go | 823 ++++++++++ internal/shopping/title_test.go | 313 ++++ internal/store/migrate_test.go | 136 +- internal/store/repository_test.go | 6 +- internal/store/schema_test.go | 1 + migrations/0016_shopping_list_untitled.sql | 81 + migrations/queries/shopping.sql | 104 ++ web/src/App.test.tsx | 38 + web/src/App.tsx | 6 + web/src/AuthBootstrap.tsx | 2 +- web/src/api/client.test.ts | 72 + web/src/api/client.ts | 74 +- web/src/plan/PlanPage.module.css | 29 +- web/src/plan/PlanPage.test.tsx | 459 +++++- web/src/plan/PlanPage.tsx | 106 +- web/src/plan/api.test.tsx | 56 +- web/src/plan/api.ts | 131 +- web/src/shopping/SupplyArchivePage.module.css | 144 ++ web/src/shopping/SupplyArchivePage.test.tsx | 422 +++++ web/src/shopping/SupplyArchivePage.tsx | 183 +++ web/src/shopping/SupplyRunPage.module.css | 274 ++++ web/src/shopping/SupplyRunPage.test.tsx | 491 ++++++ web/src/shopping/SupplyRunPage.tsx | 289 ++++ web/src/shopping/api.ts | 579 +++++++ web/src/shopping/offline.ts | 155 ++ web/src/shopping/offlineQueue.test.tsx | 616 +++++++ 67 files changed, 9252 insertions(+), 1666 deletions(-) delete mode 100644 deploy/RUNBOOK.md create mode 100644 internal/ai/skills/archive_title.go create mode 100644 internal/ai/skills/archive_title_test.go create mode 100644 internal/ai/skills/prompts/archive_title.v1.tmpl create mode 100644 internal/ai/skills/testdata/archive_title_valid.system.golden create mode 100644 internal/ai/skills/testdata/archive_title_valid.user.golden create mode 100644 internal/db/shopping.sql.go create mode 100644 internal/server/shopping.go create mode 100644 internal/server/shopping_test.go create mode 100644 internal/shopping/archive.go create mode 100644 internal/shopping/archive_test.go create mode 100644 internal/shopping/export_test.go create mode 100644 internal/shopping/shopping.go create mode 100644 internal/shopping/shopping_test.go create mode 100644 internal/shopping/title_test.go create mode 100644 migrations/0016_shopping_list_untitled.sql create mode 100644 migrations/queries/shopping.sql create mode 100644 web/src/shopping/SupplyArchivePage.module.css create mode 100644 web/src/shopping/SupplyArchivePage.test.tsx create mode 100644 web/src/shopping/SupplyArchivePage.tsx create mode 100644 web/src/shopping/SupplyRunPage.module.css create mode 100644 web/src/shopping/SupplyRunPage.test.tsx create mode 100644 web/src/shopping/SupplyRunPage.tsx create mode 100644 web/src/shopping/api.ts create mode 100644 web/src/shopping/offline.ts create mode 100644 web/src/shopping/offlineQueue.test.tsx diff --git a/README.md b/README.md index 97d7c82..1f60a80 100644 --- a/README.md +++ b/README.md @@ -11,61 +11,14 @@ single Go service with the React PWA embedded in the binary. > `/health`, embedded PWA skeleton, Makefile, Dockerfile, CI). The planned > application is described in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). -## Quick start +## Running it locally -Requirements: Go 1.25 and Node.js `^22.22.2 || ^24.15.0 || >=26.0.0` — that -is, 22.22.2 or later on the 22 line, 24.15.0 or later on the 24 line, or 26.0.0 -or later. Node.js 23, 24.0–24.14 and 25 are not supported: this is the -`engines` range of jsdom in the committed lockfile, the strictest of all the -web dependencies. - -```sh -(cd web && npm ci) -mkdir -p ~/.mealquest-dev # outside the repository: it holds a secret -chmod 700 ~/.mealquest-dev -head -c 32 /dev/urandom | base64 > ~/.mealquest-dev/secret_key -chmod 600 ~/.mealquest-dev/secret_key -export MEALQUEST_HTTP_ADDR=:8080 -export MEALQUEST_PUBLIC_URL=http://localhost:5173 -export MEALQUEST_DATA_DIR="$HOME/.mealquest-dev" -export MEALQUEST_SHUTDOWN_TIMEOUT=10s -export MEALQUEST_SECRET_KEY_FILE="$HOME/.mealquest-dev/secret_key" -# Authentik OAuth2/OpenID provider for MealQuest (redirect URI: -# $MEALQUEST_PUBLIC_URL/auth/callback). Save its client secret to the file. -export MEALQUEST_OIDC_ISSUER=https://auth.example.com/application/o/mealquest/ -export MEALQUEST_OIDC_CLIENT_ID=mealquest -export MEALQUEST_OIDC_CLIENT_SECRET_FILE="$HOME/.mealquest-dev/oidc_client_secret" -export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin -# Optional: sessions end after MEALQUEST_SESSION_IDLE unused (default 720h) -# and always MEALQUEST_SESSION_MAX after sign-in (default 4320h). -# export MEALQUEST_SESSION_IDLE=720h -# export MEALQUEST_SESSION_MAX=4320h -# Optional: comma-separated IPs/CIDRs of reverse proxies whose -# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset -# trusts no peer, which is right for local development. -# export MEALQUEST_TRUSTED_PROXY=10.60.1.33 -# Optional: a SearXNG instance the Settings screen suggests as the recipe -# search URL until one is saved. -# export MEALQUEST_SEARXNG_URL=http://localhost:8888 -export MEALQUEST_DEFAULT_TZ=America/New_York -make dev -``` - -`make dev` runs the Go server on `:8080` and the Vite dev server on `:5173` -(which proxies `/api`, `/auth` and `/media` to `:8080`). Open -http://localhost:5173. Configuration is environment-only and fails fast: -every variable above is required except the commented-out session -lifetimes (when set they must be positive, with MAX at least IDLE), -trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL -(an absolute `http://` or `https://` URL without credentials, a query or a -fragment) and `MEALQUEST_DEFAULT_TZ`, and -secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the -endpoints the issuer advertises must be `https://`; plain `http://` is -accepted only for `localhost`, `127.0.0.1` and `[::1]`. -`MEALQUEST_DEFAULT_TZ` is the IANA zone the household settings are created -with on first boot: until those settings exist, boot fails unless it is a -valid IANA zone. Later boots ignore it, set or not (change the zone in -household settings instead). +The developer setup (prerequisites, Authentik and Google clients, the +`MEALQUEST_*` environment, `make dev` and the optional SearXNG sidecar) is +runbook information and lives in CTXD: project `mealquest`, file +`RUNBOOKS.MD`, section "Local development". Configuration is read only from +the environment, and the service refuses to start, naming every missing or +invalid variable. ## Common commands @@ -78,7 +31,8 @@ household settings instead). ## Where to read next -- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, conventions. +- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, the quality + floor every change must pass, repository layout, conventions. - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — architecture of record. - [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md) — product requirements. - [design/MealQuest UI Designs/design_handoff_mealquest/README.md](design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md) — UI handoff. diff --git a/cmd/server/main.go b/cmd/server/main.go index 51b105b..f1307ce 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -28,6 +28,7 @@ import ( "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/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/suggest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" @@ -132,6 +133,11 @@ func run(cfg config.Config, logger *slog.Logger) error { 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) if err != nil { return err @@ -148,7 +154,7 @@ func run(cfg config.Config, logger *slog.Logger) error { 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 { 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 // starts (dinners.NewService registers dinners.KindGroundDinner and // suggest.NewService suggest.KindSuggestionBatch, plan.NewService -// plan.KindDraftWeek). +// plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun). func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) { builtins, err := jobs.RegisterBuiltins(runner) 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 // so no credential-bearing URL component can reach the log. func logListening(logger *slog.Logger, cfg config.Config) { diff --git a/deploy/.env.example b/deploy/.env.example index 6262452..fc2b906 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,20 +1,15 @@ -# MealQuest production environment. Copy to deploy/.env (never committed) -# and replace the placeholders. The service fails fast: a missing or invalid -# required variable stops it at boot with the variable named. +# MealQuest production environment: the template for deploy/.env (never +# committed). The service fails fast: a missing or invalid required variable +# stops it at boot with the variable named. Creating deploy/.env: CTXD +# project mealquest, RUNBOOKS.MD "Stack and first deploy" > "3. Environment +# file". # # Secrets are never set here. They live in deploy/secrets/ (never committed), -# one value per file, owned by the container user and readable only by it: -# -# mkdir -p deploy/secrets && chmod 700 deploy/secrets -# head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key -# printf '%s\n' '' > deploy/secrets/oidc_client_secret -# printf '%s\n' '' > deploy/secrets/google_client_secret -# sudo chown 10001:10001 deploy/secrets/* && sudo chmod 600 deploy/secrets/* -# -# SearXNG's secret key is the exception: compose reads it itself, so it -# stays owned by you (mode 0600). Create it after the chown above: -# -# (umask 077; printf 'SEARXNG_SECRET=%s\n' "$(openssl rand -hex 32)" > deploy/secrets/searxng.env) +# one value per file, owned by the container user 10001:10001 with mode +# 0600: secret_key, oidc_client_secret and google_client_secret. SearXNG's +# searxng.env is the exception: compose reads it itself, so it is owned by +# the host account that runs compose, mode 0600. Creating them: CTXD project +# mealquest, RUNBOOKS.MD "Stack and first deploy" > "4. Secrets". # # deploy/compose.yaml mounts them at /run/secrets/ and points the # *_FILE variables at them. @@ -42,8 +37,7 @@ MEALQUEST_TRUSTED_PROXY=10.60.1.33 # IANA zone the household settings are created with on first boot. Required # until those settings exist; ignored afterwards (change the zone in -# household settings instead). Confirm the household's zone before the first -# deploy. +# household settings instead). MEALQUEST_DEFAULT_TZ=America/New_York # --- Authentik OIDC ---------------------------------------------------------- @@ -57,10 +51,10 @@ MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin # --- Google Calendar (Push to Calendar) ------------------------------------ -# Client ID of the Google OAuth web client (deploy/RUNBOOK.md "Google -# Calendar OAuth client"). Required: the service will not start without it -# and the client secret file. Its authorized redirect URI is -# /api/v1/integrations/google/callback. +# Client ID of the Google OAuth web client (CTXD project mealquest, +# RUNBOOKS.MD "Google Calendar OAuth client"). Required: the service will +# not start without it and the client secret file. Its authorized redirect +# URI is /api/v1/integrations/google/callback. MEALQUEST_GOOGLE_CLIENT_ID=.apps.googleusercontent.com # Optional session lifetimes. A session ends after IDLE unused and always MAX @@ -82,13 +76,14 @@ MEALQUEST_SESSION_MAX=4320h # The SearXNG recipe-search sidecar on the compose network. The Settings # screen pre-fills it as the SearXNG URL until one is saved; the saved # provider and base URL come from PATCH /api/v1/settings/recipe-search. -# See deploy/RUNBOOK.md "Recipe search (SearXNG)". +# See CTXD project mealquest, RUNBOOKS.MD "Recipe search (SearXNG)". # --- Planned; not read by the service yet ------------------------------------- # # Added to deploy/compose.yaml and this file by the features that use them. # -# Web Push (Feature 19). Generate the key pair with cmd/vapidgen. +# Web Push (Feature 19). Key pair: CTXD project mealquest, RUNBOOKS.MD +# "VAPID keys (Web Push)". # MEALQUEST_VAPID_PUBLIC_KEY= # MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key # MEALQUEST_VAPID_SUBJECT=mailto: diff --git a/deploy/RUNBOOK.md b/deploy/RUNBOOK.md deleted file mode 100644 index 61137ae..0000000 --- a/deploy/RUNBOOK.md +++ /dev/null @@ -1,1417 +0,0 @@ -# MealQuest deployment runbook - -How MealQuest is deployed on office-srvr (10.60.1.14), how it is reached -through Authentik, Traefik and DNS, how it is updated and rolled back, how -its data is backed up and restored, and how its secrets are rotated. Values -are the ones in use on 2026-10-05. Secrets themselves are never written -here. - -Paths used below: - -| Name | Path | -| --- | --- | -| Checkout on the host | `/home/overseer/mealquest` | -| Data volume (`mealquest-data`) on the host | `/var/lib/docker/volumes/mealquest-data/_data` | -| Same volume inside the containers | `/data` | -| Database | `mealquest.db` (+ `mealquest.db-wal`, `mealquest.db-shm` while open) | -| Media | `media/` in the data volume | -| Backup target (NAS, over SSH) | `joshua@10.60.1.50:/volume1/homes/Joshua/mealquest-backups` | -| Same target over SFTP (Synology chroot) | `/homes/Joshua/mealquest-backups` | - -## Prerequisites - -On office-srvr (10.60.1.14), as recorded on 2026-10-05: - -| Item | Value | -| --- | --- | -| OS | Ubuntu 26.04.1 LTS | -| Docker Engine / Compose plugin | 29.8.2 / 5.6.0 | -| git, rsync, iptables | 2.53.0, 3.4.1, 1.8.11 (nf_tables) | -| Account | `overseer`, with sudo | -| Network | LAN reachability to Traefik (10.60.1.33), Authentik (`auth.cubecraftlabs.com`) and the NAS (10.60.1.50, SSH) | - -Elsewhere: - -- Admin access to Authentik at . -- Owner access to the Google Cloud project that holds MealQuest's OAuth - client (see "Google Calendar OAuth client"). -- SSH access to the Traefik host 10.60.1.33. -- Cloudflare access for the `cubecraftlabs.com` zone, and admin access to - both Pi-holes (10.60.1.101 `dns.ccl`, 10.60.1.85 `dns2.ccl`). -- A NAS account `joshua` on 10.60.1.50 with the backup directory - `/volume1/homes/Joshua/mealquest-backups` (see "Backups"). -- A Gitea token able to read `Maniacs_Mansion/MealQuest`, for the clone. - -## Stack and first deploy - -The compose stack is `deploy/compose.yaml`: the `mealquest` service (built -from the repository `Dockerfile`, runs as uid/gid 10001, listens on 8080 in -the container), the `litestream` service and the `searxng` recipe-search -sidecar (see "Recipe search (SearXNG)"). Data lives in the named volume -`mealquest-data`. - -### 1. Choose and check the port - -The container's 8080 is published on `10.60.1.14:8090`. Ports 8080 and 8081 -on this host belong to NestQuest, so 8090 was chosen. Before the first -deploy it was checked free with: - -```sh -ss -ltnp | grep 8090 # printed nothing -``` - -After the deploy the same host shows NestQuest on 8080/8081 and MealQuest -on 8090: - -``` -LISTEN 0 4096 10.60.1.14:8080 0.0.0.0:* -LISTEN 0 4096 10.60.1.14:8081 0.0.0.0:* -LISTEN 0 4096 10.60.1.14:8090 0.0.0.0:* -``` - -The published host port is fixed in `deploy/compose.yaml`, in -`deploy/systemd/mealquest-firewall.sh` and in the Traefik route; change all -three together. - -### 2. Clone - -The checkout lives at `/home/overseer/mealquest`, next to NestQuest's -`~/nestquest` (not `/opt/mealquest`). Clone over HTTPS with a Gitea token, -then reset the remote to the clean URL so the token is not stored in -`.git/config`: - -```sh -cd /home/overseer -git clone https://:@code.cubecraftlabs.com/Maniacs_Mansion/MealQuest.git mealquest -cd mealquest -git remote set-url origin https://code.cubecraftlabs.com/Maniacs_Mansion/MealQuest.git -git remote -v # must show no token -git checkout main -``` - -### 3. Environment file - -`deploy/.env` (git-ignored) is copied from `deploy/.env.example` and is -mode 0600, owned by `overseer`: - -```sh -cp deploy/.env.example deploy/.env -chmod 600 deploy/.env -``` - -Then edit `deploy/.env` to the values below. In particular -**`MEALQUEST_BIND_ADDRESS` must be changed**: `.env.example` ships -`127.0.0.1` (a dev machine), and left as is the service listens only on -the host's loopback, which Traefik cannot reach. Set it to the LAN address -and check: - -```sh -sed -i 's/^MEALQUEST_BIND_ADDRESS=.*/MEALQUEST_BIND_ADDRESS=10.60.1.14/' deploy/.env -grep '^MEALQUEST_BIND_ADDRESS=' deploy/.env # MEALQUEST_BIND_ADDRESS=10.60.1.14 -docker compose -f deploy/compose.yaml config | grep -A4 'ports:' -# host_ip: 10.60.1.14, target: 8080, published: "8090" -``` - -After step 5 the listener is `10.60.1.14:8090` (container port 8080): - -```sh -docker compose -f deploy/compose.yaml port mealquest 8080 # 10.60.1.14:8090 -sudo ss -ltnp 'sport = :8090' # 10.60.1.14:8090 only, no 0.0.0.0 or 127.0.0.1 -``` - -Values in use: - -| Variable | Value | -| --- | --- | -| `MEALQUEST_BIND_ADDRESS` | `10.60.1.14` | -| `MEALQUEST_PUBLIC_URL` | `https://mealquest.cubecraftlabs.com` | -| `MEALQUEST_SHUTDOWN_TIMEOUT` | `10s` | -| `MEALQUEST_TRUSTED_PROXY` | `10.60.1.33` | -| `MEALQUEST_DEFAULT_TZ` | `America/New_York` | -| `MEALQUEST_OIDC_ISSUER` | `https://auth.cubecraftlabs.com/application/o/mealquest/` | -| `MEALQUEST_OIDC_CLIENT_ID` | `i6zraqmWsF552b8nmte070LXz0SI5pRdArBxFxeq` (not a secret) | -| `MEALQUEST_OIDC_ALLOWED_GROUPS` | `mealquest-admin` | -| `MEALQUEST_GOOGLE_CLIENT_ID` | Client ID of the Google OAuth web client (not a secret; see "Google Calendar OAuth client") | -| `MEALQUEST_SESSION_IDLE` / `MEALQUEST_SESSION_MAX` | `720h` / `4320h` | - -`MEALQUEST_HTTP_ADDR`, `MEALQUEST_DATA_DIR` and the `*_FILE` variables are -set by `deploy/compose.yaml` and must not appear in `deploy/.env`. - -### 4. Secrets - -`deploy/secrets/` (git-ignored) is mode 0700, owned by `overseer`. Each -file holds one value, is owned by `10001:10001` (host user `mealquest`) and -is mode 0600. Compose bind-mounts file secrets with their host owner and -mode, so any other owner or mode stops the container reading them and the -service refuses to start. - -| File | Content | Mounted at | -| --- | --- | --- | -| `secret_key` | base64 of 32 random bytes; encrypts stored secrets (AES-256-GCM) | `/run/secrets/secret_key` | -| `oidc_client_secret` | Client Secret of the Authentik provider `mealquest` | `/run/secrets/oidc_client_secret` | -| `google_client_secret` | Client secret of the Google OAuth web client | `/run/secrets/google_client_secret` | -| `litestream_key` | dedicated SSH private key for the NAS (see "Backups") | `/etc/litestream/sftp_key` | -| `searxng.env` | `SEARXNG_SECRET=<64 hex characters>`; SearXNG's secret key | not mounted; compose passes it as an environment variable | - -`searxng.env` is the one exception to the owner rule: compose itself reads -it (as `env_file`), so it is owned by `overseer`, mode 0600. - -```sh -mkdir -p deploy/secrets && chmod 700 deploy/secrets -head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key -printf '%s\n' '' > deploy/secrets/oidc_client_secret -printf '%s\n' '' > deploy/secrets/google_client_secret -# litestream_key: see "Backups" > "Database — Litestream" -sudo chown 10001:10001 deploy/secrets/secret_key deploy/secrets/oidc_client_secret deploy/secrets/google_client_secret deploy/secrets/litestream_key -sudo chmod 600 deploy/secrets/* -(umask 077; printf 'SEARXNG_SECRET=%s\n' "$(openssl rand -hex 32)" > deploy/secrets/searxng.env) -sudo ls -la deploy/secrets # four files mealquest mealquest, searxng.env overseer overseer; all -rw------- -``` - -The `mealquest` host user (uid/gid 10001) is created in "Media — nightly -rsync"; until it exists `ls` shows the numeric ids. - -### 5. Firewall, then the stack - -Install the firewall unit and the stack unit first (see "Host firewall" -and "Stack unit"), so port 8090 is never open to the LAN. Create the media -directory (see "Media — nightly rsync"), then build and start: - -```sh -docker compose -f deploy/compose.yaml build -sudo systemctl enable --now mealquest # starts mealquest-firewall first -docker compose -f deploy/compose.yaml ps # mealquest healthy, litestream up, searxng healthy -curl -fsS http://10.60.1.14:8090/health -curl -s -o /dev/null -w "%{http_code}\n" https://mealquest.cubecraftlabs.com/health # 200 -``` - -### Host checkout state on 2026-10-05 - -The first deploy ran before Feature 07 reached `main`. The checkout is at -`main` `9cddbbb` and the Feature 07 files under `deploy/` were copied in -from the feature branch: `deploy/compose.yaml` and `deploy/.env.example` -are listed in `.git/info/exclude`, the rest show as untracked. Before the -first routine deploy of a `main` that contains `deploy/`, remove those -`.git/info/exclude` lines and the copied files (keep `deploy/.env` and -`deploy/secrets/`), or `git checkout` refuses to overwrite them: - -```sh -cd /home/overseer/mealquest -git status --porcelain --untracked-files=all deploy # the copied files -sed -i '/^deploy\/\(compose.yaml\|.env.example\)$/d' .git/info/exclude -git clean -n deploy # dry run: must not list .env or secrets/ (both git-ignored) -git clean -f deploy -``` - -On 2026-10-06 `deploy/compose.yaml` was replaced and -`deploy/compose.dev.yaml` and `deploy/searxng/settings.yml` were copied in -from the Feature 10 branch (task 641f468b) to start the SearXNG sidecar; -they too must go before that checkout (`git clean -n -d deploy` lists -them). - -Then follow "Routine deploy"; the checkout brings the tracked copies back. - -## Authentik - -Sign-in is OpenID Connect against Authentik at -. - -| Object | Setting | -| --- | --- | -| Provider | OAuth2/OpenID provider `mealquest`, client type **Confidential** | -| Redirect URI | `https://mealquest.cubecraftlabs.com/auth/callback` (strict) | -| Signing | RS256, with the instance's signing key | -| Scopes | MealQuest requests `openid profile email groups offline_access`; the provider's scope mappings include openid, profile, email and offline_access, and the `groups` claim is delivered by a property mapping (the default profile mapping carries `groups`) | -| Application | name MealQuest, slug `mealquest`, provider `mealquest` | -| Group | `mealquest-admin`, bound to the application; household members are added to it | -| Client ID | `i6zraqmWsF552b8nmte070LXz0SI5pRdArBxFxeq` (not a secret; in `deploy/.env` as `MEALQUEST_OIDC_CLIENT_ID`) | -| Client Secret | in `deploy/secrets/oidc_client_secret` only | - -MealQuest refuses a sign-in unless the user's `groups` claim (from the ID -token, or from userinfo when the token has none) contains a group in -`MEALQUEST_OIDC_ALLOWED_GROUPS`. - -Check discovery from the Docker host — the service discovers the issuer -at boot and will not start if it cannot: - -```sh -curl -s -o /dev/null -w "%{http_code}\n" \ - https://auth.cubecraftlabs.com/application/o/mealquest/.well-known/openid-configuration # 200 -``` - -On 2026-10-05 discovery returned 200 with issuer -`https://auth.cubecraftlabs.com/application/o/mealquest/` and -`id_token_signing_alg_values_supported: ["RS256"]`. - -## Google Calendar OAuth client - -Push to Calendar connects the household's Google account with a Google -OAuth web client. MealQuest calls Google's OAuth and Calendar REST -endpoints directly. **The service will not start without -`MEALQUEST_GOOGLE_CLIENT_ID` and `deploy/secrets/google_client_secret`**, -so create the client before deploying a release that contains Feature 16. - -In the Google Cloud console, in the project that holds MealQuest: - -| Object | Setting | -| --- | --- | -| API | Google Calendar API enabled | -| OAuth consent screen | User type External; app name MealQuest; publishing status **In production** (a client left in Testing gets refresh tokens that expire after 7 days, so Push to Calendar would stop working a week after connecting) | -| Scopes | `https://www.googleapis.com/auth/calendar.events` and `https://www.googleapis.com/auth/calendar.calendarlist.readonly` | -| Credentials | OAuth client ID, application type **Web application**, name `mealquest` | -| Authorized redirect URI | `https://mealquest.cubecraftlabs.com/api/v1/integrations/google/callback` (exactly; it is `MEALQUEST_PUBLIC_URL`'s origin plus that path) | -| Client ID | in `deploy/.env` as `MEALQUEST_GOOGLE_CLIENT_ID` (not a secret) | -| Client secret | in `deploy/secrets/google_client_secret` only | - -The app is unverified, so Google shows an "unverified app" warning on the -consent screen; choose to continue. That is expected for a single-household -deployment. - -To connect, a signed-in household member opens -`https://mealquest.cubecraftlabs.com/api/v1/integrations/google/connect` -(the Settings screen links there once it ships). MealQuest asks for offline -access with consent always prompted, and lands back on -`/settings/calendar` once the refresh token is stored. Disconnecting -(`DELETE /api/v1/integrations/google`) revokes the token at Google and -forgets it. - -## Reverse proxy (Traefik) - -MealQuest is published at by the -shared Traefik instance on 10.60.1.33 (Feature 07, task 0c1dc172). The -route is a dynamic-configuration file that lives on the Traefik host only, -at `/home/overseer/Code/traefik/config/dynamic/mealquest-ccl.yml`. Its exact -contents: - -```yaml -# MealQuest - https://mealquest.cubecraftlabs.com -# Feature 07 task 0c1dc172. Single Go binary (API + embedded PWA) on -# office-srvr 10.60.1.14:8090. HTTP -> HTTPS is handled globally by -# http-conf.yml, so only a websecure router is defined here. -# This file lives on the Traefik host only; the repo copy is recorded in -# deploy/RUNBOOK.md by task 876134bc. -http: - routers: - mealquest: - entryPoints: - - websecure - rule: "Host(`mealquest.cubecraftlabs.com`)" - priority: 100 - middlewares: - - mealquest-headers - service: mealquest - tls: - certResolver: myresolver - domains: - - main: mealquest.cubecraftlabs.com - - services: - mealquest: - loadBalancer: - servers: - - url: "http://10.60.1.14:8090" - - middlewares: - mealquest-headers: - headers: - stsSeconds: 31536000 - stsIncludeSubdomains: true - contentTypeNosniff: true - frameDeny: true -``` - -Traefik watches the dynamic directory, so saving the file loads the route; -the certificate comes from `certResolver: myresolver` (Let's Encrypt, -DNS-01 through Cloudflare). Check from the LAN: - -```sh -curl -s -o /dev/null -w "%{http_code}\n" https://mealquest.cubecraftlabs.com/health # 200 -``` - -## DNS - -| Where | Record | -| --- | --- | -| Cloudflare, zone `cubecraftlabs.com` | `CNAME mealquest.cubecraftlabs.com` → `cubecraftlabs.com`, **proxied = true** (orange cloud). Because it is proxied, public resolvers answer with Cloudflare's anycast addresses, A and AAAA (AAAA on 2026-10-05: `2606:4700:3032::6815:34ed`, `2606:4700:3035::ac43:cd75`), never the origin | -| Pi-hole primary 10.60.1.101 (`dns.ccl`), Local DNS records | `A mealquest.cubecraftlabs.com` → `10.60.1.33` (Traefik) | -| Pi-hole secondary 10.60.1.85 (`dns2.ccl`), Local DNS records | `A mealquest.cubecraftlabs.com` → `10.60.1.33` (Traefik) | - -The local record must be on **both** Pi-holes, or clients using the -secondary go out through Cloudflare. Check: - -```sh -for s in 10.60.1.101 10.60.1.85; do dig +short @$s mealquest.cubecraftlabs.com A; done -# 10.60.1.33 from each -``` - -**AAAA caveat.** The Pi-hole local record is A only, so both Pi-holes still -forward AAAA queries and return Cloudflare's IPv6 addresses -(`2606:4700:3032::6815:34ed`, `2606:4700:3035::ac43:cd75` on 2026-10-05). An -IPv6-capable LAN client may prefer those and reach MealQuest through -Cloudflare instead of directly through Traefik. It still works (Cloudflare -proxies to the public route), but it is not the LAN path. If that matters, -add a local AAAA override or block AAAA for this name on both Pi-holes. - -## Host firewall - -Docker publishes `10.60.1.14:8090` past the host's INPUT rules, so the -port is restricted in the `DOCKER-USER` chain instead: only Traefik -(10.60.1.33) and the host itself (10.60.1.14) may connect; every other -source is dropped. - -Docker DNATs the published port to the container address and port 8080 -**before** `DOCKER-USER`, so a plain `-p tcp --dport 8090` rule never -matches. The rules match the original destination `10.60.1.14:8090` with -conntrack (`--ctorigdst 10.60.1.14 --ctorigdstport 8090 --ctdir ORIGINAL`) -and are tagged with the comment `mealquest-firewall`. The port is never -open while the rules change: if the chain does not already start with -exactly the three rules, the script first inserts the DROP at position 1, -then deletes its other tagged rules and inserts the two RETURN rules above -the DROP. A re-run on a correct chain changes nothing. NestQuest's -8080/8081 rules below them are left untouched. - -| Repository file | Installed as | -| --- | --- | -| `deploy/systemd/mealquest-firewall.sh` | `/usr/local/sbin/mealquest-firewall` (root, 0755) | -| `deploy/systemd/mealquest-firewall.service` | `/etc/systemd/system/mealquest-firewall.service` (root, 0644) | - -The unit is `Type=oneshot`, `RemainAfterExit=yes`, `Requires=` and -`PartOf=docker.service`, and `WantedBy=multi-user.target docker.service`: -it re-applies the rules whenever Docker starts or restarts (Docker -recreates `DOCKER-USER` empty), and a failure fails this unit only. - -Install (done on 10.60.1.14 on 2026-10-05): - -```sh -cd /home/overseer/mealquest -sudo install -o root -g root -m 0755 deploy/systemd/mealquest-firewall.sh /usr/local/sbin/mealquest-firewall -sudo install -o root -g root -m 0644 deploy/systemd/mealquest-firewall.service /etc/systemd/system/ -sudo systemctl daemon-reload -sudo systemctl enable --now mealquest-firewall -``` - -Check: - -```sh -systemctl is-enabled mealquest-firewall; systemctl is-active mealquest-firewall # enabled, active -sudo iptables -S DOCKER-USER -``` - -Expected head of the chain (recorded 2026-10-05; NestQuest's rules follow): - -``` --A DOCKER-USER -s 10.60.1.33/32 -m conntrack --ctorigdst 10.60.1.14 --ctorigdstport 8090 --ctdir ORIGINAL -m comment --comment mealquest-firewall -j RETURN --A DOCKER-USER -s 10.60.1.14/32 -m conntrack --ctorigdst 10.60.1.14 --ctorigdstport 8090 --ctdir ORIGINAL -m comment --comment mealquest-firewall -j RETURN --A DOCKER-USER -m conntrack --ctorigdst 10.60.1.14 --ctorigdstport 8090 --ctdir ORIGINAL -m comment --comment mealquest-firewall -j DROP -``` - -From any other LAN machine, `curl -m 5 http://10.60.1.14:8090/health` must -time out; from 10.60.1.33 and 10.60.1.14 it returns 200. - -## Stack unit - -`deploy/systemd/mealquest.service` owns the compose stack and is the only -way it starts. Both compose services use `restart: "no"`, so Docker never -starts them on its own; the unit has `Requires=` and -`After=mealquest-firewall.service`, so the stack starts only after the -firewall rules are applied, and not at all if that unit fails. It is -`PartOf=docker.service`, so restarting Docker (which recreates -`DOCKER-USER` empty) stops the stack, re-applies the firewall, then starts -the stack again. `ExecStart` is `docker compose … up -d` and `ExecStop` is -`docker compose … down`; neither builds, so build before starting. - -Containers no longer restart after a crash; `systemctl restart mealquest` -brings them back. - -| Repository file | Installed as | -| --- | --- | -| `deploy/systemd/mealquest.service` | `/etc/systemd/system/mealquest.service` (root, 0644) | - -Install (done on 10.60.1.14 on 2026-10-05): - -```sh -cd /home/overseer/mealquest -sudo install -o root -g root -m 0644 deploy/systemd/mealquest.service /etc/systemd/system/ -sudo systemctl daemon-reload -sudo systemctl enable --now mealquest -``` - -Check: - -```sh -systemctl is-enabled mealquest; systemctl is-active mealquest # enabled, active -systemctl show mealquest -p Requires -p After # both list mealquest-firewall.service -``` - -Start, stop and restart the whole stack with `sudo systemctl start|stop|restart -mealquest`, never with `docker compose up` or `down`. Per-service compose -commands (`stop mealquest`, `up -d --force-recreate litestream`) are fine -while the unit is active, because the firewall is already in place. - -## Recipe search (SearXNG) - -The `searxng` service in `deploy/compose.yaml` is a private SearXNG -instance for recipe search. On the compose network it is reachable at -`http://searxng:8080`; that is the address to configure in the app. - -Starting the sidecar does not connect the app to it. The recipe-search -provider and base URL live in the `recipe_search_settings` row and are set -through `GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the -recipe search card on Settings → AI assistant). Until that row exists, -recipe search answers 409 `not_configured`. - -`deploy/compose.yaml` sets `MEALQUEST_SEARXNG_URL=http://searxng:8080`. -The service only suggests it: `GET` returns it as `default_base_url`, and -the recipe search card pre-fills the SearXNG URL with it while no URL is -saved. Nothing is stored until the card is saved, and a saved `base_url` -always wins. A set value must be an absolute `http://` or `https://` URL -without credentials, a query or a fragment, or the service refuses to -start; unset, `default_base_url` is `null`. - -To set it up, open Settings → AI assistant, check that the recipe search -card shows SearXNG with `http://searxng:8080`, select **Save recipe search** -and then **Test**. Or, from a signed-in session (every non-`GET` request -needs the `X-MealQuest-CSRF` header set to `csrfToken` from -`GET /api/v1/me`): - -1. `PATCH /api/v1/settings/recipe-search` with - `{"provider": "searxng", "base_url": "http://searxng:8080"}`. -2. `GET /api/v1/settings/recipe-search` shows `"provider": "searxng"` and - `"base_url": "http://searxng:8080"`. -3. `POST /api/v1/settings/recipe-search/test` searches - "chicken tikka masala" and must answer 200 with - `{"query": "chicken tikka masala", "count": }`. A 502 - `search_failed` carries SearXNG's error. - -| Item | Value | -| --- | --- | -| Image | `searxng/searxng:2026.10.4-d48c4b555`, pinned by digest `sha256:76b0bf285aca014c7191fc4d9234c4bfb358624ac33d8883833d496c059ec072` | -| Settings | `deploy/searxng/settings.yml`, mounted read-only at `/etc/searxng/settings.yml` | -| Secret | `deploy/secrets/searxng.env` (`SEARXNG_SECRET`), see "Secrets" | -| Network | `mealquest` only; **no published port** | -| Health | `wget http://127.0.0.1:8080/healthz` inside the container | - -The settings enable the `json` output format, turn off the public limiter -and bot detection (the only client is the `mealquest` service), and keep -only the general web search engines DuckDuckGo, Brave, Google, Bing, -Mojeek and Startpage. Engines that block the host (CAPTCHA, "too many -requests") are suspended by SearXNG for a while and listed under -`unresponsive_engines` in the JSON; the others still answer. - -Check from inside the app container (the only place it is reachable): - -```sh -docker compose -f deploy/compose.yaml exec mealquest \ - wget -qO- 'http://searxng:8080/search?q=chicken+tikka+masala+recipe&format=json' -docker compose -f deploy/compose.yaml port searxng 8080 # prints ":0": nothing published -``` - -On 2026-10-06 that query returned 17 results. From this host four of the -six engines were blocked (Brave, DuckDuckGo, Google and Startpage were -rate-limited or served a CAPTCHA), so the results came from Bing and -Mojeek. SearXNG retries suspended engines after their suspension ends, so -the set that answers can change. From 10.60.1.85 nothing on 10.60.1.14 answered as SearXNG: -8888 refused the connection and 8080 is NestQuest's API. - -For development, `deploy/compose.dev.yaml` publishes SearXNG on the -dev machine's loopback only (`127.0.0.1:8888`). It is never used on -office-srvr. - -To move to a newer image, pull the new tag, take its digest from -`docker image inspect --format '{{json .RepoDigests}}' searxng/searxng:`, -update both in `deploy/compose.yaml`, then deploy as usual. - -## Routine deploy and rollback - -Production runs `main` (a release); only `overseer` merges `dev` → `main`. -Every deploy builds the image on the host from the checked-out commit. - -### Deploy - -1. Check out and build the release. Nothing restarts yet; production - keeps running the previous SHA. - -```sh -cd /home/overseer/mealquest && -git fetch origin && -git rev-parse --short HEAD && # the SHA being replaced: note it for rollback -git checkout --detach origin/main && # or a release tag -docker compose -f deploy/compose.yaml build -``` - - Each command runs only if the one before it succeeded. If `echo $?` - then prints anything but `0`, stop: nothing has restarted. - -2. **Verify a restorable pre-deploy point — before the restart.** The - restart applies any new migrations, and an older SHA refuses the - upgraded schema, so the only rollback is restoring the database to a - point before the restart (see "Rollback to a previous SHA"). Check that - Litestream is replicating and that its replica restores to the current - data, with the commands from "Database — Litestream", "Restore the - database" and "Count check". First write `/tmp/mqcount.py` and - `/tmp/mqsnap.py` with the `cat` command in "Count check", then: - -```sh -cd /home/overseer/mealquest && ( -set -eu # any failed step ends the verification with a nonzero status -D=/var/lib/docker/volumes/mealquest-data/_data -fail() { echo "$*" >&2; exit 1; } -point= -# On the way out, run both clean-ups whatever happens, keep any failure, -# and print the restore point only if every check and clean-up succeeded. -trap 'st=$? -rm -rf /tmp/mqcheck || st=1 -sudo sh -c "rm -f $D/predeploy-check.db*" || st=1 -if [ "$st" -eq 0 ] && [ -n "$point" ]; then echo "pre-deploy restore point: $point" -else [ "$st" -ne 0 ] || st=1; echo "verification FAILED (status $st): do not restart" >&2; fi -exit "$st"' EXIT -rm -rf /tmp/mqcheck -mkdir /tmp/mqcheck - -# a. Replication is running: litestream up, no errors in its log, and the -# newest LTX file (listed) is from the latest writes. Each command's own -# status is checked: grep exits 0 on a match, 1 on none, 2+ on an error. -id=$(docker compose -f deploy/compose.yaml ps --status running --quiet litestream) || fail "docker compose ps failed" -[ -n "$id" ] || fail "litestream is not running" -docker compose -f deploy/compose.yaml logs --since 24h litestream > /tmp/mqcheck/litestream.log || fail "docker compose logs failed" -gs=0 -grep -i error /tmp/mqcheck/litestream.log > /tmp/mqcheck/litestream.errors || gs=$? -case "$gs" in - 0) cat /tmp/mqcheck/litestream.errors; fail "litestream logged errors in the last 24h";; - 1) ;; # no match: no errors logged - *) fail "the litestream log scan failed (status $gs)";; -esac -docker compose -f deploy/compose.yaml exec -T litestream \ - litestream ltx -config /etc/litestream.yml -level all /data/mealquest.db - -# b. Restore the replica to a scratch file beside the live database (the -# live files are not touched) and move it off the volume; then take a -# consistent snapshot of the live database next to it. -docker compose -f deploy/compose.yaml run --rm --no-deps litestream \ - restore -config /etc/litestream.yml -o /data/predeploy-check.db /data/mealquest.db -sudo sh -c "mv $D/predeploy-check.db* /tmp/mqcheck/" -sudo python3 /tmp/mqsnap.py "$D/mealquest.db" /tmp/mqcheck/live.db -sudo chown -R "$USER" /tmp/mqcheck -sudo ls -ln "$D" # mealquest.db-wal and -shm owned by 10001, not 0: see "Count check" - -# c. Count check both: the restored replica must report integrity ok and -# exactly the live snapshot's counts and content-sha256. -python3 /tmp/mqcount.py /tmp/mqcheck/predeploy-check.db > /tmp/mqcheck/restored.txt -python3 /tmp/mqcount.py /tmp/mqcheck/live.db > /tmp/mqcheck/live.txt -cat /tmp/mqcheck/restored.txt -grep -qx 'integrity ok' /tmp/mqcheck/restored.txt || fail "the restored replica failed integrity_check" -diff /tmp/mqcheck/restored.txt /tmp/mqcheck/live.txt || fail "the restored replica differs from the live snapshot" - -# d. The pre-deploy restore point: now, after every check passed, before -# the restart. It is printed on the way out, once the clean-ups succeed. -point=$(date -u +%Y-%m-%dT%H:%M:%SZ) -) -``` - - The verification runs in a subshell that stops at the first failed - step. On the way out it always attempts both clean-ups — removing - `/tmp/mqcheck` and any `predeploy-check.db` left on the volume — and a - failed clean-up fails the verification too. The restore point is - printed last, and only when every check and both clean-ups succeeded. - Read it like this: - - - `pre-deploy restore point:` followed by a UTC time, and `echo $?` - prints `0` — the backup is verified and cleaned up. Note the time for - rollback, with the previous SHA, and go on to step 3. - - `verification FAILED`, any other error, or a nonzero `echo $?` — no - restore point was printed. If only the counts differed, a write - landed between the restore and the snapshot: run the block again. If - a clean-up failed, remove what it names by hand before running the - block again. - - The `ls` in step b shows `mealquest.db-wal` or `-shm` owned by `0` - — stop, as in "Count check", even if the rest passed. - - **If any check fails, do not restart.** Production stays on the - previous SHA (only the new image was built). Restore replication first - — `docker compose -f deploy/compose.yaml up -d --force-recreate - litestream`, then read its log for the cause (NAS reachable, key, - host key) — and repeat this step until it passes. Never deploy a - release on an unverified backup. - -3. Restart and check the deployed release. - -```sh -cd /home/overseer/mealquest && ( -set -eu # any failed step ends the check with a nonzero status -fail() { echo "$*" >&2; exit 1; } -sha= -# On the way out, print the deployed SHA only if every check passed. -trap 'st=$? -if [ "$st" -eq 0 ] && [ -n "$sha" ]; then echo "deployed SHA: $sha" -else [ "$st" -ne 0 ] || st=1; echo "deploy FAILED (status $st): no SHA is deployed" >&2; fi -exit "$st"' EXIT -sudo systemctl restart mealquest # applies the firewall, then down + up -d -docker compose -f deploy/compose.yaml ps -for s in mealquest litestream searxng; do - id=$(docker compose -f deploy/compose.yaml ps --status running --quiet "$s") || fail "docker compose ps failed" - [ -n "$id" ] || fail "$s is not running" -done -curl -fsS http://10.60.1.14:8090/health -code=$(curl -sS -o /dev/null -w "%{http_code}" https://mealquest.cubecraftlabs.com/health) -[ "$code" = 200 ] || fail "the public /health returned $code, not 200" -sha=$(git rev-parse HEAD) # the checked-out SHA, now running and healthy -) -``` - - The check stops at the first failed step and prints the SHA last, only - when the restart, all three services running and both health checks - succeeded. `restart` returns once `mealquest` is healthy (Litestream - waits for it); SearXNG may still show `health: starting` for its first - 15 seconds. - - - `deployed SHA:` followed by the SHA, and `echo $?` prints `0` — - record that SHA, the previous SHA, the pre-deploy restore point and - the date in the Maestro task for the release. - - `deploy FAILED`, any other error, or a nonzero `echo $?` — record - nothing as deployed: the release is not running healthy, and after a - failed restart the stack may be down. Read - `journalctl -u mealquest` and `docker compose -f deploy/compose.yaml - logs`, then fix the cause or roll back ("Rollback to a previous SHA"). - -Migrations run at start-up; the service refuses to start if the database -has an applied migration it does not know. - -If `deploy/.env.example` gained a variable, add it to `deploy/.env` before -the restart; the service fails fast at boot naming any missing required variable. - -### Rollback to a previous SHA - -First check whether the rollback crosses a migration, with the rule the -service applies at start-up (`internal/store/migrate.go`): the older build -refuses a database holding a version in `schema_migrations` that is not one -of its own migration files — the `*.sql` files directly in `migrations/` -(`migrations/queries/` holds sqlc queries, not migrations). Compare the -versions a consistent snapshot of the live database has applied with the -files the previous SHA has. First write `/tmp/mqsnap.py` with the `cat` -command in "Count check", then: - -```sh -cd /home/overseer/mealquest && ( -set -eu # any failed step ends the check with a nonzero status -D=/var/lib/docker/volumes/mealquest-data/_data -P= -# On the way out, remove /tmp/mqcheck whatever happens, keep any failure, -# and report every nonzero exit. -trap 'st=$? -rm -rf /tmp/mqcheck || st=1 -if [ "$st" -ne 0 ]; then echo "check FAILED (status $st): stop and fix the cause" >&2; fi -exit "$st"' EXIT -rm -rf /tmp/mqcheck -mkdir /tmp/mqcheck -sudo python3 /tmp/mqsnap.py "$D/mealquest.db" /tmp/mqcheck/mealquest.db -sudo chown -R "$USER" /tmp/mqcheck -git ls-tree --name-only "$P" migrations/ > /tmp/mqcheck/known # top level only -python3 - /tmp/mqcheck/mealquest.db /tmp/mqcheck/known <<'PY' -import sqlite3, sys -applied = {v for (v,) in sqlite3.connect(sys.argv[1]).execute("select version from schema_migrations")} -known = {n[len("migrations/"):-len(".sql")] for n in open(sys.argv[2]).read().split() if n.endswith(".sql")} -if not applied or not known: - sys.exit("no versions read: check the snapshot and the previous SHA, then run again") -unknown = sorted(applied - known) -print("unknown to the previous SHA:", " ".join(unknown) if unknown else "none") -PY -) -``` - -The check runs in a subshell that stops at the first failed step — the -snapshot or its integrity check, `git ls-tree` or the comparison. On the -way out it always attempts to remove `/tmp/mqcheck`, and a failed removal -fails the check too; every nonzero exit prints `check FAILED`. Read it -like this: - -- `unknown to the previous SHA: none` and `echo $?` prints `0` — no - migration crossed: take the plain restart below. -- `unknown to the previous SHA:` followed by versions, and `echo $?` - prints `0` — the older build does not know them: the migration-crossing - path below is required. -- `check FAILED`, any other error, or a nonzero `echo $?` — stop and fix - the cause, then run the check again. Do not pick a path, even if an - `unknown to the previous SHA:` line was printed before it. If the - clean-up failed, remove `/tmp/mqcheck` by hand first. - -**No migration crossed** — rebuild and restart the older SHA: - -```sh -cd /home/overseer/mealquest && ( -set -eu # any failed step ends the rollback with a nonzero status -P= -fail() { echo "$*" >&2; exit 1; } -sha= -# On the way out, print the running SHA only if every check passed. -trap 'st=$? -if [ "$st" -eq 0 ] && [ -n "$sha" ]; then echo "rolled back to: $sha" -else [ "$st" -ne 0 ] || st=1; echo "rollback FAILED (status $st)" >&2; fi -exit "$st"' EXIT -git checkout --detach "$P" -docker compose -f deploy/compose.yaml build -sudo systemctl restart mealquest -docker compose -f deploy/compose.yaml ps -for s in mealquest litestream searxng; do - id=$(docker compose -f deploy/compose.yaml ps --status running --quiet "$s") || fail "docker compose ps failed" - [ -n "$id" ] || fail "$s is not running" -done -curl -fsS http://10.60.1.14:8090/health -sha=$(git rev-parse HEAD) -) -``` - -Each step runs only if the one before it succeeded, so the health -check answers only for the older SHA. Only `rolled back to:` followed by -the SHA, with `echo $?` printing `0`, means the rollback is done. On -`rollback FAILED` or a nonzero `echo $?` it is not: the first error names -the failed step, and if it came before the restart, production is still -on the release being rolled back from. - -**A migration crossed** — the older binary refuses the newer schema, so the -database is restored first and the older SHA started after. Changes made -since the pre-deploy restore point are lost; say so to the household. - -1. Check out and build the older SHA. Nothing restarts yet. - -```sh -git checkout --detach && -docker compose -f deploy/compose.yaml build -``` - - If `echo $?` then prints anything but `0`, stop: the database has not - been touched and nothing has restarted. - -2. Run "Restore the database" steps 1–4, adding - `-timestamp ` (recorded in step 2 of - "Deploy") to the `restore` command. Its step 5 then starts the older - SHA and checks health. - -## Backups - -### Database — Litestream - -The `litestream` compose service streams every committed change to -`mealquest.db` to the NAS over SFTP (`deploy/litestream.yml`). It takes a -full snapshot daily (compaction level 9) and keeps a week of snapshots -plus the incremental LTX files between them. - -One-time setup (already done on 10.60.1.14): - -1. Generate the dedicated keypair, git-ignored, owned by the container - user: - - ```sh - cd /home/overseer/mealquest - ssh-keygen -t ed25519 -N '' -C mealquest-litestream -f deploy/secrets/litestream_key - # append deploy/secrets/litestream_key.pub to joshua@10.60.1.50:~/.ssh/authorized_keys - rm deploy/secrets/litestream_key.pub - sudo chown 10001:10001 deploy/secrets/litestream_key - sudo chmod 0600 deploy/secrets/litestream_key - ``` - -2. The NAS host key is pinned in `deploy/litestream.yml` (`host-key`). - -Check replication — list every LTX file at every level. Level 9 holds the -full snapshots; the default listing (`-level 0`) shows only L0 files: - -```sh -docker compose -f deploy/compose.yaml exec -T litestream \ - litestream ltx -config /etc/litestream.yml -level all /data/mealquest.db -``` - -### Media — nightly rsync - -`mealquest-media-backup.timer` runs `mealquest-media-backup.service` at -03:30 every night. The service runs `/usr/local/sbin/mealquest-media-backup` -(`deploy/systemd/mealquest-media-backup.sh`) as the unprivileged host user -`mealquest` (uid/gid 10001, the app's own user) with no capabilities. -systemd bind-mounts the data volume read-only at `/run/mealquest-data` and -passes the Litestream key in as the credential `nas_key`; uid 10001 cannot -otherwise reach `/var/lib/docker` or `deploy/secrets/`. The script refuses a -`media` path that is a symlink and fails (exit 1) if `media` is missing. - -One-time setup (already done on 10.60.1.14): - -```sh -cd /home/overseer/mealquest -# Host user for the backup; ssh needs a passwd entry for its uid. -sudo groupadd --system --gid 10001 mealquest -sudo useradd --system --uid 10001 --gid 10001 --no-create-home \ - --home-dir /nonexistent --shell /usr/sbin/nologin mealquest -# Pinned NAS host keys, root-owned and world-readable. -sudo install -d -o root -g root -m 0755 /etc/mealquest -sudo install -o root -g root -m 0644 deploy/known_hosts /etc/mealquest/known_hosts -# The media directory; the backup fails if it is ever missing. -sudo install -d -o 10001 -g 10001 -m 0755 /var/lib/docker/volumes/mealquest-data/_data/media -# Script and units. -sudo install -o root -g root -m 0755 deploy/systemd/mealquest-media-backup.sh /usr/local/sbin/mealquest-media-backup -sudo install -o root -g root -m 0644 deploy/systemd/mealquest-media-backup.service \ - deploy/systemd/mealquest-media-backup.timer /etc/systemd/system/ -sudo systemctl daemon-reload -sudo systemctl enable --now mealquest-media-backup.timer -``` - -Run it by hand and read the result: - -```sh -sudo systemctl start mealquest-media-backup.service -journalctl -u mealquest-media-backup --no-pager -n 30 -``` - -To restore media, follow "Restore photos (media)" — never copy back while -the timer or the service can still run. - -## Restore the database - -SQLite in WAL mode keeps recent commits in `mealquest.db-wal`, indexed by -`mealquest.db-shm`. **A restored database must never sit beside an old WAL -or shared-memory file**: on open, SQLite would replay the old WAL's pages -onto the restored database and corrupt it or mix in state from the -database it replaced. So all three files are moved aside together, with -Litestream's local state directory `.mealquest.db-litestream`, before the -restore — and the restore runs into an empty volume. - -Steps 1–3: - -```sh -cd /home/overseer/mealquest && ( -set -eu # any failed step ends the restore with a nonzero status -D=/var/lib/docker/volumes/mealquest-data/_data -A=/var/tmp/mealquest-restore-aside-$(date +%Y%m%d%H%M) -fail() { echo "$*" >&2; exit 1; } -# On the way out, name the aside directory; on any failure the stack -# stays stopped. -trap 'st=$? -if [ "$st" -eq 0 ]; then echo "restored mealquest.db; the old files are in $A: run step 4" -else echo "restore FAILED (status $st): leave the stack stopped; anything moved aside is in $A" >&2; fi -exit "$st"' EXIT - -# 1. Stop the whole stack (app and Litestream). Nothing may hold the -# database open; a clean close checkpoints the WAL into mealquest.db. -sudo systemctl stop mealquest - -# 2. Move the database, its -wal and -shm files and Litestream's state -# aside — all of them, every time. Keep the directory until the -# restored stack is verified. -sudo mkdir "$A" -for f in mealquest.db mealquest.db-wal mealquest.db-shm .mealquest.db-litestream; do - if sudo test -e "$D/$f"; then sudo mv "$D/$f" "$A/"; fi -done -left=$(sudo ls -A "$D") -[ "$left" = media ] || fail "the volume must hold only media/, not: $left" - -# 3. Restore from the NAS. --no-deps keeps the app stopped so it cannot -# create a fresh, empty database first. -docker compose -f deploy/compose.yaml run --rm --no-deps litestream \ - restore -config /etc/litestream.yml -o /data/mealquest.db /data/mealquest.db -left=$(sudo ls -A "$D") -[ "$left" = "$(printf 'mealquest.db\nmedia')" ] || fail "the volume must hold only mealquest.db and media/ (no -wal, no -shm), not: $left" -) -``` - -To restore to a point in time, add `-timestamp 2026-10-05T23:00:00Z` (or -`-txid`) to the `restore` command. - -The block stops at the first failed step. Only `restored mealquest.db` -with `echo $?` printing `0` lets you go on; on `restore FAILED` leave the -stack stopped, fix the cause and run the block again (a new aside -directory is made; the earlier one keeps what it holds). - -4. Check the restored file before starting the app: run the copy block - in "Count check". - -5. Start the stack and check health, only after step 4 passed: - -```sh -cd /home/overseer/mealquest && ( -set -eu # any failed step ends the check with a nonzero status -fail() { echo "$*" >&2; exit 1; } -sha= -# On the way out, print the running SHA only if every check passed. -trap 'st=$? -if [ "$st" -eq 0 ] && [ -n "$sha" ]; then echo "stack running and healthy at $sha" -else [ "$st" -ne 0 ] || st=1; echo "start FAILED (status $st)" >&2; fi -exit "$st"' EXIT -sudo systemctl start mealquest -docker compose -f deploy/compose.yaml ps -for s in mealquest litestream searxng; do - id=$(docker compose -f deploy/compose.yaml ps --status running --quiet "$s") || fail "docker compose ps failed" - [ -n "$id" ] || fail "$s is not running" -done -curl -fsS http://10.60.1.14:8090/health -sha=$(git rev-parse HEAD) -) -``` - - Only `stack running and healthy at` with `echo $?` printing `0` ends - the restore; then the aside directory may go. - -### Count check - -`/tmp/mqcount.py` prints row counts for key tables, the number of -application tables, a SHA-256 over every application row (first 16 hex -digits; Litestream's own `_litestream*` tables are excluded) and -`PRAGMA integrity_check`. Run it on a **copy** of the database, never on the -live file. - -While the app or Litestream has the database open, the copy is a snapshot -from `/tmp/mqsnap.py`: it opens the live database read-only, copies it -with SQLite's online backup API (one consistent read, whatever is still in -the WAL included) and exits nonzero unless the snapshot's -`PRAGMA integrity_check` is `ok`. Never copy `mealquest.db` and its `-wal` -as files while they are open: a checkpoint between the two copies leaves -an older state that still reads cleanly. "Deploy" step 2 and "Rollback to -a previous SHA" use the snapshot. With the stack stopped, as in "Restore -the database" step 4, the files are closed and checkpointed, and a plain -copy is consistent (a read-only open there would leave a `-wal` and -`-shm` beside the restored file). - -`mode=ro` stops writes through the snapshot's own connection, but SQLite -can still create a missing `-wal` or `-shm` beside the database, and under -`sudo` it would be owned by root. The live snapshot therefore expects the -app and Litestream to be running and holding the existing -`mealquest.db-wal` and `mealquest.db-shm`, owned by `10001`. If -`sudo ls -ln "$D"` after a live snapshot shows either sidecar owned by -`0` — the snapshot created it — stop before going further: check -`docker compose -f deploy/compose.yaml ps` (mealquest and litestream -running) and, with the cause understood, return the sidecars to -`10001:10001` before proceeding. - -`/tmp` is temporary, so write the scripts before every check: - -```sh -cat > /tmp/mqcount.py <<'EOF' && -import sqlite3, sys, hashlib -c = sqlite3.connect(sys.argv[1]) -tabs = [r[0] for r in c.execute("select name from sqlite_master where type=\"table\" and name not like \"sqlite_%\" and name not like \"_litestream%\" order by name")] -for t in ("dinners", "week_plans", "schema_migrations"): - print(t, c.execute(f"select count(*) from {t}").fetchone()[0]) -h = hashlib.sha256() -for t in tabs: - for row in c.execute(f"select * from \"{t}\" order by rowid"): - h.update(repr(row).encode()) -print("tables", len(tabs), "content-sha256", h.hexdigest()[:16]) -print("integrity", c.execute("pragma integrity_check").fetchone()[0]) -EOF -cat > /tmp/mqsnap.py <<'EOF' -import os, sqlite3, sys -src, dst = sys.argv[1], sys.argv[2] -if os.path.exists(dst): - sys.exit(f"{dst} already exists") -s = sqlite3.connect(f"file:{src}?mode=ro", uri=True) -d = sqlite3.connect(dst) -s.backup(d) -s.close() -result = d.execute("pragma integrity_check").fetchall() -d.close() -if result != [("ok",)]: - sys.exit(f"snapshot integrity_check failed: {result}") -print("snapshot", dst, "integrity ok") -EOF -``` - -The second file is written only if the first was; `echo $?` must then -print `0` before either script is used. - -With the stack stopped, copy the database and run it: - -```sh -cd /home/overseer/mealquest && ( -set -eu # any failed step ends the check with a nonzero status -D=/var/lib/docker/volumes/mealquest-data/_data -fail() { echo "$*" >&2; exit 1; } -# On the way out, remove /tmp/mqcheck whatever happens, keep any failure, -# and give the verdict only if every step and the clean-up succeeded. -trap 'st=$? -rm -rf /tmp/mqcheck || st=1 -if [ "$st" -eq 0 ]; then echo "count check: integrity ok" -else echo "count check FAILED (status $st): do not start the stack" >&2; fi -exit "$st"' EXIT -rm -rf /tmp/mqcheck -mkdir /tmp/mqcheck -sudo sh -c "cp -a $D/mealquest.db* /tmp/mqcheck/" -sudo chown -R "$USER" /tmp/mqcheck -python3 /tmp/mqcount.py /tmp/mqcheck/mealquest.db > /tmp/mqcheck/counts.txt -cat /tmp/mqcheck/counts.txt -grep -qx 'integrity ok' /tmp/mqcheck/counts.txt || fail "the copy failed integrity_check" -) -``` - -The check stops at the first failed step — the clean-up of an earlier -`/tmp/mqcheck`, the copy, the count or the integrity check — so it never -reads a file left by an earlier run. Only `count check: integrity ok` -with `echo $?` printing `0` passes; then the counts printed above it, -before and after, must match exactly. - -## Restore photos (media) - -Recipe and dinner photos live in `media/` in the data volume and are -backed up nightly to `joshua@10.60.1.50:/volume1/homes/Joshua/mealquest-backups/media/` -(see "Media — nightly rsync"). The backup service mirrors with -`rsync --delete`. **An `rsync --delete` run must never see an empty or -partial `media/`**: it would delete the NAS copy you are about to restore -from. Stopping the timer is not enough — it does not stop a run that is -already in flight, and the service can still be started by hand. So stop -**both** the timer and the service, confirm no rsync is running, and do -not start the service until the restore is verified. - -Run from `/home/overseer/mealquest`: - -```sh -cd /home/overseer/mealquest && ( -set -eu # any failed step ends the restore with a nonzero status -M=/var/lib/docker/volumes/mealquest-data/_data/media -NAS=/volume1/homes/Joshua/mealquest-backups/media -SSH="ssh -i deploy/secrets/litestream_key -o IdentitiesOnly=yes -o UserKnownHostsFile=/etc/mealquest/known_hosts -o StrictHostKeyChecking=yes" -fail() { echo "$*" >&2; exit 1; } -nas= here= w= -# On the way out, give the counts only if every step passed; on any -# failure, report the timer's actual state, not an assumed one. -trap 'st=$? -[ -z "$w" ] || rm -rf "$w" -if [ "$st" -eq 0 ] && [ -n "$here" ]; then echo "media restored: $here files here, $nas on the NAS, all $nas match the NAS manifest" -else [ "$st" -ne 0 ] || st=1; t=$(systemctl is-active mealquest-media-backup.timer) || : - if [ "$t" = inactive ]; then echo "media restore FAILED (status $st): the timer is inactive; leave it stopped and find out why" >&2 - else echo "media restore FAILED (status $st): the timer is ${t:-unknown}, not inactive; run sudo systemctl stop mealquest-media-backup.timer mealquest-media-backup.service and confirm it is inactive before anything else" >&2; fi; fi -exit "$st"' EXIT -w=$(mktemp -d) - -# 1. Stop the nightly mirror: the timer AND any run in flight. -sudo systemctl stop mealquest-media-backup.timer mealquest-media-backup.service - -# 2. Pre-check — all must hold before media/ is touched. is-active exits -# nonzero for every state but active, so its printed state is checked; -# pgrep exits 0 on a match, 1 on none, 2+ on an error. -ts=$(systemctl is-active mealquest-media-backup.timer) || : -[ "$ts" = inactive ] || fail "the timer is ${ts:-unknown}, not inactive" -ss=$(systemctl is-active mealquest-media-backup.service) || : -case "$ss" in - inactive|failed) ;; - *) fail "the service is ${ss:-unknown}, not inactive or failed";; -esac -ps=0 -pgrep -a -u mealquest rsync || ps=$? -case "$ps" in - 0) fail "an rsync is still running as mealquest";; - 1) ;; # no match: no rsync - *) fail "pgrep failed (status $ps)";; -esac -l=$(sudo $SSH joshua@10.60.1.50 "find $NAS -type f") || fail "listing the NAS copy failed" -[ -n "$l" ] || fail "the NAS copy holds no files" -nas=$(printf '%s\n' "$l" | wc -l) # the target below -# The NAS manifest: one "sha256 ./path" line per file, taken now, before -# the copy. Every check below compares content against it, not counts. -sudo $SSH joshua@10.60.1.50 "cd $NAS && find . -type f -exec sha256sum {} +" >"$w/raw" || fail "hashing the NAS copy failed" -LC_ALL=C sort "$w/raw" >"$w/nas" -[ "$(wc -l <"$w/nas")" -eq "$nas" ] || fail "the NAS manifest has $(wc -l <"$w/nas") files, the listing $nas" - -# 3. Stop the app (no writes into media/ meanwhile). -docker compose -f deploy/compose.yaml stop mealquest - -# 4. Recreate the directory if it is gone, owned by the app user. -sudo install -d -o 10001 -g 10001 -m 0755 "$M" - -# 5. Copy back from the NAS as uid 10001's files. No --delete in this -# direction: the NAS copy is the source and is only read. --checksum -# compares content, so a local file with the NAS size and mtime but -# other bytes is still replaced. -sudo rsync -a --checksum --stats --chown=10001:10001 -e "$SSH" "joshua@10.60.1.50:$NAS/" "$M/" - -# 6. Post-condition — before anything can mirror again: at least the NAS -# count here, and the NAS count unchanged. -l=$(sudo find "$M" -type f) || fail "listing $M failed" -[ -n "$l" ] || fail "$M holds no files after the copy" -n=$(printf '%s\n' "$l" | wc -l) -[ "$n" -ge "$nas" ] || fail "only $n of the NAS's $nas files are here: the copy did not complete" -l=$(sudo $SSH joshua@10.60.1.50 "find $NAS -type f") || fail "listing the NAS copy failed" -[ -n "$l" ] && [ "$(printf '%s\n' "$l" | wc -l)" -eq "$nas" ] || fail "the NAS file count changed from $nas" -# Content: every file in the NAS manifest is here with the same bytes, -# and the NAS still matches the manifest taken before the copy. -sudo sh -c 'cd "$1" && find . -type f -exec sha256sum {} +' sh "$M" >"$w/raw" || fail "hashing $M failed" -LC_ALL=C sort "$w/raw" >"$w/here" -LC_ALL=C comm -23 "$w/nas" "$w/here" >"$w/bad" -[ ! -s "$w/bad" ] || { cat "$w/bad" >&2; fail "$(wc -l <"$w/bad") NAS file(s) above are missing here or differ in content"; } -sudo $SSH joshua@10.60.1.50 "cd $NAS && find . -type f -exec sha256sum {} +" >"$w/raw" || fail "hashing the NAS copy failed" -LC_ALL=C sort "$w/raw" | cmp -s - "$w/nas" || fail "the NAS copy changed during the restore" -here=$n - -# 7. Start the app, then the timer again. Only now may the service run. -docker compose -f deploy/compose.yaml up -d -curl -fsS http://10.60.1.14:8090/health -sudo systemctl start mealquest-media-backup.timer -systemctl is-active mealquest-media-backup.timer # active; exits nonzero otherwise -) -``` - -The block stops at the first failed step and gives the counts last, only -when every step passed and every file in the NAS manifest (a SHA-256 of -each file, taken before the copy) is present locally with the same bytes. -On `media restore FAILED` or a nonzero `echo $?` the message gives the -timer's actual state: if it is not `inactive`, stop it as the message says -before anything else; then leave it stopped. The local count may exceed the NAS count only if -`media/` still held files the NAS never received (photos added since the -last nightly run); the next nightly run then copies them up. If the local count is lower, -the copy did not complete: fix the cause and run the block again. - -When the database is restored too, restore it first ("Restore the -database"), then the media, so every photo the database refers to is -present. This media restore has not been run against production yet; the -backup direction was checked on 2026-10-05 (see "Media backup checks"). - -## Restore drill record — 2026-10-05 - -Drill on 10.60.1.14 against the live (pre-launch, empty-household) -database, Litestream 0.5.17. Commands as in "Restore the database"; -output trimmed only of compose progress lines. - -Replica before the drill (`ltx -level all`; level 9 is the full snapshot): - -``` -level min_txid max_txid size created -0 0000000000000003 0000000000000003 14110 2026-10-05T23:13:30Z -1 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z -1 0000000000000003 0000000000000003 14110 2026-10-05T23:13:30Z -2 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z -2 0000000000000003 0000000000000003 14110 2026-10-05T23:13:30Z -3 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z -9 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z -``` - -1. `docker compose -f deploy/compose.yaml down` — both containers stopped - and removed. The volume then held `mealquest.db`, - `.mealquest.db-litestream/` and `media/`; the clean close had - checkpointed and removed `-wal` and `-shm`. -2. Before (copy of the stopped database): - - ``` - dinners 0 - week_plans 0 - schema_migrations 7 - tables 26 content-sha256 836e230e16c58dc7 - integrity ok - ``` - -3. Moved aside to `/var/tmp/mealquest-restore-aside-20261005`: - `mealquest.db` and `.mealquest.db-litestream` (no `-wal`/`-shm` existed - after the clean stop; the loop moves them whenever they do). Volume - afterwards: `media/` only. -4. `docker compose -f deploy/compose.yaml run --rm --no-deps litestream restore -config /etc/litestream.yml -o /data/mealquest.db /data/mealquest.db` - — volume afterwards: `mealquest.db` (258048 bytes) and `media/`; no - `-wal`, no `-shm`. -5. After (copy of the restored file, before the app started): - - ``` - dinners 0 - week_plans 0 - schema_migrations 7 - tables 26 content-sha256 836e230e16c58dc7 - integrity ok - ``` - -6. `docker compose -f deploy/compose.yaml up -d` — `mealquest-mealquest-1` - healthy, `mealquest-litestream-1` up; `GET /health` → `200`. Counts on a - copy of the running database (`mealquest.db`, `-wal`, `-shm`) were - identical to the above. -7. Litestream resumed against the restored database: - - ``` - level=INFO msg="detected database behind replica" db=mealquest.db db_txid=0000000000000000 replica_txid=0000000000000003 - level=INFO msg="fetched latest L0 file from replica" db=mealquest.db min_txid=0000000000000003 max_txid=0000000000000003 - level=INFO msg="compaction complete" db=mealquest.db level=1 txid.min=0000000000000004 txid.max=0000000000000004 size=14110 - ``` - - Replica after the drill (`ltx -level all`): - - ``` - level min_txid max_txid size created - 0 0000000000000004 0000000000000004 14110 2026-10-05T23:27:21Z - 1 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z - 1 0000000000000003 0000000000000003 14110 2026-10-05T23:13:30Z - 1 0000000000000004 0000000000000004 14110 2026-10-05T23:27:21Z - 2 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z - 2 0000000000000003 0000000000000003 14110 2026-10-05T23:13:30Z - 3 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z - 9 0000000000000001 0000000000000002 14110 2026-10-05T23:12:38Z - ``` - -The first drill earlier the same day (before `-level all` was recorded) -gave the same before/after result: checksum `836e230e16c58dc7`, 26 tables, -integrity ok. - -### Media backup checks — 2026-10-05 - -`sudo systemctl start mealquest-media-backup.service` on 10.60.1.14: - -| Case | Result | -| --- | --- | -| Normal run | `Result=success`; journal: `synced /run/mealquest-data/media to joshua@10.60.1.50:/volume1/homes/Joshua/mealquest-backups/media/ as uid 10001` | -| A test file added | `Number of regular files transferred: 1`; removing it then gave `Number of deleted files: 1 (reg: 1)` | -| `media` replaced by a symlink to `/etc` | `Result=exit-code`, status 1; `/run/mealquest-data/media is a symlink to /etc; refusing to back it up` | -| `media` removed | `Result=exit-code`, status 1; `media directory /run/mealquest-data/media is missing` | -| `sudo -u mealquest` writing into the volume via `/var/lib/docker/...` | `Permission denied` — uid 10001 cannot traverse `/var/lib/docker` (mode 0710 root) | - -## Secret rotation - -Rotate a secret when it may have leaked, when someone with access leaves, -or on the owner's schedule. Never paste a secret into a ticket, chat or -this file. - -### App key (`deploy/secrets/secret_key`) - -The app key encrypts every stored secret: the `_ct` columns -`ai_settings.api_key_ct` (LLM API key), `recipe_search_settings.api_key_ct` -(recipe-search API key), `integrations_google.refresh_token_ct` (Google -Calendar refresh token) and `chat_settings.webhook_url_ct` (Google Chat -webhook URL). **Rotating it re-encrypts nothing**: a value encrypted with -the old key can no longer be read. A `mealquest rotate-key` helper that -re-encrypts in place is planned but does not exist yet. - -**What is recoverable today.** The service currently exposes only -household settings; no shipped flow writes or re-enters any of these four -secrets. The re-entry flows are planned: the LLM key in Settings → AI -assistant (the AI settings feature), the recipe-search key (Feature 10), -Google Calendar reconnect (Feature 16) and the Google Chat webhook -(Feature 19). So today the rotation is safe **only if no `_ct` value is -stored**, which the pre-check below proves. If the pre-check finds a -stored value, do not rotate: it could not be re-entered afterwards. Keep -the current key and wait for the matching flow (or the `rotate-key` -helper). - -**The pre-check and the key replacement run with the whole stack -stopped**, from the pre-check until the new key is in place. A raw copy of -`mealquest.db` and `mealquest.db-wal` taken while the stack runs is **not** -a consistent snapshot: the two files are copied one after the other, and a -checkpoint (by the app or by Litestream) between the two copies can move -committed pages out of the WAL into `mealquest.db` after `mealquest.db` was -already copied, then reset the WAL before it is copied. The copy then -lacks those commits — possibly a stored `_ct` value — and the pre-check -reports a false 0. A running app could also store a `_ct` value after a -correct pre-check and before the key is replaced. Stopping both containers -removes every writer (Litestream also writes and checkpoints), and the -clean close checkpoints the WAL into `mealquest.db`, so the file alone is -complete. - -Run from `/home/overseer/mealquest`: - -```sh -D=/var/lib/docker/volumes/mealquest-data/_data - -# 1. Stop the whole stack (app and Litestream) and leave it stopped until -# step 4. Nothing may hold the database open. -sudo systemctl stop mealquest -sudo ls -la "$D" # mealquest.db and media/ only: no -wal, no -shm; if - # either exists, the close was not clean — stop here - -# 2. Pre-check, on a copy of the now-quiescent database. -rm -rf /tmp/mqcheck && mkdir /tmp/mqcheck -sudo cp -a "$D/mealquest.db" /tmp/mqcheck/ && sudo chown -R "$USER" /tmp/mqcheck -python3 - /tmp/mqcheck/mealquest.db <<'PY' -import sqlite3, sys -c = sqlite3.connect(sys.argv[1]) -for t, col in (("ai_settings", "api_key_ct"), ("recipe_search_settings", "api_key_ct"), - ("integrations_google", "refresh_token_ct"), ("chat_settings", "webhook_url_ct")): - print(t, col, c.execute(f"select count(*) from {t} where {col} is not null").fetchone()[0]) -PY -rm -rf /tmp/mqcheck -# every count must be 0; otherwise start the stack again on the current -# key (sudo systemctl start mealquest) and stop here - -# 3. Replace the key, still with the stack stopped. -sudo cp -a deploy/secrets/secret_key /var/tmp/secret_key.old # keep until verified, then shred -head -c 32 /dev/urandom | base64 | sudo tee deploy/secrets/secret_key >/dev/null -sudo chown 10001:10001 deploy/secrets/secret_key -sudo chmod 600 deploy/secrets/secret_key - -# 4. Start the stack on the new key and check health. -sudo systemctl start mealquest -docker compose -f deploy/compose.yaml ps -curl -fsS http://10.60.1.14:8090/health -``` - -Then sign in and open household settings to confirm the service runs on -the new key. Finally `sudo shred -u /var/tmp/secret_key.old`. Once the -planned flows ship, this section gains a step per secret to re-enter it -and confirm it works. - -### OIDC client secret (`deploy/secrets/oidc_client_secret`) - -In Authentik, open the provider `mealquest` and regenerate the Client -Secret. Write it to the file (same owner and mode as above) and recreate -the service: - -```sh -printf '%s\n' '' | sudo tee deploy/secrets/oidc_client_secret >/dev/null -sudo chown 10001:10001 deploy/secrets/oidc_client_secret -sudo chmod 600 deploy/secrets/oidc_client_secret -docker compose -f deploy/compose.yaml up -d --force-recreate mealquest -``` - -Sign in at to confirm; sign-ins -fail between regenerating the secret in Authentik and recreating the -service. - -### Google OAuth client secret (`deploy/secrets/google_client_secret`) - -In the Google Cloud console, open the OAuth client `mealquest`, add a new -client secret, write it to the file (same owner and mode as above) and -recreate the service: - -```sh -printf '%s\n' '' | sudo tee deploy/secrets/google_client_secret >/dev/null -sudo chown 10001:10001 deploy/secrets/google_client_secret -sudo chmod 600 deploy/secrets/google_client_secret -docker compose -f deploy/compose.yaml up -d --force-recreate mealquest -``` - -Then disable and delete the old secret in the console. A new client secret -leaves the household's stored refresh token valid, so nothing needs -reconnecting. - -### LLM API key - -Stored encrypted in the database, not in `deploy/`. In the app, open -**Settings → AI assistant**, choose **Replace**, paste the new key and -**Verify**, and check the verified state is shown. Then revoke the -old key in the provider's console. (The AI assistant settings page is -delivered by its own feature; until it ships there is no stored LLM key -to rotate.) - -### VAPID keys (Web Push) - -Planned with Web Push (Feature 19): the public key goes in -`deploy/.env` as `MEALQUEST_VAPID_PUBLIC_KEY`, the private key in -`deploy/secrets/vapid_private_key` (owner `10001:10001`, mode 0600), and -the pair is generated with `cmd/vapidgen`. **Changing the VAPID pair -invalidates every push subscription**: each device must turn -notifications on again (`/settings/notifications`) before it receives -pushes. Rotate only when the private key may have leaked, write both -halves, recreate the service with `--force-recreate mealquest`, and tell -the household to re-enable notifications on each device. - -### SearXNG secret (`deploy/secrets/searxng.env`) - -Only signs SearXNG's own cookies and image-proxy links; nothing stored -depends on it. Replace it and recreate the service: - -```sh -(umask 077; printf 'SEARXNG_SECRET=%s\n' "$(openssl rand -hex 32)" > deploy/secrets/searxng.env) -docker compose -f deploy/compose.yaml up -d --force-recreate searxng -docker compose -f deploy/compose.yaml ps searxng # healthy -``` - -### Litestream / NAS key (`deploy/secrets/litestream_key`) - -Generate a new keypair as in "Database — Litestream", add the new public -key to `joshua@10.60.1.50:~/.ssh/authorized_keys`, replace the file -(owner `10001:10001`, mode 0600), run -`docker compose -f deploy/compose.yaml up -d --force-recreate litestream` -and `sudo systemctl start mealquest-media-backup.service`, check both -(`litestream ltx` and the journal), then remove the old public key from -the NAS. - -## Context records - -The confirmed values in this runbook (port 8090, checkout path, Authentik -group `mealquest-admin`, DNS, firewall, backup target) are also recorded in -the CTXD project `mealquest`: `RUNBOOKS.MD` (deployment procedure) and the -open questions in `CONTEXT.MD`, updated for Feature 07 task 876134bc. diff --git a/deploy/compose.dev.yaml b/deploy/compose.dev.yaml index ee071ba..3515700 100644 --- a/deploy/compose.dev.yaml +++ b/deploy/compose.dev.yaml @@ -1,8 +1,7 @@ -# Development override: publishes SearXNG on the dev machine's loopback so -# it can be queried directly, e.g. -# -# docker compose -f deploy/compose.yaml -f deploy/compose.dev.yaml up -d searxng -# curl 'http://127.0.0.1:8888/search?q=chicken+tikka+masala+recipe&format=json' +# Development override layered on deploy/compose.yaml: publishes SearXNG +# on the dev machine's loopback (127.0.0.1:8888) so it can be queried +# directly. Using it: CTXD project mealquest, RUNBOOKS.MD "Local +# development". # # Loopback only, never a LAN address. Never used in production: the # systemd unit starts deploy/compose.yaml alone. diff --git a/deploy/compose.yaml b/deploy/compose.yaml index f54b159..a85f106 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -1,18 +1,16 @@ -# Production stack for MealQuest on 10.60.1.14 (D-018). Build on the host, -# then start through systemd: -# -# docker compose -f deploy/compose.yaml build -# sudo systemctl restart mealquest +# Production stack for MealQuest on 10.60.1.14 (D-018). Building, deploying +# and operating it: CTXD project mealquest, RUNBOOKS.MD "Stack unit" and +# "Routine deploy and rollback". # # The services use restart: "no" on purpose: Docker must not start them # before the DOCKER-USER firewall is applied. deploy/systemd/mealquest.service # owns start and stop and requires mealquest-firewall.service first. # -# Configuration comes from deploy/.env (copy deploy/.env.example) and secrets -# from deploy/secrets/; neither is committed. Compose bind-mounts file secrets -# with their host owner and mode, so each file in deploy/secrets/ must be -# owned by the container user 10001:10001 with mode 0600 (except -# searxng.env, read by compose itself; see the searxng service). +# Configuration comes from deploy/.env (template: deploy/.env.example) and +# secrets from deploy/secrets/; neither is committed. Compose bind-mounts +# file secrets with their host owner and mode, so each file in +# deploy/secrets/ must be owned by the container user 10001:10001 with mode +# 0600 (except searxng.env, read by compose itself; see the searxng service). name: mealquest services: @@ -65,8 +63,8 @@ services: # the app's own user (owner of the volume) with a read-only root # filesystem, no privilege escalation and no Linux capabilities. # - # Restore: follow "Restore the database" in deploy/RUNBOOK.md (stop the - # stack, move the database with its -wal and -shm aside, restore, check). + # Restoring the database: CTXD project mealquest, RUNBOOKS.MD "Restore + # the database". litestream: image: litestream/litestream:0.5.17 user: "10001:10001" diff --git a/deploy/systemd/mealquest-firewall.service b/deploy/systemd/mealquest-firewall.service index c7c5f8e..90d2fdc 100644 --- a/deploy/systemd/mealquest-firewall.service +++ b/deploy/systemd/mealquest-firewall.service @@ -5,8 +5,9 @@ # are one-way: a failure here fails this unit visibly and never fails # docker.service. # -# Start MealQuest only after this unit has succeeded: -# systemctl start mealquest-firewall && docker compose up -d +# mealquest.service requires this unit and starts the stack only after it +# has succeeded. Installing and operating both: CTXD project mealquest, +# RUNBOOKS.MD "Host firewall" and "Stack unit". [Unit] Description=Restrict MealQuest port 8090 to Traefik (DOCKER-USER) After=docker.service diff --git a/deploy/systemd/mealquest-media-backup.service b/deploy/systemd/mealquest-media-backup.service index 9f5a61c..9222684 100644 --- a/deploy/systemd/mealquest-media-backup.service +++ b/deploy/systemd/mealquest-media-backup.service @@ -1,8 +1,7 @@ # Runs the nightly MealQuest media backup (/usr/local/sbin/mealquest-media-backup). -# Triggered by mealquest-media-backup.timer; run by hand with -# systemctl start mealquest-media-backup -# and read the result with -# journalctl -u mealquest-media-backup +# Triggered by mealquest-media-backup.timer; output goes to the journal. +# Installing it and running it by hand: CTXD project mealquest, +# RUNBOOKS.MD "Media — nightly rsync". # # The script runs as the app's user (10001, host user "mealquest") with no # capabilities. systemd, as root, only bind-mounts the data volume's root diff --git a/deploy/systemd/mealquest-media-backup.sh b/deploy/systemd/mealquest-media-backup.sh index f4ee14d..fd99363 100755 --- a/deploy/systemd/mealquest-media-backup.sh +++ b/deploy/systemd/mealquest-media-backup.sh @@ -15,8 +15,9 @@ # swapping the path afterwards cannot redirect it. Symlinks inside the # directory are copied as links (-a), never followed. # -# The media directory is created at deploy time (deploy/RUNBOOK.md). If it -# is missing the job fails; it is never treated as "nothing to back up". +# The media directory is created at deploy time (CTXD project mealquest, +# RUNBOOKS.MD "Media — nightly rsync"). If it is missing the job fails; it +# is never treated as "nothing to back up". # # rsync mirrors into .../mealquest-backups/media/ only: the target ends in # a slash, so --delete removes files from that directory and nothing diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d8b64d0..6522df2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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). | | `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. | -| `shopping_lists` | week_start (unique), plan_id, title, 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_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 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. | | `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. | @@ -470,6 +470,51 @@ automatic** (D-021). 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 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 `@/`, 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 @@ -738,9 +783,52 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. failures), 404 when the week has no plan, 400 `invalid_week`. The push holds the account lock 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 - successful `Push` returns that plan, which is where Feature 17 hooks - in. Nothing here generates a shopping list. + - The week's Supply run (Feature 17) is made from a pushed plan: after + a successful `Push`, the handler runs `shopping.GeneratePushed` for + 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 diff --git a/internal/ai/skills/archive_title.go b/internal/ai/skills/archive_title.go new file mode 100644 index 0000000..611cbfd --- /dev/null +++ b/internal/ai/skills/archive_title.go @@ -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 +} diff --git a/internal/ai/skills/archive_title_test.go b/internal/ai/skills/archive_title_test.go new file mode 100644 index 0000000..52a320d --- /dev/null +++ b/internal/ai/skills/archive_title_test.go @@ -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) + } + }) + } +} diff --git a/internal/ai/skills/prompts/archive_title.v1.tmpl b/internal/ai/skills/prompts/archive_title.v1.tmpl new file mode 100644 index 0000000..ffa621e --- /dev/null +++ b/internal/ai/skills/prompts/archive_title.v1.tmpl @@ -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}} diff --git a/internal/ai/skills/prompts/checksums.txt b/internal/ai/skills/prompts/checksums.txt index 79ec5ec..56c2739 100644 --- a/internal/ai/skills/prompts/checksums.txt +++ b/internal/ai/skills/prompts/checksums.txt @@ -5,3 +5,4 @@ echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32 +archive_title v1 b4f30562a8614dac09a69d512dc361883b917ee4228a0ce3b4583c7fd3f6101a diff --git a/internal/ai/skills/testdata/archive_title_valid.system.golden b/internal/ai/skills/testdata/archive_title_valid.system.golden new file mode 100644 index 0000000..00ba6e1 --- /dev/null +++ b/internal/ai/skills/testdata/archive_title_valid.system.golden @@ -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 +} \ No newline at end of file diff --git a/internal/ai/skills/testdata/archive_title_valid.user.golden b/internal/ai/skills/testdata/archive_title_valid.user.golden new file mode 100644 index 0000000..be8140f --- /dev/null +++ b/internal/ai/skills/testdata/archive_title_valid.user.golden @@ -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 \ No newline at end of file diff --git a/internal/db/models.go b/internal/db/models.go index 9cdf5d0..464b6b0 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -258,6 +258,7 @@ type ShoppingGroup struct { ID string ListID string DinnerID string + Date string Position int64 CreatedAt string UpdatedAt string @@ -278,7 +279,7 @@ type ShoppingList struct { ID string WeekStart string PlanID string - Title string + Title sql.NullString Status string SentToChatAt sql.NullString FrozenAt sql.NullString diff --git a/internal/db/querier.go b/internal/db/querier.go index 0178e6d..00e2e22 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -43,6 +43,11 @@ type Querier interface { CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) 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 // later renamed. CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error @@ -59,6 +64,8 @@ type Querier interface { DeleteIntegrationsGoogle(ctx context.Context) error DeleteRecipeIngredients(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) // Fails every unarchived dinner still pending whose grounding job for its // 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 // finished is left as it is (0 rows). FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error) + // Freezes list id into the Supply archive at frozen_at, unless it is + // frozen already. + FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error) // Returns sql.ErrNoRows until the row exists. GetAISettings(ctx context.Context) (AiSetting, error) // The job of kind queued or running for the week starting week_start, @@ -113,6 +123,12 @@ type Querier interface { GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error) // Returns sql.ErrNoRows until the row exists. 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. GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error) // 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) ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, 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 // of any status, that have no Rotation check for date yet. 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 // (0 rows). 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), // recording updated_by; a dinner no longer suggested is left as it is // (0 rows). @@ -208,6 +238,8 @@ type Querier interface { // Records a new draft over plan id: its status and note, raising its // revision. 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. SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) // 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 // plan_id as planned. 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 // it is (0 rows). StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error) diff --git a/internal/db/shopping.sql.go b/internal/db/shopping.sql.go new file mode 100644 index 0000000..55b060a --- /dev/null +++ b/internal/db/shopping.sql.go @@ -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() +} diff --git a/internal/dinners/grounding.go b/internal/dinners/grounding.go index a09df3c..101a72e 100644 --- a/internal/dinners/grounding.go +++ b/internal/dinners/grounding.go @@ -68,6 +68,13 @@ const ( 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 // the request: the page fetch (recipes.Timeout), its photo download and // normalize_recipe (harness.DefaultTimeout), with some slack. diff --git a/internal/plan/push.go b/internal/plan/push.go index e002397..5758a4c 100644 --- a/internal/plan/push.go +++ b/internal/plan/push.go @@ -84,20 +84,20 @@ type pushDay struct { // // 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 -// is the one pushed. -func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (Plan, error) { +// is the one pushed, and revision is its revision as pushed, so the Supply +// 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 { - return Plan{}, &InvalidWeekError{Value: weekStart.String()} + return Plan{}, 0, &InvalidWeekError{Value: weekStart.String()} } planID, revision, days, err := s.pushDays(ctx, weekStart) if err != nil { - return Plan{}, err + return Plan{}, 0, err } events := make([]gcal.Event, len(days)) for i, d := range days { events[i] = d.event } - var p Plan record := func() error { return s.st.WithTx(ctx, func(tx *sql.Tx) error { 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 { - 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 diff --git a/internal/plan/push_test.go b/internal/plan/push_test.go index ac570a4..1a9d92f 100644 --- a/internal/plan/push_test.go +++ b/internal/plan/push_test.go @@ -135,7 +135,7 @@ func TestPushCreatesOneEventPerDay(t *testing.T) { e.connectCalendar(t) 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 { t.Fatalf("Push: %v", err) } @@ -197,7 +197,7 @@ func TestPushAgainUpdatesOnly(t *testing.T) { e := newEnv(t) e.connectCalendar(t) 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) } // 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.ResetEventWrites() - p, err := e.svc.Push(t.Context(), week) + p, _, err := e.svc.Push(t.Context(), week) if err != nil { t.Fatalf("second Push: %v", err) } @@ -229,7 +229,7 @@ func TestSwapThenPushUpdatesThatDay(t *testing.T) { e := newEnv(t) e.connectCalendar(t) 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) } before := e.google.Events(dinnersCal.ID) @@ -247,7 +247,7 @@ func TestSwapThenPushUpdatesThatDay(t *testing.T) { 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 { t.Fatalf("Push: %v", err) } @@ -296,7 +296,7 @@ func TestPushAcrossDaylightSavingTime(t *testing.T) { e := newEnv(t) e.connectCalendar(t) 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) } events := e.google.Events(dinnersCal.ID) @@ -330,12 +330,12 @@ func TestPushAcrossDaylightSavingTime(t *testing.T) { func TestPushNeedsAChosenCalendar(t *testing.T) { e := newEnv(t) 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) } e.google.SetCalendars(0, dinnersCal) 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) } if inserts, updates := e.google.EventWrites(); inserts+updates != 0 { @@ -348,7 +348,7 @@ func TestPushWithoutAPlan(t *testing.T) { e := newEnv(t) e.connectCalendar(t) 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) } 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. 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 if !errors.As(err, &calendar) || !calendar.Retryable() { 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, // not added twice. e.google.ResetEventWrites() - p, err := e.svc.Push(t.Context(), week) + p, _, err := e.svc.Push(t.Context(), week) if err != nil { t.Fatalf("retried Push: %v", err) } @@ -393,7 +393,7 @@ func TestPushPartialFailureKeepsAChangedPlanChanged(t *testing.T) { e := newEnv(t) e.connectCalendar(t) e.storePlan(t, week, fullWeek...) - first, err := e.svc.Push(t.Context(), week) + first, _, err := e.svc.Push(t.Context(), week) if err != nil { t.Fatal(err) } @@ -403,7 +403,7 @@ func TestPushPartialFailureKeepsAChangedPlanChanged(t *testing.T) { } } 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") } p, err := e.svc.Get(t.Context(), week) @@ -431,7 +431,7 @@ func TestPushToStaleCalendar(t *testing.T) { e.connectCalendar(t) e.storePlan(t, week, fullWeek...) 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) } e.assertUnsynced(t, week, plan.StatusDraft) @@ -444,7 +444,7 @@ func TestPushGrantNoLongerAccepted(t *testing.T) { e.connectCalendar(t) e.storePlan(t, week, fullWeek...) e.google.RevokeGrant() - _, err := e.svc.Push(t.Context(), week) + _, _, err := e.svc.Push(t.Context(), week) if !errors.Is(err, gcal.ErrReconnect) { t.Fatalf("Push = %v, want ErrReconnect", err) } @@ -479,12 +479,12 @@ func TestRefusedGrantKeepsPushedWeeks(t *testing.T) { dates := map[store.LocalDate][]string{} for _, w := range pushedWeeks { 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) } } 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) } e.assertRefused(t) @@ -497,7 +497,7 @@ func TestRefusedGrantKeepsPushedWeeks(t *testing.T) { // The refused token is not offered to Google again. 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) } 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.ResetEventWrites() - p, err := e.svc.Push(t.Context(), week) + p, _, err := e.svc.Push(t.Context(), week) if err != nil || p.Status != plan.StatusSynced { 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.connectCalendar(t) 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) } e.google.RevokeGrant() @@ -551,7 +551,7 @@ func TestPushFailsWhenPlanSwappedWhilePushing(t *testing.T) { defer release() pushed := make(chan error, 1) go func() { - _, err := e.svc.Push(t.Context(), week) + _, _, err := e.svc.Push(t.Context(), week) pushed <- err }() <-arrived @@ -596,7 +596,7 @@ func TestPushAfterChoosingAnotherCalendarWritesTheWholeWeek(t *testing.T) { e := newEnv(t) e.connectCalendar(t) 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 { t.Fatal(err) } @@ -640,14 +640,14 @@ func TestPushAfterChoosingAnotherCalendarWritesTheWholeWeek(t *testing.T) { // A push that fails part way leaves the plan changed. e.google.ResetEventWrites() 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") } 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) } - p, err = e.svc.Push(t.Context(), week) + p, _, err = e.svc.Push(t.Context(), week) if err != nil { t.Fatalf("Push: %v", err) } @@ -678,7 +678,7 @@ func TestChoosingAnotherCalendarWaitsForAPush(t *testing.T) { defer release() pushed := make(chan error, 1) go func() { - _, err := e.svc.Push(t.Context(), week) + _, _, err := e.svc.Push(t.Context(), week) pushed <- err }() <-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) } e.assertWeekOn(t, kitchenCal.ID, week, dates, fullWeek...) @@ -746,7 +746,7 @@ func TestDisconnectForgetsEveryPushedWeek(t *testing.T) { dates := map[store.LocalDate][]string{} for _, w := range pushedWeeks { 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) } } @@ -764,7 +764,7 @@ func TestDisconnectForgetsEveryPushedWeek(t *testing.T) { // Connecting again chooses no calendar, so nothing is pushed until one // is chosen; then a push writes the whole week to it. 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) } e.google.SetCalendars(0, dinnersCal, kitchenCal) @@ -773,7 +773,7 @@ func TestDisconnectForgetsEveryPushedWeek(t *testing.T) { } for _, w := range pushedWeeks { 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 { 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{} for _, w := range pushedWeeks { 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) } } @@ -816,7 +816,7 @@ func TestDisconnectWaitsForAPush(t *testing.T) { defer release() pushed := make(chan error, 1) go func() { - _, err := e.svc.Push(t.Context(), week) + _, _, err := e.svc.Push(t.Context(), week) pushed <- err }() <-arrived diff --git a/internal/server/aisettings_test.go b/internal/server/aisettings_test.go index cd11105..a4d9ed7 100644 --- a/internal/server/aisettings_test.go +++ b/internal/server/aisettings_test.go @@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler { t.Helper() 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), 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 { t.Fatal(err) } diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go index 1f7aef9..229b355 100644 --- a/internal/server/dinners_test.go +++ b/internal/server/dinners_test.go @@ -92,7 +92,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv { t.Fatal(err) } 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 { t.Fatal(err) } diff --git a/internal/server/google_test.go b/internal/server/google_test.go index 3035ec5..bb9f7e4 100644 --- a/internal/server/google_test.go +++ b/internal/server/google_test.go @@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv { } 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), - 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)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/login_test.go b/internal/server/login_test.go index 3555347..9fa481a 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler { t.Fatal(err) } 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 { t.Fatal(err) } diff --git a/internal/server/media_test.go b/internal/server/media_test.go index d301ace..c95c55a 100644 --- a/internal/server/media_test.go +++ b/internal/server/media_test.go @@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) { t.Fatal(err) } 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)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index dfe3b5c..a141938 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler { } logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil))) 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 { t.Fatal(err) } diff --git a/internal/server/plans.go b/internal/server/plans.go index cf81dc6..73940c2 100644 --- a/internal/server/plans.go +++ b/internal/server/plans.go @@ -1,6 +1,7 @@ package server import ( + "context" "errors" "log/slog" "net/http" @@ -9,6 +10,7 @@ import ( "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/shopping" "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 -// (Push to Calendar) and answers with the plan as synced. -func postPlanPush(logger *slog.Logger, svc *plan.Service) http.HandlerFunc { +// (Push to Calendar) and, once the push is recorded, makes the week's +// 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) { week, ok := weekStartOrFail(w, r, logger) if !ok { return } - p, err := svc.Push(r.Context(), week) + p, revision, err := svc.Push(r.Context(), week) if err != nil { writePlanPushError(w, r, logger, err) 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}) } } diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go index 1b67833..5d6ed74 100644 --- a/internal/server/plans_test.go +++ b/internal/server/plans_test.go @@ -23,6 +23,7 @@ import ( "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/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" ) @@ -36,6 +37,8 @@ type plansEnv struct { h http.Handler st *store.Store svc *plan.Service + shop *shopping.Service + runner *jobs.Runner questLog string // google is the fake Google the plan service pushes to; it starts // unconnected. @@ -46,6 +49,13 @@ type plansEnv struct { } 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() st := newTestStore(t) ctx := t.Context() @@ -79,8 +89,12 @@ func newPlansRouter(t *testing.T) plansEnv { if err != nil { 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), - 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 { t.Fatal(err) } @@ -114,7 +128,7 @@ func newPlansRouter(t *testing.T) plansEnv { 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} } diff --git a/internal/server/recipesearch_test.go b/internal/server/recipesearch_test.go index 34f2032..126a67e 100644 --- a/internal/server/recipesearch_test.go +++ b/internal/server/recipesearch_test.go @@ -22,7 +22,7 @@ const ( func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler { t.Helper() 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)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/server.go b/internal/server/server.go index 5de1285..931c1e3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -19,6 +19,7 @@ import ( "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/recipes" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" "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 // /api/v1/plans/{weekStart}/draft, PUT // /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, // PUT /api/v1/integrations/google/calendar and DELETE // /api/v1/integrations/google; runner serves @@ -54,7 +60,7 @@ type RouteMounter interface { // GET /media/photos/{id}-{size}.jpg; assets is the web build // 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. -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 { 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 { return nil, errors.New("server: nil plan service") } + if shopSvc == nil { + return nil, errors.New("server: nil shopping service") + } if googleSvc == nil { 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.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc)) apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc)) - apiV1.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc)) + 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.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc)) // connect and callback are top-level browser navigations, so they are diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 4ede01b..c735e54 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -30,6 +30,7 @@ import ( "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/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/suggest" "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)}, "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 { t.Fatal(err) } @@ -209,6 +210,29 @@ func newTestPlan(t *testing.T) *plan.Service { 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. func newTestBox(t *testing.T) *secretbox.Box { t.Helper() @@ -263,7 +287,7 @@ func TestNewRouterRequiresAISettings(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), 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") } } @@ -272,7 +296,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) { t.Parallel() 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") } } @@ -281,7 +305,7 @@ func TestNewRouterRequiresPhotoFiles(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), 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") } } @@ -290,7 +314,7 @@ func TestNewRouterRequiresSettings(t *testing.T) { t.Parallel() 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") } } @@ -299,7 +323,7 @@ func TestNewRouterRequiresDinners(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), 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") } } @@ -308,7 +332,7 @@ func TestNewRouterRequiresSuggestions(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), 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") } } @@ -317,16 +341,25 @@ func TestNewRouterRequiresPlan(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), 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") } } +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) { 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, 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") } } @@ -335,7 +368,7 @@ func TestNewRouterRequiresJobRunner(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), 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") } } diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 8a4398f..45b6d7c 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 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 { t.Fatal(err) } diff --git a/internal/server/shopping.go b/internal/server/shopping.go new file mode 100644 index 0000000..e62ab53 --- /dev/null +++ b/internal/server/shopping.go @@ -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") + } +} diff --git a/internal/server/shopping_test.go b/internal/server/shopping_test.go new file mode 100644 index 0000000..1698821 --- /dev/null +++ b/internal/server/shopping_test.go @@ -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) + } +} diff --git a/internal/server/suggestions_test.go b/internal/server/suggestions_test.go index 2595b13..7e4bb5a 100644 --- a/internal/server/suggestions_test.go +++ b/internal/server/suggestions_test.go @@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S t.Fatal(err) } 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 { t.Fatal(err) } diff --git a/internal/shopping/archive.go b/internal/shopping/archive.go new file mode 100644 index 0000000..c87ad63 --- /dev/null +++ b/internal/shopping/archive.go @@ -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 +} diff --git a/internal/shopping/archive_test.go b/internal/shopping/archive_test.go new file mode 100644 index 0000000..00fff92 --- /dev/null +++ b/internal/shopping/archive_test.go @@ -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) + } + } +} diff --git a/internal/shopping/export_test.go b/internal/shopping/export_test.go new file mode 100644 index 0000000..3718a07 --- /dev/null +++ b/internal/shopping/export_test.go @@ -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 } diff --git a/internal/shopping/shopping.go b/internal/shopping/shopping.go new file mode 100644 index 0000000..5a5451b --- /dev/null +++ b/internal/shopping/shopping.go @@ -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 +} diff --git a/internal/shopping/shopping_test.go b/internal/shopping/shopping_test.go new file mode 100644 index 0000000..a38f37e --- /dev/null +++ b/internal/shopping/shopping_test.go @@ -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"}) +} diff --git a/internal/shopping/title_test.go b/internal/shopping/title_test.go new file mode 100644 index 0000000..00cf399 --- /dev/null +++ b/internal/shopping/title_test.go @@ -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)) + } +} diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index 4349f60..f674930 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) { if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { 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" { - 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) + 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,0016_shopping_list_untitled", got) } } @@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) { if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { 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" { - 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) + 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,0016_shopping_list_untitled", got) } if !tableExists(t, s, "dinners") { 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) } } + +// 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") + } +} diff --git a/internal/store/repository_test.go b/internal/store/repository_test.go index 58101e8..95abc51 100644 --- a/internal/store/repository_test.go +++ b/internal/store/repository_test.go @@ -43,7 +43,7 @@ var repoParents = map[string]repoRow{ }}, "shopping_groups": {"shopping_groups", map[string]any{ "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, "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"}, 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, }}, update: map[string]any{"position": int64(3)}, diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go index 8750a2a..1607353 100644 --- a/internal/store/schema_test.go +++ b/internal/store/schema_test.go @@ -136,6 +136,7 @@ func testRow(table string, i int) map[string]any { case "shopping_groups": r["list_id"] = rowID("shopping_lists", 0) r["dinner_id"] = rowID("dinners", 0) + r["date"] = fmt.Sprintf("2026-01-%02d", i+5) r["position"] = i case "shopping_items": r["group_id"] = rowID("shopping_groups", 0) diff --git a/migrations/0016_shopping_list_untitled.sql b/migrations/0016_shopping_list_untitled.sql new file mode 100644 index 0000000..2c54101 --- /dev/null +++ b/migrations/0016_shopping_list_untitled.sql @@ -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; diff --git a/migrations/queries/shopping.sql b/migrations/queries/shopping.sql new file mode 100644 index 0000000..56bf1c3 --- /dev/null +++ b/migrations/queries/shopping.sql @@ -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; diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index a3dde74..35fcc2c 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -8,6 +8,7 @@ import App, { routes } from './App.tsx' import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx' import { InDataRouter } from './test/dataRouter.tsx' import { dinnerBody } from './test/dinners.ts' +import { defaultWeek } from './plan/week.ts' afterEach(() => { Reflect.deleteProperty(window, 'matchMedia') @@ -31,6 +32,8 @@ function renderAppAt(path: string, location = fakeLocation(path)) { const SCREENS = { '/onboarding': true, '/plan': true, + '/plan/shopping': true, + '/plan/shopping/archive': true, '/discover': true, '/settings': true, '/settings/ai': true, @@ -105,6 +108,41 @@ describe('App routes', () => { expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument() }) + it('/plan/shopping renders the Supply run inside the shell', async () => { + mockViewport(390) + const week = defaultWeek(HOUSEHOLD_SETTINGS, new Date()) + stubFetchByPath({ + ...SIGNED_IN, + [`/api/v1/shopping/${week}`]: () => + jsonResponse(200, { + weekStart: week, + label: 'Week 41 · Oct 5 – 11', + title: null, + status: 'current', + sent_to_chat_at: null, + needed_count: 1, + total_count: 1, + groups: [{ dinner: { id: 'd1', name: 'Beef tacos' }, date: week, day: 'Monday', items: [{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true }] }], + }), + }) + renderAt('/plan/shopping') + const heading = await screen.findByRole('heading', { level: 1, name: 'Shopping list' }) + expect(screen.getByRole('main')).toContainElement(heading) + expect(await screen.findByRole('checkbox', { name: 'Ground beef' })).toBeChecked() + }) + + it('/plan/shopping/archive renders the Supply archive inside the shell', async () => { + mockViewport(390) + stubFetchByPath({ + ...SIGNED_IN, + '/api/v1/shopping/archive?by=week': () => jsonResponse(200, { by: 'week', periods: [] }), + }) + renderAt('/plan/shopping/archive') + const heading = await screen.findByRole('heading', { level: 1, name: 'Supply archive' }) + expect(screen.getByRole('main')).toContainElement(heading) + expect(await screen.findByText('Nothing archived yet')).toBeInTheDocument() + }) + it('/discover renders the Side quests deck inside the shell', async () => { mockViewport(390) stubFetchByPath({ diff --git a/web/src/App.tsx b/web/src/App.tsx index de51b4f..59c2b85 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,6 +16,9 @@ import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx' import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx' import { SettingsPage } from './settings/SettingsPage.tsx' import { AppShell } from './shell/AppShell.tsx' +import { SupplyRunSync } from './shopping/api.ts' +import { SupplyArchivePage } from './shopping/SupplyArchivePage.tsx' +import { SupplyRunPage } from './shopping/SupplyRunPage.tsx' // Every route the app serves, with the heading its placeholder page shows. export const routes = [ @@ -39,6 +42,8 @@ export const routes = [ // The screens built so far; every other route shows its placeholder page. const screens: Partial> = { '/plan': , + '/plan/shopping': , + '/plan/shopping/archive': , '/discover': , '/settings': , '/settings/ai': , @@ -86,6 +91,7 @@ export default function App({ api, serviceWorker }: AppProps) { + diff --git a/web/src/AuthBootstrap.tsx b/web/src/AuthBootstrap.tsx index 774ca5c..c3d9522 100644 --- a/web/src/AuthBootstrap.tsx +++ b/web/src/AuthBootstrap.tsx @@ -20,7 +20,7 @@ export const meQueryKey = [ME_PATH] as const export function AuthBootstrap({ children }: { children: ReactNode }) { const api = useApi() const forbidden = useSyncExternalStore(api.subscribe, api.forbidden) - const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity, networkMode: 'always' }) + const me = useQuery({ queryKey: meQueryKey, queryFn: () => api.loadMe(), staleTime: Infinity, networkMode: 'always' }) // fetch rejects with a TypeError only when the network fails. const savedPages = me.isError && me.error instanceof TypeError && api.session.verified() const { refetch } = me diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 15390e7..8155382 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -125,6 +125,66 @@ describe('apiFetch', () => { expect(api.forbidden()).toBe(false) }) + it('shares a csrf refresh between requests, but one whose signal aborts stops waiting and the next refreshes afresh', async () => { + // The first refresh answers only when answerFirst is called; every other + // request answers at once. + let answerFirst!: (response: Response) => void + const mes: (AbortSignal | null | undefined)[] = [] + const fetchMock = vi.fn((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((input, init) => { + if (String(input) !== '/api/v1/me') return Promise.resolve(errorResponse(403, 'csrf')) + if (mes.push(init!.signal!) === 1) return Promise.resolve(jsonResponse(200, ME)) + return new Promise((_, reject) => init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason as Error))) + }) + vi.stubGlobal('fetch', fetchMock) + const network = createNetworkStatus(window) + const api = createApiClient(fakeLocation(), network, createVerifiedSession(localStorage)) + await api.loadMe() + + const timedOut = new AbortController() + const bounded = api.apiFetch('/api/v1/jobs', { method: 'POST', body: {}, signal: timedOut.signal }) + await vi.waitFor(() => expect(mes).toHaveLength(2)) + timedOut.abort(new DOMException('no answer in time', 'TimeoutError')) + await expect(bounded).rejects.toMatchObject({ name: 'TimeoutError' }) + expect(mes[1]!.aborted).toBe(true) + expect(network.offline()).toBe(false) + }) + it('marks any other 403 as not in this household and notifies subscribers', async () => { const fetchMock = stubFetch(errorResponse(403, 'forbidden')) const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) @@ -153,6 +213,18 @@ describe('apiFetch', () => { }) describe('loadMe', () => { + it('aborts /me with its signal', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((_, init) => new Promise((_, reject) => init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason as Error)))), + ) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) + const timedOut = new AbortController() + const me = api.loadMe(timedOut.signal) + timedOut.abort(new DOMException('no answer in time', 'TimeoutError')) + await expect(me).rejects.toMatchObject({ name: 'TimeoutError' }) + }) + it('rejects a /me response without a CSRF token', async () => { stubFetch(jsonResponse(200, { ...ME, csrfToken: '' })) const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1fc884d..ae6821b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -41,6 +41,10 @@ export type ApiRequest = { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' // Sent as JSON. A GET must not have one. body?: unknown + // Aborts the request, including the /me refresh a csrf 403 makes it wait + // for; it then rejects with the signal's reason. No request has a deadline + // unless its caller aborts it. + signal?: AbortSignal } // ApiRead is a GET's decoded response body, and whether the service worker @@ -55,8 +59,9 @@ export type ApiClient = { // apiRead is apiFetch's GET that also says whether the body is the service // worker's cached copy rather than the server's answer. apiRead(path: string): Promise> - // loadMe fetches GET /api/v1/me and caches its CSRF token. - loadMe(): Promise + // loadMe fetches GET /api/v1/me and caches its CSRF token; signal aborts + // it, as it does an apiFetch. + loadMe(signal?: AbortSignal): Promise // forbidden reports whether a request was refused because the signed-in // account is not in this household; subscribe is notified when it changes. forbidden(): boolean @@ -70,15 +75,20 @@ export type ApiClient = { session: VerifiedSession } +// Refresh is the /me request refreshMe shares: controller aborts it, waiting +// counts the requests with a signal still waiting for it, and unbounded says +// whether one without a signal is. +type Refresh = { me: Promise; controller: AbortController; waiting: number; unbounded: boolean } + // createApiClient builds the app's API client. location is window.location in // the app: a 401 sends it to /auth/login with the current path to return to. export function createApiClient(location: ApiLocation, network: NetworkStatus, session: VerifiedSession): ApiClient { let csrfToken: string | null = null - let refreshing: Promise | null = null + let refreshing: Refresh | null = null let forbidden = false const listeners = new Set<() => void>() - async function send(method: string, path: string, body: unknown): Promise { + async function send(method: string, path: string, body: unknown, signal: AbortSignal | undefined): Promise { const headers: Record = { Accept: 'application/json' } if (body !== undefined) headers['Content-Type'] = 'application/json' if (method !== 'GET') { @@ -97,7 +107,7 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s } let response: Response try { - response = await fetch(path, { method, headers, credentials: 'same-origin', body: json }) + response = await fetch(path, { method, headers, credentials: 'same-origin', body: json, signal }) } catch (error) { // fetch rejects with a TypeError when the network fails; an abort is a // DOMException and says nothing about the network. @@ -112,12 +122,46 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s } // refreshMe shares one in-flight /me request between every request that - // got a csrf 403 at the same time. - function refreshMe(): Promise { - refreshing ??= loadMe().finally(() => { - refreshing = null + // got a csrf 403 at the same time. A request whose signal aborts stops + // waiting for it at once, and it is no longer shared: the next csrf 403 + // starts a fresh /me rather than wait on one that may never answer. Once + // every request with a signal waiting for it has stopped, and none without + // one waits, it is aborted. + function refreshMe(signal: AbortSignal | undefined): Promise { + 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(path: string, request: ApiRequest = {}): Promise { @@ -138,14 +182,14 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s if (method === 'GET' && request.body !== undefined) { throw new Error(`GET ${path}: a GET request cannot carry a body`) } - let response = await send(method, url, request.body) + let response = await send(method, url, request.body, request.signal) if (response.status === 403) { const error = await parseForbidden(method, path, response) // Only a non-GET carries the token, so only it can be refused for it. if (error.code !== 'csrf' || method === 'GET') throw error // The session's token changed: refresh it once and retry once. - await refreshMe() - response = await send(method, url, request.body) + await refreshMe(request.signal) + response = await send(method, url, request.body, request.signal) if (response.status === 403) throw await parseForbidden(method, path, response) } return { body: await decode(method, path, response), cached: response.headers.has(cacheFallbackHeader) } @@ -176,8 +220,8 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s return (await response.json()) as T } - async function loadMe(): Promise { - const me = await apiFetch(ME_PATH) + async function loadMe(signal?: AbortSignal): Promise { + const me = await apiFetch(ME_PATH, { signal }) if ( !isRecord(me) || typeof me.name !== 'string' || diff --git a/web/src/plan/PlanPage.module.css b/web/src/plan/PlanPage.module.css index 49acb3b..86298fd 100644 --- a/web/src/plan/PlanPage.module.css +++ b/web/src/plan/PlanPage.module.css @@ -258,8 +258,8 @@ color: var(--color-neutral-700); } -/* An action with nothing behind it yet (the shopping list until Feature - 17): it keeps focus, so hovering, tapping or focusing it shows the +/* An action with nothing behind it yet (Send to Google Chat until Feature + 19): it keeps focus, so hovering, tapping or focusing it shows the tooltip. */ .unavailable { position: relative; @@ -414,6 +414,31 @@ text-align: center; } +/* What became of the week's Supply run after a push. */ +.pushed { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + text-align: center; + color: var(--color-neutral-700); +} + +/* The way to the week's Supply run under the Draft board on mobile. */ +.shoppingLink { + display: inline-flex; + align-self: center; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 44px; + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + color: var(--color-accent-700); + text-decoration: none; +} + /* A push failure's way to the Google Calendar settings. */ .settingsLink { color: inherit; diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index f3d73bf..1f027a9 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -1,9 +1,10 @@ -import { QueryClientProvider } from '@tanstack/react-query' +import { QueryClientProvider, type QueryClient } from '@tanstack/react-query' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import { MemoryRouter } from 'react-router' +import { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { SupplyRunPage } from '../shopping/SupplyRunPage.tsx' import { cacheFallbackHeader } from '../sw/routes.ts' import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' import { declaredStyle } from '../test/cssRules.ts' @@ -52,9 +53,33 @@ function planBody(status: string, names = NAMES) { } // syncedNow is the plan of the week of Oct 5 as a push answers it: synced -// at the tests' noon. -function syncedNow() { - return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z' } +// at the tests' noon, with what became of the week's Supply run, made +// unless told otherwise. +function syncedNow(supplyRun: { status: string; error: { code: string; message: string; retryable: boolean } | null } = GENERATED) { + return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z', supply_run: supplyRun } +} + +// The Supply run outcomes a push answers with, as the server sends them. +const GENERATED = { status: 'generated', error: null } +const ARCHIVED = { + status: 'archived', + error: { code: 'archived', message: 'the Supply run is in the Supply archive', retryable: false }, +} +const PLAN_CHANGED = { + status: 'failed', + error: { + code: 'plan_changed', + message: 'the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again', + retryable: true, + }, +} +const SUPPLY_RUN_FAILED = { + status: 'failed', + error: { + code: 'supply_run_failed', + message: 'the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run', + retryable: true, + }, } // deferred is a response the test sends when it chooses. @@ -100,8 +125,12 @@ const lists = { jsonResponse(200, { dinners: [dinner('d0', 'Beef tacos', 'easy', 30), dinner('r1', 'Fish tacos', 'medium', 35)] }), } +// SUPPLY_RUN is the Supply run of a week, as GET /api/v1/shopping/{weekStart}. +const SUPPLY_RUN = /^GET \/api\/v1\/shopping\/\d{4}-\d{2}-\d{2}$/ + // stubServer answers "METHOD path" requests from responders, with /me and -// the household settings; any other request fails the test. +// the household settings, and no Supply run (404) for a week given none; +// any other request fails the test. function stubServer(responders: Record) { const all: Record = { 'GET /api/v1/me': () => jsonResponse(200, ME), @@ -110,7 +139,7 @@ function stubServer(responders: Record) { } const fetchMock = vi.fn((input, init) => { const key = `${init?.method ?? 'GET'} ${String(input)}` - const respond = Object.hasOwn(all, key) ? all[key] : undefined + const respond = Object.hasOwn(all, key) ? all[key] : SUPPLY_RUN.test(key) ? () => errorResponse(404, 'not_found') : undefined if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) return Promise.resolve(respond(init)) }) @@ -131,21 +160,67 @@ function planReads(fetchMock: Mock) { } // renderPage loads /me (for the CSRF token every write needs) and renders -// the Plan page. -async function renderPage() { +// the Plan page, over queryClient, with shopping as the Supply run screen. +async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = ) { const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) await api.loadMe() return render( - + - + + } /> + + , ) } +// 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 ( + <> +

Opened {pathname + search}

+ Back to the Plan + + ) +} + +// 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 ( + <> + + Back to the Plan + + ) +} + +// supplyRun is the Supply run of the week of Oct 5 with needed of its 32 +// items still to buy. +function supplyRun(needed: number) { + return { + weekStart: '2026-10-05', + label: 'Week 41 · Oct 5 – 11', + title: null, + status: 'current', + sent_to_chat_at: null, + needed_count: needed, + total_count: 32, + groups: [], + } +} + // Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so // the page opens on this week. beforeEach(() => { @@ -392,6 +467,236 @@ describe('PlanPage', () => { expect(planReads(fetchMock)).toBe(2) }) + it("reads the week's Supply run again once a push has made it, and says it was made", async () => { + let run = () => errorResponse(404, 'not_found') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${PUSH}`]: () => { + run = () => jsonResponse(200, supplyRun(28)) + return jsonResponse(200, syncedNow(GENERATED)) + }, + 'GET /api/v1/shopping/2026-10-05': () => run(), + }) + await renderPage() + + expect(await screen.findByRole('link', { name: 'Shopping list' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") + // The list made is read at once, not after the cached 404 goes stale. + expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument() + expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/shopping/2026-10-05')).toHaveLength(2) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('says a Supply run in the Supply archive was left as it was', async () => { + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(ARCHIVED)), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent("This week's Supply run is in the Supply archive, so it was left as it was.") + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('says no Supply run was made when the plan changed after it was pushed, reads the plan again, and pushes again on Retry', async () => { + let plan = planBody('draft') + let push = () => { + plan = planBody('changed', ['Lasagna', ...NAMES.slice(1)]) + return jsonResponse(200, syncedNow(PLAN_CHANGED)) + } + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan), + [`POST ${PUSH}`]: () => push(), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent( + 'Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push it again.', + ) + expect(screen.queryByRole('status')).not.toBeInTheDocument() + // The plan edited meanwhile is the one shown, not the one pushed. + expect(await screen.findByText('Lasagna')).toBeInTheDocument() + expect(screen.getByText('Changes not pushed to Google Calendar')).toBeInTheDocument() + expect(planReads(fetchMock)).toBe(2) + + push = () => jsonResponse(200, syncedNow(GENERATED)) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) + }) + + it('says the Supply run could not be made after the push, and makes the shopping list again', async () => { + let generate = () => errorResponse(500, 'internal') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), + 'POST /api/v1/shopping/2026-10-05/generate': () => generate(), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + // Calendar has the plan, but the push is not reported a quiet success. + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") + expect(screen.queryByRole('status')).not.toBeInTheDocument() + + fireEvent.click(within(alert).getByRole('button', { name: 'Make the shopping list' })) + expect( + await screen.findByText("Pushed to Calendar, but this week's Supply run couldn't be made. Couldn't make the shopping list. internal message"), + ).toBeInTheDocument() + + generate = () => jsonResponse(200, { ...supplyRun(28), skipped: [] }) + fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Make the shopping list' })) + expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument() + expect(posts(fetchMock)).toEqual([ + `${PUSH} undefined`, + '/api/v1/shopping/2026-10-05/generate undefined', + '/api/v1/shopping/2026-10-05/generate undefined', + ]) + }) + + it('no longer says the Supply run could not be made once the shopping list is made, after leaving the Plan and coming back', async () => { + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), + 'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") + + // A Supply run never made is still said after leaving the Plan and + // coming back. + fireEvent.click(screen.getByRole('link', { name: 'Shopping list' })) + fireEvent.click(await screen.findByRole('link', { name: 'Back to the Plan' })) + expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") + + fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Make the shopping list' })) + expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") + + // Once made, it is not said again to be unmade. + fireEvent.click(screen.getByRole('link', { name: 'Shopping list · 28' })) + fireEvent.click(await screen.findByRole('link', { name: 'Back to the Plan' })) + expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument() + }) + + it('still says the Supply run could not be made when making the shopping list fails after leaving the week', async () => { + const made = deferred() + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), + 'POST /api/v1/shopping/2026-10-05/generate': () => made.response, + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + fireEvent.click(within(await screen.findByRole('alert')).getByRole('button', { name: 'Make the shopping list' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Making the shopping list…') + + // The week is left while the shopping list is being made, which fails. + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + await act(async () => made.send(errorResponse(500, 'internal'))) + + // Coming back, the Supply run is still said to be unmade, and can be + // made again. + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") + expect(within(alert).getByRole('button', { name: 'Make the shopping list' })).toBeEnabled() + }) + + it('no longer says the Supply run could not be made once the shopping list is made after leaving the week', async () => { + const made = deferred() + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), + 'POST /api/v1/shopping/2026-10-05/generate': () => made.response, + }) + const queryClient = createQueryClient() + await renderPage(queryClient) + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + fireEvent.click(within(await screen.findByRole('alert')).getByRole('button', { name: 'Make the shopping list' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Making the shopping list…') + + // The push is kept while the week is left, until the list is made: it + // is then settled, not merely swept with the week. + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + expect(pushesKept(queryClient)).toBe(1) + await act(async () => made.send(jsonResponse(200, { ...supplyRun(28), skipped: [] }))) + expect(pushesKept(queryClient)).toBe(0) + + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument() + }) + + it('no longer says the Supply run could not be made once the shopping list is made on the Supply run screen', async () => { + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), + 'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }), + }) + const queryClient = createQueryClient() + await renderPage(queryClient, ) + + 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, ) + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") + + fireEvent.click(screen.getByRole('link', { name: 'Shopping list' })) + fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' })) + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't make the shopping list.") + expect(pushesKept(queryClient)).toBe(1) + + fireEvent.click(screen.getByRole('link', { name: 'Back to the Plan' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") + expect(within(alert).getByRole('button', { name: 'Make the shopping list' })).toBeEnabled() + }) + it('pushes nothing again until the changed plan is read and shown', async () => { let read: () => Response | Promise = () => jsonResponse(200, planBody('draft')) let push = () => errorResponse(409, 'plan_changed') @@ -729,6 +1034,85 @@ describe('PlanPage', () => { expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument() }) + it("links to the week's Supply run under the Draft board, saying how many items are still to buy", async () => { + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + 'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, supplyRun(19)), + }) + await renderPage() + + const shopping = await screen.findByRole('link', { name: 'Shopping list · 19' }) + expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05') + expect(declaredStyle(shopping).color).toBe('var(--color-accent-700)') + // It follows the push button and the sync status. + const push = screen.getByRole('button', { name: 'Looks good — push to Calendar' }) + expect(push.compareDocumentPosition(shopping) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + fireEvent.click(shopping) + expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument() + }) + + it("shows a failed read of the week's shopping list with Retry, the link still opening the Supply run", async () => { + let failing = true + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + 'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(19))), + }) + await renderPage() + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message") + expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05') + // Retry reads it again. + failing = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + const shopping = await screen.findByRole('link', { name: 'Shopping list · 19' }) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/shopping/2026-10-05')).toHaveLength(2) + fireEvent.click(shopping) + expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument() + }) + + it("drops the count when reading the week's shopping list again fails, showing the failure with Retry", async () => { + let failing = false + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + 'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(19))), + }) + await renderPage() + expect(await screen.findByRole('link', { name: 'Shopping list · 19' })).toBeInTheDocument() + + // Coming back to the page once the Supply run is stale reads it again, + // and that read fails. + failing = true + vi.setSystemTime(new Date('2026-10-07T16:01:00Z')) + act(() => { + window.dispatchEvent(new Event('visibilitychange')) + }) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message") + expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05') + expect(screen.queryByRole('link', { name: 'Shopping list · 19' })).not.toBeInTheDocument() + // Retry reads it again, and the count is back. + failing = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByRole('link', { name: 'Shopping list · 19' })).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('links to the Supply run with no count while the week has no shopping list', async () => { + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) }) + await renderPage() + + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + await waitFor(() => expect(fetchMock.mock.calls.map(([input]) => String(input))).toContain('/api/v1/shopping/2026-10-05')) + const shopping = screen.getByRole('link', { name: 'Shopping list' }) + expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05') + // No list is not a failure. + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + fireEvent.click(shopping) + expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument() + }) + it('moves between weeks with the previous and next week buttons', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), @@ -1026,11 +1410,11 @@ describe.each([ expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop') - // Actions: the shopping list unavailable, saying why; Rebuild week - // outlined and Push to Calendar primary, both working. - const shopping = screen.getByRole('button', { name: 'Shopping list' }) - expect(shopping).toHaveAttribute('aria-disabled', 'true') - expect(shopping).toHaveAccessibleDescription('The shopping list arrives once the Supply run is built.') + // Actions: the shopping list, with no count while the week has no + // Supply run, and Rebuild week outlined; Push to Calendar primary. + const shopping = await screen.findByRole('link', { name: 'Shopping list' }) + expect(shopping).not.toHaveAttribute('aria-disabled') + expect(shopping).not.toHaveAccessibleDescription() const push = screen.getByRole('button', { name: 'Push to Calendar' }) expect(push).toBeEnabled() expect(push).not.toHaveAttribute('aria-disabled') @@ -1038,7 +1422,6 @@ describe.each([ const rebuild = screen.getByRole('button', { name: 'Rebuild week' }) expect(rebuild).toBeEnabled() expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)') - fireEvent.click(shopping) expect(posts(fetchMock)).toEqual([]) // Mobile's own pieces are not shown. expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument() @@ -1098,6 +1481,48 @@ describe.each([ expect(status).toHaveTextContent('Synced to Google Calendar 1 hr ago') }) + it("shows a failed read of the week's shopping list with Retry, the action still opening the Supply run", async () => { + let failing = true + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + 'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(28))), + ...lists, + }) + await renderPage() + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message") + expect(screen.getByText('Spaghetti bolognese')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05') + failing = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + const shopping = await screen.findByRole('link', { name: 'Shopping list · 28' }) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + fireEvent.click(shopping) + expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument() + }) + + it("opens the week's Supply run from the Shopping list action, saying how many items are still to buy", async () => { + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + 'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, supplyRun(28)), + ...lists, + }) + await renderPage() + + const shopping = await screen.findByRole('link', { name: 'Shopping list · 28' }) + expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05') + expect(declaredStyle(shopping).borderColor).toBe('var(--color-neutral-300)') + // Another week's action opens that week's, which has no list yet. + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-12') + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + fireEvent.click(await screen.findByRole('link', { name: 'Shopping list · 28' })) + expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument() + }) + it('says changes to a pushed plan are not on Google Calendar', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), ...lists }) await renderPage() diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 4cf8667..87124ba 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -7,8 +7,10 @@ import { QUEST_LOG_KEY } from '../discover/api.ts' import { Confirm } from '../settings/LeaveGuard.tsx' import { methodLabels } from '../settings/RegularsSettingsPage.tsx' import { formatClockTime, useHouseholdSettings } from '../settings/api.ts' +import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts' import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts' import { Button, type ButtonVariant } from '../ui/Button.tsx' +import buttonStyles from '../ui/Button.module.css' import { EmptyState } from '../ui/EmptyState.tsx' import { ErrorBanner } from '../ui/ErrorBanner.tsx' import { Icon } from '../ui/Icon.tsx' @@ -29,6 +31,7 @@ import { type PlanDay, type PlanDinner, type PlanStatus, + type PushedSupplyRun, type Swap, } from './api.ts' import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx' @@ -79,10 +82,6 @@ const settingsFailures: Record = { }, } -// SHOPPING_HINT is why the shopping list is unavailable. -// TODO(Feature 17): open the Supply run, with its count, once it is built. -const SHOPPING_HINT = 'The shopping list arrives once the Supply run is built.' - // syncStatuses is the plan manager's line on whether the plan is on Calendar. const syncStatuses: Record = { draft: 'Not pushed to Google Calendar yet', @@ -90,6 +89,13 @@ const syncStatuses: Record = { 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, string> = { + generated: "Made this week's Supply run from the plan.", + archived: "This week's Supply run is in the Supply archive, so it was left as it was.", +} + // duration is minutes as the plan manager's cards say it: "45 min", "6 hr", // "1 hr 30 min", with no-break spaces so a narrow card wraps the line // between the method and the duration, never inside the duration. @@ -195,6 +201,22 @@ function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone ) } +// ShoppingList opens the week's Supply run: an outline action in the plan +// manager's header, a grape link under the Draft board on mobile. Once the +// week has a Supply run it says how many items are still to buy, "Shopping +// list · 28"; until then, while that is read, or when reading it failed, it +// says no count, and the Supply run screen offers to make the list or shows +// the failure. +function ShoppingList({ weekStart, neededCount, layout }: { weekStart: string; neededCount: number | undefined; layout: Breakpoint }) { + const className = layout === 'mobile' ? styles.shoppingLink : [buttonStyles.button, buttonStyles.outline, styles.action].join(' ') + return ( + + + {neededCount !== undefined ? `Shopping list · ${neededCount}` : 'Shopping list'} + + ) +} + // SkeletonTiles stands in for the seven days while the plan loads or the AI // drafts it, as two columns of tiles on mobile and seven columns wider. function SkeletonTiles({ label, layout, children }: { label: string; layout: Breakpoint; children?: string }) { @@ -217,13 +239,16 @@ function SkeletonTiles({ label, layout, children }: { label: string; layout: Bre // the plan is read again. Rebuilding a plan pushed to Calendar asks first, // and sends confirm. A failed draft is shown with Retry; no plan is made up. // -// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week, Push to -// Calendar and the unavailable Shopping list are actions in the header, -// over the plan's sync status and the 7-column grid. +// From tablet up it is the plan manager (mocks 2c, 2d): Shopping list, +// Rebuild week and Push to Calendar are actions in the header, over the +// plan's sync status and the 7-column grid. On mobile the Shopping list +// link sits under the push button. // // Push to Calendar, on either layout, sends the plan shown and shows it as // synced once the server answers; a failure says why, linking to the Google -// Calendar settings when only they can fix it. +// Calendar settings when only they can fix it. What became of the week's +// Supply run is said too: made, left as it was in the Supply archive, or +// not made, with the way to make it. function WeekBoard({ weekStart, timezone, @@ -243,6 +268,9 @@ function WeekBoard({ const [confirming, setConfirming] = useState(false) const swap = useSwapDay(weekStart) const push = usePushToCalendar(weekStart) + const run = useSupplyRun(weekStart) + // Makes the week's Supply run again after a push could not make it. + const generate = useGenerateSupplyRun(weekStart) const pushing = push.pending // The date of the day being swapped, while the swap sheet is open. const [swappingDate, setSwappingDate] = useState(null) @@ -312,6 +340,7 @@ function WeekBoard({ if (!canPush) return swap.dismiss() push.reset() + generate.reset() push.mutate() } @@ -323,6 +352,7 @@ function WeekBoard({ setJobId(null) swap.dismiss() push.reset() + generate.reset() mutate( { weekStart, confirm }, { @@ -341,8 +371,33 @@ function WeekBoard({ else start(false) } + const retryPush = cached ? () => void plan.refetch() : pushToCalendar + // makeShoppingList makes the week's Supply run again after a push could + // not make it. Once made, here or on the Supply run screen, the push is + // settled (useGenerateSupplyRun). A failure is the mutation's state, which + // generate.error reports. + function makeShoppingList() { + generate.mutate() + } + // A push's Supply run made again on the Plan stands for the push's, which + // is settled. + const supplyRun: PushedSupplyRun | null = generate.isSuccess ? { status: 'generated', error: null } : push.supplyRun const failure = push.error !== null ? ( - void plan.refetch() : pushToCalendar} /> + + ) : supplyRun?.status === 'failed' ? ( + supplyRun.error.code === 'plan_changed' ? ( + + Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push + it again. + + ) : ( + + 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}`} + + ) ) : swap.failed !== null ? ( swapIn(swap.failed!.swap)}> Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message} @@ -360,11 +415,27 @@ function WeekBoard({ ) : ( Couldn't check on the draft. {job.error.message} ) + const pushed = supplyRun !== null && supplyRun.status !== 'failed' && ( +

+ {supplyRunOutcomes[supplyRun.status]} +

+ ) + // 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 = + const supplyRunFailure = run.isError && ( + void run.refetch()}>Couldn't load this week's shopping list. {run.error.message} + ) // swapIn sends the swap chosen in the sheet and closes it. function swapIn(chosen: Swap) { setSwappingDate(null) push.reset() + generate.reset() swap.mutate(chosen) } // dropIn swaps in a dinner dragged onto a day. A drag is not picked up, @@ -462,6 +533,8 @@ function WeekBoard({ {pushing ? 'Pushing to Calendar…' : 'Looks good — push to Calendar'} + {shoppingList} + {supplyRunFailure} {sheet} ) @@ -493,10 +566,7 @@ function WeekBoard({
{plan.data ? : }
- - - Shopping list - + {shoppingList} + )} + + ) +} + +// 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('week') + return ( +
+
+ {!panel && ( + void navigate('/plan/shopping')}> + + + )} +

Supply archive

+
+ + +
+ ) +} + +// 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 ( +
+
+ +
+ +
+ ) + } + return params.has(WEEK_PARAM) ? : +} diff --git a/web/src/shopping/SupplyRunPage.module.css b/web/src/shopping/SupplyRunPage.module.css new file mode 100644 index 0000000..b682c0c --- /dev/null +++ b/web/src/shopping/SupplyRunPage.module.css @@ -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; +} diff --git a/web/src/shopping/SupplyRunPage.test.tsx b/web/src/shopping/SupplyRunPage.test.tsx new file mode 100644 index 0000000..d48f353 --- /dev/null +++ b/web/src/shopping/SupplyRunPage.test.tsx @@ -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 = {}, 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((resolve) => { + send = resolve + }) + return { response, send } +} + +type Responder = (init: RequestInit | undefined) => Response | Promise + +// stubServer answers "METHOD path" requests from responders, with /me and +// the household settings; any other request fails the test. +function stubServer(responders: Record) { + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + 'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), + ...responders, + } + const fetchMock = vi.fn((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) { + 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( + + + + + + + , + ) +} + +// 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') + }) +}) diff --git a/web/src/shopping/SupplyRunPage.tsx b/web/src/shopping/SupplyRunPage.tsx new file mode 100644 index 0000000..f9ae536 --- /dev/null +++ b/web/src/shopping/SupplyRunPage.tsx @@ -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 = ( + <> + {item.name} + {toggles?.willSync.has(item.id) && ( + + + will sync + + )} + + ) + return ( +
  • + {toggles === null ? ( + + + {name} + + ) : ( + toggles.toggle({ id: item.id, needed: !item.needed })} + /> + )} + {item.needed ? item.qty : 'have it'} +
  • + ) +} + +// GroupCard is one dinner's items: its name in Bagel over its day. +function GroupCard({ group, toggles }: { group: SupplyRunGroup; toggles: Toggles | null }) { + return ( +
    +
    +

    {group.dinner.name}

    + {weekdayKicker(group.date)} +
    +
      + {group.items.map((item) => ( + + ))} +
    +
    + ) +} + +// SentStatus is the status line once the Supply run was sent to Google Chat. +function SentStatus({ at, timezone }: { at: Date; timezone: string }) { + return ( +

    + + Sent to Google Chat · {sentAt(at, timezone)} +

    + ) +} + +// 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 ( + + + {layout === 'mobile' ? 'Supply archive' : 'Archive'} + + ) +} + +// 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 ( +
    +
    +
    +

    Supply run · {weekLabel(weekStart).dates}

    +
    +

    Shopping list

    + {viewOnly && Archived} +

    + {run.needed_count} / {run.total_count} +

    +
    +

    + {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."} +

    +
    + {wide && ( +
    + + {!viewOnly && ( + + + Send to Google Chat + + )} +
    + )} +
    + {failed !== null && ( + toggles.toggle(failed.toggle)}> + Couldn't mark {failedItem ? failedItem.name : 'an item'} {failed.toggle.needed ? 'to buy' : '“have it”'}.{' '} + {failed.error.message} + + )} + {wide && run.sent_to_chat_at !== null && } + {run.groups.length === 0 ? ( + None of this week's dinners has a recipe with ingredients yet. + ) : ( +
    + {run.groups.map((group) => ( + + ))} +
    + )} + {!wide && ( +
    + {run.sent_to_chat_at !== null && } + +
    + )} +
    + ) +} + +// SkeletonGroups stands in for the dinner cards while the Supply run loads. +function SkeletonGroups({ layout }: { layout: Breakpoint }) { + return ( +
    + +
    + {Array.from({ length: 3 }, (_, i) => ( + + ))} +
    +
    + ) +} + +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 void run.refetch()}>Couldn't load the shopping list. {run.error.message} + } + if (run.isPending || generate.isPending) return + if (run.data === null) { + return ( +
    + {readOnly ? ( + This week has no Supply run in the Supply archive. + ) : ( + generate.mutate() }}> + I'll gather the ingredients of this week's planned dinners into a Supply run. + + )} + {generate.isError && ( + generate.mutate()}>Couldn't make the shopping list. {generate.error.message} + )} +
    + ) + } + return +} + +// 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 && ( + Changes to the shopping list can't be kept on this device while offline. {problem.message} + )} + + + ) +} + +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 {`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`} + } + // 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 void settings.refetch()}>Couldn't load the household settings. {settings.error.message} + } + if (!schedule) return + const week = chosen ?? defaultWeek(schedule, new Date()) + return +} + +// isMonday says whether date is a real YYYY-MM-DD Monday. +function isMonday(date: string): boolean { + try { + return weekOf(date) === date + } catch { + return false + } +} diff --git a/web/src/shopping/api.ts b/web/src/shopping/api.ts new file mode 100644 index 0000000..09fc0a3 --- /dev/null +++ b/web/src/shopping/api.ts @@ -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): 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 => { + const path = supplyRunPath(weekStart) + let body: unknown + try { + body = (await apiRead(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 => { + const path = `${supplyRunPath(weekStart)}/generate` + return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST' })) + }, + onSuccess: (run, _, settle) => { + queryClient.setQueryData(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 + 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 { + 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 { + 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 => { + 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(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( + 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(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 => { + const path = `${SHOPPING_PATH}/archive?${new URLSearchParams({ by })}` + const where = `GET ${path}` + const { body } = await apiRead(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)) + }, + }) +} diff --git a/web/src/shopping/offline.ts b/web/src/shopping/offline.ts new file mode 100644 index 0000000..055fb00 --- /dev/null +++ b/web/src/shopping/offline.ts @@ -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; 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() + +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(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) +} diff --git a/web/src/shopping/offlineQueue.test.tsx b/web/src/shopping/offlineQueue.test.tsx new file mode 100644 index 0000000..1ab93fc --- /dev/null +++ b/web/src/shopping/offlineQueue.test.tsx @@ -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 = { '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, + holdMe?: () => Promise, +) { + 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(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) { + return new Promise((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((resolve) => waiting.push(resolve)), + waiting: () => waiting.length, + release: () => waiting.shift()!(), + } +} + +// mes counts the GET /api/v1/me requests sent. +function mes(fetchMock: Mock) { + 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) { + 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) => ( + + + + + {onScreen && } + + + + ) + 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 + +// 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(() => {}) : 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(() => {}) : 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(() => {}) : 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) + }) +})