Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 aa5277fb88 Merge the feature branch into task/supply-archive-frozen-names (Maestro 22f80eb6 refresh)
CI / ci (push) Successful in 8m31s
CI / ci (pull_request) Successful in 8m30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:39:04 -04:00
dev_agent d166b349ca Merge remote-tracking branch 'origin/feature/7383c2cb-3179-4b45-b558-bf1bc1833fad-launch-readiness' into task/supply-archive-frozen-names_22f80eb6-cdee-4377-80ea-9b28f15a866f
CI / ci (push) Successful in 8m27s
CI / ci (pull_request) Successful in 8m21s
2026-10-11 10:35:32 -04:00
dev_agentandClaude Opus 5.5 7b0a63e1b5 Fix the 0019 migration header comment (task 22f80eb6)
CI / ci (push) Successful in 8m23s
CI / ci (pull_request) Successful in 9m11s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:37:16 -04:00
dev_agentandClaude Opus 5.5 2feaced7c9 Renumber the frozen-name migration to 0019 (task 22f80eb6)
CI / ci (push) Successful in 8m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:29:32 -04:00
dev_agentandClaude Opus 5.5 a72ef1cbdd Keep dinner names frozen in Supply archive posts
CI / ci (push) Successful in 8m17s
Freezing a Supply run now records each group's dinner name in
shopping_groups.frozen_dinner_name, in the same transaction as the
freeze. A frozen list and its Supply archive post read that name, so
renaming a dinner after its week is frozen no longer rewrites the
archive; a current list still shows its dinners' names live.
Migration 0018 adds the column and fills it for lists frozen before it.

Task: 22f80eb6-cdee-4377-80ea-9b28f15a866f

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:18:54 -04:00
22 changed files with 73 additions and 45384 deletions
+1 -1
View File
@@ -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), launch evidence in [LAUNCH.md](docs/LAUNCH.md), and Lighthouse reports in `docs/lighthouse/`. |
| `docs/` | Project documentation, including [ARCHITECTURE.md](docs/ARCHITECTURE.md). |
| `design/` | Requirements and UI handoff. **Read-only reference** — do not edit. |
| `Dockerfile` | Multi-stage image build, at the repository root. |
| `Makefile` | `dev`, `test`, `build`, `docker`. |
-255
View File
@@ -1,255 +0,0 @@
# 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.
-54
View File
@@ -1,54 +0,0 @@
# 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
@@ -1,53 +0,0 @@
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
+2 -5
View File
@@ -222,9 +222,8 @@ 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,
// onHandCount counts the items marked "have it", and frozen is false
// while the list can still be changed from the Supply run.
// the week label, which stands in for title while it is null, and
// onHandCount counts the items marked "have it".
type archivePost struct {
WeekStart string `json:"weekStart"`
Label string `json:"label"`
@@ -234,7 +233,6 @@ 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
@@ -264,7 +262,6 @@ 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}
+5 -70
View File
@@ -720,7 +720,6 @@ type archiveJSON struct {
OnHandCount int `json:"onHandCount"`
Dinners []string `json:"dinners"`
Current bool `json:"current"`
Frozen bool `json:"frozen"`
} `json:"posts"`
} `json:"periods"`
}
@@ -799,7 +798,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", "frozen", "itemCount", "label", "onHandCount", "title", "weekStart"}
wantPost := []string{"current", "dinnerCount", "dinners", "itemCount", "label", "onHandCount", "title", "weekStart"}
if k := keys(period["posts"].([]any)[0].(map[string]any)); !reflect.DeepEqual(k, wantPost) {
t.Errorf("post fields = %v, want %v", k, wantPost)
}
@@ -816,13 +815,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 frozen=%t",
post.WeekStart, post.DinnerCount, post.ItemCount, post.OnHandCount, post.Dinners, post.Current, post.Frozen))
posts = append(posts, fmt.Sprintf("%s %d dinners %d items %d on hand %v current=%t",
post.WeekStart, post.DinnerCount, post.ItemCount, post.OnHandCount, post.Dinners, post.Current))
}
}
want := []string{
thisWeek.String() + " 1 dinners 2 items 0 on hand [Lemon Chicken] current=true frozen=false",
lastWeek.String() + " 1 dinners 3 items 1 on hand [Beef Stew] current=false frozen=true",
thisWeek.String() + " 1 dinners 2 items 0 on hand [Lemon Chicken] current=true",
lastWeek.String() + " 1 dinners 3 items 1 on hand [Beef Stew] current=false",
}
if got.By != "week" || !reflect.DeepEqual(posts, want) {
t.Errorf("archive by %s =\n %s\nwant\n %s", got.By, strings.Join(posts, "\n "), strings.Join(want, "\n "))
@@ -853,70 +852,6 @@ 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
+2 -8
View File
@@ -166,11 +166,6 @@ type Post struct {
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
@@ -227,7 +222,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, frozen(row))
p, err := postOf(monday, l, monday == thisWeek)
if err != nil {
return nil, err
}
@@ -242,7 +237,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, isFrozen bool) (Post, error) {
func postOf(monday store.LocalDate, l List, current bool) (Post, error) {
label, err := household.WeekLabel(monday)
if err != nil {
return Post{}, fmt.Errorf("shopping: Supply run %s: %w", l.ID, err)
@@ -260,6 +255,5 @@ func postOf(monday store.LocalDate, l List, current, isFrozen bool) (Post, error
Items: c.Total,
OnHand: c.Total - c.Needed,
Current: current,
Frozen: isFrozen,
}, nil
}
+1 -4
View File
@@ -381,9 +381,6 @@ 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
}
@@ -430,7 +427,7 @@ func TestArchiveGroups(t *testing.T) {
}
posts := map[string]string{
"jan": "2027-01-04 Week 1 · Jan 4 – 10 | - | Pad Thai, Beef Chili | 5 items, 1 on hand | current | open",
"jan": "2027-01-04 Week 1 · Jan 4 – 10 | - | Pad Thai, Beef Chili | 5 items, 1 on hand | current",
"dec": "2026-12-28 Week 53 · Dec 28 – Jan 3 | Chili, Noodles & Co | Beef Chili, Pad Thai | 5 items, 2 on hand",
"d07": "2026-12-07 Week 50 · Dec 7 – 13 | - | Pad Thai | 2 items, 0 on hand",
"nov": "2026-11-30 Week 49 · Nov 30 – Dec 6 | - | Pot Roast | 1 items, 1 on hand",
+3 -18
View File
@@ -237,21 +237,6 @@ 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(() => {
@@ -568,7 +553,7 @@ describe('PlanPage', () => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
...makesSupplyRun(() => generate()),
'POST /api/v1/shopping/2026-10-05/generate': () => generate(),
})
await renderPage()
@@ -600,7 +585,7 @@ describe('PlanPage', () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
...makesSupplyRun(() => jsonResponse(200, { ...supplyRun(28), skipped: [] })),
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
})
await renderPage()
@@ -685,7 +670,7 @@ describe('PlanPage', () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
...makesSupplyRun(() => jsonResponse(200, { ...supplyRun(28), skipped: [] })),
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
})
const queryClient = createQueryClient()
await renderPage(queryClient, <SupplyRunScreen />)
+26 -57
View File
@@ -10,7 +10,6 @@ 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}`
@@ -25,14 +24,13 @@ 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, frozen: false })
const WEEK_41 = post('2026-10-05', 'Week 41 · Oct 5 – 11', { title: 'Tacos, tikka & a salmon finale', current: true })
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')
@@ -85,9 +83,8 @@ 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,
// with supplyRun at the Supply run's route.
async function renderPage(path = '/plan/shopping/archive', supplyRun = <p>Supply run screen</p>) {
// missing PATCH is not for want of it) and renders the Supply archive at path.
async function renderPage(path = '/plan/shopping/archive') {
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
@@ -96,7 +93,7 @@ async function renderPage(path = '/plan/shopping/archive', supplyRun = <p>Supply
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/plan/shopping/archive" element={<SupplyArchivePage />} />
<Route path="/plan/shopping" element={supplyRun} />
<Route path="/plan/shopping" element={<p>Supply run screen</p>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
@@ -140,7 +137,7 @@ describe('SupplyArchivePage', () => {
expect(within(postCard('Chili season opens')).queryByText(/^\+/)).not.toBeInTheDocument()
expect(screen.getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([
'/plan/shopping?week=2026-10-05',
'/plan/shopping/archive?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',
@@ -157,7 +154,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) => (p.frozen ? `/plan/shopping/archive?week=${p.weekStart}` : `/plan/shopping?week=${p.weekStart}`)),
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
)
}
})
@@ -185,7 +182,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) => (p.frozen ? `/plan/shopping/archive?week=${p.weekStart}` : `/plan/shopping?week=${p.weekStart}`)),
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
)
}
})
@@ -369,66 +366,38 @@ describe('SupplyArchivePage frozen post', () => {
expect(await screen.findByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument()
})
it('opens the current week’s post, not yet frozen, in the Supply run, where it can still change', async () => {
stubServer({
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({
[`GET ${ARCHIVE('week')}`]: archive('week'),
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
})
await renderPage('/plan/shopping/archive', <SupplyRunPage />)
await renderPage()
fireEvent.click(await screen.findByText('Tacos, tikka & a salmon finale'))
expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeEnabled()
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
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([])
})
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 () => {
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 () => {
mockViewport(1440)
stubServer({
const fetchMock = 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', <SupplyRunPage />)
const view = await renderPage('/plan/shopping/archive?week=2026-10-05')
expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeEnabled()
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
expect(screen.queryByRole('complementary', { name: 'Supply archive' })).not.toBeInTheDocument()
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([])
view.unmount()
await renderPage()
+11 -16
View File
@@ -9,9 +9,9 @@ import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { supplyRunScreen, useSupplyArchive, WEEK_PARAM, type ArchiveGroup, type ArchivePeriod, type ArchivePost } from './api.ts'
import { useSupplyArchive, WEEK_PARAM, type ArchiveGroup, type ArchivePeriod, type ArchivePost } from './api.ts'
import styles from './SupplyArchivePage.module.css'
import { SUPPLY_ARCHIVE, SUPPLY_RUN, SupplyRunPage } from './SupplyRunPage.tsx'
import { SUPPLY_ARCHIVE, SupplyRunPage } from './SupplyRunPage.tsx'
// PAGE is how many posts the Supply archive shows at first, and how many
// more each "Load older" shows.
@@ -38,20 +38,15 @@ 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. 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.
// first dinners as chips. The current week's post is ringed in grape. It
// opens the week's list, read-only.
function PostCard({ post }: { post: ArchivePost }) {
const rest = post.dinners.length - CHIPS
return (
<Link
to={post.frozen ? archivePost(post.weekStart) : supplyRunScreen(post.weekStart)}
className={[styles.post, post.current && styles.current].filter(Boolean).join(' ')}
>
<Link to={archivePost(post.weekStart)} className={[styles.post, post.current && styles.current].filter(Boolean).join(' ')}>
<span className={styles.kicker}>
{post.label}
{post.current ? ' · Current' : !post.frozen && ' · Still open'}
{post.current && ' · Current'}
</span>
<span className={styles.postTitle}>{post.title ?? post.label}</span>
<span className={styles.meta}>
@@ -150,7 +145,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(SUPPLY_RUN)}>
<IconButton label="Back to the Supply run" onClick={() => void navigate('/plan/shopping')}>
<Icon icon={ChevronLeft} size="nav" />
</IconButton>
)}
@@ -162,10 +157,10 @@ function SupplyArchive({ panel }: { panel: boolean }) {
)
}
// 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
// SupplyArchivePage is /plan/shopping/archive. A post opens it on its week
// (?week=), whose list the Supply run screen shows read-only and tagged
// Archived, whatever its status: even the current week's post is only
// viewed here, never changed. On desktop (mock 2f) the archive is a panel at
// the right of that list, this week's editable Supply run when no post is
// open; on mobile and tablet it is the archive or, with a post open, the
// post's list.
+4 -52
View File
@@ -99,12 +99,12 @@ function writes(fetchMock: Mock<typeof fetch>) {
// renderPage loads /me (for the CSRF token every write needs) and renders
// the Supply run at path.
async function renderPage(path = '/plan/shopping', queryClient = createQueryClient()) {
async function renderPage(path = '/plan/shopping') {
const api = createApiClient(fakeLocation('/plan/shopping'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>
<QueryClientProvider client={createQueryClient()}>
<MemoryRouter initialEntries={[path]}>
<SupplyRunPage />
</MemoryRouter>
@@ -563,13 +563,9 @@ 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}`]: () => (made ? jsonResponse(200, supplyRun()) : errorResponse(404, 'not_found')),
[`POST ${GENERATE}`]: () => {
made = true
return jsonResponse(200, { ...supplyRun(), skipped: [] })
},
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${GENERATE}`]: () => jsonResponse(200, { ...supplyRun(), skipped: [] }),
})
await renderPage()
@@ -579,50 +575,6 @@ 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'),
+14 -19
View File
@@ -1,6 +1,6 @@
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
import { useState } from 'react'
import { Link, Navigate, useSearchParams } from 'react-router'
import { Link, 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,7 +15,6 @@ import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import {
useGenerateSupplyRun,
supplyRunScreen,
useSendToChat,
useSupplyRun,
useToggles,
@@ -27,9 +26,6 @@ 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'
@@ -147,10 +143,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 is
// read-only and tagged Archived, and a toggle it refused is not offered
// again.
function SupplyRunList({ weekStart, timezone, layout }: Omit<WeekProps, 'readOnly'>) {
// toggle refused puts the item back and says why. An archived one, or any
// opened from the Supply archive (readOnly), is read-only and tagged
// Archived, and a toggle it refused is not offered again.
function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
const toggles = useToggles(weekStart)
const send = useSendToChat(weekStart)
const run = toggles.shown
@@ -161,7 +157,7 @@ function SupplyRunList({ weekStart, timezone, layout }: Omit<WeekProps, 'readOnl
<UnconfirmedStatus at={run.chat_send_unconfirmed_at} timezone={timezone} />
)
const sendUnconfirmed = send.error instanceof ApiError && send.error.code === 'chat_unconfirmed'
const viewOnly = run.status === 'archived'
const viewOnly = readOnly || run.status === 'archived'
const wide = layout !== 'mobile'
const failed = toggles.failed
const failedItem = failed && run.groups.flatMap((group) => group.items).find((item) => item.id === failed.toggle.id)
@@ -179,9 +175,11 @@ function SupplyRunList({ weekStart, timezone, layout }: Omit<WeekProps, 'readOnl
</p>
</div>
<p className={styles.helper}>
{viewOnly
{run.status === 'archived'
? 'This list is in the Supply archive, as it was when the week ended.'
: "Uncheck anything you already have — it won't be counted."}
: viewOnly
? "This week's list as it stands, read-only in the Supply archive."
: "Uncheck anything you already have — it won't be counted."}
</p>
</div>
{wide && (
@@ -242,8 +240,7 @@ 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. Read-only, a Supply run not yet frozen opens the
// editable Supply run screen instead, where it can still change.
// not offered read-only.
function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
const run = useSupplyRun(weekStart)
const generate = useGenerateSupplyRun(weekStart)
@@ -270,16 +267,14 @@ function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
</div>
)
}
if (readOnly && run.data.status !== 'archived') return <Navigate to={supplyRunScreen(weekStart)} replace />
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} />
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} readOnly={readOnly} />
}
// 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: 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,
// ?week=. Opened from the Supply archive it is readOnly: tagged Archived,
// with no checkbox and nothing to make or send, whatever the week. Offline,
// where the settings cannot be read, it chooses the week by the schedule
// saved on this device.
export function SupplyRunPage({ readOnly = false }: { readOnly?: boolean }) {
+4 -17
View File
@@ -194,26 +194,18 @@ 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: async (run, _, settle) => {
await queryClient.cancelQueries({ queryKey: key })
queryClient.setQueryData<SupplyRun | null>(key, run)
onSuccess: (run, _, settle) => {
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
settle()
void queryClient.invalidateQueries({ queryKey: key })
},
})
}
@@ -545,9 +537,7 @@ 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,
// and frozen once the list is frozen into the Supply archive: until then it
// can still be changed from the Supply run.
// dinners in day order. current is true for the household's current week.
export type ArchivePost = {
weekStart: string
label: string
@@ -557,7 +547,6 @@ export type ArchivePost = {
onHandCount: number
dinners: string[]
current: boolean
frozen: boolean
}
// ArchiveGroup is one period of the Supply archive — a week, a month
@@ -572,8 +561,7 @@ 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.frozen !== 'boolean'
typeof body.current !== 'boolean'
) {
throw new Error(`${where}: response has a post that is not a Supply archive post`)
}
@@ -586,7 +574,6 @@ function parsePost(where: string, body: unknown): ArchivePost {
onHandCount: count(where, 'onHandCount', body.onHandCount),
dinners: body.dinners as string[],
current: body.current,
frozen: body.frozen,
}
}