Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd4bc8d9bb | ||
|
|
b19dad7b40 | ||
|
|
8b0e2f25a9 | ||
|
|
cfc229049f | ||
|
|
01cd9a8741 | ||
|
|
f7ebc85862 |
@@ -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`. |
|
||||
|
||||
@@ -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.
|
||||
@@ -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. |
|
||||
@@ -653,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
|
||||
@@ -1205,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.**
|
||||
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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -108,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)
|
||||
}
|
||||
|
||||
@@ -168,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 {
|
||||
|
||||
@@ -222,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"`
|
||||
@@ -233,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
|
||||
@@ -262,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)
|
||||
}
|
||||
}
|
||||
@@ -720,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"`
|
||||
}
|
||||
@@ -798,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)
|
||||
}
|
||||
@@ -815,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 "))
|
||||
@@ -852,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,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,0018_recipe_item_position_unique,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,0020_site_name_from_page", 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,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,0018_recipe_item_position_unique,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,0020_site_name_from_page", 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")
|
||||
@@ -1013,6 +1013,60 @@ func TestMigrateRecipeItemPositionUniqueRefusesDuplicate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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.
|
||||
|
||||
@@ -237,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(() => {
|
||||
@@ -553,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()
|
||||
|
||||
@@ -585,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()
|
||||
|
||||
@@ -670,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 />)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user