Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d46b58f11 | ||
|
|
a9038845a3 | ||
|
|
3aef21922b | ||
|
|
9b9421bb58 | ||
|
|
0a47b26af6 | ||
|
|
2ed5f69783 | ||
|
|
09456c48a2 |
+50
-431
@@ -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
@@ -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
@@ -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
@@ -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},
|
||||
|
||||
@@ -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, ¬Plannable) }},
|
||||
{"archived dinner", week, "2026-10-12", archived, func(err error) bool { return errors.As(err, ¬Plannable) }},
|
||||
{"day of another week", week, "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
|
||||
|
||||
@@ -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, ¬Plannable):
|
||||
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):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user