Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd4bc8d9bb | ||
|
|
b19dad7b40 | ||
|
|
8b0e2f25a9 | ||
|
|
cfc229049f | ||
|
|
01cd9a8741 | ||
|
|
f7ebc85862 | ||
|
|
9d91686c35 | ||
|
|
5275757258 | ||
|
|
e5aa009fc5 | ||
|
|
d182349646 | ||
|
|
72755a8b61 | ||
|
|
791b321b71 | ||
|
|
be41b56690 | ||
|
|
3a39fc8f2c | ||
|
|
d8633d56a4 | ||
|
|
9046b6df78 | ||
|
|
759fbbe3dd | ||
|
|
d390d8dcce | ||
|
|
25edb6fb26 | ||
|
|
17fa82ef2b |
@@ -85,7 +85,7 @@ behaviour, exercise it (hit the endpoint, render the page).
|
||||
| `migrations/` | SQL migrations (planned). |
|
||||
| `web/` | React PWA source. |
|
||||
| `deploy/` | Deployment files (planned). |
|
||||
| `docs/` | Project documentation, including [ARCHITECTURE.md](docs/ARCHITECTURE.md). |
|
||||
| `docs/` | Project documentation, including [ARCHITECTURE.md](docs/ARCHITECTURE.md), launch evidence in [LAUNCH.md](docs/LAUNCH.md), and Lighthouse reports in `docs/lighthouse/`. |
|
||||
| `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`. |
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
# MealQuest deploy runbook
|
||||
|
||||
The one way to deploy MealQuest to production: the docker compose project
|
||||
`mealquest` on office-srvr `10.60.1.14` (see
|
||||
[ARCHITECTURE.md](../docs/ARCHITECTURE.md#topology)). Production runs `main`
|
||||
or a release tag; only `overseer` merges `dev` → `main`. Every deploy builds
|
||||
on the host, from the checked-out commit, with `make docker`.
|
||||
|
||||
Host setup (clone, firewall, the systemd unit, Traefik, DNS, Litestream) and
|
||||
the database restore are in CTXD project `mealquest`, `RUNBOOKS.MD`. This
|
||||
file is the build, restart, verification and rollback procedure; where it
|
||||
uses a CTXD section, it names it.
|
||||
|
||||
All commands run on `10.60.1.14` as `overseer`, in bash, from the checkout
|
||||
`/home/overseer/mealquest`. A block that ends with `FAILED` or a non-zero
|
||||
`$?` stops the deploy: do not go on to the next step.
|
||||
|
||||
## How the image carries its version
|
||||
|
||||
`make docker` runs `docker build --build-arg VERSION=<version>` and tags the
|
||||
image `mealquest:<version>`, where `<version>` is
|
||||
`git describe --tags --always` of the checkout (a release tag such as
|
||||
`v1.0.0`, otherwise the short SHA), with `-dirty` added when a tracked file
|
||||
is modified. The [Dockerfile](../Dockerfile) links it into the binary, and
|
||||
`/health` reports it:
|
||||
|
||||
```json
|
||||
{"status":"ok","version":"v1.0.0"}
|
||||
```
|
||||
|
||||
Never pass `--build-arg VERSION` by hand, and never build with
|
||||
`docker compose build` or `docker compose up --build`: compose passes no
|
||||
version, so that image reports `dev`.
|
||||
|
||||
The compose service `mealquest` runs the image named `mealquest-mealquest`
|
||||
(compose's name for the image it would build; check with
|
||||
`docker compose -f deploy/compose.yaml config --images mealquest`). It
|
||||
already exists, so `docker compose up` uses it and never builds. A deploy
|
||||
points that name at the `make docker` image with `docker tag`. Every
|
||||
`mealquest:<version>` image is kept for rollback; do not prune them.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `make`, `git`, `curl`, `python3` and Docker Engine with Compose on the
|
||||
host (`make` is not in the base install: `sudo apt-get install make`).
|
||||
- `deploy/.env` (git-ignored, owner `overseer`, mode 0600) sets every
|
||||
variable [`deploy/.env.example`](.env.example) sets, with
|
||||
`MEALQUEST_BIND_ADDRESS=10.60.1.14`. It must not set the variables
|
||||
`deploy/compose.yaml` fixes (`MEALQUEST_HTTP_ADDR`, `MEALQUEST_DATA_DIR`,
|
||||
`MEALQUEST_SEARXNG_URL` and the `*_FILE` variables). Creating it: CTXD
|
||||
`RUNBOOKS.MD` "Stack and first deploy" > "3. Environment file".
|
||||
- `deploy/secrets/` (git-ignored, mode 0700) holds `secret_key`,
|
||||
`oidc_client_secret`, `google_client_secret`, `vapid_private_key` and
|
||||
`litestream_key`, each owned by `10001:10001` with mode 0600, and
|
||||
`searxng.env`, owned by `overseer` with mode 0600. Creating them: CTXD
|
||||
`RUNBOOKS.MD` "Stack and first deploy" > "4. Secrets".
|
||||
- The compose file is the checkout's [`deploy/compose.yaml`](compose.yaml);
|
||||
the systemd unit [`mealquest.service`](systemd/mealquest.service) starts
|
||||
and stops it and is the only way the stack starts.
|
||||
- DNS and Traefik already route `https://mealquest.cubecraftlabs.com` to
|
||||
`10.60.1.14:8090` (CTXD `RUNBOOKS.MD` "Reverse proxy (Traefik)", "DNS").
|
||||
|
||||
Check `deploy/.env` and the secrets before every deploy (a new release may
|
||||
add a variable; the service refuses to boot without it). Both commands must
|
||||
print nothing:
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest
|
||||
comm -23 <(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env.example | sort -u) \
|
||||
<(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env | sort -u) # variables missing from deploy/.env
|
||||
sudo find deploy/secrets -type f ! -perm 600 # secrets not mode 0600
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
### 1. Build
|
||||
|
||||
Record the release production is running now, check out the new release
|
||||
and build its image. Nothing restarts.
|
||||
|
||||
The rollback target is the version the running service reports on
|
||||
`/health`, read before the checkout changes, never the checkout's `HEAD`:
|
||||
after a failed or deferred deploy the checkout can be ahead of production.
|
||||
The block resolves that version to a commit and prints it as the rollback
|
||||
SHA. A running version of `dev` or `*-dirty` names no commit: the block
|
||||
fails, and the rollback target must be established by hand before going on.
|
||||
|
||||
The checkout must be clean (the same test as the Makefile's `GIT_DIRTY`:
|
||||
no modified tracked file outside the generated `internal/frontend/dist`).
|
||||
The block computes the image tag exactly as `make docker` does, `-dirty`
|
||||
included, and checks that image exists after the build.
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest && (
|
||||
set -euo pipefail
|
||||
fail() { echo "$*" >&2; exit 1; }
|
||||
built=
|
||||
trap 'st=$?
|
||||
if [ "$st" -eq 0 ] && [ -n "$built" ]; then echo "built: $built"
|
||||
else [ "$st" -ne 0 ] || st=1; echo "build FAILED (status $st)" >&2; fi
|
||||
exit "$st"' EXIT
|
||||
bind=$(grep -E '^MEALQUEST_BIND_ADDRESS=' deploy/.env | cut -d = -f 2-)
|
||||
[ -n "$bind" ] || fail "deploy/.env sets no MEALQUEST_BIND_ADDRESS"
|
||||
running=$(curl -fsS "http://$bind:8090/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|
||||
|| fail "http://$bind:8090/health did not return a version: no running release to roll back to"
|
||||
case "$running" in
|
||||
dev|*-dirty) fail "the running release reports $running, which names no commit" ;;
|
||||
esac
|
||||
git fetch --tags origin
|
||||
rollback=$(git rev-parse --verify --quiet "$running^{commit}") \
|
||||
|| fail "running version $running is not a commit in this repository"
|
||||
echo "rollback SHA: $rollback (running version $running)" # RECORD it for rollback
|
||||
git checkout --detach 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"
|
||||
v=$(git describe --tags --always)${dirty:+-dirty} # the Makefile's VERSION
|
||||
make docker
|
||||
docker image inspect "mealquest:$v" > /dev/null 2>&1 || fail "no image mealquest:$v: the build failed"
|
||||
built="mealquest:$v"
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Ship and restart
|
||||
|
||||
1. **Pre-restart gates.** Run CTXD `RUNBOOKS.MD` "Routine deploy and
|
||||
rollback" > "Deploy" step 2 (a verified pre-deploy restore point; record
|
||||
it with the rollback SHA) and step 3 (boot-required configuration). The
|
||||
restart applies migrations the previous release refuses, so without that
|
||||
restore point there is no rollback. If either fails, do not restart:
|
||||
production stays on the previous release.
|
||||
2. **Point the stack at the new image and restart.** `docker compose pull`
|
||||
fetches the pinned `litestream` and `searxng` images before anything
|
||||
stops; `mealquest` is never pulled. The restart applies the firewall,
|
||||
then runs `down` and `up -d`, and returns once `mealquest` passes its
|
||||
healthcheck (`/health` inside the container).
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest && (
|
||||
set -euo pipefail
|
||||
fail() { echo "$*" >&2; exit 1; }
|
||||
up=
|
||||
trap 'st=$?
|
||||
if [ "$st" -eq 0 ] && [ -n "$up" ]; then echo "restarted on: $up"
|
||||
else [ "$st" -ne 0 ] || st=1; echo "restart FAILED (status $st)" >&2; fi
|
||||
exit "$st"' EXIT
|
||||
v=$(git describe --tags --always)
|
||||
docker compose -f deploy/compose.yaml pull litestream searxng
|
||||
docker tag "mealquest:$v" mealquest-mealquest
|
||||
sudo systemctl restart mealquest
|
||||
for s in mealquest litestream searxng; do
|
||||
id=$(docker compose -f deploy/compose.yaml ps --status running --quiet "$s")
|
||||
[ -n "$id" ] || fail "$s is not running"
|
||||
done
|
||||
id=$(docker compose -f deploy/compose.yaml ps --quiet mealquest)
|
||||
health=$(docker inspect -f '{{.State.Health.Status}}' "$id")
|
||||
[ "$health" = healthy ] || fail "mealquest is $health, not healthy"
|
||||
up="mealquest:$v"
|
||||
)
|
||||
```
|
||||
|
||||
On failure read `journalctl -u mealquest` and
|
||||
`docker compose -f deploy/compose.yaml logs mealquest`: a configuration
|
||||
error names the variable. Fix it or roll back.
|
||||
|
||||
### 3. Verify the deployed version and a real screen
|
||||
|
||||
A `/health` 200 alone proves only that some build is up. This step checks,
|
||||
on the LAN address and through Traefik, that:
|
||||
|
||||
- `/health` reports exactly the checkout's `git describe --tags --always`;
|
||||
- `/` and the Plan screen's route `/plan` serve the built PWA shell (the
|
||||
React root and a hashed `/assets/` script, not the placeholder page an
|
||||
unbuilt embed serves);
|
||||
- that script is served as JavaScript.
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest && (
|
||||
set -euo pipefail
|
||||
fail() { echo "$*" >&2; exit 1; }
|
||||
want=
|
||||
trap 'st=$?
|
||||
if [ "$st" -eq 0 ] && [ -n "$want" ]; then echo "deployed and verified: $want ($(git rev-parse HEAD))"
|
||||
else [ "$st" -ne 0 ] || st=1; echo "verification FAILED (status $st): the deploy is not verified" >&2; fi
|
||||
exit "$st"' EXIT
|
||||
v=$(git describe --tags --always)
|
||||
for base in http://10.60.1.14:8090 https://mealquest.cubecraftlabs.com; do
|
||||
got=$(curl -fsS "$base/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|
||||
|| fail "$base/health did not return a version"
|
||||
[ "$got" = "$v" ] || fail "$base/health reports version $got, the checkout is $v"
|
||||
for path in / /plan; do
|
||||
page=$(curl -fsS "$base$path") || fail "$base$path did not return 200"
|
||||
grep -q '<div id="root"></div>' <<< "$page" || fail "$base$path is not the PWA shell"
|
||||
js=$(grep -oE 'src="/assets/[^"]+\.js"' <<< "$page" | head -n 1 | cut -d '"' -f 2) || true
|
||||
[ -n "$js" ] || fail "$base$path references no built /assets/ script"
|
||||
type=$(curl -fsS -o /dev/null -w '%{content_type}' "$base$js") || fail "$base$js did not return 200"
|
||||
case "$type" in
|
||||
text/javascript*) ;;
|
||||
*) fail "$base$js is $type, not JavaScript" ;;
|
||||
esac
|
||||
done
|
||||
echo "$base: version $got, PWA shell and $js served"
|
||||
done
|
||||
want=$v
|
||||
)
|
||||
```
|
||||
|
||||
- `deployed and verified: <version> (<SHA>)` and `$?` = `0` → open
|
||||
`https://mealquest.cubecraftlabs.com` in a browser: sign in and the Home
|
||||
screen renders with the household's data. Then record the deployed
|
||||
version and SHA, the rollback SHA, the pre-deploy restore point and the
|
||||
date in the release's Maestro task.
|
||||
- `verification FAILED`, any error or a non-zero `$?` → the deploy is not
|
||||
done. A version mismatch means the stack runs a different image than the
|
||||
checkout: repeat step 2, or roll back.
|
||||
|
||||
## Rollback to a previous release
|
||||
|
||||
Rolling back points `mealquest-mealquest` at the previous release's
|
||||
`mealquest:<version>` image again; that release's checkout keeps the
|
||||
compose file and the version check consistent with it.
|
||||
|
||||
1. **Check whether the rollback crosses a migration** with CTXD
|
||||
`RUNBOOKS.MD` "Routine deploy and rollback" > "Rollback to a previous
|
||||
SHA" (its first block), run against the rollback SHA that deploy step 1
|
||||
recorded. If it prints versions unknown to the rollback SHA, restore the
|
||||
database to the pre-deploy restore point first (CTXD `RUNBOOKS.MD`
|
||||
"Restore the database", with `-timestamp <restore point>`); changes
|
||||
since then are lost, so tell the household.
|
||||
2. **Check out the rollback SHA and retag its image.** An image built before
|
||||
this runbook was not tagged `mealquest:<version>`; `make docker` builds
|
||||
it from the same commit.
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest && (
|
||||
set -euo pipefail
|
||||
fail() { echo "$*" >&2; exit 1; }
|
||||
P=<rollback SHA> # recorded by step 1, from the running release's /health
|
||||
back=
|
||||
trap 'st=$?
|
||||
if [ "$st" -eq 0 ] && [ -n "$back" ]; then echo "rolled back to: $back"
|
||||
else [ "$st" -ne 0 ] || st=1; echo "rollback FAILED (status $st)" >&2; fi
|
||||
exit "$st"' EXIT
|
||||
git checkout --detach "$P"
|
||||
v=$(git describe --tags --always)
|
||||
docker image inspect "mealquest:$v" > /dev/null 2>&1 || make docker
|
||||
docker image inspect "mealquest:$v" > /dev/null 2>&1 || fail "no image mealquest:$v"
|
||||
docker tag "mealquest:$v" mealquest-mealquest
|
||||
sudo systemctl restart mealquest
|
||||
back="mealquest:$v"
|
||||
)
|
||||
```
|
||||
|
||||
3. **Run step 3 above** unchanged. The rollback is done only when it prints
|
||||
`deployed and verified:` with the previous release's version.
|
||||
+113
@@ -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. |
|
||||
+52
-23
@@ -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
|
||||
@@ -262,7 +272,8 @@ automatic** (D-021).
|
||||
`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
|
||||
@@ -319,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
|
||||
@@ -635,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
|
||||
@@ -1120,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
|
||||
@@ -1143,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
|
||||
@@ -1180,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
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Launch evidence
|
||||
|
||||
Evidence that the production PWA meets the launch gates. The raw reports and
|
||||
the check script are committed under [lighthouse/](lighthouse/).
|
||||
|
||||
## Run conditions
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Target | Production, <https://mealquest.cubecraftlabs.com> |
|
||||
| Build | `mealquest:743a155` |
|
||||
| Session | Signed in (household session cookie, supplied at run time) |
|
||||
| Date | 2026-10-11 |
|
||||
| Tool | Lighthouse 13.5.0, mobile form factor |
|
||||
|
||||
The session cookie value is redacted from the committed reports
|
||||
(`configSettings.extraHeaders`); nothing else in them is changed.
|
||||
|
||||
## Lighthouse
|
||||
|
||||
Reports: [lighthouse/2026-10-11/](lighthouse/2026-10-11/) — one
|
||||
`.report.json` and one `.report.html` per page.
|
||||
|
||||
| Page | Route | Performance | Accessibility | Best practices |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Home | `/` | 71 | 100 | 100 |
|
||||
| Plan | `/plan` | 72 | 100 | 100 |
|
||||
| Discover | `/discover` | 66 | 100 | 100 |
|
||||
| Dinner detail | `/dinner/<id>` | 70 | 100 | 100 |
|
||||
|
||||
**Gate: accessibility ≥ 95 on every page — met** (100 on all four).
|
||||
Performance is recorded for reference but is not gated.
|
||||
|
||||
## Installability
|
||||
|
||||
Lighthouse 10 and later removed the PWA category, so installability was checked
|
||||
directly with a Playwright probe:
|
||||
[installability-check.mjs](lighthouse/2026-10-11/installability-check.mjs).
|
||||
It loads each of the four pages above in a signed-in Chrome session with
|
||||
Playwright's Pixel 5 mobile emulation (viewport, touch, `isMobile` and mobile
|
||||
user agent; the cookie comes from the `CK` environment variable, never the
|
||||
file) and gates every page on all of:
|
||||
|
||||
- **Manifest** — the linked manifest is fetched with HTTP 200 and has
|
||||
`name` "MealQuest", `display` "standalone", a `start_url`, and icons at
|
||||
192x192, 512x512 and one with `maskable` purpose.
|
||||
- **Service worker** — `navigator.serviceWorker.ready` resolves within 10
|
||||
seconds (expiry is a failure), and a registration exists whose active worker
|
||||
is `activated`.
|
||||
|
||||
It prints a PASS or FAIL line per condition per page and exits 1 if any
|
||||
condition fails on any page.
|
||||
|
||||
**Result: PASS on all four pages.**
|
||||
@@ -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). |
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,53 @@
|
||||
import { chromium, devices } from 'playwright';
|
||||
const ck = process.env.CK;
|
||||
if (!ck) { console.error('CK (session cookie) is not set'); process.exit(2); }
|
||||
const origin = 'https://mealquest.cubecraftlabs.com';
|
||||
const pages = [['home','/'],['plan','/plan'],['discover','/discover'],['dinner','/dinner/01M4PKT9QYA6C4JCF67XE7RA6R']];
|
||||
const SW_READY_TIMEOUT_MS = 10000;
|
||||
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox'] });
|
||||
// Mobile emulation: Pixel 5 viewport, device scale, touch, isMobile and user agent.
|
||||
const ctx = await browser.newContext({ ...devices['Pixel 5'] });
|
||||
await ctx.addCookies([{ name: '__Host-mealquest_session', value: ck, url: origin + '/' }]);
|
||||
let fail = 0;
|
||||
for (const [name, path] of pages) {
|
||||
const page = await ctx.newPage();
|
||||
page.on('console', m => { if (/sw|service|error/i.test(m.text())) console.log(' [console]', m.text().slice(0,120)); });
|
||||
page.on('pageerror', e => console.log(' [pageerror]', String(e).slice(0,120)));
|
||||
await page.goto(origin + path, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
const href = await page.evaluate(() => document.querySelector('link[rel="manifest"]')?.href || null);
|
||||
const sw = await page.evaluate(async (ms) => {
|
||||
const ready = await Promise.race([
|
||||
navigator.serviceWorker.ready.then(() => 'ready', e => 'error: ' + String(e)),
|
||||
new Promise(r => setTimeout(() => r('timeout after ' + ms + ' ms'), ms)),
|
||||
]);
|
||||
const regs = await navigator.serviceWorker.getRegistrations();
|
||||
return { ready, registrations: regs.length, state: regs[0]?.active?.state || null };
|
||||
}, SW_READY_TIMEOUT_MS);
|
||||
const manifest = href
|
||||
? await page.evaluate(async (h) => { const r = await fetch(h); return { status: r.status, body: r.ok ? await r.json() : {} }; }, href)
|
||||
: { status: 'no manifest link', body: {} };
|
||||
const m = manifest.body;
|
||||
const icons = m.icons || [];
|
||||
const hasSize = (s) => icons.some(i => (i.sizes || '').split(/\s+/).includes(s));
|
||||
const checks = [
|
||||
['manifest HTTP 200', manifest.status === 200, manifest.status],
|
||||
['manifest name "MealQuest"', m.name === 'MealQuest', m.name],
|
||||
['manifest display "standalone"', m.display === 'standalone', m.display],
|
||||
['manifest start_url present', !!m.start_url, m.start_url],
|
||||
['icon 192x192', hasSize('192x192'), icons.map(i => i.sizes).join(',')],
|
||||
['icon 512x512', hasSize('512x512'), icons.map(i => i.sizes).join(',')],
|
||||
['icon purpose maskable', icons.some(i => (i.purpose || '').split(/\s+/).includes('maskable')), icons.map(i => i.purpose || 'any').join(',')],
|
||||
['service worker ready', sw.ready === 'ready', sw.ready],
|
||||
['service worker registered', sw.registrations > 0, sw.registrations],
|
||||
['service worker activated', sw.state === 'activated', sw.state],
|
||||
];
|
||||
console.log(`${name} (${path}) url=${page.url().replace(origin, '')}:`);
|
||||
for (const [label, ok, actual] of checks) console.log(` ${ok ? 'PASS' : 'FAIL'} ${label} (got ${JSON.stringify(actual ?? null)})`);
|
||||
const pass = checks.every(([, ok]) => ok);
|
||||
if (!pass) fail++;
|
||||
console.log(` => ${pass ? 'INSTALLABLE' : 'NOT INSTALLABLE'}`);
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
console.log(fail ? `FAILED on ${fail} of ${pages.length} pages` : `PASS on all ${pages.length} pages`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+17
-2
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -228,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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+36
-53
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -718,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)
|
||||
}
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
@@ -134,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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -54,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
|
||||
}
|
||||
@@ -130,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`)
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-neutral-600);
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.notice {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
@@ -236,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(() => {
|
||||
@@ -552,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()
|
||||
|
||||
@@ -584,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()
|
||||
|
||||
@@ -669,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 />)
|
||||
@@ -1222,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()
|
||||
@@ -1391,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({
|
||||
@@ -1644,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()
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 }),
|
||||
})
|
||||
|
||||
@@ -344,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 },
|
||||
],
|
||||
}
|
||||
|
||||
@@ -372,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()
|
||||
|
||||
@@ -80,7 +80,10 @@ 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()
|
||||
@@ -524,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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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!)
|
||||
})
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,8 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse } from './fetchStub.ts'
|
||||
import { dinnerBody } from './dinners.ts'
|
||||
import { fakeLocation, jsonResponse } from './fetchStub.ts'
|
||||
|
||||
// AI_SETTINGS is a GET /api/v1/settings/ai body: Anthropic with a verified key.
|
||||
export const AI_SETTINGS = {
|
||||
@@ -99,12 +100,16 @@ export const CHAT_WEBHOOK = {
|
||||
last_test_status: null,
|
||||
}
|
||||
|
||||
// SETTINGS_REGULARS is a GET /api/v1/dinners?status=regular body: one
|
||||
// Regular, its recipe found.
|
||||
export const SETTINGS_REGULARS = { dinners: [dinnerBody('r1', 'Beef tacos')] }
|
||||
|
||||
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
|
||||
// today's server does: the dinners endpoint does not exist yet, and neither
|
||||
// Google Calendar nor Google Chat is connected. The home's sub-tabs stay mounted on mobile, so
|
||||
// it also reads the AI models and recipe search settings.
|
||||
// today's server does: one Regular on rotation, and neither Google Calendar
|
||||
// nor Google Chat connected. The home's sub-tabs stay mounted on mobile, so
|
||||
// it also reads the Regulars list, the AI models and recipe search settings.
|
||||
export const SETTINGS_API = {
|
||||
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||
'/api/v1/dinners?status=regular': () => jsonResponse(200, SETTINGS_REGULARS),
|
||||
'/api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||
'/api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
|
||||
'/api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK_NONE),
|
||||
|
||||
@@ -3,16 +3,22 @@ import { act } from '@testing-library/react'
|
||||
type Listener = (event: MediaQueryListEvent) => void
|
||||
|
||||
// Replaces window.matchMedia with one that answers min-width / max-width
|
||||
// queries for a pretend viewport width, and prefers-reduced-motion: reduce
|
||||
// with reducedMotion (jsdom has no matchMedia and no layout). Returns a
|
||||
// function that resizes the pretend viewport and notifies every query whose
|
||||
// answer changed.
|
||||
export function mockViewport(initialWidth: number, { reducedMotion = false } = {}): (width: number) => void {
|
||||
// queries for a pretend viewport width, orientation: portrait for its height
|
||||
// (a viewport given no height is landscape), and prefers-reduced-motion:
|
||||
// reduce with reducedMotion (jsdom has no matchMedia and no layout). Returns
|
||||
// a function that resizes the pretend viewport and notifies every query
|
||||
// whose answer changed.
|
||||
export function mockViewport(
|
||||
initialWidth: number,
|
||||
{ reducedMotion = false, height: initialHeight = 0 } = {},
|
||||
): (width: number, height?: number) => void {
|
||||
let width = initialWidth
|
||||
let height = initialHeight
|
||||
const lists: { list: MediaQueryList; listeners: Set<Listener> }[] = []
|
||||
|
||||
function evaluate(query: string): boolean {
|
||||
if (query === '(prefers-reduced-motion: reduce)') return reducedMotion
|
||||
if (query === '(orientation: portrait)') return height >= width
|
||||
const min = /\(min-width:\s*(\d+)px\)/.exec(query)
|
||||
const max = /\(max-width:\s*(\d+)px\)/.exec(query)
|
||||
if (!min && !max) throw new Error(`mockViewport cannot evaluate ${JSON.stringify(query)}`)
|
||||
@@ -37,9 +43,10 @@ export function mockViewport(initialWidth: number, { reducedMotion = false } = {
|
||||
return list
|
||||
}
|
||||
|
||||
return (next: number) => {
|
||||
return (nextWidth: number, nextHeight = 0) => {
|
||||
const before = lists.map(({ list }) => list.matches)
|
||||
width = next
|
||||
width = nextWidth
|
||||
height = nextHeight
|
||||
act(() => {
|
||||
lists.forEach(({ list, listeners }, i) => {
|
||||
if (list.matches === before[i]) return
|
||||
|
||||
Reference in New Issue
Block a user