Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 8d46b58f11 Keep plan manager columns usable from 768 px; show method and sync time
CI / ci (push) Successful in 3m11s
CI / ci (pull_request) Successful in 3m14s
The week's columns are never narrower than 112 px, which holds a
dinner's words whole and the tag beside the swap icon. Where seven do not
fit (768-1100 px tablets, desktop up to about 1430 px beside the panel)
the week scrolls sideways inside the content area; at 1180 and 1440 px
the grid is unchanged. Names no longer hyphenate or break mid-word, and
the card footer no longer wraps the swap icon under the tag.

Each card now says how the dinner is cooked as well as how long:
"Crock pot · 6 hr", "Stovetop · 45 min", or the method alone when the
minutes are unknown. The plan API's method is parsed (and carried by a
swap from the Quest log or Regulars), and an unknown method is refused.

The synced status line says how long ago the plan was pushed, from
calendar_synced_at ("Synced to Google Calendar 2 min ago"), kept current
minute by minute.

Maestro task 4657cba1-c709-420e-8b1e-18761e9e0e27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:08:00 -04:00
dev_agentandClaude Opus 5.5 a9038845a3 Add tablet and desktop plan manager (mocks 2c, 2d)
CI / ci (push) Successful in 3m12s
CI / ci (pull_request) Successful in 3m8s
From 768 px up /plan renders the plan manager: "Week N · Plan" kicker,
the week's dates, previous/next buttons and, on desktop, a "This week"
pill; the sync status line; "Shopping list" and "Push to Calendar"
unavailable with tooltips saying why (Features 17 and 16), and a working
outlined "Rebuild week" that confirms over a plan pushed to Calendar; and
a 7-column grid of day cards with washed photo, name, minutes, crock-pot
start-by time, Regular / Side quest tag (Side quests on teal-200) and a
swap icon that opens the existing swap sheet.

Desktop adds the 290 px right panel: "Quest log · N | Regulars · N",
the drag helper, rows with photo, name, difficulty and a 6-dot grip, and
"Swipe more side quests". Tablet shows the Quest log as a chip strip under
the grid. Dragging is presentational here; it lands in task 5c2842a8.

The plan day now reads the photo and minutes the plan API already sends,
and an optimistic swap carries them from the chosen dinner. Tag gains a
neutral tone for "Regular".

Maestro task 4657cba1-c709-420e-8b1e-18761e9e0e27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:40:39 -04:00
orchestrator-agent 3aef21922b Mobile swap sheet (Feature 14) (#118)
CI / ci (push) Successful in 3m13s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 13:28:03 -04:00
orchestrator-agent 9b9421bb58 Mobile draft board (1f) (#117)
CI / ci (push) Successful in 3m21s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 12:42:54 -04:00
orchestrator-agent 0a47b26af6 Rotation check: tried dinners and keep/drop decisions (#116)
CI / ci (push) Failing after 5s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 07:39:22 -04:00
orchestrator-agent 2ed5f69783 Plan service and weekly plan API (#114)
CI / ci (push) Successful in 3m10s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 07:04:50 -04:00
orchestrator-agent 09456c48a2 draft_week skill v1 (#113)
CI / ci (push) Successful in 2m46s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 06:08:13 -04:00
15 changed files with 108 additions and 1536 deletions
+50 -431
View File
@@ -491,263 +491,42 @@ Every deploy builds the image on the host from the checked-out commit.
### Deploy
1. Check out and build the release. Nothing restarts yet; production
keeps running the previous SHA.
```sh
cd /home/overseer/mealquest &&
git fetch origin &&
git rev-parse --short HEAD && # the SHA being replaced: note it for rollback
git checkout --detach origin/main && # or a release tag
cd /home/overseer/mealquest
git fetch origin
git rev-parse --short HEAD # the SHA being replaced: note it for rollback
git checkout --detach origin/main # or a release tag
docker compose -f deploy/compose.yaml build
```
Each command runs only if the one before it succeeded. If `echo $?`
then prints anything but `0`, stop: nothing has restarted.
2. **Verify a restorable pre-deploy point — before the restart.** The
restart applies any new migrations, and an older SHA refuses the
upgraded schema, so the only rollback is restoring the database to a
point before the restart (see "Rollback to a previous SHA"). Check that
Litestream is replicating and that its replica restores to the current
data, with the commands from "Database — Litestream", "Restore the
database" and "Count check". First write `/tmp/mqcount.py` and
`/tmp/mqsnap.py` with the `cat` command in "Count check", then:
```sh
cd /home/overseer/mealquest && (
set -eu # any failed step ends the verification with a nonzero status
D=/var/lib/docker/volumes/mealquest-data/_data
fail() { echo "$*" >&2; exit 1; }
point=
# On the way out, run both clean-ups whatever happens, keep any failure,
# and print the restore point only if every check and clean-up succeeded.
trap 'st=$?
rm -rf /tmp/mqcheck || st=1
sudo sh -c "rm -f $D/predeploy-check.db*" || st=1
if [ "$st" -eq 0 ] && [ -n "$point" ]; then echo "pre-deploy restore point: $point"
else [ "$st" -ne 0 ] || st=1; echo "verification FAILED (status $st): do not restart" >&2; fi
exit "$st"' EXIT
rm -rf /tmp/mqcheck
mkdir /tmp/mqcheck
# a. Replication is running: litestream up, no errors in its log, and the
# newest LTX file (listed) is from the latest writes. Each command's own
# status is checked: grep exits 0 on a match, 1 on none, 2+ on an error.
id=$(docker compose -f deploy/compose.yaml ps --status running --quiet litestream) || fail "docker compose ps failed"
[ -n "$id" ] || fail "litestream is not running"
docker compose -f deploy/compose.yaml logs --since 24h litestream > /tmp/mqcheck/litestream.log || fail "docker compose logs failed"
gs=0
grep -i error /tmp/mqcheck/litestream.log > /tmp/mqcheck/litestream.errors || gs=$?
case "$gs" in
0) cat /tmp/mqcheck/litestream.errors; fail "litestream logged errors in the last 24h";;
1) ;; # no match: no errors logged
*) fail "the litestream log scan failed (status $gs)";;
esac
docker compose -f deploy/compose.yaml exec -T litestream \
litestream ltx -config /etc/litestream.yml -level all /data/mealquest.db
# b. Restore the replica to a scratch file beside the live database (the
# live files are not touched) and move it off the volume; then take a
# consistent snapshot of the live database next to it.
docker compose -f deploy/compose.yaml run --rm --no-deps litestream \
restore -config /etc/litestream.yml -o /data/predeploy-check.db /data/mealquest.db
sudo sh -c "mv $D/predeploy-check.db* /tmp/mqcheck/"
sudo python3 /tmp/mqsnap.py "$D/mealquest.db" /tmp/mqcheck/live.db
sudo chown -R "$USER" /tmp/mqcheck
sudo ls -ln "$D" # mealquest.db-wal and -shm owned by 10001, not 0: see "Count check"
# c. Count check both: the restored replica must report integrity ok and
# exactly the live snapshot's counts and content-sha256.
python3 /tmp/mqcount.py /tmp/mqcheck/predeploy-check.db > /tmp/mqcheck/restored.txt
python3 /tmp/mqcount.py /tmp/mqcheck/live.db > /tmp/mqcheck/live.txt
cat /tmp/mqcheck/restored.txt
grep -qx 'integrity ok' /tmp/mqcheck/restored.txt || fail "the restored replica failed integrity_check"
diff /tmp/mqcheck/restored.txt /tmp/mqcheck/live.txt || fail "the restored replica differs from the live snapshot"
# d. The pre-deploy restore point: now, after every check passed, before
# the restart. It is printed on the way out, once the clean-ups succeed.
point=$(date -u +%Y-%m-%dT%H:%M:%SZ)
)
```
The verification runs in a subshell that stops at the first failed
step. On the way out it always attempts both clean-ups — removing
`/tmp/mqcheck` and any `predeploy-check.db` left on the volume — and a
failed clean-up fails the verification too. The restore point is
printed last, and only when every check and both clean-ups succeeded.
Read it like this:
- `pre-deploy restore point:` followed by a UTC time, and `echo $?`
prints `0` — the backup is verified and cleaned up. Note the time for
rollback, with the previous SHA, and go on to step 3.
- `verification FAILED`, any other error, or a nonzero `echo $?` — no
restore point was printed. If only the counts differed, a write
landed between the restore and the snapshot: run the block again. If
a clean-up failed, remove what it names by hand before running the
block again.
- The `ls` in step b shows `mealquest.db-wal` or `-shm` owned by `0`
— stop, as in "Count check", even if the rest passed.
**If any check fails, do not restart.** Production stays on the
previous SHA (only the new image was built). Restore replication first
— `docker compose -f deploy/compose.yaml up -d --force-recreate
litestream`, then read its log for the cause (NAS reachable, key,
host key) — and repeat this step until it passes. Never deploy a
release on an unverified backup.
3. Restart and check the deployed release.
```sh
cd /home/overseer/mealquest && (
set -eu # any failed step ends the check with a nonzero status
fail() { echo "$*" >&2; exit 1; }
sha=
# On the way out, print the deployed SHA only if every check passed.
trap 'st=$?
if [ "$st" -eq 0 ] && [ -n "$sha" ]; then echo "deployed SHA: $sha"
else [ "$st" -ne 0 ] || st=1; echo "deploy FAILED (status $st): no SHA is deployed" >&2; fi
exit "$st"' EXIT
sudo systemctl restart mealquest # applies the firewall, then down + up -d
docker compose -f deploy/compose.yaml ps
for s in mealquest litestream searxng; do
id=$(docker compose -f deploy/compose.yaml ps --status running --quiet "$s") || fail "docker compose ps failed"
[ -n "$id" ] || fail "$s is not running"
done
docker compose -f deploy/compose.yaml ps # mealquest healthy, litestream up, searxng healthy
curl -fsS http://10.60.1.14:8090/health
code=$(curl -sS -o /dev/null -w "%{http_code}" https://mealquest.cubecraftlabs.com/health)
[ "$code" = 200 ] || fail "the public /health returned $code, not 200"
sha=$(git rev-parse HEAD) # the checked-out SHA, now running and healthy
)
curl -s -o /dev/null -w "%{http_code}\n" https://mealquest.cubecraftlabs.com/health # 200
git rev-parse HEAD # the deployed SHA
```
The check stops at the first failed step and prints the SHA last, only
when the restart, all three services running and both health checks
succeeded. `restart` returns once `mealquest` is healthy (Litestream
waits for it); SearXNG may still show `health: starting` for its first
15 seconds.
- `deployed SHA:` followed by the SHA, and `echo $?` prints `0` —
record that SHA, the previous SHA, the pre-deploy restore point and
the date in the Maestro task for the release.
- `deploy FAILED`, any other error, or a nonzero `echo $?` — record
nothing as deployed: the release is not running healthy, and after a
failed restart the stack may be down. Read
`journalctl -u mealquest` and `docker compose -f deploy/compose.yaml
logs`, then fix the cause or roll back ("Rollback to a previous SHA").
Migrations run at start-up; the service refuses to start if the database
has an applied migration it does not know.
Record the deployed SHA, the previous SHA and the date in the Maestro task
for the release. Migrations run at start-up; the service refuses to start
if the database has an applied migration it does not know.
If `deploy/.env.example` gained a variable, add it to `deploy/.env` before
the restart; the service fails fast at boot naming any missing required variable.
### Rollback to a previous SHA
First check whether the rollback crosses a migration, with the rule the
service applies at start-up (`internal/store/migrate.go`): the older build
refuses a database holding a version in `schema_migrations` that is not one
of its own migration files — the `*.sql` files directly in `migrations/`
(`migrations/queries/` holds sqlc queries, not migrations). Compare the
versions a consistent snapshot of the live database has applied with the
files the previous SHA has. First write `/tmp/mqsnap.py` with the `cat`
command in "Count check", then:
```sh
cd /home/overseer/mealquest && (
set -eu # any failed step ends the check with a nonzero status
D=/var/lib/docker/volumes/mealquest-data/_data
P=<previous SHA>
# On the way out, remove /tmp/mqcheck whatever happens, keep any failure,
# and report every nonzero exit.
trap 'st=$?
rm -rf /tmp/mqcheck || st=1
if [ "$st" -ne 0 ]; then echo "check FAILED (status $st): stop and fix the cause" >&2; fi
exit "$st"' EXIT
rm -rf /tmp/mqcheck
mkdir /tmp/mqcheck
sudo python3 /tmp/mqsnap.py "$D/mealquest.db" /tmp/mqcheck/mealquest.db
sudo chown -R "$USER" /tmp/mqcheck
git ls-tree --name-only "$P" migrations/ > /tmp/mqcheck/known # top level only
python3 - /tmp/mqcheck/mealquest.db /tmp/mqcheck/known <<'PY'
import sqlite3, sys
applied = {v for (v,) in sqlite3.connect(sys.argv[1]).execute("select version from schema_migrations")}
known = {n[len("migrations/"):-len(".sql")] for n in open(sys.argv[2]).read().split() if n.endswith(".sql")}
if not applied or not known:
sys.exit("no versions read: check the snapshot and the previous SHA, then run again")
unknown = sorted(applied - known)
print("unknown to the previous SHA:", " ".join(unknown) if unknown else "none")
PY
)
```
The check runs in a subshell that stops at the first failed step — the
snapshot or its integrity check, `git ls-tree` or the comparison. On the
way out it always attempts to remove `/tmp/mqcheck`, and a failed removal
fails the check too; every nonzero exit prints `check FAILED`. Read it
like this:
- `unknown to the previous SHA: none` and `echo $?` prints `0` — no
migration crossed: take the plain restart below.
- `unknown to the previous SHA:` followed by versions, and `echo $?`
prints `0` — the older build does not know them: the migration-crossing
path below is required.
- `check FAILED`, any other error, or a nonzero `echo $?` — stop and fix
the cause, then run the check again. Do not pick a path, even if an
`unknown to the previous SHA:` line was printed before it. If the
clean-up failed, remove `/tmp/mqcheck` by hand first.
**No migration crossed** — rebuild and restart the older SHA:
```sh
cd /home/overseer/mealquest && (
set -eu # any failed step ends the rollback with a nonzero status
P=<previous SHA>
fail() { echo "$*" >&2; exit 1; }
sha=
# On the way out, print the running SHA only if every check passed.
trap 'st=$?
if [ "$st" -eq 0 ] && [ -n "$sha" ]; then echo "rolled back to: $sha"
else [ "$st" -ne 0 ] || st=1; echo "rollback FAILED (status $st)" >&2; fi
exit "$st"' EXIT
git checkout --detach "$P"
cd /home/overseer/mealquest
git checkout --detach <previous SHA>
docker compose -f deploy/compose.yaml build
sudo systemctl restart mealquest
docker compose -f deploy/compose.yaml ps
for s in mealquest litestream searxng; do
id=$(docker compose -f deploy/compose.yaml ps --status running --quiet "$s") || fail "docker compose ps failed"
[ -n "$id" ] || fail "$s is not running"
done
curl -fsS http://10.60.1.14:8090/health
sha=$(git rev-parse HEAD)
)
```
Each step runs only if the one before it succeeded, so the health
check answers only for the older SHA. Only `rolled back to:` followed by
the SHA, with `echo $?` printing `0`, means the rollback is done. On
`rollback FAILED` or a nonzero `echo $?` it is not: the first error names
the failed step, and if it came before the restart, production is still
on the release being rolled back from.
**A migration crossed** — the older binary refuses the newer schema, so the
database is restored first and the older SHA started after. Changes made
since the pre-deploy restore point are lost; say so to the household.
1. Check out and build the older SHA. Nothing restarts yet.
```sh
git checkout --detach <previous SHA> &&
docker compose -f deploy/compose.yaml build
```
If `echo $?` then prints anything but `0`, stop: the database has not
been touched and nothing has restarted.
2. Run "Restore the database" steps 1–4, adding
`-timestamp <pre-deploy restore point>` (recorded in step 2 of
"Deploy") to the `restore` command. Its step 5 then starts the older
SHA and checks health.
A rollback across a release that added a migration fails at start-up
(the older binary refuses the newer schema). In that case restore the
database to a point before the upgrade — "Restore the database" with
`-timestamp` set just before the deploy — then start the older SHA.
Changes made since that point are lost; say so to the household.
## Backups
@@ -834,20 +613,11 @@ database it replaced. So all three files are moved aside together, with
Litestream's local state directory `.mealquest.db-litestream`, before the
restore — and the restore runs into an empty volume.
Steps 1–3:
Run from `/home/overseer/mealquest`:
```sh
cd /home/overseer/mealquest && (
set -eu # any failed step ends the restore with a nonzero status
D=/var/lib/docker/volumes/mealquest-data/_data
A=/var/tmp/mealquest-restore-aside-$(date +%Y%m%d%H%M)
fail() { echo "$*" >&2; exit 1; }
# On the way out, name the aside directory; on any failure the stack
# stays stopped.
trap 'st=$?
if [ "$st" -eq 0 ]; then echo "restored mealquest.db; the old files are in $A: run step 4"
else echo "restore FAILED (status $st): leave the stack stopped; anything moved aside is in $A" >&2; fi
exit "$st"' EXIT
# 1. Stop the whole stack (app and Litestream). Nothing may hold the
# database open; a clean close checkpoints the WAL into mealquest.db.
@@ -860,90 +630,34 @@ sudo mkdir "$A"
for f in mealquest.db mealquest.db-wal mealquest.db-shm .mealquest.db-litestream; do
if sudo test -e "$D/$f"; then sudo mv "$D/$f" "$A/"; fi
done
left=$(sudo ls -A "$D")
[ "$left" = media ] || fail "the volume must hold only media/, not: $left"
sudo ls -la "$D" # must list only media/
# 3. Restore from the NAS. --no-deps keeps the app stopped so it cannot
# create a fresh, empty database first.
docker compose -f deploy/compose.yaml run --rm --no-deps litestream \
restore -config /etc/litestream.yml -o /data/mealquest.db /data/mealquest.db
left=$(sudo ls -A "$D")
[ "$left" = "$(printf 'mealquest.db\nmedia')" ] || fail "the volume must hold only mealquest.db and media/ (no -wal, no -shm), not: $left"
)
sudo ls -la "$D" # mealquest.db only: no -wal, no -shm
# 4. Check the restored file before starting the app (see "Count check").
# 5. Start the stack and check health.
sudo systemctl start mealquest
docker compose -f deploy/compose.yaml ps
curl -s -o /dev/null -w "health %{http_code}\n" http://10.60.1.14:8090/health
```
To restore to a point in time, add `-timestamp 2026-10-05T23:00:00Z` (or
`-txid`) to the `restore` command.
The block stops at the first failed step. Only `restored mealquest.db`
with `echo $?` printing `0` lets you go on; on `restore FAILED` leave the
stack stopped, fix the cause and run the block again (a new aside
directory is made; the earlier one keeps what it holds).
4. Check the restored file before starting the app: run the copy block
in "Count check".
5. Start the stack and check health, only after step 4 passed:
```sh
cd /home/overseer/mealquest && (
set -eu # any failed step ends the check with a nonzero status
fail() { echo "$*" >&2; exit 1; }
sha=
# On the way out, print the running SHA only if every check passed.
trap 'st=$?
if [ "$st" -eq 0 ] && [ -n "$sha" ]; then echo "stack running and healthy at $sha"
else [ "$st" -ne 0 ] || st=1; echo "start FAILED (status $st)" >&2; fi
exit "$st"' EXIT
sudo systemctl start mealquest
docker compose -f deploy/compose.yaml ps
for s in mealquest litestream searxng; do
id=$(docker compose -f deploy/compose.yaml ps --status running --quiet "$s") || fail "docker compose ps failed"
[ -n "$id" ] || fail "$s is not running"
done
curl -fsS http://10.60.1.14:8090/health
sha=$(git rev-parse HEAD)
)
```
Only `stack running and healthy at` with `echo $?` printing `0` ends
the restore; then the aside directory may go.
### Count check
`/tmp/mqcount.py` prints row counts for key tables, the number of
application tables, a SHA-256 over every application row (first 16 hex
digits; Litestream's own `_litestream*` tables are excluded) and
`PRAGMA integrity_check`. Run it on a **copy** of the database, never on the
live file.
live file; copy `-wal`/`-shm` along with it when the app is running.
While the app or Litestream has the database open, the copy is a snapshot
from `/tmp/mqsnap.py`: it opens the live database read-only, copies it
with SQLite's online backup API (one consistent read, whatever is still in
the WAL included) and exits nonzero unless the snapshot's
`PRAGMA integrity_check` is `ok`. Never copy `mealquest.db` and its `-wal`
as files while they are open: a checkpoint between the two copies leaves
an older state that still reads cleanly. "Deploy" step 2 and "Rollback to
a previous SHA" use the snapshot. With the stack stopped, as in "Restore
the database" step 4, the files are closed and checkpointed, and a plain
copy is consistent (a read-only open there would leave a `-wal` and
`-shm` beside the restored file).
`mode=ro` stops writes through the snapshot's own connection, but SQLite
can still create a missing `-wal` or `-shm` beside the database, and under
`sudo` it would be owned by root. The live snapshot therefore expects the
app and Litestream to be running and holding the existing
`mealquest.db-wal` and `mealquest.db-shm`, owned by `10001`. If
`sudo ls -ln "$D"` after a live snapshot shows either sidecar owned by
`0` — the snapshot created it — stop before going further: check
`docker compose -f deploy/compose.yaml ps` (mealquest and litestream
running) and, with the cause understood, return the sidecars to
`10001:10001` before proceeding.
`/tmp` is temporary, so write the scripts before every check:
```sh
cat > /tmp/mqcount.py <<'EOF' &&
```python
import sqlite3, sys, hashlib
c = sqlite3.connect(sys.argv[1])
tabs = [r[0] for r in c.execute("select name from sqlite_master where type=\"table\" and name not like \"sqlite_%\" and name not like \"_litestream%\" order by name")]
@@ -955,56 +669,15 @@ for t in tabs:
h.update(repr(row).encode())
print("tables", len(tabs), "content-sha256", h.hexdigest()[:16])
print("integrity", c.execute("pragma integrity_check").fetchone()[0])
EOF
cat > /tmp/mqsnap.py <<'EOF'
import os, sqlite3, sys
src, dst = sys.argv[1], sys.argv[2]
if os.path.exists(dst):
sys.exit(f"{dst} already exists")
s = sqlite3.connect(f"file:{src}?mode=ro", uri=True)
d = sqlite3.connect(dst)
s.backup(d)
s.close()
result = d.execute("pragma integrity_check").fetchall()
d.close()
if result != [("ok",)]:
sys.exit(f"snapshot integrity_check failed: {result}")
print("snapshot", dst, "integrity ok")
EOF
```
The second file is written only if the first was; `echo $?` must then
print `0` before either script is used.
With the stack stopped, copy the database and run it:
```sh
cd /home/overseer/mealquest && (
set -eu # any failed step ends the check with a nonzero status
D=/var/lib/docker/volumes/mealquest-data/_data
fail() { echo "$*" >&2; exit 1; }
# On the way out, remove /tmp/mqcheck whatever happens, keep any failure,
# and give the verdict only if every step and the clean-up succeeded.
trap 'st=$?
rm -rf /tmp/mqcheck || st=1
if [ "$st" -eq 0 ]; then echo "count check: integrity ok"
else echo "count check FAILED (status $st): do not start the stack" >&2; fi
exit "$st"' EXIT
rm -rf /tmp/mqcheck
mkdir /tmp/mqcheck
sudo sh -c "cp -a $D/mealquest.db* /tmp/mqcheck/"
sudo chown -R "$USER" /tmp/mqcheck
python3 /tmp/mqcount.py /tmp/mqcheck/mealquest.db > /tmp/mqcheck/counts.txt
cat /tmp/mqcheck/counts.txt
grep -qx 'integrity ok' /tmp/mqcheck/counts.txt || fail "the copy failed integrity_check"
)
rm -rf /tmp/mqcheck && mkdir /tmp/mqcheck
sudo sh -c "cp -a $D/mealquest.db* /tmp/mqcheck/" && sudo chown -R "$USER" /tmp/mqcheck
python3 /tmp/mqcount.py /tmp/mqcheck/mealquest.db
```
The check stops at the first failed step — the clean-up of an earlier
`/tmp/mqcheck`, the copy, the count or the integrity check — so it never
reads a file left by an earlier run. Only `count check: integrity ok`
with `echo $?` printing `0` passes; then the counts printed above it,
before and after, must match exactly.
Before and after must match exactly.
## Restore photos (media)
@@ -1021,52 +694,20 @@ not start the service until the restore is verified.
Run from `/home/overseer/mealquest`:
```sh
cd /home/overseer/mealquest && (
set -eu # any failed step ends the restore with a nonzero status
M=/var/lib/docker/volumes/mealquest-data/_data/media
NAS=/volume1/homes/Joshua/mealquest-backups/media
SSH="ssh -i deploy/secrets/litestream_key -o IdentitiesOnly=yes -o UserKnownHostsFile=/etc/mealquest/known_hosts -o StrictHostKeyChecking=yes"
fail() { echo "$*" >&2; exit 1; }
nas= here= w=
# On the way out, give the counts only if every step passed; on any
# failure, report the timer's actual state, not an assumed one.
trap 'st=$?
[ -z "$w" ] || rm -rf "$w"
if [ "$st" -eq 0 ] && [ -n "$here" ]; then echo "media restored: $here files here, $nas on the NAS, all $nas match the NAS manifest"
else [ "$st" -ne 0 ] || st=1; t=$(systemctl is-active mealquest-media-backup.timer) || :
if [ "$t" = inactive ]; then echo "media restore FAILED (status $st): the timer is inactive; leave it stopped and find out why" >&2
else echo "media restore FAILED (status $st): the timer is ${t:-unknown}, not inactive; run sudo systemctl stop mealquest-media-backup.timer mealquest-media-backup.service and confirm it is inactive before anything else" >&2; fi; fi
exit "$st"' EXIT
w=$(mktemp -d)
# 1. Stop the nightly mirror: the timer AND any run in flight.
sudo systemctl stop mealquest-media-backup.timer mealquest-media-backup.service
# 2. Pre-check — all must hold before media/ is touched. is-active exits
# nonzero for every state but active, so its printed state is checked;
# pgrep exits 0 on a match, 1 on none, 2+ on an error.
ts=$(systemctl is-active mealquest-media-backup.timer) || :
[ "$ts" = inactive ] || fail "the timer is ${ts:-unknown}, not inactive"
ss=$(systemctl is-active mealquest-media-backup.service) || :
case "$ss" in
inactive|failed) ;;
*) fail "the service is ${ss:-unknown}, not inactive or failed";;
esac
ps=0
pgrep -a -u mealquest rsync || ps=$?
case "$ps" in
0) fail "an rsync is still running as mealquest";;
1) ;; # no match: no rsync
*) fail "pgrep failed (status $ps)";;
esac
l=$(sudo $SSH joshua@10.60.1.50 "find $NAS -type f") || fail "listing the NAS copy failed"
[ -n "$l" ] || fail "the NAS copy holds no files"
nas=$(printf '%s\n' "$l" | wc -l) # the target below
# The NAS manifest: one "sha256 ./path" line per file, taken now, before
# the copy. Every check below compares content against it, not counts.
sudo $SSH joshua@10.60.1.50 "cd $NAS && find . -type f -exec sha256sum {} +" >"$w/raw" || fail "hashing the NAS copy failed"
LC_ALL=C sort "$w/raw" >"$w/nas"
[ "$(wc -l <"$w/nas")" -eq "$nas" ] || fail "the NAS manifest has $(wc -l <"$w/nas") files, the listing $nas"
# 2. Pre-check — all three must hold before media/ is touched. If any
# fails, stop here and find out why.
systemctl is-active mealquest-media-backup.timer # inactive
systemctl is-active mealquest-media-backup.service # inactive (or failed)
pgrep -a -u mealquest rsync || echo "no rsync" # no rsync
sudo $SSH joshua@10.60.1.50 "find $NAS -type f | wc -l"
# ^ note this NAS file count; it must be non-zero and is the target below.
# 3. Stop the app (no writes into media/ meanwhile).
docker compose -f deploy/compose.yaml stop mealquest
@@ -1075,46 +716,24 @@ docker compose -f deploy/compose.yaml stop mealquest
sudo install -d -o 10001 -g 10001 -m 0755 "$M"
# 5. Copy back from the NAS as uid 10001's files. No --delete in this
# direction: the NAS copy is the source and is only read. --checksum
# compares content, so a local file with the NAS size and mtime but
# other bytes is still replaced.
sudo rsync -a --checksum --stats --chown=10001:10001 -e "$SSH" "joshua@10.60.1.50:$NAS/" "$M/"
# direction: the NAS copy is the source and is only read.
sudo rsync -a --stats --chown=10001:10001 -e "$SSH" "joshua@10.60.1.50:$NAS/" "$M/"
# 6. Post-condition — before anything can mirror again: at least the NAS
# count here, and the NAS count unchanged.
l=$(sudo find "$M" -type f) || fail "listing $M failed"
[ -n "$l" ] || fail "$M holds no files after the copy"
n=$(printf '%s\n' "$l" | wc -l)
[ "$n" -ge "$nas" ] || fail "only $n of the NAS's $nas files are here: the copy did not complete"
l=$(sudo $SSH joshua@10.60.1.50 "find $NAS -type f") || fail "listing the NAS copy failed"
[ -n "$l" ] && [ "$(printf '%s\n' "$l" | wc -l)" -eq "$nas" ] || fail "the NAS file count changed from $nas"
# Content: every file in the NAS manifest is here with the same bytes,
# and the NAS still matches the manifest taken before the copy.
sudo sh -c 'cd "$1" && find . -type f -exec sha256sum {} +' sh "$M" >"$w/raw" || fail "hashing $M failed"
LC_ALL=C sort "$w/raw" >"$w/here"
LC_ALL=C comm -23 "$w/nas" "$w/here" >"$w/bad"
[ ! -s "$w/bad" ] || { cat "$w/bad" >&2; fail "$(wc -l <"$w/bad") NAS file(s) above are missing here or differ in content"; }
sudo $SSH joshua@10.60.1.50 "cd $NAS && find . -type f -exec sha256sum {} +" >"$w/raw" || fail "hashing the NAS copy failed"
LC_ALL=C sort "$w/raw" | cmp -s - "$w/nas" || fail "the NAS copy changed during the restore"
here=$n
# 6. Post-condition — before anything can mirror again:
sudo find "$M" -type f | wc -l # >= the NAS count from step 2
sudo $SSH joshua@10.60.1.50 "find $NAS -type f | wc -l" # unchanged from step 2
# 7. Start the app, then the timer again. Only now may the service run.
docker compose -f deploy/compose.yaml up -d
curl -fsS http://10.60.1.14:8090/health
sudo systemctl start mealquest-media-backup.timer
systemctl is-active mealquest-media-backup.timer # active; exits nonzero otherwise
)
systemctl is-active mealquest-media-backup.timer # active
```
The block stops at the first failed step and gives the counts last, only
when every step passed and every file in the NAS manifest (a SHA-256 of
each file, taken before the copy) is present locally with the same bytes.
On `media restore FAILED` or a nonzero `echo $?` the message gives the
timer's actual state: if it is not `inactive`, stop it as the message says
before anything else; then leave it stopped. The local count may exceed the NAS count only if
`media/` still held files the NAS never received (photos added since the
last nightly run); the next nightly run then copies them up. If the local count is lower,
the copy did not complete: fix the cause and run the block again.
The local count may exceed the NAS count only if `media/` still held
files the NAS never received (photos added since the last nightly run);
the next nightly run then copies them up. If the local count is lower,
the copy did not complete: leave the timer stopped and re-run step 5.
When the database is restored too, restore it first ("Restore the
database"), then the media, so every photo the database refers to is
+5 -14
View File
@@ -357,25 +357,16 @@ automatic** (D-021).
left as it is and the job fails `confirm_required`. The job reads the
plan's revision before draft_week runs; if the plan was edited (a swap,
or another draft stored) by the time the draft is stored, the plan is
left as it is and the job fails `plan_changed`. The pool is read again
in the same transaction that stores the draft; if a dinner the draft
planned has left it meanwhile (archived, `tried`, or a Quest log dinner
stored on another current or later week's plan, as by a draft of
another week), nothing is stored and the job fails `pool_changed`,
which drafting again resolves. A pool under seven
left as it is and the job fails `plan_changed`. A pool under seven
dinners fails the job `not_enough_dinners`, draft_week failing
`ai_failed`. `PUT /api/v1/plans/{weekStart}/days/{date}` takes
`{dinner_id}`, a Regular or Quest log dinner, swaps the day, recomputes
`is_new`, moves a `synced` plan to `changed`, and answers 200 with the
plan. A date outside the week is 400 `invalid_date`, an unknown dinner or
a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and
a dinner already on another day of the week 409 `dinner_already_planned`.
- Rotation check (`plan.Service`, D-021). This release provides the
`MarkTried` service method and the rotation review read and decide APIs
below. The `rotation_check` schedule that is to call `MarkTried` for the
household-local day just past is planned (Feature 20, the scheduler) and
not wired yet: nothing in production calls `MarkTried`, so no Rotation
check opens automatically. `MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any
a week with no plan 404, and any other dinner 409 `dinner_not_plannable`.
- Rotation check (`plan.Service`, D-021). `MarkTried(date)`, which the
`rotation_check` schedule calls for the household-local day just past,
moves every `quest_log` dinner planned on that date, on a plan of any
status, to `tried` and opens a `pending` `rotation_reviews` row for it;
a dinner already checked for that date is left as it is, so it is
idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the
+6 -23
View File
@@ -36,9 +36,9 @@ func (r draftResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// Regulars and the Quest log dinners no other current or later plan has,
// given the household context and last week's dinners, and the draft is
// stored as the week's plan if the plan is still as it was before
// draft_week ran and every dinner it planned is still in the pool. A pool
// under seven dinners fails with ReasonNotEnoughDinners and draft_week
// failing with ReasonAIFailed; no week is made up instead.
// draft_week ran. A pool under seven dinners fails with
// ReasonNotEnoughDinners and draft_week failing with ReasonAIFailed; no
// week is made up instead.
func (s *Service) runDraft(ctx context.Context, p DraftJob, _ func(draftResult) error) (draftResult, error) {
weekStart, err := ParseWeekStart(p.WeekStart)
if err != nil {
@@ -68,7 +68,7 @@ func (s *Service) runDraft(ctx context.Context, p DraftJob, _ func(draftResult)
for _, w := range week.Warnings {
s.logger.InfoContext(ctx, "week draft warning", "week_start", p.WeekStart, "warning", w)
}
planID, err := s.storeDraft(ctx, weekStart, household.WeekStart(in.Context.Today), p.Confirm, base, week)
planID, err := s.storeDraft(ctx, weekStart, p.Confirm, base, week)
if err != nil {
return draftResult{}, err
}
@@ -142,28 +142,11 @@ func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skill
// draft was asked for without confirm is left as it is, failing with
// ReasonConfirmRequired, and so is a plan no longer at revision base (the
// revision before draft_week ran, or noPlan), failing with
// ReasonPlanChanged. The pool is read again, as of thisWeek like the pool
// draft_week was given: a day whose dinner is no longer in it, or is in it
// as a Regular rather than the Quest log or the other way round, leaves
// everything as it is, failing with ReasonPoolChanged.
func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) {
// ReasonPlanChanged.
func (s *Service) storeDraft(ctx context.Context, weekStart store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) {
var planID string
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
rows, err := q.ListDraftPool(ctx, db.ListDraftPoolParams{FromWeekStart: thisWeek.String(), WeekStart: weekStart.String()})
if err != nil {
return fmt.Errorf("plan: list draft pool: %w", err)
}
pool := make(map[string]string, len(rows))
for _, d := range rows {
pool[d.ID] = d.Status
}
for _, d := range week.Days {
status, ok := pool[d.DinnerID]
if !ok || (status == dinners.StatusQuestLog) != d.IsNew {
return &jobs.Failure{Code: ReasonPoolChanged}
}
}
now := store.Now().String()
note := sql.NullString{String: week.Note, Valid: week.Note != ""}
cur, err := q.GetWeekPlan(ctx, weekStart.String())
+4 -21
View File
@@ -53,15 +53,10 @@ const (
// ReasonPlanChanged: the plan was edited while draft_week ran, so the
// draft does not replace the edit.
ReasonPlanChanged = "plan_changed"
// ReasonPoolChanged: a dinner the draft planned left the pool while
// draft_week ran (archived, tried, or its Quest log dinner planned on
// another week), so the draft is not stored; drafting again uses the
// pool as it is now.
ReasonPoolChanged = "pool_changed"
)
// Reasons are every draft failure reason.
var Reasons = []string{ReasonNotEnoughDinners, ReasonAIFailed, ReasonConfirmRequired, ReasonPlanChanged, ReasonPoolChanged}
var Reasons = []string{ReasonNotEnoughDinners, ReasonAIFailed, ReasonConfirmRequired, ReasonPlanChanged}
// ContextBudget is the character budget of the household context
// draft_week is given (see harness.BuildContext).
@@ -76,10 +71,6 @@ var ErrNotFound = errors.New("plan not found")
// ErrDinnerNotFound is a swap to an unknown dinner.
var ErrDinnerNotFound = errors.New("dinner not found")
// ErrDinnerAlreadyPlanned is a swap to a dinner already on another day of
// the week.
var ErrDinnerAlreadyPlanned = errors.New("the dinner is already on another day of this week")
// ErrConfirmRequired is a draft over a synced or changed plan asked for
// without confirm.
var ErrConfirmRequired = errors.New("the plan was pushed to Calendar; drafting over it needs confirm")
@@ -346,10 +337,9 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con
// Swap plans dinnerID on date of the week starting weekStart, marking the
// day new when the dinner is in the Quest log. Swapping a synced plan
// makes it changed. The week must have a plan (else ErrNotFound), date
// must be one of its days (else *InvalidDayError), the dinner must be a
// Regular or in the Quest log (else ErrDinnerNotFound or
// *DinnerNotPlannableError), and it must not be on another day of the
// week (else ErrDinnerAlreadyPlanned). It returns the plan as swapped.
// must be one of its days (else *InvalidDayError), and the dinner must be
// a Regular or in the Quest log (else ErrDinnerNotFound or
// *DinnerNotPlannableError). It returns the plan as swapped.
func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, dinnerID string) (Plan, error) {
dates, err := household.WeekDays(weekStart)
if err != nil {
@@ -379,13 +369,6 @@ func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, din
if !slices.Contains(plannable, d.Status) {
return &DinnerNotPlannableError{Status: d.Status}
}
days, err := q.ListPlanDays(ctx, cur.ID)
if err != nil {
return fmt.Errorf("plan: read days of %s: %w", cur.ID, err)
}
if slices.ContainsFunc(days, func(r db.ListPlanDaysRow) bool { return r.Date != day.String() && r.Dinner.ID == d.ID }) {
return ErrDinnerAlreadyPlanned
}
now := store.Now().String()
n, err := q.SwapPlanDay(ctx, db.SwapPlanDayParams{
DinnerID: sql.NullString{String: d.ID, Valid: true},
+7 -81
View File
@@ -174,15 +174,9 @@ func (e *env) reply(note string, names ...string) {
// weekReply is the draft_week reply planning the dinners named names on
// weekDates, in order.
func (e *env) weekReply(note string, names ...string) llm.GenerateResult {
return e.weekReplyOn(weekDates, note, names...)
}
// weekReplyOn is the draft_week reply planning the dinners named names on
// dates, in order.
func (e *env) weekReplyOn(dates []string, note string, names ...string) llm.GenerateResult {
var days []string
for i, name := range names {
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], e.ids[name]))
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, weekDates[i], e.ids[name]))
}
js := fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note)
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
@@ -404,16 +398,15 @@ func TestStartByAcrossDaylightSavingTimeEnd(t *testing.T) {
t.Errorf("%s start_by = %s, %v; want %s", dates[tc.day], b, err, tc.want)
}
}
// Swapped to Short Ribs (240 minutes), Sunday starts at 13:30 EST.
ribs := e.dinner(t, "Short Ribs", "regular", "crockpot", 240)
if _, err := e.svc.Swap(t.Context(), dst, "2026-11-01", ribs); err != nil {
// Swapped to Beef Chili, Sunday starts at 13:30 EST.
if _, err := e.svc.Swap(t.Context(), dst, "2026-11-01", e.ids["Beef Chili"]); err != nil {
t.Fatal(err)
}
if p, err = e.svc.Get(t.Context(), dst); err != nil {
t.Fatal(err)
}
if b, err := json.Marshal(p.Days[6].StartBy); err != nil || string(b) != `"2026-11-01T13:30:00-05:00"` {
t.Errorf("Sunday Short Ribs start_by = %s, %v; want 13:30 EST", b, err)
t.Errorf("Sunday Beef Chili start_by = %s, %v; want 13:30 EST", b, err)
}
}
@@ -473,70 +466,6 @@ func TestDraftPoolLeavesOutQuestLogPlannedOnAnotherWeek(t *testing.T) {
}
}
func TestDraftFailsWhenPoolDinnerLeavesWhileDrafting(t *testing.T) {
for _, status := range []string{"archived", "tried"} {
t.Run(status, func(t *testing.T) {
e := newEnv(t)
e.start(t)
// Pad Thai leaves the Quest log while draft_week plans it.
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("", fullWeek...)
r.Started, r.Release = started, release
e.model.QueueGenerate(r)
d, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
<-started
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = ? WHERE id = ?`, status, e.ids["Pad Thai"]); err != nil {
t.Fatal(err)
}
close(release)
job := e.waitJob(t, d.JobID)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged)
}
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Get = %v, want ErrNotFound: no partial plan", err)
}
})
}
}
func TestDraftsOfTwoWeeksDoNotShareAQuestLogDinner(t *testing.T) {
e := newEnv(t)
e.start(t)
// This week's draft plans Pad Thai; while draft_week runs, the other
// worker drafts a later week with Pad Thai too and stores it first.
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("", fullWeek...)
r.Started, r.Release = started, release
e.model.QueueGenerate(r)
d, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
<-started
later := mustDate("2099-01-05")
laterDates := []string{"2099-01-05", "2099-01-06", "2099-01-07", "2099-01-08", "2099-01-09", "2099-01-10", "2099-01-11"}
e.model.QueueGenerate(e.weekReplyOn(laterDates, "", fullWeek...))
other, err := e.svc.StartDraft(t.Context(), later, false)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, other.JobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("later week's draft %s %v", job.Status, job.Error)
}
close(release)
job := e.waitJob(t, d.JobID)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged)
}
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Get = %v, want ErrNotFound: no partial plan", err)
}
}
func TestDraftFailsWithNotEnoughDinners(t *testing.T) {
e := newEnv(t)
e.start(t)
@@ -704,8 +633,7 @@ func TestDraftFailsWhenPlanSwappedWhileDrafting(t *testing.T) {
t.Fatal(err)
}
<-started
e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"]); err != nil {
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Sheet Pan Salmon"]); err != nil {
t.Fatal(err)
}
close(release)
@@ -718,7 +646,7 @@ func TestDraftFailsWhenPlanSwappedWhileDrafting(t *testing.T) {
t.Fatal(err)
}
want := slices.Clone(fullWeek)
want[2] = "Fish Tacos"
want[2] = "Sheet Pan Salmon"
if *p.AINote != "first" || !slices.Equal(dayNames(p), want) {
t.Errorf("plan = %q %v, want the first draft with the swap %v", *p.AINote, dayNames(p), want)
}
@@ -894,8 +822,7 @@ func TestSwapStatusTransitions(t *testing.T) {
e.reply("", fullWeek...)
e.mustDraft(t, false)
e.setStatus(t, tc.from)
fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
p, err := e.svc.Swap(t.Context(), week, "2026-10-12", fishTacos)
p, err := e.svc.Swap(t.Context(), week, "2026-10-12", e.ids["Pad Thai"])
if err != nil {
t.Fatal(err)
}
@@ -925,7 +852,6 @@ func TestSwapRefusals(t *testing.T) {
}{
{"no plan", mustDate("2026-10-19"), "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.Is(err, plan.ErrNotFound) }},
{"unknown dinner", week, "2026-10-12", "no-such-dinner", func(err error) bool { return errors.Is(err, plan.ErrDinnerNotFound) }},
{"dinner on another day", week, "2026-10-12", e.ids["Pot Roast"], func(err error) bool { return errors.Is(err, plan.ErrDinnerAlreadyPlanned) }},
{"suggested dinner", week, "2026-10-12", suggested, func(err error) bool { return errors.As(err, &notPlannable) }},
{"archived dinner", week, "2026-10-12", archived, func(err error) bool { return errors.As(err, &notPlannable) }},
{"day of another week", week, "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
+2 -4
View File
@@ -107,8 +107,8 @@ func weekStartOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger
// writePlanError maps a plan.Service error to its API error: a week with
// no plan or an unknown dinner is 404, a date outside the week is 400, and
// drafting over a synced plan without confirm, a confirmed draft while an
// unconfirmed one runs, planning a dinner that is neither a Regular nor in
// the Quest log, or one already on another day of the week, is 409.
// unconfirmed one runs, or planning a dinner that is neither a Regular nor
// in the Quest log, is 409.
func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var day *plan.InvalidDayError
var week *plan.InvalidWeekError
@@ -120,8 +120,6 @@ func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger,
writeError(w, r, logger, http.StatusBadRequest, "invalid_week", week.Error())
case errors.As(err, &notPlannable):
writeError(w, r, logger, http.StatusConflict, "dinner_not_plannable", notPlannable.Error())
case errors.Is(err, plan.ErrDinnerAlreadyPlanned):
writeError(w, r, logger, http.StatusConflict, "dinner_already_planned", err.Error())
case errors.Is(err, plan.ErrConfirmRequired):
writeError(w, r, logger, http.StatusConflict, "confirm_required", err.Error())
case errors.Is(err, plan.ErrDraftRunning):
-1
View File
@@ -309,7 +309,6 @@ func TestPutPlanDay(t *testing.T) {
{"2026-10-12/days/2026-10-32", `{"dinner_id":"` + e.questLog + `"}`, http.StatusBadRequest, "invalid_date"},
{"2026-10-12/days/2026-10-14", `{"dinner_id":"no-such-dinner"}`, http.StatusNotFound, "not_found"},
{"2026-10-19/days/2026-10-20", `{"dinner_id":"` + e.questLog + `"}`, http.StatusNotFound, "not_found"},
{"2026-10-12/days/2026-10-15", `{"dinner_id":"` + e.questLog + `"}`, http.StatusConflict, "dinner_already_planned"},
} {
rec, body := callDinners(t, e.h, http.MethodPut, plansPath+tc.target, tc.body)
if rec.Code != tc.status || errorCode(body) != tc.code {
-35
View File
@@ -464,7 +464,6 @@
}
.card {
position: relative;
box-sizing: border-box;
flex: 1;
display: flex;
@@ -532,40 +531,6 @@
gap: 4px;
}
/* The day a dragged dinner drops on: a dashed 2.5 px grape border on
grape-100 over the card, saying which dinner it replaces. */
.dropTarget {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4px;
padding: 12px;
border: 2.5px dashed var(--color-accent);
border-radius: inherit;
background: var(--color-accent-100);
text-align: center;
}
.dropTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 16px;
color: var(--color-accent-700);
}
.dropReplaces {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
overflow-wrap: break-word;
}
/* The swap icon sits bare on the card, beside the tag: its 44 px hit box
keeps the 16 px footprint of its icon, reaching over the card's padding
and the tag. */
+2 -385
View File
@@ -810,8 +810,8 @@ describe.each([
const orzo = within(within(panel).getByRole('list', { name: 'Quest log' })).getByRole('listitem')
expect(within(orzo).getByText('Lemon orzo').nextElementSibling).toHaveTextContent('Easy')
expect(declaredStyle(within(orzo).getByRole('img', { hidden: true, name: 'Lemon orzo' })).width).toBe('48px')
// The 6-dot grip, last in the row's button.
expect(within(orzo).getByRole('button').lastElementChild!.children).toHaveLength(6)
// The 6-dot grip.
expect(orzo.lastElementChild!.children).toHaveLength(6)
fireEvent.click(within(panel).getByRole('radio', { name: 'Regulars · 2' }))
const regulars = within(within(panel).getByRole('list', { name: 'Regulars' })).getAllByRole('listitem')
@@ -972,389 +972,6 @@ describe.each([
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`]))
})
it('reads the Quest log again once a draft finishes, dropping the dinners it planned', async () => {
let plan = planBody('draft')
let job = draftJob('running')
let questLog = [dinner('q1', 'Lemon orzo', 'easy', 25)]
stubServer({
...lists,
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
[`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }),
[`GET ${JOB}`]: () => jsonResponse(200, job),
[`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: questLog }),
})
await renderPage()
const list = await screen.findByRole('list', { name: layout === 'desktop' ? 'Quest log' : 'Quest log dinners' })
expect(await within(list).findByText('Lemon orzo')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' }))
expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument()
plan = planBody('draft', ['Lemon orzo', ...NAMES.slice(1)])
questLog = []
job = draftJob('succeeded')
const week = await screen.findByRole('list', { name: 'Dinners this week' }, { timeout: 3000 })
expect(await within(week).findByText('Lemon orzo', {}, { timeout: 3000 })).toBeInTheDocument()
await waitFor(() => expect(within(list).queryByText('Lemon orzo')).not.toBeInTheDocument())
})
})
// Dragging a dinner from desktop's panel or tablet's strip onto a day swaps
// it in, by pointer or keyboard.
describe.each([
{ width: 1180, layout: 'tablet' },
{ width: 1440, layout: 'desktop' },
])('PlanPage drag and drop at $width px', ({ width, layout }) => {
const FRIDAY = `${THIS_WEEK}/days/2026-10-09`
let capture: Mock<(pointerId: number) => void>
// The element document.elementFromPoint finds under the pointer.
let hovered: Element | null = null
// jsdom has no pointer capture and no layout to hit-test.
beforeEach(() => {
mockViewport(width)
capture = vi.fn()
Element.prototype.setPointerCapture = capture
hovered = null
document.elementFromPoint = () => hovered
})
afterEach(() => {
Reflect.deleteProperty(Element.prototype, 'setPointerCapture')
Reflect.deleteProperty(document, 'elementFromPoint')
})
// The Quest log has lemon orzo and miso salmon.
const dragLists = {
...lists,
[`GET ${QUEST_LOG}`]: () =>
jsonResponse(200, { dinners: [dinner('q1', 'Lemon orzo', 'easy', 25), dinner('q2', 'Miso salmon', 'medium', 30)] }),
}
// swappedBody is the plan as the server answers a swap of Friday to lemon
// orzo, a Quest-log pick.
function swappedBody() {
const body = planBody('draft')
body.days[4] = { ...body.days[4]!, dinner: { ...body.days[4]!.dinner, id: 'q1', name: 'Lemon orzo' }, is_new: true }
return body
}
function puts(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => init?.method === 'PUT')
.map(([input, init]) => `${String(input)} ${String(init?.body)}`)
}
function column(day: string) {
return within(screen.getByRole('list', { name: 'Dinners this week' })).getByRole('listitem', { name: day })
}
// quest is the Quest-log dinner's draggable: a row of the panel on
// desktop, a chip of the strip on tablet.
async function quest(name: string) {
const list = await screen.findByRole('list', { name: layout === 'desktop' ? 'Quest log' : 'Quest log dinners' })
return within(list).getByRole('button', { name: new RegExp(name) })
}
const announcer = () => screen.getByTestId('drag-announcer')
const float = () => screen.queryByTestId('drag-float')
// press, move and release drive pointer 3 on the draggable: down at
// (100, 100), moved over an element (or none) at (160, 140) or the given
// point, and let go there.
function press(draggable: Element) {
fireEvent.pointerDown(draggable, { pointerId: 3, isPrimary: true, button: 0, clientX: 100, clientY: 100 })
}
function move(draggable: Element, over: Element | null, [clientX, clientY] = [160, 140]) {
hovered = over
fireEvent.pointerMove(draggable, { pointerId: 3, isPrimary: true, clientX, clientY })
}
function release(draggable: Element) {
fireEvent.pointerUp(draggable, { pointerId: 3, isPrimary: true, button: 0, clientX: 160, clientY: 140 })
}
it('drops a dinner dragged onto a day, swapping it in at once and landing the plan the server answers', async () => {
let answer!: (response: Response) => void
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
...dragLists,
[`PUT ${FRIDAY}`]: () => new Promise<Response>((resolve) => (answer = resolve)),
})
await renderPage()
await screen.findByText('Pizza night')
const orzo = await quest('Lemon orzo')
expect(declaredStyle(orzo).getPropertyValue('touch-action')).toBe('none')
expect(orzo).toHaveAccessibleDescription(
'Press Enter or Space to pick it up, the arrow keys to choose a day, Enter to drop it on that day and Escape to cancel.',
)
press(orzo)
expect(capture).toHaveBeenCalledExactlyOnceWith(3)
expect(capture.mock.contexts[0]).toBe(orzo)
// A press alone is no drag.
expect(float()).not.toBeInTheDocument()
// Over Friday: the source a dashed ghost at 55%, its copy floating with
// the large shadow at -3deg, and Friday the drop target.
move(orzo, within(column('Friday')).getByText('Pizza night'))
expect(declaredStyle(orzo).opacity).toBe('0.55')
expect(declaredStyle(orzo).borderStyle).toBe('dashed')
const copy = float()!
expect(copy).toHaveTextContent('Lemon orzo')
expect(copy).toHaveAttribute('aria-hidden', 'true')
expect(declaredStyle(copy).boxShadow).toBe('var(--shadow-lg)')
expect(declaredStyle(copy).transform).toBe('rotate(-3deg)')
expect(declaredStyle(copy).pointerEvents).toBe('none')
// Offset 48 px below right of the pointer at (160, 140), clear of the
// day's text under it.
expect(copy.style.left).toBe('208px')
expect(copy.style.top).toBe('188px')
expect(announcer()).toHaveTextContent('Picked up Lemon orzo.')
const target = within(column('Friday')).getByText('Drop to swap').parentElement!
expect(target).toHaveTextContent('Drop to swapReplaces Pizza night')
expect(declaredStyle(target).border).toBe('2.5px dashed var(--color-accent)')
expect(declaredStyle(target).background).toBe('var(--color-accent-100)')
expect(within(column('Thursday')).queryByText('Drop to swap')).not.toBeInTheDocument()
// Moving on to Thursday moves the target.
move(orzo, column('Thursday'))
expect(within(column('Friday')).queryByText('Drop to swap')).not.toBeInTheDocument()
expect(within(column('Thursday')).getByText('Replaces Chicken stir-fry')).toBeInTheDocument()
move(orzo, within(column('Friday')).getByRole('button', { name: 'Swap Friday' }))
release(orzo)
expect(float()).not.toBeInTheDocument()
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
expect(declaredStyle(orzo).opacity).toBe('')
expect(announcer()).toHaveTextContent('Dropped Lemon orzo on Friday, replacing Pizza night.')
// Optimistic: Friday is the Side quest at once.
const friday = await waitFor(() => within(column('Friday')).getByText('Lemon orzo').parentElement!)
expect(within(friday).getByText('Side quest')).toBeInTheDocument()
await waitFor(() => expect(puts(fetchMock)).toEqual([`${FRIDAY} {"dinner_id":"q1"}`]))
await act(async () => answer(jsonResponse(200, swappedBody())))
expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument()
expect(screen.queryByText('Pizza night')).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(puts(fetchMock)).toHaveLength(1)
})
it('flips the floating copy to the left of and above the pointer where it would leave the viewport', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...dragLists })
await renderPage()
await screen.findByText('Pizza night')
const orzo = await quest('Lemon orzo')
vi.spyOn(orzo, 'getBoundingClientRect').mockReturnValue(DOMRect.fromRect({ x: 90, y: 90, width: 240, height: 60 }))
press(orzo)
const [x, y] = [window.innerWidth - 100, window.innerHeight - 40]
move(orzo, document.body, [x, y])
const copy = float()!
expect(copy.style.width).toBe('240px')
expect(copy.style.left).toBe(`${x - 48 - 240}px`)
expect(copy.style.top).toBe(`${y - 48 - 60}px`)
release(orzo)
})
it('cancels a drag let go over no day, or cancelled, with no swap', async () => {
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...dragLists })
await renderPage()
await screen.findByText('Pizza night')
const orzo = await quest('Lemon orzo')
press(orzo)
move(orzo, column('Friday'))
expect(within(column('Friday')).getByText('Drop to swap')).toBeInTheDocument()
// Off the grid, onto the page around it.
move(orzo, document.body)
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
expect(float()).toBeInTheDocument()
release(orzo)
expect(float()).not.toBeInTheDocument()
expect(declaredStyle(orzo).opacity).toBe('')
expect(announcer()).toHaveTextContent('Cancelled. Lemon orzo was not swapped in.')
// A cancelled pointer, over a day, is no drop either.
press(orzo)
move(orzo, column('Monday'))
expect(within(column('Monday')).getByText('Replaces Beef tacos')).toBeInTheDocument()
fireEvent.pointerCancel(orzo, { pointerId: 3, isPrimary: true })
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
expect(float()).not.toBeInTheDocument()
// Nor is a drag Escape ends.
press(orzo)
move(orzo, column('Monday'))
fireEvent.keyDown(orzo, { key: 'Escape' })
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
release(orzo)
expect(within(column('Friday')).getByText('Pizza night')).toBeInTheDocument()
expect(within(column('Monday')).getByText('Beef tacos')).toBeInTheDocument()
expect(puts(fetchMock)).toEqual([])
})
it('puts the day back and shows the failure when the dropped swap is refused', async () => {
let refuse!: () => void
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
...dragLists,
[`PUT ${FRIDAY}`]: () => new Promise<Response>((resolve) => (refuse = () => resolve(errorResponse(409, 'dinner_not_plannable')))),
})
await renderPage()
await screen.findByText('Pizza night')
const orzo = await quest('Lemon orzo')
press(orzo)
move(orzo, column('Friday'))
release(orzo)
await waitFor(() => expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument())
await waitFor(() => expect(puts(fetchMock)).toHaveLength(1))
// While the swap is pending no other drag starts, by pointer or keyboard.
const salmon = await quest('Miso salmon')
expect(salmon).toHaveAttribute('aria-disabled', 'true')
press(salmon)
move(salmon, column('Monday'))
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
expect(float()).not.toBeInTheDocument()
release(salmon)
fireEvent.keyDown(salmon, { key: 'Enter' })
expect(announcer()).toHaveTextContent('A swap is still being saved. Pick Miso salmon up once it is done.')
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
await act(async () => refuse())
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message")
expect(within(column('Friday')).getByText('Pizza night')).toBeInTheDocument()
expect(within(column('Friday')).queryByText('Lemon orzo')).not.toBeInTheDocument()
expect(puts(fetchMock)).toHaveLength(1)
await waitFor(() => expect(salmon).not.toHaveAttribute('aria-disabled'))
})
it('swaps a dinner in with the keyboard, announcing each step', async () => {
let answer!: (response: Response) => void
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
...dragLists,
[`PUT ${FRIDAY}`]: () => new Promise<Response>((resolve) => (answer = resolve)),
})
await renderPage()
await screen.findByText('Pizza night')
const orzo = await quest('Lemon orzo')
expect(announcer()).toHaveAttribute('aria-live', 'polite')
// Enter picks it up onto Monday.
orzo.focus()
fireEvent.keyDown(orzo, { key: 'Enter' })
expect(announcer()).toHaveTextContent(
'Picked up Lemon orzo. Monday: replaces Beef tacos. Use the arrow keys to choose a day, Enter to drop, Escape to cancel.',
)
expect(declaredStyle(orzo).opacity).toBe('0.55')
expect(within(column('Monday')).getByText('Replaces Beef tacos')).toBeInTheDocument()
// No pointer, so no floating copy.
expect(float()).not.toBeInTheDocument()
// The arrow keys choose the day, going no further than Monday.
fireEvent.keyDown(orzo, { key: 'ArrowLeft' })
expect(within(column('Monday')).getByText('Drop to swap')).toBeInTheDocument()
fireEvent.keyDown(orzo, { key: 'ArrowRight' })
expect(announcer()).toHaveTextContent('Tuesday: replaces Spaghetti bolognese.')
expect(within(column('Monday')).queryByText('Drop to swap')).not.toBeInTheDocument()
fireEvent.keyDown(orzo, { key: 'ArrowDown' })
fireEvent.keyDown(orzo, { key: 'ArrowRight' })
fireEvent.keyDown(orzo, { key: 'ArrowRight' })
fireEvent.keyDown(orzo, { key: 'ArrowRight' })
fireEvent.keyDown(orzo, { key: 'ArrowUp' })
expect(announcer()).toHaveTextContent('Friday: replaces Pizza night.')
expect(within(column('Friday')).getByText('Replaces Pizza night')).toBeInTheDocument()
expect(puts(fetchMock)).toEqual([])
// Enter drops it, swapping it in at once; focus moves to the day.
fireEvent.keyDown(orzo, { key: 'Enter' })
expect(announcer()).toHaveTextContent('Dropped Lemon orzo on Friday, replacing Pizza night.')
expect(screen.getByRole('button', { name: 'Swap Friday' })).toHaveFocus()
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
await waitFor(() => expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument())
await waitFor(() => expect(puts(fetchMock)).toEqual([`${FRIDAY} {"dinner_id":"q1"}`]))
await act(async () => answer(jsonResponse(200, swappedBody())))
expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument()
expect(puts(fetchMock)).toHaveLength(1)
})
it('cancels a keyboard drag with Escape, or by leaving it', async () => {
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...dragLists })
await renderPage()
await screen.findByText('Pizza night')
const orzo = await quest('Lemon orzo')
// Space picks it up too, but does not drop it.
orzo.focus()
fireEvent.keyDown(orzo, { key: ' ' })
fireEvent.keyDown(orzo, { key: 'ArrowRight' })
fireEvent.keyDown(orzo, { key: ' ' })
expect(within(column('Tuesday')).getByText('Drop to swap')).toBeInTheDocument()
fireEvent.keyDown(orzo, { key: 'Escape' })
expect(announcer()).toHaveTextContent('Cancelled. Lemon orzo was not swapped in.')
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
expect(declaredStyle(orzo).opacity).toBe('')
expect(orzo).toHaveFocus()
// The arrow keys do nothing until it is picked up again; Enter does.
fireEvent.keyDown(orzo, { key: 'ArrowRight' })
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
fireEvent.keyDown(orzo, { key: 'Enter' })
expect(within(column('Monday')).getByText('Drop to swap')).toBeInTheDocument()
// Tabbing away cancels.
fireEvent.blur(orzo)
expect(announcer()).toHaveTextContent('Cancelled. Lemon orzo was not swapped in.')
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
expect(within(column('Monday')).getByText('Beef tacos')).toBeInTheDocument()
expect(puts(fetchMock)).toEqual([])
})
if (layout === 'desktop') {
it('does not pick up a Regular already this week', async () => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
...dragLists,
[`PUT ${THIS_WEEK}/days/2026-10-07`]: () => new Promise<Response>(() => undefined),
})
await renderPage()
await screen.findByText('Pizza night')
fireEvent.click(await screen.findByRole('radio', { name: 'Regulars · 2' }))
const regulars = screen.getByRole('list', { name: 'Regulars' })
const tacos = within(regulars).getByRole('button', { name: /Beef tacos/ })
expect(tacos).toHaveAttribute('aria-disabled', 'true')
fireEvent.keyDown(tacos, { key: 'Enter' })
expect(announcer()).toHaveTextContent('Beef tacos is already this week.')
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
// A Regular not this week is dragged in as one, not NEW.
const fish = within(regulars).getByRole('button', { name: /Fish tacos/ })
expect(fish).not.toHaveAttribute('aria-disabled')
press(fish)
move(fish, column('Wednesday'))
release(fish)
await waitFor(() => expect(within(column('Wednesday')).getByText('Fish tacos').parentElement).toHaveTextContent('Regular'))
await waitFor(() => expect(puts(fetchMock)).toEqual([`${THIS_WEEK}/days/2026-10-07 {"dinner_id":"r1"}`]))
})
}
})
// Mobile's Draft board has no drag: its tiles open the swap sheet.
describe('PlanPage drag and drop on mobile', () => {
it('has no draggables, drop targets or drag announcements', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists })
const { container } = await renderPage()
await screen.findByText('Pizza night')
expect(container.querySelector('[data-drop-date]')).toBeNull()
expect(screen.queryByTestId('drag-announcer')).not.toBeInTheDocument()
expect(screen.queryByText('Drag a dinner onto a day to swap it in.')).not.toBeInTheDocument()
})
})
// At tablet's narrow end seven usable columns do not fit beside the rail:
+11 -36
View File
@@ -2,7 +2,6 @@ import { useQueryClient } from '@tanstack/react-query'
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
import { useJob } from '../api/index.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
import { Confirm } from '../settings/LeaveGuard.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
@@ -29,7 +28,6 @@ import {
type PlanStatus,
type Swap,
} from './api.ts'
import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx'
import { PlanPanel, QuestLogStrip } from './PlanPanel.tsx'
import { SwapSheet } from './SwapSheet.tsx'
import {
@@ -58,7 +56,6 @@ const draftFailures: Record<string, string> = {
ai_failed: "The AI assistant couldn't draft it.",
confirm_required: 'The plan was pushed to Calendar while it was drafting.',
plan_changed: 'The plan was edited while it was drafting.',
pool_changed: 'A dinner it picked was archived, tried or planned for another week while it was drafting.',
internal_error: 'Something went wrong on the server.',
}
@@ -141,16 +138,13 @@ function DayTile({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone:
// over a card with the dinner's washed photo, name, method and time, a
// crock-pot dinner's start-by time in the household's zone, and its Regular
// or Side quest tag, a Side quest's card in teal. The swap icon opens the swap
// sheet; while that is open the card is swapping, ringed in grape. A dinner
// dragged over the day drops on it: the card is swapping, covered by a
// dashed grape "Drop to swap" saying which dinner it replaces.
// sheet; while that is open the card is swapping, ringed in grape.
function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) {
const { dinner } = day
const time = cooking(dinner)
const target = useDropTarget(day.date) !== null
const classes = [styles.card, day.is_new && styles.sideQuestCard, (swapping || target) && styles.swapping].filter(Boolean).join(' ')
const classes = [styles.card, day.is_new && styles.sideQuestCard, swapping && styles.swapping].filter(Boolean).join(' ')
return (
<li className={styles.column} aria-label={weekdayName(day.date)} {...{ [DROP_DATE]: day.date }}>
<li className={styles.column} aria-label={weekdayName(day.date)}>
<p className={styles.dayLabel} aria-hidden="true">
<span className={styles.dayName}>{weekdayKicker(day.date)}</span>
<span className={styles.dayNumber}>{dayOfMonth(day.date)}</span>
@@ -171,12 +165,6 @@ function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone
<Icon icon={RefreshCw} size="inline" />
</IconButton>
</div>
{target && (
<div className={styles.dropTarget}>
<p className={styles.dropTitle}>Drop to swap</p>
<p className={styles.dropReplaces}>Replaces {dinner.name}</p>
</div>
)}
</div>
</li>
)
@@ -241,14 +229,12 @@ function WeekBoard({
// Once the job ends the plan is read again: a succeeded draft is followed
// until that read lands; a failed one stays shown, and may have met a
// plan changed meanwhile. The Quest log is read again too, as after a
// swap: the draft may have planned some of it.
// plan changed meanwhile.
const [readJobId, setReadJobId] = useState<string | null>(null)
const ended = job.status === 'succeeded' || job.status === 'failed'
useEffect(() => {
if (!ended) return
let active = true
void queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }).then(() => {
if (active) setReadJobId(jobId)
})
@@ -330,12 +316,6 @@ function WeekBoard({
setSwappingDate(null)
swap.mutate(chosen)
}
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
// nor dropped, while a swap of the week is pending.
function dropIn(chosen: Swap) {
swap.dismiss()
swapIn(chosen)
}
// openSwap opens the swap sheet on a day. One swap of the week is sent at
// a time, even across leaving the week and coming back: the plan the
// server answers it with must not overwrite another still pending.
@@ -379,7 +359,6 @@ function WeekBoard({
body =
layout !== 'mobile' ? (
<>
<DropBoard days={days} pending={swap.pending} drop={dropIn} />
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
{days.map((day) => (
<DayColumn
@@ -566,8 +545,7 @@ function Unavailable({
// Regulars in a right panel on desktop (2d). It opens on next week once this
// week's plan_draft time has come in the household's zone, otherwise on this
// week. The board keeps its place in the tree across layouts, so crossing a
// breakpoint keeps a draft being followed. From tablet up a dinner is
// dragged from the strip or the panel onto a day of the grid to swap it in.
// breakpoint keeps a draft being followed.
export function PlanPage() {
const layout = useBreakpoint()
const settings = useHouseholdSettings()
@@ -585,15 +563,12 @@ export function PlanPage() {
content = <WeekBoard key={week} weekStart={week} timezone={settings.data.timezone} layout={layout} onWeek={setWeek} />
}
return (
<PlanDragProvider>
<div className={`${styles.page} ${styles[`${layout}Page`]}`}>
<div className={styles.board}>
{content}
{layout === 'tablet' && <QuestLogStrip />}
</div>
{layout === 'desktop' && <PlanPanel />}
{layout !== 'mobile' && <DragAnnouncer />}
<div className={`${styles.page} ${styles[`${layout}Page`]}`}>
<div className={styles.board}>
{content}
{layout === 'tablet' && <QuestLogStrip />}
</div>
</PlanDragProvider>
{layout === 'desktop' && <PlanPanel />}
</div>
)
}
-52
View File
@@ -25,15 +25,12 @@
}
.row {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
border-radius: 22px;
background: var(--color-surface);
text-align: left;
}
/* The 48 px washed photo; Photo applies the washed filter. The placeholder's
@@ -172,52 +169,3 @@
height: 28px;
font-size: 0;
}
/* A row or a chip is a button dragged onto a day: by a pointer (no
scrolling or text selection under it) or with the keyboard. Its 2 px
border is clear until it is the ghost, inside its padding. */
.draggable {
margin: 0;
border: 2px solid transparent;
font: inherit;
color: var(--color-text);
cursor: grab;
touch-action: none;
user-select: none;
}
.row.draggable {
padding: 8px;
}
.chip.draggable {
padding: 3px 12px 3px 3px;
}
/* A dinner that cannot be picked up now: no grid to drop on, a swap
pending, or already this week. */
.draggable[aria-disabled='true'] {
cursor: default;
}
/* The row or chip being dragged: a dashed ghost at 55% opacity. */
.ghost {
border-style: dashed;
border-color: var(--color-neutral-500);
background: transparent;
opacity: 0.55;
}
/* The dragged copy floating below right of the pointer: lifted with the
large shadow and tilted -3deg. It lets the pointer through to the day
under it. */
.floating {
position: fixed;
z-index: 10;
box-sizing: border-box;
margin: 0;
box-shadow: var(--shadow-lg);
transform: rotate(-3deg);
pointer-events: none;
cursor: grabbing;
}
+9 -81
View File
@@ -1,6 +1,5 @@
import { Compass } from 'lucide-react'
import { useId, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { useState, type ReactNode } from 'react'
import { Link } from 'react-router'
import { useRegulars } from '../dinners/api.ts'
import { useQuestLog, type QuestLogDinner } from '../discover/api.ts'
@@ -10,16 +9,14 @@ import { Icon } from '../ui/Icon.tsx'
import { Photo } from '../ui/Photo.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { useDraggable, type Float } from './drag.tsx'
import { emptyLists, planDinner, sourceNames, type Source } from './SwapSheet.tsx'
import { emptyLists, sourceNames, type Source } from './SwapSheet.tsx'
import styles from './PlanPanel.module.css'
// DRAG_HINT says how a dinner is swapped in from the panel or the strip.
// TODO(5c2842a8): drag-and-drop and its keyboard alternative; until then the
// rows and chips are not draggable.
const DRAG_HINT = 'Drag a dinner onto a day to swap it in.'
// KEYS_HINT says how a dinner is swapped in with the keyboard.
const KEYS_HINT = 'Press Enter or Space to pick it up, the arrow keys to choose a day, Enter to drop it on that day and Escape to cancel.'
type List = ReturnType<typeof useQuestLog>
// ListBody is a list of the panel or the strip once it has loaded, or why it
@@ -57,64 +54,6 @@ function Grip() {
)
}
// Draggable is a dinner of the panel or the strip, dragged onto a day to
// swap it in: a button whose content is the row or the chip. While it is
// dragged it is a dashed ghost, and with a pointer a copy of it floats
// below right of the pointer, lifted and tilted.
function Draggable({
dinner,
fromQuestLog,
className,
keysHintId,
children,
}: {
dinner: QuestLogDinner
fromQuestLog: boolean
className: string | undefined
keysHintId: string
children: ReactNode
}) {
const { drag, float, props } = useDraggable({ dinner: planDinner(dinner), fromQuestLog })
return (
<li>
<button
type="button"
className={[className, styles.draggable, drag !== null && styles.ghost].filter(Boolean).join(' ')}
aria-describedby={keysHintId}
{...props}
>
{children}
</button>
{float !== null && <Floating float={float} className={className}>{children}</Floating>}
</li>
)
}
// Floating is the dragged copy of a row or a chip, drawn over the page.
function Floating({ float, className, children }: { float: Float; className: string | undefined; children: ReactNode }) {
return createPortal(
<div
aria-hidden="true"
data-testid="drag-float"
className={[className, styles.floating].filter(Boolean).join(' ')}
style={{ left: float.left, top: float.top, width: float.width }}
>
{children}
</div>,
document.body,
)
}
// KeysHint is the keyboard's way to swap a dinner in, describing each
// draggable.
function KeysHint({ id }: { id: string }) {
return (
<p id={id} hidden>
{KEYS_HINT}
</p>
)
}
// PlanPanel is desktop's right panel (mock 2d): the Quest log and the
// Regulars, segmented "Quest log · N | Regulars · N", each dinner with its
// 48 px photo, name, difficulty and grip, and a link to swipe more Side
@@ -124,7 +63,6 @@ export function PlanPanel() {
const regulars = useRegulars()
const [source, setSource] = useState<Source>('quest_log')
const lists = { quest_log: questLog, regulars }
const keysHintId = useId()
const label = (s: Source) => {
const dinners = lists[s].data
@@ -143,18 +81,11 @@ export function PlanPanel() {
onValueChange={setSource}
/>
<p className={styles.hint}>{DRAG_HINT}</p>
<KeysHint id={keysHintId} />
<ListBody source={source} list={lists[source]}>
{(dinners) => (
<ul aria-label={sourceNames[source]} className={styles.rows}>
{dinners.map((dinner) => (
<Draggable
key={dinner.id}
dinner={dinner}
fromQuestLog={source === 'quest_log'}
className={styles.row}
keysHintId={keysHintId}
>
<li key={dinner.id} className={styles.row}>
{/* The row's name already says what the photo shows. */}
<span aria-hidden="true">
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-pill)" sizes="48px" className={styles.rowPhoto} />
@@ -164,7 +95,7 @@ export function PlanPanel() {
{dinner.difficulty !== null && <span className={styles.rowMeta}>{DIFFICULTY_LABEL[dinner.difficulty]}</span>}
</span>
<Grip />
</Draggable>
</li>
))}
</ul>
)}
@@ -178,11 +109,9 @@ export function PlanPanel() {
}
// QuestLogStrip is tablet's Quest log under the week's grid (mock 2c): each
// dinner a chip with its 28 px photo and name, dragged as the panel's rows
// are.
// dinner a chip with its 28 px photo and name.
export function QuestLogStrip() {
const questLog = useQuestLog()
const keysHintId = useId()
const title = questLog.data === undefined ? sourceNames.quest_log : `${sourceNames.quest_log} · ${questLog.data.length}`
return (
<section aria-label={sourceNames.quest_log} className={styles.strip}>
@@ -190,17 +119,16 @@ export function QuestLogStrip() {
<h2 className={styles.stripTitle}>{title}</h2>
<p className={styles.stripHint}>{DRAG_HINT}</p>
</div>
<KeysHint id={keysHintId} />
<ListBody source="quest_log" list={questLog}>
{(dinners) => (
<ul aria-label={`${sourceNames.quest_log} dinners`} className={styles.chips}>
{dinners.map((dinner) => (
<Draggable key={dinner.id} dinner={dinner} fromQuestLog className={styles.chip} keysHintId={keysHintId}>
<li key={dinner.id} className={styles.chip}>
<span aria-hidden="true">
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-pill)" sizes="28px" className={styles.chipPhoto} />
</span>
{dinner.name}
</Draggable>
</li>
))}
</ul>
)}
+12 -7
View File
@@ -9,7 +9,7 @@ import { Segmented } from '../ui/Segmented.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import type { PlanDay, PlanDinner, Swap } from './api.ts'
import type { PlanDay, Swap } from './api.ts'
import { weekdayName } from './week.ts'
import styles from './SwapSheet.module.css'
@@ -23,11 +23,6 @@ export const emptyLists: Record<Source, string> = {
regulars: 'You have no Regulars yet.',
}
// planDinner is a listed dinner as it is planned on a day.
export function planDinner(dinner: QuestLogDinner): PlanDinner {
return { id: dinner.id, name: dinner.name, photo_id: dinner.photo_id, minutes: dinner.minutes_total, method: dinner.method }
}
export type SwapSheetProps = {
// The day being swapped.
day: PlanDay
@@ -93,7 +88,17 @@ export function SwapSheet({ day, planned, onSwap, onClose }: SwapSheetProps) {
already
? undefined
: () =>
onSwap({ date: day.date, dinner: planDinner(dinner), fromQuestLog: source === 'quest_log' })
onSwap({
date: day.date,
dinner: {
id: dinner.id,
name: dinner.name,
photo_id: dinner.photo_id,
minutes: dinner.minutes_total,
method: dinner.method,
},
fromQuestLog: source === 'quest_log',
})
}
trailing={already && <Tag>Already this week</Tag>}
/>
-12
View File
@@ -1,12 +0,0 @@
/* The drag's live region: read out, not shown. */
.announcer {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
-353
View File
@@ -1,353 +0,0 @@
import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from 'react'
import type { PlanDay, PlanDinner, Swap } from './api.ts'
import { weekdayName } from './week.ts'
import styles from './drag.module.css'
// Pickup is a dinner picked up from the plan panel or the Quest-log strip,
// and whether it is a Quest-log dinner.
export type Pickup = { dinner: PlanDinner; fromQuestLog: boolean }
// Board is the week's grid as a drag drops onto it: its days, whether a
// swap of the week is pending, and how a dinner dropped on a day is swapped
// in.
export type Board = { days: PlanDay[]; pending: boolean; drop: (swap: Swap) => void }
// Drag is the dinner being dragged, by pointer or keyboard, and the date of
// the day it would replace, if any.
export type Drag = { pickup: Pickup; keyboard: boolean; date: string | null }
type PlanDrag = {
drag: Drag | null
board: Board | null
setBoard: (board: Board | null) => void
message: string
// start picks a dinner up, unless it cannot be dropped: there is no grid,
// a swap of the week is pending, or the dinner is already this week. It
// says whether it did.
start: (pickup: Pickup, keyboard: boolean) => boolean
// over moves the drag onto the day of date, or off every day (null).
over: (date: string | null) => void
// step moves a keyboard drag to the previous (-1) or next (1) day.
step: (by: -1 | 1) => void
// end drops the dinner on the day of date, or cancels the drag when date
// is null. It says whether the dinner was dropped.
end: (date: string | null) => boolean
cancel: () => void
}
const PlanDragContext = createContext<PlanDrag | null>(null)
function usePlanDrag(): PlanDrag {
const context = useContext(PlanDragContext)
if (context === null) throw new Error('usePlanDrag must be used inside PlanDragProvider')
return context
}
// replaces says which dinner dropping on day replaces: "Friday: replaces
// Pizza night."
function replaces(day: PlanDay): string {
return `${weekdayName(day.date)}: replaces ${day.dinner.name}.`
}
// PlanDragProvider holds the drag of a dinner from the plan panel or the
// Quest-log strip onto a day of the week's grid, which the grid registers as
// the Board. Each step of a drag is announced to screen readers.
export function PlanDragProvider({ children }: { children: ReactNode }) {
const [board, setBoard] = useState<Board | null>(null)
const [drag, setDrag] = useState<Drag | null>(null)
const [message, setMessage] = useState('')
// The drag as last set, read by handlers that run before a render: a blur
// the drop's focus move causes must not cancel the drag it just ended.
const current = useRef<Drag | null>(null)
const value = useMemo((): PlanDrag => {
function set(next: Drag | null) {
current.current = next
setDrag(next)
}
function dayOf(date: string | null): PlanDay | undefined {
return board?.days.find((day) => day.date === date)
}
function cancel() {
const now = current.current
if (now === null) return
set(null)
setMessage(`Cancelled. ${now.pickup.dinner.name} was not swapped in.`)
}
return {
drag,
board,
setBoard,
message,
start(pickup, keyboard) {
const { name } = pickup.dinner
if (board === null) {
setMessage(`There is no plan this week to swap ${name} into.`)
return false
}
if (board.pending) {
setMessage(`A swap is still being saved. Pick ${name} up once it is done.`)
return false
}
if (board.days.some((day) => day.dinner.id === pickup.dinner.id)) {
setMessage(`${name} is already this week.`)
return false
}
// A keyboard drag starts on Monday; a pointer drag on no day.
const first = board.days[0]!
set({ pickup, keyboard, date: keyboard ? first.date : null })
setMessage(
keyboard
? `Picked up ${name}. ${replaces(first)} Use the arrow keys to choose a day, Enter to drop, Escape to cancel.`
: `Picked up ${name}.`,
)
return true
},
over(date) {
const now = current.current
if (now === null || now.date === date) return
set({ ...now, date })
},
step(by) {
const now = current.current
if (now === null || board === null) return
const at = board.days.findIndex((day) => day.date === now.date)
const next = board.days[Math.min(Math.max(at + by, 0), board.days.length - 1)]!
set({ ...now, date: next.date })
setMessage(replaces(next))
},
end(date) {
const now = current.current
if (now === null) return false
const day = dayOf(date)
if (day === undefined || board === null) {
cancel()
return false
}
set(null)
if (board.pending) {
setMessage(`A swap is still being saved. ${now.pickup.dinner.name} was not swapped in.`)
return false
}
board.drop({ date: day.date, dinner: now.pickup.dinner, fromQuestLog: now.pickup.fromQuestLog })
setMessage(`Dropped ${now.pickup.dinner.name} on ${weekdayName(day.date)}, replacing ${day.dinner.name}.`)
return true
},
cancel,
}
}, [board, drag, message])
return <PlanDragContext.Provider value={value}>{children}</PlanDragContext.Provider>
}
// DragAnnouncer is the polite live region a drag's steps are announced in.
export function DragAnnouncer() {
const { message } = usePlanDrag()
return (
<p aria-live="polite" aria-atomic="true" data-testid="drag-announcer" className={styles.announcer}>
{message}
</p>
)
}
// DropBoard registers the week's grid as the Board while it is shown.
export function DropBoard({ days, pending, drop }: Board) {
const { setBoard } = usePlanDrag()
// drop is a new function on every render: the Board calls the latest.
const latestDrop = useRef(drop)
useLayoutEffect(() => {
latestDrop.current = drop
})
useLayoutEffect(() => {
setBoard({ days, pending, drop: (swap) => latestDrop.current(swap) })
return () => setBoard(null)
}, [setBoard, days, pending])
return null
}
// useDropTarget is the drag over the day of date, if it is over that day.
export function useDropTarget(date: string): Drag | null {
const { drag } = usePlanDrag()
return drag !== null && drag.date === date ? drag : null
}
// DROP_DATE is the attribute naming the date of a day a dinner drops on.
export const DROP_DATE = 'data-drop-date'
// dateAt is the date of the day under the point (x, y), or null when it is
// over no day.
function dateAt(x: number, y: number): string | null {
return document.elementFromPoint(x, y)?.closest(`[${DROP_DATE}]`)?.getAttribute(DROP_DATE) ?? null
}
// A press becomes a drag once the pointer has moved this far, so a tap is
// not one.
const DRAG_THRESHOLD = 4
// The dragged copy floats this far below and to the right of the pointer,
// as a drag image does, so it stays clear of the "Drop to swap" text
// centred in the day the pointer is over. Where it would leave the
// viewport it flips to the left of the pointer, or above it.
const FLOAT_OFFSET = 48
// Press is a pointer held on a draggable: its id, where it went down, the
// draggable's size, and whether it has become a drag.
type Press = { pointerId: number; x: number; y: number; width: number; height: number; dragging: boolean }
// Float is where the dragged copy of a draggable is drawn, and how wide.
export type Float = { left: number; top: number; width: number }
// floatAt is where the dragged copy of the held draggable is drawn for the
// pointer at (x, y): offset below right of it, flipped where that would
// leave the viewport.
function floatAt(held: Press, x: number, y: number): Float {
const right = x + FLOAT_OFFSET
const below = y + FLOAT_OFFSET
return {
left: right + held.width > window.innerWidth ? x - FLOAT_OFFSET - held.width : right,
top: below + held.height > window.innerHeight ? y - FLOAT_OFFSET - held.height : below,
width: held.width,
}
}
// useDraggable makes a panel row or a strip chip a draggable dinner. With a
// pointer it is pressed, captured and moved past DRAG_THRESHOLD to pick it
// up, its copy floats with the pointer, and letting go over a day drops it
// there, anywhere else cancels. With the keyboard Enter or Space picks it
// up onto Monday, the arrow keys choose the day, Enter drops it and
// Escape, or leaving it, cancels; a drop moves focus to the day's swap
// button, as a Quest-log dinner leaves the Quest log once planned.
export function useDraggable(pickup: Pickup) {
const plan = usePlanDrag()
const [float, setFloat] = useState<Float | null>(null)
const press = useRef<Press | null>(null)
const drag = plan.drag !== null && plan.drag.pickup.dinner.id === pickup.dinner.id ? plan.drag : null
const disabled = plan.drag === null && (plan.board === null || plan.board.pending || plan.board.days.some((day) => day.dinner.id === pickup.dinner.id))
// A draggable that goes mid-drag, as on crossing to mobile, cancels it;
// once the drag has ended, cancel does nothing.
const { cancel } = plan
const dragging = drag !== null
const latestCancel = useRef(cancel)
useLayoutEffect(() => {
latestCancel.current = cancel
})
useEffect(() => {
if (!dragging) return
return () => latestCancel.current()
}, [dragging])
function release() {
press.current = null
setFloat(null)
}
function onPointerDown(event: PointerEvent<HTMLElement>) {
if (press.current !== null || plan.drag !== null || !event.isPrimary || event.button !== 0) return
const rect = event.currentTarget.getBoundingClientRect()
event.currentTarget.setPointerCapture(event.pointerId)
press.current = {
pointerId: event.pointerId,
x: event.clientX,
y: event.clientY,
width: rect.width,
height: rect.height,
dragging: false,
}
}
function onPointerMove(event: PointerEvent<HTMLElement>) {
const held = press.current
if (held === null || held.pointerId !== event.pointerId) return
if (!held.dragging) {
if (Math.hypot(event.clientX - held.x, event.clientY - held.y) < DRAG_THRESHOLD) return
if (!plan.start(pickup, false)) {
press.current = null
return
}
held.dragging = true
}
setFloat(floatAt(held, event.clientX, event.clientY))
plan.over(dateAt(event.clientX, event.clientY))
}
function onPointerUp(event: PointerEvent<HTMLElement>) {
const held = press.current
if (held === null || held.pointerId !== event.pointerId) return
release()
if (held.dragging) plan.end(dateAt(event.clientX, event.clientY))
}
// A cancel, or losing the capture before the release, cancels the drag.
// After a release the press is already over, so this ends only a drag
// whose capture was lost before it.
function onPointerCancel(event: PointerEvent<HTMLElement>) {
const held = press.current
if (held === null || held.pointerId !== event.pointerId) return
release()
if (held.dragging) plan.cancel()
}
function onKeyDown(event: KeyboardEvent<HTMLElement>) {
const button = event.currentTarget
if (drag === null) {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
if (plan.drag === null) plan.start(pickup, true)
return
}
if (event.key === 'Escape') {
event.preventDefault()
release()
plan.cancel()
return
}
if (!drag.keyboard) return
// Space picked it up and does not drop it; it does not scroll the page.
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') plan.step(-1)
else if (event.key === 'ArrowRight' || event.key === 'ArrowDown') plan.step(1)
else if (event.key === 'Enter') drop(button.ownerDocument, drag.date)
else if (event.key !== ' ') return
event.preventDefault()
}
// drop ends a keyboard drag on the day of date and, once dropped, moves
// focus to that day's swap button.
function drop(document: Document, date: string | null) {
if (!plan.end(date)) return
const swapButton = document.querySelector<HTMLElement>(`[${DROP_DATE}="${date}"] button`)
if (swapButton === null) throw new Error(`The day ${date} dropped on has no swap button to focus.`)
swapButton.focus()
}
// Leaving a keyboard drag cancels it.
function onBlur() {
if (drag !== null && drag.keyboard) plan.cancel()
}
return {
drag,
float,
props: {
'aria-disabled': disabled || undefined,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onLostPointerCapture: onPointerCancel,
onKeyDown,
onBlur,
},
}
}