Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 57cecee89b Deploy runbook: roll back to the running release, reject a dirty build (Maestro 8611c4b6, MQ-203-01, MQ-203-02)
CI / ci (push) Successful in 8m34s
CI / ci (pull_request) Successful in 8m30s
MQ-203-01: the build step reads the version from the running service's
/health on the bind address before the checkout changes, resolves it to a
commit and records that as the rollback SHA, instead of the checkout's
HEAD, which can be ahead of production after a failed or deferred deploy.
A running version of dev or *-dirty fails the step. The rollback and its
migration check use that SHA.

MQ-203-02: after checkout the build step fails on modified tracked files
outside internal/frontend/dist (the Makefile's GIT_DIRTY predicate), and
inspects the exact tag make docker produces, computed as the Makefile's
VERSION including -dirty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:07:42 -04:00
dev_agentandClaude Opus 5.5 57c672217a Add deploy runbook that embeds and verifies the deployed version (Maestro 8611c4b6)
CI / ci (push) Successful in 8m34s
CI / ci (pull_request) Successful in 8m31s
deploy/RUNBOOK.md documents the one deploy flow on 10.60.1.14: build with
make docker (version from git describe, no manual build argument), retag
the image compose runs (mealquest-mealquest), restart through the systemd
unit, then step 3 checks /health reports the checkout's version and that /
and /plan serve the built PWA shell and its script, on the LAN address and
through Traefik. Rollback retags the previous release's image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:42:15 -04:00
orchestrator-agent cfc229049f Supply archive: mark an unfrozen past run as still open (Maestro 29b629aa) (#200)
CI / ci (push) Successful in 8m36s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 17:29:09 -04:00
orchestrator-agent 01cd9a8741 Supply run: a late in-flight read must not overwrite a just-made list (Maestro 4d299127) (#199)
CI / ci (push) Successful in 8m30s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 16:34:35 -04:00
orchestrator-agent f7ebc85862 Supply archive fidelity: frozen posts keep dinner names as frozen (Maestro 22f80eb6) (#201)
CI / ci (push) Successful in 10m7s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 15:57:29 -04:00
orchestrator-agent 9d91686c35 Enforce unique recipe item positions per recipe (Maestro 75bb8cc1) (#198)
CI / ci (push) Successful in 9m43s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 15:29:59 -04:00
orchestrator-agent 5275757258 Suggest batch: replace a card dropped at final store, correct failed-batch doc (Maestro 244f9834) (#197)
CI / ci (push) Successful in 9m33s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 14:45:23 -04:00
orchestrator-agent e5aa009fc5 Record source-attribution provenance instead of inferring it from the name shape (Maestro 966066e3) (#202)
CI / ci (push) Successful in 9m39s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 13:05:44 -04:00
orchestrator-agent d182349646 Plan draft schedule tests: assert the plan_ready title and channel (Maestro ece60217) (#195)
CI / ci (push) Successful in 8m11s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 09:38:12 -04:00
orchestrator-agent 72755a8b61 Assert Home tonight dinner exact id and photo URL (Maestro 9d81c325) (#196)
CI / ci (push) Successful in 9m42s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 09:38:06 -04:00
orchestrator-agent 791b321b71 Expose the Supply run title job and cover the replacement race (#194)
CI / ci (push) Successful in 8m9s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 08:24:51 -04:00
orchestrator-agent be41b56690 Make login OAuth state single-use (#193)
CI / ci (push) Successful in 8m6s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 02:13:34 -04:00
orchestrator-agent 3a39fc8f2c Plan-screen deferred hardening (#192)
CI / ci (push) Successful in 9m29s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 01:21:55 -04:00
orchestrator-agent d8633d56a4 Embed the Regular dinners screen in the desktop Settings tab (#191)
CI / ci (push) Successful in 8m15s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 00:54:54 -04:00
orchestrator-agent 9046b6df78 Harden make sqlc-check against a failing git status (#190)
CI / ci (push) Successful in 8m20s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 00:25:04 -04:00
orchestrator-agent 759fbbe3dd Accessibility pass (#189)
CI / ci (push) Failing after 6s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 00:04:22 -04:00
orchestrator-agent d390d8dcce Portrait tablet and tablet settings layouts (#188)
CI / ci (push) Successful in 8m23s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-10 23:35:24 -04:00
orchestrator-agent 25edb6fb26 Empty, loading and error state sweep (#187)
CI / ci (push) Successful in 8m17s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-10 23:00:12 -04:00
orchestrator-agent 17fa82ef2b Deflake the Supply run offline-queue tests under host load (#186)
CI / ci (push) Successful in 8m26s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-10 22:05:38 -04:00
103 changed files with 3413 additions and 2650 deletions
+5 -9
View File
@@ -78,18 +78,17 @@ behaviour, exercise it (hit the endpoint, render the page).
| Path | Purpose |
| --- | --- |
| `cmd/server` | Entry point. Wiring only — no business logic. |
| `cmd/vapidgen` | Prints a new VAPID key pair for Web Push. |
| `internal/config` | `MEALQUEST_*` environment config; secrets from `*_FILE`. |
| `internal/server` | HTTP server, routes, `/health`. |
| `internal/frontend` | `go:embed` of the built PWA with SPA fallback. |
| `internal/*` | `auth`, `forwarded`, `secureurl`, `db`, `store`, `secretbox`, `household`, `dinners`, `recipes`, `ai/llm`, `ai/harness`, `ai/skills`, `ai/aisettings`, `suggest`, `plan`, `shopping`, `ideas`, `home`, `gcal`, `notify`, `push`, `chat`, `vapid`, `jobs`, `scheduler` — one line each in [ARCHITECTURE.md](docs/ARCHITECTURE.md#repository-layout). |
| `migrations/` | SQL migrations, embedded and applied at boot by `internal/store`; sqlc queries in `migrations/queries/`. |
| `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: compose stack, systemd units, [deploy runbook](deploy/RUNBOOK.md) and the automated deploy (`auto-deploy.sh` with `systemd/mealquest-auto-deploy.*`). |
| `deploy/` | Deployment files (planned). |
| `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. |
| `Makefile` | `dev`, `test`, `build`, `docker`; `sqlc` and `sqlc-check` regenerate and check `internal/db`. |
| `Makefile` | `dev`, `test`, `build`, `docker`. |
| `.gitea/workflows/ci.yml` | CI quality floor. |
Design references:
@@ -105,10 +104,7 @@ Design references:
- **Task branches:** `task/<slug>_<task-uuid>`, branched from the feature branch.
- Task pull requests **squash-merge into the feature branch**.
- The feature pull request merges into `dev`.
- **Only `overseer` merges `dev` → `main`** (a release). On the production
host, `mealquest-auto-deploy.timer` then deploys the new `main` once its
CI passes ([deploy/RUNBOOK.md](deploy/RUNBOOK.md) "Automated deploys");
nothing deploys `dev` or a feature branch.
- **Only `overseer` merges `dev` → `main`** (a release).
- Never push directly to `dev` or `main`.
- Authors never review, approve, merge or close their own work.
- Never store credentials in the repository; environment or ignored files only.
+3 -2
View File
@@ -82,7 +82,8 @@ sqlc:
$(SQLC) generate
# Fails on any difference from the committed generated code, including new
# or deleted files.
# or deleted files. A failing git status is itself a failure: its empty
# output would otherwise read as "no drift".
sqlc-check: sqlc
@drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))"; \
@drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))" || { echo "git status failed; cannot check internal/db for drift"; exit 1; }; \
if [ -n "$$drift" ]; then echo "internal/db is stale; run make sqlc and commit:"; echo "$$drift"; git diff -- $(SQLC_OUT); exit 1; fi
+3 -30
View File
@@ -7,27 +7,9 @@ builds a weekly plan, pushes it to Google Calendar, and generates a shopping
list grouped by dinner, kept as a browsable weekly history. It ships as a
single Go service with the React PWA embedded in the binary.
Production runs at <https://mealquest.cubecraftlabs.com> for one household,
signed in through Authentik.
## Stack
- **Backend:** Go 1.25 (`CGO_ENABLED=0`, one static binary), chi router,
SQLite through `modernc.org/sqlite` with sqlc queries and embedded
migrations, Authentik OIDC, `log/slog`. AI calls go to an
OpenAI-compatible, Anthropic or Ollama endpoint over plain HTTP; recipes
come from SearXNG or Brave search and the recipe page's schema.org data.
Google Calendar, Google Chat and Web Push are the integrations.
- **Frontend:** React 19, TypeScript, Vite, React Router 7, TanStack Query 5,
`vite-plugin-pwa`, CSS Modules over the design tokens; tests with Vitest,
Testing Library and jsdom.
- **Deployment:** a Docker Compose stack (the service, Litestream
replication to the NAS, a SearXNG sidecar) behind Traefik, with systemd
units for the firewall, nightly media backup and the automated deploy of
`main`.
The approved dependency list is in [AGENTS.md](AGENTS.md); nothing else is
added without the owner's approval.
> Status: this repository is the Feature 01 scaffold (Go service with
> `/health`, embedded PWA skeleton, Makefile, Dockerfile, CI). The planned
> application is described in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## Running it locally
@@ -46,15 +28,6 @@ invalid variable.
| `make test` | `gofmt` check, `go vet`, `go test -race`, web typecheck and tests. |
| `make build` | Build the PWA, embed it, build the static binary `bin/mealquest`. |
| `make docker` | Build the container image from the root `Dockerfile`. |
| `make sqlc-check` | Regenerate `internal/db` with sqlc and fail if it drifted. |
## Deploying and operating
[deploy/RUNBOOK.md](deploy/RUNBOOK.md) is the deploy runbook: build, ship and
restart, verifying the deployed version, rollback and the automated deploy.
Database restore, the boot configuration and other host procedures are in
CTXD: project `mealquest`, file `RUNBOOKS.MD`. Only `overseer` releases, by
merging `dev` into `main`.
## Where to read next
+1 -1
View File
@@ -158,7 +158,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, sched.Wake, cfg.PublicURL, logger)
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, sched.Wake, cfg.PublicURL, sched.Now, logger)
if err != nil {
return err
}
+1 -103
View File
@@ -110,7 +110,7 @@ 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
git checkout --detach origin/main # 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"
@@ -253,105 +253,3 @@ 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
@@ -1,251 +0,0 @@
#!/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"
@@ -1,23 +0,0 @@
# 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
@@ -1,13 +0,0 @@
# 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
@@ -0,0 +1,113 @@
# 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. |
+67 -112
View File
@@ -2,8 +2,10 @@
This mirrors the architecture section of `CONTEXT.MD` in the CTXD project
`mealquest`, which is the source of record; if the two disagree, CTXD wins and
this file should be updated. It describes the application as built; nothing
it names is still only planned.
this file should be updated. It describes the **planned** application. The
repository today is the Feature 01 scaffold: `cmd/server`, `internal/config`,
`internal/server` (`/health`), `internal/frontend` (embedded PWA with SPA
fallback), the `web/` skeleton, `Makefile`, `Dockerfile` and CI.
Decision numbers (D-NNN) refer to `DECISIONS.MD` in CTXD.
@@ -44,22 +46,20 @@ headers, so sign-in works whichever peer the request arrives from.
| `cmd/vapidgen` | Prints a new VAPID key pair for Web Push. |
| `internal/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. |
| `internal/server` | HTTP server, router, middleware. |
| `internal/forwarded` | Client IP and scheme from `X-Forwarded-*`, trusted only from `MEALQUEST_TRUSTED_PROXY`. |
| `internal/secureurl` | The transport rule for URLs that carry credentials (`secureurl.Check`). |
| `internal/frontend` | Embedded PWA, SPA fallback. |
| `internal/auth` | Authentik OIDC, sessions, CSRF. |
| `internal/db`, `internal/store` | sqlc-generated queries; SQLite connections, migrations at boot, transactions. |
| `internal/db`, `internal/store` | SQLite connections, sqlc queries. |
| `internal/secretbox` | AES-256-GCM encryption for `_ct` columns. |
| `internal/household`, `internal/dinners`, `internal/recipes` | Domain: household settings and local time, dinners (grounding, photo backfill), recipe search, fetch and grounding. |
| `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills`, `internal/ai/aisettings` | AI provider adapters, skill harness, skills, AI settings. |
| `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas`, `internal/home` | Feature services. |
| `internal/household`, `internal/dinners`, `internal/recipes` | Domain. |
| `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills` | AI provider adapters, skill harness, skills. |
| `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. |
| `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. |
| `internal/vapid` | VAPID key generation and validation. |
| `internal/jobs`, `internal/scheduler` | Background jobs and schedules. |
| `migrations/` | SQL migrations, embedded and applied at boot by `internal/store` (no goose or golang-migrate); sqlc queries in `migrations/queries/`. |
| `migrations/` | SQL migrations (no goose or golang-migrate). |
| `web/` | React PWA. |
| `deploy/` | Production compose stack, systemd units, the [deploy runbook](../deploy/RUNBOOK.md) and the automated deploy (see Deployment). |
| `docs/` | Documentation (this file). |
| `deploy/` | Deployment files. |
| `docs/` | Documentation. |
| `design/` | Requirements and UI handoff (read-only reference). |
## Backend stack and conventions
@@ -95,8 +95,8 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| --- | --- |
| `household_settings` | One row: timezone, dinner_time (17:30), reminder_time (16:30), review_time (09:00), plan_draft day/time (Fri 09:00), suggestions day/time (Sun 08:00), chat_autosend day/time (Sat 08:00), onboarding_completed_at, updates_seen_at. |
| `recipes` | A real recipe page: source_url (unique), site_name, title, total/prep/cook minutes (the page's JSON-LD), yield, raw_jsonld, fetched_at; and its grounding: photo_id, method, difficulty, minutes_total (normalized prep to table). |
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
| `recipe_steps` | recipe_id, position, text. |
| `recipe_ingredients` | recipe_id, position (unique per recipe: the recipe order), raw_text, name, qty, unit, note. |
| `recipe_steps` | recipe_id, position (unique per recipe: the recipe order), text. |
| `photos` | source_url, local_path, width, height, sha256, fetched_at. |
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised whenever a dinner is renamed, so only the latest `ground_dinner` job may write it and no candidate offered before the rename grounds it), updated_by. |
| `suggestion_batches` | trigger, status, ai_run_id. |
@@ -105,7 +105,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). |
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. |
| `shopping_lists` | week_start (unique), plan_id, title (the AI-written post title; NULL until written or when the AI fails, when the week label is shown instead; never empty), status (`current` / `archived`), sent_to_chat_at, chat_send_unconfirmed_at (a send to Google Chat recorded as begun before posting and cleared once its outcome is known; left set when it may or may not have posted), frozen_at. |
| `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by `shopping.Generate` from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged. |
| `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date), frozen_dinner_name (the dinner's name when its list was frozen into the Supply archive, kept whatever it is later renamed; NULL while the list is current, which shows its dinners' names now; never empty). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by `shopping.Generate` from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged. |
| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
| `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url (the SearXNG instance), api_key_ct and api_key_last4 (Brave). |
| `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. |
@@ -121,6 +121,16 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
`recipes` holds a fetched recipe page; `dinners` holds the household's meal
record and its lifecycle, optionally linked to a recipe and a photo.
Migration `0018_recipe_item_position_unique` makes position unique per
recipe and fails, so the service refuses to start, on a database already
holding a duplicate. Before deploying it, check the database; both queries
must return no rows:
```sql
SELECT recipe_id, position FROM recipe_ingredients GROUP BY 1, 2 HAVING count(*) > 1;
SELECT recipe_id, position FROM recipe_steps GROUP BY 1, 2 HAVING count(*) > 1;
```
## Dinner lifecycle
```
@@ -215,17 +225,17 @@ automatic** (D-021).
raw_text}]` and `steps` the step texts, both in recipe position order and
empty (`[]`) with no recipe, as for a dinner whose grounding is
`pending` or `failed`; a recipe entered by hand has only `raw_text`.
Two items at one position leave recipe order unknown, so such a recipe
is 500 rather than shown out of order. `source` is `{site_name}` of the
page the recipe was fetched from, null for a manual recipe or none; it
has no `url`, as the page's link may carry a personal token.
`site_name` is the name the page gives its site (publisher or
`og:site_name`) and null otherwise: a page naming no site is stored
under its host, which may be the pasted link's — a token in a
subdomain — or a redirect's, so any stored name that could be a host
(the link's host, an IP address or a dotted host name, even a real
name like "Food.com") is withheld. The detail screen shows "Recipe
from {site}" only when `site_name` is not null. `why_picked` is the reason of the
`source` is `{site_name, url}` of the page the recipe was fetched from,
null for a manual recipe or none, as `dinners.SourceOf` gives it — the
one rule for every fetched recipe's source, also used by idea results
and recipe candidates. `url`
is the page's link only when it is free of secrets (https, no user,
query or fragment), else null. `site_name` is the name the page gives
its site (publisher or `og:site_name`), else the link's host without
"www." when `url` is shown, else null. Extraction never stores a host
as a site name, so a stored name is always the page's; migration 0020
cleared the host-shaped names stored before that. The detail screen
shows "Recipe from {site}" only when `site_name` is not null. `why_picked` is the reason of the
suggestion that created the dinner — its pending or accepted one, as
declining a suggestion archives its dinner — null for one no suggestion
created; a dinner with more than one such suggestion is 500, as nothing
@@ -259,33 +269,11 @@ 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`
ranks them; it fetches pages but downloads no photo and runs no model,
ranks them, with `site_name` as the recipe detail's (`dinners.SourceOf`,
null when the site cannot be named without leaking the link); it fetches pages but downloads no photo and runs no model,
so `photo_url` is set only for a page already stored. `candidate_id` is
an opaque id the server resolves to the page it last offered that dinner
(kept in memory, so a restart means fetching the candidates again) at
@@ -342,15 +330,22 @@ automatic** (D-021).
suggestion with the name it is suggested under, the reason and
`cited_regular_ids` — unless, since the job read its input, a dinner
it repeats was added, a suggestion it repeats passed, or a Regular it
cites renamed or archived (checked in the storing transaction). The job
cites renamed or archived (checked in the storing transaction). A
notifying batch also rechecks the Side quests it holds after each round
and lets go of those no longer eligible, so a later round grounds a
spare in place of each; only a change committed between the last
recheck and the store can leave it short of 5 with spares unused. The job
publishes `{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
`grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the
skill or `normalize_recipe` failed; no Side quest is made up instead,
D-017), `search_not_configured` or `search_failed`, and the batch is set
`failed`. A failed `scheduled` or `manual` batch has stored nothing; a
`failed`. A `scheduled` or `manual` batch that fails before its final
store (the AI, grounding, or the store itself) has stored nothing; a
failed `replenish` batch's Side quests stored before the failure stay
in the deck. If
recording the batch's end fails, its job fails; the job runner retries
recording the batch's end fails, its job fails, even after the final
store committed: a notifying batch's Side quests and their
`new_suggestions` then stay; the job runner retries
a job's final status write with backoff until it lands, each attempt
bounded at 5 seconds; at shutdown an attempt that fails or times out is
not retried, and the next start fails the job as interrupted. Recording
@@ -658,7 +653,10 @@ automatic** (D-021).
`shopping.Freeze(weekStart)` moves a week's Supply run into the Supply
archive: `status` becomes `archived` and `frozen_at` is set, after which
generating the list or changing an item is 409 `archived`. It freezes only
generating the list or changing an item is 409 `archived`. In the same
transaction each group keeps its dinner's name as `frozen_dinner_name`, so
the frozen list and its post show the names they had when frozen, whatever
a dinner is renamed later. It freezes only
a week that has ended in the household's time zone (its Monday is before
the Monday of the household's current week), so a week not yet ended is
`ErrWeekNotEnded` and a week with no list `ErrListNotFound`; freezing a
@@ -811,20 +809,6 @@ purge are fixed and not configurable.
Runs are recorded in `schedule_runs` and executed through `jobs`.
Job kinds (`internal/jobs`; each registered when `cmd/server` builds the
service named, before the runner starts). `POST /api/v1/jobs` refuses a
service-only kind; `GET /api/v1/jobs/{id}` reports any job.
| Kind | Registered by | Enqueued by | Service-only |
| --- | --- | --- | --- |
| `noop` | `jobs.RegisterBuiltins` | `POST /api/v1/jobs` (does nothing; proves the runner works) | no |
| `ground_dinner` | `dinners.Service` | Adding or renaming a `pending` dinner | no |
| `backfill_dinner_photos` | `dinners.Service` | `POST /api/v1/settings/photo-backfill` | yes |
| `suggestion_batch` | `suggest.Service` | `POST /api/v1/suggestions/batches`, a deck decision (replenish), `suggestions_weekly` | no |
| `draft_week` | `plan.Service` | `POST /api/v1/plans/{weekStart}/draft`, `plan_draft` | yes |
| `title_supply_run` | `shopping.Service` | Generating an untitled Supply run | yes |
| `ideas_from_ingredients`, `quick_ideas` | `ideas.Service` | `POST /api/v1/ideas/from-ingredients`, `POST /api/v1/ideas/quick` | yes |
`internal/scheduler` runs them. A schedule registers a name, a rule read
from the settings (`scheduler.Daily`, `Weekly` or `WeeklyForNextWeek`, or `DailyAt` for a time read from data) and a
run function; settings changes reach it through `SettingsService.Subscribe`
@@ -1157,7 +1141,10 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
- The week's Supply run (Feature 17) is made from a pushed plan: after
a successful `Push`, the handler runs `shopping.GeneratePushed` for
the week at the plan revision `Push` pushed, and the 200 answer
carries `supply_run: {status, error}`. `status` is `generated`;
carries `supply_run: {status, error, title_job_id}`. `title_job_id` is
the `title_supply_run` job titling a generated list, as for `POST
/shopping/{weekStart}/generate`; null when the list already has its
title or was not generated. `status` is `generated`;
`archived` when the week's Supply run is in the Supply archive and was
left as it was; or `failed` — error `plan_changed` (retryable, logged
as a warning) when the plan was edited between the push and
@@ -1180,8 +1167,12 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
when the week has no Supply run, 400 `invalid_week`.
- `POST /api/v1/shopping/{weekStart}/generate` makes or remakes the list
through `shopping.Generate` and answers with the same shape plus
`skipped: [{date, dinner_id, dinner_name, reason}]`. 404 when the week
has no plan, 409 `archived` when its list is in the Supply archive.
`skipped: [{date, dinner_id, dinner_name, reason}]` and
`title_job_id`, the `title_supply_run` job titling the list, which
`GET /api/v1/jobs/{id}` reports on (failed `title_failed` when the title
could not be written); null when the list already has its title. 404
when the week has no plan, 409 `archived` when its list is in the Supply
archive.
- `POST /api/v1/shopping/{weekStart}/send-chat` (CSRF-checked) takes
`{}` or `{"resend": true}` and posts the Supply run to Google Chat
through `chat.PostSupplyRun`, in the week's thread: "Supply run · " and
@@ -1217,7 +1208,8 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
Monday is a household-local date, so the week of Monday 28 December
is in December. `label` is the week label ("Week 41 · Oct 5 – 11"; the
UI sets it in capitals), `title` null while untitled, `dinners` the
list's dinners in day order and `dinnerCount` their number,
list's dinners in day order (as named when the list was frozen; the
current week's as named now) and `dinnerCount` their number,
`itemCount` all items, `onHandCount` the items marked "have it", and
`current` true for the household's current week. 400 `invalid_request`
for any other `by`. A frozen list is read through `GET
@@ -1333,12 +1325,10 @@ fewer the server answers 400 `invalid_settings` naming
`onboarding_completed` and changes nothing. Step 3 can be reached with
fewer — by its URL, or after another member removes a Regular — so it then
shows the server's reason with an "Add Regulars" button back to step 2.
Notification permission is never asked for on page load. "Turn on
notifications" subscribes this device to Web Push from the tap and says
how it ended — on, blocked by the browser, or unsupported (on iPhone and
iPad, until MealQuest is on the Home Screen) — then offers only "Finish
setting up" (which completes onboarding as above); a dismissed permission
prompt can be asked again. Offline on the
Notification permission is never asked for on page load; until Feature 19
wires Web Push, "Turn on notifications" asks for nothing and says plainly
that notifications are not on yet, offering "Finish setting up" (which
completes onboarding as above) instead of a silent success. Offline on the
saved pages the settings cannot be read (the service worker never caches `/api/v1/settings`),
so the saved pages open as they are.
@@ -1355,41 +1345,6 @@ are undone if the server refuses them. While any Regular is `pending`, the
list is read again every 3 seconds (not while a change is in flight), and
polling stops once none is.
## Deployment
Production is the compose project `mealquest` on 10.60.1.14
([`deploy/compose.yaml`](../deploy/compose.yaml)): the `mealquest` image built
from the root `Dockerfile`, `litestream` replicating the database to the
NAS over SFTP, and `searxng` on the internal network only. Configuration is
`deploy/.env` and file secrets in `deploy/secrets/`, neither committed. The
services never restart themselves; systemd units in `deploy/systemd/` own
the host:
| Unit | Does |
| --- | --- |
| `mealquest-firewall.service` | Restricts port 8090 to Traefik in `DOCKER-USER`; required before the stack starts. |
| `mealquest.service` | Starts and stops the compose stack. |
| `mealquest-media-backup.timer` / `.service` | Backs up cached photos to the NAS nightly at 03:30. |
| `mealquest-auto-deploy.timer` / `.service` | Automated deploy of `main` (below). |
Releases: only `overseer` merges `dev` into `main`. The image carries its
version (`git describe`: a release tag, else the short SHA), which
`/health` reports. A deploy follows
[`deploy/RUNBOOK.md`](../deploy/RUNBOOK.md) — build, pre-deploy restore
point, boot-config check, ship and restart, verify the deployed SHA and a
real screen — with rollback to the previous release. The automated deploy
([`deploy/auto-deploy.sh`](../deploy/auto-deploy.sh), installed as
`/usr/local/sbin/mealquest-auto-deploy`) runs those same steps unattended:
its timer checks every quarter hour (plus up to 5 minutes' random delay),
and deploys `refs/remotes/origin/main` only when it differs from the last
SHA in the deploy record `/home/overseer/mealquest-auto-deploy.log`, its
Gitea CI status is `success`, its last attempt did not fail, and
`mealquest.service` is active. It stops at the first failure and never
retries a failed SHA; `--ref <ref>` is the manual override. Installing,
seeding the record and enabling the timer are host steps in the runbook's
"Automated deploys". Database restore and the boot configuration are in
CTXD `RUNBOOKS.MD`.
## Boundaries and gotchas
- **Secrets never reach the client** — API keys, OAuth tokens and the Chat
+50
View File
@@ -0,0 +1,50 @@
# 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). |
+17 -2
View File
@@ -79,6 +79,7 @@ type Auth struct {
provider *oidc.Provider
client *http.Client
cookie loginCookie
pending *PendingStates
sessions Sessions
allowed []string
// endSessionURL is the provider's end_session_endpoint; logout ends
@@ -157,6 +158,7 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
client: client,
allowed: slices.Clone(cfg.AllowedGroups),
cookie: newLoginCookie(cfg.SecretKey),
pending: NewPendingStates(loginCookieTTL),
sessions: sessions,
endSessionURL: discovered.EndSessionURI,
logger: logger,
@@ -250,7 +252,13 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
Verifier: oauth2.GenerateVerifier(),
Return: returnTo,
}
if err := a.cookie.set(w, state, time.Now()); err != nil {
now := time.Now()
if err := a.pending.Add(state.State, now); err != nil {
a.logger.WarnContext(r.Context(), "login refused", "error", err)
http.Error(w, "too many sign-ins in progress; try again later", http.StatusServiceUnavailable)
return
}
if err := a.cookie.set(w, state, now); err != nil {
a.logger.ErrorContext(r.Context(), "set login cookie", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
@@ -262,7 +270,7 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
}
// callback completes the flow: it checks state against the login cookie,
// exchanges the code, verifies the ID token and nonce, then starts a session
// consumes it, exchanges the code, verifies the ID token and nonce, then starts a session
// if the account is in an allowed group. Membership is checked on every
// login, so an account removed from the group is refused at its next one.
func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
@@ -283,6 +291,13 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
return
}
// A copy of the cookie verifies until it expires, so the state is
// single-use only because it is consumed here.
if !a.pending.Consume(state.State) {
a.logger.WarnContext(ctx, "login callback state already used")
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
return
}
if q.Get("error") != "" {
// error and error_description are provider-supplied text; only the
// fixed category is logged.
+91
View File
@@ -497,6 +497,59 @@ func TestCallbackDefaultReturnPath(t *testing.T) {
}
}
// A saved copy of the login cookie still verifies until it expires, so a
// replay with a second valid code is refused only because the server
// consumed the state on first use.
func TestCallbackRefusesReplayedLoginState(t *testing.T) {
t.Parallel()
h := newHarness(t)
l := h.login(t, LoginPath)
idToken := h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))
if resp := h.callback(l, l.query.Get("state"), idToken); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("first callback status = %d, want 303", resp.StatusCode)
}
resp := h.callback(l, l.query.Get("state"), idToken)
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("replayed callback status = %d, want 400", resp.StatusCode)
}
if n := len(h.sessions.started()); n != 1 {
t.Errorf("%d sessions started, want 1", n)
}
assertLoginCookieCleared(t, resp)
}
// Pending login states live in memory, so a login started before a restart
// fails closed after it even though its cookie still verifies.
func TestCallbackRefusesLoginStartedBeforeRestart(t *testing.T) {
t.Parallel()
h := newHarness(t)
l := h.login(t, LoginPath)
restarted, err := New(context.Background(), Config{
Issuer: h.provider.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("server-secret-key"),
AllowedGroups: testAllowedGroups,
}, h.sessions, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatal(err)
}
r := chi.NewRouter()
restarted.Mount(r)
h.router = r
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("callback after restart status = %d, want 400", resp.StatusCode)
}
if n := len(h.sessions.started()); n != 0 {
t.Errorf("%d sessions started, want none", n)
}
}
func TestCallbackRejected(t *testing.T) {
t.Parallel()
@@ -819,6 +872,44 @@ func TestLoginCookieExpires(t *testing.T) {
}
}
func TestPendingStates(t *testing.T) {
t.Parallel()
p := NewPendingStates(time.Minute)
now := time.Now()
if err := p.Add("first", now); err != nil {
t.Fatal(err)
}
if !p.Consume("first") {
t.Error("Consume of a pending state = false")
}
if p.Consume("first") {
t.Error("second Consume of a state = true")
}
if p.Consume("never-added") {
t.Error("Consume of an unknown state = true")
}
for i := range maxPendingStates - 1 {
if err := p.Add(fmt.Sprint("s", i), now); err != nil {
t.Fatalf("Add %d: %v", i, err)
}
}
// "first" was consumed but has not expired, so it still counts.
if err := p.Add("over", now); !errors.Is(err, ErrTooManyPending) {
t.Errorf("Add past the bound = %v, want ErrTooManyPending", err)
}
// Once the earlier states expire, they are forgotten and a new one fits.
if err := p.Add("later", now.Add(time.Minute)); err != nil {
t.Errorf("Add after expiry: %v", err)
}
if p.Consume("s0") {
t.Error("an expired state was still pending")
}
if !p.Consume("later") {
t.Error("the state added after expiry is not pending")
}
}
func TestLoginRejectsOffOriginReturn(t *testing.T) {
t.Parallel()
h := newHarness(t)
+68
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"time"
)
@@ -153,6 +154,73 @@ func (c FlowCookie) Read(r *http.Request, v flowValue, now time.Time) error {
return nil
}
// maxPendingStates bounds each flow's pending states. The login route is
// unauthenticated, so without a bound anyone could grow the set without
// limit; one household never has close to this many flows in progress.
const maxPendingStates = 10000
// ErrTooManyPending means a flow's pending states are at maxPendingStates,
// so a new flow is refused until older ones expire.
var ErrTooManyPending = errors.New("too many sign-in flows in progress")
// PendingStates holds the state of every redirect flow started and not yet
// completed, until its flow cookie expires. A copy of a flow cookie verifies
// until it expires, so a flow is single-use only because its callback
// consumes the state here. The states live in memory — MealQuest runs as one
// process — so a flow started before a restart fails closed after it.
type PendingStates struct {
ttl time.Duration
mu sync.Mutex
states map[string]time.Time
// order is every added state in the order added. Every state is pending
// for the same ttl, so it is also expiry order and Add forgets expired
// states from its front.
order []pendingState
}
type pendingState struct {
state string
expires time.Time
}
// NewPendingStates returns an empty set whose states are pending for ttl,
// the flow cookie's lifetime.
func NewPendingStates(ttl time.Duration) *PendingStates {
return &PendingStates{ttl: ttl, states: map[string]time.Time{}}
}
// Add records state, started at now, as pending and forgets expired ones.
// It fails with ErrTooManyPending when maxPendingStates are pending.
func (p *PendingStates) Add(state string, now time.Time) error {
p.mu.Lock()
defer p.mu.Unlock()
expired := 0
for expired < len(p.order) && !now.Before(p.order[expired].expires) {
delete(p.states, p.order[expired].state)
expired++
}
p.order = p.order[expired:]
// The bound counts every unexpired state, consumed or not: a consumed
// state leaves states at once but order only when it expires.
if len(p.order) >= maxPendingStates {
return ErrTooManyPending
}
expires := now.Add(p.ttl)
p.states[state] = expires
p.order = append(p.order, pendingState{state: state, expires: expires})
return nil
}
// Consume removes state from the pending states, reporting whether it was
// there.
func (p *PendingStates) Consume(state string) bool {
p.mu.Lock()
defer p.mu.Unlock()
_, ok := p.states[state]
delete(p.states, state)
return ok
}
// returnPath validates the optional post-login return path. Empty means "/".
// Only same-origin absolute paths are allowed: no scheme, host, protocol-
// relative "//" prefix, backslashes or control characters.
-164
View File
@@ -214,26 +214,6 @@ 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 = ?
@@ -364,58 +344,6 @@ 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,
@@ -516,64 +444,6 @@ 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
@@ -690,40 +560,6 @@ 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 = ?,
+8 -7
View File
@@ -255,13 +255,14 @@ type Session struct {
}
type ShoppingGroup struct {
ID string
ListID string
DinnerID string
Date string
Position int64
CreatedAt string
UpdatedAt string
ID string
ListID string
DinnerID string
Date string
Position int64
CreatedAt string
UpdatedAt string
FrozenDinnerName sql.NullString
}
type ShoppingItem struct {
+5 -21
View File
@@ -107,6 +107,9 @@ type Querier interface {
// Records a pending or running batch as done or failed; a batch already
// finished is left as it is (0 rows).
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
// Keeps the name each dinner of list list_id has now as its group's
// frozen_dinner_name, as the list is frozen into the Supply archive.
FreezeShoppingGroupNames(ctx context.Context, listID string) error
// Freezes list id into the Supply archive at frozen_at, unless it is
// frozen already.
FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error)
@@ -117,10 +120,6 @@ 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)
@@ -179,13 +178,6 @@ 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,
@@ -217,12 +209,6 @@ 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)
@@ -245,7 +231,8 @@ type Querier interface {
ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error)
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
// The groups of list list_id with their dinner's name, in list order.
// The groups of list list_id with their dinner's name now, in list order.
// A group of a frozen list shows its frozen_dinner_name instead.
ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error)
// The groups of list list_id, in list order.
ListShoppingGroups(ctx context.Context, listID string) ([]ShoppingGroup, error)
@@ -324,9 +311,6 @@ 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
+2 -2
View File
@@ -220,7 +220,7 @@ func (q *Queries) GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (R
const listRecipeIngredients = `-- name: ListRecipeIngredients :many
SELECT id, recipe_id, position, raw_text, name, qty, unit, note, created_at, updated_at FROM recipe_ingredients
WHERE recipe_id = ?
ORDER BY position, id
ORDER BY position
`
func (q *Queries) ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error) {
@@ -260,7 +260,7 @@ func (q *Queries) ListRecipeIngredients(ctx context.Context, recipeID string) ([
const listRecipeSteps = `-- name: ListRecipeSteps :many
SELECT id, recipe_id, position, text, created_at, updated_at FROM recipe_steps
WHERE recipe_id = ?
ORDER BY position, id
ORDER BY position
`
func (q *Queries) ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error) {
+19 -3
View File
@@ -155,6 +155,19 @@ func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error {
return err
}
const freezeShoppingGroupNames = `-- name: FreezeShoppingGroupNames :exec
UPDATE shopping_groups
SET frozen_dinner_name = (SELECT dinners.name FROM dinners WHERE dinners.id = shopping_groups.dinner_id)
WHERE list_id = ?
`
// Keeps the name each dinner of list list_id has now as its group's
// frozen_dinner_name, as the list is frozen into the Supply archive.
func (q *Queries) FreezeShoppingGroupNames(ctx context.Context, listID string) error {
_, err := q.db.ExecContext(ctx, freezeShoppingGroupNames, listID)
return err
}
const freezeShoppingList = `-- name: FreezeShoppingList :execrows
UPDATE shopping_lists
SET status = 'archived', frozen_at = ?, updated_at = ?
@@ -235,7 +248,7 @@ func (q *Queries) GetShoppingList(ctx context.Context, weekStart string) (Shoppi
}
const listShoppingGroupDinners = `-- name: ListShoppingGroupDinners :many
SELECT shopping_groups.id, shopping_groups.list_id, shopping_groups.dinner_id, shopping_groups.date, shopping_groups.position, shopping_groups.created_at, shopping_groups.updated_at, dinners.name AS dinner_name
SELECT shopping_groups.id, shopping_groups.list_id, shopping_groups.dinner_id, shopping_groups.date, shopping_groups.position, shopping_groups.created_at, shopping_groups.updated_at, shopping_groups.frozen_dinner_name, dinners.name AS dinner_name
FROM shopping_groups
JOIN dinners ON dinners.id = shopping_groups.dinner_id
WHERE shopping_groups.list_id = ?
@@ -247,7 +260,8 @@ type ListShoppingGroupDinnersRow struct {
DinnerName string
}
// The groups of list list_id with their dinner's name, in list order.
// The groups of list list_id with their dinner's name now, in list order.
// A group of a frozen list shows its frozen_dinner_name instead.
func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error) {
rows, err := q.db.QueryContext(ctx, listShoppingGroupDinners, listID)
if err != nil {
@@ -265,6 +279,7 @@ func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) (
&i.ShoppingGroup.Position,
&i.ShoppingGroup.CreatedAt,
&i.ShoppingGroup.UpdatedAt,
&i.ShoppingGroup.FrozenDinnerName,
&i.DinnerName,
); err != nil {
return nil, err
@@ -281,7 +296,7 @@ func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) (
}
const listShoppingGroups = `-- name: ListShoppingGroups :many
SELECT id, list_id, dinner_id, date, position, created_at, updated_at FROM shopping_groups
SELECT id, list_id, dinner_id, date, position, created_at, updated_at, frozen_dinner_name FROM shopping_groups
WHERE list_id = ?
ORDER BY position, id
`
@@ -304,6 +319,7 @@ func (q *Queries) ListShoppingGroups(ctx context.Context, listID string) ([]Shop
&i.Position,
&i.CreatedAt,
&i.UpdatedAt,
&i.FrozenDinnerName,
); err != nil {
return nil, err
}
-199
View File
@@ -1,199 +0,0 @@
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
@@ -1,425 +0,0 @@
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)
}
}
+36 -53
View File
@@ -58,14 +58,14 @@ type Ingredient struct {
RawText string `json:"raw_text"`
}
// Source is the site a recipe was fetched from. SiteName is the name the
// page itself gives its site, and null when it gives none or the stored
// name could have come from a link (see pageSiteName). URL is the page's
// link only when it is verifiably free of secrets (see safeSourceURL), and
// null otherwise: a link pasted or offered as a candidate may carry a
// personal token, so a client links the credit only when URL is not null.
// Source is set for every fetched recipe, so a client always credits it:
// by SiteName, else by URL's host, else without naming the site.
// Source is the site a recipe was fetched from, as SourceOf gives it.
// SiteName is the name the page gives its site, else the host of URL when
// URL is shown, else null. URL is the page's link only when it is
// verifiably free of secrets (see safeSourceURL), and null otherwise: a
// link pasted or offered as a candidate may carry a personal token, so a
// client links the credit only when URL is not null. Source is set for
// every fetched recipe, so a client always credits it: by SiteName, else
// without naming the site.
type Source struct {
SiteName *string `json:"site_name"`
URL *string `json:"url"`
@@ -157,10 +157,7 @@ func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail)
if err != nil {
return fmt.Errorf("ingredients: %w", err)
}
for i, ing := range ings {
if i > 0 && ing.Position == ings[i-1].Position {
return duplicatePosition("ingredient", ing.Position)
}
for _, ing := range ings {
d.Ingredients = append(d.Ingredients, Ingredient{
Name: nullString(ing.Name),
Qty: nullString(ing.Qty),
@@ -173,52 +170,46 @@ func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail)
if err != nil {
return fmt.Errorf("steps: %w", err)
}
for i, step := range steps {
if i > 0 && step.Position == steps[i-1].Position {
return duplicatePosition("step", step.Position)
}
for _, step := range steps {
d.Steps = append(d.Steps, step.Text)
}
return nil
}
// SourceOf is the Source of a recipe fetched from sourceURL whose stored
// site name is siteName: the name only when the page gave it (see
// pageSiteName) and the link only when it is free of secrets (see
// safeSourceURL). It is the one way a fetched recipe's source reaches a
// client. A sourceURL that does not parse is an error, whether or not a
// site name is stored.
// site name is siteName, the name its page gave (recipes.Recipe.SiteName),
// null when the page named none. It is the one rule by which a fetched
// recipe's source reaches a client — the recipe detail, an idea and a
// recipe candidate offered for a dinner alike:
//
// - the link is shown only when it is free of secrets (see
// safeSourceURL);
// - a site name the page gave is shown as it stands, as the page
// publishes it to everyone who reads it;
// - otherwise the site is named by the link's host without a leading
// "www.", and only when the link itself is shown, so the name tells
// nothing the shown link does not; else it is null.
//
// A host is never stored as a site name, so a stored name is always the
// page's: migration 0020 cleared the names stored before that, when a
// page naming no site was stored under its host. A sourceURL that does not
// parse is an error, whether or not a site name is stored.
func SourceOf(siteName sql.NullString, sourceURL string) (Source, error) {
link, err := url.Parse(sourceURL)
if err != nil {
// Not wrapped: url.Parse's error quotes the link.
return Source{}, errors.New("source url does not parse")
}
return Source{SiteName: pageSiteName(siteName, link), URL: safeSourceURL(sourceURL)}, nil
}
// pageSiteName is the stored site name of a recipe fetched from link
// when it is one the page gave, else nil. A page that names no site is
// stored under recipes.HostSiteName of the URL it was served from: the
// pasted link's host, which may hold a personal token (a private
// subdomain), or that of a page the link redirected to, which is not
// stored. Nothing records which a stored name is, so any name that could
// be a host — the link's own host, an IP address, or a dotted host name —
// is treated as one and withheld. A site that names itself as a domain,
// such as "Food.com", loses its name with them: a credit that does not
// name the site is better than one that leaks a link.
func pageSiteName(stored sql.NullString, link *url.URL) *string {
if !stored.Valid {
return nil
src := Source{URL: safeSourceURL(sourceURL)}
switch {
case siteName.Valid && siteName.String != "":
name := siteName.String
src.SiteName = &name
case src.URL != nil:
host := strings.TrimPrefix(link.Hostname(), "www.")
src.SiteName = &host
}
name := stored.String
if strings.EqualFold(name, recipes.HostSiteName(link)) || net.ParseIP(name) != nil {
return nil
}
if u, err := url.Parse("https://" + name + "/"); err == nil && u.Host == name && strings.Contains(name, ".") {
return nil
}
return &name
return src, nil
}
// safeSourceURL is sourceURL when it is verifiably free of secrets — an
@@ -283,11 +274,3 @@ func browserHost(u *url.URL) bool {
}
return true
}
// duplicatePosition is the error for two recipe items of kind at position.
// Position is the only record of recipe order, and the id that breaks the
// tie says nothing about it, so a recipe with a duplicate is refused rather
// than shown in an order nobody can trust.
func duplicatePosition(kind string, position int64) error {
return fmt.Errorf("two %ss at position %d: recipe order is unknown", kind, position)
}
+2 -19
View File
@@ -79,11 +79,6 @@ 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
@@ -143,8 +138,6 @@ 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()
@@ -169,8 +162,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 and
// KindBackfillPhotos jobs on runner, which must not have started yet.
// dinner's method or minutes, and registers the KindGroundDinner job 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:
@@ -191,14 +184,6 @@ 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
}
@@ -717,8 +702,6 @@ 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
+27 -22
View File
@@ -76,35 +76,40 @@ func TestCreateRegularNeverNamesFromLink(t *testing.T) {
check("duplicate error", dup.Error())
}
// TestPageSiteNameWithholdsHosts: a stored site name that could be a host —
// the link's own, in any case and without "www.", an IP address or a dotted
// host name — is withheld; a name the page gave, dotless or with spaces, is
// kept.
func TestPageSiteNameWithholdsHosts(t *testing.T) {
// TestSourceOfSiteName: a site name the page gave is shown as it stands,
// even one shaped like a host and even over a link that is withheld; a
// recipe whose page named none is named by its link's host without "www."
// only when the link itself is shown, and is unnamed otherwise.
func TestSourceOfSiteName(t *testing.T) {
t.Parallel()
const link = "https://www.TokenHost/recipes/stew"
const (
ordinary = "https://www.Kitchen.example/recipes/stew"
token = "https://private-token.example.com/recipes/stew?access_token=abc123"
)
for _, tc := range []struct {
stored string
valid bool
want *string
stored string
valid bool
link string
want, wantURL *string
}{
{"", false, nil},
{"tokenhost", true, nil},
{"TOKENHOST", true, nil},
{"private-token.example.com", true, nil},
{"Food.com", true, nil},
{"203.0.113.7", true, nil},
{"2001:db8::1", true, nil},
{"Delish", true, ptr("Delish")},
{"Bon Appétit", true, ptr("Bon Appétit")},
{"Mr. Food Test Kitchen", true, ptr("Mr. Food Test Kitchen")},
{"Delish", true, ordinary, ptr("Delish"), ptr(ordinary)},
{"Food.com", true, ordinary, ptr("Food.com"), ptr(ordinary)},
{"Bon Appétit", true, token, ptr("Bon Appétit"), nil},
{"", false, ordinary, ptr("Kitchen.example"), ptr(ordinary)},
{"", true, ordinary, ptr("Kitchen.example"), ptr(ordinary)},
{"", false, token, nil, nil},
{"", false, "https://203.0.113.7/stew", ptr("203.0.113.7"), ptr("https://203.0.113.7/stew")},
{"", false, "http://kitchen.example/stew", nil, nil},
} {
src, err := dinners.SourceOf(sql.NullString{String: tc.stored, Valid: tc.valid}, link)
src, err := dinners.SourceOf(sql.NullString{String: tc.stored, Valid: tc.valid}, tc.link)
if err != nil {
t.Fatalf("SourceOf(%q): %v", tc.stored, err)
t.Fatalf("SourceOf(%q, %q): %v", tc.stored, tc.link, err)
}
if got := src.SiteName; (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) {
t.Errorf("SourceOf(%q) site name = %v, want %v", tc.stored, deref(got), deref(tc.want))
t.Errorf("SourceOf(%q, %q) site name = %v, want %v", tc.stored, tc.link, deref(got), deref(tc.want))
}
if got := src.URL; (got == nil) != (tc.wantURL == nil) || (got != nil && *got != *tc.wantURL) {
t.Errorf("SourceOf(%q, %q) url = %v, want %v", tc.stored, tc.link, deref(got), deref(tc.wantURL))
}
}
// A link that does not parse is an error with or without a site name.
-15
View File
@@ -74,18 +74,3 @@ 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
}
+8 -2
View File
@@ -304,7 +304,9 @@ type Candidate struct {
// may carry a token.
CandidateID string `json:"candidate_id"`
Title string `json:"title"`
SiteName string `json:"site_name"`
// SiteName is the page's site as SourceOf names it, null when it
// cannot be named without leaking the URL.
SiteName *string `json:"site_name"`
// PhotoURL is the cached photo of a page already grounded, else null.
PhotoURL *string `json:"photo_url"`
Minutes *int `json:"minutes"`
@@ -367,9 +369,13 @@ func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error
out := make([]Candidate, len(offers))
urls := make(map[string]string, len(offers))
for i, o := range offers {
src, err := SourceOf(sql.NullString{String: o.SiteName, Valid: o.SiteName != ""}, o.URL)
if err != nil {
return nil, fmt.Errorf("%w: dinner %s: candidate %d: %w", ErrCandidatesFailed, id, i+1, err)
}
cid := s.candidateID(row.ID, o.URL)
urls[cid] = o.URL
out[i] = Candidate{CandidateID: cid, Title: o.Title, SiteName: o.SiteName, Minutes: o.Minutes}
out[i] = Candidate{CandidateID: cid, Title: o.Title, SiteName: src.SiteName, Minutes: o.Minutes}
if o.PhotoID != "" {
u := PhotoURL(o.PhotoID)
out[i].PhotoURL = &u
+3 -4
View File
@@ -39,7 +39,6 @@ type env struct {
runner *jobs.Runner
search *recipestest.Search
pages *recipestest.Pages
images *recipestest.Images
model *llm.Fake
}
@@ -109,7 +108,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, images: images, model: model}
return &env{svc: svc, st: st, runner: runner, search: search, pages: pages, model: model}
}
// start starts the env's job runner, stopping it when the test ends.
@@ -719,11 +718,11 @@ func TestCandidates(t *testing.T) {
}
first, second := got[0], got[1]
wantPhoto := dinners.PhotoURL(e.dinner(t, c.ID).PhotoID.String)
if first.Title != "Chicken Curry" || first.SiteName != "Curry House" ||
if first.Title != "Chicken Curry" || deref(first.SiteName) != "Curry House" ||
first.PhotoURL == nil || *first.PhotoURL != wantPhoto || first.Minutes == nil || *first.Minutes != 40 {
t.Errorf("first = %+v, want the stored curryA with photo %s", first, wantPhoto)
}
if second.Title != "Easy Weeknight Curry" || second.SiteName != "B Kitchen" ||
if second.Title != "Easy Weeknight Curry" || deref(second.SiteName) != "B Kitchen" ||
second.PhotoURL != nil || second.Minutes == nil || *second.Minutes != 25 {
t.Errorf("second = %+v, want curryB without a photo", second)
}
+8 -30
View File
@@ -137,10 +137,9 @@ type Service struct {
account sync.Mutex
// pending holds the state of every connect started and not yet
// completed, with when it expires. Complete consumes a state once, so a
// replayed cookie finds it gone.
pendingMu sync.Mutex
pending map[string]time.Time
// completed. Complete consumes a state once, so a replayed cookie finds
// it gone.
pending *auth.PendingStates
}
// NewService returns the service over st, sealing refresh tokens with box.
@@ -175,7 +174,7 @@ func NewService(st *store.Store, box *secretbox.Box, cfg Config) (*Service, erro
cookie: auth.NewFlowCookie(connectCookieName, connectCookieKeyLabel, connectCookieTTL, cfg.SecretKey),
retryDelay: firstRetryDelay,
now: time.Now,
pending: map[string]time.Time{},
pending: auth.NewPendingStates(connectCookieTTL),
}, nil
}
@@ -210,10 +209,12 @@ func (s *Service) Connect(w http.ResponseWriter, session auth.Session) (string,
Session: sessionBinding(session),
}
now := s.now()
if err := s.pending.Add(state.State, now); err != nil {
return "", fmt.Errorf("gcal: %w", err)
}
if err := s.cookie.Set(w, &state, now); err != nil {
return "", fmt.Errorf("gcal: set connect cookie: %w", err)
}
s.addPending(state.State, now)
return s.oauth.AuthCodeURL(state.State,
oauth2.AccessTypeOffline,
oauth2.ApprovalForce,
@@ -243,7 +244,7 @@ func (s *Service) Complete(w http.ResponseWriter, r *http.Request, session auth.
}
// A copy of the cookie verifies until it expires, so the state is
// single-use only because it is consumed here.
if !s.consumePending(state.State) {
if !s.pending.Consume(state.State) {
return fmt.Errorf("%w: already used", ErrBadState)
}
if q.Get("error") != "" {
@@ -276,29 +277,6 @@ func (s *Service) Complete(w http.ResponseWriter, r *http.Request, session auth.
return s.store(ctx, sealed)
}
// addPending records state, started at now, as pending until the connect
// cookie expires, and forgets expired ones.
func (s *Service) addPending(state string, now time.Time) {
s.pendingMu.Lock()
defer s.pendingMu.Unlock()
for k, expires := range s.pending {
if !now.Before(expires) {
delete(s.pending, k)
}
}
s.pending[state] = now.Add(connectCookieTTL)
}
// consumePending removes state from the pending states, reporting whether
// it was there.
func (s *Service) consumePending(state string) bool {
s.pendingMu.Lock()
defer s.pendingMu.Unlock()
_, ok := s.pending[state]
delete(s.pending, state)
return ok
}
// checkScopes fails with ErrScopes unless token's granted scopes include
// every scope in Scopes. Google lets the household untick a permission on
// the consent screen and reports what was granted in "scope".
+34 -6
View File
@@ -94,7 +94,7 @@ func newEnv(t *testing.T) *env {
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no model in this test")
}
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, time.Now, logger)
if err != nil {
t.Fatal(err)
}
@@ -222,16 +222,44 @@ func TestTonightSwitchesAtLocalMidnight(t *testing.T) {
func TestTonightDinner(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.planWeek(t)
ctx := t.Context()
planID := e.planWeek(t)
planned := func(date string) string {
t.Helper()
var id string
if err := e.st.Reader.QueryRowContext(ctx, `SELECT dinner_id FROM plan_days WHERE plan_id = ? AND date = ?`, planID, date).Scan(&id); err != nil {
t.Fatal(err)
}
return id
}
h := e.at(t, "2026-10-13 18:00")
if h.Tonight == nil || h.Tonight.ID == "" || h.Tonight.Origin != "regular" || h.Tonight.Photo != nil ||
if h.Tonight == nil || h.Tonight.ID != planned("2026-10-13") || h.Tonight.Origin != "regular" || h.Tonight.Photo != nil ||
h.Tonight.MinutesTotal == nil || *h.Tonight.MinutesTotal != 30 {
t.Errorf("tonight = %+v, want Tuesday's 30-minute Regular with no photo", h.Tonight)
t.Errorf("tonight = %+v, want Tuesday's 30-minute Regular %s with no photo", h.Tonight, planned("2026-10-13"))
}
h = e.at(t, "2026-10-14 18:00")
if h.Tonight == nil || h.Tonight.Origin != "side_quest" || h.Tonight.MinutesTotal != nil {
t.Errorf("tonight = %+v, want Wednesday's Side quest of unknown minutes", h.Tonight)
if h.Tonight == nil || h.Tonight.ID != planned("2026-10-14") || h.Tonight.Origin != "side_quest" || h.Tonight.MinutesTotal != nil {
t.Errorf("tonight = %+v, want Wednesday's Side quest %s of unknown minutes", h.Tonight, planned("2026-10-14"))
}
// Thursday's dinner has a photo: Home serves its list-sized copy.
now := store.Now().String()
photo, err := db.New(e.st.Writer).CreatePhoto(ctx, db.CreatePhotoParams{
ID: store.NewID(), SourceUrl: "https://example.com/thursday.jpg", LocalPath: "photos/thursday.jpg",
Width: 800, Height: 600, Sha256: "thursday", FetchedAt: now, CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
thursday := planned("2026-10-15")
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE dinners SET photo_id = ? WHERE id = ?`, photo.ID, thursday); err != nil {
t.Fatal(err)
}
h = e.at(t, "2026-10-15 18:00")
wantPhoto := "/media/photos/" + photo.ID + "-400.jpg"
if h.Tonight == nil || h.Tonight.ID != thursday || h.Tonight.Photo == nil || *h.Tonight.Photo != wantPhoto {
t.Errorf("tonight = %+v, want Thursday's dinner %s with photo %s", h.Tonight, thursday, wantPhoto)
}
}
+2 -1
View File
@@ -486,7 +486,8 @@ func sourceOf(gr recipes.Grounded) (dinners.Source, error) {
// link sourceURL and site name siteName, as a client may see it (see
// dinners.SourceOf). A result stored now already holds it, and one stored
// before sources were made safe holds the grounded recipe's own, with ""
// for no site name, as sourceOf reads it; a null link, which only a result
// for no site name, as sourceOf reads it, less any name that could be a
// host, which migration 0020 cleared; a null link, which only a result
// stored now holds, is checked as "".
func shownSource(sourceURL, siteName *string) (dinners.Source, error) {
var link string
+1 -1
View File
@@ -96,7 +96,7 @@ func (s *Service) revision(ctx context.Context, weekStart store.LocalDate) (int6
// the household context, the pool, last week's dinners and the dinner
// time.
func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skills.WeekRequest, error) {
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
hctx, err := harness.BuildContext(ctx, s.st, s.now(), ContextBudget)
if err != nil {
return skills.WeekRequest{}, fmt.Errorf("plan: %w", err)
}
+10 -5
View File
@@ -149,16 +149,19 @@ type Service struct {
// publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links
// to its dinner.
publicURL *url.URL
// now is the clock today is read from: the scheduler's.
now func() time.Time
}
// NewService returns a service over st that drafts with draft, pushes to
// cal with links under publicURL, tells the household through notifier,
// which must be over st, calls wake (the scheduler's Wake) after every
// committed change to a plan's days, and registers the KindDraftWeek job on
// runner, which must not have started yet. The kind is service-only:
// committed change to a plan's days, reads today from now (the scheduler's
// Now) and registers the KindDraftWeek job on runner, which must not have
// started yet. The kind is service-only:
// StartDraft and StartScheduledDraft, which keep one draft in flight per
// week, are the only way to enqueue it.
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, wake func(), publicURL *url.URL, logger *slog.Logger) (*Service, error) {
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, wake func(), publicURL *url.URL, now func() time.Time, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("plan: nil store")
@@ -174,10 +177,12 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calenda
return nil, errors.New("plan: nil wake")
case publicURL == nil:
return nil, errors.New("plan: nil public URL")
case now == nil:
return nil, errors.New("plan: nil clock")
case logger == nil:
return nil, errors.New("plan: nil logger")
}
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, wake: wake, logger: logger, publicURL: publicURL}
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, wake: wake, logger: logger, publicURL: publicURL, now: now}
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
if err != nil {
return nil, err
@@ -501,7 +506,7 @@ func (s *Service) Tonight(ctx context.Context) (week, today store.LocalDate, err
if err != nil {
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: household settings: %w", err)
}
today = household.Today(time.Now(), zone)
today = household.Today(s.now(), zone)
week = household.WeekStart(today)
_, err = q.GetWeekPlan(ctx, week.String())
if errors.Is(err, sql.ErrNoRows) {
+1 -1
View File
@@ -146,7 +146,7 @@ func newEnv(t *testing.T) *env {
if err != nil {
t.Fatal(err)
}
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), func() {}, publicURL, logger)
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), func() {}, publicURL, time.Now, logger)
if err != nil {
t.Fatal(err)
}
+7 -14
View File
@@ -28,8 +28,10 @@ var (
type Recipe struct {
// Title is the recipe's name.
Title string
// SiteName is the publisher's name, else the page's og:site_name, else
// HostSiteName of the page's URL.
// SiteName is the name the page gives its site: the publisher's name,
// else the page's og:site_name; empty when it names none. It is never
// taken from the page's URL, whose host may carry a personal token, so
// a stored site name is always one a page gave (see dinners.SourceOf).
SiteName string
// ImageURL is the largest image, absolute; empty when the recipe has
// none.
@@ -173,7 +175,7 @@ func recipeFrom(n map[string]any, g graph, base *url.URL, ogSiteName string) (Re
if r.Title = cleanText(name); !ok || r.Title == "" {
return Recipe{}, errors.New("name is missing")
}
if r.SiteName, err = siteName(n["publisher"], g, ogSiteName, base); err != nil {
if r.SiteName, err = siteName(n["publisher"], g, ogSiteName); err != nil {
return Recipe{}, err
}
if r.ImageURL, err = imageURL(n["image"], g, base); err != nil {
@@ -213,7 +215,7 @@ func cleanText(s string) string {
return strings.TrimSpace(html.UnescapeString(s))
}
func siteName(publisher any, g graph, ogSiteName string, base *url.URL) (string, error) {
func siteName(publisher any, g graph, ogSiteName string) (string, error) {
pubs, ok := publisher.([]any)
if !ok {
pubs = []any{publisher}
@@ -229,16 +231,7 @@ func siteName(publisher any, g graph, ogSiteName string, base *url.URL) (string,
}
}
}
if ogSiteName != "" {
return ogSiteName, nil
}
return HostSiteName(base), nil
}
// HostSiteName is the site name of a page that names none: its host
// without a leading "www.".
func HostSiteName(u *url.URL) string {
return strings.TrimPrefix(u.Hostname(), "www.")
return ogSiteName, nil
}
// imageURL picks the largest of the recipe's images by width × height;
+2 -2
View File
@@ -251,10 +251,10 @@ func TestExtractShapes(t *testing.T) {
},
},
{
name: "site name from host without publisher or og:site_name",
name: "no site name without publisher or og:site_name, never the host",
html: page("", `{"@type":"Recipe","name":"Stew"}`),
check: func(t *testing.T, r Recipe) {
if r.SiteName != "example.com" {
if r.SiteName != "" {
t.Errorf("SiteName = %q", r.SiteName)
}
},
+7 -3
View File
@@ -65,7 +65,9 @@ type Grounded struct {
// Difficulty is easy, medium or hard.
Difficulty string `json:"difficulty"`
SourceURL string `json:"source_url"`
SiteName string `json:"site_name"`
// SiteName is the name the page gives its site, "" when it names none
// (see Recipe.SiteName).
SiteName string `json:"site_name"`
}
// SearchSource returns the household's current search provider;
@@ -200,8 +202,10 @@ func (g *Grounder) searchRanked(ctx context.Context, dishName, query string) (ra
// Offer is a ranked candidate offered for the household to ground a dish
// in, with GroundURL on its URL.
type Offer struct {
URL string
Title string
URL string
Title string
// SiteName is the name the page gives its site, "" when it names none
// (see Recipe.SiteName).
SiteName string
// PhotoID is the cached photo of a candidate already stored, "" for
// one that is not: a candidate's photo is cached only when it grounds.
+73 -13
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"log/slog"
"net/url"
"slices"
"strings"
"sync"
"testing"
@@ -85,7 +86,21 @@ func (c noCalendar) PushEvents(context.Context, []gcal.Event, func() error) erro
// pass the fake clock's waits do not expect.
func (e *env) plans(ids map[string]string) *planEnv {
e.t.Helper()
return e.newPlans(ids, func() {})
return e.newPlans(ids, func() {}, time.Now)
}
// scheduledPlans is plans whose today is read from the clock of the
// scheduler start last started, as in the server, so a draft's plan_ready
// title follows the fake clock. It drafts only once a scheduler started.
func (e *env) scheduledPlans(ids map[string]string) *planEnv {
e.t.Helper()
return e.newPlans(ids, func() {}, func() time.Time {
s := e.sched.Load()
if s == nil {
panic("plan service read the clock before a scheduler started")
}
return s.Now()
})
}
// wakingPlans is plans whose changes wake the scheduler, as in the server.
@@ -93,11 +108,12 @@ func (e *env) plans(ids map[string]string) *planEnv {
// the wake's pass with changed.
func (e *env) wakingPlans(ids map[string]string) *planEnv {
e.t.Helper()
return e.newPlans(ids, e.wake)
return e.newPlans(ids, e.wake, time.Now)
}
// newPlans is plans whose service calls wake after a plan change.
func (e *env) newPlans(ids map[string]string, wake func()) *planEnv {
// newPlans is plans whose service calls wake after a plan change and reads
// today from now.
func (e *env) newPlans(ids map[string]string, wake func(), now func() time.Time) *planEnv {
e.t.Helper()
p := &planEnv{e: e, model: &llm.Fake{}, ids: ids, logs: &syncBuffer{}}
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
@@ -118,7 +134,7 @@ func (e *env) newPlans(ids map[string]string, wake func()) *planEnv {
if err != nil {
e.t.Fatal(err)
}
p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), wake, publicURL, logger)
p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), wake, publicURL, now, logger)
if err != nil {
e.t.Fatal(err)
}
@@ -235,6 +251,41 @@ func (e *env) planReady() []string {
return keys
}
// readyRow is a stored plan_ready notification as these tests check it.
type readyRow struct {
Key string
Channel notify.Channel
Title string
}
// planReadyRows returns every plan_ready notification stored, oldest
// first, each week's Google Chat row before its push row.
func (e *env) planReadyRows() []readyRow {
e.t.Helper()
var out []readyRow
for _, r := range notifytest.Rows(e.t, e.st, notify.PlanReady) {
out = append(out, readyRow{Key: r.Key, Channel: r.Channel, Title: r.Payload.Title})
}
return out
}
// nextWeekReady is the plan_ready rows of a draft of the week starting
// week, drafted the week before: one to Google Chat and one to push.
func nextWeekReady(week string) []readyRow {
const title = "Next week's plan is ready"
key := string(notify.PlanReady) + ":" + week
return []readyRow{{key, notify.ChannelChat, title}, {key, notify.ChannelPush, title}}
}
// wantReady fails unless the plan_ready rows are got.
func wantReady(t *testing.T, what string, got []readyRow, want ...[]readyRow) {
t.Helper()
all := slices.Concat(want...)
if !slices.Equal(got, all) {
t.Fatalf("%s: plan_ready rows = %+v, want %+v", what, got, all)
}
}
var (
succeeded = draftJob{Status: string(jobs.StatusSucceeded)}
aiFailed = draftJob{Status: string(jobs.StatusFailed), Error: plan.ReasonAIFailed}
@@ -257,7 +308,7 @@ func mustLocalDate(s string) store.LocalDate {
// by its Monday once.
func TestPlanDraftDraftsNextWeek(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p := e.scheduledPlans(e.pool())
p.reply(w43)
p.reply(w44)
@@ -271,6 +322,7 @@ func TestPlanDraftDraftsNextWeek(t *testing.T) {
wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded)
wantKeys(t, "plans at Friday 09:00", e.weekPlans(), []string{"2026-10-19"})
wantKeys(t, "plan_ready of W43", e.planReady(), []string{"plan_ready:2026-10-19"})
wantReady(t, "plan_ready rows of W43", e.planReadyRows(), nextWeekReady("2026-10-19"))
got, err := p.svc.Get(context.Background(), w43)
if err != nil {
t.Fatal(err)
@@ -283,6 +335,7 @@ func TestPlanDraftDraftsNextWeek(t *testing.T) {
wantDrafts(t, "W44", e.drafts(), succeeded, succeeded)
wantKeys(t, "plans", e.weekPlans(), []string{"2026-10-19", "2026-10-26"})
wantKeys(t, "plan_ready of W43 and W44", e.planReady(), []string{"plan_ready:2026-10-19", "plan_ready:2026-10-26"})
wantReady(t, "plan_ready rows of W43 and W44", e.planReadyRows(), nextWeekReady("2026-10-19"), nextWeekReady("2026-10-26"))
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43", "2026-W44"})
}
@@ -291,23 +344,25 @@ func TestPlanDraftDraftsNextWeek(t *testing.T) {
// notifies no one and says so.
func TestPlanDraftLeavesAnExistingPlan(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p := e.scheduledPlans(e.pool())
p.reply(w43)
p.reply(w43) // a second draft would succeed too
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
if _, err := p.svc.StartDraft(context.Background(), w43, false); err != nil {
t.Fatal(err)
}
wantDrafts(t, "manual draft", e.drafts(), succeeded)
wantKeys(t, "plan_ready of the manual draft", e.planReady(), []string{"plan_ready:2026-10-19"})
wantReady(t, "plan_ready rows of the manual draft", e.planReadyRows(), nextWeekReady("2026-10-19"))
before, err := p.svc.Get(context.Background(), w43)
if err != nil {
t.Fatal(err)
}
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
r.runUntil(at(2026, 10, 16, 9, 0))
wantDrafts(t, "with a plan", e.drafts(), succeeded)
wantKeys(t, "plan_ready with a plan", e.planReady(), []string{"plan_ready:2026-10-19"})
wantReady(t, "plan_ready rows with a plan", e.planReadyRows(), nextWeekReady("2026-10-19"))
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
after, err := p.svc.Get(context.Background(), w43)
if err != nil {
@@ -327,23 +382,25 @@ func TestPlanDraftLeavesAnExistingPlan(t *testing.T) {
func TestPlanDraftNoDuplicateAfterRestart(t *testing.T) {
e := newEnv(t)
ids := e.pool()
p := e.plans(ids)
p := e.scheduledPlans(ids)
p.reply(w43)
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
r.runUntil(at(2026, 10, 16, 9, 0))
wantDrafts(t, "first process", e.drafts(), succeeded)
wantReady(t, "plan_ready rows of the first process", e.planReadyRows(), nextWeekReady("2026-10-19"))
r.stop()
p.stop()
// The restarted process has a new job runner, plan service and
// scheduler; it would answer a second draft if one were started.
p2 := e.plans(ids)
p2 := e.scheduledPlans(ids)
p2.reply(w43)
r2 := e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
r2.runUntil(at(2026, 10, 23, 8, 59))
wantDrafts(t, "after restart", e.drafts(), succeeded)
wantKeys(t, "plans after restart", e.weekPlans(), []string{"2026-10-19"})
wantKeys(t, "plan_ready after restart", e.planReady(), []string{"plan_ready:2026-10-19"})
wantReady(t, "plan_ready rows after restart", e.planReadyRows(), nextWeekReady("2026-10-19"))
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
}
@@ -354,7 +411,7 @@ func TestPlanDraftNoDuplicateAfterRestart(t *testing.T) {
func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
e := newEnv(t)
ids := e.pool()
p := e.plans(ids)
p := e.scheduledPlans(ids)
p.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
@@ -362,6 +419,7 @@ func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
wantDrafts(t, "after the AI failed", e.drafts(), aiFailed)
wantKeys(t, "plans after the AI failed", e.weekPlans(), nil)
wantKeys(t, "plan_ready after the AI failed", e.planReady(), nil)
wantReady(t, "plan_ready rows after the AI failed", e.planReadyRows())
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
var status, msg string
@@ -377,11 +435,12 @@ func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
r.stop()
p.stop()
p2 := e.plans(ids)
p2 := e.scheduledPlans(ids)
p2.reply(w43)
e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
wantDrafts(t, "after restart", e.drafts(), aiFailed)
wantKeys(t, "plan_ready after restart", e.planReady(), nil)
wantReady(t, "plan_ready rows after restart", e.planReadyRows())
}
// TestPlanDraftFollowsSettingsWithoutRestart: a plan_draft_dow and
@@ -390,7 +449,7 @@ func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
// week.
func TestPlanDraftFollowsSettingsWithoutRestart(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p := e.scheduledPlans(e.pool())
p.reply(w43)
r := e.start(at(2026, 10, 12, 12, 0), p.svc.DraftSchedule()) // Monday
@@ -401,6 +460,7 @@ func TestPlanDraftFollowsSettingsWithoutRestart(t *testing.T) {
r.runUntil(at(2026, 10, 14, 18, 30))
wantDrafts(t, "at Wednesday 18:30", e.drafts(), succeeded)
wantKeys(t, "plan_ready", e.planReady(), []string{"plan_ready:2026-10-19"})
wantReady(t, "plan_ready rows", e.planReadyRows(), nextWeekReady("2026-10-19"))
// Friday 09:00, the old fire time, drafts nothing more.
r.runUntil(at(2026, 10, 16, 9, 0))
+5
View File
@@ -378,6 +378,11 @@ func (s *Scheduler) Wake() {
s.signal()
}
// Now is the time on the scheduler's clock, by which its schedules fire.
func (s *Scheduler) Now() time.Time {
return s.now()
}
// signal leaves one token in wake for the loop.
func (s *Scheduler) signal() {
select {
+75 -50
View File
@@ -3,6 +3,7 @@ package server
import (
"database/sql"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"os"
@@ -14,6 +15,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// getDetail answers GET /dinners/{id} for id, failing unless it is 200, and
@@ -70,8 +72,8 @@ func TestGetDinnerGroundedSideQuest(t *testing.T) {
}
for _, stmt := range []string{
`UPDATE dinners SET status = 'quest_log', origin = 'side_quest', is_favorite = 1 WHERE id = ?`,
`UPDATE recipe_ingredients SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
`UPDATE recipe_steps SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
`UPDATE recipe_ingredients SET position = 9 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
`UPDATE recipe_steps SET position = 9 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
} {
if _, err := e.st.Writer.Exec(stmt, id); err != nil {
t.Fatal(err)
@@ -328,14 +330,33 @@ func TestGetDinnerSourceURLOnlyWhenSafe(t *testing.T) {
}
}
// TestGetDinnerSiteNameNeverFromLink: a page that names no site is stored
// under its host, so a recipe grounded in a pasted link whose host carries a
// token has site_name null and nothing from the link in its detail; a page
// that names its site keeps that name, even on a token-bearing host; and a
// stored name shaped like another host, as a redirect leaves, is withheld.
// The links carry a query, so source.url is withheld too and nothing of the
// link may appear.
func TestGetDinnerSiteNameNeverFromLink(t *testing.T) {
// siteNameMigration is migration 0020, which clears the host-shaped site
// names stored before extraction stopped storing hosts.
const siteNameMigration = "0020_site_name_from_page.sql"
// applySiteNameMigration runs siteNameMigration again over rows a test
// stored as the code before it did, as upgrading a database holding them
// does.
func applySiteNameMigration(t *testing.T, st *store.Store) {
t.Helper()
body, err := fs.ReadFile(migrations.FS, siteNameMigration)
if err != nil {
t.Fatal(err)
}
if _, err := st.Writer.ExecContext(t.Context(), string(body)); err != nil {
t.Fatalf("%s: %v", siteNameMigration, err)
}
}
// TestGetDinnerSiteNameProvenance: extraction stores only a site name the
// page gave, never the URL's host, and the detail applies one rule to what
// is stored. A name from page metadata is shown even over a token-bearing
// link; a page naming no site stores no name, and is named by its link's
// host only when the link is shown — so over a link with a token nothing
// of the link reaches the client. A name stored before extraction stopped
// storing hosts is read by the same rule once migration 0020 has cleared
// it if it could be a host.
func TestGetDinnerSiteNameProvenance(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
const (
@@ -343,11 +364,14 @@ func TestGetDinnerSiteNameNeverFromLink(t *testing.T) {
namedToken = "named-token-def456"
bare = "https://" + bareToken + ".example.com/recipe?print=1"
named = "https://www." + namedToken + ".example.com/recipe?print=1"
ordinary = "https://www.kitchen.example/recipes/curry"
)
e.pages.Bodies[bare] = recipestest.RecipePage("Chicken Curry", "", "https://a.example/curry.jpg", 40)
e.pages.Bodies[named] = recipestest.RecipePage("Weeknight Curry", "Curry House", "https://b.example/curry.jpg", 25)
e.pages.Bodies[ordinary] = recipestest.RecipePage("Green Curry", "", "https://c.example/curry.jpg", 30)
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
e.images.JPEGs["https://c.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 3)
ground := func(dish, page string) (id, recipeID string) {
t.Helper()
id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending
@@ -360,15 +384,18 @@ func TestGetDinnerSiteNameNeverFromLink(t *testing.T) {
}
return id, recipeID
}
storedSite := func(recipeID string) string {
storedSite := func(recipeID string) any {
t.Helper()
var site string
var site sql.NullString
if err := e.st.Reader.QueryRow(`SELECT site_name FROM recipes WHERE id = ?`, recipeID).Scan(&site); err != nil {
t.Fatal(err)
}
return site
if !site.Valid {
return nil
}
return site.String
}
check := func(what, id string, leaks []string, want any) {
check := func(what, id string, leaks []string, want map[string]any) {
t.Helper()
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusOK {
@@ -380,28 +407,48 @@ func TestGetDinnerSiteNameNeverFromLink(t *testing.T) {
}
}
raw, _ := getDetail(t, e.h, id)
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": want, "url": nil}) {
t.Errorf("source of %s = %v, want site_name %v and url null", what, raw["source"], want)
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, want) {
t.Errorf("source of %s = %v, want %v", what, raw["source"], want)
}
}
bareID, bareRecipe := ground("Chicken curry", bare)
if got := storedSite(bareRecipe); got != bareToken+".example.com" {
t.Fatalf("stored site_name = %q, want the link's host", got)
}
check("a page naming no site", bareID, []string{bare, bareToken, "example.com"}, nil)
namedID, namedRecipe := ground("Weeknight curry", named)
if got := storedSite(namedRecipe); got != "Curry House" {
t.Fatalf("stored site_name = %q, want the page's", got)
t.Fatalf("stored site_name = %v, want the page's", got)
}
check("a page naming its site", namedID, []string{named, namedToken, "example.com"}, "Curry House")
check("a metadata-derived name over a token-bearing link", namedID, []string{named, namedToken, "example.com"},
map[string]any{"site_name": "Curry House", "url": nil})
const redirectHost = "redirect-token-ghi789.example.net"
if _, err := e.st.Writer.Exec(`UPDATE recipes SET site_name = ? WHERE id = ?`, redirectHost, namedRecipe); err != nil {
t.Fatal(err)
bareID, bareRecipe := ground("Chicken curry", bare)
if got := storedSite(bareRecipe); got != nil {
t.Fatalf("stored site_name = %v, want null: the page names no site", got)
}
check("a page named by a redirect's host", namedID, []string{redirectHost, "redirect-token", namedToken}, nil)
check("a hostname-derived name over a token-bearing link", bareID, []string{bare, bareToken, "example.com"},
map[string]any{"site_name": nil, "url": nil})
ordinaryID, ordinaryRecipe := ground("Green curry", ordinary)
if got := storedSite(ordinaryRecipe); got != nil {
t.Fatalf("stored site_name = %v, want null: the page names no site", got)
}
check("a hostname-derived name over an ordinary link", ordinaryID, nil,
map[string]any{"site_name": "kitchen.example", "url": ordinary})
// Rows as extraction stored them before it stopped storing hosts: the
// token-bearing link's own host, and a redirect's host under a page
// that named its site, are cleared by 0020; a page's own name stays.
const redirectHost = "redirect-token-ghi789.example.net"
for recipeID, site := range map[string]string{bareRecipe: bareToken + ".example.com", namedRecipe: redirectHost, ordinaryRecipe: "Green Kitchen"} {
if _, err := e.st.Writer.Exec(`UPDATE recipes SET site_name = ? WHERE id = ?`, site, recipeID); err != nil {
t.Fatal(err)
}
}
applySiteNameMigration(t, e.st)
check("a row stored before, named by its token-bearing link's host", bareID, []string{bare, bareToken, "example.com"},
map[string]any{"site_name": nil, "url": nil})
check("a row stored before, named by a redirect's host", namedID, []string{redirectHost, "redirect-token", namedToken},
map[string]any{"site_name": nil, "url": nil})
check("a row stored before, named by its page", ordinaryID, nil,
map[string]any{"site_name": "Green Kitchen", "url": ordinary})
}
// TestGetDinnerWhyPickedIsCreatingSuggestion: why_picked is the reason of
@@ -445,25 +492,3 @@ func TestGetDinnerWhyPickedIsCreatingSuggestion(t *testing.T) {
t.Errorf("why_picked = %v, want %q", got.WhyPicked, reason)
}
}
// TestGetDinnerDuplicatePositionFails: a recipe with two ingredients or two
// steps at one position has no order anyone can trust, so its detail is a
// 500 rather than the recipe in some order.
func TestGetDinnerDuplicatePositionFails(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
for _, table := range []string{"recipe_ingredients", "recipe_steps"} {
id := createDinner(t, e.h, `{"name":"Family chili `+table+`"}`)
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body)
}
if _, err := e.st.Writer.Exec(`UPDATE `+table+` SET position = 0 WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id); err != nil {
t.Fatal(err)
}
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "Simmer.") || strings.Contains(rec.Body.String(), "1 lb beef") {
t.Errorf("GET with duplicate %s positions = %d: %s, want 500 and no recipe", table, rec.Code, rec.Body)
}
}
}
+7 -20
View File
@@ -72,11 +72,11 @@ func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
// getDinner answers GET /dinners/{id} with the dinner, its recipe and the
// reason it was suggested. An archived dinner is 404, like an unknown one,
// not a 200 with its status: it has no detail to show. source never has the
// recipe page's link, and source.site_name is null unless the page itself
// named its site — a name that could be a host, the link's or a redirect's,
// is withheld (dinners.Source) — so the detail screen shows "Recipe from
// {site}" only when site_name is not null.
// not a 200 with its status: it has no detail to show. source is as
// dinners.SourceOf gives it: the recipe page's link only when it is free of
// secrets, and site_name the name the page gave its site, else the shown
// link's host, else null — so the detail screen shows "Recipe from {site}"
// only when site_name is not null.
func getDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
d, err := svc.Get(r.Context(), chi.URLParam(r, "id"))
@@ -112,20 +112,6 @@ 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) {
@@ -148,7 +134,8 @@ func patchDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
}
// getDinnerCandidates answers GET /dinners/{id}/recipe-candidates with up
// to dinners.MaxCandidates recipe pages for the dinner's name.
// to dinners.MaxCandidates recipe pages for the dinner's name, each
// site_name as dinners.SourceOf gives it.
func getDinnerCandidates(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
list, err := svc.Candidates(r.Context(), chi.URLParam(r, "id"))
+45 -40
View File
@@ -184,6 +184,51 @@ func TestGetRecipeCandidates(t *testing.T) {
}
}
// TestGetRecipeCandidatesSiteNames: a candidate's site_name follows the
// recipe detail's rule (dinners.SourceOf), not a hostname fallback of its
// own: a name the page gave is offered as it stands, even over a link with
// a token; a page naming no site is named by its link's host only when
// the link is free of secrets, and is null over a token-bearing link, so
// nothing of that link reaches the client.
func TestGetRecipeCandidatesSiteNames(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
const (
token = "offer-token-abc123"
named = "https://named.example/curry?access_token=" + token
bare = "https://" + token + ".example.com/curry?print=1"
ordinary = "https://www.kitchen.example/recipes/curry"
)
e.search.Results["Chicken curry"] = []recipes.Candidate{{URL: named}, {URL: bare}, {URL: ordinary}}
e.pages.Bodies[named] = recipestest.RecipePage("Named Curry", "Curry House", "https://a.example/curry.jpg", 40)
e.pages.Bodies[bare] = recipestest.RecipePage("Bare Curry", "", "https://b.example/curry.jpg", 30)
e.pages.Bodies[ordinary] = recipestest.RecipePage("Ordinary Curry", "", "https://c.example/curry.jpg", 25)
id := createDinner(t, e.h, `{"name":"Chicken curry"}`) // runner not started: stays pending
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
}
for _, leak := range []string{token, "access_token", "example.com"} {
if strings.Contains(rec.Body.String(), leak) {
t.Errorf("candidates contain %q: %s", leak, rec.Body)
}
}
want := map[string]any{"Named Curry": "Curry House", "Bare Curry": nil, "Ordinary Curry": "kitchen.example"}
list, _ := body["candidates"].([]any)
if len(list) != len(want) {
t.Fatalf("candidates = %v, want %d", body, len(want))
}
for _, c := range list {
c := c.(map[string]any)
title, _ := c["title"].(string)
site, ok := c["site_name"]
if w, known := want[title]; !known || !ok || site != w {
t.Errorf("candidate %q site_name = %#v (present %v), want %#v", title, site, ok, w)
}
}
}
func TestGetRecipeCandidatesSearchFails(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
@@ -556,43 +601,3 @@ 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 -1
View File
@@ -73,7 +73,7 @@ func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *reci
t.Error("draft_week ran: an idea action must never draft a plan")
return skills.Week{}, context.Canceled
}
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, time.Now, logger)
if err != nil {
t.Fatal(err)
}
+9 -5
View File
@@ -16,7 +16,7 @@ import (
)
// sourceCase is a recipe page an idea grounds in: its link and the site
// name its page gives (empty for none, so the host is stored instead).
// name its page gives (empty for none, so no name is stored).
type sourceCase struct {
dish, link, site string
// want is the idea's source_url and site_name; nil is null.
@@ -33,7 +33,7 @@ const (
// sourceCases are a clean link, credited by its page's name, and links
// carrying a token in the query and the userinfo whose pages name no site,
// so the stored name is the link's host.
// so no name is stored and, the links being unsafe, none is shown.
var sourceCases = []sourceCase{
{"Chicken Curry", cleanSourceLink, "Curry House", cleanSourceLink, "Curry House"},
{"Beef Stew", "https://" + queryHost + "/stew?access_token=" + queryToken, "", nil, nil},
@@ -137,8 +137,9 @@ func legacyIdea(name, link, site string) map[string]any {
}
// A job of either idea kind whose result was stored before sources were
// made safe is served with the sources a job stored now holds: neither a
// token-bearing link nor a name taken from its host.
// made safe, and upgraded by migration 0020, is served with the sources a
// job stored now holds: neither a token-bearing link nor a name taken from
// its host.
func TestJobServesLegacyIdeaResultsWithOnlySafeSources(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := newQuickFakes()
@@ -162,7 +163,10 @@ func TestJobServesLegacyIdeaResultsWithOnlySafeSources(t *testing.T) {
id, kind, string(js)); err != nil {
t.Fatal(err)
}
checkIdeaSources(t, h, id)
}
applySiteNameMigration(t, st)
for kind := range map[string]bool{ideas.KindFromIngredients: true, ideas.KindQuick: true} {
checkIdeaSources(t, h, "legacy-"+kind)
}
}
+10 -5
View File
@@ -111,10 +111,13 @@ type pushedPlan struct {
// plan Calendar does not show and pushing again makes it; or
// supply_run_failed when it could not be made, and POST
// /shopping/{weekStart}/generate makes it again. error is null when status
// is generated.
// is generated. title_job_id is the job titling a generated Supply run,
// which GET /jobs/{id} reports on; null when it already has its title or
// was not generated.
type pushedSupplyRun struct {
Status string `json:"status"`
Error *apiError `json:"error"`
Status string `json:"status"`
Error *apiError `json:"error"`
TitleJobID *string `json:"title_job_id"`
}
// postPlanPush pushes the week's plan to the chosen Google calendar
@@ -143,7 +146,9 @@ func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service
// gone.
ctx := context.WithoutCancel(r.Context())
run := pushedSupplyRun{Status: "generated"}
if _, err := shop.GeneratePushed(ctx, week, revision); errors.Is(err, shopping.ErrFrozen) {
if g, err := shop.GeneratePushed(ctx, week, revision); err == nil {
run.TitleJobID = g.TitleJobID
} else if errors.Is(err, shopping.ErrFrozen) {
logger.InfoContext(ctx, "Supply run in the Supply archive left as it was after Push to Calendar", "week", week.String())
run = pushedSupplyRun{Status: "archived", Error: &apiError{Code: "archived", Message: err.Error()}}
} else if errors.Is(err, shopping.ErrPlanChanged) {
@@ -153,7 +158,7 @@ func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service
Message: "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again",
Retryable: true,
}}
} else if err != nil {
} else {
logger.ErrorContext(ctx, "generate Supply run after Push to Calendar", "week", week.String(), "error", err)
run = pushedSupplyRun{Status: "failed", Error: &apiError{
Code: "supply_run_failed",
+3 -2
View File
@@ -14,6 +14,7 @@ import (
"sync"
"testing"
"testing/fstest"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
@@ -93,7 +94,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
t.Fatal(err)
}
notifier := notifytest.New(t, st)
svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, func() {}, pub, logger)
svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, func() {}, pub, time.Now, logger)
if err != nil {
t.Fatal(err)
}
@@ -107,7 +108,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc,
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestDinnersOn(t, st), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
-1
View File
@@ -162,7 +162,6 @@ 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))
+9 -3
View File
@@ -15,6 +15,7 @@ import (
"strings"
"testing"
"testing/fstest"
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/oauth2"
@@ -167,7 +168,12 @@ func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
// with its own unstarted job runner and a grounder that finds nothing.
func newTestDinners(t *testing.T) *dinners.Service {
t.Helper()
st := newTestStore(t)
return newTestDinnersOn(t, newTestStore(t))
}
// newTestDinnersOn is newTestDinners over st.
func newTestDinnersOn(t *testing.T, st *store.Store) *dinners.Service {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
@@ -240,7 +246,7 @@ func newTestPlan(t *testing.T) *plan.Service {
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no model in this test")
}
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, time.Now, logger)
if err != nil {
t.Fatal(err)
}
@@ -303,7 +309,7 @@ func newTestHomeOn(t *testing.T, st *store.Store) *home.Service {
if err != nil {
t.Fatal(err)
}
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, time.Now, logger)
if err != nil {
t.Fatal(err)
}
+12 -6
View File
@@ -60,10 +60,13 @@ type supplyRunItem struct {
}
// generatedSupplyRun is the POST /shopping/{weekStart}/generate answer:
// the Supply run as made, and the plan days with nothing to shop for.
// the Supply run as made, the plan days with nothing to shop for, and the
// title_job_id of the job titling it, which GET /jobs/{id} reports on;
// null when the Supply run already has its title.
type generatedSupplyRun struct {
supplyRun
Skipped []shopping.SkippedDay `json:"skipped"`
Skipped []shopping.SkippedDay `json:"skipped"`
TitleJobID *string `json:"title_job_id"`
}
// sendChatRequest is the POST /shopping/{weekStart}/send-chat body:
@@ -108,7 +111,7 @@ func getSupplyRun(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
}
// postSupplyRunGenerate makes, or remakes, the week's Supply run from its
// plan and answers with it and the plan days it skipped.
// plan and answers with it, the plan days it skipped and its title job.
func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
@@ -129,7 +132,7 @@ func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.Hand
if skipped == nil {
skipped = []shopping.SkippedDay{}
}
writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped})
writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped, TitleJobID: g.TitleJobID})
}
}
@@ -219,8 +222,9 @@ type archivePeriod struct {
}
// archivePost is one week's Supply run in the Supply archive. label is
// the week label, which stands in for title while it is null, and
// onHandCount counts the items marked "have it".
// the week label, which stands in for title while it is null,
// onHandCount counts the items marked "have it", and frozen is false
// while the list can still be changed from the Supply run.
type archivePost struct {
WeekStart string `json:"weekStart"`
Label string `json:"label"`
@@ -230,6 +234,7 @@ type archivePost struct {
OnHandCount int `json:"onHandCount"`
Dinners []string `json:"dinners"`
Current bool `json:"current"`
Frozen bool `json:"frozen"`
}
// getSupplyArchive answers GET /shopping/archive?by=week|month|year with
@@ -259,6 +264,7 @@ func getSupplyArchive(logger *slog.Logger, svc *shopping.Service) http.HandlerFu
OnHandCount: post.OnHand,
Dinners: post.Dinners,
Current: post.Current,
Frozen: post.Frozen,
}
}
out.Periods[i] = archivePeriod{Key: p.Key, Heading: p.Heading, Posts: posts}
+210 -16
View File
@@ -75,7 +75,7 @@ func (e plansEnv) groundWeek(t *testing.T) {
func (e plansEnv) archive(t *testing.T) {
t.Helper()
if _, err := e.st.Writer.ExecContext(t.Context(),
`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
t.Fatal(err)
}
}
@@ -186,7 +186,7 @@ func TestSupplyRunShape(t *testing.T) {
if status != http.StatusOK {
t.Fatalf("generate = %d %v", status, raw)
}
wantTop := []string{"chat_send_unconfirmed_at", "groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "total_count", "weekStart"}
wantTop := []string{"chat_send_unconfirmed_at", "groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "title_job_id", "total_count", "weekStart"}
if got := keys(raw); !reflect.DeepEqual(got, wantTop) {
t.Errorf("generate fields = %v, want %v", got, wantTop)
}
@@ -199,7 +199,7 @@ func TestSupplyRunShape(t *testing.T) {
if status != http.StatusOK {
t.Fatalf("GET = %d %v", status, raw)
}
if k := keys(raw); !reflect.DeepEqual(k, slices.DeleteFunc(slices.Clone(wantTop), func(s string) bool { return s == "skipped" })) {
if k := keys(raw); !reflect.DeepEqual(k, slices.DeleteFunc(slices.Clone(wantTop), func(s string) bool { return s == "skipped" || s == "title_job_id" })) {
t.Errorf("GET fields = %v", k)
}
group := raw["groups"].([]any)[0].(map[string]any)
@@ -376,8 +376,12 @@ func TestPushPlanMakesSupplyRun(t *testing.T) {
if rec.Code != http.StatusOK || body["status"] != "synced" {
t.Fatalf("push = %d %s", rec.Code, rec.Body)
}
if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) {
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
run, _ := body["supply_run"].(map[string]any)
if k := keys(run); run["status"] != "generated" || run["error"] != nil || !reflect.DeepEqual(k, []string{"error", "status", "title_job_id"}) {
t.Errorf("supply_run = %v, want generated with no error", run)
}
if job := titleJob(t, e, run["title_job_id"]); job["status"] != "queued" {
t.Errorf("title job = %v, want queued: no runner in this test", job)
}
var l supplyRunJSON
if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l); status != http.StatusOK {
@@ -467,9 +471,11 @@ func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
if rec.Code != http.StatusOK || body["status"] != "synced" {
t.Fatalf("push = %d %s", rec.Code, rec.Body)
}
if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) {
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
run, _ := body["supply_run"].(map[string]any)
if run["status"] != "generated" || run["error"] != nil {
t.Errorf("supply_run = %v, want generated with no error", run)
}
titleJobID, _ := run["title_job_id"].(string)
held.waitEntered(t)
var l supplyRunJSON
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
@@ -478,6 +484,7 @@ func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
}
close(held.release)
wantTitleJobSucceeded(t, e, titleJobID)
if got := e.waitTitled(t); got != "Chili and Friends" {
t.Errorf("title = %q, want the title job's", got)
}
@@ -493,7 +500,8 @@ func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
e.startRunner(t)
var l supplyRunJSON
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l); status != http.StatusOK {
status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l)
if status != http.StatusOK {
t.Fatalf("generate = %d %v", status, body)
}
if l.Title != nil {
@@ -502,12 +510,66 @@ func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
if !reflect.DeepEqual(l.render(), weekLines) {
t.Errorf("generated = %v, want %v", l.render(), weekLines)
}
titleJobID, _ := body["title_job_id"].(string)
held.waitEntered(t)
close(held.release)
wantTitleJobSucceeded(t, e, titleJobID)
if got := e.waitTitled(t); got != "Chili and Friends" {
t.Errorf("title = %q, want the title job's", got)
}
if _, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil); body["title_job_id"] != nil {
t.Errorf("remake title_job_id = %v, want null: the Supply run has its title", body["title_job_id"])
}
}
// TestSupplyRunGenerateTitleJobFails: a title job that cannot title the
// Supply run is reported failed with title_failed by GET /jobs/{id}, and
// the week's next generation answers with a new title job.
func TestSupplyRunGenerateTitleJobFails(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.groundWeek(t)
e.startRunner(t)
_, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
first := titleJob(t, e, body["title_job_id"])
job := waitJob(t, e.h, first["id"].(string))
if job["status"] != "failed" || job["error"] != shopping.ReasonTitleFailed {
t.Errorf("title job = %v, want failed %s", job, shopping.ReasonTitleFailed)
}
_, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
if second := titleJob(t, e, body["title_job_id"]); second["id"] == first["id"] {
t.Errorf("retry title_job_id = %v, want a new job", second["id"])
}
}
// titleJob returns the title_supply_run job id, as GET /jobs/{id} reports
// it; id must be a job id string.
func titleJob(t *testing.T, e plansEnv, id any) map[string]any {
t.Helper()
s, ok := id.(string)
if !ok || s == "" {
t.Fatalf("title_job_id = %v, want a job id", id)
}
rec, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+s, "")
if rec.Code != http.StatusOK || job["kind"] != shopping.KindTitleSupplyRun {
t.Fatalf("GET title job = %d %s, want a %s job", rec.Code, rec.Body, shopping.KindTitleSupplyRun)
}
return job
}
// wantTitleJobSucceeded waits for title job id to finish and fails t
// unless GET /jobs/{id} reports it succeeded with the title written.
func wantTitleJobSucceeded(t *testing.T, e plansEnv, id string) {
t.Helper()
titleJob(t, e, id)
job := waitJob(t, e.h, id)
result, _ := job["result"].(map[string]any)
if job["status"] != "succeeded" || result["title"] != "Chili and Friends" {
t.Errorf("title job = %v, want succeeded with the title", job)
}
}
// TestFailedPushMakesNoSupplyRun: a push Calendar refused makes no Supply
@@ -546,7 +608,7 @@ func TestPushPlanSupplyRunFails(t *testing.T) {
"code": "supply_run_failed",
"message": "the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run",
"retryable": true,
}}
}, "title_job_id": nil}
if !reflect.DeepEqual(body["supply_run"], want) {
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
}
@@ -599,7 +661,7 @@ func TestPushPlanEditedBeforeSupplyRun(t *testing.T) {
"code": "plan_changed",
"message": "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again",
"retryable": true,
}}
}, "title_job_id": nil}
if !reflect.DeepEqual(body["supply_run"], want) {
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
}
@@ -632,7 +694,7 @@ func TestPushPlanLeavesArchivedSupplyRun(t *testing.T) {
t.Fatalf("push = %d %s", rec.Code, rec.Body)
}
run, _ := body["supply_run"].(map[string]any)
if apiErr, _ := run["error"].(map[string]any); run["status"] != "archived" || apiErr["code"] != "archived" {
if apiErr, _ := run["error"].(map[string]any); run["status"] != "archived" || apiErr["code"] != "archived" || run["title_job_id"] != nil {
t.Errorf("supply_run = %v, want archived", body["supply_run"])
}
var l supplyRunJSON
@@ -658,6 +720,7 @@ type archiveJSON struct {
OnHandCount int `json:"onHandCount"`
Dinners []string `json:"dinners"`
Current bool `json:"current"`
Frozen bool `json:"frozen"`
} `json:"posts"`
} `json:"periods"`
}
@@ -736,7 +799,7 @@ func TestSupplyArchive(t *testing.T) {
if k := keys(period); !reflect.DeepEqual(k, []string{"heading", "key", "posts"}) {
t.Errorf("period fields = %v", k)
}
wantPost := []string{"current", "dinnerCount", "dinners", "itemCount", "label", "onHandCount", "title", "weekStart"}
wantPost := []string{"current", "dinnerCount", "dinners", "frozen", "itemCount", "label", "onHandCount", "title", "weekStart"}
if k := keys(period["posts"].([]any)[0].(map[string]any)); !reflect.DeepEqual(k, wantPost) {
t.Errorf("post fields = %v, want %v", k, wantPost)
}
@@ -753,13 +816,13 @@ func TestSupplyArchive(t *testing.T) {
if post.Label != label || post.Title != nil {
t.Errorf("post %s: label %q title %v, want %q and null", post.WeekStart, post.Label, post.Title, label)
}
posts = append(posts, fmt.Sprintf("%s %d dinners %d items %d on hand %v current=%t",
post.WeekStart, post.DinnerCount, post.ItemCount, post.OnHandCount, post.Dinners, post.Current))
posts = append(posts, fmt.Sprintf("%s %d dinners %d items %d on hand %v current=%t frozen=%t",
post.WeekStart, post.DinnerCount, post.ItemCount, post.OnHandCount, post.Dinners, post.Current, post.Frozen))
}
}
want := []string{
thisWeek.String() + " 1 dinners 2 items 0 on hand [Lemon Chicken] current=true",
lastWeek.String() + " 1 dinners 3 items 1 on hand [Beef Stew] current=false",
thisWeek.String() + " 1 dinners 2 items 0 on hand [Lemon Chicken] current=true frozen=false",
lastWeek.String() + " 1 dinners 3 items 1 on hand [Beef Stew] current=false frozen=true",
}
if got.By != "week" || !reflect.DeepEqual(posts, want) {
t.Errorf("archive by %s =\n %s\nwant\n %s", got.By, strings.Join(posts, "\n "), strings.Join(want, "\n "))
@@ -790,6 +853,137 @@ func TestSupplyArchive(t *testing.T) {
}
}
// TestSupplyArchiveUnfrozenPastWeek: a past week's Supply run that
// week_rollover has not frozen is in the Supply archive as not frozen for
// as long as the Supply run route can still change it, and as frozen once
// it cannot.
func TestSupplyArchiveUnfrozenPastWeek(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM plan_days; DELETE FROM week_plans`); err != nil {
t.Fatal(err)
}
zone, err := household.LoadZone("America/New_York") // the env's household zone
if err != nil {
t.Fatal(err)
}
thisWeek := household.WeekStart(household.Today(time.Now(), zone))
y, m, d := thisWeek.Date()
lastWeek := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
last := e.weekSupplyRun(t, lastWeek, "Beef Stew", shopIng{"stewing beef", "2", "lb"}, shopIng{"carrots", "4", ""})
// post is last week's post in the Supply archive.
post := func() (frozen bool) {
t.Helper()
var got archiveJSON
if status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got); status != http.StatusOK {
t.Fatalf("GET archive = %d %v", status, raw)
}
for _, p := range got.Periods {
for _, post := range p.Posts {
if post.WeekStart == lastWeek.String() {
if post.Current {
t.Errorf("last week's post is current")
}
return post.Frozen
}
}
}
t.Fatalf("archive %+v has no post for %s", got, lastWeek)
return false
}
// change marks carrots and reports whether the Supply run route took it.
change := func(needed bool) bool {
t.Helper()
status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+last.itemID(t, "carrots"), fmt.Sprintf(`{"needed":%t}`, needed), nil)
switch {
case status == http.StatusOK:
return true
case status == http.StatusConflict && errorCode(body) == "archived":
return false
}
t.Fatalf("PATCH = %d %v, want 200 or 409 archived", status, body)
return false
}
if frozen, changed := post(), change(false); frozen || !changed {
t.Errorf("unfrozen past week: post frozen=%t, PATCH changed=%t; want a post not frozen for a list that changes", frozen, changed)
}
if err := e.shop.Freeze(t.Context(), lastWeek); err != nil {
t.Fatal(err)
}
if frozen, changed := post(), change(true); !frozen || changed {
t.Errorf("frozen past week: post frozen=%t, PATCH changed=%t; want a frozen post for a list that cannot change", frozen, changed)
}
}
// TestSupplyArchiveKeepsFrozenNames freezes last week's Supply run, then
// renames its dinner and this week's through the dinners API: the frozen
// post and list keep the name the dinner had when frozen, while this
// week's list, not frozen, shows the new name.
func TestSupplyArchiveKeepsFrozenNames(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM plan_days; DELETE FROM week_plans`); err != nil {
t.Fatal(err)
}
zone, err := household.LoadZone("America/New_York") // the env's household zone
if err != nil {
t.Fatal(err)
}
thisWeek := household.WeekStart(household.Today(time.Now(), zone))
y, m, d := thisWeek.Date()
lastWeek := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
last := e.weekSupplyRun(t, lastWeek, "Beef Stew", shopIng{"stewing beef", "2", "lb"})
current := e.weekSupplyRun(t, thisWeek, "Lemon Chicken", shopIng{"lemon", "1", ""})
if err := e.shop.Freeze(t.Context(), lastWeek); err != nil {
t.Fatal(err)
}
for id, name := range map[string]string{
last.Groups[0].Dinner.ID: "Sunday Beef Stew",
current.Groups[0].Dinner.ID: "Lemon Garlic Chicken",
} {
if status, body := callShopping(t, e, http.MethodPatch, dinnersPath+"/"+id, `{"name":"`+name+`"}`, nil); status != http.StatusOK {
t.Fatalf("PATCH dinner %s = %d %v", id, status, body)
}
}
var got archiveJSON
if status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got); status != http.StatusOK {
t.Fatalf("GET archive = %d %v", status, raw)
}
var posts []string
for _, p := range got.Periods {
for _, post := range p.Posts {
posts = append(posts, fmt.Sprintf("%s %v current=%t", post.WeekStart, post.Dinners, post.Current))
}
}
want := []string{
thisWeek.String() + " [Lemon Garlic Chicken] current=true",
lastWeek.String() + " [Beef Stew] current=false",
}
if !reflect.DeepEqual(posts, want) {
t.Errorf("archive after rename =\n %s\nwant\n %s", strings.Join(posts, "\n "), strings.Join(want, "\n "))
}
for _, tc := range []struct {
week store.LocalDate
want string
}{
{lastWeek, "Monday " + lastWeek.String() + " Beef Stew: stewing beef [2 lb] needed"},
{thisWeek, "Monday " + thisWeek.String() + " Lemon Garlic Chicken: lemon [1] needed"},
} {
var l supplyRunJSON
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+tc.week.String(), "", &l); status != http.StatusOK {
t.Fatalf("GET %s = %d %v", tc.week, status, body)
}
if got := l.render(); !reflect.DeepEqual(got, []string{tc.want}) {
t.Errorf("list %s after rename = %v, want [%s]", tc.week, got, tc.want)
}
}
}
// TestSupplyRunSendChat posts the week's Supply run to Google Chat once,
// records sent_to_chat_at, answers a second send with the status as it
// is, and posts again only with resend.
+31 -11
View File
@@ -22,7 +22,9 @@ const (
// Freeze moves the Supply run of the week starting weekStart into the
// Supply archive: its status becomes archived and frozen_at is set, after
// which it is read-only (Generate and SetNeeded are ErrFrozen). Freezing a
// which it is read-only (Generate and SetNeeded are ErrFrozen). The list
// keeps the names its dinners have as it is frozen, so its post in the
// Supply archive shows them whatever a dinner is renamed later. Freezing a
// frozen list does nothing and is no error.
//
// The week must have ended in the household's time zone: a week not yet
@@ -79,14 +81,25 @@ func (s *Service) freeze(ctx context.Context, weekStart store.LocalDate, now tim
"week", list.WeekStart, "list", list.ID, "error", err)
}
}
// A list frozen meanwhile is left as it is: the freeze is done.
// A list frozen meanwhile is left as it is, its names as they were
// frozen: the freeze is done.
stamp := store.Now().String()
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID,
}); err != nil {
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
}
return nil
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
n, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID,
})
if err != nil {
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
}
if n == 0 {
return nil
}
if err := q.FreezeShoppingGroupNames(ctx, list.ID); err != nil {
return fmt.Errorf("shopping: freeze %s: keep dinner names: %w", weekStart, err)
}
return nil
})
}
// freezeTitleJob is the title job of list as Generate would enqueue it:
@@ -145,13 +158,19 @@ type Post struct {
Label string
// Title is nil while the list is untitled, when Label stands in.
Title *string
// Dinners are the names of the list's dinners, in day order.
// Dinners are the names of the list's dinners, in day order: as they
// were when the list was frozen, or as they are now while it is not.
Dinners []string
// Items is how many items the list has, and OnHand how many of them
// the household marked "have it".
Items, OnHand int
// Current is true for the household's current week.
Current bool
// Frozen is true once the list is frozen into the Supply archive and
// can no longer change. A list not yet frozen — the current week's, or
// a past week's that week_rollover has not frozen — can still be
// changed from the Supply run.
Frozen bool
}
// Archive returns the Supply archive: the Supply run of every week up to
@@ -208,7 +227,7 @@ func (s *Service) Archive(ctx context.Context, by string) (out []Period, err err
if err != nil {
return nil, err
}
p, err := postOf(monday, l, monday == thisWeek)
p, err := postOf(monday, l, monday == thisWeek, frozen(row))
if err != nil {
return nil, err
}
@@ -223,7 +242,7 @@ func (s *Service) Archive(ctx context.Context, by string) (out []Period, err err
// postOf is l, the Supply run of the week starting monday, as a post in
// the Supply archive.
func postOf(monday store.LocalDate, l List, current bool) (Post, error) {
func postOf(monday store.LocalDate, l List, current, isFrozen bool) (Post, error) {
label, err := household.WeekLabel(monday)
if err != nil {
return Post{}, fmt.Errorf("shopping: Supply run %s: %w", l.ID, err)
@@ -241,5 +260,6 @@ func postOf(monday store.LocalDate, l List, current bool) (Post, error) {
Items: c.Total,
OnHand: c.Total - c.Needed,
Current: current,
Frozen: isFrozen,
}, nil
}
+5 -2
View File
@@ -381,6 +381,9 @@ func postLine(p shopping.Post) string {
if p.Current {
cur = " | current"
}
if !p.Frozen {
cur += " | open"
}
return p.WeekStart + " " + p.Label + " | " + title + " | " + strings.Join(p.Dinners, ", ") + " | " +
strconv.Itoa(p.Items) + " items, " + strconv.Itoa(p.OnHand) + " on hand" + cur
}
@@ -422,12 +425,12 @@ func TestArchiveGroups(t *testing.T) {
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili, Noodles & Co' WHERE week_start = '2026-12-28'`); err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2027-01-03T11:05:00.000Z' WHERE week_start < '2027-01-04'`); err != nil {
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2027-01-03T11:05:00.000Z' WHERE week_start < '2027-01-04'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
t.Fatal(err)
}
posts := map[string]string{
"jan": "2027-01-04 Week 1 · Jan 4 – 10 | - | Pad Thai, Beef Chili | 5 items, 1 on hand | current",
"jan": "2027-01-04 Week 1 · Jan 4 – 10 | - | Pad Thai, Beef Chili | 5 items, 1 on hand | current | open",
"dec": "2026-12-28 Week 53 · Dec 28 – Jan 3 | Chili, Noodles & Co | Beef Chili, Pad Thai | 5 items, 2 on hand",
"d07": "2026-12-07 Week 50 · Dec 7 – 13 | - | Pad Thai | 2 items, 0 on hand",
"nov": "2026-11-30 Week 49 · Nov 30 – Dec 6 | - | Pot Roast | 1 items, 1 on hand",
+1 -1
View File
@@ -152,7 +152,7 @@ func TestSendToChatFailuresRecordNothing(t *testing.T) {
t.Errorf("after failed sends sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want both unset", l.SentToChatAt, l.ChatSendUnconfirmedAt, err)
}
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
t.Fatal(err)
}
posts := len(e.chat.Posts())
+11 -2
View File
@@ -492,7 +492,9 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, e
return readList(ctx, q, list)
}
// readList reads the groups and items of list through q.
// readList reads the groups and items of list through q. A group of a
// frozen list is named as its dinner was when the list was frozen; one
// with no such name is an error.
func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, error) {
rows, err := q.ListShoppingGroupDinners(ctx, list.ID)
if err != nil {
@@ -512,8 +514,15 @@ func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, e
groups := make([]Group, 0, len(rows))
for _, r := range rows {
g := r.ShoppingGroup
name := r.DinnerName
if frozen(list) {
if !g.FrozenDinnerName.Valid {
return List{}, fmt.Errorf("shopping: group %s of frozen list %s has no frozen dinner name", g.ID, list.WeekStart)
}
name = g.FrozenDinnerName.String
}
groups = append(groups, Group{
ID: g.ID, DinnerID: g.DinnerID, DinnerName: r.DinnerName, Date: g.Date, Position: int(g.Position),
ID: g.ID, DinnerID: g.DinnerID, DinnerName: name, Date: g.Date, Position: int(g.Position),
Items: byGroup[g.ID],
})
}
+2 -2
View File
@@ -692,7 +692,7 @@ func TestGenerateRefusesFrozenList(t *testing.T) {
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
e.plan(t, "Beef Chili")
e.generate(t)
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
t.Fatal(err)
}
e.swap(t, "2026-10-12", "Pad Thai")
@@ -834,7 +834,7 @@ func TestSetNeededRefusesFrozenList(t *testing.T) {
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Beef Chili")
l := e.generate(t).List
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
t.Fatal(err)
}
+36 -1
View File
@@ -239,7 +239,7 @@ func TestGenerateTitleNotStoredOnFrozenList(t *testing.T) {
e := titleWeek(t)
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
e.beforeTitle = func() {
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
t.Error(err)
}
}
@@ -311,3 +311,38 @@ func TestGenerateTitleOverlapAcrossRename(t *testing.T) {
t.Errorf("stored title = %v, want one of the two titles", deref(got))
}
}
// TestGenerateTitleNotStoredOnReplacedList: a list replaced by a new
// untitled list of the same week once archive_title has answered, before
// its title is stored, keeps the replacement untitled; the old list's
// title job fails, and the failure is logged.
func TestGenerateTitleNotStoredOnReplacedList(t *testing.T) {
e := titleWeek(t)
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
e.afterTitle = func() {
for _, stmt := range []string{
`DELETE FROM shopping_items WHERE group_id IN (SELECT id FROM shopping_groups WHERE list_id IN (SELECT id FROM shopping_lists WHERE week_start = ?1))`,
`DELETE FROM shopping_groups WHERE list_id IN (SELECT id FROM shopping_lists WHERE week_start = ?1)`,
`UPDATE shopping_lists SET id = 'replacement' WHERE week_start = ?1`,
} {
if _, err := e.st.Writer.Exec(stmt, week.String()); err != nil {
t.Error(err)
}
}
}
g := e.generate(t)
wantJob(t, e.waitTitle(t, g), jobs.StatusFailed, shopping.ReasonTitleFailed)
var id string
if err := e.st.Reader.QueryRow(`SELECT id FROM shopping_lists WHERE week_start = ?`, week.String()).Scan(&id); err != nil {
t.Fatal(err)
}
if id != "replacement" || id == g.List.ID {
t.Fatalf("week's list = %s, want the replacement of %s", id, g.List.ID)
}
wantUntitled(t, "replacement's title", e.storedTitle(t))
if !strings.Contains(e.log.String(), "the Supply run was frozen or replaced before its title was stored") {
t.Errorf("log lacks the failure:\n%s", e.log.String())
}
}
+229 -4
View File
@@ -2,6 +2,7 @@ package store
import (
"context"
"fmt"
"io/fs"
"reflect"
"slices"
@@ -176,8 +177,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_recipe_item_position_unique,0019_shopping_group_frozen_name,0020_site_name_from_page" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_recipe_item_position_unique,0019_shopping_group_frozen_name,0020_site_name_from_page", got)
}
}
@@ -197,8 +198,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_recipe_item_position_unique,0019_shopping_group_frozen_name,0020_site_name_from_page" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_recipe_item_position_unique,0019_shopping_group_frozen_name,0020_site_name_from_page", got)
}
if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created")
@@ -922,3 +923,227 @@ func TestMigrateShoppingListChatSendUnconfirmed(t *testing.T) {
t.Errorf("shopping list after 0017 = %s, want %s", got, want)
}
}
// seedRecipeItems inserts two recipes, each with an ingredient and a step
// at position 0, then the extra statements.
func seedRecipeItems(t *testing.T, s *Store, extra ...string) {
t.Helper()
stmts := []string{
`INSERT INTO recipes (id, source_url, title, raw_jsonld, fetched_at, created_at, updated_at)
VALUES ('recipe-1', 'https://example.com/chili', 'Chili', '{}', 'f', 'c', 'u')`,
`INSERT INTO recipes (id, source_url, title, raw_jsonld, fetched_at, created_at, updated_at)
VALUES ('recipe-2', 'https://example.com/curry', 'Curry', '{}', 'f', 'c', 'u')`,
`INSERT INTO recipe_ingredients (id, recipe_id, position, raw_text, created_at, updated_at)
VALUES ('ing-1', 'recipe-1', 0, '1 onion', 'c', 'u'), ('ing-2', 'recipe-2', 0, '1 lime', 'c', 'u')`,
`INSERT INTO recipe_steps (id, recipe_id, position, text, created_at, updated_at)
VALUES ('step-1', 'recipe-1', 0, 'Chop.', 'c', 'u'), ('step-2', 'recipe-2', 0, 'Squeeze.', 'c', 'u')`,
}
for _, stmt := range append(stmts, extra...) {
if _, err := s.Writer.Exec(stmt); err != nil {
t.Fatalf("seed: %v", err)
}
}
}
// TestMigrateRecipeItemPositionUnique applies 0018 over two recipes with
// items at the same position: each recipe keeps its items, and a second
// ingredient or step at a position its recipe already uses is refused.
func TestMigrateRecipeItemPositionUnique(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
migrateBefore(t, s, "0018")
seedRecipeItems(t, s)
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
var got string
if err := s.Reader.QueryRowContext(ctx, `SELECT
(SELECT group_concat(id || '@' || recipe_id || ':' || position, ',') FROM (SELECT * FROM recipe_ingredients ORDER BY id)) || ' / ' ||
(SELECT group_concat(id || '@' || recipe_id || ':' || position, ',') FROM (SELECT * FROM recipe_steps ORDER BY id))`).Scan(&got); err != nil {
t.Fatal(err)
}
if want := "ing-1@recipe-1:0,ing-2@recipe-2:0 / step-1@recipe-1:0,step-2@recipe-2:0"; got != want {
t.Errorf("recipe items after 0018 = %s, want %s", got, want)
}
for _, tc := range []struct{ name, stmt string }{
{"second ingredient at a position", `INSERT INTO recipe_ingredients (id, recipe_id, position, raw_text, created_at, updated_at)
VALUES ('ing-3', 'recipe-1', 0, '2 onions', 'c', 'u')`},
{"second step at a position", `INSERT INTO recipe_steps (id, recipe_id, position, text, created_at, updated_at)
VALUES ('step-3', 'recipe-1', 0, 'Dice.', 'c', 'u')`},
{"ingredient moved onto another's position", `UPDATE recipe_ingredients SET recipe_id = 'recipe-1' WHERE id = 'ing-2'`},
{"step moved onto another's position", `UPDATE recipe_steps SET recipe_id = 'recipe-1' WHERE id = 'step-2'`},
} {
if _, err := s.Writer.Exec(tc.stmt); err == nil || !strings.Contains(err.Error(), "UNIQUE constraint failed") {
t.Errorf("%s: error %v, want UNIQUE constraint failed", tc.name, err)
}
}
}
// TestMigrateRecipeItemPositionUniqueRefusesDuplicate applies 0018 over a
// recipe already holding two ingredients, or two steps, at one position:
// its order is unknown, so the migration fails and is not recorded.
func TestMigrateRecipeItemPositionUniqueRefusesDuplicate(t *testing.T) {
for _, tc := range []struct{ table, stmt string }{
{"recipe_ingredients", `INSERT INTO recipe_ingredients (id, recipe_id, position, raw_text, created_at, updated_at)
VALUES ('ing-3', 'recipe-1', 0, '2 onions', 'c', 'u')`},
{"recipe_steps", `INSERT INTO recipe_steps (id, recipe_id, position, text, created_at, updated_at)
VALUES ('step-3', 'recipe-1', 0, 'Dice.', 'c', 'u')`},
} {
t.Run(tc.table, func(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
migrateBefore(t, s, "0018")
seedRecipeItems(t, s, tc.stmt)
err := Migrate(ctx, s.Writer, migrations.FS)
if err == nil || !strings.Contains(err.Error(), "UNIQUE constraint failed: "+tc.table+".recipe_id, "+tc.table+".position") {
t.Fatalf("Migrate = %v, want the duplicate position refused", err)
}
var n int
if err := s.Reader.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations WHERE version = '0018_recipe_item_position_unique'`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Error("0018 recorded as applied")
}
})
}
}
// TestMigrateShoppingGroupFrozenName applies 0019 over a frozen list and
// a current one: the frozen list's group takes its dinner's name now, the
// current list's group has none, and a frozen name is never empty.
func TestMigrateShoppingGroupFrozenName(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
migrateBefore(t, s, "0019")
for _, stmt := range []string{
`INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at)
VALUES ('dinner-1', 'Beef Stew', 'beef stew', 'regular', 'regular', 0, 'manual', 'c', 'u'),
('dinner-2', 'Lemon Chicken', 'lemon chicken', 'regular', 'regular', 0, 'manual', 'c', 'u')`,
`INSERT INTO week_plans (id, week_start, status, created_at, updated_at)
VALUES ('plan-1', '2026-10-05', 'synced', 'c', 'u'),
('plan-2', '2026-10-12', 'synced', 'c', 'u')`,
`INSERT INTO shopping_lists (id, week_start, plan_id, status, frozen_at, created_at, updated_at)
VALUES ('list-1', '2026-10-05', 'plan-1', 'archived', 'f', 'c', 'u'),
('list-2', '2026-10-12', 'plan-2', 'current', NULL, 'c', 'u')`,
`INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
VALUES ('group-1', 'list-1', 'dinner-1', '2026-10-05', 0, 'c', 'u'),
('group-2', 'list-2', 'dinner-2', '2026-10-12', 0, 'c', 'u')`,
} {
if _, err := s.Writer.Exec(stmt); err != nil {
t.Fatalf("seed: %v", err)
}
}
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
rows, err := s.Reader.QueryContext(ctx, `SELECT id || '|' || COALESCE(frozen_dinner_name, 'NULL') || '|' || updated_at FROM shopping_groups ORDER BY id`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var got []string
for rows.Next() {
var row string
if err := rows.Scan(&row); err != nil {
t.Fatal(err)
}
got = append(got, row)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if want := []string{"group-1|Beef Stew|u", "group-2|NULL|u"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("shopping groups after 0019 = %v, want %v", got, want)
}
if _, err := s.Writer.Exec(`UPDATE shopping_groups SET frozen_dinner_name = '' WHERE id = 'group-1'`); err == nil || !strings.Contains(err.Error(), "CHECK constraint failed") {
t.Errorf("empty frozen name: error %v, want CHECK constraint failed", err)
}
}
// TestMigrateSiteNameFromPage applies 0020 over recipes and idea job
// results stored when a page naming no site was stored under its host: a
// stored name that could be a host — one with no space that is dotted, an
// IP address or spelled in the recipe's own link — is cleared, so every
// stored name is one a page gave; a name with a space is kept, as is
// everything else in the rows.
func TestMigrateSiteNameFromPage(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
migrateBefore(t, s, "0020")
sites := []struct {
stored any
link string
want any
}{
{"private-token.example.com", "https://private-token.example.com/stew", nil},
{"redirect_token.example.net", "https://kitchen.example/stew", nil},
{"bücher.example", "https://kitchen.example/stew", nil},
{"Food.com", "https://www.food.com/stew", nil},
{"203.0.113.7", "https://203.0.113.7/stew", nil},
{"2001:db8::1", "https://[2001:db8::1]/stew", nil},
{"TokenHost", "https://www.tokenhost/stew", nil},
{"Delish", "https://www.delish.com/stew", nil},
{"Delish", "https://kitchen.example/stew", "Delish"},
{"Curry House", "https://curry-house.example/stew", "Curry House"},
{"Mr. Food Test Kitchen", "https://kitchen.example/stew", "Mr. Food Test Kitchen"},
{nil, "https://kitchen.example/stew", nil},
}
for i, c := range sites {
if _, err := s.Writer.ExecContext(ctx, `INSERT INTO recipes (id, source_url, site_name, title, raw_jsonld, fetched_at, created_at, updated_at)
VALUES (?, ?, ?, 'Stew', '{}', 'f', 'c', 'u')`, fmt.Sprintf("recipe-%d", i), fmt.Sprintf("%s?row=%d", c.link, i), c.stored); err != nil {
t.Fatal(err)
}
}
const (
legacy = `{"stage":"done","ideas":[{"name":"A","site_name":"query-host.example","source_url":"https://query-host.example/a?t=1"},{"name":"B","site_name":"Curry House","source_url":"https://b.example/b"},{"name":"C","site_name":null,"source_url":null},{"name":"D","site_name":"Kitchen","source_url":"https://kitchen.example/d"}],"dropped":1}`
cleaned = `{"stage":"done","ideas":[{"name":"A","site_name":null,"source_url":"https://query-host.example/a?t=1"},{"name":"B","site_name":"Curry House","source_url":"https://b.example/b"},{"name":"C","site_name":null,"source_url":null},{"name":"D","site_name":null,"source_url":"https://kitchen.example/d"}],"dropped":1}`
other = `{"ideas":[{"site_name":"host.example"}]}`
)
for _, j := range [][3]string{
{"job-ideas", "ideas_from_ingredients", legacy},
{"job-quick", "quick_ideas", legacy},
{"job-other", "ground_dinner", other},
{"job-empty", "quick_ideas", `{"stage":"searching","ideas":[]}`},
} {
if _, err := s.Writer.ExecContext(ctx, `INSERT INTO jobs (id, kind, status, payload, result, created_at, updated_at)
VALUES (?, ?, 'succeeded', '{}', ?, 'c', 'u')`, j[0], j[1], j[2]); err != nil {
t.Fatal(err)
}
}
if _, err := s.Writer.ExecContext(ctx, `INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
VALUES ('job-running', 'quick_ideas', 'running', '{}', 'c', 'u')`); err != nil {
t.Fatal(err)
}
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
for i, c := range sites {
var got any
var title string
if err := s.Reader.QueryRowContext(ctx, `SELECT site_name, title FROM recipes WHERE id = ?`, fmt.Sprintf("recipe-%d", i)).Scan(&got, &title); err != nil {
t.Fatal(err)
}
if got != c.want || title != "Stew" {
t.Errorf("recipe stored with site_name %v: site_name %v, title %q after 0020; want %v, Stew", c.stored, got, title, c.want)
}
}
for id, want := range map[string]any{"job-ideas": cleaned, "job-quick": cleaned, "job-other": other,
"job-empty": `{"stage":"searching","ideas":[]}`, "job-running": nil} {
var got any
if err := s.Reader.QueryRowContext(ctx, `SELECT result FROM jobs WHERE id = ?`, id).Scan(&got); err != nil {
t.Fatal(err)
}
if got != want {
t.Errorf("%s result after 0020 = %v, want %v", id, got, want)
}
}
}
+4
View File
@@ -408,6 +408,8 @@ func TestSchemaUniqueConstraintsRejectDuplicates(t *testing.T) {
{"notification_outbox", []string{"event_key", "channel"}},
{"schedule_runs", []string{"schedule", "period_key"}},
{"notification_prefs", []string{"event"}},
{"recipe_ingredients", []string{"recipe_id", "position"}},
{"recipe_steps", []string{"recipe_id", "position"}},
}
s := seededStore(t)
for _, c := range cases {
@@ -445,6 +447,8 @@ func TestSchemaCompositeUniqueAllowsPartialOverlap(t *testing.T) {
{"same plan_date, different dinner", "rotation_reviews", "plan_date", "dinners", map[string]any{"dinner_id": rowID("dinners", 1)}},
{"same event_key, different channel", "notification_outbox", "event_key", "", map[string]any{"channel": "chat"}},
{"same period_key, different schedule", "schedule_runs", "period_key", "", map[string]any{"schedule": "week_rollover"}},
{"same position, different recipe", "recipe_ingredients", "position", "recipes", map[string]any{"recipe_id": rowID("recipes", 1)}},
{"same position, different recipe", "recipe_steps", "position", "recipes", map[string]any{"recipe_id": rowID("recipes", 1)}},
}
s := seededStore(t)
for _, c := range cases {
+85 -29
View File
@@ -54,7 +54,8 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// no recipe grounds is dropped; any other grounding failure fails the
// batch, as does suggest_side_quests failing, with no Side quest made up
// instead. The batch ends done or failed. When recording the batch's end
// fails, so does the job; when it times out at shutdown, the job is left
// fails, so does the job, with what the batch stored, and any
// new_suggestions it emitted, kept; when it times out at shutdown, the job is left
// running and the batch with it, for the next start to fail.
//
// When a batch stores its Side quests depends on whether it notifies (see
@@ -64,7 +65,12 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// Side quest stored. So the household is told of exactly what the batch
// added, once; and a batch that fails at any round, including a later
// round's normalize_recipe, stores nothing and sends nothing, leaving no
// Side quest in the deck that was never announced. A replenish batch,
// Side quest in the deck that was never announced. After each round it
// rechecks every Side quest it holds (see eligible) and lets go of those
// the household's changes made ineligible, so a later round grounds a
// spare Side quest in place of each; only a change committed between the
// last recheck and the store can still leave the batch short of
// BatchSize while spare Side quests remain. A replenish batch,
// which tops up the deck as the household swipes and never notifies,
// stores each round's Side quests before the next round starts, so the
// deck fills as it goes; those stored before a failure stay in the deck.
@@ -146,7 +152,13 @@ func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publis
}
res.Dropped += len(round) - len(grounded)
if note != nil {
held = append(held, grounded...)
// Recheck every held Side quest, not just this round's, so
// one the household's changes made ineligible while this
// round grounded is replaced by a later round, not dropped by
// the store once no round is left to replace it.
if held, err = s.recheck(ctx, &res, append(held, grounded...)); err != nil {
return batchResult{}, err
}
} else if err := s.storeInto(ctx, &res, nil, grounded); err != nil {
return batchResult{}, err
}
@@ -163,6 +175,22 @@ func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publis
return res, nil
}
// recheck returns, in order, those of a notifying batch's held Side
// quests that store would still keep (see eligible), adding those left
// out to res.Dropped.
func (s *Service) recheck(ctx context.Context, res *batchResult, held []groundedQuest) ([]groundedQuest, error) {
kept, err := s.eligible(ctx, db.New(s.st.Reader), res.BatchID, held)
if err != nil {
return nil, err
}
res.Dropped += len(held) - len(kept)
out := make([]groundedQuest, len(kept))
for i, k := range kept {
out[i] = k.groundedQuest
}
return out, nil
}
// storeInto stores quests for res's batch with note (see store) and adds
// the suggestions stored, and the Side quests left out, to res.
func (s *Service) storeInto(ctx context.Context, res *batchResult, note *notice, quests []groundedQuest) error {
@@ -277,11 +305,11 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
// store stores each grounded Side quest of batchID as a dinner with status
// suggested and origin side_quest, grounded in its recipe, and a pending
// suggestion citing its Regulars by id, all in one transaction, and
// returns the suggestion ids. A Side quest is left out when, since
// suggest_side_quests ran, a dinner it repeats was added, a suggestion it
// repeats passed or a Rotation check dropped, or a Regular it cites renamed
// or archived. Passed names are read in the same transaction as the store,
// so a Pass or drop committed before it is never missed.
// returns the suggestion ids. A Side quest eligible leaves out is not
// stored: since suggest_side_quests ran, a dinner it repeats was added, a
// suggestion it repeats passed or a Rotation check dropped, or a Regular
// it cites renamed or archived. eligible reads in the same transaction as
// the store, so a Pass or drop committed before it is never missed.
//
// When note is not nil and a Side quest is stored, new_suggestions keyed by
// note, counting and naming those stored, is emitted in the same
@@ -295,28 +323,13 @@ func (s *Service) store(ctx context.Context, batchID string, note *notice, quest
var stored []string
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
existing, err := q.ListUnarchivedDinners(ctx)
kept, err := s.eligible(ctx, q, batchID, quests)
if err != nil {
return fmt.Errorf("suggest: list dinners for dedupe: %w", err)
return err
}
names, err := q.ListPassedNames(ctx)
if err != nil {
return fmt.Errorf("suggest: list passed names for dedupe: %w", err)
}
regulars := map[string][]string{}
for _, row := range existing {
names = append(names, row.Dinner.Name)
if row.Dinner.Status == dinners.StatusRegular {
regulars[row.Dinner.Name] = append(regulars[row.Dinner.Name], row.Dinner.ID)
}
}
for _, gq := range quests {
cited, ok := citedIDs(gq.quest.CitedRegulars, regulars)
if !ok || repeats(gq.quest.Name, names) {
s.logger.InfoContext(ctx, "suggested dinner dropped: changed while grounding", "batch_id", batchID)
continue
}
citedJSON, err := json.Marshal(cited)
for _, k := range kept {
gq := k.groundedQuest
citedJSON, err := json.Marshal(k.cited)
if err != nil {
return fmt.Errorf("suggest: encode cited Regulars: %w", err)
}
@@ -354,7 +367,6 @@ func (s *Service) store(ctx context.Context, batchID string, note *notice, quest
}); err != nil {
return fmt.Errorf("suggest: create suggestion: %w", err)
}
names = append(names, d.Name)
ids = append(ids, id)
stored = append(stored, d.Name)
}
@@ -372,6 +384,50 @@ func (s *Service) store(ctx context.Context, batchID string, note *notice, quest
return ids, nil
}
// eligibleQuest is a Side quest store would keep, with the ids of the
// Regulars it cites.
type eligibleQuest struct {
groundedQuest
cited []string
}
// eligible returns, in order, those of batchID's quests that q's dinners
// and passed names still allow, each with the ids of the Regulars it
// cites. A Side quest is left out when a dinner it repeats is unarchived,
// a suggestion it repeats passed or a Rotation check dropped, a Regular it
// cites is renamed or archived, or an earlier Side quest of quests kept
// repeats it. store checks with this in its transaction; a notifying
// batch checks the Side quests it holds with it after each round (see
// fill).
func (s *Service) eligible(ctx context.Context, q *db.Queries, batchID string, quests []groundedQuest) ([]eligibleQuest, error) {
existing, err := q.ListUnarchivedDinners(ctx)
if err != nil {
return nil, fmt.Errorf("suggest: list dinners for dedupe: %w", err)
}
names, err := q.ListPassedNames(ctx)
if err != nil {
return nil, fmt.Errorf("suggest: list passed names for dedupe: %w", err)
}
regulars := map[string][]string{}
for _, row := range existing {
names = append(names, row.Dinner.Name)
if row.Dinner.Status == dinners.StatusRegular {
regulars[row.Dinner.Name] = append(regulars[row.Dinner.Name], row.Dinner.ID)
}
}
var kept []eligibleQuest
for _, gq := range quests {
cited, ok := citedIDs(gq.quest.CitedRegulars, regulars)
if !ok || repeats(gq.quest.Name, names) {
s.logger.InfoContext(ctx, "suggested dinner dropped: changed while grounding", "batch_id", batchID)
continue
}
names = append(names, gq.quest.Name)
kept = append(kept, eligibleQuest{groundedQuest: gq, cited: cited})
}
return kept, nil
}
// citedIDs returns the ids of the Regulars named cited, false when one
// names no Regular or more than one.
func citedIDs(cited []string, regulars map[string][]string) ([]string, bool) {
+69 -1
View File
@@ -660,6 +660,74 @@ func TestBatchKeepsAtMostBatchSize(t *testing.T) {
}
}
// TestBatchReplacesSideQuestMadeIneligibleWhileHeld: a notifying batch
// holds each round's Side quests until its last round is done; when the
// household adds a dinner repeating one held from an earlier round while a
// later round grounds, the batch lets it go and grounds a spare Side quest
// in its place, so it still stores BatchSize and announces them once.
func TestBatchReplacesSideQuestMadeIneligibleWhileHeld(t *testing.T) {
for _, trigger := range []string{suggest.TriggerScheduled, suggest.TriggerManual} {
t.Run(trigger, func(t *testing.T) {
e := newEnv(t)
e.start(t)
// Fajita Dish B is ungroundable, so the first round holds
// BatchSize-1 and a second round grounds the next; the last is
// the spare a third round grounds in place of Fajita Dish A.
var quests, want []string
for i := range suggest.BatchSize + 2 {
name := fmt.Sprintf("Fajita Dish %c", 'A'+i)
e.search.Results[name] = nil
if i != 1 {
e.recipe(t, name)
}
quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas"))
if i > 1 {
want = append(want, name)
}
}
e.reply(quests...)
// The second round's search waits, so the household adds a
// dinner repeating Fajita Dish A, held from the first round,
// while that round grounds.
second := fmt.Sprintf("Fajita Dish %c", 'A'+suggest.BatchSize)
gate := make(chan struct{})
e.search.Gates = map[string]chan struct{}{second: gate}
b, key := e.startBatch(t, trigger)
deadline := time.Now().Add(10 * time.Second)
for !slices.Contains(e.search.Queries(), second) {
if time.Now().After(deadline) {
close(gate)
t.Fatalf("search for %s never ran", second)
}
time.Sleep(5 * time.Millisecond)
}
e.dinner(t, "Fajita Dish A", "regular")
close(gate)
job := e.waitJob(t, b.JobID)
if job.Status != jobs.StatusSucceeded {
t.Fatalf("batch job %s, error %v", job.Status, job.Error)
}
var res batchResult
if err := json.Unmarshal(job.Result, &res); err != nil {
t.Fatal(err)
}
if len(res.SuggestionIDs) != suggest.BatchSize || res.Dropped != 2 {
t.Errorf("batch result = %+v, want %d stored and 2 dropped", res, suggest.BatchSize)
}
if got := e.deckNames(t); !slices.Equal(got, want) {
t.Errorf("deck = %v, want %v", got, want)
}
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
title := fmt.Sprintf("%d new Side quests are in", suggest.BatchSize)
if len(rows) != 1 || rows[0].Key != key || rows[0].Payload.Title != title {
t.Errorf("new_suggestions rows = %+v, want one keyed %s titled %q", rows, key, title)
}
})
}
}
func TestDeckIsOldestFirst(t *testing.T) {
e := newEnv(t)
e.start(t)
@@ -1157,7 +1225,7 @@ func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) {
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no draft in this test")
}
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), func() {}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), func() {}, &url.URL{Scheme: "https", Host: "meals.example.com"}, time.Now, logger)
if err != nil {
t.Fatal(err)
}
@@ -0,0 +1,9 @@
-- 0018_recipe_item_position_unique: position is the only record of recipe
-- order, so a recipe never has two ingredients or two steps at one
-- position. Writers replace a recipe's items whole, numbered from 0, and
-- the detail read orders by position alone. A database already holding a
-- duplicate fails this migration rather than keep an order nobody can
-- trust.
CREATE UNIQUE INDEX recipe_ingredients_recipe_position ON recipe_ingredients (recipe_id, position);
CREATE UNIQUE INDEX recipe_steps_recipe_position ON recipe_steps (recipe_id, position);
@@ -0,0 +1,14 @@
-- 0019_shopping_group_frozen_name: a Supply run frozen into the Supply
-- archive keeps the names its dinners had when it was frozen. A dinner can
-- be renamed at any time, so a frozen list reads each group's dinner name
-- from frozen_dinner_name, never from the dinner, as 0012 does for a Pass.
-- Only a group of a frozen list has one; a current list reads its dinners'
-- names live. A list frozen before this takes its dinners' names now.
ALTER TABLE shopping_groups ADD COLUMN frozen_dinner_name TEXT CHECK (frozen_dinner_name <> '');
UPDATE shopping_groups
SET frozen_dinner_name = (SELECT dinners.name FROM dinners WHERE dinners.id = shopping_groups.dinner_id)
WHERE list_id IN (
SELECT id FROM shopping_lists WHERE status = 'archived' OR frozen_at IS NOT NULL
);
+41
View File
@@ -0,0 +1,41 @@
-- 0020_site_name_from_page: recipes.site_name now holds only the name a
-- recipe page gives its site (its publisher's name or og:site_name), and is
-- null when the page names none; a client is credited with the link's host
-- only when dinners.SourceOf finds the link safe to show. Before this, a
-- page naming no site was stored under the host of the URL it was served
-- from, which may carry a personal token, and nothing recorded which a
-- stored name was. A stored name that could be a host is cleared here, so
-- every stored name is one a page gave: one with no space (a host has
-- none) that is dotted or holds a colon — a host name or an IP address —
-- or that appears in the recipe's own link, as a dotless host does. This
-- clears every name the read rule before it withheld, and more: a site
-- that names itself as a domain, such as "Food.com", or by a word its link
-- spells, such as "Delish" on delish.com, loses its stored name until its
-- recipe is fetched again, and is credited by its link's host meanwhile
-- when the link is safe.
--
-- Idea job results stored before sources were made safe hold the grounded
-- recipe's site name and link as they were stored, and are cleared the
-- same way: an idea's stored site name is shown as it stands. The ideas
-- array is rebuilt from a plain scan of json_each, which yields its
-- elements in array order, so the ideas keep their order (sqlc's parser
-- refuses an ORDER BY inside json_group_array).
UPDATE recipes SET site_name = NULL
WHERE instr(site_name, ' ') = 0
AND (site_name GLOB '*[.:]*' OR instr(lower(source_url), lower(site_name)) > 0);
UPDATE jobs SET result = json_set(result, '$.ideas', (
SELECT json_group_array(
CASE
WHEN instr(json_extract(idea.value, '$.site_name'), ' ') = 0
AND (json_extract(idea.value, '$.site_name') GLOB '*[.:]*'
OR instr(lower(json_extract(idea.value, '$.source_url')), lower(json_extract(idea.value, '$.site_name'))) > 0)
THEN json_set(idea.value, '$.site_name', json('null'))
ELSE json(idea.value)
END
)
FROM json_each(jobs.result, '$.ideas') AS idea
))
WHERE kind IN ('ideas_from_ingredients', 'quick_ideas')
AND json_type(result, '$.ideas') = 'array';
-62
View File
@@ -181,65 +181,3 @@ 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);
+2 -2
View File
@@ -48,7 +48,7 @@ INSERT INTO recipe_ingredients (
-- name: ListRecipeIngredients :many
SELECT * FROM recipe_ingredients
WHERE recipe_id = ?
ORDER BY position, id;
ORDER BY position;
-- name: DeleteRecipeIngredients :exec
DELETE FROM recipe_ingredients
@@ -64,7 +64,7 @@ INSERT INTO recipe_steps (
-- name: ListRecipeSteps :many
SELECT * FROM recipe_steps
WHERE recipe_id = ?
ORDER BY position, id;
ORDER BY position;
-- name: DeleteRecipeSteps :exec
DELETE FROM recipe_steps
+9 -1
View File
@@ -60,7 +60,8 @@ DELETE FROM shopping_items
WHERE id = ?;
-- name: ListShoppingGroupDinners :many
-- The groups of list list_id with their dinner's name, in list order.
-- The groups of list list_id with their dinner's name now, in list order.
-- A group of a frozen list shows its frozen_dinner_name instead.
SELECT sqlc.embed(shopping_groups), dinners.name AS dinner_name
FROM shopping_groups
JOIN dinners ON dinners.id = shopping_groups.dinner_id
@@ -117,6 +118,13 @@ UPDATE shopping_lists
SET status = 'archived', frozen_at = ?, updated_at = ?
WHERE id = ? AND frozen_at IS NULL;
-- name: FreezeShoppingGroupNames :exec
-- Keeps the name each dinner of list list_id has now as its group's
-- frozen_dinner_name, as the list is frozen into the Supply archive.
UPDATE shopping_groups
SET frozen_dinner_name = (SELECT dinners.name FROM dinners WHERE dinners.id = shopping_groups.dinner_id)
WHERE list_id = ?;
-- name: ListShoppingListsThrough :many
-- The Supply runs of the weeks starting on or before week_start, newest
-- week first.
+86
View File
@@ -0,0 +1,86 @@
// 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,8 +52,6 @@ function listed(is_favorite: boolean) {
is_favorite,
photo_url: null,
grounding_status: 'grounded',
grounding_error: null,
photo_backfill_eligible: false,
}
}
+9 -2
View File
@@ -341,6 +341,13 @@
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;
}
@@ -433,7 +440,7 @@
}
.setOut {
color: var(--color-neutral-600);
color: var(--color-neutral-700);
text-decoration: line-through;
}
@@ -515,7 +522,7 @@
}
.stepDone {
color: var(--color-neutral-600);
color: var(--color-neutral-700);
}
.stepCurrent {
+16 -1
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-600)')
expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-700)')
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(18px * var(--reading-scale))')
fireEvent.click(next)
@@ -381,6 +381,21 @@ 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()
+6 -20
View File
@@ -20,12 +20,8 @@ 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. 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.
// 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.
export type Regular = {
id: string
name: string
@@ -35,8 +31,6 @@ export type Regular = {
is_favorite: boolean
photo_id: string | undefined
grounding_status: GroundingStatus
grounding_error: string | null
photo_backfill_eligible: boolean
pending?: true
}
@@ -60,7 +54,9 @@ export type RecipeFix = { candidate_id: string } | { recipe_url: string } | { ma
export type RecipeCandidate = {
candidate_id: string
title: string
site_name: string
// site_name is null when the server cannot name the page's site without
// leaking its link (dinners.SourceOf).
site_name: string | null
photo_id: string | undefined
minutes: number | null
}
@@ -119,9 +115,6 @@ 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,
@@ -131,11 +124,6 @@ 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,
}
}
@@ -144,7 +132,7 @@ function parseCandidate(where: string, body: unknown): RecipeCandidate {
!isRecord(body) ||
typeof body.candidate_id !== 'string' ||
typeof body.title !== 'string' ||
typeof body.site_name !== 'string'
(body.site_name !== null && typeof body.site_name !== 'string')
) {
throw new Error(`${where}: response is not a recipe candidate`)
}
@@ -239,8 +227,6 @@ 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-600);
color: var(--color-neutral-700);
}
.notice {
+7 -1
View File
@@ -203,7 +203,7 @@
.swapping .dinner,
.swapping .startBy {
color: var(--color-neutral-600);
color: var(--color-neutral-700);
}
.rebuildCell {
@@ -410,6 +410,12 @@
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;
+81 -6
View File
@@ -8,6 +8,7 @@ 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'
@@ -114,8 +115,6 @@ 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,
}
}
@@ -238,6 +237,21 @@ function supplyRun(needed: number) {
}
}
// makesSupplyRun answers POST /api/v1/shopping/2026-10-05/generate with
// generate and, as the server does, GET of the week's Supply run with the
// one made once generate has made it: until then the week has none.
function makesSupplyRun(generate: () => Response): Record<string, Responder> {
let made = false
return {
'GET /api/v1/shopping/2026-10-05': () => (made ? jsonResponse(200, supplyRun(28)) : errorResponse(404, 'not_found')),
'POST /api/v1/shopping/2026-10-05/generate': () => {
const response = generate()
made ||= response.ok
return response
},
}
}
// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so
// the page opens on this week.
beforeEach(() => {
@@ -554,7 +568,7 @@ describe('PlanPage', () => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => generate(),
...makesSupplyRun(() => generate()),
})
await renderPage()
@@ -586,7 +600,7 @@ describe('PlanPage', () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
...makesSupplyRun(() => jsonResponse(200, { ...supplyRun(28), skipped: [] })),
})
await renderPage()
@@ -671,7 +685,7 @@ describe('PlanPage', () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
...makesSupplyRun(() => jsonResponse(200, { ...supplyRun(28), skipped: [] })),
})
const queryClient = createQueryClient()
await renderPage(queryClient, <SupplyRunScreen />)
@@ -1224,7 +1238,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-600)')
expect(declaredStyle(within(friday).getByText('Pizza night')).color).toBe('var(--color-neutral-700)')
// "Quest log · N | Regulars · N", on the Quest log first.
expect(await within(sheet).findByRole('radio', { name: 'Quest log · 1', checked: true })).toBeInTheDocument()
@@ -1393,6 +1407,64 @@ describe('PlanPage', () => {
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
})
it("still shows a swap's failure on coming back to the week, through another, and asks for the swap again only on Retry", async () => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
[`GET /api/v1/plans/2026-10-19`]: () => errorResponse(404, 'not_found'),
...lists,
[`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_not_plannable'),
})
await renderPage()
fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ }))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message")
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
await waitFor(() => expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-19'))
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
await waitFor(() => expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-12'))
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message")
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
expect(puts(fetchMock)).toHaveLength(1)
})
it('reads the plan again when the dinner is already on another day, and offers to choose another rather than send it again', async () => {
// Meanwhile lemon orzo was planned on Wednesday.
let read = planBody('draft')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, read),
...lists,
[`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_already_planned'),
})
await renderPage()
const sheet = await openFriday()
read = planBody('draft')
read.days[2] = { ...read.days[2]!, dinner: { ...read.days[2]!.dinner, id: 'q1', name: 'Lemon orzo' }, is_new: true }
fireEvent.click(await within(sheet).findByRole('button', { name: /Lemon orzo/ }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't swap Friday's dinner. Lemon orzo is already on another day this week.")
expect(within(alert).queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
await waitFor(() => expect(within(tile('WED · NEW')).getByText('Lemon orzo')).toBeInTheDocument())
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
// Choosing again opens Friday's sheet on the plan read, where lemon
// orzo is already this week; nothing is sent again.
fireEvent.click(within(alert).getByRole('button', { name: 'Choose another dinner' }))
const again = screen.getByRole('dialog', { name: 'Swap Friday' })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
const orzo = within(within(again).getByRole('list', { name: 'Quest log' })).getByRole('listitem')
expect(within(orzo).getByText('Already this week')).toBeInTheDocument()
expect(within(orzo).queryByRole('button')).not.toBeInTheDocument()
expect(puts(fetchMock)).toHaveLength(1)
})
it('waits for a pending swap before Rebuild week, keeping its failure', async () => {
let refuse!: () => void
const fetchMock = stubServer({
@@ -1646,6 +1718,9 @@ 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()
+26 -5
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, type Breakpoint } from '../shell/useBreakpoint.ts'
import { useBreakpoint, useOrientation, 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'
@@ -127,6 +127,12 @@ export function ago(at: Date, now: Date): string {
return days === 1 ? '1 day ago' : `${days} days ago`
}
// alreadyPlanned says whether a swap was refused because its dinner is
// already on another day of the week: sending it again cannot succeed.
function alreadyPlanned(error: Error): boolean {
return error instanceof ApiError && error.code === 'dinner_already_planned'
}
// pushedToCalendar says whether drafting over a plan of this status needs
// confirm: it was pushed to Calendar.
function pushedToCalendar(status: PlanStatus): boolean {
@@ -398,9 +404,18 @@ function WeekBoard({
</ErrorBanner>
)
) : swap.failed !== null ? (
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
</ErrorBanner>
alreadyPlanned(swap.failed.error) ? (
// The plan is read again (useSwapDay): the household chooses another
// dinner from it rather than sending the refused swap again.
<ErrorBanner onRetry={() => openSwap(swap.failed!.swap.date)} retryLabel="Choose another dinner">
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.swap.dinner.name} is already on another day
this week.
</ErrorBanner>
) : (
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
</ErrorBanner>
)
) : startDraft.isError ? (
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
) : result.error !== null ? (
@@ -673,8 +688,10 @@ 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)
@@ -699,7 +716,11 @@ export function PlanPage() {
}
return (
<PlanDragProvider>
<div className={`${styles.page} ${styles[`${layout}Page`]}`}>
<div
className={[styles.page, styles[`${layout}Page`], layout === 'tablet' && orientation === 'portrait' && styles.portraitPage]
.filter(Boolean)
.join(' ')}
>
<div className={styles.board}>
{content}
{layout === 'tablet' && <QuestLogStrip />}
+5
View File
@@ -190,7 +190,12 @@
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-600);
color: var(--color-neutral-700);
}
.skeletonRow {
+132
View File
@@ -127,6 +127,17 @@ describe('usePlan', () => {
['a method it does not know', (body: ReturnType<typeof planBody>) => (body.days[0]!.dinner.method = 'microwave'), 'method "microwave"'],
['no method', (body: ReturnType<typeof planBody>) => Reflect.deleteProperty(body.days[0]!.dinner, 'method'), 'method undefined'],
['a sync time that is no instant', (body: ReturnType<typeof planBody>) => (body.calendar_synced_at = 'yesterday'), 'calendar_synced_at "yesterday"'],
// Date reads both, in this device's zone: neither says which instant.
[
'a sync time with no offset',
(body: ReturnType<typeof planBody>) => (body.calendar_synced_at = '2026-10-07T15:58:00'),
'calendar_synced_at "2026-10-07T15:58:00" is not an instant',
],
[
'a sync date with no time',
(body: ReturnType<typeof planBody>) => (body.calendar_synced_at = '2026-10-07'),
'calendar_synced_at "2026-10-07" is not an instant',
],
])('refuses a plan with %s', async (_, change, said) => {
const body = planBody()
change(body)
@@ -208,6 +219,72 @@ describe('useSwapDay', () => {
expect(result.current.swap.failed?.error.message).toBe('dinner_not_plannable message')
})
it('leaves a day a later plan read landed, the very day the swap wrote, when the swap of it fails', async () => {
const friday = deferred()
let read = planBody()
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, read),
[`PUT ${FRIDAY}`]: () => friday.reply,
})
const { result, plan, queryClient } = await renderSwaps()
// The read finds Friday lemon orzo, field for field the day the swap
// wrote: the server has it, whatever this swap's PUT is answered with.
const swapped = { date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: null }
act(() => result.current.swap.mutate(ORZO))
await waitFor(() => expect(plan().days[4]).toEqual(swapped))
read = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })
await act(() => result.current.plan.refetch())
await act(async () => friday.send(errorResponse(500, 'internal')))
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
expect(plan().days[4]).toEqual(swapped)
expect(result.current.swap.failed?.error.message).toBe('internal message')
})
it('puts the day back when the swap of it fails with no plan read since, after a later swap of another day', async () => {
const friday = deferred()
const monday = deferred()
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
[`PUT ${FRIDAY}`]: () => friday.reply,
[`PUT ${MONDAY}`]: () => monday.reply,
})
const { result, plan, queryClient } = await renderSwaps()
act(() => result.current.swap.mutate(ORZO))
act(() => result.current.swap.mutate(FISH))
await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos'))
await act(async () => friday.send(errorResponse(500, 'internal')))
await waitFor(() => expect(plan().days[4]!.dinner.name).toBe('Dinner 4'))
expect(plan().days[0]!.dinner.name).toBe('Fish tacos')
await act(async () => monday.send(errorResponse(500, 'internal')))
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
expect(plan().days.map((day) => day.dinner.name)).toEqual(planBody().days.map((day) => day.dinner.name))
})
it('reads the plan again when the dinner is already on another day of the week, and sends the swap only once', async () => {
let read = planBody()
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, read),
[`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_already_planned'),
})
const { result, plan, queryClient } = await renderSwaps()
// Meanwhile lemon orzo was planned on Wednesday.
read = planBody({ 2: { id: 'q1', name: 'Lemon orzo', is_new: true } })
act(() => result.current.swap.mutate(ORZO))
await waitFor(() => expect(plan().days[2]!.dinner.name).toBe('Lemon orzo'))
expect(plan().days[4]!.dinner.name).toBe('Dinner 4')
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
expect(result.current.swap.failed?.error.message).toBe('dinner_already_planned message')
const sent = vi.mocked(fetch).mock.calls.map(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}`)
expect(sent.filter((call) => call === `PUT ${FRIDAY}`)).toHaveLength(1)
expect(sent.filter((call) => call === `GET ${PLAN}`)).toHaveLength(2)
})
it('refuses to dismiss a pending swap', async () => {
const friday = deferred()
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), [`PUT ${FRIDAY}`]: () => friday.reply })
@@ -353,6 +430,61 @@ describe('week mutations', () => {
expect(weekMutations(queryClient, LATER)).toEqual([])
})
it("leaves behind a week's settled swaps but for the failure that speaks for it, which stays until its week leaves it behind", async () => {
let refuse = true
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
[`PUT ${FRIDAY}`]: () => (refuse ? errorResponse(409, 'dinner_not_plannable') : jsonResponse(200, planBody())),
[`PUT ${MONDAY}`]: () => jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } })),
[`GET /api/v1/plans/${NEXT}`]: () => errorResponse(404, 'not_found'),
[`GET /api/v1/plans/${LATER}`]: () => errorResponse(404, 'not_found'),
})
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const queryClient = createQueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ApiProvider>
)
const hook = renderHook(({ week }) => ({ plan: usePlan(week), swap: useSwapDay(week) }), {
wrapper,
initialProps: { week: WEEK },
})
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
// Two swaps settle while the week is shown: the earlier is needed for
// ordering no longer, and only the latest stays.
act(() => hook.result.current.swap.mutate(FISH))
act(() => hook.result.current.swap.mutate(ORZO))
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap error']))
// Leaving the week, through two others, keeps the failure alone.
for (const week of [NEXT, LATER, NEXT]) {
hook.rerender({ week })
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
expect(weekMutations(queryClient, WEEK)).toEqual(['swap error'])
}
// Coming back shows it; it fails again, and again one stays.
hook.rerender({ week: WEEK })
expect(hook.result.current.swap.failed?.error.message).toBe('dinner_not_plannable message')
act(() => hook.result.current.swap.dismiss())
act(() => hook.result.current.swap.mutate(ORZO))
await waitFor(() => expect(hook.result.current.swap.failed).not.toBeNull())
expect(weekMutations(queryClient, WEEK)).toEqual(['swap error'])
// A swap that succeeds is left behind once the week is.
refuse = false
act(() => hook.result.current.swap.dismiss())
act(() => hook.result.current.swap.mutate(ORZO))
await waitFor(() => expect(hook.result.current.swap.succeeded).not.toBeNull())
expect(weekMutations(queryClient, WEEK)).toEqual(['swap success'])
hook.rerender({ week: NEXT })
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
expect(weekMutations(queryClient, WEEK)).toEqual([])
})
it('keeps a push whose Supply run was not made, settled in a week no longer shown, to be shown on coming back', async () => {
const nextPush = deferred()
stubServer({
+102 -35
View File
@@ -1,5 +1,14 @@
import { useEffect } from 'react'
import { useMutation, useMutationState, useQuery, useQueryClient, type Mutation, type QueryClient } from '@tanstack/react-query'
import {
useMutation,
useMutationState,
useQuery,
useQueryClient,
type Mutation,
type Query,
type QueryClient,
type QueryKey,
} from '@tanstack/react-query'
import { ApiError, useApi, type ApiRead } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
@@ -83,10 +92,16 @@ function parseDay(where: string, body: unknown): PlanDay {
return { date: body.date, dinner, is_new: body.is_new, start_by: body.start_by }
}
// RFC3339 is an RFC 3339 instant as the server writes it: a full date and
// time, to the second or finer, and its offset, Z or ±hh:mm. A date alone,
// or a time with no offset, which Date would read in this device's zone, is
// not one.
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
// syncedAt reads calendar_synced_at, an RFC 3339 instant or null.
function syncedAt(where: string, value: unknown): Date | null {
if (value === null) return null
const at = typeof value === 'string' ? new Date(value) : null
const at = typeof value === 'string' && RFC3339.test(value) ? new Date(value) : null
if (at === null || Number.isNaN(at.getTime())) {
throw new Error(`${where}: calendar_synced_at ${JSON.stringify(value)} is not an instant`)
}
@@ -264,38 +279,54 @@ const weeksShown = new WeakMap<QueryClient, WeeksShown>()
// time a week is shown, and is never removed, so a swap or push that settles
// after its Draft board, or the whole Plan, is gone is still collected.
//
// When a week is shown, the settled ones of every other week are removed,
// unless that week still has one pending: a pending one, and those an
// earlier pending one must see, stay across leaving its week and coming
// back. When one settles, the same is done, except that a week's latest,
// when it failed, stays until a week is next shown, so that coming straight
// back to it shows the failure. A week's latest push whose Supply run was
// not made stays whatever is shown: the week has no shopping list until one
// is made, which settles the push, or a push sent since replaces it, and
// making it may still be under way when the week is left. The cache holds
// at most the weeks shown's, those of weeks left pending, one push of each
// week whose Supply run was not made, and one failure of each week whose
// last pending one settled since a week was last shown.
// A swap or push pending is kept, with every other of its kind and week:
// an earlier pending one must see those sent after it, across leaving its
// week and coming back. Once none of a kind and week is pending, only the
// latest is needed, and the rest are removed, whenever a week is shown or
// one settles. The latest itself stays while its week is shown. In a week
// not shown it is removed, unless:
//
// - it is a failed swap: it stays, whatever is shown, until the week's
// board leaves it behind (useWeekSwap, Rebuild week, Push to Calendar),
// so coming back to the week shows the failure;
// - it is a push whose Supply run was not made: the week has no shopping
// list until one is made, which settles the push, or a push sent since
// replaces it, and making it may still be under way when the week is
// left;
// - it is a failed push that settled after its week was left: it stays
// until a week is next shown, so coming straight back shows it.
//
// So once none of its kind is pending, a week keeps at most one settled
// swap and one settled push, and a week left keeps none but those above:
// settled swaps and pushes never accumulate.
function keepWeekMutations(queryClient: QueryClient): WeeksShown {
const kept = weeksShown.get(queryClient)
if (kept !== undefined) return kept
const mutations = queryClient.getMutationCache()
// How many times each week is shown at once.
const shown = new Map<string, number>()
// keep says whether the latest of its kind and week, none of which is
// pending, stays; settled says a mutation settling swept, not a week
// shown.
function keep(mutation: Mutation, of: { kind: 'swap' | 'push'; week: string }, settled: boolean): boolean {
if (shown.has(of.week)) return true
if (of.kind === 'swap') return failed(mutation)
return unmade(mutation) || (settled && failed(mutation))
}
function sweep(settled: boolean) {
// The mutations of each kind of each week not shown, in the order sent.
const left = new Map<string, Mutation[]>()
// The mutations of each kind of each week, in the order sent.
const sent = new Map<string, { of: { kind: 'swap' | 'push'; week: string }; week: Mutation[] }>()
for (const mutation of mutations.getAll()) {
const of = weekMutation(mutation)
if (of === null || shown.has(of.week)) continue
if (of === null) continue
const id = `${of.kind} ${of.week}`
left.set(id, [...(left.get(id) ?? []), mutation])
sent.set(id, { of, week: [...(sent.get(id)?.week ?? []), mutation] })
}
for (const week of left.values()) {
for (const { of, week } of sent.values()) {
if (week.some((mutation) => mutation.state.status === 'pending')) continue
const latest = week.at(-1)
for (const mutation of week) {
if (mutation === latest && (unmade(mutation) || (settled && failed(mutation)))) continue
if (mutation === latest && keep(mutation, of, settled)) continue
mutations.remove(mutation)
}
}
@@ -324,6 +355,30 @@ function useWeekShown(weekStart: string) {
useEffect(() => keepWeekMutations(queryClient).show(weekStart), [queryClient, weekStart])
}
// swapWrites counts, for each plan query, its updates that were a swap's
// own optimistic write or rollback, not a plan read from the server.
const swapWrites = new WeakMap<Query, number>()
// serverReads is how many plans read from the server have landed in the
// plan query: its updates, less the swaps' own writes. A plan read lands
// whether or not it differs from the plan shown.
function serverReads(query: Query): number {
return query.state.dataUpdateCount - (swapWrites.get(query) ?? 0)
}
// writeSwap updates the plan query as a swap's own write, which serverReads
// does not count.
function writeSwap(queryClient: QueryClient, query: Query, update: (plan: Plan | null | undefined) => Plan | null | undefined) {
const before = query.state.dataUpdateCount
queryClient.setQueryData<Plan | null>(query.queryKey, update)
swapWrites.set(query, (swapWrites.get(query) ?? 0) + query.state.dataUpdateCount - before)
}
// planQuery is the plan query keyed key, if the query cache has it.
function planQuery(queryClient: QueryClient, key: QueryKey): Query | undefined {
return queryClient.getQueryCache().find({ queryKey: key, exact: true })
}
// swapKey keys the swaps of the week starting on weekStart in the mutation
// cache, where they outlive the Draft board that sent them.
function swapKey(weekStart: string) {
@@ -345,9 +400,13 @@ export type SwapDay = {
// (PUT /api/v1/plans/{weekStart}/days/{date}). The day shows the dinner at
// once, NEW when it came from the Quest log, with no start-by time until the
// server answers with the plan as swapped, which then replaces it. A refused
// swap puts the day back as it was, unless it has changed again meanwhile.
// However the swap ends, the Quest log is read again: the server leaves a
// dinner on the plan out of it.
// swap puts the day back as it was, unless it has changed again meanwhile:
// another swap wrote over it, or any plan read from the server landed
// since, even one with the very day the swap wrote. A swap refused because
// the dinner is already on another day of the week (409
// dinner_already_planned) reads the plan again, so the household chooses
// again from the plan as it is. However the swap ends, the Quest log is
// read again: the server leaves a dinner on the plan out of it.
//
// The week's swaps live in the mutation cache, not in the component, so
// leaving the week and coming back keeps them: pending and failed are read
@@ -374,7 +433,7 @@ export function useSwapDay(weekStart: string): SwapDay {
function mutate(chosen: Swap) {
// isLatest says whether no swap of the week was sent after this one.
const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId)
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(queryClient, {
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay; query: Query; reads: number }>(queryClient, {
mutationKey,
// Kept until dismissed or settled in a week not shown
// (keepWeekMutations): a later swap must stay in the cache while an
@@ -387,24 +446,32 @@ export function useSwapDay(weekStart: string): SwapDay {
},
onMutate: async ({ date, dinner, fromQuestLog }) => {
await queryClient.cancelQueries({ queryKey: key })
const plan = queryClient.getQueryData<Plan | null>(key)
const query = planQuery(queryClient, key)
const plan = query?.state.data as Plan | null | undefined
const previous = plan?.days.find((day) => day.date === date)
if (!plan || previous === undefined) throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`)
if (query === undefined || !plan || previous === undefined) {
throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`)
}
const swapped: PlanDay = { date, dinner, is_new: fromQuestLog, start_by: null }
queryClient.setQueryData<Plan>(key, { ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) })
return { previous, swapped }
writeSwap(queryClient, query, () => ({ ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) }))
return { previous, swapped, query, reads: serverReads(query) }
},
onSuccess: (plan) => {
if (isLatest()) queryClient.setQueryData<Plan>(key, plan)
},
// The day goes back only while it is still, field for field, the day
// this swap wrote: a later swap or plan read that replaced it is left
// alone.
onError: (_error, _swap, context) => {
if (context === undefined) return
queryClient.setQueryData<Plan | null>(key, (plan) =>
plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) },
)
// this swap wrote, and no plan read from the server has landed since
// (serverReads): a later swap that replaced it, or a read that did,
// even with the same day, is left alone.
onError: (error, _swap, context) => {
if (context !== undefined && planQuery(queryClient, key) === context.query && serverReads(context.query) === context.reads) {
writeSwap(queryClient, context.query, (plan) =>
plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) },
)
}
if (error instanceof ApiError && error.code === 'dinner_already_planned') {
void queryClient.invalidateQueries({ queryKey: key })
}
},
onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
})
@@ -300,27 +300,3 @@
.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;
}
+3 -205
View File
@@ -85,11 +85,7 @@ 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',
photo_backfill_eligible: true,
})
const PIZZA = dinnerBody('d3', 'Pizza night', { grounding_status: 'failed', grounding_error: 'no_recipe_found' })
const ROAST = dinnerBody('d4', 'Pot roast', { method: 'oven', grounding_status: 'manual' })
function rows() {
@@ -348,7 +344,7 @@ describe('RegularsSettingsPage', () => {
minutes: 45,
},
{ candidate_id: 'https://cook.test/pizza', title: 'Sheet-pan pizza', site_name: 'Cook Test', photo_url: null, minutes: null },
{ candidate_id: 'https://food.test/p', title: 'Pizza margherita', site_name: 'Food Test', photo_url: null, minutes: 30 },
{ candidate_id: 'https://food.test/p', title: 'Pizza margherita', site_name: null, photo_url: null, minutes: 30 },
],
}
@@ -376,7 +372,7 @@ describe('RegularsSettingsPage', () => {
expect(options.map((option) => option.getAttribute('aria-label'))).toEqual([
'Use Weeknight pizza from Example Kitchen',
'Use Sheet-pan pizza from Cook Test',
'Use Pizza margherita from Food Test',
'Use Pizza margherita',
])
expect(within(options[0]!).getByRole('img', { name: 'Weeknight pizza' })).toHaveAttribute('src', '/media/photos/p7-400.jpg')
expect(within(options[0]!).getByText('Example Kitchen')).toBeInTheDocument()
@@ -500,204 +496,6 @@ 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.
+16 -150
View File
@@ -34,7 +34,6 @@ 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.
@@ -58,8 +57,7 @@ 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. Find
// missing photos searches again for every dinner left without one.
// edits or removes it, or fixes its recipe when none was found.
export function RegularsSettingsPage() {
const navigate = useNavigate()
return (
@@ -82,13 +80,15 @@ export function RegularsSettingsPage() {
// Which sheet is open, for which Regular.
type Open = { sheet: 'edit' | 'fix'; id: string } | null
function RegularsManager() {
// 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() {
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,10 +97,8 @@ 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, and one whose recipe was
// found meanwhile closes its fix-recipe sheet.
// A Regular removed meanwhile closes its 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()
@@ -193,7 +191,6 @@ function RegularsManager() {
)}
</section>
)}
{regulars.isSuccess && <FindMissingPhotos backfill={backfill} />}
{opened && open.sheet === 'edit' && (
<EditRegularSheet
@@ -203,13 +200,12 @@ function RegularsManager() {
onRemove={() => onRemove(opened)}
/>
)}
{fixing && (
{opened && open.sheet === 'fix' && (
<FixRecipeSheet
key={fixing.id}
regular={fixing}
backfill={backfill}
key={opened.id}
regular={opened}
onClose={() => setOpen(null)}
onEdit={() => setOpen({ sheet: 'edit', id: fixing.id })}
onEdit={() => setOpen({ sheet: 'edit', id: opened.id })}
/>
)}
</>
@@ -238,88 +234,6 @@ 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
@@ -536,7 +450,6 @@ const fixPaths = [
type FixRecipeSheetProps = {
regular: Regular
backfill: PhotoBackfill
onClose: () => void
onEdit: () => void
}
@@ -549,20 +462,10 @@ function lines(text: string): string[] {
.filter((line) => line !== '')
}
// 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) {
// 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) {
const [path, setPath] = useState<FixPath>('pick')
const ground = useGroundRegular()
@@ -575,15 +478,6 @@ function FixRecipeSheet({ regular, backfill, onClose, onEdit }: FixRecipeSheetPr
<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}
@@ -613,34 +507,6 @@ function FixRecipeSheet({ regular, backfill, onClose, onEdit }: FixRecipeSheetPr
)
}
// 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} />
@@ -661,13 +527,13 @@ function PickCandidate({ id, onPick }: { id: string; onPick: (candidateId: strin
<button
type="button"
className={styles.candidate}
aria-label={`Use ${candidate.title} from ${candidate.site_name}`}
aria-label={candidate.site_name === null ? `Use ${candidate.title}` : `Use ${candidate.title} from ${candidate.site_name}`}
onClick={() => onPick(candidate.candidate_id)}
>
<Photo id={candidate.photo_id} name={candidate.title} radius="14px" sizes="56px" className={styles.candidatePhoto} />
<span className={styles.candidateBody}>
<span className={styles.candidateTitle}>{candidate.title}</span>
<span className={styles.candidateSite}>{candidate.site_name}</span>
{candidate.site_name !== null && <span className={styles.candidateSite}>{candidate.site_name}</span>}
</span>
</button>
</li>
+4 -1
View File
@@ -104,7 +104,10 @@ 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',
@@ -117,7 +120,7 @@ describe('SettingsPage', () => {
})
it('shows the not-yet-available states while the server has no endpoint for them', async () => {
stubSettings()
stubSettings({ [REGULARS_PATH]: () => errorResponse(404, 'not_found') })
renderSettings()
expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument()
expect(
+6 -21
View File
@@ -83,6 +83,12 @@
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;
@@ -95,24 +101,3 @@
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);
}
+124 -10
View File
@@ -11,9 +11,11 @@ import {
HOUSEHOLD_SETTINGS,
NOTIFICATION_SETTINGS,
RECIPE_SEARCH_SETTINGS,
SETTINGS_REGULARS,
} from '../test/ApiProviders.tsx'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
import { fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { SettingsPage } from './SettingsPage.tsx'
@@ -32,6 +34,7 @@ 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) => {
@@ -121,7 +124,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(within(panel('Regular dinners')).getByRole('region', { name: 'Regular dinners' })).toBeInTheDocument()
expect(await within(panel('Regular dinners')).findByRole('heading', { name: 'On rotation · 1' })).toBeInTheDocument()
expect(panel('Household')).not.toBeVisible()
fireEvent.keyDown(tab('Regular dinners'), { key: 'End' })
@@ -165,6 +168,123 @@ 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 }
@@ -195,10 +315,7 @@ 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/dinners?status=regular': () => errorResponse(404, 'not_found'),
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
})
stubAPI({ '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()
@@ -209,10 +326,7 @@ 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/dinners?status=regular': () => errorResponse(404, 'not_found'),
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
})
stubAPI({ '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' },
+10 -23
View File
@@ -1,10 +1,11 @@
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
import { Card } from '../ui/Card.tsx'
import { useBreakpoint, useOrientation } from '../shell/useBreakpoint.ts'
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'
@@ -21,7 +22,10 @@ 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)
@@ -64,7 +68,10 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
}
return (
<div className={styles.page} hidden={hidden}>
<div
className={[styles.page, layout === 'tablet' && orientation === 'portrait' && styles.portrait].filter(Boolean).join(' ')}
hidden={hidden}
>
<header className={styles.header}>
<div className={styles.heading}>
<h1 className={styles.title}>Settings</h1>
@@ -94,13 +101,7 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
{panel('household', styles.single, <HouseholdSettingsSections onDirtyChange={setHouseholdDirty} />)}
{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('regulars', styles.single, <RegularsManager />)}
{panel(
'notifications-ai',
@@ -121,17 +122,3 @@ 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
@@ -1,63 +0,0 @@
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
@@ -1,110 +0,0 @@
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,3 +22,9 @@
.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;
}
+5 -3
View File
@@ -2,15 +2,17 @@ import { Outlet } from 'react-router'
import { BottomBar } from './BottomBar.tsx'
import { Rail } from './Rail.tsx'
import { Sidebar } from './Sidebar.tsx'
import { useBreakpoint } from './useBreakpoint.ts'
import { useBreakpoint, useOrientation } 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.
// scrolling main area for the routed page. A portrait viewport is marked so
// the narrower upright tablet keeps its content clear of the rail.
export function AppShell() {
const breakpoint = useBreakpoint()
const orientation = useOrientation()
return (
<div className={`${styles.shell} ${styles[breakpoint]}`}>
<div className={[styles.shell, styles[breakpoint], orientation === 'portrait' && styles.portrait].filter(Boolean).join(' ')}>
{breakpoint === 'mobile' && <BottomBar />}
{breakpoint === 'tablet' && <Rail />}
{breakpoint === 'desktop' && <Sidebar />}
+6
View File
@@ -48,6 +48,12 @@
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,6 +182,18 @@ 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
@@ -0,0 +1,225 @@
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,6 +7,12 @@ 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 {
@@ -29,3 +35,16 @@ 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'))
}
+57 -26
View File
@@ -10,6 +10,7 @@ import { mockViewport } from '../test/matchMedia.ts'
import type { ArchivePost } from './api.ts'
import { PAGE, SupplyArchivePage } from './SupplyArchivePage.tsx'
import styles from './SupplyArchivePage.module.css'
import { SupplyRunPage } from './SupplyRunPage.tsx'
const ARCHIVE = (by: string) => `/api/v1/shopping/archive?by=${by}`
@@ -24,13 +25,14 @@ function post(weekStart: string, label: string, overrides: Partial<ArchivePost>
onHandCount: 4,
dinners: ['Beef tacos', 'Tikka masala', 'Pizza', 'Pot roast', 'Orzo', 'Chili', 'Salmon'],
current: false,
frozen: true,
...overrides,
}
}
// The posts of mock 1m: this week's titled post, then the weeks across the
// September–October boundary and the 2025–2026 one, the last untitled.
const WEEK_41 = post('2026-10-05', 'Week 41 · Oct 5 – 11', { title: 'Tacos, tikka & a salmon finale', current: true })
const WEEK_41 = post('2026-10-05', 'Week 41 · Oct 5 – 11', { title: 'Tacos, tikka & a salmon finale', current: true, frozen: false })
const WEEK_40 = post('2026-09-28', 'Week 40 · Sep 28 – Oct 4', { title: 'Pizza, pot roast & the great orzo trial', itemCount: 26 })
const WEEK_39 = post('2026-09-21', 'Week 39 · Sep 21 – 27', { title: 'Chili season opens', dinnerCount: 1, itemCount: 1, onHandCount: 0, dinners: ['Chili'] })
const WEEK_1 = post('2025-12-29', 'Week 1 · Dec 29 – Jan 4')
@@ -83,8 +85,9 @@ function stubServer(responders: Record<string, Responder>) {
}
// renderPage loads /me (for the CSRF token any write would need, so a
// missing PATCH is not for want of it) and renders the Supply archive at path.
async function renderPage(path = '/plan/shopping/archive') {
// missing PATCH is not for want of it) and renders the Supply archive at path,
// with supplyRun at the Supply run's route.
async function renderPage(path = '/plan/shopping/archive', supplyRun = <p>Supply run screen</p>) {
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
@@ -93,7 +96,7 @@ async function renderPage(path = '/plan/shopping/archive') {
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/plan/shopping/archive" element={<SupplyArchivePage />} />
<Route path="/plan/shopping" element={<p>Supply run screen</p>} />
<Route path="/plan/shopping" element={supplyRun} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
@@ -137,7 +140,7 @@ describe('SupplyArchivePage', () => {
expect(within(postCard('Chili season opens')).queryByText(/^\+/)).not.toBeInTheDocument()
expect(screen.getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([
'/plan/shopping/archive?week=2026-10-05',
'/plan/shopping?week=2026-10-05',
'/plan/shopping/archive?week=2026-09-28',
'/plan/shopping/archive?week=2026-09-21',
'/plan/shopping/archive?week=2025-12-29',
@@ -154,7 +157,7 @@ describe('SupplyArchivePage', () => {
for (const period of BY.week) {
const section = screen.getByRole('region', { name: period.heading })
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
period.posts.map((p) => (p.frozen ? `/plan/shopping/archive?week=${p.weekStart}` : `/plan/shopping?week=${p.weekStart}`)),
)
}
})
@@ -182,7 +185,7 @@ describe('SupplyArchivePage', () => {
for (const period of BY[by]) {
const section = screen.getByRole('region', { name: period.heading })
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
period.posts.map((p) => (p.frozen ? `/plan/shopping/archive?week=${p.weekStart}` : `/plan/shopping?week=${p.weekStart}`)),
)
}
})
@@ -366,38 +369,66 @@ describe('SupplyArchivePage frozen post', () => {
expect(await screen.findByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument()
})
it('opens the current week’s post read-only too, tagged Archived, with no checkbox, nothing to send and no PATCH', async () => {
const fetchMock = stubServer({
it('opens the current week’s post, not yet frozen, in the Supply run, where it can still change', async () => {
stubServer({
[`GET ${ARCHIVE('week')}`]: archive('week'),
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
})
await renderPage()
await renderPage('/plan/shopping/archive', <SupplyRunPage />)
fireEvent.click(await screen.findByText('Tacos, tikka & a salmon finale'))
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(screen.getByText("This week's list as it stands, read-only in the Supply archive.")).toBeInTheDocument()
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
expect(screen.queryAllByRole('checkbox')).toEqual([])
fireEvent.click(screen.getByText('Corn tortillas'))
fireEvent.click(screen.getByText('Limes'))
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeEnabled()
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
})
it('on desktop opens the current week’s post read-only, and with no post open shows this week’s Supply run to change', async () => {
it('marks a past week’s post not yet frozen Still open and opens it in the Supply run, where it can still change', async () => {
const open = { ...WEEK_40, frozen: false }
stubServer({
[`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'week', periods: [{ key: '2026-W40', heading: open.label, posts: [open] }] }),
'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, { ...FROZEN, status: 'current' }),
})
await renderPage('/plan/shopping/archive', <SupplyRunPage />)
await screen.findByText('Pizza, pot roast & the great orzo trial')
const card = postCard('Pizza, pot roast & the great orzo trial')
expect(within(card).getByText('Week 40 · Sep 28 – Oct 4 · Still open')).toHaveClass(styles.kicker!)
expect(card).not.toHaveClass(styles.current!)
expect(card).toHaveAttribute('href', '/plan/shopping?week=2026-09-28')
fireEvent.click(card)
expect(await screen.findByRole('checkbox', { name: 'Pizza dough' })).toBeEnabled()
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
})
it('marks no frozen post Still open', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
await screen.findByText('Chili season opens')
expect(screen.queryByText(/Still open/)).not.toBeInTheDocument()
})
it('opens a saved link to a past week’s list not yet frozen in the editable Supply run, not read-only', async () => {
stubServer({ 'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, { ...FROZEN, status: 'current' }) })
await renderPage('/plan/shopping/archive?week=2026-09-28', <SupplyRunPage />)
expect(await screen.findByRole('checkbox', { name: 'Pizza dough' })).toBeEnabled()
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
expect(screen.getByText("Uncheck anything you already have — it won't be counted.")).toBeInTheDocument()
})
it('on desktop opens a saved link to the current week’s list, not yet frozen, in the editable Supply run, as with no post open', async () => {
mockViewport(1440)
const fetchMock = stubServer({
stubServer({
[`GET ${ARCHIVE('week')}`]: archive('week'),
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
})
const view = await renderPage('/plan/shopping/archive?week=2026-10-05')
const view = await renderPage('/plan/shopping/archive?week=2026-10-05', <SupplyRunPage />)
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
expect(screen.queryAllByRole('checkbox')).toEqual([])
expect(screen.queryByText('Send to Google Chat')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Corn tortillas'))
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeEnabled()
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
expect(screen.queryByRole('complementary', { name: 'Supply archive' })).not.toBeInTheDocument()
view.unmount()
await renderPage()
+16 -11
View File
@@ -9,9 +9,9 @@ import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { useSupplyArchive, WEEK_PARAM, type ArchiveGroup, type ArchivePeriod, type ArchivePost } from './api.ts'
import { supplyRunScreen, useSupplyArchive, WEEK_PARAM, type ArchiveGroup, type ArchivePeriod, type ArchivePost } from './api.ts'
import styles from './SupplyArchivePage.module.css'
import { SUPPLY_ARCHIVE, SupplyRunPage } from './SupplyRunPage.tsx'
import { SUPPLY_ARCHIVE, SUPPLY_RUN, SupplyRunPage } from './SupplyRunPage.tsx'
// PAGE is how many posts the Supply archive shows at first, and how many
// more each "Load older" shows.
@@ -38,15 +38,20 @@ function plural(n: number, one: string, many: string): string {
// PostCard is one week's Supply run (mock 1m): the week label as its kicker,
// the AI's title — the week label while it has none — the counts and its
// first dinners as chips. The current week's post is ringed in grape. It
// opens the week's list, read-only.
// first dinners as chips. The current week's post is ringed in grape. A
// frozen post opens the week's list here, read-only; a post not yet frozen —
// this week's, or a past week's the week rollover has not frozen — is marked
// Current or Still open and opens the Supply run, where it can still change.
function PostCard({ post }: { post: ArchivePost }) {
const rest = post.dinners.length - CHIPS
return (
<Link to={archivePost(post.weekStart)} className={[styles.post, post.current && styles.current].filter(Boolean).join(' ')}>
<Link
to={post.frozen ? archivePost(post.weekStart) : supplyRunScreen(post.weekStart)}
className={[styles.post, post.current && styles.current].filter(Boolean).join(' ')}
>
<span className={styles.kicker}>
{post.label}
{post.current && ' · Current'}
{post.current ? ' · Current' : !post.frozen && ' · Still open'}
</span>
<span className={styles.postTitle}>{post.title ?? post.label}</span>
<span className={styles.meta}>
@@ -145,7 +150,7 @@ function SupplyArchive({ panel }: { panel: boolean }) {
<div className={styles.archive}>
<header className={styles.header}>
{!panel && (
<IconButton label="Back to the Supply run" onClick={() => void navigate('/plan/shopping')}>
<IconButton label="Back to the Supply run" onClick={() => void navigate(SUPPLY_RUN)}>
<Icon icon={ChevronLeft} size="nav" />
</IconButton>
)}
@@ -157,10 +162,10 @@ function SupplyArchive({ panel }: { panel: boolean }) {
)
}
// SupplyArchivePage is /plan/shopping/archive. A post opens it on its week
// (?week=), whose list the Supply run screen shows read-only and tagged
// Archived, whatever its status: even the current week's post is only
// viewed here, never changed. On desktop (mock 2f) the archive is a panel at
// SupplyArchivePage is /plan/shopping/archive. A frozen post opens it on its
// week (?week=), whose list the Supply run screen shows read-only and tagged
// Archived; a post not yet frozen opens the Supply run instead, as does a
// ?week= visit to a list not yet frozen. On desktop (mock 2f) the archive is a panel at
// the right of that list, this week's editable Supply run when no post is
// open; on mobile and tablet it is the archive or, with a post open, the
// post's list.
+52 -4
View File
@@ -99,12 +99,12 @@ function writes(fetchMock: Mock<typeof fetch>) {
// renderPage loads /me (for the CSRF token every write needs) and renders
// the Supply run at path.
async function renderPage(path = '/plan/shopping') {
async function renderPage(path = '/plan/shopping', queryClient = createQueryClient()) {
const api = createApiClient(fakeLocation('/plan/shopping'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[path]}>
<SupplyRunPage />
</MemoryRouter>
@@ -563,9 +563,13 @@ describe('SupplyRunPage', () => {
})
it('offers to make the list when the week has none, and shows the list made', async () => {
let made = false
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${GENERATE}`]: () => jsonResponse(200, { ...supplyRun(), skipped: [] }),
[`GET ${THIS_WEEK}`]: () => (made ? jsonResponse(200, supplyRun()) : errorResponse(404, 'not_found')),
[`POST ${GENERATE}`]: () => {
made = true
return jsonResponse(200, { ...supplyRun(), skipped: [] })
},
})
await renderPage()
@@ -575,6 +579,50 @@ describe('SupplyRunPage', () => {
expect(writes(fetchMock)).toEqual([`POST ${GENERATE} undefined`])
})
it('keeps the list made when a read sent before it was made answers after it', async () => {
const queryClient = createQueryClient()
const made = deferred()
const late = deferred()
const again = deferred()
let reads = 0
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => {
reads++
if (reads === 1) return errorResponse(404, 'not_found')
return reads === 2 ? late.response : again.response
},
[`POST ${GENERATE}`]: () => made.response,
})
await renderPage('/plan/shopping', queryClient)
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
// A read of the week sent while the list is being made, so from before it.
void queryClient.invalidateQueries({ queryKey: ['shopping', '2026-10-05'] })
await waitFor(() => expect(reads).toBe(2))
made.send(jsonResponse(200, { ...supplyRun(), skipped: [] }))
expect(await screen.findByText('4 / 6')).toBeInTheDocument()
// The list made is written, and the week read again from the server.
await waitFor(() => expect(reads).toBe(3))
// The read from before the list was made answers only now.
late.send(errorResponse(404, 'not_found'))
await late.response
await new Promise((resolve) => setTimeout(resolve, 0))
expect(screen.getByText('4 / 6')).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'No shopping list yet' })).not.toBeInTheDocument()
// The read sent after it was made is the one shown: by then the
// household has the ground beef.
const groups = GROUPS.map((group, g) => ({
...group,
items: group.items.map((item, i) => (g === 0 && i === 0 ? { ...item, needed: false } : item)),
}))
again.send(jsonResponse(200, supplyRun({ needed_count: 3, groups })))
expect(await screen.findByText('3 / 6')).toBeInTheDocument()
expect(reads).toBe(3)
expect(writes(fetchMock)).toEqual([`POST ${GENERATE} undefined`])
})
it('says why the list could not be made', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
+19 -14
View File
@@ -1,6 +1,6 @@
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router'
import { Link, Navigate, useSearchParams } from 'react-router'
import { defaultWeek, isMonday, localNow, weekdayKicker, weekLabel } from '../plan/week.ts'
import { ApiError } from '../api/errors.ts'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
@@ -15,6 +15,7 @@ import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import {
useGenerateSupplyRun,
supplyRunScreen,
useSendToChat,
useSupplyRun,
useToggles,
@@ -26,6 +27,9 @@ import {
import { keep, savedSchedule, useSavedProblem } from './offline.ts'
import styles from './SupplyRunPage.module.css'
// SUPPLY_RUN is the Supply run screen.
export const SUPPLY_RUN = '/plan/shopping'
// SUPPLY_ARCHIVE is the Supply archive screen.
export const SUPPLY_ARCHIVE = '/plan/shopping/archive'
@@ -143,10 +147,10 @@ function ArchiveLink({ layout }: { layout: Breakpoint }) {
// tablet up, where Archive and Send to Google Chat are actions in the
// header; a send that fails says why, and one whose outcome is not known
// offers no retry and leaves the status line saying so. On a current Supply run each item toggles at once (useToggles); a
// toggle refused puts the item back and says why. An archived one, or any
// opened from the Supply archive (readOnly), is read-only and tagged
// Archived, and a toggle it refused is not offered again.
function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
// toggle refused puts the item back and says why. An archived one is
// read-only and tagged Archived, and a toggle it refused is not offered
// again.
function SupplyRunList({ weekStart, timezone, layout }: Omit<WeekProps, 'readOnly'>) {
const toggles = useToggles(weekStart)
const send = useSendToChat(weekStart)
const run = toggles.shown
@@ -157,7 +161,7 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
<UnconfirmedStatus at={run.chat_send_unconfirmed_at} timezone={timezone} />
)
const sendUnconfirmed = send.error instanceof ApiError && send.error.code === 'chat_unconfirmed'
const viewOnly = readOnly || run.status === 'archived'
const viewOnly = run.status === 'archived'
const wide = layout !== 'mobile'
const failed = toggles.failed
const failedItem = failed && run.groups.flatMap((group) => group.items).find((item) => item.id === failed.toggle.id)
@@ -175,11 +179,9 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
</p>
</div>
<p className={styles.helper}>
{run.status === 'archived'
{viewOnly
? 'This list is in the Supply archive, as it was when the week ended.'
: viewOnly
? "This week's list as it stands, read-only in the Supply archive."
: "Uncheck anything you already have — it won't be counted."}
: "Uncheck anything you already have — it won't be counted."}
</p>
</div>
{wide && (
@@ -240,7 +242,8 @@ type WeekProps = { weekStart: string; timezone: string; layout: Breakpoint; read
// WeekSupplyRun is the Supply run of the week starting on weekStart, or,
// when the week has none yet, the offer to make it from the week's plan —
// not offered read-only.
// not offered read-only. Read-only, a Supply run not yet frozen opens the
// editable Supply run screen instead, where it can still change.
function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
const run = useSupplyRun(weekStart)
const generate = useGenerateSupplyRun(weekStart)
@@ -267,14 +270,16 @@ function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
</div>
)
}
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} readOnly={readOnly} />
if (readOnly && run.data.status !== 'archived') return <Navigate to={supplyRunScreen(weekStart)} replace />
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} />
}
// SupplyRunPage is the Supply run (mock 1l on mobile, 2b on tablet, 2f on
// desktop) of the week the Plan opens on — next week once this week's
// plan_draft time has come, otherwise this week — or of the week chosen by
// ?week=. Opened from the Supply archive it is readOnly: tagged Archived,
// with no checkbox and nothing to make or send, whatever the week. Offline,
// ?week=. Opened from the Supply archive it is readOnly: a frozen Supply run
// is tagged Archived, with no checkbox and nothing to make or send; one not
// yet frozen opens the editable Supply run screen instead. Offline,
// where the settings cannot be read, it chooses the week by the schedule
// saved on this device.
export function SupplyRunPage({ readOnly = false }: { readOnly?: boolean }) {
+17 -4
View File
@@ -194,18 +194,26 @@ export function useSupplyRun(weekStart: string) {
// /api/v1/shopping/{weekStart}/generate); the Supply run it answers with is
// the one shown. Once made, from whichever screen, a push of the week whose
// Supply run was not made is settled (settleUnmadePushes).
//
// A read of the week's Supply run still in flight as the answer arrives may
// have been sent before the Supply run was made: it is cancelled before the
// answer is written, so it cannot land over it, and the week is then read
// again from the server.
export function useGenerateSupplyRun(weekStart: string) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
const key = supplyRunKey(weekStart)
return useMutation({
onMutate: () => settleUnmadePushes(queryClient, weekStart),
mutationFn: async (): Promise<SupplyRun> => {
const path = `${supplyRunPath(weekStart)}/generate`
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
},
onSuccess: (run, _, settle) => {
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
onSuccess: async (run, _, settle) => {
await queryClient.cancelQueries({ queryKey: key })
queryClient.setQueryData<SupplyRun | null>(key, run)
settle()
void queryClient.invalidateQueries({ queryKey: key })
},
})
}
@@ -537,7 +545,9 @@ export type ArchivePeriod = (typeof ARCHIVE_PERIODS)[number]
// ArchivePost is one week's Supply run in the Supply archive: label is the
// week label, "Week 41 · Oct 5 – 11", which stands in for title while it is
// null; onHandCount counts the items marked "have it", and dinners names the
// dinners in day order. current is true for the household's current week.
// dinners in day order. current is true for the household's current week,
// and frozen once the list is frozen into the Supply archive: until then it
// can still be changed from the Supply run.
export type ArchivePost = {
weekStart: string
label: string
@@ -547,6 +557,7 @@ export type ArchivePost = {
onHandCount: number
dinners: string[]
current: boolean
frozen: boolean
}
// ArchiveGroup is one period of the Supply archive — a week, a month
@@ -561,7 +572,8 @@ function parsePost(where: string, body: unknown): ArchivePost {
(body.title !== null && typeof body.title !== 'string') ||
!Array.isArray(body.dinners) ||
!body.dinners.every((name) => typeof name === 'string') ||
typeof body.current !== 'boolean'
typeof body.current !== 'boolean' ||
typeof body.frozen !== 'boolean'
) {
throw new Error(`${where}: response has a post that is not a Supply archive post`)
}
@@ -574,6 +586,7 @@ function parsePost(where: string, body: unknown): ArchivePost {
onHandCount: count(where, 'onHandCount', body.onHandCount),
dinners: body.dinners as string[],
current: body.current,
frozen: body.frozen,
}
}
+111 -68
View File
@@ -1,5 +1,5 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { defaultScheduler, notifyManager, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, 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,8 +213,46 @@ function reload(page: { unmount: () => void }) {
vi.setSystemTime(now)
}
async function advance(ms: number) {
await act(() => vi.advanceTimersByTimeAsync(ms))
// 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()
}
// The page's setTimeout, watched: retrySecondTry waits for a timer to be set.
@@ -224,8 +262,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 waitFor(() => expect(timers).toHaveBeenCalledWith(expect.any(Function), 2 * RETRY_FIRST))
await advance(2 * RETRY_FIRST)
await until(() => expect(timers).toHaveBeenCalledWith(expect.any(Function), 2 * RETRY_FIRST))
advance(2 * RETRY_FIRST)
}
function checkbox(name: string) {
@@ -240,16 +278,21 @@ beforeEach(() => {
onLine = true
reachable = true
mockViewport(390)
// setTimeout moves with real time, and a test can jump past a deadline or
// a wait (advance).
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'], shouldAdvanceTime: true })
// 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'] })
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')
})
@@ -258,7 +301,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 screen.findByText('2 / 3')).toBeInTheDocument()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Taco seasoning'))
@@ -276,7 +319,7 @@ describe('offline toggle queue', () => {
expect(patches(fetchMock)).toEqual([])
goOnline()
await waitFor(() => expect(queue()).toEqual([]))
await until(() => expect(queue()).toEqual([]))
expect(patches(fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i1')} {"needed":false}`,
@@ -292,14 +335,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 screen.findByRole('checkbox', { name: 'Taco seasoning' }))
await waitFor(() => expect(queue()).toEqual([]))
fireEvent.click(await until(() => screen.getByRole('checkbox', { name: 'Taco seasoning' })))
await until(() => expect(queue()).toEqual([]))
expect(screen.getByText('3 / 3')).toBeInTheDocument()
// The browser still says online, but the server cannot be reached.
reachable = false
fireEvent.click(checkbox('Corn tortillas'))
expect(await within(row('Corn tortillas')).findByText('will sync')).toBeInTheDocument()
expect(await until(() => within(row('Corn tortillas')).getByText('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()
@@ -307,7 +350,7 @@ describe('offline toggle queue', () => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
goOnline()
await waitFor(() => expect(queue()).toEqual([]))
await until(() => 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()
@@ -316,7 +359,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 screen.findByText('2 / 3')).toBeInTheDocument()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Taco seasoning'))
fireEvent.click(checkbox('Ground beef'))
@@ -325,7 +368,7 @@ describe('offline toggle queue', () => {
// Reload while offline: the settings, /me and the list cannot be read.
reload(first)
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(checkbox('Taco seasoning')).toBeChecked()
expect(checkbox('Ground beef')).not.toBeChecked()
expect(within(row('Taco seasoning')).getByText('will sync')).toBeInTheDocument()
@@ -333,9 +376,9 @@ describe('offline toggle queue', () => {
expect(patches(fetchMock)).toEqual([])
goOnline()
await waitFor(() => expect(queue()).toEqual([]))
await until(() => expect(queue()).toEqual([]))
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`])
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(checkbox('Taco seasoning')).toBeChecked()
expect(checkbox('Ground beef')).not.toBeChecked()
@@ -344,7 +387,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 screen.findByText('2 / 3')).toBeInTheDocument()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Taco seasoning'))
@@ -352,25 +395,25 @@ describe('offline toggle queue', () => {
server.archive()
goOnline()
const alert = await screen.findByRole('alert')
const alert = await until(() => screen.getByRole('alert'))
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. archived message")
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(await until(() => screen.getByText('Archived'))).toBeInTheDocument()
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
expect(queue()).toEqual([])
// Sent once, never again.
goOnline()
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`]))
await until(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`]))
})
it('does not queue a toggle the server refuses: it is put back and says why', async () => {
const server = stubServer(() => errorResponse(400, 'invalid'))
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
fireEvent.click(checkbox('Ground beef'))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't mark Ground beef “have it”. invalid message")
expect(await until(() => screen.getByRole('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()
@@ -378,7 +421,7 @@ describe('offline toggle queue', () => {
goOffline()
goOnline()
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i1')} {"needed":false}`]))
await until(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i1')} {"needed":false}`]))
})
})
@@ -395,16 +438,16 @@ describe('offline toggle queue, replay', () => {
localStorage.setItem(SAVED_KEY, JSON.stringify({ schedule: null, runs, queue: queued }))
await openPage()
await waitFor(() => expect(answers.waiting()).toBe(1))
fireEvent.click(await screen.findByRole('checkbox', { name: 'Corn tortillas' }))
await until(() => expect(answers.waiting()).toBe(1))
fireEvent.click(await until(() => screen.getByRole('checkbox', { name: 'Corn tortillas' })))
for (let sent = 1; sent <= 4; sent++) {
await waitFor(() => expect(patches(server.fetchMock)).toHaveLength(sent))
await until(() => expect(patches(server.fetchMock)).toHaveLength(sent))
// The next one is not sent before this one is answered.
await new Promise((resolve) => setTimeout(resolve, 20))
await settle()
expect(patches(server.fetchMock)).toHaveLength(sent)
act(() => answers.release())
}
await waitFor(() => expect(queue()).toEqual([]))
await until(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
`${ITEM('n1')} {"needed":false}`,
@@ -412,27 +455,27 @@ describe('offline toggle queue, replay', () => {
`${ITEM('i2')} {"needed":false}`,
])
expect(server.mostInFlight()).toBe(1)
await waitFor(() => expect(screen.getByText('1 / 3')).toBeInTheDocument())
await until(() => expect(screen.getByText('1 / 3')).toBeInTheDocument())
})
it('replays at least once: a toggle the server applied before a reload is sent again and leaves the item and count the same', async () => {
const answers = held()
const server = stubServer(undefined, answers.hold)
const first = await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
// The server applied it; the page reloads before its answer arrives.
await waitFor(() => expect(answers.waiting()).toBe(1))
await until(() => expect(answers.waiting()).toBe(1))
expect(queue()).toEqual(['i3 true'])
reload(first)
act(() => answers.release())
await openPage()
await waitFor(() => expect(answers.waiting()).toBe(1))
await until(() => expect(answers.waiting()).toBe(1))
act(() => answers.release())
await waitFor(() => expect(queue()).toEqual([]))
await until(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`])
await waitFor(() => expect(screen.getByText('3 / 3')).toBeInTheDocument())
await until(() => 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()
@@ -442,18 +485,18 @@ describe('offline toggle queue, replay', () => {
const answers = held()
stubServer(undefined, answers.hold)
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Ground beef'))
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
goOnline()
await waitFor(() => expect(answers.waiting()).toBe(1))
await until(() => expect(answers.waiting()).toBe(1))
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(queue()).toEqual(['i1 false'])
act(() => answers.release())
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(queue()).toEqual([])
expect(screen.getByText('1 / 3')).toBeInTheDocument()
})
@@ -463,29 +506,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 screen.findByText('2 / 3')).toBeInTheDocument()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
await waitFor(() => expect(patches(server.fetchMock)).toHaveLength(1))
await until(() => expect(patches(server.fetchMock)).toHaveLength(1))
fireEvent.click(checkbox('Ground beef'))
expect(queue()).toEqual(['i3 true', 'i1 false'])
// Past the deadline the hung PATCH is aborted; both still wait, in order.
await advance(TOGGLE_DEADLINE)
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument()
advance(TOGGLE_DEADLINE)
expect(await until(() => within(row('Taco seasoning')).getByText('will sync'))).toBeInTheDocument()
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(patches(server.fetchMock)).toHaveLength(1)
expect(queue()).toEqual(['i3 true', 'i1 false'])
await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([]))
await until(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i1')} {"needed":false}`,
])
expect(server.mostInFlight()).toBe(1)
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
@@ -496,26 +539,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 screen.findByText('2 / 3')).toBeInTheDocument()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
await waitFor(() => expect(tries).toBe(2))
await until(() => expect(tries).toBe(2))
fireEvent.click(checkbox('Ground beef'))
expect(queue()).toEqual(['i3 true', 'i1 false'])
// Past the deadline the hung /me is abandoned; both still wait, in order.
await advance(TOGGLE_DEADLINE)
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument()
advance(TOGGLE_DEADLINE)
expect(await until(() => within(row('Taco seasoning')).getByText('will sync'))).toBeInTheDocument()
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(patches(server.fetchMock)).toEqual([])
// The next try sends a fresh /me, which the server answers.
await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([]))
await until(() => expect(queue()).toEqual([]))
expect(mes(server.fetchMock)).toBe(3)
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`])
expect(server.mostInFlight()).toBe(1)
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
@@ -536,29 +579,29 @@ describe('offline toggle queue, replay', () => {
() => (refusedAt !== null && ++tries === 1 ? new Promise<void>(() => {}) : Promise.resolve()),
)
await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
await waitFor(() => expect(tries).toBe(1))
await until(() => expect(tries).toBe(1))
fireEvent.click(checkbox('Ground beef'))
expect(patches(server.fetchMock)).toHaveLength(1)
// Past the deadline the hung refresh is abandoned; both still wait, in order.
await advance(TOGGLE_DEADLINE)
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument()
advance(TOGGLE_DEADLINE)
expect(await until(() => within(row('Taco seasoning')).getByText('will sync'))).toBeInTheDocument()
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(queue()).toEqual(['i3 true', 'i1 false'])
// The next try is refused again and sends a fresh refresh, which answers.
await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([]))
await until(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i1')} {"needed":false}`,
])
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
@@ -566,21 +609,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 screen.findByText('2 / 3')).toBeInTheDocument()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
// The browser says online throughout; the server cannot be reached, then can.
reachable = false
fireEvent.click(checkbox('Corn tortillas'))
expect(await within(row('Corn tortillas')).findByText('will sync')).toBeInTheDocument()
expect(await until(() => within(row('Corn tortillas')).getByText('will sync'))).toBeInTheDocument()
reachable = true
expect(patches(fetchMock)).toEqual([])
// Nothing says the server is back: only the wait's timer tries it again.
await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([]))
await until(() => expect(queue()).toEqual([]))
// The first try failed at GET /api/v1/me, before its PATCH.
expect(patches(fetchMock)).toEqual([`${ITEM('i2')} {"needed":false}`])
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('1 / 3')).toBeInTheDocument()
})
@@ -588,20 +631,20 @@ describe('offline toggle queue, replay', () => {
const answers = held()
stubServer(undefined, answers.hold)
const page = await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Ground beef'))
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
page.leave()
goOnline()
await waitFor(() => expect(answers.waiting()).toBe(1))
await until(() => expect(answers.waiting()).toBe(1))
page.reopen()
expect(await within(row('Ground beef')).findByText('will sync')).toBeInTheDocument()
expect(await until(() => within(row('Ground beef')).getByText('will sync'))).toBeInTheDocument()
expect(queue()).toEqual(['i1 false'])
act(() => answers.release())
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(queue()).toEqual([])
expect(screen.getByText('1 / 3')).toBeInTheDocument()
})
@@ -613,10 +656,10 @@ describe('offline toggle queue, replay', () => {
const getItem = vi.spyOn(Storage.prototype, 'getItem')
await openPage()
expect(await screen.findByText(/Couldn't load the household settings/)).toBeInTheDocument()
expect(await until(() => screen.getByText(/Couldn't load the household settings/))).toBeInTheDocument()
expect(screen.getByText(/can't be kept on this device while offline/)).toBeInTheDocument()
const reads = getItem.mock.calls.filter(([key]) => key === SAVED_KEY).length
await new Promise((resolve) => setTimeout(resolve, 50))
await settle()
expect(getItem.mock.calls.filter(([key]) => key === SAVED_KEY)).toHaveLength(reads)
})
})
+601
View File
@@ -0,0 +1,601 @@
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' }),
)
})
})

Some files were not shown because too many files have changed in this diff Show More