Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 f4bed5d82d Fail interrupted groundings at boot and bound hand grounding
CI / ci (push) Successful in 2m25s
CI / ci (pull_request) Successful in 2m20s
At boot, after the job runner fails the jobs a previous process left
running, FailInterruptedGroundings sets every dinner still pending with
no ground_dinner job queued or running to failed (interrupted). A crash
mid-job, or between saving a dinner and enqueuing its job, no longer
leaves the dinner pending forever.

A search failure on recipe-candidates or ground now logs only the reason
code search_failed, never the wrapped error, which may quote the
provider; the response stays redacted.

When the grounding job cannot be enqueued, POST /dinners answers 201
with the saved dinner failed (grounding_error) and grounding_job_id
null, instead of a 500 that hid the dinner it had created.

Grounding a candidate or link by hand runs under a 90-second deadline
(fetch, photo, normalize_recipe); past it the request answers 504
ground_timeout and the dinner is unchanged.

Maestro task: 0cbaa5bf-8be4-4cbd-aab2-891e7f1bc2b5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:30:53 -04:00
dev_agentandClaude Opus 5.5 d6d82e8368 Ground new Regulars in the background and add recipe fix-up API
CI / ci (push) Successful in 2m37s
CI / ci (pull_request) Successful in 2m18s
Creating a Regular now enqueues a ground_dinner job and returns at once
with its grounding_job_id. The job grounds the dinner's recipe_url with
GroundURL, else its name with Ground, and sets grounding_status grounded
(recipe, photo, method, minutes, difficulty) or failed with a
grounding_error reason code (migration 0009). It writes only a dinner
still pending, so a fix made meanwhile wins.

GET /api/v1/dinners/{id}/recipe-candidates returns up to 3 ranked
candidates {candidate_id, title, site_name, photo_url, minutes} without
downloading photos or running the model. POST /api/v1/dinners/{id}/ground
takes exactly one of {candidate_id}, {recipe_url} or {manual: {...}} and
sets grounded or manual.

PhotoCache takes an ImageFetcher interface and recipestest provides fakes
so other packages can test grounding with fake search, fetch and model.
main wires the Grounder with normalize_recipe over the AI settings.

Maestro task: 0cbaa5bf-8be4-4cbd-aab2-891e7f1bc2b5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:14:31 -04:00
57 changed files with 458 additions and 7004 deletions
+17 -80
View File
@@ -96,7 +96,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
| `recipe_steps` | recipe_id, position, text. |
| `photos` | source_url, local_path, width, height, sha256, fetched_at. |
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised when a pending dinner is renamed, so only the latest `ground_dinner` job may write it), updated_by. |
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), updated_by. |
| `suggestion_batches` | trigger, status, ai_run_id. |
| `suggestions` | dinner_id, batch_id, reason, cited_regular_ids (JSON), status, decided_at. |
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. |
@@ -179,18 +179,16 @@ automatic** (D-021).
response holds the key. `Service.HarnessConfig` reads the settings for
each skill run, so a saved change applies to the next run.
- Dinners (`dinners.Service`). `POST /api/v1/dinners` adds a Regular from
`{name}` or `{name, recipe_url}` (an https:// link, kept in
`recipe_url` for grounding and never returned in a dinner response, as
it may carry a personal token) with status and origin `regular` and
grounding_status `pending`, answering 201 with the dinner. The name is
always the household's: no part of a link is ever used as a dinner's
name, and a link without a name is 400. A name
exactly one of `{name}` or `{recipe_url}` (an https:// link, kept in
`recipe_url` for grounding) with status and origin `regular` and
grounding_status `pending`, answering 201 with the dinner. A link's
dinner is named from its last path segment (separators become spaces,
an extension and all-digit words are dropped: `…/12345/beef-stew/` is
"Beef stew"); a link that names nothing is 400 — add it by name. A name
is trimmed, at most 120 characters and must keep a word after
`harness.Normalize`, which sets `normalized_name`. A name
`harness.Similar` to an unarchived dinner's, or a link one already has
(the source_url of the recipe it is grounded in, else its pasted
`recipe_url`), is 409 `duplicate_dinner`; archived names may be added
again.
`harness.Similar` to an unarchived dinner's, or a link one already has,
is 409 `duplicate_dinner`; archived names may be added again.
`GET /api/v1/dinners?status=regular|quest_log` returns
`{dinners: [...]}` ordered by normalized name, each with `photo_url`
(`/media/photos/{photo_id}-400.jpg`, or null) and `grounding_status`;
@@ -213,22 +211,11 @@ automatic** (D-021).
`failed` with `grounding_error` — also the job's failure code — one of
`no_recipe_found`, `recipe_unusable` (the pasted link), `search_not_configured`,
`search_failed`, `ai_failed`, `interrupted` (the server stopped) or
`grounding_error`. Each job carries the dinner's `grounding_revision`
from when it was enqueued; renaming a `pending` dinner clears its
`recipe_url`, raises the revision and enqueues a job for the new name.
A job for a dinner archived, no longer `pending` or at another revision
does no work, and a job only writes a dinner still unarchived, `pending`
and at its revision, so a fix-up made meanwhile wins and an earlier job
for an old name — queued or running — never decides the outcome.
Archiving or renaming a dinner also stops its running job where it is,
so no further search, page fetch, photo download or model call is made
for it. A
method, minutes_total or difficulty a member saved while the dinner was
`pending` is kept; grounding fills only those left empty. At boot, after
the job runner fails the jobs a previous process left running, every
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.
`grounding_error`. It only writes a dinner still `pending`, so a fix-up
made meanwhile wins. At boot, after the job runner fails the jobs a
previous process left running, every dinner still `pending` with no
`ground_dinner` job queued or running is set `failed` with `interrupted`,
so no dinner stays pending with nothing left to ground it.
`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`
@@ -242,24 +229,14 @@ automatic** (D-021).
— no model runs — as a `recipes` row whose source_url is
`mealquest:manual:{recipe_id}` and whose raw_jsonld is a schema.org
Recipe built from it, and sets `manual` with its method and minutes, no
photo and the dinner's difficulty unchanged. Either clears the dinner's
pasted `recipe_url`: the recipe chosen replaces it. It answers 200 with
the dinner; an invalid body is 400, an unknown dinner 404, a page that cannot
photo and the dinner's difficulty unchanged. It answers 200 with the
dinner; an invalid body is 400, an unknown dinner 404, a page that cannot
ground it 422 `ungroundable` (the dinner is unchanged), recipe search not
set up 409 `not_configured`, a search or model failure 502
`search_failed` / `ai_failed` (only the reason code is logged, never the
provider's text), and grounding a candidate or link that runs past 90
seconds (fetch, photo and `normalize_recipe`, all in the request) 504
`ground_timeout`, leaving the dinner unchanged. An archived dinner is 409
`dinner_archived`. A candidate or link is written only if the dinner is
still as it was before grounding began: one renamed or grounded some
other way meanwhile is 409 `ground_conflict`, and one archived meanwhile
409 `dinner_archived`, either leaving the dinner unchanged. A method,
minutes or difficulty a member edits meanwhile is kept over the recipe's.
Any other failure grounding a candidate or link, or recording it on the
dinner, is 500, logged with a fixed reason and the dinner id only —
never the link, which can carry a personal token, or dependency error
text.
`ground_timeout`, leaving the dinner unchanged.
- `/health` — liveness.
- Everything else serves the embedded PWA with SPA fallback.
@@ -507,46 +484,6 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
`/settings/household`, `/settings/regulars` and `/settings/calendar` come from
CTXD; the other routes are defined in the UI handoff.
First run: while `household_settings.onboarding_completed_at` (read from
`GET /api/v1/settings/household`) is null, every route redirects to
`/onboarding`. Onboarding is a full screen outside the app shell, in three
steps: the welcome (`/onboarding`), the Regulars (`/onboarding/regulars`,
mock 1h, adding and archiving through the dinners API optimistically; Next
waits for three saved Regulars, and a common pick is not offered once a
Regular has a name `harness.Similar` to it — the PWA's copy of the name
rules in `web/src/dinners/names.ts` folds letters with
`web/src/dinners/letterFolds.json`, which `gen_folds.py` writes from the
same table as the server's, and runs the same cases,
`internal/ai/harness/testdata/dedupe_cases.json`, as the server's) and
notifications (`/onboarding/notifications`: what MealQuest notifies about,
the iOS install hint, "Turn on notifications" and "Maybe later"). Either
button sends `PATCH /api/v1/settings/household` with
`{"onboarding_completed": true}`; the server sets `onboarding_completed_at`
to the time of that update (keeping the first time if it is already set;
the field itself is read-only and `false` is refused) and the app goes Home.
Completing needs at least three Regulars (`household.MinRegulars`); with
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. 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.
Regular dinners (`/settings/regulars`) lists the Regulars from
`GET /api/v1/dinners?status=regular`, each with its photo, "method ·
minutes", favorite heart and grounding badge ("Finding recipe…" while
`pending`, "Recipe found" when `grounded` or `manual`, "Needs a recipe" when
`failed`). The add field takes a name or a pasted `https://` recipe link.
Tapping a Regular opens an edit sheet (name, method, minutes, difficulty;
remove archives it after a confirmation); tapping one that needs a recipe
opens "Fix recipe" (pick one of the recipe candidates, paste a link or type
the ingredients and steps in). Adds, removals and the heart show at once and
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.
## Boundaries and gotchas
- **Secrets never reach the client** — API keys, OAuth tokens and the Chat
-60
View File
@@ -1,70 +1,10 @@
package harness
import (
"encoding/json"
"os"
"testing"
"unicode"
"unicode/utf8"
)
// TestSharedDedupeCases runs testdata/dedupe_cases.json, which the PWA's
// copy of Normalize and Similar (web/src/dinners/names.ts) runs too, so the
// two cannot drift apart on these names.
func TestSharedDedupeCases(t *testing.T) {
raw, err := os.ReadFile("testdata/dedupe_cases.json")
if err != nil {
t.Fatal(err)
}
var cases struct {
Normalize []struct{ Name, Want string }
Similar []struct {
A, B string
Want bool
}
}
if err := json.Unmarshal(raw, &cases); err != nil {
t.Fatal(err)
}
if len(cases.Normalize) == 0 || len(cases.Similar) == 0 {
t.Fatal("dedupe_cases.json has no cases")
}
for _, c := range cases.Normalize {
if got := Normalize(c.Name); got != c.Want {
t.Errorf("Normalize(%q) = %q, want %q", c.Name, got, c.Want)
}
}
for _, c := range cases.Similar {
if got := Similar(c.A, c.B); got != c.Want {
t.Errorf("Similar(%q, %q) = %v, want %v", c.A, c.B, got, c.Want)
}
}
}
// TestLetterFoldsMatchPWA checks that the PWA's letter-fold table, which
// gen_folds.py writes beside folds_table.go, is letterFolds exactly; run
// go generate when it fails.
func TestLetterFoldsMatchPWA(t *testing.T) {
raw, err := os.ReadFile("../../../web/src/dinners/letterFolds.json")
if err != nil {
t.Fatal(err)
}
var pwa map[string]string
if err := json.Unmarshal(raw, &pwa); err != nil {
t.Fatal(err)
}
for r, want := range letterFolds {
if got, ok := pwa[string(r)]; !ok || got != want {
t.Errorf("PWA folds %U to %q (present %v), want %q", r, got, ok, want)
}
}
for k := range pwa {
if utf8.RuneCountInString(k) != 1 || letterFolds[[]rune(k)[0]] == "" {
t.Errorf("PWA folds %q, which letterFolds does not", k)
}
}
}
func TestNormalize(t *testing.T) {
tests := []struct {
name, want string
+1 -10
View File
@@ -1,8 +1,5 @@
#!/usr/bin/env python3
"""Generate folds_table.go, the letter-fold table used by harness.Normalize,
and its copy web/src/dinners/letterFolds.json, used by the PWA's
normalizeName (web/src/dinners/names.ts). TestLetterFoldsMatchPWA fails when
the two differ.
"""Generate folds_table.go, the letter-fold table used by harness.Normalize.
Run from this directory with `go generate ./internal/ai/harness` (or
`python3 gen_folds.py`; gofmt must be on PATH). It uses only the Python
@@ -31,14 +28,11 @@ Hangul syllables are left out: their decomposition is into conjoining jamo,
not combining marks.
"""
import json
import re
import subprocess
import sys
import unicodedata
PWA_TABLE = "../../../web/src/dinners/letterFolds.json"
EXTRA = {
"ß": "ss", "ẞ": "ss", "æ": "ae", "Æ": "ae", "œ": "oe", "Œ": "oe",
"þ": "th", "Þ": "th", "ð": "d", "Ð": "d", "ı": "i", "ʼn": "n",
@@ -116,9 +110,6 @@ def main():
with open("folds_table.go", "w", encoding="utf-8") as f:
f.write("\n".join(out) + "\n")
subprocess.run(["gofmt", "-w", "folds_table.go"], check=True)
with open(PWA_TABLE, "w", encoding="ascii") as f:
json.dump({chr(cp): folded for cp, folded in entries}, f, indent=2)
f.write("\n")
if __name__ == "__main__":
-136
View File
@@ -1,136 +0,0 @@
{
"normalize": [
{
"name": "Easy Chicken Tikka-Masala!",
"want": "chicken tikka masala"
},
{
"name": " Homemade Chili ",
"want": "chili"
},
{
"name": "Grandma's Stew",
"want": "grandmas stew"
},
{
"name": "Grandma’s Stew",
"want": "grandmas stew"
},
{
"name": "İskender Kebab",
"want": "iskender kebab"
},
{
"name": "Paella à la Valenciana",
"want": "paella la valenciana"
},
{
"name": "Crème Brûlée",
"want": "creme brulee"
},
{
"name": "Crème Brûlée",
"want": "creme brulee"
},
{
"name": "Smørrebrød",
"want": "smorrebrod"
},
{
"name": "Pierogi z Łososiem",
"want": "pierogi z lososiem"
},
{
"name": "Straße Würstchen",
"want": "strasse wurstchen"
},
{
"name": "Œufs en Cocotte",
"want": "oeufs en cocotte"
},
{
"name": "Æbleskiver",
"want": "aebleskiver"
},
{
"name": "Phở Bò",
"want": "pho bo"
},
{
"name": "The Best Quick Recipe",
"want": ""
},
{
"name": "Chicken stir-fry",
"want": "chicken stir fry"
},
{
"name": "7-layer dip",
"want": "7 layer dip"
},
{
"name": "Ƈhili",
"want": "chili"
},
{
"name": "Ꞔarbanzo Stew",
"want": "carbanzo stew"
},
{
"name": "ΜΟΥΣΑΚΑΣ",
"want": "μουσακασ"
}
],
"similar": [
{
"a": "Homemade chili",
"b": "Chili",
"want": true
},
{
"a": "Easy Spaghetti Bolognese",
"b": "spaghetti bolognese",
"want": true
},
{
"a": "Chicken stir fry",
"b": "Chicken stir-fry",
"want": true
},
{
"a": "Crème brûlée",
"b": "Creme brulee",
"want": true
},
{
"a": "Smørrebrød",
"b": "smorrebrod",
"want": true
},
{
"a": "Beef tacos",
"b": "Fish tacos",
"want": false
},
{
"a": "Chili",
"b": "White chicken chili",
"want": false
},
{
"a": "Pot roast",
"b": "Roast",
"want": false
},
{
"a": "The best",
"b": "Easy recipe",
"want": false
},
{
"a": "Ƈhili",
"b": "Chili",
"want": true
}
]
}
+1 -2
View File
@@ -190,7 +190,6 @@ func TestDinners(t *testing.T) {
Difficulty: str("medium"),
Cuisine: sql.NullString{},
RecipeID: sql.NullString{},
RecipeUrl: str("https://example.com/tacos"),
PhotoID: str(photo.ID),
GroundingStatus: "failed",
UpdatedBy: str("sam@example.com"),
@@ -268,7 +267,7 @@ func TestDinners(t *testing.T) {
}
var unarchivedIDs []string
for _, d := range unarchived {
unarchivedIDs = append(unarchivedIDs, d.Dinner.ID)
unarchivedIDs = append(unarchivedIDs, d.ID)
}
assertIDs(t, "unarchived", unarchivedIDs, other.ID, third.ID)
_, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt})
+94 -185
View File
@@ -16,7 +16,7 @@ SET status = 'archived',
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING 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
RETURNING 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
`
type ArchiveDinnerParams struct {
@@ -48,24 +48,10 @@ func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (D
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
const countRegularDinners = `-- name: CountRegularDinners :one
SELECT COUNT(*) FROM dinners
WHERE status = 'regular'
`
// How many Regulars the household has; archived dinners are not counted.
func (q *Queries) CountRegularDinners(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countRegularDinners)
var count int64
err := row.Scan(&count)
return count, err
}
const createDinner = `-- name: CreateDinner :one
INSERT INTO dinners (
id, name, normalized_name, status, origin, method, minutes_total,
@@ -76,7 +62,7 @@ INSERT INTO dinners (
?, ?, ?, ?, ?, ?,
?, ?, ?, ?
)
RETURNING 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
RETURNING 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
`
type CreateDinnerParams struct {
@@ -139,7 +125,6 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
@@ -150,13 +135,11 @@ SET grounding_status = 'failed',
grounding_error = ?1,
updated_at = ?2
WHERE grounding_status = 'pending'
AND status <> 'archived'
AND NOT EXISTS (
SELECT 1 FROM jobs
WHERE jobs.kind = ?3
AND jobs.status IN ('queued', 'running')
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
AND json_extract(jobs.payload, '$.revision') = dinners.grounding_revision
)
`
@@ -166,11 +149,10 @@ type FailOrphanedPendingDinnerGroundingsParams struct {
Kind string
}
// Fails every unarchived dinner still pending whose grounding job for its
// current grounding_revision is no longer queued or running: the process
// stopped before the job, or before its handler recorded a result. An
// archived dinner is left as it is; archiving stopped its grounding. Run at
// boot, after the job runner fails the jobs a previous process left running.
// Fails every dinner still pending whose grounding job is no longer queued
// or running: the process stopped before the job, or before its handler
// recorded a result. Run at boot, after the job runner fails the jobs a
// previous process left running.
func (q *Queries) FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failOrphanedPendingDinnerGroundings, arg.GroundingError, arg.UpdatedAt, arg.Kind)
if err != nil {
@@ -184,30 +166,19 @@ UPDATE dinners
SET grounding_status = 'failed',
grounding_error = ?,
updated_at = ?
WHERE id = ?
AND status <> 'archived'
AND grounding_status = 'pending'
AND grounding_revision = ?
WHERE id = ? AND grounding_status = 'pending'
`
type FailPendingDinnerGroundingParams struct {
GroundingError sql.NullString
UpdatedAt string
ID string
GroundingRevision int64
GroundingError sql.NullString
UpdatedAt string
ID string
}
// Records why the background grounding of an unarchived dinner still
// pending at the grounding_revision it was grounded for failed; a dinner
// grounded some other way, renamed or archived meanwhile is left as it is
// (0 rows).
// Records why the background grounding of a dinner still pending failed; a
// dinner grounded some other way meanwhile is left as it is (0 rows).
func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failPendingDinnerGrounding,
arg.GroundingError,
arg.UpdatedAt,
arg.ID,
arg.GroundingRevision,
)
result, err := q.db.ExecContext(ctx, failPendingDinnerGrounding, arg.GroundingError, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
@@ -215,7 +186,7 @@ func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendin
}
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
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 FROM dinners
WHERE id = ?
`
@@ -241,83 +212,50 @@ func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) {
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
const groundDinner = `-- name: GroundDinner :one
UPDATE dinners
SET recipe_id = ?1,
recipe_url = NULL,
photo_id = ?2,
method = CASE WHEN method IS ?3
THEN ?4 ELSE method END,
minutes_total = CASE WHEN minutes_total IS ?5
THEN ?6 ELSE minutes_total END,
difficulty = CASE WHEN difficulty IS ?7
THEN ?8 ELSE difficulty END,
grounding_status = ?9,
SET recipe_id = ?,
photo_id = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
grounding_status = ?,
grounding_error = NULL,
updated_by = ?10,
updated_at = ?11
WHERE id = ?12
AND status <> 'archived'
AND name = ?13
AND normalized_name = ?14
AND grounding_status = ?15
AND grounding_revision = ?16
AND recipe_id IS ?17
RETURNING 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
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING 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
`
type GroundDinnerParams struct {
RecipeID sql.NullString
PhotoID sql.NullString
ObservedMethod sql.NullString
Method sql.NullString
ObservedMinutesTotal sql.NullInt64
MinutesTotal sql.NullInt64
ObservedDifficulty sql.NullString
Difficulty sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
UpdatedAt string
ID string
ObservedName string
ObservedNormalizedName string
ObservedGroundingStatus string
ObservedGroundingRevision int64
ObservedRecipeID sql.NullString
RecipeID sql.NullString
PhotoID sql.NullString
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// Records a dinner's grounding by hand (grounded or manual) and clears any
// grounding_error and pasted recipe_url: the recipe chosen replaces the
// link. It writes only an unarchived dinner still as it was observed
// before grounding (same name, normalized_name, grounding_status,
// grounding_revision and recipe_id), so a dinner renamed, archived or
// grounded some other way meanwhile is left as it is. That dinner, or an
// 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.
// Records a dinner's grounding (grounded or manual) and clears any
// grounding_error. An unknown id returns sql.ErrNoRows.
func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error) {
row := q.db.QueryRowContext(ctx, groundDinner,
arg.RecipeID,
arg.PhotoID,
arg.ObservedMethod,
arg.Method,
arg.ObservedMinutesTotal,
arg.MinutesTotal,
arg.ObservedDifficulty,
arg.Difficulty,
arg.GroundingStatus,
arg.UpdatedBy,
arg.UpdatedAt,
arg.ID,
arg.ObservedName,
arg.ObservedNormalizedName,
arg.ObservedGroundingStatus,
arg.ObservedGroundingRevision,
arg.ObservedRecipeID,
)
var i Dinner
err := row.Scan(
@@ -339,43 +277,35 @@ func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Din
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
UPDATE dinners
SET recipe_id = ?1,
photo_id = ?2,
method = COALESCE(method, ?3),
minutes_total = COALESCE(minutes_total, ?4),
difficulty = COALESCE(difficulty, ?5),
SET recipe_id = ?,
photo_id = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
grounding_status = 'grounded',
grounding_error = NULL,
updated_at = ?6
WHERE id = ?7
AND status <> 'archived'
AND grounding_status = 'pending'
AND grounding_revision = ?8
updated_at = ?
WHERE id = ? AND grounding_status = 'pending'
`
type GroundPendingDinnerParams struct {
RecipeID sql.NullString
PhotoID sql.NullString
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
UpdatedAt string
ID string
GroundingRevision int64
RecipeID sql.NullString
PhotoID sql.NullString
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
UpdatedAt string
ID string
}
// 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,
// minutes_total or difficulty already set is kept: a pending dinner has
// them only when a member edited them while it was grounding.
// Records the background grounding of a dinner still pending; a dinner
// grounded some other way meanwhile is left as it is (0 rows).
func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error) {
result, err := q.db.ExecContext(ctx, groundPendingDinner,
arg.RecipeID,
@@ -385,7 +315,6 @@ func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinn
arg.Difficulty,
arg.UpdatedAt,
arg.ID,
arg.GroundingRevision,
)
if err != nil {
return 0, err
@@ -394,7 +323,7 @@ func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinn
}
const listDinnersByStatus = `-- name: ListDinnersByStatus :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
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 FROM dinners
WHERE status = ?
ORDER BY normalized_name, id
`
@@ -427,7 +356,6 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
); err != nil {
return nil, err
}
@@ -443,50 +371,40 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
}
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
LEFT JOIN recipes ON recipes.id = dinners.recipe_id
WHERE dinners.status <> 'archived'
ORDER BY dinners.id
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 FROM dinners
WHERE status <> 'archived'
ORDER BY id
`
type ListUnarchivedDinnersRow struct {
Dinner Dinner
RecipeSourceUrl sql.NullString
}
// Every dinner a new or renamed dinner must not repeat, with the
// source_url of the recipe it is grounded in (NULL while it has none).
func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error) {
// Every dinner a new or renamed dinner must not repeat.
func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]Dinner, error) {
rows, err := q.db.QueryContext(ctx, listUnarchivedDinners)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListUnarchivedDinnersRow
var items []Dinner
for rows.Next() {
var i ListUnarchivedDinnersRow
var i Dinner
if err := rows.Scan(
&i.Dinner.ID,
&i.Dinner.Name,
&i.Dinner.NormalizedName,
&i.Dinner.Status,
&i.Dinner.Origin,
&i.Dinner.Method,
&i.Dinner.MinutesTotal,
&i.Dinner.Difficulty,
&i.Dinner.Cuisine,
&i.Dinner.IsFavorite,
&i.Dinner.RecipeID,
&i.Dinner.PhotoID,
&i.Dinner.GroundingStatus,
&i.Dinner.UpdatedBy,
&i.Dinner.CreatedAt,
&i.Dinner.UpdatedAt,
&i.Dinner.RecipeUrl,
&i.Dinner.GroundingError,
&i.Dinner.GroundingRevision,
&i.RecipeSourceUrl,
&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,
); err != nil {
return nil, err
}
@@ -507,7 +425,7 @@ SET is_favorite = ?,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING 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
RETURNING 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
`
type SetDinnerFavoriteParams struct {
@@ -545,7 +463,6 @@ func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoritePa
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
@@ -556,7 +473,7 @@ SET is_favorite = 1 - is_favorite,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING 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
RETURNING 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
`
type ToggleDinnerFavoriteParams struct {
@@ -588,7 +505,6 @@ func (q *Queries) ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavo
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
@@ -604,33 +520,29 @@ SET name = ?,
difficulty = ?,
cuisine = ?,
recipe_id = ?,
recipe_url = ?,
photo_id = ?,
grounding_status = ?,
grounding_revision = ?,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING 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
RETURNING 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
`
type UpdateDinnerParams struct {
Name string
NormalizedName string
Status string
Origin string
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
Cuisine sql.NullString
RecipeID sql.NullString
RecipeUrl sql.NullString
PhotoID sql.NullString
GroundingStatus string
GroundingRevision int64
UpdatedBy sql.NullString
UpdatedAt string
ID string
Name string
NormalizedName string
Status string
Origin string
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
Cuisine sql.NullString
RecipeID sql.NullString
PhotoID sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// An unknown id returns sql.ErrNoRows.
@@ -645,10 +557,8 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
arg.Difficulty,
arg.Cuisine,
arg.RecipeID,
arg.RecipeUrl,
arg.PhotoID,
arg.GroundingStatus,
arg.GroundingRevision,
arg.UpdatedBy,
arg.UpdatedAt,
arg.ID,
@@ -673,7 +583,6 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
+18 -19
View File
@@ -51,25 +51,24 @@ type ChatSetting struct {
}
type Dinner struct {
ID string
Name string
NormalizedName string
Status string
Origin string
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
Cuisine sql.NullString
IsFavorite int64
RecipeID sql.NullString
PhotoID sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
CreatedAt string
UpdatedAt string
RecipeUrl sql.NullString
GroundingError sql.NullString
GroundingRevision int64
ID string
Name string
NormalizedName string
Status string
Origin string
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
Cuisine sql.NullString
IsFavorite int64
RecipeID sql.NullString
PhotoID sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
CreatedAt string
UpdatedAt string
RecipeUrl sql.NullString
GroundingError sql.NullString
}
type HouseholdSetting struct {
+12 -27
View File
@@ -21,8 +21,6 @@ type Querier interface {
// Drops references to the runs created before the cutoff, so the purge
// can delete them.
ClearWeekPlanAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
// How many Regulars the household has; archived dinners are not counted.
CountRegularDinners(ctx context.Context) (int64, error)
CreateAIRun(ctx context.Context, arg CreateAIRunParams) error
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
CreateJob(ctx context.Context, arg CreateJobParams) error
@@ -36,16 +34,13 @@ type Querier interface {
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
DeleteRecipeSteps(ctx context.Context, recipeID string) error
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
// Fails every unarchived dinner still pending whose grounding job for its
// current grounding_revision is no longer queued or running: the process
// stopped before the job, or before its handler recorded a result. An
// archived dinner is left as it is; archiving stopped its grounding. Run at
// boot, after the job runner fails the jobs a previous process left running.
// Fails every dinner still pending whose grounding job is no longer queued
// or running: the process stopped before the job, or before its handler
// recorded a result. Run at boot, after the job runner fails the jobs a
// previous process left running.
FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error)
// Records why the background grounding of an unarchived dinner still
// pending at the grounding_revision it was grounded for failed; a dinner
// grounded some other way, renamed or archived meanwhile is left as it is
// (0 rows).
// Records why the background grounding of a dinner still pending failed; a
// dinner grounded some other way meanwhile is left as it is (0 rows).
FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error)
// Fails every job left running, at boot before any worker starts.
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
@@ -67,20 +62,11 @@ type Querier interface {
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
// Returns sql.ErrNoRows until the row exists.
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
// Records a dinner's grounding by hand (grounded or manual) and clears any
// grounding_error and pasted recipe_url: the recipe chosen replaces the
// link. It writes only an unarchived dinner still as it was observed
// before grounding (same name, normalized_name, grounding_status,
// grounding_revision and recipe_id), so a dinner renamed, archived or
// grounded some other way meanwhile is left as it is. That dinner, or an
// 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.
// Records a dinner's grounding (grounded or manual) and clears any
// grounding_error. An unknown id returns sql.ErrNoRows.
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, 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,
// minutes_total or difficulty already set is kept: a pending dinner has
// them only when a member edited them while it was grounding.
// Records the background grounding of a dinner still pending; a dinner
// grounded some other way meanwhile is left as it is (0 rows).
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
// The planned dinners dated from from_date up to but not including
@@ -91,9 +77,8 @@ type Querier interface {
ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error)
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
// Every dinner a new or renamed dinner must not repeat, with the
// source_url of the recipe it is grounded in (NULL while it has none).
ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error)
// Every dinner a new or renamed dinner must not repeat.
ListUnarchivedDinners(ctx context.Context) ([]Dinner, error)
// Stores a partial result of a running job.
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
// An unknown id returns sql.ErrNoRows.
+101 -117
View File
@@ -11,11 +11,10 @@ import (
"errors"
"fmt"
"log/slog"
"math"
"net/url"
"slices"
"strings"
"sync"
"unicode"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
@@ -54,9 +53,7 @@ var listable = []string{StatusRegular, StatusQuestLog}
// ErrNotFound is returned for an unknown dinner id.
var ErrNotFound = errors.New("dinner not found")
// Dinner is a dinner as the API shows it. It has no recipe_url: a pasted
// link may carry a personal token, so it stays in the database for
// grounding and never leaves the server.
// Dinner is a dinner as the API shows it.
type Dinner struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -68,6 +65,7 @@ type Dinner struct {
Cuisine *string `json:"cuisine"`
IsFavorite bool `json:"is_favorite"`
RecipeID *string `json:"recipe_id"`
RecipeURL *string `json:"recipe_url"`
PhotoURL *string `json:"photo_url"`
GroundingStatus string `json:"grounding_status"`
// GroundingError is why grounding failed, one of the Reason codes;
@@ -78,10 +76,7 @@ type Dinner struct {
UpdatedAt string `json:"updated_at"`
}
// NewRegular is a Regular to add: its Name, which the household always
// gives, and optionally the RecipeURL to ground it in. The name is never
// taken from the link: a pasted link may carry a personal token anywhere in
// it, and the name is shown in every dinner response.
// NewRegular is a Regular to add: exactly one of Name and RecipeURL.
type NewRegular struct {
Name *string
RecipeURL *string
@@ -135,11 +130,6 @@ type Service struct {
grounder *recipes.Grounder
logger *slog.Logger
groundJob *jobs.Kind[GroundJob]
// groundingsMu guards groundings: the KindGroundDinner jobs running,
// by dinner id, so archiving or renaming a dinner can stop them.
groundingsMu sync.Mutex
groundings map[string]map[*runningGrounding]struct{}
}
// NewService returns a service over st that grounds dinners with grounder
@@ -156,7 +146,7 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner
case logger == nil:
return nil, errors.New("dinners: nil logger")
}
s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{})}
s := &Service{st: st, grounder: grounder, logger: logger}
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
if err != nil {
return nil, err
@@ -173,9 +163,10 @@ type Created struct {
GroundingJobID *string `json:"grounding_job_id"`
}
// CreateRegular adds a Regular named r.Name, keeping r.RecipeURL for
// grounding when given, with status and origin regular and grounding_status
// pending, recording updatedBy, and enqueues the KindGroundDinner job that grounds it. It returns without
// CreateRegular adds a Regular named by r.Name, or by the last path segment
// of r.RecipeURL (whose recipe grounding later replaces), with status and
// origin regular and grounding_status pending, recording updatedBy, and
// enqueues the KindGroundDinner job that grounds it. It returns without
// waiting for the job. A name repeating an unarchived dinner is a
// *DuplicateError. When the job cannot be enqueued, the stored dinner is
// returned with grounding_status failed (ReasonGroundingError) and no job
@@ -185,64 +176,61 @@ func (s *Service) CreateRegular(ctx context.Context, r NewRegular, updatedBy str
if err != nil {
return Created{}, err
}
jobID, err := s.enqueueGrounding(ctx, d.ID, d.GroundingRevision)
if err != nil {
return Created{}, err
}
if jobID == nil {
row, err := db.New(s.st.Reader).GetDinner(context.WithoutCancel(ctx), d.ID)
if err != nil {
return Created{}, fmt.Errorf("dinners: read %s: %w", d.ID, err)
}
return Created{Dinner: fromRow(row)}, nil
}
return Created{Dinner: fromRow(d), GroundingJobID: jobID}, nil
}
// enqueueGrounding enqueues the KindGroundDinner job for pending dinner id
// at grounding_revision revision and returns its id. When the job cannot be
// enqueued, the dinner's grounding is failed with ReasonGroundingError, so
// it is not left pending with no job, and the id is nil.
func (s *Service) enqueueGrounding(ctx context.Context, id string, revision int64) (*string, error) {
jobID, err := s.groundJob.Enqueue(ctx, GroundJob{DinnerID: id, Revision: revision})
jobID, err := s.groundJob.Enqueue(ctx, GroundJob{DinnerID: d.ID})
if err == nil {
return &jobID, nil
return Created{Dinner: d, GroundingJobID: &jobID}, nil
}
// The error is a database error, never a dependency's text.
s.logger.ErrorContext(ctx, "dinner grounding not enqueued", "dinner_id", id, "error", err)
if _, err := db.New(s.st.Writer).FailPendingDinnerGrounding(context.WithoutCancel(ctx), db.FailPendingDinnerGroundingParams{
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
UpdatedAt: store.Now().String(),
ID: id,
GroundingRevision: revision,
// The dinner is stored; record that it will not be grounded so it is
// not left pending with no job. The error is a database error, never a
// dependency's text.
s.logger.ErrorContext(ctx, "dinner grounding not enqueued", "dinner_id", d.ID, "error", err)
write := context.WithoutCancel(ctx)
if _, err := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
UpdatedAt: store.Now().String(),
ID: d.ID,
}); err != nil {
return nil, fmt.Errorf("dinners: record failed grounding of %s: %w", id, err)
return Created{}, fmt.Errorf("dinners: record failed grounding of %s: %w", d.ID, err)
}
return nil, nil
row, err := db.New(s.st.Reader).GetDinner(write, d.ID)
if err != nil {
return Created{}, fmt.Errorf("dinners: read %s: %w", d.ID, err)
}
return Created{Dinner: fromRow(row)}, nil
}
func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy string) (db.Dinner, error) {
func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return db.Dinner{}, errors.New("dinners: create: no member to record as updated_by")
}
if r.Name == nil {
return db.Dinner{}, invalid("name", "give the dinner's name, with or without a recipe_url")
}
name, err := checkName(*r.Name)
if err != nil {
return db.Dinner{}, err
return Dinner{}, errors.New("dinners: create: no member to record as updated_by")
}
var name string
var recipeURL sql.NullString
if r.RecipeURL != nil {
switch {
case r.Name != nil && r.RecipeURL != nil:
return Dinner{}, invalid("name", "give a name or a recipe_url, not both")
case r.Name != nil:
n, err := checkName(*r.Name)
if err != nil {
return Dinner{}, err
}
name = n
case r.RecipeURL != nil:
u, err := checkRecipeURL(*r.RecipeURL)
if err != nil {
return db.Dinner{}, err
return Dinner{}, err
}
n, ok := nameFromURL(u)
if !ok {
return Dinner{}, invalid("recipe_url", "the link does not name a dinner; add it by name instead")
}
name = n
recipeURL = sql.NullString{String: u.String(), Valid: true}
default:
return Dinner{}, invalid("name", "give a name or a recipe_url")
}
var row db.Dinner
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
if err := checkUnique(ctx, q, "", name, recipeURL); err != nil {
return err
@@ -267,9 +255,9 @@ func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy str
return nil
})
if err != nil {
return db.Dinner{}, err
return Dinner{}, err
}
return row, nil
return fromRow(row), nil
}
// List returns the dinners with status, which must be regular or
@@ -291,11 +279,7 @@ func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
// Update applies p to dinner id, recording updatedBy. An invalid patch is a
// *ValidationError and a new name repeating another unarchived dinner a
// *DuplicateError; either changes nothing. Renaming a dinner still pending
// clears its recipe_url, raises its grounding_revision and enqueues a fresh
// KindGroundDinner job that grounds the new name: a job queued or running
// for the old name or link carries the old revision, so it can no longer
// write the dinner, and one running is stopped.
// *DuplicateError; either changes nothing.
func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: update: no member to record as updated_by")
@@ -306,7 +290,6 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
}
var row db.Dinner
var regrounding bool
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetDinner(ctx, id)
@@ -317,22 +300,20 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
return fmt.Errorf("dinners: read %s: %w", id, err)
}
params := db.UpdateDinnerParams{
ID: cur.ID,
Name: cur.Name,
NormalizedName: cur.NormalizedName,
Status: cur.Status,
Origin: cur.Origin,
Method: cur.Method,
MinutesTotal: cur.MinutesTotal,
Difficulty: cur.Difficulty,
Cuisine: cur.Cuisine,
RecipeID: cur.RecipeID,
RecipeUrl: cur.RecipeUrl,
PhotoID: cur.PhotoID,
GroundingStatus: cur.GroundingStatus,
GroundingRevision: cur.GroundingRevision,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
ID: cur.ID,
Name: cur.Name,
NormalizedName: cur.NormalizedName,
Status: cur.Status,
Origin: cur.Origin,
Method: cur.Method,
MinutesTotal: cur.MinutesTotal,
Difficulty: cur.Difficulty,
Cuisine: cur.Cuisine,
RecipeID: cur.RecipeID,
PhotoID: cur.PhotoID,
GroundingStatus: cur.GroundingStatus,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
}
if p.Name != nil {
if err := checkUnique(ctx, q, cur.ID, name, sql.NullString{}); err != nil {
@@ -340,12 +321,6 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
}
params.Name = name
params.NormalizedName = harness.Normalize(name)
if cur.GroundingStatus == GroundingPending && params.NormalizedName != cur.NormalizedName {
// The link was the old dinner's recipe; the new name is
// grounded by name, and a link can be given by hand.
params.RecipeUrl = sql.NullString{}
params.GroundingRevision++
}
}
if p.Method != nil {
params.Method = sql.NullString{String: *p.Method, Valid: true}
@@ -360,31 +335,16 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
if err != nil {
return fmt.Errorf("dinners: update %s: %w", id, err)
}
regrounding = row.GroundingRevision != cur.GroundingRevision
return nil
})
if err != nil {
return Dinner{}, err
}
if regrounding {
s.stopGroundings(row.ID, row.GroundingRevision)
jobID, err := s.enqueueGrounding(ctx, row.ID, row.GroundingRevision)
if err != nil {
return Dinner{}, err
}
if jobID == nil {
row, err = db.New(s.st.Reader).GetDinner(context.WithoutCancel(ctx), row.ID)
if err != nil {
return Dinner{}, fmt.Errorf("dinners: read %s: %w", id, err)
}
}
}
return fromRow(row), nil
}
// Archive sets dinner id's status to archived, recording updatedBy. The row
// is kept, so its name still counts for Side quest dedupe. A
// KindGroundDinner job running for it is stopped.
// is kept, so its name still counts for Side quest dedupe.
func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: archive: no member to record as updated_by")
@@ -400,7 +360,6 @@ func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, er
if err != nil {
return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err)
}
s.stopGroundings(row.ID, math.MaxInt64)
return fromRow(row), nil
}
@@ -487,25 +446,49 @@ func checkRecipeURL(raw string) (*url.URL, error) {
return u, nil
}
// nameFromURL names a dinner from the last path segment of u: separators
// become spaces, a file extension and words of only digits are dropped and
// the first letter is capitalized, so
// https://example.com/recipes/12345/chicken-tikka-masala/ is
// "Chicken tikka masala". ok is false when no usable name is left.
func nameFromURL(u *url.URL) (name string, ok bool) {
segments := strings.FieldsFunc(u.Path, func(r rune) bool { return r == '/' })
if len(segments) == 0 {
return "", false
}
slug := segments[len(segments)-1]
if i := strings.LastIndexByte(slug, '.'); i > 0 {
slug = slug[:i]
}
var words []string
for _, w := range strings.FieldsFunc(slug, func(r rune) bool { return r == '-' || r == '_' || r == '+' || unicode.IsSpace(r) }) {
if strings.IndexFunc(w, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
words = append(words, strings.ToLower(w))
}
}
name = strings.Join(words, " ")
if r, size := utf8.DecodeRuneInString(name); r != utf8.RuneError {
name = string(unicode.ToUpper(r)) + name[size:]
}
if _, err := checkName(name); err != nil {
return "", false
}
return name, true
}
// checkUnique returns a *DuplicateError when name is harness.Similar to an
// unarchived dinner other than self, or recipeURL is one's recipe link: the
// source_url of the recipe it is grounded in, else the recipe_url still
// awaiting grounding.
// unarchived dinner other than self, or recipeURL is one's recipe link.
func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) error {
rows, err := q.ListUnarchivedDinners(ctx)
if err != nil {
return fmt.Errorf("dinners: list for dedupe: %w", err)
}
for _, row := range rows {
if row.Dinner.ID == self {
if row.ID == self {
continue
}
link := row.Dinner.RecipeUrl
if row.RecipeSourceUrl.Valid {
link = row.RecipeSourceUrl
}
if harness.Similar(name, row.Dinner.Name) || (recipeURL.Valid && link == recipeURL) {
return &DuplicateError{Existing: fromRow(row.Dinner)}
if harness.Similar(name, row.Name) || (recipeURL.Valid && row.RecipeUrl == recipeURL) {
return &DuplicateError{Existing: fromRow(row)}
}
}
return nil
@@ -527,6 +510,7 @@ func fromRow(r db.Dinner) Dinner {
Cuisine: nullString(r.Cuisine),
IsFavorite: r.IsFavorite == 1,
RecipeID: nullString(r.RecipeID),
RecipeURL: nullString(r.RecipeUrl),
GroundingStatus: r.GroundingStatus,
GroundingError: nullString(r.GroundingError),
UpdatedBy: nullString(r.UpdatedBy),
+24 -65
View File
@@ -1,74 +1,33 @@
package dinners_test
package dinners
import (
"encoding/json"
"errors"
"strings"
"net/url"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
)
// TestCreateRegularNeverNamesFromLink: a Regular added with a recipe link
// is named only by the household, so no part of the link — host, path,
// query or fragment — is stored as its name or shown in a dinner or a
// duplicate error, even a token of plain letters in the path.
func TestCreateRegularNeverNamesFromLink(t *testing.T) {
func TestNameFromURL(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
const link = "https://c.example/stew-key-abcdefghijklmno?sig=zyxwvut#frag"
leaks := []string{"c.example", "key", "abcdefghijklmno", "sig", "zyxwvut", "frag"}
check := func(what, text string) {
t.Helper()
lower := strings.ToLower(text)
for _, leak := range leaks {
if strings.Contains(lower, leak) {
t.Errorf("%s contains %q: %s", what, leak, text)
}
tests := []struct {
link string
want string // "" means no usable name
}{
{"https://example.com/recipes/12345/chicken-tikka-masala/", "Chicken tikka masala"},
{"https://example.com/recipe/beef_stew.html", "Beef stew"},
{"https://example.com/2024/05/best-ever-chili-123", "Best ever chili"},
{"https://example.com/crème-brûlée", "Crème brûlée"},
{"https://example.com/recipes/chili?utm=1#notes", "Chili"},
{"https://example.com/", ""},
{"https://example.com/recipe/98765", ""},
{"https://example.com/the-best-easy-recipe", ""},
}
for _, tt := range tests {
u, err := url.Parse(tt.link)
if err != nil {
t.Fatal(err)
}
got, ok := nameFromURL(u)
if got != tt.want || ok != (tt.want != "") {
t.Errorf("nameFromURL(%s) = %q, %v; want %q", tt.link, got, ok, tt.want)
}
}
_, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(link)}, member)
var verr *dinners.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("link without a name: err = %v, want a *ValidationError", err)
}
check("validation error", verr.Error())
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr(" Sunday stew "), RecipeURL: ptr(link)}, member)
if err != nil {
t.Fatal(err)
}
if c.Name != "Sunday stew" {
t.Errorf("name = %q, want the household's %q", c.Name, "Sunday stew")
}
created, err := json.Marshal(c)
if err != nil {
t.Fatal(err)
}
check("create response", string(created))
row := e.dinner(t, c.ID)
check("stored name", row.Name+" "+row.NormalizedName)
if row.RecipeUrl.String != link {
t.Errorf("stored recipe_url = %q, want %q kept for grounding", row.RecipeUrl.String, link)
}
list, err := e.svc.List(t.Context(), dinners.StatusRegular)
if err != nil {
t.Fatal(err)
}
listed, err := json.Marshal(list)
if err != nil {
t.Fatal(err)
}
check("list response", string(listed))
// The same link under another name is still a duplicate, and its error
// names the existing dinner by the household's name.
_, err = e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Weeknight chili"), RecipeURL: ptr(link)}, member)
var dup *dinners.DuplicateError
if !errors.As(err, &dup) || dup.Existing.ID != c.ID {
t.Fatalf("same link again: err = %v, want a duplicate of %s", err, c.ID)
}
check("duplicate error", dup.Error())
}
-9
View File
@@ -1,7 +1,6 @@
package dinners
import (
"context"
"testing"
"time"
)
@@ -13,11 +12,3 @@ func SetGroundTimeout(t *testing.T, d time.Duration) {
groundTimeout = d
t.Cleanup(func() { groundTimeout = old })
}
// RunGroundJob runs the KindGroundDinner handler for p in the caller's
// goroutine, as the job runner would, and returns the grounding status it
// reports.
func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error) {
r, err := s.runGroundJob(ctx, p, nil)
return r.GroundingStatus, err
}
+57 -195
View File
@@ -76,29 +76,14 @@ var groundTimeout = GroundTimeout
// runs past GroundTimeout; the dinner is left as it was.
var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long")
// ErrGroundFailed wraps any unexpected failure grounding a dinner by hand.
// Its text may quote the link and dependency errors, so it is never
// logged.
var ErrGroundFailed = errors.New("dinners: grounding the recipe failed")
// ErrArchived is grounding an archived dinner by hand.
var ErrArchived = errors.New("dinner archived")
// ErrGroundConflict is a candidate or link grounded for a dinner that was
// renamed or grounded some other way while it was grounding.
var ErrGroundConflict = errors.New("dinner changed while its recipe was grounding")
// GroundJob is the KindGroundDinner payload: the dinner to ground and its
// grounding_revision when the job was enqueued.
// GroundJob is the KindGroundDinner payload.
type GroundJob struct {
DinnerID string `json:"dinner_id"`
Revision int64 `json:"revision"`
}
// groundResult is the KindGroundDinner result: the dinner's grounding
// status once the job ends. It is not grounded by this job when the dinner
// was grounded by hand before the job finished, renamed (a later job
// grounds it) or archived; the job then reports the status it found.
// status once the job ends. It is not grounded when the dinner was
// grounded by hand before the job finished.
type groundResult struct {
DinnerID string `json:"dinner_id"`
GroundingStatus string `json:"grounding_status"`
@@ -108,38 +93,24 @@ func (r groundResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// runGroundJob grounds a pending dinner as its creation asked: its
// recipe_url with GroundURL, else its name with Ground. Success records
// the recipe and photo with grounding_status grounded, and the recipe's
// method, minutes and difficulty where a member has not set them; failure
// records grounding_status failed with its Reason, which is also the job's
// failure code. A dinner archived, no longer pending, or at a
// grounding_revision other than p.Revision is not grounded at all, and
// either result is recorded only while the dinner is still unarchived,
// pending and at p.Revision: a dinner grounded by hand meanwhile is left as
// it is, and one renamed or archived meanwhile is left pending — for the
// job its rename enqueued, or for none. Archiving or renaming the dinner
// stops the grounding where it is, so no further search, fetch, photo
// download or model call is made for it.
// the recipe, photo, method, minutes and difficulty with grounding_status
// grounded; failure records grounding_status failed with its Reason, which
// is also the job's failure code. A dinner no longer pending (grounded by
// hand meanwhile) is left as it is.
func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundResult) error) (groundResult, error) {
// Tracked before the dinner is read: an archive or rename committed
// after the read finds this job to stop.
groundCtx, done := s.trackGrounding(ctx, p.DinnerID, p.Revision)
defer done()
row, err := db.New(s.st.Reader).GetDinner(ctx, p.DinnerID)
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", p.DinnerID, err)
}
if row.Status == StatusArchived || row.GroundingStatus != GroundingPending || row.GroundingRevision != p.Revision {
if row.GroundingStatus != GroundingPending {
return groundResult{DinnerID: row.ID, GroundingStatus: row.GroundingStatus}, nil
}
var gr recipes.Grounded
if row.RecipeUrl.Valid {
gr, err = s.grounder.GroundURL(groundCtx, row.RecipeUrl.String)
gr, err = s.grounder.GroundURL(ctx, row.RecipeUrl.String)
} else {
gr, err = s.grounder.Ground(groundCtx, row.Name, row.Name)
}
if err != nil && ctx.Err() == nil && errors.Is(context.Cause(groundCtx), errGroundingStopped) {
return s.currentGrounding(ctx, row.ID)
gr, err = s.grounder.Ground(ctx, row.Name, row.Name)
}
if err != nil {
reason := reasonFor(err, row.RecipeUrl.Valid)
@@ -151,10 +122,9 @@ func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundRe
s.logger.InfoContext(ctx, "dinner grounding failed", "dinner_id", row.ID, "reason", reason)
write := context.WithoutCancel(ctx)
if _, failErr := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{
GroundingError: sql.NullString{String: reason, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
GroundingRevision: p.Revision,
GroundingError: sql.NullString{String: reason, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
}); failErr != nil {
return groundResult{}, fmt.Errorf("dinners: ground: record failure of %s: %w", row.ID, failErr)
}
@@ -165,85 +135,31 @@ func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundRe
}
n, err := db.New(s.st.Writer).GroundPendingDinner(ctx, db.GroundPendingDinnerParams{
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: p.Revision,
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,
})
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: record %s: %w", row.ID, err)
}
if n == 0 {
return s.currentGrounding(ctx, row.ID)
cur, err := db.New(s.st.Reader).GetDinner(ctx, row.ID)
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", row.ID, err)
}
return groundResult{DinnerID: cur.ID, GroundingStatus: cur.GroundingStatus}, nil
}
return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil
}
// currentGrounding is the KindGroundDinner result of a job that did not
// ground dinner id: the grounding status the dinner has now.
func (s *Service) currentGrounding(ctx context.Context, id string) (groundResult, error) {
cur, err := db.New(s.st.Reader).GetDinner(ctx, id)
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", id, err)
}
return groundResult{DinnerID: cur.ID, GroundingStatus: cur.GroundingStatus}, nil
}
// errGroundingStopped is the cause a running KindGroundDinner job's
// grounding is cancelled with when its dinner is archived or renamed.
var errGroundingStopped = errors.New("dinners: grounding stopped: dinner archived or renamed")
// runningGrounding is a KindGroundDinner job running for a dinner at
// revision, with the cancel of its grounding.
type runningGrounding struct {
revision int64
cancel context.CancelCauseFunc
}
// trackGrounding returns a context for grounding dinnerID at revision that
// stopGroundings cancels, and the func to call when the job ends.
func (s *Service) trackGrounding(ctx context.Context, dinnerID string, revision int64) (context.Context, func()) {
groundCtx, cancel := context.WithCancelCause(ctx)
r := &runningGrounding{revision: revision, cancel: cancel}
s.groundingsMu.Lock()
if s.groundings[dinnerID] == nil {
s.groundings[dinnerID] = make(map[*runningGrounding]struct{})
}
s.groundings[dinnerID][r] = struct{}{}
s.groundingsMu.Unlock()
return groundCtx, func() {
s.groundingsMu.Lock()
delete(s.groundings[dinnerID], r)
if len(s.groundings[dinnerID]) == 0 {
delete(s.groundings, dinnerID)
}
s.groundingsMu.Unlock()
cancel(nil)
}
}
// stopGroundings cancels, with errGroundingStopped, the grounding of every
// KindGroundDinner job running for dinnerID at a revision below below. Call
// it once the change that makes those jobs stale is committed.
func (s *Service) stopGroundings(dinnerID string, below int64) {
s.groundingsMu.Lock()
defer s.groundingsMu.Unlock()
for r := range s.groundings[dinnerID] {
if r.revision < below {
r.cancel(errGroundingStopped)
}
}
}
// FailInterruptedGroundings fails, with ReasonInterrupted, every unarchived
// dinner still pending whose KindGroundDinner job is no longer queued or
// running: a previous process stopped before enqueuing the job or before
// its handler recorded a result. An archived dinner is left pending, since
// archiving, not a restart, stopped its grounding. Call it once at boot, after the job runner's
// FailInterruptedGroundings fails, with ReasonInterrupted, every dinner
// still pending whose KindGroundDinner job is no longer queued or running:
// a previous process stopped before enqueuing the job or before its
// handler recorded a result. Call it once at boot, after the job runner's
// Start has failed the jobs that process left running, so no dinner stays
// pending with nothing left to ground it. It returns how many it failed.
func (s *Service) FailInterruptedGroundings(ctx context.Context) (int64, error) {
@@ -335,17 +251,11 @@ type ManualRecipe struct {
// Ground grounds dinner id as f says, recording updatedBy. A candidate or
// link is grounded with recipes.Grounder.GroundURL and sets
// grounding_status grounded, with the recipe's photo, method, minutes and
// difficulty; one a member changed while it was grounding is kept. A
// manual recipe is stored as typed — no model runs — and sets
// grounding_status manual with its method and minutes; it has no photo,
// and the dinner keeps its difficulty. An invalid f is a *ValidationError
// and an archived dinner ErrArchived. A failure grounding a candidate or
// link, or recording it on the dinner, wraps ErrGroundFailed; a page that
// cannot ground the dinner is also a *recipes.ErrUngroundable. A dinner
// renamed, archived or grounded some other way while a candidate or link
// was grounding is ErrGroundConflict or ErrArchived. A missing dinner is
// ErrNotFound; any other failure, by candidate, link or manual recipe,
// wraps ErrGroundFailed. None of these changes the dinner.
// difficulty. A manual recipe is stored as typed — no model runs — and
// sets grounding_status manual with its method and minutes; it has no
// photo, and the dinner keeps its difficulty. An invalid f is a
// *ValidationError; a page that cannot ground the dinner is a
// *recipes.ErrUngroundable and changes nothing.
func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: ground: no member to record as updated_by")
@@ -357,16 +267,9 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
if manual != nil {
return s.groundManual(ctx, id, *manual, updatedBy)
}
cur, err := s.get(ctx, id)
if errors.Is(err, ErrNotFound) {
if _, err := s.get(ctx, id); err != nil {
return Dinner{}, err
}
if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
}
if cur.Status == StatusArchived {
return Dinner{}, ErrArchived
}
groundCtx, cancel := context.WithTimeout(ctx, groundTimeout)
defer cancel()
gr, err := s.grounder.GroundURL(groundCtx, link)
@@ -374,57 +277,30 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
if ctx.Err() == nil && errors.Is(groundCtx.Err(), context.DeadlineExceeded) {
return Dinner{}, ErrGroundTimeout
}
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err)
}
row, err := db.New(s.st.Writer).GroundDinner(ctx, db.GroundDinnerParams{
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},
GroundingStatus: GroundingGrounded,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
ID: cur.ID,
ObservedName: cur.Name,
ObservedNormalizedName: cur.NormalizedName,
ObservedGroundingStatus: cur.GroundingStatus,
ObservedGroundingRevision: cur.GroundingRevision,
ObservedRecipeID: cur.RecipeID,
ObservedMethod: cur.Method,
ObservedMinutesTotal: cur.MinutesTotal,
ObservedDifficulty: cur.Difficulty,
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},
GroundingStatus: GroundingGrounded,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
ID: id,
})
if errors.Is(err, sql.ErrNoRows) {
return Dinner{}, s.groundRejected(ctx, id)
return Dinner{}, ErrNotFound
}
if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err)
}
return fromRow(row), nil
}
// groundRejected is the error for dinner id when it was not as observed
// before grounding: ErrNotFound, ErrArchived or ErrGroundConflict. Failing
// to read it wraps ErrGroundFailed.
func (s *Service) groundRejected(ctx context.Context, id string) error {
cur, err := s.get(ctx, id)
if errors.Is(err, ErrNotFound) {
return err
}
if err != nil {
return fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
}
if cur.Status == StatusArchived {
return ErrArchived
}
return ErrGroundConflict
}
// groundManual stores m as dinner id's recipe, with its ingredients and
// steps, and records it on the dinner, all in one transaction. A missing
// or archived dinner is ErrNotFound or ErrArchived; any other failure
// wraps ErrGroundFailed.
// steps, and records it on the dinner, all in one transaction.
func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, updatedBy string) (Dinner, error) {
var row db.Dinner
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
@@ -436,9 +312,6 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
if err != nil {
return fmt.Errorf("dinners: read %s: %w", id, err)
}
if cur.Status == StatusArchived {
return ErrArchived
}
ld, err := json.Marshal(manualJSONLD{
Context: "https://schema.org",
Type: "Recipe",
@@ -494,33 +367,22 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
}
}
row, err = q.GroundDinner(ctx, db.GroundDinnerParams{
RecipeID: sql.NullString{String: rec.ID, Valid: true},
Method: method,
MinutesTotal: minutes,
Difficulty: cur.Difficulty,
GroundingStatus: GroundingManual,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: now,
ID: cur.ID,
ObservedName: cur.Name,
ObservedNormalizedName: cur.NormalizedName,
ObservedGroundingStatus: cur.GroundingStatus,
ObservedGroundingRevision: cur.GroundingRevision,
ObservedRecipeID: cur.RecipeID,
ObservedMethod: cur.Method,
ObservedMinutesTotal: cur.MinutesTotal,
ObservedDifficulty: cur.Difficulty,
RecipeID: sql.NullString{String: rec.ID, Valid: true},
Method: method,
MinutesTotal: minutes,
Difficulty: cur.Difficulty,
GroundingStatus: GroundingManual,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: now,
ID: cur.ID,
})
if err != nil {
return fmt.Errorf("dinners: ground %s: %w", id, err)
}
return nil
})
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrArchived) {
return Dinner{}, err
}
if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
return Dinner{}, err
}
return fromRow(row), nil
}
+2 -722
View File
@@ -2,15 +2,11 @@ package dinners_test
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"io/fs"
"log/slog"
"slices"
"testing"
"testing/fstest"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
@@ -56,13 +52,6 @@ func newEnv(t *testing.T) *env {
func newUnstartedEnv(t *testing.T) *env {
t.Helper()
dataDir := t.TempDir()
return newUnstartedEnvOn(t, openStore(t, dataDir), dataDir)
}
// openStore opens the store in dataDir, closing it when the test ends. It
// is not migrated.
func openStore(t *testing.T, dataDir string) *store.Store {
t.Helper()
st, err := store.Open(t.Context(), dataDir)
if err != nil {
t.Fatal(err)
@@ -72,13 +61,6 @@ func openStore(t *testing.T, dataDir string) *store.Store {
t.Error(err)
}
})
return st
}
// newUnstartedEnvOn is newUnstartedEnv over st, the store in dataDir,
// which it migrates to the latest version.
func newUnstartedEnvOn(t *testing.T, st *store.Store, dataDir string) *env {
t.Helper()
if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
@@ -194,7 +176,7 @@ func TestCreateByRecipeURLGroundsThatURL(t *testing.T) {
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Beef stew"), RecipeURL: ptr(stew)}, member)
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(stew)}, member)
if err != nil {
t.Fatal(err)
}
@@ -223,7 +205,7 @@ func TestCreateGroundingFailureRecordsReason(t *testing.T) {
reason string
}{
{"no candidates", dinners.NewRegular{Name: ptr("Unknown dish")}, nil, dinners.ReasonNoRecipe},
{"unusable link", dinners.NewRegular{Name: ptr("Lost soup"), RecipeURL: ptr("https://gone.example/recipes/lost-soup")}, nil, dinners.ReasonRecipeUnusable},
{"unusable link", dinners.NewRegular{RecipeURL: ptr("https://gone.example/recipes/lost-soup")}, nil, dinners.ReasonRecipeUnusable},
{"search fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) { e.search.Err = recipes.ErrSearchRateLimited }, dinners.ReasonSearchFailed},
{"model fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) {
// One provider failure per candidate tried.
@@ -304,331 +286,6 @@ func TestManualFixWhilePendingIsKept(t *testing.T) {
}
}
// TestRenameWhilePendingGroundsNewName: a Regular renamed while its job is
// still grounding the old name never gets the old name's result, whether
// the old name would be found or not: the rename stops that job where it
// is, and the job the rename enqueues grounds the new name.
func TestRenameWhilePendingGroundsNewName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
oldName string
}{
{"old name found", "Chicken curry"},
{"old name not found", "Unknown dish"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
e.search.Gate = make(chan struct{})
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr(tt.oldName)}, member)
if err != nil {
t.Fatal(err)
}
// Rename only once the job has read the old name and is searching it.
e.waitQuery(t, tt.oldName)
d, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member)
if err != nil {
t.Fatal(err)
}
if d.GroundingStatus != dinners.GroundingPending {
t.Fatalf("renamed = %+v, want still pending", d)
}
// The old name's job ends while its search is still held.
first := e.waitJob(t, *c.GroundingJobID)
if first.Status != jobs.StatusSucceeded || string(first.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"pending"}` {
t.Fatalf("old name's job = %+v (result %s), want succeeded leaving the dinner pending", first, first.Result)
}
close(e.search.Gate)
row := e.waitGrounding(t, c.ID)
if row.GroundingStatus != dinners.GroundingGrounded {
t.Fatalf("dinner = %+v, want grounded for the new name", row)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != stew || rec.PhotoID != row.PhotoID || row.Method.String != "oven" || row.MinutesTotal.Int64 != 150 {
t.Errorf("dinner = %+v recipe %s, want grounded in %s", row, rec.SourceUrl, stew)
}
if got := e.search.Queries(); !slices.Equal(got, []string{tt.oldName, "Beef stew"}) {
t.Errorf("searched %q, want the old name then the new", got)
}
if got := len(e.model.Requests()); got != 1 {
t.Errorf("model called %d times, want once, for the new name only", got)
}
})
}
}
// TestRenameLinkWhilePendingGroundsNewName: a Regular added from a link and
// renamed while still pending drops the link and grounds its new name, not
// the old link's recipe and photo.
func TestRenameLinkWhilePendingGroundsNewName(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry"), RecipeURL: ptr(curryA)}, member)
if err != nil {
t.Fatal(err)
}
d, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member)
if err != nil {
t.Fatal(err)
}
if d.GroundingStatus != dinners.GroundingPending {
t.Fatalf("renamed = %+v, want still pending", d)
}
if row, err := db.New(e.st.Reader).GetDinner(t.Context(), c.ID); err != nil || row.RecipeUrl.Valid {
t.Fatalf("renamed row = %+v (err %v), want no recipe_url", row, err)
}
e.start(t)
row := e.waitGrounding(t, c.ID)
if row.GroundingStatus != dinners.GroundingGrounded {
t.Fatalf("dinner = %+v, want grounded for the new name", row)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != stew || rec.PhotoID != row.PhotoID || row.Method.String != "oven" || row.MinutesTotal.Int64 != 150 {
t.Errorf("dinner = %+v recipe %s, want grounded in %s, not the old link %s", row, rec.SourceUrl, stew, curryA)
}
if got := e.search.Queries(); !slices.Equal(got, []string{"Beef stew"}) {
t.Errorf("searched %q, want only the new name", got)
}
if row.RecipeUrl.Valid {
t.Errorf("recipe_url = %q, want the old link dropped", row.RecipeUrl.String)
}
if _, err := db.New(e.st.Reader).GetRecipeBySourceURL(t.Context(), curryA); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("old link's recipe lookup err = %v, want it never grounded (sql.ErrNoRows)", err)
}
}
// queuedJob reads the payload of queued KindGroundDinner job id.
func (e *env) queuedJob(t *testing.T, id string) dinners.GroundJob {
t.Helper()
var payload string
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT payload FROM jobs WHERE id = ?`, id).Scan(&payload); err != nil {
t.Fatal(err)
}
var p dinners.GroundJob
if err := json.Unmarshal([]byte(payload), &p); err != nil {
t.Fatal(err)
}
return p
}
// TestRenameBeforeJobStartsSkipsStaleJob: a Regular renamed before its
// first job starts is grounded only by the job its rename enqueued. The
// first job, though it would read the new name, does no work and writes
// nothing, so its failure cannot leave the dinner "Needs a recipe" while
// the rename's job finds a recipe.
func TestRenameBeforeJobStartsSkipsStaleJob(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
first := e.queuedJob(t, *c.GroundingJobID)
if first.DinnerID != c.ID || first.Revision != e.dinner(t, c.ID).GroundingRevision {
t.Fatalf("first job = %+v, want dinner %s at its revision", first, c.ID)
}
if _, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
t.Fatal(err)
}
renamed := e.dinner(t, c.ID)
if renamed.GroundingRevision != first.Revision+1 {
t.Fatalf("revision after rename = %d, want %d", renamed.GroundingRevision, first.Revision+1)
}
// The first job would fail if it searched.
e.search.Err = recipes.ErrSearchRateLimited
status, err := e.svc.RunGroundJob(t.Context(), first)
if err != nil || status != dinners.GroundingPending {
t.Fatalf("first job = %q, %v; want pending, no error", status, err)
}
if q := e.search.Queries(); len(q) != 0 {
t.Errorf("first job searched %q, want no work for a stale revision", q)
}
if row := e.dinner(t, c.ID); row != renamed {
t.Fatalf("first job wrote the dinner: %+v, was %+v", row, renamed)
}
e.search.Err = nil
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
status, err = e.svc.RunGroundJob(t.Context(), dinners.GroundJob{DinnerID: c.ID, Revision: renamed.GroundingRevision})
if err != nil || status != dinners.GroundingGrounded {
t.Fatalf("rename's job = %q, %v; want grounded", status, err)
}
row := e.dinner(t, c.ID)
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
if err != nil {
t.Fatal(err)
}
if row.GroundingStatus != dinners.GroundingGrounded || rec.SourceUrl != stew {
t.Errorf("dinner = %+v recipe %s, want grounded in %s", row, rec.SourceUrl, stew)
}
// A stale job finishing last changes nothing either.
if status, err := e.svc.RunGroundJob(t.Context(), first); err != nil || status != dinners.GroundingGrounded {
t.Errorf("first job run last = %q, %v; want grounded reported, no error", status, err)
}
if after := e.dinner(t, c.ID); after != row {
t.Errorf("stale job changed the grounded dinner: %+v, was %+v", after, row)
}
}
// TestEditWhilePendingIsKept: a method or minutes a member saves while a
// Regular is grounding is kept when grounding succeeds; what the member
// did not set comes from the recipe.
func TestEditWhilePendingIsKept(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.search.Gate = make(chan struct{})
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.waitQuery(t, "Chicken curry")
if _, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Method: ptr("grill"), MinutesTotal: ptr(15)}, member); err != nil {
t.Fatal(err)
}
close(e.search.Gate)
row := e.waitGrounding(t, c.ID)
if row.GroundingStatus != dinners.GroundingGrounded || !row.RecipeID.Valid || !row.PhotoID.Valid {
t.Fatalf("dinner = %+v, want grounded", row)
}
if row.Method.String != "grill" || row.MinutesTotal.Int64 != 15 || row.Difficulty.String != "easy" {
t.Errorf("dinner = %s, %d minutes, %s; want the member's grill and 15 minutes, the recipe's easy",
row.Method.String, row.MinutesTotal.Int64, row.Difficulty.String)
}
}
// TestArchiveWhilePendingStopsGrounding: a pending Regular archived before
// its job starts is not searched for, and one archived while its job runs
// is stopped there, with no page fetched or model called; neither ends up
// grounded.
func TestArchiveWhilePendingStopsGrounding(t *testing.T) {
t.Parallel()
t.Run("before the job starts", func(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(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)
}
if _, err := e.svc.Archive(t.Context(), c.ID, member); err != nil {
t.Fatal(err)
}
e.start(t)
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v, want succeeded with nothing to do", job)
}
if q := e.search.Queries(); len(q) != 0 {
t.Errorf("searched %q for an archived dinner", q)
}
if row := e.dinner(t, c.ID); row.Status != dinners.StatusArchived || row.GroundingStatus == dinners.GroundingGrounded || row.RecipeID.Valid {
t.Errorf("dinner = %+v, want archived and not grounded", row)
}
})
t.Run("while the job runs", func(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.search.Gate = make(chan struct{})
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.waitQuery(t, "Chicken curry")
if _, err := e.svc.Archive(t.Context(), c.ID, member); err != nil {
t.Fatal(err)
}
// The job ends while its search is still held: archiving stopped it.
job := e.waitJob(t, *c.GroundingJobID)
close(e.search.Gate)
if job.Status != jobs.StatusSucceeded || string(job.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"pending"}` {
t.Fatalf("job = %+v (result %s), want succeeded leaving the dinner pending", job, job.Result)
}
if got := e.pages.Fetched(); len(got) != 0 {
t.Errorf("fetched %q for an archived dinner", got)
}
if got := len(e.model.Requests()); got != 0 {
t.Errorf("model called %d times for an archived dinner", got)
}
if row := e.dinner(t, c.ID); row.Status != dinners.StatusArchived || row.GroundingStatus == dinners.GroundingGrounded || row.RecipeID.Valid {
t.Errorf("dinner = %+v, want archived and not grounded", row)
}
})
}
// TestRenameWhilePendingEnqueueFailure: a pending Regular renamed when the
// job for its new name cannot be enqueued is failed, not left pending.
func TestRenameWhilePendingEnqueueFailure(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.ExecContext(t.Context(),
`CREATE TRIGGER refuse_jobs BEFORE INSERT ON jobs BEGIN SELECT RAISE(ABORT, 'no jobs'); END`); err != nil {
t.Fatal(err)
}
d, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member)
if err != nil {
t.Fatal(err)
}
if d.Name != "Beef stew" || d.GroundingStatus != dinners.GroundingFailed ||
d.GroundingError == nil || *d.GroundingError != dinners.ReasonGroundingError {
t.Fatalf("renamed = %+v, want Beef stew failed (grounding_error)", d)
}
}
// waitQuery waits until the fake search has been asked query.
func (e *env) waitQuery(t *testing.T, query string) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for !slices.Contains(e.search.Queries(), query) {
if time.Now().After(deadline) {
t.Fatalf("search never asked %q; asked %q", query, e.search.Queries())
}
time.Sleep(5 * time.Millisecond)
}
}
// waitGrounding waits for dinner id to be no longer pending and returns it.
func (e *env) waitGrounding(t *testing.T, id string) db.Dinner {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
row := e.dinner(t, id)
if row.GroundingStatus != dinners.GroundingPending {
return row
}
if time.Now().After(deadline) {
t.Fatalf("dinner %s still pending", id)
}
time.Sleep(5 * time.Millisecond)
}
}
// failedDinner creates a Regular whose background grounding fails.
func failedDinner(t *testing.T, e *env, name string) string {
t.Helper()
@@ -721,62 +378,6 @@ func TestFixViaCandidate(t *testing.T) {
}
}
// TestFixReplacesPendingLink: fixing a Regular added from a link that held
// no recipe drops that link, so it no longer blocks adding the link again,
// while the link of the recipe chosen does.
func TestFixReplacesPendingLink(t *testing.T) {
t.Parallel()
const lost = "https://gone.example/recipes/lamb-tagine"
tests := []struct {
name string
fix dinners.Fix
// linkTaken is the recipe link the fixed dinner now has, if any.
linkTaken string
}{
{"candidate", dinners.Fix{CandidateID: ptr(curryB)}, curryB},
{"manual", dinners.Fix{Manual: &dinners.ManualRecipe{Ingredients: []string{"1 lamb"}, Minutes: ptr(90), Method: ptr("oven")}}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Lamb roast"), RecipeURL: ptr(lost)}, member)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusFailed {
t.Fatalf("job = %+v, want failed", job)
}
// Renamed, so only the link could repeat it.
if _, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Weeknight dinner")}, member); err != nil {
t.Fatal(err)
}
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
if _, err := e.svc.Ground(t.Context(), c.ID, tt.fix, member); err != nil {
t.Fatal(err)
}
row, err := db.New(e.st.Reader).GetDinner(t.Context(), c.ID)
if err != nil {
t.Fatal(err)
}
if row.RecipeUrl.Valid {
t.Errorf("recipe_url = %q after the fix, want the unusable link dropped", row.RecipeUrl.String)
}
if _, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Lentil soup"), RecipeURL: ptr(lost)}, member); err != nil {
t.Errorf("adding the dropped link again: %v, want added", err)
}
if tt.linkTaken != "" {
_, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Curry night"), RecipeURL: ptr(tt.linkTaken)}, member)
var dup *dinners.DuplicateError
if !errors.As(err, &dup) || dup.Existing.ID != c.ID {
t.Errorf("adding the fixed dinner's recipe link: err = %v, want a duplicate of %s", err, c.ID)
}
}
})
}
}
func TestFixViaRecipeURL(t *testing.T) {
t.Parallel()
e := newEnv(t)
@@ -952,126 +553,6 @@ func TestRestartFailsInterruptedGrounding(t *testing.T) {
}
}
// TestRestartKeepsArchivedPendingDinner: a pending Regular archived while
// its job runs stays pending, its job succeeded; the boot cleanup leaves it
// as it is rather than failing it as interrupted.
func TestRestartKeepsArchivedPendingDinner(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.search.Gate = make(chan struct{})
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
e.waitQuery(t, "Chicken curry")
if _, err := e.svc.Archive(t.Context(), c.ID, member); err != nil {
t.Fatal(err)
}
job := e.waitJob(t, *c.GroundingJobID)
close(e.search.Gate)
if job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v, want succeeded", job)
}
// The database is now as the next boot finds it.
n, err := e.svc.FailInterruptedGroundings(t.Context())
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("failed %d dinners, want 0", n)
}
if row := e.dinner(t, c.ID); row.Status != dinners.StatusArchived ||
row.GroundingStatus != dinners.GroundingPending || row.GroundingError.Valid {
t.Errorf("dinner = %s, %s (%s), want archived and pending with no error",
row.Status, row.GroundingStatus, row.GroundingError.String)
}
}
// TestUpgradeKeepsLegacyQueuedGrounding: a pending Regular whose
// ground_dinner job was queued before migration 0010, so its payload has
// no revision, is not failed as interrupted at the first boot after the
// upgrade; its job still grounds it. The upgrade starts either from 0009
// or from a database that has already applied 0010.
func TestUpgradeKeepsLegacyQueuedGrounding(t *testing.T) {
t.Parallel()
for name, below := range map[string]string{
"from 0009": "0010",
"from 0010": "0011",
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
upgradeWithLegacyQueuedGrounding(t, below)
})
}
}
// upgradeWithLegacyQueuedGrounding migrates a database to every migration
// named before below, adds a pending Regular with a queued ground_dinner
// job whose payload has no revision, then boots on the latest migrations.
func upgradeWithLegacyQueuedGrounding(t *testing.T, below string) {
ctx := t.Context()
dataDir := t.TempDir()
st := openStore(t, dataDir)
before := fstest.MapFS{}
files, err := fs.Glob(migrations.FS, "*.sql")
if err != nil {
t.Fatal(err)
}
for _, name := range files {
if name >= below {
continue
}
body, err := fs.ReadFile(migrations.FS, name)
if err != nil {
t.Fatal(err)
}
before[name] = &fstest.MapFile{Data: body}
}
if err := store.Migrate(ctx, st.Writer, before); err != nil {
t.Fatal(err)
}
now := store.Now().String()
if _, err := st.Writer.ExecContext(ctx, `INSERT INTO dinners
(id, name, normalized_name, status, origin, is_favorite, grounding_status, updated_by, created_at, updated_at)
VALUES ('dinner-1', 'Chicken curry', 'chicken curry', 'regular', 'regular', 0, 'pending', ?, ?, ?)`,
member, now, now); err != nil {
t.Fatal(err)
}
if _, err := st.Writer.ExecContext(ctx, `INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
VALUES ('job-1', ?, 'queued', '{"dinner_id":"dinner-1"}', ?, ?)`,
dinners.KindGroundDinner, now, now); err != nil {
t.Fatal(err)
}
// Boot: migrate, start the runner, then fail interrupted groundings. The
// search is held, so the job cannot finish before the cleanup runs.
e := newUnstartedEnvOn(t, st, dataDir)
e.search.Gate = make(chan struct{})
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
if p := e.queuedJob(t, "job-1"); p != (dinners.GroundJob{DinnerID: "dinner-1", Revision: 0}) {
t.Errorf("migrated payload = %+v, want dinner-1 at revision 0", p)
}
e.start(t)
n, err := e.svc.FailInterruptedGroundings(ctx)
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("failed %d dinners, want 0", n)
}
if row := e.dinner(t, "dinner-1"); row.GroundingStatus != dinners.GroundingPending {
t.Fatalf("dinner = %s (%s), want still pending", row.GroundingStatus, row.GroundingError.String)
}
close(e.search.Gate)
if job := e.waitJob(t, "job-1"); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v", job)
}
if row := e.dinner(t, "dinner-1"); row.GroundingStatus != dinners.GroundingGrounded {
t.Errorf("dinner = %s, want grounded", row.GroundingStatus)
}
}
// TestCreateEnqueueFailureReturnsFailedDinner: a dinner saved when its job
// cannot be enqueued is returned, with its id, failed and no job id.
func TestCreateEnqueueFailureReturnsFailedDinner(t *testing.T) {
@@ -1112,204 +593,3 @@ func TestGroundTimesOut(t *testing.T) {
t.Errorf("grounding_status = %s, want failed as before", row.GroundingStatus)
}
}
// TestFixInFlightWhenDinnerChanges: a link still grounding when its dinner
// is renamed, archived or grounded some other way writes nothing; the
// change made meanwhile is kept.
func TestFixInFlightWhenDinnerChanges(t *testing.T) {
t.Parallel()
tests := []struct {
name string
change func(t *testing.T, e *env, id string)
want error
// kept checks the dinner shows the change, not the link's recipe.
kept func(row db.Dinner) bool
}{
{"renamed", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Chicken curry")}, member); err != nil {
t.Fatal(err)
}
}, dinners.ErrGroundConflict, func(row db.Dinner) bool {
return row.Name == "Chicken curry" && row.GroundingStatus == dinners.GroundingFailed && !row.RecipeID.Valid
}},
{"renamed to the same normalized name", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Easy Grandma's stew")}, member); err != nil {
t.Fatal(err)
}
}, dinners.ErrGroundConflict, func(row db.Dinner) bool {
return row.Name == "Easy Grandma's stew" && row.GroundingStatus == dinners.GroundingFailed && !row.RecipeID.Valid
}},
{"archived", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Archive(t.Context(), id, member); err != nil {
t.Fatal(err)
}
}, dinners.ErrArchived, func(row db.Dinner) bool {
return row.Status == dinners.StatusArchived && row.GroundingStatus == dinners.GroundingFailed && !row.RecipeID.Valid
}},
{"grounded by hand", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Ground(t.Context(), id, dinners.Fix{Manual: &dinners.ManualRecipe{
Ingredients: []string{"1 lb beef"}, Minutes: ptr(240), Method: ptr("crockpot"),
}}, member); err != nil {
t.Fatal(err)
}
}, dinners.ErrGroundConflict, func(row db.Dinner) bool {
return row.GroundingStatus == dinners.GroundingManual && row.Method.String == "crockpot"
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Grandma's stew")
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
e.pages.Gate = make(chan struct{})
done := make(chan error, 1)
go func() {
_, err := e.svc.Ground(context.Background(), id, dinners.Fix{RecipeURL: ptr(stew)}, member)
done <- err
}()
e.waitFetched(t, stew)
tt.change(t, e, id)
close(e.pages.Gate)
if err := <-done; !errors.Is(err, tt.want) {
t.Fatalf("err = %v, want %v", err, tt.want)
}
if row := e.dinner(t, id); !tt.kept(row) {
t.Errorf("dinner = %+v, want the %s change kept and the link's recipe not written", row, tt.name)
}
})
}
}
// TestFixInFlightKeepsMemberEdits: a method, minutes or difficulty a
// member sets while a link is grounding is kept; a field they left alone
// takes the link's recipe, even one already set before grounding began.
func TestFixInFlightKeepsMemberEdits(t *testing.T) {
t.Parallel()
// Every field is set before grounding, then one is changed in flight.
before := dinners.Patch{Method: ptr("stovetop"), MinutesTotal: ptr(45), Difficulty: ptr("easy")}
tests := []struct {
name string
edit dinners.Patch
field string
}{
{"method", dinners.Patch{Method: ptr("grill")}, "method"},
{"minutes", dinners.Patch{MinutesTotal: ptr(20)}, "minutes_total"},
{"difficulty", dinners.Patch{Difficulty: ptr("medium")}, "difficulty"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Grandma's stew")
if _, err := e.svc.Update(t.Context(), id, before, member); err != nil {
t.Fatal(err)
}
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
e.pages.Gate = make(chan struct{})
done := make(chan error, 1)
go func() {
_, err := e.svc.Ground(context.Background(), id, dinners.Fix{RecipeURL: ptr(stew)}, member)
done <- err
}()
e.waitFetched(t, stew)
if _, err := e.svc.Update(t.Context(), id, tt.edit, member); err != nil {
t.Fatal(err)
}
close(e.pages.Gate)
if err := <-done; err != nil {
t.Fatal(err)
}
row := e.dinner(t, id)
if row.GroundingStatus != dinners.GroundingGrounded || !row.RecipeID.Valid {
t.Fatalf("dinner = %+v, want grounded in the link's recipe", row)
}
var recipe fields
if err := e.st.Reader.QueryRowContext(t.Context(),
`SELECT method, minutes_total, difficulty FROM recipes WHERE id = ?`, row.RecipeID.String,
).Scan(&recipe.method, &recipe.minutes, &recipe.difficulty); err != nil {
t.Fatal(err)
}
old, edited := patchFields(before), patchFields(tt.edit)
want := recipe
switch tt.field {
case "method":
want.method = edited.method
case "minutes_total":
want.minutes = edited.minutes
case "difficulty":
want.difficulty = edited.difficulty
}
if recipe.method == old.method || recipe.minutes == old.minutes || recipe.difficulty == old.difficulty {
t.Fatalf("recipe %+v shares a value with the dinner before grounding %+v", recipe, old)
}
got := fields{row.Method.String, row.MinutesTotal.Int64, row.Difficulty.String}
if got != want {
t.Errorf("method, minutes, difficulty = %+v, want %+v: the %s edit kept, the rest from the recipe", got, want, tt.field)
}
})
}
}
// fields are a dinner's or recipe's method, minutes_total and difficulty.
type fields struct {
method string
minutes int64
difficulty string
}
// patchFields returns the fields p sets, zero where it sets none.
func patchFields(p dinners.Patch) fields {
var f fields
if p.Method != nil {
f.method = *p.Method
}
if p.MinutesTotal != nil {
f.minutes = int64(*p.MinutesTotal)
}
if p.Difficulty != nil {
f.difficulty = *p.Difficulty
}
return f
}
// TestFixArchivedDinner: an archived dinner cannot be grounded by hand.
func TestFixArchivedDinner(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Grandma's stew")
if _, err := e.svc.Archive(t.Context(), id, member); err != nil {
t.Fatal(err)
}
before := e.dinner(t, id)
for _, f := range []dinners.Fix{
{RecipeURL: ptr(stew)},
{Manual: &dinners.ManualRecipe{Ingredients: []string{"1 lb beef"}, Minutes: ptr(240), Method: ptr("crockpot")}},
} {
if _, err := e.svc.Ground(t.Context(), id, f, member); !errors.Is(err, dinners.ErrArchived) {
t.Errorf("fix %+v: err = %v, want ErrArchived", f, err)
}
}
if got := e.pages.Fetched(); len(got) != 0 {
t.Errorf("fetched %q for an archived dinner", got)
}
if after := e.dinner(t, id); after != before {
t.Errorf("archived dinner changed: %+v, was %+v", after, before)
}
}
// waitFetched waits until rawURL has been fetched.
func (e *env) waitFetched(t *testing.T, rawURL string) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for !slices.Contains(e.pages.Fetched(), rawURL) {
if time.Now().After(deadline) {
t.Fatalf("%s never fetched", rawURL)
}
time.Sleep(5 * time.Millisecond)
}
}
+1 -28
View File
@@ -68,18 +68,8 @@ type SettingsPatch struct {
SuggestionsTime *string
ChatAutosendDow *int
ChatAutosendTime *string
// OnboardingCompleted, when true, records that the household finished
// onboarding: onboarding_completed_at becomes the time of the update,
// unless it is already set, when the first time is kept. Onboarding is
// never undone, so false is invalid, and it cannot first be completed
// with fewer than MinRegulars Regulars.
OnboardingCompleted *bool
}
// MinRegulars is how many Regulars the household must have to complete
// onboarding.
const MinRegulars = 3
// Problem is one invalid field of a SettingsPatch, named by its JSON key.
type Problem struct {
Field string
@@ -172,8 +162,7 @@ func (s *SettingsService) Get(ctx context.Context) (Settings, error) {
// Update validates patch, applies it, records updatedBy (the signed-in
// member's subject, which must not be empty) and publishes the new settings
// to every subscriber. An invalid patch is a *ValidationError and changes
// nothing, as does completing onboarding with fewer than MinRegulars
// Regulars.
// nothing.
func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updatedBy string) (Settings, error) {
if updatedBy == "" {
return Settings{}, errors.New("household: update settings: no member to record as updated_by")
@@ -219,19 +208,6 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
setString(&p.SuggestionsTime, patch.SuggestionsTime)
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid {
n, err := q.CountRegularDinners(ctx)
if err != nil {
return fmt.Errorf("household: count Regulars: %w", err)
}
if n < MinRegulars {
return &ValidationError{Problems: []Problem{{
Field: "onboarding_completed",
Reason: fmt.Sprintf("add at least %d Regulars first (have %d)", MinRegulars, n),
}}}
}
p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true}
}
row, err = q.UpsertHouseholdSettings(ctx, p)
if err != nil {
return fmt.Errorf("household: update settings: %w", err)
@@ -315,9 +291,6 @@ func (p SettingsPatch) validate() error {
fail(d.field, fmt.Sprintf("invalid day of the week %d (want 0-6, Sunday = 0)", *d.value))
}
}
if p.OnboardingCompleted != nil && !*p.OnboardingCompleted {
fail("onboarding_completed", "onboarding cannot be undone (want true)")
}
if len(problems) > 0 {
return &ValidationError{Problems: problems}
}
+1 -91
View File
@@ -3,13 +3,10 @@ package household
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"sync"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
@@ -37,34 +34,12 @@ func migratedStore(t *testing.T, dir string) *store.Store {
// seededService returns a settings service over a migrated temp database
// seeded with zone America/New_York.
func seededService(t *testing.T) *SettingsService {
t.Helper()
svc, _ := seededServiceStore(t)
return svc
}
// seededServiceStore is seededService with its store.
func seededServiceStore(t *testing.T) (*SettingsService, *store.Store) {
t.Helper()
st := migratedStore(t, t.TempDir())
if err := SeedSettings(context.Background(), st, "America/New_York"); err != nil {
t.Fatal(err)
}
return NewSettingsService(st), st
}
// addDinners stores n dinners with status.
func addDinners(t *testing.T, st *store.Store, status string, n int) {
t.Helper()
for range n {
id := store.NewID()
now := store.Now().String()
if _, err := db.New(st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
ID: id, Name: "Dinner " + id, NormalizedName: "dinner " + strings.ToLower(id),
Status: status, Origin: "regular", GroundingStatus: "pending", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
}
return NewSettingsService(st)
}
func ptr[T any](v T) *T { return &v }
@@ -242,70 +217,6 @@ func TestUpdateAppliesPatchAndRecordsUpdatedBy(t *testing.T) {
}
}
func TestUpdateCompletesOnboardingOnce(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc, st := seededServiceStore(t)
addDinners(t, st, "regular", MinRegulars)
got, err := svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "sam")
if err != nil {
t.Fatal(err)
}
if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != got.UpdatedAt {
t.Fatalf("onboarding_completed_at = %v, want the update time %s", got.OnboardingCompletedAt, got.UpdatedAt)
}
first := *got.OnboardingCompletedAt
// Completing again keeps the first time.
got, err = svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "alex")
if err != nil {
t.Fatal(err)
}
if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != first {
t.Errorf("second completion: onboarding_completed_at = %v, want %s", got.OnboardingCompletedAt, first)
}
// Other changes leave it alone.
got, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, "sam")
if err != nil {
t.Fatal(err)
}
if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != first {
t.Errorf("after another change: onboarding_completed_at = %v, want %s", got.OnboardingCompletedAt, first)
}
}
// TestUpdateOnboardingNeedsMinRegulars: onboarding cannot be completed
// with fewer than MinRegulars Regulars; archived and Quest log dinners do
// not count.
func TestUpdateOnboardingNeedsMinRegulars(t *testing.T) {
t.Parallel()
for regulars := range MinRegulars {
t.Run(fmt.Sprintf("%d Regulars", regulars), func(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc, st := seededServiceStore(t)
addDinners(t, st, "regular", regulars)
addDinners(t, st, "archived", MinRegulars)
addDinners(t, st, "quest_log", MinRegulars)
_, err := svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "sam")
var verr *ValidationError
if !errors.As(err, &verr) || len(verr.Problems) != 1 || verr.Problems[0].Field != "onboarding_completed" {
t.Fatalf("Update = %v, want a ValidationError on onboarding_completed", err)
}
got, err := svc.Get(ctx)
if err != nil {
t.Fatal(err)
}
if got.OnboardingCompletedAt != nil {
t.Errorf("onboarding_completed_at = %s after a refused completion, want null", *got.OnboardingCompletedAt)
}
})
}
}
func TestUpdateRequiresMember(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -348,7 +259,6 @@ func TestUpdateValidation(t *testing.T) {
{"dow 7", SettingsPatch{PlanDraftDow: ptr(7)}, []string{"plan_draft_dow"}},
{"dow -1", SettingsPatch{SuggestionsDow: ptr(-1)}, []string{"suggestions_dow"}},
{"dow 100", SettingsPatch{ChatAutosendDow: ptr(100)}, []string{"chat_autosend_dow"}},
{"onboarding undone", SettingsPatch{OnboardingCompleted: ptr(false)}, []string{"onboarding_completed"}},
{
"every problem is reported",
SettingsPatch{Timezone: ptr("Nowhere"), DinnerTime: ptr("5pm"), PlanDraftDow: ptr(9)},
+1 -1
View File
@@ -266,7 +266,7 @@ func (g *Grounder) GroundURL(ctx context.Context, rawURL string) (Grounded, erro
}
gr, reason, err := g.ground(ctx, rawURL, r)
if err != nil {
return Grounded{}, fmt.Errorf("recipes: ground pasted link: %w", err)
return Grounded{}, fmt.Errorf("recipes: ground %s: %w", rawURL, err)
}
if reason != nil {
return ungroundable(reason)
@@ -66,15 +66,9 @@ func (s *Search) Queries() []string {
type Pages struct {
Bodies map[string][]byte
Gate chan struct{}
mu sync.Mutex
fetched []string
}
func (p *Pages) Fetch(ctx context.Context, rawURL string) (recipes.Page, error) {
p.mu.Lock()
p.fetched = append(p.fetched, rawURL)
p.mu.Unlock()
if p.Gate != nil {
select {
case <-p.Gate:
@@ -89,13 +83,6 @@ func (p *Pages) Fetch(ctx context.Context, rawURL string) (recipes.Page, error)
return recipes.Page{URL: rawURL, ContentType: "text/html", Body: body}, nil
}
// Fetched returns every URL fetched, in order.
func (p *Pages) Fetched() []string {
p.mu.Lock()
defer p.mu.Unlock()
return append([]string(nil), p.fetched...)
}
// Images is a recipes.ImageFetcher serving JPEGs by URL; any other URL is
// a 404.
type Images struct {
+2 -17
View File
@@ -19,8 +19,7 @@ const maxDinnerBody = 16 << 10
// manual recipe of dinners.MaxManualLines lines of each kind.
const maxGroundBody = 512 << 10
// dinnerCreate is the POST /dinners body: a name, with or without a
// recipe_url.
// dinnerCreate is the POST /dinners body: a name or a recipe_url.
type dinnerCreate struct {
Name *string `json:"name"`
RecipeURL *string `json:"recipe_url"`
@@ -201,11 +200,7 @@ func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (a
// writeDinnerError maps a dinners.Service error to its API error. A page
// that cannot ground the dinner is 422 ungroundable; recipe search not set
// up is 409 not_configured; a search or model failure is 502; grounding by
// hand that runs past dinners.GroundTimeout is 504 ground_timeout; grounding
// an archived dinner is 409 dinner_archived and one changed while grounding
// 409 ground_conflict. Any other grounding failure, by candidate, link or
// manual recipe, wraps dinners.ErrGroundFailed and is 500, logged by reason
// and dinner id only.
// hand that runs past dinners.GroundTimeout is 504 ground_timeout.
func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var invalid *dinners.ValidationError
var dup *dinners.DuplicateError
@@ -225,16 +220,6 @@ func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logge
case errors.Is(err, recipes.ErrNormalizeFailed):
logger.WarnContext(r.Context(), action, "reason", "normalize_recipe failed")
writeError(w, r, logger, http.StatusBadGateway, "ai_failed", "the AI provider failed to read the recipe")
case errors.Is(err, dinners.ErrGroundFailed):
// The error may quote the pasted link, which can carry a personal
// token, and dependency errors, so only the reason and dinner are
// logged.
logger.ErrorContext(r.Context(), action, "reason", "ground_failed", "dinner_id", chi.URLParam(r, "id"))
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
case errors.Is(err, dinners.ErrArchived):
writeError(w, r, logger, http.StatusConflict, "dinner_archived", "the dinner is archived")
case errors.Is(err, dinners.ErrGroundConflict):
writeError(w, r, logger, http.StatusConflict, "ground_conflict", "the dinner changed while its recipe was grounding; reload and try again")
case errors.Is(err, dinners.ErrGroundTimeout):
logger.WarnContext(r.Context(), action, "reason", "ground_timeout")
writeError(w, r, logger, http.StatusGatewayTimeout, "ground_timeout", "grounding the recipe took too long; try again")
+14 -89
View File
@@ -159,6 +159,7 @@ func TestCreateDinnerByName(t *testing.T) {
"origin": "regular",
"grounding_status": "pending",
"is_favorite": false,
"recipe_url": nil,
"photo_url": nil,
"method": nil,
"minutes_total": nil,
@@ -169,9 +170,6 @@ func TestCreateDinnerByName(t *testing.T) {
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
}
}
if got, ok := body["recipe_url"]; ok {
t.Errorf("recipe_url = %#v, want no recipe_url in a dinner response", got)
}
var normalized, updatedBy string
if err := st.Reader.QueryRow(`SELECT normalized_name, updated_by FROM dinners WHERE id = ?`, body["id"]).Scan(&normalized, &updatedBy); err != nil {
t.Fatal(err)
@@ -186,12 +184,13 @@ func TestCreateDinnerByURL(t *testing.T) {
router, _ := newDinnersRouter(t)
link := "https://www.example.com/recipes/12345/beef-and-broccoli-stir-fry/"
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Stir fry night","recipe_url":"`+link+`"}`)
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"`+link+`"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
}
for k, v := range map[string]any{
"name": "Stir fry night",
"name": "Beef and broccoli stir fry",
"recipe_url": link,
"status": "regular",
"origin": "regular",
"grounding_status": "pending",
@@ -201,27 +200,24 @@ func TestCreateDinnerByURL(t *testing.T) {
t.Errorf("%s = %#v, want %#v", k, body[k], v)
}
}
// The link stays on the server for grounding; responses never carry it.
if got, ok := body["recipe_url"]; ok {
t.Errorf("recipe_url = %#v, want no recipe_url in a dinner response", got)
}
// The same link again is a duplicate even though its name would differ.
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Something else entirely"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("unrelated name = %d: %s", rec.Code, rec.Body)
}
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Beef and broccoli","recipe_url":"`+link+`"}`)
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"`+link+`"}`)
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
t.Errorf("same link again = %d %v, want 409 duplicate_dinner", rec.Code, body)
}
for _, tc := range []struct{ name, body string }{
{"plain http", `{"name":"Chili","recipe_url":"http://example.com/recipes/chili"}`},
{"not a URL", `{"name":"Chili","recipe_url":"chili"}`},
{"credentials", `{"name":"Chili","recipe_url":"https://me:pw@example.com/recipes/chili"}`},
{"link without a name", `{"recipe_url":"https://example.com/recipes/chili"}`},
{"filler name with a link", `{"name":"The Best Easy Recipe","recipe_url":"https://example.com/recipes/chili"}`},
{"plain http", `{"recipe_url":"http://example.com/recipes/chili"}`},
{"not a URL", `{"recipe_url":"chili"}`},
{"credentials", `{"recipe_url":"https://me:pw@example.com/recipes/chili"}`},
{"no name in path", `{"recipe_url":"https://example.com/"}`},
{"numeric slug", `{"recipe_url":"https://example.com/recipe/98765"}`},
{"both fields", `{"name":"Chili","recipe_url":"https://example.com/recipes/chili"}`},
{"neither field", `{}`},
} {
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, tc.body)
@@ -231,77 +227,6 @@ func TestCreateDinnerByURL(t *testing.T) {
}
}
// TestDinnerResponsesHideRecipeURL: a pasted link may carry a personal
// token, so neither the create response nor a later list read shows any
// part of it, while the stored link still awaits grounding.
func TestDinnerResponsesHideRecipeURL(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
const link = "https://c.example/recipes/stew?access_token=personal-token-abc123"
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Stew","recipe_url":"`+link+`"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
}
created := rec.Body.String()
rec, _ = callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET status=regular = %d: %s", rec.Code, rec.Body)
}
for _, resp := range []struct{ name, body string }{{"create", created}, {"list", rec.Body.String()}} {
for _, secret := range []string{link, "c.example", "access_token", "personal-token-abc123", "recipe_url"} {
if strings.Contains(resp.body, secret) {
t.Errorf("%s response contains %q: %s", resp.name, secret, resp.body)
}
}
}
var stored string
if err := st.Reader.QueryRow(`SELECT recipe_url FROM dinners WHERE id = ?`, body["id"]).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored != link {
t.Errorf("stored recipe_url = %q, want %q kept for grounding", stored, link)
}
}
// TestDinnerResponsesHideTokenInLinkPath: a personal token can sit
// anywhere in a link's path, even as plain letters, so a dinner added from
// a link is named only by the household and neither the create response, a
// later list read nor the duplicate error for the same link shows any of
// the link.
func TestDinnerResponsesHideTokenInLinkPath(t *testing.T) {
t.Parallel()
router, _ := newDinnersRouter(t)
const link = "https://c.example/recipes/stew-key-abcdefghijklmno"
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Sunday stew","recipe_url":"`+link+`"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
}
if body["name"] != "Sunday stew" {
t.Errorf("name = %v, want Sunday stew", body["name"])
}
created := rec.Body.String()
rec, _ = callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET status=regular = %d: %s", rec.Code, rec.Body)
}
listed := rec.Body.String()
rec, _ = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Beef stew","recipe_url":"`+link+`"}`)
if rec.Code != http.StatusConflict {
t.Fatalf("same link again = %d, want 409: %s", rec.Code, rec.Body)
}
for _, resp := range []struct{ name, body string }{{"create", created}, {"list", listed}, {"duplicate", rec.Body.String()}} {
lower := strings.ToLower(resp.body)
for _, secret := range []string{"c.example", "recipes", "key", "abcdefghijklmno"} {
if strings.Contains(lower, secret) {
t.Errorf("%s response contains %q: %s", resp.name, secret, resp.body)
}
}
}
}
func TestCreateDinnerDuplicate(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
@@ -317,10 +242,10 @@ func TestCreateDinnerDuplicate(t *testing.T) {
t.Errorf("%q: %d %v, want 409 duplicate_dinner", name, rec.Code, body)
}
}
// A name repeating the dish is a duplicate with a link too.
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Chicken tikka masala","recipe_url":"https://example.com/other-curry.html"}`)
// A slug naming the same dish is a duplicate too.
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"https://example.com/easy-chicken-tikka-masala.html"}`)
if rec.Code != http.StatusConflict {
t.Errorf("named duplicate with a link = %d %v, want 409", rec.Code, body)
t.Errorf("URL slug duplicate = %d %v, want 409", rec.Code, body)
}
var n int
-109
View File
@@ -224,115 +224,6 @@ func TestPostGroundViaRecipeURL(t *testing.T) {
}
}
// TestPostGroundViaRecipeURLStorageFailure: a pasted link that fails for a
// reason other than its recipe is a 500 logged by reason and dinner id,
// never with the link, which can carry a personal token, or the dependency
// error.
func TestPostGroundViaRecipeURLStorageFailure(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
const token = "personal-token-abc123"
link := stewPage + "?access_token=" + token
e.pages.Bodies[link] = e.pages.Bodies[stewPage]
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
if _, err := e.st.Writer.ExecContext(t.Context(),
`CREATE TRIGGER refuse_recipes BEFORE INSERT ON recipes BEGIN SELECT RAISE(ABORT, 'disk full'); END`); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+link+`"}`)
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" || strings.Contains(rec.Body.String(), token) {
t.Errorf("POST = %d %s, want 500 internal without the link", rec.Code, rec.Body)
}
logs := e.logs.String()
if strings.Contains(logs, token) || strings.Contains(logs, "c.example") || strings.Contains(logs, "disk full") ||
!strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the link or dependency error", logs)
}
}
// TestPostGroundViaRecipeURLDinnerWriteFailure: a pasted link whose recipe
// is stored but whose dinner cannot be written is a 500 logged by reason
// and dinner id, never with the dependency error.
func TestPostGroundViaRecipeURLDinnerWriteFailure(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
if _, err := e.st.Writer.ExecContext(t.Context(),
`CREATE TRIGGER refuse_grounding BEFORE UPDATE OF recipe_id ON dinners WHEN NEW.recipe_id IS NOT NULL BEGIN SELECT RAISE(ABORT, 'disk full'); END`); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+stewPage+`"}`)
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
t.Errorf("POST = %d %s, want 500 internal", rec.Code, rec.Body)
}
var stored int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM recipes WHERE source_url = ?`, stewPage).Scan(&stored); err != nil || stored != 1 {
t.Fatalf("recipes stored for the link = %d (%v), want 1: the failure must be the dinner write", stored, err)
}
logs := e.logs.String()
if strings.Contains(logs, "disk full") || !strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the dependency error", logs)
}
}
// TestPostGroundUnexpectedFailures: grounding by hand that fails reading
// the dinner, or storing a manual recipe, is a 500 logged by reason and
// dinner id, never with the database error.
func TestPostGroundUnexpectedFailures(t *testing.T) {
t.Parallel()
const (
// Renaming a column every dinner read selects fails the read.
breakRead = `ALTER TABLE dinners RENAME COLUMN cuisine TO cuisine_gone`
// The trigger's message stands in for the database error.
breakStore = `CREATE TRIGGER refuse_recipes BEFORE INSERT ON recipes BEGIN SELECT RAISE(ABORT, 'disk full'); END`
manual = `{"manual":{"ingredients":["1 lb beef"],"steps":["Brown."],"minutes":240,"method":"crockpot"}}`
)
for _, tc := range []struct {
name, sabotage, fix, leak string
}{
{"link read", breakRead, `{"recipe_url":"` + stewPage + `"}`, "cuisine"},
{"manual read", breakRead, manual, "cuisine"},
{"manual store", breakStore, manual, "disk full"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
if _, err := e.st.Writer.ExecContext(t.Context(), tc.sabotage); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", tc.fix)
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
t.Errorf("POST = %d %s, want 500 internal", rec.Code, rec.Body)
}
logs := e.logs.String()
if strings.Contains(logs, tc.leak) || !strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the database error", logs)
}
})
}
}
// TestPostGroundArchived: grounding an archived dinner by hand is a 409.
func TestPostGroundArchived(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Family chili"}`)
if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
`{"manual":{"ingredients":["1 lb beef"],"minutes":240,"method":"crockpot"}}`)
if rec.Code != http.StatusConflict || errorCode(body) != "dinner_archived" {
t.Errorf("POST = %d %v, want 409 dinner_archived", rec.Code, body)
}
}
func TestPostGroundViaManual(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
-3
View File
@@ -37,9 +37,6 @@ type settingsPatch struct {
SuggestionsTime *string `json:"suggestions_time"`
ChatAutosendDow *int `json:"chat_autosend_dow"`
ChatAutosendTime *string `json:"chat_autosend_time"`
// OnboardingCompleted true sets onboarding_completed_at, which is
// itself read-only.
OnboardingCompleted *bool `json:"onboarding_completed"`
}
func getHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc {
-61
View File
@@ -3,7 +3,6 @@ package server
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
@@ -14,7 +13,6 @@ import (
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
@@ -117,63 +115,6 @@ func TestPatchHouseholdSettings(t *testing.T) {
}
}
// newOnboardingRouter returns a router whose settings store holds regulars
// Regulars.
func newOnboardingRouter(t *testing.T, regulars int) http.Handler {
t.Helper()
st := newTestStore(t)
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
t.Fatal(err)
}
for i := range regulars {
now := store.Now().String()
if _, err := db.New(st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
ID: store.NewID(), Name: fmt.Sprintf("Dinner %d", i), NormalizedName: fmt.Sprintf("dinner %d", i),
Status: "regular", Origin: "regular", GroundingStatus: "pending", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
}
return newTestRouterWith(t, "dev", household.NewSettingsService(st), newTestRunner(t))
}
func TestPatchHouseholdSettingsCompletesOnboarding(t *testing.T) {
t.Parallel()
rec, body := serve(t, newOnboardingRouter(t, household.MinRegulars), signedIn(http.MethodPatch, settingsPath,
strings.NewReader(`{"onboarding_completed":true}`)))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
}
if at, ok := body["onboarding_completed_at"].(string); !ok || at != body["updated_at"] {
t.Errorf("onboarding_completed_at = %#v, want the update time %#v", body["onboarding_completed_at"], body["updated_at"])
}
}
// TestPatchHouseholdSettingsOnboardingNeedsRegulars: completing onboarding
// with fewer than three Regulars is refused and leaves it incomplete.
func TestPatchHouseholdSettingsOnboardingNeedsRegulars(t *testing.T) {
t.Parallel()
for regulars := range household.MinRegulars {
t.Run(fmt.Sprintf("%d Regulars", regulars), func(t *testing.T) {
t.Parallel()
router := newOnboardingRouter(t, regulars)
rec, _ := serve(t, router, signedIn(http.MethodPatch, settingsPath,
strings.NewReader(`{"onboarding_completed":true}`)))
if rec.Code != http.StatusBadRequest ||
!strings.Contains(rec.Body.String(), `"code":"invalid_settings"`) ||
!strings.Contains(rec.Body.String(), "onboarding_completed: add at least 3 Regulars first") {
t.Fatalf("PATCH = %d %s, want 400 invalid_settings on onboarding_completed", rec.Code, rec.Body)
}
_, body := serve(t, router, signedIn(http.MethodGet, settingsPath, nil))
if body["onboarding_completed_at"] != nil {
t.Errorf("onboarding_completed_at = %#v after a refused completion, want null", body["onboarding_completed_at"])
}
})
}
}
func TestPatchHouseholdSettingsRejected(t *testing.T) {
t.Parallel()
@@ -195,8 +136,6 @@ func TestPatchHouseholdSettingsRejected(t *testing.T) {
{"null value", `{"timezone":null}`, "invalid_request", "timezone"},
{"unknown field", `{"dinner":"17:30"}`, "invalid_request", "dinner"},
{"read-only field", `{"updated_by":"someone"}`, "invalid_request", "updated_by"},
{"read-only onboarding time", `{"onboarding_completed_at":"2026-10-06T12:00:00.000Z"}`, "invalid_request", "onboarding_completed_at"},
{"onboarding undone", `{"onboarding_completed":false}`, "invalid_settings", "onboarding_completed"},
{"not an object", `["timezone"]`, "invalid_request", "JSON object"},
{"null body", `null`, "invalid_request", "JSON object"},
{"trailing data", `{"dinner_time":"18:00"} {}`, "invalid_request", "JSON object"},
+4 -4
View File
@@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error", got)
}
}
@@ -196,8 +196,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error", got)
}
if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created")
@@ -1,7 +0,0 @@
-- 0010_dinner_grounding_revision: each ground_dinner job carries the
-- dinner's grounding_revision when it was enqueued, and records its result
-- only while the dinner still has that revision. Renaming a pending dinner
-- raises the revision before enqueuing the job for the new name, so an
-- earlier job — queued or running — can no longer write the dinner.
ALTER TABLE dinners ADD COLUMN grounding_revision INTEGER NOT NULL DEFAULT 0;
@@ -1,11 +0,0 @@
-- 0011_ground_dinner_job_revision: every dinner starts at grounding
-- revision 0 (0010), so a ground_dinner job enqueued before 0010, whose
-- payload has only dinner_id, is for revision 0. Recording it lets the boot
-- cleanup, which matches a pending dinner to its job by revision, see a job
-- still queued for its dinner. This is a migration of its own, not part of
-- 0010, so it also runs on a database that has already applied 0010.
UPDATE jobs
SET payload = json_set(payload, '$.revision', 0)
WHERE kind = 'ground_dinner'
AND json_type(payload, '$.revision') IS NULL;
+31 -71
View File
@@ -19,19 +19,11 @@ SELECT * FROM dinners
WHERE status = ?
ORDER BY normalized_name, id;
-- name: CountRegularDinners :one
-- How many Regulars the household has; archived dinners are not counted.
SELECT COUNT(*) FROM dinners
WHERE status = 'regular';
-- name: ListUnarchivedDinners :many
-- Every dinner a new or renamed dinner must not repeat, with the
-- source_url of the recipe it is grounded in (NULL while it has none).
SELECT sqlc.embed(dinners), recipes.source_url AS recipe_source_url
FROM dinners
LEFT JOIN recipes ON recipes.id = dinners.recipe_id
WHERE dinners.status <> 'archived'
ORDER BY dinners.id;
-- Every dinner a new or renamed dinner must not repeat.
SELECT * FROM dinners
WHERE status <> 'archived'
ORDER BY id;
-- name: UpdateDinner :one
-- An unknown id returns sql.ErrNoRows.
@@ -45,10 +37,8 @@ SET name = ?,
difficulty = ?,
cuisine = ?,
recipe_id = ?,
recipe_url = ?,
photo_id = ?,
grounding_status = ?,
grounding_revision = ?,
updated_by = ?,
updated_at = ?
WHERE id = ?
@@ -82,87 +72,57 @@ WHERE id = ?
RETURNING *;
-- name: GroundDinner :one
-- Records a dinner's grounding by hand (grounded or manual) and clears any
-- grounding_error and pasted recipe_url: the recipe chosen replaces the
-- link. It writes only an unarchived dinner still as it was observed
-- before grounding (same name, normalized_name, grounding_status,
-- grounding_revision and recipe_id), so a dinner renamed, archived or
-- grounded some other way meanwhile is left as it is. That dinner, or an
-- 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.
-- Records a dinner's grounding (grounded or manual) and clears any
-- grounding_error. An unknown id returns sql.ErrNoRows.
UPDATE dinners
SET recipe_id = sqlc.arg(recipe_id),
recipe_url = NULL,
photo_id = sqlc.arg(photo_id),
method = CASE WHEN method IS sqlc.narg(observed_method)
THEN sqlc.arg(method) ELSE method END,
minutes_total = CASE WHEN minutes_total IS sqlc.narg(observed_minutes_total)
THEN sqlc.arg(minutes_total) ELSE minutes_total END,
difficulty = CASE WHEN difficulty IS sqlc.narg(observed_difficulty)
THEN sqlc.arg(difficulty) ELSE difficulty END,
grounding_status = sqlc.arg(grounding_status),
SET recipe_id = ?,
photo_id = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
grounding_status = ?,
grounding_error = NULL,
updated_by = sqlc.arg(updated_by),
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id)
AND status <> 'archived'
AND name = sqlc.arg(observed_name)
AND normalized_name = sqlc.arg(observed_normalized_name)
AND grounding_status = sqlc.arg(observed_grounding_status)
AND grounding_revision = sqlc.arg(observed_grounding_revision)
AND recipe_id IS sqlc.narg(observed_recipe_id)
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING *;
-- name: GroundPendingDinner :execrows
-- 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,
-- minutes_total or difficulty already set is kept: a pending dinner has
-- them only when a member edited them while it was grounding.
-- Records the background grounding of a dinner still pending; a dinner
-- grounded some other way meanwhile is left as it is (0 rows).
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)),
SET recipe_id = ?,
photo_id = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
grounding_status = 'grounded',
grounding_error = NULL,
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id)
AND status <> 'archived'
AND grounding_status = 'pending'
AND grounding_revision = sqlc.arg(grounding_revision);
updated_at = ?
WHERE id = ? AND grounding_status = 'pending';
-- name: FailPendingDinnerGrounding :execrows
-- Records why the background grounding of an unarchived dinner still
-- pending at the grounding_revision it was grounded for failed; a dinner
-- grounded some other way, renamed or archived meanwhile is left as it is
-- (0 rows).
-- Records why the background grounding of a dinner still pending failed; a
-- dinner grounded some other way meanwhile is left as it is (0 rows).
UPDATE dinners
SET grounding_status = 'failed',
grounding_error = ?,
updated_at = ?
WHERE id = ?
AND status <> 'archived'
AND grounding_status = 'pending'
AND grounding_revision = ?;
WHERE id = ? AND grounding_status = 'pending';
-- name: FailOrphanedPendingDinnerGroundings :execrows
-- Fails every unarchived dinner still pending whose grounding job for its
-- current grounding_revision is no longer queued or running: the process
-- stopped before the job, or before its handler recorded a result. An
-- archived dinner is left as it is; archiving stopped its grounding. Run at
-- boot, after the job runner fails the jobs a previous process left running.
-- Fails every dinner still pending whose grounding job is no longer queued
-- or running: the process stopped before the job, or before its handler
-- recorded a result. Run at boot, after the job runner fails the jobs a
-- previous process left running.
UPDATE dinners
SET grounding_status = 'failed',
grounding_error = sqlc.arg(grounding_error),
updated_at = sqlc.arg(updated_at)
WHERE grounding_status = 'pending'
AND status <> 'archived'
AND NOT EXISTS (
SELECT 1 FROM jobs
WHERE jobs.kind = sqlc.arg(kind)
AND jobs.status IN ('queued', 'running')
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
AND json_extract(jobs.payload, '$.revision') = dinners.grounding_revision
);
+12 -83
View File
@@ -7,7 +7,6 @@ import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stub
import App, { routes } from './App.tsx'
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
import { InDataRouter } from './test/dataRouter.tsx'
import { dinnerBody } from './test/dinners.ts'
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
@@ -28,20 +27,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
}
// The routes that render a built screen rather than a placeholder page.
const SCREENS = {
'/onboarding': true,
'/settings': true,
'/settings/ai': true,
'/settings/household': true,
'/settings/regulars': true,
}
// SIGNED_IN answers what every route reads first: the session, then the
// household settings, onboarding finished.
const SIGNED_IN = {
'/api/v1/me': () => jsonResponse(200, ME),
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
}
const SCREENS = { '/settings': true, '/settings/ai': true, '/settings/household': true }
describe('App routes', () => {
it('registers every handoff route plus the extra settings pages', () => {
@@ -68,7 +54,7 @@ describe('App routes', () => {
'%s renders its placeholder page inside the shell',
async (path, title) => {
mockViewport(390)
stubFetchByPath(SIGNED_IN)
stubFetch(jsonResponse(200, ME))
renderAt(path)
const heading = await screen.findByRole('heading', { level: 1, name: title })
expect(screen.getByRole('main')).toContainElement(heading)
@@ -91,7 +77,7 @@ describe('App routes', () => {
it('/settings/ai renders the AI assistant screen inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
...SIGNED_IN,
'/api/v1/me': () => jsonResponse(200, ME),
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }),
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
@@ -104,73 +90,21 @@ describe('App routes', () => {
it('/settings/household renders the household settings screen inside the shell', async () => {
mockViewport(390)
stubFetchByPath(SIGNED_IN)
stubFetchByPath({
'/api/v1/me': () => jsonResponse(200, ME),
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
})
renderAt('/settings/household')
const heading = await screen.findByRole('heading', { level: 1, name: 'Household' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByLabelText('Dinner time')).toHaveValue('17:30')
})
it('/settings/regulars renders the Regular dinners screen inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
...SIGNED_IN,
'/api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: [dinnerBody('d1', 'Beef tacos')] }),
})
renderAt('/settings/regulars')
const heading = await screen.findByRole('heading', { level: 1, name: 'Regular dinners' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
})
})
describe('App first-run redirect', () => {
const FIRST_RUN = {
'/api/v1/me': () => jsonResponse(200, ME),
'/api/v1/settings/household': () => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }),
}
it.each(routes.filter(({ path }) => path !== '/onboarding').map(({ path }) => path.replace(':id', 'abc123')))(
'%s redirects to /onboarding while onboarding is not finished',
async (path) => {
mockViewport(390)
stubFetchByPath(FIRST_RUN)
renderAt(path)
expect(await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })).toBeInTheDocument()
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
},
)
it('stays on an onboarding step', async () => {
mockViewport(390)
stubFetchByPath({ ...FIRST_RUN, '/api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: [] }) })
renderAt('/onboarding/regulars')
expect(await screen.findByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument()
})
it('opens the routes once onboarding is finished', async () => {
mockViewport(390)
stubFetchByPath(SIGNED_IN)
renderAt('/plan')
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
it('reports a household settings failure with a retry instead of rendering the routes', async () => {
mockViewport(390)
stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS))
renderAt('/plan')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
screen.getByRole('button', { name: 'Retry' }).click()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
})
describe('App bootstrap', () => {
it('shows the skeleton until /me loads, then the routes', async () => {
mockViewport(390)
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
const fetchMock = stubFetch(jsonResponse(200, ME))
renderAt('/plan')
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
@@ -204,7 +138,7 @@ 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))
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME))
renderAt('/')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
@@ -224,7 +158,7 @@ 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)
stubFetch(jsonResponse(200, ME))
const { unmount } = renderAt('/')
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
unmount()
@@ -292,17 +226,12 @@ describe('App offline bootstrap', () => {
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
const fetchMock = stubFetchByPath(SIGNED_IN)
const fetchMock = stubFetch(jsonResponse(200, ME))
onLine = true
act(() => {
window.dispatchEvent(new Event('online'))
})
await vi.waitFor(() =>
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
'/api/v1/me',
'/api/v1/settings/household',
]),
)
await vi.waitFor(() => expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' }))
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
+4 -15
View File
@@ -3,14 +3,11 @@ import { QueryClientProvider } from '@tanstack/react-query'
import { Route, Routes } from 'react-router'
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
import { AuthBootstrap } from './AuthBootstrap.tsx'
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
import { OfflinePill } from './pwa/OfflinePill.tsx'
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
import { AISettingsPage } from './settings/AISettingsPage.tsx'
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
import { SettingsPage } from './settings/SettingsPage.tsx'
import { AppShell } from './shell/AppShell.tsx'
@@ -38,24 +35,16 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
'/settings/household': <HouseholdSettingsPage />,
'/settings/regulars': <RegularsSettingsPage />,
}
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
// Onboarding is a full screen of its own, outside the shell; until it is
// finished, OnboardingGate sends every other route to it.
export function AppRoutes() {
return (
<Routes>
<Route element={<OnboardingGate />}>
<Route path={`${ONBOARDING_PATH}/*`} element={<OnboardingPage />} />
<Route element={<AppShell />}>
{routes
.filter(({ path }) => path !== ONBOARDING_PATH)
.map(({ path, title }) => (
<Route key={path} path={path} element={screens[path] ?? <PlaceholderPage title={title} />} />
))}
</Route>
<Route element={<AppShell />}>
{routes.map(({ path, title }) => (
<Route key={path} path={path} element={screens[path] ?? <PlaceholderPage title={title} />} />
))}
</Route>
</Routes>
)
-5
View File
@@ -41,11 +41,6 @@ export function AuthBootstrap({ children }: { children: ReactNode }) {
</main>
)
}
return <LoadingScreen />
}
// LoadingScreen is the full-screen skeleton shown while MealQuest loads.
export function LoadingScreen() {
return (
<main className={styles.screen} aria-busy="true" aria-label="Loading MealQuest">
<div className={styles.loading}>
-335
View File
@@ -1,335 +0,0 @@
import { useIsMutating, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
export const DINNERS_PATH = '/api/v1/dinners'
export const REGULARS_PATH = `${DINNERS_PATH}?status=regular`
// GROUNDING_POLL_MS is how often the Regulars are read again while any of
// them is still finding its recipe.
export const GROUNDING_POLL_MS = 3000
// Methods and Difficulties are the values the dinners API allows.
export const METHODS = ['stovetop', 'oven', 'crockpot', 'grill', 'other'] as const
export const DIFFICULTIES = ['easy', 'medium', 'hard'] as const
const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const
export type Method = (typeof METHODS)[number]
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.
export type Regular = {
id: string
name: string
method: Method | null
minutes_total: number | null
difficulty: Difficulty | null
is_favorite: boolean
photo_id: string | undefined
grounding_status: GroundingStatus
pending?: true
}
// NewRegular is a POST /api/v1/dinners body: the household's name for the
// dinner, with the recipe link to ground it in when one was pasted. A
// dinner is never named from its link, which may carry private details.
export type NewRegular = { name: string; recipe_url?: string }
// RegularPatch is a PATCH /api/v1/dinners/{id} body; every field is optional.
export type RegularPatch = Partial<{ name: string; method: Method; minutes_total: number; difficulty: Difficulty }>
// ManualRecipe is a recipe the household types in; the server needs its
// minutes and method.
export type ManualRecipe = { ingredients: string[]; steps: string[]; minutes: number; method: Method }
// RecipeFix is a POST /api/v1/dinners/{id}/ground body: exactly one way to
// ground the dinner.
export type RecipeFix = { candidate_id: string } | { recipe_url: string } | { manual: ManualRecipe }
// RecipeCandidate is one recipe page a dinner could be grounded in.
export type RecipeCandidate = {
candidate_id: string
title: string
site_name: string
photo_id: string | undefined
minutes: number | null
}
// CachedRegular is a Regular as the cache holds it. removing is true while
// an optimistic removal waits for the server: the entry keeps its place, so
// a refused removal returns exactly there whatever else changed meanwhile.
type CachedRegular = Regular & { removing?: true }
function shown(list: CachedRegular[]): Regular[] {
return list.filter((regular) => !regular.removing)
}
function restored(regular: CachedRegular): CachedRegular {
const copy = { ...regular }
delete copy.removing
return copy
}
// REGULARS_KEY prefixes every Regulars query, so a change refreshes them all
// (the settings home's count included).
export const REGULARS_KEY = ['dinners', 'regular'] as const
const REGULARS_LIST_KEY = [...REGULARS_KEY, 'list'] as const
const REGULARS_MUTATION_KEY = ['dinners', 'regular', 'change'] as const
function oneOf<T extends string>(where: string, field: string, values: readonly T[], value: unknown): T {
if (typeof value !== 'string' || !(values as readonly string[]).includes(value)) {
throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not one of ${values.join(', ')}`)
}
return value as T
}
function nullable<T>(value: unknown, parse: (value: unknown) => T): T | null {
return value === null ? null : parse(value)
}
function wholeNumber(where: string, field: string, value: unknown): number {
if (typeof value !== 'number' || !Number.isInteger(value)) throw new Error(`${where}: ${field} is not a whole number`)
return value
}
const PHOTO_URL = /^\/media\/photos\/([^/]+)-400\.jpg$/
// photoId reads the photo id out of a photo_url, which the server always
// sends as /media/photos/{id}-400.jpg or null.
function photoId(where: string, value: unknown): string | undefined {
if (value === null) return undefined
const match = typeof value === 'string' ? PHOTO_URL.exec(value) : null
if (!match?.[1]) throw new Error(`${where}: photo_url ${JSON.stringify(value)} is not a stored photo`)
return decodeURIComponent(match[1])
}
function parseDinner(where: string, body: unknown): Regular {
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
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`)
return {
id: body.id,
name: body.name,
method: nullable(body.method, (v) => oneOf(where, 'method', METHODS, v)),
minutes_total: nullable(body.minutes_total, (v) => wholeNumber(where, 'minutes_total', v)),
difficulty: nullable(body.difficulty, (v) => oneOf(where, 'difficulty', DIFFICULTIES, v)),
is_favorite: body.is_favorite,
photo_id: photoId(where, body.photo_url),
grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status),
}
}
function parseCandidate(where: string, body: unknown): RecipeCandidate {
if (
!isRecord(body) ||
typeof body.candidate_id !== 'string' ||
typeof body.title !== 'string' ||
typeof body.site_name !== 'string'
) {
throw new Error(`${where}: response is not a recipe candidate`)
}
return {
candidate_id: body.candidate_id,
title: body.title,
site_name: body.site_name,
photo_id: photoId(where, body.photo_url),
minutes: nullable(body.minutes, (v) => wholeNumber(where, 'minutes', v)),
}
}
function dinnerPath(id: string): string {
return `${DINNERS_PATH}/${encodeURIComponent(id)}`
}
// useRegulars is the household's Regulars, in the server's order. While any
// of them is still finding its recipe it is read again every
// GROUNDING_POLL_MS, except while any change is in flight (whose settling
// reads it again anyway), so a poll never overwrites a change being saved.
// Watching the changes in flight re-renders the hook as each one starts and
// settles, which pauses and resumes the poll.
export function useRegulars() {
const { apiFetch } = useApi()
const changing = useIsMutating({ mutationKey: REGULARS_MUTATION_KEY }) > 0
return useQuery({
queryKey: REGULARS_LIST_KEY,
queryFn: async () => {
const where = `GET ${REGULARS_PATH}`
const body = await apiFetch<unknown>(REGULARS_PATH)
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
return body.dinners.map((dinner): CachedRegular => parseDinner(where, dinner))
},
select: shown,
refetchInterval: (query) => {
const list = query.state.data
if (list === undefined || changing) return false
return list.some((regular) => regular.grounding_status === 'pending') ? GROUNDING_POLL_MS : false
},
})
}
// changeOptimistically stops Regulars reads in flight, so they cannot
// overwrite the optimistic list, then applies change to it.
async function changeOptimistically(queryClient: QueryClient, change: (list: CachedRegular[]) => CachedRegular[]) {
await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY })
setRegulars(queryClient, change)
}
function setRegulars(queryClient: QueryClient, change: (list: CachedRegular[]) => CachedRegular[]) {
queryClient.setQueryData<CachedRegular[]>(REGULARS_LIST_KEY, (list) => change(list ?? []))
}
// replace puts the server's answer for a dinner in its place in the list.
function replace(queryClient: QueryClient, dinner: Regular) {
setRegulars(queryClient, (list) => list.map((regular) => (regular.id === dinner.id ? dinner : regular)))
}
// settle refetches the Regulars once the last change in flight is done, so
// an earlier change's refetch never drops a later change's optimistic entry.
function settle(queryClient: QueryClient) {
if (queryClient.isMutating({ mutationKey: REGULARS_MUTATION_KEY }) === 1) {
void queryClient.invalidateQueries({ queryKey: REGULARS_KEY })
}
}
let pendingIds = 0
// useAddRegular adds a Regular by name, with or without a recipe link (POST
// /api/v1/dinners). It shows in the list at once under its name and is
// taken out again if the server refuses it.
export function useAddRegular() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationKey: REGULARS_MUTATION_KEY,
mutationFn: async (add: NewRegular) =>
parseDinner(`POST ${DINNERS_PATH}`, await apiFetch<unknown>(DINNERS_PATH, { method: 'POST', body: add })),
onMutate: async (add) => {
const id = `pending-${++pendingIds}`
const optimistic: CachedRegular = {
id,
name: add.name,
method: null,
minutes_total: null,
difficulty: null,
is_favorite: false,
photo_id: undefined,
grounding_status: 'pending',
pending: true,
}
await changeOptimistically(queryClient, (list) => [...list, optimistic])
return { id }
},
onSuccess: (dinner, _add, context) => {
setRegulars(queryClient, (list) => list.map((regular) => (regular.id === context.id ? dinner : regular)))
},
onError: (_error, _add, context) => {
if (context === undefined) return
setRegulars(queryClient, (list) => list.filter((regular) => regular.id !== context.id))
},
onSettled: () => settle(queryClient),
})
}
// useRemoveRegular archives a Regular (DELETE /api/v1/dinners/{id}). It
// leaves the list at once and is put back in its place if the server
// refuses.
export function useRemoveRegular() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationKey: REGULARS_MUTATION_KEY,
mutationFn: async (regular: Regular) => {
await apiFetch<unknown>(dinnerPath(regular.id), { method: 'DELETE' })
},
onMutate: async (regular) => {
await changeOptimistically(queryClient, (list) =>
list.map((r): CachedRegular => (r.id === regular.id ? { ...r, removing: true } : r)),
)
},
onSuccess: (_data, regular) => {
setRegulars(queryClient, (list) => list.filter((r) => r.id !== regular.id))
},
onError: (_error, regular) => {
setRegulars(queryClient, (list) => list.map((r) => (r.id === regular.id ? restored(r) : r)))
},
onSettled: () => settle(queryClient),
})
}
// useEditRegular saves changes to a Regular (PATCH /api/v1/dinners/{id})
// and shows the server's answer once it is saved.
export function useEditRegular() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationKey: REGULARS_MUTATION_KEY,
mutationFn: async ({ id, patch }: { id: string; patch: RegularPatch }) =>
parseDinner(`PATCH ${DINNERS_PATH}/{id}`, await apiFetch<unknown>(dinnerPath(id), { method: 'PATCH', body: patch })),
onSuccess: (dinner) => replace(queryClient, dinner),
onSettled: () => settle(queryClient),
})
}
// useToggleFavorite toggles a Regular's favorite heart (POST
// /api/v1/dinners/{id}/favorite). The heart changes at once and changes
// back if the server refuses.
export function useToggleFavorite() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
const set = (id: string, is_favorite: boolean) =>
setRegulars(queryClient, (list) => list.map((r) => (r.id === id ? { ...r, is_favorite } : r)))
return useMutation({
mutationKey: REGULARS_MUTATION_KEY,
mutationFn: async (regular: Regular) =>
parseDinner(
`POST ${DINNERS_PATH}/{id}/favorite`,
await apiFetch<unknown>(`${dinnerPath(regular.id)}/favorite`, { method: 'POST' }),
),
onMutate: async (regular) => {
await changeOptimistically(queryClient, (list) =>
list.map((r) => (r.id === regular.id ? { ...r, is_favorite: !regular.is_favorite } : r)),
)
},
onSuccess: (dinner) => replace(queryClient, dinner),
onError: (_error, regular) => set(regular.id, regular.is_favorite),
onSettled: () => settle(queryClient),
})
}
// useGroundRegular fixes a Regular's recipe (POST /api/v1/dinners/{id}/ground)
// with a candidate, a pasted link or a recipe typed in. A candidate or link
// is read in the request, which can take up to a minute and a half.
export function useGroundRegular() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationKey: REGULARS_MUTATION_KEY,
mutationFn: async ({ id, fix }: { id: string; fix: RecipeFix }) =>
parseDinner(
`POST ${DINNERS_PATH}/{id}/ground`,
await apiFetch<unknown>(`${dinnerPath(id)}/ground`, { method: 'POST', body: fix }),
),
onSuccess: (dinner) => replace(queryClient, dinner),
onSettled: () => settle(queryClient),
})
}
// useRecipeCandidates is up to three recipe pages the dinner could be
// grounded in, best first (GET /api/v1/dinners/{id}/recipe-candidates).
export function useRecipeCandidates(id: string) {
const { apiFetch } = useApi()
return useQuery({
queryKey: ['dinners', 'candidates', id],
queryFn: async () => {
const path = `${dinnerPath(id)}/recipe-candidates`
const where = `GET ${DINNERS_PATH}/{id}/recipe-candidates`
const body = await apiFetch<unknown>(path)
if (!isRecord(body) || !Array.isArray(body.candidates)) throw new Error(`${where}: response has no candidates list`)
return body.candidates.map((candidate) => parseCandidate(where, candidate))
},
})
}
File diff suppressed because it is too large Load Diff
-20
View File
@@ -1,20 +0,0 @@
import { describe, expect, it } from 'vitest'
import cases from '../../../internal/ai/harness/testdata/dedupe_cases.json'
import { normalizeName, similarNames } from './names.ts'
// The cases internal/ai/harness's tests run against Normalize and Similar.
describe('dinner names, as the server compares them', () => {
it('has cases to run', () => {
expect(cases.normalize.length).toBeGreaterThan(0)
expect(cases.similar.length).toBeGreaterThan(0)
})
it.each(cases.normalize)('normalizes "$name" to "$want"', ({ name, want }) => {
expect(normalizeName(name)).toBe(want)
})
it.each(cases.similar)('"$a" and "$b" similar: $want', ({ a, b, want }) => {
expect(similarNames(a, b)).toBe(want)
expect(similarNames(b, a)).toBe(want)
})
})
-58
View File
@@ -1,58 +0,0 @@
// The PWA's copy of the server's dinner-name rules (internal/ai/harness
// Normalize and Similar), so a screen can tell beforehand that the dinners
// API would refuse a name as a duplicate. The server still decides.
// internal/ai/harness/testdata/dedupe_cases.json is run against both.
import folds from './letterFolds.json'
// SIMILAR_THRESHOLD is harness.SimilarThreshold.
const SIMILAR_THRESHOLD = 0.85
// fillerWords is harness's fillerWords.
const fillerWords = new Set(['easy', 'best', 'homemade', 'quick', 'simple', 'the', 'a', 'recipe', 'classic'])
// letterFolds is harness's letterFolds: gen_folds.py writes both tables,
// and harness's TestLetterFoldsMatchPWA fails when they differ.
const letterFolds: Record<string, string> = folds
const combiningMark = /\p{Mn}/u
const wordChar = /[\p{L}\p{Nd}]/u
// normalizeName is harness.Normalize: lowercased, accents dropped, letters
// folded, apostrophes removed, other punctuation turned into spaces and
// filler words dropped. "Easy Chicken Tikka-Masala!" is
// "chicken tikka masala".
export function normalizeName(name: string): string {
let out = ''
// Lowercased letter by letter, as Go's strings.ToLower does, so no
// letter is lowered by its context (a final Σ is σ, not ς).
for (const letter of name) {
for (const ch of letter.toLowerCase()) {
if (combiningMark.test(ch) || ch === "'" || ch === '’') continue
const fold = letterFolds[ch]
if (fold !== undefined) out += fold
else if (wordChar.test(ch)) out += ch
else out += ' '
}
}
return out
.split(' ')
.filter((word) => word !== '' && !fillerWords.has(word))
.join(' ')
}
function words(name: string): Set<string> {
const normalized = normalizeName(name)
return new Set(normalized === '' ? [] : normalized.split(' '))
}
// similarNames is harness.Similar: whether a and b name the same dinner, by
// the share of the words of their normalized forms they have in common.
export function similarNames(a: string, b: string): boolean {
const left = words(a)
const right = words(b)
if (left.size === 0 || right.size === 0) return false
let shared = 0
for (const word of left) if (right.has(word)) shared++
return shared / (left.size + right.size - shared) >= SIMILAR_THRESHOLD
}
-26
View File
@@ -1,26 +0,0 @@
import { useRef, useState } from 'react'
// Refusal is one change the server turned down. key is unique to that
// attempt, so two attempts of the same change in flight each keep their own
// refusal. change names what was tried, so trying it again once it has been
// refused clears the earlier refusal and no other.
export type Refusal = { key: number; change: string; text: string }
// useRefusals keeps each change's own refusal: the mutation hooks only
// report their latest change, which would hide a refusal while another
// change is in flight. track(change, what, answer) clears change's earlier
// refusal and records "Could not {what}: …" if answer rejects.
export function useRefusals() {
const [refusals, setRefusals] = useState<Refusal[]>([])
const attempts = useRef(0)
function track(change: string, what: string, answer: Promise<unknown>) {
const key = ++attempts.current
setRefusals((all) => all.filter((refusal) => refusal.change !== change))
answer.catch((error: Error) => {
setRefusals((all) => [...all, { key, change, text: `Could not ${what}: ${error.message}` }])
})
}
return { refusals, track }
}
@@ -1,301 +0,0 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { Route, Routes } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
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 { declaredStyle } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { NotificationsStep, notificationsUnavailable, type EnableNotifications } from './NotificationsStep.tsx'
import styles from './Onboarding.module.css'
const IPHONE_SAFARI =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1'
const COMPLETED_AT = '2026-10-06T21:30:00.000Z'
// requestPermission stands in for the browser's Notification API, which
// jsdom lacks, so a test can prove nothing asks for permission.
let requestPermission: Mock<() => Promise<NotificationPermission>>
beforeEach(() => {
requestPermission = vi.fn<() => Promise<NotificationPermission>>(() => Promise.resolve('granted'))
vi.stubGlobal('Notification', { permission: 'default', requestPermission })
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
Reflect.deleteProperty(navigator, 'maxTouchPoints')
Reflect.deleteProperty(navigator, 'standalone')
localStorage.clear()
})
// A PATCH the fake server holds until the test answers it.
type Held = { body: unknown; answer: (response: Response) => void }
// stubFirstRunServer stands in for the server on first run: signed in,
// onboarding not finished. Each PATCH of the household settings is held in
// `held` until the test answers it.
function stubFirstRunServer() {
const held: Held[] = []
const fetchMock = vi.fn<typeof fetch>((input, init) => {
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/settings/household') {
return Promise.resolve(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }))
}
if (method === 'PATCH' && path === '/api/v1/settings/household') {
return new Promise<Response>((resolve) => {
held.push({ body: JSON.parse(String(init?.body)), answer: resolve })
})
}
throw new Error(`unexpected fetch: ${method} ${path}`)
})
vi.stubGlobal('fetch', fetchMock)
return { fetchMock, held }
}
function completed(request: Held) {
request.answer(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: COMPLETED_AT }))
}
// renderApp renders the whole app at step 3, so the first-run redirect is
// what decides where finishing lands.
async function renderApp() {
mockViewport(390)
const server = stubFirstRunServer()
const api = createApiClient(
fakeLocation('/onboarding/notifications'),
createNetworkStatus(window),
createVerifiedSession(localStorage),
)
render(
<InDataRouter path="/onboarding/notifications">
<App api={api} serviceWorker={undefined} />
</InDataRouter>,
)
await screen.findByRole('heading', { level: 1, name: 'Stay in the loop' })
return server
}
// renderStep renders step 3 alone with the given enableNotifications, and a
// Home that says it was reached. AuthBootstrap reads /api/v1/me for the CSRF
// token every write needs.
async function renderStep(enableNotifications?: EnableNotifications) {
const server = stubFirstRunServer()
render(
<InDataRouter path="/onboarding/notifications">
<ApiProviders>
<AuthBootstrap>
<Routes>
<Route
path="/onboarding/notifications"
element={<NotificationsStep enableNotifications={enableNotifications} />}
/>
<Route path="/" element={<h1>Home</h1>} />
<Route path="/onboarding/regulars" element={<h1>Regulars step</h1>} />
</Routes>
</AuthBootstrap>
</ApiProviders>
</InDataRouter>,
)
await screen.findByRole('heading', { level: 1, name: 'Stay in the loop' })
return server
}
function turnOn() {
return screen.getByRole('button', { name: 'Turn on notifications' })
}
function later() {
return screen.getByRole('button', { name: 'Maybe later' })
}
describe('onboarding step 3', () => {
it('explains what MealQuest notifies about, with all three progress pills filled', async () => {
await renderApp()
expect(screen.getByText('Step 3 of 3')).toBeInTheDocument()
const pills = Array.from(document.getElementsByClassName(styles.pill!))
expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, true, true])
const topics = within(screen.getByRole('list')).getAllByRole('listitem')
expect(topics.map((topic) => topic.textContent)).toEqual([
expect.stringMatching(/^Plan ready/),
expect.stringMatching(/^Supply run ready/),
expect.stringMatching(/^Tonight's dinner/),
expect.stringMatching(/^New Side quests/),
])
expect(turnOn()).toBeEnabled()
expect(later()).toBeEnabled()
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
})
it('never asks for notification permission on render', async () => {
await renderApp()
expect(requestPermission).not.toHaveBeenCalled()
})
it('sizes both actions as tap targets and fixes them to the bottom', async () => {
await renderApp()
expectMinHitArea(turnOn())
expectMinHitArea(later())
expect(declaredStyle(turnOn()).height).toBe('52px')
expect(declaredStyle(later()).height).toBe('52px')
expect(declaredStyle(turnOn().parentElement!.parentElement!).position).toBe('fixed')
})
it('shows no install hint outside iOS Safari', async () => {
await renderApp()
expect(screen.queryByText('Tap Share, then Add to Home Screen to get notifications')).not.toBeInTheDocument()
})
it('shows the install hint in an iOS Safari tab', async () => {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(IPHONE_SAFARI)
Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 })
Object.defineProperty(navigator, 'standalone', { configurable: true, value: false })
vi.stubGlobal('matchMedia', (query: string) => ({ matches: false, media: query }) as MediaQueryList)
await renderStep()
expect(screen.getByRole('note')).toHaveTextContent('Tap Share, then Add to Home Screen to get notifications')
})
it('"Maybe later" finishes onboarding and goes Home, ending the first-run redirect', async () => {
const server = await renderApp()
fireEvent.click(later())
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
expect(turnOn()).toBeDisabled()
expect(later()).toBeDisabled()
completed(server.held[0]!)
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
expect(requestPermission).not.toHaveBeenCalled()
})
it('"Turn on notifications" finishes onboarding and goes Home while notifications are unavailable', async () => {
const server = await renderApp()
fireEvent.click(turnOn())
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(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
expect(requestPermission).not.toHaveBeenCalled()
})
it('"Turn on notifications" asks the injected enableNotifications first, then finishes', async () => {
let answer: (result: 'enabled') => void = () => {}
const enable = vi.fn<EnableNotifications>(
() =>
new Promise((resolve) => {
answer = resolve
}),
)
const server = await renderStep(enable)
expect(enable).not.toHaveBeenCalled()
fireEvent.click(turnOn())
expect(enable).toHaveBeenCalledOnce()
expect(turnOn()).toBeDisabled()
expect(later()).toBeDisabled()
// Onboarding is not finished until enableNotifications has answered.
await Promise.resolve()
expect(server.held).toHaveLength(0)
answer('enabled')
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()
})
it('"Maybe later" does not ask the injected enableNotifications', async () => {
const enable = vi.fn<EnableNotifications>(() => Promise.resolve('enabled'))
const server = await renderStep(enable)
fireEvent.click(later())
await vi.waitFor(() => expect(server.held).toHaveLength(1))
completed(server.held[0]!)
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
expect(enable).not.toHaveBeenCalled()
})
it('says why and stays when enableNotifications fails', async () => {
const server = await renderStep(() => Promise.reject(new Error('push service down')))
fireEvent.click(turnOn())
expect(await screen.findByRole('alert')).toHaveTextContent('Could not turn on notifications: push service down')
expect(server.held).toHaveLength(0)
expect(turnOn()).toBeEnabled()
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
})
it('says why and stays when the server refuses to finish onboarding, then finishes on retry', async () => {
const server = await renderApp()
fireEvent.click(later())
await vi.waitFor(() => expect(server.held).toHaveLength(1))
server.held[0]!.answer(errorResponse(500, 'internal'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not finish setting up: internal message')
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
expect(later()).toBeEnabled()
fireEvent.click(later())
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()
})
})
describe('onboarding step 3 with too few Regulars', () => {
it('says why and offers a way back to add Regulars', async () => {
const server = await renderStep()
fireEvent.click(later())
await vi.waitFor(() => expect(server.held).toHaveLength(1))
server.held[0]!.answer(
jsonResponse(400, {
error: {
code: 'invalid_settings',
message: 'invalid household settings: onboarding_completed: add at least 3 Regulars first (have 2)',
retryable: false,
},
}),
)
expect(await screen.findByRole('alert')).toHaveTextContent(
'Could not finish setting up: invalid household settings: onboarding_completed: add at least 3 Regulars first (have 2)',
)
const addRegulars = screen.getByRole('button', { name: 'Add Regulars' })
expectMinHitArea(addRegulars)
fireEvent.click(addRegulars)
expect(await screen.findByRole('heading', { level: 1, name: 'Regulars step' })).toBeInTheDocument()
})
it('offers no way back for any other refusal', async () => {
const server = await renderStep()
fireEvent.click(later())
await vi.waitFor(() => expect(server.held).toHaveLength(1))
server.held[0]!.answer(errorResponse(500, 'internal'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not finish setting up: internal message')
expect(screen.queryByRole('button', { name: 'Add Regulars' })).not.toBeInTheDocument()
})
})
describe('notificationsUnavailable', () => {
it('answers "unavailable" without asking for permission', async () => {
await expect(notificationsUnavailable()).resolves.toBe('unavailable')
expect(requestPermission).not.toHaveBeenCalled()
})
})
-143
View File
@@ -1,143 +0,0 @@
import { useState } from 'react'
import { CalendarDays, ShoppingBasket, Sparkles, UtensilsCrossed, type LucideIcon } from 'lucide-react'
import { useNavigate } from 'react-router'
import { InstallHint } from '../pwa/InstallHint.tsx'
import { needsMoreRegulars, useCompleteOnboarding } from '../settings/api.ts'
import { Button } from '../ui/Button.tsx'
import { Icon } from '../ui/Icon.tsx'
import { OnboardingStep } from './OnboardingStep.tsx'
import styles from './Onboarding.module.css'
// NotificationsResult is how asking to turn on notifications ended.
export type NotificationsResult = 'enabled' | 'denied' | 'unavailable'
export type EnableNotifications = () => Promise<NotificationsResult>
// TODO(Feature 19): Web Push. Until it is wired, notifications cannot be
// turned on, and nothing asks the browser for permission.
export function notificationsUnavailable(): Promise<NotificationsResult> {
return Promise.resolve('unavailable')
}
const topics: { icon: LucideIcon; name: string; meaning: string }[] = [
{
icon: CalendarDays,
name: 'Plan ready',
meaning: "The week's Draft board is ready for the household to adjust.",
},
{
icon: ShoppingBasket,
name: 'Supply run ready',
meaning: "The week's shopping list, grouped by dinner, is ready to shop.",
},
{
icon: UtensilsCrossed,
name: "Tonight's dinner",
meaning: "A reminder before dinner of what's on the plan tonight.",
},
{
icon: Sparkles,
name: 'New Side quests',
meaning: 'Fresh dinners to Try it or Pass, picked from your Regulars.',
},
]
export type NotificationsStepProps = {
// Asks to turn on notifications when the household taps "Turn on
// notifications" — never on render.
enableNotifications?: EnableNotifications
}
// NotificationsStep is onboarding step 3: what MealQuest notifies about, the
// iOS install hint, and finishing onboarding. Either button records that
// onboarding is finished and goes Home. When the server refuses because the
// household has fewer than three Regulars, the reason shows with a way back
// to step 2 to add them.
export function NotificationsStep({ enableNotifications = notificationsUnavailable }: NotificationsStepProps) {
const navigate = useNavigate()
const complete = useCompleteOnboarding()
const [enabling, setEnabling] = useState(false)
const [problem, setProblem] = useState<string | null>(null)
const [fewRegulars, setFewRegulars] = useState(false)
const busy = enabling || complete.isPending
async function finish() {
try {
await complete.mutateAsync()
} catch (error) {
setProblem(`Could not finish setting up: ${(error as Error).message}`)
setFewRegulars(needsMoreRegulars(error as Error))
return
}
void navigate('/', { replace: true })
}
function clearProblem() {
setProblem(null)
setFewRegulars(false)
}
async function turnOn() {
clearProblem()
setEnabling(true)
try {
await enableNotifications()
} catch (error) {
setProblem(`Could not turn on notifications: ${(error as Error).message}`)
return
} finally {
setEnabling(false)
}
await finish()
}
function later() {
clearProblem()
void finish()
}
return (
<OnboardingStep
step={3}
title="Stay in the loop"
action={
<div className={styles.actions}>
<Button className={styles.action} disabled={busy} onClick={() => void turnOn()}>
Turn on notifications
</Button>
<Button variant="outline" className={styles.action} disabled={busy} onClick={later}>
Maybe later
</Button>
</div>
}
>
<p className={styles.helper}>MealQuest can let the household know when there&apos;s something to look at:</p>
<ul className={styles.terms}>
{topics.map(({ icon, name, meaning }) => (
<li key={name} className={styles.term}>
<span className={styles.termIcon}>
<Icon icon={icon} size="nav" />
</span>
<p className={styles.termText}>
<span className={styles.termName}>{name}</span>
{meaning}
</p>
</li>
))}
</ul>
<div className={styles.hint}>
<InstallHint />
</div>
{problem && (
<p role="alert" className={styles.problem}>
{problem}
</p>
)}
{fewRegulars && (
<Button variant="outline" className={styles.recover} onClick={() => void navigate('/onboarding/regulars')}>
Add Regulars
</Button>
)}
</OnboardingStep>
)
}
-254
View File
@@ -1,254 +0,0 @@
/* Onboarding, handoff mock 1h: a full screen outside the app shell with
the step's action fixed to the bottom. */
.screen {
min-height: 100dvh;
box-sizing: border-box;
/* Room for the fixed 52 px action and its padding. */
padding: var(--space-6) var(--space-4) calc(52px + 2 * var(--space-6));
background: var(--color-bg);
}
.column {
max-width: 560px;
margin: 0 auto;
display: flex;
flex-direction: column;
}
.progress {
display: flex;
gap: 6px;
}
.pill {
flex: 1;
height: 6px;
border-radius: var(--radius-pill);
background: var(--color-neutral-300);
}
.filled {
background: var(--color-accent);
}
.kicker {
margin: 30px 0 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-accent-700);
}
.title {
margin: 8px 0 0;
font-family: var(--font-heading);
font-size: 30px;
font-weight: 400;
line-height: 1.1;
}
.helper {
margin: 12px 0 0;
font-family: var(--font-body);
font-size: 15px;
line-height: 1.45;
color: var(--color-neutral-800);
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: var(--space-4) var(--space-4) var(--space-6);
background: var(--color-bg);
}
.action {
display: flex;
width: 100%;
max-width: 560px;
height: 52px;
margin: 0 auto;
font-size: 17px;
}
/* Step 3: two stacked actions. */
.actions {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
/* Room for the second fixed action and the gap above it. */
.screen:has(.actions) {
padding-bottom: calc(2 * 52px + var(--space-3) + 2 * var(--space-6));
}
/* Step 1: what MealQuest's words mean; step 3: what it notifies about. */
.terms {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin: var(--space-6) 0 0;
padding: 0;
list-style: none;
}
.term {
display: flex;
gap: var(--space-3);
align-items: flex-start;
padding: var(--space-4);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.termIcon {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--radius-pill);
background: var(--color-accent-200);
color: var(--color-accent);
}
.termText {
margin: 0;
font-family: var(--font-body);
font-size: 15px;
line-height: 1.4;
}
.termName {
display: block;
font-weight: 700;
}
/* Step 2: the add pill, with the "+" inside it. */
.add {
position: relative;
margin-top: var(--space-6);
}
/* Beats the TextInput's own padding whatever order the sheets load in. */
.add .addInput {
width: 100%;
padding-right: 52px;
}
/* 44 px hit box around the 36 px grape face, inset 2 px in the 48 px pill. */
.addButton {
position: absolute;
top: 2px;
right: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
cursor: pointer;
}
.addButton:disabled {
cursor: not-allowed;
}
.addFace {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-pill);
background: var(--color-accent);
color: var(--on-accent);
transition: transform 100ms var(--ease-out-soft);
}
.addButton:disabled .addFace {
opacity: 0.45;
}
.addButton:active:not(:disabled) .addFace {
transform: scale(0.97);
}
.groupLabel {
margin: var(--space-6) 0 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 10px 0 0;
padding: 0;
list-style: none;
}
.pending {
opacity: 0.6;
}
.pick {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 44px;
min-height: 44px;
padding: 0 16px;
border: 1.5px dashed var(--color-neutral-400);
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-text);
font-family: var(--font-body);
font-size: 15px;
cursor: pointer;
}
.pick:active {
background: var(--color-neutral-200);
}
.problem {
margin: var(--space-3) 0 0;
font-family: var(--font-body);
font-size: 14px;
color: var(--color-accent-700);
}
/* Step 3: back to step 2 when the server wants more Regulars. */
.recover {
margin-top: var(--space-3);
}
.status {
margin-top: 10px;
}
/* Step 3: the iOS install hint, when it shows. */
.hint {
margin-top: var(--space-4);
}
.hint:empty {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.addFace {
transition: none;
}
}
-52
View File
@@ -1,52 +0,0 @@
import { useEffect } from 'react'
import { Navigate, Outlet, useLocation } from 'react-router'
import { useApi } from '../api/index.ts'
import { LoadingScreen } from '../AuthBootstrap.tsx'
import { useHouseholdSettings } from '../settings/api.ts'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import styles from '../pages/FullScreen.module.css'
export const ONBOARDING_PATH = '/onboarding'
function inOnboarding(pathname: string): boolean {
return pathname === ONBOARDING_PATH || pathname.startsWith(`${ONBOARDING_PATH}/`)
}
// OnboardingGate reads the household settings before any page: while
// onboarding_completed_at is null, every route outside /onboarding
// redirects there.
//
// The service worker never caches the settings, so offline on the saved
// pages (see AuthBootstrap) they cannot be read: the saved pages open as they
// are, since onboarding needs the server anyway, and the settings are read
// again when the browser is back online.
export function OnboardingGate() {
const api = useApi()
const { pathname } = useLocation()
const settings = useHouseholdSettings()
const offline = settings.isError && settings.error instanceof TypeError && api.session.verified()
const { refetch } = settings
useEffect(() => {
if (!offline) return
return api.network.subscribe(() => {
if (!api.network.offline()) void refetch()
})
}, [offline, api, refetch])
if (settings.data !== undefined) {
if (settings.data.onboarding_completed_at === null && !inOnboarding(pathname)) {
return <Navigate to={ONBOARDING_PATH} replace />
}
return <Outlet />
}
if (offline) return <Outlet />
if (settings.isError) {
return (
<main className={styles.screen}>
<ErrorBanner onRetry={() => void refetch()}>MealQuest could not load: {settings.error.message}</ErrorBanner>
</main>
)
}
return <LoadingScreen />
}
-419
View File
@@ -1,419 +0,0 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { createApiClient, createVerifiedSession } from '../api/index.ts'
import App from '../App.tsx'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea, hitArea } from '../test/hitArea.ts'
import styles from './Onboarding.module.css'
afterEach(() => {
vi.unstubAllGlobals()
})
type Dinner = DinnerBody
// A request the fake server holds until the test answers it.
type Held = { method: string; path: string; body: unknown; answer: (response: Response) => void }
// stubDinnersServer stands in for the server a household meets on first
// run: signed in, onboarding not finished and the given Regulars. GET answers at once;
// each POST and DELETE is held in `held` until the test answers it, so the
// test sees the optimistic list first. Once failReads is set, every GET of
// the Regulars fails, so the test sees the list the client keeps by itself.
function stubDinnersServer(regulars: Dinner[] = []) {
const held: Held[] = []
const reads = { fail: false }
const fetchMock: Mock<typeof fetch> = vi.fn<typeof fetch>((input, init) => {
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/settings/household') {
return Promise.resolve(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }))
}
if (method === 'GET' && path === '/api/v1/dinners?status=regular') {
if (reads.fail) return Promise.resolve(errorResponse(500, 'internal'))
return Promise.resolve(jsonResponse(200, { dinners: regulars }))
}
if ((method === 'POST' && path === '/api/v1/dinners') || (method === 'DELETE' && path.startsWith('/api/v1/dinners/'))) {
return new Promise<Response>((resolve) => {
held.push({ method, path, body: init?.body === undefined ? undefined : JSON.parse(String(init.body)), answer: resolve })
})
}
throw new Error(`unexpected fetch: ${method} ${path}`)
})
vi.stubGlobal('fetch', fetchMock)
return { fetchMock, held, regulars, reads }
}
// created answers a held POST /api/v1/dinners as the server does, adding the
// Regular to the list later GETs read.
function created(server: ReturnType<typeof stubDinnersServer>, request: Held, id: string) {
const { name } = request.body as { name: string }
server.regulars.push(dinnerBody(id, name))
request.answer(jsonResponse(201, { ...dinnerBody(id, name, { grounding_status: 'pending' }), grounding_job_id: 'job-1' }))
}
// renderAt renders the whole app, so /me supplies the CSRF token every
// write needs and OnboardingGate reads the household settings.
function renderAt(path: string) {
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<InDataRouter path={path}>
<App api={api} serviceWorker={undefined} />
</InDataRouter>,
)
}
async function regularsStep(regulars: Dinner[] = []) {
const server = stubDinnersServer(regulars)
renderAt('/onboarding/regulars')
// Common picks show once the Regulars have loaded.
await screen.findByRole('heading', { level: 2, name: 'Common picks' })
expect(screen.getByRole('heading', { level: 2, name: `Your regulars · ${regulars.length}` })).toBeInTheDocument()
return server
}
function input() {
return screen.getByRole('textbox', { name: 'Dinner name' })
}
function regularsList() {
return screen.getByRole('heading', { level: 2, name: /^Your regulars/ }).nextElementSibling as HTMLElement
}
function regularNames() {
return within(regularsList())
.queryAllByRole('listitem')
.map((item) => item.textContent)
}
function next() {
return screen.getByRole('button', { name: 'Next: notifications' })
}
describe('onboarding step 1', () => {
it('welcomes the household, explains the words and starts with step 2', async () => {
stubDinnersServer()
renderAt('/onboarding')
expect(await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })).toBeInTheDocument()
expect(screen.getByText('Step 1 of 3')).toBeInTheDocument()
const terms = within(screen.getByRole('list')).getAllByRole('listitem')
expect(terms.map((term) => term.textContent)).toEqual([
expect.stringMatching(/^Regulars/),
expect.stringMatching(/^Side quests/),
expect.stringMatching(/^Quest log/),
])
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: "Let's start" }))
expect(await screen.findByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument()
})
it('fills one progress pill of three', async () => {
stubDinnersServer()
renderAt('/onboarding')
await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })
const pills = Array.from(document.getElementsByClassName(styles.pill!))
expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, false, false])
})
})
describe('onboarding step 2 (mock 1h)', () => {
it('shows the step as the mock does', async () => {
await regularsStep([dinnerBody('d1', 'Beef tacos')])
expect(screen.getByText('Step 2 of 3')).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument()
expect(
screen.getByText('Add the dinners your household makes on repeat. MealQuest builds suggestions from these.'),
).toBeInTheDocument()
expect(input()).toHaveAttribute('placeholder', 'e.g. Chicken enchiladas')
expect(regularNames()).toEqual(['Beef tacos'])
expect(screen.getByRole('button', { name: 'Remove Beef tacos' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 2, name: 'Common picks' })).toBeInTheDocument()
// A common pick the household already has is not offered again.
expect(screen.queryByRole('button', { name: 'Add Beef tacos' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Chili' })).toHaveTextContent('+Chili')
const pills = Array.from(document.getElementsByClassName(styles.pill!))
expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, true, false])
expect(declaredStyle(pills[0]!).height).toBe('6px')
})
it('does not offer a common pick the server would refuse as a duplicate of a Regular', async () => {
await regularsStep([dinnerBody('d1', 'Homemade chili'), dinnerBody('d2', 'Easy Chicken Stir Fry')])
expect(screen.queryByRole('button', { name: 'Add Chili' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Add Chicken stir-fry' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Beef tacos' })).toBeInTheDocument()
})
it('sizes the input pill, "+" button, picks and Next as the mock does', async () => {
await regularsStep()
expect(hitArea(input()).height).toBe(48)
const add = screen.getByRole('button', { name: 'Add Regular' })
expectMinHitArea(add)
expect(declaredStyle(add.firstElementChild!).width).toBe('36px')
expect(declaredStyle(add.firstElementChild!).height).toBe('36px')
expectMinHitArea(screen.getByRole('button', { name: 'Add Chili' }))
expect(declaredStyle(next()).height).toBe('52px')
expect(declaredStyle(next().parentElement!).position).toBe('fixed')
})
it('adds a Regular on Enter, showing it before the server answers', async () => {
const server = await regularsStep()
fireEvent.change(input(), { target: { value: ' Chicken enchiladas ' } })
// Enter in the input submits its form.
fireEvent.submit(input())
expect(input()).toHaveValue('')
await vi.waitFor(() => expect(regularNames()).toEqual(['Chicken enchiladas']))
expect(screen.getByRole('heading', { level: 2, name: 'Your regulars · 1' })).toBeInTheDocument()
// Not removable until the server has given it an id.
expect(screen.queryByRole('button', { name: 'Remove Chicken enchiladas' })).not.toBeInTheDocument()
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]).toMatchObject({ method: 'POST', path: '/api/v1/dinners', body: { name: 'Chicken enchiladas' } })
created(server, server.held[0]!, 'd1')
expect(await screen.findByRole('button', { name: 'Remove Chicken enchiladas' })).toBeInTheDocument()
})
it('adds a Regular with the "+" button', async () => {
const server = await regularsStep()
const add = screen.getByRole('button', { name: 'Add Regular' })
expect(add).toBeDisabled()
fireEvent.change(input(), { target: { value: 'Shrimp scampi' } })
fireEvent.click(add)
await vi.waitFor(() => expect(regularNames()).toEqual(['Shrimp scampi']))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]).toMatchObject({ method: 'POST', body: { name: 'Shrimp scampi' } })
})
it('adds a common pick on tap and stops offering it', async () => {
const server = await regularsStep()
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
await vi.waitFor(() => expect(regularNames()).toEqual(['Chili']))
expect(screen.queryByRole('button', { name: 'Add Chili' })).not.toBeInTheDocument()
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]).toMatchObject({ method: 'POST', path: '/api/v1/dinners', body: { name: 'Chili' } })
})
it('takes an add back out and says why when the server refuses it', async () => {
const server = await regularsStep([dinnerBody('d1', 'Chili')])
fireEvent.change(input(), { target: { value: 'chili' } })
fireEvent.submit(input())
await vi.waitFor(() => expect(regularNames()).toEqual(['Chili', 'chili']))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add chili: duplicate_dinner message')
expect(regularNames()).toEqual(['Chili'])
})
it('says why an add was refused while another add is still unanswered', async () => {
const server = await regularsStep()
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
await vi.waitFor(() => expect(server.held).toHaveLength(2))
expect(regularNames()).toEqual(['Burgers', 'Chili'])
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
expect(regularNames()).toEqual(['Chili'])
// The other add's answer does not hide the refusal.
created(server, server.held[1]!, 'd1')
await screen.findByRole('button', { name: 'Remove Chili' })
expect(screen.getByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
expect(regularNames()).toEqual(['Chili'])
})
it('says why each of two overlapping adds was refused', async () => {
const server = await regularsStep()
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
await vi.waitFor(() => expect(server.held).toHaveLength(2))
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
server.held[1]!.answer(errorResponse(500, 'internal'))
await vi.waitFor(() =>
expect(screen.getAllByRole('alert').map((alert) => alert.textContent)).toEqual([
'Could not add Burgers: duplicate_dinner message',
'Could not add Chili: internal message',
]),
)
expect(regularNames()).toEqual([])
})
it('says why each of two overlapping adds of the same name was refused', async () => {
const server = await regularsStep()
fireEvent.change(input(), { target: { value: 'Lasagne' } })
fireEvent.submit(input())
await vi.waitFor(() => expect(server.held).toHaveLength(1))
fireEvent.change(input(), { target: { value: 'Lasagne' } })
fireEvent.submit(input())
await vi.waitFor(() => expect(server.held).toHaveLength(2))
expect(regularNames()).toEqual(['Lasagne', 'Lasagne'])
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Lasagne: duplicate_dinner message')
expect(regularNames()).toEqual(['Lasagne'])
// The second add's refusal shows beside the first instead of replacing it.
server.held[1]!.answer(errorResponse(500, 'internal'))
await vi.waitFor(() =>
expect(screen.getAllByRole('alert').map((alert) => alert.textContent)).toEqual([
'Could not add Lasagne: duplicate_dinner message',
'Could not add Lasagne: internal message',
]),
)
expect(regularNames()).toEqual([])
})
it('removes a Regular at once and archives it on the server', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
])
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
await vi.waitFor(() => expect(regularNames()).toEqual(['Pizza night']))
expect(screen.getByRole('heading', { level: 2, name: 'Your regulars · 1' })).toBeInTheDocument()
// Removed, it is a common pick again.
expect(screen.getByRole('button', { name: 'Add Beef tacos' })).toBeInTheDocument()
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]).toMatchObject({ method: 'DELETE', path: '/api/v1/dinners/d1' })
})
it('puts a removed Regular back in its place when the server refuses', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
])
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
server.held[0]!.answer(errorResponse(500, 'internal'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
expect(regularNames()).toEqual(['Beef tacos', 'Pizza night'])
})
it('puts a refused removal back and says why while another removal is still unanswered', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
dinnerBody('d3', 'Chili'),
])
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' }))
await vi.waitFor(() => expect(server.held).toHaveLength(2))
expect(regularNames()).toEqual(['Chili'])
server.held[0]!.answer(errorResponse(500, 'internal'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
expect(regularNames()).toEqual(['Beef tacos', 'Chili'])
// The other removal's answer does not hide the refusal.
server.regulars.splice(1, 1)
server.held[1]!.answer(new Response(null, { status: 204 }))
await vi.waitFor(() => expect(server.fetchMock.mock.calls.filter(([path]) => String(path).includes('status=regular'))).toHaveLength(2))
await vi.waitFor(() => expect(regularNames()).toEqual(['Beef tacos', 'Chili']))
expect(screen.getByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
})
it('puts two refused removals back in their places without rereading the list', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
dinnerBody('d3', 'Chili'),
])
server.reads.fail = true
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' }))
await vi.waitFor(() => expect(server.held).toHaveLength(2))
expect(regularNames()).toEqual(['Chili'])
server.held[0]!.answer(errorResponse(500, 'internal'))
await screen.findByRole('alert')
server.held[1]!.answer(errorResponse(500, 'internal'))
await screen.findByText('Could not remove Pizza night: internal message')
// The reread after the last answer fails, so the order is the client's own.
expect(await screen.findByText(/Your Regulars could not load/)).toBeInTheDocument()
expect(screen.getAllByRole('button', { name: /^Remove / }).map((button) => button.getAttribute('aria-label'))).toEqual([
'Remove Beef tacos',
'Remove Pizza night',
'Remove Chili',
])
})
it('enables "Next: notifications" once the server has saved 3 Regulars', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
])
expect(next()).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
await screen.findByRole('heading', { level: 2, name: 'Your regulars · 3' })
// The third chip shows at once, but Next waits for the server.
expect(next()).toBeDisabled()
await vi.waitFor(() => expect(server.held).toHaveLength(1))
created(server, server.held[0]!, 'd3')
await screen.findByRole('button', { name: 'Remove Burgers' })
expect(next()).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' }))
await vi.waitFor(() => expect(next()).toBeDisabled())
await vi.waitFor(() => expect(server.held).toHaveLength(2))
server.held[1]!.answer(errorResponse(500, 'internal'))
await vi.waitFor(() => expect(next()).toBeEnabled())
fireEvent.click(next())
expect(await screen.findByText('Step 3 of 3')).toBeInTheDocument()
})
it('keeps "Next: notifications" disabled when the third add is refused', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
])
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
await screen.findByRole('heading', { level: 2, name: 'Your regulars · 3' })
expect(next()).toBeDisabled()
await vi.waitFor(() => expect(server.held).toHaveLength(1))
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
expect(regularNames()).toEqual(['Beef tacos', 'Pizza night'])
expect(next()).toBeDisabled()
})
it('keeps "Next: notifications" disabled while a fourth add is unanswered', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
dinnerBody('d3', 'Chili'),
])
expect(next()).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
await screen.findByRole('heading', { level: 2, name: 'Your regulars · 4' })
expect(next()).toBeDisabled()
await vi.waitFor(() => expect(server.held).toHaveLength(1))
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
await screen.findByRole('alert')
expect(next()).toBeEnabled()
})
})
-68
View File
@@ -1,68 +0,0 @@
import { Repeat, ScrollText, Sparkles, type LucideIcon } from 'lucide-react'
import { Route, Routes, useNavigate } from 'react-router'
import { Button } from '../ui/Button.tsx'
import { Icon } from '../ui/Icon.tsx'
import { NotificationsStep } from './NotificationsStep.tsx'
import { OnboardingStep } from './OnboardingStep.tsx'
import { RegularsStep } from './RegularsStep.tsx'
import styles from './Onboarding.module.css'
// OnboardingPage is /onboarding and its steps: the welcome, the Regulars
// (/onboarding/regulars) and notifications (/onboarding/notifications).
export function OnboardingPage() {
return (
<Routes>
<Route index element={<WelcomeStep />} />
<Route path="regulars" element={<RegularsStep />} />
<Route path="notifications" element={<NotificationsStep />} />
</Routes>
)
}
const terms: { icon: LucideIcon; name: string; meaning: string }[] = [
{
icon: Repeat,
name: 'Regulars',
meaning: 'The dinners our kitchen makes on repeat. You add a few next.',
},
{
icon: Sparkles,
name: 'Side quests',
meaning: 'New dinners MealQuest suggests from your Regulars. Try it or Pass.',
},
{
icon: ScrollText,
name: 'Quest log',
meaning: 'The Side quests you said Try it to, waiting for a night on the plan.',
},
]
function WelcomeStep() {
const navigate = useNavigate()
return (
<OnboardingStep
step={1}
title="Welcome to MealQuest"
action={
<Button className={styles.action} onClick={() => void navigate('/onboarding/regulars')}>
Let&apos;s start
</Button>
}
>
<p className={styles.helper}>MealQuest plans the household&apos;s dinners around what you already love to cook.</p>
<ul className={styles.terms}>
{terms.map(({ icon, name, meaning }) => (
<li key={name} className={styles.term}>
<span className={styles.termIcon}>
<Icon icon={icon} size="nav" />
</span>
<p className={styles.termText}>
<span className={styles.termName}>{name}</span>
{meaning}
</p>
</li>
))}
</ul>
</OnboardingStep>
)
}
-35
View File
@@ -1,35 +0,0 @@
import type { ReactNode } from 'react'
import styles from './Onboarding.module.css'
export const ONBOARDING_STEPS = 3
export type OnboardingStepProps = {
// 1-based; the progress pills fill up to it.
step: number
title: string
children: ReactNode
// The step's primary action, fixed to the bottom of the screen.
action?: ReactNode
}
// OnboardingStep is the frame every onboarding step shares: progress pills,
// the "Step N of 3" kicker and the Bagel title.
export function OnboardingStep({ step, title, children, action }: OnboardingStepProps) {
return (
<main className={styles.screen}>
<div className={styles.column}>
<div className={styles.progress} aria-hidden="true">
{Array.from({ length: ONBOARDING_STEPS }, (_, i) => (
<span key={i} className={[styles.pill, i < step && styles.filled].filter(Boolean).join(' ')} />
))}
</div>
<p className={styles.kicker}>
Step {step} of {ONBOARDING_STEPS}
</p>
<h1 className={styles.title}>{title}</h1>
{children}
</div>
{action && <div className={styles.footer}>{action}</div>}
</main>
)
}
-147
View File
@@ -1,147 +0,0 @@
import { useState, type FormEvent } from 'react'
import { Plus } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useAddRegular, useRegulars, useRemoveRegular, type Regular } from '../dinners/api.ts'
import { similarNames } from '../dinners/names.ts'
import { useRefusals } from '../dinners/useRefusals.ts'
import { Button } from '../ui/Button.tsx'
import { Chip } from '../ui/Chip.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { TextInput } from '../ui/TextInput.tsx'
import { OnboardingStep } from './OnboardingStep.tsx'
import styles from './Onboarding.module.css'
// MIN_REGULARS is how many Regulars the server has saved before the
// household moves on.
export const MIN_REGULARS = 3
// commonPicks are offered as one-tap Regulars until the household has them,
// or a Regular the server would count as the same dinner.
export const commonPicks = [
'Beef tacos',
'Spaghetti bolognese',
'Chicken stir-fry',
'Pizza night',
'Chili',
'Pot roast',
'Burgers',
'Fried rice',
]
// RegularsStep is onboarding step 2, handoff mock 1h: the household adds
// the dinners it makes on repeat. Each add and remove shows at once and is
// undone if the server refuses it.
export function RegularsStep() {
const navigate = useNavigate()
const regulars = useRegulars()
const add = useAddRegular()
const remove = useRemoveRegular()
const [draft, setDraft] = useState('')
const { refusals, track } = useRefusals()
const list = regulars.data ?? []
const picks = commonPicks.filter((pick) => !list.some((regular) => similarNames(regular.name, pick)))
// Next waits for every add to be answered, so a refused add's error shows
// here and an add the server takes back never counts towards the three.
const adding = list.some((regular) => regular.pending)
const saved = list.filter((regular) => !regular.pending).length
function addRegular(name: string) {
track(`add:${name.toLowerCase()}`, `add ${name}`, add.mutateAsync({ name }))
}
function removeRegular(regular: Regular) {
track(`remove:${regular.id}`, `remove ${regular.name}`, remove.mutateAsync(regular))
}
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const name = draft.trim()
if (name === '') return
addRegular(name)
setDraft('')
}
return (
<OnboardingStep
step={2}
title="What's on your usual rotation?"
action={
<Button
className={styles.action}
disabled={adding || saved < MIN_REGULARS}
onClick={() => void navigate('/onboarding/notifications')}
>
Next: notifications
</Button>
}
>
<p className={styles.helper}>
Add the dinners your household makes on repeat. MealQuest builds suggestions from these.
</p>
<form className={styles.add} onSubmit={onSubmit}>
<TextInput
label="Dinner name"
labelHidden
className={styles.addInput}
placeholder="e.g. Chicken enchiladas"
autoComplete="off"
enterKeyHint="done"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<button type="submit" className={styles.addButton} aria-label="Add Regular" disabled={draft.trim() === ''}>
<span className={styles.addFace}>
<Icon icon={Plus} size="nav" />
</span>
</button>
</form>
{refusals.map((refusal) => (
<p key={refusal.key} role="alert" className={styles.problem}>
{refusal.text}
</p>
))}
<h2 className={styles.groupLabel}>Your regulars · {list.length}</h2>
{regulars.isPending && <Skeleton className={styles.status} />}
{regulars.isError && (
<div className={styles.status}>
<ErrorBanner onRetry={() => void regulars.refetch()}>
Your Regulars could not load: {regulars.error.message}
</ErrorBanner>
</div>
)}
{list.length > 0 && (
<ul className={styles.chips}>
{list.map((regular: Regular) => (
<li key={regular.id}>
<Chip
label={regular.name}
className={regular.pending && styles.pending}
aria-busy={regular.pending}
onRemove={regular.pending ? undefined : () => removeRegular(regular)}
/>
</li>
))}
</ul>
)}
{regulars.isSuccess && picks.length > 0 && (
<>
<h2 className={styles.groupLabel}>Common picks</h2>
<ul className={styles.chips}>
{picks.map((pick) => (
<li key={pick}>
<button type="button" className={styles.pick} aria-label={`Add ${pick}`} onClick={() => addRegular(pick)}>
<span aria-hidden="true">+</span>
{pick}
</button>
</li>
))}
</ul>
</>
)}
</OnboardingStep>
)
}
@@ -1,302 +0,0 @@
/* Settings · Regular dinners. */
.page {
box-sizing: border-box;
min-height: 100%;
max-width: 560px;
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 6px;
}
.header {
display: flex;
align-items: center;
gap: 16px;
}
.kicker {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-accent-700);
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 28px;
font-weight: 400;
}
.shared {
margin: 0;
padding: 14px 16px;
border-radius: 26px;
background: var(--color-accent-2-100);
color: var(--color-accent-2-900);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
}
/* The add form: the add row, then the name field a pasted link needs. */
.addForm {
display: flex;
flex-direction: column;
gap: 8px;
}
/* The add field: the input fills the row beside the grape + button. */
.add {
display: flex;
align-items: center;
gap: 8px;
}
.add > :first-child {
flex: 1;
min-width: 0;
}
.add .addInput {
width: 100%;
}
.loading {
height: 320px;
border-radius: 26px;
}
.section {
display: flex;
flex-direction: column;
gap: 10px;
}
.groupLabel {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
.card {
padding: 6px;
border: 1px solid var(--color-neutral-300);
}
.rows {
margin: 0;
padding: 0;
list-style: none;
}
.item + .item {
border-top: 1px solid var(--color-divider);
}
.pending {
opacity: 0.6;
}
.photo {
width: 48px;
height: 48px;
}
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
margin-top: 4px;
}
/* Beat the Tag's own tones whatever order the sheets load in. */
.meta .finding {
background: var(--color-neutral-200);
color: var(--color-neutral-700);
}
.meta .found {
background: var(--color-accent-2-200);
color: var(--color-accent-2-800);
}
.meta .needs {
background: var(--color-accent-100);
color: var(--color-accent-800);
}
.filled {
color: var(--color-accent);
fill: currentColor;
}
.hint,
.problem,
.status {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.4;
}
.hint {
color: var(--color-neutral-700);
}
.problem {
font-weight: 700;
color: var(--color-accent-700);
}
.status {
color: var(--color-accent-2-800);
}
.status:empty {
display: none;
}
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 560px;
margin: 0 auto;
}
.sheetTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.form {
display: flex;
flex-direction: column;
gap: 14px;
}
.columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
align-items: end;
}
/* Beside a dropdown, a minutes field matches its height. */
.columns .paired {
width: 100%;
height: 56px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
.textarea {
box-sizing: border-box;
width: 100%;
min-height: 120px;
padding: 14px 18px;
border: 1px solid var(--color-neutral-300);
border-radius: 22px;
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-body);
font-size: 15px;
line-height: 1.5;
resize: vertical;
}
.actions {
display: grid;
gap: 10px;
}
.actions > button {
min-height: 52px;
}
.candidatesLoading {
height: 200px;
border-radius: 22px;
}
.candidates {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.candidate {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-width: 44px;
min-height: 72px;
padding: 8px 16px 8px 8px;
border: 1px solid var(--color-neutral-300);
border-radius: 22px;
background: var(--color-surface);
color: var(--color-text);
text-align: start;
cursor: pointer;
}
.candidate:hover {
background: var(--color-neutral-200);
}
.candidatePhoto {
flex: none;
width: 56px;
height: 56px;
}
.candidateBody {
display: flex;
flex-direction: column;
min-width: 0;
}
.candidateTitle {
font-family: var(--font-body);
font-size: 15px;
font-weight: 700;
}
.candidateSite {
font-family: var(--font-body);
font-size: 12px;
font-weight: 600;
color: var(--color-neutral-700);
}
/* A form locked while its save is pending. It lays out as if it were not
there. */
.lock {
display: contents;
}
@@ -1,591 +0,0 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { Route, Routes } from 'react-router'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { GROUNDING_POLL_MS } from '../dinners/api.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { RegularsSettingsPage } from './RegularsSettingsPage.tsx'
const REGULARS = '/api/v1/dinners?status=regular'
type Responder = (body: unknown) => Response | Promise<Response>
// stubServer stands in for the dinners API: GET of the Regulars answers
// the current server.regulars, so a test changes what the next read sees;
// every other request is answered by responders, keyed "METHOD path" and
// called with the request's decoded JSON body. Any other request fails the
// test.
function stubServer(regulars: DinnerBody[], responders: Record<string, Responder> = {}) {
const server = { regulars, fetchMock: undefined as unknown as Mock<typeof fetch> }
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
[`GET ${REGULARS}`]: () => jsonResponse(200, { dinners: server.regulars }),
...responders,
}
server.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}`)
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined
return Promise.resolve(respond(body))
})
vi.stubGlobal('fetch', server.fetchMock)
return server
}
// sent lists the requests other than GETs, as "METHOD path" and body.
function sent(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
.map(([input, init]) => ({
request: `${init?.method} ${String(input)}`,
body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined,
}))
}
function reads(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === REGULARS)
.length
}
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
// renderPage loads /me (for the CSRF token every write needs) and renders
// the page at /settings/regulars once its Regulars have loaded.
async function renderPage() {
const api = createApiClient(fakeLocation('/settings/regulars'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings/regulars">
<Routes>
<Route path="/settings/regulars" element={<RegularsSettingsPage />} />
</Routes>
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
await screen.findByRole('heading', { level: 2, name: /^On rotation/ })
}
const TACOS = dinnerBody('d1', 'Beef tacos', {
method: 'stovetop',
minutes_total: 40,
difficulty: 'easy',
is_favorite: true,
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 ROAST = dinnerBody('d4', 'Pot roast', { method: 'oven', grounding_status: 'manual' })
function rows() {
return within(screen.getByRole('list', { name: 'Regulars' })).queryAllByRole('listitem')
}
function row(name: string) {
const item = rows().find((li) => within(li).queryByText(name) !== null)
if (item === undefined) throw new Error(`no Regular row named ${name}`)
return item
}
function addField() {
return screen.getByRole('textbox', { name: 'Add a Regular' })
}
function add(text: string) {
fireEvent.change(addField(), { target: { value: text } })
fireEvent.click(screen.getByRole('button', { name: 'Add Regular' }))
}
function openRow(name: string) {
fireEvent.click(within(row(name)).getByRole('button', { name: new RegExp(`^${name}`) }))
}
describe('RegularsSettingsPage', () => {
it('lists each Regular with its photo, method and minutes, favorite heart and grounding badge', async () => {
stubServer([TACOS, CHILI, PIZZA, ROAST])
await renderPage()
expect(screen.getByRole('heading', { level: 1, name: 'Regular dinners' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 4' })).toBeInTheDocument()
expect(rows()).toHaveLength(4)
const tacos = within(row('Beef tacos'))
expect(tacos.getByRole('img', { name: 'Beef tacos' })).toHaveAttribute('src', '/media/photos/p1-400.jpg')
expect(tacos.getByText('Stovetop · 40 min')).toBeInTheDocument()
expect(tacos.getByText('Recipe found')).toBeInTheDocument()
expect(tacos.getByRole('button', { name: 'Favorite Beef tacos' })).toHaveAttribute('aria-pressed', 'true')
const chili = within(row('Chili'))
expect(chili.getByText('Crock pot · 480 min')).toBeInTheDocument()
expect(chili.getByText('Finding recipe…')).toBeInTheDocument()
expect(chili.getByRole('button', { name: 'Favorite Chili' })).toHaveAttribute('aria-pressed', 'false')
const pizza = within(row('Pizza night'))
// No photo yet: the striped placeholder stands in.
expect(pizza.getByRole('img', { name: 'Pizza night' })).toHaveTextContent('dish photo')
expect(pizza.getByText('Needs a recipe')).toBeInTheDocument()
expect(within(row('Pot roast')).getByText('Oven')).toBeInTheDocument()
expect(within(row('Pot roast')).getByText('Recipe found')).toBeInTheDocument()
})
it('keeps every control at least 44 px', async () => {
stubServer([TACOS])
await renderPage()
expectMinHitArea(screen.getByRole('button', { name: 'Add Regular' }))
expectMinHitArea(screen.getByRole('button', { name: 'Favorite Beef tacos' }))
expectMinHitArea(within(row('Beef tacos')).getByRole('button', { name: /^Beef tacos/ }))
expectMinHitArea(screen.getByRole('button', { name: 'Back to Settings' }))
})
it('says so when there are no Regulars yet', async () => {
stubServer([])
await renderPage()
expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 0' })).toBeInTheDocument()
expect(screen.getByText(/No Regulars yet/)).toBeInTheDocument()
})
it('reports Regulars that could not load, and retries', async () => {
const server = stubServer([], { [`GET ${REGULARS}`]: () => errorResponse(500, 'internal') })
const api = createApiClient(fakeLocation('/settings/regulars'), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings/regulars">
<RegularsSettingsPage />
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
expect(await screen.findByRole('alert')).toHaveTextContent('Your Regulars could not load: internal message')
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
})
it('adds a Regular by name, showing it at once and finding its recipe', async () => {
const server = stubServer([TACOS], {
'POST /api/v1/dinners': (body) => {
const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' })
server.regulars = [...server.regulars, created]
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
},
})
await renderPage()
add(' Chicken enchiladas ')
expect(addField()).toHaveValue('')
expect(await screen.findByText('Chicken enchiladas')).toBeInTheDocument()
expect(within(row('Chicken enchiladas')).getByText('Finding recipe…')).toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual([{ request: 'POST /api/v1/dinners', body: { name: 'Chicken enchiladas' } }])
await waitFor(() => expect(row('Chicken enchiladas')).not.toHaveAttribute('aria-busy'))
expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
})
it('adds a Regular from a pasted recipe link under the name the household gives it', async () => {
const link = 'https://example.com/recipes/stew-key-abcdefghijklmno'
const server = stubServer([], {
'POST /api/v1/dinners': (body) => {
const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' })
server.regulars = [created]
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
},
})
await renderPage()
fireEvent.change(addField(), { target: { value: ` ${link} ` } })
// A link alone cannot be added: it never names a dinner.
expect(screen.getByRole('button', { name: 'Add Regular' })).toBeDisabled()
const name = screen.getByRole('textbox', { name: 'Dinner name' })
expect(name).toHaveAccessibleDescription(/never names a dinner/)
fireEvent.change(name, { target: { value: ' Sunday stew ' } })
fireEvent.click(screen.getByRole('button', { name: 'Add Regular' }))
expect(addField()).toHaveValue('')
expect(screen.queryByRole('textbox', { name: 'Dinner name' })).not.toBeInTheDocument()
expect(await screen.findByText('Sunday stew')).toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual([
{ request: 'POST /api/v1/dinners', body: { name: 'Sunday stew', recipe_url: link } },
])
expect(within(row('Sunday stew')).getByText('Finding recipe…')).toBeInTheDocument()
expect(document.body).not.toHaveTextContent(/example\.com|abcdefghijklmno|key/i)
})
it('takes back an add the server refuses and says why', async () => {
stubServer([TACOS], { 'POST /api/v1/dinners': () => errorResponse(409, 'duplicate_dinner') })
await renderPage()
add('Beef tacos!')
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Beef tacos!: duplicate_dinner message')
expect(rows()).toHaveLength(1)
})
it('toggles the favorite heart', async () => {
const server = stubServer([TACOS], {
'POST /api/v1/dinners/d1/favorite': () => {
server.regulars = [{ ...TACOS, is_favorite: false }]
return jsonResponse(200, server.regulars[0])
},
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Favorite Beef tacos' }))
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Favorite Beef tacos' })).toHaveAttribute('aria-pressed', 'false'),
)
expect(sent(server.fetchMock)).toEqual([{ request: 'POST /api/v1/dinners/d1/favorite', body: undefined }])
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
expect(screen.getByRole('button', { name: 'Favorite Beef tacos' })).toHaveAttribute('aria-pressed', 'false')
})
it('edits a Regular: name, method, minutes and difficulty', async () => {
const server = stubServer([TACOS], {
'PATCH /api/v1/dinners/d1': (body) => {
const edited = { ...TACOS, ...(body as object) }
server.regulars = [edited]
return jsonResponse(200, edited)
},
})
await renderPage()
openRow('Beef tacos')
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
expect(sheet.getByLabelText('Name')).toHaveValue('Beef tacos')
expect(sheet.getByLabelText('Method')).toHaveValue('stovetop')
expect(sheet.getByLabelText('Minutes')).toHaveValue(40)
expect(sheet.getByLabelText('Difficulty')).toHaveValue('easy')
expect(sheet.getByRole('button', { name: 'Save changes' })).toBeDisabled()
fireEvent.change(sheet.getByLabelText('Name'), { target: { value: 'Fish tacos' } })
fireEvent.change(sheet.getByLabelText('Method'), { target: { value: 'grill' } })
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '25' } })
fireEvent.change(sheet.getByLabelText('Difficulty'), { target: { value: 'medium' } })
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(sent(server.fetchMock)).toEqual([
{
request: 'PATCH /api/v1/dinners/d1',
body: { name: 'Fish tacos', method: 'grill', minutes_total: 25, difficulty: 'medium' },
},
])
expect(within(row('Fish tacos')).getByText('Grill · 25 min')).toBeInTheDocument()
})
it('sends only the changed fields, refuses bad minutes and shows a refused edit', async () => {
const server = stubServer([TACOS], { 'PATCH /api/v1/dinners/d1': () => errorResponse(400, 'invalid_dinner') })
await renderPage()
openRow('Beef tacos')
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '0' } })
expect(sheet.getByText('Minutes must be a whole number from 1 to 1440.')).toBeInTheDocument()
expect(sheet.getByRole('button', { name: 'Save changes' })).toBeDisabled()
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '45' } })
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
expect(await sheet.findByRole('alert')).toHaveTextContent('Not saved: invalid_dinner message')
expect(sent(server.fetchMock)).toEqual([{ request: 'PATCH /api/v1/dinners/d1', body: { minutes_total: 45 } }])
})
it('removes a Regular only once confirmed', async () => {
const server = stubServer([TACOS, ROAST], {
'DELETE /api/v1/dinners/d1': () => {
server.regulars = [ROAST]
return jsonResponse(200, { ...TACOS, status: 'archived' })
},
})
await renderPage()
openRow('Beef tacos')
fireEvent.click(screen.getByRole('button', { name: 'Remove from Regulars' }))
const confirm = within(screen.getByRole('dialog', { name: 'Remove Beef tacos?' }))
fireEvent.click(confirm.getByRole('button', { name: 'Keep it' }))
expect(screen.getByRole('dialog', { name: 'Edit Beef tacos' })).toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual([])
fireEvent.click(screen.getByRole('button', { name: 'Remove from Regulars' }))
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await waitFor(() => expect(rows()).toHaveLength(1))
expect(within(rows()[0]!).getByText('Pot roast')).toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual([{ request: 'DELETE /api/v1/dinners/d1', body: undefined }])
})
it('puts a refused removal back and says why', async () => {
stubServer([TACOS], { 'DELETE /api/v1/dinners/d1': () => errorResponse(500, 'internal') })
await renderPage()
openRow('Beef tacos')
fireEvent.click(screen.getByRole('button', { name: 'Remove from Regulars' }))
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
expect(rows()).toHaveLength(1)
})
describe('Fix recipe', () => {
const CANDIDATES = {
candidates: [
{
candidate_id: 'https://example.com/pizza',
title: 'Weeknight pizza',
site_name: 'Example Kitchen',
photo_url: '/media/photos/p7-400.jpg',
minutes: 45,
},
{ candidate_id: 'https://cook.test/pizza', title: 'Sheet-pan pizza', site_name: 'Cook Test', photo_url: null, minutes: null },
{ candidate_id: 'https://food.test/p', title: 'Pizza margherita', site_name: 'Food Test', photo_url: null, minutes: 30 },
],
}
function grounded(server: { regulars: DinnerBody[] }, overrides: Partial<DinnerBody>) {
const fixed = { ...PIZZA, grounding_error: null, ...overrides }
server.regulars = [fixed]
return jsonResponse(200, fixed)
}
function fixSheet() {
return within(screen.getByRole('dialog', { name: 'Fix recipe' }))
}
it('opens from a Needs a recipe row and picks one of up to three matches', async () => {
const server = stubServer([PIZZA], {
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
'POST /api/v1/dinners/d3/ground': () =>
grounded(server, { grounding_status: 'grounded', photo_url: '/media/photos/p7-400.jpg', method: 'oven', minutes_total: 45 }),
})
await renderPage()
openRow('Pizza night')
const matches = within(await fixSheet().findByRole('list', { name: 'Recipe matches' }))
const options = matches.getAllByRole('button')
expect(options.map((option) => option.getAttribute('aria-label'))).toEqual([
'Use Weeknight pizza from Example Kitchen',
'Use Sheet-pan pizza from Cook Test',
'Use Pizza margherita from Food Test',
])
expect(within(options[0]!).getByRole('img', { name: 'Weeknight pizza' })).toHaveAttribute('src', '/media/photos/p7-400.jpg')
expect(within(options[0]!).getByText('Example Kitchen')).toBeInTheDocument()
expectMinHitArea(options[0]!)
fireEvent.click(options[0]!)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(sent(server.fetchMock)).toEqual([
{ request: 'POST /api/v1/dinners/d3/ground', body: { candidate_id: 'https://example.com/pizza' } },
])
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
expect(within(row('Pizza night')).getByText('Oven · 45 min')).toBeInTheDocument()
})
it('says so when no match was found', async () => {
stubServer([PIZZA], { 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, { candidates: [] }) })
await renderPage()
openRow('Pizza night')
expect(await fixSheet().findByText('No matches were found. Paste a recipe link or type it in.')).toBeInTheDocument()
})
it('grounds a pasted recipe link, and says why a link was refused', async () => {
let answers = 0
const server = stubServer([PIZZA], {
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
'POST /api/v1/dinners/d3/ground': () =>
++answers === 1 ? errorResponse(422, 'ungroundable') : grounded(server, { grounding_status: 'grounded' }),
})
await renderPage()
openRow('Pizza night')
fireEvent.click(fixSheet().getByRole('radio', { name: 'Paste a link' }))
fireEvent.change(fixSheet().getByLabelText('Recipe link'), { target: { value: ' https://example.com/blog/pizza ' } })
fireEvent.click(fixSheet().getByRole('button', { name: 'Use this recipe' }))
expect(await fixSheet().findByRole('alert')).toHaveTextContent('Not fixed: ungroundable message')
fireEvent.change(fixSheet().getByLabelText('Recipe link'), { target: { value: 'https://example.com/pizza-2' } })
fireEvent.click(fixSheet().getByRole('button', { name: 'Use this recipe' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(sent(server.fetchMock)).toEqual([
{ request: 'POST /api/v1/dinners/d3/ground', body: { recipe_url: 'https://example.com/blog/pizza' } },
{ request: 'POST /api/v1/dinners/d3/ground', body: { recipe_url: 'https://example.com/pizza-2' } },
])
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
})
it('saves ingredients and steps typed in', async () => {
const server = stubServer([PIZZA], {
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
'POST /api/v1/dinners/d3/ground': () =>
grounded(server, { grounding_status: 'manual', method: 'oven', minutes_total: 35 }),
})
await renderPage()
openRow('Pizza night')
fireEvent.click(fixSheet().getByRole('radio', { name: 'Type it in' }))
const save = fixSheet().getByRole('button', { name: 'Save recipe' })
expect(save).toBeDisabled()
fireEvent.change(fixSheet().getByLabelText('Ingredients, one per line'), {
target: { value: '1 pizza dough\n\n 1 cup tomato sauce \n2 cups mozzarella' },
})
fireEvent.change(fixSheet().getByLabelText('Steps, one per line'), {
target: { value: 'Heat the oven to 250°C.\nTop the dough and bake 12 minutes.\n' },
})
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: 'oven' } })
fireEvent.change(fixSheet().getByLabelText('Minutes'), { target: { value: '35' } })
fireEvent.click(save)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(sent(server.fetchMock)).toEqual([
{
request: 'POST /api/v1/dinners/d3/ground',
body: {
manual: {
ingredients: ['1 pizza dough', '1 cup tomato sauce', '2 cups mozzarella'],
steps: ['Heat the oven to 250°C.', 'Top the dough and bake 12 minutes.'],
minutes: 35,
method: 'oven',
},
},
},
])
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
expect(within(row('Pizza night')).getByText('Oven · 35 min')).toBeInTheDocument()
})
it('saves a typed-in recipe only once its method and minutes are set', async () => {
const server = stubServer([PIZZA], {
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
})
await renderPage()
openRow('Pizza night')
fireEvent.click(fixSheet().getByRole('radio', { name: 'Type it in' }))
const save = fixSheet().getByRole('button', { name: 'Save recipe' })
fireEvent.change(fixSheet().getByLabelText('Ingredients, one per line'), { target: { value: '1 pizza dough' } })
expect(fixSheet().getByText('Choose a method and minutes to save.')).toBeInTheDocument()
expect(save).toBeDisabled()
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: 'oven' } })
expect(save).toBeDisabled()
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: '' } })
fireEvent.change(fixSheet().getByLabelText('Minutes'), { target: { value: '35' } })
expect(save).toBeDisabled()
// Even a submit that skips the button sends nothing.
fireEvent.submit(save.closest('form')!)
expect(sent(server.fetchMock)).toEqual([])
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: 'oven' } })
expect(fixSheet().queryByText('Choose a method and minutes to save.')).not.toBeInTheDocument()
expect(save).toBeEnabled()
})
it('switches to editing the Regular', async () => {
stubServer([PIZZA], { 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES) })
await renderPage()
openRow('Pizza night')
fireEvent.click(fixSheet().getByRole('button', { name: 'Edit or remove Pizza night' }))
expect(screen.getByRole('dialog', { name: 'Edit Pizza night' })).toBeInTheDocument()
})
})
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.
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
const server = stubServer([TACOS, CHILI])
await renderPage()
expect(within(row('Chili')).getByText('Finding recipe…')).toBeInTheDocument()
expect(reads(server.fetchMock)).toBe(1)
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
expect(within(row('Chili')).getByText('Finding recipe…')).toBeInTheDocument()
server.regulars = [TACOS, { ...CHILI, grounding_status: 'grounded', photo_url: '/media/photos/p2-400.jpg' }]
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
expect(await within(row('Chili')).findByText('Recipe found')).toBeInTheDocument()
expect(within(row('Chili')).getByRole('img', { name: 'Chili' })).toHaveAttribute('src', '/media/photos/p2-400.jpg')
expect(reads(server.fetchMock)).toBe(3)
// Nothing is pending any more, so nothing is read again.
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
expect(reads(server.fetchMock)).toBe(3)
})
// held answers a request only when release is called, so a test can look
// at the page while a save is in flight.
function held(answer: () => Response) {
let release = () => {}
const response = new Promise<Response>((resolve) => {
release = () => resolve(answer())
})
return { respond: () => response, release: () => release() }
}
it('pauses the refresh while an edit is being saved, then resumes', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
const edited = { ...TACOS, name: 'Fish tacos' }
const patch = held(() => jsonResponse(200, edited))
const server = stubServer([TACOS, CHILI], { 'PATCH /api/v1/dinners/d1': patch.respond })
await renderPage()
openRow('Beef tacos')
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
fireEvent.change(sheet.getByLabelText('Name'), { target: { value: 'Fish tacos' } })
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
await waitFor(() => expect(sent(server.fetchMock)).toHaveLength(1))
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
expect(reads(server.fetchMock)).toBe(1)
server.regulars = [edited, CHILI]
patch.release()
// Settling reads the list once, then the refresh carries on.
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
await waitFor(() => expect(reads(server.fetchMock)).toBe(3))
})
it('pauses the refresh while a recipe fix is being saved, then resumes', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
const fixed = { ...PIZZA, grounding_status: 'grounded' as const, grounding_error: null }
const ground = held(() => jsonResponse(200, fixed))
const server = stubServer([CHILI, PIZZA], {
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, { candidates: [] }),
'POST /api/v1/dinners/d3/ground': ground.respond,
})
await renderPage()
openRow('Pizza night')
const sheet = within(screen.getByRole('dialog', { name: 'Fix recipe' }))
fireEvent.click(sheet.getByRole('radio', { name: 'Paste a link' }))
fireEvent.change(sheet.getByLabelText('Recipe link'), { target: { value: 'https://example.com/pizza' } })
fireEvent.click(sheet.getByRole('button', { name: 'Use this recipe' }))
await waitFor(() => expect(sent(server.fetchMock)).toHaveLength(1))
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
expect(reads(server.fetchMock)).toBe(1)
server.regulars = [CHILI, fixed]
ground.release()
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
await waitFor(() => expect(reads(server.fetchMock)).toBe(3))
})
it('does not poll when no Regular is finding its recipe', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
const server = stubServer([TACOS, PIZZA])
await renderPage()
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
expect(reads(server.fetchMock)).toBe(1)
})
})
-641
View File
@@ -1,641 +0,0 @@
import { ChevronLeft, Heart, Plus } from 'lucide-react'
import { useId, useState, type FormEvent, type ReactNode } from 'react'
import { useNavigate } from 'react-router'
import {
DIFFICULTIES,
METHODS,
useAddRegular,
useEditRegular,
useGroundRegular,
useRecipeCandidates,
useRegulars,
useRemoveRegular,
useToggleFavorite,
type Difficulty,
type GroundingStatus,
type ManualRecipe,
type Method,
type NewRegular,
type RecipeFix,
type Regular,
type RegularPatch,
} from '../dinners/api.ts'
import { useRefusals } from '../dinners/useRefusals.ts'
import { Button } from '../ui/Button.tsx'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { ListRow } from '../ui/ListRow.tsx'
import { Photo } from '../ui/Photo.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Select } from '../ui/Select.tsx'
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 styles from './RegularsSettingsPage.module.css'
// The most minutes a dinner may take, as the dinners API allows: a day.
const MAX_MINUTES = 1440
export const methodLabels: Record<Method, string> = {
stovetop: 'Stovetop',
oven: 'Oven',
crockpot: 'Crock pot',
grill: 'Grill',
other: 'Other',
}
const difficultyLabels: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
// A pasted link is added as a recipe link under a name the household gives;
// anything else is a dinner name.
const LINK = /^https?:\/\//i
// Settings · Regular dinners: the household's Regulars, each with its photo,
// 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.
export function RegularsSettingsPage() {
const navigate = useNavigate()
return (
<div className={styles.page}>
<header className={styles.header}>
<IconButton label="Back to Settings" onClick={() => void navigate('/settings')}>
<Icon icon={ChevronLeft} size="nav" />
</IconButton>
<div>
<p className={styles.kicker}>Settings</p>
<h1 className={styles.title}>Regular dinners</h1>
</div>
</header>
<p className={styles.shared}>Changes apply to everyone in the household.</p>
<RegularsManager />
</div>
)
}
// Which sheet is open, for which Regular.
type Open = { sheet: 'edit' | 'fix'; id: string } | null
function RegularsManager() {
const regulars = useRegulars()
const add = useAddRegular()
const remove = useRemoveRegular()
const favorite = useToggleFavorite()
const { refusals, track } = useRefusals()
const [draft, setDraft] = useState('')
const [linkName, setLinkName] = useState('')
const [open, setOpen] = useState<Open>(null)
const headingId = useId()
const linkHintId = useId()
const isLink = LINK.test(draft.trim())
const ready = draft.trim() !== '' && (!isLink || linkName.trim() !== '')
const list = regulars.data ?? []
// A Regular removed meanwhile closes its sheet.
const opened = open && list.find((regular) => regular.id === open.id && !regular.pending)
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!ready) return
const text = draft.trim()
const body: NewRegular = isLink ? { name: linkName.trim(), recipe_url: text } : { name: text }
track(`add:${body.name.toLowerCase()}`, `add ${body.name}`, add.mutateAsync(body))
setDraft('')
setLinkName('')
}
function onRemove(regular: Regular) {
setOpen(null)
track(`remove:${regular.id}`, `remove ${regular.name}`, remove.mutateAsync(regular))
}
function onFavorite(regular: Regular) {
track(`favorite:${regular.id}`, `change the favorite heart on ${regular.name}`, favorite.mutateAsync(regular))
}
return (
<>
<form className={styles.addForm} onSubmit={onSubmit}>
<div className={styles.add}>
<TextInput
label="Add a Regular"
labelHidden
className={styles.addInput}
placeholder="Dinner name or recipe link"
autoComplete="off"
enterKeyHint="done"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<IconButton type="submit" label="Add Regular" tone="accent" disabled={!ready}>
<Icon icon={Plus} size="nav" />
</IconButton>
</div>
{isLink && (
<>
<TextInput
label="Dinner name"
placeholder="What do you call this dinner?"
autoComplete="off"
enterKeyHint="done"
aria-describedby={linkHintId}
value={linkName}
onChange={(event) => setLinkName(event.target.value)}
/>
<p id={linkHintId} className={styles.hint}>
Name it yourself: a link can carry private details, so it never names a dinner.
</p>
</>
)}
</form>
{refusals.map((refusal) => (
<p key={refusal.key} role="alert" className={styles.problem}>
{refusal.text}
</p>
))}
{regulars.isPending && <Skeleton className={styles.loading} />}
{regulars.isError && (
<ErrorBanner onRetry={() => void regulars.refetch()}>
Your Regulars could not load: {regulars.error.message}
</ErrorBanner>
)}
{regulars.isSuccess && (
<section aria-labelledby={headingId} className={styles.section}>
<h2 id={headingId} className={styles.groupLabel}>
On rotation · {list.length}
</h2>
{list.length === 0 ? (
<p className={styles.hint}>No Regulars yet. Add the dinners your household makes on repeat.</p>
) : (
<Card className={styles.card}>
<ul className={styles.rows} aria-label="Regulars">
{list.map((regular) => (
<RegularRow
key={regular.id}
regular={regular}
onOpen={() =>
setOpen({ sheet: regular.grounding_status === 'failed' ? 'fix' : 'edit', id: regular.id })
}
onFavorite={() => onFavorite(regular)}
/>
))}
</ul>
</Card>
)}
</section>
)}
{opened && open.sheet === 'edit' && (
<EditRegularSheet
key={opened.id}
regular={opened}
onClose={() => setOpen(null)}
onRemove={() => onRemove(opened)}
/>
)}
{opened && open.sheet === 'fix' && (
<FixRecipeSheet
key={opened.id}
regular={opened}
onClose={() => setOpen(null)}
onEdit={() => setOpen({ sheet: 'edit', id: opened.id })}
/>
)}
</>
)
}
// details is a Regular's "method · minutes" line, or null when neither is
// known yet.
export function details(regular: Regular): string | null {
const parts = [
regular.method && methodLabels[regular.method],
regular.minutes_total !== null && `${regular.minutes_total} min`,
].filter((part): part is string => typeof part === 'string')
return parts.length === 0 ? null : parts.join(' · ')
}
const badges: Record<GroundingStatus, { text: string; className: string | undefined }> = {
pending: { text: 'Finding recipe…', className: styles.finding },
grounded: { text: 'Recipe found', className: styles.found },
manual: { text: 'Recipe found', className: styles.found },
failed: { text: 'Needs a recipe', className: styles.needs },
}
function GroundingBadge({ status }: { status: GroundingStatus }) {
const badge = badges[status]
return <Tag className={`${styles.badge} ${badge.className}`}>{badge.text}</Tag>
}
type RegularRowProps = {
regular: Regular
onOpen: () => void
onFavorite: () => void
}
function RegularRow({ regular, onOpen, onFavorite }: RegularRowProps) {
const line = details(regular)
return (
<li className={styles.item} aria-busy={regular.pending}>
<ListRow
className={regular.pending && styles.pending}
leading={<Photo id={regular.photo_id} name={regular.name} radius="14px" sizes="48px" className={styles.photo} />}
title={regular.name}
meta={
<span className={styles.meta}>
{line && <span>{line}</span>}
<GroundingBadge status={regular.grounding_status} />
</span>
}
onClick={regular.pending ? undefined : onOpen}
trailing={
<IconButton
label={`Favorite ${regular.name}`}
aria-pressed={regular.is_favorite}
disabled={regular.pending}
onClick={onFavorite}
>
<Icon icon={Heart} size="nav" className={regular.is_favorite ? styles.filled : undefined} />
</IconButton>
}
/>
</li>
)
}
type SheetFrameProps = {
title: string
onClose: () => void
children: ReactNode
}
function SheetFrame({ title, onClose, children }: SheetFrameProps) {
const titleId = useId()
return (
<Sheet open onClose={onClose} aria-labelledby={titleId}>
<div className={styles.sheet}>
<h2 id={titleId} className={styles.sheetTitle}>
{title}
</h2>
{children}
</div>
</Sheet>
)
}
// minutesProblem is why a typed minutes value cannot be saved, or null.
function minutesProblem(text: string): string | null {
if (text === '') return null
const minutes = Number(text)
if (!Number.isInteger(minutes) || minutes < 1 || minutes > MAX_MINUTES) {
return `Minutes must be a whole number from 1 to ${MAX_MINUTES}.`
}
return null
}
type Draft = { name: string; method: Method | ''; minutes: string; difficulty: Difficulty | '' }
function draftOf(regular: Regular): Draft {
return {
name: regular.name,
method: regular.method ?? '',
minutes: regular.minutes_total === null ? '' : String(regular.minutes_total),
difficulty: regular.difficulty ?? '',
}
}
// patchOf is the PATCH body for the fields draft changes. A field the
// Regular has no value for stays unset until one is chosen.
function patchOf(regular: Regular, draft: Draft): RegularPatch {
const saved = draftOf(regular)
const patch: RegularPatch = {}
if (draft.name.trim() !== saved.name) patch.name = draft.name.trim()
if (draft.method !== saved.method && draft.method !== '') patch.method = draft.method
if (draft.minutes !== saved.minutes && draft.minutes !== '') patch.minutes_total = Number(draft.minutes)
if (draft.difficulty !== saved.difficulty && draft.difficulty !== '') patch.difficulty = draft.difficulty
return patch
}
type EditRegularSheetProps = {
regular: Regular
onClose: () => void
onRemove: () => void
}
// EditRegularSheet renames a Regular and sets its method, minutes and
// difficulty, or removes it from the rotation once confirmed.
function EditRegularSheet({ regular, onClose, onRemove }: EditRegularSheetProps) {
const [draft, setDraft] = useState(() => draftOf(regular))
const [confirming, setConfirming] = useState(false)
const edit = useEditRegular()
const patch = patchOf(regular, draft)
const minutesError = minutesProblem(draft.minutes)
const nameError = draft.name.trim() === '' ? 'A Regular needs a name.' : null
const canSave = Object.keys(patch).length > 0 && minutesError === null && nameError === null
function change(next: Partial<Draft>) {
edit.reset()
setDraft((current) => ({ ...current, ...next }))
}
function onSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!canSave) return
edit.mutate({ id: regular.id, patch }, { onSuccess: onClose })
}
if (confirming) {
return (
<SheetFrame title={`Remove ${regular.name}?`} onClose={onClose}>
<p className={styles.hint}>
It leaves your Regulars, so suggestions and plans stop using it. You can add it again later.
</p>
<div className={styles.actions}>
<Button onClick={onRemove}>Remove</Button>
<Button variant="outline" onClick={() => setConfirming(false)}>
Keep it
</Button>
</div>
</SheetFrame>
)
}
return (
<SheetFrame title={`Edit ${regular.name}`} onClose={onClose}>
<form className={styles.form} onSubmit={onSave}>
<fieldset className={styles.lock} disabled={edit.isPending}>
<TextInput
label="Name"
value={draft.name}
autoComplete="off"
aria-invalid={nameError !== null}
onChange={(event) => change({ name: event.target.value })}
/>
{nameError && <p className={styles.problem}>{nameError}</p>}
<div className={styles.columns}>
<Select label="Method" value={draft.method} onChange={(event) => change({ method: event.target.value as Method })}>
{regular.method === null && (
<option value="" disabled>
Not set
</option>
)}
{METHODS.map((method) => (
<option key={method} value={method}>
{methodLabels[method]}
</option>
))}
</Select>
<TextInput
label="Minutes"
className={styles.paired}
type="number"
inputMode="numeric"
min={1}
max={MAX_MINUTES}
value={draft.minutes}
aria-invalid={minutesError !== null}
onChange={(event) => change({ minutes: event.target.value })}
/>
</div>
{minutesError && <p className={styles.problem}>{minutesError}</p>}
<Select
label="Difficulty"
value={draft.difficulty}
onChange={(event) => change({ difficulty: event.target.value as Difficulty })}
>
{regular.difficulty === null && (
<option value="" disabled>
Not set
</option>
)}
{DIFFICULTIES.map((difficulty) => (
<option key={difficulty} value={difficulty}>
{difficultyLabels[difficulty]}
</option>
))}
</Select>
{edit.isError && (
<p role="alert" className={styles.problem}>
Not saved: {edit.error.message}
</p>
)}
<div className={styles.actions}>
<Button type="submit" disabled={!canSave}>
{edit.isPending ? 'Saving…' : 'Save changes'}
</Button>
<Button variant="outline" onClick={() => setConfirming(true)}>
Remove from Regulars
</Button>
</div>
</fieldset>
</form>
</SheetFrame>
)
}
type FixPath = 'pick' | 'link' | 'manual'
const fixPaths = [
{ value: 'pick', label: 'Pick one' },
{ value: 'link', label: 'Paste a link' },
{ value: 'manual', label: 'Type it in' },
] as const
type FixRecipeSheetProps = {
regular: Regular
onClose: () => void
onEdit: () => void
}
// lines is text split into its non-blank lines, trimmed.
function lines(text: string): string[] {
return text
.split('\n')
.map((line) => line.trim())
.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) {
const [path, setPath] = useState<FixPath>('pick')
const ground = useGroundRegular()
function fix(body: RecipeFix) {
ground.mutate({ id: regular.id, fix: body }, { onSuccess: onClose })
}
return (
<SheetFrame title="Fix recipe" onClose={onClose}>
<p className={styles.hint}>
We couldn&apos;t find a recipe for {regular.name}. Pick a match below, paste a recipe link or type it in.
</p>
<Segmented
label="How to fix the recipe"
options={fixPaths}
value={path}
onValueChange={(next) => {
ground.reset()
setPath(next)
}}
/>
<fieldset className={styles.lock} disabled={ground.isPending}>
{path === 'pick' && <PickCandidate id={regular.id} onPick={(candidate_id) => fix({ candidate_id })} />}
{path === 'link' && <PasteLink onSubmit={(recipe_url) => fix({ recipe_url })} />}
{path === 'manual' && <ManualEntry onSubmit={(manual) => fix({ manual })} />}
</fieldset>
<p role="status" className={styles.status}>
{ground.isPending && 'Finding recipe… this can take a minute.'}
</p>
{ground.isError && (
<p role="alert" className={styles.problem}>
Not fixed: {ground.error.message}
</p>
)}
<Button variant="outline" disabled={ground.isPending} onClick={onEdit}>
Edit or remove {regular.name}
</Button>
</SheetFrame>
)
}
function PickCandidate({ id, onPick }: { id: string; onPick: (candidateId: string) => void }) {
const candidates = useRecipeCandidates(id)
if (candidates.isPending) return <Skeleton className={styles.candidatesLoading} />
if (candidates.isError) {
return (
<ErrorBanner onRetry={() => void candidates.refetch()}>
Recipe matches could not load: {candidates.error.message}
</ErrorBanner>
)
}
if (candidates.data.length === 0) {
return <p className={styles.hint}>No matches were found. Paste a recipe link or type it in.</p>
}
return (
<ul className={styles.candidates} aria-label="Recipe matches">
{candidates.data.map((candidate) => (
<li key={candidate.candidate_id}>
<button
type="button"
className={styles.candidate}
aria-label={`Use ${candidate.title} from ${candidate.site_name}`}
onClick={() => onPick(candidate.candidate_id)}
>
<Photo id={candidate.photo_id} name={candidate.title} radius="14px" sizes="56px" className={styles.candidatePhoto} />
<span className={styles.candidateBody}>
<span className={styles.candidateTitle}>{candidate.title}</span>
<span className={styles.candidateSite}>{candidate.site_name}</span>
</span>
</button>
</li>
))}
</ul>
)
}
function PasteLink({ onSubmit }: { onSubmit: (recipeUrl: string) => void }) {
const [link, setLink] = useState('')
const url = link.trim()
return (
<form
className={styles.form}
onSubmit={(event) => {
event.preventDefault()
if (url !== '') onSubmit(url)
}}
>
<TextInput
label="Recipe link"
type="url"
inputMode="url"
placeholder="https://"
autoComplete="off"
value={link}
onChange={(event) => setLink(event.target.value)}
/>
<Button type="submit" disabled={url === ''}>
Use this recipe
</Button>
</form>
)
}
function ManualEntry({ onSubmit }: { onSubmit: (manual: ManualRecipe) => void }) {
const [ingredients, setIngredients] = useState('')
const [steps, setSteps] = useState('')
const [minutes, setMinutes] = useState('')
const [method, setMethod] = useState<Method | ''>('')
const ingredientsId = useId()
const stepsId = useId()
const minutesError = minutesProblem(minutes)
const ingredientLines = lines(ingredients)
const missing = method === '' || minutes === ''
const canSave = ingredientLines.length > 0 && !missing && minutesError === null
function onSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!canSave) return
onSubmit({ ingredients: ingredientLines, steps: lines(steps), minutes: Number(minutes), method })
}
return (
<form className={styles.form} onSubmit={onSave}>
<div className={styles.field}>
<label htmlFor={ingredientsId} className={styles.label}>
Ingredients, one per line
</label>
<textarea
id={ingredientsId}
className={styles.textarea}
rows={5}
value={ingredients}
onChange={(event) => setIngredients(event.target.value)}
/>
</div>
<div className={styles.field}>
<label htmlFor={stepsId} className={styles.label}>
Steps, one per line
</label>
<textarea
id={stepsId}
className={styles.textarea}
rows={5}
value={steps}
onChange={(event) => setSteps(event.target.value)}
/>
</div>
<div className={styles.columns}>
<Select label="Method" value={method} onChange={(event) => setMethod(event.target.value as Method | '')}>
<option value="">Not set</option>
{METHODS.map((m) => (
<option key={m} value={m}>
{methodLabels[m]}
</option>
))}
</Select>
<TextInput
label="Minutes"
className={styles.paired}
type="number"
inputMode="numeric"
min={1}
max={MAX_MINUTES}
value={minutes}
aria-invalid={minutesError !== null}
onChange={(event) => setMinutes(event.target.value)}
/>
</div>
{minutesError && <p className={styles.problem}>{minutesError}</p>}
{ingredientLines.length > 0 && missing && <p className={styles.problem}>Choose a method and minutes to save.</p>}
<Button type="submit" disabled={!canSave}>
Save recipe
</Button>
</form>
)
}
+5 -39
View File
@@ -37,17 +37,13 @@ export type HouseholdSettings = {
suggestions_time: string
chat_autosend_dow: number
chat_autosend_time: string
// When the household finished onboarding; null until it has, and every
// app route sends it to /onboarding. Not editable in settings.
onboarding_completed_at: string | null
}
// HouseholdSettingsField is a household setting the settings pages edit.
export type HouseholdSettingsField = Exclude<keyof HouseholdSettings, 'onboarding_completed_at'>
// HouseholdSettingsPatch is the PATCH /api/v1/settings/household body: only
// the fields that change.
export type HouseholdSettingsPatch = Partial<Pick<HouseholdSettings, HouseholdSettingsField>>
export type HouseholdSettingsPatch = Partial<HouseholdSettings>
export type HouseholdSettingsField = keyof HouseholdSettings
const householdTimeFields = [
'dinner_time',
@@ -359,13 +355,10 @@ function parseHouseholdSettings(where: string, body: unknown): HouseholdSettings
}
days[field] = value
}
if (!isStringOrNull(body.onboarding_completed_at)) {
throw new Error(`${where}: onboarding_completed_at ${JSON.stringify(body.onboarding_completed_at)} is not a time or null`)
}
return { timezone: body.timezone, ...times, ...days, onboarding_completed_at: body.onboarding_completed_at }
return { timezone: body.timezone, ...times, ...days }
}
export const HOUSEHOLD_SETTINGS_KEY = ['settings', 'household'] as const
const HOUSEHOLD_SETTINGS_KEY = ['settings', 'household'] as const
export function useHouseholdSettings() {
const { apiFetch } = useApi()
@@ -389,21 +382,6 @@ export function useSaveHouseholdSettings() {
})
}
// useCompleteOnboarding records that the household finished onboarding: the
// server sets onboarding_completed_at, which ends the first-run redirect.
export function useCompleteOnboarding() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async () =>
parseHouseholdSettings(
`PATCH ${HOUSEHOLD_SETTINGS_PATH}`,
await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH, { method: 'PATCH', body: { onboarding_completed: true } }),
),
onSuccess: (settings) => queryClient.setQueryData(HOUSEHOLD_SETTINGS_KEY, settings),
})
}
// HouseholdSettingsProblems is a refused household settings save: the reason
// for each field the server named, and any reasons that name no field.
export type HouseholdSettingsProblems = {
@@ -441,18 +419,6 @@ export function householdSettingsProblems(error: Error): HouseholdSettingsProble
return problems
}
// needsMoreRegulars reports whether error is the server refusing to finish
// onboarding because the household has fewer than three Regulars
// (internal/household MinRegulars) — the only refusal of
// {"onboarding_completed": true}.
export function needsMoreRegulars(error: Error): boolean {
return (
error instanceof ApiError &&
error.code === 'invalid_settings' &&
error.message.startsWith(`${invalidHouseholdPrefix}onboarding_completed: `)
)
}
// aiStatus is the AI assistant row's status line: the provider and the
// model's display name, or that no model is chosen yet (the seed).
export function aiStatus(settings: Pick<AISettings, 'provider' | 'model'>): string {
+20 -21
View File
@@ -21,7 +21,7 @@ afterEach(() => {
vi.unstubAllGlobals()
})
async function renderAt(path: string, width: number) {
function renderAt(path: string, width: number) {
const resize = mockViewport(width)
stubFetchByPath(SETTINGS_API)
render(
@@ -31,8 +31,6 @@ async function renderAt(path: string, width: number) {
</ApiProviders>
</InDataRouter>,
)
// The routes wait for the household settings (OnboardingGate).
await screen.findByRole('navigation', { name: 'Primary' })
return resize
}
@@ -51,8 +49,8 @@ function current() {
}
describe('AppShell', () => {
it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', async (width) => {
await renderAt('/', width)
it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', (width) => {
renderAt('/', width)
expect(navLinks().map((link) => link.textContent)).toEqual(ORDER)
expect(navLinks().map((link) => link.getAttribute('href'))).toEqual([
'/',
@@ -63,8 +61,8 @@ describe('AppShell', () => {
])
})
it('shows only the mobile bottom bar below 768 px', async () => {
await renderAt('/', 767)
it('shows only the mobile bottom bar below 768 px', () => {
renderAt('/', 767)
expect(screen.getAllByRole('navigation')).toHaveLength(1)
expect(nav()).toHaveClass(bottomBarStyles.bar!)
expect(screen.queryByText('M')).not.toBeInTheDocument()
@@ -82,8 +80,8 @@ describe('AppShell', () => {
expect(bar.borderRadius).toBe('var(--radius-pill)')
})
it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', async () => {
await renderAt('/discover', MOBILE)
it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', () => {
renderAt('/discover', MOBILE)
const pills = nav().querySelectorAll(`.${bottomBarStyles.pill}`)
expect(pills).toHaveLength(1)
const pill = declaredStyle(pills[0]!)
@@ -103,24 +101,24 @@ describe('AppShell', () => {
expect(within(inactive).getByText('Plan')).toHaveClass(bottomBarStyles.hiddenLabel!)
})
it('makes the full 62 px bar height the mobile tap target', async () => {
await renderAt('/', MOBILE)
it('makes the full 62 px bar height the mobile tap target', () => {
renderAt('/', MOBILE)
for (const link of navLinks()) {
expect(declaredStyle(link).height).toBe('62px')
expectMinHitArea(link)
}
})
it('reserves at least 100 px below the mobile scroll area', async () => {
await renderAt('/', MOBILE)
it('reserves at least 100 px below the mobile scroll area', () => {
renderAt('/', MOBILE)
const padding = declaredStyle(screen.getByRole('main')).paddingBottom
const reserved = /^calc\((\d+)px \+ env\(safe-area-inset-bottom\)\)$/.exec(padding)
expect(reserved, `padding-bottom ${JSON.stringify(padding)}`).not.toBeNull()
expect(Number(reserved![1])).toBeGreaterThanOrEqual(100)
})
it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', async (width) => {
await renderAt('/plan', width)
it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', (width) => {
renderAt('/plan', width)
expect(screen.getAllByRole('navigation')).toHaveLength(1)
expect(nav()).toHaveClass(railStyles.rail!)
expect(declaredStyle(nav()).width).toBe('96px')
@@ -148,8 +146,8 @@ describe('AppShell', () => {
expect(screen.getByRole('link', { name: 'Home' }).firstElementChild).not.toHaveClass(railStyles.active!)
})
it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', async (width) => {
await renderAt('/quick', width)
it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', (width) => {
renderAt('/quick', width)
expect(screen.getAllByRole('navigation')).toHaveLength(1)
const sidebar = nav().closest('aside')!
expect(sidebar).toHaveClass(sidebarStyles.sidebar!)
@@ -178,8 +176,8 @@ describe('AppShell', () => {
for (const link of navLinks()) expect(declaredStyle(link).minHeight).toBe('44px')
})
it('switches layout when the viewport crosses a breakpoint', async () => {
const resize = await renderAt('/', MOBILE)
it('switches layout when the viewport crosses a breakpoint', () => {
const resize = renderAt('/', MOBILE)
expect(nav()).toHaveClass(bottomBarStyles.bar!)
resize(TABLET)
expect(nav()).toHaveClass(railStyles.rail!)
@@ -208,10 +206,11 @@ describe('AppShell', () => {
['/settings/household', ['Settings']],
['/settings/regulars', ['Settings']],
['/settings/calendar', ['Settings']],
['/onboarding', []],
['/cook-from-pantry', []],
['/dinner/abc123', []],
])('%s marks %j current', async (path, expected) => {
await renderAt(path, width)
])('%s marks %j current', (path, expected) => {
renderAt(path, width)
expect(current()).toEqual(expected)
})
})
+24 -26
View File
@@ -70,7 +70,7 @@ afterEach(() => {
vi.unstubAllGlobals()
})
async function renderAt(path: string, reducedMotion = false) {
function renderAt(path: string, reducedMotion = false) {
mockViewport(MOBILE, { reducedMotion })
stubFetchByPath(SETTINGS_API)
render(
@@ -80,8 +80,6 @@ async function renderAt(path: string, reducedMotion = false) {
</ApiProviders>
</InDataRouter>,
)
// The routes wait for the household settings (OnboardingGate).
await screen.findByRole('navigation', { name: 'Primary' })
}
function nav() {
@@ -111,21 +109,21 @@ function animationsOf(element: Element) {
}
describe('BottomBar', () => {
it('places the pill on the active item without animating on first render', async () => {
await renderAt('/plan')
it('places the pill on the active item without animating on first render', () => {
renderAt('/plan')
expect(pill().style.left).toBe('54px')
expect(pill().style.width).toBe('110px')
expect(pill()).toBeVisible()
expect(animate).not.toHaveBeenCalled()
})
it('hides the pill when no item is active', async () => {
await renderAt('/cook-from-pantry')
it('hides the pill when no item is active', () => {
renderAt('/onboarding')
expect(pill()).not.toBeVisible()
})
it('slides the pill from the old item to the new one over 420 ms on the spring curve', async () => {
await renderAt('/')
it('slides the pill from the old item to the new one over 420 ms on the spring curve', () => {
renderAt('/')
fireEvent.click(link('Quick'))
expect(animationsOf(pill())).toEqual([
@@ -141,8 +139,8 @@ describe('BottomBar', () => {
expect(pill().style.width).toBe('110px')
})
it('FLIPs every item that moved from its old left to its new one with the same timing', async () => {
await renderAt('/')
it('FLIPs every item that moved from its old left to its new one with the same timing', () => {
renderAt('/')
fireEvent.click(link('Quick'))
// Home shrinks from 110 to 48 px, so Plan and Discover shift 62 px left
@@ -165,8 +163,8 @@ describe('BottomBar', () => {
expect(animationsOf(link('Settings'))).toEqual([])
})
it('fades the new label in over 220 ms ease-out after 120 ms', async () => {
await renderAt('/')
it('fades the new label in over 220 ms ease-out after 120 ms', () => {
renderAt('/')
fireEvent.click(link('Discover'))
const label = within(link('Discover')).getByText('Discover')
@@ -182,14 +180,14 @@ describe('BottomBar', () => {
])
})
it('does not animate when the active item stays the same', async () => {
await renderAt('/plan')
it('does not animate when the active item stays the same', () => {
renderAt('/plan')
fireEvent.click(link('Plan'))
expect(animate).not.toHaveBeenCalled()
})
it('jumps the pill to the new item with no animation under reduced motion', async () => {
await renderAt('/', true)
it('jumps the pill to the new item with no animation under reduced motion', () => {
renderAt('/', true)
fireEvent.click(link('Settings'))
expect(link('Settings')).toHaveAttribute('aria-current', 'page')
@@ -198,8 +196,8 @@ describe('BottomBar', () => {
expect(pill().style.width).toBe('110px')
})
it('re-places the pill when the viewport resizes', async () => {
await renderAt('/discover')
it('re-places the pill when the viewport resizes', () => {
renderAt('/discover')
expect(pill().style.left).toBe('102px')
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(120)
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96)
@@ -209,8 +207,8 @@ describe('BottomBar', () => {
expect(animate).not.toHaveBeenCalled()
})
it('starts a second slide from where the first one is on screen, not from its destination', async () => {
await renderAt('/')
it('starts a second slide from where the first one is on screen, not from its destination', () => {
renderAt('/')
fireEvent.click(link('Quick'))
const [firstPill] = runningOn(pill())
const firstPlan = runningOn(link('Plan'))[0]!
@@ -249,8 +247,8 @@ describe('BottomBar', () => {
])
})
it('stops a running slide and places the pill on the active item when the viewport resizes', async () => {
await renderAt('/')
it('stops a running slide and places the pill on the active item when the viewport resizes', () => {
renderAt('/')
fireEvent.click(link('Quick'))
const slide = [pill(), link('Plan'), link('Discover'), link('Quick')].flatMap(runningOn)
expect(slide).toHaveLength(4)
@@ -271,7 +269,7 @@ describe('BottomBar', () => {
const ready = new Promise<void>((resolve) => (fontsReady = resolve))
Object.defineProperty(document, 'fonts', { configurable: true, value: { ready } })
try {
await renderAt('/discover')
renderAt('/discover')
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(124)
fontsReady()
await ready
@@ -281,8 +279,8 @@ describe('BottomBar', () => {
}
})
it('crossfades the icon colour with a .2s colour transition', async () => {
await renderAt('/')
it('crossfades the icon colour with a .2s colour transition', () => {
renderAt('/')
for (const item of within(nav()).getAllByRole('link')) {
expect(declaredStyle(item).transition).toBe('color .2s')
}
-47
View File
@@ -1,47 +0,0 @@
// Runs the service worker's activate handler against mocked Cache Storage,
// with the Workbox precache and router stubbed out. tsconfig.sw.json checks
// this file with the worker's types, like src/sw.ts itself.
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('workbox-precaching', () => ({
PrecacheFallbackPlugin: class {},
matchPrecache: vi.fn(),
precacheAndRoute: vi.fn(),
}))
vi.mock('workbox-routing', () => ({ registerRoute: vi.fn() }))
describe('service worker activation', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('deletes the v1 API cache and keeps the precache, photo and font caches', async () => {
// A household on the v1 worker: its API cache held dinners with the
// pasted recipe_url; its photos and fonts are not in the precache.
const names = [
'mealquest-precache-v1',
'mealquest-api-v1',
'mealquest-media-v1',
'mealquest-fonts-v1',
'other-app-cache',
]
const cacheStorage = {
keys: vi.fn(async () => names),
delete: vi.fn(async () => true),
}
vi.stubGlobal('caches', cacheStorage)
vi.stubGlobal('__WB_MANIFEST', [])
await import('./sw')
const pending: Promise<unknown>[] = []
const activate = Object.assign(new Event('activate'), {
waitUntil: (promise: Promise<unknown>) => pending.push(promise),
})
self.dispatchEvent(activate)
await Promise.all(pending)
expect(pending).toHaveLength(1)
expect(cacheStorage.delete).toHaveBeenCalledWith('mealquest-api-v1')
expect(cacheStorage.delete).toHaveBeenCalledTimes(1)
})
})
+2 -2
View File
@@ -18,7 +18,7 @@ import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
import { createApiStrategy } from './sw/apiStrategy'
import {
cacheNames,
cacheVersions,
cacheVersion,
routeFor,
staleCaches,
type Route,
@@ -29,7 +29,7 @@ declare const self: ServiceWorkerGlobalScope & {
}
// The precache becomes mealquest-precache-v1.
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersions.precache })
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersion })
const day = 24 * 60 * 60
+1 -13
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { cacheFallbackHeader, cacheNames, cacheVersions, markCacheFallback, routeFor, staleCaches, type Route } from './routes'
import { cacheFallbackHeader, cacheNames, markCacheFallback, routeFor, staleCaches, type Route } from './routes'
const origin = 'https://mealquest.test'
@@ -146,18 +146,6 @@ describe('cache names', () => {
it('all start with mealquest-', () => {
for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/)
})
it('retire only the v1 API cache', () => {
// v1 API responses held dinners with the pasted recipe_url, which can
// carry a personal token; offline, the worker would still serve them.
// The precache, photos and fonts keep their v1 caches.
expect(cacheNames).toEqual({
api: 'mealquest-api-v2',
media: 'mealquest-media-v1',
fonts: 'mealquest-fonts-v1',
})
expect(cacheVersions.precache).toBe('v1')
})
})
describe('staleCaches', () => {
+5 -14
View File
@@ -24,23 +24,14 @@ export interface RouteRequest {
mode: RequestMode
}
// Each cache carries its own version. Bumping one renames only that cache;
// activate then deletes its old name and keeps every other cache, so saved
// photos and fonts stay available offline.
export const cacheVersions = {
precache: 'v1',
// v2 retires the v1 API cache, whose dinner responses still carried the
// pasted recipe_url.
api: 'v2',
media: 'v1',
fonts: 'v1',
} as const
// Bumping the version renames every cache; activate then deletes the old ones.
export const cacheVersion = 'v1'
export const cachePrefix = 'mealquest-'
export const cacheNames = {
api: `${cachePrefix}api-${cacheVersions.api}`,
media: `${cachePrefix}media-${cacheVersions.media}`,
fonts: `${cachePrefix}fonts-${cacheVersions.fonts}`,
api: `${cachePrefix}api-${cacheVersion}`,
media: `${cachePrefix}media-${cacheVersion}`,
fonts: `${cachePrefix}fonts-${cacheVersion}`,
} as const
export const networkTimeoutSeconds = 3
-1
View File
@@ -2,7 +2,6 @@
// source: they pin the parts routes.ts cannot express — the API cache's
// strategy, the font strategy's expiration, the font route's place ahead of
// the precache route and network-only requests left to the browser.
// src/sw.test.ts runs the worker's activation against mocked Cache Storage.
import { describe, expect, it } from 'vitest'
import source from '../sw.ts?raw'
+2 -2
View File
@@ -21,7 +21,7 @@ export const AI_SETTINGS = {
export const AI_MODELS = { models: [{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }] }
// HOUSEHOLD_SETTINGS is a GET /api/v1/settings/household body: the seed
// times in New York, onboarding finished.
// times in New York.
export const HOUSEHOLD_SETTINGS = {
timezone: 'America/New_York',
dinner_time: '17:30',
@@ -33,7 +33,7 @@ export const HOUSEHOLD_SETTINGS = {
suggestions_time: '08:00',
chat_autosend_dow: 6,
chat_autosend_time: '08:00',
onboarding_completed_at: '2026-10-01T15:30:00.000Z',
onboarding_completed_at: null,
updates_seen_at: null,
updated_by: null,
created_at: '2026-10-01T15:00:00.000Z',
-43
View File
@@ -1,43 +0,0 @@
// DinnerBody is a dinner as the dinners API sends it.
export type DinnerBody = {
id: string
name: string
status: string
origin: string
method: string | null
minutes_total: number | null
difficulty: string | null
cuisine: string | null
is_favorite: boolean
recipe_id: string | null
photo_url: string | null
grounding_status: string
grounding_error: string | null
updated_by: string | null
created_at: string
updated_at: string
}
// dinnerBody is a Regular as the server sends it: grounded with no photo,
// method or minutes unless overrides set them.
export function dinnerBody(id: string, name: string, overrides: Partial<DinnerBody> = {}): DinnerBody {
return {
id,
name,
status: 'regular',
origin: 'regular',
method: null,
minutes_total: null,
difficulty: null,
cuisine: null,
is_favorite: false,
recipe_id: null,
photo_url: null,
grounding_status: 'grounded',
grounding_error: null,
updated_by: null,
created_at: '2026-10-01T15:00:00.000Z',
updated_at: '2026-10-01T15:00:00.000Z',
...overrides,
}
}
+1 -1
View File
@@ -19,5 +19,5 @@
"noEmit": true
},
"include": ["src", "vite.config.ts", "manifest.json"],
"exclude": ["src/sw.ts", "src/sw.test.ts"]
"exclude": ["src/sw.ts"]
}
+1 -1
View File
@@ -4,6 +4,6 @@
"lib": ["ES2022", "WebWorker"],
"types": []
},
"include": ["src/sw.ts", "src/sw.test.ts"],
"include": ["src/sw.ts"],
"exclude": []
}