Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13ddf4f5ac | ||
|
|
884d16638f | ||
|
|
34e84964a3 | ||
|
|
fd087e0ba3 |
@@ -78,17 +78,18 @@ behaviour, exercise it (hit the endpoint, render the page).
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `cmd/server` | Entry point. Wiring only — no business logic. |
|
||||
| `cmd/vapidgen` | Prints a new VAPID key pair for Web Push. |
|
||||
| `internal/config` | `MEALQUEST_*` environment config; secrets from `*_FILE`. |
|
||||
| `internal/server` | HTTP server, routes, `/health`. |
|
||||
| `internal/frontend` | `go:embed` of the built PWA with SPA fallback. |
|
||||
| `internal/*` (planned) | `auth`, `db`, `store`, `secretbox`, `household`, `dinners`, `recipes`, `ai/llm`, `ai/harness`, `ai/skills`, `suggest`, `plan`, `shopping`, `ideas`, `gcal`, `notify`, `push`, `chat`, `jobs`, `scheduler`. |
|
||||
| `migrations/` | SQL migrations (planned). |
|
||||
| `internal/*` | `auth`, `forwarded`, `secureurl`, `db`, `store`, `secretbox`, `household`, `dinners`, `recipes`, `ai/llm`, `ai/harness`, `ai/skills`, `ai/aisettings`, `suggest`, `plan`, `shopping`, `ideas`, `home`, `gcal`, `notify`, `push`, `chat`, `vapid`, `jobs`, `scheduler` — one line each in [ARCHITECTURE.md](docs/ARCHITECTURE.md#repository-layout). |
|
||||
| `migrations/` | SQL migrations, embedded and applied at boot by `internal/store`; sqlc queries in `migrations/queries/`. |
|
||||
| `web/` | React PWA source. |
|
||||
| `deploy/` | Deployment files (planned). |
|
||||
| `deploy/` | Deployment files: compose stack, systemd units, [deploy runbook](deploy/RUNBOOK.md) and the automated deploy (`auto-deploy.sh` with `systemd/mealquest-auto-deploy.*`). |
|
||||
| `docs/` | Project documentation, including [ARCHITECTURE.md](docs/ARCHITECTURE.md). |
|
||||
| `design/` | Requirements and UI handoff. **Read-only reference** — do not edit. |
|
||||
| `Dockerfile` | Multi-stage image build, at the repository root. |
|
||||
| `Makefile` | `dev`, `test`, `build`, `docker`. |
|
||||
| `Makefile` | `dev`, `test`, `build`, `docker`; `sqlc` and `sqlc-check` regenerate and check `internal/db`. |
|
||||
| `.gitea/workflows/ci.yml` | CI quality floor. |
|
||||
|
||||
Design references:
|
||||
@@ -104,7 +105,10 @@ Design references:
|
||||
- **Task branches:** `task/<slug>_<task-uuid>`, branched from the feature branch.
|
||||
- Task pull requests **squash-merge into the feature branch**.
|
||||
- The feature pull request merges into `dev`.
|
||||
- **Only `overseer` merges `dev` → `main`** (a release).
|
||||
- **Only `overseer` merges `dev` → `main`** (a release). On the production
|
||||
host, `mealquest-auto-deploy.timer` then deploys the new `main` once its
|
||||
CI passes ([deploy/RUNBOOK.md](deploy/RUNBOOK.md) "Automated deploys");
|
||||
nothing deploys `dev` or a feature branch.
|
||||
- Never push directly to `dev` or `main`.
|
||||
- Authors never review, approve, merge or close their own work.
|
||||
- Never store credentials in the repository; environment or ignored files only.
|
||||
|
||||
@@ -7,9 +7,27 @@ builds a weekly plan, pushes it to Google Calendar, and generates a shopping
|
||||
list grouped by dinner, kept as a browsable weekly history. It ships as a
|
||||
single Go service with the React PWA embedded in the binary.
|
||||
|
||||
> Status: this repository is the Feature 01 scaffold (Go service with
|
||||
> `/health`, embedded PWA skeleton, Makefile, Dockerfile, CI). The planned
|
||||
> application is described in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
Production runs at <https://mealquest.cubecraftlabs.com> for one household,
|
||||
signed in through Authentik.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend:** Go 1.25 (`CGO_ENABLED=0`, one static binary), chi router,
|
||||
SQLite through `modernc.org/sqlite` with sqlc queries and embedded
|
||||
migrations, Authentik OIDC, `log/slog`. AI calls go to an
|
||||
OpenAI-compatible, Anthropic or Ollama endpoint over plain HTTP; recipes
|
||||
come from SearXNG or Brave search and the recipe page's schema.org data.
|
||||
Google Calendar, Google Chat and Web Push are the integrations.
|
||||
- **Frontend:** React 19, TypeScript, Vite, React Router 7, TanStack Query 5,
|
||||
`vite-plugin-pwa`, CSS Modules over the design tokens; tests with Vitest,
|
||||
Testing Library and jsdom.
|
||||
- **Deployment:** a Docker Compose stack (the service, Litestream
|
||||
replication to the NAS, a SearXNG sidecar) behind Traefik, with systemd
|
||||
units for the firewall, nightly media backup and the automated deploy of
|
||||
`main`.
|
||||
|
||||
The approved dependency list is in [AGENTS.md](AGENTS.md); nothing else is
|
||||
added without the owner's approval.
|
||||
|
||||
## Running it locally
|
||||
|
||||
@@ -28,6 +46,15 @@ invalid variable.
|
||||
| `make test` | `gofmt` check, `go vet`, `go test -race`, web typecheck and tests. |
|
||||
| `make build` | Build the PWA, embed it, build the static binary `bin/mealquest`. |
|
||||
| `make docker` | Build the container image from the root `Dockerfile`. |
|
||||
| `make sqlc-check` | Regenerate `internal/db` with sqlc and fail if it drifted. |
|
||||
|
||||
## Deploying and operating
|
||||
|
||||
[deploy/RUNBOOK.md](deploy/RUNBOOK.md) is the deploy runbook: build, ship and
|
||||
restart, verifying the deployed version, rollback and the automated deploy.
|
||||
Database restore, the boot configuration and other host procedures are in
|
||||
CTXD: project `mealquest`, file `RUNBOOKS.MD`. Only `overseer` releases, by
|
||||
merging `dev` into `main`.
|
||||
|
||||
## Where to read next
|
||||
|
||||
|
||||
+7
-1
@@ -23,6 +23,7 @@ import (
|
||||
"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/home"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
@@ -177,6 +178,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
homeSvc, err := home.NewService(settings, planSvc, suggestSvc, shopSvc, notifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -221,7 +227,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, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, runner, photos, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, homeSvc, runner, photos, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
# MealQuest deploy runbook
|
||||
|
||||
The one way to deploy MealQuest to production: the docker compose project
|
||||
`mealquest` on office-srvr `10.60.1.14` (see
|
||||
[ARCHITECTURE.md](../docs/ARCHITECTURE.md#topology)). Production runs `main`
|
||||
or a release tag; only `overseer` merges `dev` → `main`. Every deploy builds
|
||||
on the host, from the checked-out commit, with `make docker`.
|
||||
|
||||
Host setup (clone, firewall, the systemd unit, Traefik, DNS, Litestream) and
|
||||
the database restore are in CTXD project `mealquest`, `RUNBOOKS.MD`. This
|
||||
file is the build, restart, verification and rollback procedure; where it
|
||||
uses a CTXD section, it names it.
|
||||
|
||||
All commands run on `10.60.1.14` as `overseer`, in bash, from the checkout
|
||||
`/home/overseer/mealquest`. A block that ends with `FAILED` or a non-zero
|
||||
`$?` stops the deploy: do not go on to the next step.
|
||||
|
||||
## How the image carries its version
|
||||
|
||||
`make docker` runs `docker build --build-arg VERSION=<version>` and tags the
|
||||
image `mealquest:<version>`, where `<version>` is
|
||||
`git describe --tags --always` of the checkout (a release tag such as
|
||||
`v1.0.0`, otherwise the short SHA), with `-dirty` added when a tracked file
|
||||
is modified. The [Dockerfile](../Dockerfile) links it into the binary, and
|
||||
`/health` reports it:
|
||||
|
||||
```json
|
||||
{"status":"ok","version":"v1.0.0"}
|
||||
```
|
||||
|
||||
Never pass `--build-arg VERSION` by hand, and never build with
|
||||
`docker compose build` or `docker compose up --build`: compose passes no
|
||||
version, so that image reports `dev`.
|
||||
|
||||
The compose service `mealquest` runs the image named `mealquest-mealquest`
|
||||
(compose's name for the image it would build; check with
|
||||
`docker compose -f deploy/compose.yaml config --images mealquest`). It
|
||||
already exists, so `docker compose up` uses it and never builds. A deploy
|
||||
points that name at the `make docker` image with `docker tag`. Every
|
||||
`mealquest:<version>` image is kept for rollback; do not prune them.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `make`, `git`, `curl`, `python3` and Docker Engine with Compose on the
|
||||
host (`make` is not in the base install: `sudo apt-get install make`).
|
||||
- `deploy/.env` (git-ignored, owner `overseer`, mode 0600) sets every
|
||||
variable [`deploy/.env.example`](.env.example) sets, with
|
||||
`MEALQUEST_BIND_ADDRESS=10.60.1.14`. It must not set the variables
|
||||
`deploy/compose.yaml` fixes (`MEALQUEST_HTTP_ADDR`, `MEALQUEST_DATA_DIR`,
|
||||
`MEALQUEST_SEARXNG_URL` and the `*_FILE` variables). Creating it: CTXD
|
||||
`RUNBOOKS.MD` "Stack and first deploy" > "3. Environment file".
|
||||
- `deploy/secrets/` (git-ignored, mode 0700) holds `secret_key`,
|
||||
`oidc_client_secret`, `google_client_secret`, `vapid_private_key` and
|
||||
`litestream_key`, each owned by `10001:10001` with mode 0600, and
|
||||
`searxng.env`, owned by `overseer` with mode 0600. Creating them: CTXD
|
||||
`RUNBOOKS.MD` "Stack and first deploy" > "4. Secrets".
|
||||
- The compose file is the checkout's [`deploy/compose.yaml`](compose.yaml);
|
||||
the systemd unit [`mealquest.service`](systemd/mealquest.service) starts
|
||||
and stops it and is the only way the stack starts.
|
||||
- DNS and Traefik already route `https://mealquest.cubecraftlabs.com` to
|
||||
`10.60.1.14:8090` (CTXD `RUNBOOKS.MD` "Reverse proxy (Traefik)", "DNS").
|
||||
|
||||
Check `deploy/.env` and the secrets before every deploy (a new release may
|
||||
add a variable; the service refuses to boot without it). Both commands must
|
||||
print nothing:
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest
|
||||
comm -23 <(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env.example | sort -u) \
|
||||
<(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env | sort -u) # variables missing from deploy/.env
|
||||
sudo find deploy/secrets -type f ! -perm 600 # secrets not mode 0600
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
### 1. Build
|
||||
|
||||
Record the release production is running now, check out the new release
|
||||
and build its image. Nothing restarts.
|
||||
|
||||
The rollback target is the version the running service reports on
|
||||
`/health`, read before the checkout changes, never the checkout's `HEAD`:
|
||||
after a failed or deferred deploy the checkout can be ahead of production.
|
||||
The block resolves that version to a commit and prints it as the rollback
|
||||
SHA. A running version of `dev` or `*-dirty` names no commit: the block
|
||||
fails, and the rollback target must be established by hand before going on.
|
||||
|
||||
The checkout must be clean (the same test as the Makefile's `GIT_DIRTY`:
|
||||
no modified tracked file outside the generated `internal/frontend/dist`).
|
||||
The block computes the image tag exactly as `make docker` does, `-dirty`
|
||||
included, and checks that image exists after the build.
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest && (
|
||||
set -euo pipefail
|
||||
fail() { echo "$*" >&2; exit 1; }
|
||||
built=
|
||||
trap 'st=$?
|
||||
if [ "$st" -eq 0 ] && [ -n "$built" ]; then echo "built: $built"
|
||||
else [ "$st" -ne 0 ] || st=1; echo "build FAILED (status $st)" >&2; fi
|
||||
exit "$st"' EXIT
|
||||
bind=$(grep -E '^MEALQUEST_BIND_ADDRESS=' deploy/.env | cut -d = -f 2-)
|
||||
[ -n "$bind" ] || fail "deploy/.env sets no MEALQUEST_BIND_ADDRESS"
|
||||
running=$(curl -fsS "http://$bind:8090/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|
||||
|| fail "http://$bind:8090/health did not return a version: no running release to roll back to"
|
||||
case "$running" in
|
||||
dev|*-dirty) fail "the running release reports $running, which names no commit" ;;
|
||||
esac
|
||||
git fetch --tags origin
|
||||
rollback=$(git rev-parse --verify --quiet "$running^{commit}") \
|
||||
|| fail "running version $running is not a commit in this repository"
|
||||
echo "rollback SHA: $rollback (running version $running)" # RECORD it for rollback
|
||||
git checkout --detach "refs/remotes/origin/main^{commit}" # or the release tag, e.g. v1.0.0
|
||||
dirty=$(git status --porcelain --untracked-files=no -- . ':(exclude)internal/frontend/dist')
|
||||
[ -z "$dirty" ] || fail "the checkout has modified tracked files; clean it first:
|
||||
$dirty"
|
||||
v=$(git describe --tags --always)${dirty:+-dirty} # the Makefile's VERSION
|
||||
make docker
|
||||
docker image inspect "mealquest:$v" > /dev/null 2>&1 || fail "no image mealquest:$v: the build failed"
|
||||
built="mealquest:$v"
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Ship and restart
|
||||
|
||||
1. **Pre-restart gates.** Run CTXD `RUNBOOKS.MD` "Routine deploy and
|
||||
rollback" > "Deploy" step 2 (a verified pre-deploy restore point; record
|
||||
it with the rollback SHA) and step 3 (boot-required configuration). The
|
||||
restart applies migrations the previous release refuses, so without that
|
||||
restore point there is no rollback. If either fails, do not restart:
|
||||
production stays on the previous release.
|
||||
2. **Point the stack at the new image and restart.** `docker compose pull`
|
||||
fetches the pinned `litestream` and `searxng` images before anything
|
||||
stops; `mealquest` is never pulled. The restart applies the firewall,
|
||||
then runs `down` and `up -d`, and returns once `mealquest` passes its
|
||||
healthcheck (`/health` inside the container).
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest && (
|
||||
set -euo pipefail
|
||||
fail() { echo "$*" >&2; exit 1; }
|
||||
up=
|
||||
trap 'st=$?
|
||||
if [ "$st" -eq 0 ] && [ -n "$up" ]; then echo "restarted on: $up"
|
||||
else [ "$st" -ne 0 ] || st=1; echo "restart FAILED (status $st)" >&2; fi
|
||||
exit "$st"' EXIT
|
||||
v=$(git describe --tags --always)
|
||||
docker compose -f deploy/compose.yaml pull litestream searxng
|
||||
docker tag "mealquest:$v" mealquest-mealquest
|
||||
sudo systemctl restart mealquest
|
||||
for s in mealquest litestream searxng; do
|
||||
id=$(docker compose -f deploy/compose.yaml ps --status running --quiet "$s")
|
||||
[ -n "$id" ] || fail "$s is not running"
|
||||
done
|
||||
id=$(docker compose -f deploy/compose.yaml ps --quiet mealquest)
|
||||
health=$(docker inspect -f '{{.State.Health.Status}}' "$id")
|
||||
[ "$health" = healthy ] || fail "mealquest is $health, not healthy"
|
||||
up="mealquest:$v"
|
||||
)
|
||||
```
|
||||
|
||||
On failure read `journalctl -u mealquest` and
|
||||
`docker compose -f deploy/compose.yaml logs mealquest`: a configuration
|
||||
error names the variable. Fix it or roll back.
|
||||
|
||||
### 3. Verify the deployed version and a real screen
|
||||
|
||||
A `/health` 200 alone proves only that some build is up. This step checks,
|
||||
on the LAN address and through Traefik, that:
|
||||
|
||||
- `/health` reports exactly the checkout's `git describe --tags --always`;
|
||||
- `/` and the Plan screen's route `/plan` serve the built PWA shell (the
|
||||
React root and a hashed `/assets/` script, not the placeholder page an
|
||||
unbuilt embed serves);
|
||||
- that script is served as JavaScript.
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest && (
|
||||
set -euo pipefail
|
||||
fail() { echo "$*" >&2; exit 1; }
|
||||
want=
|
||||
trap 'st=$?
|
||||
if [ "$st" -eq 0 ] && [ -n "$want" ]; then echo "deployed and verified: $want ($(git rev-parse HEAD))"
|
||||
else [ "$st" -ne 0 ] || st=1; echo "verification FAILED (status $st): the deploy is not verified" >&2; fi
|
||||
exit "$st"' EXIT
|
||||
v=$(git describe --tags --always)
|
||||
for base in http://10.60.1.14:8090 https://mealquest.cubecraftlabs.com; do
|
||||
got=$(curl -fsS "$base/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|
||||
|| fail "$base/health did not return a version"
|
||||
[ "$got" = "$v" ] || fail "$base/health reports version $got, the checkout is $v"
|
||||
for path in / /plan; do
|
||||
page=$(curl -fsS "$base$path") || fail "$base$path did not return 200"
|
||||
grep -q '<div id="root"></div>' <<< "$page" || fail "$base$path is not the PWA shell"
|
||||
js=$(grep -oE 'src="/assets/[^"]+\.js"' <<< "$page" | head -n 1 | cut -d '"' -f 2) || true
|
||||
[ -n "$js" ] || fail "$base$path references no built /assets/ script"
|
||||
type=$(curl -fsS -o /dev/null -w '%{content_type}' "$base$js") || fail "$base$js did not return 200"
|
||||
case "$type" in
|
||||
text/javascript*) ;;
|
||||
*) fail "$base$js is $type, not JavaScript" ;;
|
||||
esac
|
||||
done
|
||||
echo "$base: version $got, PWA shell and $js served"
|
||||
done
|
||||
want=$v
|
||||
)
|
||||
```
|
||||
|
||||
- `deployed and verified: <version> (<SHA>)` and `$?` = `0` → open
|
||||
`https://mealquest.cubecraftlabs.com` in a browser: sign in and the Home
|
||||
screen renders with the household's data. Then record the deployed
|
||||
version and SHA, the rollback SHA, the pre-deploy restore point and the
|
||||
date in the release's Maestro task.
|
||||
- `verification FAILED`, any error or a non-zero `$?` → the deploy is not
|
||||
done. A version mismatch means the stack runs a different image than the
|
||||
checkout: repeat step 2, or roll back.
|
||||
|
||||
## Rollback to a previous release
|
||||
|
||||
Rolling back points `mealquest-mealquest` at the previous release's
|
||||
`mealquest:<version>` image again; that release's checkout keeps the
|
||||
compose file and the version check consistent with it.
|
||||
|
||||
1. **Check whether the rollback crosses a migration** with CTXD
|
||||
`RUNBOOKS.MD` "Routine deploy and rollback" > "Rollback to a previous
|
||||
SHA" (its first block), run against the rollback SHA that deploy step 1
|
||||
recorded. If it prints versions unknown to the rollback SHA, restore the
|
||||
database to the pre-deploy restore point first (CTXD `RUNBOOKS.MD`
|
||||
"Restore the database", with `-timestamp <restore point>`); changes
|
||||
since then are lost, so tell the household.
|
||||
2. **Check out the rollback SHA and retag its image.** An image built before
|
||||
this runbook was not tagged `mealquest:<version>`; `make docker` builds
|
||||
it from the same commit.
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest && (
|
||||
set -euo pipefail
|
||||
fail() { echo "$*" >&2; exit 1; }
|
||||
P=<rollback SHA> # recorded by step 1, from the running release's /health
|
||||
back=
|
||||
trap 'st=$?
|
||||
if [ "$st" -eq 0 ] && [ -n "$back" ]; then echo "rolled back to: $back"
|
||||
else [ "$st" -ne 0 ] || st=1; echo "rollback FAILED (status $st)" >&2; fi
|
||||
exit "$st"' EXIT
|
||||
git checkout --detach "$P"
|
||||
v=$(git describe --tags --always)
|
||||
docker image inspect "mealquest:$v" > /dev/null 2>&1 || make docker
|
||||
docker image inspect "mealquest:$v" > /dev/null 2>&1 || fail "no image mealquest:$v"
|
||||
docker tag "mealquest:$v" mealquest-mealquest
|
||||
sudo systemctl restart mealquest
|
||||
back="mealquest:$v"
|
||||
)
|
||||
```
|
||||
|
||||
3. **Run step 3 above** unchanged. The rollback is done only when it prints
|
||||
`deployed and verified:` with the previous release's version.
|
||||
|
||||
## Automated deploys
|
||||
|
||||
[`auto-deploy.sh`](auto-deploy.sh), installed as
|
||||
`/usr/local/sbin/mealquest-auto-deploy`, runs this runbook's deploy unattended:
|
||||
step 1 (build), the prerequisite `deploy/.env` and secrets check, CTXD
|
||||
`RUNBOOKS.MD` "Deploy" steps 2 (pre-deploy restore point) and 3
|
||||
(boot-required configuration), step 2 (ship and restart) and step 3
|
||||
(verify). No check is skipped or weakened, and it stops at the first
|
||||
failure. Every failure before the restart leaves production on the previous
|
||||
release.
|
||||
|
||||
### What runs, and when
|
||||
|
||||
[`mealquest-auto-deploy.timer`](systemd/mealquest-auto-deploy.timer) starts
|
||||
[`mealquest-auto-deploy.service`](systemd/mealquest-auto-deploy.service)
|
||||
at every quarter hour (`OnCalendar=*-*-* *:0/15:00`), plus up to 5 minutes'
|
||||
random delay; a check missed while the host was down runs as soon as it is
|
||||
back (`Persistent=true`). The service runs the script as `overseer` from the
|
||||
checkout, and only while `mealquest.service` is active: a stack stopped on
|
||||
purpose (a database or media restore) is skipped, not redeployed. Output goes
|
||||
to the journal (`journalctl -u mealquest-auto-deploy`).
|
||||
|
||||
Each run fetches `origin` and compares `origin/main` with the last
|
||||
`deployed=` SHA in the deploy record `/home/overseer/mealquest-auto-deploy.log`:
|
||||
|
||||
| `origin/main` | Result |
|
||||
| --- | --- |
|
||||
| is the last deployed SHA | No-op: exit 0, no output. |
|
||||
| CI (Gitea commit status) `pending` | Exit 0, no output; the next run checks again. |
|
||||
| CI `failure`, `error`, `warning`, or no status yet | Error in the journal, exit 1, nothing deployed. |
|
||||
| the last record line is `FAILED` for this SHA | Error in the journal, exit 1, nothing deployed: a failed deploy is never retried by the timer. |
|
||||
| CI `success` | Deploy. |
|
||||
|
||||
The CI status is read from
|
||||
`https://code.cubecraftlabs.com/api/v1/repos/Maniacs_Mansion/MealQuest/commits/<SHA>/status`
|
||||
(public; no credential). A commit merged moments before a run can briefly
|
||||
report no status, which fails that run; the next run reads it again.
|
||||
|
||||
A deploy appends one line to the record:
|
||||
|
||||
```text
|
||||
2026-10-12T15:04:05Z previous=<rollback SHA> deployed=<SHA> restore_point=2026-10-12T15:01:59Z
|
||||
```
|
||||
|
||||
`previous` is the rollback SHA (from the running release's `/health`,
|
||||
never the checkout) and `restore_point` the verified pre-deploy restore
|
||||
point: record both in the release's Maestro task. A failed deploy appends
|
||||
`<date> FAILED target=<SHA> status=<exit status>` and logs
|
||||
`auto-deploy FAILED (status <n>)` to the journal, adding whether it failed
|
||||
before the restart (production untouched) or after it (read
|
||||
`journalctl -u mealquest`, then fix or roll back). Either way the checkout is
|
||||
left at the target commit; the running release on `/health` stays the
|
||||
rollback target.
|
||||
|
||||
### Install on the host
|
||||
|
||||
The script and units are installed copies: repeat this whenever a release
|
||||
changes `deploy/auto-deploy.sh` or the units. From the checkout, at a release
|
||||
that contains them:
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest
|
||||
sudo install -m 0755 deploy/auto-deploy.sh /usr/local/sbin/mealquest-auto-deploy
|
||||
sudo install -m 0644 deploy/systemd/mealquest-auto-deploy.service \
|
||||
deploy/systemd/mealquest-auto-deploy.timer /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
The timer refuses to deploy until the record holds a deploy, so seed it with
|
||||
one manual deploy of the release production should run (below), then start
|
||||
the timer:
|
||||
|
||||
```sh
|
||||
sudo /usr/local/sbin/mealquest-auto-deploy --ref refs/remotes/origin/main
|
||||
sudo systemctl enable --now mealquest-auto-deploy.timer
|
||||
systemctl list-timers mealquest-auto-deploy.timer
|
||||
```
|
||||
|
||||
To stop automated deploys: `sudo systemctl disable --now mealquest-auto-deploy.timer`.
|
||||
|
||||
### Manual override
|
||||
|
||||
```sh
|
||||
sudo /usr/local/sbin/mealquest-auto-deploy --ref <ref>
|
||||
```
|
||||
|
||||
deploys an explicit ref (a SHA, tag or `origin/<branch>`) through the same
|
||||
blocks, bypassing the CI and no-op gates: for a roll-forward fix, a re-run
|
||||
after a `FAILED` record, or seeding the record. Run as root, the script
|
||||
re-executes itself as `overseer`; it holds a lock, so it never runs alongside
|
||||
the timer's run. It prints `deployed and verified:` and exits 0 only when
|
||||
step 3 passed.
|
||||
|
||||
### Rollback
|
||||
|
||||
Rollback is not automated: follow "Rollback to a previous release" above
|
||||
and CTXD `RUNBOOKS.MD` "Routine deploy and rollback" > "Rollback to a
|
||||
previous SHA", using the `previous` SHA and `restore_point` from the record.
|
||||
The timer does not undo a manual rollback: `origin/main` is still the last
|
||||
recorded deploy, so its runs are no-ops until `main` moves, and a new `main`
|
||||
then deploys as usual. Disable the timer if the rollback must hold.
|
||||
Executable
+251
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env bash
|
||||
# MealQuest automated deploy: deploy/RUNBOOK.md, run unattended.
|
||||
#
|
||||
# mealquest-auto-deploy deploy origin/main if it is new and CI passed
|
||||
# mealquest-auto-deploy --ref <ref> deploy <ref> now, bypassing the CI and no-op gates
|
||||
#
|
||||
# Installed as /usr/local/sbin/mealquest-auto-deploy and run as overseer by
|
||||
# mealquest-auto-deploy.service (deploy/systemd); install, semantics and the
|
||||
# manual override: deploy/RUNBOOK.md "Automated deploys". Run as root (sudo)
|
||||
# it re-executes itself as overseer.
|
||||
#
|
||||
# Without --ref: exit 0 with no output when origin/main is the last deployed
|
||||
# SHA in the deploy record or its CI is pending; exit 1, deploying nothing,
|
||||
# when its CI failed or reports no status, or when the last attempt to deploy
|
||||
# that SHA failed. Every deploy runs the runbook's blocks in order and stops
|
||||
# at the first failure; nothing before the restart touches production. A
|
||||
# verified deploy appends one line to the deploy record:
|
||||
# <UTC date> previous=<SHA> deployed=<SHA> restore_point=<UTC date>
|
||||
# a failed one appends:
|
||||
# <UTC date> FAILED target=<SHA> status=<exit status>
|
||||
set -euo pipefail
|
||||
|
||||
CHECKOUT=/home/overseer/mealquest
|
||||
RECORD=/home/overseer/mealquest-auto-deploy.log
|
||||
LOCK=/home/overseer/mealquest-auto-deploy.lock
|
||||
STATUS_API=https://code.cubecraftlabs.com/api/v1/repos/Maniacs_Mansion/MealQuest/commits
|
||||
PUBLIC=https://mealquest.cubecraftlabs.com
|
||||
D=/var/lib/docker/volumes/mealquest-data/_data
|
||||
COMPOSE=(docker compose -f deploy/compose.yaml)
|
||||
|
||||
fail() { echo "$*" >&2; exit 1; }
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
exec sudo -u overseer -H -- "$0" "$@"
|
||||
fi
|
||||
[ "$(id -un)" = overseer ] || fail "run as overseer (or root), not $(id -un)"
|
||||
|
||||
ref=
|
||||
case "$#" in
|
||||
0) ;;
|
||||
2) [ "$1" = --ref ] && [ -n "$2" ] || fail "usage: $0 [--ref <ref>]"; ref=$2 ;;
|
||||
*) fail "usage: $0 [--ref <ref>]" ;;
|
||||
esac
|
||||
|
||||
exec 9>> "$LOCK"
|
||||
flock -n 9 || fail "another mealquest-auto-deploy run holds $LOCK"
|
||||
|
||||
cd "$CHECKOUT"
|
||||
git fetch --quiet --tags origin || fail "git fetch origin failed"
|
||||
|
||||
if [ -n "$ref" ]; then
|
||||
target=$(git rev-parse --verify --quiet "$ref^{commit}") || fail "$ref is not a commit in $CHECKOUT"
|
||||
else
|
||||
# The full remote-tracking ref: a bare origin/main resolves a tag or local
|
||||
# branch of that name first, which could deploy a commit not on main.
|
||||
target=$(git rev-parse --verify --quiet "refs/remotes/origin/main^{commit}") \
|
||||
|| fail "refs/remotes/origin/main is not a commit in $CHECKOUT"
|
||||
[ -f "$RECORD" ] || fail "no deploy record at $RECORD: seed it with one manual deploy (deploy/RUNBOOK.md \"Automated deploys\")"
|
||||
last=$(grep -oE 'deployed=[0-9a-f]{40}' "$RECORD" | tail -n 1 | cut -d = -f 2) || true
|
||||
[ -n "$last" ] || fail "$RECORD records no deploy: seed it with one manual deploy (deploy/RUNBOOK.md \"Automated deploys\")"
|
||||
[ "$target" != "$last" ] || exit 0
|
||||
if tail -n 1 "$RECORD" | grep -q " FAILED target=$target status="; then
|
||||
fail "auto-deploy of $target already failed (see $RECORD and the journal): fix it, then deploy with --ref"
|
||||
fi
|
||||
state=$(curl -fsS --max-time 30 "$STATUS_API/$target/status" \
|
||||
| python3 -c 'import json, sys; d = json.load(sys.stdin); print(d["state"] if d["total_count"] else "absent")') \
|
||||
|| fail "could not read the CI status of $target from $STATUS_API"
|
||||
case "$state" in
|
||||
success) ;;
|
||||
pending) exit 0 ;;
|
||||
*) fail "CI for origin/main $target is $state, not success: not deploying" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo "auto-deploy: deploying $target${ref:+ (--ref $ref)}"
|
||||
restarted=
|
||||
W=
|
||||
on_exit() {
|
||||
local st=$?
|
||||
[ -z "$W" ] || rm -rf "$W" || st=1
|
||||
if [ "$st" -ne 0 ]; then
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) FAILED target=$target status=$st" >> "$RECORD"
|
||||
if [ -n "$restarted" ]; then
|
||||
echo "auto-deploy FAILED (status $st) after the restart: production may be down or unverified; read journalctl -u mealquest, then fix or roll back (deploy/RUNBOOK.md)" >&2
|
||||
else
|
||||
echo "auto-deploy FAILED (status $st): production untouched" >&2
|
||||
fi
|
||||
fi
|
||||
exit "$st"
|
||||
}
|
||||
trap on_exit EXIT
|
||||
|
||||
# deploy/RUNBOOK.md "Deploy" > "1. Build": record the rollback SHA from the
|
||||
# RUNNING release, check out the target, reject a dirty checkout, build.
|
||||
bind=$(grep -E '^MEALQUEST_BIND_ADDRESS=' deploy/.env | cut -d = -f 2-) || fail "deploy/.env sets no MEALQUEST_BIND_ADDRESS"
|
||||
[ -n "$bind" ] || fail "deploy/.env sets no MEALQUEST_BIND_ADDRESS"
|
||||
running=$(curl -fsS "http://$bind:8090/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|
||||
|| fail "http://$bind:8090/health did not return a version: no running release to roll back to"
|
||||
case "$running" in
|
||||
dev|*-dirty) fail "the running release reports $running, which names no commit" ;;
|
||||
esac
|
||||
rollback=$(git rev-parse --verify --quiet "$running^{commit}") \
|
||||
|| fail "running version $running is not a commit in this repository"
|
||||
echo "rollback SHA: $rollback (running version $running)"
|
||||
git checkout --quiet --detach "$target"
|
||||
dirty=$(git status --porcelain --untracked-files=no -- . ':(exclude)internal/frontend/dist')
|
||||
[ -z "$dirty" ] || fail "the checkout has modified tracked files; clean it first:
|
||||
$dirty"
|
||||
v=$(git describe --tags --always)${dirty:+-dirty} # the Makefile's VERSION
|
||||
make docker
|
||||
docker image inspect "mealquest:$v" > /dev/null 2>&1 || fail "no image mealquest:$v: the build failed"
|
||||
echo "built: mealquest:$v"
|
||||
|
||||
# deploy/RUNBOOK.md "Prerequisites": deploy/.env sets every variable of the
|
||||
# target's .env.example, and every secret is mode 0600.
|
||||
missing=$(comm -23 <(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env.example | sort -u) \
|
||||
<(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env | sort -u))
|
||||
[ -z "$missing" ] || fail "deploy/.env is missing: $missing"
|
||||
loose=$(sudo find deploy/secrets -type f ! -perm 600)
|
||||
[ -z "$loose" ] || fail "secrets not mode 0600: $loose"
|
||||
|
||||
# CTXD RUNBOOKS.MD "Routine deploy and rollback" > "Deploy" step 2: a verified
|
||||
# pre-deploy restore point. The scripts are CTXD "Count check". They and the
|
||||
# check files live in a private mktemp directory (mode 0700), not at shared
|
||||
# /tmp paths, and python runs with -I: the snapshot runs as root, and a
|
||||
# shared directory on its import path would let any local user run code as
|
||||
# root.
|
||||
W=$(mktemp -d)
|
||||
cat > "$W/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")]
|
||||
for t in ("dinners", "week_plans", "schema_migrations"):
|
||||
print(t, c.execute(f"select count(*) from {t}").fetchone()[0])
|
||||
h = hashlib.sha256()
|
||||
for t in tabs:
|
||||
for row in c.execute(f"select * from \"{t}\" order by rowid"):
|
||||
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 > "$W/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
|
||||
point=$(
|
||||
set -eu
|
||||
point=
|
||||
trap 'st=$?
|
||||
rm -rf "$W" || 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" >&2; echo "$point"
|
||||
else [ "$st" -ne 0 ] || st=1; echo "verification FAILED (status $st): do not restart" >&2; fi
|
||||
exit "$st"' EXIT
|
||||
exec 3>&1 1>&2 # block output to the journal; only the restore point to $point
|
||||
mkdir "$W/mqcheck"
|
||||
# a. Replication running, no log errors (grep: 0 match, 1 none, 2+ error), newest LTX listed
|
||||
id=$("${COMPOSE[@]}" ps --status running --quiet litestream) || fail "docker compose ps failed"
|
||||
[ -n "$id" ] || fail "litestream is not running"
|
||||
"${COMPOSE[@]}" logs --since 24h litestream > "$W/mqcheck/litestream.log" || fail "docker compose logs failed"
|
||||
gs=0
|
||||
grep -i error "$W/mqcheck/litestream.log" > "$W/mqcheck/litestream.errors" || gs=$?
|
||||
case "$gs" in
|
||||
0) cat "$W/mqcheck/litestream.errors"; fail "litestream logged errors in the last 24h";;
|
||||
1) ;;
|
||||
*) fail "the litestream log scan failed (status $gs)";;
|
||||
esac
|
||||
"${COMPOSE[@]}" exec -T litestream \
|
||||
litestream ltx -config /etc/litestream.yml -level all /data/mealquest.db
|
||||
# b. Restore replica to a scratch file (live files untouched), move off the volume, snapshot live
|
||||
"${COMPOSE[@]}" 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* $W/mqcheck/"
|
||||
sudo python3 -I "$W/mqsnap.py" "$D/mealquest.db" "$W/mqcheck/live.db"
|
||||
sudo chown -R "$(id -un)" "$W/mqcheck"
|
||||
sudo ls -ln "$D"
|
||||
# mealquest.db-wal and -shm owned by 10001, never 0 (CTXD "Count check")
|
||||
root=$(sudo find "$D" -maxdepth 1 \( -name mealquest.db-wal -o -name mealquest.db-shm \) -uid 0)
|
||||
[ -z "$root" ] || fail "owned by root, not 10001 (CTXD RUNBOOKS.MD \"Count check\"): $root"
|
||||
# c. Restored replica: integrity ok and identical counts/content-sha256 to the live snapshot
|
||||
python3 -I "$W/mqcount.py" "$W/mqcheck/predeploy-check.db" > "$W/mqcheck/restored.txt"
|
||||
python3 -I "$W/mqcount.py" "$W/mqcheck/live.db" > "$W/mqcheck/live.txt"
|
||||
cat "$W/mqcheck/restored.txt"
|
||||
grep -qx 'integrity ok' "$W/mqcheck/restored.txt" || fail "the restored replica failed integrity_check"
|
||||
diff "$W/mqcheck/restored.txt" "$W/mqcheck/live.txt" || fail "the restored replica differs from the live snapshot"
|
||||
# d. Restore point: after every check, before the restart
|
||||
point=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
exec 1>&3
|
||||
)
|
||||
|
||||
# CTXD RUNBOOKS.MD "Routine deploy and rollback" > "Deploy" step 3:
|
||||
# boot-required configuration, secrets read the way the service reads them.
|
||||
[ -f deploy/.env ] || fail "missing deploy/.env"
|
||||
cid=$(grep -m 1 -E '^MEALQUEST_GOOGLE_CLIENT_ID=' deploy/.env | cut -d = -f 2- | tr -d '"' | tr -d "'") \
|
||||
|| fail "missing MEALQUEST_GOOGLE_CLIENT_ID in deploy/.env"
|
||||
[ -n "$cid" ] || fail "missing MEALQUEST_GOOGLE_CLIENT_ID in deploy/.env"
|
||||
[ -e deploy/secrets/google_client_secret ] || fail "missing deploy/secrets/google_client_secret"
|
||||
[ -s deploy/secrets/google_client_secret ] || fail "deploy/secrets/google_client_secret is empty"
|
||||
id=$("${COMPOSE[@]}" ps --status running --quiet mealquest) || fail "docker compose ps failed"
|
||||
[ -n "$id" ] || fail "the mealquest container is not running: start it (the check reads secrets the way the service does)"
|
||||
docker exec "$id" sh -c 'test -s /run/secrets/google_client_secret && test -r /run/secrets/google_client_secret' \
|
||||
|| fail "google_client_secret is not readable by the service user inside the container"
|
||||
echo "boot-required configuration present"
|
||||
|
||||
# deploy/RUNBOOK.md "2. Ship and restart".
|
||||
"${COMPOSE[@]}" pull litestream searxng
|
||||
docker tag "mealquest:$v" mealquest-mealquest
|
||||
restarted=yes
|
||||
sudo systemctl restart mealquest
|
||||
for s in mealquest litestream searxng; do
|
||||
id=$("${COMPOSE[@]}" ps --status running --quiet "$s")
|
||||
[ -n "$id" ] || fail "$s is not running"
|
||||
done
|
||||
id=$("${COMPOSE[@]}" ps --quiet mealquest)
|
||||
health=$(docker inspect -f '{{.State.Health.Status}}' "$id")
|
||||
[ "$health" = healthy ] || fail "mealquest is $health, not healthy"
|
||||
echo "restarted on: mealquest:$v"
|
||||
|
||||
# deploy/RUNBOOK.md "3. Verify the deployed version and a real screen".
|
||||
for base in "http://$bind:8090" "$PUBLIC"; do
|
||||
got=$(curl -fsS "$base/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|
||||
|| fail "$base/health did not return a version"
|
||||
[ "$got" = "$v" ] || fail "$base/health reports version $got, the checkout is $v"
|
||||
for path in / /plan; do
|
||||
page=$(curl -fsS "$base$path") || fail "$base$path did not return 200"
|
||||
grep -q '<div id="root"></div>' <<< "$page" || fail "$base$path is not the PWA shell"
|
||||
js=$(grep -oE 'src="/assets/[^"]+\.js"' <<< "$page" | head -n 1 | cut -d '"' -f 2) || true
|
||||
[ -n "$js" ] || fail "$base$path references no built /assets/ script"
|
||||
type=$(curl -fsS -o /dev/null -w '%{content_type}' "$base$js") || fail "$base$js did not return 200"
|
||||
case "$type" in
|
||||
text/javascript*) ;;
|
||||
*) fail "$base$js is $type, not JavaScript" ;;
|
||||
esac
|
||||
done
|
||||
echo "$base: version $got, PWA shell and $js served"
|
||||
done
|
||||
|
||||
deployed=$(git rev-parse HEAD)
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) previous=$rollback deployed=$deployed restore_point=$point" >> "$RECORD"
|
||||
echo "deployed and verified: $v ($deployed), previous $rollback, restore point $point"
|
||||
@@ -0,0 +1,23 @@
|
||||
# Deploys origin/main to production when it is new and its CI passed
|
||||
# (/usr/local/sbin/mealquest-auto-deploy, from deploy/auto-deploy.sh).
|
||||
# Triggered by mealquest-auto-deploy.timer; output goes to the journal.
|
||||
# Installing it and the manual override: deploy/RUNBOOK.md "Automated deploys".
|
||||
#
|
||||
# ExecCondition= skips the run while mealquest.service is not active, so a
|
||||
# stack stopped on purpose (a database or media restore) is never redeployed
|
||||
# under the operator. It is deliberately not Requires=/Requisite= on
|
||||
# mealquest.service: those would start the stack, or stop this run when the
|
||||
# deploy itself restarts mealquest.service. Runs as overseer, who owns the
|
||||
# checkout and uses sudo for the runbook's privileged steps.
|
||||
[Unit]
|
||||
Description=MealQuest automated deploy of main
|
||||
Wants=network-online.target
|
||||
After=network-online.target docker.service mealquest.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=overseer
|
||||
Group=overseer
|
||||
WorkingDirectory=/home/overseer/mealquest
|
||||
ExecCondition=/usr/bin/systemctl is-active --quiet mealquest.service
|
||||
ExecStart=/usr/local/sbin/mealquest-auto-deploy
|
||||
@@ -0,0 +1,13 @@
|
||||
# Checks for a new main at every quarter hour (plus up to 5 minutes' random
|
||||
# delay). Persistent= runs a check at once when the host comes back if a
|
||||
# scheduled one was missed while it was down.
|
||||
[Unit]
|
||||
Description=Periodic MealQuest automated deploy
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* *:0/15:00
|
||||
RandomizedDelaySec=5min
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
+151
-20
@@ -2,10 +2,8 @@
|
||||
|
||||
This mirrors the architecture section of `CONTEXT.MD` in the CTXD project
|
||||
`mealquest`, which is the source of record; if the two disagree, CTXD wins and
|
||||
this file should be updated. It describes the **planned** application. The
|
||||
repository today is the Feature 01 scaffold: `cmd/server`, `internal/config`,
|
||||
`internal/server` (`/health`), `internal/frontend` (embedded PWA with SPA
|
||||
fallback), the `web/` skeleton, `Makefile`, `Dockerfile` and CI.
|
||||
this file should be updated. It describes the application as built; nothing
|
||||
it names is still only planned.
|
||||
|
||||
Decision numbers (D-NNN) refer to `DECISIONS.MD` in CTXD.
|
||||
|
||||
@@ -46,20 +44,22 @@ headers, so sign-in works whichever peer the request arrives from.
|
||||
| `cmd/vapidgen` | Prints a new VAPID key pair for Web Push. |
|
||||
| `internal/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. |
|
||||
| `internal/server` | HTTP server, router, middleware. |
|
||||
| `internal/forwarded` | Client IP and scheme from `X-Forwarded-*`, trusted only from `MEALQUEST_TRUSTED_PROXY`. |
|
||||
| `internal/secureurl` | The transport rule for URLs that carry credentials (`secureurl.Check`). |
|
||||
| `internal/frontend` | Embedded PWA, SPA fallback. |
|
||||
| `internal/auth` | Authentik OIDC, sessions, CSRF. |
|
||||
| `internal/db`, `internal/store` | SQLite connections, sqlc queries. |
|
||||
| `internal/db`, `internal/store` | sqlc-generated queries; SQLite connections, migrations at boot, transactions. |
|
||||
| `internal/secretbox` | AES-256-GCM encryption for `_ct` columns. |
|
||||
| `internal/household`, `internal/dinners`, `internal/recipes` | Domain. |
|
||||
| `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills` | AI provider adapters, skill harness, skills. |
|
||||
| `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. |
|
||||
| `internal/household`, `internal/dinners`, `internal/recipes` | Domain: household settings and local time, dinners (grounding, photo backfill), recipe search, fetch and grounding. |
|
||||
| `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills`, `internal/ai/aisettings` | AI provider adapters, skill harness, skills, AI settings. |
|
||||
| `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas`, `internal/home` | Feature services. |
|
||||
| `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. |
|
||||
| `internal/vapid` | VAPID key generation and validation. |
|
||||
| `internal/jobs`, `internal/scheduler` | Background jobs and schedules. |
|
||||
| `migrations/` | SQL migrations (no goose or golang-migrate). |
|
||||
| `migrations/` | SQL migrations, embedded and applied at boot by `internal/store` (no goose or golang-migrate); sqlc queries in `migrations/queries/`. |
|
||||
| `web/` | React PWA. |
|
||||
| `deploy/` | Deployment files. |
|
||||
| `docs/` | Documentation. |
|
||||
| `deploy/` | Production compose stack, systemd units, the [deploy runbook](../deploy/RUNBOOK.md) and the automated deploy (see Deployment). |
|
||||
| `docs/` | Documentation (this file). |
|
||||
| `design/` | Requirements and UI handoff (read-only reference). |
|
||||
|
||||
## Backend stack and conventions
|
||||
@@ -259,6 +259,29 @@ automatic** (D-021).
|
||||
unarchived dinner still `pending` with no `ground_dinner` job for its revision
|
||||
queued or running is set `failed` with `interrupted`, so no dinner stays
|
||||
pending with nothing left to ground it.
|
||||
Photo backfill. `POST /api/v1/settings/photo-backfill` (CSRF-checked)
|
||||
enqueues a `backfill_dinner_photos` job and answers 202 `{id}` without
|
||||
waiting for it; while one is queued or running it enqueues nothing and
|
||||
answers with that job's id, so two passes never overlap, across restarts
|
||||
too. The kind is refused by `POST /api/v1/jobs`. The job
|
||||
grounds, one at a time with `Ground` on its name, every unarchived dinner
|
||||
that is `failed` with no photo and no pasted `recipe_url` — grounded,
|
||||
`manual` and `pending` dinners are never listed, and a pasted link is
|
||||
never replaced by a search. Success sets grounding_status `grounded` as
|
||||
the `ground_dinner` job does (the recipe keeps its source URL and site);
|
||||
failure leaves the dinner `failed` with the new `grounding_error`, for
|
||||
the fix-recipe flow. One dinner's failure never stops the others; a
|
||||
dinner renamed, archived or grounded by hand meanwhile is left as it is.
|
||||
Its result is `{dinners: [{dinner_id, outcome, reason}]}`, outcome
|
||||
`grounded`, `failed` (with its reason) or `skipped`. A dinner whose write
|
||||
fails is `failed` with `grounding_error` and left as it was; the others
|
||||
still run, then the job fails with `internal_error`, keeping that result.
|
||||
The responses returning a `dinners.Dinner` representation (the list; the
|
||||
create, update, archive, favorite and grounding responses; the idea
|
||||
responses) carry `photo_backfill_eligible`, and
|
||||
`GET /api/v1/dinners/{id}` does not; it says whether the backfill grounds
|
||||
that dinner, computed by the same condition it lists dinners by, so a
|
||||
client offers a search only where one runs.
|
||||
`GET /api/v1/dinners/{id}/recipe-candidates`
|
||||
searches for the dinner's name and returns `{candidates: [{candidate_id,
|
||||
title, site_name, photo_url, minutes}]}`, at most 3, ranked as `Ground`
|
||||
@@ -788,6 +811,20 @@ purge are fixed and not configurable.
|
||||
|
||||
Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
|
||||
Job kinds (`internal/jobs`; each registered when `cmd/server` builds the
|
||||
service named, before the runner starts). `POST /api/v1/jobs` refuses a
|
||||
service-only kind; `GET /api/v1/jobs/{id}` reports any job.
|
||||
|
||||
| Kind | Registered by | Enqueued by | Service-only |
|
||||
| --- | --- | --- | --- |
|
||||
| `noop` | `jobs.RegisterBuiltins` | `POST /api/v1/jobs` (does nothing; proves the runner works) | no |
|
||||
| `ground_dinner` | `dinners.Service` | Adding or renaming a `pending` dinner | no |
|
||||
| `backfill_dinner_photos` | `dinners.Service` | `POST /api/v1/settings/photo-backfill` | yes |
|
||||
| `suggestion_batch` | `suggest.Service` | `POST /api/v1/suggestions/batches`, a deck decision (replenish), `suggestions_weekly` | no |
|
||||
| `draft_week` | `plan.Service` | `POST /api/v1/plans/{weekStart}/draft`, `plan_draft` | yes |
|
||||
| `title_supply_run` | `shopping.Service` | Generating an untitled Supply run | yes |
|
||||
| `ideas_from_ingredients`, `quick_ideas` | `ideas.Service` | `POST /api/v1/ideas/from-ingredients`, `POST /api/v1/ideas/quick` | yes |
|
||||
|
||||
`internal/scheduler` runs them. A schedule registers a name, a rule read
|
||||
from the settings (`scheduler.Daily`, `Weekly` or `WeeklyForNextWeek`, or `DailyAt` for a time read from data) and a
|
||||
run function; settings changes reach it through `SettingsService.Subscribe`
|
||||
@@ -800,9 +837,11 @@ failed run is not retried. A claim that fails is not skipped: the pass is
|
||||
repeated every `scheduler.ClaimRetry` until the claim succeeds. A settings
|
||||
change is recorded with the instant it was saved, and fire times up to that
|
||||
instant follow the old rule and later ones the new, however late the
|
||||
scheduler applies it. At boot only each schedule's most recent fire
|
||||
time is considered: it runs if it passed within 24 hours, and is otherwise
|
||||
claimed without running (logged as missed) so it never runs later. DST
|
||||
scheduler applies it. At boot, and whenever the scheduler wakes late,
|
||||
every unclaimed fire time within 24 hours (`scheduler.CatchUpWindow`) is
|
||||
processed oldest first, each running once. A most recent fire time that
|
||||
passed longer ago is claimed without running (logged as missed) so it
|
||||
never runs later; older ones are not looked at. DST
|
||||
follows `household.LocalTime`: a skipped time fires later by the gap, a
|
||||
repeated time fires once, at its first occurrence.
|
||||
|
||||
@@ -870,14 +909,14 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
- Where each event is emitted, with its idempotency key. Each is stored by
|
||||
`notify.EmitTx` in the transaction of the change it is about, so the
|
||||
change and its notification commit together or not at all:
|
||||
- `plan_ready:{weekStart}` (URL `/plan`) when a draft job, asked for
|
||||
- `plan_ready:{weekStart}` (URL `/plan?week={weekStart}`) when a draft job, asked for
|
||||
by the API or the `plan_draft` schedule, stores the AI's draft of a
|
||||
week. Only the first draft of a week notifies: a
|
||||
redraft (Rebuild week) has the same key, and a manual swap never
|
||||
drafts. Its title is "Next week's plan is ready" (or "This week's",
|
||||
or "The plan for the week of Oct 26") and its body the drafted
|
||||
dinners Monday to Sunday.
|
||||
- `list_ready:{weekStart}` (URL `/plan/shopping`) when a week's Supply
|
||||
- `list_ready:{weekStart}` (URL `/plan/shopping?week={weekStart}`) when a week's Supply
|
||||
run is first created, by `POST /shopping/{weekStart}/generate` or
|
||||
after Push to Calendar; remaking it does not notify.
|
||||
- `new_suggestions:{YYYY-Www}` for a `scheduled` batch, the ISO week it
|
||||
@@ -1184,6 +1223,61 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
for any other `by`. A frozen list is read through `GET
|
||||
/api/v1/shopping/{weekStart}` with `status` `archived`.
|
||||
|
||||
## Home API (Feature 21)
|
||||
|
||||
- `GET /api/v1/home` (`home.Service`) aggregates the Home screen from the
|
||||
services that own each value, at "now" in the household's zone (never
|
||||
UTC), and answers:
|
||||
|
||||
```json
|
||||
{
|
||||
"dateLabel": "Friday, Oct 2",
|
||||
"timezone": "America/Chicago",
|
||||
"tonight": {"id": "…", "name": "…", "photo": "/media/…", "origin": "regular", "minutes_total": 40},
|
||||
"sideQuestCount": 10,
|
||||
"shoppingList": {"weekStart": "2026-10-05", "label": "Week 41 · Oct 5 – 11", "ready": true, "sentToChatAt": null},
|
||||
"recentUpdates": {"unread": true}
|
||||
}
|
||||
```
|
||||
|
||||
`tonight` is today's day of the current week's plan (`plan.Get`), null
|
||||
when the week has no plan; it changes at the household's local
|
||||
midnight, so the PWA reads Home again at the next local midnight of
|
||||
`timezone`, the household's IANA zone. `photo` and `minutes_total` are
|
||||
null when unknown. `sideQuestCount` is the pending cards of the deck,
|
||||
read without writing (`suggest.PendingCount`): a Home read never fails
|
||||
orphaned batches the way a `suggest.Deck` read does.
|
||||
`shoppingList` is the upcoming week's Supply run when it has one, else
|
||||
the current week's (`shopping.Get`); with neither, it is the current
|
||||
week with `ready` false. `recentUpdates.unread` is true when a
|
||||
notification was stored in `notification_outbox` after
|
||||
`household_settings.updates_seen_at`, or at all while that is null
|
||||
(`notify.StoredSince`); an event whose channels are all switched off is
|
||||
never stored and never counts.
|
||||
- `GET /api/v1/updates` (`home.Service.Updates`, over `notify.Recent`)
|
||||
answers the Recent updates sheet: the last 10 distinct events in
|
||||
`notification_outbox`, newest first, one per event key whatever its
|
||||
channels or delivery status (an event stored for push and Google Chat is
|
||||
one update):
|
||||
|
||||
```json
|
||||
{"updates": [{"type": "plan_ready", "title": "…", "body": "…", "url": "/plan?week=2026-10-12", "created_at": "2026-10-09T14:00:00.000Z"}]}
|
||||
```
|
||||
|
||||
Every field comes from the stored payload, which never holds a webhook
|
||||
URL or secret; `url` is the same-origin path the event opens.
|
||||
- `POST /api/v1/updates/seen` with `{"through": "<created_at>"}` (CSRF
|
||||
header required) records that the household has seen its updates
|
||||
through the newest one it was shown, and answers 204. The last-seen
|
||||
instant is `household_settings.updates_seen_at` — the one place it is
|
||||
stored. It only moves forward (`MarkUpdatesSeen`), so a `through` older
|
||||
than the stored one is accepted and changes nothing, and an event
|
||||
stored after `through` keeps `unread` true. It is not a settings edit:
|
||||
`updated_by` and `updated_at` are left as they are and settings
|
||||
subscribers are not told. 400 `invalid_request` for a body that is not
|
||||
exactly that object, a `through` that is not RFC 3339, or one after
|
||||
now.
|
||||
|
||||
## Frontend
|
||||
|
||||
- React 19, TypeScript, Vite, React Router 7, TanStack Query 5,
|
||||
@@ -1239,10 +1333,12 @@ fewer the server answers 400 `invalid_settings` naming
|
||||
`onboarding_completed` and changes nothing. Step 3 can be reached with
|
||||
fewer — by its URL, or after another member removes a Regular — so it then
|
||||
shows the server's reason with an "Add Regulars" button back to step 2.
|
||||
Notification permission is never asked for on page load; until Feature 19
|
||||
wires Web Push, "Turn on notifications" asks for nothing and says plainly
|
||||
that notifications are not on yet, offering "Finish setting up" (which
|
||||
completes onboarding as above) instead of a silent success. Offline on the
|
||||
Notification permission is never asked for on page load. "Turn on
|
||||
notifications" subscribes this device to Web Push from the tap and says
|
||||
how it ended — on, blocked by the browser, or unsupported (on iPhone and
|
||||
iPad, until MealQuest is on the Home Screen) — then offers only "Finish
|
||||
setting up" (which completes onboarding as above); a dismissed permission
|
||||
prompt can be asked again. Offline on the
|
||||
saved pages the settings cannot be read (the service worker never caches `/api/v1/settings`),
|
||||
so the saved pages open as they are.
|
||||
|
||||
@@ -1259,6 +1355,41 @@ are undone if the server refuses them. While any Regular is `pending`, the
|
||||
list is read again every 3 seconds (not while a change is in flight), and
|
||||
polling stops once none is.
|
||||
|
||||
## Deployment
|
||||
|
||||
Production is the compose project `mealquest` on 10.60.1.14
|
||||
([`deploy/compose.yaml`](../deploy/compose.yaml)): the `mealquest` image built
|
||||
from the root `Dockerfile`, `litestream` replicating the database to the
|
||||
NAS over SFTP, and `searxng` on the internal network only. Configuration is
|
||||
`deploy/.env` and file secrets in `deploy/secrets/`, neither committed. The
|
||||
services never restart themselves; systemd units in `deploy/systemd/` own
|
||||
the host:
|
||||
|
||||
| Unit | Does |
|
||||
| --- | --- |
|
||||
| `mealquest-firewall.service` | Restricts port 8090 to Traefik in `DOCKER-USER`; required before the stack starts. |
|
||||
| `mealquest.service` | Starts and stops the compose stack. |
|
||||
| `mealquest-media-backup.timer` / `.service` | Backs up cached photos to the NAS nightly at 03:30. |
|
||||
| `mealquest-auto-deploy.timer` / `.service` | Automated deploy of `main` (below). |
|
||||
|
||||
Releases: only `overseer` merges `dev` into `main`. The image carries its
|
||||
version (`git describe`: a release tag, else the short SHA), which
|
||||
`/health` reports. A deploy follows
|
||||
[`deploy/RUNBOOK.md`](../deploy/RUNBOOK.md) — build, pre-deploy restore
|
||||
point, boot-config check, ship and restart, verify the deployed SHA and a
|
||||
real screen — with rollback to the previous release. The automated deploy
|
||||
([`deploy/auto-deploy.sh`](../deploy/auto-deploy.sh), installed as
|
||||
`/usr/local/sbin/mealquest-auto-deploy`) runs those same steps unattended:
|
||||
its timer checks every quarter hour (plus up to 5 minutes' random delay),
|
||||
and deploys `refs/remotes/origin/main` only when it differs from the last
|
||||
SHA in the deploy record `/home/overseer/mealquest-auto-deploy.log`, its
|
||||
Gitea CI status is `success`, its last attempt did not fail, and
|
||||
`mealquest.service` is active. It stops at the first failure and never
|
||||
retries a failed SHA; `--ref <ref>` is the manual override. Installing,
|
||||
seeding the record and enabling the timer are host steps in the runbook's
|
||||
"Automated deploys". Database restore and the boot configuration are in
|
||||
CTXD `RUNBOOKS.MD`.
|
||||
|
||||
## Boundaries and gotchas
|
||||
|
||||
- **Secrets never reach the client** — API keys, OAuth tokens and the Chat
|
||||
|
||||
@@ -342,8 +342,8 @@ func TestSendThreadsByWeek(t *testing.T) {
|
||||
t.Errorf("plan text = %q", reqs[0].Body["text"])
|
||||
}
|
||||
|
||||
// The cards' "Open" buttons link under the public URL.
|
||||
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan", 1: "https://mealquest.example/plan/shopping"} {
|
||||
// The cards' "Open" buttons link under the public URL to the event's week.
|
||||
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan?week=2026-10-12", 1: "https://mealquest.example/plan/shopping?week=2026-10-12"} {
|
||||
data, _ := json.Marshal(reqs[i].Body["cardsV2"])
|
||||
var cards []cardV2
|
||||
if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -46,7 +47,7 @@ func PlanReady(week, thisWeek store.LocalDate, dinners []string) notify.Event {
|
||||
Key: string(notify.PlanReady) + ":" + week.String(),
|
||||
Title: planTitle(week, thisWeek),
|
||||
Body: nameList(dinners),
|
||||
URL: "/plan",
|
||||
URL: "/plan?" + url.Values{"week": {week.String()}}.Encode(),
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
@@ -59,7 +60,7 @@ func ListReady(week store.LocalDate, needed, dinners int) notify.Event {
|
||||
Key: string(notify.ListReady) + ":" + week.String(),
|
||||
Title: "This week's Supply run is ready",
|
||||
Body: fmt.Sprintf("%s to buy for %s", count(needed, "item", "items"), count(dinners, "dinner", "dinners")),
|
||||
URL: "/plan/shopping",
|
||||
URL: "/plan/shopping?" + url.Values{"week": {week.String()}}.Encode(),
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "plan ready",
|
||||
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}),
|
||||
key: "plan_ready:2026-10-12",
|
||||
url: "/plan",
|
||||
url: "/plan?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "This week's plan is ready",
|
||||
body: "Tacos, Bolognese, Tikka masala + 4 more",
|
||||
@@ -49,7 +49,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "plan ready with three dinners",
|
||||
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala"}),
|
||||
key: "plan_ready:2026-10-12",
|
||||
url: "/plan",
|
||||
url: "/plan?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "This week's plan is ready",
|
||||
body: "Tacos, Bolognese, Tikka masala",
|
||||
@@ -59,7 +59,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "plan ready for next week",
|
||||
ev: PlanReady(week, mustDate(t, "2026-10-05"), []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo"}),
|
||||
key: "plan_ready:2026-10-12",
|
||||
url: "/plan",
|
||||
url: "/plan?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "Next week's plan is ready",
|
||||
body: "Tacos, Bolognese, Tikka masala + 1 more",
|
||||
@@ -69,7 +69,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "plan ready for a later week",
|
||||
ev: PlanReady(mustDate(t, "2026-10-26"), mustDate(t, "2026-10-05"), []string{"Tacos"}),
|
||||
key: "plan_ready:2026-10-26",
|
||||
url: "/plan",
|
||||
url: "/plan?week=2026-10-26",
|
||||
week: "2026-10-26",
|
||||
title: "The plan for the week of Oct 26 is ready",
|
||||
body: "Tacos",
|
||||
@@ -79,7 +79,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "list ready",
|
||||
ev: ListReady(week, 19, 7),
|
||||
key: "list_ready:2026-10-12",
|
||||
url: "/plan/shopping",
|
||||
url: "/plan/shopping?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "This week's Supply run is ready",
|
||||
body: "19 items to buy for 7 dinners",
|
||||
@@ -89,7 +89,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "list ready singular",
|
||||
ev: ListReady(week, 1, 1),
|
||||
key: "list_ready:2026-10-12",
|
||||
url: "/plan/shopping",
|
||||
url: "/plan/shopping?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "This week's Supply run is ready",
|
||||
body: "1 item to buy for 1 dinner",
|
||||
|
||||
@@ -214,6 +214,26 @@ func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendin
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getActivePhotoBackfillJob = `-- name: GetActivePhotoBackfillJob :one
|
||||
SELECT id
|
||||
FROM jobs
|
||||
WHERE kind = ?1
|
||||
AND status IN ('queued', 'running')
|
||||
AND json_extract(payload, '$.dinner_id') IS NULL
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
// The id of the full photo backfill job (no dinner_id) of kind queued or
|
||||
// running. StartPhotoBackfill enqueues one only when there is none, so
|
||||
// there is at most one. Returns sql.ErrNoRows when there is none.
|
||||
func (q *Queries) GetActivePhotoBackfillJob(ctx context.Context, kind string) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getActivePhotoBackfillJob, kind)
|
||||
var id string
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const getDinner = `-- name: GetDinner :one
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
|
||||
WHERE id = ?
|
||||
@@ -344,6 +364,58 @@ func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Din
|
||||
return i, err
|
||||
}
|
||||
|
||||
const groundFailedDinner = `-- name: GroundFailedDinner :execrows
|
||||
UPDATE dinners
|
||||
SET recipe_id = ?1,
|
||||
photo_id = ?2,
|
||||
method = COALESCE(method, ?3),
|
||||
minutes_total = COALESCE(minutes_total, ?4),
|
||||
difficulty = COALESCE(difficulty, ?5),
|
||||
grounding_status = 'grounded',
|
||||
grounding_error = NULL,
|
||||
updated_at = ?6
|
||||
WHERE id = ?7
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'failed'
|
||||
AND photo_id IS NULL
|
||||
AND recipe_url IS NULL
|
||||
AND grounding_revision = ?8
|
||||
`
|
||||
|
||||
type GroundFailedDinnerParams struct {
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
GroundingRevision int64
|
||||
}
|
||||
|
||||
// Records a photo backfill's grounding of a dinner it listed, while the
|
||||
// dinner is still unarchived, failed, photoless, without a recipe_url and at
|
||||
// the grounding_revision it was listed at; a dinner grounded by hand,
|
||||
// renamed or archived meanwhile is left as it is (0 rows). Like
|
||||
// GroundPendingDinner it keeps a method, minutes_total or difficulty a
|
||||
// member already set.
|
||||
func (q *Queries) GroundFailedDinner(ctx context.Context, arg GroundFailedDinnerParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, groundFailedDinner,
|
||||
arg.RecipeID,
|
||||
arg.PhotoID,
|
||||
arg.Method,
|
||||
arg.MinutesTotal,
|
||||
arg.Difficulty,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.GroundingRevision,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
|
||||
UPDATE dinners
|
||||
SET recipe_id = ?1,
|
||||
@@ -444,6 +516,64 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listPhotoBackfillDinners = `-- name: ListPhotoBackfillDinners :many
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
|
||||
WHERE status <> 'archived'
|
||||
AND grounding_status = 'failed'
|
||||
AND photo_id IS NULL
|
||||
AND recipe_url IS NULL
|
||||
AND id = COALESCE(?1, id)
|
||||
ORDER BY normalized_name, id
|
||||
`
|
||||
|
||||
// The dinners a photo backfill grounds by name: unarchived, failed, with no
|
||||
// photo and no pasted recipe_url (the household's own link is never
|
||||
// replaced by a search), ordered by normalized name. A non-NULL id narrows
|
||||
// the list to that dinner. Grounded, manual and pending dinners are never
|
||||
// listed: they have a recipe, or a ground_dinner job grounding them.
|
||||
func (q *Queries) ListPhotoBackfillDinners(ctx context.Context, id sql.NullString) ([]Dinner, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listPhotoBackfillDinners, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Dinner
|
||||
for rows.Next() {
|
||||
var i Dinner
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NormalizedName,
|
||||
&i.Status,
|
||||
&i.Origin,
|
||||
&i.Method,
|
||||
&i.MinutesTotal,
|
||||
&i.Difficulty,
|
||||
&i.Cuisine,
|
||||
&i.IsFavorite,
|
||||
&i.RecipeID,
|
||||
&i.PhotoID,
|
||||
&i.GroundingStatus,
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listUnarchivedDinners = `-- name: ListUnarchivedDinners :many
|
||||
SELECT dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision, recipes.source_url AS recipe_source_url
|
||||
FROM dinners
|
||||
@@ -560,6 +690,40 @@ func (q *Queries) ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStar
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const refailDinnerGrounding = `-- name: RefailDinnerGrounding :execrows
|
||||
UPDATE dinners
|
||||
SET grounding_error = ?1,
|
||||
updated_at = ?2
|
||||
WHERE id = ?3
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'failed'
|
||||
AND photo_id IS NULL
|
||||
AND recipe_url IS NULL
|
||||
AND grounding_revision = ?4
|
||||
`
|
||||
|
||||
type RefailDinnerGroundingParams struct {
|
||||
GroundingError sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
GroundingRevision int64
|
||||
}
|
||||
|
||||
// Records why a photo backfill could not ground a dinner it listed, under
|
||||
// the same conditions as GroundFailedDinner (0 rows otherwise).
|
||||
func (q *Queries) RefailDinnerGrounding(ctx context.Context, arg RefailDinnerGroundingParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, refailDinnerGrounding,
|
||||
arg.GroundingError,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.GroundingRevision,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const setDinnerFavorite = `-- name: SetDinnerFavorite :one
|
||||
UPDATE dinners
|
||||
SET is_favorite = ?,
|
||||
|
||||
@@ -97,6 +97,22 @@ func (q *Queries) GetNotificationPref(ctx context.Context, event string) (Notifi
|
||||
return i, err
|
||||
}
|
||||
|
||||
const hasNotificationSince = `-- name: HasNotificationSince :one
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM notification_outbox
|
||||
WHERE CAST(?1 AS TEXT) IS NULL OR created_at > CAST(?1 AS TEXT)
|
||||
) AS found
|
||||
`
|
||||
|
||||
// Reports whether a notification was stored after since, or at all when
|
||||
// since is NULL.
|
||||
func (q *Queries) HasNotificationSince(ctx context.Context, since sql.NullString) (bool, error) {
|
||||
row := q.db.QueryRowContext(ctx, hasNotificationSince, since)
|
||||
var found bool
|
||||
err := row.Scan(&found)
|
||||
return found, err
|
||||
}
|
||||
|
||||
const insertNotification = `-- name: InsertNotification :execrows
|
||||
INSERT INTO notification_outbox (
|
||||
id, event_key, channel, payload, status, attempts, next_attempt_at, created_at, updated_at
|
||||
@@ -166,6 +182,53 @@ func (q *Queries) ListNotificationPrefs(ctx context.Context) ([]NotificationPref
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listRecentNotifications = `-- name: ListRecentNotifications :many
|
||||
SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o
|
||||
WHERE o.id = (
|
||||
SELECT i.id FROM notification_outbox i
|
||||
WHERE i.event_key = o.event_key
|
||||
ORDER BY i.created_at DESC, i.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
ORDER BY o.created_at DESC, o.event_key DESC
|
||||
LIMIT ?1
|
||||
`
|
||||
|
||||
type ListRecentNotificationsRow struct {
|
||||
EventKey string
|
||||
Payload string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// Returns the last lim distinct events stored, newest first: one row per
|
||||
// event key, the last stored for it whatever its channel, so an event
|
||||
// stored for push and Google Chat is one row. The last, because a channel
|
||||
// switched on later can store the same event key again, and
|
||||
// HasNotificationSince counts that row: an event is as new as its newest
|
||||
// row, so marking it seen also covers that row.
|
||||
func (q *Queries) ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listRecentNotifications, lim)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListRecentNotificationsRow
|
||||
for rows.Next() {
|
||||
var i ListRecentNotificationsRow
|
||||
if err := rows.Scan(&i.EventKey, &i.Payload, &i.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const markNotificationSent = `-- name: MarkNotificationSent :execrows
|
||||
UPDATE notification_outbox
|
||||
SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?1
|
||||
|
||||
@@ -117,6 +117,10 @@ type Querier interface {
|
||||
// when there is none, so there is at most one. Returns sql.ErrNoRows when
|
||||
// there is none.
|
||||
GetActiveDraftJob(ctx context.Context, arg GetActiveDraftJobParams) (GetActiveDraftJobRow, error)
|
||||
// The id of the full photo backfill job (no dinner_id) of kind queued or
|
||||
// running. StartPhotoBackfill enqueues one only when there is none, so
|
||||
// there is at most one. Returns sql.ErrNoRows when there is none.
|
||||
GetActivePhotoBackfillJob(ctx context.Context, kind string) (string, error)
|
||||
// The batch still pending or running, oldest first. Returns sql.ErrNoRows
|
||||
// when there is none.
|
||||
GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
|
||||
@@ -175,6 +179,13 @@ type Querier interface {
|
||||
// unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
|
||||
// no longer as observed is kept: a member edited it while it was grounding.
|
||||
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
|
||||
// Records a photo backfill's grounding of a dinner it listed, while the
|
||||
// dinner is still unarchived, failed, photoless, without a recipe_url and at
|
||||
// the grounding_revision it was listed at; a dinner grounded by hand,
|
||||
// renamed or archived meanwhile is left as it is (0 rows). Like
|
||||
// GroundPendingDinner it keeps a method, minutes_total or difficulty a
|
||||
// member already set.
|
||||
GroundFailedDinner(ctx context.Context, arg GroundFailedDinnerParams) (int64, error)
|
||||
// Records the background grounding of an unarchived dinner still pending at
|
||||
// the grounding_revision it was grounded for; a dinner grounded some other
|
||||
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
|
||||
@@ -182,6 +193,9 @@ type Querier interface {
|
||||
// them only when a member edited them while it was grounding. Like
|
||||
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
||||
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
|
||||
// Reports whether a notification was stored after since, or at all when
|
||||
// since is NULL.
|
||||
HasNotificationSince(ctx context.Context, since sql.NullString) (bool, error)
|
||||
// Stores a pending notification for one channel, due at once. A row already
|
||||
// stored for the event key and channel, in any status, is left unchanged and
|
||||
// no row is inserted.
|
||||
@@ -203,6 +217,12 @@ type Querier interface {
|
||||
// The pending Rotation checks whose dinner is still tried, oldest plan
|
||||
// date first, with their dinners.
|
||||
ListPendingRotationReviews(ctx context.Context) ([]ListPendingRotationReviewsRow, error)
|
||||
// The dinners a photo backfill grounds by name: unarchived, failed, with no
|
||||
// photo and no pasted recipe_url (the household's own link is never
|
||||
// replaced by a search), ordered by normalized name. A non-NULL id narrows
|
||||
// the list to that dinner. Grounded, manual and pending dinners are never
|
||||
// listed: they have a recipe, or a ground_dinner job grounding them.
|
||||
ListPhotoBackfillDinners(ctx context.Context, id sql.NullString) ([]Dinner, error)
|
||||
// The days of plan plan_id with their dinners, Monday first. A day with
|
||||
// no dinner is left out.
|
||||
ListPlanDays(ctx context.Context, planID string) ([]ListPlanDaysRow, error)
|
||||
@@ -212,6 +232,13 @@ type Querier interface {
|
||||
// Every stored subscription with its keys, for the push sender, oldest
|
||||
// first.
|
||||
ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error)
|
||||
// Returns the last lim distinct events stored, newest first: one row per
|
||||
// event key, the last stored for it whatever its channel, so an event
|
||||
// stored for push and Google Chat is one row. The last, because a channel
|
||||
// switched on later can store the same event key again, and
|
||||
// HasNotificationSince counts that row: an event is as new as its newest
|
||||
// row, so marking it seen also covers that row.
|
||||
ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error)
|
||||
// Reads for the AI household context block (internal/ai/harness).
|
||||
// The most recent names the household turned down, newest first, as
|
||||
// ListPassedNames has them.
|
||||
@@ -254,6 +281,10 @@ type Querier interface {
|
||||
// Records list id sent to Google Chat at sent_to_chat_at, its send
|
||||
// confirmed, unless it is frozen into the Supply archive.
|
||||
MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error)
|
||||
// Moves updates_seen_at forward to seen_at; affects no row when it is
|
||||
// already at or after seen_at, so it never moves back. It is not a settings
|
||||
// edit: updated_by and updated_at are left as they are.
|
||||
MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error)
|
||||
// Records an edit of plan id, raising its revision: a synced plan becomes
|
||||
// changed, any other keeps its status.
|
||||
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
|
||||
@@ -293,6 +324,9 @@ type Querier interface {
|
||||
// Records a new draft over plan id: its status and note, raising its
|
||||
// revision.
|
||||
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
|
||||
// Records why a photo backfill could not ground a dinner it listed, under
|
||||
// the same conditions as GroundFailedDinner (0 rows otherwise).
|
||||
RefailDinnerGrounding(ctx context.Context, arg RefailDinnerGroundingParams) (int64, error)
|
||||
// Records a generation of list id from plan plan_id.
|
||||
RegenerateShoppingList(ctx context.Context, arg RegenerateShoppingListParams) error
|
||||
// Records a failed delivery and when to try again. The lease must still be
|
||||
|
||||
@@ -161,6 +161,23 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett
|
||||
return i, err
|
||||
}
|
||||
|
||||
const markUpdatesSeen = `-- name: MarkUpdatesSeen :execrows
|
||||
UPDATE household_settings
|
||||
SET updates_seen_at = CAST(?1 AS TEXT)
|
||||
WHERE id = 1 AND (updates_seen_at IS NULL OR updates_seen_at < CAST(?1 AS TEXT))
|
||||
`
|
||||
|
||||
// Moves updates_seen_at forward to seen_at; affects no row when it is
|
||||
// already at or after seen_at, so it never moves back. It is not a settings
|
||||
// edit: updated_by and updated_at are left as they are.
|
||||
func (q *Queries) MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, markUpdatesSeen, seenAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const recordChatTest = `-- name: RecordChatTest :execrows
|
||||
UPDATE chat_settings
|
||||
SET last_test_at = ?, last_test_status = ?, updated_at = ?
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package dinners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// KindBackfillPhotos is the job that grounds, by name, the dinners left
|
||||
// without a photo: those whose grounding failed and that have no pasted
|
||||
// recipe_url (ListPhotoBackfillDinners).
|
||||
const KindBackfillPhotos = "backfill_dinner_photos"
|
||||
|
||||
// Photo backfill outcomes for one dinner.
|
||||
const (
|
||||
BackfillGrounded = "grounded"
|
||||
BackfillFailed = "failed"
|
||||
// BackfillSkipped: the dinner was grounded by hand, renamed or archived
|
||||
// while the backfill grounded it, so the backfill left it as it is.
|
||||
BackfillSkipped = "skipped"
|
||||
)
|
||||
|
||||
// PhotoBackfillEligible reports whether a photo backfill grounds r: the
|
||||
// condition ListPhotoBackfillDinners lists dinners by, and GroundFailedDinner
|
||||
// and RefailDinnerGrounding write under, for one dinner. The dinner is
|
||||
// unarchived, failed, with no photo and no pasted recipe_url.
|
||||
func PhotoBackfillEligible(r db.Dinner) bool {
|
||||
return r.Status != StatusArchived &&
|
||||
r.GroundingStatus == GroundingFailed &&
|
||||
!r.PhotoID.Valid &&
|
||||
!r.RecipeUrl.Valid
|
||||
}
|
||||
|
||||
// BackfillJob is the KindBackfillPhotos payload. DinnerID narrows the
|
||||
// backfill to that dinner; empty backfills every dinner without a photo.
|
||||
type BackfillJob struct {
|
||||
DinnerID string `json:"dinner_id,omitempty"`
|
||||
}
|
||||
|
||||
// BackfillOutcome is what a photo backfill did for one dinner. Reason is
|
||||
// set only for BackfillFailed: the Reason recorded as its grounding_error.
|
||||
type BackfillOutcome struct {
|
||||
DinnerID string `json:"dinner_id"`
|
||||
Outcome string `json:"outcome"`
|
||||
Reason *string `json:"reason"`
|
||||
}
|
||||
|
||||
// backfillResult is the KindBackfillPhotos result: the outcome of each
|
||||
// dinner the backfill tried, in the order it tried them.
|
||||
type backfillResult struct {
|
||||
Dinners []BackfillOutcome `json:"dinners"`
|
||||
}
|
||||
|
||||
func (r backfillResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
|
||||
// StartPhotoBackfill enqueues a KindBackfillPhotos job over every dinner
|
||||
// without a photo and returns its id, without waiting for it. While a full
|
||||
// backfill is queued or running it enqueues nothing and returns that job's
|
||||
// id instead, so two passes never ground the same dinner at once; the jobs
|
||||
// table holds the claim, so it holds across restarts too.
|
||||
func (s *Service) StartPhotoBackfill(ctx context.Context) (string, error) {
|
||||
var id string
|
||||
created := false
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
active, err := db.New(tx).GetActivePhotoBackfillJob(ctx, KindBackfillPhotos)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
case err != nil:
|
||||
return fmt.Errorf("dinners: read active photo backfill: %w", err)
|
||||
default:
|
||||
id = active
|
||||
return nil
|
||||
}
|
||||
id, err = s.backfillJob.EnqueueTx(ctx, tx, BackfillJob{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: enqueue photo backfill: %w", err)
|
||||
}
|
||||
created = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if created {
|
||||
s.backfillJob.Wake()
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// errBackfillNotRecorded fails a photo backfill that could not record the
|
||||
// outcome of one or more of its dinners.
|
||||
var errBackfillNotRecorded = errors.New("dinners: photo backfill: dinner outcomes not recorded")
|
||||
|
||||
// runBackfillJob grounds each dinner ListPhotoBackfillDinners lists for p,
|
||||
// one at a time, with recipes.Grounder.Ground on its name: the first ranked
|
||||
// candidate whose photo caches and whose ingredients normalize grounds it,
|
||||
// with the recipe's source URL and site kept as any grounding keeps them.
|
||||
// Success records the recipe and photo with grounding_status grounded;
|
||||
// failure leaves the dinner failed with the new Reason as its
|
||||
// grounding_error, for the household to fix its recipe by hand. One
|
||||
// dinner's failure, its search's, model's or database write's, never stops
|
||||
// the others. A failure to list the dinners, or the job's context ending,
|
||||
// fails the job at once. A write that failed fails the job once every
|
||||
// dinner has been tried, as a grounding job's failed write does, with the
|
||||
// outcomes published as its result, so the job never reports done for a
|
||||
// dinner whose outcome is not stored.
|
||||
func (s *Service) runBackfillJob(ctx context.Context, p BackfillJob, publish func(backfillResult) error) (backfillResult, error) {
|
||||
q := db.New(s.st.Reader)
|
||||
rows, err := q.ListPhotoBackfillDinners(ctx, sql.NullString{String: p.DinnerID, Valid: p.DinnerID != ""})
|
||||
if err != nil {
|
||||
return backfillResult{}, fmt.Errorf("dinners: photo backfill: list: %w", err)
|
||||
}
|
||||
res := backfillResult{Dinners: make([]BackfillOutcome, 0, len(rows))}
|
||||
grounded, unrecorded := false, false
|
||||
for _, row := range rows {
|
||||
out, recorded, err := s.backfillDinner(ctx, row)
|
||||
if err != nil {
|
||||
return backfillResult{}, err
|
||||
}
|
||||
grounded = grounded || out.Outcome == BackfillGrounded
|
||||
unrecorded = unrecorded || !recorded
|
||||
res.Dinners = append(res.Dinners, out)
|
||||
}
|
||||
if grounded {
|
||||
s.wake()
|
||||
}
|
||||
if unrecorded {
|
||||
if err := publish(res); err != nil {
|
||||
return backfillResult{}, errors.Join(errBackfillNotRecorded, err)
|
||||
}
|
||||
return backfillResult{}, errBackfillNotRecorded
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// backfillDinner grounds row, a dinner ListPhotoBackfillDinners listed, as
|
||||
// runBackfillJob describes, and reports whether its outcome was recorded.
|
||||
// Its error is only the job's context ending.
|
||||
func (s *Service) backfillDinner(ctx context.Context, row db.Dinner) (BackfillOutcome, bool, error) {
|
||||
out := BackfillOutcome{DinnerID: row.ID}
|
||||
w := db.New(s.st.Writer)
|
||||
gr, err := s.grounder.Ground(ctx, row.Name, row.Name)
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return BackfillOutcome{}, false, ctxErr
|
||||
}
|
||||
if err != nil {
|
||||
reason := reasonFor(err, false)
|
||||
// Only the reason is logged: the error may quote a dependency's
|
||||
// response, which jobs never store, return or log.
|
||||
s.logger.InfoContext(ctx, "dinner photo backfill failed", "dinner_id", row.ID, "reason", reason)
|
||||
n, err := w.RefailDinnerGrounding(ctx, db.RefailDinnerGroundingParams{
|
||||
GroundingError: sql.NullString{String: reason, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: row.ID,
|
||||
GroundingRevision: row.GroundingRevision,
|
||||
})
|
||||
out, recorded := s.backfillRecorded(ctx, out, BackfillFailed, reason, n, err)
|
||||
return out, recorded, nil
|
||||
}
|
||||
n, err := w.GroundFailedDinner(ctx, db.GroundFailedDinnerParams{
|
||||
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
||||
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
||||
Method: sql.NullString{String: gr.Method, Valid: true},
|
||||
MinutesTotal: nullInt(gr.MinutesTotal),
|
||||
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: row.ID,
|
||||
GroundingRevision: row.GroundingRevision,
|
||||
})
|
||||
out, recorded := s.backfillRecorded(ctx, out, BackfillGrounded, "", n, err)
|
||||
return out, recorded, nil
|
||||
}
|
||||
|
||||
// backfillRecorded is out once the backfill recorded outcome (with reason,
|
||||
// for BackfillFailed) on the dinner, writing n rows with err: a dinner that
|
||||
// changed meanwhile (no row) is BackfillSkipped, and one whose write failed
|
||||
// is BackfillFailed with ReasonGroundingError, the dinner left as it was,
|
||||
// and not recorded.
|
||||
func (s *Service) backfillRecorded(ctx context.Context, out BackfillOutcome, outcome, reason string, n int64, err error) (BackfillOutcome, bool) {
|
||||
switch {
|
||||
case err != nil:
|
||||
// The error is a database error, never a dependency's text.
|
||||
s.logger.ErrorContext(ctx, "dinner photo backfill not recorded", "dinner_id", out.DinnerID, "error", err)
|
||||
out.Outcome, reason = BackfillFailed, ReasonGroundingError
|
||||
case n == 0:
|
||||
out.Outcome, reason = BackfillSkipped, ""
|
||||
default:
|
||||
out.Outcome = outcome
|
||||
}
|
||||
if reason != "" {
|
||||
out.Reason = &reason
|
||||
}
|
||||
return out, err == nil
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package dinners_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// failedDinner stores a Regular named name whose grounding failed with
|
||||
// no_recipe_found, without a photo or recipe_url, and returns its id.
|
||||
func (e *env) failedDinner(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
row, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: dinners.StatusRegular,
|
||||
Origin: "regular",
|
||||
GroundingStatus: dinners.GroundingPending,
|
||||
UpdatedBy: sql.NullString{String: member, Valid: true},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := q.FailPendingDinnerGrounding(t.Context(), db.FailPendingDinnerGroundingParams{
|
||||
GroundingError: sql.NullString{String: dinners.ReasonNoRecipe, Valid: true},
|
||||
UpdatedAt: now,
|
||||
ID: row.ID,
|
||||
GroundingRevision: row.GroundingRevision,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return row.ID
|
||||
}
|
||||
|
||||
func outcome(id, out string, reason *string) dinners.BackfillOutcome {
|
||||
return dinners.BackfillOutcome{DinnerID: id, Outcome: out, Reason: reason}
|
||||
}
|
||||
|
||||
func checkOutcomes(t *testing.T, got, want []dinners.BackfillOutcome) {
|
||||
t.Helper()
|
||||
gj, _ := json.Marshal(got)
|
||||
wj, _ := json.Marshal(want)
|
||||
if string(gj) != string(wj) {
|
||||
t.Fatalf("outcomes = %s, want %s", gj, wj)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillGroundsFirstCandidateWithAPhoto(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newUnstartedEnv(t)
|
||||
// curryA ranks first but its photo is gone; curryB's photo caches.
|
||||
delete(e.images.JPEGs, "https://a.example/curry.jpg")
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
id := e.failedDinner(t, "Chicken curry")
|
||||
|
||||
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkOutcomes(t, got, []dinners.BackfillOutcome{outcome(id, dinners.BackfillGrounded, nil)})
|
||||
|
||||
row := e.dinner(t, id)
|
||||
if row.GroundingStatus != dinners.GroundingGrounded || !row.PhotoID.Valid || !row.RecipeID.Valid || row.GroundingError.Valid {
|
||||
t.Fatalf("dinner = %+v, want grounded with a photo and no error", row)
|
||||
}
|
||||
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.SourceUrl != curryB || rec.SiteName.String != "B Kitchen" || rec.PhotoID != row.PhotoID {
|
||||
t.Errorf("recipe = %s from %q photo %s, want %s from B Kitchen photo %s", rec.SourceUrl, rec.SiteName.String, rec.PhotoID.String, curryB, row.PhotoID.String)
|
||||
}
|
||||
if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) {
|
||||
t.Errorf("searched %q, want the dinner's name", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillReusesCachedRecipeAndPhoto(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newUnstartedEnv(t)
|
||||
e.search.Results["Curry chicken"] = []recipes.Candidate{{URL: curryA}}
|
||||
// One reply: the second dinner must reuse the recipe the first stored.
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
first := e.failedDinner(t, "Chicken curry")
|
||||
second := e.failedDinner(t, "Curry chicken")
|
||||
|
||||
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkOutcomes(t, got, []dinners.BackfillOutcome{
|
||||
outcome(first, dinners.BackfillGrounded, nil),
|
||||
outcome(second, dinners.BackfillGrounded, nil),
|
||||
})
|
||||
a, b := e.dinner(t, first), e.dinner(t, second)
|
||||
if a.RecipeID != b.RecipeID || a.PhotoID != b.PhotoID || !a.PhotoID.Valid {
|
||||
t.Errorf("dinners grounded in recipe %s photo %s and recipe %s photo %s, want the same cached ones",
|
||||
a.RecipeID.String, a.PhotoID.String, b.RecipeID.String, b.PhotoID.String)
|
||||
}
|
||||
if n := len(e.model.Requests()); n != 1 {
|
||||
t.Errorf("normalize_recipe ran %d times, want 1", n)
|
||||
}
|
||||
if n := slices.Index(e.pages.Fetched(), curryA); n < 0 || slices.Contains(e.pages.Fetched()[n+1:], curryA) {
|
||||
t.Errorf("fetched %q, want %s fetched once", e.pages.Fetched(), curryA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillIsolatesEachDinnersFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newUnstartedEnv(t)
|
||||
e.search.Fail = map[string]error{"Broken search": errors.New("search provider down")}
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
// Listed by normalized name: both failures come before the curry.
|
||||
broken := e.failedDinner(t, "Broken search")
|
||||
curry := e.failedDinner(t, "Chicken curry")
|
||||
absent := e.failedDinner(t, "Abalone surprise")
|
||||
|
||||
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkOutcomes(t, got, []dinners.BackfillOutcome{
|
||||
outcome(absent, dinners.BackfillFailed, ptr(dinners.ReasonNoRecipe)),
|
||||
outcome(broken, dinners.BackfillFailed, ptr(dinners.ReasonSearchFailed)),
|
||||
outcome(curry, dinners.BackfillGrounded, nil),
|
||||
})
|
||||
for id, reason := range map[string]string{absent: dinners.ReasonNoRecipe, broken: dinners.ReasonSearchFailed} {
|
||||
row := e.dinner(t, id)
|
||||
if row.GroundingStatus != dinners.GroundingFailed || row.GroundingError.String != reason || row.PhotoID.Valid {
|
||||
t.Errorf("dinner %s = %+v, want failed with %s and no photo", row.Name, row, reason)
|
||||
}
|
||||
}
|
||||
if row := e.dinner(t, curry); row.GroundingStatus != dinners.GroundingGrounded || !row.PhotoID.Valid {
|
||||
t.Errorf("curry = %+v, want grounded with a photo", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillSkipsDinnersNotMissingAPhoto(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.waitJob(t, *c.GroundingJobID)
|
||||
if row := e.dinner(t, c.ID); row.GroundingStatus != dinners.GroundingGrounded {
|
||||
t.Fatalf("dinner = %+v, want grounded", row)
|
||||
}
|
||||
// A failed dinner with a pasted link keeps that link for a fix by hand.
|
||||
linked, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Mystery pie"), RecipeURL: ptr("https://d.example/pie")}, member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.waitJob(t, *linked.GroundingJobID)
|
||||
if row := e.dinner(t, linked.ID); row.GroundingStatus != dinners.GroundingFailed {
|
||||
t.Fatalf("linked dinner = %+v, want failed", row)
|
||||
}
|
||||
archived := e.failedDinner(t, "Old stew")
|
||||
if _, err := e.svc.Archive(t.Context(), archived, member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
searched := len(e.search.Queries())
|
||||
|
||||
id, err := e.svc.StartPhotoBackfill(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job := e.waitJob(t, id)
|
||||
if job.Kind != dinners.KindBackfillPhotos || string(job.Result) != `{"dinners":[]}` {
|
||||
t.Fatalf("job = %+v (result %s), want a backfill with no dinners", job, job.Result)
|
||||
}
|
||||
if got := e.search.Queries()[searched:]; len(got) != 0 {
|
||||
t.Errorf("backfill searched %q, want nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPhotoBackfillEligibleIsWhatTheBackfillLists holds the API's
|
||||
// photo_backfill_eligible to ListPhotoBackfillDinners for a dinner the
|
||||
// backfill grounds and for each kind it never does.
|
||||
func TestPhotoBackfillEligibleIsWhatTheBackfillLists(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
grounded, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.waitJob(t, *grounded.GroundingJobID)
|
||||
linked, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Mystery pie"), RecipeURL: ptr("https://d.example/pie")}, member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.waitJob(t, *linked.GroundingJobID)
|
||||
failed := e.failedDinner(t, "Abalone surprise")
|
||||
archived, err := e.svc.Archive(t.Context(), e.failedDinner(t, "Old stew"), member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
shown := map[string]dinners.Dinner{archived.ID: archived}
|
||||
regulars, err := e.svc.List(t.Context(), dinners.StatusRegular)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range regulars {
|
||||
shown[d.ID] = d
|
||||
}
|
||||
q := db.New(e.st.Reader)
|
||||
for id, want := range map[string]bool{grounded.ID: false, linked.ID: false, failed: true, archived.ID: false} {
|
||||
listed, err := q.ListPhotoBackfillDinners(t.Context(), sql.NullString{String: id, Valid: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, ok := shown[id]
|
||||
if !ok {
|
||||
t.Fatalf("dinner %s not shown", id)
|
||||
}
|
||||
if got := len(listed) == 1; got != want || d.PhotoBackfillEligible != want {
|
||||
t.Errorf("%s: listed %t, photo_backfill_eligible %t, want both %t", d.Name, got, d.PhotoBackfillEligible, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillOfOneDinnerGroundsOnlyIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newUnstartedEnv(t)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
curry := e.failedDinner(t, "Chicken curry")
|
||||
other := e.failedDinner(t, "Abalone surprise")
|
||||
|
||||
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{DinnerID: curry})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkOutcomes(t, got, []dinners.BackfillOutcome{outcome(curry, dinners.BackfillGrounded, nil)})
|
||||
if row := e.dinner(t, other); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
|
||||
t.Errorf("other dinner = %+v, want left failed", row)
|
||||
}
|
||||
if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) {
|
||||
t.Errorf("searched %q, want only the dinner's name", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillLeavesDinnerChangedWhileGrounding(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newUnstartedEnv(t)
|
||||
id := e.failedDinner(t, "Chicken curry")
|
||||
gate := make(chan struct{})
|
||||
e.search.Gate = gate
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
|
||||
done := make(chan []dinners.BackfillOutcome)
|
||||
go func() {
|
||||
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{DinnerID: id})
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
done <- got
|
||||
}()
|
||||
// The backfill listed the dinner and waits at its search.
|
||||
for len(e.search.Queries()) == 0 {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Chicken korma")}, member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(gate)
|
||||
checkOutcomes(t, <-done, []dinners.BackfillOutcome{outcome(id, dinners.BackfillSkipped, nil)})
|
||||
if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
|
||||
t.Errorf("dinner = %+v, want left failed without a photo", row)
|
||||
}
|
||||
}
|
||||
|
||||
// triggerAtOnce calls svc.StartPhotoBackfill from n goroutines released
|
||||
// together and returns the job ids they got.
|
||||
func triggerAtOnce(t *testing.T, svc *dinners.Service, n int) []string {
|
||||
t.Helper()
|
||||
ids := make([]string, n)
|
||||
errs := make([]error, n)
|
||||
release := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-release
|
||||
ids[i], errs[i] = svc.StartPhotoBackfill(t.Context())
|
||||
}()
|
||||
}
|
||||
close(release)
|
||||
wg.Wait()
|
||||
if err := errors.Join(errs...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// backfillJobs counts the KindBackfillPhotos jobs in st.
|
||||
func backfillJobs(t *testing.T, st *store.Store) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := st.Reader.QueryRowContext(t.Context(), `SELECT count(*) FROM jobs WHERE kind = ?`, dinners.KindBackfillPhotos).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestStartPhotoBackfillIsSingleFlight(t *testing.T) {
|
||||
t.Parallel()
|
||||
const triggers = 8
|
||||
dataDir := t.TempDir()
|
||||
st := openStore(t, dataDir)
|
||||
e := newUnstartedEnvOn(t, st, dataDir)
|
||||
e.failedDinner(t, "Chicken curry")
|
||||
gate := make(chan struct{})
|
||||
e.search.Gate = gate
|
||||
|
||||
// Queued: simultaneous triggers all get the one pass they enqueued.
|
||||
ids := triggerAtOnce(t, e.svc, triggers)
|
||||
first := ids[0]
|
||||
for _, id := range ids {
|
||||
if id != first {
|
||||
t.Fatalf("simultaneous triggers = %q, want one job id", ids)
|
||||
}
|
||||
}
|
||||
if n := backfillJobs(t, st); n != 1 {
|
||||
t.Fatalf("%d backfill jobs after simultaneous triggers, want 1", n)
|
||||
}
|
||||
|
||||
// After a restart the queued pass is still the one: the claim is the
|
||||
// stored job, not the process's memory.
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st = openStore(t, dataDir)
|
||||
restarted := newUnstartedEnvOn(t, st, dataDir)
|
||||
restarted.search.Gate = gate
|
||||
restarted.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
if again, err := restarted.svc.StartPhotoBackfill(t.Context()); err != nil || again != first {
|
||||
t.Fatalf("trigger after restart = %q, %v; want the queued %q", again, err, first)
|
||||
}
|
||||
|
||||
// Running: the pass waits at its search, and simultaneous triggers all
|
||||
// return it.
|
||||
restarted.start(t)
|
||||
for len(restarted.search.Queries()) == 0 {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
for _, id := range triggerAtOnce(t, restarted.svc, triggers) {
|
||||
if id != first {
|
||||
t.Fatalf("trigger while running = %q, want the running %q", id, first)
|
||||
}
|
||||
}
|
||||
if n := backfillJobs(t, st); n != 1 {
|
||||
t.Fatalf("%d backfill jobs, want 1", n)
|
||||
}
|
||||
|
||||
// Finished: the next trigger starts a new pass.
|
||||
close(gate)
|
||||
if job := restarted.waitJob(t, first); job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("job = %+v (result %s), want succeeded", job, job.Result)
|
||||
}
|
||||
next, err := restarted.svc.StartPhotoBackfill(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if next == first {
|
||||
t.Fatalf("trigger after the pass finished = %q, want a new job", next)
|
||||
}
|
||||
restarted.waitJob(t, next)
|
||||
}
|
||||
|
||||
func TestBackfillFailsJobWhenAWriteFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
absent := e.failedDinner(t, "Abalone surprise")
|
||||
curry := e.failedDinner(t, "Chicken curry")
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_grounded BEFORE UPDATE OF grounding_status ON dinners
|
||||
WHEN NEW.id = '`+curry+`' AND NEW.grounding_status = 'grounded'
|
||||
BEGIN SELECT RAISE(ABORT, 'grounded refused'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id, err := e.svc.StartPhotoBackfill(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job := e.waitJob(t, id)
|
||||
if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != jobs.CodeInternal {
|
||||
t.Fatalf("job = %+v, want failed with %s", job, jobs.CodeInternal)
|
||||
}
|
||||
var got struct {
|
||||
Dinners []dinners.BackfillOutcome `json:"dinners"`
|
||||
}
|
||||
if err := json.Unmarshal(job.Result, &got); err != nil {
|
||||
t.Fatalf("result %s: %v", job.Result, err)
|
||||
}
|
||||
// The other dinner was still tried and recorded.
|
||||
checkOutcomes(t, got.Dinners, []dinners.BackfillOutcome{
|
||||
outcome(absent, dinners.BackfillFailed, ptr(dinners.ReasonNoRecipe)),
|
||||
outcome(curry, dinners.BackfillFailed, ptr(dinners.ReasonGroundingError)),
|
||||
})
|
||||
if row := e.dinner(t, curry); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
|
||||
t.Errorf("curry = %+v, want left failed without a photo", row)
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,11 @@ type Dinner struct {
|
||||
UpdatedBy *string `json:"updated_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
|
||||
// PhotoBackfillEligible is whether a photo backfill grounds the dinner
|
||||
// (PhotoBackfillEligible): false for one with a pasted recipe link,
|
||||
// which a search never replaces.
|
||||
PhotoBackfillEligible bool `json:"photo_backfill_eligible"`
|
||||
}
|
||||
|
||||
// NewRegular is a Regular to add: its Name, which the household always
|
||||
@@ -138,6 +143,8 @@ type Service struct {
|
||||
grounder *recipes.Grounder
|
||||
logger *slog.Logger
|
||||
groundJob *jobs.Kind[GroundJob]
|
||||
// backfillJob enqueues KindBackfillPhotos jobs.
|
||||
backfillJob *jobs.Kind[BackfillJob]
|
||||
// wake is called after every committed change to a dinner's method or
|
||||
// minutes, which can move its crock-pot start reminder.
|
||||
wake func()
|
||||
@@ -162,8 +169,8 @@ type Service struct {
|
||||
|
||||
// NewService returns a service over st that grounds dinners with grounder,
|
||||
// calls wake (the scheduler's Wake) after every committed change to a
|
||||
// dinner's method or minutes, and registers the KindGroundDinner job on
|
||||
// runner, which must not have started yet.
|
||||
// dinner's method or minutes, and registers the KindGroundDinner and
|
||||
// KindBackfillPhotos jobs on runner, which must not have started yet.
|
||||
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, wake func(), logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
@@ -184,6 +191,14 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner
|
||||
return nil, err
|
||||
}
|
||||
s.groundJob = kind
|
||||
backfill, err := jobs.Register(runner, KindBackfillPhotos, s.runBackfillJob)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := backfill.ServiceOnly(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.backfillJob = backfill
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -702,6 +717,8 @@ func fromRow(r db.Dinner) Dinner {
|
||||
UpdatedBy: nullString(r.UpdatedBy),
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
|
||||
PhotoBackfillEligible: PhotoBackfillEligible(r),
|
||||
}
|
||||
if r.MinutesTotal.Valid {
|
||||
d.MinutesTotal = &r.MinutesTotal.Int64
|
||||
|
||||
@@ -74,3 +74,18 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error)
|
||||
|
||||
// SafeSourceURL is safeSourceURL.
|
||||
var SafeSourceURL = safeSourceURL
|
||||
|
||||
// RunBackfillJob runs the KindBackfillPhotos handler for p in the caller's
|
||||
// goroutine, as the job runner would, and returns its outcomes: on an
|
||||
// error, those it last published.
|
||||
func (s *Service) RunBackfillJob(ctx context.Context, p BackfillJob) ([]BackfillOutcome, error) {
|
||||
var published backfillResult
|
||||
r, err := s.runBackfillJob(ctx, p, func(partial backfillResult) error {
|
||||
published = partial
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return published.Dinners, err
|
||||
}
|
||||
return r.Dinners, nil
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ type env struct {
|
||||
runner *jobs.Runner
|
||||
search *recipestest.Search
|
||||
pages *recipestest.Pages
|
||||
images *recipestest.Images
|
||||
model *llm.Fake
|
||||
}
|
||||
|
||||
@@ -108,7 +109,7 @@ func newUnstartedEnvOn(t *testing.T, st *store.Store, dataDir string) *env {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &env{svc: svc, st: st, runner: runner, search: search, pages: pages, model: model}
|
||||
return &env{svc: svc, st: st, runner: runner, search: search, pages: pages, images: images, model: model}
|
||||
}
|
||||
|
||||
// start starts the env's job runner, stopping it when the test ends.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package home
|
||||
|
||||
import "time"
|
||||
|
||||
// SetClock makes now the clock s reads "now" and "tonight" on.
|
||||
func SetClock(s *Service, now func() time.Time) { s.now = now }
|
||||
@@ -0,0 +1,253 @@
|
||||
// Package home reads the Home screen: tonight's dinner, the Side quests to
|
||||
// swipe, the Supply run and whether there are recent updates. It owns no
|
||||
// data: every value is read through the service that owns it, and "now"
|
||||
// and "tonight" are in the household's zone, never UTC.
|
||||
package home
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
)
|
||||
|
||||
// Service reads the Home screen.
|
||||
type Service struct {
|
||||
settings *household.SettingsService
|
||||
plans *plan.Service
|
||||
suggest *suggest.Service
|
||||
shopping *shopping.Service
|
||||
notifier *notify.Service
|
||||
// now is the clock "now" and "tonight" are read on; tests replace it.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService returns a service reading through the given services.
|
||||
func NewService(settings *household.SettingsService, plans *plan.Service, suggestSvc *suggest.Service, shop *shopping.Service, notifier *notify.Service) (*Service, error) {
|
||||
switch {
|
||||
case settings == nil:
|
||||
return nil, errors.New("home: nil household settings service")
|
||||
case plans == nil:
|
||||
return nil, errors.New("home: nil plan service")
|
||||
case suggestSvc == nil:
|
||||
return nil, errors.New("home: nil suggestions service")
|
||||
case shop == nil:
|
||||
return nil, errors.New("home: nil shopping service")
|
||||
case notifier == nil:
|
||||
return nil, errors.New("home: nil notifier")
|
||||
}
|
||||
return &Service{settings: settings, plans: plans, suggest: suggestSvc, shopping: shop, notifier: notifier, now: time.Now}, nil
|
||||
}
|
||||
|
||||
// Home is the Home screen as the API shows it.
|
||||
type Home struct {
|
||||
// DateLabel is today in the household's zone, e.g. "Friday, Oct 2".
|
||||
DateLabel string `json:"dateLabel"`
|
||||
// Timezone is the household's IANA zone, whose next local midnight
|
||||
// is when Home is read again for the new day.
|
||||
Timezone string `json:"timezone"`
|
||||
// Tonight is the dinner planned for today on the current week's plan;
|
||||
// nil when the week has no plan.
|
||||
Tonight *Dinner `json:"tonight"`
|
||||
// SideQuestCount is how many Side quests are pending on the deck.
|
||||
SideQuestCount int `json:"sideQuestCount"`
|
||||
// ShoppingList is the Supply run of the upcoming week when it has one,
|
||||
// else of the current week.
|
||||
ShoppingList ShoppingStatus `json:"shoppingList"`
|
||||
RecentUpdates RecentUpdates `json:"recentUpdates"`
|
||||
}
|
||||
|
||||
// Dinner is tonight's dinner.
|
||||
type Dinner struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Photo *string `json:"photo"`
|
||||
// Origin is regular or side_quest.
|
||||
Origin string `json:"origin"`
|
||||
MinutesTotal *int64 `json:"minutes_total"`
|
||||
}
|
||||
|
||||
// ShoppingStatus is whether a week's Supply run is ready.
|
||||
type ShoppingStatus struct {
|
||||
// WeekStart is the Monday of the week, YYYY-MM-DD.
|
||||
WeekStart string `json:"weekStart"`
|
||||
// Label is the week label, e.g. "Week 41 · Oct 5 – 11".
|
||||
Label string `json:"label"`
|
||||
// Ready is true when the week has a Supply run.
|
||||
Ready bool `json:"ready"`
|
||||
// SentToChatAt is when the Supply run was sent to Google Chat; nil
|
||||
// until it is, and when there is no Supply run.
|
||||
SentToChatAt *string `json:"sentToChatAt"`
|
||||
}
|
||||
|
||||
// RecentUpdates says whether there is something new for the bell.
|
||||
type RecentUpdates struct {
|
||||
// Unread is true when a notification was stored after the household
|
||||
// last saw its updates (household_settings.updates_seen_at), or at all
|
||||
// when it never has.
|
||||
Unread bool `json:"unread"`
|
||||
}
|
||||
|
||||
// Get reads the Home screen at the service's "now". It only reads: it
|
||||
// writes nothing, not even the batch bookkeeping a Deck read does.
|
||||
func (s *Service) Get(ctx context.Context) (Home, error) {
|
||||
settings, err := s.settings.Get(ctx)
|
||||
if err != nil {
|
||||
return Home{}, fmt.Errorf("home: read household settings: %w", err)
|
||||
}
|
||||
zone, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return Home{}, fmt.Errorf("home: household settings: %w", err)
|
||||
}
|
||||
now := s.now().In(zone)
|
||||
today := household.Today(now, zone)
|
||||
thisWeek := household.WeekStart(today)
|
||||
|
||||
tonight, err := s.tonight(ctx, thisWeek, today)
|
||||
if err != nil {
|
||||
return Home{}, err
|
||||
}
|
||||
sideQuests, err := s.suggest.PendingCount(ctx)
|
||||
if err != nil {
|
||||
return Home{}, fmt.Errorf("home: read deck: %w", err)
|
||||
}
|
||||
list, err := s.shoppingStatus(ctx, thisWeek)
|
||||
if err != nil {
|
||||
return Home{}, err
|
||||
}
|
||||
unread, err := s.unread(ctx, settings.UpdatesSeenAt)
|
||||
if err != nil {
|
||||
return Home{}, err
|
||||
}
|
||||
return Home{
|
||||
DateLabel: now.Format("Monday, Jan 2"),
|
||||
Timezone: settings.Timezone,
|
||||
Tonight: tonight,
|
||||
SideQuestCount: sideQuests,
|
||||
ShoppingList: list,
|
||||
RecentUpdates: RecentUpdates{Unread: unread},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// tonight returns the dinner planned on today in the plan of the week
|
||||
// starting thisWeek, or nil when the week has no plan.
|
||||
func (s *Service) tonight(ctx context.Context, thisWeek, today store.LocalDate) (*Dinner, error) {
|
||||
p, err := s.plans.Get(ctx, thisWeek)
|
||||
if errors.Is(err, plan.ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("home: read plan: %w", err)
|
||||
}
|
||||
for _, d := range p.Days {
|
||||
if d.Date == today.String() {
|
||||
return &Dinner{
|
||||
ID: d.Dinner.ID, Name: d.Dinner.Name, Photo: d.Dinner.Photo,
|
||||
Origin: d.Dinner.Origin, MinutesTotal: d.Dinner.Minutes,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("home: plan of week %s has no day %s", thisWeek, today)
|
||||
}
|
||||
|
||||
// shoppingStatus returns the status of the upcoming week's Supply run when
|
||||
// it has one, else of the current week's, ready or not.
|
||||
func (s *Service) shoppingStatus(ctx context.Context, thisWeek store.LocalDate) (ShoppingStatus, error) {
|
||||
y, m, d := thisWeek.Date()
|
||||
nextWeek := store.LocalDateOf(time.Date(y, m, d+7, 0, 0, 0, 0, time.UTC))
|
||||
for _, week := range []store.LocalDate{nextWeek, thisWeek} {
|
||||
l, err := s.shopping.Get(ctx, week)
|
||||
if errors.Is(err, shopping.ErrListNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return ShoppingStatus{}, fmt.Errorf("home: read Supply run: %w", err)
|
||||
}
|
||||
return weekStatus(week, true, l.SentToChatAt)
|
||||
}
|
||||
return weekStatus(thisWeek, false, nil)
|
||||
}
|
||||
|
||||
func weekStatus(week store.LocalDate, ready bool, sentToChatAt *string) (ShoppingStatus, error) {
|
||||
label, err := household.WeekLabel(week)
|
||||
if err != nil {
|
||||
return ShoppingStatus{}, fmt.Errorf("home: %w", err)
|
||||
}
|
||||
return ShoppingStatus{WeekStart: week.String(), Label: label, Ready: ready, SentToChatAt: sentToChatAt}, nil
|
||||
}
|
||||
|
||||
// unread reports whether a notification was stored after seenAt, the
|
||||
// stored updates_seen_at, or at all when it is nil.
|
||||
func (s *Service) unread(ctx context.Context, seenAt *string) (bool, error) {
|
||||
var since *time.Time
|
||||
if seenAt != nil {
|
||||
var ts store.Timestamp
|
||||
if err := ts.Scan(*seenAt); err != nil {
|
||||
return false, fmt.Errorf("home: household settings updates_seen_at: %w", err)
|
||||
}
|
||||
since = &ts.Time
|
||||
}
|
||||
unread, err := s.notifier.StoredSince(ctx, since)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("home: %w", err)
|
||||
}
|
||||
return unread, nil
|
||||
}
|
||||
|
||||
// RecentLimit is how many events the Recent updates sheet lists.
|
||||
const RecentLimit = 10
|
||||
|
||||
// Update is one event in the Recent updates sheet.
|
||||
type Update struct {
|
||||
// Type is the event type, e.g. plan_ready.
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
// URL is the path-absolute, same-origin path a tap on the update opens.
|
||||
URL string `json:"url"`
|
||||
// CreatedAt is when the event was stored, UTC RFC 3339 with
|
||||
// milliseconds; it is what POST /updates/seen takes back.
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Updates returns the last RecentLimit distinct notification events, newest
|
||||
// first, whatever channels they went to.
|
||||
func (s *Service) Updates(ctx context.Context) ([]Update, error) {
|
||||
events, err := s.notifier.Recent(ctx, RecentLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("home: %w", err)
|
||||
}
|
||||
out := make([]Update, len(events))
|
||||
for i, ev := range events {
|
||||
out[i] = Update{
|
||||
Type: string(ev.Type), Title: ev.Title, Body: ev.Body, URL: ev.URL,
|
||||
CreatedAt: store.Timestamp{Time: ev.CreatedAt}.String(),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ErrSeenInFuture is returned by MarkUpdatesSeen for a time after now.
|
||||
var ErrSeenInFuture = errors.New("home: updates cannot be seen through a time after now")
|
||||
|
||||
// MarkUpdatesSeen records that the household has seen its updates through
|
||||
// through, the created_at of the newest update it was shown, so
|
||||
// recentUpdates.unread is false until an event is stored after it. A time
|
||||
// before the one already recorded changes nothing; one after now is
|
||||
// ErrSeenInFuture, since it would hide events not yet stored.
|
||||
func (s *Service) MarkUpdatesSeen(ctx context.Context, through time.Time) error {
|
||||
if through.After(s.now()) {
|
||||
return ErrSeenInFuture
|
||||
}
|
||||
if err := s.settings.MarkUpdatesSeen(ctx, through); err != nil {
|
||||
return fmt.Errorf("home: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
package home_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"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/chat"
|
||||
"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/home"
|
||||
"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/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// zone is the household's zone in these tests: not UTC, so a late local
|
||||
// evening is already the next day in UTC.
|
||||
const zone = "America/Chicago"
|
||||
|
||||
// weekDates is the week of 2026-10-12, Monday first.
|
||||
var weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
|
||||
|
||||
type env struct {
|
||||
svc *home.Service
|
||||
st *store.Store
|
||||
settings *household.SettingsService
|
||||
notifier *notify.Service
|
||||
loc *time.Location
|
||||
}
|
||||
|
||||
// newEnv returns a home service over a migrated temp database whose
|
||||
// household is in zone, reading "now" from a clock set to at.
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
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)
|
||||
}
|
||||
if err := household.SeedSettings(ctx, st, zone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier := notifytest.New(t, st)
|
||||
pub := &url.URL{Scheme: "https", Host: "mealquest.example"}
|
||||
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(make([]byte, secretbox.KeySize)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
google := gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath)
|
||||
googleSvc, 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(),
|
||||
CalendarAPI: google.CalendarAPI(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||
sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
return skills.SideQuests{}, errors.New("no model in this test")
|
||||
}
|
||||
suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chatSvc, err := chat.NewService(st, box, pub, http.DefaultTransport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
title := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||
return skills.ArchiveTitle{}, errors.New("no model in this test")
|
||||
}
|
||||
shop, err := shopping.NewService(st, title, runner, notifier, chatSvc, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings := household.NewSettingsService(st)
|
||||
svc, err := home.NewService(settings, plans, suggestSvc, shop, notifier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loc, err := time.LoadLocation(zone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &env{svc: svc, st: st, settings: settings, notifier: notifier, loc: loc}
|
||||
}
|
||||
|
||||
// at reads the Home screen with the clock at the household-local wall
|
||||
// time "YYYY-MM-DD HH:MM".
|
||||
func (e *env) at(t *testing.T, local string) home.Home {
|
||||
t.Helper()
|
||||
now, err := time.ParseInLocation("2006-01-02 15:04", local, e.loc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home.SetClock(e.svc, func() time.Time { return now })
|
||||
h, err := e.svc.Get(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// dinner stores a dinner and returns its id.
|
||||
func (e *env) dinner(t *testing.T, name, status, origin string, minutes int64) string {
|
||||
t.Helper()
|
||||
now := store.Now().String()
|
||||
d, err := db.New(e.st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{
|
||||
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: status, Origin: origin,
|
||||
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
|
||||
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d.ID
|
||||
}
|
||||
|
||||
// planWeek stores the plan of the week of 2026-10-12: the dinner named for
|
||||
// its date each day, a Regular of 30 minutes, but Wednesday a Side quest of
|
||||
// unknown minutes. It returns the plan's id.
|
||||
func (e *env) planWeek(t *testing.T) string {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
planID := store.NewID()
|
||||
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: weekDates[0], CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, date := range weekDates {
|
||||
id := e.dinner(t, "Dinner "+date, "regular", "regular", 30)
|
||||
if date == "2026-10-14" {
|
||||
id = e.dinner(t, "Dinner "+date, "quest_log", "side_quest", 0)
|
||||
}
|
||||
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date,
|
||||
DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return planID
|
||||
}
|
||||
|
||||
// Tonight's dinner and the date label follow the household's local
|
||||
// midnight, not UTC's: late on Tuesday evening in Chicago it is already
|
||||
// Wednesday in UTC, and Home still shows Tuesday's dinner.
|
||||
func TestTonightSwitchesAtLocalMidnight(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
e.planWeek(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
local, label, dinner string
|
||||
}{
|
||||
// 23:59 CDT is 04:59 UTC the next day.
|
||||
{"2026-10-13 23:59", "Tuesday, Oct 13", "Dinner 2026-10-13"},
|
||||
{"2026-10-14 00:00", "Wednesday, Oct 14", "Dinner 2026-10-14"},
|
||||
// 19:00 CDT on Sunday is Monday 00:00 UTC, a week with no plan.
|
||||
{"2026-10-18 19:00", "Sunday, Oct 18", "Dinner 2026-10-18"},
|
||||
} {
|
||||
h := e.at(t, tc.local)
|
||||
if h.DateLabel != tc.label {
|
||||
t.Errorf("at %s: dateLabel = %q, want %q", tc.local, h.DateLabel, tc.label)
|
||||
}
|
||||
if h.Tonight == nil || h.Tonight.Name != tc.dinner {
|
||||
t.Errorf("at %s: tonight = %+v, want %s", tc.local, h.Tonight, tc.dinner)
|
||||
}
|
||||
}
|
||||
|
||||
// Late Sunday evening of the week before, the plan's week has not
|
||||
// begun locally, though in UTC it has.
|
||||
if h := e.at(t, "2026-10-11 23:30"); h.Tonight != nil || h.DateLabel != "Sunday, Oct 11" {
|
||||
t.Errorf("at 2026-10-11 23:30: tonight = %+v, dateLabel = %q, want no dinner on Sunday, Oct 11", h.Tonight, h.DateLabel)
|
||||
}
|
||||
}
|
||||
|
||||
// Tonight carries the dinner's id, name, photo, origin and minutes.
|
||||
func TestTonightDinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
e.planWeek(t)
|
||||
|
||||
h := e.at(t, "2026-10-13 18:00")
|
||||
if h.Tonight == nil || h.Tonight.ID == "" || h.Tonight.Origin != "regular" || h.Tonight.Photo != nil ||
|
||||
h.Tonight.MinutesTotal == nil || *h.Tonight.MinutesTotal != 30 {
|
||||
t.Errorf("tonight = %+v, want Tuesday's 30-minute Regular with no photo", h.Tonight)
|
||||
}
|
||||
h = e.at(t, "2026-10-14 18:00")
|
||||
if h.Tonight == nil || h.Tonight.Origin != "side_quest" || h.Tonight.MinutesTotal != nil {
|
||||
t.Errorf("tonight = %+v, want Wednesday's Side quest of unknown minutes", h.Tonight)
|
||||
}
|
||||
}
|
||||
|
||||
// With no plan, no suggestions, no Supply run and no notifications, Home
|
||||
// says so: no dinner tonight, nothing to swipe, the current week's Supply
|
||||
// run not ready and nothing unread.
|
||||
func TestHomeEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
|
||||
h := e.at(t, "2026-10-13 23:59")
|
||||
want := home.Home{
|
||||
DateLabel: "Tuesday, Oct 13",
|
||||
Timezone: zone,
|
||||
ShoppingList: home.ShoppingStatus{
|
||||
WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18",
|
||||
},
|
||||
}
|
||||
if h != want {
|
||||
t.Errorf("home = %+v, want %+v", h, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The Side quest count is the pending cards of the deck: a decided
|
||||
// suggestion is not counted.
|
||||
func TestSideQuestCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
batch, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := q.FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: batch.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"Pho", "Ramen", "Tagine"} {
|
||||
id := e.dinner(t, name, "suggested", "side_quest", 0)
|
||||
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
|
||||
ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: name, Reason: "Because", CitedRegularIds: "[]",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 3 {
|
||||
t.Errorf("sideQuestCount = %d, want 3", h.SideQuestCount)
|
||||
}
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE suggestions SET status = 'declined', decided_at = ? WHERE name = 'Pho'`, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 2 {
|
||||
t.Errorf("sideQuestCount after a Pass = %d, want 2", h.SideQuestCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Reading Home writes nothing: a batch still pending whose job is gone,
|
||||
// which a Deck read would fail, is left as it was, and the read succeeds.
|
||||
func TestGetDoesNotWriteBatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
created := "2026-10-13T12:00:00.000Z"
|
||||
batch, err := db.New(e.st.Writer).CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: created, UpdatedAt: created})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batch.Status != "pending" {
|
||||
t.Fatalf("new batch status = %q, want pending", batch.Status)
|
||||
}
|
||||
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 0 {
|
||||
t.Errorf("sideQuestCount = %d, want 0", h.SideQuestCount)
|
||||
}
|
||||
var status, updatedAt string
|
||||
if err := e.st.Reader.QueryRowContext(ctx, `SELECT status, updated_at FROM suggestion_batches WHERE id = ?`, batch.ID).Scan(&status, &updatedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "pending" || updatedAt != created {
|
||||
t.Errorf("batch after a Home read = %s at %s, want pending at %s", status, updatedAt, created)
|
||||
}
|
||||
}
|
||||
|
||||
// The Supply run shown is the upcoming week's when it has one, else the
|
||||
// current week's; the weeks are the household's local weeks.
|
||||
func TestShoppingStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
planID := e.planWeek(t)
|
||||
list := func(weekStart string, sent sql.NullString) {
|
||||
t.Helper()
|
||||
now := store.Now().String()
|
||||
if _, err := e.st.Writer.ExecContext(ctx,
|
||||
`INSERT INTO shopping_lists (id, week_start, plan_id, status, sent_to_chat_at, created_at, updated_at) VALUES (?, ?, ?, 'current', ?, ?, ?)`,
|
||||
store.NewID(), weekStart, planID, sent, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
list("2026-10-12", sql.NullString{})
|
||||
want := home.ShoppingStatus{WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18", Ready: true}
|
||||
if h := e.at(t, "2026-10-16 09:00"); h.ShoppingList != want {
|
||||
t.Errorf("shoppingList = %+v, want %+v", h.ShoppingList, want)
|
||||
}
|
||||
|
||||
sent := "2026-10-17T13:00:00.000Z"
|
||||
list("2026-10-19", sql.NullString{String: sent, Valid: true})
|
||||
h := e.at(t, "2026-10-16 09:00")
|
||||
if s := h.ShoppingList; s.WeekStart != "2026-10-19" || s.Label != "Week 43 · Oct 19 – 25" || !s.Ready || s.SentToChatAt == nil || *s.SentToChatAt != sent {
|
||||
t.Errorf("shoppingList = %+v, want the upcoming week's, sent at %s", s, sent)
|
||||
}
|
||||
|
||||
// Late Sunday evening locally is Monday in UTC: the current week is
|
||||
// still the week of 2026-10-12, so its upcoming week's list shows.
|
||||
if s := e.at(t, "2026-10-18 23:30").ShoppingList; s.WeekStart != "2026-10-19" {
|
||||
t.Errorf("late Sunday: shoppingList week = %s, want 2026-10-19", s.WeekStart)
|
||||
}
|
||||
// On Monday the week of 2026-10-19 is current and has no upcoming
|
||||
// list.
|
||||
if s := e.at(t, "2026-10-19 08:00").ShoppingList; s.WeekStart != "2026-10-19" || !s.Ready {
|
||||
t.Errorf("Monday: shoppingList = %+v, want the current week's", s)
|
||||
}
|
||||
// A week later neither the current nor the upcoming week has a list.
|
||||
want = home.ShoppingStatus{WeekStart: "2026-10-26", Label: "Week 44 · Oct 26 – Nov 1"}
|
||||
if s := e.at(t, "2026-10-26 08:00").ShoppingList; s != want {
|
||||
t.Errorf("no list: shoppingList = %+v, want %+v", s, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Recent updates are unread when a notification was stored after the
|
||||
// household last saw its updates, or ever when it never has.
|
||||
func TestRecentUpdatesUnread(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread {
|
||||
t.Error("unread with no notifications")
|
||||
}
|
||||
if err := e.notifier.Emit(ctx, notify.Event{Type: notify.NewSuggestions, Key: "new_suggestions:1", Title: "New Side quests", URL: "/discover"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread {
|
||||
t.Error("not unread with a notification never seen")
|
||||
}
|
||||
seen := func(at time.Time) {
|
||||
t.Helper()
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updates_seen_at = ?`, store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
seen(time.Now().Add(time.Hour))
|
||||
if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread {
|
||||
t.Error("unread with every notification seen")
|
||||
}
|
||||
seen(time.Now().Add(-time.Hour))
|
||||
if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread {
|
||||
t.Error("not unread with a notification stored after updates were seen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceRequiresServices(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := home.NewService(nil, nil, nil, nil, nil); err == nil {
|
||||
t.Fatal("NewService accepted nil services")
|
||||
}
|
||||
}
|
||||
|
||||
// store stores event key of type typ in the outbox at instant at, for each
|
||||
// of channels, as notify stores an event.
|
||||
func (e *env) store(t *testing.T, typ notify.EventType, key string, at time.Time, channels ...notify.Channel) {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(notify.Payload{Event: typ, Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key, Tag: key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()
|
||||
for _, ch := range channels {
|
||||
if _, err := db.New(e.st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{
|
||||
ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload),
|
||||
NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Updates are the last ten distinct events, newest first: an event stored
|
||||
// for push and Google Chat is listed once, whatever became of its delivery.
|
||||
func TestUpdates(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
got, err := e.svc.Updates(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || len(got) != 0 {
|
||||
t.Errorf("updates with no notifications = %#v, want an empty list", got)
|
||||
}
|
||||
|
||||
base := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
||||
for i := range 12 {
|
||||
key := fmt.Sprintf("plan_ready:%02d", i)
|
||||
e.store(t, notify.PlanReady, key, base.Add(time.Duration(i)*time.Minute), notify.ChannelPush, notify.ChannelChat)
|
||||
}
|
||||
// A delivered and a failed row of one event are still one event.
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'sent' WHERE event_key = 'plan_ready:11' AND channel = 'push'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'failed' WHERE event_key = 'plan_ready:11' AND channel = 'chat'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err = e.svc.Updates(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != home.RecentLimit {
|
||||
t.Fatalf("updates = %d, want %d: %+v", len(got), home.RecentLimit, got)
|
||||
}
|
||||
for i, u := range got {
|
||||
n := 11 - i
|
||||
key := fmt.Sprintf("plan_ready:%02d", n)
|
||||
want := home.Update{
|
||||
Type: "plan_ready", Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key,
|
||||
CreatedAt: store.Timestamp{Time: base.Add(time.Duration(n) * time.Minute)}.String(),
|
||||
}
|
||||
if u != want {
|
||||
t.Errorf("updates[%d] = %+v, want %+v", i, u, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An event emitted to push and Google Chat is one update.
|
||||
func TestUpdatesEmittedToTwoChannels(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
if err := e.notifier.Emit(ctx, notify.Event{Type: notify.PlanReady, Key: "plan_ready:2026-10-12", Title: "Your week is drafted", Body: "Take a look", URL: "/plan"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows int
|
||||
if err := e.st.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM notification_outbox`).Scan(&rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows != 2 {
|
||||
t.Fatalf("outbox rows = %d, want push and chat", rows)
|
||||
}
|
||||
got, err := e.svc.Updates(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Type != "plan_ready" || got[0].Title != "Your week is drafted" || got[0].Body != "Take a look" || got[0].URL != "/plan" {
|
||||
t.Errorf("updates = %+v, want the one plan_ready event", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Marking updates seen through the newest clears unread until a newer event
|
||||
// is stored; marking them seen through an older time does not move the
|
||||
// mark back, and a time after now is refused.
|
||||
func TestMarkUpdatesSeen(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
unread := func() bool {
|
||||
t.Helper()
|
||||
return e.at(t, "2026-10-13 12:00").RecentUpdates.Unread
|
||||
}
|
||||
first := time.Date(2026, 10, 13, 16, 0, 0, 0, time.UTC)
|
||||
e.store(t, notify.NewSuggestions, "new_suggestions:1", first, notify.ChannelPush)
|
||||
if !unread() {
|
||||
t.Fatal("not unread with an event never seen")
|
||||
}
|
||||
// The settings were last edited well before: marking updates seen must
|
||||
// keep that time and tell no settings subscriber.
|
||||
edited := (store.Timestamp{Time: time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)}).String()
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updated_at = ?`, edited); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var notified int
|
||||
unsubscribe := e.settings.Subscribe(func(household.Settings) { notified++ })
|
||||
defer unsubscribe()
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unread() {
|
||||
t.Error("unread after marking the newest event seen")
|
||||
}
|
||||
|
||||
second := first.Add(time.Millisecond)
|
||||
e.store(t, notify.PlanReady, "plan_ready:2", second, notify.ChannelPush, notify.ChannelChat)
|
||||
if !unread() {
|
||||
t.Error("not unread with an event stored after the mark")
|
||||
}
|
||||
// Seen again through the older event, as a sheet opened before the
|
||||
// newer one arrived would: nothing moves, so it stays unread.
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !unread() {
|
||||
t.Error("an older mark hid the newer event")
|
||||
}
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unread() {
|
||||
t.Error("unread after marking the newer event seen")
|
||||
}
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err := e.settings.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := (store.Timestamp{Time: second}).String(); settings.UpdatesSeenAt == nil || *settings.UpdatesSeenAt != want {
|
||||
t.Errorf("updates_seen_at = %v, want %s: an older mark moved it back", settings.UpdatesSeenAt, want)
|
||||
}
|
||||
if settings.UpdatedBy != nil {
|
||||
t.Errorf("updated_by = %q, want unset: marking updates seen is not a settings edit", *settings.UpdatedBy)
|
||||
}
|
||||
if settings.UpdatedAt != edited {
|
||||
t.Errorf("updated_at = %s, want %s: marking updates seen is not a settings edit", settings.UpdatedAt, edited)
|
||||
}
|
||||
if notified != 0 {
|
||||
t.Errorf("settings subscribers told %d times, want none: marking updates seen is not a settings edit", notified)
|
||||
}
|
||||
|
||||
// The clock reads 17:00 UTC.
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, first.Add(2*time.Hour)); !errors.Is(err, home.ErrSeenInFuture) {
|
||||
t.Errorf("mark seen through an hour from now = %v, want ErrSeenInFuture", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An event stored again for a channel switched on later is as new as that
|
||||
// row: the update shows its time, and marking it seen clears unread.
|
||||
func TestUpdateStoredAgainForAnotherChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
first := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
|
||||
later := first.Add(30 * time.Minute)
|
||||
e.store(t, notify.PlanReady, "plan_ready:1", first, notify.ChannelPush)
|
||||
e.store(t, notify.PlanReady, "plan_ready:1", later, notify.ChannelChat)
|
||||
|
||||
got, err := e.svc.Updates(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := (store.Timestamp{Time: later}).String(); len(got) != 1 || got[0].CreatedAt != want {
|
||||
t.Fatalf("updates = %+v, want the one event at %s", got, want)
|
||||
}
|
||||
e.at(t, "2026-10-13 12:00")
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, later); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.at(t, "2026-10-13 12:00").RecentUpdates.Unread {
|
||||
t.Error("unread after the event was marked seen through its listed time")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
@@ -200,6 +201,21 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// MarkUpdatesSeen records that the household has seen its Recent updates
|
||||
// through seenAt: updates_seen_at moves forward to seenAt, and is left as it
|
||||
// is when it is already at or after it. It is not a settings edit, so
|
||||
// updated_by and updated_at are kept and subscribers are not told.
|
||||
func (s *SettingsService) MarkUpdatesSeen(ctx context.Context, seenAt time.Time) error {
|
||||
if seenAt.IsZero() {
|
||||
return errors.New("household: mark updates seen: zero time")
|
||||
}
|
||||
_, err := db.New(s.st.Writer).MarkUpdatesSeen(ctx, store.Timestamp{Time: seenAt.UTC().Truncate(time.Millisecond)}.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: mark updates seen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyPatch applies a validated patch to the household settings row in q's
|
||||
// transaction, recording updatedBy, and returns the new row.
|
||||
func applyPatch(ctx context.Context, q *db.Queries, patch SettingsPatch, updatedBy string) (db.HouseholdSetting, error) {
|
||||
|
||||
@@ -190,6 +190,61 @@ func (s *Service) EmitTx(ctx context.Context, tx *sql.Tx, ev Event) error {
|
||||
})
|
||||
}
|
||||
|
||||
// StoredSince reports whether a notification was stored after since, or
|
||||
// at all when since is nil. An event counts once it is stored for any
|
||||
// channel, whatever became of its delivery; one whose preference switches
|
||||
// every channel off is never stored and never counts.
|
||||
func (s *Service) StoredSince(ctx context.Context, since *time.Time) (bool, error) {
|
||||
var after sql.NullString
|
||||
if since != nil {
|
||||
after = sql.NullString{String: timestamp(*since), Valid: true}
|
||||
}
|
||||
found, err := db.New(s.st.Reader).HasNotificationSince(ctx, after)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("notify: read notifications since %s: %w", after.String, err)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// Stored is one event as it was stored, whatever channels it went to.
|
||||
type Stored struct {
|
||||
Type EventType
|
||||
Title string
|
||||
Body string
|
||||
// URL is the path-absolute, same-origin path the event opens.
|
||||
URL string
|
||||
// CreatedAt is when the event was last stored, for any channel: the
|
||||
// instant StoredSince must be given for it not to count.
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Recent returns the last n distinct events stored, newest first. An event
|
||||
// stored for more than one channel is one event, as new as its newest row;
|
||||
// one whose preference switches every channel off is never stored and
|
||||
// never listed.
|
||||
func (s *Service) Recent(ctx context.Context, n int) ([]Stored, error) {
|
||||
if n <= 0 {
|
||||
return nil, fmt.Errorf("notify: list %d recent events: want a positive count", n)
|
||||
}
|
||||
rows, err := db.New(s.st.Reader).ListRecentNotifications(ctx, int64(n))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("notify: list recent events: %w", err)
|
||||
}
|
||||
out := make([]Stored, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var p Payload
|
||||
if err := json.Unmarshal([]byte(r.Payload), &p); err != nil {
|
||||
return nil, fmt.Errorf("notify: decode payload of %s: %w", r.EventKey, err)
|
||||
}
|
||||
created, err := parseTimestamp(r.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("notify: created_at of %s: %w", r.EventKey, err)
|
||||
}
|
||||
out = append(out, Stored{Type: p.Event, Title: p.Title, Body: p.Body, URL: p.URL, CreatedAt: created})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// emitTx stores ev in tx for each channel that channels chooses from the
|
||||
// preference of ev's type.
|
||||
func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels func(db.NotificationPref) ([]Channel, error)) error {
|
||||
|
||||
@@ -375,9 +375,9 @@ func TestDraftEmitsPlanReady(t *testing.T) {
|
||||
}
|
||||
for _, r := range rows {
|
||||
p := r.Payload
|
||||
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan" || p.Week != "2026-10-12" ||
|
||||
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan?week=2026-10-12" || p.Week != "2026-10-12" ||
|
||||
p.Title != want.Title || p.Body != "Beef Chili, Pot Roast, Pad Thai + 4 more" {
|
||||
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan", r.Channel, r, want.Title)
|
||||
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan?week=2026-10-12", r.Channel, r, want.Title)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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), newTestChat(t), newTestDinners(t), newTestSuggest(t),
|
||||
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ func (b *lockedBuffer) String() string {
|
||||
func newChatRouter(t *testing.T, svc *chat.Service, logs io.Writer) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -233,7 +233,7 @@ func TestChatWebhookNeedsCSRF(t *testing.T) {
|
||||
func TestNewRouterRequiresChat(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, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(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, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil Google Chat service")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,20 @@ func postDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// postPhotoBackfill starts a photo backfill over every dinner without a
|
||||
// photo and answers 202 with its job id, which GET /jobs/{id} reports on,
|
||||
// without waiting for the job.
|
||||
func postPhotoBackfill(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := svc.StartPhotoBackfill(r.Context())
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "start photo backfill", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusAccepted, jobCreated{ID: id})
|
||||
}
|
||||
}
|
||||
|
||||
// patchDinner edits a dinner, recording the session's actor as updated_by.
|
||||
func patchDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -98,7 +98,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), newTestChat(t),
|
||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv {
|
||||
}
|
||||
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), newTestChat(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -556,3 +556,43 @@ func TestPostDinnerEnqueueFailureAnswersFailedDinner(t *testing.T) {
|
||||
t.Errorf("listed = %v, want failed", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostPhotoBackfillGroundsFailedDinners(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
e.start(t)
|
||||
|
||||
// No model reply is scripted, so grounding on creation fails.
|
||||
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
id := created["id"].(string)
|
||||
waitJob(t, e.h, created["grounding_job_id"].(string))
|
||||
if d := listedDinner(t, e.h, id); d["grounding_status"] != "failed" || d["photo_url"] != nil {
|
||||
t.Fatalf("dinner = %v, want failed without a photo", d)
|
||||
}
|
||||
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
rec, started := callDinners(t, e.h, http.MethodPost, "/api/v1/settings/photo-backfill", "")
|
||||
jobID, _ := started["id"].(string)
|
||||
if rec.Code != http.StatusAccepted || jobID == "" {
|
||||
t.Fatalf("POST photo-backfill = %d: %s, want 202 with a job id", rec.Code, rec.Body)
|
||||
}
|
||||
job := waitJob(t, e.h, jobID)
|
||||
want := fmt.Sprintf(`map[dinners:[map[dinner_id:%s outcome:grounded reason:<nil>]]]`, id)
|
||||
if job["status"] != "succeeded" || job["kind"] != "backfill_dinner_photos" || fmt.Sprint(job["result"]) != want {
|
||||
t.Fatalf("job = %v, want backfill_dinner_photos succeeded with %s", job, want)
|
||||
}
|
||||
d := listedDinner(t, e.h, id)
|
||||
photo, _ := d["photo_url"].(string)
|
||||
if d["grounding_status"] != "grounded" || d["grounding_error"] != nil || !strings.HasSuffix(photo, "-400.jpg") {
|
||||
t.Errorf("dinner = %v, want grounded with a photo", d)
|
||||
}
|
||||
|
||||
// The backfill is started only by its own endpoint.
|
||||
rec, _ = callDinners(t, e.h, http.MethodPost, "/api/v1/jobs", `{"kind":"backfill_dinner_photos","payload":{}}`)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "service_only_kind") {
|
||||
t.Errorf("POST /jobs backfill = %d: %s, want 400 service_only_kind", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
|
||||
)
|
||||
|
||||
// getHome answers GET /home with the Home screen at the household's local
|
||||
// "now".
|
||||
func getHome(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h, err := svc.Get(r.Context())
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "read home", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, h)
|
||||
}
|
||||
}
|
||||
|
||||
// updatesBody is the GET /updates answer.
|
||||
type updatesBody struct {
|
||||
Updates []home.Update `json:"updates"`
|
||||
}
|
||||
|
||||
// getUpdates answers GET /updates with the Recent updates: the last
|
||||
// home.RecentLimit distinct notification events, newest first.
|
||||
func getUpdates(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updates, err := svc.Updates(r.Context())
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "read recent updates", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, updatesBody{Updates: updates})
|
||||
}
|
||||
}
|
||||
|
||||
// maxUpdatesSeenBody bounds a POST /updates/seen request body.
|
||||
const maxUpdatesSeenBody = 1 << 10
|
||||
|
||||
// updatesSeenRequest is the POST /updates/seen body: through is the
|
||||
// created_at of the newest update the household was shown.
|
||||
type updatesSeenRequest struct {
|
||||
Through *string `json:"through"`
|
||||
}
|
||||
|
||||
// postUpdatesSeen answers POST /updates/seen by recording that the
|
||||
// household has seen its updates through the body's through, and answers
|
||||
// 204. A through before the one already recorded changes nothing.
|
||||
func postUpdatesSeen(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := decodePatch[updatesSeenRequest](http.MaxBytesReader(w, r.Body, maxUpdatesSeenBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if req.Through == nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: required")
|
||||
return
|
||||
}
|
||||
through, err := time.Parse(time.RFC3339Nano, *req.Through)
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: want an RFC 3339 timestamp")
|
||||
return
|
||||
}
|
||||
err = svc.MarkUpdatesSeen(r.Context(), through)
|
||||
if errors.Is(err, home.ErrSeenInFuture) {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: must not be after now")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "mark recent updates seen", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// GET /home answers the Home screen in its JSON shape; newTestHome's
|
||||
// household (America/New_York) has no plan, no suggestions, no Supply run
|
||||
// and no notifications.
|
||||
func TestGetHome(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newTestRouter(t, "dev")
|
||||
loc, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// want is the answer at instant now; the request is read between two
|
||||
// instants, so it matches the answer at one of them.
|
||||
want := func(now time.Time) string {
|
||||
week := household.WeekStart(household.Today(now, loc))
|
||||
label, err := household.WeekLabel(week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"dateLabel": now.In(loc).Format("Monday, Jan 2"),
|
||||
"timezone": loc.String(),
|
||||
"tonight": nil,
|
||||
"sideQuestCount": 0,
|
||||
"shoppingList": map[string]any{"weekStart": week.String(), "label": label, "ready": false, "sentToChatAt": nil},
|
||||
"recentUpdates": map[string]any{"unread": false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
before := time.Now()
|
||||
rec, _ := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil))
|
||||
after := time.Now()
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
var got any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(canonical) != want(before) && string(canonical) != want(after) {
|
||||
t.Errorf("GET /api/v1/home = %s, want %s", canonical, want(after))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHomeNeedsSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/home", nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("GET /api/v1/home without a session = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// homeRouter returns a router whose Home reads st, and st.
|
||||
func homeRouter(t *testing.T) (http.Handler, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
svc := newTestHomeOn(t, st)
|
||||
return newTestRouterWithHome(t, "dev", newTestSettings(t), newTestRunner(t), svc), st
|
||||
}
|
||||
|
||||
// storeEvent stores event key in st's outbox at instant at for each of
|
||||
// channels, as notify stores an event, and returns at as stored.
|
||||
func storeEvent(t *testing.T, st *store.Store, typ notify.EventType, key, title string, at time.Time, channels ...notify.Channel) string {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(notify.Payload{Event: typ, Title: title, Body: "Body of " + key, URL: "/plan", Tag: key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()
|
||||
for _, ch := range channels {
|
||||
if _, err := db.New(st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{
|
||||
ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload),
|
||||
NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
// unread reads recentUpdates.unread from GET /home.
|
||||
func unread(t *testing.T, h http.Handler) bool {
|
||||
t.Helper()
|
||||
rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
updates, ok := body["recentUpdates"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("GET /api/v1/home has no recentUpdates: %s", rec.Body)
|
||||
}
|
||||
return updates["unread"] == true
|
||||
}
|
||||
|
||||
// markSeen posts body to /updates/seen and returns the answer.
|
||||
func markSeen(t *testing.T, h http.Handler, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)))
|
||||
return rec
|
||||
}
|
||||
|
||||
// GET /updates lists the last distinct events newest first, one per event
|
||||
// whatever its channels, in their JSON shape.
|
||||
func TestGetUpdates(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st := homeRouter(t)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil))
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != `{"updates":[]}` {
|
||||
t.Errorf("GET /api/v1/updates with none = %d %s, want 200 {\"updates\":[]}", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
older := storeEvent(t, st, notify.NewSuggestions, "new_suggestions:1", "New Side quests", now.Add(-2*time.Minute), notify.ChannelPush)
|
||||
newer := storeEvent(t, st, notify.PlanReady, "plan_ready:2026-10-12", "Your week is drafted", now.Add(-time.Minute), notify.ChannelPush, notify.ChannelChat)
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil))
|
||||
want := `{"updates":[` +
|
||||
`{"type":"plan_ready","title":"Your week is drafted","body":"Body of plan_ready:2026-10-12","url":"/plan","created_at":"` + newer + `"},` +
|
||||
`{"type":"new_suggestions","title":"New Side quests","body":"Body of new_suggestions:1","url":"/plan","created_at":"` + older + `"}]}`
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != want {
|
||||
t.Errorf("GET /api/v1/updates = %d %s, want 200 %s", rec.Code, rec.Body, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Unread is true while an event newer than the last seen one exists,
|
||||
// clears once POST /updates/seen marks the newest seen, and returns when a
|
||||
// newer event is stored; marking an older one seen again moves nothing.
|
||||
func TestUpdatesSeen(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st := homeRouter(t)
|
||||
now := time.Now()
|
||||
|
||||
if unread(t, h) {
|
||||
t.Error("unread with no events")
|
||||
}
|
||||
first := storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", now.Add(-2*time.Minute), notify.ChannelPush, notify.ChannelChat)
|
||||
if !unread(t, h) {
|
||||
t.Error("not unread with an event never seen")
|
||||
}
|
||||
if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
|
||||
t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body)
|
||||
}
|
||||
if unread(t, h) {
|
||||
t.Error("unread after the newest event was marked seen")
|
||||
}
|
||||
|
||||
second := storeEvent(t, st, notify.ListReady, "list_ready:2", "Supply run ready", now.Add(-time.Minute), notify.ChannelPush)
|
||||
if !unread(t, h) {
|
||||
t.Error("not unread with an event newer than the last seen")
|
||||
}
|
||||
// Already seen: marking the older event seen again is accepted and
|
||||
// changes nothing.
|
||||
if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("POST /api/v1/updates/seen of an older event = %d %s, want 204", rec.Code, rec.Body)
|
||||
}
|
||||
if !unread(t, h) {
|
||||
t.Error("marking an older event seen hid a newer one")
|
||||
}
|
||||
if rec := markSeen(t, h, `{"through":"`+second+`"}`); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body)
|
||||
}
|
||||
if unread(t, h) {
|
||||
t.Error("unread after the newer event was marked seen")
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed POST /updates/seen is 400 and records nothing.
|
||||
func TestUpdatesSeenRejectsMalformed(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st := homeRouter(t)
|
||||
storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", time.Now().Add(-time.Minute), notify.ChannelPush)
|
||||
|
||||
for _, body := range []string{
|
||||
``,
|
||||
`[]`,
|
||||
`{}`,
|
||||
`{"through":null}`,
|
||||
`{"through":42}`,
|
||||
`{"through":"yesterday"}`,
|
||||
`{"through":"2026-10-13 12:00:00"}`,
|
||||
`{"through":"2026-10-13T12:00:00.000Z","extra":true}`,
|
||||
`{"through":"2026-10-13T12:00:00.000Z"} {}`,
|
||||
`{"through":"` + time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano) + `"}`,
|
||||
} {
|
||||
rec := markSeen(t, h, body)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"invalid_request"`) {
|
||||
t.Errorf("POST /api/v1/updates/seen %q = %d %s, want 400 invalid_request", body, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
if !unread(t, h) {
|
||||
t.Error("a malformed POST /updates/seen marked updates seen")
|
||||
}
|
||||
}
|
||||
|
||||
// Both endpoints need a session, and POST /updates/seen the CSRF token.
|
||||
func TestUpdatesNeedSessionAndCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _ := homeRouter(t)
|
||||
body := `{"through":"2026-10-01T12:00:00.000Z"}`
|
||||
for _, req := range []*http.Request{
|
||||
httptest.NewRequest(http.MethodGet, "/api/v1/updates", nil),
|
||||
httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)),
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s without a session = %d, want 401", req.Method, req.URL.Path, rec.Code)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body))
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("POST /api/v1/updates/seen without the CSRF token = %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *reci
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||
dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), newTestPush(t), ideasSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), newTestPush(t), ideasSvc, newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestes
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), svc, newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t),
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t),
|
||||
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc,
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func newTestPushWith(t *testing.T) (*push.Service, *store.Store, vapid.KeyPair)
|
||||
func newPushRouter(t *testing.T, svc *push.Service) 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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestIdeas(t), newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestIdeas(t), newTestHome(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -103,7 +103,7 @@ func TestNewRouterRequiresPush(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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestIdeas(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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil push service")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"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/home"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
@@ -66,12 +67,13 @@ type RouteMounter interface {
|
||||
// /api/v1/settings/notifications/test-push; ideasSvc serves POST
|
||||
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with
|
||||
// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with
|
||||
// planSvc too, /cook-tonight; runner serves
|
||||
// planSvc too, /cook-tonight; homeSvc serves GET /api/v1/home, GET
|
||||
// /api/v1/updates and POST /api/v1/updates/seen; 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, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, ideasSvc *ideas.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, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, ideasSvc *ideas.Service, homeSvc *home.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
@@ -105,6 +107,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
if ideasSvc == nil {
|
||||
return nil, errors.New("server: nil ideas service")
|
||||
}
|
||||
if homeSvc == nil {
|
||||
return nil, errors.New("server: nil home service")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
}
|
||||
@@ -137,6 +142,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
sub.NotFound(notFound)
|
||||
}
|
||||
apiV1.Get("/me", me(logger))
|
||||
apiV1.Get("/home", getHome(logger, homeSvc))
|
||||
apiV1.Get("/updates", getUpdates(logger, homeSvc))
|
||||
apiV1.Post("/updates/seen", postUpdatesSeen(logger, homeSvc))
|
||||
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
||||
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
||||
apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings))
|
||||
@@ -154,6 +162,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Get("/settings/chat-webhook", getChatWebhook(logger, chatSvc))
|
||||
apiV1.Put("/settings/chat-webhook", putChatWebhook(logger, chatSvc))
|
||||
apiV1.Post("/settings/chat-webhook/test", postChatWebhookTest(logger, chatSvc))
|
||||
apiV1.Post("/settings/photo-backfill", postPhotoBackfill(logger, dinnerSvc))
|
||||
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
|
||||
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
|
||||
apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc))
|
||||
|
||||
@@ -22,8 +22,10 @@ import (
|
||||
"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/chat"
|
||||
"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/home"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
@@ -50,13 +52,19 @@ func newTestRouter(t *testing.T, version string) http.Handler {
|
||||
}
|
||||
|
||||
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler {
|
||||
t.Helper()
|
||||
return newTestRouterWithHome(t, version, settings, runner, newTestHome(t))
|
||||
}
|
||||
|
||||
// newTestRouterWithHome is newTestRouterWith serving Home from homeSvc.
|
||||
func newTestRouterWithHome(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner, homeSvc *home.Service) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
assets := fstest.MapFS{
|
||||
"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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), homeSvc, runner, newTestPhotos(t), assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -257,6 +265,71 @@ func newTestShopping(t *testing.T) *shopping.Service {
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestHome returns a home service whose services all share one
|
||||
// migrated temp database with zone America/New_York, with an unstarted job
|
||||
// runner, no model, and Google and Google Chat endpoints that refuse every
|
||||
// connection. One database keeps the routers that never ask for Home cheap.
|
||||
func newTestHome(t *testing.T) *home.Service {
|
||||
t.Helper()
|
||||
return newTestHomeOn(t, newTestStore(t))
|
||||
}
|
||||
|
||||
// newTestHomeOn returns a home service over st, a migrated database, which
|
||||
// it seeds with zone America/New_York and the seed notification preferences.
|
||||
func newTestHomeOn(t *testing.T, st *store.Store) *home.Service {
|
||||
t.Helper()
|
||||
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier := notifytest.New(t, st)
|
||||
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
pub := &url.URL{Scheme: "https", Host: "mealquest.example"}
|
||||
googleSvc, err := gcal.NewService(st, 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",
|
||||
CalendarAPI: "http://127.0.0.1:1/calendar/v3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||
sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
return skills.SideQuests{}, errors.New("no model in this test")
|
||||
}
|
||||
suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chatSvc, err := chat.NewService(st, newTestBox(t), pub, refuseTransport{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shop, err := shopping.NewService(st, untitled, runner, notifier, chatSvc, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := home.NewService(household.NewSettingsService(st), planSvc, suggestSvc, shop, notifier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// untitled is a shopping.Titler with no model: every Supply run it is
|
||||
// asked to title stays untitled.
|
||||
func untitled(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||
@@ -317,7 +390,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, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||
}
|
||||
}
|
||||
@@ -326,7 +399,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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||
}
|
||||
}
|
||||
@@ -335,7 +408,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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted nil photo files")
|
||||
}
|
||||
}
|
||||
@@ -344,7 +417,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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
@@ -353,7 +426,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), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(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), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil dinners service")
|
||||
}
|
||||
}
|
||||
@@ -362,7 +435,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), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(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), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil suggestions service")
|
||||
}
|
||||
}
|
||||
@@ -371,7 +444,7 @@ 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), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(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), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil plan service")
|
||||
}
|
||||
}
|
||||
@@ -380,7 +453,7 @@ func TestNewRouterRequiresShopping(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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestIdeas(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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil shopping service")
|
||||
}
|
||||
}
|
||||
@@ -389,16 +462,25 @@ func TestNewRouterRequiresIdeas(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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil ideas service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresHome(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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil home 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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestIdeas(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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil Google Calendar service")
|
||||
}
|
||||
}
|
||||
@@ -407,7 +489,7 @@ 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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil job runner")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,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), newTestChat(t),
|
||||
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -37,9 +37,9 @@ func TestGenerateEmitsListReadyOnce(t *testing.T) {
|
||||
}
|
||||
for _, r := range rows {
|
||||
p := r.Payload
|
||||
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping" || p.Week != "2026-10-12" ||
|
||||
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping?week=2026-10-12" || p.Week != "2026-10-12" ||
|
||||
p.Title != "This week's Supply run is ready" || p.Body != "3 items to buy for 2 dinners" {
|
||||
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping", r.Channel, r)
|
||||
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping?week=2026-10-12", r.Channel, r)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -366,6 +366,17 @@ func (s *Service) Deck(ctx context.Context) (Deck, error) {
|
||||
return deck, nil
|
||||
}
|
||||
|
||||
// PendingCount is how many Side quests are pending on the deck. Unlike
|
||||
// Deck it only reads: it does not fail orphaned batches, so a reader such
|
||||
// as Home never writes.
|
||||
func (s *Service) PendingCount(ctx context.Context) (int, error) {
|
||||
rows, err := db.New(s.st.Reader).ListDeck(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("suggest: list deck: %w", err)
|
||||
}
|
||||
return len(rows), nil
|
||||
}
|
||||
|
||||
// Decision is a suggestion once decided, with the id of the replenish
|
||||
// batch's job when the decision called for one: the batch it started, or
|
||||
// the one already pending or running that it joined.
|
||||
|
||||
@@ -181,3 +181,65 @@ WHERE grounding_status = 'pending'
|
||||
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
|
||||
AND json_extract(jobs.payload, '$.revision') = dinners.grounding_revision
|
||||
);
|
||||
|
||||
-- name: ListPhotoBackfillDinners :many
|
||||
-- The dinners a photo backfill grounds by name: unarchived, failed, with no
|
||||
-- photo and no pasted recipe_url (the household's own link is never
|
||||
-- replaced by a search), ordered by normalized name. A non-NULL id narrows
|
||||
-- the list to that dinner. Grounded, manual and pending dinners are never
|
||||
-- listed: they have a recipe, or a ground_dinner job grounding them.
|
||||
SELECT * FROM dinners
|
||||
WHERE status <> 'archived'
|
||||
AND grounding_status = 'failed'
|
||||
AND photo_id IS NULL
|
||||
AND recipe_url IS NULL
|
||||
AND id = COALESCE(sqlc.narg(id), id)
|
||||
ORDER BY normalized_name, id;
|
||||
|
||||
-- name: GetActivePhotoBackfillJob :one
|
||||
-- The id of the full photo backfill job (no dinner_id) of kind queued or
|
||||
-- running. StartPhotoBackfill enqueues one only when there is none, so
|
||||
-- there is at most one. Returns sql.ErrNoRows when there is none.
|
||||
SELECT id
|
||||
FROM jobs
|
||||
WHERE kind = sqlc.arg(kind)
|
||||
AND status IN ('queued', 'running')
|
||||
AND json_extract(payload, '$.dinner_id') IS NULL
|
||||
ORDER BY id
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GroundFailedDinner :execrows
|
||||
-- Records a photo backfill's grounding of a dinner it listed, while the
|
||||
-- dinner is still unarchived, failed, photoless, without a recipe_url and at
|
||||
-- the grounding_revision it was listed at; a dinner grounded by hand,
|
||||
-- renamed or archived meanwhile is left as it is (0 rows). Like
|
||||
-- GroundPendingDinner it keeps a method, minutes_total or difficulty a
|
||||
-- member already set.
|
||||
UPDATE dinners
|
||||
SET recipe_id = sqlc.arg(recipe_id),
|
||||
photo_id = sqlc.arg(photo_id),
|
||||
method = COALESCE(method, sqlc.arg(method)),
|
||||
minutes_total = COALESCE(minutes_total, sqlc.arg(minutes_total)),
|
||||
difficulty = COALESCE(difficulty, sqlc.arg(difficulty)),
|
||||
grounding_status = 'grounded',
|
||||
grounding_error = NULL,
|
||||
updated_at = sqlc.arg(updated_at)
|
||||
WHERE id = sqlc.arg(id)
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'failed'
|
||||
AND photo_id IS NULL
|
||||
AND recipe_url IS NULL
|
||||
AND grounding_revision = sqlc.arg(grounding_revision);
|
||||
|
||||
-- name: RefailDinnerGrounding :execrows
|
||||
-- Records why a photo backfill could not ground a dinner it listed, under
|
||||
-- the same conditions as GroundFailedDinner (0 rows otherwise).
|
||||
UPDATE dinners
|
||||
SET grounding_error = sqlc.arg(grounding_error),
|
||||
updated_at = sqlc.arg(updated_at)
|
||||
WHERE id = sqlc.arg(id)
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'failed'
|
||||
AND photo_id IS NULL
|
||||
AND recipe_url IS NULL
|
||||
AND grounding_revision = sqlc.arg(grounding_revision);
|
||||
|
||||
@@ -66,3 +66,28 @@ SELECT * FROM notification_prefs;
|
||||
UPDATE notification_prefs
|
||||
SET push = ?, chat = ?, updated_at = ?
|
||||
WHERE event = ?;
|
||||
|
||||
-- name: HasNotificationSince :one
|
||||
-- Reports whether a notification was stored after since, or at all when
|
||||
-- since is NULL.
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM notification_outbox
|
||||
WHERE CAST(sqlc.narg(since) AS TEXT) IS NULL OR created_at > CAST(sqlc.narg(since) AS TEXT)
|
||||
) AS found;
|
||||
|
||||
-- name: ListRecentNotifications :many
|
||||
-- Returns the last lim distinct events stored, newest first: one row per
|
||||
-- event key, the last stored for it whatever its channel, so an event
|
||||
-- stored for push and Google Chat is one row. The last, because a channel
|
||||
-- switched on later can store the same event key again, and
|
||||
-- HasNotificationSince counts that row: an event is as new as its newest
|
||||
-- row, so marking it seen also covers that row.
|
||||
SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o
|
||||
WHERE o.id = (
|
||||
SELECT i.id FROM notification_outbox i
|
||||
WHERE i.event_key = o.event_key
|
||||
ORDER BY i.created_at DESC, i.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
ORDER BY o.created_at DESC, o.event_key DESC
|
||||
LIMIT sqlc.arg(lim);
|
||||
|
||||
@@ -154,3 +154,11 @@ RETURNING *;
|
||||
UPDATE chat_settings
|
||||
SET last_test_at = ?, last_test_status = ?, updated_at = ?
|
||||
WHERE id = 1 AND webhook_url_ct = ?;
|
||||
|
||||
-- name: MarkUpdatesSeen :execrows
|
||||
-- Moves updates_seen_at forward to seen_at; affects no row when it is
|
||||
-- already at or after seen_at, so it never moves back. It is not a settings
|
||||
-- edit: updated_by and updated_at are left as they are.
|
||||
UPDATE household_settings
|
||||
SET updates_seen_at = CAST(sqlc.arg(seen_at) AS TEXT)
|
||||
WHERE id = 1 AND (updates_seen_at IS NULL OR updates_seen_at < CAST(sqlc.arg(seen_at) AS TEXT));
|
||||
|
||||
+28
-8
@@ -5,7 +5,7 @@ import { createNetworkStatus } from './pwa/networkStatus.ts'
|
||||
import { mockViewport } from './test/matchMedia.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
|
||||
import App, { routes } from './App.tsx'
|
||||
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { AI_SETTINGS, HOME, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { InDataRouter } from './test/dataRouter.tsx'
|
||||
import { dinnerBody } from './test/dinners.ts'
|
||||
import { defaultWeek } from './plan/week.ts'
|
||||
@@ -30,6 +30,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
|
||||
|
||||
// The routes that render a built screen rather than a placeholder page.
|
||||
const SCREENS = {
|
||||
'/': true,
|
||||
'/onboarding': true,
|
||||
'/plan': true,
|
||||
'/plan/shopping': true,
|
||||
@@ -85,6 +86,16 @@ describe('App routes', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('/ renders the Home screen inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME), '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }) })
|
||||
renderAt('/')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/settings renders the settings home inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
@@ -262,12 +273,19 @@ describe('App bootstrap', () => {
|
||||
|
||||
it('reports a /me failure with a retry instead of rendering the routes', async () => {
|
||||
mockViewport(390)
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
const mes = [errorResponse(500, 'internal'), jsonResponse(200, ME)]
|
||||
stubFetchByPath({
|
||||
...SIGNED_IN,
|
||||
'/api/v1/me': () => mes.shift()!,
|
||||
'/api/v1/home': () => jsonResponse(200, HOME),
|
||||
'/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }),
|
||||
})
|
||||
renderAt('/')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||
screen.getByRole('button', { name: 'Retry' }).click()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -282,9 +300,9 @@ describe('App offline bootstrap', () => {
|
||||
// visitOnline is an earlier online visit on this device: /me answers with a
|
||||
// member, then the app is closed.
|
||||
async function visitOnline() {
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME), '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }) })
|
||||
const { unmount } = renderAt('/')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
unmount()
|
||||
}
|
||||
|
||||
@@ -295,14 +313,16 @@ describe('App offline bootstrap', () => {
|
||||
})
|
||||
|
||||
// A test that emits an offline event leaves TanStack Query's online
|
||||
// manager, shared by every test, offline until an online event.
|
||||
// manager, shared by every test, offline until an online event. The
|
||||
// manager listens for it only while a query client is mounted, so the
|
||||
// event goes out before cleanup unmounts the app.
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
window.dispatchEvent(new Event('online'))
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/', 'Home'],
|
||||
['/', "Tonight's quest"],
|
||||
['/settings/notifications', 'Notifications'],
|
||||
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
||||
await visitOnline()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx'
|
||||
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
||||
import { HomePage } from './home/HomePage.tsx'
|
||||
import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx'
|
||||
import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx'
|
||||
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
||||
@@ -45,6 +46,7 @@ export const routes = [
|
||||
|
||||
// The screens built so far; every other route shows its placeholder page.
|
||||
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/': <HomePage />,
|
||||
'/plan': <PlanPage />,
|
||||
'/plan/shopping': <SupplyRunPage />,
|
||||
'/plan/shopping/archive': <SupplyArchivePage />,
|
||||
|
||||
@@ -52,6 +52,8 @@ function listed(is_favorite: boolean) {
|
||||
is_favorite,
|
||||
photo_url: null,
|
||||
grounding_status: 'grounded',
|
||||
grounding_error: null,
|
||||
photo_backfill_eligible: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-2
@@ -20,8 +20,12 @@ export type Difficulty = (typeof DIFFICULTIES)[number]
|
||||
export type GroundingStatus = (typeof GROUNDING_STATUSES)[number]
|
||||
|
||||
// Regular is the part of a dinner the Regulars screens read. photo_id is
|
||||
// the stored photo's id, from photo_url. pending is true while an
|
||||
// optimistic add waits for the server; its id is not the server's.
|
||||
// the stored photo's id, from photo_url. grounding_error is why its
|
||||
// grounding failed, a reason code, set only while grounding_status is
|
||||
// failed. photo_backfill_eligible is whether a photo backfill (Search
|
||||
// again) searches for it: never for a dinner with a pasted recipe link.
|
||||
// pending is true while an optimistic add waits for the server;
|
||||
// its id is not the server's.
|
||||
export type Regular = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -31,6 +35,8 @@ export type Regular = {
|
||||
is_favorite: boolean
|
||||
photo_id: string | undefined
|
||||
grounding_status: GroundingStatus
|
||||
grounding_error: string | null
|
||||
photo_backfill_eligible: boolean
|
||||
pending?: true
|
||||
}
|
||||
|
||||
@@ -113,6 +119,9 @@ export function parseDinner(where: string, body: unknown): Regular {
|
||||
throw new Error(`${where}: response is not a dinner`)
|
||||
}
|
||||
if (typeof body.is_favorite !== 'boolean') throw new Error(`${where}: is_favorite is not a boolean`)
|
||||
if (typeof body.photo_backfill_eligible !== 'boolean') {
|
||||
throw new Error(`${where}: photo_backfill_eligible is not a boolean`)
|
||||
}
|
||||
return {
|
||||
id: body.id,
|
||||
name: body.name,
|
||||
@@ -122,6 +131,11 @@ export function parseDinner(where: string, body: unknown): Regular {
|
||||
is_favorite: body.is_favorite,
|
||||
photo_id: photoId(where, body.photo_url),
|
||||
grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status),
|
||||
grounding_error: nullable(body.grounding_error, (v) => {
|
||||
if (typeof v !== 'string') throw new Error(`${where}: grounding_error is not a string`)
|
||||
return v
|
||||
}),
|
||||
photo_backfill_eligible: body.photo_backfill_eligible,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +239,8 @@ export function useAddRegular() {
|
||||
is_favorite: false,
|
||||
photo_id: undefined,
|
||||
grounding_status: 'pending',
|
||||
grounding_error: null,
|
||||
photo_backfill_eligible: false,
|
||||
pending: true,
|
||||
}
|
||||
await changeOptimistically(queryClient, (list) => [...list, optimistic])
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/* Home, handoff mock 1g. */
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* The bell's 8 px grape unread dot sits on the 40 px face, which is
|
||||
centred in the IconButton's 44 px hit box. */
|
||||
.bell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bell .bellIcon {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 11px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 32px;
|
||||
background: var(--color-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.photo {
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.cardFoot {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 2px 18px 16px;
|
||||
}
|
||||
|
||||
.cardText {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* The dark "Recipe" pill: the mock's 38 px pill at the 44 px tap minimum. */
|
||||
.recipe {
|
||||
flex: none;
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
box-sizing: border-box;
|
||||
height: 104px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
border-radius: 26px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.quick {
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.pantry {
|
||||
background: var(--color-accent-2-300);
|
||||
color: var(--color-accent-2-900);
|
||||
}
|
||||
|
||||
.tileLabel {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 16px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.statuses {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.status {
|
||||
box-sizing: border-box;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 20px;
|
||||
background: var(--color-neutral-100);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.status .statusIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.statusText {
|
||||
flex: 1;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tile:focus-visible,
|
||||
.status:focus-visible {
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.skeletonDate {
|
||||
width: 96px;
|
||||
height: 14px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.skeletonCard {
|
||||
height: 238px;
|
||||
border-radius: 32px;
|
||||
}
|
||||
|
||||
.skeletonStatus {
|
||||
height: 44px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProviders, HOME } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { errorResponse, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { HOME_PATH } from './api.ts'
|
||||
import { HomePage } from './HomePage.tsx'
|
||||
import { ROTATION_REVIEWS_PATH } from './rotation.ts'
|
||||
|
||||
// NOTHING_YET is the home of a household with no plan, no Side quests, no
|
||||
// Supply run and nothing unread.
|
||||
const NOTHING_YET = {
|
||||
...HOME,
|
||||
tonight: null,
|
||||
sideQuestCount: 0,
|
||||
shoppingList: { weekStart: '2026-09-28', label: 'Week 40 · Sep 28 – Oct 4', ready: false, sentToChatAt: null },
|
||||
recentUpdates: { unread: false },
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// Where shows the route a link went to.
|
||||
function Where() {
|
||||
const location = useLocation()
|
||||
return <p data-testid="where">{location.pathname + location.search}</p>
|
||||
}
|
||||
|
||||
function renderHome(body: unknown) {
|
||||
stubFetchByPath({ [HOME_PATH]: () => jsonResponse(200, body), [ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }) })
|
||||
return renderRouter()
|
||||
}
|
||||
|
||||
function renderRouter() {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{ path: '/', element: <HomePage /> },
|
||||
{ path: '*', element: <Where /> },
|
||||
],
|
||||
{ initialEntries: ['/'] },
|
||||
)
|
||||
render(
|
||||
<ApiProviders>
|
||||
<RouterProvider router={router} />
|
||||
</ApiProviders>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('HomePage', () => {
|
||||
it('renders mock 1g from GET /api/v1/home', async () => {
|
||||
renderHome(HOME)
|
||||
|
||||
expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
|
||||
const tonight = screen.getByRole('region', { name: 'Tonight' })
|
||||
expect(within(tonight).getByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
expect(within(tonight).getByText('Regular · 40 min')).toBeInTheDocument()
|
||||
expect(within(tonight).getByRole('img', { name: 'Pizza night' })).toHaveAttribute('src', '/media/photos/p1-400.jpg')
|
||||
expect(within(tonight).getByRole('link', { name: 'Recipe' })).toHaveAttribute('href', '/dinner/d1')
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Quick dinner' })).toHaveAttribute('href', '/quick')
|
||||
expect(screen.getByRole('link', { name: 'Cook from what I have' })).toHaveAttribute('href', '/cook-from-pantry')
|
||||
expect(screen.getByRole('link', { name: '10 new side quests to swipe' })).toHaveAttribute('href', '/discover')
|
||||
expect(screen.getByRole('link', { name: 'Shopping list ready · Oct 5–11' })).toHaveAttribute(
|
||||
'href',
|
||||
'/plan/shopping?week=2026-10-05',
|
||||
)
|
||||
expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("reads Home again at the household's next local midnight, so a Home left open moves to the new day", async () => {
|
||||
// setTimeout moves with real time, and the test jumps to midnight.
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'], shouldAdvanceTime: true })
|
||||
// 23:58 on Friday Oct 2 in New York, the household's zone.
|
||||
vi.setSystemTime(new Date('2026-10-03T03:58:00Z'))
|
||||
const days = [HOME, { ...HOME, dateLabel: 'Saturday, Oct 3', tonight: null }]
|
||||
let reads = 0
|
||||
stubFetchByPath({
|
||||
[HOME_PATH]: () => jsonResponse(200, days[Math.min(reads++, 1)]),
|
||||
[ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }),
|
||||
})
|
||||
renderRouter()
|
||||
expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument()
|
||||
expect(reads).toBe(1)
|
||||
|
||||
// A minute before midnight Home is not read again.
|
||||
await act(() => vi.advanceTimersByTimeAsync(60_000))
|
||||
expect(reads).toBe(1)
|
||||
// At midnight it is, and shows the new day.
|
||||
await act(() => vi.advanceTimersByTimeAsync(60_000))
|
||||
expect(await screen.findByText('Saturday, Oct 3')).toBeInTheDocument()
|
||||
expect(reads).toBe(2)
|
||||
expect(screen.getByRole('heading', { name: 'No quest planned tonight' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the grape unread dot on the bell when there are unread updates', async () => {
|
||||
renderHome(HOME)
|
||||
|
||||
const bell = await screen.findByRole('button', { name: 'Recent updates, unread' })
|
||||
const dot = within(bell).getByTestId('unread-dot')
|
||||
expect(declaredStyle(dot).background).toBe('var(--color-accent)')
|
||||
expect(declaredStyle(dot).width).toBe('8px')
|
||||
expectMinHitArea(bell)
|
||||
})
|
||||
|
||||
it('shows the bell without a dot when every update is seen', async () => {
|
||||
renderHome(NOTHING_YET)
|
||||
|
||||
const bell = await screen.findByRole('button', { name: 'Recent updates' })
|
||||
expect(within(bell).queryByTestId('unread-dot')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('labels a Side quest tonight "Side quest"', async () => {
|
||||
renderHome({ ...HOME, tonight: { ...HOME.tonight, origin: 'side_quest', minutes_total: 30 } })
|
||||
expect(await screen.findByText('Side quest · 30 min')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only the origin when tonight has no minutes', async () => {
|
||||
renderHome({ ...HOME, tonight: { ...HOME.tonight, minutes_total: null, photo: null } })
|
||||
const tonight = await screen.findByRole('region', { name: 'Tonight' })
|
||||
expect(within(tonight).getByText('Regular')).toBeInTheDocument()
|
||||
// No photo: the striped placeholder, named for the dinner.
|
||||
expect(within(tonight).getByRole('img', { name: 'Pizza night' })).toHaveTextContent('dish photo')
|
||||
})
|
||||
|
||||
it('offers to plan the week when tonight has no plan, and hides the rows that do not apply', async () => {
|
||||
renderHome(NOTHING_YET)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'No quest planned tonight' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /side quests? to swipe/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
// The tiles stay.
|
||||
expect(screen.getByRole('link', { name: 'Quick dinner' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Plan this week' }))
|
||||
expect(await screen.findByTestId('where')).toHaveTextContent('/plan')
|
||||
})
|
||||
|
||||
it('hides the Side quests row at zero and keeps a ready Supply run', async () => {
|
||||
renderHome({ ...HOME, sideQuestCount: 0 })
|
||||
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list ready · Oct 5–11' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /side quests? to swipe/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the Supply run row when no list is ready', async () => {
|
||||
renderHome({ ...HOME, shoppingList: NOTHING_YET.shoppingList })
|
||||
|
||||
expect(await screen.findByRole('link', { name: '10 new side quests to swipe' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('counts a single Side quest in the singular', async () => {
|
||||
renderHome({ ...HOME, sideQuestCount: 1 })
|
||||
expect(await screen.findByRole('link', { name: '1 new side quest to swipe' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a week spanning two months in full', async () => {
|
||||
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, weekStart: '2026-09-28', label: 'Week 40 · Sep 28 – Oct 4' } })
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list ready · Sep 28–Oct 4' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Recipe', '/dinner/d1'],
|
||||
['Quick dinner', '/quick'],
|
||||
['Cook from what I have', '/cook-from-pantry'],
|
||||
['10 new side quests to swipe', '/discover'],
|
||||
['Shopping list ready · Oct 5–11', '/plan/shopping?week=2026-10-05'],
|
||||
])('%s goes to %s', async (name, to) => {
|
||||
renderHome(HOME)
|
||||
|
||||
fireEvent.click(await screen.findByRole('link', { name }))
|
||||
expect(await screen.findByTestId('where')).toHaveTextContent(to)
|
||||
})
|
||||
|
||||
it('styles the card, tiles and rows as mock 1g with tap targets of at least 44 px', async () => {
|
||||
renderHome(HOME)
|
||||
|
||||
const tonight = await screen.findByRole('region', { name: 'Tonight' })
|
||||
expect(declaredStyle(tonight).borderRadius).toBe('32px')
|
||||
expect(declaredStyle(within(tonight).getByRole('img')).height).toBe('150px')
|
||||
expect(declaredStyle(within(tonight).getByRole('heading')).fontSize).toBe('22px')
|
||||
|
||||
const quick = screen.getByRole('link', { name: 'Quick dinner' })
|
||||
expect(declaredStyle(quick).height).toBe('104px')
|
||||
expect(declaredStyle(quick).background).toBe('var(--color-accent)')
|
||||
expect(declaredStyle(quick).color).toBe('var(--on-accent)')
|
||||
const pantry = screen.getByRole('link', { name: 'Cook from what I have' })
|
||||
expect(declaredStyle(pantry).height).toBe('104px')
|
||||
expect(declaredStyle(pantry).background).toBe('var(--color-accent-2-300)')
|
||||
|
||||
expect(declaredStyle(screen.getByText('Friday, Oct 2')).fontSize).toBe('12px')
|
||||
|
||||
expectMinHitArea(within(tonight).getByRole('link', { name: 'Recipe' }))
|
||||
// The tiles are 104 px tall (above); the status rows fill the width at 44 px or more.
|
||||
for (const row of within(screen.getByRole('list', { name: 'Status' })).getAllByRole('link')) {
|
||||
expect(declaredStyle(row).minHeight).toBe('44px')
|
||||
}
|
||||
})
|
||||
|
||||
it('shows skeletons while the home loads', async () => {
|
||||
let answer!: (response: Response) => void
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>(() => new Promise((resolve) => (answer = resolve))),
|
||||
)
|
||||
renderRouter()
|
||||
|
||||
expect(screen.getByTestId('skeleton-tonight')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('skeleton-date')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('skeleton-status')).toHaveLength(2)
|
||||
expect(screen.getByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
|
||||
answer(jsonResponse(200, HOME))
|
||||
expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('skeleton-tonight')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('skeleton-status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a failure with Retry, and no made-up content', async () => {
|
||||
const homes = [errorResponse(500, 'internal'), jsonResponse(200, HOME)]
|
||||
stubFetchByPath({
|
||||
[HOME_PATH]: () => homes.shift()!,
|
||||
[ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }),
|
||||
})
|
||||
renderRouter()
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load tonight's quest. internal message")
|
||||
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a home it cannot read rather than guessing', async () => {
|
||||
renderHome({ ...HOME, tonight: { ...HOME.tonight, origin: 'leftovers' } })
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('origin "leftovers" is not one of regular, side_quest')
|
||||
})
|
||||
|
||||
it.each([0, -1, 2.5])('reports tonight with minutes_total %s rather than showing it', async (minutes) => {
|
||||
renderHome({ ...HOME, tonight: { ...HOME.tonight, minutes_total: minutes } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
`tonight's minutes_total ${minutes} is not a positive whole number`,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/min$/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports tonight without a minutes_total rather than showing it as unknown', async () => {
|
||||
const { minutes_total: _, ...tonight } = HOME.tonight
|
||||
renderHome({ ...HOME, tonight })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('tonight has no minutes_total')
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Regular')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a missing', undefined],
|
||||
['an empty', ''],
|
||||
])('reports a ready Supply run with %s label rather than showing it', async (_, label) => {
|
||||
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, label } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('the ready shoppingList has no label')
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a ready Supply run whose label is not its week rather than showing it', async () => {
|
||||
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, label: 'Week 40 · Sep 28 – Oct 4' } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'shoppingList label "Week 40 · Sep 28 – Oct 4" is not the week of 2026-10-05',
|
||||
)
|
||||
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a missing', undefined],
|
||||
['an empty', ''],
|
||||
])('reports an unready Supply run with %s label rather than showing the home', async (_, label) => {
|
||||
renderHome({ ...HOME, shoppingList: { ...NOTHING_YET.shoppingList, label } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('the unready shoppingList has no label')
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports an unready Supply run whose label is not its week rather than showing the home', async () => {
|
||||
renderHome({ ...HOME, shoppingList: { ...NOTHING_YET.shoppingList, label: 'Week 41 · Oct 5 – 11' } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'shoppingList label "Week 41 · Oct 5 – 11" is not the week of 2026-09-28',
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a Supply run whose weekStart is not a date rather than showing it', async () => {
|
||||
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, weekStart: 'Oct 5' } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('not a YYYY-MM-DD date')
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Bell, ChevronRight, Compass, Search, ShoppingCart, Zap, type LucideIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { duration } from '../plan/PlanPage.tsx'
|
||||
import { supplyRunScreen } from '../shopping/api.ts'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
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 { useHome, type Home, type Origin, type Tonight } from './api.ts'
|
||||
import styles from './HomePage.module.css'
|
||||
import { RotationCheck } from './RotationCheck.tsx'
|
||||
import { UpdatesSheet } from './UpdatesSheet.tsx'
|
||||
|
||||
const ORIGIN_LABEL: Record<Origin, string> = { regular: 'Regular', side_quest: 'Side quest' }
|
||||
|
||||
// meta is the Tonight card's "Regular · 40 min" line, the time only when
|
||||
// the recipe says it.
|
||||
function meta({ origin, minutes_total }: Tonight): string {
|
||||
return minutes_total === null ? ORIGIN_LABEL[origin] : `${ORIGIN_LABEL[origin]} · ${duration(minutes_total)}`
|
||||
}
|
||||
|
||||
// TonightCard is the dinner planned for today: its washed photo, name and
|
||||
// meta line, and the dark Recipe pill to its recipe.
|
||||
function TonightCard({ tonight }: { tonight: Tonight }) {
|
||||
return (
|
||||
<section aria-label="Tonight" className={styles.card}>
|
||||
<Photo id={tonight.photo_id} name={tonight.name} inset="10px" sizes="400px" className={styles.photo} />
|
||||
<div className={styles.cardFoot}>
|
||||
<div className={styles.cardText}>
|
||||
<h2 className={styles.name}>{tonight.name}</h2>
|
||||
<span className={styles.meta}>{meta(tonight)}</span>
|
||||
</div>
|
||||
<Link to={`/dinner/${encodeURIComponent(tonight.id)}`} className={[buttonStyles.button, buttonStyles.dark, styles.recipe].join(' ')}>
|
||||
Recipe
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// NoPlan stands in for the Tonight card when today has no plan.
|
||||
function NoPlan() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<EmptyState title="No quest planned tonight" action={{ label: 'Plan this week', onClick: () => void navigate('/plan') }}>
|
||||
Draft this week's dinners and tonight's quest shows up here.
|
||||
</EmptyState>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// StatusRow is one neutral row under the tiles: a grape-700 icon, its line
|
||||
// and a chevron, linking to the screen it is about.
|
||||
function StatusRow({ to, icon, children }: { to: string; icon: LucideIcon; children: string }) {
|
||||
return (
|
||||
<li>
|
||||
<Link to={to} className={styles.status}>
|
||||
<Icon icon={icon} className={styles.statusIcon} />
|
||||
<span className={styles.statusText}>{children}</span>
|
||||
<Icon icon={ChevronRight} />
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// StatusRows are the rows that apply: Side quests waiting in the deck, and
|
||||
// a Supply run ready. With neither, there are no rows.
|
||||
function StatusRows({ home }: { home: Home }) {
|
||||
const count = home.sideQuestCount
|
||||
const list = home.shoppingList
|
||||
if (count === 0 && !list.ready) return null
|
||||
return (
|
||||
<ul aria-label="Status" className={styles.statuses}>
|
||||
{count > 0 && (
|
||||
<StatusRow to="/discover" icon={Compass}>
|
||||
{`${count} new side ${count === 1 ? 'quest' : 'quests'} to swipe`}
|
||||
</StatusRow>
|
||||
)}
|
||||
{list.ready && (
|
||||
<StatusRow to={supplyRunScreen(list.weekStart)} icon={ShoppingCart}>
|
||||
{`Shopping list ready · ${list.dates}`}
|
||||
</StatusRow>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
// Tiles are the two shortcuts, Quick dinner and Cook from what I have.
|
||||
function Tiles() {
|
||||
return (
|
||||
<div className={styles.tiles}>
|
||||
<Link to="/quick" className={[styles.tile, styles.quick].join(' ')}>
|
||||
<Icon icon={Zap} size="nav" />
|
||||
<span className={styles.tileLabel}>Quick dinner</span>
|
||||
</Link>
|
||||
<Link to="/cook-from-pantry" className={[styles.tile, styles.pantry].join(' ')}>
|
||||
<Icon icon={Search} size="nav" />
|
||||
<span className={styles.tileLabel}>Cook from what I have</span>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// HomePage is mock 1g, Home: today's date over "Tonight's quest" and the
|
||||
// Recent updates bell, the Tonight card (or the way to plan the week), the
|
||||
// Quick dinner and Cook from what I have tiles, and the status rows, all
|
||||
// from GET /api/v1/home. Above the tiles is the Rotation check, "Keep it in
|
||||
// rotation?", while a tried dinner waits for the household's answer.
|
||||
//
|
||||
// The bell carries a grape dot while there are unread updates and opens the
|
||||
// Recent updates sheet, which marks them seen.
|
||||
export function HomePage() {
|
||||
const home = useHome()
|
||||
const data = home.data
|
||||
const [updatesOpen, setUpdatesOpen] = useState(false)
|
||||
return (
|
||||
<div className={styles.page} aria-busy={home.isPending}>
|
||||
<header className={styles.header}>
|
||||
<div>
|
||||
{data !== undefined ? (
|
||||
<p className={styles.date}>{data.dateLabel}</p>
|
||||
) : (
|
||||
home.isPending && <Skeleton data-testid="skeleton-date" className={styles.skeletonDate} />
|
||||
)}
|
||||
<h1 className={styles.title}>Tonight's quest</h1>
|
||||
</div>
|
||||
<IconButton
|
||||
label={data?.unread ? 'Recent updates, unread' : 'Recent updates'}
|
||||
className={styles.bell}
|
||||
onClick={() => setUpdatesOpen(true)}
|
||||
>
|
||||
<Icon icon={Bell} className={styles.bellIcon} />
|
||||
{data?.unread && <span data-testid="unread-dot" className={styles.dot} />}
|
||||
</IconButton>
|
||||
</header>
|
||||
{home.isPending && <Skeleton data-testid="skeleton-tonight" className={styles.skeletonCard} />}
|
||||
{home.isError && (
|
||||
<ErrorBanner onRetry={() => void home.refetch()}>Couldn't load tonight's quest. {home.error.message}</ErrorBanner>
|
||||
)}
|
||||
{data !== undefined && (data.tonight === null ? <NoPlan /> : <TonightCard tonight={data.tonight} />)}
|
||||
<RotationCheck />
|
||||
<Tiles />
|
||||
{home.isPending && (
|
||||
<div className={styles.statuses} aria-hidden="true">
|
||||
<Skeleton data-testid="skeleton-status" className={styles.skeletonStatus} />
|
||||
<Skeleton data-testid="skeleton-status" className={styles.skeletonStatus} />
|
||||
</div>
|
||||
)}
|
||||
{data !== undefined && <StatusRows home={data} />}
|
||||
<UpdatesSheet open={updatesOpen} onClose={() => setUpdatesOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/* The Rotation check on Home: "Keep it in rotation?" above the tiles. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 32px;
|
||||
background: var(--color-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.photo {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 4px 18px 12px;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.question {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* "We didn't make it": a text link at the 44 px tap minimum. */
|
||||
.notMade {
|
||||
align-self: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-accent-700);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notMade:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.notMade:focus-visible {
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.confirmation {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 20px;
|
||||
background: var(--color-accent-2-300);
|
||||
color: var(--color-accent-2-900);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clickPath, openNotification, parsePushPayload, type ClickWindow } from '../push/notification.ts'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession, ME_PATH } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOME } from '../test/ApiProviders.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { HOME_PATH } from './api.ts'
|
||||
import { HomePage } from './HomePage.tsx'
|
||||
import { rotationReviewPath, ROTATION_REVIEWS_PATH } from './rotation.ts'
|
||||
|
||||
// SHAKSHUKA and RAMEN are pending Rotation checks, oldest first, as GET
|
||||
// /api/v1/rotation-reviews sends them.
|
||||
const SHAKSHUKA = { dinner: { id: 'd7', name: 'Shakshuka', photo: '/media/photos/p7-400.jpg' }, plan_date: '2026-10-01' }
|
||||
const RAMEN = { dinner: { id: 'd8', name: 'Miso ramen', photo: null }, plan_date: '2026-10-02' }
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(navigator, 'serviceWorker')
|
||||
})
|
||||
|
||||
type Deferred = { resolve: (response: Response) => void; path: string; body: unknown }
|
||||
|
||||
// stubApi answers Home's reads like the server: the pending Rotation checks
|
||||
// are reviews, and each POST /api/v1/dinners/{id}/rotation-review waits in
|
||||
// decisions until the test answers it; a 200 answer takes the check off the
|
||||
// server's list, as the server does.
|
||||
function stubApi(reviews: (typeof SHAKSHUKA | typeof RAMEN)[]) {
|
||||
const server = [...reviews]
|
||||
const decisions: Deferred[] = []
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const path = String(input)
|
||||
if (path === ME_PATH) return Promise.resolve(jsonResponse(200, ME))
|
||||
if (path === HOME_PATH) return Promise.resolve(jsonResponse(200, HOME))
|
||||
if (path === ROTATION_REVIEWS_PATH) return Promise.resolve(jsonResponse(200, { reviews: server }))
|
||||
const review = server.find((r) => rotationReviewPath(r.dinner.id) === path)
|
||||
if (init?.method === 'POST' && review !== undefined) {
|
||||
expect(new Headers(init.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
|
||||
const body: unknown = JSON.parse(String(init.body))
|
||||
return new Promise<Response>((resolve) =>
|
||||
decisions.push({
|
||||
path,
|
||||
body,
|
||||
resolve: (response) => {
|
||||
if (response.ok) server.splice(server.indexOf(review), 1)
|
||||
resolve(response)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return { fetchMock, decisions, server }
|
||||
}
|
||||
|
||||
// decided is the server's answer to a decision on review.
|
||||
function decided(review: typeof SHAKSHUKA | typeof RAMEN, decision: string, dinnerStatus: string) {
|
||||
return jsonResponse(200, {
|
||||
dinner: review.dinner,
|
||||
plan_date: review.plan_date,
|
||||
decision,
|
||||
dinner_status: dinnerStatus,
|
||||
decided_at: '2026-10-02T13:00:00Z',
|
||||
updated_by: 'sub-1',
|
||||
})
|
||||
}
|
||||
|
||||
// renderHome loads /me (for the CSRF token an answer needs) and renders Home
|
||||
// at path.
|
||||
async function renderHome(path = '/') {
|
||||
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const router = createMemoryRouter([{ path: '/', element: <HomePage /> }], { initialEntries: [path] })
|
||||
render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
const card = () => screen.findByRole('region', { name: 'Keep it in rotation?' })
|
||||
const posts = (fetchMock: ReturnType<typeof stubApi>['fetchMock']) =>
|
||||
fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST')
|
||||
|
||||
describe('RotationCheck', () => {
|
||||
it('shows no card when nothing waits for an answer', async () => {
|
||||
const { fetchMock } = stubApi([])
|
||||
await renderHome()
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(ROTATION_REVIEWS_PATH, expect.anything()))
|
||||
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Quick dinner' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('asks about the oldest pending check above the tiles, and decides nothing by itself', async () => {
|
||||
const { fetchMock } = stubApi([SHAKSHUKA, RAMEN])
|
||||
await renderHome()
|
||||
|
||||
const check = await card()
|
||||
expect(within(check).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
expect(within(check).getByRole('heading', { level: 2, name: 'Keep it in rotation?' })).toBeInTheDocument()
|
||||
expect(within(check).getByRole('img', { name: 'Shakshuka' })).toHaveAttribute('src', '/media/photos/p7-400.jpg')
|
||||
expect(screen.queryByText('Last night: Miso ramen')).not.toBeInTheDocument()
|
||||
|
||||
const keep = within(check).getByRole('button', { name: 'Keep it' })
|
||||
const drop = within(check).getByRole('button', { name: 'Not for us' })
|
||||
const notMade = within(check).getByRole('button', { name: "We didn't make it" })
|
||||
expect(keep.className).toMatch(/primary/)
|
||||
expect(drop.className).toMatch(/outline/)
|
||||
expect(notMade.className).toMatch(/notMade/)
|
||||
for (const button of [keep, drop, notMade]) expectMinHitArea(button)
|
||||
|
||||
const tiles = screen.getByRole('link', { name: 'Quick dinner' })
|
||||
expect(check.compareDocumentPosition(tiles) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(screen.getByRole('region', { name: 'Tonight' }).compareDocumentPosition(check) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
|
||||
// The card waits for the household: nothing is sent unasked.
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 50)))
|
||||
expect(posts(fetchMock)).toHaveLength(0)
|
||||
expect(await card()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Keep it', 'keep', 'regular', 'Added to your Regulars'],
|
||||
['Not for us', 'drop', 'archived', "Got it — we won't suggest it again"],
|
||||
["We didn't make it", 'not_made', 'quest_log', 'Back in your Quest log'],
|
||||
])('"%s" sends %s at once and confirms "%s"', async (label, decision, status, confirmation) => {
|
||||
const { fetchMock, decisions } = stubApi([SHAKSHUKA])
|
||||
await renderHome()
|
||||
|
||||
fireEvent.click(within(await card()).getByRole('button', { name: label }))
|
||||
|
||||
// Applied before the server answers.
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(confirmation)
|
||||
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(decisions).toHaveLength(1))
|
||||
expect(decisions[0]).toMatchObject({ path: '/api/v1/dinners/d7/rotation-review', body: { decision } })
|
||||
expect(posts(fetchMock)).toHaveLength(1)
|
||||
|
||||
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, decision, status)))
|
||||
// Rotation checks are read again once saved; none is left.
|
||||
await waitFor(() =>
|
||||
expect(fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length).toBeGreaterThanOrEqual(2),
|
||||
)
|
||||
expect(screen.getByRole('status')).toHaveTextContent(confirmation)
|
||||
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reveals the next pending check at once, its answers locked until the first is saved', async () => {
|
||||
const { decisions } = stubApi([SHAKSHUKA, RAMEN])
|
||||
await renderHome()
|
||||
|
||||
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
|
||||
|
||||
expect(await screen.findByText('Last night: Miso ramen')).toBeInTheDocument()
|
||||
const next = await card()
|
||||
expect(within(next).getByRole('img', { name: 'Miso ramen' })).toHaveTextContent('dish photo')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Added to your Regulars')
|
||||
for (const button of within(next).getAllByRole('button')) expect(button).toBeDisabled()
|
||||
|
||||
await waitFor(() => expect(decisions).toHaveLength(1))
|
||||
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, 'keep', 'regular')))
|
||||
await waitFor(() => expect(within(next).getByRole('button', { name: 'Not for us' })).toBeEnabled())
|
||||
|
||||
fireEvent.click(within(next).getByRole('button', { name: 'Not for us' }))
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("Got it — we won't suggest it again")
|
||||
await waitFor(() => expect(decisions).toHaveLength(2))
|
||||
expect(decisions[1]).toMatchObject({ path: '/api/v1/dinners/d8/rotation-review', body: { decision: 'drop' } })
|
||||
act(() => decisions[1]!.resolve(decided(RAMEN, 'drop', 'archived')))
|
||||
await waitFor(() => expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument())
|
||||
expect(screen.queryByText(/Last night:/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('puts the check back with an ErrorBanner when the answer fails', async () => {
|
||||
const { decisions } = stubApi([SHAKSHUKA, RAMEN])
|
||||
await renderHome()
|
||||
|
||||
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
|
||||
expect(await screen.findByText('Last night: Miso ramen')).toBeInTheDocument()
|
||||
await waitFor(() => expect(decisions).toHaveLength(1))
|
||||
|
||||
act(() => decisions[0]!.resolve(errorResponse(500, 'internal')))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't save that answer. internal message")
|
||||
const check = await card()
|
||||
expect(within(check).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(within(check).getByRole('button', { name: 'Keep it' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('reports an answer that is not that check decided that way, and shows what the server has', async () => {
|
||||
const { decisions } = stubApi([SHAKSHUKA])
|
||||
await renderHome()
|
||||
|
||||
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
|
||||
await waitFor(() => expect(decisions).toHaveLength(1))
|
||||
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, 'drop', 'archived')))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('response is not the Rotation check decided keep')
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
// The server closed the check, so the list read again has none.
|
||||
await waitFor(() => expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('reports Rotation checks it cannot load, with Retry', async () => {
|
||||
const answers = [errorResponse(500, 'internal'), jsonResponse(200, { reviews: [SHAKSHUKA] })]
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>((input) =>
|
||||
Promise.resolve(
|
||||
String(input) === ME_PATH
|
||||
? jsonResponse(200, ME)
|
||||
: String(input) === HOME_PATH
|
||||
? jsonResponse(200, HOME)
|
||||
: answers.shift()!,
|
||||
),
|
||||
),
|
||||
)
|
||||
await renderHome()
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load the Rotation check. internal message")
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps a card already shown, with an ErrorBanner beside it, when reading the checks again fails', async () => {
|
||||
// navigator.serviceWorker is where the worker's postMessage to this
|
||||
// window arrives; a notification tap there reads the checks again.
|
||||
const container = new EventTarget()
|
||||
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
|
||||
const answers = [jsonResponse(200, { reviews: [SHAKSHUKA] }), errorResponse(500, 'internal')]
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>((input) =>
|
||||
Promise.resolve(
|
||||
String(input) === ME_PATH
|
||||
? jsonResponse(200, ME)
|
||||
: String(input) === HOME_PATH
|
||||
? jsonResponse(200, HOME)
|
||||
: answers.shift()!,
|
||||
),
|
||||
),
|
||||
)
|
||||
await renderHome()
|
||||
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
|
||||
const origin = 'https://mealquest.test'
|
||||
const win = {
|
||||
url: `${origin}/`,
|
||||
focused: true,
|
||||
focus: vi.fn(async () => {}),
|
||||
navigate: vi.fn(async () => {}),
|
||||
postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })),
|
||||
} satisfies ClickWindow
|
||||
await act(() =>
|
||||
openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin),
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load the Rotation check. internal message")
|
||||
const shown = screen.getByRole('region', { name: 'Keep it in rotation?' })
|
||||
expect(within(shown).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
expect(within(shown).getByRole('button', { name: 'Keep it' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('opens Home with the card from a rotation_review push notification', async () => {
|
||||
stubApi([SHAKSHUKA])
|
||||
const origin = 'https://mealquest.test'
|
||||
const { notice } = parsePushPayload(
|
||||
JSON.stringify({ event: 'rotation_review', title: 'Keep it in rotation?', body: 'How was Shakshuka?', url: '/' }),
|
||||
origin,
|
||||
)
|
||||
const path = clickPath({ url: notice.url }, origin)
|
||||
expect(path).toBe('/')
|
||||
|
||||
await renderHome(path)
|
||||
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the card when a rotation_review notification focuses Home already open, without a remount', async () => {
|
||||
// navigator.serviceWorker is where the worker's postMessage to this
|
||||
// window arrives.
|
||||
const container = new EventTarget()
|
||||
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
|
||||
const { fetchMock, server } = stubApi([])
|
||||
const reads = () => fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length
|
||||
await renderHome('/')
|
||||
const home = await screen.findByRole('heading', { name: 'Pizza night' })
|
||||
await waitFor(() => expect(reads()).toBe(1))
|
||||
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
|
||||
|
||||
// Within the cached list's staleTime the Rotation check comes due and
|
||||
// its notification is tapped while Home is open at /.
|
||||
server.push(SHAKSHUKA)
|
||||
const origin = 'https://mealquest.test'
|
||||
const win = {
|
||||
url: `${origin}/`,
|
||||
focused: true,
|
||||
focus: vi.fn(async () => {}),
|
||||
navigate: vi.fn(async () => {}),
|
||||
postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })),
|
||||
} satisfies ClickWindow
|
||||
await act(() =>
|
||||
openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin),
|
||||
)
|
||||
|
||||
expect(win.navigate).not.toHaveBeenCalled()
|
||||
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
expect(reads()).toBe(2)
|
||||
expect(screen.getByRole('heading', { name: 'Pizza night' })).toBe(home)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import { useDecideRotationReview, useRotationReviews, type Decision, type RotationReview } from './rotation.ts'
|
||||
import styles from './RotationCheck.module.css'
|
||||
|
||||
// CONFIRMATION is what Home says once the household has answered.
|
||||
const CONFIRMATION: Record<Decision, string> = {
|
||||
keep: 'Added to your Regulars',
|
||||
drop: "Got it — we won't suggest it again",
|
||||
not_made: 'Back in your Quest log',
|
||||
}
|
||||
|
||||
type RotationCardProps = { review: RotationReview; disabled: boolean; onDecide: (decision: Decision) => void }
|
||||
|
||||
// RotationCard asks "Keep it in rotation?" about a tried dinner: its washed
|
||||
// photo, "Last night: {dinner}", and the three answers.
|
||||
function RotationCard({ review, disabled, onDecide }: RotationCardProps) {
|
||||
const { dinner } = review
|
||||
return (
|
||||
<section aria-label="Keep it in rotation?" className={styles.card}>
|
||||
<Photo id={dinner.photo_id} name={dinner.name} inset="10px" sizes="400px" className={styles.photo} />
|
||||
<div className={styles.body}>
|
||||
<p className={styles.kicker}>Last night: {dinner.name}</p>
|
||||
<h2 className={styles.question}>Keep it in rotation?</h2>
|
||||
<div className={styles.actions}>
|
||||
<Button disabled={disabled} onClick={() => onDecide('keep')}>
|
||||
Keep it
|
||||
</Button>
|
||||
<Button variant="outline" disabled={disabled} onClick={() => onDecide('drop')}>
|
||||
Not for us
|
||||
</Button>
|
||||
</div>
|
||||
<button type="button" className={styles.notMade} disabled={disabled} onClick={() => onDecide('not_made')}>
|
||||
We didn't make it
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// RotationCheck is the Rotation check on Home: the card for the oldest
|
||||
// pending check (GET /api/v1/rotation-reviews), which stays until the
|
||||
// household answers it — nothing is decided for them. An answer is applied
|
||||
// at once: Home confirms it and shows the next pending check, if any, while
|
||||
// the answer is saved; the answers are locked until it settles. A failed
|
||||
// answer puts its check back with an ErrorBanner. A failed read shows an
|
||||
// ErrorBanner, beside the checks last read: a card already shown stays
|
||||
// until it is answered.
|
||||
export function RotationCheck() {
|
||||
const reviews = useRotationReviews()
|
||||
const decide = useDecideRotationReview()
|
||||
const deciding = decide.isPending ? decide.variables.review.dinner.id : null
|
||||
const next = reviews.data?.find((review) => review.dinner.id !== deciding)
|
||||
const answered = decide.isPending || decide.isSuccess ? decide.variables : undefined
|
||||
return (
|
||||
<>
|
||||
{answered !== undefined && (
|
||||
<p role="status" className={styles.confirmation}>
|
||||
{CONFIRMATION[answered.decision]}
|
||||
</p>
|
||||
)}
|
||||
{reviews.isError && (
|
||||
<ErrorBanner onRetry={() => void reviews.refetch()}>
|
||||
Couldn't load the Rotation check. {reviews.error.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
{decide.isError && <ErrorBanner>Couldn't save that answer. {decide.error.message}</ErrorBanner>}
|
||||
{next !== undefined && (
|
||||
<RotationCard
|
||||
key={next.dinner.id}
|
||||
review={next}
|
||||
disabled={decide.isPending}
|
||||
onDecide={(decision) => decide.mutate({ review: next, decision })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/* The Recent updates sheet the Home bell opens. */
|
||||
.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;
|
||||
}
|
||||
|
||||
.rows {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.time {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skeletonRow {
|
||||
height: 56px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOME } from '../test/ApiProviders.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { HOME_KEY, HOME_PATH, UPDATES_PATH, UPDATES_SEEN_PATH } from './api.ts'
|
||||
import { ROTATION_REVIEWS_PATH } from './rotation.ts'
|
||||
import { HomePage } from './HomePage.tsx'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// minutesAgo is the stored form of the instant n minutes before now.
|
||||
function minutesAgo(n: number): string {
|
||||
return new Date(Date.now() - n * 60_000).toISOString()
|
||||
}
|
||||
|
||||
// UPDATES are a GET /api/v1/updates body, newest first: the week drafted
|
||||
// five minutes ago, new Side quests three hours ago and the Supply run two
|
||||
// days ago.
|
||||
const UPDATES = {
|
||||
updates: [
|
||||
{
|
||||
type: 'plan_ready',
|
||||
title: 'Your week is drafted',
|
||||
body: 'Take a look at the Draft board.',
|
||||
url: '/plan?week=2026-10-12',
|
||||
created_at: minutesAgo(5),
|
||||
},
|
||||
{ type: 'new_suggestions', title: '5 new Side quests', body: '', url: '/discover', created_at: minutesAgo(3 * 60) },
|
||||
{
|
||||
type: 'list_ready',
|
||||
title: 'Supply run ready',
|
||||
body: 'Week 41 · Oct 5 – 11',
|
||||
url: '/plan/shopping?week=2026-10-05',
|
||||
created_at: minutesAgo(2 * 24 * 60),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
|
||||
|
||||
// stubServer answers "METHOD path" requests from responders, with /me; any
|
||||
// other request fails the test. Home is unread until the updates are marked
|
||||
// seen, as the server answers.
|
||||
function stubServer(responders: Record<string, Responder> = {}) {
|
||||
let seen = false
|
||||
const all: Record<string, Responder> = {
|
||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||
[`GET ${HOME_PATH}`]: () => jsonResponse(200, { ...HOME, recentUpdates: { unread: !seen } }),
|
||||
[`GET ${UPDATES_PATH}`]: () => jsonResponse(200, UPDATES),
|
||||
[`GET ${ROTATION_REVIEWS_PATH}`]: () => jsonResponse(200, { reviews: [] }),
|
||||
[`POST ${UPDATES_SEEN_PATH}`]: () => {
|
||||
seen = true
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
...responders,
|
||||
}
|
||||
const fetchMock = 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(init))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
// posts lists the bodies of the POST /updates/seen requests sent, each of
|
||||
// which must carry the CSRF token.
|
||||
function posts(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls
|
||||
.filter(([input, init]) => init?.method === 'POST' && String(input) === UPDATES_SEEN_PATH)
|
||||
.map(([, init]) => {
|
||||
expect(new Headers(init?.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
|
||||
return String(init?.body)
|
||||
})
|
||||
}
|
||||
|
||||
function homeReads(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls.filter(([input, init]) => String(input) === HOME_PATH && (init?.method ?? 'GET') === 'GET')
|
||||
.length
|
||||
}
|
||||
|
||||
function updatesReads(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls.filter(
|
||||
([input, init]) => String(input) === UPDATES_PATH && (init?.method ?? 'GET') === 'GET',
|
||||
).length
|
||||
}
|
||||
|
||||
// Where shows the route a row went to.
|
||||
function Where() {
|
||||
const location = useLocation()
|
||||
return <p data-testid="where">{location.pathname + location.search}</p>
|
||||
}
|
||||
|
||||
// renderHome loads /me (for the CSRF token the POST needs) and renders Home,
|
||||
// answering its query client.
|
||||
async function renderHome() {
|
||||
const api = createApiClient(fakeLocation('/'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{ path: '/', element: <HomePage /> },
|
||||
{ path: '*', element: <Where /> },
|
||||
],
|
||||
{ initialEntries: ['/'] },
|
||||
)
|
||||
const queryClient = createQueryClient()
|
||||
render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
return queryClient
|
||||
}
|
||||
|
||||
// openSheet taps the unread bell and answers the open sheet.
|
||||
async function openSheet() {
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Recent updates, unread' }))
|
||||
return screen.getByRole('dialog', { name: 'Recent updates' })
|
||||
}
|
||||
|
||||
async function rows(sheet: HTMLElement) {
|
||||
return within(await within(sheet).findByRole('list', { name: 'Recent updates' })).getAllByRole('listitem')
|
||||
}
|
||||
|
||||
describe('Recent updates sheet', () => {
|
||||
it('lists the updates newest first with how long ago each came', async () => {
|
||||
stubServer()
|
||||
await renderHome()
|
||||
const listed = await rows(await openSheet())
|
||||
|
||||
expect(listed).toHaveLength(3)
|
||||
expect(listed[0]).toHaveTextContent('Your week is drafted')
|
||||
expect(listed[0]).toHaveTextContent('Take a look at the Draft board.')
|
||||
expect(listed[0]).toHaveTextContent('5 min ago')
|
||||
expect(listed[1]).toHaveTextContent('5 new Side quests')
|
||||
expect(listed[1]).toHaveTextContent('3 hr ago')
|
||||
expect(listed[2]).toHaveTextContent('Supply run ready')
|
||||
expect(listed[2]).toHaveTextContent('2 days ago')
|
||||
expect(within(listed[0]!).getByText('5 min ago')).toHaveAttribute('dateTime', UPDATES.updates[0]!.created_at)
|
||||
})
|
||||
|
||||
it('marks the updates seen through the newest, and the grape dot clears and stays cleared', async () => {
|
||||
const fetchMock = stubServer()
|
||||
await renderHome()
|
||||
expect(await screen.findByTestId('unread-dot')).toBeInTheDocument()
|
||||
await openSheet()
|
||||
|
||||
await waitFor(() => expect(posts(fetchMock)).toEqual([JSON.stringify({ through: UPDATES.updates[0]!.created_at })]))
|
||||
expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument()
|
||||
// Home is read again once the mark is saved, and the server says
|
||||
// nothing is unread: the dot does not come back.
|
||||
await waitFor(() => expect(homeReads(fetchMock)).toBe(2))
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
||||
expect(await screen.findByRole('button', { name: 'Recent updates' })).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reads the updates afresh on each opening, so one stored since the last opening is listed and marked seen', async () => {
|
||||
// The server holds the stored events, newest first, and says Home is
|
||||
// unread while the newest is after the time marked seen.
|
||||
const stored = [...UPDATES.updates]
|
||||
let seenThrough = ''
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${HOME_PATH}`]: () => jsonResponse(200, { ...HOME, recentUpdates: { unread: stored[0]!.created_at > seenThrough } }),
|
||||
[`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: stored }),
|
||||
[`POST ${UPDATES_SEEN_PATH}`]: (init) => {
|
||||
seenThrough = (JSON.parse(String(init?.body)) as { through: string }).through
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
})
|
||||
const queryClient = await renderHome()
|
||||
await rows(await openSheet())
|
||||
await waitFor(() => expect(posts(fetchMock)).toHaveLength(1))
|
||||
await waitFor(() => expect(homeReads(fetchMock)).toBe(2))
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
||||
|
||||
// A new event is stored a moment later, well within the query client's
|
||||
// staleTime, and Home is read again, as on coming back to it.
|
||||
const reminder = {
|
||||
type: 'day_of_reminder',
|
||||
title: 'Tacos tonight',
|
||||
body: 'Dinner at 6:00.',
|
||||
url: '/plan',
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
stored.unshift(reminder)
|
||||
await queryClient.invalidateQueries({ queryKey: HOME_KEY })
|
||||
expect(await screen.findByTestId('unread-dot')).toBeInTheDocument()
|
||||
|
||||
const listed = await rows(await openSheet())
|
||||
expect(listed).toHaveLength(4)
|
||||
expect(listed[0]).toHaveTextContent('Tacos tonight')
|
||||
await waitFor(() =>
|
||||
expect(posts(fetchMock)).toEqual([
|
||||
JSON.stringify({ through: UPDATES.updates[0]!.created_at }),
|
||||
JSON.stringify({ through: reminder.created_at }),
|
||||
]),
|
||||
)
|
||||
await waitFor(() => expect(homeReads(fetchMock)).toBe(4))
|
||||
expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('on an immediate reopen waits for that opening’s read, and marks the newer event seen, not the list held from before', async () => {
|
||||
const stored = [...UPDATES.updates]
|
||||
let reads = 0
|
||||
let answer: (() => void) | undefined
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${UPDATES_PATH}`]: () => {
|
||||
const body = { updates: [...stored] }
|
||||
reads++
|
||||
// Every reading after the first is held until the test answers it.
|
||||
if (reads === 1) return jsonResponse(200, body)
|
||||
return new Promise<Response>((resolve) => (answer = () => resolve(jsonResponse(200, body))))
|
||||
},
|
||||
})
|
||||
await renderHome()
|
||||
await rows(await openSheet())
|
||||
await waitFor(() => expect(posts(fetchMock)).toHaveLength(1))
|
||||
|
||||
// Closed and opened again at once, before any cache-collection timer
|
||||
// could run, with a new event stored in between.
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
||||
const reminder = {
|
||||
type: 'day_of_reminder',
|
||||
title: 'Tacos tonight',
|
||||
body: 'Dinner at 6:00.',
|
||||
url: '/plan',
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
stored.unshift(reminder)
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Recent updates/ }))
|
||||
const sheet = screen.getByRole('dialog', { name: 'Recent updates' })
|
||||
|
||||
// The list held from the first opening is neither shown nor marked
|
||||
// seen while this opening's read is in flight.
|
||||
await waitFor(() => expect(updatesReads(fetchMock)).toBe(2))
|
||||
expect(within(sheet).getByLabelText('Updates loading')).toBeInTheDocument()
|
||||
expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
|
||||
expect(posts(fetchMock)).toHaveLength(1)
|
||||
|
||||
answer!()
|
||||
const listed = await rows(sheet)
|
||||
expect(listed).toHaveLength(4)
|
||||
expect(listed[0]).toHaveTextContent('Tacos tonight')
|
||||
await waitFor(() =>
|
||||
expect(posts(fetchMock)).toEqual([
|
||||
JSON.stringify({ through: UPDATES.updates[0]!.created_at }),
|
||||
JSON.stringify({ through: reminder.created_at }),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('closing the sheet while its first read is in flight cancels that read, and a reopen reads afresh', async () => {
|
||||
const stored = [...UPDATES.updates]
|
||||
let firstAborted = false
|
||||
let answerFirst: (() => void) | undefined
|
||||
let reads = 0
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${UPDATES_PATH}`]: (init) => {
|
||||
const body = { updates: [...stored] }
|
||||
reads++
|
||||
if (reads > 1) return jsonResponse(200, body)
|
||||
// The first reading is held open, and fails as fetch does if it is
|
||||
// aborted.
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
answerFirst = () => resolve(jsonResponse(200, body))
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
firstAborted = true
|
||||
reject(init.signal!.reason as Error)
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
await renderHome()
|
||||
await openSheet()
|
||||
await waitFor(() => expect(updatesReads(fetchMock)).toBe(1))
|
||||
|
||||
// Closed before the first read answers, a new event stored, and opened
|
||||
// again at once.
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
||||
const reminder = {
|
||||
type: 'day_of_reminder',
|
||||
title: 'Tacos tonight',
|
||||
body: 'Dinner at 6:00.',
|
||||
url: '/plan',
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
stored.unshift(reminder)
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Recent updates/ }))
|
||||
const sheet = screen.getByRole('dialog', { name: 'Recent updates' })
|
||||
|
||||
// The first read was cancelled, and the reopen sent its own.
|
||||
await waitFor(() => expect(updatesReads(fetchMock)).toBe(2))
|
||||
expect(firstAborted).toBe(true)
|
||||
// Answering the cancelled read changes nothing.
|
||||
answerFirst!()
|
||||
|
||||
const listed = await rows(sheet)
|
||||
expect(listed).toHaveLength(4)
|
||||
expect(listed[0]).toHaveTextContent('Tacos tonight')
|
||||
await waitFor(() => expect(posts(fetchMock)).toEqual([JSON.stringify({ through: reminder.created_at })]))
|
||||
})
|
||||
|
||||
it.each([
|
||||
[0, '/plan?week=2026-10-12'],
|
||||
[1, '/discover'],
|
||||
[2, '/plan/shopping?week=2026-10-05'],
|
||||
])('row %i goes to %s and closes the sheet', async (i, to) => {
|
||||
stubServer()
|
||||
await renderHome()
|
||||
const listed = await rows(await openSheet())
|
||||
|
||||
fireEvent.click(within(listed[i]!).getByRole('button'))
|
||||
expect(await screen.findByTestId('where')).toHaveTextContent(to)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says there are no updates yet, and marks nothing seen', async () => {
|
||||
const fetchMock = stubServer({ [`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: [] }) })
|
||||
await renderHome()
|
||||
const sheet = await openSheet()
|
||||
|
||||
expect(await within(sheet).findByText(/No updates yet/)).toBeInTheDocument()
|
||||
expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
|
||||
expect(posts(fetchMock)).toEqual([])
|
||||
})
|
||||
|
||||
it('puts the dot back and says so when marking the updates seen fails', async () => {
|
||||
let refuse!: (response: Response) => void
|
||||
const fetchMock = stubServer({
|
||||
[`POST ${UPDATES_SEEN_PATH}`]: () => new Promise<Response>((resolve) => (refuse = resolve)),
|
||||
})
|
||||
await renderHome()
|
||||
const sheet = await openSheet()
|
||||
|
||||
await waitFor(() => expect(posts(fetchMock)).toHaveLength(1))
|
||||
// Cleared at once, while the mark is in flight.
|
||||
expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument()
|
||||
|
||||
refuse(errorResponse(500, 'internal'))
|
||||
expect(await within(sheet).findByRole('alert')).toHaveTextContent(
|
||||
"Couldn't mark your updates as seen. internal message",
|
||||
)
|
||||
expect(screen.getByTestId('unread-dot')).toBeInTheDocument()
|
||||
// The updates are still listed.
|
||||
expect(within(sheet).getByRole('list', { name: 'Recent updates' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports updates it cannot load, with Retry', async () => {
|
||||
let fail = true
|
||||
stubServer({ [`GET ${UPDATES_PATH}`]: () => (fail ? errorResponse(500, 'internal') : jsonResponse(200, UPDATES)) })
|
||||
await renderHome()
|
||||
const sheet = await openSheet()
|
||||
|
||||
expect(await within(sheet).findByRole('alert')).toHaveTextContent("Couldn't load your updates. internal message")
|
||||
fail = false
|
||||
fireEvent.click(within(sheet).getByRole('button', { name: 'Retry' }))
|
||||
expect(await within(sheet).findByRole('list', { name: 'Recent updates' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports an update that would open another site rather than listing it', async () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: [{ ...UPDATES.updates[0], url: '//evil.example/plan' }] }),
|
||||
})
|
||||
await renderHome()
|
||||
const sheet = await openSheet()
|
||||
|
||||
expect(await within(sheet).findByRole('alert')).toHaveTextContent(
|
||||
'update url "//evil.example/plan" is not a path on this app',
|
||||
)
|
||||
expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
|
||||
expect(posts(fetchMock)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { CalendarDays, ChefHat, Compass, Repeat, ShoppingCart, type LucideIcon } from 'lucide-react'
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { ago } from '../plan/PlanPage.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { ListRow } from '../ui/ListRow.tsx'
|
||||
import { Sheet } from '../ui/Sheet.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { useMarkUpdatesSeen, useUpdates, type Update, type UpdateType } from './api.ts'
|
||||
import styles from './UpdatesSheet.module.css'
|
||||
|
||||
const ICONS: Record<UpdateType, LucideIcon> = {
|
||||
plan_ready: CalendarDays,
|
||||
list_ready: ShoppingCart,
|
||||
day_of_reminder: ChefHat,
|
||||
new_suggestions: Compass,
|
||||
rotation_review: Repeat,
|
||||
}
|
||||
|
||||
// UpdateRow is one update: its icon, title and body, and how long ago it
|
||||
// came. A tap opens its url.
|
||||
function UpdateRow({ update, now, onOpen }: { update: Update; now: Date; onOpen: (url: string) => void }) {
|
||||
return (
|
||||
<li>
|
||||
<ListRow
|
||||
leading={<Icon icon={ICONS[update.type]} className={styles.icon} />}
|
||||
title={update.title}
|
||||
meta={update.body === '' ? undefined : update.body}
|
||||
trailing={
|
||||
<time dateTime={update.created_at} className={styles.time}>
|
||||
{ago(new Date(update.created_at), now)}
|
||||
</time>
|
||||
}
|
||||
onClick={() => onOpen(update.url)}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// Updates lists the Recent updates, newest first, and marks them seen
|
||||
// through the newest once they are shown, so the bell's dot clears.
|
||||
function Updates({ onOpen }: { onOpen: (url: string) => void }) {
|
||||
const updates = useUpdates()
|
||||
const markSeen = useMarkUpdatesSeen()
|
||||
const { mutate } = markSeen
|
||||
// fresh says this opening's read has come back; until then a list still
|
||||
// held from an earlier opening is neither shown nor marked seen.
|
||||
const fresh = updates.isSuccess && updates.isFetchedAfterMount
|
||||
const newest = fresh ? updates.data[0]?.created_at : undefined
|
||||
// The instant the relative times count from, fixed while the sheet is
|
||||
// open.
|
||||
const [now] = useState(() => new Date())
|
||||
|
||||
useEffect(() => {
|
||||
if (newest !== undefined) mutate(newest)
|
||||
}, [newest, mutate])
|
||||
|
||||
if (updates.isError) {
|
||||
return (
|
||||
<ErrorBanner onRetry={() => void updates.refetch()}>Couldn't load your updates. {updates.error.message}</ErrorBanner>
|
||||
)
|
||||
}
|
||||
if (!fresh) {
|
||||
return (
|
||||
<div aria-busy="true" aria-label="Updates loading" className={styles.rows}>
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className={styles.skeletonRow} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (updates.data.length === 0) {
|
||||
return <p className={styles.empty}>No updates yet. Plans, Supply runs and new Side quests show up here.</p>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{markSeen.isError && newest !== undefined && (
|
||||
<ErrorBanner onRetry={() => mutate(newest)}>
|
||||
Couldn't mark your updates as seen. {markSeen.error.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
<ul aria-label="Recent updates" className={styles.rows}>
|
||||
{updates.data.map((update, i) => (
|
||||
<UpdateRow key={`${i} ${update.created_at}`} update={update} now={now} onOpen={onOpen} />
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export type UpdatesSheetProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// UpdatesSheet is the Recent updates sheet the Home bell opens: the last
|
||||
// ten notification events with how long ago each came, each opening the
|
||||
// screen it is about. Opening it marks them seen.
|
||||
export function UpdatesSheet({ open, onClose }: UpdatesSheetProps) {
|
||||
const titleId = useId()
|
||||
const navigate = useNavigate()
|
||||
const openUpdate = (url: string) => {
|
||||
onClose()
|
||||
void navigate(url)
|
||||
}
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} aria-labelledby={titleId}>
|
||||
<div className={styles.sheet}>
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
Recent updates
|
||||
</h2>
|
||||
<Updates onOpen={openUpdate} />
|
||||
</div>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { HOME } from '../test/ApiProviders.tsx'
|
||||
import { parseHome, parseUpdates } from './api.ts'
|
||||
|
||||
const origin = 'https://mealquest.example'
|
||||
const where = 'GET /api/v1/updates'
|
||||
|
||||
// body is a GET /api/v1/updates body holding one update that opens url.
|
||||
function body(url: string) {
|
||||
return {
|
||||
updates: [{ type: 'plan_ready', title: 'Your week is drafted', body: '', url, created_at: '2026-10-10T16:00:00.000Z' }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseUpdates', () => {
|
||||
it('keeps a same-origin path with its query and fragment, as parsed', () => {
|
||||
expect(parseUpdates(where, body('/plan/shopping?week=2026-10-05#tuesday'), origin)[0]!.url).toBe(
|
||||
'/plan/shopping?week=2026-10-05#tuesday',
|
||||
)
|
||||
expect(parseUpdates(where, body('/plan/../discover'), origin)[0]!.url).toBe('/discover')
|
||||
})
|
||||
|
||||
// Each of these would open another site, or run script, once parsed: URL
|
||||
// parsing strips tabs and newlines and reads a backslash as a slash.
|
||||
it.each([
|
||||
['a tab', '/\t/evil.example/x'],
|
||||
['a newline', '/\n/evil.example/x'],
|
||||
['a carriage return', '/\r/evil.example/x'],
|
||||
['a backslash', '/\\evil.example/x'],
|
||||
['dot segments', '/..//evil.example/x'],
|
||||
['a protocol-relative URL', '//evil.example/x'],
|
||||
['an absolute URL', 'https://evil.example/x'],
|
||||
['javascript:', 'javascript:alert(1)'],
|
||||
['a relative path', 'plan'],
|
||||
])('refuses an update url with %s', (_, url) => {
|
||||
expect(() => parseUpdates(where, body(url), origin)).toThrow(
|
||||
`${where}: update url ${JSON.stringify(url)} is not a path on this app`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseHome', () => {
|
||||
it("keeps the household's zone, and refuses one that is not an IANA zone", () => {
|
||||
expect(parseHome('GET /api/v1/home', HOME).timezone).toBe('America/New_York')
|
||||
expect(() => parseHome('GET /api/v1/home', { ...HOME, timezone: 'Mars/Olympus' })).toThrow(
|
||||
'GET /api/v1/home: timezone "Mars/Olympus" is not an IANA zone',
|
||||
)
|
||||
expect(() => parseHome('GET /api/v1/home', { ...HOME, timezone: undefined })).toThrow(
|
||||
'GET /api/v1/home: timezone undefined is not an IANA zone',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { oneOf, photoId } from '../dinners/api.ts'
|
||||
import { untilLocalMidnight, weekLabel } from '../plan/week.ts'
|
||||
import { appPath } from '../push/notification.ts'
|
||||
|
||||
export const HOME_PATH = '/api/v1/home'
|
||||
export const HOME_KEY = ['home'] as const
|
||||
export const UPDATES_PATH = '/api/v1/updates'
|
||||
export const UPDATES_SEEN_PATH = '/api/v1/updates/seen'
|
||||
export const UPDATES_KEY = ['updates'] as const
|
||||
|
||||
export const ORIGINS = ['regular', 'side_quest'] as const
|
||||
export type Origin = (typeof ORIGINS)[number]
|
||||
|
||||
// Tonight is the dinner planned for today: photo_id is its stored photo's
|
||||
// id, from photo, and minutes_total its minutes from prep to table, a
|
||||
// positive whole number, or null when the recipe does not say.
|
||||
export type Tonight = { id: string; name: string; photo_id: string | undefined; origin: Origin; minutes_total: number | null }
|
||||
|
||||
// ShoppingListStatus is the Supply run Home points at: next week's once it
|
||||
// has one, otherwise this week's, ready when that week has a list.
|
||||
// weekStart is the week's Monday, YYYY-MM-DD, and dates the week as the
|
||||
// status row shows it, "Oct 5–11" (the API's label is the long "Week 41 ·
|
||||
// Oct 5 – 11").
|
||||
export type ShoppingListStatus = { weekStart: string; dates: string; ready: boolean }
|
||||
|
||||
// Home is GET /api/v1/home: dateLabel is today, household-local, "Friday,
|
||||
// Oct 2"; timezone is the household's IANA zone, whose midnight starts the
|
||||
// next day; tonight is null when today has no plan; sideQuestCount is how
|
||||
// many Side quests wait in the deck; unread says the Recent updates hold
|
||||
// something not yet seen.
|
||||
export type Home = {
|
||||
dateLabel: string
|
||||
timezone: string
|
||||
tonight: Tonight | null
|
||||
sideQuestCount: number
|
||||
shoppingList: ShoppingListStatus
|
||||
unread: boolean
|
||||
}
|
||||
|
||||
function parseTonight(where: string, body: unknown): Tonight | null {
|
||||
if (body === null) return null
|
||||
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
|
||||
throw new Error(`${where}: tonight is not a dinner`)
|
||||
}
|
||||
if (!('minutes_total' in body)) throw new Error(`${where}: tonight has no minutes_total`)
|
||||
const minutes = body.minutes_total
|
||||
if (minutes !== null && (!Number.isInteger(minutes) || (minutes as number) <= 0)) {
|
||||
throw new Error(`${where}: tonight's minutes_total ${String(minutes)} is not a positive whole number`)
|
||||
}
|
||||
return {
|
||||
id: body.id,
|
||||
name: body.name,
|
||||
photo_id: photoId(where, body.photo),
|
||||
origin: oneOf(where, 'origin', ORIGINS, body.origin),
|
||||
minutes_total: minutes as number | null,
|
||||
}
|
||||
}
|
||||
|
||||
// parseShoppingList checks the shoppingList of a GET /api/v1/home body. The
|
||||
// list, ready or not, must carry the API's label for its week, which must be
|
||||
// the label weekStart has, since the status row shows dates from weekStart.
|
||||
function parseShoppingList(where: string, list: unknown): ShoppingListStatus {
|
||||
if (!isRecord(list) || typeof list.weekStart !== 'string' || typeof list.ready !== 'boolean') {
|
||||
throw new Error(`${where}: shoppingList is not a week's Supply run status`)
|
||||
}
|
||||
const week = weekLabel(list.weekStart)
|
||||
if (typeof list.label !== 'string' || list.label === '') {
|
||||
throw new Error(`${where}: the ${list.ready ? 'ready' : 'unready'} shoppingList has no label`)
|
||||
}
|
||||
if (list.label !== `${week.week} · ${week.dates}`) {
|
||||
throw new Error(`${where}: shoppingList label "${list.label}" is not the week of ${list.weekStart}`)
|
||||
}
|
||||
return { weekStart: list.weekStart, dates: week.dates.replace(' – ', '–'), ready: list.ready }
|
||||
}
|
||||
|
||||
// parseHome checks a GET /api/v1/home body.
|
||||
export function parseHome(where: string, body: unknown): Home {
|
||||
if (!isRecord(body) || typeof body.dateLabel !== 'string') throw new Error(`${where}: response is not the home`)
|
||||
if (typeof body.timezone !== 'string' || !isZone(body.timezone)) {
|
||||
throw new Error(`${where}: timezone ${JSON.stringify(body.timezone)} is not an IANA zone`)
|
||||
}
|
||||
if (!Number.isInteger(body.sideQuestCount) || (body.sideQuestCount as number) < 0) {
|
||||
throw new Error(`${where}: sideQuestCount is not a count`)
|
||||
}
|
||||
const updates = body.recentUpdates
|
||||
if (!isRecord(updates) || typeof updates.unread !== 'boolean') throw new Error(`${where}: recentUpdates has no unread flag`)
|
||||
return {
|
||||
dateLabel: body.dateLabel,
|
||||
timezone: body.timezone,
|
||||
tonight: parseTonight(where, body.tonight),
|
||||
sideQuestCount: body.sideQuestCount as number,
|
||||
shoppingList: parseShoppingList(where, body.shoppingList),
|
||||
unread: updates.unread,
|
||||
}
|
||||
}
|
||||
|
||||
// isZone says whether zone is an IANA zone this browser knows.
|
||||
function isZone(zone: string): boolean {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: zone })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// useHome is the Home screen's summary (GET /api/v1/home). Home is read
|
||||
// again at the household's next local midnight, counted from each read, so
|
||||
// a Home left open does not keep yesterday's date and dinner.
|
||||
export function useHome() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
const home = useQuery({
|
||||
queryKey: HOME_KEY,
|
||||
queryFn: async () => parseHome(`GET ${HOME_PATH}`, await apiFetch<unknown>(HOME_PATH)),
|
||||
})
|
||||
const zone = home.data?.timezone
|
||||
const readAt = home.dataUpdatedAt
|
||||
useEffect(() => {
|
||||
if (zone === undefined) return
|
||||
const timer = setTimeout(
|
||||
() => void queryClient.invalidateQueries({ queryKey: HOME_KEY }),
|
||||
untilLocalMidnight(zone, new Date()),
|
||||
)
|
||||
return () => clearTimeout(timer)
|
||||
}, [queryClient, zone, readAt])
|
||||
return home
|
||||
}
|
||||
|
||||
export const UPDATE_TYPES = ['plan_ready', 'list_ready', 'day_of_reminder', 'new_suggestions', 'rotation_review'] as const
|
||||
export type UpdateType = (typeof UPDATE_TYPES)[number]
|
||||
|
||||
// Update is one of the Recent updates: a notification event, whatever
|
||||
// channels it went to. url is the same-origin path it opens; created_at is
|
||||
// when it was stored, the UTC RFC 3339 instant POST /api/v1/updates/seen
|
||||
// takes back.
|
||||
export type Update = { type: UpdateType; title: string; body: string; url: string; created_at: string }
|
||||
|
||||
// parseUpdate checks one update. Its url must stay on origin once parsed, as
|
||||
// the server only ever stores a path on the app's own origin; url is that
|
||||
// path as parsed.
|
||||
function parseUpdate(where: string, body: unknown, origin: string): Update {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.title !== 'string' ||
|
||||
typeof body.body !== 'string' ||
|
||||
typeof body.url !== 'string' ||
|
||||
typeof body.created_at !== 'string'
|
||||
) {
|
||||
throw new Error(`${where}: an update is not a notification event`)
|
||||
}
|
||||
const url = appPath(body.url, origin)
|
||||
if (url === null) throw new Error(`${where}: update url ${JSON.stringify(body.url)} is not a path on this app`)
|
||||
if (Number.isNaN(Date.parse(body.created_at))) {
|
||||
throw new Error(`${where}: update created_at "${body.created_at}" is not a time`)
|
||||
}
|
||||
return {
|
||||
type: oneOf(where, 'type', UPDATE_TYPES, body.type),
|
||||
title: body.title,
|
||||
body: body.body,
|
||||
url,
|
||||
created_at: body.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
// parseUpdates checks a GET /api/v1/updates body, from the app at origin:
|
||||
// the updates, newest first.
|
||||
export function parseUpdates(where: string, body: unknown, origin: string): Update[] {
|
||||
if (!isRecord(body) || !Array.isArray(body.updates)) throw new Error(`${where}: response is not the updates`)
|
||||
return body.updates.map((update) => parseUpdate(where, update, origin))
|
||||
}
|
||||
|
||||
// useUpdates is the Recent updates (GET /api/v1/updates): the last ten
|
||||
// notification events, newest first. Each opening of the sheet reads it
|
||||
// afresh (refetchOnMount 'always'), whatever the query client still holds;
|
||||
// the sheet shows, and marks seen, only the list that opening read. Closing
|
||||
// the sheet aborts a read still in flight, so a reopen sends its own rather
|
||||
// than adopt one sent before it.
|
||||
export function useUpdates() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: UPDATES_KEY,
|
||||
queryFn: async ({ signal }) =>
|
||||
parseUpdates(`GET ${UPDATES_PATH}`, await apiFetch<unknown>(UPDATES_PATH, { signal }), window.location.origin),
|
||||
refetchOnMount: 'always',
|
||||
})
|
||||
}
|
||||
|
||||
const MARK_SEEN_KEY = ['updates', 'seen'] as const
|
||||
|
||||
// useMarkUpdatesSeen records that the household has seen its updates
|
||||
// through the created_at of the newest one shown (POST
|
||||
// /api/v1/updates/seen). The bell's unread dot clears at once, and a Home
|
||||
// read in flight is stopped so it cannot bring it back; if the server
|
||||
// refuses, the dot is put back as it was before the first of the marks in
|
||||
// flight. Once the last mark in flight settles Home is read again, so the
|
||||
// dot shows only for an event stored since.
|
||||
export function useMarkUpdatesSeen() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
// alone says whether this is the only mark in flight; each counts itself
|
||||
// until it has settled.
|
||||
const alone = () => queryClient.isMutating({ mutationKey: MARK_SEEN_KEY }) === 1
|
||||
return useMutation({
|
||||
mutationKey: MARK_SEEN_KEY,
|
||||
mutationFn: async (through: string) => {
|
||||
await apiFetch<unknown>(UPDATES_SEEN_PATH, { method: 'POST', body: { through } })
|
||||
},
|
||||
onMutate: async () => {
|
||||
// A mark sent while another is in flight would snapshot that one's
|
||||
// optimistic answer, so only the first snapshots.
|
||||
const unread = alone() ? queryClient.getQueryData<Home>(HOME_KEY)?.unread : undefined
|
||||
await queryClient.cancelQueries({ queryKey: HOME_KEY })
|
||||
queryClient.setQueryData<Home>(HOME_KEY, (home) => home && { ...home, unread: false })
|
||||
return { unread }
|
||||
},
|
||||
onError: (_error, _through, context) => {
|
||||
if (context?.unread === undefined || !alone()) return
|
||||
const { unread } = context
|
||||
queryClient.setQueryData<Home>(HOME_KEY, (home) => home && { ...home, unread })
|
||||
},
|
||||
onSettled: () => {
|
||||
if (alone()) void queryClient.invalidateQueries({ queryKey: HOME_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { photoId } from '../dinners/api.ts'
|
||||
import { isNotificationOpened } from '../push/notification.ts'
|
||||
|
||||
export const ROTATION_REVIEWS_PATH = '/api/v1/rotation-reviews'
|
||||
export const ROTATION_REVIEWS_KEY = ['rotation-reviews'] as const
|
||||
const DINNERS_KEY = ['dinners'] as const
|
||||
|
||||
// rotationReviewPath is where the household answers "Keep it in rotation?"
|
||||
// for a tried dinner.
|
||||
export function rotationReviewPath(dinnerId: string): string {
|
||||
return `/api/v1/dinners/${encodeURIComponent(dinnerId)}/rotation-review`
|
||||
}
|
||||
|
||||
// Rotation check answers: keep makes the tried dinner a Regular, drop ("Not
|
||||
// for us") archives it so it is never suggested again, and not_made ("We
|
||||
// didn't make it") returns it to the Quest log. Keep is the only way a tried
|
||||
// dinner becomes a Regular; nothing decides one automatically.
|
||||
export const DECISIONS = ['keep', 'drop', 'not_made'] as const
|
||||
export type Decision = (typeof DECISIONS)[number]
|
||||
|
||||
// RotationReview is a pending Rotation check: the dinner tried, photo_id its
|
||||
// stored photo's id, and the household-local plan date, YYYY-MM-DD, it was
|
||||
// planned on.
|
||||
export type RotationReview = { dinner: { id: string; name: string; photo_id: string | undefined }; plan_date: string }
|
||||
|
||||
function parseReview(where: string, body: unknown): RotationReview {
|
||||
if (!isRecord(body) || typeof body.plan_date !== 'string') throw new Error(`${where}: a review is not a Rotation check`)
|
||||
const dinner = body.dinner
|
||||
if (!isRecord(dinner) || typeof dinner.id !== 'string' || typeof dinner.name !== 'string') {
|
||||
throw new Error(`${where}: a Rotation check has no dinner`)
|
||||
}
|
||||
return { dinner: { id: dinner.id, name: dinner.name, photo_id: photoId(where, dinner.photo) }, plan_date: body.plan_date }
|
||||
}
|
||||
|
||||
// parseRotationReviews checks a GET /api/v1/rotation-reviews body: the
|
||||
// pending Rotation checks, oldest plan date first.
|
||||
export function parseRotationReviews(where: string, body: unknown): RotationReview[] {
|
||||
if (!isRecord(body) || !Array.isArray(body.reviews)) throw new Error(`${where}: response is not the Rotation checks`)
|
||||
return body.reviews.map((review) => parseReview(where, review))
|
||||
}
|
||||
|
||||
// useRotationReviews is the pending Rotation checks (GET
|
||||
// /api/v1/rotation-reviews), oldest plan date first. Each mount of Home
|
||||
// reads them afresh, and so does a notification tap that focuses Home
|
||||
// already open (the service worker's NotificationOpened), so Home opened
|
||||
// from a rotation_review notification shows the check it is about.
|
||||
export function useRotationReviews() {
|
||||
const { apiFetch } = useApi()
|
||||
const query = useQuery({
|
||||
queryKey: ROTATION_REVIEWS_KEY,
|
||||
queryFn: async () =>
|
||||
parseRotationReviews(`GET ${ROTATION_REVIEWS_PATH}`, await apiFetch<unknown>(ROTATION_REVIEWS_PATH)),
|
||||
refetchOnMount: 'always',
|
||||
})
|
||||
const { refetch } = query
|
||||
useEffect(() => {
|
||||
// Without a service worker there are no notifications to tap.
|
||||
if (!('serviceWorker' in navigator)) return
|
||||
const container = navigator.serviceWorker
|
||||
const onMessage = (event: MessageEvent<unknown>) => {
|
||||
if (isNotificationOpened(event.data)) void refetch()
|
||||
}
|
||||
container.addEventListener('message', onMessage)
|
||||
return () => container.removeEventListener('message', onMessage)
|
||||
}, [refetch])
|
||||
return query
|
||||
}
|
||||
|
||||
// useDecideRotationReview answers "Keep it in rotation?" for a pending
|
||||
// Rotation check (POST /api/v1/dinners/{id}/rotation-review). The answer
|
||||
// must be that dinner, decided that way.
|
||||
//
|
||||
// Home leaves the check out while its answer is pending (the mutation's
|
||||
// variables say which), so the next one shows at once; a read in flight is
|
||||
// stopped so it cannot bring the check back meanwhile. Once saved, the check
|
||||
// leaves the cached list; once failed, it is shown again as the list has it.
|
||||
// Either way the Rotation checks and the dinners lists (the Regulars and the
|
||||
// Quest log the answer moved it to) are read again.
|
||||
export function useDecideRotationReview() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation<void, Error, { review: RotationReview; decision: Decision }>({
|
||||
mutationFn: async ({ review, decision }) => {
|
||||
const path = rotationReviewPath(review.dinner.id)
|
||||
const body = await apiFetch<unknown>(path, { method: 'POST', body: { decision } })
|
||||
if (!isRecord(body) || !isRecord(body.dinner) || body.dinner.id !== review.dinner.id || body.decision !== decision) {
|
||||
throw new Error(`POST ${path}: response is not the Rotation check decided ${decision}`)
|
||||
}
|
||||
},
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: ROTATION_REVIEWS_KEY })
|
||||
},
|
||||
onSuccess: (_data, { review }) => {
|
||||
queryClient.setQueryData<RotationReview[]>(ROTATION_REVIEWS_KEY, (reviews) =>
|
||||
reviews?.filter((r) => r.dinner.id !== review.dinner.id),
|
||||
)
|
||||
},
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ROTATION_REVIEWS_KEY })
|
||||
void queryClient.invalidateQueries({ queryKey: DINNERS_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { createApiClient, createVerifiedSession } from '../api/index.ts'
|
||||
import App from '../App.tsx'
|
||||
import { AuthBootstrap } from '../AuthBootstrap.tsx'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { ApiProviders, HOME, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
@@ -58,6 +58,7 @@ function stubFirstRunServer(vapidKey: () => Response = () => jsonResponse(200, V
|
||||
const path = String(input)
|
||||
const method = init?.method ?? 'GET'
|
||||
if (method === 'GET' && path === '/api/v1/me') return Promise.resolve(jsonResponse(200, ME))
|
||||
if (method === 'GET' && path === '/api/v1/home') return Promise.resolve(jsonResponse(200, HOME))
|
||||
if (method === 'GET' && path === '/api/v1/push/vapid-public-key') return Promise.resolve(vapidKey())
|
||||
if (method === 'POST' && path === '/api/v1/push/subscriptions') {
|
||||
subscriptions.push(JSON.parse(String(init?.body)))
|
||||
@@ -110,7 +111,7 @@ async function renderStep(vapidKey?: () => Response) {
|
||||
<AuthBootstrap>
|
||||
<Routes>
|
||||
<Route path="/onboarding/notifications" element={<NotificationsStep />} />
|
||||
<Route path="/" element={<h1>Home</h1>} />
|
||||
<Route path="/" element={<h1>Tonight's quest</h1>} />
|
||||
<Route path="/onboarding/regulars" element={<h1>Regulars step</h1>} />
|
||||
</Routes>
|
||||
</AuthBootstrap>
|
||||
@@ -134,7 +135,7 @@ async function finishFromResult(server: ReturnType<typeof stubFirstRunServer>) {
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
|
||||
completed(server.held[0]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
}
|
||||
|
||||
function useIphoneSafariTab() {
|
||||
@@ -210,7 +211,7 @@ describe('onboarding step 3', () => {
|
||||
expect(later()).toBeDisabled()
|
||||
completed(server.held[0]!)
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
expect(requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -314,7 +315,7 @@ describe('onboarding step 3', () => {
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
completed(server.held[0]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
expect(browser.notification.requestPermission).not.toHaveBeenCalled()
|
||||
expect(browser.pushManager.subscribe).not.toHaveBeenCalled()
|
||||
expect(server.subscriptions).toEqual([])
|
||||
@@ -358,7 +359,7 @@ describe('onboarding step 3', () => {
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
completed(server.held[1]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -114,6 +114,8 @@ function dinner(id: string, name: string, difficulty: string | null, minutes: nu
|
||||
is_favorite: false,
|
||||
photo_url: null,
|
||||
grounding_status: 'grounded',
|
||||
grounding_error: null,
|
||||
photo_backfill_eligible: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,16 +162,25 @@ function planReads(fetchMock: Mock<typeof fetch>) {
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||
// the Plan page, over queryClient, with shopping as the Supply run screen.
|
||||
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />) {
|
||||
// the Plan page at entry, over queryClient, with shopping as the Supply run
|
||||
// screen.
|
||||
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />, entry = '/plan') {
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/plan']}>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes>
|
||||
<Route path="/plan" element={<PlanPage />} />
|
||||
<Route
|
||||
path="/plan"
|
||||
element={
|
||||
<>
|
||||
<PlanPage />
|
||||
<Searched />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Route path="/plan/shopping" element={shopping} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
@@ -190,6 +201,11 @@ function Opened() {
|
||||
)
|
||||
}
|
||||
|
||||
// Searched shows the Plan's search, the week it keeps in ?week=.
|
||||
function Searched() {
|
||||
return <p data-testid="search">{useLocation().search}</p>
|
||||
}
|
||||
|
||||
// pushesKept counts the pushes of the week of Oct 5 the mutation cache of
|
||||
// queryClient keeps.
|
||||
function pushesKept(queryClient: QueryClient) {
|
||||
@@ -1114,6 +1130,30 @@ describe('PlanPage', () => {
|
||||
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens on the week of ?week=, as a plan_ready update for an older week links it', async () => {
|
||||
stubServer({
|
||||
[`GET ${PREVIOUS_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
...lists,
|
||||
})
|
||||
await renderPage(createQueryClient(), <Opened />, '/plan?week=2026-09-28')
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Sep 28 – Oct 4' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument()
|
||||
// The week buttons keep ?week= on the week shown.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-05')
|
||||
})
|
||||
|
||||
it('says so when ?week= is not a Monday, and reads no plan', async () => {
|
||||
const fetchMock = stubServer({ ...lists })
|
||||
await renderPage(createQueryClient(), <Opened />, '/plan?week=2026-10-07')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('“2026-10-07” is not the Monday of a week, as YYYY-MM-DD.')
|
||||
expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith('/api/v1/plans/'))).toBe(false)
|
||||
})
|
||||
|
||||
it('moves between weeks with the previous and next week buttons', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { Link, useSearchParams } from 'react-router'
|
||||
import { ApiError, 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 { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
|
||||
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun, WEEK_PARAM } from '../shopping/api.ts'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
addDays,
|
||||
dayOfMonth,
|
||||
defaultWeek,
|
||||
isMonday,
|
||||
localNow,
|
||||
weekdayKicker,
|
||||
weekdayName,
|
||||
@@ -666,19 +667,28 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
|
||||
// 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
|
||||
// Regulars in a right panel on desktop (2d). It opens on the week chosen by
|
||||
// ?week=, as a plan_ready update links it, else 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
|
||||
// week; the week buttons keep ?week= on the week shown. 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()))
|
||||
const [params, setParams] = useSearchParams()
|
||||
const chosen = params.get(WEEK_PARAM)
|
||||
// The default week is chosen once, not on every render: the plan_draft
|
||||
// time passing does not move the page to another week.
|
||||
const [opened, setOpened] = useState<string | null>(null)
|
||||
if (opened === null && settings.data) setOpened(defaultWeek(settings.data, new Date()))
|
||||
const week = chosen ?? opened
|
||||
const setWeek = (weekStart: string) => setParams({ [WEEK_PARAM]: weekStart }, { replace: true })
|
||||
|
||||
let content
|
||||
if (settings.isError) {
|
||||
if (chosen !== null && !isMonday(chosen)) {
|
||||
content = <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner>
|
||||
} else if (settings.isError) {
|
||||
content = (
|
||||
<ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addDays, dayAndDate, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
|
||||
import {
|
||||
addDays,
|
||||
dayAndDate,
|
||||
dayOfMonth,
|
||||
defaultWeek,
|
||||
isMonday,
|
||||
localNow,
|
||||
untilLocalMidnight,
|
||||
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' }
|
||||
@@ -72,4 +84,45 @@ describe('week helpers', () => {
|
||||
expect(defaultWeek(sunday, new Date('2026-10-10T16:00:00Z'))).toBe('2026-10-05') // Sat
|
||||
expect(defaultWeek(sunday, new Date('2026-10-11T22:00:00Z'))).toBe('2026-10-12') // Sun 18:00
|
||||
})
|
||||
|
||||
it('says whether a date is a real Monday', () => {
|
||||
expect(isMonday('2026-10-05')).toBe(true)
|
||||
expect(isMonday('2026-10-07')).toBe(false)
|
||||
expect(isMonday('2026-02-30')).toBe(false)
|
||||
expect(isMonday('next week')).toBe(false)
|
||||
})
|
||||
|
||||
it('counts the time to the next midnight in the household zone, not UTC', () => {
|
||||
// 23:59:30.250 in New York is already 03:59 the next day in UTC.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-10-03T03:59:30.250Z'))).toBe(29_750)
|
||||
// At midnight itself the next is a day away.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-10-03T04:00:00Z'))).toBe(24 * 60 * 60 * 1000)
|
||||
// The same instant is 13:00 in Tokyo: eleven hours to go.
|
||||
expect(untilLocalMidnight('Asia/Tokyo', new Date('2026-10-03T04:00:00Z'))).toBe(11 * 60 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('counts a day shortened by the spring clock change as 23 hours', () => {
|
||||
const hour = 60 * 60 * 1000
|
||||
// 01:30 EST on Sunday March 8, 2026: clocks go forward at 02:00, so the
|
||||
// next midnight is 00:00 EDT on March 9 (04:00Z), 21.5 hours away.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T06:30:00Z'))).toBe(21.5 * hour)
|
||||
// 03:30 EDT, after the change: 20.5 hours to midnight.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T07:30:00Z'))).toBe(20.5 * hour)
|
||||
// At midnight starting the 23-hour day, the next midnight is 23 hours away.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T05:00:00Z'))).toBe(23 * hour)
|
||||
// Noon EST the day before: the change is after the coming midnight.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-03-07T17:00:00Z'))).toBe(12 * hour)
|
||||
})
|
||||
|
||||
it('counts a day lengthened by the autumn clock change as 25 hours', () => {
|
||||
const hour = 60 * 60 * 1000
|
||||
// Midnight EDT on Sunday November 1, 2026: clocks go back at 02:00.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-11-01T04:00:00Z'))).toBe(25 * hour)
|
||||
})
|
||||
|
||||
it('starts the next day at the clock change when the clocks skip midnight', () => {
|
||||
// Santiago goes from 24:00 -04 to 01:00 -03 on September 6, 2026 (04:00Z);
|
||||
// at 23:00 -04 on September 5 the next day starts an hour later.
|
||||
expect(untilLocalMidnight('America/Santiago', new Date('2026-09-06T03:00:00Z'))).toBe(60 * 60 * 1000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,6 +35,15 @@ export function weekStart(date: string): string {
|
||||
return addDays(date, -sinceMonday)
|
||||
}
|
||||
|
||||
// isMonday says whether date is a real YYYY-MM-DD Monday.
|
||||
export function isMonday(date: string): boolean {
|
||||
try {
|
||||
return weekStart(date) === date
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// weekdayKicker is date's day as a tile kicker: "MON" to "SUN".
|
||||
export function weekdayKicker(date: string): string {
|
||||
return WEEKDAYS[midnightUTC(date).getUTCDay()]!
|
||||
@@ -115,3 +124,39 @@ export function defaultWeek(schedule: { timezone: string; plan_draft_dow: number
|
||||
const drafted = today.date > draftDay || (today.date === draftDay && today.time >= schedule.plan_draft_time)
|
||||
return drafted ? addDays(thisWeek, 7) : thisWeek
|
||||
}
|
||||
|
||||
// wallClock is the household-local wall clock at instant t in IANA zone,
|
||||
// read as if it were UTC: the zone's UTC offset at t is wallClock - t.
|
||||
function wallClock(zone: string, t: number): number {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: zone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(new Date(t))
|
||||
const part = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((p) => p.type === type)!.value)
|
||||
const wall = Date.UTC(part('year'), part('month') - 1, part('day'), part('hour'), part('minute'), part('second'))
|
||||
return wall + (((t % 1000) + 1000) % 1000)
|
||||
}
|
||||
|
||||
// untilLocalMidnight is how many milliseconds after instant now the next
|
||||
// local day begins in IANA zone. It finds the actual instant of the next
|
||||
// day's midnight, so a day shortened or lengthened by a change of UTC offset
|
||||
// is counted as it is. The candidates are that midnight at the zone's offset
|
||||
// now and at the offset that candidate lands on; the earliest that falls on
|
||||
// the next day is its start, which is the offset change itself when the
|
||||
// clocks skip midnight.
|
||||
export function untilLocalMidnight(zone: string, now: Date): number {
|
||||
const t = now.getTime()
|
||||
const tomorrow = addDays(localNow(zone, now).date, 1)
|
||||
const midnight = midnightUTC(tomorrow).getTime()
|
||||
const first = midnight - (wallClock(zone, t) - t)
|
||||
const second = midnight - (wallClock(zone, first) - first)
|
||||
const starts = [first, second].filter((c) => localNow(zone, new Date(c)).date === tomorrow)
|
||||
if (starts.length === 0) throw new Error(`no start of ${tomorrow} found in ${zone}`)
|
||||
return Math.min(...starts) - t
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
GENERIC_NOTICE,
|
||||
appPath,
|
||||
clickPath,
|
||||
isNotificationOpened,
|
||||
openNotification,
|
||||
parsePushPayload,
|
||||
planClick,
|
||||
@@ -178,6 +179,7 @@ describe('openNotification', () => {
|
||||
focused,
|
||||
focus: vi.fn(async () => {}),
|
||||
navigate: vi.fn(async () => {}),
|
||||
postMessage: vi.fn(),
|
||||
} satisfies ClickWindow
|
||||
}
|
||||
|
||||
@@ -193,6 +195,18 @@ describe('openNotification', () => {
|
||||
expect(win.focus).toHaveBeenCalled()
|
||||
expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`)
|
||||
expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!)
|
||||
expect(win.postMessage).not.toHaveBeenCalled()
|
||||
expect(all.openWindow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('focuses a window already at the url and tells it NotificationOpened instead of navigating', async () => {
|
||||
const win = window('/', true)
|
||||
const all = clients([win])
|
||||
await openNotification({ url: '/' }, all, origin)
|
||||
expect(win.focus).toHaveBeenCalled()
|
||||
expect(win.navigate).not.toHaveBeenCalled()
|
||||
expect(win.postMessage).toHaveBeenCalledWith({ type: 'NOTIFICATION_OPENED', url: `${origin}/` })
|
||||
expect(isNotificationOpened(win.postMessage.mock.calls[0]![0])).toBe(true)
|
||||
expect(all.openWindow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -150,6 +150,18 @@ export function planClick<W extends AppWindow>(windows: readonly W[], path: stri
|
||||
export type ClickWindow = AppWindow & {
|
||||
focus(): Promise<unknown>
|
||||
navigate(url: string): Promise<unknown>
|
||||
postMessage(message: NotificationOpened): void
|
||||
}
|
||||
|
||||
// NotificationOpened is the message a tap posts to an open window it focuses
|
||||
// without navigating, because the window is already at url: nothing remounts
|
||||
// there, so the screen reads afresh what the notification is about.
|
||||
export type NotificationOpened = { type: 'NOTIFICATION_OPENED'; url: string }
|
||||
|
||||
// isNotificationOpened reports whether a message from the service worker is
|
||||
// NotificationOpened.
|
||||
export function isNotificationOpened(message: unknown): message is NotificationOpened {
|
||||
return isRecord(message) && message.type === 'NOTIFICATION_OPENED' && typeof message.url === 'string'
|
||||
}
|
||||
|
||||
export type ClickClients<W extends ClickWindow> = {
|
||||
@@ -158,7 +170,8 @@ export type ClickClients<W extends ClickWindow> = {
|
||||
}
|
||||
|
||||
// openNotification carries out a tap on a notification with data: focus
|
||||
// first, while the tap still allows it, then navigate.
|
||||
// first, while the tap still allows it, then navigate; a window already at
|
||||
// the url is told NotificationOpened instead.
|
||||
export async function openNotification<W extends ClickWindow>(
|
||||
data: unknown,
|
||||
clients: ClickClients<W>,
|
||||
@@ -171,4 +184,5 @@ export async function openNotification<W extends ClickWindow>(
|
||||
}
|
||||
await plan.window.focus()
|
||||
if (plan.navigate) await plan.window.navigate(plan.url)
|
||||
else plan.window.postMessage({ type: 'NOTIFICATION_OPENED', url: plan.url })
|
||||
}
|
||||
|
||||
@@ -300,3 +300,27 @@
|
||||
.lock {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Find missing photos: its hint, button and progress, padded like a sheet. */
|
||||
.card.backfill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Why a Regular's recipe was not found. */
|
||||
.reason {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
background: var(--color-neutral-100);
|
||||
color: var(--color-neutral-900);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.reasonLabel {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,11 @@ const TACOS = dinnerBody('d1', 'Beef tacos', {
|
||||
photo_url: '/media/photos/p1-400.jpg',
|
||||
})
|
||||
const CHILI = dinnerBody('d2', 'Chili', { method: 'crockpot', minutes_total: 480, grounding_status: 'pending' })
|
||||
const PIZZA = dinnerBody('d3', 'Pizza night', { grounding_status: 'failed', grounding_error: 'no_recipe_found' })
|
||||
const PIZZA = dinnerBody('d3', 'Pizza night', {
|
||||
grounding_status: 'failed',
|
||||
grounding_error: 'no_recipe_found',
|
||||
photo_backfill_eligible: true,
|
||||
})
|
||||
const ROAST = dinnerBody('d4', 'Pot roast', { method: 'oven', grounding_status: 'manual' })
|
||||
|
||||
function rows() {
|
||||
@@ -496,6 +500,204 @@ describe('RegularsSettingsPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Photo backfill', () => {
|
||||
const BACKFILL = 'POST /api/v1/settings/photo-backfill'
|
||||
const JOB = 'GET /api/v1/jobs/j9'
|
||||
|
||||
// backfillJob is a GET /api/v1/jobs/j9 answer for the photo backfill.
|
||||
function backfillJob(status: string, result: unknown = null, error: string | null = null) {
|
||||
return jsonResponse(200, { id: 'j9', kind: 'backfill_dinner_photos', status, result, error })
|
||||
}
|
||||
|
||||
// polls answers each poll of the job with the next answer, then the last
|
||||
// one again; an answer may change the server first.
|
||||
function polls(...answers: (() => Response)[]) {
|
||||
let next = 0
|
||||
return () => answers[Math.min(next++, answers.length - 1)]!()
|
||||
}
|
||||
|
||||
function fixSheet() {
|
||||
return within(screen.getByRole('dialog', { name: 'Fix recipe' }))
|
||||
}
|
||||
|
||||
const NO_CANDIDATES = { 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, { candidates: [] }) }
|
||||
|
||||
it('shows why the recipe was not found in the fix-recipe sheet', async () => {
|
||||
stubServer([PIZZA], NO_CANDIDATES)
|
||||
await renderPage()
|
||||
|
||||
openRow('Pizza night')
|
||||
expect(fixSheet().getByText('No recipe page the search found had a recipe we could use.')).toBeInTheDocument()
|
||||
expect(fixSheet().getByRole('button', { name: 'Search again' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('offers no search again for a pasted link that had no usable recipe', async () => {
|
||||
const linked = { ...PIZZA, grounding_error: 'recipe_unusable', photo_backfill_eligible: false }
|
||||
stubServer([linked], NO_CANDIDATES)
|
||||
await renderPage()
|
||||
|
||||
openRow('Pizza night')
|
||||
expect(fixSheet().getByText('The pasted recipe link has no recipe we could use.')).toBeInTheDocument()
|
||||
expect(fixSheet().queryByRole('button', { name: 'Search again' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers no search again for a dinner with a pasted link whatever its reason', async () => {
|
||||
const linked = { ...PIZZA, grounding_error: 'ai_failed', photo_backfill_eligible: false }
|
||||
stubServer([linked], NO_CANDIDATES)
|
||||
await renderPage()
|
||||
|
||||
openRow('Pizza night')
|
||||
expect(fixSheet().getByText("The AI assistant couldn't read the recipe.")).toBeInTheDocument()
|
||||
expect(fixSheet().queryByRole('button', { name: 'Search again' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('searches again from the fix-recipe sheet and shows the new reason when it still fails', async () => {
|
||||
const server = stubServer([PIZZA], {
|
||||
...NO_CANDIDATES,
|
||||
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
|
||||
[JOB]: polls(
|
||||
() => backfillJob('running'),
|
||||
() => {
|
||||
server.regulars = [{ ...PIZZA, grounding_error: 'search_failed' }]
|
||||
return backfillJob('succeeded', { dinners: [{ dinner_id: 'd3', outcome: 'failed', reason: 'search_failed' }] })
|
||||
},
|
||||
),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
openRow('Pizza night')
|
||||
fireEvent.click(fixSheet().getByRole('button', { name: 'Search again' }))
|
||||
expect(await fixSheet().findByRole('button', { name: 'Searching again…' })).toBeDisabled()
|
||||
expect(fixSheet().getByText('Searching again… this can take a few minutes.')).toBeInTheDocument()
|
||||
expect(sent(server.fetchMock)).toEqual([{ request: BACKFILL, body: undefined }])
|
||||
|
||||
expect(await fixSheet().findByText('Searched again: still no recipe found.', {}, { timeout: 3000 })).toBeInTheDocument()
|
||||
expect(await fixSheet().findByText("The recipe search couldn't be reached.")).toBeInTheDocument()
|
||||
expect(fixSheet().getByRole('button', { name: 'Search again' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('closes the fix-recipe sheet once searching again finds the recipe and its photo', async () => {
|
||||
const server = stubServer([PIZZA], {
|
||||
...NO_CANDIDATES,
|
||||
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
|
||||
[JOB]: () => {
|
||||
server.regulars = [{ ...PIZZA, grounding_status: 'grounded', grounding_error: null, photo_url: '/media/photos/p8-400.jpg' }]
|
||||
return backfillJob('succeeded', { dinners: [{ dinner_id: 'd3', outcome: 'grounded', reason: null }] })
|
||||
},
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
openRow('Pizza night')
|
||||
fireEvent.click(fixSheet().getByRole('button', { name: 'Search again' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
|
||||
expect(within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })).toHaveAttribute('src', '/media/photos/p8-400.jpg')
|
||||
})
|
||||
|
||||
it('says when a dinner was not included in the search', async () => {
|
||||
stubServer([PIZZA], {
|
||||
...NO_CANDIDATES,
|
||||
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
|
||||
[JOB]: () => backfillJob('succeeded', { dinners: [] }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
openRow('Pizza night')
|
||||
fireEvent.click(fixSheet().getByRole('button', { name: 'Search again' }))
|
||||
expect(await fixSheet().findByText('This dinner was not included in that search.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('finds missing photos: the placeholder shows until the backfill finds a photo', async () => {
|
||||
const found = { ...PIZZA, grounding_status: 'grounded', grounding_error: null, photo_url: '/media/photos/p8-400.jpg' }
|
||||
const server = stubServer([TACOS, PIZZA], {
|
||||
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
|
||||
[JOB]: polls(
|
||||
() => backfillJob('queued'),
|
||||
() => {
|
||||
server.regulars = [TACOS, found]
|
||||
return backfillJob('succeeded', { dinners: [{ dinner_id: 'd3', outcome: 'grounded', reason: null }] })
|
||||
},
|
||||
),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
// The photoless dinner shows the striped placeholder, not an image.
|
||||
const placeholder = within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })
|
||||
expect(placeholder).not.toHaveAttribute('src')
|
||||
expect(placeholder).toHaveTextContent('dish photo')
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'Find missing photos' })
|
||||
expectMinHitArea(trigger)
|
||||
fireEvent.click(trigger)
|
||||
expect(await screen.findByRole('button', { name: 'Finding photos…' })).toBeDisabled()
|
||||
expect(await screen.findByText('Finding missing photos… this can take a few minutes.')).toBeInTheDocument()
|
||||
expect(within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })).not.toHaveAttribute('src')
|
||||
expect(sent(server.fetchMock)).toEqual([{ request: BACKFILL, body: undefined }])
|
||||
|
||||
expect(await screen.findByText('Photo search done: 1 photo found.', {}, { timeout: 3000 })).toBeInTheDocument()
|
||||
await waitFor(() =>
|
||||
expect(within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })).toHaveAttribute('src', '/media/photos/p8-400.jpg'),
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Find missing photos' })).toBeEnabled()
|
||||
expect(reads(server.fetchMock)).toBe(2)
|
||||
})
|
||||
|
||||
it('sums up what the backfill did', async () => {
|
||||
stubServer([PIZZA], {
|
||||
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
|
||||
[JOB]: () =>
|
||||
backfillJob('succeeded', {
|
||||
dinners: [
|
||||
{ dinner_id: 'd3', outcome: 'grounded', reason: null },
|
||||
{ dinner_id: 'd5', outcome: 'grounded', reason: null },
|
||||
{ dinner_id: 'd6', outcome: 'failed', reason: 'no_recipe_found' },
|
||||
{ dinner_id: 'd7', outcome: 'skipped', reason: null },
|
||||
],
|
||||
}),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Photo search done: 2 photos found · 1 dinner still needs a recipe · 1 dinner changed meanwhile, left as it was.',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says when no dinner was missing a photo', async () => {
|
||||
stubServer([TACOS], {
|
||||
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
|
||||
[JOB]: () => backfillJob('succeeded', { dinners: [] }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
|
||||
expect(await screen.findByText('No dinners were missing a photo.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says why the backfill stopped', async () => {
|
||||
stubServer([PIZZA], {
|
||||
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
|
||||
[JOB]: () => backfillJob('failed', { dinners: [] }, 'internal_error'),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Photo search stopped: Something went wrong on the server.')
|
||||
expect(screen.getByRole('button', { name: 'Find missing photos' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('says why the backfill could not start', async () => {
|
||||
stubServer([PIZZA], { [BACKFILL]: () => errorResponse(403, 'csrf_failed') })
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Photo search not started: csrf_failed message')
|
||||
expect(screen.getByRole('button', { name: 'Find missing photos' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes grounding status while any Regular is finding its recipe, then stops', async () => {
|
||||
// Only intervals are faked: TanStack Query polls with setInterval, and
|
||||
// Testing Library's waits keep their real timers.
|
||||
|
||||
@@ -34,6 +34,7 @@ import { Sheet } from '../ui/Sheet.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import { TextInput } from '../ui/TextInput.tsx'
|
||||
import { usePhotoBackfill, type BackfillResult, type PhotoBackfill } from './photoBackfill.ts'
|
||||
import styles from './RegularsSettingsPage.module.css'
|
||||
|
||||
// The most minutes a dinner may take, as the dinners API allows: a day.
|
||||
@@ -57,7 +58,8 @@ const LINK = /^https?:\/\//i
|
||||
// method and minutes, favorite heart and whether its recipe was found. The
|
||||
// field at the top adds one by name, or by pasted recipe link with a name
|
||||
// the household gives it; tapping one
|
||||
// edits or removes it, or fixes its recipe when none was found.
|
||||
// edits or removes it, or fixes its recipe when none was found. Find
|
||||
// missing photos searches again for every dinner left without one.
|
||||
export function RegularsSettingsPage() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
@@ -86,6 +88,7 @@ function RegularsManager() {
|
||||
const remove = useRemoveRegular()
|
||||
const favorite = useToggleFavorite()
|
||||
const { refusals, track } = useRefusals()
|
||||
const backfill = usePhotoBackfill()
|
||||
const [draft, setDraft] = useState('')
|
||||
const [linkName, setLinkName] = useState('')
|
||||
const [open, setOpen] = useState<Open>(null)
|
||||
@@ -94,8 +97,10 @@ function RegularsManager() {
|
||||
const isLink = LINK.test(draft.trim())
|
||||
const ready = draft.trim() !== '' && (!isLink || linkName.trim() !== '')
|
||||
const list = regulars.data ?? []
|
||||
// A Regular removed meanwhile closes its sheet.
|
||||
// A Regular removed meanwhile closes its sheet, and one whose recipe was
|
||||
// found meanwhile closes its fix-recipe sheet.
|
||||
const opened = open && list.find((regular) => regular.id === open.id && !regular.pending)
|
||||
const fixing = opened && open.sheet === 'fix' && opened.grounding_status === 'failed' ? opened : undefined
|
||||
|
||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
@@ -188,6 +193,7 @@ function RegularsManager() {
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{regulars.isSuccess && <FindMissingPhotos backfill={backfill} />}
|
||||
|
||||
{opened && open.sheet === 'edit' && (
|
||||
<EditRegularSheet
|
||||
@@ -197,12 +203,13 @@ function RegularsManager() {
|
||||
onRemove={() => onRemove(opened)}
|
||||
/>
|
||||
)}
|
||||
{opened && open.sheet === 'fix' && (
|
||||
{fixing && (
|
||||
<FixRecipeSheet
|
||||
key={opened.id}
|
||||
regular={opened}
|
||||
key={fixing.id}
|
||||
regular={fixing}
|
||||
backfill={backfill}
|
||||
onClose={() => setOpen(null)}
|
||||
onEdit={() => setOpen({ sheet: 'edit', id: opened.id })}
|
||||
onEdit={() => setOpen({ sheet: 'edit', id: fixing.id })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -231,6 +238,88 @@ function GroundingBadge({ status }: { status: GroundingStatus }) {
|
||||
return <Tag className={`${styles.badge} ${badge.className}`}>{badge.text}</Tag>
|
||||
}
|
||||
|
||||
// groundingReasons says why a dinner's recipe was not found, by its
|
||||
// grounding_error reason code.
|
||||
const groundingReasons: Record<string, string> = {
|
||||
no_recipe_found: 'No recipe page the search found had a recipe we could use.',
|
||||
recipe_unusable: 'The pasted recipe link has no recipe we could use.',
|
||||
search_not_configured: 'Recipe search is not set up. An admin can set it up in Settings.',
|
||||
search_failed: "The recipe search couldn't be reached.",
|
||||
ai_failed: "The AI assistant couldn't read the recipe.",
|
||||
interrupted: 'The server stopped while finding the recipe.',
|
||||
grounding_error: 'Something went wrong on the server.',
|
||||
}
|
||||
|
||||
// groundingReason says why a dinner's recipe was not found; an unknown
|
||||
// reason code is shown as it is.
|
||||
export function groundingReason(code: string): string {
|
||||
return groundingReasons[code] ?? code
|
||||
}
|
||||
|
||||
// backfillJobFailures says why a photo backfill job failed, by its failure
|
||||
// code; an unknown code is shown as it is.
|
||||
const backfillJobFailures: Record<string, string> = {
|
||||
internal_error: 'Something went wrong on the server.',
|
||||
}
|
||||
|
||||
// backfillProblem says what went wrong with a photo backfill, or null.
|
||||
function backfillProblem(backfill: PhotoBackfill): string | null {
|
||||
if (backfill.startError) return `Photo search not started: ${backfill.startError.message}`
|
||||
if (backfill.status === 'failed' && backfill.error) {
|
||||
return `Photo search stopped: ${backfillJobFailures[backfill.error.message] ?? backfill.error.message}`
|
||||
}
|
||||
if (backfill.error) return `Couldn't check on the photo search: ${backfill.error.message}`
|
||||
return null
|
||||
}
|
||||
|
||||
function count(n: number, one: string, many: string): string {
|
||||
return `${n} ${n === 1 ? one : many}`
|
||||
}
|
||||
|
||||
// backfillSummary says what a finished photo backfill did.
|
||||
function backfillSummary(result: BackfillResult): string {
|
||||
const tally = (outcome: string) => result.dinners.filter((dinner) => dinner.outcome === outcome).length
|
||||
if (result.dinners.length === 0) return 'No dinners were missing a photo.'
|
||||
const parts = [count(tally('grounded'), 'photo found', 'photos found')]
|
||||
const failed = tally('failed')
|
||||
if (failed > 0) parts.push(`${count(failed, 'dinner still needs', 'dinners still need')} a recipe`)
|
||||
const skipped = tally('skipped')
|
||||
if (skipped > 0) parts.push(`${count(skipped, 'dinner', 'dinners')} changed meanwhile, left as it was`)
|
||||
return `Photo search done: ${parts.join(' · ')}.`
|
||||
}
|
||||
|
||||
// FindMissingPhotos searches again, by name, for a recipe and photo for
|
||||
// every dinner left without a photo because its recipe was not found.
|
||||
function FindMissingPhotos({ backfill }: { backfill: PhotoBackfill }) {
|
||||
const headingId = useId()
|
||||
const problem = backfillProblem(backfill)
|
||||
return (
|
||||
<section aria-labelledby={headingId} className={styles.section}>
|
||||
<h2 id={headingId} className={styles.groupLabel}>
|
||||
Missing photos
|
||||
</h2>
|
||||
<Card className={`${styles.card} ${styles.backfill}`}>
|
||||
<p className={styles.hint}>
|
||||
Search again for a recipe and photo for every dinner whose recipe wasn't found. Dinners with a pasted
|
||||
recipe link keep it.
|
||||
</p>
|
||||
<Button disabled={backfill.active} onClick={backfill.start}>
|
||||
{backfill.active ? 'Finding photos…' : 'Find missing photos'}
|
||||
</Button>
|
||||
<p role="status" className={styles.status}>
|
||||
{backfill.active && 'Finding missing photos… this can take a few minutes.'}
|
||||
{backfill.status === 'succeeded' && backfill.result && backfillSummary(backfill.result)}
|
||||
</p>
|
||||
{problem && (
|
||||
<p role="alert" className={styles.problem}>
|
||||
{problem}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
type RegularRowProps = {
|
||||
regular: Regular
|
||||
onOpen: () => void
|
||||
@@ -447,6 +536,7 @@ const fixPaths = [
|
||||
|
||||
type FixRecipeSheetProps = {
|
||||
regular: Regular
|
||||
backfill: PhotoBackfill
|
||||
onClose: () => void
|
||||
onEdit: () => void
|
||||
}
|
||||
@@ -459,10 +549,20 @@ function lines(text: string): string[] {
|
||||
.filter((line) => line !== '')
|
||||
}
|
||||
|
||||
// FixRecipeSheet grounds a Regular whose recipe was not found: in one of
|
||||
// up to three recipe pages found for its name, in a pasted recipe link, or
|
||||
// in ingredients and steps typed in.
|
||||
function FixRecipeSheet({ regular, onClose, onEdit }: FixRecipeSheetProps) {
|
||||
// retryOutcomes says what searching again did for this dinner.
|
||||
const retryOutcomes = {
|
||||
grounded: 'Searched again: a recipe and photo were found.',
|
||||
failed: 'Searched again: still no recipe found.',
|
||||
skipped: 'It changed while it was searched again, so it was left as it is.',
|
||||
} as const
|
||||
|
||||
// FixRecipeSheet shows why a Regular's recipe was not found and grounds it:
|
||||
// by searching its name again (a photo backfill), in one of up to three
|
||||
// recipe pages found for its name, in a pasted recipe link, or in
|
||||
// ingredients and steps typed in. Searching again is offered only for a
|
||||
// dinner the backfill searches (photo_backfill_eligible): it never replaces
|
||||
// a pasted link.
|
||||
function FixRecipeSheet({ regular, backfill, onClose, onEdit }: FixRecipeSheetProps) {
|
||||
const [path, setPath] = useState<FixPath>('pick')
|
||||
const ground = useGroundRegular()
|
||||
|
||||
@@ -475,6 +575,15 @@ function FixRecipeSheet({ regular, onClose, onEdit }: FixRecipeSheetProps) {
|
||||
<p className={styles.hint}>
|
||||
We couldn't find a recipe for {regular.name}. Pick a match below, paste a recipe link or type it in.
|
||||
</p>
|
||||
{regular.grounding_error !== null && (
|
||||
<p className={styles.reason}>
|
||||
<span className={styles.reasonLabel}>Why: </span>
|
||||
{groundingReason(regular.grounding_error)}
|
||||
</p>
|
||||
)}
|
||||
{regular.photo_backfill_eligible && (
|
||||
<RetrySearch id={regular.id} backfill={backfill} disabled={ground.isPending} />
|
||||
)}
|
||||
<Segmented
|
||||
label="How to fix the recipe"
|
||||
options={fixPaths}
|
||||
@@ -504,6 +613,34 @@ function FixRecipeSheet({ regular, onClose, onEdit }: FixRecipeSheetProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// RetrySearch searches the dinner's name again with a photo backfill, and
|
||||
// says what it did for the dinner once it ends. The backfill searches
|
||||
// every dinner without a photo, this one with them.
|
||||
function RetrySearch({ id, backfill, disabled }: { id: string; backfill: PhotoBackfill; disabled: boolean }) {
|
||||
const problem = backfillProblem(backfill)
|
||||
const tried = backfill.result?.dinners.find((dinner) => dinner.dinner_id === id)
|
||||
let status: string | null = null
|
||||
if (backfill.active) status = 'Searching again… this can take a few minutes.'
|
||||
else if (backfill.status === 'succeeded') {
|
||||
status = tried ? retryOutcomes[tried.outcome] : 'This dinner was not included in that search.'
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" disabled={disabled || backfill.active} onClick={backfill.start}>
|
||||
{backfill.active ? 'Searching again…' : 'Search again'}
|
||||
</Button>
|
||||
<p role="status" className={styles.status}>
|
||||
{status}
|
||||
</p>
|
||||
{problem && (
|
||||
<p role="alert" className={styles.problem}>
|
||||
{problem}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PickCandidate({ id, onPick }: { id: string; onPick: (candidateId: string) => void }) {
|
||||
const candidates = useRecipeCandidates(id)
|
||||
if (candidates.isPending) return <Skeleton className={styles.candidatesLoading} />
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { QueryClientProvider } 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 { HOME_KEY } from '../home/api.ts'
|
||||
import { planKey } from '../plan/api.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { fakeLocation, jsonResponse, ME, stubFetch } from '../test/fetchStub.ts'
|
||||
import { parseBackfillResult, usePhotoBackfill } from './photoBackfill.ts'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
const RESULT = { dinners: [{ dinner_id: 'd3', outcome: 'grounded', reason: null }] }
|
||||
|
||||
function backfillJob(status: string, result: unknown) {
|
||||
return jsonResponse(200, { id: 'j9', kind: 'backfill_dinner_photos', status, result, error: null })
|
||||
}
|
||||
|
||||
describe('usePhotoBackfill', () => {
|
||||
it('reads the plans, home and dinners again once the backfill ends, so a found photo shows everywhere', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
jsonResponse(200, ME),
|
||||
jsonResponse(202, { id: 'j9' }),
|
||||
backfillJob('running', null),
|
||||
backfillJob('succeeded', RESULT),
|
||||
)
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const queryClient = createQueryClient()
|
||||
const plan = planKey('2026-10-05')
|
||||
for (const key of [plan, HOME_KEY, ['dinners', 'quest_log']]) queryClient.setQueryData(key, {})
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
const { result } = renderHook(() => usePhotoBackfill(), { wrapper })
|
||||
|
||||
act(() => result.current.start())
|
||||
await waitFor(() => expect(result.current.status).toBe('running'))
|
||||
expect(result.current.active).toBe(true)
|
||||
expect(queryClient.getQueryState(plan)?.isInvalidated).toBe(false)
|
||||
|
||||
await waitFor(() => expect(result.current.status).toBe('succeeded'), { timeout: 3000 })
|
||||
expect(result.current).toMatchObject({ active: false, error: null, result: RESULT })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
for (const key of [plan, HOME_KEY, ['dinners', 'quest_log']]) {
|
||||
expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBackfillResult', () => {
|
||||
it('refuses a result that is not a photo backfill', () => {
|
||||
expect(() => parseBackfillResult('job', { dinners: [{ dinner_id: 'd3', outcome: 'lost', reason: null }] })).toThrow(
|
||||
'job: outcome "lost" is not one of grounded, failed, skipped',
|
||||
)
|
||||
expect(() => parseBackfillResult('job', { stage: 'x' })).toThrow('job: result is not a photo backfill')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useApi, useJob, type JobStatus } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { oneOf } from '../dinners/api.ts'
|
||||
import { HOME_KEY } from '../home/api.ts'
|
||||
|
||||
export const PHOTO_BACKFILL_PATH = '/api/v1/settings/photo-backfill'
|
||||
|
||||
// BACKFILL_JOB_KIND is the kind of the job a photo backfill runs as.
|
||||
export const BACKFILL_JOB_KIND = 'backfill_dinner_photos'
|
||||
|
||||
export const BACKFILL_OUTCOMES = ['grounded', 'failed', 'skipped'] as const
|
||||
export type BackfillOutcome = (typeof BACKFILL_OUTCOMES)[number]
|
||||
|
||||
// BackfillDinner is what a photo backfill did for one dinner. reason is
|
||||
// set only when it failed: the reason code now its grounding_error.
|
||||
export type BackfillDinner = { dinner_id: string; outcome: BackfillOutcome; reason: string | null }
|
||||
|
||||
// BackfillResult is the photo backfill job's result: each dinner it tried,
|
||||
// in the order it tried them.
|
||||
export type BackfillResult = { dinners: BackfillDinner[] }
|
||||
|
||||
// Every query showing a dinner's photo, read again once a backfill ends:
|
||||
// the dinners lists and details, the plans and the home dashboard.
|
||||
const PHOTO_KEYS = [['dinners'], ['plans'], HOME_KEY] as const
|
||||
|
||||
// parseBackfillResult checks a photo backfill job's result, which useJob
|
||||
// passes on unread.
|
||||
export function parseBackfillResult(where: string, body: unknown): BackfillResult {
|
||||
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: result is not a photo backfill`)
|
||||
return {
|
||||
dinners: body.dinners.map((dinner): BackfillDinner => {
|
||||
if (!isRecord(dinner) || typeof dinner.dinner_id !== 'string' || (dinner.reason !== null && typeof dinner.reason !== 'string')) {
|
||||
throw new Error(`${where}: result has a dinner that is not a backfill outcome`)
|
||||
}
|
||||
return {
|
||||
dinner_id: dinner.dinner_id,
|
||||
outcome: oneOf(where, 'outcome', BACKFILL_OUTCOMES, dinner.outcome),
|
||||
reason: dinner.reason,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// PhotoBackfill is a photo backfill started from this screen, as it goes.
|
||||
export type PhotoBackfill = {
|
||||
// start asks the server for a backfill over every dinner without a
|
||||
// photo. While one is already queued or running the server answers with
|
||||
// that job, so starting never runs two at once.
|
||||
start: () => void
|
||||
// active is true from start until the job ends, except while a poll's
|
||||
// failure leaves its progress unknown: starting again then asks the
|
||||
// server for the job it is running and polls it anew.
|
||||
active: boolean
|
||||
// Why the backfill could not be started.
|
||||
startError: Error | null
|
||||
// The job's status, undefined until it is started and first polled.
|
||||
status: JobStatus | undefined
|
||||
// The job's result, once it has one.
|
||||
result: BackfillResult | null
|
||||
// The job's failure, a poll's failure, or a result that is not a backfill.
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
// usePhotoBackfill starts a photo backfill (POST
|
||||
// /api/v1/settings/photo-backfill, 202 {id}) and follows its job with
|
||||
// useJob. When the job ends, every query showing a dinner's photo is read
|
||||
// again, so dinners it grounded show their photo everywhere.
|
||||
export function usePhotoBackfill(): PhotoBackfill {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
const [jobId, setJobId] = useState<string | null>(null)
|
||||
const job = useJob<unknown>(BACKFILL_JOB_KIND, jobId)
|
||||
const begin = useMutation({
|
||||
mutationFn: async (): Promise<string> => {
|
||||
const body = await apiFetch<unknown>(PHOTO_BACKFILL_PATH, { method: 'POST' })
|
||||
if (!isRecord(body) || typeof body.id !== 'string') throw new Error(`POST ${PHOTO_BACKFILL_PATH}: response has no job id`)
|
||||
return body.id
|
||||
},
|
||||
onSuccess: (id) => {
|
||||
setJobId(id)
|
||||
job.poll()
|
||||
},
|
||||
})
|
||||
|
||||
const ended = job.status === 'succeeded' || job.status === 'failed'
|
||||
useEffect(() => {
|
||||
if (!ended) return
|
||||
for (const queryKey of PHOTO_KEYS) void queryClient.invalidateQueries({ queryKey })
|
||||
}, [ended, jobId, queryClient])
|
||||
|
||||
const parsed = useMemo(() => {
|
||||
if (job.result === null) return { result: null, error: null }
|
||||
try {
|
||||
return { result: parseBackfillResult(`GET /api/v1/jobs/${jobId}`, job.result), error: null }
|
||||
} catch (error) {
|
||||
return { result: null, error: error as Error }
|
||||
}
|
||||
}, [job.result, jobId])
|
||||
|
||||
return {
|
||||
start: () => begin.mutate(),
|
||||
active: begin.isPending || (jobId !== null && !ended && job.error === null),
|
||||
startError: begin.error,
|
||||
status: job.status,
|
||||
result: parsed.result,
|
||||
error: job.error ?? parsed.error,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router'
|
||||
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
|
||||
import { defaultWeek, isMonday, localNow, weekdayKicker, weekLabel } from '../plan/week.ts'
|
||||
import { ApiError } from '../api/errors.ts'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
@@ -310,12 +310,3 @@ function ChosenSupplyRun({ readOnly }: { readOnly: boolean }) {
|
||||
const week = chosen ?? defaultWeek(schedule, new Date())
|
||||
return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} readOnly={readOnly} />
|
||||
}
|
||||
|
||||
// isMonday says whether date is a real YYYY-MM-DD Monday.
|
||||
function isMonday(date: string): boolean {
|
||||
try {
|
||||
return weekOf(date) === date
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,18 @@ export const GOOGLE_NOT_CONNECTED = {
|
||||
last_sync_error: null,
|
||||
}
|
||||
|
||||
// HOME is a GET /api/v1/home body as mock 1g shows it: Pizza night, a
|
||||
// Regular, tonight; ten Side quests; next week's Supply run ready; an
|
||||
// unread update.
|
||||
export const HOME = {
|
||||
dateLabel: 'Friday, Oct 2',
|
||||
timezone: 'America/New_York',
|
||||
tonight: { id: 'd1', name: 'Pizza night', photo: '/media/photos/p1-400.jpg', origin: 'regular', minutes_total: 40 },
|
||||
sideQuestCount: 10,
|
||||
shoppingList: { weekStart: '2026-10-05', label: 'Week 41 · Oct 5 – 11', ready: true, sentToChatAt: null },
|
||||
recentUpdates: { unread: true },
|
||||
}
|
||||
|
||||
// NOTIFICATION_SETTINGS is a GET /api/v1/settings/notifications body: the
|
||||
// seed channels and reminder time.
|
||||
export const NOTIFICATION_SETTINGS = {
|
||||
|
||||
@@ -13,6 +13,7 @@ export type DinnerBody = {
|
||||
photo_url: string | null
|
||||
grounding_status: string
|
||||
grounding_error: string | null
|
||||
photo_backfill_eligible: boolean
|
||||
updated_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -35,6 +36,7 @@ export function dinnerBody(id: string, name: string, overrides: Partial<DinnerBo
|
||||
photo_url: null,
|
||||
grounding_status: 'grounded',
|
||||
grounding_error: null,
|
||||
photo_backfill_eligible: false,
|
||||
updated_by: null,
|
||||
created_at: '2026-10-01T15:00:00.000Z',
|
||||
updated_at: '2026-10-01T15:00:00.000Z',
|
||||
|
||||
Reference in New Issue
Block a user