Compare commits

..
Author SHA1 Message Date
overseer 561f2490d6 Merge pull request 'Release: Feature 24 (Automated deploys)' (#211) from release/2026-10-12b into main
CI / ci (push) Successful in 8m27s
Reviewed-on: #211
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-12 08:37:35 -04:00
dev_agent 6d3312045d Merge remote-tracking branch 'origin/main' into HEAD
CI / ci (push) Successful in 10m44s
CI / ci (pull_request) Successful in 10m36s
2026-10-12 07:33:57 -04:00
orchestrator-agent 884d16638f Feature 24: Automated deploys (pull-based, runbook-faithful) (#210)
CI / ci (push) Successful in 10m37s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-12 07:33:35 -04:00
overseer 88209638cd Merge pull request 'Release: Feature 23 (Meal photos for manually added dinners)' (#209) from release/2026-10-12 into main
CI / ci (push) Successful in 8m20s
Reviewed-on: #209
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-12 06:44:31 -04:00
dev_agent e43df7f165 Merge remote-tracking branch 'origin/main' into HEAD
CI / ci (push) Successful in 9m51s
CI / ci (pull_request) Successful in 9m52s
2026-10-11 23:59:18 -04:00
orchestrator-agent 34e84964a3 Feature 23: Meal photos for manually added dinners (#207)
CI / ci (pull_request) Successful in 9m41s
CI / ci (push) Successful in 8m42s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 23:47:49 -04:00
overseer 743a1555d3 Merge pull request 'Release: Features 15, 19, 20 and 21' (#185) from release/2026-10-10 into main
CI / ci (push) Successful in 8m44s
Reviewed-on: #185
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-10 21:28:49 -04:00
dev_agentandClaude Opus 5.5 3be3c9b968 Merge origin/main into release/2026-10-10
CI / ci (pull_request) Successful in 8m9s
CI / ci (push) Successful in 8m16s
Resolve the Feature 18 placeholder Home against Feature 21's real Home
screen: keep the Feature 21 Home route and App tests, and drop the
placeholder web/src/pages/HomePage so / renders only the real Home.
Quick dinner and Cook from what I have links are covered by
web/src/home/HomePage.test.tsx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:35:46 -04:00
overseer 540ea4ddd7 Merge pull request 'Release: Feature 18 (Cook From What I Have and Quick Dinners)' (#158) from release/2026-10-09 into main
CI / ci (push) Successful in 6m13s
Reviewed-on: #158
2026-10-10 14:53:01 -04:00
dev_agentandClaude Opus 5.5 2bc412ae06 Add Quick dinner and Cook from what I have shortcuts to Home
CI / ci (push) Successful in 5m39s
CI / ci (pull_request) Successful in 5m43s
The placeholder Home now links to /quick and /cook-from-pantry, so
Cook from what I have has an in-app entry point. App tests click each
link and check the destination screen renders.

Task: ab589dee-4cdc-4a17-aff9-7b3cba975548

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:14:33 -04:00
orchestrator-agent 01038b376a Feature 18 release-review fixes: true Quest-log state and Find ideas with a draft (#160)
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
(cherry picked from commit 14f7141e44)
2026-10-10 14:13:05 -04:00
dev_agent d1be28467c Merge remote-tracking branch 'origin/main' into HEAD
CI / ci (push) Successful in 5m44s
CI / ci (pull_request) Successful in 5m40s
2026-10-09 10:45:52 -04:00
overseer d7c7a3314d Merge pull request 'Release: Feature 17 (Shopping List and Supply Archive)' (#142) from release/2026-10-08b into main
CI / ci (push) Successful in 4m50s
Reviewed-on: #142
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-08 18:15:48 -04:00
dev_agent e0574b1511 Merge remote-tracking branch 'origin/main' into release/2026-10-08b
CI / ci (push) Successful in 5m15s
CI / ci (pull_request) Successful in 5m8s
2026-10-08 17:15:21 -04:00
overseer e9b7593d3c Merge pull request 'Promote dev to main: Feature 16 (Google Calendar Sync) + release corrections' (#131) from release/2026-10-08 into main
CI / ci (push) Failing after 3m39s
Reviewed-on: #131
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-08 12:01:31 -04:00
dev_agent 8e33b7b824 Merge remote-tracking branch 'origin/main' into release/2026-10-08
CI / ci (push) Successful in 4m52s
CI / ci (pull_request) Successful in 4m22s
2026-10-08 03:14:36 -04:00
overseer 75b8d64f74 Merge pull request 'Promote dev to main: Feature 14 (Weekly Plan Builder)' (#123) from release/2026-10-07c into main
CI / ci (push) Successful in 3m19s
Reviewed-on: #123
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-07 19:29:54 -04:00
dev_agent fd225b9f90 Merge remote-tracking branch 'origin/main' into release/2026-10-07c
CI / ci (pull_request) Successful in 3m16s
CI / ci (push) Successful in 3m39s
2026-10-07 16:54:33 -04:00
overseer 66fc292166 Merge pull request 'Promote dev to main: Feature 13 (Discover - Side Quest Suggestions)' (#115) from release/2026-10-07b into main
CI / ci (push) Successful in 2m45s
Reviewed-on: #115
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-07 08:07:10 -04:00
dev_agent fa62813bb7 Merge remote-tracking branch 'origin/main' into release/2026-10-07b
CI / ci (push) Successful in 3m5s
CI / ci (pull_request) Successful in 2m45s
2026-10-07 07:05:10 -04:00
overseer 1bbacf2df5 Merge pull request 'Promote dev to main: Feature 12 (regular dinners and onboarding)' (#98) from release/2026-10-07 into main
CI / ci (push) Successful in 2m49s
Reviewed-on: #98
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-07 06:54:05 -04:00
dev_agent 372424e2f5 Merge remote-tracking branch 'origin/dev' into release/2026-10-07
CI / ci (push) Successful in 2m28s
CI / ci (pull_request) Successful in 2m26s
2026-10-07 00:41:03 -04:00
dev_agent a27ef6a4c4 Merge remote-tracking branch 'origin/dev' into release/2026-10-07
CI / ci (push) Successful in 2m28s
CI / ci (pull_request) Successful in 2m25s
2026-10-07 00:14:42 -04:00
dev_agent 7138721a28 Merge remote-tracking branch 'origin/dev' into release/2026-10-07
CI / ci (push) Successful in 2m26s
CI / ci (pull_request) Successful in 2m35s
2026-10-06 23:13:06 -04:00
dev_agent c54af1656c Merge remote-tracking branch 'origin/main' into release/2026-10-07
CI / ci (push) Successful in 2m24s
CI / ci (pull_request) Successful in 2m37s
2026-10-06 22:00:58 -04:00
overseer b0a6d896ef Merge pull request 'Promote dev to main: Feature 11 (settings home and Admin AI assistant)' (#87) from release/2026-10-06c into main
CI / ci (push) Successful in 2m2s
Reviewed-on: #87
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-06 13:46:37 -04:00
dev_agent 2fe439cdcd Merge dev into release/2026-10-06c: incorporate the deflake correction
CI / ci (push) Successful in 2m9s
CI / ci (pull_request) Successful in 2m4s
2026-10-06 13:19:22 -04:00
dev_agent cc6c1f9cf9 Merge main into release/2026-10-06c before promotion review
CI / ci (push) Successful in 2m1s
CI / ci (pull_request) Failing after 1m2s
2026-10-06 12:46:00 -04:00
overseer 768bb48b21 Merge pull request 'Promote dev to main: Feature 10 (recipe grounding and photos)' (#79) from release/2026-10-06b into main
CI / ci (push) Successful in 1m49s
Reviewed-on: #79
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-06 07:39:40 -04:00
dev_agent a6a32b8426 Merge main into release/2026-10-06b before promotion review
CI / ci (push) Successful in 1m53s
CI / ci (pull_request) Successful in 1m52s
2026-10-06 07:28:53 -04:00
overseer d4be6212bc Merge pull request 'Promote dev to main: Feature 07 (deployment, identity and backups)' (#76) from release/2026-10-06 into main
CI / ci (push) Successful in 1m40s
Reviewed-on: #76
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-05 21:19:34 -04:00
dev_agent 2dbfc74f3e Merge main into release/2026-10-06 before promotion review
CI / ci (push) Successful in 1m42s
CI / ci (pull_request) Successful in 1m43s
2026-10-05 21:01:02 -04:00
overseer 9cddbbb8a9 Merge pull request 'Promote dev to main: Feature 06 (PWA shell and offline)' (#71) from release/2026-10-05b into main
CI / ci (push) Successful in 1m40s
Reviewed-on: #71
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-05 16:02:03 -04:00
dev_agent a2f3143ac2 Merge main into release/2026-10-05b before promotion review
CI / ci (push) Successful in 1m42s
CI / ci (pull_request) Successful in 1m40s
2026-10-05 15:27:27 -04:00
overseer 6e134968cc Merge pull request 'Promote dev to main: Features 08-09' (#63) from release/2026-10-05 into main
CI / ci (push) Successful in 1m35s
Reviewed-on: #63
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-05 12:19:09 -04:00
dev_agent 86e7cc63c2 Merge dev into release/2026-10-05: incorporate cancelled-backoff correction
CI / ci (push) Successful in 1m35s
CI / ci (pull_request) Successful in 1m35s
2026-10-05 11:42:11 -04:00
dev_agent 37315c7821 Merge main into release/2026-10-05 before promotion review
CI / ci (push) Successful in 1m37s
CI / ci (pull_request) Successful in 1m36s
2026-10-05 11:21:47 -04:00
overseer 084350cb03 Merge pull request 'Promote dev to main: Features 01–05' (#47) from release/2026-10-04 into main
CI / ci (push) Successful in 1m30s
Reviewed-on: #47
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-04 10:35:01 -04:00
51 changed files with 2350 additions and 1478 deletions
+1 -1
View File
@@ -84,7 +84,7 @@ behaviour, exercise it (hit the endpoint, render the page).
| `internal/*` (planned) | `auth`, `db`, `store`, `secretbox`, `household`, `dinners`, `recipes`, `ai/llm`, `ai/harness`, `ai/skills`, `suggest`, `plan`, `shopping`, `ideas`, `gcal`, `notify`, `push`, `chat`, `jobs`, `scheduler`. |
| `migrations/` | SQL migrations (planned). |
| `web/` | React PWA source. |
| `deploy/` | Deployment files (planned). |
| `deploy/` | Deployment files: compose stack, systemd units, [deploy runbook](deploy/RUNBOOK.md) and the automated deploy (`auto-deploy.sh` with `systemd/mealquest-auto-deploy.*`). |
| `docs/` | Project documentation, including [ARCHITECTURE.md](docs/ARCHITECTURE.md). |
| `design/` | Requirements and UI handoff. **Read-only reference** — do not edit. |
| `Dockerfile` | Multi-stage image build, at the repository root. |
+2 -3
View File
@@ -82,8 +82,7 @@ sqlc:
$(SQLC) generate
# Fails on any difference from the committed generated code, including new
# or deleted files. A failing git status is itself a failure: its empty
# output would otherwise read as "no drift".
# or deleted files.
sqlc-check: sqlc
@drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))" || { echo "git status failed; cannot check internal/db for drift"; exit 1; }; \
@drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))"; \
if [ -n "$$drift" ]; then echo "internal/db is stale; run make sqlc and commit:"; echo "$$drift"; git diff -- $(SQLC_OUT); exit 1; fi
+357
View File
@@ -0,0 +1,357 @@
# MealQuest deploy runbook
The one way to deploy MealQuest to production: the docker compose project
`mealquest` on office-srvr `10.60.1.14` (see
[ARCHITECTURE.md](../docs/ARCHITECTURE.md#topology)). Production runs `main`
or a release tag; only `overseer` merges `dev` → `main`. Every deploy builds
on the host, from the checked-out commit, with `make docker`.
Host setup (clone, firewall, the systemd unit, Traefik, DNS, Litestream) and
the database restore are in CTXD project `mealquest`, `RUNBOOKS.MD`. This
file is the build, restart, verification and rollback procedure; where it
uses a CTXD section, it names it.
All commands run on `10.60.1.14` as `overseer`, in bash, from the checkout
`/home/overseer/mealquest`. A block that ends with `FAILED` or a non-zero
`$?` stops the deploy: do not go on to the next step.
## How the image carries its version
`make docker` runs `docker build --build-arg VERSION=<version>` and tags the
image `mealquest:<version>`, where `<version>` is
`git describe --tags --always` of the checkout (a release tag such as
`v1.0.0`, otherwise the short SHA), with `-dirty` added when a tracked file
is modified. The [Dockerfile](../Dockerfile) links it into the binary, and
`/health` reports it:
```json
{"status":"ok","version":"v1.0.0"}
```
Never pass `--build-arg VERSION` by hand, and never build with
`docker compose build` or `docker compose up --build`: compose passes no
version, so that image reports `dev`.
The compose service `mealquest` runs the image named `mealquest-mealquest`
(compose's name for the image it would build; check with
`docker compose -f deploy/compose.yaml config --images mealquest`). It
already exists, so `docker compose up` uses it and never builds. A deploy
points that name at the `make docker` image with `docker tag`. Every
`mealquest:<version>` image is kept for rollback; do not prune them.
## Prerequisites
- `make`, `git`, `curl`, `python3` and Docker Engine with Compose on the
host (`make` is not in the base install: `sudo apt-get install make`).
- `deploy/.env` (git-ignored, owner `overseer`, mode 0600) sets every
variable [`deploy/.env.example`](.env.example) sets, with
`MEALQUEST_BIND_ADDRESS=10.60.1.14`. It must not set the variables
`deploy/compose.yaml` fixes (`MEALQUEST_HTTP_ADDR`, `MEALQUEST_DATA_DIR`,
`MEALQUEST_SEARXNG_URL` and the `*_FILE` variables). Creating it: CTXD
`RUNBOOKS.MD` "Stack and first deploy" > "3. Environment file".
- `deploy/secrets/` (git-ignored, mode 0700) holds `secret_key`,
`oidc_client_secret`, `google_client_secret`, `vapid_private_key` and
`litestream_key`, each owned by `10001:10001` with mode 0600, and
`searxng.env`, owned by `overseer` with mode 0600. Creating them: CTXD
`RUNBOOKS.MD` "Stack and first deploy" > "4. Secrets".
- The compose file is the checkout's [`deploy/compose.yaml`](compose.yaml);
the systemd unit [`mealquest.service`](systemd/mealquest.service) starts
and stops it and is the only way the stack starts.
- DNS and Traefik already route `https://mealquest.cubecraftlabs.com` to
`10.60.1.14:8090` (CTXD `RUNBOOKS.MD` "Reverse proxy (Traefik)", "DNS").
Check `deploy/.env` and the secrets before every deploy (a new release may
add a variable; the service refuses to boot without it). Both commands must
print nothing:
```sh
cd /home/overseer/mealquest
comm -23 <(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env.example | sort -u) \
<(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env | sort -u) # variables missing from deploy/.env
sudo find deploy/secrets -type f ! -perm 600 # secrets not mode 0600
```
## Deploy
### 1. Build
Record the release production is running now, check out the new release
and build its image. Nothing restarts.
The rollback target is the version the running service reports on
`/health`, read before the checkout changes, never the checkout's `HEAD`:
after a failed or deferred deploy the checkout can be ahead of production.
The block resolves that version to a commit and prints it as the rollback
SHA. A running version of `dev` or `*-dirty` names no commit: the block
fails, and the rollback target must be established by hand before going on.
The checkout must be clean (the same test as the Makefile's `GIT_DIRTY`:
no modified tracked file outside the generated `internal/frontend/dist`).
The block computes the image tag exactly as `make docker` does, `-dirty`
included, and checks that image exists after the build.
```sh
cd /home/overseer/mealquest && (
set -euo pipefail
fail() { echo "$*" >&2; exit 1; }
built=
trap 'st=$?
if [ "$st" -eq 0 ] && [ -n "$built" ]; then echo "built: $built"
else [ "$st" -ne 0 ] || st=1; echo "build FAILED (status $st)" >&2; fi
exit "$st"' EXIT
bind=$(grep -E '^MEALQUEST_BIND_ADDRESS=' deploy/.env | cut -d = -f 2-)
[ -n "$bind" ] || fail "deploy/.env sets no MEALQUEST_BIND_ADDRESS"
running=$(curl -fsS "http://$bind:8090/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|| fail "http://$bind:8090/health did not return a version: no running release to roll back to"
case "$running" in
dev|*-dirty) fail "the running release reports $running, which names no commit" ;;
esac
git fetch --tags origin
rollback=$(git rev-parse --verify --quiet "$running^{commit}") \
|| fail "running version $running is not a commit in this repository"
echo "rollback SHA: $rollback (running version $running)" # RECORD it for rollback
git checkout --detach "refs/remotes/origin/main^{commit}" # or the release tag, e.g. v1.0.0
dirty=$(git status --porcelain --untracked-files=no -- . ':(exclude)internal/frontend/dist')
[ -z "$dirty" ] || fail "the checkout has modified tracked files; clean it first:
$dirty"
v=$(git describe --tags --always)${dirty:+-dirty} # the Makefile's VERSION
make docker
docker image inspect "mealquest:$v" > /dev/null 2>&1 || fail "no image mealquest:$v: the build failed"
built="mealquest:$v"
)
```
### 2. Ship and restart
1. **Pre-restart gates.** Run CTXD `RUNBOOKS.MD` "Routine deploy and
rollback" > "Deploy" step 2 (a verified pre-deploy restore point; record
it with the rollback SHA) and step 3 (boot-required configuration). The
restart applies migrations the previous release refuses, so without that
restore point there is no rollback. If either fails, do not restart:
production stays on the previous release.
2. **Point the stack at the new image and restart.** `docker compose pull`
fetches the pinned `litestream` and `searxng` images before anything
stops; `mealquest` is never pulled. The restart applies the firewall,
then runs `down` and `up -d`, and returns once `mealquest` passes its
healthcheck (`/health` inside the container).
```sh
cd /home/overseer/mealquest && (
set -euo pipefail
fail() { echo "$*" >&2; exit 1; }
up=
trap 'st=$?
if [ "$st" -eq 0 ] && [ -n "$up" ]; then echo "restarted on: $up"
else [ "$st" -ne 0 ] || st=1; echo "restart FAILED (status $st)" >&2; fi
exit "$st"' EXIT
v=$(git describe --tags --always)
docker compose -f deploy/compose.yaml pull litestream searxng
docker tag "mealquest:$v" mealquest-mealquest
sudo systemctl restart mealquest
for s in mealquest litestream searxng; do
id=$(docker compose -f deploy/compose.yaml ps --status running --quiet "$s")
[ -n "$id" ] || fail "$s is not running"
done
id=$(docker compose -f deploy/compose.yaml ps --quiet mealquest)
health=$(docker inspect -f '{{.State.Health.Status}}' "$id")
[ "$health" = healthy ] || fail "mealquest is $health, not healthy"
up="mealquest:$v"
)
```
On failure read `journalctl -u mealquest` and
`docker compose -f deploy/compose.yaml logs mealquest`: a configuration
error names the variable. Fix it or roll back.
### 3. Verify the deployed version and a real screen
A `/health` 200 alone proves only that some build is up. This step checks,
on the LAN address and through Traefik, that:
- `/health` reports exactly the checkout's `git describe --tags --always`;
- `/` and the Plan screen's route `/plan` serve the built PWA shell (the
React root and a hashed `/assets/` script, not the placeholder page an
unbuilt embed serves);
- that script is served as JavaScript.
```sh
cd /home/overseer/mealquest && (
set -euo pipefail
fail() { echo "$*" >&2; exit 1; }
want=
trap 'st=$?
if [ "$st" -eq 0 ] && [ -n "$want" ]; then echo "deployed and verified: $want ($(git rev-parse HEAD))"
else [ "$st" -ne 0 ] || st=1; echo "verification FAILED (status $st): the deploy is not verified" >&2; fi
exit "$st"' EXIT
v=$(git describe --tags --always)
for base in http://10.60.1.14:8090 https://mealquest.cubecraftlabs.com; do
got=$(curl -fsS "$base/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|| fail "$base/health did not return a version"
[ "$got" = "$v" ] || fail "$base/health reports version $got, the checkout is $v"
for path in / /plan; do
page=$(curl -fsS "$base$path") || fail "$base$path did not return 200"
grep -q '<div id="root"></div>' <<< "$page" || fail "$base$path is not the PWA shell"
js=$(grep -oE 'src="/assets/[^"]+\.js"' <<< "$page" | head -n 1 | cut -d '"' -f 2) || true
[ -n "$js" ] || fail "$base$path references no built /assets/ script"
type=$(curl -fsS -o /dev/null -w '%{content_type}' "$base$js") || fail "$base$js did not return 200"
case "$type" in
text/javascript*) ;;
*) fail "$base$js is $type, not JavaScript" ;;
esac
done
echo "$base: version $got, PWA shell and $js served"
done
want=$v
)
```
- `deployed and verified: <version> (<SHA>)` and `$?` = `0` → open
`https://mealquest.cubecraftlabs.com` in a browser: sign in and the Home
screen renders with the household's data. Then record the deployed
version and SHA, the rollback SHA, the pre-deploy restore point and the
date in the release's Maestro task.
- `verification FAILED`, any error or a non-zero `$?` → the deploy is not
done. A version mismatch means the stack runs a different image than the
checkout: repeat step 2, or roll back.
## Rollback to a previous release
Rolling back points `mealquest-mealquest` at the previous release's
`mealquest:<version>` image again; that release's checkout keeps the
compose file and the version check consistent with it.
1. **Check whether the rollback crosses a migration** with CTXD
`RUNBOOKS.MD` "Routine deploy and rollback" > "Rollback to a previous
SHA" (its first block), run against the rollback SHA that deploy step 1
recorded. If it prints versions unknown to the rollback SHA, restore the
database to the pre-deploy restore point first (CTXD `RUNBOOKS.MD`
"Restore the database", with `-timestamp <restore point>`); changes
since then are lost, so tell the household.
2. **Check out the rollback SHA and retag its image.** An image built before
this runbook was not tagged `mealquest:<version>`; `make docker` builds
it from the same commit.
```sh
cd /home/overseer/mealquest && (
set -euo pipefail
fail() { echo "$*" >&2; exit 1; }
P=<rollback SHA> # recorded by step 1, from the running release's /health
back=
trap 'st=$?
if [ "$st" -eq 0 ] && [ -n "$back" ]; then echo "rolled back to: $back"
else [ "$st" -ne 0 ] || st=1; echo "rollback FAILED (status $st)" >&2; fi
exit "$st"' EXIT
git checkout --detach "$P"
v=$(git describe --tags --always)
docker image inspect "mealquest:$v" > /dev/null 2>&1 || make docker
docker image inspect "mealquest:$v" > /dev/null 2>&1 || fail "no image mealquest:$v"
docker tag "mealquest:$v" mealquest-mealquest
sudo systemctl restart mealquest
back="mealquest:$v"
)
```
3. **Run step 3 above** unchanged. The rollback is done only when it prints
`deployed and verified:` with the previous release's version.
## Automated deploys
[`auto-deploy.sh`](auto-deploy.sh), installed as
`/usr/local/sbin/mealquest-auto-deploy`, runs this runbook's deploy unattended:
step 1 (build), the prerequisite `deploy/.env` and secrets check, CTXD
`RUNBOOKS.MD` "Deploy" steps 2 (pre-deploy restore point) and 3
(boot-required configuration), step 2 (ship and restart) and step 3
(verify). No check is skipped or weakened, and it stops at the first
failure. Every failure before the restart leaves production on the previous
release.
### What runs, and when
[`mealquest-auto-deploy.timer`](systemd/mealquest-auto-deploy.timer) starts
[`mealquest-auto-deploy.service`](systemd/mealquest-auto-deploy.service)
at every quarter hour (`OnCalendar=*-*-* *:0/15:00`), plus up to 5 minutes'
random delay; a check missed while the host was down runs as soon as it is
back (`Persistent=true`). The service runs the script as `overseer` from the
checkout, and only while `mealquest.service` is active: a stack stopped on
purpose (a database or media restore) is skipped, not redeployed. Output goes
to the journal (`journalctl -u mealquest-auto-deploy`).
Each run fetches `origin` and compares `origin/main` with the last
`deployed=` SHA in the deploy record `/home/overseer/mealquest-auto-deploy.log`:
| `origin/main` | Result |
| --- | --- |
| is the last deployed SHA | No-op: exit 0, no output. |
| CI (Gitea commit status) `pending` | Exit 0, no output; the next run checks again. |
| CI `failure`, `error`, `warning`, or no status yet | Error in the journal, exit 1, nothing deployed. |
| the last record line is `FAILED` for this SHA | Error in the journal, exit 1, nothing deployed: a failed deploy is never retried by the timer. |
| CI `success` | Deploy. |
The CI status is read from
`https://code.cubecraftlabs.com/api/v1/repos/Maniacs_Mansion/MealQuest/commits/<SHA>/status`
(public; no credential). A commit merged moments before a run can briefly
report no status, which fails that run; the next run reads it again.
A deploy appends one line to the record:
```text
2026-10-12T15:04:05Z previous=<rollback SHA> deployed=<SHA> restore_point=2026-10-12T15:01:59Z
```
`previous` is the rollback SHA (from the running release's `/health`,
never the checkout) and `restore_point` the verified pre-deploy restore
point: record both in the release's Maestro task. A failed deploy appends
`<date> FAILED target=<SHA> status=<exit status>` and logs
`auto-deploy FAILED (status <n>)` to the journal, adding whether it failed
before the restart (production untouched) or after it (read
`journalctl -u mealquest`, then fix or roll back). Either way the checkout is
left at the target commit; the running release on `/health` stays the
rollback target.
### Install on the host
The script and units are installed copies: repeat this whenever a release
changes `deploy/auto-deploy.sh` or the units. From the checkout, at a release
that contains them:
```sh
cd /home/overseer/mealquest
sudo install -m 0755 deploy/auto-deploy.sh /usr/local/sbin/mealquest-auto-deploy
sudo install -m 0644 deploy/systemd/mealquest-auto-deploy.service \
deploy/systemd/mealquest-auto-deploy.timer /etc/systemd/system/
sudo systemctl daemon-reload
```
The timer refuses to deploy until the record holds a deploy, so seed it with
one manual deploy of the release production should run (below), then start
the timer:
```sh
sudo /usr/local/sbin/mealquest-auto-deploy --ref refs/remotes/origin/main
sudo systemctl enable --now mealquest-auto-deploy.timer
systemctl list-timers mealquest-auto-deploy.timer
```
To stop automated deploys: `sudo systemctl disable --now mealquest-auto-deploy.timer`.
### Manual override
```sh
sudo /usr/local/sbin/mealquest-auto-deploy --ref <ref>
```
deploys an explicit ref (a SHA, tag or `origin/<branch>`) through the same
blocks, bypassing the CI and no-op gates: for a roll-forward fix, a re-run
after a `FAILED` record, or seeding the record. Run as root, the script
re-executes itself as `overseer`; it holds a lock, so it never runs alongside
the timer's run. It prints `deployed and verified:` and exits 0 only when
step 3 passed.
### Rollback
Rollback is not automated: follow "Rollback to a previous release" above
and CTXD `RUNBOOKS.MD` "Routine deploy and rollback" > "Rollback to a
previous SHA", using the `previous` SHA and `restore_point` from the record.
The timer does not undo a manual rollback: `origin/main` is still the last
recorded deploy, so its runs are no-ops until `main` moves, and a new `main`
then deploys as usual. Disable the timer if the rollback must hold.
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env bash
# MealQuest automated deploy: deploy/RUNBOOK.md, run unattended.
#
# mealquest-auto-deploy deploy origin/main if it is new and CI passed
# mealquest-auto-deploy --ref <ref> deploy <ref> now, bypassing the CI and no-op gates
#
# Installed as /usr/local/sbin/mealquest-auto-deploy and run as overseer by
# mealquest-auto-deploy.service (deploy/systemd); install, semantics and the
# manual override: deploy/RUNBOOK.md "Automated deploys". Run as root (sudo)
# it re-executes itself as overseer.
#
# Without --ref: exit 0 with no output when origin/main is the last deployed
# SHA in the deploy record or its CI is pending; exit 1, deploying nothing,
# when its CI failed or reports no status, or when the last attempt to deploy
# that SHA failed. Every deploy runs the runbook's blocks in order and stops
# at the first failure; nothing before the restart touches production. A
# verified deploy appends one line to the deploy record:
# <UTC date> previous=<SHA> deployed=<SHA> restore_point=<UTC date>
# a failed one appends:
# <UTC date> FAILED target=<SHA> status=<exit status>
set -euo pipefail
CHECKOUT=/home/overseer/mealquest
RECORD=/home/overseer/mealquest-auto-deploy.log
LOCK=/home/overseer/mealquest-auto-deploy.lock
STATUS_API=https://code.cubecraftlabs.com/api/v1/repos/Maniacs_Mansion/MealQuest/commits
PUBLIC=https://mealquest.cubecraftlabs.com
D=/var/lib/docker/volumes/mealquest-data/_data
COMPOSE=(docker compose -f deploy/compose.yaml)
fail() { echo "$*" >&2; exit 1; }
if [ "$(id -u)" -eq 0 ]; then
exec sudo -u overseer -H -- "$0" "$@"
fi
[ "$(id -un)" = overseer ] || fail "run as overseer (or root), not $(id -un)"
ref=
case "$#" in
0) ;;
2) [ "$1" = --ref ] && [ -n "$2" ] || fail "usage: $0 [--ref <ref>]"; ref=$2 ;;
*) fail "usage: $0 [--ref <ref>]" ;;
esac
exec 9>> "$LOCK"
flock -n 9 || fail "another mealquest-auto-deploy run holds $LOCK"
cd "$CHECKOUT"
git fetch --quiet --tags origin || fail "git fetch origin failed"
if [ -n "$ref" ]; then
target=$(git rev-parse --verify --quiet "$ref^{commit}") || fail "$ref is not a commit in $CHECKOUT"
else
# The full remote-tracking ref: a bare origin/main resolves a tag or local
# branch of that name first, which could deploy a commit not on main.
target=$(git rev-parse --verify --quiet "refs/remotes/origin/main^{commit}") \
|| fail "refs/remotes/origin/main is not a commit in $CHECKOUT"
[ -f "$RECORD" ] || fail "no deploy record at $RECORD: seed it with one manual deploy (deploy/RUNBOOK.md \"Automated deploys\")"
last=$(grep -oE 'deployed=[0-9a-f]{40}' "$RECORD" | tail -n 1 | cut -d = -f 2) || true
[ -n "$last" ] || fail "$RECORD records no deploy: seed it with one manual deploy (deploy/RUNBOOK.md \"Automated deploys\")"
[ "$target" != "$last" ] || exit 0
if tail -n 1 "$RECORD" | grep -q " FAILED target=$target status="; then
fail "auto-deploy of $target already failed (see $RECORD and the journal): fix it, then deploy with --ref"
fi
state=$(curl -fsS --max-time 30 "$STATUS_API/$target/status" \
| python3 -c 'import json, sys; d = json.load(sys.stdin); print(d["state"] if d["total_count"] else "absent")') \
|| fail "could not read the CI status of $target from $STATUS_API"
case "$state" in
success) ;;
pending) exit 0 ;;
*) fail "CI for origin/main $target is $state, not success: not deploying" ;;
esac
fi
echo "auto-deploy: deploying $target${ref:+ (--ref $ref)}"
restarted=
W=
on_exit() {
local st=$?
[ -z "$W" ] || rm -rf "$W" || st=1
if [ "$st" -ne 0 ]; then
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) FAILED target=$target status=$st" >> "$RECORD"
if [ -n "$restarted" ]; then
echo "auto-deploy FAILED (status $st) after the restart: production may be down or unverified; read journalctl -u mealquest, then fix or roll back (deploy/RUNBOOK.md)" >&2
else
echo "auto-deploy FAILED (status $st): production untouched" >&2
fi
fi
exit "$st"
}
trap on_exit EXIT
# deploy/RUNBOOK.md "Deploy" > "1. Build": record the rollback SHA from the
# RUNNING release, check out the target, reject a dirty checkout, build.
bind=$(grep -E '^MEALQUEST_BIND_ADDRESS=' deploy/.env | cut -d = -f 2-) || fail "deploy/.env sets no MEALQUEST_BIND_ADDRESS"
[ -n "$bind" ] || fail "deploy/.env sets no MEALQUEST_BIND_ADDRESS"
running=$(curl -fsS "http://$bind:8090/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|| fail "http://$bind:8090/health did not return a version: no running release to roll back to"
case "$running" in
dev|*-dirty) fail "the running release reports $running, which names no commit" ;;
esac
rollback=$(git rev-parse --verify --quiet "$running^{commit}") \
|| fail "running version $running is not a commit in this repository"
echo "rollback SHA: $rollback (running version $running)"
git checkout --quiet --detach "$target"
dirty=$(git status --porcelain --untracked-files=no -- . ':(exclude)internal/frontend/dist')
[ -z "$dirty" ] || fail "the checkout has modified tracked files; clean it first:
$dirty"
v=$(git describe --tags --always)${dirty:+-dirty} # the Makefile's VERSION
make docker
docker image inspect "mealquest:$v" > /dev/null 2>&1 || fail "no image mealquest:$v: the build failed"
echo "built: mealquest:$v"
# deploy/RUNBOOK.md "Prerequisites": deploy/.env sets every variable of the
# target's .env.example, and every secret is mode 0600.
missing=$(comm -23 <(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env.example | sort -u) \
<(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env | sort -u))
[ -z "$missing" ] || fail "deploy/.env is missing: $missing"
loose=$(sudo find deploy/secrets -type f ! -perm 600)
[ -z "$loose" ] || fail "secrets not mode 0600: $loose"
# CTXD RUNBOOKS.MD "Routine deploy and rollback" > "Deploy" step 2: a verified
# pre-deploy restore point. The scripts are CTXD "Count check". They and the
# check files live in a private mktemp directory (mode 0700), not at shared
# /tmp paths, and python runs with -I: the snapshot runs as root, and a
# shared directory on its import path would let any local user run code as
# root.
W=$(mktemp -d)
cat > "$W/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 > "$W/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
point=$(
set -eu
point=
trap 'st=$?
rm -rf "$W" || 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" >&2; echo "$point"
else [ "$st" -ne 0 ] || st=1; echo "verification FAILED (status $st): do not restart" >&2; fi
exit "$st"' EXIT
exec 3>&1 1>&2 # block output to the journal; only the restore point to $point
mkdir "$W/mqcheck"
# a. Replication running, no log errors (grep: 0 match, 1 none, 2+ error), newest LTX listed
id=$("${COMPOSE[@]}" ps --status running --quiet litestream) || fail "docker compose ps failed"
[ -n "$id" ] || fail "litestream is not running"
"${COMPOSE[@]}" logs --since 24h litestream > "$W/mqcheck/litestream.log" || fail "docker compose logs failed"
gs=0
grep -i error "$W/mqcheck/litestream.log" > "$W/mqcheck/litestream.errors" || gs=$?
case "$gs" in
0) cat "$W/mqcheck/litestream.errors"; fail "litestream logged errors in the last 24h";;
1) ;;
*) fail "the litestream log scan failed (status $gs)";;
esac
"${COMPOSE[@]}" exec -T litestream \
litestream ltx -config /etc/litestream.yml -level all /data/mealquest.db
# b. Restore replica to a scratch file (live files untouched), move off the volume, snapshot live
"${COMPOSE[@]}" 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* $W/mqcheck/"
sudo python3 -I "$W/mqsnap.py" "$D/mealquest.db" "$W/mqcheck/live.db"
sudo chown -R "$(id -un)" "$W/mqcheck"
sudo ls -ln "$D"
# mealquest.db-wal and -shm owned by 10001, never 0 (CTXD "Count check")
root=$(sudo find "$D" -maxdepth 1 \( -name mealquest.db-wal -o -name mealquest.db-shm \) -uid 0)
[ -z "$root" ] || fail "owned by root, not 10001 (CTXD RUNBOOKS.MD \"Count check\"): $root"
# c. Restored replica: integrity ok and identical counts/content-sha256 to the live snapshot
python3 -I "$W/mqcount.py" "$W/mqcheck/predeploy-check.db" > "$W/mqcheck/restored.txt"
python3 -I "$W/mqcount.py" "$W/mqcheck/live.db" > "$W/mqcheck/live.txt"
cat "$W/mqcheck/restored.txt"
grep -qx 'integrity ok' "$W/mqcheck/restored.txt" || fail "the restored replica failed integrity_check"
diff "$W/mqcheck/restored.txt" "$W/mqcheck/live.txt" || fail "the restored replica differs from the live snapshot"
# d. Restore point: after every check, before the restart
point=$(date -u +%Y-%m-%dT%H:%M:%SZ)
exec 1>&3
)
# CTXD RUNBOOKS.MD "Routine deploy and rollback" > "Deploy" step 3:
# boot-required configuration, secrets read the way the service reads them.
[ -f deploy/.env ] || fail "missing deploy/.env"
cid=$(grep -m 1 -E '^MEALQUEST_GOOGLE_CLIENT_ID=' deploy/.env | cut -d = -f 2- | tr -d '"' | tr -d "'") \
|| fail "missing MEALQUEST_GOOGLE_CLIENT_ID in deploy/.env"
[ -n "$cid" ] || fail "missing MEALQUEST_GOOGLE_CLIENT_ID in deploy/.env"
[ -e deploy/secrets/google_client_secret ] || fail "missing deploy/secrets/google_client_secret"
[ -s deploy/secrets/google_client_secret ] || fail "deploy/secrets/google_client_secret is empty"
id=$("${COMPOSE[@]}" ps --status running --quiet mealquest) || fail "docker compose ps failed"
[ -n "$id" ] || fail "the mealquest container is not running: start it (the check reads secrets the way the service does)"
docker exec "$id" sh -c 'test -s /run/secrets/google_client_secret && test -r /run/secrets/google_client_secret' \
|| fail "google_client_secret is not readable by the service user inside the container"
echo "boot-required configuration present"
# deploy/RUNBOOK.md "2. Ship and restart".
"${COMPOSE[@]}" pull litestream searxng
docker tag "mealquest:$v" mealquest-mealquest
restarted=yes
sudo systemctl restart mealquest
for s in mealquest litestream searxng; do
id=$("${COMPOSE[@]}" ps --status running --quiet "$s")
[ -n "$id" ] || fail "$s is not running"
done
id=$("${COMPOSE[@]}" ps --quiet mealquest)
health=$(docker inspect -f '{{.State.Health.Status}}' "$id")
[ "$health" = healthy ] || fail "mealquest is $health, not healthy"
echo "restarted on: mealquest:$v"
# deploy/RUNBOOK.md "3. Verify the deployed version and a real screen".
for base in "http://$bind:8090" "$PUBLIC"; do
got=$(curl -fsS "$base/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|| fail "$base/health did not return a version"
[ "$got" = "$v" ] || fail "$base/health reports version $got, the checkout is $v"
for path in / /plan; do
page=$(curl -fsS "$base$path") || fail "$base$path did not return 200"
grep -q '<div id="root"></div>' <<< "$page" || fail "$base$path is not the PWA shell"
js=$(grep -oE 'src="/assets/[^"]+\.js"' <<< "$page" | head -n 1 | cut -d '"' -f 2) || true
[ -n "$js" ] || fail "$base$path references no built /assets/ script"
type=$(curl -fsS -o /dev/null -w '%{content_type}' "$base$js") || fail "$base$js did not return 200"
case "$type" in
text/javascript*) ;;
*) fail "$base$js is $type, not JavaScript" ;;
esac
done
echo "$base: version $got, PWA shell and $js served"
done
deployed=$(git rev-parse HEAD)
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) previous=$rollback deployed=$deployed restore_point=$point" >> "$RECORD"
echo "deployed and verified: $v ($deployed), previous $rollback, restore point $point"
@@ -0,0 +1,23 @@
# Deploys origin/main to production when it is new and its CI passed
# (/usr/local/sbin/mealquest-auto-deploy, from deploy/auto-deploy.sh).
# Triggered by mealquest-auto-deploy.timer; output goes to the journal.
# Installing it and the manual override: deploy/RUNBOOK.md "Automated deploys".
#
# ExecCondition= skips the run while mealquest.service is not active, so a
# stack stopped on purpose (a database or media restore) is never redeployed
# under the operator. It is deliberately not Requires=/Requisite= on
# mealquest.service: those would start the stack, or stop this run when the
# deploy itself restarts mealquest.service. Runs as overseer, who owns the
# checkout and uses sudo for the runbook's privileged steps.
[Unit]
Description=MealQuest automated deploy of main
Wants=network-online.target
After=network-online.target docker.service mealquest.service
[Service]
Type=oneshot
User=overseer
Group=overseer
WorkingDirectory=/home/overseer/mealquest
ExecCondition=/usr/bin/systemctl is-active --quiet mealquest.service
ExecStart=/usr/local/sbin/mealquest-auto-deploy
@@ -0,0 +1,13 @@
# Checks for a new main at every quarter hour (plus up to 5 minutes' random
# delay). Persistent= runs a check at once when the host comes back if a
# scheduled one was missed while it was down.
[Unit]
Description=Periodic MealQuest automated deploy
[Timer]
OnCalendar=*-*-* *:0/15:00
RandomizedDelaySec=5min
Persistent=true
[Install]
WantedBy=timers.target
-113
View File
@@ -1,113 +0,0 @@
# Accessibility pass
A pass over every route the PWA serves (the routes in [STATES.md](STATES.md))
for launch, on 2026-10-10. Each route was checked for:
- **Keyboard** — every action reachable and operable with the keyboard alone.
- **Focus** — a visible focus ring on everything that takes focus.
- **Names and roles** — what a screen reader announces: switches, checkboxes,
the current nav item, dialogs and their labels.
- **Contrast** — text at least 4.5:1 against its ground, for every token pair
used for text.
- **Hit targets** — every tap target at least 44 × 44 px.
- **Reduced motion** — what `prefers-reduced-motion: reduce` turns off.
The pass read each screen's markup and stylesheets, computed contrast from
[tokens.css](../web/src/styles/tokens.css), and checked the fixes' computed
styles in Chrome. Severity: **high** blocks a task for keyboard or
screen-reader users; **medium** fails a criterion above but leaves a way
through; **low** is a gap outside the criteria above, or a polish item. High
and medium findings are fixed in this pass with a regression test; low ones are
follow-ups.
## Shared building blocks
Most of the criteria are met once, in the shared components every screen uses:
| Concern | Where | Behaviour |
| --- | --- | --- |
| Focus ring | [base.css](../web/src/styles/base.css) | `:focus-visible` draws a 2 px grape (`--color-accent`) outline 2 px out, on every focusable element. |
| Dialogs | [Sheet](../web/src/ui/Sheet.tsx) | `role="dialog"`, `aria-modal="true"`, and a required `aria-label` or `aria-labelledby` (the type will not compile without one). Opening moves focus in, Tab is trapped, Escape and the scrim close, the page behind is `inert`, and focus returns to the opener. Every sheet is labelled by its visible title. |
| Toggles | [Toggle](../web/src/ui/Toggle.tsx) | A `<button role="switch" aria-checked>`; Space and Enter flip it; a 44 px hit box around the 40 × 24 track. |
| Checkboxes | [Checkbox](../web/src/ui/Checkbox.tsx) | A native `<input type="checkbox">` inside its `<label>`; the label row is the 44 px target. |
| Segmented controls | [Segmented](../web/src/ui/Segmented.tsx) | `role="radiogroup"` of `role="radio"` buttons with a roving tab stop; arrows, Home and End move the choice. |
| Icon buttons | [IconButton](../web/src/ui/IconButton.tsx) | A required `label` names every icon-only button; the 40 px face sits in a 44 px hit box. |
| Primary nav | [BottomBar](../web/src/shell/BottomBar.tsx), [Rail](../web/src/shell/Rail.tsx), [Sidebar](../web/src/shell/Sidebar.tsx) | `<nav aria-label="Primary">` of `NavLink`s; the current item has `aria-current="page"`, nested routes (`/plan/shopping`, `/settings/ai`) keep their section current. The bottom bar's inactive items keep their name in a visually hidden label. |
| Status and errors | [ErrorBanner](../web/src/ui/ErrorBanner.tsx), `role="status"` / `role="alert"` lines | Loading regions are `aria-busy` and named; errors are announced. |
| Reduced motion | `--motion-scale` in tokens.css; `@media (prefers-reduced-motion: reduce)` in each animated component; `matchMedia` before every Web Animations call | Sheets appear without rising, skeletons stop pulsing, buttons do not scale, toggles and checkboxes do not spring, the nav pill jumps rather than slides, and the Discover deck fades a decided card out with no throw, bump, wiggle or promotion. |
## Routes
| Route | Keyboard | Focus | Names and roles | Hit targets | Reduced motion |
| --- | --- | --- | --- | --- | --- |
| App shell (every route) | Nav is links in tab order. | Global ring; **the bottom bar's ring was 1.8:1 on the dark bar — fixed (M2).** | `nav "Primary"`, `aria-current="page"`. | Bottom bar items 62 px tall, ≥ 44 px wide; rail items 56 × 44. | Pill jumps; no label fade. |
| `/` Home | All tiles, status rows, Recipe, the bell and the Rotation check are buttons or links. | Global ring; tiles, status rows and "We didn't make it" draw a 3 px ring, inside their cards' padding. | Tonight `section`, Status list; the bell is named "Recent updates" and its sheet is labelled by its title. | Recipe pill and "We didn't make it" kept at 44 px. | — |
| `/onboarding` (all steps) | Buttons and form fields; Add Regular submits on Enter. | Global ring. | `h1` per step; progress bar hidden; "Add Regular" and "Add *pick*" named. | Covered by onboarding tests. | — |
| `/plan` Draft board (mobile) | **Swap without drag:** each day tile is a button that opens the swap sheet; the sheet's rows are buttons. Previous/Next week, Rebuild week, Push to Calendar are buttons. | Global ring. | Week list "Dinners this week"; swap sheet labelled "Swap *Day*"; Quest log / Regulars radio group. | Tiles ≥ 86 px tall. | Sheet appears without rising. |
| `/plan` plan manager (tablet, desktop) | **Swap without drag:** every day has a "Swap *Day*" button opening the swap sheet. Dragging also works by keyboard: Enter or Space picks a dinner up, arrows move between days, Enter drops, Escape cancels; a polite live region narrates each step and focus lands on the day's swap button. | Global ring. | Days named by weekday; the panel is "Quest log and Regulars"; each draggable row is described by the keys hint. | **The tablet-portrait Quest log chips were 38 px tall — fixed (M4).** | — |
| `/plan/shopping` Supply run | Native checkboxes; links to the archive. | Global ring. | Groups named by dinner; native checkbox per item; read-only weeks say "have it" in text, not by colour alone. | Checkbox rows 44 px. | — |
| `/plan/shopping/archive` | Period radio group; posts are links. | Posts draw a 3 px ring. | Periods named by heading. | — | — |
| `/discover` | **← Pass, → Try it** anywhere on the page (not while typing, nor with a sheet open); Pass and Try it are also buttons; the Quest log pill opens its sheet. | Global ring. | Only the top card is exposed, labelled by its name; cards behind are `aria-hidden` and `inert`; Try it / Pass labels are shown under the buttons. | Pass and Try it 62 px; Quest log pill 44 px. | Card fades out; no throw, bump, wiggle, snap-back spring or promotion. |
| `/quick` Quick dinners | Tab radio group; add buttons; More link. | Global ring; More draws a 3 px ring. | Rows named by idea; "Added" is a status. | Covered by Quick dinners tests. | — |
| `/cook-from-pantry` | Field adds an ingredient on Enter; chips' remove buttons; Find ideas. | The field rings as a whole (`:focus-within`). | Ingredients list "Ingredients on hand"; idea cards named. | Chip remove buttons 44 px. | — |
| `/dinner/:id` (phone) | Buttons for Favorite, Add to week, back. | Global ring. | Favorite is `aria-pressed`; Add to week sheet labelled. | Favorite 52 px. | — |
| `/dinner/:id` Kitchen reading mode | ← / → move steps (not while typing, nor with a sheet open); Previous and Next step buttons; text size radio group; ingredient checkboxes. | **The text-size pill clipped its focus ring — fixed (M3).** | Keep screen on is a `role="switch"`; ingredients are native checkboxes; the current step is `aria-current="step"`. | Checkboxes 44 px rows. | — |
| `/settings` | Rows are links; on tablet, a `tablist` with arrow keys. | Global ring. | Tabs `role="tab"` / `tabpanel`. | Covered by Settings tests. | — |
| `/settings/notifications` | Every channel is a switch; reminder time button; webhook form. | Global ring. | Each switch named "*Event*: *Channel*"; the matrix is a named group; the webhook sheet is labelled. | Toggle hit box 44 px. | — |
| `/settings/ai` | Selects, slider, Add/Replace key. | Global ring. | Native `select`s and range input with labels; key sheet labelled. | Covered by AI settings tests. | — |
| `/settings/household` | Selects; time zone list of buttons. | Global ring. | The chosen time zone is `aria-pressed`. | Zone options 48 px. | — |
| `/settings/regulars` | Add, edit and favourite buttons; method selects; recipe search. | Global ring. | Favourite is `aria-pressed`; edit sheet labelled. | Covered by Regulars tests. | — |
| `/settings/calendar` | Connect link; calendar select. | Global ring. | Reconnect problems are `role="alert"`. | — | — |
## Text contrast
WCAG AA asks 4.5:1 for normal-size text. Every colour a stylesheet gives text,
on every ground it is drawn on:
| Text token | Grounds | Lowest ratio |
| --- | --- | --- |
| `--color-text` | bg, surface, neutral-100, neutral-200, accent-100, accent-200, accent-2-100, accent-2-200, accent-2-300 | 11.10 (on accent-2-300) |
| `--color-neutral-700` | bg, surface, neutral-100, neutral-200, accent-100, accent-200, accent-2-100, accent-2-200 | 5.22 (on accent-200) |
| `--color-neutral-800` | the same light grounds | 7.90 |
| `--color-neutral-900` | the same light grounds | 11.18 |
| `--color-accent` | the same light grounds | 7.16 |
| `--color-accent-700` | the same light grounds | 5.76 |
| `--color-accent-800` | the same light grounds | 8.69 |
| `--color-accent-2-800` | the same light grounds | 7.51 |
| `--color-accent-2-900` | the light grounds and accent-2-300 | 9.51 (on accent-2-300) |
| `--on-accent` | accent, accent-800, neutral-800 | 8.39 (on accent) |
| `--color-bg` | text, neutral-800 | 9.26 (on neutral-800) |
| `--color-neutral-400` | text (the bottom bar's inactive items) | 8.28 |
`--color-neutral-600` (3.5:1 on surface, 4.0:1 on bg) was used for muted text
in five places and failed — fixed (M1). [contrast.test.ts](../web/src/contrast.test.ts)
checks every pair above from tokens.css and fails if any stylesheet gives text a
colour token not in this table.
Non-text: the teal Try it button and teal "+" buttons draw a light icon on
`--color-accent-2-600` at 3.73:1, which meets the 3:1 asked of icons; their
names are text elsewhere (the shown label, or `aria-label`).
## Findings
### Fixed in this pass
| # | Severity | Route | Finding | Fix | Regression test |
| --- | --- | --- | --- | --- | --- |
| M1 | Medium | `/plan`, `/dinner/:id`, `/cook-from-pantry` | Muted text in `--color-neutral-600` at 3.5–4.0:1: the swapping day's dinner and start-by time, Regulars already this week in the swap sheet, ingredients set out and steps done in Kitchen reading mode, and the ingredient field's placeholder. | `--color-neutral-700` (≥ 5.4:1 on bg and surface); still muted against `--color-text`, and set-out ingredients keep their strike-through. | [contrast.test.ts](../web/src/contrast.test.ts); the swapping-tile and done-step colour assertions in [PlanPage.test.tsx](../web/src/plan/PlanPage.test.tsx) and [KitchenReading.test.tsx](../web/src/dinners/KitchenReading.test.tsx). |
| M2 | Medium | Every route (phone) | The bottom bar's focus ring was the global grape ring on the near-black bar: 1.8:1, nearly invisible. | The bar's items ring in `--on-accent` (15.4:1 on the bar). | "rings a focused item in on-accent ink" in [BottomBar.test.tsx](../web/src/shell/BottomBar.test.tsx). |
| M3 | Medium | `/dinner/:id` Kitchen reading mode | The text-size pill hides overflow, so the focus ring of the chosen size — drawn outside it — was cut off top and bottom, and in grape on the grape fill. | The ring sits 4 px inside the size, in `--on-accent` (8.4:1 on grape). Only the chosen size takes focus (roving tab stop). | "rings the focused size inside the clipped pill" in [KitchenReading.test.tsx](../web/src/dinners/KitchenReading.test.tsx). |
| M4 | Medium | `/plan` (tablet portrait, 768–1179 px) | The Quest log strip's draggable chips were 38 px tall (28 px photo, 3 px padding, 2 px border). | The chip button keeps a 44 × 44 px minimum. | The strip test's `expectMinHitArea` in [PlanPage.test.tsx](../web/src/plan/PlanPage.test.tsx). |
No high findings.
### Follow-ups (low)
| # | Route | Finding | Suggested follow-up |
| --- | --- | --- | --- |
| L1 | Every route with a checkbox | An unchecked checkbox's `--color-neutral-400` border is 1.9:1 on bg — below the 3:1 WCAG asks of a control's boundary. Its label row still names and reveals it. | Darken the border (for example `--color-neutral-600`, 4.0:1) with the design owner. |
| L2 | Settings, Kitchen reading mode | An off toggle's `--color-neutral-300` track is 1.4:1 on bg; the knob's position and the switch's name carry the state. | Give the off track a ≥ 3:1 edge with the design owner. |
| L3 | `/discover` | The ← / → shortcuts are not announced to screen readers. | Add `aria-keyshortcuts` to Pass and Try it. |
| L4 | `/discover` | Deciding the last card replaces Pass and Try it with "All caught up", and focus falls back to the page. | Move focus to "Get more now" when the deck empties. |
| L5 | `/discover` | A decision is not announced; the next card's name appears silently. | A polite status ("Added to the Quest log" / "Passed") after each decision. |
| L6 | `/plan` (mobile) | A day tile's name is its day and dinner ("MON Beef tacos"); it does not say it opens the swap sheet. | Add "Swap" to the tile's accessible name. |
+23
View File
@@ -259,6 +259,29 @@ automatic** (D-021).
unarchived dinner still `pending` with no `ground_dinner` job for its revision
queued or running is set `failed` with `interrupted`, so no dinner stays
pending with nothing left to ground it.
Photo backfill. `POST /api/v1/settings/photo-backfill` (CSRF-checked)
enqueues a `backfill_dinner_photos` job and answers 202 `{id}` without
waiting for it; while one is queued or running it enqueues nothing and
answers with that job's id, so two passes never overlap, across restarts
too. The kind is refused by `POST /api/v1/jobs`. The job
grounds, one at a time with `Ground` on its name, every unarchived dinner
that is `failed` with no photo and no pasted `recipe_url` — grounded,
`manual` and `pending` dinners are never listed, and a pasted link is
never replaced by a search. Success sets grounding_status `grounded` as
the `ground_dinner` job does (the recipe keeps its source URL and site);
failure leaves the dinner `failed` with the new `grounding_error`, for
the fix-recipe flow. One dinner's failure never stops the others; a
dinner renamed, archived or grounded by hand meanwhile is left as it is.
Its result is `{dinners: [{dinner_id, outcome, reason}]}`, outcome
`grounded`, `failed` (with its reason) or `skipped`. A dinner whose write
fails is `failed` with `grounding_error` and left as it was; the others
still run, then the job fails with `internal_error`, keeping that result.
The responses returning a `dinners.Dinner` representation (the list; the
create, update, archive, favorite and grounding responses; the idea
responses) carry `photo_backfill_eligible`, and
`GET /api/v1/dinners/{id}` does not; it says whether the backfill grounds
that dinner, computed by the same condition it lists dinners by, so a
client offers a search only where one runs.
`GET /api/v1/dinners/{id}/recipe-candidates`
searches for the dinner's name and returns `{candidates: [{candidate_id,
title, site_name, photo_url, minutes}]}`, at most 3, ranked as `Ground`
-50
View File
@@ -1,50 +0,0 @@
# Empty, loading and error states
Every route the PWA serves — the handoff's routes plus the added Settings
screens — with its empty, loading and error state. The handoff does not mock
these states; it gives one pattern for each
([handoff README](../design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md),
"Empty / loading / error states"), and every screen uses the shared component
for it:
| State | Component | Pattern |
| --- | --- | --- |
| Loading | `Skeleton` ([web/src/ui/Skeleton.tsx](../web/src/ui/Skeleton.tsx)) | Pill-shaped skeletons in `--color-surface` with a 1.2 s opacity pulse (no pulse under reduced motion). |
| Error | `ErrorBanner` ([web/src/ui/ErrorBanner.tsx](../web/src/ui/ErrorBanner.tsx)) | `--color-neutral-100` card, grape-700 (`--color-accent-700`) text, and a **Retry** button that reads or asks again. |
| Empty | `EmptyState` ([web/src/ui/EmptyState.tsx](../web/src/ui/EmptyState.tsx)) | Bagel title, body text and an optional primary action — the Discover deck's "All caught up / New side quests arrive Sunday." with "Get more now" is the model. |
[web/src/states.test.tsx](../web/src/states.test.tsx) renders the loading,
empty and error variant of each route below against a stubbed API, asserting
the copy and pressing Retry. Each screen's own test file covers its other
states.
The loading and error rows name the screen's main read. Smaller reads on a
screen (a Quest log count, a calendar list, a webhook card) use the same
`Skeleton` and `ErrorBanner` in place.
## Handoff routes
| Route | Screen | Empty | Loading | Error |
| --- | --- | --- | --- | --- |
| `/` | Home | `EmptyState` "No quest planned tonight" with **Plan this week**; rows that do not apply are hidden. | Skeletons for the date, Tonight card and status rows. | "Couldn't load tonight's quest. …" + Retry. |
| `/onboarding` | Onboarding — Welcome step | None: the step reads nothing; it shows the welcome and the Regulars, Side quests and Quest log terms with **Let's start**. | None: nothing to read. | None: nothing to read. |
| `/onboarding/regulars` | Onboarding — Regulars step | "Your regulars · 0" with the Common picks to add; **Next: notifications** stays disabled until enough Regulars are saved. | Skeleton under "Your regulars". | "Your Regulars could not load: …" + Retry. |
| `/onboarding/notifications` | Onboarding — Notifications step | None: the step reads nothing. | While finishing, **Turn on notifications** and **Maybe later** are disabled. | "Could not finish setting up: …"; the buttons come back to try again, with **Add Regulars** when fewer than three Regulars are saved. |
| `/plan` | Weekly plan (Draft board) | `EmptyState` "Nothing planned yet" with **Draft this week**. | Seven skeleton tiles ("Plan loading"; "Drafting this week" while the AI drafts). | "Couldn't load this week's plan. …" + Retry. |
| `/plan/shopping` | Supply run | `EmptyState` "No shopping list yet" with **Make the shopping list**; an archived week with none shows "No shopping list". A list with no ingredients shows "Nothing to buy". | Skeleton title and dinner cards. | "Couldn't load the shopping list. …" + Retry. |
| `/plan/shopping/archive` | Supply archive | `EmptyState` "Nothing archived yet" — "Each week's Supply run becomes a post here once it's made." | Skeleton posts. | "Couldn't load the Supply archive. …" + Retry. |
| `/discover` | Side quests deck | `EmptyState` **"All caught up"** — "New side quests arrive Sunday." with **Get more now**. | Skeleton card stack (also while a batch finds more). | "Couldn't load your side quests. …" + Retry. |
| `/quick` | Quick dinners | No ideas found: "Couldn't find Quick dinners. No ideas came back with a recipe to match." + Retry (an `ErrorBanner`, as no idea was found to show). | Six skeleton rows while the tab's ideas are found. | "Couldn't start looking for Quick dinners. …" + Retry. |
| `/cook-from-pantry` | Cook from what I have | Before a search: no ingredient chips and **Find ideas** disabled. No ideas found: "Couldn't find ideas. No ideas came back with a recipe to match." + Retry. | Five skeleton cards ("Ideas loading"). | "Couldn't start looking for ideas. …" + Retry. |
| `/dinner/:id` | Dinner detail | `EmptyState` "Dinner not found" — "This dinner isn't in our kitchen — it may have been removed." | Skeleton hero, title and lines. | "Couldn't load this dinner. …" + Retry. |
| `/settings` | Settings home | Status lines "None on rotation yet", "Not connected", "Push off · Google Chat not connected". | A skeleton per status line. | "Settings could not load: …" + Retry, which reads every failed status again. |
| `/settings/notifications` | Notifications & Google Chat | No webhook stored: the webhook form with **Save webhook**. | Skeletons for the channel matrix and the webhook card. | "Notification settings could not load: …" + Retry ("The Google Chat webhook could not load: …" for the webhook card). A failed webhook test says "Test message not sent: …" under the form. |
| `/settings/ai` | Admin · AI assistant | No key stored: "No key stored" with **Add key**. | Skeleton. | "AI settings could not load: …" + Retry. |
## Added Settings screens
| Route | Screen | Empty | Loading | Error |
| --- | --- | --- | --- | --- |
| `/settings/household` | Household | None: the household's settings always exist, seeded on first start; the screen shows the seeded values. | Skeleton. | "Household settings could not load: …" + Retry. |
| `/settings/regulars` | Regular dinners | "On rotation · 0" — "No Regulars yet. Add the dinners your household makes on repeat." | Skeleton. | "Your Regulars could not load: …" + Retry. |
| `/settings/calendar` | Google Calendar | "Not connected" with **Connect Google Calendar**. | Skeleton (and one for the calendar picker). | "Google Calendar status could not load: …" + Retry ("Your calendars could not load: …" + Retry for the picker). |
+164
View File
@@ -214,6 +214,26 @@ func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendin
return result.RowsAffected()
}
const getActivePhotoBackfillJob = `-- name: GetActivePhotoBackfillJob :one
SELECT id
FROM jobs
WHERE kind = ?1
AND status IN ('queued', 'running')
AND json_extract(payload, '$.dinner_id') IS NULL
ORDER BY id
LIMIT 1
`
// The id of the full photo backfill job (no dinner_id) of kind queued or
// running. StartPhotoBackfill enqueues one only when there is none, so
// there is at most one. Returns sql.ErrNoRows when there is none.
func (q *Queries) GetActivePhotoBackfillJob(ctx context.Context, kind string) (string, error) {
row := q.db.QueryRowContext(ctx, getActivePhotoBackfillJob, kind)
var id string
err := row.Scan(&id)
return id, err
}
const getDinner = `-- name: GetDinner :one
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
WHERE id = ?
@@ -344,6 +364,58 @@ func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Din
return i, err
}
const groundFailedDinner = `-- name: GroundFailedDinner :execrows
UPDATE dinners
SET recipe_id = ?1,
photo_id = ?2,
method = COALESCE(method, ?3),
minutes_total = COALESCE(minutes_total, ?4),
difficulty = COALESCE(difficulty, ?5),
grounding_status = 'grounded',
grounding_error = NULL,
updated_at = ?6
WHERE id = ?7
AND status <> 'archived'
AND grounding_status = 'failed'
AND photo_id IS NULL
AND recipe_url IS NULL
AND grounding_revision = ?8
`
type GroundFailedDinnerParams struct {
RecipeID sql.NullString
PhotoID sql.NullString
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
UpdatedAt string
ID string
GroundingRevision int64
}
// Records a photo backfill's grounding of a dinner it listed, while the
// dinner is still unarchived, failed, photoless, without a recipe_url and at
// the grounding_revision it was listed at; a dinner grounded by hand,
// renamed or archived meanwhile is left as it is (0 rows). Like
// GroundPendingDinner it keeps a method, minutes_total or difficulty a
// member already set.
func (q *Queries) GroundFailedDinner(ctx context.Context, arg GroundFailedDinnerParams) (int64, error) {
result, err := q.db.ExecContext(ctx, groundFailedDinner,
arg.RecipeID,
arg.PhotoID,
arg.Method,
arg.MinutesTotal,
arg.Difficulty,
arg.UpdatedAt,
arg.ID,
arg.GroundingRevision,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
UPDATE dinners
SET recipe_id = ?1,
@@ -444,6 +516,64 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
return items, nil
}
const listPhotoBackfillDinners = `-- name: ListPhotoBackfillDinners :many
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
WHERE status <> 'archived'
AND grounding_status = 'failed'
AND photo_id IS NULL
AND recipe_url IS NULL
AND id = COALESCE(?1, id)
ORDER BY normalized_name, id
`
// The dinners a photo backfill grounds by name: unarchived, failed, with no
// photo and no pasted recipe_url (the household's own link is never
// replaced by a search), ordered by normalized name. A non-NULL id narrows
// the list to that dinner. Grounded, manual and pending dinners are never
// listed: they have a recipe, or a ground_dinner job grounding them.
func (q *Queries) ListPhotoBackfillDinners(ctx context.Context, id sql.NullString) ([]Dinner, error) {
rows, err := q.db.QueryContext(ctx, listPhotoBackfillDinners, id)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Dinner
for rows.Next() {
var i Dinner
if err := rows.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
); 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 listUnarchivedDinners = `-- name: ListUnarchivedDinners :many
SELECT dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision, recipes.source_url AS recipe_source_url
FROM dinners
@@ -560,6 +690,40 @@ func (q *Queries) ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStar
return items, nil
}
const refailDinnerGrounding = `-- name: RefailDinnerGrounding :execrows
UPDATE dinners
SET grounding_error = ?1,
updated_at = ?2
WHERE id = ?3
AND status <> 'archived'
AND grounding_status = 'failed'
AND photo_id IS NULL
AND recipe_url IS NULL
AND grounding_revision = ?4
`
type RefailDinnerGroundingParams struct {
GroundingError sql.NullString
UpdatedAt string
ID string
GroundingRevision int64
}
// Records why a photo backfill could not ground a dinner it listed, under
// the same conditions as GroundFailedDinner (0 rows otherwise).
func (q *Queries) RefailDinnerGrounding(ctx context.Context, arg RefailDinnerGroundingParams) (int64, error) {
result, err := q.db.ExecContext(ctx, refailDinnerGrounding,
arg.GroundingError,
arg.UpdatedAt,
arg.ID,
arg.GroundingRevision,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const setDinnerFavorite = `-- name: SetDinnerFavorite :one
UPDATE dinners
SET is_favorite = ?,
+20
View File
@@ -117,6 +117,10 @@ type Querier interface {
// when there is none, so there is at most one. Returns sql.ErrNoRows when
// there is none.
GetActiveDraftJob(ctx context.Context, arg GetActiveDraftJobParams) (GetActiveDraftJobRow, error)
// The id of the full photo backfill job (no dinner_id) of kind queued or
// running. StartPhotoBackfill enqueues one only when there is none, so
// there is at most one. Returns sql.ErrNoRows when there is none.
GetActivePhotoBackfillJob(ctx context.Context, kind string) (string, error)
// The batch still pending or running, oldest first. Returns sql.ErrNoRows
// when there is none.
GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
@@ -175,6 +179,13 @@ type Querier interface {
// unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
// no longer as observed is kept: a member edited it while it was grounding.
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
// Records a photo backfill's grounding of a dinner it listed, while the
// dinner is still unarchived, failed, photoless, without a recipe_url and at
// the grounding_revision it was listed at; a dinner grounded by hand,
// renamed or archived meanwhile is left as it is (0 rows). Like
// GroundPendingDinner it keeps a method, minutes_total or difficulty a
// member already set.
GroundFailedDinner(ctx context.Context, arg GroundFailedDinnerParams) (int64, error)
// Records the background grounding of an unarchived dinner still pending at
// the grounding_revision it was grounded for; a dinner grounded some other
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
@@ -206,6 +217,12 @@ type Querier interface {
// The pending Rotation checks whose dinner is still tried, oldest plan
// date first, with their dinners.
ListPendingRotationReviews(ctx context.Context) ([]ListPendingRotationReviewsRow, error)
// The dinners a photo backfill grounds by name: unarchived, failed, with no
// photo and no pasted recipe_url (the household's own link is never
// replaced by a search), ordered by normalized name. A non-NULL id narrows
// the list to that dinner. Grounded, manual and pending dinners are never
// listed: they have a recipe, or a ground_dinner job grounding them.
ListPhotoBackfillDinners(ctx context.Context, id sql.NullString) ([]Dinner, error)
// The days of plan plan_id with their dinners, Monday first. A day with
// no dinner is left out.
ListPlanDays(ctx context.Context, planID string) ([]ListPlanDaysRow, error)
@@ -307,6 +324,9 @@ 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 why a photo backfill could not ground a dinner it listed, under
// the same conditions as GroundFailedDinner (0 rows otherwise).
RefailDinnerGrounding(ctx context.Context, arg RefailDinnerGroundingParams) (int64, error)
// Records a generation of list id from plan plan_id.
RegenerateShoppingList(ctx context.Context, arg RegenerateShoppingListParams) error
// Records a failed delivery and when to try again. The lease must still be
+199
View File
@@ -0,0 +1,199 @@
package dinners
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// KindBackfillPhotos is the job that grounds, by name, the dinners left
// without a photo: those whose grounding failed and that have no pasted
// recipe_url (ListPhotoBackfillDinners).
const KindBackfillPhotos = "backfill_dinner_photos"
// Photo backfill outcomes for one dinner.
const (
BackfillGrounded = "grounded"
BackfillFailed = "failed"
// BackfillSkipped: the dinner was grounded by hand, renamed or archived
// while the backfill grounded it, so the backfill left it as it is.
BackfillSkipped = "skipped"
)
// PhotoBackfillEligible reports whether a photo backfill grounds r: the
// condition ListPhotoBackfillDinners lists dinners by, and GroundFailedDinner
// and RefailDinnerGrounding write under, for one dinner. The dinner is
// unarchived, failed, with no photo and no pasted recipe_url.
func PhotoBackfillEligible(r db.Dinner) bool {
return r.Status != StatusArchived &&
r.GroundingStatus == GroundingFailed &&
!r.PhotoID.Valid &&
!r.RecipeUrl.Valid
}
// BackfillJob is the KindBackfillPhotos payload. DinnerID narrows the
// backfill to that dinner; empty backfills every dinner without a photo.
type BackfillJob struct {
DinnerID string `json:"dinner_id,omitempty"`
}
// BackfillOutcome is what a photo backfill did for one dinner. Reason is
// set only for BackfillFailed: the Reason recorded as its grounding_error.
type BackfillOutcome struct {
DinnerID string `json:"dinner_id"`
Outcome string `json:"outcome"`
Reason *string `json:"reason"`
}
// backfillResult is the KindBackfillPhotos result: the outcome of each
// dinner the backfill tried, in the order it tried them.
type backfillResult struct {
Dinners []BackfillOutcome `json:"dinners"`
}
func (r backfillResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// StartPhotoBackfill enqueues a KindBackfillPhotos job over every dinner
// without a photo and returns its id, without waiting for it. While a full
// backfill is queued or running it enqueues nothing and returns that job's
// id instead, so two passes never ground the same dinner at once; the jobs
// table holds the claim, so it holds across restarts too.
func (s *Service) StartPhotoBackfill(ctx context.Context) (string, error) {
var id string
created := false
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
active, err := db.New(tx).GetActivePhotoBackfillJob(ctx, KindBackfillPhotos)
switch {
case errors.Is(err, sql.ErrNoRows):
case err != nil:
return fmt.Errorf("dinners: read active photo backfill: %w", err)
default:
id = active
return nil
}
id, err = s.backfillJob.EnqueueTx(ctx, tx, BackfillJob{})
if err != nil {
return fmt.Errorf("dinners: enqueue photo backfill: %w", err)
}
created = true
return nil
})
if err != nil {
return "", err
}
if created {
s.backfillJob.Wake()
}
return id, nil
}
// errBackfillNotRecorded fails a photo backfill that could not record the
// outcome of one or more of its dinners.
var errBackfillNotRecorded = errors.New("dinners: photo backfill: dinner outcomes not recorded")
// runBackfillJob grounds each dinner ListPhotoBackfillDinners lists for p,
// one at a time, with recipes.Grounder.Ground on its name: the first ranked
// candidate whose photo caches and whose ingredients normalize grounds it,
// with the recipe's source URL and site kept as any grounding keeps them.
// Success records the recipe and photo with grounding_status grounded;
// failure leaves the dinner failed with the new Reason as its
// grounding_error, for the household to fix its recipe by hand. One
// dinner's failure, its search's, model's or database write's, never stops
// the others. A failure to list the dinners, or the job's context ending,
// fails the job at once. A write that failed fails the job once every
// dinner has been tried, as a grounding job's failed write does, with the
// outcomes published as its result, so the job never reports done for a
// dinner whose outcome is not stored.
func (s *Service) runBackfillJob(ctx context.Context, p BackfillJob, publish func(backfillResult) error) (backfillResult, error) {
q := db.New(s.st.Reader)
rows, err := q.ListPhotoBackfillDinners(ctx, sql.NullString{String: p.DinnerID, Valid: p.DinnerID != ""})
if err != nil {
return backfillResult{}, fmt.Errorf("dinners: photo backfill: list: %w", err)
}
res := backfillResult{Dinners: make([]BackfillOutcome, 0, len(rows))}
grounded, unrecorded := false, false
for _, row := range rows {
out, recorded, err := s.backfillDinner(ctx, row)
if err != nil {
return backfillResult{}, err
}
grounded = grounded || out.Outcome == BackfillGrounded
unrecorded = unrecorded || !recorded
res.Dinners = append(res.Dinners, out)
}
if grounded {
s.wake()
}
if unrecorded {
if err := publish(res); err != nil {
return backfillResult{}, errors.Join(errBackfillNotRecorded, err)
}
return backfillResult{}, errBackfillNotRecorded
}
return res, nil
}
// backfillDinner grounds row, a dinner ListPhotoBackfillDinners listed, as
// runBackfillJob describes, and reports whether its outcome was recorded.
// Its error is only the job's context ending.
func (s *Service) backfillDinner(ctx context.Context, row db.Dinner) (BackfillOutcome, bool, error) {
out := BackfillOutcome{DinnerID: row.ID}
w := db.New(s.st.Writer)
gr, err := s.grounder.Ground(ctx, row.Name, row.Name)
if ctxErr := ctx.Err(); ctxErr != nil {
return BackfillOutcome{}, false, ctxErr
}
if err != nil {
reason := reasonFor(err, false)
// Only the reason is logged: the error may quote a dependency's
// response, which jobs never store, return or log.
s.logger.InfoContext(ctx, "dinner photo backfill failed", "dinner_id", row.ID, "reason", reason)
n, err := w.RefailDinnerGrounding(ctx, db.RefailDinnerGroundingParams{
GroundingError: sql.NullString{String: reason, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
GroundingRevision: row.GroundingRevision,
})
out, recorded := s.backfillRecorded(ctx, out, BackfillFailed, reason, n, err)
return out, recorded, nil
}
n, err := w.GroundFailedDinner(ctx, db.GroundFailedDinnerParams{
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
Method: sql.NullString{String: gr.Method, Valid: true},
MinutesTotal: nullInt(gr.MinutesTotal),
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
GroundingRevision: row.GroundingRevision,
})
out, recorded := s.backfillRecorded(ctx, out, BackfillGrounded, "", n, err)
return out, recorded, nil
}
// backfillRecorded is out once the backfill recorded outcome (with reason,
// for BackfillFailed) on the dinner, writing n rows with err: a dinner that
// changed meanwhile (no row) is BackfillSkipped, and one whose write failed
// is BackfillFailed with ReasonGroundingError, the dinner left as it was,
// and not recorded.
func (s *Service) backfillRecorded(ctx context.Context, out BackfillOutcome, outcome, reason string, n int64, err error) (BackfillOutcome, bool) {
switch {
case err != nil:
// The error is a database error, never a dependency's text.
s.logger.ErrorContext(ctx, "dinner photo backfill not recorded", "dinner_id", out.DinnerID, "error", err)
out.Outcome, reason = BackfillFailed, ReasonGroundingError
case n == 0:
out.Outcome, reason = BackfillSkipped, ""
default:
out.Outcome = outcome
}
if reason != "" {
out.Reason = &reason
}
return out, err == nil
}
+425
View File
@@ -0,0 +1,425 @@
package dinners_test
import (
"database/sql"
"encoding/json"
"errors"
"slices"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"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"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// failedDinner stores a Regular named name whose grounding failed with
// no_recipe_found, without a photo or recipe_url, and returns its id.
func (e *env) failedDinner(t *testing.T, name string) string {
t.Helper()
q := db.New(e.st.Writer)
now := store.Now().String()
row, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: dinners.StatusRegular,
Origin: "regular",
GroundingStatus: dinners.GroundingPending,
UpdatedBy: sql.NullString{String: member, Valid: true},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if _, err := q.FailPendingDinnerGrounding(t.Context(), db.FailPendingDinnerGroundingParams{
GroundingError: sql.NullString{String: dinners.ReasonNoRecipe, Valid: true},
UpdatedAt: now,
ID: row.ID,
GroundingRevision: row.GroundingRevision,
}); err != nil {
t.Fatal(err)
}
return row.ID
}
func outcome(id, out string, reason *string) dinners.BackfillOutcome {
return dinners.BackfillOutcome{DinnerID: id, Outcome: out, Reason: reason}
}
func checkOutcomes(t *testing.T, got, want []dinners.BackfillOutcome) {
t.Helper()
gj, _ := json.Marshal(got)
wj, _ := json.Marshal(want)
if string(gj) != string(wj) {
t.Fatalf("outcomes = %s, want %s", gj, wj)
}
}
func TestBackfillGroundsFirstCandidateWithAPhoto(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
// curryA ranks first but its photo is gone; curryB's photo caches.
delete(e.images.JPEGs, "https://a.example/curry.jpg")
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
id := e.failedDinner(t, "Chicken curry")
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
if err != nil {
t.Fatal(err)
}
checkOutcomes(t, got, []dinners.BackfillOutcome{outcome(id, dinners.BackfillGrounded, nil)})
row := e.dinner(t, id)
if row.GroundingStatus != dinners.GroundingGrounded || !row.PhotoID.Valid || !row.RecipeID.Valid || row.GroundingError.Valid {
t.Fatalf("dinner = %+v, want grounded with a photo and no error", row)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != curryB || rec.SiteName.String != "B Kitchen" || rec.PhotoID != row.PhotoID {
t.Errorf("recipe = %s from %q photo %s, want %s from B Kitchen photo %s", rec.SourceUrl, rec.SiteName.String, rec.PhotoID.String, curryB, row.PhotoID.String)
}
if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) {
t.Errorf("searched %q, want the dinner's name", got)
}
}
func TestBackfillReusesCachedRecipeAndPhoto(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.search.Results["Curry chicken"] = []recipes.Candidate{{URL: curryA}}
// One reply: the second dinner must reuse the recipe the first stored.
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
first := e.failedDinner(t, "Chicken curry")
second := e.failedDinner(t, "Curry chicken")
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
if err != nil {
t.Fatal(err)
}
checkOutcomes(t, got, []dinners.BackfillOutcome{
outcome(first, dinners.BackfillGrounded, nil),
outcome(second, dinners.BackfillGrounded, nil),
})
a, b := e.dinner(t, first), e.dinner(t, second)
if a.RecipeID != b.RecipeID || a.PhotoID != b.PhotoID || !a.PhotoID.Valid {
t.Errorf("dinners grounded in recipe %s photo %s and recipe %s photo %s, want the same cached ones",
a.RecipeID.String, a.PhotoID.String, b.RecipeID.String, b.PhotoID.String)
}
if n := len(e.model.Requests()); n != 1 {
t.Errorf("normalize_recipe ran %d times, want 1", n)
}
if n := slices.Index(e.pages.Fetched(), curryA); n < 0 || slices.Contains(e.pages.Fetched()[n+1:], curryA) {
t.Errorf("fetched %q, want %s fetched once", e.pages.Fetched(), curryA)
}
}
func TestBackfillIsolatesEachDinnersFailure(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.search.Fail = map[string]error{"Broken search": errors.New("search provider down")}
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
// Listed by normalized name: both failures come before the curry.
broken := e.failedDinner(t, "Broken search")
curry := e.failedDinner(t, "Chicken curry")
absent := e.failedDinner(t, "Abalone surprise")
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
if err != nil {
t.Fatal(err)
}
checkOutcomes(t, got, []dinners.BackfillOutcome{
outcome(absent, dinners.BackfillFailed, ptr(dinners.ReasonNoRecipe)),
outcome(broken, dinners.BackfillFailed, ptr(dinners.ReasonSearchFailed)),
outcome(curry, dinners.BackfillGrounded, nil),
})
for id, reason := range map[string]string{absent: dinners.ReasonNoRecipe, broken: dinners.ReasonSearchFailed} {
row := e.dinner(t, id)
if row.GroundingStatus != dinners.GroundingFailed || row.GroundingError.String != reason || row.PhotoID.Valid {
t.Errorf("dinner %s = %+v, want failed with %s and no photo", row.Name, row, reason)
}
}
if row := e.dinner(t, curry); row.GroundingStatus != dinners.GroundingGrounded || !row.PhotoID.Valid {
t.Errorf("curry = %+v, want grounded with a photo", row)
}
}
func TestBackfillSkipsDinnersNotMissingAPhoto(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
e.waitJob(t, *c.GroundingJobID)
if row := e.dinner(t, c.ID); row.GroundingStatus != dinners.GroundingGrounded {
t.Fatalf("dinner = %+v, want grounded", row)
}
// A failed dinner with a pasted link keeps that link for a fix by hand.
linked, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Mystery pie"), RecipeURL: ptr("https://d.example/pie")}, member)
if err != nil {
t.Fatal(err)
}
e.waitJob(t, *linked.GroundingJobID)
if row := e.dinner(t, linked.ID); row.GroundingStatus != dinners.GroundingFailed {
t.Fatalf("linked dinner = %+v, want failed", row)
}
archived := e.failedDinner(t, "Old stew")
if _, err := e.svc.Archive(t.Context(), archived, member); err != nil {
t.Fatal(err)
}
searched := len(e.search.Queries())
id, err := e.svc.StartPhotoBackfill(t.Context())
if err != nil {
t.Fatal(err)
}
job := e.waitJob(t, id)
if job.Kind != dinners.KindBackfillPhotos || string(job.Result) != `{"dinners":[]}` {
t.Fatalf("job = %+v (result %s), want a backfill with no dinners", job, job.Result)
}
if got := e.search.Queries()[searched:]; len(got) != 0 {
t.Errorf("backfill searched %q, want nothing", got)
}
}
// TestPhotoBackfillEligibleIsWhatTheBackfillLists holds the API's
// photo_backfill_eligible to ListPhotoBackfillDinners for a dinner the
// backfill grounds and for each kind it never does.
func TestPhotoBackfillEligibleIsWhatTheBackfillLists(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
grounded, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
e.waitJob(t, *grounded.GroundingJobID)
linked, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Mystery pie"), RecipeURL: ptr("https://d.example/pie")}, member)
if err != nil {
t.Fatal(err)
}
e.waitJob(t, *linked.GroundingJobID)
failed := e.failedDinner(t, "Abalone surprise")
archived, err := e.svc.Archive(t.Context(), e.failedDinner(t, "Old stew"), member)
if err != nil {
t.Fatal(err)
}
shown := map[string]dinners.Dinner{archived.ID: archived}
regulars, err := e.svc.List(t.Context(), dinners.StatusRegular)
if err != nil {
t.Fatal(err)
}
for _, d := range regulars {
shown[d.ID] = d
}
q := db.New(e.st.Reader)
for id, want := range map[string]bool{grounded.ID: false, linked.ID: false, failed: true, archived.ID: false} {
listed, err := q.ListPhotoBackfillDinners(t.Context(), sql.NullString{String: id, Valid: true})
if err != nil {
t.Fatal(err)
}
d, ok := shown[id]
if !ok {
t.Fatalf("dinner %s not shown", id)
}
if got := len(listed) == 1; got != want || d.PhotoBackfillEligible != want {
t.Errorf("%s: listed %t, photo_backfill_eligible %t, want both %t", d.Name, got, d.PhotoBackfillEligible, want)
}
}
}
func TestBackfillOfOneDinnerGroundsOnlyIt(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
curry := e.failedDinner(t, "Chicken curry")
other := e.failedDinner(t, "Abalone surprise")
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{DinnerID: curry})
if err != nil {
t.Fatal(err)
}
checkOutcomes(t, got, []dinners.BackfillOutcome{outcome(curry, dinners.BackfillGrounded, nil)})
if row := e.dinner(t, other); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
t.Errorf("other dinner = %+v, want left failed", row)
}
if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) {
t.Errorf("searched %q, want only the dinner's name", got)
}
}
func TestBackfillLeavesDinnerChangedWhileGrounding(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
id := e.failedDinner(t, "Chicken curry")
gate := make(chan struct{})
e.search.Gate = gate
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
done := make(chan []dinners.BackfillOutcome)
go func() {
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{DinnerID: id})
if err != nil {
t.Error(err)
}
done <- got
}()
// The backfill listed the dinner and waits at its search.
for len(e.search.Queries()) == 0 {
time.Sleep(time.Millisecond)
}
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Chicken korma")}, member); err != nil {
t.Fatal(err)
}
close(gate)
checkOutcomes(t, <-done, []dinners.BackfillOutcome{outcome(id, dinners.BackfillSkipped, nil)})
if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
t.Errorf("dinner = %+v, want left failed without a photo", row)
}
}
// triggerAtOnce calls svc.StartPhotoBackfill from n goroutines released
// together and returns the job ids they got.
func triggerAtOnce(t *testing.T, svc *dinners.Service, n int) []string {
t.Helper()
ids := make([]string, n)
errs := make([]error, n)
release := make(chan struct{})
var wg sync.WaitGroup
for i := range n {
wg.Add(1)
go func() {
defer wg.Done()
<-release
ids[i], errs[i] = svc.StartPhotoBackfill(t.Context())
}()
}
close(release)
wg.Wait()
if err := errors.Join(errs...); err != nil {
t.Fatal(err)
}
return ids
}
// backfillJobs counts the KindBackfillPhotos jobs in st.
func backfillJobs(t *testing.T, st *store.Store) int {
t.Helper()
var n int
if err := st.Reader.QueryRowContext(t.Context(), `SELECT count(*) FROM jobs WHERE kind = ?`, dinners.KindBackfillPhotos).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
func TestStartPhotoBackfillIsSingleFlight(t *testing.T) {
t.Parallel()
const triggers = 8
dataDir := t.TempDir()
st := openStore(t, dataDir)
e := newUnstartedEnvOn(t, st, dataDir)
e.failedDinner(t, "Chicken curry")
gate := make(chan struct{})
e.search.Gate = gate
// Queued: simultaneous triggers all get the one pass they enqueued.
ids := triggerAtOnce(t, e.svc, triggers)
first := ids[0]
for _, id := range ids {
if id != first {
t.Fatalf("simultaneous triggers = %q, want one job id", ids)
}
}
if n := backfillJobs(t, st); n != 1 {
t.Fatalf("%d backfill jobs after simultaneous triggers, want 1", n)
}
// After a restart the queued pass is still the one: the claim is the
// stored job, not the process's memory.
if err := st.Close(); err != nil {
t.Fatal(err)
}
st = openStore(t, dataDir)
restarted := newUnstartedEnvOn(t, st, dataDir)
restarted.search.Gate = gate
restarted.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
if again, err := restarted.svc.StartPhotoBackfill(t.Context()); err != nil || again != first {
t.Fatalf("trigger after restart = %q, %v; want the queued %q", again, err, first)
}
// Running: the pass waits at its search, and simultaneous triggers all
// return it.
restarted.start(t)
for len(restarted.search.Queries()) == 0 {
time.Sleep(time.Millisecond)
}
for _, id := range triggerAtOnce(t, restarted.svc, triggers) {
if id != first {
t.Fatalf("trigger while running = %q, want the running %q", id, first)
}
}
if n := backfillJobs(t, st); n != 1 {
t.Fatalf("%d backfill jobs, want 1", n)
}
// Finished: the next trigger starts a new pass.
close(gate)
if job := restarted.waitJob(t, first); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v (result %s), want succeeded", job, job.Result)
}
next, err := restarted.svc.StartPhotoBackfill(t.Context())
if err != nil {
t.Fatal(err)
}
if next == first {
t.Fatalf("trigger after the pass finished = %q, want a new job", next)
}
restarted.waitJob(t, next)
}
func TestBackfillFailsJobWhenAWriteFails(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
absent := e.failedDinner(t, "Abalone surprise")
curry := e.failedDinner(t, "Chicken curry")
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_grounded BEFORE UPDATE OF grounding_status ON dinners
WHEN NEW.id = '`+curry+`' AND NEW.grounding_status = 'grounded'
BEGIN SELECT RAISE(ABORT, 'grounded refused'); END`); err != nil {
t.Fatal(err)
}
id, err := e.svc.StartPhotoBackfill(t.Context())
if err != nil {
t.Fatal(err)
}
job := e.waitJob(t, id)
if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != jobs.CodeInternal {
t.Fatalf("job = %+v, want failed with %s", job, jobs.CodeInternal)
}
var got struct {
Dinners []dinners.BackfillOutcome `json:"dinners"`
}
if err := json.Unmarshal(job.Result, &got); err != nil {
t.Fatalf("result %s: %v", job.Result, err)
}
// The other dinner was still tried and recorded.
checkOutcomes(t, got.Dinners, []dinners.BackfillOutcome{
outcome(absent, dinners.BackfillFailed, ptr(dinners.ReasonNoRecipe)),
outcome(curry, dinners.BackfillFailed, ptr(dinners.ReasonGroundingError)),
})
if row := e.dinner(t, curry); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
t.Errorf("curry = %+v, want left failed without a photo", row)
}
}
+19 -2
View File
@@ -79,6 +79,11 @@ type Dinner struct {
UpdatedBy *string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
// PhotoBackfillEligible is whether a photo backfill grounds the dinner
// (PhotoBackfillEligible): false for one with a pasted recipe link,
// which a search never replaces.
PhotoBackfillEligible bool `json:"photo_backfill_eligible"`
}
// NewRegular is a Regular to add: its Name, which the household always
@@ -138,6 +143,8 @@ type Service struct {
grounder *recipes.Grounder
logger *slog.Logger
groundJob *jobs.Kind[GroundJob]
// backfillJob enqueues KindBackfillPhotos jobs.
backfillJob *jobs.Kind[BackfillJob]
// wake is called after every committed change to a dinner's method or
// minutes, which can move its crock-pot start reminder.
wake func()
@@ -162,8 +169,8 @@ type Service struct {
// NewService returns a service over st that grounds dinners with grounder,
// calls wake (the scheduler's Wake) after every committed change to a
// dinner's method or minutes, and registers the KindGroundDinner job on
// runner, which must not have started yet.
// dinner's method or minutes, and registers the KindGroundDinner and
// KindBackfillPhotos jobs on runner, which must not have started yet.
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, wake func(), logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
@@ -184,6 +191,14 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner
return nil, err
}
s.groundJob = kind
backfill, err := jobs.Register(runner, KindBackfillPhotos, s.runBackfillJob)
if err != nil {
return nil, err
}
if err := backfill.ServiceOnly(); err != nil {
return nil, err
}
s.backfillJob = backfill
return s, nil
}
@@ -702,6 +717,8 @@ func fromRow(r db.Dinner) Dinner {
UpdatedBy: nullString(r.UpdatedBy),
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
PhotoBackfillEligible: PhotoBackfillEligible(r),
}
if r.MinutesTotal.Valid {
d.MinutesTotal = &r.MinutesTotal.Int64
+15
View File
@@ -74,3 +74,18 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error)
// SafeSourceURL is safeSourceURL.
var SafeSourceURL = safeSourceURL
// RunBackfillJob runs the KindBackfillPhotos handler for p in the caller's
// goroutine, as the job runner would, and returns its outcomes: on an
// error, those it last published.
func (s *Service) RunBackfillJob(ctx context.Context, p BackfillJob) ([]BackfillOutcome, error) {
var published backfillResult
r, err := s.runBackfillJob(ctx, p, func(partial backfillResult) error {
published = partial
return nil
})
if err != nil {
return published.Dinners, err
}
return r.Dinners, nil
}
+2 -1
View File
@@ -39,6 +39,7 @@ type env struct {
runner *jobs.Runner
search *recipestest.Search
pages *recipestest.Pages
images *recipestest.Images
model *llm.Fake
}
@@ -108,7 +109,7 @@ func newUnstartedEnvOn(t *testing.T, st *store.Store, dataDir string) *env {
if err != nil {
t.Fatal(err)
}
return &env{svc: svc, st: st, runner: runner, search: search, pages: pages, model: model}
return &env{svc: svc, st: st, runner: runner, search: search, pages: pages, images: images, model: model}
}
// start starts the env's job runner, stopping it when the test ends.
+14
View File
@@ -112,6 +112,20 @@ func postDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
}
}
// postPhotoBackfill starts a photo backfill over every dinner without a
// photo and answers 202 with its job id, which GET /jobs/{id} reports on,
// without waiting for the job.
func postPhotoBackfill(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := svc.StartPhotoBackfill(r.Context())
if err != nil {
writeDinnerError(w, r, logger, "start photo backfill", err)
return
}
writeJSON(w, r, logger, http.StatusAccepted, jobCreated{ID: id})
}
}
// patchDinner edits a dinner, recording the session's actor as updated_by.
func patchDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
+40
View File
@@ -556,3 +556,43 @@ func TestPostDinnerEnqueueFailureAnswersFailedDinner(t *testing.T) {
t.Errorf("listed = %v, want failed", got)
}
}
func TestPostPhotoBackfillGroundsFailedDinners(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
e.start(t)
// No model reply is scripted, so grounding on creation fails.
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
id := created["id"].(string)
waitJob(t, e.h, created["grounding_job_id"].(string))
if d := listedDinner(t, e.h, id); d["grounding_status"] != "failed" || d["photo_url"] != nil {
t.Fatalf("dinner = %v, want failed without a photo", d)
}
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
rec, started := callDinners(t, e.h, http.MethodPost, "/api/v1/settings/photo-backfill", "")
jobID, _ := started["id"].(string)
if rec.Code != http.StatusAccepted || jobID == "" {
t.Fatalf("POST photo-backfill = %d: %s, want 202 with a job id", rec.Code, rec.Body)
}
job := waitJob(t, e.h, jobID)
want := fmt.Sprintf(`map[dinners:[map[dinner_id:%s outcome:grounded reason:<nil>]]]`, id)
if job["status"] != "succeeded" || job["kind"] != "backfill_dinner_photos" || fmt.Sprint(job["result"]) != want {
t.Fatalf("job = %v, want backfill_dinner_photos succeeded with %s", job, want)
}
d := listedDinner(t, e.h, id)
photo, _ := d["photo_url"].(string)
if d["grounding_status"] != "grounded" || d["grounding_error"] != nil || !strings.HasSuffix(photo, "-400.jpg") {
t.Errorf("dinner = %v, want grounded with a photo", d)
}
// The backfill is started only by its own endpoint.
rec, _ = callDinners(t, e.h, http.MethodPost, "/api/v1/jobs", `{"kind":"backfill_dinner_photos","payload":{}}`)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "service_only_kind") {
t.Errorf("POST /jobs backfill = %d: %s, want 400 service_only_kind", rec.Code, rec.Body)
}
}
+1
View File
@@ -162,6 +162,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Get("/settings/chat-webhook", getChatWebhook(logger, chatSvc))
apiV1.Put("/settings/chat-webhook", putChatWebhook(logger, chatSvc))
apiV1.Post("/settings/chat-webhook/test", postChatWebhookTest(logger, chatSvc))
apiV1.Post("/settings/photo-backfill", postPhotoBackfill(logger, dinnerSvc))
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc))
+62
View File
@@ -181,3 +181,65 @@ WHERE grounding_status = 'pending'
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
AND json_extract(jobs.payload, '$.revision') = dinners.grounding_revision
);
-- name: ListPhotoBackfillDinners :many
-- The dinners a photo backfill grounds by name: unarchived, failed, with no
-- photo and no pasted recipe_url (the household's own link is never
-- replaced by a search), ordered by normalized name. A non-NULL id narrows
-- the list to that dinner. Grounded, manual and pending dinners are never
-- listed: they have a recipe, or a ground_dinner job grounding them.
SELECT * FROM dinners
WHERE status <> 'archived'
AND grounding_status = 'failed'
AND photo_id IS NULL
AND recipe_url IS NULL
AND id = COALESCE(sqlc.narg(id), id)
ORDER BY normalized_name, id;
-- name: GetActivePhotoBackfillJob :one
-- The id of the full photo backfill job (no dinner_id) of kind queued or
-- running. StartPhotoBackfill enqueues one only when there is none, so
-- there is at most one. Returns sql.ErrNoRows when there is none.
SELECT id
FROM jobs
WHERE kind = sqlc.arg(kind)
AND status IN ('queued', 'running')
AND json_extract(payload, '$.dinner_id') IS NULL
ORDER BY id
LIMIT 1;
-- name: GroundFailedDinner :execrows
-- Records a photo backfill's grounding of a dinner it listed, while the
-- dinner is still unarchived, failed, photoless, without a recipe_url and at
-- the grounding_revision it was listed at; a dinner grounded by hand,
-- renamed or archived meanwhile is left as it is (0 rows). Like
-- GroundPendingDinner it keeps a method, minutes_total or difficulty a
-- member already set.
UPDATE dinners
SET recipe_id = sqlc.arg(recipe_id),
photo_id = sqlc.arg(photo_id),
method = COALESCE(method, sqlc.arg(method)),
minutes_total = COALESCE(minutes_total, sqlc.arg(minutes_total)),
difficulty = COALESCE(difficulty, sqlc.arg(difficulty)),
grounding_status = 'grounded',
grounding_error = NULL,
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id)
AND status <> 'archived'
AND grounding_status = 'failed'
AND photo_id IS NULL
AND recipe_url IS NULL
AND grounding_revision = sqlc.arg(grounding_revision);
-- name: RefailDinnerGrounding :execrows
-- Records why a photo backfill could not ground a dinner it listed, under
-- the same conditions as GroundFailedDinner (0 rows otherwise).
UPDATE dinners
SET grounding_error = sqlc.arg(grounding_error),
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id)
AND status <> 'archived'
AND grounding_status = 'failed'
AND photo_id IS NULL
AND recipe_url IS NULL
AND grounding_revision = sqlc.arg(grounding_revision);
-86
View File
@@ -1,86 +0,0 @@
// Text contrast over the design tokens (docs/A11Y.md, "Text contrast"). Every
// colour a stylesheet gives text must be a token listed in TEXT_GROUNDS, and
// every pair listed there must reach WCAG AA's 4.5:1 for normal-size text.
import { describe, expect, it } from 'vitest'
import tokens from './styles/tokens.css?raw'
const stylesheets = import.meta.glob<string>(['./**/*.css', '!./styles/tokens.css'], {
query: '?raw',
import: 'default',
eager: true,
})
// The grounds MealQuest's pages, cards, rows and tinted fills use.
const LIGHT_GROUNDS = [
'--color-bg',
'--color-surface',
'--color-neutral-100',
'--color-neutral-200',
'--color-accent-100',
'--color-accent-200',
'--color-accent-2-100',
'--color-accent-2-200',
]
// Each text colour token, with every ground it is drawn on.
const TEXT_GROUNDS: Record<string, string[]> = {
'--color-text': [...LIGHT_GROUNDS, '--color-accent-2-300'],
'--color-neutral-700': LIGHT_GROUNDS,
'--color-neutral-800': LIGHT_GROUNDS,
'--color-neutral-900': LIGHT_GROUNDS,
'--color-accent': LIGHT_GROUNDS,
'--color-accent-700': LIGHT_GROUNDS,
'--color-accent-800': LIGHT_GROUNDS,
'--color-accent-2-800': LIGHT_GROUNDS,
'--color-accent-2-900': [...LIGHT_GROUNDS, '--color-accent-2-300'],
// Light ink on the grape fills (buttons, the active nav pill, the chosen
// text size) and on the dark ones (the Offline pill).
'--on-accent': ['--color-accent', '--color-accent-800', '--color-neutral-800'],
// The dark pill button and the chosen settings tab.
'--color-bg': ['--color-text', '--color-neutral-800'],
// The bottom bar's inactive items, on the dark bar.
'--color-neutral-400': ['--color-text'],
}
function hex(name: string): string {
const value = new RegExp(`^\\s*${name}:\\s*(#[0-9a-fA-F]{6})\\b`, 'm').exec(tokens)?.[1]
if (value === undefined) throw new Error(`${name} is not a hex colour in tokens.css`)
return value
}
// Relative luminance, per WCAG 2.
function luminance(colour: string): number {
const [r, g, b] = [1, 3, 5].map((i) => {
const c = Number.parseInt(colour.slice(i, i + 2), 16) / 255
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
})
return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!
}
function contrast(a: string, b: string): number {
const [light, dark] = [luminance(hex(a)), luminance(hex(b))].sort((x, y) => y - x)
return (light! + 0.05) / (dark! + 0.05)
}
describe('text contrast', () => {
const pairs = Object.entries(TEXT_GROUNDS).flatMap(([text, grounds]) => grounds.map((ground) => [text, ground] as const))
it.each(pairs)('%s on %s reaches 4.5:1', (text, ground) => {
expect(contrast(text, ground)).toBeGreaterThanOrEqual(4.5)
})
it('gives text only the colour tokens checked above', () => {
expect(Object.keys(stylesheets).length).toBeGreaterThan(0)
const unchecked = Object.entries(stylesheets).flatMap(([path, css]) =>
Array.from(css.matchAll(/(?<![\w-])color:\s*([^;}]+)/g))
.map((match) => match[1]!.trim())
.filter((value) => value !== 'inherit')
.filter((value) => {
const token = /^var\((--[\w-]+)\)$/.exec(value)?.[1]
return token === undefined || !(token in TEXT_GROUNDS)
})
.map((value) => `${path}: color: ${value}`),
)
expect(unchecked).toEqual([])
})
})
@@ -52,6 +52,8 @@ function listed(is_favorite: boolean) {
is_favorite,
photo_url: null,
grounding_status: 'grounded',
grounding_error: null,
photo_backfill_eligible: false,
}
}
+2 -9
View File
@@ -341,13 +341,6 @@
box-shadow: none;
}
/* The pill clips anything outside it, so the focus ring sits inside the
size, drawn in on-accent ink: only the chosen size takes focus. */
.readingHeader .textSize > :focus-visible {
outline-color: var(--on-accent);
outline-offset: -4px;
}
.readingHeader .textSize > :nth-child(1) {
font-size: 13px;
}
@@ -440,7 +433,7 @@
}
.setOut {
color: var(--color-neutral-700);
color: var(--color-neutral-600);
text-decoration: line-through;
}
@@ -522,7 +515,7 @@
}
.stepDone {
color: var(--color-neutral-700);
color: var(--color-neutral-600);
}
.stepCurrent {
+1 -16
View File
@@ -325,7 +325,7 @@ describe('Kitchen reading mode', () => {
// The step done is muted, its number a check.
expect(steps()[0]!.textContent).toBe('Rinse the rice and start it cooking.')
expect(steps()[0]!.querySelector('svg')).not.toBeNull()
expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-700)')
expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-600)')
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(18px * var(--reading-scale))')
fireEvent.click(next)
@@ -381,21 +381,6 @@ describe('Kitchen reading mode', () => {
})
describe('text size', () => {
it('rings the focused size inside the clipped pill, in on-accent ink on the grape', async () => {
serve()
await renderPage()
const medium = screen.getByRole('radio', { name: 'Medium text' })
// A key press makes the next focus a keyboard one, which
// :focus-visible matches.
fireEvent.keyDown(document.body, { key: 'Tab' })
medium.focus()
expect(medium.matches(':focus-visible')).toBe(true)
// The pill hides overflow, so a ring outside the size would be cut off.
expect(declaredStyle(medium).getPropertyValue('outline-offset')).toBe('-4px')
expect(declaredStyle(medium).getPropertyValue('outline-color')).toBe('var(--on-accent)')
expect(declaredStyle(medium).background).toBe('var(--color-accent)')
})
it('is remembered on this device', async () => {
serve()
const view = await renderPage()
+18 -2
View File
@@ -20,8 +20,12 @@ export type Difficulty = (typeof DIFFICULTIES)[number]
export type GroundingStatus = (typeof GROUNDING_STATUSES)[number]
// Regular is the part of a dinner the Regulars screens read. photo_id is
// the stored photo's id, from photo_url. pending is true while an
// optimistic add waits for the server; its id is not the server's.
// the stored photo's id, from photo_url. grounding_error is why its
// grounding failed, a reason code, set only while grounding_status is
// failed. photo_backfill_eligible is whether a photo backfill (Search
// again) searches for it: never for a dinner with a pasted recipe link.
// pending is true while an optimistic add waits for the server;
// its id is not the server's.
export type Regular = {
id: string
name: string
@@ -31,6 +35,8 @@ export type Regular = {
is_favorite: boolean
photo_id: string | undefined
grounding_status: GroundingStatus
grounding_error: string | null
photo_backfill_eligible: boolean
pending?: true
}
@@ -113,6 +119,9 @@ export function parseDinner(where: string, body: unknown): Regular {
throw new Error(`${where}: response is not a dinner`)
}
if (typeof body.is_favorite !== 'boolean') throw new Error(`${where}: is_favorite is not a boolean`)
if (typeof body.photo_backfill_eligible !== 'boolean') {
throw new Error(`${where}: photo_backfill_eligible is not a boolean`)
}
return {
id: body.id,
name: body.name,
@@ -122,6 +131,11 @@ export function parseDinner(where: string, body: unknown): Regular {
is_favorite: body.is_favorite,
photo_id: photoId(where, body.photo_url),
grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status),
grounding_error: nullable(body.grounding_error, (v) => {
if (typeof v !== 'string') throw new Error(`${where}: grounding_error is not a string`)
return v
}),
photo_backfill_eligible: body.photo_backfill_eligible,
}
}
@@ -225,6 +239,8 @@ export function useAddRegular() {
is_favorite: false,
photo_id: undefined,
grounding_status: 'pending',
grounding_error: null,
photo_backfill_eligible: false,
pending: true,
}
await changeOptimistically(queryClient, (list) => [...list, optimistic])
+1 -1
View File
@@ -71,7 +71,7 @@
}
.input::placeholder {
color: var(--color-neutral-700);
color: var(--color-neutral-600);
}
.notice {
+1 -7
View File
@@ -203,7 +203,7 @@
.swapping .dinner,
.swapping .startBy {
color: var(--color-neutral-700);
color: var(--color-neutral-600);
}
.rebuildCell {
@@ -410,12 +410,6 @@
list-style: none;
}
/* An upright tablet's week: four days over three, every column wide enough
that nothing scrolls sideways. */
.portraitPage .week {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.column {
min-width: 0;
display: flex;
+3 -5
View File
@@ -8,7 +8,6 @@ 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'
import { expectMinHitArea } from '../test/hitArea.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { ago, cooking, duration, PlanPage } from './PlanPage.tsx'
@@ -115,6 +114,8 @@ function dinner(id: string, name: string, difficulty: string | null, minutes: nu
is_favorite: false,
photo_url: null,
grounding_status: 'grounded',
grounding_error: null,
photo_backfill_eligible: false,
}
}
@@ -1223,7 +1224,7 @@ describe('PlanPage', () => {
expect(declaredStyle(friday).background).toBe('var(--color-bg)')
expect(declaredStyle(friday).boxShadow).toBe('inset 0 0 0 2px var(--color-accent)')
expect(declaredStyle(within(friday).getByText('FRI · SWAPPING…')).color).toBe('var(--color-accent-700)')
expect(declaredStyle(within(friday).getByText('Pizza night')).color).toBe('var(--color-neutral-700)')
expect(declaredStyle(within(friday).getByText('Pizza night')).color).toBe('var(--color-neutral-600)')
// "Quest log · N | Regulars · N", on the Quest log first.
expect(await within(sheet).findByRole('radio', { name: 'Quest log · 1', checked: true })).toBeInTheDocument()
@@ -1645,9 +1646,6 @@ describe.each([
const chip = within(within(strip).getByRole('list', { name: 'Quest log dinners' })).getByRole('listitem')
expect(chip).toHaveTextContent('Lemon orzo')
expect(declaredStyle(within(chip).getByRole('img', { hidden: true, name: 'Lemon orzo' })).width).toBe('28px')
// The 28 px photo makes a 38 px chip; the chip is a button, so it
// keeps the 44 px tap minimum.
expectMinHitArea(within(chip).getByRole('button', { name: /Lemon orzo/ }))
// The strip follows the grid.
const grid = await screen.findByRole('list', { name: 'Dinners this week' })
expect(grid.compareDocumentPosition(strip) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
+2 -8
View File
@@ -8,7 +8,7 @@ import { Confirm } from '../settings/LeaveGuard.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun, WEEK_PARAM } from '../shopping/api.ts'
import { useBreakpoint, useOrientation, type Breakpoint } from '../shell/useBreakpoint.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
import { EmptyState } from '../ui/EmptyState.tsx'
@@ -673,10 +673,8 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
// week; the week buttons keep ?week= on the week shown. The board keeps its place in the tree across layouts, so crossing a
// breakpoint keeps a draft being followed. From tablet up a dinner is
// dragged from the strip or the panel onto a day of the grid to swap it in.
// An upright tablet lays the week out four days over three.
export function PlanPage() {
const layout = useBreakpoint()
const orientation = useOrientation()
const settings = useHouseholdSettings()
const [params, setParams] = useSearchParams()
const chosen = params.get(WEEK_PARAM)
@@ -701,11 +699,7 @@ export function PlanPage() {
}
return (
<PlanDragProvider>
<div
className={[styles.page, styles[`${layout}Page`], layout === 'tablet' && orientation === 'portrait' && styles.portraitPage]
.filter(Boolean)
.join(' ')}
>
<div className={`${styles.page} ${styles[`${layout}Page`]}`}>
<div className={styles.board}>
{content}
{layout === 'tablet' && <QuestLogStrip />}
-5
View File
@@ -190,12 +190,7 @@
padding: 8px;
}
/* The 28 px photo makes a 38 px chip; the chip is a button, so it keeps the
44 px tap minimum. */
.chip.draggable {
box-sizing: border-box;
min-width: 44px;
min-height: 44px;
padding: 3px 12px 3px 3px;
}
+1 -1
View File
@@ -40,7 +40,7 @@
/* A dinner already this week: shown, muted, and not a button. */
.already {
color: var(--color-neutral-700);
color: var(--color-neutral-600);
}
.skeletonRow {
@@ -300,3 +300,27 @@
.lock {
display: contents;
}
/* Find missing photos: its hint, button and progress, padded like a sheet. */
.card.backfill {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
/* Why a Regular's recipe was not found. */
.reason {
margin: 0;
padding: 12px 14px;
border-radius: 18px;
background: var(--color-neutral-100);
color: var(--color-neutral-900);
font-family: var(--font-body);
font-size: 14px;
line-height: 1.4;
}
.reasonLabel {
font-weight: 700;
}
+203 -1
View File
@@ -85,7 +85,11 @@ const TACOS = dinnerBody('d1', 'Beef tacos', {
photo_url: '/media/photos/p1-400.jpg',
})
const CHILI = dinnerBody('d2', 'Chili', { method: 'crockpot', minutes_total: 480, grounding_status: 'pending' })
const PIZZA = dinnerBody('d3', 'Pizza night', { grounding_status: 'failed', grounding_error: 'no_recipe_found' })
const PIZZA = dinnerBody('d3', 'Pizza night', {
grounding_status: 'failed',
grounding_error: 'no_recipe_found',
photo_backfill_eligible: true,
})
const ROAST = dinnerBody('d4', 'Pot roast', { method: 'oven', grounding_status: 'manual' })
function rows() {
@@ -496,6 +500,204 @@ describe('RegularsSettingsPage', () => {
})
})
describe('Photo backfill', () => {
const BACKFILL = 'POST /api/v1/settings/photo-backfill'
const JOB = 'GET /api/v1/jobs/j9'
// backfillJob is a GET /api/v1/jobs/j9 answer for the photo backfill.
function backfillJob(status: string, result: unknown = null, error: string | null = null) {
return jsonResponse(200, { id: 'j9', kind: 'backfill_dinner_photos', status, result, error })
}
// polls answers each poll of the job with the next answer, then the last
// one again; an answer may change the server first.
function polls(...answers: (() => Response)[]) {
let next = 0
return () => answers[Math.min(next++, answers.length - 1)]!()
}
function fixSheet() {
return within(screen.getByRole('dialog', { name: 'Fix recipe' }))
}
const NO_CANDIDATES = { 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, { candidates: [] }) }
it('shows why the recipe was not found in the fix-recipe sheet', async () => {
stubServer([PIZZA], NO_CANDIDATES)
await renderPage()
openRow('Pizza night')
expect(fixSheet().getByText('No recipe page the search found had a recipe we could use.')).toBeInTheDocument()
expect(fixSheet().getByRole('button', { name: 'Search again' })).toBeEnabled()
})
it('offers no search again for a pasted link that had no usable recipe', async () => {
const linked = { ...PIZZA, grounding_error: 'recipe_unusable', photo_backfill_eligible: false }
stubServer([linked], NO_CANDIDATES)
await renderPage()
openRow('Pizza night')
expect(fixSheet().getByText('The pasted recipe link has no recipe we could use.')).toBeInTheDocument()
expect(fixSheet().queryByRole('button', { name: 'Search again' })).not.toBeInTheDocument()
})
it('offers no search again for a dinner with a pasted link whatever its reason', async () => {
const linked = { ...PIZZA, grounding_error: 'ai_failed', photo_backfill_eligible: false }
stubServer([linked], NO_CANDIDATES)
await renderPage()
openRow('Pizza night')
expect(fixSheet().getByText("The AI assistant couldn't read the recipe.")).toBeInTheDocument()
expect(fixSheet().queryByRole('button', { name: 'Search again' })).not.toBeInTheDocument()
})
it('searches again from the fix-recipe sheet and shows the new reason when it still fails', async () => {
const server = stubServer([PIZZA], {
...NO_CANDIDATES,
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: polls(
() => backfillJob('running'),
() => {
server.regulars = [{ ...PIZZA, grounding_error: 'search_failed' }]
return backfillJob('succeeded', { dinners: [{ dinner_id: 'd3', outcome: 'failed', reason: 'search_failed' }] })
},
),
})
await renderPage()
openRow('Pizza night')
fireEvent.click(fixSheet().getByRole('button', { name: 'Search again' }))
expect(await fixSheet().findByRole('button', { name: 'Searching again…' })).toBeDisabled()
expect(fixSheet().getByText('Searching again… this can take a few minutes.')).toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual([{ request: BACKFILL, body: undefined }])
expect(await fixSheet().findByText('Searched again: still no recipe found.', {}, { timeout: 3000 })).toBeInTheDocument()
expect(await fixSheet().findByText("The recipe search couldn't be reached.")).toBeInTheDocument()
expect(fixSheet().getByRole('button', { name: 'Search again' })).toBeEnabled()
})
it('closes the fix-recipe sheet once searching again finds the recipe and its photo', async () => {
const server = stubServer([PIZZA], {
...NO_CANDIDATES,
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: () => {
server.regulars = [{ ...PIZZA, grounding_status: 'grounded', grounding_error: null, photo_url: '/media/photos/p8-400.jpg' }]
return backfillJob('succeeded', { dinners: [{ dinner_id: 'd3', outcome: 'grounded', reason: null }] })
},
})
await renderPage()
openRow('Pizza night')
fireEvent.click(fixSheet().getByRole('button', { name: 'Search again' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
expect(within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })).toHaveAttribute('src', '/media/photos/p8-400.jpg')
})
it('says when a dinner was not included in the search', async () => {
stubServer([PIZZA], {
...NO_CANDIDATES,
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: () => backfillJob('succeeded', { dinners: [] }),
})
await renderPage()
openRow('Pizza night')
fireEvent.click(fixSheet().getByRole('button', { name: 'Search again' }))
expect(await fixSheet().findByText('This dinner was not included in that search.')).toBeInTheDocument()
})
it('finds missing photos: the placeholder shows until the backfill finds a photo', async () => {
const found = { ...PIZZA, grounding_status: 'grounded', grounding_error: null, photo_url: '/media/photos/p8-400.jpg' }
const server = stubServer([TACOS, PIZZA], {
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: polls(
() => backfillJob('queued'),
() => {
server.regulars = [TACOS, found]
return backfillJob('succeeded', { dinners: [{ dinner_id: 'd3', outcome: 'grounded', reason: null }] })
},
),
})
await renderPage()
// The photoless dinner shows the striped placeholder, not an image.
const placeholder = within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })
expect(placeholder).not.toHaveAttribute('src')
expect(placeholder).toHaveTextContent('dish photo')
const trigger = screen.getByRole('button', { name: 'Find missing photos' })
expectMinHitArea(trigger)
fireEvent.click(trigger)
expect(await screen.findByRole('button', { name: 'Finding photos…' })).toBeDisabled()
expect(await screen.findByText('Finding missing photos… this can take a few minutes.')).toBeInTheDocument()
expect(within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })).not.toHaveAttribute('src')
expect(sent(server.fetchMock)).toEqual([{ request: BACKFILL, body: undefined }])
expect(await screen.findByText('Photo search done: 1 photo found.', {}, { timeout: 3000 })).toBeInTheDocument()
await waitFor(() =>
expect(within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })).toHaveAttribute('src', '/media/photos/p8-400.jpg'),
)
expect(screen.getByRole('button', { name: 'Find missing photos' })).toBeEnabled()
expect(reads(server.fetchMock)).toBe(2)
})
it('sums up what the backfill did', async () => {
stubServer([PIZZA], {
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: () =>
backfillJob('succeeded', {
dinners: [
{ dinner_id: 'd3', outcome: 'grounded', reason: null },
{ dinner_id: 'd5', outcome: 'grounded', reason: null },
{ dinner_id: 'd6', outcome: 'failed', reason: 'no_recipe_found' },
{ dinner_id: 'd7', outcome: 'skipped', reason: null },
],
}),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
expect(
await screen.findByText(
'Photo search done: 2 photos found · 1 dinner still needs a recipe · 1 dinner changed meanwhile, left as it was.',
),
).toBeInTheDocument()
})
it('says when no dinner was missing a photo', async () => {
stubServer([TACOS], {
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: () => backfillJob('succeeded', { dinners: [] }),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
expect(await screen.findByText('No dinners were missing a photo.')).toBeInTheDocument()
})
it('says why the backfill stopped', async () => {
stubServer([PIZZA], {
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: () => backfillJob('failed', { dinners: [] }, 'internal_error'),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Photo search stopped: Something went wrong on the server.')
expect(screen.getByRole('button', { name: 'Find missing photos' })).toBeEnabled()
})
it('says why the backfill could not start', async () => {
stubServer([PIZZA], { [BACKFILL]: () => errorResponse(403, 'csrf_failed') })
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Photo search not started: csrf_failed message')
expect(screen.getByRole('button', { name: 'Find missing photos' })).toBeEnabled()
})
})
it('refreshes grounding status while any Regular is finding its recipe, then stops', async () => {
// Only intervals are faked: TanStack Query polls with setInterval, and
// Testing Library's waits keep their real timers.
+148 -14
View File
@@ -34,6 +34,7 @@ import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import { TextInput } from '../ui/TextInput.tsx'
import { usePhotoBackfill, type BackfillResult, type PhotoBackfill } from './photoBackfill.ts'
import styles from './RegularsSettingsPage.module.css'
// The most minutes a dinner may take, as the dinners API allows: a day.
@@ -57,7 +58,8 @@ const LINK = /^https?:\/\//i
// method and minutes, favorite heart and whether its recipe was found. The
// field at the top adds one by name, or by pasted recipe link with a name
// the household gives it; tapping one
// edits or removes it, or fixes its recipe when none was found.
// edits or removes it, or fixes its recipe when none was found. Find
// missing photos searches again for every dinner left without one.
export function RegularsSettingsPage() {
const navigate = useNavigate()
return (
@@ -80,15 +82,13 @@ export function RegularsSettingsPage() {
// Which sheet is open, for which Regular.
type Open = { sheet: 'edit' | 'fix'; id: string } | null
// RegularsManager lists, adds, edits and fixes the recipes of the
// household's Regulars: this page's body, and the Settings "Regular
// dinners" sub-tab at tablet and desktop widths.
export function RegularsManager() {
function RegularsManager() {
const regulars = useRegulars()
const add = useAddRegular()
const remove = useRemoveRegular()
const favorite = useToggleFavorite()
const { refusals, track } = useRefusals()
const backfill = usePhotoBackfill()
const [draft, setDraft] = useState('')
const [linkName, setLinkName] = useState('')
const [open, setOpen] = useState<Open>(null)
@@ -97,8 +97,10 @@ export function RegularsManager() {
const isLink = LINK.test(draft.trim())
const ready = draft.trim() !== '' && (!isLink || linkName.trim() !== '')
const list = regulars.data ?? []
// A Regular removed meanwhile closes its sheet.
// A Regular removed meanwhile closes its sheet, and one whose recipe was
// found meanwhile closes its fix-recipe sheet.
const opened = open && list.find((regular) => regular.id === open.id && !regular.pending)
const fixing = opened && open.sheet === 'fix' && opened.grounding_status === 'failed' ? opened : undefined
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
@@ -191,6 +193,7 @@ export function RegularsManager() {
)}
</section>
)}
{regulars.isSuccess && <FindMissingPhotos backfill={backfill} />}
{opened && open.sheet === 'edit' && (
<EditRegularSheet
@@ -200,12 +203,13 @@ export function RegularsManager() {
onRemove={() => onRemove(opened)}
/>
)}
{opened && open.sheet === 'fix' && (
{fixing && (
<FixRecipeSheet
key={opened.id}
regular={opened}
key={fixing.id}
regular={fixing}
backfill={backfill}
onClose={() => setOpen(null)}
onEdit={() => setOpen({ sheet: 'edit', id: opened.id })}
onEdit={() => setOpen({ sheet: 'edit', id: fixing.id })}
/>
)}
</>
@@ -234,6 +238,88 @@ function GroundingBadge({ status }: { status: GroundingStatus }) {
return <Tag className={`${styles.badge} ${badge.className}`}>{badge.text}</Tag>
}
// groundingReasons says why a dinner's recipe was not found, by its
// grounding_error reason code.
const groundingReasons: Record<string, string> = {
no_recipe_found: 'No recipe page the search found had a recipe we could use.',
recipe_unusable: 'The pasted recipe link has no recipe we could use.',
search_not_configured: 'Recipe search is not set up. An admin can set it up in Settings.',
search_failed: "The recipe search couldn't be reached.",
ai_failed: "The AI assistant couldn't read the recipe.",
interrupted: 'The server stopped while finding the recipe.',
grounding_error: 'Something went wrong on the server.',
}
// groundingReason says why a dinner's recipe was not found; an unknown
// reason code is shown as it is.
export function groundingReason(code: string): string {
return groundingReasons[code] ?? code
}
// backfillJobFailures says why a photo backfill job failed, by its failure
// code; an unknown code is shown as it is.
const backfillJobFailures: Record<string, string> = {
internal_error: 'Something went wrong on the server.',
}
// backfillProblem says what went wrong with a photo backfill, or null.
function backfillProblem(backfill: PhotoBackfill): string | null {
if (backfill.startError) return `Photo search not started: ${backfill.startError.message}`
if (backfill.status === 'failed' && backfill.error) {
return `Photo search stopped: ${backfillJobFailures[backfill.error.message] ?? backfill.error.message}`
}
if (backfill.error) return `Couldn't check on the photo search: ${backfill.error.message}`
return null
}
function count(n: number, one: string, many: string): string {
return `${n} ${n === 1 ? one : many}`
}
// backfillSummary says what a finished photo backfill did.
function backfillSummary(result: BackfillResult): string {
const tally = (outcome: string) => result.dinners.filter((dinner) => dinner.outcome === outcome).length
if (result.dinners.length === 0) return 'No dinners were missing a photo.'
const parts = [count(tally('grounded'), 'photo found', 'photos found')]
const failed = tally('failed')
if (failed > 0) parts.push(`${count(failed, 'dinner still needs', 'dinners still need')} a recipe`)
const skipped = tally('skipped')
if (skipped > 0) parts.push(`${count(skipped, 'dinner', 'dinners')} changed meanwhile, left as it was`)
return `Photo search done: ${parts.join(' · ')}.`
}
// FindMissingPhotos searches again, by name, for a recipe and photo for
// every dinner left without a photo because its recipe was not found.
function FindMissingPhotos({ backfill }: { backfill: PhotoBackfill }) {
const headingId = useId()
const problem = backfillProblem(backfill)
return (
<section aria-labelledby={headingId} className={styles.section}>
<h2 id={headingId} className={styles.groupLabel}>
Missing photos
</h2>
<Card className={`${styles.card} ${styles.backfill}`}>
<p className={styles.hint}>
Search again for a recipe and photo for every dinner whose recipe wasn&apos;t found. Dinners with a pasted
recipe link keep it.
</p>
<Button disabled={backfill.active} onClick={backfill.start}>
{backfill.active ? 'Finding photos…' : 'Find missing photos'}
</Button>
<p role="status" className={styles.status}>
{backfill.active && 'Finding missing photos… this can take a few minutes.'}
{backfill.status === 'succeeded' && backfill.result && backfillSummary(backfill.result)}
</p>
{problem && (
<p role="alert" className={styles.problem}>
{problem}
</p>
)}
</Card>
</section>
)
}
type RegularRowProps = {
regular: Regular
onOpen: () => void
@@ -450,6 +536,7 @@ const fixPaths = [
type FixRecipeSheetProps = {
regular: Regular
backfill: PhotoBackfill
onClose: () => void
onEdit: () => void
}
@@ -462,10 +549,20 @@ function lines(text: string): string[] {
.filter((line) => line !== '')
}
// FixRecipeSheet grounds a Regular whose recipe was not found: in one of
// up to three recipe pages found for its name, in a pasted recipe link, or
// in ingredients and steps typed in.
function FixRecipeSheet({ regular, onClose, onEdit }: FixRecipeSheetProps) {
// retryOutcomes says what searching again did for this dinner.
const retryOutcomes = {
grounded: 'Searched again: a recipe and photo were found.',
failed: 'Searched again: still no recipe found.',
skipped: 'It changed while it was searched again, so it was left as it is.',
} as const
// FixRecipeSheet shows why a Regular's recipe was not found and grounds it:
// by searching its name again (a photo backfill), in one of up to three
// recipe pages found for its name, in a pasted recipe link, or in
// ingredients and steps typed in. Searching again is offered only for a
// dinner the backfill searches (photo_backfill_eligible): it never replaces
// a pasted link.
function FixRecipeSheet({ regular, backfill, onClose, onEdit }: FixRecipeSheetProps) {
const [path, setPath] = useState<FixPath>('pick')
const ground = useGroundRegular()
@@ -478,6 +575,15 @@ function FixRecipeSheet({ regular, onClose, onEdit }: FixRecipeSheetProps) {
<p className={styles.hint}>
We couldn&apos;t find a recipe for {regular.name}. Pick a match below, paste a recipe link or type it in.
</p>
{regular.grounding_error !== null && (
<p className={styles.reason}>
<span className={styles.reasonLabel}>Why: </span>
{groundingReason(regular.grounding_error)}
</p>
)}
{regular.photo_backfill_eligible && (
<RetrySearch id={regular.id} backfill={backfill} disabled={ground.isPending} />
)}
<Segmented
label="How to fix the recipe"
options={fixPaths}
@@ -507,6 +613,34 @@ function FixRecipeSheet({ regular, onClose, onEdit }: FixRecipeSheetProps) {
)
}
// RetrySearch searches the dinner's name again with a photo backfill, and
// says what it did for the dinner once it ends. The backfill searches
// every dinner without a photo, this one with them.
function RetrySearch({ id, backfill, disabled }: { id: string; backfill: PhotoBackfill; disabled: boolean }) {
const problem = backfillProblem(backfill)
const tried = backfill.result?.dinners.find((dinner) => dinner.dinner_id === id)
let status: string | null = null
if (backfill.active) status = 'Searching again… this can take a few minutes.'
else if (backfill.status === 'succeeded') {
status = tried ? retryOutcomes[tried.outcome] : 'This dinner was not included in that search.'
}
return (
<>
<Button variant="outline" disabled={disabled || backfill.active} onClick={backfill.start}>
{backfill.active ? 'Searching again…' : 'Search again'}
</Button>
<p role="status" className={styles.status}>
{status}
</p>
{problem && (
<p role="alert" className={styles.problem}>
{problem}
</p>
)}
</>
)
}
function PickCandidate({ id, onPick }: { id: string; onPick: (candidateId: string) => void }) {
const candidates = useRecipeCandidates(id)
if (candidates.isPending) return <Skeleton className={styles.candidatesLoading} />
+1 -4
View File
@@ -104,10 +104,7 @@ describe('SettingsPage', () => {
expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
expect(within(row('AI assistant')).getByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
// The rotation count and the hidden Regular dinners sub-tab both read
// the Regulars.
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
REGULARS_PATH,
REGULARS_PATH,
CALENDAR_PATH,
'/api/v1/settings/ai',
@@ -120,7 +117,7 @@ describe('SettingsPage', () => {
})
it('shows the not-yet-available states while the server has no endpoint for them', async () => {
stubSettings({ [REGULARS_PATH]: () => errorResponse(404, 'not_found') })
stubSettings()
renderSettings()
expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument()
expect(
+21 -6
View File
@@ -83,12 +83,6 @@
gap: 20px;
}
/* An upright tablet stacks the columns: Notifications above the AI
assistant and Recipe search. */
.portrait .columns {
grid-template-columns: minmax(0, 1fr);
}
.column {
min-width: 0;
display: flex;
@@ -101,3 +95,24 @@
padding: 24px;
border-radius: 30px;
}
.placeholder {
display: flex;
flex-direction: column;
gap: 14px;
}
.cardTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.hint {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-neutral-700);
}
+10 -124
View File
@@ -11,11 +11,9 @@ import {
HOUSEHOLD_SETTINGS,
NOTIFICATION_SETTINGS,
RECIPE_SEARCH_SETTINGS,
SETTINGS_REGULARS,
} from '../test/ApiProviders.tsx'
import { InDataRouter } from '../test/dataRouter.tsx'
import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
import { fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { SettingsPage } from './SettingsPage.tsx'
@@ -34,7 +32,6 @@ function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch>
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
'GET /api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK),
'GET /api/v1/dinners?status=regular': () => jsonResponse(200, SETTINGS_REGULARS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
@@ -124,7 +121,7 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
expect(tab('Regular dinners')).toHaveAttribute('aria-selected', 'true')
expect(tab('Regular dinners')).toHaveFocus()
expect(panel('Regular dinners')).toBeVisible()
expect(await within(panel('Regular dinners')).findByRole('heading', { name: 'On rotation · 1' })).toBeInTheDocument()
expect(within(panel('Regular dinners')).getByRole('region', { name: 'Regular dinners' })).toBeInTheDocument()
expect(panel('Household')).not.toBeVisible()
fireEvent.keyDown(tab('Regular dinners'), { key: 'End' })
@@ -168,123 +165,6 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
})
})
const TACOS = dinnerBody('d1', 'Beef tacos', { method: 'stovetop', minutes_total: 40, difficulty: 'easy' })
const PIZZA = dinnerBody('d3', 'Pizza night', { grounding_status: 'failed', grounding_error: 'no_recipe_found' })
// stubRegulars answers the Regulars from server.regulars, so a write's
// responder changes what the next read sees.
function stubRegulars(regulars: DinnerBody[], responders: (server: { regulars: DinnerBody[] }) => Record<string, Responder>) {
const server = { regulars }
const fetchMock = stubAPI({
'GET /api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: server.regulars }),
...responders(server),
})
return { server, fetchMock }
}
// writes lists the requests other than GETs, as method, path and body.
function writes(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
.map(([input, init]) => [init?.method, String(input), init?.body])
}
// openRegulars shows the Regular dinners tab once its Regulars have loaded.
async function openRegulars() {
fireEvent.click(tab('Regular dinners'))
const regulars = within(panel('Regular dinners'))
await regulars.findByRole('heading', { level: 2, name: /^On rotation/ })
return regulars
}
function regularRow(name: string) {
const item = within(screen.getByRole('list', { name: 'Regulars' }))
.getAllByRole('listitem')
.find((li) => within(li).queryByText(name) !== null)
if (item === undefined) throw new Error(`no Regular row named ${name}`)
return within(item)
}
describe.each([1440, 1180])('SettingsPage Regular dinners tab at %i px (mock 2e)', (width) => {
it('lists the Regulars as /settings/regulars does, under the page title rather than its own', async () => {
stubRegulars([TACOS, PIZZA], () => ({}))
await renderAt(width)
const regulars = await openRegulars()
expect(regulars.getByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
expect(regularRow('Beef tacos').getByText('Stovetop · 40 min')).toBeInTheDocument()
expect(regularRow('Beef tacos').getByText('Recipe found')).toBeInTheDocument()
expect(regularRow('Pizza night').getByText('Needs a recipe')).toBeInTheDocument()
expect(regulars.getByRole('textbox', { name: 'Add a Regular' })).toBeInTheDocument()
expect(screen.getAllByRole('heading', { level: 1 }).map((h) => h.textContent)).toEqual(['Settings'])
expect(regulars.queryByRole('button', { name: 'Back to Settings' })).not.toBeInTheDocument()
})
it('adds a Regular by name', async () => {
const { fetchMock } = stubRegulars([TACOS], (server) => ({
'POST /api/v1/dinners': (body) => {
const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' })
server.regulars = [...server.regulars, created]
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
},
}))
await renderAt(width)
const regulars = await openRegulars()
fireEvent.change(regulars.getByRole('textbox', { name: 'Add a Regular' }), { target: { value: 'Chicken enchiladas' } })
fireEvent.click(regulars.getByRole('button', { name: 'Add Regular' }))
expect(await regulars.findByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
expect(regularRow('Chicken enchiladas').getByText('Finding recipe…')).toBeInTheDocument()
expect(writes(fetchMock)).toEqual([['POST', '/api/v1/dinners', JSON.stringify({ name: 'Chicken enchiladas' })]])
})
it('edits a Regular in its sheet', async () => {
const { fetchMock } = stubRegulars([TACOS], (server) => ({
'PATCH /api/v1/dinners/d1': (body) => {
const edited = { ...TACOS, ...(body as object) }
server.regulars = [edited]
return jsonResponse(200, edited)
},
}))
await renderAt(width)
await openRegulars()
fireEvent.click(regularRow('Beef tacos').getByRole('button', { name: /^Beef tacos/ }))
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '25' } })
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(writes(fetchMock)).toEqual([['PATCH', '/api/v1/dinners/d1', JSON.stringify({ minutes_total: 25 })]])
expect(await regularRow('Beef tacos').findByText('Stovetop · 25 min')).toBeInTheDocument()
})
it('fixes the recipe of a Regular that needs one', async () => {
const { fetchMock } = stubRegulars([PIZZA], (server) => ({
'GET /api/v1/dinners/d3/recipe-candidates': () =>
jsonResponse(200, {
candidates: [
{ candidate_id: 'https://example.com/pizza', title: 'Weeknight pizza', site_name: 'Example Kitchen', photo_url: null, minutes: 45 },
],
}),
'POST /api/v1/dinners/d3/ground': () => {
const fixed = { ...PIZZA, grounding_status: 'grounded', grounding_error: null }
server.regulars = [fixed]
return jsonResponse(200, fixed)
},
}))
await renderAt(width)
await openRegulars()
fireEvent.click(regularRow('Pizza night').getByRole('button', { name: /^Pizza night/ }))
const sheet = within(screen.getByRole('dialog', { name: 'Fix recipe' }))
fireEvent.click(await sheet.findByRole('button', { name: 'Use Weeknight pizza from Example Kitchen' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(writes(fetchMock)).toEqual([
['POST', '/api/v1/dinners/d3/ground', JSON.stringify({ candidate_id: 'https://example.com/pizza' })],
])
expect(await regularRow('Pizza night').findByText('Recipe found')).toBeInTheDocument()
})
})
describe('SettingsPage reminder time across tabs', () => {
it('shows a reminder time saved on Notifications in the Household tab, keeping its draft', async () => {
const saved = { ...HOUSEHOLD_SETTINGS }
@@ -315,7 +195,10 @@ describe('SettingsPage reminder time across tabs', () => {
describe('SettingsPage across the 768 px breakpoint', () => {
it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => {
stubAPI({ 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED) })
stubAPI({
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
})
const resize = await renderAt(767)
expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai')
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
@@ -326,7 +209,10 @@ describe('SettingsPage across the 768 px breakpoint', () => {
})
it('keeps an unsaved draft and its leave guard across 1180 px, 390 px and back', async () => {
stubAPI({ 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED) })
stubAPI({
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
})
const resize = await renderAt(1180)
fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), {
target: { value: 'A draft' },
+23 -10
View File
@@ -1,11 +1,10 @@
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
import { useBreakpoint, useOrientation } from '../shell/useBreakpoint.ts'
import { Card } from '../ui/Card.tsx'
import { AIAssistantCard } from './AISettingsPage.tsx'
import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx'
import { LeaveGuard } from './LeaveGuard.tsx'
import { ChatWebhookCard, NotificationsCard } from './NotificationsPage.tsx'
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
import { RegularsManager } from './RegularsSettingsPage.tsx'
import styles from './SettingsTabs.module.css'
type Tab = 'household' | 'regulars' | 'notifications-ai'
@@ -22,10 +21,7 @@ const step: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1 }
// sub-tabs instead of the mobile list. Every tab stays mounted while hidden,
// so a draft survives switching tabs, and one leave guard covers them all.
// hidden hides the page but keeps it mounted, drafts and leave guard included.
// On an upright tablet the two columns stack into one.
export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
const layout = useBreakpoint()
const orientation = useOrientation()
const [tab, setTab] = useState<Tab>('notifications-ai')
const [householdDirty, setHouseholdDirty] = useState(false)
const [aiDirty, setAIDirty] = useState(false)
@@ -68,10 +64,7 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
}
return (
<div
className={[styles.page, layout === 'tablet' && orientation === 'portrait' && styles.portrait].filter(Boolean).join(' ')}
hidden={hidden}
>
<div className={styles.page} hidden={hidden}>
<header className={styles.header}>
<div className={styles.heading}>
<h1 className={styles.title}>Settings</h1>
@@ -101,7 +94,13 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
{panel('household', styles.single, <HouseholdSettingsSections onDirtyChange={setHouseholdDirty} />)}
{panel('regulars', styles.single, <RegularsManager />)}
{panel(
'regulars',
styles.single,
// TODO(Feature 12): embed the Regular dinners screen here once Feature
// 12 builds it.
<Placeholder title="Regular dinners">Managing the household's Regulars arrives in a later update.</Placeholder>,
)}
{panel(
'notifications-ai',
@@ -122,3 +121,17 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
</div>
)
}
// Placeholder is a neutral card holding a section's place until the feature
// that owns it renders it.
function Placeholder({ title, children }: { title: string; children: ReactNode }) {
const titleId = useId()
return (
<Card className={styles.placeholder} aria-labelledby={titleId} role="region">
<h2 id={titleId} className={styles.cardTitle}>
{title}
</h2>
<p className={styles.hint}>{children}</p>
</Card>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { HOME_KEY } from '../home/api.ts'
import { planKey } from '../plan/api.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { fakeLocation, jsonResponse, ME, stubFetch } from '../test/fetchStub.ts'
import { parseBackfillResult, usePhotoBackfill } from './photoBackfill.ts'
afterEach(() => {
vi.unstubAllGlobals()
})
const RESULT = { dinners: [{ dinner_id: 'd3', outcome: 'grounded', reason: null }] }
function backfillJob(status: string, result: unknown) {
return jsonResponse(200, { id: 'j9', kind: 'backfill_dinner_photos', status, result, error: null })
}
describe('usePhotoBackfill', () => {
it('reads the plans, home and dinners again once the backfill ends, so a found photo shows everywhere', async () => {
const fetchMock = stubFetch(
jsonResponse(200, ME),
jsonResponse(202, { id: 'j9' }),
backfillJob('running', null),
backfillJob('succeeded', RESULT),
)
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const queryClient = createQueryClient()
const plan = planKey('2026-10-05')
for (const key of [plan, HOME_KEY, ['dinners', 'quest_log']]) queryClient.setQueryData(key, {})
const wrapper = ({ children }: { children: ReactNode }) => (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ApiProvider>
)
const { result } = renderHook(() => usePhotoBackfill(), { wrapper })
act(() => result.current.start())
await waitFor(() => expect(result.current.status).toBe('running'))
expect(result.current.active).toBe(true)
expect(queryClient.getQueryState(plan)?.isInvalidated).toBe(false)
await waitFor(() => expect(result.current.status).toBe('succeeded'), { timeout: 3000 })
expect(result.current).toMatchObject({ active: false, error: null, result: RESULT })
expect(fetchMock).toHaveBeenCalledTimes(4)
for (const key of [plan, HOME_KEY, ['dinners', 'quest_log']]) {
expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true)
}
})
})
describe('parseBackfillResult', () => {
it('refuses a result that is not a photo backfill', () => {
expect(() => parseBackfillResult('job', { dinners: [{ dinner_id: 'd3', outcome: 'lost', reason: null }] })).toThrow(
'job: outcome "lost" is not one of grounded, failed, skipped',
)
expect(() => parseBackfillResult('job', { stage: 'x' })).toThrow('job: result is not a photo backfill')
})
})
+110
View File
@@ -0,0 +1,110 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { useApi, useJob, type JobStatus } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { oneOf } from '../dinners/api.ts'
import { HOME_KEY } from '../home/api.ts'
export const PHOTO_BACKFILL_PATH = '/api/v1/settings/photo-backfill'
// BACKFILL_JOB_KIND is the kind of the job a photo backfill runs as.
export const BACKFILL_JOB_KIND = 'backfill_dinner_photos'
export const BACKFILL_OUTCOMES = ['grounded', 'failed', 'skipped'] as const
export type BackfillOutcome = (typeof BACKFILL_OUTCOMES)[number]
// BackfillDinner is what a photo backfill did for one dinner. reason is
// set only when it failed: the reason code now its grounding_error.
export type BackfillDinner = { dinner_id: string; outcome: BackfillOutcome; reason: string | null }
// BackfillResult is the photo backfill job's result: each dinner it tried,
// in the order it tried them.
export type BackfillResult = { dinners: BackfillDinner[] }
// Every query showing a dinner's photo, read again once a backfill ends:
// the dinners lists and details, the plans and the home dashboard.
const PHOTO_KEYS = [['dinners'], ['plans'], HOME_KEY] as const
// parseBackfillResult checks a photo backfill job's result, which useJob
// passes on unread.
export function parseBackfillResult(where: string, body: unknown): BackfillResult {
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: result is not a photo backfill`)
return {
dinners: body.dinners.map((dinner): BackfillDinner => {
if (!isRecord(dinner) || typeof dinner.dinner_id !== 'string' || (dinner.reason !== null && typeof dinner.reason !== 'string')) {
throw new Error(`${where}: result has a dinner that is not a backfill outcome`)
}
return {
dinner_id: dinner.dinner_id,
outcome: oneOf(where, 'outcome', BACKFILL_OUTCOMES, dinner.outcome),
reason: dinner.reason,
}
}),
}
}
// PhotoBackfill is a photo backfill started from this screen, as it goes.
export type PhotoBackfill = {
// start asks the server for a backfill over every dinner without a
// photo. While one is already queued or running the server answers with
// that job, so starting never runs two at once.
start: () => void
// active is true from start until the job ends, except while a poll's
// failure leaves its progress unknown: starting again then asks the
// server for the job it is running and polls it anew.
active: boolean
// Why the backfill could not be started.
startError: Error | null
// The job's status, undefined until it is started and first polled.
status: JobStatus | undefined
// The job's result, once it has one.
result: BackfillResult | null
// The job's failure, a poll's failure, or a result that is not a backfill.
error: Error | null
}
// usePhotoBackfill starts a photo backfill (POST
// /api/v1/settings/photo-backfill, 202 {id}) and follows its job with
// useJob. When the job ends, every query showing a dinner's photo is read
// again, so dinners it grounded show their photo everywhere.
export function usePhotoBackfill(): PhotoBackfill {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
const [jobId, setJobId] = useState<string | null>(null)
const job = useJob<unknown>(BACKFILL_JOB_KIND, jobId)
const begin = useMutation({
mutationFn: async (): Promise<string> => {
const body = await apiFetch<unknown>(PHOTO_BACKFILL_PATH, { method: 'POST' })
if (!isRecord(body) || typeof body.id !== 'string') throw new Error(`POST ${PHOTO_BACKFILL_PATH}: response has no job id`)
return body.id
},
onSuccess: (id) => {
setJobId(id)
job.poll()
},
})
const ended = job.status === 'succeeded' || job.status === 'failed'
useEffect(() => {
if (!ended) return
for (const queryKey of PHOTO_KEYS) void queryClient.invalidateQueries({ queryKey })
}, [ended, jobId, queryClient])
const parsed = useMemo(() => {
if (job.result === null) return { result: null, error: null }
try {
return { result: parseBackfillResult(`GET /api/v1/jobs/${jobId}`, job.result), error: null }
} catch (error) {
return { result: null, error: error as Error }
}
}, [job.result, jobId])
return {
start: () => begin.mutate(),
active: begin.isPending || (jobId !== null && !ended && job.error === null),
startError: begin.error,
status: job.status,
result: parsed.result,
error: job.error ?? parsed.error,
}
}
-6
View File
@@ -22,9 +22,3 @@
.desktop .main {
padding: 32px 36px;
}
/* An upright tablet (820 px wide) leaves 724 px beside the rail; slimmer side
padding gives the page a little more of it. */
.tablet.portrait .main {
padding: 32px 28px;
}
+3 -5
View File
@@ -2,17 +2,15 @@ import { Outlet } from 'react-router'
import { BottomBar } from './BottomBar.tsx'
import { Rail } from './Rail.tsx'
import { Sidebar } from './Sidebar.tsx'
import { useBreakpoint, useOrientation } from './useBreakpoint.ts'
import { useBreakpoint } from './useBreakpoint.ts'
import styles from './AppShell.module.css'
// The responsive frame around every page: one nav layout per breakpoint and a
// scrolling main area for the routed page. A portrait viewport is marked so
// the narrower upright tablet keeps its content clear of the rail.
// scrolling main area for the routed page.
export function AppShell() {
const breakpoint = useBreakpoint()
const orientation = useOrientation()
return (
<div className={[styles.shell, styles[breakpoint], orientation === 'portrait' && styles.portrait].filter(Boolean).join(' ')}>
<div className={`${styles.shell} ${styles[breakpoint]}`}>
{breakpoint === 'mobile' && <BottomBar />}
{breakpoint === 'tablet' && <Rail />}
{breakpoint === 'desktop' && <Sidebar />}
-6
View File
@@ -48,12 +48,6 @@
color: var(--on-accent);
}
/* The app-wide grape focus ring barely shows on the dark bar (1.8:1); the
bar's ring is the light on-accent ink instead. */
.item:focus-visible {
outline-color: var(--on-accent);
}
.label {
font-family: var(--font-body);
font-size: 13px;
-12
View File
@@ -182,18 +182,6 @@ describe('BottomBar', () => {
])
})
it('rings a focused item in on-accent ink, which shows on the dark bar', async () => {
await renderAt('/')
const item = link('Plan')
// A key press makes the next focus a keyboard one, which :focus-visible
// matches; earlier tests' clicks would leave it a pointer one.
fireEvent.keyDown(document.body, { key: 'Tab' })
item.focus()
expect(item).toHaveFocus()
expect(item.matches(':focus-visible')).toBe(true)
expect(declaredStyle(item).getPropertyValue('outline-color')).toBe('var(--on-accent)')
})
it('does not animate when the active item stays the same', async () => {
await renderAt('/plan')
fireEvent.click(link('Plan'))
-225
View File
@@ -1,225 +0,0 @@
import { render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AppRoutes } from '../App.tsx'
import planStyles from '../plan/PlanPage.module.css'
import settingsTabsStyles from '../settings/SettingsTabs.module.css'
import {
AI_MODELS,
AI_SETTINGS,
CHAT_WEBHOOK_NONE,
GOOGLE_NOT_CONNECTED,
HOME,
HOUSEHOLD_SETTINGS,
NOTIFICATION_SETTINGS,
RECIPE_SEARCH_SETTINGS,
ApiProviders,
} from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { dinnerBody } from '../test/dinners.ts'
import { jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import shellStyles from './AppShell.module.css'
import railStyles from './Rail.module.css'
// Every primary route on a tablet held upright (820 × 1180) and on its side
// (1180 × 820): both have the 96 px rail and the tablet layout; upright, the
// shell is marked portrait, the Plan week reflows four days over three, and
// Settings' two columns stack into one.
const PORTRAIT = { width: 820, height: 1180 }
const LANDSCAPE = { width: 1180, height: 820 }
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
const PLAN = {
id: 'plan-1',
week_start: '2026-10-05',
week_label: 'Week 41 · Oct 5 – 11',
status: 'draft',
ai_note: null,
calendar_synced_at: null,
days: DAYS.map((date, i) => ({
date,
dinner: { id: `d${i}`, name: `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop', origin: 'regular' },
is_new: false,
start_by: null,
})),
updated_at: '2026-10-04T15:00:00Z',
}
const SUPPLY_RUN = {
weekStart: '2026-10-05',
label: 'Week 41 · Oct 5 – 11',
title: null,
status: 'current',
sent_to_chat_at: null,
chat_send_unconfirmed_at: null,
needed_count: 1,
total_count: 1,
groups: [
{
dinner: { id: 'd0', name: 'Dinner 0' },
date: '2026-10-05',
day: 'Monday',
items: [{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true }],
},
],
}
const DETAIL = {
id: 'd1',
name: 'Korean beef bowls',
status: 'regular',
origin: 'regular',
method: 'stovetop',
minutes_total: 30,
difficulty: 'easy',
cuisine: 'korean',
is_favorite: false,
photo_urls: null,
ingredients: [{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }],
steps: ['Brown the beef.'],
source: null,
grounding_status: 'manual',
grounding_error: null,
why_picked: null,
}
// The API every route reads, answered as a settled household's server would.
// Any other request stays pending, so a route that reads more just shows its
// loading state.
const API: Record<string, unknown> = {
'/api/v1/me': ME,
'/api/v1/home': HOME,
'/api/v1/rotation-reviews': { reviews: [] },
'/api/v1/settings/household': HOUSEHOLD_SETTINGS,
'/api/v1/settings/ai': AI_SETTINGS,
'/api/v1/settings/ai/models': AI_MODELS,
'/api/v1/settings/recipe-search': RECIPE_SEARCH_SETTINGS,
'/api/v1/settings/notifications': NOTIFICATION_SETTINGS,
'/api/v1/settings/chat-webhook': CHAT_WEBHOOK_NONE,
'/api/v1/integrations/google': GOOGLE_NOT_CONNECTED,
'/api/v1/plans/2026-10-05': PLAN,
'/api/v1/shopping/2026-10-05': SUPPLY_RUN,
'/api/v1/shopping/archive?by=week': { by: 'week', periods: [] },
'/api/v1/suggestions/deck': { suggestions: [], active_batch: null, failed_batch: null },
'/api/v1/dinners?status=regular': { dinners: [dinnerBody('r1', 'Beef tacos')] },
'/api/v1/dinners?status=quest_log': { dinners: [] },
'/api/v1/dinners/d1': DETAIL,
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
const path = (input: Parameters<typeof fetch>[0]) => new URL(String(input), 'https://mealquest.test')
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>((input) => {
const url = path(input)
const key = `${url.pathname}${url.search}`
if (!Object.hasOwn(API, key)) return new Promise<Response>(() => {})
return Promise.resolve(jsonResponse(200, API[key]))
}),
)
})
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
vi.useRealTimers()
vi.unstubAllGlobals()
})
async function renderAt(path: string, { width, height }: { width: number; height: number }) {
mockViewport(width, { height })
render(
<InDataRouter path={path}>
<ApiProviders>
<AppRoutes />
</ApiProviders>
</InDataRouter>,
)
// The routes wait for the household settings (OnboardingGate).
await screen.findByRole('navigation', { name: 'Primary' })
}
function shell() {
return screen.getByRole('main').parentElement!
}
const ROUTES: [string, string][] = [
['Home', '/'],
['Plan', '/plan?week=2026-10-05'],
['Supply run', '/plan/shopping'],
['Supply archive', '/plan/shopping/archive'],
['Discover', '/discover'],
['Quick dinners', '/quick'],
['Cook from what I have', '/cook-from-pantry'],
['Dinner detail (Kitchen reading mode)', '/dinner/d1'],
['Settings', '/settings'],
['Notifications', '/settings/notifications'],
['AI assistant', '/settings/ai'],
['Household', '/settings/household'],
['Regulars', '/settings/regulars'],
['Calendar', '/settings/calendar'],
]
describe.each([
['portrait', PORTRAIT],
['landscape', LANDSCAPE],
])('tablet %s', (orientation, viewport) => {
const portrait = orientation === 'portrait'
it.each(ROUTES)(`renders %s with the 96 px rail in the tablet ${orientation} layout`, async (_name, path) => {
await renderAt(path, viewport)
expect(screen.getAllByRole('navigation')).toHaveLength(1)
expect(screen.getByRole('navigation', { name: 'Primary' })).toHaveClass(railStyles.rail!)
expect(declaredStyle(screen.getByRole('navigation', { name: 'Primary' })).width).toBe('96px')
expect(shell()).toHaveClass(shellStyles.tablet!)
if (portrait) expect(shell()).toHaveClass(shellStyles.portrait!)
else expect(shell()).not.toHaveClass(shellStyles.portrait!)
expect(await screen.findByRole('heading', { level: 1 })).toBeInTheDocument()
})
it(`lays the Plan week out ${portrait ? 'four days over three' : 'in seven columns'}`, async () => {
await renderAt('/plan?week=2026-10-05', viewport)
const week = await screen.findByRole('list', { name: 'Dinners this week' })
const page = week.closest(`.${planStyles.tabletPage}`)
expect(page).not.toBeNull()
if (portrait) expect(page).toHaveClass(planStyles.portraitPage!)
else expect(page).not.toHaveClass(planStyles.portraitPage!)
expect(declaredStyle(week).gridTemplateColumns).toBe(
portrait ? 'repeat(4, minmax(0, 1fr))' : 'repeat(7, minmax(112px, 1fr))',
)
expect(declaredStyle(week).overflowX).toBe('auto')
})
it(`shows Settings (mock 2e) in ${portrait ? 'one column' : 'two columns'}`, async () => {
await renderAt('/settings', viewport)
const panel = screen.getByRole('tabpanel', { name: 'Notifications & AI' })
const page = panel.closest(`.${settingsTabsStyles.page}`)
if (portrait) expect(page).toHaveClass(settingsTabsStyles.portrait!)
else expect(page).not.toHaveClass(settingsTabsStyles.portrait!)
expect(declaredStyle(panel).gridTemplateColumns).toBe(
portrait ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) minmax(0, 1.15fr)',
)
})
})
it('reflows when the tablet turns', async () => {
const resize = mockViewport(PORTRAIT.width, { height: PORTRAIT.height })
render(
<InDataRouter path="/plan?week=2026-10-05">
<ApiProviders>
<AppRoutes />
</ApiProviders>
</InDataRouter>,
)
const week = await screen.findByRole('list', { name: 'Dinners this week' })
expect(shell()).toHaveClass(shellStyles.portrait!)
expect(week.closest(`.${planStyles.tabletPage}`)).toHaveClass(planStyles.portraitPage!)
resize(LANDSCAPE.width, LANDSCAPE.height)
expect(shell()).toHaveClass(shellStyles.tablet!)
expect(shell()).not.toHaveClass(shellStyles.portrait!)
expect(week.closest(`.${planStyles.tabletPage}`)).not.toHaveClass(planStyles.portraitPage!)
})
-19
View File
@@ -7,12 +7,6 @@ export type Breakpoint = 'mobile' | 'tablet' | 'desktop'
export const TABLET_QUERY = '(min-width: 768px)'
export const DESKTOP_QUERY = '(min-width: 1200px)'
export type Orientation = 'portrait' | 'landscape'
// A tablet held upright (820 × 1180) has the rail and the tablet layout but
// far less width than one on its side, so pages reflow for it.
export const PORTRAIT_QUERY = '(orientation: portrait)'
// The layout the current viewport calls for, updated as the viewport crosses
// a breakpoint.
export function useBreakpoint(): Breakpoint {
@@ -35,16 +29,3 @@ export function useBreakpoint(): Breakpoint {
lists.desktop.matches ? 'desktop' : lists.tablet.matches ? 'tablet' : 'mobile',
)
}
// The viewport's orientation, updated as the device turns.
export function useOrientation(): Orientation {
const [list] = useState(() => window.matchMedia(PORTRAIT_QUERY))
const subscribe = useCallback(
(onChange: () => void) => {
list.addEventListener('change', onChange)
return () => list.removeEventListener('change', onChange)
},
[list],
)
return useSyncExternalStore(subscribe, () => (list.matches ? 'portrait' : 'landscape'))
}
+68 -111
View File
@@ -1,5 +1,5 @@
import { defaultScheduler, notifyManager, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, within } from '@testing-library/react'
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'
@@ -213,46 +213,8 @@ function reload(page: { unmount: () => void }) {
vi.setSystemTime(now)
}
// advance jumps the clock past a deadline or a wait, firing the timers due
// by then; what they set off runs as later turns (until, settle) let it.
function advance(ms: number) {
act(() => vi.advanceTimersByTime(ms))
}
// The host's setImmediate, which these tests do not fake: it runs its
// callback on the next turn of the event loop, once every promise already
// resolved has run, whatever the clock says.
const nextTurn = (globalThis as unknown as { setImmediate: (callback: () => void) => void }).setImmediate
// turn lets the page take one turn of the event loop, inside act: the
// promises it has resolved run, and React renders and runs the effects they
// cause. No time passes, real or fake.
async function turn() {
await act(() => new Promise<void>((resolve) => nextTurn(resolve)))
}
// TURNS bounds how many turns until waits and settle takes: far more than
// any change here needs to reach the page.
const TURNS = 50
// until lets the page take turns until check passes, and returns what check
// returns; it fails with check's error if check still fails after TURNS.
async function until<T>(check: () => T): Promise<T> {
for (let turns = 0; ; turns++) {
try {
return check()
} catch (error) {
if (turns === TURNS) throw error
}
await turn()
}
}
// settle lets the page take TURNS turns, the clock standing still: whatever
// it would do without a deadline or a wait passing, such as send a PATCH too
// soon, it has done.
async function settle() {
for (let turns = 0; turns < TURNS; turns++) await turn()
async function advance(ms: number) {
await act(() => vi.advanceTimersByTimeAsync(ms))
}
// The page's setTimeout, watched: retrySecondTry waits for a timer to be set.
@@ -262,8 +224,8 @@ let timers: MockInstance<typeof setTimeout>
// 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 until(() => expect(timers).toHaveBeenCalledWith(expect.any(Function), 2 * RETRY_FIRST))
advance(2 * RETRY_FIRST)
await waitFor(() => expect(timers).toHaveBeenCalledWith(expect.any(Function), 2 * RETRY_FIRST))
await advance(2 * RETRY_FIRST)
}
function checkbox(name: string) {
@@ -278,21 +240,16 @@ beforeEach(() => {
onLine = true
reachable = true
mockViewport(390)
// The clock and setTimeout move only when a test jumps past a deadline or
// a wait (advance), never with real time: under load, real time would fire
// a deadline or a wait the test never reached. React Query sends its
// batched notices a turn later, as it does in the app on a setTimeout of 0,
// so the page renders its changes without the clock moving.
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
// 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'))
notifyManager.setScheduler(nextTurn)
timers = vi.spyOn(globalThis, 'setTimeout')
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
notifyManager.setScheduler(defaultScheduler)
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
})
@@ -301,7 +258,7 @@ 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 until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Taco seasoning'))
@@ -319,7 +276,7 @@ describe('offline toggle queue', () => {
expect(patches(fetchMock)).toEqual([])
goOnline()
await until(() => expect(queue()).toEqual([]))
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i1')} {"needed":false}`,
@@ -335,14 +292,14 @@ describe('offline toggle queue', () => {
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 until(() => screen.getByRole('checkbox', { name: 'Taco seasoning' })))
await until(() => expect(queue()).toEqual([]))
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 until(() => within(row('Corn tortillas')).getByText('will sync'))).toBeInTheDocument()
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()
@@ -350,7 +307,7 @@ describe('offline toggle queue', () => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
goOnline()
await until(() => expect(queue()).toEqual([]))
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()
@@ -359,7 +316,7 @@ describe('offline toggle queue', () => {
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 until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Taco seasoning'))
fireEvent.click(checkbox('Ground beef'))
@@ -368,7 +325,7 @@ describe('offline toggle queue', () => {
// Reload while offline: the settings, /me and the list cannot be read.
reload(first)
await openPage()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
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()
@@ -376,9 +333,9 @@ describe('offline toggle queue', () => {
expect(patches(fetchMock)).toEqual([])
goOnline()
await until(() => expect(queue()).toEqual([]))
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`])
await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
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()
@@ -387,7 +344,7 @@ describe('offline toggle queue', () => {
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 until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Taco seasoning'))
@@ -395,25 +352,25 @@ describe('offline toggle queue', () => {
server.archive()
goOnline()
const alert = await until(() => screen.getByRole('alert'))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. archived message")
expect(await until(() => screen.getByText('Archived'))).toBeInTheDocument()
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 until(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`]))
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 until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Ground beef'))
expect(await until(() => screen.getByRole('alert'))).toHaveTextContent("Couldn't mark Ground beef “have it”. invalid message")
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()
@@ -421,7 +378,7 @@ describe('offline toggle queue', () => {
goOffline()
goOnline()
await until(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i1')} {"needed":false}`]))
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i1')} {"needed":false}`]))
})
})
@@ -438,16 +395,16 @@ describe('offline toggle queue, replay', () => {
localStorage.setItem(SAVED_KEY, JSON.stringify({ schedule: null, runs, queue: queued }))
await openPage()
await until(() => expect(answers.waiting()).toBe(1))
fireEvent.click(await until(() => screen.getByRole('checkbox', { name: 'Corn tortillas' })))
await waitFor(() => expect(answers.waiting()).toBe(1))
fireEvent.click(await screen.findByRole('checkbox', { name: 'Corn tortillas' }))
for (let sent = 1; sent <= 4; sent++) {
await until(() => expect(patches(server.fetchMock)).toHaveLength(sent))
await waitFor(() => expect(patches(server.fetchMock)).toHaveLength(sent))
// The next one is not sent before this one is answered.
await settle()
await new Promise((resolve) => setTimeout(resolve, 20))
expect(patches(server.fetchMock)).toHaveLength(sent)
act(() => answers.release())
}
await until(() => expect(queue()).toEqual([]))
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
`${ITEM('n1')} {"needed":false}`,
@@ -455,27 +412,27 @@ describe('offline toggle queue, replay', () => {
`${ITEM('i2')} {"needed":false}`,
])
expect(server.mostInFlight()).toBe(1)
await until(() => expect(screen.getByText('1 / 3')).toBeInTheDocument())
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 until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
// The server applied it; the page reloads before its answer arrives.
await until(() => expect(answers.waiting()).toBe(1))
await waitFor(() => expect(answers.waiting()).toBe(1))
expect(queue()).toEqual(['i3 true'])
reload(first)
act(() => answers.release())
await openPage()
await until(() => expect(answers.waiting()).toBe(1))
await waitFor(() => expect(answers.waiting()).toBe(1))
act(() => answers.release())
await until(() => expect(queue()).toEqual([]))
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`])
await until(() => expect(screen.getByText('3 / 3')).toBeInTheDocument())
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()
@@ -485,18 +442,18 @@ describe('offline toggle queue, replay', () => {
const answers = held()
stubServer(undefined, answers.hold)
await openPage()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Ground beef'))
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
goOnline()
await until(() => expect(answers.waiting()).toBe(1))
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 until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(queue()).toEqual([])
expect(screen.getByText('1 / 3')).toBeInTheDocument()
})
@@ -506,29 +463,29 @@ describe('offline toggle queue, replay', () => {
// The first PATCH never answers; every later one answers at once.
const server = stubServer(undefined, () => (++tries === 1 ? new Promise<void>(() => {}) : Promise.resolve()))
await openPage()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
await until(() => expect(patches(server.fetchMock)).toHaveLength(1))
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.
advance(TOGGLE_DEADLINE)
expect(await until(() => within(row('Taco seasoning')).getByText('will sync'))).toBeInTheDocument()
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 until(() => expect(queue()).toEqual([]))
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 until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
@@ -539,26 +496,26 @@ describe('offline toggle queue, replay', () => {
// answers. Every later /me answers at once.
const server = stubServer(undefined, undefined, () => (++tries === 2 ? new Promise<void>(() => {}) : Promise.resolve()))
await openPage()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
await until(() => expect(tries).toBe(2))
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.
advance(TOGGLE_DEADLINE)
expect(await until(() => within(row('Taco seasoning')).getByText('will sync'))).toBeInTheDocument()
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 until(() => expect(queue()).toEqual([]))
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 until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
@@ -579,29 +536,29 @@ describe('offline toggle queue, replay', () => {
() => (refusedAt !== null && ++tries === 1 ? new Promise<void>(() => {}) : Promise.resolve()),
)
await openPage()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
await until(() => expect(tries).toBe(1))
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.
advance(TOGGLE_DEADLINE)
expect(await until(() => within(row('Taco seasoning')).getByText('will sync'))).toBeInTheDocument()
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 until(() => expect(queue()).toEqual([]))
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 until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
@@ -609,21 +566,21 @@ describe('offline toggle queue, replay', () => {
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 until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
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 until(() => within(row('Corn tortillas')).getByText('will sync'))).toBeInTheDocument()
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 until(() => expect(queue()).toEqual([]))
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 until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('1 / 3')).toBeInTheDocument()
})
@@ -631,20 +588,20 @@ describe('offline toggle queue, replay', () => {
const answers = held()
stubServer(undefined, answers.hold)
const page = await openPage()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
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 until(() => expect(answers.waiting()).toBe(1))
await waitFor(() => expect(answers.waiting()).toBe(1))
page.reopen()
expect(await until(() => within(row('Ground beef')).getByText('will sync'))).toBeInTheDocument()
expect(await within(row('Ground beef')).findByText('will sync')).toBeInTheDocument()
expect(queue()).toEqual(['i1 false'])
act(() => answers.release())
await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(queue()).toEqual([])
expect(screen.getByText('1 / 3')).toBeInTheDocument()
})
@@ -656,10 +613,10 @@ describe('offline toggle queue, replay', () => {
const getItem = vi.spyOn(Storage.prototype, 'getItem')
await openPage()
expect(await until(() => screen.getByText(/Couldn't load the household settings/))).toBeInTheDocument()
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 settle()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(getItem.mock.calls.filter(([key]) => key === SAVED_KEY)).toHaveLength(reads)
})
})
-601
View File
@@ -1,601 +0,0 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import type { ReactNode } from 'react'
import { Route, Routes } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from './api/index.ts'
import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx'
import { DiscoverPage } from './discover/DiscoverPage.tsx'
import { HomePage } from './home/HomePage.tsx'
import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx'
import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx'
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
import { createNetworkStatus } from './pwa/networkStatus.ts'
import { PlanPage } from './plan/PlanPage.tsx'
import { defaultWeek } from './plan/week.ts'
import { AISettingsPage } from './settings/AISettingsPage.tsx'
import { CalendarSettingsPage } from './settings/CalendarSettingsPage.tsx'
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
import { NotificationsPage } from './settings/NotificationsPage.tsx'
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
import { SettingsPage } from './settings/SettingsPage.tsx'
import { SupplyArchivePage } from './shopping/SupplyArchivePage.tsx'
import { SupplyRunPage } from './shopping/SupplyRunPage.tsx'
import {
AI_MODELS,
AI_SETTINGS,
CHAT_WEBHOOK_NONE,
GOOGLE_NOT_CONNECTED,
HOME,
HOUSEHOLD_SETTINGS,
NOTIFICATION_SETTINGS,
RECIPE_SEARCH_SETTINGS,
SETTINGS_API,
} from './test/ApiProviders.tsx'
import { InDataRouter } from './test/dataRouter.tsx'
import { dinnerBody } from './test/dinners.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from './test/fetchStub.ts'
import { mockViewport } from './test/matchMedia.ts'
import skeletonStyles from './ui/Skeleton.module.css'
// The empty, loading and error state of every route, as docs/STATES.md
// lists them: each route renders its loading, empty and error variants
// against a stubbed API.
type Responder = () => Response | Promise<Response>
// pending is a response that never comes, so the page stays loading.
const pending: Responder = () => new Promise<Response>(() => {})
// failThen answers the first request with a 500, and every later one with
// then: Retry reads again and gets it.
function failThen(then: Responder): Responder {
let failed = false
return () => {
if (failed) return then()
failed = true
return errorResponse(500, 'internal')
}
}
const json = (body: unknown): Responder => () => jsonResponse(200, body)
// startJobs answers each POST that starts a job with the next job id, j1,
// j2 and so on.
function startJobs(): Responder {
let n = 0
return () => jsonResponse(202, { jobId: `j${++n}` })
}
// serve answers "METHOD path" requests from responders, with /me and the
// household settings; any other request fails the test.
function serve(responders: Record<string, Responder>) {
const all: Record<string, Responder> = {
'GET /api/v1/me': json(ME),
'GET /api/v1/settings/household': json(HOUSEHOLD_SETTINGS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond())
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// renderRoute renders element on its route pattern at path, under a data
// router as App renders it, once /me has loaded as AuthBootstrap loads it.
async function renderRoute(path: string, pattern: string, element: ReactNode) {
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path={path}>
<Routes>
<Route path={pattern} element={element} />
</Routes>
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
function skeletons() {
return document.querySelectorAll(`.${skeletonStyles.skeleton}`)
}
// expectLoading waits for the pill skeletons and checks nothing failed.
async function expectLoading() {
await waitFor(() => expect(skeletons().length).toBeGreaterThan(0))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
}
// expectOnlyLoadingBefore checks, once a page's independent sections have
// settled, that the one skeleton left is the held read's: it sits before
// next, the first settled section after it, and nothing failed.
function expectOnlyLoadingBefore(next: Element) {
expect(skeletons()).toHaveLength(1)
const left = skeletons().item(0)
if (left === null) throw new Error('no skeleton left')
expect(next.contains(left)).toBe(false)
expect(left.compareDocumentPosition(next) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
}
// expectRetry finds the error banner saying said, presses its Retry and
// waits for recovered.
async function expectRetry(said: string, recovered: () => Promise<unknown>) {
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent(said)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await recovered()
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
}
const WEEK = () => defaultWeek(HOUSEHOLD_SETTINGS, new Date())
beforeEach(() => {
mockViewport(390)
})
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
vi.unstubAllGlobals()
})
describe('/ Home', () => {
const NOTHING_TONIGHT = { ...HOME, tonight: null, sideQuestCount: 0, recentUpdates: { unread: false } }
const page = async (home: Responder) => {
serve({ 'GET /api/v1/home': home, 'GET /api/v1/rotation-reviews': json({ reviews: [] }) })
await renderRoute('/', '/', <HomePage />)
}
it('loading: skeletons', async () => {
await page(pending)
await expectLoading()
})
it('empty: no quest planned tonight, with Plan this week', async () => {
await page(json(NOTHING_TONIGHT))
expect(await screen.findByRole('heading', { name: 'No quest planned tonight' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Plan this week' })).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(json(NOTHING_TONIGHT)))
await expectRetry("Couldn't load tonight's quest. internal message", () =>
screen.findByRole('heading', { name: 'No quest planned tonight' }),
)
})
})
describe('/onboarding Welcome step', () => {
it('reads nothing, so it has no empty, loading or error state', async () => {
const fetchMock = serve({})
await renderRoute('/onboarding', '/onboarding/*', <OnboardingPage />)
expect(screen.getByRole('heading', { name: 'Welcome to MealQuest' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: "Let's start" })).toBeEnabled()
expect(skeletons()).toHaveLength(0)
expect(fetchMock.mock.calls.map(([input]) => String(input))).toEqual(['/api/v1/me'])
})
})
describe('/onboarding/regulars Regulars step', () => {
const page = async (regulars: Responder) => {
serve({ 'GET /api/v1/dinners?status=regular': regulars })
await renderRoute('/onboarding/regulars', '/onboarding/*', <OnboardingPage />)
}
it('loading: skeleton', async () => {
await page(pending)
await expectLoading()
})
it('empty: no Regulars yet, common picks offered and Next disabled', async () => {
await page(json({ dinners: [] }))
expect(await screen.findByRole('heading', { name: 'Common picks' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Your regulars · 0' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Next: notifications' })).toBeDisabled()
})
it('error: banner with Retry', async () => {
await page(failThen(json({ dinners: [] })))
await expectRetry('Your Regulars could not load: internal message', () =>
screen.findByRole('heading', { name: 'Common picks' }),
)
})
})
describe('/onboarding/notifications Notifications step', () => {
const page = async (finish: Responder) => {
serve({ 'PATCH /api/v1/settings/household': finish })
await renderRoute('/onboarding/notifications', '/onboarding/*', <OnboardingPage />)
fireEvent.click(screen.getByRole('button', { name: 'Maybe later' }))
}
it("loading: the step's buttons wait while it finishes", async () => {
await page(pending)
await waitFor(() => expect(screen.getByRole('button', { name: 'Maybe later' })).toBeDisabled())
expect(screen.getByRole('button', { name: 'Turn on notifications' })).toBeDisabled()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('error: says finishing failed and lets it be tried again', async () => {
await page(failThen(pending))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not finish setting up: internal message')
expect(screen.getByRole('button', { name: 'Maybe later' })).toBeEnabled()
})
})
describe('/plan Draft board', () => {
const page = async (plan: Responder) => {
serve({ [`GET /api/v1/plans/${WEEK()}`]: plan, [`GET /api/v1/shopping/${WEEK()}`]: () => errorResponse(404, 'not_found') })
await renderRoute('/plan', '/plan', <PlanPage />)
}
const noPlan = () => errorResponse(404, 'not_found')
it('loading: skeleton tiles', async () => {
await page(pending)
expect(await screen.findByRole('region', { name: 'Plan loading' })).toBeInTheDocument()
await expectLoading()
})
it('empty: nothing planned yet, with Draft this week', async () => {
await page(noPlan)
expect(await screen.findByRole('heading', { name: 'Nothing planned yet' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(noPlan))
await expectRetry("Couldn't load this week's plan. internal message", () =>
screen.findByRole('heading', { name: 'Nothing planned yet' }),
)
})
})
describe('/plan/shopping Supply run', () => {
const page = async (run: Responder) => {
serve({ [`GET /api/v1/shopping/${WEEK()}`]: run })
await renderRoute('/plan/shopping', '/plan/shopping', <SupplyRunPage />)
}
const noList = () => errorResponse(404, 'not_found')
it('loading: skeleton cards', async () => {
await page(pending)
await expectLoading()
})
it('empty: no shopping list yet, with Make the shopping list', async () => {
await page(noList)
expect(await screen.findByRole('heading', { name: 'No shopping list yet' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Make the shopping list' })).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(noList))
await expectRetry("Couldn't load the shopping list. internal message", () =>
screen.findByRole('heading', { name: 'No shopping list yet' }),
)
})
})
describe('/plan/shopping/archive Supply archive', () => {
const page = async (archive: Responder) => {
serve({ 'GET /api/v1/shopping/archive?by=week': archive })
await renderRoute('/plan/shopping/archive', '/plan/shopping/archive', <SupplyArchivePage />)
}
const nothing = json({ by: 'week', periods: [] })
it('loading: skeleton posts', async () => {
await page(pending)
await expectLoading()
})
it('empty: nothing archived yet', async () => {
await page(nothing)
expect(await screen.findByRole('heading', { name: 'Nothing archived yet' })).toBeInTheDocument()
expect(screen.getByText("Each week's Supply run becomes a post here once it's made.")).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(nothing))
await expectRetry("Couldn't load the Supply archive. internal message", () =>
screen.findByRole('heading', { name: 'Nothing archived yet' }),
)
})
})
describe('/discover Side quests', () => {
const page = async (deck: Responder) => {
serve({ 'GET /api/v1/suggestions/deck': deck, 'GET /api/v1/dinners?status=quest_log': json({ dinners: [] }) })
await renderRoute('/discover', '/discover', <DiscoverPage />)
}
const empty = json({ suggestions: [], active_batch: null, failed_batch: null })
it('loading: skeleton cards', async () => {
await page(pending)
await expectLoading()
})
it('empty: All caught up / New side quests arrive Sunday., with Get more now', async () => {
await page(empty)
expect(await screen.findByRole('heading', { name: 'All caught up' })).toBeInTheDocument()
expect(screen.getByText('New side quests arrive Sunday.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Get more now' })).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(empty))
await expectRetry("Couldn't load your side quests. internal message", () =>
screen.findByRole('heading', { name: 'All caught up' }),
)
})
})
describe('/quick Quick dinners', () => {
const QUICK = 'POST /api/v1/ideas/quick'
const job = (status: string, error: string | null) => ({
id: 'j1',
kind: 'quick_ideas',
status,
result: { stage: 'grounding', kind: 'under30', exclude: [], ideas: [], dropped: 0 },
error,
})
const page = async (quick: Responder, poll: Responder = pending) => {
serve({ [QUICK]: quick, 'GET /api/v1/jobs/j1': poll, 'GET /api/v1/jobs/j2': pending })
await renderRoute('/quick', '/quick', <QuickDinnersPage />)
}
it('loading: skeleton rows', async () => {
await page(pending)
await waitFor(() => expect(screen.getAllByTestId('skeleton-row')).toHaveLength(6))
await expectLoading()
})
it('empty: no ideas found, with Retry', async () => {
await page(startJobs(), json(job('failed', 'no_ideas_found')))
await expectRetry("Couldn't find Quick dinners. No ideas came back with a recipe to match.", () =>
waitFor(() => expect(skeletons().length).toBeGreaterThan(0)),
)
})
it('error: banner with Retry', async () => {
await page(failThen(pending))
await expectRetry("Couldn't start looking for Quick dinners. internal message", () =>
waitFor(() => expect(screen.getAllByTestId('skeleton-row')).toHaveLength(6)),
)
})
})
describe('/cook-from-pantry Cook from what I have', () => {
const FIND = 'POST /api/v1/ideas/from-ingredients'
const job = {
id: 'j1',
kind: 'ideas_from_ingredients',
status: 'failed',
result: { stage: 'grounding', ingredients: ['Rice'], quick_only: false, ideas: [], dropped: 0 },
error: 'no_ideas_found',
}
const page = async (find: Responder, poll: Responder = pending) => {
serve({ [FIND]: find, 'GET /api/v1/jobs/j1': poll, 'GET /api/v1/jobs/j2': pending })
await renderRoute('/cook-from-pantry', '/cook-from-pantry', <CookFromPantryPage />)
}
const search = () => {
fireEvent.change(screen.getByRole('textbox', { name: 'Add ingredient' }), { target: { value: 'Rice' } })
fireEvent.click(screen.getByRole('button', { name: 'Find ideas' }))
}
it('loading: skeleton cards', async () => {
await page(pending)
search()
expect(await screen.findByRole('region', { name: 'Ideas loading' })).toBeInTheDocument()
await expectLoading()
})
it('empty: no ingredients yet, with Find ideas disabled; no ideas found, with Retry', async () => {
await page(startJobs(), json(job))
expect(screen.getByRole('button', { name: 'Find ideas' })).toBeDisabled()
search()
await expectRetry("Couldn't find ideas. No ideas came back with a recipe to match.", () =>
screen.findByRole('region', { name: 'Ideas' }),
)
})
it('error: banner with Retry', async () => {
await page(failThen(pending))
search()
await expectRetry("Couldn't start looking for ideas. internal message", () =>
screen.findByRole('region', { name: 'Ideas loading' }),
)
})
})
describe('/dinner/:id Dinner', () => {
const page = async (dinner: Responder) => {
serve({ 'GET /api/v1/dinners/d1': dinner })
await renderRoute('/dinner/d1', '/dinner/:id', <DinnerDetailPage />)
}
const notFound = () => errorResponse(404, 'not_found')
it('loading: skeletons', async () => {
await page(pending)
await expectLoading()
})
it('empty: dinner not found', async () => {
await page(notFound)
expect(await screen.findByRole('heading', { name: 'Dinner not found' })).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(notFound))
await expectRetry("Couldn't load this dinner. internal message", () =>
screen.findByRole('heading', { name: 'Dinner not found' }),
)
})
})
describe('/settings Settings home', () => {
const page = async (calendar: Responder) => {
const api = Object.fromEntries(Object.entries(SETTINGS_API).map(([path, respond]) => [`GET ${path}`, respond]))
serve({ ...api, 'GET /api/v1/dinners?status=regular': json({ dinners: [] }), 'GET /api/v1/integrations/google': calendar })
await renderRoute('/settings', '/settings', <SettingsPage />)
}
const notConnected = json(GOOGLE_NOT_CONNECTED)
it('loading: status skeletons', async () => {
await page(pending)
await expectLoading()
})
it('empty: no Regulars, calendar not connected, Google Chat not connected', async () => {
await page(notConnected)
expect(await screen.findByText('None on rotation yet')).toBeInTheDocument()
expect(await screen.findByText('Not connected')).toBeInTheDocument()
expect(await screen.findByText(/Google Chat not connected/)).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(notConnected))
await expectRetry('Settings could not load: internal message', () => screen.findByText('Not connected'))
})
})
describe('/settings/notifications Notifications', () => {
const page = async (prefs: Responder) => {
serve({ 'GET /api/v1/settings/notifications': prefs, 'GET /api/v1/settings/chat-webhook': json(CHAT_WEBHOOK_NONE) })
await renderRoute('/settings/notifications', '/settings/notifications', <NotificationsPage />)
}
it('loading: skeleton for the channel matrix', async () => {
await page(pending)
// The webhook card and this device's push status read on their own;
// once they settle, the one skeleton left is the channel matrix's.
await screen.findByRole('button', { name: 'Save webhook' })
const device = screen.getByRole('group', { name: 'Notifications on this device' })
await waitFor(() => expect(device.querySelectorAll(`.${skeletonStyles.skeleton}`)).toHaveLength(0))
expectOnlyLoadingBefore(device)
expect(screen.queryByRole('group', { name: 'Notification channels' })).not.toBeInTheDocument()
})
it('empty: no Google Chat webhook stored', async () => {
await page(json(NOTIFICATION_SETTINGS))
expect(await screen.findByRole('button', { name: 'Save webhook' })).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(json(NOTIFICATION_SETTINGS)))
await expectRetry('Notification settings could not load: internal message', () =>
screen.findByRole('group', { name: 'Notification channels' }),
)
})
})
describe('/settings/ai AI assistant', () => {
const NO_KEY = { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null }
const page = async (settings: Responder) => {
serve({
'GET /api/v1/settings/ai': settings,
'GET /api/v1/settings/ai/models': json(AI_MODELS),
'GET /api/v1/settings/recipe-search': json(RECIPE_SEARCH_SETTINGS),
})
await renderRoute('/settings/ai', '/settings/ai', <AISettingsPage />)
}
it('loading: skeleton for the AI assistant form', async () => {
await page(pending)
// The Recipe search card reads on its own; once it settles, the one
// skeleton left is the AI assistant form's.
await screen.findByRole('button', { name: 'Save recipe search' })
expectOnlyLoadingBefore(screen.getByRole('region', { name: 'Recipe search' }))
expect(screen.queryByText('No key stored')).not.toBeInTheDocument()
})
it('empty: no key stored, with Add key', async () => {
await page(json(NO_KEY))
expect(await screen.findByText('No key stored')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add key' })).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(json(NO_KEY)))
await expectRetry('AI settings could not load: internal message', () => screen.findByText('No key stored'))
})
})
describe('/settings/household Household', () => {
const page = async (settings: Responder) => {
serve({ 'GET /api/v1/settings/household': settings })
await renderRoute('/settings/household', '/settings/household', <HouseholdSettingsPage />)
}
it('loading: skeleton', async () => {
await page(pending)
await expectLoading()
})
// The household's settings always exist (seeded on first start), so its
// "empty" state is the seeded defaults.
it('empty: the seeded settings', async () => {
await page(json(HOUSEHOLD_SETTINGS))
expect(await screen.findByLabelText('Dinner time')).toHaveValue('17:30')
})
it('error: banner with Retry', async () => {
await page(failThen(json(HOUSEHOLD_SETTINGS)))
await expectRetry('Household settings could not load: internal message', () => screen.findByLabelText('Dinner time'))
})
})
describe('/settings/regulars Regular dinners', () => {
const page = async (regulars: Responder) => {
serve({ 'GET /api/v1/dinners?status=regular': regulars })
await renderRoute('/settings/regulars', '/settings/regulars', <RegularsSettingsPage />)
}
const none = json({ dinners: [] })
it('loading: skeleton', async () => {
await page(pending)
await expectLoading()
})
it('empty: no Regulars yet', async () => {
await page(none)
expect(await screen.findByText('No Regulars yet. Add the dinners your household makes on repeat.')).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(json({ dinners: [dinnerBody('d1', 'Beef tacos')] })))
await expectRetry('Your Regulars could not load: internal message', () => screen.findByText('Beef tacos'))
})
})
describe('/settings/calendar Calendar', () => {
const page = async (connection: Responder) => {
serve({ 'GET /api/v1/integrations/google': connection })
await renderRoute('/settings/calendar', '/settings/calendar', <CalendarSettingsPage />)
}
const notConnected = json(GOOGLE_NOT_CONNECTED)
it('loading: skeleton', async () => {
await page(pending)
await expectLoading()
})
it('empty: not connected, with Connect Google Calendar', async () => {
await page(notConnected)
expect(await screen.findByRole('heading', { name: 'Not connected' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(notConnected))
await expectRetry('Google Calendar status could not load: internal message', () =>
screen.findByRole('heading', { name: 'Not connected' }),
)
})
})
+5 -10
View File
@@ -2,8 +2,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
import { useState, type ReactNode } from 'react'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { dinnerBody } from './dinners.ts'
import { fakeLocation, jsonResponse } from './fetchStub.ts'
import { errorResponse, fakeLocation, jsonResponse } from './fetchStub.ts'
// AI_SETTINGS is a GET /api/v1/settings/ai body: Anthropic with a verified key.
export const AI_SETTINGS = {
@@ -100,16 +99,12 @@ export const CHAT_WEBHOOK = {
last_test_status: null,
}
// SETTINGS_REGULARS is a GET /api/v1/dinners?status=regular body: one
// Regular, its recipe found.
export const SETTINGS_REGULARS = { dinners: [dinnerBody('r1', 'Beef tacos')] }
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
// today's server does: one Regular on rotation, and neither Google Calendar
// nor Google Chat connected. The home's sub-tabs stay mounted on mobile, so
// it also reads the Regulars list, the AI models and recipe search settings.
// today's server does: the dinners endpoint does not exist yet, and neither
// Google Calendar nor Google Chat is connected. The home's sub-tabs stay mounted on mobile, so
// it also reads the AI models and recipe search settings.
export const SETTINGS_API = {
'/api/v1/dinners?status=regular': () => jsonResponse(200, SETTINGS_REGULARS),
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'/api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
'/api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
'/api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK_NONE),
+2
View File
@@ -13,6 +13,7 @@ export type DinnerBody = {
photo_url: string | null
grounding_status: string
grounding_error: string | null
photo_backfill_eligible: boolean
updated_by: string | null
created_at: string
updated_at: string
@@ -35,6 +36,7 @@ export function dinnerBody(id: string, name: string, overrides: Partial<DinnerBo
photo_url: null,
grounding_status: 'grounded',
grounding_error: null,
photo_backfill_eligible: false,
updated_by: null,
created_at: '2026-10-01T15:00:00.000Z',
updated_at: '2026-10-01T15:00:00.000Z',
+7 -14
View File
@@ -3,22 +3,16 @@ import { act } from '@testing-library/react'
type Listener = (event: MediaQueryListEvent) => void
// Replaces window.matchMedia with one that answers min-width / max-width
// queries for a pretend viewport width, orientation: portrait for its height
// (a viewport given no height is landscape), and prefers-reduced-motion:
// reduce with reducedMotion (jsdom has no matchMedia and no layout). Returns
// a function that resizes the pretend viewport and notifies every query
// whose answer changed.
export function mockViewport(
initialWidth: number,
{ reducedMotion = false, height: initialHeight = 0 } = {},
): (width: number, height?: number) => void {
// queries for a pretend viewport width, and prefers-reduced-motion: reduce
// with reducedMotion (jsdom has no matchMedia and no layout). Returns a
// function that resizes the pretend viewport and notifies every query whose
// answer changed.
export function mockViewport(initialWidth: number, { reducedMotion = false } = {}): (width: number) => void {
let width = initialWidth
let height = initialHeight
const lists: { list: MediaQueryList; listeners: Set<Listener> }[] = []
function evaluate(query: string): boolean {
if (query === '(prefers-reduced-motion: reduce)') return reducedMotion
if (query === '(orientation: portrait)') return height >= width
const min = /\(min-width:\s*(\d+)px\)/.exec(query)
const max = /\(max-width:\s*(\d+)px\)/.exec(query)
if (!min && !max) throw new Error(`mockViewport cannot evaluate ${JSON.stringify(query)}`)
@@ -43,10 +37,9 @@ export function mockViewport(
return list
}
return (nextWidth: number, nextHeight = 0) => {
return (next: number) => {
const before = lists.map(({ list }) => list.matches)
width = nextWidth
height = nextHeight
width = next
act(() => {
lists.forEach(({ list, listeners }, i) => {
if (list.matches === before[i]) return