Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 2cd2e5af32 Serialize Google connect and disconnect; consume connect state once
CI / ci (push) Successful in 3m32s
CI / ci (pull_request) Successful in 4m2s
A disconnect read the stored refresh token, revoked it at Google, then
deleted the row only if it still held that token. Google revokes the whole
grant, so a reconnect whose token was issued before that revoke was kept
as connected with a dead token. Connect's exchange-and-store and
disconnect's read-revoke-delete now hold one mutex across the Google
request and the database write, and the delete is unconditional (the
conditional delete and ErrChanged are gone: the lock is the one guard).

The connect cookie was single-use only in that the browser was asked to
drop it; a saved copy verified again until it expired. The service now
keeps each started connect's state in memory until the cookie expires and
the callback consumes it, so a replayed cookie is refused as bad state.

The fake Google now issues a distinct refresh token per exchange, revokes
the whole grant like Google, and can hold a token or revoke request so
tests can interleave a reconnect and a disconnect.

Maestro task 1e9d7850-3a09-4572-b8a1-cb18cd4774cb (MQ125-01, MQ125-02).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:46:35 -04:00
dev_agentandClaude Opus 5.5 59d0f57a47 Add Google Calendar OAuth connect and disconnect
CI / ci (push) Successful in 3m34s
CI / ci (pull_request) Successful in 3m27s
GET /api/v1/integrations/google/connect sends the browser to Google's
consent screen (calendar.events and calendar.calendarlist.readonly,
access_type=offline, prompt=consent, PKCE S256) with a state carried in a
signed __Host-mealquest_google flow cookie bound to the session.
GET /api/v1/integrations/google/callback checks the state and session,
exchanges the code, refuses a response without a refresh token
(reconnect_required) or without both scopes, seals the refresh token with
secretbox into integrations_google and sets connected_at.
DELETE /api/v1/integrations/google revokes the token at Google and clears
it; a revoke failure keeps the connection so it can be retried.

- internal/gcal: the connect service; Google endpoints are overridable and
  internal/gcal/gcaltest fakes the token and revoke endpoints.
- internal/auth: the login cookie becomes the exported FlowCookie both
  flows use (same wire format); CheckRedirect and ExchangeError are
  exported for reuse.
- internal/config: MEALQUEST_GOOGLE_CLIENT_ID and
  MEALQUEST_GOOGLE_CLIENT_SECRET_FILE, required at boot.
- DeleteIntegrationsGoogle query (sqlc regenerated); no migration, the
  integrations_google table already exists.
- deploy: compose mounts google_client_secret; .env.example and RUNBOOK
  document the Google OAuth client, its redirect URI and rotation.

Maestro task 1e9d7850-3a09-4572-b8a1-cb18cd4774cb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:17:20 -04:00
orchestrator-agent 5ac5982c1f Release corrections round 4: verified pre-deploy backup step and honest rotation-check documentation (#124)
CI / ci (push) Successful in 3m16s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 20:02:53 -04:00
orchestrator-agent 1862f450db Feature 14: Weekly Plan Builder (#121)
CI / ci (push) Successful in 3m51s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 16:53:23 -04:00
53 changed files with 5753 additions and 242 deletions
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+2 -2
View File
@@ -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
View File
@@ -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 "/".
+12
View File
@@ -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),
+47 -1
View File
@@ -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)
+1
View File
@@ -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
View File
@@ -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
+401
View File
@@ -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)}
}
+586
View File
@@ -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()
}
+6
View File
@@ -0,0 +1,6 @@
package gcal
import "time"
// SetClock replaces s's clock.
func SetClock(s *Service, now func() time.Time) { s.now = now }
+295
View File
@@ -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
View File
@@ -36,9 +36,9 @@ func (r draftResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// Regulars and the Quest log dinners no other current or later plan has,
// given the household context and last week's dinners, and the draft is
// stored as the week's plan if the plan is still as it was before
// draft_week ran. 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
View File
@@ -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},
+81 -7
View File
@@ -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, &notPlannable) }},
{"archived dinner", week, "2026-10-12", archived, func(err error) bool { return errors.As(err, &notPlannable) }},
{"day of another week", week, "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+102
View File
@@ -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")
}
}
}
+307
View File
@@ -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")
}
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
}
+4 -2
View File
@@ -107,8 +107,8 @@ func weekStartOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger
// writePlanError maps a plan.Service error to its API error: a week with
// no plan or an unknown dinner is 404, a date outside the week is 400, and
// drafting over a synced plan without confirm, a confirmed draft while an
// unconfirmed one runs, 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, &notPlannable):
writeError(w, r, logger, http.StatusConflict, "dinner_not_plannable", notPlannable.Error())
case errors.Is(err, plan.ErrDinnerAlreadyPlanned):
writeError(w, r, logger, http.StatusConflict, "dinner_already_planned", err.Error())
case errors.Is(err, plan.ErrConfirmRequired):
writeError(w, r, logger, http.StatusConflict, "confirm_required", err.Error())
case errors.Is(err, plan.ErrDraftRunning):
+2 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 -2
View File
@@ -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))
+43 -9
View File
@@ -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")
}
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+4
View File
@@ -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
+1 -1
View File
@@ -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`,
+8 -6
View File
@@ -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,
}),
)
+362 -15
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+367 -61
View File
@@ -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>
)
}
+223
View File
@@ -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;
}
+210
View File
@@ -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>
)
}
+57
View File
@@ -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);
}
+129
View File
@@ -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>
)
}
+205
View File
@@ -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
View File
@@ -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,
}
}
+12
View File
@@ -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;
}
+353
View File
@@ -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,
},
}
}
+12 -1
View File
@@ -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')
+12
View File
@@ -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)
+5
View File
@@ -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);
}
+5
View File
@@ -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
View File
@@ -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} />