Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cd2e5af32 | ||
|
|
59d0f57a47 | ||
|
|
5ac5982c1f | ||
|
|
1862f450db |
@@ -44,6 +44,10 @@ func loadConfig(t *testing.T, keyFile string) config.Config {
|
||||
if err := os.WriteFile(clientSecretPath, []byte("client-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
googleSecretPath := filepath.Join(dir, "google_client_secret")
|
||||
if err := os.WriteFile(googleSecretPath, []byte("google-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(config.EnvHTTPAddr, "127.0.0.1:0")
|
||||
t.Setenv(config.EnvPublicURL, "http://localhost")
|
||||
t.Setenv(config.EnvDataDir, t.TempDir())
|
||||
@@ -53,6 +57,8 @@ func loadConfig(t *testing.T, keyFile string) config.Config {
|
||||
t.Setenv(config.EnvOIDCClientID, "mealquest")
|
||||
t.Setenv(config.EnvOIDCClientSecretFile, clientSecretPath)
|
||||
t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin")
|
||||
t.Setenv(config.EnvGoogleClientID, "mealquest.apps.googleusercontent.com")
|
||||
t.Setenv(config.EnvGoogleClientSecretFile, googleSecretPath)
|
||||
// MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value.
|
||||
t.Setenv(config.EnvDefaultTZ, "")
|
||||
if err := os.Unsetenv(config.EnvDefaultTZ); err != nil {
|
||||
|
||||
+14
-1
@@ -21,6 +21,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
@@ -118,6 +119,18 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
googleSvc, err := gcal.NewService(db, box, gcal.Config{
|
||||
ClientID: cfg.GoogleClientID,
|
||||
ClientSecret: cfg.GoogleClientSecret.Reveal(),
|
||||
PublicURL: cfg.PublicURL,
|
||||
SecretKey: []byte(cfg.SecretKey.Reveal()),
|
||||
Endpoint: gcal.GoogleEndpoint,
|
||||
RevokeURL: gcal.GoogleRevokeURL,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -134,7 +147,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, runner, photos, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, googleSvc, runner, photos, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+11
-4
@@ -8,6 +8,7 @@
|
||||
# mkdir -p deploy/secrets && chmod 700 deploy/secrets
|
||||
# head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key
|
||||
# printf '%s\n' '<Authentik client secret>' > deploy/secrets/oidc_client_secret
|
||||
# printf '%s\n' '<Google OAuth client secret>' > deploy/secrets/google_client_secret
|
||||
# sudo chown 10001:10001 deploy/secrets/* && sudo chmod 600 deploy/secrets/*
|
||||
#
|
||||
# SearXNG's secret key is the exception: compose reads it itself, so it
|
||||
@@ -54,6 +55,14 @@ MEALQUEST_OIDC_CLIENT_ID=mealquest
|
||||
# Comma-separated Authentik groups whose members may sign in.
|
||||
MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
|
||||
# --- Google Calendar (Push to Calendar) ------------------------------------
|
||||
|
||||
# Client ID of the Google OAuth web client (deploy/RUNBOOK.md "Google
|
||||
# Calendar OAuth client"). Required: the service will not start without it
|
||||
# and the client secret file. Its authorized redirect URI is
|
||||
# <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
|
||||
MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
|
||||
|
||||
# Optional session lifetimes. A session ends after IDLE unused and always MAX
|
||||
# after sign-in; MAX must be at least IDLE. Defaults: 720h and 4320h.
|
||||
MEALQUEST_SESSION_IDLE=720h
|
||||
@@ -67,6 +76,8 @@ MEALQUEST_SESSION_MAX=4320h
|
||||
# Base64 of 32 random bytes; encrypts secrets at rest (AES-256-GCM).
|
||||
# MEALQUEST_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
|
||||
# The Authentik provider's client secret.
|
||||
# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret
|
||||
# The Google OAuth web client's secret.
|
||||
# MEALQUEST_SEARXNG_URL=http://searxng:8080
|
||||
# The SearXNG recipe-search sidecar on the compose network. The Settings
|
||||
# screen pre-fills it as the SearXNG URL until one is saved; the saved
|
||||
@@ -81,7 +92,3 @@ MEALQUEST_SESSION_MAX=4320h
|
||||
# MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
|
||||
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
|
||||
# MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
|
||||
#
|
||||
# Google Calendar OAuth web client (Feature 16).
|
||||
# MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
|
||||
# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret
|
||||
|
||||
+489
-54
@@ -33,6 +33,8 @@ On office-srvr (10.60.1.14), as recorded on 2026-10-05:
|
||||
Elsewhere:
|
||||
|
||||
- Admin access to Authentik at <https://auth.cubecraftlabs.com>.
|
||||
- Owner access to the Google Cloud project that holds MealQuest's OAuth
|
||||
client (see "Google Calendar OAuth client").
|
||||
- SSH access to the Traefik host 10.60.1.33.
|
||||
- Cloudflare access for the `cubecraftlabs.com` zone, and admin access to
|
||||
both Pi-holes (10.60.1.101 `dns.ccl`, 10.60.1.85 `dns2.ccl`).
|
||||
@@ -129,6 +131,7 @@ Values in use:
|
||||
| `MEALQUEST_OIDC_ISSUER` | `https://auth.cubecraftlabs.com/application/o/mealquest/` |
|
||||
| `MEALQUEST_OIDC_CLIENT_ID` | `i6zraqmWsF552b8nmte070LXz0SI5pRdArBxFxeq` (not a secret) |
|
||||
| `MEALQUEST_OIDC_ALLOWED_GROUPS` | `mealquest-admin` |
|
||||
| `MEALQUEST_GOOGLE_CLIENT_ID` | Client ID of the Google OAuth web client (not a secret; see "Google Calendar OAuth client") |
|
||||
| `MEALQUEST_SESSION_IDLE` / `MEALQUEST_SESSION_MAX` | `720h` / `4320h` |
|
||||
|
||||
`MEALQUEST_HTTP_ADDR`, `MEALQUEST_DATA_DIR` and the `*_FILE` variables are
|
||||
@@ -146,6 +149,7 @@ service refuses to start.
|
||||
| --- | --- | --- |
|
||||
| `secret_key` | base64 of 32 random bytes; encrypts stored secrets (AES-256-GCM) | `/run/secrets/secret_key` |
|
||||
| `oidc_client_secret` | Client Secret of the Authentik provider `mealquest` | `/run/secrets/oidc_client_secret` |
|
||||
| `google_client_secret` | Client secret of the Google OAuth web client | `/run/secrets/google_client_secret` |
|
||||
| `litestream_key` | dedicated SSH private key for the NAS (see "Backups") | `/etc/litestream/sftp_key` |
|
||||
| `searxng.env` | `SEARXNG_SECRET=<64 hex characters>`; SearXNG's secret key | not mounted; compose passes it as an environment variable |
|
||||
|
||||
@@ -156,11 +160,12 @@ it (as `env_file`), so it is owned by `overseer`, mode 0600.
|
||||
mkdir -p deploy/secrets && chmod 700 deploy/secrets
|
||||
head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key
|
||||
printf '%s\n' '<Authentik client secret>' > deploy/secrets/oidc_client_secret
|
||||
printf '%s\n' '<Google OAuth client secret>' > deploy/secrets/google_client_secret
|
||||
# litestream_key: see "Backups" > "Database — Litestream"
|
||||
sudo chown 10001:10001 deploy/secrets/secret_key deploy/secrets/oidc_client_secret deploy/secrets/litestream_key
|
||||
sudo chown 10001:10001 deploy/secrets/secret_key deploy/secrets/oidc_client_secret deploy/secrets/google_client_secret deploy/secrets/litestream_key
|
||||
sudo chmod 600 deploy/secrets/*
|
||||
(umask 077; printf 'SEARXNG_SECRET=%s\n' "$(openssl rand -hex 32)" > deploy/secrets/searxng.env)
|
||||
sudo ls -la deploy/secrets # three files mealquest mealquest, searxng.env overseer overseer; all -rw-------
|
||||
sudo ls -la deploy/secrets # four files mealquest mealquest, searxng.env overseer overseer; all -rw-------
|
||||
```
|
||||
|
||||
The `mealquest` host user (uid/gid 10001) is created in "Media — nightly
|
||||
@@ -238,6 +243,38 @@ On 2026-10-05 discovery returned 200 with issuer
|
||||
`https://auth.cubecraftlabs.com/application/o/mealquest/` and
|
||||
`id_token_signing_alg_values_supported: ["RS256"]`.
|
||||
|
||||
## Google Calendar OAuth client
|
||||
|
||||
Push to Calendar connects the household's Google account with a Google
|
||||
OAuth web client. MealQuest calls Google's OAuth and Calendar REST
|
||||
endpoints directly. **The service will not start without
|
||||
`MEALQUEST_GOOGLE_CLIENT_ID` and `deploy/secrets/google_client_secret`**,
|
||||
so create the client before deploying a release that contains Feature 16.
|
||||
|
||||
In the Google Cloud console, in the project that holds MealQuest:
|
||||
|
||||
| Object | Setting |
|
||||
| --- | --- |
|
||||
| API | Google Calendar API enabled |
|
||||
| OAuth consent screen | User type External; app name MealQuest; publishing status **In production** (a client left in Testing gets refresh tokens that expire after 7 days, so Push to Calendar would stop working a week after connecting) |
|
||||
| Scopes | `https://www.googleapis.com/auth/calendar.events` and `https://www.googleapis.com/auth/calendar.calendarlist.readonly` |
|
||||
| Credentials | OAuth client ID, application type **Web application**, name `mealquest` |
|
||||
| Authorized redirect URI | `https://mealquest.cubecraftlabs.com/api/v1/integrations/google/callback` (exactly; it is `MEALQUEST_PUBLIC_URL`'s origin plus that path) |
|
||||
| Client ID | in `deploy/.env` as `MEALQUEST_GOOGLE_CLIENT_ID` (not a secret) |
|
||||
| Client secret | in `deploy/secrets/google_client_secret` only |
|
||||
|
||||
The app is unverified, so Google shows an "unverified app" warning on the
|
||||
consent screen; choose to continue. That is expected for a single-household
|
||||
deployment.
|
||||
|
||||
To connect, a signed-in household member opens
|
||||
`https://mealquest.cubecraftlabs.com/api/v1/integrations/google/connect`
|
||||
(the Settings screen links there once it ships). MealQuest asks for offline
|
||||
access with consent always prompted, and lands back on
|
||||
`/settings/calendar` once the refresh token is stored. Disconnecting
|
||||
(`DELETE /api/v1/integrations/google`) revokes the token at Google and
|
||||
forgets it.
|
||||
|
||||
## Reverse proxy (Traefik)
|
||||
|
||||
MealQuest is published at <https://mealquest.cubecraftlabs.com> by the
|
||||
@@ -491,42 +528,263 @@ 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
|
||||
sudo systemctl restart mealquest # applies the firewall, then down + up -d
|
||||
docker compose -f deploy/compose.yaml ps # mealquest healthy, litestream up, searxng healthy
|
||||
curl -fsS http://10.60.1.14:8090/health
|
||||
curl -s -o /dev/null -w "%{http_code}\n" https://mealquest.cubecraftlabs.com/health # 200
|
||||
git rev-parse HEAD # the deployed SHA
|
||||
```
|
||||
|
||||
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.
|
||||
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
|
||||
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
|
||||
)
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
git checkout --detach <previous SHA>
|
||||
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"
|
||||
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)
|
||||
)
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Backups
|
||||
|
||||
@@ -613,11 +871,20 @@ 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.
|
||||
|
||||
Run from `/home/overseer/mealquest`:
|
||||
Steps 1–3:
|
||||
|
||||
```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.
|
||||
@@ -630,34 +897,90 @@ 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
|
||||
sudo ls -la "$D" # must list only media/
|
||||
left=$(sudo ls -A "$D")
|
||||
[ "$left" = media ] || fail "the volume must hold only media/, not: $left"
|
||||
|
||||
# 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
|
||||
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
|
||||
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"
|
||||
)
|
||||
```
|
||||
|
||||
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; copy `-wal`/`-shm` along with it when the app is running.
|
||||
live file.
|
||||
|
||||
```python
|
||||
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' &&
|
||||
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")]
|
||||
@@ -669,15 +992,56 @@ 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
|
||||
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
|
||||
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"
|
||||
)
|
||||
```
|
||||
|
||||
Before and after must match exactly.
|
||||
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.
|
||||
|
||||
## Restore photos (media)
|
||||
|
||||
@@ -694,20 +1058,52 @@ 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 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.
|
||||
# 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"
|
||||
|
||||
# 3. Stop the app (no writes into media/ meanwhile).
|
||||
docker compose -f deploy/compose.yaml stop mealquest
|
||||
@@ -716,24 +1112,46 @@ 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.
|
||||
sudo rsync -a --stats --chown=10001:10001 -e "$SSH" "joshua@10.60.1.50:$NAS/" "$M/"
|
||||
# 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/"
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# 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
|
||||
systemctl is-active mealquest-media-backup.timer # active; exits nonzero otherwise
|
||||
)
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
When the database is restored too, restore it first ("Restore the
|
||||
database"), then the media, so every photo the database refers to is
|
||||
@@ -932,6 +1350,23 @@ Sign in at <https://mealquest.cubecraftlabs.com> to confirm; sign-ins
|
||||
fail between regenerating the secret in Authentik and recreating the
|
||||
service.
|
||||
|
||||
### Google OAuth client secret (`deploy/secrets/google_client_secret`)
|
||||
|
||||
In the Google Cloud console, open the OAuth client `mealquest`, add a new
|
||||
client secret, write it to the file (same owner and mode as above) and
|
||||
recreate the service:
|
||||
|
||||
```sh
|
||||
printf '%s\n' '<new Google OAuth client secret>' | sudo tee deploy/secrets/google_client_secret >/dev/null
|
||||
sudo chown 10001:10001 deploy/secrets/google_client_secret
|
||||
sudo chmod 600 deploy/secrets/google_client_secret
|
||||
docker compose -f deploy/compose.yaml up -d --force-recreate mealquest
|
||||
```
|
||||
|
||||
Then disable and delete the old secret in the console. A new client secret
|
||||
leaves the household's stored refresh token valid, so nothing needs
|
||||
reconnecting.
|
||||
|
||||
### LLM API key
|
||||
|
||||
Stored encrypted in the database, not in `deploy/`. In the app, open
|
||||
|
||||
@@ -32,10 +32,12 @@ services:
|
||||
MEALQUEST_DATA_DIR: /data
|
||||
MEALQUEST_SECRET_KEY_FILE: /run/secrets/secret_key
|
||||
MEALQUEST_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client_secret
|
||||
MEALQUEST_GOOGLE_CLIENT_SECRET_FILE: /run/secrets/google_client_secret
|
||||
MEALQUEST_SEARXNG_URL: http://searxng:8080
|
||||
secrets:
|
||||
- secret_key
|
||||
- oidc_client_secret
|
||||
- google_client_secret
|
||||
volumes:
|
||||
- mealquest-data:/data
|
||||
ports:
|
||||
@@ -128,6 +130,8 @@ secrets:
|
||||
file: ./secrets/secret_key
|
||||
oidc_client_secret:
|
||||
file: ./secrets/oidc_client_secret
|
||||
google_client_secret:
|
||||
file: ./secrets/google_client_secret
|
||||
|
||||
volumes:
|
||||
mealquest-data:
|
||||
|
||||
+43
-5
@@ -357,16 +357,25 @@ 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`. A pool under seven
|
||||
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
|
||||
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, 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
|
||||
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
|
||||
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
|
||||
@@ -609,6 +618,35 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
not come back.
|
||||
- Connected once with Google OAuth in Settings; one event per planned
|
||||
dinner.
|
||||
- `internal/gcal` owns the connection, as a confidential OAuth web client
|
||||
(`MEALQUEST_GOOGLE_CLIENT_ID`, `MEALQUEST_GOOGLE_CLIENT_SECRET_FILE`, both
|
||||
required at boot) over plain REST — no Google SDK. All three routes are
|
||||
under the signed-in `/api/v1`:
|
||||
- `GET /api/v1/integrations/google/connect` redirects to Google's consent
|
||||
screen with scopes `calendar.events` and `calendar.calendarlist.readonly`,
|
||||
`access_type=offline`, `prompt=consent` and PKCE. Its `state` travels in
|
||||
the signed `__Host-mealquest_google` flow cookie (the same
|
||||
`auth.FlowCookie` mechanism as the login cookie), bound to the session by
|
||||
a hash of the session's CSRF token. The service also keeps each started
|
||||
connect's `state` in memory until the cookie expires; the callback
|
||||
consumes it, so a replayed cookie is refused (`invalid_state`), and a
|
||||
restart mid-connect means connecting again.
|
||||
- `GET /api/v1/integrations/google/callback` (the redirect URI:
|
||||
`MEALQUEST_PUBLIC_URL`'s origin plus this path) checks the state and
|
||||
session, exchanges the code, refuses a response with no refresh token
|
||||
(`reconnect_required`) or without both scopes, seals the refresh token
|
||||
with `secretbox` into `integrations_google.refresh_token_ct`, sets
|
||||
`connected_at` and redirects to `/settings/calendar`. A reconnect keeps
|
||||
the chosen calendar and clears `last_sync_error`.
|
||||
- `DELETE /api/v1/integrations/google` (CSRF-checked) revokes the token at
|
||||
Google, then deletes the row; a token Google reports as `invalid_token`
|
||||
counts as revoked, and any other revoke failure keeps the connection.
|
||||
- Connect's exchange-and-store and disconnect's read-revoke-delete hold
|
||||
one lock across the Google request and the database write: Google
|
||||
revokes the whole grant, so a revoke overlapping a reconnect would
|
||||
otherwise leave a stored token Google has already revoked.
|
||||
- No response, log line or error ever holds a token or Google's response
|
||||
text.
|
||||
- **Deterministic event ids**; each plan day records its
|
||||
`calendar_event_id`.
|
||||
|
||||
|
||||
+14
-12
@@ -107,7 +107,7 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect}
|
||||
client := &http.Client{Timeout: providerTimeout, CheckRedirect: CheckRedirect}
|
||||
discovery := *client
|
||||
recorder := &statusRecorder{next: http.DefaultTransport}
|
||||
discovery.Transport = recorder
|
||||
@@ -205,11 +205,12 @@ func checkEndpoint(name, raw string) error {
|
||||
// maxRedirects matches net/http's default redirect limit.
|
||||
const maxRedirects = 10
|
||||
|
||||
// checkRedirect is the provider client's redirect policy. On a 307 or 308 to
|
||||
// the same host Go resends the body and Authorization header, which carry the
|
||||
// code and client secret, so every target must pass secureurl.Check and an
|
||||
// https:// request never follows a redirect to plain http://, even loopback.
|
||||
func checkRedirect(req *http.Request, via []*http.Request) error {
|
||||
// CheckRedirect is the redirect policy of every client that talks to an
|
||||
// OAuth provider. On a 307 or 308 to the same host Go resends the body and
|
||||
// Authorization header, which carry the code and client secret, so every
|
||||
// target must pass secureurl.Check and an https:// request never follows a
|
||||
// redirect to plain http://, even loopback.
|
||||
func CheckRedirect(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= maxRedirects {
|
||||
return fmt.Errorf("stopped after %d redirects", maxRedirects)
|
||||
}
|
||||
@@ -350,7 +351,7 @@ func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claim
|
||||
token, err := a.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, a.client), code,
|
||||
oauth2.VerifierOption(state.Verifier))
|
||||
if err != nil {
|
||||
return Claims{}, exchangeError(err)
|
||||
return Claims{}, ExchangeError(err)
|
||||
}
|
||||
raw, ok := token.Extra("id_token").(string)
|
||||
if !ok || raw == "" {
|
||||
@@ -400,7 +401,7 @@ func (a *Auth) userinfoGroups(ctx context.Context, token *oauth2.Token, subject
|
||||
}
|
||||
|
||||
// userinfoError reduces a failed userinfo request to a fixed category, like
|
||||
// exchangeError: go-oidc's error text can carry the response body.
|
||||
// ExchangeError: go-oidc's error text can carry the response body.
|
||||
func userinfoError(err error) error {
|
||||
var transport *url.Error
|
||||
if errors.As(err, &transport) {
|
||||
@@ -425,10 +426,11 @@ func (a *Auth) member(groups []string) bool {
|
||||
return slices.ContainsFunc(groups, func(g string) bool { return slices.Contains(a.allowed, g) })
|
||||
}
|
||||
|
||||
// exchangeError reduces a failed code exchange to a fixed category and safe
|
||||
// metadata. The oauth2 error text can carry the token endpoint's response
|
||||
// body or error_description, which may echo credentials, so it is dropped.
|
||||
func exchangeError(err error) error {
|
||||
// ExchangeError reduces a failed OAuth code exchange to a fixed category and
|
||||
// safe metadata. The oauth2 error text can carry the token endpoint's
|
||||
// response body or error_description, which may echo credentials, so it is
|
||||
// dropped.
|
||||
func ExchangeError(err error) error {
|
||||
var retrieve *oauth2.RetrieveError
|
||||
var transport *url.Error
|
||||
switch {
|
||||
|
||||
@@ -1060,7 +1060,7 @@ func TestCheckRedirect(t *testing.T) {
|
||||
{"https://auth.example.com/token", "ftp://auth.example.com/token", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
err := checkRedirect(request(tc.to), []*http.Request{request(tc.from)})
|
||||
err := CheckRedirect(request(tc.to), []*http.Request{request(tc.from)})
|
||||
if (err == nil) != tc.ok {
|
||||
t.Errorf("redirect %s -> %s: error = %v, want ok=%v", tc.from, tc.to, err, tc.ok)
|
||||
}
|
||||
@@ -1069,7 +1069,7 @@ func TestCheckRedirect(t *testing.T) {
|
||||
for i := range via {
|
||||
via[i] = request("https://auth.example.com/token")
|
||||
}
|
||||
if err := checkRedirect(request("https://auth.example.com/token"), via); err == nil {
|
||||
if err := CheckRedirect(request("https://auth.example.com/token"), via); err == nil {
|
||||
t.Errorf("redirect after %d hops: error = nil, want limit", maxRedirects)
|
||||
}
|
||||
}
|
||||
|
||||
+77
-25
@@ -27,38 +27,86 @@ type loginState struct {
|
||||
Nonce string `json:"n"`
|
||||
Verifier string `json:"v"`
|
||||
Return string `json:"r"`
|
||||
Expires int64 `json:"e"` // Unix seconds
|
||||
FlowExpiry
|
||||
}
|
||||
|
||||
// loginCookie signs and verifies the login cookie with HMAC-SHA256.
|
||||
// loginCookie is the FlowCookie carrying loginState.
|
||||
type loginCookie struct {
|
||||
key []byte
|
||||
flow FlowCookie
|
||||
}
|
||||
|
||||
func newLoginCookie(secretKey []byte) loginCookie {
|
||||
mac := hmac.New(sha256.New, secretKey)
|
||||
mac.Write([]byte(loginCookieKeyLabel))
|
||||
return loginCookie{key: mac.Sum(nil)}
|
||||
return loginCookie{flow: NewFlowCookie(loginCookieName, loginCookieKeyLabel, loginCookieTTL, secretKey)}
|
||||
}
|
||||
|
||||
func (c loginCookie) sign(payload string) string {
|
||||
func (c loginCookie) set(w http.ResponseWriter, s loginState, now time.Time) error {
|
||||
return c.flow.Set(w, &s, now)
|
||||
}
|
||||
|
||||
func (c loginCookie) clear(w http.ResponseWriter) { c.flow.Clear(w) }
|
||||
|
||||
func (c loginCookie) read(r *http.Request, now time.Time) (loginState, error) {
|
||||
var s loginState
|
||||
if err := c.flow.Read(r, &s, now); err != nil {
|
||||
return loginState{}, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// FlowExpiry is embedded in every value a FlowCookie carries; Set fills it
|
||||
// and Read enforces it.
|
||||
type FlowExpiry struct {
|
||||
Expires int64 `json:"e"` // Unix seconds
|
||||
}
|
||||
|
||||
func (e *FlowExpiry) flowExpiry() *FlowExpiry { return e }
|
||||
|
||||
// flowValue is a pointer to a struct that embeds FlowExpiry.
|
||||
type flowValue interface {
|
||||
flowExpiry() *FlowExpiry
|
||||
}
|
||||
|
||||
// FlowCookie carries a redirect flow's state — the OAuth state, PKCE
|
||||
// verifier and the like — from the request that sends the browser to a
|
||||
// provider to the callback it returns to. The value is JSON, signed with
|
||||
// HMAC-SHA256 under a key derived from the server secret and the cookie's
|
||||
// own label, and expires after a fixed time. It is not encrypted: it must
|
||||
// not hold anything the browser may not see.
|
||||
type FlowCookie struct {
|
||||
name string
|
||||
ttl time.Duration
|
||||
key []byte
|
||||
}
|
||||
|
||||
// NewFlowCookie returns the flow cookie name, valid for ttl, signed under a
|
||||
// key derived from secretKey and keyLabel. Each flow uses its own keyLabel so
|
||||
// one flow's cookie never verifies as another's. name must carry the __Host-
|
||||
// prefix: the cookie is Secure, host-only and Path=/.
|
||||
func NewFlowCookie(name, keyLabel string, ttl time.Duration, secretKey []byte) FlowCookie {
|
||||
mac := hmac.New(sha256.New, secretKey)
|
||||
mac.Write([]byte(keyLabel))
|
||||
return FlowCookie{name: name, ttl: ttl, key: mac.Sum(nil)}
|
||||
}
|
||||
|
||||
func (c FlowCookie) sign(payload string) string {
|
||||
mac := hmac.New(sha256.New, c.key)
|
||||
mac.Write([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (c loginCookie) set(w http.ResponseWriter, s loginState, now time.Time) error {
|
||||
s.Expires = now.Add(loginCookieTTL).Unix()
|
||||
data, err := json.Marshal(s)
|
||||
// Set stores v, which expires ttl after now, in the cookie.
|
||||
func (c FlowCookie) Set(w http.ResponseWriter, v flowValue, now time.Time) error {
|
||||
v.flowExpiry().Expires = now.Add(c.ttl).Unix()
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := base64.RawURLEncoding.EncodeToString(data)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: loginCookieName,
|
||||
Name: c.name,
|
||||
Value: payload + "." + c.sign(payload),
|
||||
Path: "/",
|
||||
MaxAge: int(loginCookieTTL / time.Second),
|
||||
MaxAge: int(c.ttl / time.Second),
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
// Lax so the cookie rides the provider's top-level redirect back.
|
||||
@@ -67,9 +115,12 @@ func (c loginCookie) set(w http.ResponseWriter, s loginState, now time.Time) err
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c loginCookie) clear(w http.ResponseWriter) {
|
||||
// Clear asks the browser to discard the cookie. A copy of it still verifies
|
||||
// until it expires, so a flow that must be single-use also consumes its
|
||||
// state on the server.
|
||||
func (c FlowCookie) Clear(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: loginCookieName,
|
||||
Name: c.name,
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
Secure: true,
|
||||
@@ -78,27 +129,28 @@ func (c loginCookie) clear(w http.ResponseWriter) {
|
||||
})
|
||||
}
|
||||
|
||||
func (c loginCookie) read(r *http.Request, now time.Time) (loginState, error) {
|
||||
cookie, err := r.Cookie(loginCookieName)
|
||||
// Read verifies the cookie r carries and decodes it into v. It fails when
|
||||
// the cookie is missing, its signature is invalid or it has expired at now.
|
||||
func (c FlowCookie) Read(r *http.Request, v flowValue, now time.Time) error {
|
||||
cookie, err := r.Cookie(c.name)
|
||||
if err != nil {
|
||||
return loginState{}, errors.New("login cookie missing")
|
||||
return fmt.Errorf("%s cookie missing", c.name)
|
||||
}
|
||||
payload, sig, ok := strings.Cut(cookie.Value, ".")
|
||||
if !ok || !hmac.Equal([]byte(sig), []byte(c.sign(payload))) {
|
||||
return loginState{}, errors.New("login cookie signature invalid")
|
||||
return fmt.Errorf("%s cookie signature invalid", c.name)
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return loginState{}, fmt.Errorf("login cookie encoding: %w", err)
|
||||
return fmt.Errorf("%s cookie encoding: %w", c.name, err)
|
||||
}
|
||||
var s loginState
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return loginState{}, fmt.Errorf("login cookie payload: %w", err)
|
||||
if err := json.Unmarshal(data, v); err != nil {
|
||||
return fmt.Errorf("%s cookie payload: %w", c.name, err)
|
||||
}
|
||||
if now.Unix() >= s.Expires {
|
||||
return loginState{}, errors.New("login cookie expired")
|
||||
if now.Unix() >= v.flowExpiry().Expires {
|
||||
return fmt.Errorf("%s cookie expired", c.name)
|
||||
}
|
||||
return s, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// returnPath validates the optional post-login return path. Empty means "/".
|
||||
|
||||
@@ -29,6 +29,9 @@ const (
|
||||
EnvOIDCClientSecretFile = "MEALQUEST_OIDC_CLIENT_SECRET_FILE"
|
||||
EnvOIDCAllowedGroups = "MEALQUEST_OIDC_ALLOWED_GROUPS"
|
||||
|
||||
EnvGoogleClientID = "MEALQUEST_GOOGLE_CLIENT_ID"
|
||||
EnvGoogleClientSecretFile = "MEALQUEST_GOOGLE_CLIENT_SECRET_FILE"
|
||||
|
||||
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
|
||||
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
||||
|
||||
@@ -61,6 +64,12 @@ type Config struct {
|
||||
// OIDCAllowedGroups lists the groups whose members may sign in.
|
||||
OIDCAllowedGroups []string
|
||||
|
||||
// GoogleClientID and GoogleClientSecret are the Google OAuth web client
|
||||
// Push to Calendar connects with. Its redirect URI is PublicURL's origin
|
||||
// followed by /api/v1/integrations/google/callback.
|
||||
GoogleClientID string
|
||||
GoogleClientSecret Secret
|
||||
|
||||
// SessionIdle ends a session left unused this long.
|
||||
SessionIdle time.Duration
|
||||
// SessionMax ends every session this long after sign-in, used or not.
|
||||
@@ -110,6 +119,9 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
OIDCClientSecret: r.secretFile(EnvOIDCClientSecretFile),
|
||||
OIDCAllowedGroups: r.list(EnvOIDCAllowedGroups),
|
||||
|
||||
GoogleClientID: r.str(EnvGoogleClientID),
|
||||
GoogleClientSecret: r.secretFile(EnvGoogleClientSecretFile),
|
||||
|
||||
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
|
||||
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ func TestLoad(t *testing.T) {
|
||||
if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
googleSecretPath := filepath.Join(dir, "google_client_secret")
|
||||
if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
emptyPath := filepath.Join(dir, "empty_secret")
|
||||
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -46,6 +50,9 @@ func TestLoad(t *testing.T) {
|
||||
EnvOIDCClientID: "mealquest",
|
||||
EnvOIDCClientSecretFile: clientSecretPath,
|
||||
EnvOIDCAllowedGroups: "mealquest-admin",
|
||||
|
||||
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
||||
EnvGoogleClientSecretFile: googleSecretPath,
|
||||
}
|
||||
}
|
||||
with := func(changes map[string]string, unset ...string) map[string]string {
|
||||
@@ -94,6 +101,7 @@ func TestLoad(t *testing.T) {
|
||||
badVars: []string{
|
||||
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
|
||||
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
|
||||
EnvGoogleClientID, EnvGoogleClientSecretFile,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -211,6 +219,31 @@ func TestLoad(t *testing.T) {
|
||||
env: with(map[string]string{"MEALQUEST_SECRET_KEY": "inline"}),
|
||||
badVars: []string{"MEALQUEST_SECRET_KEY"},
|
||||
},
|
||||
{
|
||||
name: "Google client ID missing",
|
||||
env: with(nil, EnvGoogleClientID),
|
||||
badVars: []string{EnvGoogleClientID},
|
||||
},
|
||||
{
|
||||
name: "Google client secret file missing",
|
||||
env: with(nil, EnvGoogleClientSecretFile),
|
||||
badVars: []string{EnvGoogleClientSecretFile},
|
||||
},
|
||||
{
|
||||
name: "Google client secret file unreadable",
|
||||
env: with(map[string]string{EnvGoogleClientSecretFile: unreadablePath}),
|
||||
badVars: []string{EnvGoogleClientSecretFile},
|
||||
},
|
||||
{
|
||||
name: "Google client secret file empty",
|
||||
env: with(map[string]string{EnvGoogleClientSecretFile: emptyPath}),
|
||||
badVars: []string{EnvGoogleClientSecretFile},
|
||||
},
|
||||
{
|
||||
name: "inline Google client secret rejected",
|
||||
env: with(map[string]string{"MEALQUEST_GOOGLE_CLIENT_SECRET": "inline"}),
|
||||
badVars: []string{"MEALQUEST_GOOGLE_CLIENT_SECRET"},
|
||||
},
|
||||
{
|
||||
name: "inline OIDC client secret rejected",
|
||||
env: with(map[string]string{"MEALQUEST_OIDC_CLIENT_SECRET": "inline"}),
|
||||
@@ -409,6 +442,10 @@ func TestLoadValues(t *testing.T) {
|
||||
if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
googleSecretPath := filepath.Join(dir, "google_client_secret")
|
||||
if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := map[string]string{
|
||||
EnvHTTPAddr: "127.0.0.1:9000",
|
||||
EnvPublicURL: "http://localhost:9000",
|
||||
@@ -421,6 +458,9 @@ func TestLoadValues(t *testing.T) {
|
||||
EnvOIDCClientSecretFile: clientSecretPath,
|
||||
EnvOIDCAllowedGroups: " mealquest-admin ,family",
|
||||
|
||||
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
||||
EnvGoogleClientSecretFile: googleSecretPath,
|
||||
|
||||
EnvDefaultTZ: "Europe/London",
|
||||
|
||||
EnvSearXNGURL: "http://searxng:8080",
|
||||
@@ -454,6 +494,12 @@ func TestLoadValues(t *testing.T) {
|
||||
if cfg.OIDCClientSecret.Reveal() != "client-s3cret" {
|
||||
t.Errorf("OIDCClientSecret.Reveal() = %q", cfg.OIDCClientSecret.Reveal())
|
||||
}
|
||||
if cfg.GoogleClientID != "mealquest.apps.googleusercontent.com" {
|
||||
t.Errorf("GoogleClientID = %q", cfg.GoogleClientID)
|
||||
}
|
||||
if cfg.GoogleClientSecret.Reveal() != "google-s3cret" {
|
||||
t.Errorf("GoogleClientSecret.Reveal() = %q", cfg.GoogleClientSecret.Reveal())
|
||||
}
|
||||
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
|
||||
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
|
||||
}
|
||||
@@ -492,7 +538,7 @@ func TestSecretRedacted(t *testing.T) {
|
||||
|
||||
s := Secret{value: "s3cret-value"}
|
||||
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
|
||||
cfg := Config{SecretKey: s, OIDCClientSecret: s}
|
||||
cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s}
|
||||
for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} {
|
||||
if strings.Contains(out, "s3cret-value") {
|
||||
t.Errorf("format %s leaked secret: %s", format, out)
|
||||
|
||||
@@ -50,6 +50,7 @@ type Querier interface {
|
||||
// decided is left as it is (0 rows).
|
||||
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
|
||||
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
||||
DeleteIntegrationsGoogle(ctx context.Context) error
|
||||
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
|
||||
|
||||
@@ -10,6 +10,16 @@ import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const deleteIntegrationsGoogle = `-- name: DeleteIntegrationsGoogle :exec
|
||||
DELETE FROM integrations_google
|
||||
WHERE id = 1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteIntegrationsGoogle(ctx context.Context) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteIntegrationsGoogle)
|
||||
return err
|
||||
}
|
||||
|
||||
const getAISettings = `-- name: GetAISettings :one
|
||||
SELECT id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at FROM ai_settings
|
||||
WHERE id = 1
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
// Package gcal connects the household's Google account for Push to Calendar.
|
||||
// The service is a confidential OAuth web client: the authorization code,
|
||||
// tokens and client secret stay on the server. The refresh token is stored
|
||||
// sealed by secretbox in integrations_google and is never logged or
|
||||
// returned.
|
||||
package gcal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/endpoints"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// CallbackPath is where Google returns the browser after consent. It is
|
||||
// served under the signed-in /api/v1 router, so the redirect URI registered
|
||||
// with Google is MEALQUEST_PUBLIC_URL's origin followed by this path.
|
||||
const CallbackPath = "/api/v1/integrations/google/callback"
|
||||
|
||||
// Scopes requested at connect: create and change the events MealQuest pushes,
|
||||
// and list the calendars the household can push to.
|
||||
var Scopes = []string{
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
|
||||
}
|
||||
|
||||
// GoogleRevokeURL is Google's OAuth token revocation endpoint.
|
||||
const GoogleRevokeURL = "https://oauth2.googleapis.com/revoke"
|
||||
|
||||
// GoogleEndpoint is Google's OAuth authorization and token endpoint.
|
||||
var GoogleEndpoint = oauth2.Endpoint{
|
||||
AuthURL: endpoints.Google.AuthURL,
|
||||
TokenURL: endpoints.Google.TokenURL,
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
}
|
||||
|
||||
const (
|
||||
connectCookieName = "__Host-mealquest_google"
|
||||
connectCookieTTL = 10 * time.Minute
|
||||
// connectCookieKeyLabel separates the connect cookie's signing key from
|
||||
// the login cookie's and every other key derived from the server secret.
|
||||
connectCookieKeyLabel = "mealquest google connect cookie v1"
|
||||
// googleTimeout bounds every request to Google.
|
||||
googleTimeout = 10 * time.Second
|
||||
// maxRevokeBody caps how much of a revoke response is read.
|
||||
maxRevokeBody = 4 << 10
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrBadState means the callback's state is missing, does not match the
|
||||
// connect cookie, or the cookie is missing, expired or belongs to another
|
||||
// session.
|
||||
ErrBadState = errors.New("gcal: connect state is missing, expired or does not match this session")
|
||||
// ErrDenied means Google returned an authorization error, usually because
|
||||
// the household declined consent.
|
||||
ErrDenied = errors.New("gcal: Google did not grant access")
|
||||
// ErrNoCode means the callback carries no authorization code.
|
||||
ErrNoCode = errors.New("gcal: callback has no authorization code")
|
||||
// ErrNoRefreshToken means Google granted access without a refresh token,
|
||||
// so MealQuest could not push later; the household must connect again.
|
||||
ErrNoRefreshToken = errors.New("gcal: Google returned no refresh token")
|
||||
// ErrScopes means the household did not grant every scope in Scopes.
|
||||
ErrScopes = errors.New("gcal: Google did not grant every Calendar permission")
|
||||
// ErrNotConnected means no Google account is connected.
|
||||
ErrNotConnected = errors.New("gcal: Google Calendar is not connected")
|
||||
)
|
||||
|
||||
// Config is what NewService needs to talk to Google.
|
||||
type Config struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
// PublicURL is the service's public URL; its origin prefixes CallbackPath
|
||||
// to form the redirect URI.
|
||||
PublicURL *url.URL
|
||||
// SecretKey is the server key the connect cookie's signing key derives
|
||||
// from.
|
||||
SecretKey []byte
|
||||
// Endpoint is Google's authorization and token endpoint; production uses
|
||||
// GoogleEndpoint and tests a fake.
|
||||
Endpoint oauth2.Endpoint
|
||||
// RevokeURL is Google's revocation endpoint; production uses
|
||||
// GoogleRevokeURL and tests a fake.
|
||||
RevokeURL string
|
||||
}
|
||||
|
||||
// Service connects and disconnects the household's Google account.
|
||||
// MealQuest runs as one process, so the service's in-memory lock and pending
|
||||
// states see every request.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
box *secretbox.Box
|
||||
oauth oauth2.Config
|
||||
revokeURL string
|
||||
client *http.Client
|
||||
cookie auth.FlowCookie
|
||||
// now is the clock; tests replace it.
|
||||
now func() time.Time
|
||||
|
||||
// account serializes every change to the connected account — a
|
||||
// connect's exchange and store, a disconnect's read, revoke and delete —
|
||||
// across the request to Google and the database write. Google revokes the
|
||||
// whole grant, so a revoke overlapping a connect would also kill the
|
||||
// token the connect stores.
|
||||
account sync.Mutex
|
||||
|
||||
// pending holds the state of every connect started and not yet
|
||||
// completed, with when it expires. Complete consumes a state once, so a
|
||||
// replayed cookie finds it gone.
|
||||
pendingMu sync.Mutex
|
||||
pending map[string]time.Time
|
||||
}
|
||||
|
||||
// NewService returns the service over st, sealing refresh tokens with box.
|
||||
func NewService(st *store.Store, box *secretbox.Box, cfg Config) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("gcal: nil store")
|
||||
case box == nil:
|
||||
return nil, errors.New("gcal: nil secret box")
|
||||
case cfg.ClientID == "", cfg.ClientSecret == "":
|
||||
return nil, errors.New("gcal: client ID and client secret are required")
|
||||
case cfg.PublicURL == nil:
|
||||
return nil, errors.New("gcal: public URL is required")
|
||||
case len(cfg.SecretKey) == 0:
|
||||
return nil, errors.New("gcal: secret key is required")
|
||||
case cfg.Endpoint.AuthURL == "", cfg.Endpoint.TokenURL == "", cfg.RevokeURL == "":
|
||||
return nil, errors.New("gcal: authorization, token and revoke endpoints are required")
|
||||
}
|
||||
return &Service{
|
||||
st: st,
|
||||
box: box,
|
||||
oauth: oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Endpoint: cfg.Endpoint,
|
||||
RedirectURL: cfg.PublicURL.Scheme + "://" + cfg.PublicURL.Host + CallbackPath,
|
||||
Scopes: slices.Clone(Scopes),
|
||||
},
|
||||
revokeURL: cfg.RevokeURL,
|
||||
client: &http.Client{Timeout: googleTimeout, CheckRedirect: auth.CheckRedirect},
|
||||
cookie: auth.NewFlowCookie(connectCookieName, connectCookieKeyLabel, connectCookieTTL, cfg.SecretKey),
|
||||
now: time.Now,
|
||||
pending: map[string]time.Time{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// connectState travels from Connect to Complete in the signed connect
|
||||
// cookie.
|
||||
type connectState struct {
|
||||
State string `json:"s"`
|
||||
Verifier string `json:"v"`
|
||||
// Session binds the state to the session that started the connect: the
|
||||
// SHA-256 of its CSRF token, which is unique to the session and never
|
||||
// changes during it.
|
||||
Session string `json:"b"`
|
||||
auth.FlowExpiry
|
||||
}
|
||||
|
||||
// sessionBinding is the value connectState.Session must hold for s.
|
||||
func sessionBinding(s auth.Session) string {
|
||||
sum := sha256.Sum256([]byte(s.CSRFToken))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Connect sets the connect cookie on w, bound to session, and returns
|
||||
// Google's consent URL: offline access, consent always prompted so Google
|
||||
// always issues a refresh token, and PKCE (S256).
|
||||
func (s *Service) Connect(w http.ResponseWriter, session auth.Session) (string, error) {
|
||||
if session.CSRFToken == "" {
|
||||
return "", errors.New("gcal: connect needs a session")
|
||||
}
|
||||
state := connectState{
|
||||
State: rand.Text(),
|
||||
Verifier: oauth2.GenerateVerifier(),
|
||||
Session: sessionBinding(session),
|
||||
}
|
||||
now := s.now()
|
||||
if err := s.cookie.Set(w, &state, now); err != nil {
|
||||
return "", fmt.Errorf("gcal: set connect cookie: %w", err)
|
||||
}
|
||||
s.addPending(state.State, now)
|
||||
return s.oauth.AuthCodeURL(state.State,
|
||||
oauth2.AccessTypeOffline,
|
||||
oauth2.ApprovalForce,
|
||||
oauth2.S256ChallengeOption(state.Verifier),
|
||||
), nil
|
||||
}
|
||||
|
||||
// Complete finishes the connect Google redirected r back with. It clears the
|
||||
// connect cookie, checks the state against it and session, consumes the
|
||||
// state, exchanges the code and stores the refresh token, sealed, with
|
||||
// connected_at now. A reconnect replaces the stored token and keeps the
|
||||
// chosen calendar.
|
||||
func (s *Service) Complete(w http.ResponseWriter, r *http.Request, session auth.Session) error {
|
||||
ctx := r.Context()
|
||||
s.cookie.Clear(w)
|
||||
|
||||
var state connectState
|
||||
if err := s.cookie.Read(r, &state, s.now()); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrBadState, err)
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(state.State)) != 1 {
|
||||
return fmt.Errorf("%w: state mismatch", ErrBadState)
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(sessionBinding(session)), []byte(state.Session)) != 1 {
|
||||
return fmt.Errorf("%w: started by another session", ErrBadState)
|
||||
}
|
||||
// A copy of the cookie verifies until it expires, so the state is
|
||||
// single-use only because it is consumed here.
|
||||
if !s.consumePending(state.State) {
|
||||
return fmt.Errorf("%w: already used", ErrBadState)
|
||||
}
|
||||
if q.Get("error") != "" {
|
||||
// error and error_description are Google-supplied text; neither is
|
||||
// kept.
|
||||
return ErrDenied
|
||||
}
|
||||
code := q.Get("code")
|
||||
if code == "" {
|
||||
return ErrNoCode
|
||||
}
|
||||
|
||||
s.account.Lock()
|
||||
defer s.account.Unlock()
|
||||
token, err := s.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, s.client), code,
|
||||
oauth2.VerifierOption(state.Verifier))
|
||||
if err != nil {
|
||||
return fmt.Errorf("gcal: %w", auth.ExchangeError(err))
|
||||
}
|
||||
if token.RefreshToken == "" {
|
||||
return ErrNoRefreshToken
|
||||
}
|
||||
if err := checkScopes(token); err != nil {
|
||||
return err
|
||||
}
|
||||
sealed, err := s.box.Encrypt([]byte(token.RefreshToken))
|
||||
if err != nil {
|
||||
return fmt.Errorf("gcal: seal refresh token: %w", err)
|
||||
}
|
||||
return s.store(ctx, sealed)
|
||||
}
|
||||
|
||||
// addPending records state, started at now, as pending until the connect
|
||||
// cookie expires, and forgets expired ones.
|
||||
func (s *Service) addPending(state string, now time.Time) {
|
||||
s.pendingMu.Lock()
|
||||
defer s.pendingMu.Unlock()
|
||||
for k, expires := range s.pending {
|
||||
if !now.Before(expires) {
|
||||
delete(s.pending, k)
|
||||
}
|
||||
}
|
||||
s.pending[state] = now.Add(connectCookieTTL)
|
||||
}
|
||||
|
||||
// consumePending removes state from the pending states, reporting whether
|
||||
// it was there.
|
||||
func (s *Service) consumePending(state string) bool {
|
||||
s.pendingMu.Lock()
|
||||
defer s.pendingMu.Unlock()
|
||||
_, ok := s.pending[state]
|
||||
delete(s.pending, state)
|
||||
return ok
|
||||
}
|
||||
|
||||
// checkScopes fails with ErrScopes unless token's granted scopes include
|
||||
// every scope in Scopes. Google lets the household untick a permission on
|
||||
// the consent screen and reports what was granted in "scope".
|
||||
func checkScopes(token *oauth2.Token) error {
|
||||
granted, _ := token.Extra("scope").(string)
|
||||
have := strings.Fields(granted)
|
||||
for _, want := range Scopes {
|
||||
if !slices.Contains(have, want) {
|
||||
return ErrScopes
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// store saves sealed as the refresh token with connected_at now, clearing
|
||||
// any earlier sync error.
|
||||
func (s *Service) store(ctx context.Context, sealed []byte) error {
|
||||
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
row, err := q.GetIntegrationsGoogle(ctx)
|
||||
exists := err == nil
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("gcal: read Google integration: %w", err)
|
||||
}
|
||||
now := store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String()
|
||||
created := now
|
||||
if exists {
|
||||
created = row.CreatedAt
|
||||
}
|
||||
if _, err := q.UpsertIntegrationsGoogle(ctx, db.UpsertIntegrationsGoogleParams{
|
||||
RefreshTokenCt: sealed,
|
||||
CalendarID: row.CalendarID,
|
||||
CalendarName: row.CalendarName,
|
||||
ConnectedAt: now,
|
||||
CreatedAt: created,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("gcal: save Google integration: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Disconnect revokes the stored refresh token at Google, then deletes the
|
||||
// connection. A token Google no longer knows (the household already removed
|
||||
// MealQuest's access in their Google account) counts as revoked. Any other
|
||||
// revoke failure keeps the connection so the household can retry.
|
||||
func (s *Service) Disconnect(ctx context.Context) error {
|
||||
s.account.Lock()
|
||||
defer s.account.Unlock()
|
||||
row, err := db.New(s.st.Reader).GetIntegrationsGoogle(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotConnected
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("gcal: read Google integration: %w", err)
|
||||
}
|
||||
token, err := s.box.Decrypt(row.RefreshTokenCt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gcal: open refresh token: %w", err)
|
||||
}
|
||||
if err := s.revoke(ctx, string(token)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.New(s.st.Writer).DeleteIntegrationsGoogle(ctx); err != nil {
|
||||
return fmt.Errorf("gcal: delete Google integration: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeError is a revoke Google refused or that failed in transit. Its text
|
||||
// never holds the token or Google's response body.
|
||||
type RevokeError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *RevokeError) Error() string { return e.msg }
|
||||
|
||||
// revoke revokes token at Google. HTTP 200 and HTTP 400 invalid_token (the
|
||||
// token is already revoked or expired) both leave it unusable.
|
||||
func (s *Service) revoke(ctx context.Context, token string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.revokeURL,
|
||||
strings.NewReader(url.Values{"token": {token}}.Encode()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("gcal: build revoke request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
var transport *url.Error
|
||||
if errors.As(err, &transport) {
|
||||
// The URL is dropped: after a refused redirect it is the
|
||||
// Google-supplied Location.
|
||||
return &RevokeError{msg: fmt.Sprintf("gcal: revoke request failed: %s: %v", transport.Op, transport.Err)}
|
||||
}
|
||||
return &RevokeError{msg: "gcal: revoke request failed"}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxRevokeBody))
|
||||
if err != nil {
|
||||
return &RevokeError{msg: "gcal: read revoke response failed"}
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
var reply struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &reply) == nil && reply.Error == "invalid_token" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return &RevokeError{msg: fmt.Sprintf("gcal: Google refused the revoke (HTTP %d)", resp.StatusCode)}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
package gcal_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
const publicURL = "https://meals.example.com/app"
|
||||
|
||||
var (
|
||||
redirectURL = "https://meals.example.com" + gcal.CallbackPath
|
||||
session = auth.Session{Subject: "user-1", CSRFToken: "csrf-session-one"}
|
||||
other = auth.Session{Subject: "user-1", CSRFToken: "csrf-session-two"}
|
||||
)
|
||||
|
||||
type harness struct {
|
||||
svc *gcal.Service
|
||||
google *gcaltest.Google
|
||||
st *store.Store
|
||||
box *secretbox.Box
|
||||
clock time.Time
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pub, err := url.Parse(publicURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
google := gcaltest.New(t, redirectURL)
|
||||
svc, err := gcal.NewService(st, box, gcal.Config{
|
||||
ClientID: gcaltest.ClientID,
|
||||
ClientSecret: gcaltest.ClientSecret,
|
||||
PublicURL: pub,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
Endpoint: google.Endpoint(),
|
||||
RevokeURL: google.RevokeURL(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &harness{svc: svc, google: google, st: st, box: box, clock: time.Date(2026, 10, 7, 18, 30, 0, 0, time.UTC)}
|
||||
gcal.SetClock(svc, func() time.Time { return h.clock })
|
||||
return h
|
||||
}
|
||||
|
||||
// connect is a started connect: Google's consent URL and the connect
|
||||
// cookie.
|
||||
type connect struct {
|
||||
consent *url.URL
|
||||
cookie *http.Cookie
|
||||
}
|
||||
|
||||
func (h *harness) connect(t *testing.T, s auth.Session) connect {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
raw, err := h.svc.Connect(rec, s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
consent, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookies := rec.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("Connect set %d cookies, want 1", len(cookies))
|
||||
}
|
||||
return connect{consent: consent, cookie: cookies[0]}
|
||||
}
|
||||
|
||||
// callback completes c as session s with query, returning the error and the
|
||||
// recorder Complete wrote to.
|
||||
func (h *harness) callback(c connect, s auth.Session, query url.Values) (*httptest.ResponseRecorder, error) {
|
||||
req := httptest.NewRequest(http.MethodGet, gcal.CallbackPath+"?"+query.Encode(), nil)
|
||||
if c.cookie != nil {
|
||||
req.AddCookie(c.cookie)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
return rec, h.svc.Complete(rec, req, s)
|
||||
}
|
||||
|
||||
// consented is the query Google sends back after consent to c.
|
||||
func (h *harness) consented(c connect) url.Values {
|
||||
q := c.consent.Query()
|
||||
return url.Values{"state": {q.Get("state")}, "code": {h.google.Authorize(q.Get("code_challenge"))}}
|
||||
}
|
||||
|
||||
func (h *harness) row(t *testing.T) (db.IntegrationsGoogle, bool) {
|
||||
t.Helper()
|
||||
row, err := db.New(h.st.Reader).GetIntegrationsGoogle(context.Background())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return db.IntegrationsGoogle{}, false
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return row, true
|
||||
}
|
||||
|
||||
func TestConnectRedirectsToConsent(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
|
||||
if got, want := c.consent.Scheme+"://"+c.consent.Host+c.consent.Path, h.google.Endpoint().AuthURL; got != want {
|
||||
t.Errorf("consent URL = %s, want %s", got, want)
|
||||
}
|
||||
q := c.consent.Query()
|
||||
for name, want := range map[string]string{
|
||||
"client_id": gcaltest.ClientID,
|
||||
"redirect_uri": redirectURL,
|
||||
"response_type": "code",
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
"code_challenge_method": "S256",
|
||||
} {
|
||||
if got := q.Get(name); got != want {
|
||||
t.Errorf("%s = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
if got := strings.Fields(q.Get("scope")); !slices.Equal(got, []string{
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
|
||||
}) {
|
||||
t.Errorf("scope = %q", q.Get("scope"))
|
||||
}
|
||||
if q.Get("state") == "" || q.Get("code_challenge") == "" {
|
||||
t.Errorf("state %q, code_challenge %q: want both set", q.Get("state"), q.Get("code_challenge"))
|
||||
}
|
||||
if q.Get("client_secret") != "" || strings.Contains(c.consent.String(), gcaltest.ClientSecret) {
|
||||
t.Error("consent URL carries the client secret")
|
||||
}
|
||||
|
||||
ck := c.cookie
|
||||
if ck.Name != "__Host-mealquest_google" || ck.Path != "/" || !ck.Secure || !ck.HttpOnly ||
|
||||
ck.SameSite != http.SameSiteLaxMode || ck.MaxAge != 600 || ck.Domain != "" {
|
||||
t.Errorf("connect cookie = %+v, want __Host-, Path=/, Secure, HttpOnly, Lax, 10 minutes", ck)
|
||||
}
|
||||
// Two connects never share a state.
|
||||
if again := h.connect(t, session); again.consent.Query().Get("state") == q.Get("state") {
|
||||
t.Error("two connects share a state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteStoresSealedRefreshToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
rec, err := h.callback(c, session, h.consented(c))
|
||||
if err != nil {
|
||||
t.Fatalf("Complete: %v", err)
|
||||
}
|
||||
|
||||
row, ok := h.row(t)
|
||||
if !ok {
|
||||
t.Fatal("no integration stored")
|
||||
}
|
||||
if bytes.Contains(row.RefreshTokenCt, []byte(gcaltest.RefreshToken)) {
|
||||
t.Error("refresh token stored in plain text")
|
||||
}
|
||||
plain, err := h.box.Decrypt(row.RefreshTokenCt)
|
||||
if err != nil || string(plain) != gcaltest.RefreshToken {
|
||||
t.Errorf("stored token opens to %q, %v; want the refresh token", plain, err)
|
||||
}
|
||||
if want := "2026-10-07T18:30:00.000Z"; row.ConnectedAt != want || row.CreatedAt != want || row.UpdatedAt != want {
|
||||
t.Errorf("connected_at %q created_at %q updated_at %q, want %s", row.ConnectedAt, row.CreatedAt, row.UpdatedAt, want)
|
||||
}
|
||||
if row.CalendarID.Valid || row.CalendarName.Valid || row.LastSyncError.Valid {
|
||||
t.Errorf("new connection has calendar or sync error: %+v", row)
|
||||
}
|
||||
assertCookieCleared(t, rec)
|
||||
assertNoSecret(t, "response", rec.Body.String()+headerText(rec.Header()))
|
||||
}
|
||||
|
||||
func TestReconnectReplacesTokenAndKeepsCalendar(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
if _, err := db.New(h.st.Writer).UpsertIntegrationsGoogle(ctx, db.UpsertIntegrationsGoogleParams{
|
||||
RefreshTokenCt: []byte("old sealed token"),
|
||||
CalendarID: sql.NullString{String: "family@group.calendar.google.com", Valid: true},
|
||||
CalendarName: sql.NullString{String: "Family", Valid: true},
|
||||
ConnectedAt: "2026-09-01T00:00:00.000Z",
|
||||
LastSyncError: sql.NullString{String: "token revoked", Valid: true},
|
||||
CreatedAt: "2026-09-01T00:00:00.000Z",
|
||||
UpdatedAt: "2026-09-01T00:00:00.000Z",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := h.connect(t, session)
|
||||
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||||
t.Fatalf("Complete: %v", err)
|
||||
}
|
||||
row, _ := h.row(t)
|
||||
if plain, err := h.box.Decrypt(row.RefreshTokenCt); err != nil || string(plain) != gcaltest.RefreshToken {
|
||||
t.Errorf("token after reconnect opens to %q, %v", plain, err)
|
||||
}
|
||||
if row.CalendarID.String != "family@group.calendar.google.com" || row.CalendarName.String != "Family" {
|
||||
t.Errorf("calendar after reconnect = %v %v, want it kept", row.CalendarID, row.CalendarName)
|
||||
}
|
||||
if row.LastSyncError.Valid {
|
||||
t.Errorf("last_sync_error = %q, want it cleared", row.LastSyncError.String)
|
||||
}
|
||||
if row.CreatedAt != "2026-09-01T00:00:00.000Z" || row.ConnectedAt != "2026-10-07T18:30:00.000Z" {
|
||||
t.Errorf("created_at %q connected_at %q", row.CreatedAt, row.ConnectedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRefusesBadState(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// edit changes the connect, the callback's session or its query.
|
||||
edit func(t *testing.T, h *harness, c *connect, s *auth.Session, q url.Values)
|
||||
}{
|
||||
{"missing state", func(_ *testing.T, _ *harness, _ *connect, _ *auth.Session, q url.Values) { q.Del("state") }},
|
||||
{"wrong state", func(_ *testing.T, _ *harness, _ *connect, _ *auth.Session, q url.Values) { q.Set("state", "forged") }},
|
||||
{"no connect cookie", func(_ *testing.T, _ *harness, c *connect, _ *auth.Session, _ url.Values) { c.cookie = nil }},
|
||||
{"tampered cookie", func(_ *testing.T, _ *harness, c *connect, _ *auth.Session, _ url.Values) {
|
||||
c.cookie = &http.Cookie{Name: c.cookie.Name, Value: "x" + c.cookie.Value}
|
||||
}},
|
||||
{"another session", func(_ *testing.T, _ *harness, _ *connect, s *auth.Session, _ url.Values) { *s = other }},
|
||||
{"expired", func(_ *testing.T, h *harness, _ *connect, _ *auth.Session, _ url.Values) {
|
||||
h.clock = h.clock.Add(10 * time.Minute)
|
||||
}},
|
||||
{"state of another connect", func(t *testing.T, h *harness, _ *connect, _ *auth.Session, q url.Values) {
|
||||
q.Set("state", h.connect(t, session).consent.Query().Get("state"))
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
s := session
|
||||
q := h.consented(c)
|
||||
tc.edit(t, h, &c, &s, q)
|
||||
|
||||
rec, err := h.callback(c, s, q)
|
||||
if !errors.Is(err, gcal.ErrBadState) {
|
||||
t.Fatalf("Complete error = %v, want ErrBadState", err)
|
||||
}
|
||||
if h.google.TokenCalls() != 0 {
|
||||
t.Error("the code was exchanged despite the bad state")
|
||||
}
|
||||
if _, ok := h.row(t); ok {
|
||||
t.Error("an integration was stored")
|
||||
}
|
||||
assertCookieCleared(t, rec)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompleteRefusesReplayedState presents a connect's cookie and state a
|
||||
// second time, with a second valid code from the same consent URL, as an
|
||||
// attacker holding a copy of the cookie could. The server refuses it,
|
||||
// whatever the browser did with the cleared cookie.
|
||||
func TestCompleteRefusesReplayedState(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||||
t.Fatalf("Complete: %v", err)
|
||||
}
|
||||
stored, _ := h.row(t)
|
||||
|
||||
_, err := h.callback(c, session, h.consented(c))
|
||||
if !errors.Is(err, gcal.ErrBadState) {
|
||||
t.Fatalf("replayed Complete error = %v, want ErrBadState", err)
|
||||
}
|
||||
if got := h.google.TokenCalls(); got != 1 {
|
||||
t.Errorf("token endpoint called %d times, want once: the replayed code was exchanged", got)
|
||||
}
|
||||
if row, _ := h.row(t); !bytes.Equal(row.RefreshTokenCt, stored.RefreshTokenCt) {
|
||||
t.Error("the replay replaced the stored token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRefusesMissingRefreshToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.google.OmitRefreshToken()
|
||||
c := h.connect(t, session)
|
||||
_, err := h.callback(c, session, h.consented(c))
|
||||
if !errors.Is(err, gcal.ErrNoRefreshToken) {
|
||||
t.Fatalf("Complete error = %v, want ErrNoRefreshToken", err)
|
||||
}
|
||||
if _, ok := h.row(t); ok {
|
||||
t.Error("an integration was stored without a refresh token")
|
||||
}
|
||||
assertNoSecret(t, "error", err.Error())
|
||||
}
|
||||
|
||||
func TestCompleteRefusesMissingScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.google.GrantScope("https://www.googleapis.com/auth/calendar.calendarlist.readonly")
|
||||
c := h.connect(t, session)
|
||||
_, err := h.callback(c, session, h.consented(c))
|
||||
if !errors.Is(err, gcal.ErrScopes) {
|
||||
t.Fatalf("Complete error = %v, want ErrScopes", err)
|
||||
}
|
||||
if _, ok := h.row(t); ok {
|
||||
t.Error("an integration was stored without calendar.events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
q := url.Values{"state": {c.consent.Query().Get("state")}, "error": {"access_denied"}}
|
||||
if _, err := h.callback(c, session, q); !errors.Is(err, gcal.ErrDenied) {
|
||||
t.Fatalf("Complete error = %v, want ErrDenied", err)
|
||||
}
|
||||
if h.google.TokenCalls() != 0 {
|
||||
t.Error("token endpoint called after a denial")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteExchangeFailureIsRedacted(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.google.FailToken(http.StatusBadRequest)
|
||||
c := h.connect(t, session)
|
||||
_, err := h.callback(c, session, h.consented(c))
|
||||
if err == nil {
|
||||
t.Fatal("Complete succeeded despite the token endpoint failing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HTTP 400") {
|
||||
t.Errorf("error = %v, want the status", err)
|
||||
}
|
||||
assertNoSecret(t, "error", err.Error())
|
||||
if _, ok := h.row(t); ok {
|
||||
t.Error("an integration was stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectRevokesAndClears(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.svc.Disconnect(context.Background()); err != nil {
|
||||
t.Fatalf("Disconnect: %v", err)
|
||||
}
|
||||
if got := h.google.Revoked(); !slices.Equal(got, []string{gcaltest.RefreshToken}) {
|
||||
t.Errorf("revoked %q, want the refresh token", got)
|
||||
}
|
||||
if _, ok := h.row(t); ok {
|
||||
t.Error("integration still stored after disconnect")
|
||||
}
|
||||
if err := h.svc.Disconnect(context.Background()); !errors.Is(err, gcal.ErrNotConnected) {
|
||||
t.Errorf("second Disconnect = %v, want ErrNotConnected", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisconnectRacingReconnect drives a disconnect and a reconnect into
|
||||
// each other at Google, with distinct tokens. However they interleave, the
|
||||
// end state is consistent: disconnected, or connected with a token Google
|
||||
// has not revoked.
|
||||
func TestDisconnectRacingReconnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// hold stops the request the other operation must not slip past.
|
||||
hold func(*gcaltest.Google) (<-chan struct{}, func())
|
||||
// first is held at Google while second runs.
|
||||
first, second func(h *harness, c connect, q url.Values) error
|
||||
}{
|
||||
{
|
||||
// The disconnect has read the stored token and is revoking it
|
||||
// when the reconnect would store a new one.
|
||||
name: "reconnect during revoke",
|
||||
hold: (*gcaltest.Google).HoldRevoke,
|
||||
first: disconnect,
|
||||
second: reconnect,
|
||||
},
|
||||
{
|
||||
// The reconnect's new token is issued but not yet stored when
|
||||
// the disconnect would revoke the old one.
|
||||
name: "disconnect during exchange",
|
||||
hold: (*gcaltest.Google).HoldToken,
|
||||
first: reconnect,
|
||||
second: disconnect,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
first := h.connect(t, session)
|
||||
if _, err := h.callback(first, session, h.consented(first)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := h.connect(t, session)
|
||||
q := h.consented(c)
|
||||
|
||||
arrived, release := tc.hold(h.google)
|
||||
defer release()
|
||||
firstDone := make(chan error, 1)
|
||||
go func() { firstDone <- tc.first(h, c, q) }()
|
||||
<-arrived
|
||||
secondDone := make(chan error, 1)
|
||||
go func() { secondDone <- tc.second(h, c, q) }()
|
||||
// Let the second operation run as far as it can while the first
|
||||
// is held at Google: to the end when nothing serializes them.
|
||||
select {
|
||||
case err := <-secondDone:
|
||||
secondDone <- err
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
release()
|
||||
for _, done := range []chan error{firstDone, secondDone} {
|
||||
if err := <-done; err != nil {
|
||||
t.Logf("operation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
row, ok := h.row(t)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
plain, err := h.box.Decrypt(row.RefreshTokenCt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !h.google.Live(string(plain)) {
|
||||
t.Errorf("stored as connected with a revoked token (Google revoked %d tokens)", len(h.google.Revoked()))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect(h *harness, _ connect, _ url.Values) error {
|
||||
return h.svc.Disconnect(context.Background())
|
||||
}
|
||||
|
||||
func reconnect(h *harness, c connect, q url.Values) error {
|
||||
_, err := h.callback(c, session, q)
|
||||
return err
|
||||
}
|
||||
|
||||
func TestDisconnectTokenAlreadyRevokedAtGoogle(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.google.AnswerRevoke(http.StatusBadRequest, `{"error":"invalid_token","error_description":"Token expired or revoked"}`)
|
||||
if err := h.svc.Disconnect(context.Background()); err != nil {
|
||||
t.Fatalf("Disconnect: %v", err)
|
||||
}
|
||||
if _, ok := h.row(t); ok {
|
||||
t.Error("integration still stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectRevokeFailureKeepsConnection(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, status := range []int{http.StatusBadRequest, http.StatusServiceUnavailable} {
|
||||
h := newHarness(t)
|
||||
c := h.connect(t, session)
|
||||
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.google.AnswerRevoke(status, "")
|
||||
err := h.svc.Disconnect(context.Background())
|
||||
var revoke *gcal.RevokeError
|
||||
if !errors.As(err, &revoke) {
|
||||
t.Fatalf("HTTP %d: Disconnect error = %v, want *RevokeError", status, err)
|
||||
}
|
||||
assertNoSecret(t, "error", err.Error())
|
||||
if _, ok := h.row(t); !ok {
|
||||
t.Errorf("HTTP %d: connection cleared although Google did not revoke", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectNotConnected(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
if err := h.svc.Disconnect(context.Background()); !errors.Is(err, gcal.ErrNotConnected) {
|
||||
t.Fatalf("Disconnect = %v, want ErrNotConnected", err)
|
||||
}
|
||||
if len(h.google.Revoked()) != 0 {
|
||||
t.Error("revoke called with nothing connected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceRequiresConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
pub, _ := url.Parse(publicURL)
|
||||
valid := gcal.Config{
|
||||
ClientID: "id", ClientSecret: "secret", PublicURL: pub, SecretKey: []byte("k"),
|
||||
Endpoint: gcal.GoogleEndpoint, RevokeURL: gcal.GoogleRevokeURL,
|
||||
}
|
||||
if _, err := gcal.NewService(h.st, h.box, valid); err != nil {
|
||||
t.Fatalf("valid config: %v", err)
|
||||
}
|
||||
for name, edit := range map[string]func(*gcal.Config){
|
||||
"client ID": func(c *gcal.Config) { c.ClientID = "" },
|
||||
"client secret": func(c *gcal.Config) { c.ClientSecret = "" },
|
||||
"public URL": func(c *gcal.Config) { c.PublicURL = nil },
|
||||
"secret key": func(c *gcal.Config) { c.SecretKey = nil },
|
||||
"token URL": func(c *gcal.Config) { c.Endpoint.TokenURL = "" },
|
||||
"revoke URL": func(c *gcal.Config) { c.RevokeURL = "" },
|
||||
} {
|
||||
cfg := valid
|
||||
edit(&cfg)
|
||||
if _, err := gcal.NewService(h.st, h.box, cfg); err == nil {
|
||||
t.Errorf("NewService accepted a config without the %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertCookieCleared(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == "__Host-mealquest_google" && c.MaxAge < 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("connect cookie not cleared")
|
||||
}
|
||||
|
||||
// assertNoSecret fails when text holds the refresh token, the access token
|
||||
// or the client secret.
|
||||
func assertNoSecret(t *testing.T, what, text string) {
|
||||
t.Helper()
|
||||
for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} {
|
||||
if strings.Contains(text, secret) {
|
||||
t.Errorf("%s contains a secret: %s", what, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func headerText(h http.Header) string {
|
||||
var b strings.Builder
|
||||
for k, vs := range h {
|
||||
b.WriteString(k + ": " + strings.Join(vs, ", ") + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package gcal
|
||||
|
||||
import "time"
|
||||
|
||||
// SetClock replaces s's clock.
|
||||
func SetClock(s *Service, now func() time.Time) { s.now = now }
|
||||
@@ -0,0 +1,295 @@
|
||||
// Package gcaltest provides a fake Google OAuth server for tests of the
|
||||
// Google Calendar connection: a token endpoint that checks the client, the
|
||||
// redirect URI and PKCE and issues a distinct refresh token per exchange, and
|
||||
// a revoke endpoint that, like Google's, revokes the whole grant: every token
|
||||
// issued so far. It never touches the network beyond its own httptest
|
||||
// server.
|
||||
package gcaltest
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
)
|
||||
|
||||
// Fixed credentials and tokens the fake issues and expects. The first
|
||||
// exchange issues RefreshToken; exchange n after it issues RefreshToken
|
||||
// followed by "-n", so every issued refresh token contains RefreshToken.
|
||||
const (
|
||||
ClientID = "mealquest-test.apps.googleusercontent.com"
|
||||
ClientSecret = "google-client-s3cret-4f1c"
|
||||
RefreshToken = "1//google-refresh-t0ken-9a8b7c"
|
||||
AccessToken = "ya29.google-access-t0ken-5d6e"
|
||||
)
|
||||
|
||||
// Google is the fake. Its exported fields script the next responses; set
|
||||
// them before the request they affect.
|
||||
type Google struct {
|
||||
Server *httptest.Server
|
||||
// RedirectURL is the redirect URI the token endpoint requires.
|
||||
RedirectURL string
|
||||
|
||||
mu sync.Mutex
|
||||
// omitRefresh leaves refresh_token out of the token response.
|
||||
omitRefresh bool
|
||||
// scope is the token response's granted scopes.
|
||||
scope string
|
||||
// tokenStatus, when not 200, fails the token endpoint with a body that
|
||||
// quotes the refresh token and client secret.
|
||||
tokenStatus int
|
||||
// revokeStatus and revokeBody answer the revoke endpoint; revokeBody
|
||||
// quotes the token when revokeStatus is not 200 or 400.
|
||||
revokeStatus int
|
||||
revokeBody string
|
||||
codes map[string]string // code -> PKCE challenge
|
||||
tokenCalls int
|
||||
revoked []string
|
||||
// issued is every refresh token issued, in order; dead is those a
|
||||
// revoke has invalidated.
|
||||
issued []string
|
||||
dead map[string]bool
|
||||
// tokenHold and revokeHold, when set, stop the next request to that
|
||||
// endpoint.
|
||||
tokenHold, revokeHold *hold
|
||||
}
|
||||
|
||||
// hold stops one request: arrived is closed when it arrives, and it goes on
|
||||
// once release is closed.
|
||||
type hold struct {
|
||||
arrived, release chan struct{}
|
||||
}
|
||||
|
||||
// New starts a fake Google that requires redirectURL at its token endpoint.
|
||||
// It grants every scope in gcal.Scopes with a refresh token by default.
|
||||
func New(t *testing.T, redirectURL string) *Google {
|
||||
t.Helper()
|
||||
g := &Google{
|
||||
RedirectURL: redirectURL,
|
||||
scope: strings.Join(gcal.Scopes, " "),
|
||||
tokenStatus: http.StatusOK,
|
||||
revokeStatus: http.StatusOK,
|
||||
codes: map[string]string{},
|
||||
dead: map[string]bool{},
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /token", g.token)
|
||||
mux.HandleFunc("POST /revoke", g.revoke)
|
||||
g.Server = httptest.NewServer(mux)
|
||||
t.Cleanup(g.Server.Close)
|
||||
return g
|
||||
}
|
||||
|
||||
// Endpoint is the fake's authorization and token endpoint, with Google's
|
||||
// client authentication style.
|
||||
func (g *Google) Endpoint() oauth2.Endpoint {
|
||||
return oauth2.Endpoint{
|
||||
AuthURL: g.Server.URL + "/auth",
|
||||
TokenURL: g.Server.URL + "/token",
|
||||
AuthStyle: gcal.GoogleEndpoint.AuthStyle,
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeURL is the fake's revocation endpoint.
|
||||
func (g *Google) RevokeURL() string { return g.Server.URL + "/revoke" }
|
||||
|
||||
// Authorize plays the consent screen for an authorization request with PKCE
|
||||
// challenge and returns the code Google would send back.
|
||||
func (g *Google) Authorize(challenge string) string {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
code := fmt.Sprintf("code-%d", len(g.codes)+1)
|
||||
g.codes[code] = challenge
|
||||
return code
|
||||
}
|
||||
|
||||
// OmitRefreshToken makes the token endpoint answer without a refresh token,
|
||||
// as Google does when it reuses an earlier grant.
|
||||
func (g *Google) OmitRefreshToken() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.omitRefresh = true
|
||||
}
|
||||
|
||||
// GrantScope sets the scopes the token endpoint reports as granted.
|
||||
func (g *Google) GrantScope(scope string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.scope = scope
|
||||
}
|
||||
|
||||
// FailToken makes the token endpoint answer status with a body that quotes
|
||||
// the refresh token and client secret.
|
||||
func (g *Google) FailToken(status int) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.tokenStatus = status
|
||||
}
|
||||
|
||||
// AnswerRevoke makes the revoke endpoint answer status with body. A body of
|
||||
// "" with a status other than 200 quotes the token it was sent.
|
||||
func (g *Google) AnswerRevoke(status int, body string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.revokeStatus, g.revokeBody = status, body
|
||||
}
|
||||
|
||||
// TokenCalls is how many requests reached the token endpoint.
|
||||
func (g *Google) TokenCalls() int {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.tokenCalls
|
||||
}
|
||||
|
||||
// Revoked lists the tokens the revoke endpoint received, in order.
|
||||
func (g *Google) Revoked() []string {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return append([]string(nil), g.revoked...)
|
||||
}
|
||||
|
||||
// Live reports whether token was issued by the fake and no revoke has
|
||||
// invalidated it since.
|
||||
func (g *Google) Live(token string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return slices.Contains(g.issued, token) && !g.dead[token]
|
||||
}
|
||||
|
||||
// HoldToken stops the next token request after its refresh token is issued
|
||||
// and before it is answered, so a revoke can land in between. It returns a
|
||||
// channel closed when the request arrives and the function that lets it go
|
||||
// on.
|
||||
func (g *Google) HoldToken() (arrived <-chan struct{}, release func()) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.tokenHold = newHold()
|
||||
return g.tokenHold.arrived, g.tokenHold.releaseFunc()
|
||||
}
|
||||
|
||||
// HoldRevoke stops the next revoke request before Google acts on it. It
|
||||
// returns a channel closed when the request arrives and the function that
|
||||
// lets it go on.
|
||||
func (g *Google) HoldRevoke() (arrived <-chan struct{}, release func()) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.revokeHold = newHold()
|
||||
return g.revokeHold.arrived, g.revokeHold.releaseFunc()
|
||||
}
|
||||
|
||||
func newHold() *hold {
|
||||
return &hold{arrived: make(chan struct{}), release: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (h *hold) releaseFunc() func() {
|
||||
var once sync.Once
|
||||
return func() { once.Do(func() { close(h.release) }) }
|
||||
}
|
||||
|
||||
// wait stops the request at h, if any, without holding g.mu.
|
||||
func (g *Google) wait(h *hold) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
g.mu.Unlock()
|
||||
close(h.arrived)
|
||||
<-h.release
|
||||
g.mu.Lock()
|
||||
}
|
||||
|
||||
func (g *Google) token(w http.ResponseWriter, r *http.Request) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.tokenCalls++
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
f := r.PostForm
|
||||
challenge, known := g.codes[f.Get("code")]
|
||||
delete(g.codes, f.Get("code"))
|
||||
switch {
|
||||
case f.Get("grant_type") != "authorization_code":
|
||||
oauthError(w, http.StatusBadRequest, "unsupported_grant_type")
|
||||
return
|
||||
case f.Get("client_id") != ClientID || f.Get("client_secret") != ClientSecret:
|
||||
oauthError(w, http.StatusUnauthorized, "invalid_client")
|
||||
return
|
||||
case !known, f.Get("redirect_uri") != g.RedirectURL, s256(f.Get("code_verifier")) != challenge:
|
||||
oauthError(w, http.StatusBadRequest, "invalid_grant")
|
||||
return
|
||||
}
|
||||
if g.tokenStatus != http.StatusOK {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(g.tokenStatus)
|
||||
fmt.Fprintf(w, `{"error":"invalid_grant","error_description":"echo %s %s"}`, RefreshToken, ClientSecret)
|
||||
return
|
||||
}
|
||||
refresh := ""
|
||||
if !g.omitRefresh {
|
||||
issue := RefreshToken
|
||||
if n := len(g.issued); n > 0 {
|
||||
issue = fmt.Sprintf("%s-%d", RefreshToken, n+1)
|
||||
}
|
||||
g.issued = append(g.issued, issue)
|
||||
refresh = fmt.Sprintf(`"refresh_token":%q,`, issue)
|
||||
}
|
||||
h := g.tokenHold
|
||||
g.tokenHold = nil
|
||||
g.wait(h)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"access_token":%q,%s"expires_in":3599,"scope":%q,"token_type":"Bearer"}`,
|
||||
AccessToken, refresh, g.scope)
|
||||
}
|
||||
|
||||
func (g *Google) revoke(w http.ResponseWriter, r *http.Request) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
|
||||
http.Error(w, "want a form", http.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h := g.revokeHold
|
||||
g.revokeHold = nil
|
||||
g.wait(h)
|
||||
token := r.PostForm.Get("token")
|
||||
g.revoked = append(g.revoked, token)
|
||||
if g.revokeStatus == http.StatusOK && slices.Contains(g.issued, token) {
|
||||
// Google revokes the grant, not just the token: every token issued
|
||||
// to it so far stops working.
|
||||
for _, t := range g.issued {
|
||||
g.dead[t] = true
|
||||
}
|
||||
}
|
||||
body := g.revokeBody
|
||||
if body == "" && g.revokeStatus != http.StatusOK {
|
||||
body = "cannot revoke " + token
|
||||
}
|
||||
w.WriteHeader(g.revokeStatus)
|
||||
fmt.Fprint(w, body)
|
||||
}
|
||||
|
||||
func oauthError(w http.ResponseWriter, status int, code string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprintf(w, `{"error":%q}`, code)
|
||||
}
|
||||
|
||||
// s256 is the PKCE S256 challenge for verifier.
|
||||
func s256(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
+23
-6
@@ -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. A pool under seven dinners fails with
|
||||
// ReasonNotEnoughDinners and draft_week failing with ReasonAIFailed; no
|
||||
// week is made up instead.
|
||||
// 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.
|
||||
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, p.Confirm, base, week)
|
||||
planID, err := s.storeDraft(ctx, weekStart, household.WeekStart(in.Context.Today), p.Confirm, base, week)
|
||||
if err != nil {
|
||||
return draftResult{}, err
|
||||
}
|
||||
@@ -142,11 +142,28 @@ 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.
|
||||
func (s *Service) storeDraft(ctx context.Context, weekStart store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) {
|
||||
// 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) {
|
||||
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())
|
||||
|
||||
+21
-4
@@ -53,10 +53,15 @@ 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}
|
||||
var Reasons = []string{ReasonNotEnoughDinners, ReasonAIFailed, ReasonConfirmRequired, ReasonPlanChanged, ReasonPoolChanged}
|
||||
|
||||
// ContextBudget is the character budget of the household context
|
||||
// draft_week is given (see harness.BuildContext).
|
||||
@@ -71,6 +76,10 @@ 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")
|
||||
@@ -337,9 +346,10 @@ 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), and the dinner must be
|
||||
// a Regular or in the Quest log (else ErrDinnerNotFound or
|
||||
// *DinnerNotPlannableError). It returns the plan as swapped.
|
||||
// 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.
|
||||
func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, dinnerID string) (Plan, error) {
|
||||
dates, err := household.WeekDays(weekStart)
|
||||
if err != nil {
|
||||
@@ -369,6 +379,13 @@ 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,9 +174,15 @@ 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}`, weekDates[i], e.ids[name]))
|
||||
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[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)}}
|
||||
@@ -398,15 +404,16 @@ func TestStartByAcrossDaylightSavingTimeEnd(t *testing.T) {
|
||||
t.Errorf("%s start_by = %s, %v; want %s", dates[tc.day], b, err, tc.want)
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
// 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 {
|
||||
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 Beef Chili start_by = %s, %v; want 13:30 EST", b, err)
|
||||
t.Errorf("Sunday Short Ribs start_by = %s, %v; want 13:30 EST", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,6 +473,70 @@ 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)
|
||||
@@ -633,7 +704,8 @@ func TestDraftFailsWhenPlanSwappedWhileDrafting(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-started
|
||||
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Sheet Pan Salmon"]); err != nil {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(release)
|
||||
@@ -646,7 +718,7 @@ func TestDraftFailsWhenPlanSwappedWhileDrafting(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := slices.Clone(fullWeek)
|
||||
want[2] = "Sheet Pan Salmon"
|
||||
want[2] = "Fish Tacos"
|
||||
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)
|
||||
}
|
||||
@@ -822,7 +894,8 @@ func TestSwapStatusTransitions(t *testing.T) {
|
||||
e.reply("", fullWeek...)
|
||||
e.mustDraft(t, false)
|
||||
e.setStatus(t, tc.from)
|
||||
p, err := e.svc.Swap(t.Context(), week, "2026-10-12", e.ids["Pad Thai"])
|
||||
fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
|
||||
p, err := e.svc.Swap(t.Context(), week, "2026-10-12", fishTacos)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -852,6 +925,7 @@ 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) }},
|
||||
|
||||
@@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t),
|
||||
newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
svc, newTestSuggest(t), newTestPlan(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
svc, newTestSuggest(t), newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
)
|
||||
|
||||
// googleConnectedPath is where a completed connect sends the browser: the
|
||||
// Google Calendar settings screen.
|
||||
const googleConnectedPath = "/settings/calendar"
|
||||
|
||||
// getGoogleConnect sends the browser to Google's consent screen with a state
|
||||
// bound to its session. The household opens it as a top-level navigation;
|
||||
// the session cookie is SameSite=Lax, so it rides along.
|
||||
func getGoogleConnect(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
session, ok := auth.SessionFrom(r.Context())
|
||||
if !ok {
|
||||
logger.ErrorContext(r.Context(), "Google connect without a session")
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
consent, err := svc.Connect(w, session)
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "start Google connect", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, consent, http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
// getGoogleCallback completes the connect Google redirects back with, then
|
||||
// sends the browser to the Google Calendar settings screen. Every failure is
|
||||
// an API error; none holds a token or Google's response text.
|
||||
func getGoogleCallback(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
ctx := r.Context()
|
||||
session, ok := auth.SessionFrom(ctx)
|
||||
if !ok {
|
||||
logger.ErrorContext(ctx, "Google callback without a session")
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
err := svc.Complete(w, r, session)
|
||||
switch {
|
||||
case err == nil:
|
||||
http.Redirect(w, r, googleConnectedPath, http.StatusSeeOther)
|
||||
case errors.Is(err, gcal.ErrBadState):
|
||||
logger.WarnContext(ctx, "Google callback refused", "error", err)
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_state",
|
||||
"this Google Calendar connection expired or was not started here; connect again")
|
||||
case errors.Is(err, gcal.ErrDenied):
|
||||
logger.WarnContext(ctx, "Google returned an authorization error")
|
||||
writeError(w, r, logger, http.StatusForbidden, "google_denied",
|
||||
"Google did not grant access to your calendar; connect again to retry")
|
||||
case errors.Is(err, gcal.ErrNoCode):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "missing authorization code")
|
||||
case errors.Is(err, gcal.ErrNoRefreshToken):
|
||||
logger.WarnContext(ctx, "Google connect refused", "error", err)
|
||||
writeError(w, r, logger, http.StatusBadGateway, "reconnect_required",
|
||||
"Google did not grant lasting access to your calendar; connect Google Calendar again")
|
||||
case errors.Is(err, gcal.ErrScopes):
|
||||
logger.WarnContext(ctx, "Google connect refused", "error", err)
|
||||
writeError(w, r, logger, http.StatusForbidden, "scopes_missing",
|
||||
"allow MealQuest to see your calendars and manage their events, then connect Google Calendar again")
|
||||
default:
|
||||
logger.WarnContext(ctx, "Google connect failed", "error", err)
|
||||
writeError(w, r, logger, http.StatusBadGateway, "google_failed",
|
||||
"Google Calendar could not be connected; connect again")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deleteGoogle revokes the connected account's access at Google and forgets
|
||||
// it: 204 on success, 409 not_connected when nothing is connected, 502 when
|
||||
// Google could not revoke (the connection is kept, so it can be retried).
|
||||
func deleteGoogle(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
err := svc.Disconnect(r.Context())
|
||||
var revoke *gcal.RevokeError
|
||||
switch {
|
||||
case err == nil:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case errors.Is(err, gcal.ErrNotConnected):
|
||||
writeError(w, r, logger, http.StatusConflict, "not_connected", "Google Calendar is not connected")
|
||||
case errors.As(err, &revoke):
|
||||
logger.WarnContext(r.Context(), "revoke Google access", "error", err)
|
||||
writeError(w, r, logger, http.StatusBadGateway, "revoke_failed",
|
||||
"Google could not revoke MealQuest's access; try again")
|
||||
default:
|
||||
logger.ErrorContext(r.Context(), "disconnect Google Calendar", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
googleConnectPath = "/api/v1/integrations/google/connect"
|
||||
googleCallbackPath = "/api/v1/integrations/google/callback"
|
||||
googlePath = "/api/v1/integrations/google"
|
||||
)
|
||||
|
||||
// googleEnv is a router whose Google Calendar service talks to a fake
|
||||
// Google, with every log line it writes.
|
||||
type googleEnv struct {
|
||||
h http.Handler
|
||||
google *gcaltest.Google
|
||||
st *store.Store
|
||||
box *secretbox.Box
|
||||
logs *bytes.Buffer
|
||||
// seen collects every response body and header, to check none holds a
|
||||
// secret.
|
||||
seen strings.Builder
|
||||
}
|
||||
|
||||
func newGoogleEnv(t *testing.T) *googleEnv {
|
||||
t.Helper()
|
||||
pub, err := url.Parse("https://mealquest.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := &googleEnv{
|
||||
google: gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath),
|
||||
st: newTestStore(t),
|
||||
box: newTestBox(t),
|
||||
logs: &bytes.Buffer{},
|
||||
}
|
||||
svc, err := gcal.NewService(e.st, e.box, gcal.Config{
|
||||
ClientID: gcaltest.ClientID,
|
||||
ClientSecret: gcaltest.ClientSecret,
|
||||
PublicURL: pub,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
Endpoint: e.google.Endpoint(),
|
||||
RevokeURL: e.google.RevokeURL(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), svc, newTestRunner(t), newTestPhotos(t),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} {
|
||||
if strings.Contains(e.seen.String(), secret) {
|
||||
t.Errorf("a response holds a secret: %s", e.seen.String())
|
||||
}
|
||||
if strings.Contains(e.logs.String(), secret) {
|
||||
t.Errorf("the logs hold a secret: %s", e.logs.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *googleEnv) serve(req *http.Request) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, req)
|
||||
e.seen.WriteString(rec.Body.String())
|
||||
for k, vs := range rec.Header() {
|
||||
e.seen.WriteString(k + ": " + strings.Join(vs, ", ") + "\n")
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
// connect starts a connect as the test session and returns Google's consent
|
||||
// URL and the connect cookie.
|
||||
func (e *googleEnv) connect(t *testing.T) (*url.URL, *http.Cookie) {
|
||||
t.Helper()
|
||||
rec := e.serve(signedIn(http.MethodGet, googleConnectPath, nil))
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("connect = %d %s, want 302", rec.Code, rec.Body)
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
|
||||
t.Errorf("connect Cache-Control = %q, want no-store", cc)
|
||||
}
|
||||
consent, err := url.Parse(rec.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == "__Host-mealquest_google" {
|
||||
cookie = c
|
||||
}
|
||||
}
|
||||
if cookie == nil {
|
||||
t.Fatal("connect set no connect cookie")
|
||||
}
|
||||
return consent, cookie
|
||||
}
|
||||
|
||||
// callback returns to the callback as the test session with the connect
|
||||
// cookie and query.
|
||||
func (e *googleEnv) callback(cookie *http.Cookie, query url.Values) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, googleCallbackPath+"?"+query.Encode(), nil)
|
||||
req.AddCookie(validSessionCookie)
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
return e.serve(req)
|
||||
}
|
||||
|
||||
func (e *googleEnv) consented(consent *url.URL) url.Values {
|
||||
q := consent.Query()
|
||||
return url.Values{"state": {q.Get("state")}, "code": {e.google.Authorize(q.Get("code_challenge"))}}
|
||||
}
|
||||
|
||||
func (e *googleEnv) stored(t *testing.T) bool {
|
||||
t.Helper()
|
||||
_, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func errorBodyCode(t *testing.T, rec *httptest.ResponseRecorder) (code, message string) {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Error apiError `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return body.Error.Code, body.Error.Message
|
||||
}
|
||||
|
||||
func TestGoogleConnectAndDisconnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newGoogleEnv(t)
|
||||
|
||||
consent, cookie := e.connect(t)
|
||||
q := consent.Query()
|
||||
if consent.Host != strings.TrimPrefix(e.google.Server.URL, "http://") || consent.Path != "/auth" {
|
||||
t.Errorf("consent URL = %s, want the fake's /auth", consent)
|
||||
}
|
||||
if q.Get("access_type") != "offline" || q.Get("prompt") != "consent" ||
|
||||
q.Get("scope") != strings.Join(gcal.Scopes, " ") ||
|
||||
q.Get("redirect_uri") != "https://mealquest.example"+gcal.CallbackPath {
|
||||
t.Errorf("consent query = %v", q)
|
||||
}
|
||||
|
||||
rec := e.callback(cookie, e.consented(consent))
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/settings/calendar" {
|
||||
t.Fatalf("callback = %d Location %q %s, want 303 to /settings/calendar", rec.Code, rec.Header().Get("Location"), rec.Body)
|
||||
}
|
||||
row, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plain, err := e.box.Decrypt(row.RefreshTokenCt); err != nil || string(plain) != gcaltest.RefreshToken {
|
||||
t.Errorf("stored token opens to %q, %v", plain, err)
|
||||
}
|
||||
if row.ConnectedAt == "" {
|
||||
t.Error("connected_at not set")
|
||||
}
|
||||
|
||||
// DELETE needs the CSRF header like every other write.
|
||||
req := httptest.NewRequest(http.MethodDelete, googlePath, nil)
|
||||
req.AddCookie(validSessionCookie)
|
||||
if rec := e.serve(req); rec.Code != http.StatusForbidden {
|
||||
t.Errorf("DELETE without CSRF = %d, want 403", rec.Code)
|
||||
}
|
||||
if len(e.google.Revoked()) != 0 || !e.stored(t) {
|
||||
t.Fatal("a DELETE without CSRF revoked or cleared the connection")
|
||||
}
|
||||
|
||||
rec = e.serve(signedIn(http.MethodDelete, googlePath, nil))
|
||||
if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
|
||||
t.Fatalf("DELETE = %d %q, want 204 with no body", rec.Code, rec.Body)
|
||||
}
|
||||
if got := e.google.Revoked(); !slices.Equal(got, []string{gcaltest.RefreshToken}) {
|
||||
t.Errorf("revoked %q, want the refresh token", got)
|
||||
}
|
||||
if e.stored(t) {
|
||||
t.Error("connection still stored after DELETE")
|
||||
}
|
||||
|
||||
rec = e.serve(signedIn(http.MethodDelete, googlePath, nil))
|
||||
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusConflict || code != "not_connected" {
|
||||
t.Errorf("second DELETE = %d %s, want 409 not_connected", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleCallbackBadState(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newGoogleEnv(t)
|
||||
consent, cookie := e.connect(t)
|
||||
good := e.consented(consent)
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
cookie *http.Cookie
|
||||
query url.Values
|
||||
}{
|
||||
"missing state": {cookie, url.Values{"code": good["code"]}},
|
||||
"wrong state": {cookie, url.Values{"state": {"forged"}, "code": good["code"]}},
|
||||
"missing cookie": {nil, good},
|
||||
} {
|
||||
rec := e.callback(tc.cookie, tc.query)
|
||||
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadRequest || code != "invalid_state" {
|
||||
t.Errorf("%s: callback = %d %s, want 400 invalid_state", name, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
if e.google.TokenCalls() != 0 {
|
||||
t.Error("a code was exchanged despite a bad state")
|
||||
}
|
||||
if e.stored(t) {
|
||||
t.Error("a connection was stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleCallbackWithoutRefreshTokenAsksToReconnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newGoogleEnv(t)
|
||||
e.google.OmitRefreshToken()
|
||||
consent, cookie := e.connect(t)
|
||||
rec := e.callback(cookie, e.consented(consent))
|
||||
code, message := errorBodyCode(t, rec)
|
||||
if rec.Code != http.StatusBadGateway || code != "reconnect_required" || !strings.Contains(message, "connect Google Calendar again") {
|
||||
t.Errorf("callback = %d %s, want 502 reconnect_required asking to connect again", rec.Code, rec.Body)
|
||||
}
|
||||
if e.stored(t) {
|
||||
t.Error("a connection was stored without a refresh token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleCallbackFailuresAreRedacted(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newGoogleEnv(t)
|
||||
e.google.FailToken(http.StatusBadRequest)
|
||||
consent, cookie := e.connect(t)
|
||||
rec := e.callback(cookie, e.consented(consent))
|
||||
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadGateway || code != "google_failed" {
|
||||
t.Errorf("callback = %d %s, want 502 google_failed", rec.Code, rec.Body)
|
||||
}
|
||||
// newGoogleEnv's cleanup checks the response and logs hold no secret.
|
||||
}
|
||||
|
||||
func TestGoogleRevokeFailureKeepsConnection(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newGoogleEnv(t)
|
||||
consent, cookie := e.connect(t)
|
||||
if rec := e.callback(cookie, e.consented(consent)); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("callback = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
e.google.AnswerRevoke(http.StatusServiceUnavailable, "")
|
||||
rec := e.serve(signedIn(http.MethodDelete, googlePath, nil))
|
||||
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadGateway || code != "revoke_failed" {
|
||||
t.Errorf("DELETE = %d %s, want 502 revoke_failed", rec.Code, rec.Body)
|
||||
}
|
||||
if !e.stored(t) {
|
||||
t.Error("connection cleared although Google did not revoke")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleRoutesRequireSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newGoogleEnv(t)
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{http.MethodGet, googleConnectPath},
|
||||
{http.MethodGet, googleCallbackPath + "?state=s&code=c"},
|
||||
{http.MethodDelete, googlePath},
|
||||
} {
|
||||
rec := e.serve(httptest.NewRequest(c.method, c.path, nil))
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
|
||||
t.Errorf("%s %s signed out = %d %s, want 401", c.method, c.path, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
if e.google.TokenCalls() != 0 || len(e.google.Revoked()) != 0 {
|
||||
t.Error("a signed-out request reached Google")
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t),
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t),
|
||||
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -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, or planning a dinner that is neither a Regular nor
|
||||
// in the Quest log, is 409.
|
||||
// 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.
|
||||
func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||
var day *plan.InvalidDayError
|
||||
var week *plan.InvalidWeekError
|
||||
@@ -120,6 +120,8 @@ 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):
|
||||
|
||||
@@ -56,7 +56,7 @@ func newPlansRouter(t *testing.T) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), newTestSuggest(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), newTestSuggest(t), svc, newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -309,6 +309,7 @@ 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 {
|
||||
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
@@ -45,12 +46,14 @@ type RouteMounter interface {
|
||||
// /decline; planSvc serves GET /api/v1/plans/{weekStart}, POST
|
||||
// /api/v1/plans/{weekStart}/draft, PUT
|
||||
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
|
||||
// POST /api/v1/dinners/{id}/rotation-review; runner serves
|
||||
// POST /api/v1/dinners/{id}/rotation-review; googleSvc serves GET
|
||||
// /api/v1/integrations/google/connect and /callback and DELETE
|
||||
// /api/v1/integrations/google; runner serves
|
||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, googleSvc *gcal.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
@@ -69,6 +72,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
if planSvc == nil {
|
||||
return nil, errors.New("server: nil plan service")
|
||||
}
|
||||
if googleSvc == nil {
|
||||
return nil, errors.New("server: nil Google Calendar service")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
}
|
||||
@@ -128,6 +134,12 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc))
|
||||
apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc))
|
||||
apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc))
|
||||
// connect and callback are top-level browser navigations, so they are
|
||||
// GETs: the Lax session cookie rides both and CSRF applies to neither.
|
||||
// The callback's state is bound to the session that started connect.
|
||||
apiV1.Get("/integrations/google/connect", getGoogleConnect(logger, googleSvc))
|
||||
apiV1.Get("/integrations/google/callback", getGoogleCallback(logger, googleSvc))
|
||||
apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc))
|
||||
apiV1.Post("/jobs", postJob(logger, runner))
|
||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||
media.Get("/photos/{name}", getPhoto(logger, photos))
|
||||
|
||||
@@ -11,16 +11,19 @@ import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
@@ -50,7 +53,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), runner, newTestPhotos(t), assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -218,6 +221,28 @@ func newTestBox(t *testing.T) *secretbox.Box {
|
||||
return box
|
||||
}
|
||||
|
||||
// newTestGoogle returns a Google Calendar service over a migrated temp
|
||||
// database whose Google endpoints refuse every connection.
|
||||
func newTestGoogle(t *testing.T) *gcal.Service {
|
||||
t.Helper()
|
||||
pub, err := url.Parse("https://mealquest.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := gcal.NewService(newTestStore(t), newTestBox(t), gcal.Config{
|
||||
ClientID: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
PublicURL: pub,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
Endpoint: oauth2.Endpoint{AuthURL: "http://127.0.0.1:1/auth", TokenURL: "http://127.0.0.1:1/token"},
|
||||
RevokeURL: "http://127.0.0.1:1/revoke",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestAI returns an AI settings service over a migrated temp database
|
||||
// with the seed AI settings and a random secret box.
|
||||
func newTestAI(t *testing.T) *aisettings.Service {
|
||||
@@ -237,7 +262,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||
}
|
||||
}
|
||||
@@ -246,7 +271,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||
}
|
||||
}
|
||||
@@ -255,7 +280,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted nil photo files")
|
||||
}
|
||||
}
|
||||
@@ -264,7 +289,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
@@ -273,7 +298,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil dinners service")
|
||||
}
|
||||
}
|
||||
@@ -282,7 +307,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil suggestions service")
|
||||
}
|
||||
}
|
||||
@@ -291,16 +316,25 @@ func TestNewRouterRequiresPlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil plan service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresGoogle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil Google Calendar service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresJobRunner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil job runner")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), svc, newTestPlan(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), svc, newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -106,6 +106,10 @@ ON CONFLICT (id) DO UPDATE SET
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteIntegrationsGoogle :exec
|
||||
DELETE FROM integrations_google
|
||||
WHERE id = 1;
|
||||
|
||||
-- name: GetChatSettings :one
|
||||
-- Returns sql.ErrNoRows until the row exists.
|
||||
SELECT * FROM chat_settings
|
||||
|
||||
@@ -20,7 +20,7 @@ export type QuestLogSheetProps = {
|
||||
|
||||
// details is a Quest-log dinner's "difficulty · minutes" line, or null when
|
||||
// neither is known.
|
||||
function details(dinner: QuestLogDinner): string | null {
|
||||
export function details(dinner: QuestLogDinner): string | null {
|
||||
const parts = [
|
||||
dinner.difficulty !== null && DIFFICULTY_LABEL[dinner.difficulty],
|
||||
dinner.minutes_total !== null && `${dinner.minutes_total} min`,
|
||||
|
||||
@@ -51,8 +51,9 @@ export const BATCH_JOB_KIND = 'suggestion_batch'
|
||||
export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number }
|
||||
|
||||
// QuestLogDinner is a dinner in the Quest log, as the Quest log sheet lists
|
||||
// it: photo, name, difficulty and minutes.
|
||||
export type QuestLogDinner = Pick<Regular, 'id' | 'name' | 'photo_id' | 'difficulty' | 'minutes_total'>
|
||||
// it: photo, name, difficulty and minutes, with the method a swap carries
|
||||
// onto the plan.
|
||||
export type QuestLogDinner = Pick<Regular, 'id' | 'name' | 'photo_id' | 'difficulty' | 'method' | 'minutes_total'>
|
||||
|
||||
// DECISION_STATUS is the status a suggestion has once each choice is saved.
|
||||
const DECISION_STATUS: Record<Choice, string> = { try: 'accepted', pass: 'declined' }
|
||||
@@ -127,8 +128,8 @@ export function useQuestLog() {
|
||||
const body = await apiFetch<unknown>(QUEST_LOG_PATH)
|
||||
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
|
||||
return body.dinners.map((dinner) => {
|
||||
const { id, name, photo_id, difficulty, minutes_total } = parseDinner(where, dinner)
|
||||
return { id, name, photo_id, difficulty, minutes_total }
|
||||
const { id, name, photo_id, difficulty, method, minutes_total } = parseDinner(where, dinner)
|
||||
return { id, name, photo_id, difficulty, method, minutes_total }
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -250,13 +251,14 @@ export function useDecide() {
|
||||
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) =>
|
||||
dinners === undefined || dinners.some((d) => d.id === quest.dinner_id)
|
||||
? dinners
|
||||
: // The deck does not say the minutes; the Quest log read after the
|
||||
// decision does.
|
||||
: // The deck does not say the method or minutes; the Quest log
|
||||
// read after the decision does.
|
||||
dinners.concat({
|
||||
id: quest.dinner_id,
|
||||
name: quest.name,
|
||||
photo_id: quest.photo_id,
|
||||
difficulty: quest.difficulty,
|
||||
method: null,
|
||||
minutes_total: null,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,13 +1,40 @@
|
||||
/* Plan · Draft board (mock 1f). */
|
||||
/* Plan · Draft board (mock 1f) and plan manager (mocks 2c, 2d). */
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.board {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.mobilePage {
|
||||
max-width: 440px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* The grid fills the screen below the header: the main area's 32 px top and
|
||||
bottom padding (AppShell) is taken back off the viewport height. */
|
||||
.tabletPage .board {
|
||||
min-height: calc(100dvh - 64px);
|
||||
}
|
||||
|
||||
/* Desktop: the board beside the 290 px right panel, which runs the full
|
||||
height of the screen to its right edge over the main area's padding. */
|
||||
.desktopPage {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 290px;
|
||||
min-height: 100dvh;
|
||||
margin: -32px -36px -32px 0;
|
||||
}
|
||||
|
||||
.desktopPage .board {
|
||||
padding: 32px 36px 32px 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -95,22 +122,38 @@
|
||||
.tile {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
min-height: 86px;
|
||||
padding: 12px;
|
||||
border-radius: 22px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* The whole tile is the button that opens the swap sheet. */
|
||||
.tileButton {
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* A Quest-log pick. */
|
||||
.newTile {
|
||||
background: var(--color-accent-2-300);
|
||||
}
|
||||
|
||||
.dayKicker {
|
||||
display: block;
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 10px;
|
||||
@@ -125,6 +168,7 @@
|
||||
}
|
||||
|
||||
.dinner {
|
||||
display: block;
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 15px;
|
||||
@@ -134,6 +178,7 @@
|
||||
}
|
||||
|
||||
.startBy {
|
||||
display: block;
|
||||
margin: 4px 0 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
@@ -145,6 +190,22 @@
|
||||
color: var(--color-accent-2-900);
|
||||
}
|
||||
|
||||
/* The day being swapped, while the swap sheet is open: the page background
|
||||
ringed in grape, its name muted. */
|
||||
.swapping {
|
||||
background: var(--color-bg);
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent);
|
||||
}
|
||||
|
||||
.swapping .dayKicker {
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.swapping .dinner,
|
||||
.swapping .startBy {
|
||||
color: var(--color-neutral-600);
|
||||
}
|
||||
|
||||
.rebuildCell {
|
||||
display: flex;
|
||||
}
|
||||
@@ -168,10 +229,15 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rebuild:hover {
|
||||
.rebuild:hover:not(:disabled) {
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.rebuild:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -192,9 +258,10 @@
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* "Looks good — push to Calendar", unavailable until Feature 16: it keeps
|
||||
focus, so hovering, tapping or focusing it shows the tooltip. */
|
||||
.push {
|
||||
/* An action with nothing behind it yet (Push to Calendar until Feature 16,
|
||||
the shopping list until Feature 17): it keeps focus, so hovering, tapping
|
||||
or focusing it shows the tooltip. */
|
||||
.unavailable {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -204,18 +271,24 @@
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.pushButton[aria-disabled='true'] {
|
||||
.unavailable [aria-disabled='true'] {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Unavailable, it neither darkens nor presses. */
|
||||
.push .pushButton[aria-disabled='true']:hover,
|
||||
.push .pushButton[aria-disabled='true']:active {
|
||||
.unavailable .primary[aria-disabled='true']:hover,
|
||||
.unavailable .primary[aria-disabled='true']:active {
|
||||
background: var(--color-accent);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.unavailable .outline[aria-disabled='true']:hover,
|
||||
.unavailable .outline[aria-disabled='true']:active {
|
||||
background: transparent;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.hint {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
@@ -236,7 +309,281 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.push:hover .hint,
|
||||
.pushButton:focus + .hint {
|
||||
/* In the plan manager's header the tooltip opens below, at the action's
|
||||
right edge. */
|
||||
.below .hint {
|
||||
top: calc(100% + 8px);
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
right: 0;
|
||||
transform: none;
|
||||
width: 240px;
|
||||
max-width: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.unavailable:hover .hint,
|
||||
.unavailable [aria-disabled='true']:focus + .hint {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* The plan manager's header: kicker and title beside the week buttons, over
|
||||
the sync status and the actions. */
|
||||
.wideHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.titleBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.wideTitle {
|
||||
margin: 4px 0 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 40px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.tabletPage .wideTitle {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.wideHeader .kicker {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wideHeader .weekNav {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.thisWeek {
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.thisWeek:hover {
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* The status keeps to one line; the actions wrap under it when the row is
|
||||
too narrow for both. */
|
||||
.status {
|
||||
flex: 1 0 auto;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.synced {
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action {
|
||||
min-height: 44px;
|
||||
padding: 0 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The week: seven columns, each a day label over its card. A column is
|
||||
never narrower than 112 px, which holds a dinner's words whole and the
|
||||
tag beside the swap icon; where seven of those do not fit (tablet's
|
||||
narrow end, desktop beside the panel) the week scrolls sideways. Its 8 px
|
||||
padding, taken back by the margin, keeps the swap icons' hit boxes and
|
||||
focus rings inside the scroll area. */
|
||||
.week {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(112px, 1fr));
|
||||
gap: 12px;
|
||||
margin: -8px;
|
||||
padding: 8px;
|
||||
overflow-x: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.column {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dayLabel {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.dayName {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.dayNumber {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
padding: 12px;
|
||||
border-radius: 26px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tablet .card {
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
/* A Side quest's card. */
|
||||
.sideQuestCard {
|
||||
background: var(--color-accent-2-200);
|
||||
}
|
||||
|
||||
.cardPhoto {
|
||||
width: 100%;
|
||||
height: 110px;
|
||||
}
|
||||
|
||||
.tablet .cardPhoto {
|
||||
height: 96px;
|
||||
}
|
||||
|
||||
.cardName {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 17px;
|
||||
line-height: 1.15;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.tablet .cardName {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.cardMinutes,
|
||||
.cardStartBy {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
.cardStartBy {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sideQuestCard .cardMinutes,
|
||||
.sideQuestCard .cardStartBy {
|
||||
color: var(--color-accent-2-900);
|
||||
}
|
||||
|
||||
.cardFooter {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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. */
|
||||
.cardFooter .swapButton {
|
||||
margin: -14px;
|
||||
}
|
||||
|
||||
.cardFooter .cardTag {
|
||||
margin-right: auto;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.swapButton > span {
|
||||
background: transparent;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.skeletonCard {
|
||||
height: 300px;
|
||||
border-radius: 26px;
|
||||
}
|
||||
|
||||
+1012
-7
File diff suppressed because it is too large
Load Diff
+367
-61
@@ -1,18 +1,47 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
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'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { Button, type ButtonVariant } from '../ui/Button.tsx'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import { DRAFT_JOB_KIND, parseDraftResult, planKey, usePlan, useStartDraft, type DraftResult, type PlanDay, type PlanStatus } from './api.ts'
|
||||
import { addDays, defaultWeek, localNow, weekdayKicker, weekLabel } from './week.ts'
|
||||
import {
|
||||
DRAFT_JOB_KIND,
|
||||
parseDraftResult,
|
||||
planKey,
|
||||
usePlan,
|
||||
useStartDraft,
|
||||
useSwapDay,
|
||||
type DraftResult,
|
||||
type Plan,
|
||||
type PlanDay,
|
||||
type PlanDinner,
|
||||
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 {
|
||||
addDays,
|
||||
dayOfMonth,
|
||||
defaultWeek,
|
||||
localNow,
|
||||
weekdayKicker,
|
||||
weekdayName,
|
||||
weekLabel,
|
||||
weekStart as weekOf,
|
||||
} from './week.ts'
|
||||
import styles from './PlanPage.module.css'
|
||||
|
||||
// statusTags is the tag beside the week's title for each plan status.
|
||||
@@ -29,6 +58,7 @@ 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.',
|
||||
}
|
||||
|
||||
@@ -36,6 +66,48 @@ const draftFailures: Record<string, string> = {
|
||||
// TODO(Feature 16): connect Push to Calendar once Google Calendar is.
|
||||
const PUSH_HINT = 'Push to Calendar arrives once Google Calendar can be connected.'
|
||||
|
||||
// SHOPPING_HINT is why the shopping list is unavailable.
|
||||
// TODO(Feature 17): open the Supply run, with its count, once it is built.
|
||||
const SHOPPING_HINT = 'The shopping list arrives once the Supply run is built.'
|
||||
|
||||
// syncStatuses is the plan manager's line on whether the plan is on Calendar.
|
||||
const syncStatuses: Record<PlanStatus, string> = {
|
||||
draft: 'Not pushed to Google Calendar yet',
|
||||
synced: 'Synced to Google Calendar',
|
||||
changed: 'Changes not pushed to Google Calendar',
|
||||
}
|
||||
|
||||
// duration is minutes as the plan manager's cards say it: "45 min", "6 hr",
|
||||
// "1 hr 30 min", with no-break spaces so a narrow card wraps the line
|
||||
// between the method and the duration, never inside the duration.
|
||||
export function duration(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const rest = minutes % 60
|
||||
if (hours === 0) return `${rest}\u00a0min`
|
||||
return rest === 0 ? `${hours}\u00a0hr` : `${hours}\u00a0hr\u00a0${rest}\u00a0min`
|
||||
}
|
||||
|
||||
// cooking is a card's "method · duration" line, "Crock pot · 6 hr", with
|
||||
// whichever of the two is known, or null when neither is.
|
||||
export function cooking(dinner: PlanDinner): string | null {
|
||||
const parts = [dinner.method && methodLabels[dinner.method], dinner.minutes !== null && duration(dinner.minutes)].filter(
|
||||
(part): part is string => typeof part === 'string',
|
||||
)
|
||||
return parts.length === 0 ? null : parts.join(' · ')
|
||||
}
|
||||
|
||||
// ago is how long before now the instant at was: "just now", "2 min ago",
|
||||
// "3 hr ago", "2 days ago".
|
||||
export function ago(at: Date, now: Date): string {
|
||||
const minutes = Math.floor((now.getTime() - at.getTime()) / 60_000)
|
||||
if (minutes < 1) return 'just now'
|
||||
if (minutes < 60) return `${minutes} min ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours} hr ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return days === 1 ? '1 day ago' : `${days} days ago`
|
||||
}
|
||||
|
||||
// pushedToCalendar says whether drafting over a plan of this status needs
|
||||
// confirm: it was pushed to Calendar.
|
||||
function pushedToCalendar(status: PlanStatus): boolean {
|
||||
@@ -44,16 +116,66 @@ function pushedToCalendar(status: PlanStatus): boolean {
|
||||
|
||||
// DayTile is one day of the Draft board: a Regular on the surface, a
|
||||
// Quest-log pick in teal with its "NEW" kicker, and a crock-pot dinner's
|
||||
// start-by time in the household's zone.
|
||||
function DayTile({ day, timezone }: { day: PlanDay; timezone: string }) {
|
||||
// start-by time in the household's zone. Tapping it opens the swap sheet;
|
||||
// while that is open the tile is swapping: on the page background, ringed in
|
||||
// grape, its name muted.
|
||||
function DayTile({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) {
|
||||
const kicker = weekdayKicker(day.date)
|
||||
const classes = [styles.tile, day.is_new && styles.newTile, swapping && styles.swapping].filter(Boolean).join(' ')
|
||||
return (
|
||||
<li className={[styles.tile, day.is_new && styles.newTile].filter(Boolean).join(' ')}>
|
||||
<p className={styles.dayKicker}>{day.is_new ? `${kicker} · NEW` : kicker}</p>
|
||||
<div>
|
||||
<p className={styles.dinner}>{day.dinner.name}</p>
|
||||
<li className={classes}>
|
||||
<button type="button" className={styles.tileButton} onClick={onSwap}>
|
||||
<span className={styles.dayKicker}>{swapping ? `${kicker} · SWAPPING…` : day.is_new ? `${kicker} · NEW` : kicker}</span>
|
||||
<span>
|
||||
<span className={styles.dinner}>{day.dinner.name}</span>
|
||||
{day.start_by !== null && (
|
||||
<span className={styles.startBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// DayColumn is one day of the plan manager's 7-column grid: its day label
|
||||
// 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.
|
||||
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(' ')
|
||||
return (
|
||||
<li className={styles.column} aria-label={weekdayName(day.date)} {...{ [DROP_DATE]: 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>
|
||||
</p>
|
||||
<div className={classes}>
|
||||
{/* The card's name already says what the photo shows. */}
|
||||
<span aria-hidden="true">
|
||||
<Photo id={dinner.photo_id} name={dinner.name} radius="18px" sizes="160px" className={styles.cardPhoto} />
|
||||
</span>
|
||||
<p className={styles.cardName}>{dinner.name}</p>
|
||||
{time !== null && <p className={styles.cardMinutes}>{time}</p>}
|
||||
{day.start_by !== null && (
|
||||
<p className={styles.startBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</p>
|
||||
<p className={styles.cardStartBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</p>
|
||||
)}
|
||||
<div className={styles.cardFooter}>
|
||||
<Tag tone={day.is_new ? 'grape' : 'neutral'} className={styles.cardTag}>{day.is_new ? 'Side quest' : 'Regular'}</Tag>
|
||||
<IconButton label={`Swap ${weekdayName(day.date)}`} className={styles.swapButton} onClick={onSwap}>
|
||||
<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>
|
||||
@@ -61,13 +183,14 @@ function DayTile({ day, timezone }: { day: PlanDay; timezone: string }) {
|
||||
}
|
||||
|
||||
// SkeletonTiles stands in for the seven days while the plan loads or the AI
|
||||
// drafts it.
|
||||
function SkeletonTiles({ label, children }: { label: string; children?: string }) {
|
||||
// drafts it, as two columns of tiles on mobile and seven columns wider.
|
||||
function SkeletonTiles({ label, layout, children }: { label: string; layout: Breakpoint; children?: string }) {
|
||||
const wide = layout !== 'mobile'
|
||||
return (
|
||||
<section aria-busy="true" aria-label={label} className={styles.loading}>
|
||||
<div className={styles.grid}>
|
||||
<div className={wide ? `${styles.week} ${styles[layout]}` : styles.grid}>
|
||||
{Array.from({ length: 7 }, (_, i) => (
|
||||
<Skeleton key={i} data-testid="skeleton-tile" className={styles.skeletonTile} />
|
||||
<Skeleton key={i} data-testid="skeleton-tile" className={wide ? styles.skeletonCard : styles.skeletonTile} />
|
||||
))}
|
||||
</div>
|
||||
{children !== undefined && <p className={styles.drafting}>{children}</p>}
|
||||
@@ -80,13 +203,30 @@ function SkeletonTiles({ label, children }: { label: string; children?: string }
|
||||
// starts the draft_week job and shows skeleton tiles until the job ends and
|
||||
// the plan is read again. Rebuilding a plan pushed to Calendar asks first,
|
||||
// and sends confirm. A failed draft is shown with Retry; no plan is made up.
|
||||
function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezone: string; onWeek: (weekStart: string) => void }) {
|
||||
//
|
||||
// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week and the
|
||||
// unavailable Shopping list and Push to Calendar are actions in the header,
|
||||
// over the plan's sync status and the 7-column grid.
|
||||
function WeekBoard({
|
||||
weekStart,
|
||||
timezone,
|
||||
layout,
|
||||
onWeek,
|
||||
}: {
|
||||
weekStart: string
|
||||
timezone: string
|
||||
layout: Breakpoint
|
||||
onWeek: (weekStart: string) => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const plan = usePlan(weekStart)
|
||||
const startDraft = useStartDraft()
|
||||
const [jobId, setJobId] = useState<string | null>(null)
|
||||
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const swap = useSwapDay(weekStart)
|
||||
// The date of the day being swapped, while the swap sheet is open.
|
||||
const [swappingDate, setSwappingDate] = useState<string | null>(null)
|
||||
const label = weekLabel(weekStart)
|
||||
|
||||
// The draft's result, once the job has succeeded, or why it cannot be read.
|
||||
@@ -101,12 +241,14 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
||||
|
||||
// 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.
|
||||
// plan changed meanwhile. The Quest log is read again too, as after a
|
||||
// swap: the draft may have planned some of it.
|
||||
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)
|
||||
})
|
||||
@@ -143,8 +285,10 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
||||
const { mutate } = startDraft
|
||||
function start(confirm: boolean) {
|
||||
setConfirming(false)
|
||||
// A draft asked for again leaves the last one's failure behind.
|
||||
// A draft asked for again leaves the last one's failure, and a failed
|
||||
// swap's, behind.
|
||||
setJobId(null)
|
||||
swap.dismiss()
|
||||
mutate(
|
||||
{ weekStart, confirm },
|
||||
{
|
||||
@@ -155,12 +299,19 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
||||
)
|
||||
}
|
||||
// rebuild drafts the week, asking first over a plan pushed to Calendar.
|
||||
// It waits for a swap of the week still pending, which may yet fail: the
|
||||
// Rebuild week tile is disabled meanwhile.
|
||||
function rebuild() {
|
||||
if (swap.pending) return
|
||||
if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true)
|
||||
else start(false)
|
||||
}
|
||||
|
||||
const failure = startDraft.isError ? (
|
||||
const failure = swap.failed !== null ? (
|
||||
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
||||
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
||||
</ErrorBanner>
|
||||
) : startDraft.isError ? (
|
||||
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
|
||||
) : result.error !== null ? (
|
||||
<ErrorBanner onRetry={rebuild}>Couldn't read the draft. {result.error.message}</ErrorBanner>
|
||||
@@ -174,16 +325,40 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
||||
<ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner>
|
||||
)
|
||||
|
||||
// swapIn sends the swap chosen in the sheet and closes it.
|
||||
function swapIn(chosen: Swap) {
|
||||
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.
|
||||
function openSwap(date: string) {
|
||||
if (swap.pending) return
|
||||
swap.dismiss()
|
||||
setSwappingDate(date)
|
||||
}
|
||||
|
||||
let body
|
||||
if (plan.isError) {
|
||||
body = <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load this week's plan. {plan.error.message}</ErrorBanner>
|
||||
} else if (drafting) {
|
||||
body = <SkeletonTiles label="Drafting this week">Drafting your week…</SkeletonTiles>
|
||||
body = (
|
||||
<SkeletonTiles label="Drafting this week" layout={layout}>
|
||||
Drafting your week…
|
||||
</SkeletonTiles>
|
||||
)
|
||||
} else if (resultError !== null) {
|
||||
// An unread draft shows no board: the plan read is not the draft.
|
||||
body = null
|
||||
} else if (plan.isPending) {
|
||||
body = <SkeletonTiles label="Plan loading" />
|
||||
body = <SkeletonTiles label="Plan loading" layout={layout} />
|
||||
} else if (plan.data === null) {
|
||||
body = (
|
||||
<EmptyState title="Nothing planned yet" action={{ label: 'Draft this week', onClick: rebuild }}>
|
||||
@@ -192,35 +367,109 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
||||
)
|
||||
} else {
|
||||
const { ai_note, days } = plan.data
|
||||
body = (
|
||||
<>
|
||||
{ai_note !== null && (
|
||||
<div className={styles.assistant}>
|
||||
<span className={styles.avatar} aria-hidden="true">
|
||||
Q
|
||||
</span>
|
||||
<p className={styles.bubble}>{ai_note}</p>
|
||||
</div>
|
||||
)}
|
||||
<ul className={styles.grid} aria-label="Dinners this week">
|
||||
{days.map((day) => (
|
||||
<DayTile key={day.date} day={day} timezone={timezone} />
|
||||
))}
|
||||
<li className={styles.rebuildCell}>
|
||||
<button type="button" className={styles.rebuild} onClick={rebuild}>
|
||||
<Icon icon={Plus} size="inline" />
|
||||
Rebuild week
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<PushToCalendar />
|
||||
</>
|
||||
const swappingDay = days.find((day) => day.date === swappingDate)
|
||||
const sheet = swappingDay !== undefined && (
|
||||
<SwapSheet
|
||||
day={swappingDay}
|
||||
planned={new Set(days.map((day) => day.dinner.id))}
|
||||
onSwap={swapIn}
|
||||
onClose={() => setSwappingDate(null)}
|
||||
/>
|
||||
)
|
||||
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
|
||||
key={day.date}
|
||||
day={day}
|
||||
timezone={timezone}
|
||||
swapping={day.date === swappingDate}
|
||||
onSwap={() => openSwap(day.date)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{sheet}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{ai_note !== null && (
|
||||
<div className={styles.assistant}>
|
||||
<span className={styles.avatar} aria-hidden="true">
|
||||
Q
|
||||
</span>
|
||||
<p className={styles.bubble}>{ai_note}</p>
|
||||
</div>
|
||||
)}
|
||||
<ul className={styles.grid} aria-label="Dinners this week">
|
||||
{days.map((day) => (
|
||||
<DayTile
|
||||
key={day.date}
|
||||
day={day}
|
||||
timezone={timezone}
|
||||
swapping={day.date === swappingDate}
|
||||
onSwap={() => openSwap(day.date)}
|
||||
/>
|
||||
))}
|
||||
<li className={styles.rebuildCell}>
|
||||
<button type="button" className={styles.rebuild} onClick={rebuild} disabled={swap.pending}>
|
||||
<Icon icon={Plus} size="inline" />
|
||||
Rebuild week
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<Unavailable hint={PUSH_HINT} className={styles.pushButton}>
|
||||
Looks good — push to Calendar
|
||||
</Unavailable>
|
||||
{sheet}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const tag = plan.data ? statusTags[plan.data.status] : null
|
||||
return (
|
||||
<>
|
||||
const header =
|
||||
layout !== 'mobile' ? (
|
||||
<header className={styles.wideHeader}>
|
||||
<div className={styles.titleBar}>
|
||||
<div className={styles.heading}>
|
||||
<p className={styles.kicker}>{label.week} · Plan</p>
|
||||
<h1 className={styles.wideTitle}>{label.dates}</h1>
|
||||
</div>
|
||||
<div className={styles.weekNav}>
|
||||
<IconButton label="Previous week" onClick={() => onWeek(addDays(weekStart, -7))}>
|
||||
<Icon icon={ChevronLeft} size="inline" />
|
||||
</IconButton>
|
||||
{layout === 'desktop' && (
|
||||
<button type="button" className={styles.thisWeek} onClick={() => onWeek(weekOf(localNow(timezone, new Date()).date))}>
|
||||
This week
|
||||
</button>
|
||||
)}
|
||||
<IconButton label="Next week" onClick={() => onWeek(addDays(weekStart, 7))}>
|
||||
<Icon icon={ChevronRight} size="inline" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.actionBar}>
|
||||
{plan.data ? <SyncStatus plan={plan.data} /> : <span className={styles.status} />}
|
||||
<div className={styles.actions}>
|
||||
<Unavailable hint={SHOPPING_HINT} variant="outline" below className={styles.action}>
|
||||
<Icon icon={ShoppingCart} size="inline" />
|
||||
Shopping list
|
||||
</Unavailable>
|
||||
<Button variant="outline" className={styles.action} onClick={rebuild} disabled={swap.pending || drafting || !plan.isSuccess}>
|
||||
<Icon icon={RefreshCw} size="inline" />
|
||||
Rebuild week
|
||||
</Button>
|
||||
<Unavailable hint={PUSH_HINT} below className={styles.action}>
|
||||
<Icon icon={CalendarDays} size="inline" />
|
||||
Push to Calendar
|
||||
</Unavailable>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
) : (
|
||||
<header className={styles.header}>
|
||||
<div className={styles.heading}>
|
||||
<p className={styles.kicker}>{label.week}</p>
|
||||
@@ -238,6 +487,10 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
||||
</IconButton>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
{body}
|
||||
{failure}
|
||||
<Confirm
|
||||
@@ -254,27 +507,69 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
||||
)
|
||||
}
|
||||
|
||||
// PushToCalendar is "Looks good — push to Calendar", unavailable until
|
||||
// Google Calendar can be connected: it stays focusable, so a tap or focus
|
||||
// shows the tooltip saying why.
|
||||
function PushToCalendar() {
|
||||
// SyncStatus is the plan manager's status line: whether the plan is on
|
||||
// Google Calendar and, once synced, how long ago it was pushed, kept current
|
||||
// minute by minute.
|
||||
function SyncStatus({ plan }: { plan: Plan }) {
|
||||
const { status, calendar_synced_at: syncedAt } = plan
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(new Date()), 60_000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
return (
|
||||
<p className={[styles.status, status === 'synced' && styles.synced].filter(Boolean).join(' ')}>
|
||||
{status === 'synced' && <Icon icon={Check} size="inline" />}
|
||||
{syncStatuses[status]}
|
||||
{status === 'synced' && syncedAt !== null && ` ${ago(syncedAt, now)}`}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// Unavailable is an action with nothing behind it yet (Push to Calendar, the
|
||||
// shopping list): it stays focusable, so a tap or focus shows the tooltip
|
||||
// saying why, above it or, at the top of the page, below.
|
||||
function Unavailable({
|
||||
hint,
|
||||
variant = 'primary',
|
||||
below = false,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
hint: string
|
||||
variant?: ButtonVariant
|
||||
below?: boolean
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const hintId = useId()
|
||||
return (
|
||||
<div className={styles.push}>
|
||||
<Button className={styles.pushButton} aria-disabled="true" aria-describedby={hintId}>
|
||||
Looks good — push to Calendar
|
||||
<div className={[styles.unavailable, below && styles.below].filter(Boolean).join(' ')}>
|
||||
<Button
|
||||
variant={variant}
|
||||
className={[className, styles[variant]].filter(Boolean).join(' ')}
|
||||
aria-disabled="true"
|
||||
aria-describedby={hintId}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
<span id={hintId} role="tooltip" className={styles.hint}>
|
||||
{PUSH_HINT}
|
||||
{hint}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// PlanPage is mock 1f, the mobile Draft board, on the week chosen with the
|
||||
// previous / next week buttons. It opens on next week once this week's
|
||||
// plan_draft time has come in the household's zone, otherwise on this week.
|
||||
// PlanPage is the Draft board on the week chosen with the previous / next
|
||||
// week buttons: mock 1f on mobile; from tablet up the plan manager, with the
|
||||
// Quest log as a strip under the grid on tablet (2c) and the Quest log and
|
||||
// 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.
|
||||
export function PlanPage() {
|
||||
const layout = useBreakpoint()
|
||||
const settings = useHouseholdSettings()
|
||||
const [week, setWeek] = useState<string | null>(null)
|
||||
if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date()))
|
||||
@@ -285,9 +580,20 @@ export function PlanPage() {
|
||||
<ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
|
||||
)
|
||||
} else if (week === null || !settings.data) {
|
||||
content = <SkeletonTiles label="Plan loading" />
|
||||
content = <SkeletonTiles label="Plan loading" layout={layout} />
|
||||
} else {
|
||||
content = <WeekBoard key={week} weekStart={week} timezone={settings.data.timezone} onWeek={setWeek} />
|
||||
content = <WeekBoard key={week} weekStart={week} timezone={settings.data.timezone} layout={layout} onWeek={setWeek} />
|
||||
}
|
||||
return <div className={styles.page}>{content}</div>
|
||||
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>
|
||||
</PlanDragProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/* Desktop's right panel (mock 2d) and tablet's Quest-log strip (mock 2c). */
|
||||
.panel {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 32px 22px;
|
||||
border-left: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
.rows {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.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
|
||||
"dish photo" caption does not fit, so only its stripes show. */
|
||||
.rowPhoto {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.rowText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.rowName {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.rowMeta {
|
||||
font-size: 12px;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* Two columns of three 4 px dots. */
|
||||
.grip {
|
||||
flex: none;
|
||||
display: grid;
|
||||
grid-template-columns: 4px 4px;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-neutral-500);
|
||||
}
|
||||
|
||||
.more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 44px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-700);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.more:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
height: 68px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* Tablet: the Quest log as chips on a surface card under the grid. */
|
||||
.strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 18px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.stripHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stripTitle {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.stripHint {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.chips {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 14px 5px 5px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-bg);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* As the row's photo, at 28 px. */
|
||||
.chipPhoto {
|
||||
width: 28px;
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Compass } from 'lucide-react'
|
||||
import { useId, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Link } from 'react-router'
|
||||
import { useRegulars } from '../dinners/api.ts'
|
||||
import { useQuestLog, type QuestLogDinner } from '../discover/api.ts'
|
||||
import { DIFFICULTY_LABEL } from '../discover/DeckCard.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
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 styles from './PlanPanel.module.css'
|
||||
|
||||
// DRAG_HINT says how a dinner is swapped in from the panel or the strip.
|
||||
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
|
||||
// has not: its error with Retry, skeletons while loading, or that it is
|
||||
// empty.
|
||||
function ListBody({ source, list, children }: { source: Source; list: List; children: (dinners: QuestLogDinner[]) => ReactNode }) {
|
||||
if (list.isError) {
|
||||
return (
|
||||
<ErrorBanner onRetry={() => void list.refetch()}>
|
||||
Couldn't load your {sourceNames[source]}. {list.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
}
|
||||
if (list.isPending) {
|
||||
return (
|
||||
<div aria-busy="true" aria-label={`${sourceNames[source]} loading`} className={styles.loading}>
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className={styles.skeleton} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (list.data.length === 0) return <p className={styles.empty}>{emptyLists[source]}</p>
|
||||
return children(list.data)
|
||||
}
|
||||
|
||||
// Grip is the 6-dot drag handle of a panel row.
|
||||
function Grip() {
|
||||
return (
|
||||
<span className={styles.grip} aria-hidden="true">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<span key={i} className={styles.dot} />
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
// quests.
|
||||
export function PlanPanel() {
|
||||
const questLog = useQuestLog()
|
||||
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
|
||||
return dinners === undefined ? sourceNames[s] : `${sourceNames[s]} · ${dinners.length}`
|
||||
}
|
||||
|
||||
return (
|
||||
<aside aria-label="Quest log and Regulars" className={styles.panel}>
|
||||
<Segmented
|
||||
label="Dinners to swap in"
|
||||
options={[
|
||||
{ value: 'quest_log', label: label('quest_log') },
|
||||
{ value: 'regulars', label: label('regulars') },
|
||||
]}
|
||||
value={source}
|
||||
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}
|
||||
>
|
||||
{/* 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} />
|
||||
</span>
|
||||
<span className={styles.rowText}>
|
||||
<span className={styles.rowName}>{dinner.name}</span>
|
||||
{dinner.difficulty !== null && <span className={styles.rowMeta}>{DIFFICULTY_LABEL[dinner.difficulty]}</span>}
|
||||
</span>
|
||||
<Grip />
|
||||
</Draggable>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ListBody>
|
||||
<Link to="/discover" className={styles.more}>
|
||||
<Icon icon={Compass} size="inline" />
|
||||
Swipe more side quests
|
||||
</Link>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
// 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.
|
||||
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}>
|
||||
<div className={styles.stripHeader}>
|
||||
<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}>
|
||||
<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>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ListBody>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* The mobile swap sheet: the Quest log and Regulars of desktop's plan panel
|
||||
(mock 2d) as a bottom sheet. */
|
||||
.sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.replaces {
|
||||
margin: 2px 0 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.rows {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* The 48 px washed photo; Photo applies the washed filter. */
|
||||
.photo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
/* A dinner already this week: shown, muted, and not a button. */
|
||||
.already {
|
||||
color: var(--color-neutral-600);
|
||||
}
|
||||
|
||||
.skeletonRow {
|
||||
height: 72px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { useRegulars } from '../dinners/api.ts'
|
||||
import { useQuestLog, type QuestLogDinner } from '../discover/api.ts'
|
||||
import { details } from '../discover/QuestLogSheet.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { ListRow } from '../ui/ListRow.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
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 { weekdayName } from './week.ts'
|
||||
import styles from './SwapSheet.module.css'
|
||||
|
||||
// Source is a list a dinner is swapped in from.
|
||||
export type Source = 'quest_log' | 'regulars'
|
||||
|
||||
export const sourceNames: Record<Source, string> = { quest_log: 'Quest log', regulars: 'Regulars' }
|
||||
|
||||
export const emptyLists: Record<Source, string> = {
|
||||
quest_log: 'Your Quest log is empty — swipe right on a Side quest to add one.',
|
||||
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
|
||||
// The ids of the dinners already on the week's plan.
|
||||
planned: ReadonlySet<string>
|
||||
onSwap: (swap: Swap) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// SwapSheet is the mobile swap sheet: the Quest log and the Regulars, as
|
||||
// desktop's plan panel lists them, segmented "Quest log · N | Regulars · N",
|
||||
// each dinner with its 48 px photo, name, difficulty and minutes. Choosing a
|
||||
// dinner swaps it into the day; a dinner already this week can't be chosen.
|
||||
export function SwapSheet({ day, planned, onSwap, onClose }: SwapSheetProps) {
|
||||
const titleId = useId()
|
||||
const questLog = useQuestLog()
|
||||
const regulars = useRegulars()
|
||||
const [source, setSource] = useState<Source>('quest_log')
|
||||
const lists = { quest_log: questLog, regulars }
|
||||
const list = lists[source]
|
||||
|
||||
const label = (s: Source) => {
|
||||
const dinners = lists[s].data
|
||||
return dinners === undefined ? sourceNames[s] : `${sourceNames[s]} · ${dinners.length}`
|
||||
}
|
||||
|
||||
let body
|
||||
if (list.isError) {
|
||||
body = (
|
||||
<ErrorBanner onRetry={() => void list.refetch()}>
|
||||
Couldn't load your {sourceNames[source]}. {list.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
} else if (list.isPending) {
|
||||
body = (
|
||||
<div aria-busy="true" aria-label={`${sourceNames[source]} loading`} className={styles.rows}>
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className={styles.skeletonRow} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
} else if (list.data.length === 0) {
|
||||
body = <p className={styles.empty}>{emptyLists[source]}</p>
|
||||
} else {
|
||||
body = (
|
||||
<ul aria-label={sourceNames[source]} className={styles.rows}>
|
||||
{list.data.map((dinner: QuestLogDinner) => {
|
||||
const already = planned.has(dinner.id)
|
||||
return (
|
||||
<li key={dinner.id}>
|
||||
<ListRow
|
||||
className={already ? styles.already : undefined}
|
||||
leading={
|
||||
// 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-md)" sizes="48px" className={styles.photo} />
|
||||
</span>
|
||||
}
|
||||
title={dinner.name}
|
||||
meta={details(dinner)}
|
||||
// A dinner already this week is no button: it can't be chosen.
|
||||
onClick={
|
||||
already
|
||||
? undefined
|
||||
: () =>
|
||||
onSwap({ date: day.date, dinner: planDinner(dinner), fromQuestLog: source === 'quest_log' })
|
||||
}
|
||||
trailing={already && <Tag>Already this week</Tag>}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open onClose={onClose} aria-labelledby={titleId}>
|
||||
<div className={styles.sheet}>
|
||||
<div>
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
Swap {weekdayName(day.date)}
|
||||
</h2>
|
||||
<p className={styles.replaces}>Replaces {day.dinner.name}</p>
|
||||
</div>
|
||||
<Segmented
|
||||
label="Swap in from"
|
||||
options={[
|
||||
{ value: 'quest_log', label: label('quest_log') },
|
||||
{ value: 'regulars', label: label('regulars') },
|
||||
]}
|
||||
value={source}
|
||||
onValueChange={setSource}
|
||||
/>
|
||||
{body}
|
||||
</div>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { planKey, usePlan, useSwapDay, type Plan } from './api.ts'
|
||||
|
||||
const WEEK = '2026-10-05'
|
||||
const PLAN = `/api/v1/plans/${WEEK}`
|
||||
const MONDAY = `${PLAN}/days/2026-10-05`
|
||||
const FRIDAY = `${PLAN}/days/2026-10-09`
|
||||
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
|
||||
|
||||
// planBody is the plan of the week of Oct 5, dinners d0 to d6, with days
|
||||
// changed as given by index.
|
||||
function planBody(changes: Record<number, { id: string; name: string; is_new?: boolean; start_by?: string | null }> = {}) {
|
||||
return {
|
||||
id: 'plan-1',
|
||||
week_start: WEEK,
|
||||
status: 'draft',
|
||||
ai_note: null,
|
||||
calendar_synced_at: null as string | null,
|
||||
days: DAYS.map((date, i) => {
|
||||
const change = changes[i]
|
||||
return {
|
||||
date,
|
||||
dinner: { id: change?.id ?? `d${i}`, name: change?.name ?? `Dinner ${i}`, photo: null, minutes: null, method: null as string | null, origin: 'regular' },
|
||||
is_new: change?.is_new ?? false,
|
||||
start_by: change?.start_by ?? null,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// deferred is a reply the test sends when it chooses.
|
||||
function deferred() {
|
||||
let send!: (response: Response) => void
|
||||
const reply = new Promise<Response>((resolve) => (send = resolve))
|
||||
return { reply, send }
|
||||
}
|
||||
|
||||
// stubServer answers "METHOD path" requests from responders, with /me and
|
||||
// the Quest log; any other request fails the test.
|
||||
function stubServer(responders: Record<string, () => Response | Promise<Response>>) {
|
||||
const all: Record<string, () => Response | Promise<Response>> = {
|
||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||
'GET /api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }),
|
||||
...responders,
|
||||
}
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>((input, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(respond())
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// renderSwaps renders the week's plan and swaps over a query client.
|
||||
async function renderSwaps() {
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const queryClient: QueryClient = createQueryClient()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
const hook = renderHook(() => ({ plan: usePlan(WEEK), swap: useSwapDay(WEEK) }), { wrapper })
|
||||
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||
return { ...hook, queryClient, plan: () => queryClient.getQueryData<Plan>(planKey(WEEK))! }
|
||||
}
|
||||
|
||||
const ORZO = { date: '2026-10-09', dinner: { id: 'q1', name: 'Lemon orzo', photo_id: undefined, minutes: null, method: null }, fromQuestLog: true }
|
||||
const FISH = { date: '2026-10-05', dinner: { id: 'r1', name: 'Fish tacos', photo_id: undefined, minutes: null, method: null }, fromQuestLog: false }
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// renderPlan renders the week's plan read over a query client.
|
||||
async function renderPlan() {
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>{children}</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
const hook = renderHook(() => usePlan(WEEK), { wrapper })
|
||||
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
||||
return hook.result.current
|
||||
}
|
||||
|
||||
describe('usePlan', () => {
|
||||
it("reads each dinner's method and when the plan was pushed to Calendar", async () => {
|
||||
const body = planBody()
|
||||
body.days[2]!.dinner.method = 'crockpot'
|
||||
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, { ...body, status: 'synced', calendar_synced_at: '2026-10-07T15:58:00Z' }) })
|
||||
const plan = await renderPlan()
|
||||
|
||||
expect(plan.data?.calendar_synced_at).toEqual(new Date('2026-10-07T15:58:00Z'))
|
||||
expect(plan.data?.days.map((day) => day.dinner.method)).toEqual([null, null, 'crockpot', null, null, null, null])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a method it does not know', (body: ReturnType<typeof planBody>) => (body.days[0]!.dinner.method = 'microwave'), 'method "microwave"'],
|
||||
['no method', (body: ReturnType<typeof planBody>) => Reflect.deleteProperty(body.days[0]!.dinner, 'method'), 'method undefined'],
|
||||
['a sync time that is no instant', (body: ReturnType<typeof planBody>) => (body.calendar_synced_at = 'yesterday'), 'calendar_synced_at "yesterday"'],
|
||||
])('refuses a plan with %s', async (_, change, said) => {
|
||||
const body = planBody()
|
||||
change(body)
|
||||
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, body) })
|
||||
const plan = await renderPlan()
|
||||
|
||||
expect(plan.error?.message).toContain(said)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSwapDay', () => {
|
||||
// Friday's swap is sent first, then Monday's; the server answers Monday's
|
||||
// with both swapped, Friday's with only Friday.
|
||||
const fridayPlan = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })
|
||||
const bothPlan = planBody({ 0: { id: 'r1', name: 'Fish tacos' }, 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })
|
||||
|
||||
it.each([
|
||||
['the later swap answered first', ['monday', 'friday'] as const],
|
||||
['the earlier swap answered first', ['friday', 'monday'] as const],
|
||||
])('keeps the plan of the later of two swaps, %s', async (_, order) => {
|
||||
const replies = { friday: deferred(), monday: deferred() }
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
|
||||
[`PUT ${FRIDAY}`]: () => replies.friday.reply,
|
||||
[`PUT ${MONDAY}`]: () => replies.monday.reply,
|
||||
})
|
||||
const { result, plan, queryClient } = await renderSwaps()
|
||||
|
||||
act(() => result.current.swap.mutate(ORZO))
|
||||
act(() => result.current.swap.mutate(FISH))
|
||||
await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos'))
|
||||
|
||||
// Whichever reply settles, Monday stays swapped: Friday's plan, the
|
||||
// earlier one, never lands over it.
|
||||
for (const [settled, day] of order.entries()) {
|
||||
await act(async () => replies[day].send(jsonResponse(200, day === 'friday' ? fridayPlan : bothPlan)))
|
||||
await waitFor(() => expect(queryClient.isMutating()).toBe(1 - settled))
|
||||
expect(plan().days[0]!.dinner.name).toBe('Fish tacos')
|
||||
}
|
||||
expect(plan().days.map((day) => day.dinner.name)).toEqual(bothPlan.days.map((day) => day.dinner.name))
|
||||
})
|
||||
|
||||
it("does not take an earlier swap's failure for the week's", async () => {
|
||||
const friday = deferred()
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
|
||||
[`PUT ${FRIDAY}`]: () => friday.reply,
|
||||
[`PUT ${MONDAY}`]: () => jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } })),
|
||||
})
|
||||
const { result, plan } = await renderSwaps()
|
||||
|
||||
act(() => result.current.swap.mutate(ORZO))
|
||||
act(() => result.current.swap.mutate(FISH))
|
||||
await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos'))
|
||||
await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable')))
|
||||
|
||||
await waitFor(() => expect(result.current.swap.pending).toBe(false))
|
||||
expect(result.current.swap.failed).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a day a later plan read landed when the swap of it fails', async () => {
|
||||
const friday = deferred()
|
||||
// The read finds Friday already lemon orzo, starting by 2 pm.
|
||||
let read = planBody()
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, read),
|
||||
[`PUT ${FRIDAY}`]: () => friday.reply,
|
||||
})
|
||||
const { result, plan, queryClient } = await renderSwaps()
|
||||
|
||||
act(() => result.current.swap.mutate(ORZO))
|
||||
await waitFor(() => expect(plan().days[4]).toEqual({ date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: null }))
|
||||
read = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true, start_by: '2026-10-09T14:00:00-04:00' } })
|
||||
await act(() => result.current.plan.refetch())
|
||||
await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable')))
|
||||
|
||||
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
|
||||
expect(plan().days[4]).toEqual({ date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: '2026-10-09T14:00:00-04:00' })
|
||||
expect(result.current.swap.failed?.error.message).toBe('dinner_not_plannable message')
|
||||
})
|
||||
|
||||
it('refuses to dismiss a pending swap', async () => {
|
||||
const friday = deferred()
|
||||
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), [`PUT ${FRIDAY}`]: () => friday.reply })
|
||||
const { result } = await renderSwaps()
|
||||
|
||||
act(() => result.current.swap.mutate(ORZO))
|
||||
await waitFor(() => expect(result.current.swap.pending).toBe(true))
|
||||
expect(() => result.current.swap.dismiss()).toThrow('The week of 2026-10-05 has a swap pending, which cannot be dismissed.')
|
||||
await act(async () => friday.send(jsonResponse(200, planBody())))
|
||||
})
|
||||
})
|
||||
+150
-6
@@ -1,7 +1,8 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiError, useApi, type ApiRead } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { oneOf } from '../dinners/api.ts'
|
||||
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
|
||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
|
||||
export const PLANS_PATH = '/api/v1/plans'
|
||||
|
||||
@@ -21,24 +22,31 @@ export const PLAN_STATUSES = ['draft', 'synced', 'changed'] as const
|
||||
// synced plan makes it changed.
|
||||
export type PlanStatus = (typeof PLAN_STATUSES)[number]
|
||||
|
||||
// PlanDinner is a planned dinner: photo_id is its stored photo's id, from
|
||||
// photo, minutes its minutes from prep to table and method how it is
|
||||
// cooked, each when known.
|
||||
export type PlanDinner = { id: string; name: string; photo_id: string | undefined; minutes: number | null; method: Method | null }
|
||||
|
||||
// PlanDay is one day of a plan: its household-local date, the dinner
|
||||
// planned, whether that dinner is a Quest-log pick, and, for a crock-pot
|
||||
// dinner of known minutes, the instant it must start by.
|
||||
export type PlanDay = {
|
||||
date: string
|
||||
dinner: { id: string; name: string }
|
||||
dinner: PlanDinner
|
||||
is_new: boolean
|
||||
start_by: string | null
|
||||
}
|
||||
|
||||
// Plan is GET /api/v1/plans/{weekStart}: the week's seven days, Monday to
|
||||
// Sunday, and the AI's note on the draft, if any. cached says the service
|
||||
// worker answered from its cache: the server may have changed the plan since.
|
||||
// Sunday, the AI's note on the draft, if any, and when it was last pushed to
|
||||
// Google Calendar, if ever. cached says the service worker answered from its
|
||||
// cache: the server may have changed the plan since.
|
||||
export type Plan = {
|
||||
id: string
|
||||
week_start: string
|
||||
status: PlanStatus
|
||||
ai_note: string | null
|
||||
calendar_synced_at: Date | null
|
||||
days: PlanDay[]
|
||||
cached: boolean
|
||||
}
|
||||
@@ -56,12 +64,30 @@ function parseDay(where: string, body: unknown): PlanDay {
|
||||
!isRecord(body.dinner) ||
|
||||
typeof body.dinner.id !== 'string' ||
|
||||
typeof body.dinner.name !== 'string' ||
|
||||
(body.dinner.minutes !== null && !Number.isInteger(body.dinner.minutes)) ||
|
||||
typeof body.is_new !== 'boolean' ||
|
||||
(body.start_by !== null && typeof body.start_by !== 'string')
|
||||
) {
|
||||
throw new Error(`${where}: response has a day that is not a plan day`)
|
||||
}
|
||||
return { date: body.date, dinner: { id: body.dinner.id, name: body.dinner.name }, is_new: body.is_new, start_by: body.start_by }
|
||||
const dinner: PlanDinner = {
|
||||
id: body.dinner.id,
|
||||
name: body.dinner.name,
|
||||
photo_id: photoId(where, body.dinner.photo),
|
||||
minutes: body.dinner.minutes as number | null,
|
||||
method: body.dinner.method === null ? null : oneOf(where, 'method', METHODS, body.dinner.method),
|
||||
}
|
||||
return { date: body.date, dinner, is_new: body.is_new, start_by: body.start_by }
|
||||
}
|
||||
|
||||
// syncedAt reads calendar_synced_at, an RFC 3339 instant or null.
|
||||
function syncedAt(where: string, value: unknown): Date | null {
|
||||
if (value === null) return null
|
||||
const at = typeof value === 'string' ? new Date(value) : null
|
||||
if (at === null || Number.isNaN(at.getTime())) {
|
||||
throw new Error(`${where}: calendar_synced_at ${JSON.stringify(value)} is not an instant`)
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
|
||||
@@ -80,6 +106,7 @@ function parsePlan(where: string, weekStart: string, body: unknown, cached: bool
|
||||
week_start: weekStart,
|
||||
status: oneOf(where, 'status', PLAN_STATUSES, body.status),
|
||||
ai_note: body.ai_note,
|
||||
calendar_synced_at: syncedAt(where, body.calendar_synced_at),
|
||||
days: body.days.map((day) => parseDay(where, day)),
|
||||
cached,
|
||||
}
|
||||
@@ -130,3 +157,120 @@ export function useStartDraft() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Swap is one day's swap: the day, and the dinner chosen for it with whether
|
||||
// it was chosen from the Quest log.
|
||||
export type Swap = { date: string; dinner: PlanDinner; fromQuestLog: boolean }
|
||||
|
||||
// sameDay says whether two plan days are the same in every field.
|
||||
function sameDay(a: PlanDay, b: PlanDay): boolean {
|
||||
return (
|
||||
a.date === b.date &&
|
||||
a.dinner.id === b.dinner.id &&
|
||||
a.dinner.name === b.dinner.name &&
|
||||
a.dinner.photo_id === b.dinner.photo_id &&
|
||||
a.dinner.minutes === b.dinner.minutes &&
|
||||
a.dinner.method === b.dinner.method &&
|
||||
a.is_new === b.is_new &&
|
||||
a.start_by === b.start_by
|
||||
)
|
||||
}
|
||||
|
||||
// swapKey keys the swaps of the week starting on weekStart in the mutation
|
||||
// cache, where they outlive the Draft board that sent them.
|
||||
function swapKey(weekStart: string) {
|
||||
return ['plans', weekStart, 'swap'] as const
|
||||
}
|
||||
|
||||
// SwapDay is the swaps of one week, as the mutation cache holds them:
|
||||
// whether one is pending, and the failure of the latest, if it failed.
|
||||
export type SwapDay = {
|
||||
pending: boolean
|
||||
failed: { swap: Swap; error: Error } | null
|
||||
mutate: (chosen: Swap) => void
|
||||
dismiss: () => void
|
||||
}
|
||||
|
||||
// useSwapDay swaps the dinner of one day of the week starting on weekStart
|
||||
// (PUT /api/v1/plans/{weekStart}/days/{date}). The day shows the dinner at
|
||||
// once, NEW when it came from the Quest log, with no start-by time until the
|
||||
// server answers with the plan as swapped, which then replaces it. A refused
|
||||
// swap puts the day back as it was, unless it has changed again meanwhile.
|
||||
// However the swap ends, the Quest log is read again: the server leaves a
|
||||
// dinner on the plan out of it.
|
||||
//
|
||||
// The week's swaps live in the mutation cache, not in the component, so
|
||||
// leaving the week and coming back keeps them: pending and failed are read
|
||||
// from there. Only the latest swap of the week speaks for it: the plan an
|
||||
// earlier one is answered with is not written over a later one, and its
|
||||
// failure is not the week's. dismiss leaves settled swaps behind; it is
|
||||
// refused while one is pending, so a later swap is always in the cache for
|
||||
// an earlier, pending one to see.
|
||||
export function useSwapDay(weekStart: string): SwapDay {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
const key = planKey(weekStart)
|
||||
const mutationKey = swapKey(weekStart)
|
||||
const mutations = queryClient.getMutationCache()
|
||||
// The week's swaps, in the order sent.
|
||||
const swaps = useMutationState({
|
||||
filters: { mutationKey },
|
||||
select: (mutation) => ({ status: mutation.state.status, swap: mutation.state.variables as Swap, error: mutation.state.error }),
|
||||
})
|
||||
const latest = swaps.at(-1)
|
||||
|
||||
function mutate(chosen: Swap) {
|
||||
// isLatest says whether no swap of the week was sent after this one.
|
||||
const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId)
|
||||
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(queryClient, {
|
||||
mutationKey,
|
||||
// Kept until dismissed: a later swap must stay in the cache while an
|
||||
// earlier one is pending.
|
||||
gcTime: Infinity,
|
||||
mutationFn: async ({ date, dinner }) => {
|
||||
const path = `${planPath(weekStart)}/days/${encodeURIComponent(date)}`
|
||||
const body = await apiFetch<unknown>(path, { method: 'PUT', body: { dinner_id: dinner.id } })
|
||||
return parsePlan(`PUT ${path}`, weekStart, body, false)
|
||||
},
|
||||
onMutate: async ({ date, dinner, fromQuestLog }) => {
|
||||
await queryClient.cancelQueries({ queryKey: key })
|
||||
const plan = queryClient.getQueryData<Plan | null>(key)
|
||||
const previous = plan?.days.find((day) => day.date === date)
|
||||
if (!plan || previous === undefined) throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`)
|
||||
const swapped: PlanDay = { date, dinner, is_new: fromQuestLog, start_by: null }
|
||||
queryClient.setQueryData<Plan>(key, { ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) })
|
||||
return { previous, swapped }
|
||||
},
|
||||
onSuccess: (plan) => {
|
||||
if (isLatest()) queryClient.setQueryData<Plan>(key, plan)
|
||||
},
|
||||
// The day goes back only while it is still, field for field, the day
|
||||
// this swap wrote: a later swap or plan read that replaced it is left
|
||||
// alone.
|
||||
onError: (_error, _swap, context) => {
|
||||
if (context === undefined) return
|
||||
queryClient.setQueryData<Plan | null>(key, (plan) =>
|
||||
plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) },
|
||||
)
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
|
||||
})
|
||||
// A failure is the mutation's state, which failed reports.
|
||||
mutation.execute(chosen).catch(() => undefined)
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
const week = mutations.findAll({ mutationKey })
|
||||
if (week.some((mutation) => mutation.state.status === 'pending')) {
|
||||
throw new Error(`The week of ${weekStart} has a swap pending, which cannot be dismissed.`)
|
||||
}
|
||||
for (const mutation of week) mutations.remove(mutation)
|
||||
}
|
||||
|
||||
return {
|
||||
pending: swaps.some((swap) => swap.status === 'pending'),
|
||||
failed: latest?.status === 'error' ? { swap: latest.swap, error: latest.error! } : null,
|
||||
mutate,
|
||||
dismiss,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addDays, defaultWeek, localNow, weekdayKicker, weekLabel, weekStart } from './week.ts'
|
||||
import { addDays, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
|
||||
|
||||
// SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York.
|
||||
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
|
||||
@@ -31,6 +31,17 @@ describe('week helpers', () => {
|
||||
expect(weekdayKicker('2026-10-11')).toBe('SUN')
|
||||
})
|
||||
|
||||
it('names the day of a date in full', () => {
|
||||
expect(weekdayName('2026-10-09')).toBe('Friday')
|
||||
expect(weekdayName('2026-10-11')).toBe('Sunday')
|
||||
})
|
||||
|
||||
it('reads the day of the month of a date', () => {
|
||||
expect(dayOfMonth('2026-10-05')).toBe(5)
|
||||
expect(dayOfMonth('2026-10-31')).toBe(31)
|
||||
expect(() => dayOfMonth('2026-10-32')).toThrow('not a real date')
|
||||
})
|
||||
|
||||
it('refuses a week start that is not a Monday, and a date that is not real', () => {
|
||||
expect(() => weekLabel('2026-10-06')).toThrow('week start 2026-10-06 is not a Monday')
|
||||
expect(() => addDays('2026-02-30', 1)).toThrow('not a real date')
|
||||
|
||||
@@ -40,6 +40,18 @@ export function weekdayKicker(date: string): string {
|
||||
return WEEKDAYS[midnightUTC(date).getUTCDay()]!
|
||||
}
|
||||
|
||||
// dayOfMonth is date's day of the month: 1 to 31.
|
||||
export function dayOfMonth(date: string): number {
|
||||
return midnightUTC(date).getUTCDate()
|
||||
}
|
||||
|
||||
const weekday = new Intl.DateTimeFormat('en-US', { weekday: 'long', timeZone: 'UTC' })
|
||||
|
||||
// weekdayName is date's day in full: "Monday" to "Sunday".
|
||||
export function weekdayName(date: string): string {
|
||||
return weekday.format(midnightUTC(date))
|
||||
}
|
||||
|
||||
// monday reads start as midnight UTC, or throws when it is not a Monday.
|
||||
function monday(start: string): Date {
|
||||
const t = midnightUTC(start)
|
||||
|
||||
@@ -19,3 +19,8 @@
|
||||
background: var(--color-accent-2-200);
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
background: var(--color-neutral-100);
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
@@ -15,4 +15,9 @@ describe('Tag', () => {
|
||||
render(<Tag tone="teal">Synced</Tag>)
|
||||
expect(screen.getByText('Synced')).toHaveClass(styles.teal!)
|
||||
})
|
||||
|
||||
it('supports the neutral tone', () => {
|
||||
render(<Tag tone="neutral">Regular</Tag>)
|
||||
expect(screen.getByText('Regular')).toHaveClass(styles.neutral!)
|
||||
})
|
||||
})
|
||||
|
||||
+3
-2
@@ -1,13 +1,14 @@
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import styles from './Tag.module.css'
|
||||
|
||||
export type TagTone = 'grape' | 'teal'
|
||||
export type TagTone = 'grape' | 'teal' | 'neutral'
|
||||
|
||||
export type TagProps = ComponentPropsWithRef<'span'> & {
|
||||
tone?: TagTone
|
||||
}
|
||||
|
||||
// Static label such as "Draft", "Side quest" or "Synced". Not interactive.
|
||||
// Static label such as "Draft", "Side quest", "Synced" or "Regular". Not
|
||||
// interactive.
|
||||
export function Tag({ tone = 'grape', className, ...rest }: TagProps) {
|
||||
const classes = [styles.tag, styles[tone], className].filter(Boolean).join(' ')
|
||||
return <span className={classes} {...rest} />
|
||||
|
||||
Reference in New Issue
Block a user