diff --git a/cmd/server/main.go b/cmd/server/main.go index 4b78236..3935c0a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -14,8 +14,11 @@ import ( "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" @@ -75,7 +78,10 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - photos, err := recipes.NewPhotoCache(recipes.NewFetcher(), db, cfg.DataDir) + // One fetcher serves pages and photos, so its per-host spacing and + // page cache cover both. + fetcher := recipes.NewFetcher() + photos, err := recipes.NewPhotoCache(fetcher, db, cfg.DataDir) if err != nil { return err } @@ -88,6 +94,18 @@ func run(cfg config.Config, logger *slog.Logger) error { if err != nil { return err } + guard, err := harness.NewGuard(harness.DefaultMaxCalls) + if err != nil { + return err + } + grounder, err := recipes.NewGrounder(db, search, fetcher, photos, normalizer(ai, guard)) + if err != nil { + return err + } + dinnerSvc, err := dinners.NewService(db, grounder, runner, logger) + if err != nil { + return err + } sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) if err != nil { @@ -105,7 +123,7 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, runner, photos, frontend.Assets()) + router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, runner, photos, frontend.Assets()) if err != nil { return err } @@ -121,6 +139,12 @@ func run(cfg config.Config, logger *slog.Logger) error { if err := runner.Start(jobsCtx); err != nil { return err } + // With those jobs failed, a dinner still pending with no grounding job + // queued or running is failed as interrupted, before any request can + // create one. + if _, err := dinnerSvc.FailInterruptedGroundings(ctx); err != nil { + return err + } srv := &http.Server{ Addr: cfg.HTTPAddr, Handler: router, @@ -202,7 +226,7 @@ func seedDefaults(ctx context.Context, db *store.Store, defaultTZ string) error return household.SeedNotificationPrefs(ctx, db) } -// jobKinds enqueues every job kind the server runs. +// jobKinds enqueues the framework's job kinds. type jobKinds struct { builtins *jobs.Builtins } @@ -221,8 +245,9 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds return runner, kinds, nil } -// registerJobKinds registers every job kind's handler on runner. Each -// feature's job kinds are registered here. +// registerJobKinds registers the framework's job kinds on runner. A feature +// service registers its own kinds when it is built, before the runner +// starts (dinners.NewService registers dinners.KindGroundDinner). func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) { builtins, err := jobs.RegisterBuiltins(runner) if err != nil { @@ -231,6 +256,18 @@ func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) { return &jobKinds{builtins: builtins}, nil } +// normalizer runs normalize_recipe with the AI settings current at each +// run, sharing guard. +func normalizer(ai *aisettings.Service, guard *harness.Guard) recipes.Normalizer { + return func(ctx context.Context, scope string, in skills.RecipeText) (skills.NormalizedRecipe, error) { + cfg, err := ai.HarnessConfig(ctx, guard) + if err != nil { + return skills.NormalizedRecipe{}, err + } + return skills.NormalizeRecipe(ctx, cfg, scope, in) + } +} + // logListening logs the startup line. Only the public URL's origin is logged // so no credential-bearing URL component can reach the log. func logListening(logger *slog.Logger, cfg config.Config) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 226ab04..4da8dd8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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, photo_id, grounding_status, 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), grounding_revision (raised when a pending dinner is renamed, so only the latest `ground_dinner` job may write it), 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. | @@ -178,6 +178,88 @@ automatic** (D-021). restores `household.DefaultPersonaPrompt` (CTXD PROMPTS.MD §4). No 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 + 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. + `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`; + any other status is 400. `PATCH /api/v1/dinners/{id}` edits `name` + (deduplicated against the other unarchived dinners), `method` + (`stovetop` / `oven` / `crockpot` / `grill` / `other`), `minutes_total` + (1–1440) and `difficulty` (`easy` / `medium` / `hard`); invalid values + are 400 `invalid_dinner` and change nothing. `DELETE /api/v1/dinners/{id}` + sets status `archived` and keeps the row; `POST + /api/v1/dinners/{id}/favorite` toggles `is_favorite`. Every write records + the session's subject as `updated_by`; an unknown id is 404. +- Dinner grounding. `POST /api/v1/dinners` also enqueues a `ground_dinner` + job and answers with its `grounding_job_id` without waiting for it + (`GET /api/v1/jobs/{id}` reports it); if the job cannot be enqueued it + still answers 201, with grounding_status `failed` (`grounding_error`) + and `grounding_job_id` null. The job grounds the dinner's + `recipe_url` directly with `GroundURL`, else its name with `Ground` + (see Recipe grounding), and sets grounding_status `grounded` with the + recipe's recipe_id, photo_id, method, minutes_total and difficulty, or + `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. + `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` + ranks them; it fetches pages but downloads no photo and runs no model, + so `photo_url` is set only for a page already stored, and `candidate_id` + is the page's URL. `POST /api/v1/dinners/{id}/ground` takes exactly one + of `{candidate_id}`, `{recipe_url}` (both grounded with `GroundURL`, + setting `grounded`) or `{manual: {ingredients[], steps[], minutes, + method}}` (1–100 ingredient lines, up to 100 steps, each non-blank and at + most 500 characters; minutes 1–1440). A manual recipe is stored as typed + — 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 + 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. - `/health` — liveness. - Everything else serves the embedded PWA with SPA fallback. @@ -346,7 +428,10 @@ automatic** (D-021). Rank's per-candidate reasons, photo or normalize failures). Failures that are not the candidates' — search, model provider, database — are returned as they are. `GroundURL(ctx, url)` does the same for one pasted - URL. `GroundMany` grounds items 3 at a time and returns one result per + URL. `Candidates(ctx, dishName, query, n)` returns the first n of the + candidates `Ground` would try, without grounding any. Search failures + wrap `recipes.ErrSearchFailed` and model provider failures + `recipes.ErrNormalizeFailed`. `GroundMany` grounds items 3 at a time and returns one result per item, so one failure never affects its siblings. A recipe is usable only with ingredients, an image and, if given, a positive total time. @@ -422,6 +507,46 @@ 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 diff --git a/internal/ai/harness/dedupe_test.go b/internal/ai/harness/dedupe_test.go index 99332d3..9f7ff2b 100644 --- a/internal/ai/harness/dedupe_test.go +++ b/internal/ai/harness/dedupe_test.go @@ -1,10 +1,70 @@ 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 diff --git a/internal/ai/harness/gen_folds.py b/internal/ai/harness/gen_folds.py index 6e907c4..4de6bb6 100644 --- a/internal/ai/harness/gen_folds.py +++ b/internal/ai/harness/gen_folds.py @@ -1,5 +1,8 @@ #!/usr/bin/env python3 -"""Generate folds_table.go, the letter-fold table used by harness.Normalize. +"""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. 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 @@ -28,11 +31,14 @@ 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", @@ -110,6 +116,9 @@ 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__": diff --git a/internal/ai/harness/testdata/dedupe_cases.json b/internal/ai/harness/testdata/dedupe_cases.json new file mode 100644 index 0000000..20c3aec --- /dev/null +++ b/internal/ai/harness/testdata/dedupe_cases.json @@ -0,0 +1,136 @@ +{ + "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 + } + ] +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go index bdbba38..01f0a80 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -106,13 +106,22 @@ func TestDinners(t *testing.T) { Cuisine: str("Mexican"), IsFavorite: 0, RecipeID: str(recipe.ID), + RecipeUrl: str("https://example.com/tacos"), PhotoID: str(photo.ID), GroundingStatus: "grounded", UpdatedBy: str("alex@example.com"), CreatedAt: created, UpdatedAt: created, } - want := db.Dinner(create) + want := db.Dinner{ + ID: create.ID, Name: create.Name, NormalizedName: create.NormalizedName, + Status: create.Status, Origin: create.Origin, Method: create.Method, + MinutesTotal: create.MinutesTotal, Difficulty: create.Difficulty, + Cuisine: create.Cuisine, IsFavorite: create.IsFavorite, RecipeID: create.RecipeID, + PhotoID: create.PhotoID, GroundingStatus: create.GroundingStatus, + UpdatedBy: create.UpdatedBy, CreatedAt: created, UpdatedAt: created, + RecipeUrl: create.RecipeUrl, + } got, err := q.CreateDinner(ctx, create) if err != nil { t.Fatalf("CreateDinner: %v", err) @@ -181,6 +190,7 @@ 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"), @@ -197,6 +207,7 @@ func TestDinners(t *testing.T) { Cuisine: update.Cuisine, IsFavorite: 0, RecipeID: update.RecipeID, PhotoID: update.PhotoID, GroundingStatus: update.GroundingStatus, UpdatedBy: update.UpdatedBy, CreatedAt: created, UpdatedAt: updated, + RecipeUrl: create.RecipeUrl, // UpdateDinner leaves the recipe link } if got != want { t.Fatalf("UpdateDinner returned %+v, want %+v", got, want) @@ -227,6 +238,17 @@ func TestDinners(t *testing.T) { _, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ID: "missing", IsFavorite: 1, UpdatedAt: updated}) mustNoRows(t, "SetDinnerFavorite missing", err) + for _, wantFav := range []int64{1, 0} { + got, err = q.ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{ + ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: updated, + }) + if err != nil || got.IsFavorite != wantFav || got.UpdatedBy != str("alex@example.com") { + t.Fatalf("ToggleDinnerFavorite = %+v, %v; want is_favorite %d", got, err, wantFav) + } + } + _, err = q.ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{ID: "missing", UpdatedAt: updated}) + mustNoRows(t, "ToggleDinnerFavorite missing", err) + const archivedAt = "2026-10-05T12:00:00.000Z" got, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: archivedAt, @@ -240,6 +262,15 @@ func TestDinners(t *testing.T) { } assertIDs(t, "archived", listIDs("archived"), create.ID) assertIDs(t, "tried after archive", listIDs("tried")) + unarchived, err := q.ListUnarchivedDinners(ctx) + if err != nil { + t.Fatalf("ListUnarchivedDinners: %v", err) + } + var unarchivedIDs []string + for _, d := range unarchived { + unarchivedIDs = append(unarchivedIDs, d.Dinner.ID) + } + assertIDs(t, "unarchived", unarchivedIDs, other.ID, third.ID) _, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt}) mustNoRows(t, "ArchiveDinner missing", err) } diff --git a/internal/db/dinners.sql.go b/internal/db/dinners.sql.go index 3b7b213..74c929d 100644 --- a/internal/db/dinners.sql.go +++ b/internal/db/dinners.sql.go @@ -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 +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 ` type ArchiveDinnerParams struct { @@ -46,21 +46,37 @@ func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (D &i.UpdatedBy, &i.CreatedAt, &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, - difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, - updated_by, created_at, updated_at + difficulty, cuisine, is_favorite, recipe_id, recipe_url, photo_id, + grounding_status, updated_by, created_at, updated_at ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ? + ?, ?, ?, ? ) -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 +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 ` type CreateDinnerParams struct { @@ -75,6 +91,7 @@ type CreateDinnerParams struct { Cuisine sql.NullString IsFavorite int64 RecipeID sql.NullString + RecipeUrl sql.NullString PhotoID sql.NullString GroundingStatus string UpdatedBy sql.NullString @@ -95,6 +112,7 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din arg.Cuisine, arg.IsFavorite, arg.RecipeID, + arg.RecipeUrl, arg.PhotoID, arg.GroundingStatus, arg.UpdatedBy, @@ -119,12 +137,85 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.RecipeUrl, + &i.GroundingError, + &i.GroundingRevision, ) return i, err } +const failOrphanedPendingDinnerGroundings = `-- name: FailOrphanedPendingDinnerGroundings :execrows +UPDATE dinners +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 + ) +` + +type FailOrphanedPendingDinnerGroundingsParams struct { + GroundingError sql.NullString + UpdatedAt string + 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. +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 { + return 0, err + } + return result.RowsAffected() +} + +const failPendingDinnerGrounding = `-- name: FailPendingDinnerGrounding :execrows +UPDATE dinners +SET grounding_status = 'failed', + grounding_error = ?, + updated_at = ? +WHERE id = ? + AND status <> 'archived' + AND grounding_status = 'pending' + AND grounding_revision = ? +` + +type FailPendingDinnerGroundingParams struct { + GroundingError sql.NullString + UpdatedAt string + ID string + GroundingRevision int64 +} + +// 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). +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, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + 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 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, grounding_revision FROM dinners WHERE id = ? ` @@ -148,12 +239,162 @@ func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) { &i.UpdatedBy, &i.CreatedAt, &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, + 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 +` + +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 +} + +// 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. +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( + &i.ID, + &i.Name, + &i.NormalizedName, + &i.Status, + &i.Origin, + &i.Method, + &i.MinutesTotal, + &i.Difficulty, + &i.Cuisine, + &i.IsFavorite, + &i.RecipeID, + &i.PhotoID, + &i.GroundingStatus, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.RecipeUrl, + &i.GroundingError, + &i.GroundingRevision, + ) + 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), + grounding_status = 'grounded', + grounding_error = NULL, + updated_at = ?6 +WHERE id = ?7 + AND status <> 'archived' + AND grounding_status = 'pending' + AND grounding_revision = ?8 +` + +type GroundPendingDinnerParams struct { + RecipeID sql.NullString + PhotoID sql.NullString + Method sql.NullString + MinutesTotal sql.NullInt64 + Difficulty sql.NullString + UpdatedAt string + ID string + GroundingRevision int64 +} + +// 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. +func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error) { + result, err := q.db.ExecContext(ctx, groundPendingDinner, + arg.RecipeID, + arg.PhotoID, + arg.Method, + arg.MinutesTotal, + arg.Difficulty, + arg.UpdatedAt, + arg.ID, + arg.GroundingRevision, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const 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 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, grounding_revision FROM dinners WHERE status = ? ORDER BY normalized_name, id ` @@ -184,6 +425,68 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.RecipeUrl, + &i.GroundingError, + &i.GroundingRevision, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUnarchivedDinners = `-- name: ListUnarchivedDinners :many +SELECT dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision, recipes.source_url AS recipe_source_url +FROM dinners +LEFT JOIN recipes ON recipes.id = dinners.recipe_id +WHERE dinners.status <> 'archived' +ORDER BY dinners.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) { + rows, err := q.db.QueryContext(ctx, listUnarchivedDinners) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUnarchivedDinnersRow + for rows.Next() { + var i ListUnarchivedDinnersRow + 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, ); err != nil { return nil, err } @@ -204,7 +507,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 +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 ` type SetDinnerFavoriteParams struct { @@ -240,6 +543,52 @@ func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoritePa &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.RecipeUrl, + &i.GroundingError, + &i.GroundingRevision, + ) + return i, err +} + +const toggleDinnerFavorite = `-- name: ToggleDinnerFavorite :one +UPDATE dinners +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 +` + +type ToggleDinnerFavoriteParams struct { + UpdatedBy sql.NullString + UpdatedAt string + ID string +} + +// An unknown id returns sql.ErrNoRows. +func (q *Queries) ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error) { + row := q.db.QueryRowContext(ctx, toggleDinnerFavorite, arg.UpdatedBy, arg.UpdatedAt, arg.ID) + var i Dinner + err := row.Scan( + &i.ID, + &i.Name, + &i.NormalizedName, + &i.Status, + &i.Origin, + &i.Method, + &i.MinutesTotal, + &i.Difficulty, + &i.Cuisine, + &i.IsFavorite, + &i.RecipeID, + &i.PhotoID, + &i.GroundingStatus, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.RecipeUrl, + &i.GroundingError, + &i.GroundingRevision, ) return i, err } @@ -255,29 +604,33 @@ 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 +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 ` 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 - PhotoID sql.NullString - GroundingStatus string - 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 + RecipeUrl sql.NullString + PhotoID sql.NullString + GroundingStatus string + GroundingRevision int64 + UpdatedBy sql.NullString + UpdatedAt string + ID string } // An unknown id returns sql.ErrNoRows. @@ -292,8 +645,10 @@ 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, @@ -316,6 +671,9 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.RecipeUrl, + &i.GroundingError, + &i.GroundingRevision, ) return i, err } diff --git a/internal/db/models.go b/internal/db/models.go index 7879c3e..b026169 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -51,22 +51,25 @@ 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 + 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 } type HouseholdSetting struct { diff --git a/internal/db/querier.go b/internal/db/querier.go index 4afff6a..0a27a86 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -21,9 +21,14 @@ 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 + // A recipe the household typed in. Its source_url is a mealquest:manual: + // URN rather than a page, so it never matches a fetched page. + CreateManualRecipe(ctx context.Context, arg CreateManualRecipeParams) (Recipe, error) CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error) CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error @@ -31,6 +36,17 @@ 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. + 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). + 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) // Returns sql.ErrNoRows until the row exists. @@ -51,6 +67,21 @@ 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. + 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. + 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 // to_date, oldest first. Days with no dinner are left out. @@ -60,12 +91,17 @@ 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) // Stores a partial result of a running job. PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error) // An unknown id returns sql.ErrNoRows. SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error) // An unknown id returns sql.ErrNoRows. + ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error) + // An unknown id returns sql.ErrNoRows. UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error) // Creates the single row (id 1) or replaces every column except id and // created_at. diff --git a/internal/db/recipes.sql.go b/internal/db/recipes.sql.go index 2098ef8..b43b465 100644 --- a/internal/db/recipes.sql.go +++ b/internal/db/recipes.sql.go @@ -10,6 +10,67 @@ import ( "database/sql" ) +const createManualRecipe = `-- name: CreateManualRecipe :one +INSERT INTO recipes ( + id, source_url, title, total_minutes, raw_jsonld, method, + minutes_total, fetched_at, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ? +) +RETURNING id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at, photo_id, method, difficulty, minutes_total +` + +type CreateManualRecipeParams struct { + ID string + SourceUrl string + Title string + TotalMinutes sql.NullInt64 + RawJsonld string + Method sql.NullString + MinutesTotal sql.NullInt64 + FetchedAt string + CreatedAt string + UpdatedAt string +} + +// A recipe the household typed in. Its source_url is a mealquest:manual: +// URN rather than a page, so it never matches a fetched page. +func (q *Queries) CreateManualRecipe(ctx context.Context, arg CreateManualRecipeParams) (Recipe, error) { + row := q.db.QueryRowContext(ctx, createManualRecipe, + arg.ID, + arg.SourceUrl, + arg.Title, + arg.TotalMinutes, + arg.RawJsonld, + arg.Method, + arg.MinutesTotal, + arg.FetchedAt, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i Recipe + err := row.Scan( + &i.ID, + &i.SourceUrl, + &i.SiteName, + &i.Title, + &i.TotalMinutes, + &i.PrepMinutes, + &i.CookMinutes, + &i.Yield, + &i.RawJsonld, + &i.FetchedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.PhotoID, + &i.Method, + &i.Difficulty, + &i.MinutesTotal, + ) + return i, err +} + const createRecipeIngredient = `-- name: CreateRecipeIngredient :exec INSERT INTO recipe_ingredients ( id, recipe_id, position, raw_text, name, qty, unit, note, diff --git a/internal/dinners/dinners.go b/internal/dinners/dinners.go new file mode 100644 index 0000000..ae2a20b --- /dev/null +++ b/internal/dinners/dinners.go @@ -0,0 +1,551 @@ +// Package dinners keeps the household's dinners: adding Regulars by name or +// recipe link, listing Regulars and the Quest log, editing, archiving and +// favoriting, and grounding each dinner in a recipe. Names are deduplicated with the harness's dinner-name rules, +// so the Regulars the household types and the Side quests the AI suggests +// are compared the same way. +package dinners + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "math" + "net/url" + "slices" + "strings" + "sync" + "unicode/utf8" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// Dinner statuses (the dinners.status column). +const ( + StatusSuggested = "suggested" + StatusQuestLog = "quest_log" + StatusTried = "tried" + StatusRegular = "regular" + StatusArchived = "archived" +) + +// Limits on what a member may enter. +const ( + MaxNameChars = 120 + MaxRecipeURL = 2048 + MaxMinutes = 1440 // a day; a crock-pot dinner fits + photoListWidth = 400 // the stored photo size lists show +) + +// Methods and Difficulties are the values the dinners table allows. +var ( + Methods = []string{"stovetop", "oven", "crockpot", "grill", "other"} + Difficulties = []string{"easy", "medium", "hard"} +) + +// listable are the statuses List answers for. +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. +type Dinner struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Origin string `json:"origin"` + Method *string `json:"method"` + MinutesTotal *int64 `json:"minutes_total"` + Difficulty *string `json:"difficulty"` + Cuisine *string `json:"cuisine"` + IsFavorite bool `json:"is_favorite"` + RecipeID *string `json:"recipe_id"` + PhotoURL *string `json:"photo_url"` + GroundingStatus string `json:"grounding_status"` + // GroundingError is why grounding failed, one of the Reason codes; + // null unless GroundingStatus is failed. + GroundingError *string `json:"grounding_error"` + UpdatedBy *string `json:"updated_by"` + CreatedAt string `json:"created_at"` + 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. +type NewRegular struct { + Name *string + RecipeURL *string +} + +// Patch edits a dinner. Nil fields are left as they are. +type Patch struct { + Name *string + Method *string + MinutesTotal *int + Difficulty *string +} + +// Problem is one invalid field, named by its JSON key. +type Problem struct { + Field string + Reason string +} + +// ValidationError lists every invalid field of a request. +type ValidationError struct { + Problems []Problem +} + +func (e *ValidationError) Error() string { + parts := make([]string, len(e.Problems)) + for i, p := range e.Problems { + parts[i] = p.Field + ": " + p.Reason + } + return "invalid dinner: " + strings.Join(parts, "; ") +} + +func invalid(field, reason string) *ValidationError { + return &ValidationError{Problems: []Problem{{Field: field, Reason: reason}}} +} + +// DuplicateError is returned when a new or renamed dinner repeats an +// unarchived one: its name is harness.Similar, or it has the same recipe +// link. +type DuplicateError struct { + Existing Dinner +} + +func (e *DuplicateError) Error() string { + return fmt.Sprintf("%q is already a dinner (%s)", e.Existing.Name, e.Existing.Status) +} + +// Service reads and changes dinners. +type Service struct { + st *store.Store + 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 +// and registers the KindGroundDinner job on runner, which must not have +// started yet. +func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, logger *slog.Logger) (*Service, error) { + switch { + case st == nil: + return nil, errors.New("dinners: nil store") + case grounder == nil: + return nil, errors.New("dinners: nil grounder") + case runner == nil: + return nil, errors.New("dinners: nil job 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{})} + kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...) + if err != nil { + return nil, err + } + s.groundJob = kind + return s, nil +} + +// Created is a new Regular with the id of the job grounding it, which is +// nil when the job could not be enqueued and the dinner's grounding is +// failed instead. +type Created struct { + Dinner + 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 +// 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 +// id, so the household can fix its recipe by hand. +func (s *Service) CreateRegular(ctx context.Context, r NewRegular, updatedBy string) (Created, error) { + d, err := s.createRegular(ctx, r, updatedBy) + 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}) + if err == nil { + return &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, + }); err != nil { + return nil, fmt.Errorf("dinners: record failed grounding of %s: %w", id, err) + } + return nil, nil +} + +func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy string) (db.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 + } + var recipeURL sql.NullString + if r.RecipeURL != nil { + u, err := checkRecipeURL(*r.RecipeURL) + if err != nil { + return db.Dinner{}, err + } + recipeURL = sql.NullString{String: u.String(), Valid: true} + } + + var row db.Dinner + 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 + } + now := store.Now().String() + var err error + row, err = q.CreateDinner(ctx, db.CreateDinnerParams{ + ID: store.NewID(), + Name: name, + NormalizedName: harness.Normalize(name), + Status: StatusRegular, + Origin: "regular", + RecipeUrl: recipeURL, + GroundingStatus: "pending", + UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + return fmt.Errorf("dinners: create: %w", err) + } + return nil + }) + if err != nil { + return db.Dinner{}, err + } + return row, nil +} + +// List returns the dinners with status, which must be regular or +// quest_log, ordered by normalized name. +func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) { + if !slices.Contains(listable, status) { + return nil, invalid("status", "must be one of "+strings.Join(listable, ", ")) + } + rows, err := db.New(s.st.Reader).ListDinnersByStatus(ctx, status) + if err != nil { + return nil, fmt.Errorf("dinners: list %s: %w", status, err) + } + out := make([]Dinner, len(rows)) + for i, row := range rows { + out[i] = fromRow(row) + } + return out, nil +} + +// 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. +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") + } + name, err := p.validate() + if err != nil { + return Dinner{}, err + } + + 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) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + if err != nil { + 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(), + } + if p.Name != nil { + if err := checkUnique(ctx, q, cur.ID, name, sql.NullString{}); err != nil { + return err + } + 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} + } + if p.MinutesTotal != nil { + params.MinutesTotal = sql.NullInt64{Int64: int64(*p.MinutesTotal), Valid: true} + } + if p.Difficulty != nil { + params.Difficulty = sql.NullString{String: *p.Difficulty, Valid: true} + } + row, err = q.UpdateDinner(ctx, params) + 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. +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") + } + row, err := db.New(s.st.Writer).ArchiveDinner(ctx, db.ArchiveDinnerParams{ + ID: id, + UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, + UpdatedAt: store.Now().String(), + }) + if errors.Is(err, sql.ErrNoRows) { + return Dinner{}, ErrNotFound + } + if err != nil { + return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err) + } + s.stopGroundings(row.ID, math.MaxInt64) + return fromRow(row), nil +} + +// ToggleFavorite flips dinner id's is_favorite, recording updatedBy. +func (s *Service) ToggleFavorite(ctx context.Context, id, updatedBy string) (Dinner, error) { + if updatedBy == "" { + return Dinner{}, errors.New("dinners: favorite: no member to record as updated_by") + } + row, err := db.New(s.st.Writer).ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{ + ID: id, + UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, + UpdatedAt: store.Now().String(), + }) + if errors.Is(err, sql.ErrNoRows) { + return Dinner{}, ErrNotFound + } + if err != nil { + return Dinner{}, fmt.Errorf("dinners: favorite %s: %w", id, err) + } + return fromRow(row), nil +} + +// validate checks every field of p and returns its trimmed name. +func (p Patch) validate() (string, error) { + var problems []Problem + var name string + if p.Name == nil && p.Method == nil && p.MinutesTotal == nil && p.Difficulty == nil { + return "", invalid("patch", "no fields to change") + } + if p.Name != nil { + n, err := checkName(*p.Name) + var verr *ValidationError + if errors.As(err, &verr) { + problems = append(problems, verr.Problems...) + } + name = n + } + if p.Method != nil && !slices.Contains(Methods, *p.Method) { + problems = append(problems, Problem{"method", "must be one of " + strings.Join(Methods, ", ")}) + } + if p.MinutesTotal != nil && (*p.MinutesTotal < 1 || *p.MinutesTotal > MaxMinutes) { + problems = append(problems, Problem{"minutes_total", fmt.Sprintf("must be 1 to %d", MaxMinutes)}) + } + if p.Difficulty != nil && !slices.Contains(Difficulties, *p.Difficulty) { + problems = append(problems, Problem{"difficulty", "must be one of " + strings.Join(Difficulties, ", ")}) + } + if problems != nil { + return "", &ValidationError{Problems: problems} + } + return name, nil +} + +// checkName returns raw trimmed, refusing a name that is empty, too long or +// has no words left once normalized (so it could not be deduplicated). +func checkName(raw string) (string, error) { + name := strings.TrimSpace(raw) + switch { + case name == "": + return "", invalid("name", "must not be empty") + case utf8.RuneCountInString(name) > MaxNameChars: + return "", invalid("name", fmt.Sprintf("must be at most %d characters", MaxNameChars)) + case harness.Normalize(name) == "": + return "", invalid("name", "must name a dish, not only filler words") + } + return name, nil +} + +// checkRecipeURL parses raw as an absolute https:// link to a recipe page, +// the only kind recipe grounding fetches. +func checkRecipeURL(raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if len(raw) > MaxRecipeURL { + return nil, invalid("recipe_url", fmt.Sprintf("must be at most %d bytes", MaxRecipeURL)) + } + u, err := url.Parse(raw) + switch { + case err != nil: + return nil, invalid("recipe_url", "is not a URL") + case u.Scheme != "https" || u.Hostname() == "": + return nil, invalid("recipe_url", "must be an https:// link") + case u.User != nil: + return nil, invalid("recipe_url", "must not carry a user name or password") + } + return u, nil +} + +// 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. +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 { + 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)} + } + } + return nil +} + +// PhotoURL is where the API serves a list-sized copy of photo id. +func PhotoURL(id string) string { + return fmt.Sprintf("/media/photos/%s-%d.jpg", id, photoListWidth) +} + +func fromRow(r db.Dinner) Dinner { + d := Dinner{ + ID: r.ID, + Name: r.Name, + Status: r.Status, + Origin: r.Origin, + Method: nullString(r.Method), + Difficulty: nullString(r.Difficulty), + Cuisine: nullString(r.Cuisine), + IsFavorite: r.IsFavorite == 1, + RecipeID: nullString(r.RecipeID), + GroundingStatus: r.GroundingStatus, + GroundingError: nullString(r.GroundingError), + UpdatedBy: nullString(r.UpdatedBy), + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } + if r.MinutesTotal.Valid { + d.MinutesTotal = &r.MinutesTotal.Int64 + } + if r.PhotoID.Valid { + u := PhotoURL(r.PhotoID.String) + d.PhotoURL = &u + } + return d +} + +func nullString(s sql.NullString) *string { + if !s.Valid { + return nil + } + return &s.String +} diff --git a/internal/dinners/dinners_test.go b/internal/dinners/dinners_test.go new file mode 100644 index 0000000..620fdc6 --- /dev/null +++ b/internal/dinners/dinners_test.go @@ -0,0 +1,74 @@ +package dinners_test + +import ( + "encoding/json" + "errors" + "strings" + "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) { + 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) + } + } + } + + _, 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()) +} diff --git a/internal/dinners/export_test.go b/internal/dinners/export_test.go new file mode 100644 index 0000000..ca06c60 --- /dev/null +++ b/internal/dinners/export_test.go @@ -0,0 +1,23 @@ +package dinners + +import ( + "context" + "testing" + "time" +) + +// SetGroundTimeout sets the deadline of grounding by hand until t ends. A +// test calling it must not run in parallel. +func SetGroundTimeout(t *testing.T, d time.Duration) { + old := groundTimeout + 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 +} diff --git a/internal/dinners/grounding.go b/internal/dinners/grounding.go new file mode 100644 index 0000000..c978505 --- /dev/null +++ b/internal/dinners/grounding.go @@ -0,0 +1,635 @@ +package dinners + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "time" + "unicode/utf8" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// KindGroundDinner is the job that grounds a new Regular in a recipe. +const KindGroundDinner = "ground_dinner" + +// Grounding statuses (the dinners.grounding_status column). +const ( + GroundingPending = "pending" + GroundingGrounded = "grounded" + GroundingFailed = "failed" + GroundingManual = "manual" +) + +// Reasons a dinner's background grounding failed, stored in +// dinners.grounding_error and as the KindGroundDinner job's failure code. +const ( + // ReasonNoRecipe: no page the search found holds a usable recipe. + ReasonNoRecipe = "no_recipe_found" + // ReasonRecipeUnusable: the dinner's recipe_url holds no usable recipe. + ReasonRecipeUnusable = "recipe_unusable" + // ReasonSearchNotConfigured: recipe search is not set up. + ReasonSearchNotConfigured = "search_not_configured" + // ReasonSearchFailed: the search provider failed. + ReasonSearchFailed = "search_failed" + // ReasonAIFailed: normalize_recipe's model provider could not be built + // or called. + ReasonAIFailed = "ai_failed" + // ReasonInterrupted: the server stopped while grounding. + ReasonInterrupted = "interrupted" + // ReasonGroundingError: any other failure, such as the database's. + ReasonGroundingError = "grounding_error" +) + +// Reasons are every grounding failure reason. +var Reasons = []string{ + ReasonNoRecipe, ReasonRecipeUnusable, ReasonSearchNotConfigured, ReasonSearchFailed, + ReasonAIFailed, ReasonInterrupted, ReasonGroundingError, +} + +// Limits on candidates and on a recipe entered by hand. +const ( + MaxCandidates = 3 + MaxManualLines = 100 + MaxManualLineChar = 500 + // manualSourcePrefix starts the source_url of a recipe entered by hand, + // followed by the recipe's id; it is never a fetchable page. + manualSourcePrefix = "mealquest:manual:" +) + +// GroundTimeout bounds grounding a candidate or link by hand, which runs in +// the request: the page fetch (recipes.Timeout), its photo download and +// normalize_recipe (harness.DefaultTimeout), with some slack. +const GroundTimeout = 90 * time.Second + +// groundTimeout is GroundTimeout; tests shorten it. +var groundTimeout = GroundTimeout + +// ErrGroundTimeout is returned by Ground when grounding a candidate or link +// 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. +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. +type groundResult struct { + DinnerID string `json:"dinner_id"` + GroundingStatus string `json:"grounding_status"` +} + +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. +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 { + 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) + } 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) + } + if err != nil { + reason := reasonFor(err, row.RecipeUrl.Valid) + if ctx.Err() != nil { + reason = ReasonInterrupted + } + // Only the reason is logged: the error may quote a dependency's + // response, which jobs never store, return or log. + s.logger.InfoContext(ctx, "dinner 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, + }); failErr != nil { + return groundResult{}, fmt.Errorf("dinners: ground: record failure of %s: %w", row.ID, failErr) + } + if ctx.Err() != nil { + return groundResult{}, ctx.Err() + } + return groundResult{}, &jobs.Failure{Code: reason} + } + + 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, + }) + if err != nil { + return groundResult{}, fmt.Errorf("dinners: ground: record %s: %w", row.ID, err) + } + if n == 0 { + return s.currentGrounding(ctx, row.ID) + } + 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 +// 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) { + n, err := db.New(s.st.Writer).FailOrphanedPendingDinnerGroundings(ctx, db.FailOrphanedPendingDinnerGroundingsParams{ + GroundingError: sql.NullString{String: ReasonInterrupted, Valid: true}, + UpdatedAt: store.Now().String(), + Kind: KindGroundDinner, + }) + if err != nil { + return 0, fmt.Errorf("dinners: fail interrupted groundings: %w", err) + } + if n > 0 { + s.logger.WarnContext(ctx, "failed dinner groundings interrupted by restart", "count", n) + } + return n, nil +} + +// reasonFor is the Reason for a grounding error; fromURL says whether the +// dinner's recipe_url was grounded rather than its name. +func reasonFor(err error, fromURL bool) string { + var ungroundable *recipes.ErrUngroundable + switch { + case errors.As(err, &ungroundable) && fromURL: + return ReasonRecipeUnusable + case errors.As(err, &ungroundable): + return ReasonNoRecipe + case errors.Is(err, recipes.ErrSearchNotConfigured): + return ReasonSearchNotConfigured + case errors.Is(err, recipes.ErrSearchFailed): + return ReasonSearchFailed + case errors.Is(err, recipes.ErrNormalizeFailed): + return ReasonAIFailed + } + return ReasonGroundingError +} + +// Candidate is a recipe page offered to ground a dinner in. +type Candidate struct { + // CandidateID is the page's URL; Fix.CandidateID grounds it. + CandidateID string `json:"candidate_id"` + Title string `json:"title"` + SiteName string `json:"site_name"` + // PhotoURL is the cached photo of a page already grounded, else null. + PhotoURL *string `json:"photo_url"` + Minutes *int `json:"minutes"` +} + +// Candidates searches for dinner id's name and returns up to MaxCandidates +// recipe pages to ground it in, best first; the list may be empty. A +// search that fails wraps recipes.ErrSearchFailed. +func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error) { + row, err := s.get(ctx, id) + if err != nil { + return nil, err + } + offers, err := s.grounder.Candidates(ctx, row.Name, row.Name, MaxCandidates) + if err != nil { + return nil, err + } + out := make([]Candidate, len(offers)) + for i, o := range offers { + out[i] = Candidate{CandidateID: o.URL, Title: o.Title, SiteName: o.SiteName, Minutes: o.Minutes} + if o.PhotoID != "" { + u := PhotoURL(o.PhotoID) + out[i].PhotoURL = &u + } + } + return out, nil +} + +// Fix grounds a dinner by hand: exactly one of a candidate from +// Candidates, a pasted recipe link or a recipe typed in. +type Fix struct { + CandidateID *string + RecipeURL *string + Manual *ManualRecipe +} + +// ManualRecipe is a recipe typed in by the household. +type ManualRecipe struct { + // Ingredients are the ingredient lines, at least one. + Ingredients []string + // Steps are the instructions, in order; there may be none. + Steps []string + Minutes *int + Method *string +} + +// 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. +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") + } + link, manual, err := f.validate() + if err != nil { + return Dinner{}, err + } + if manual != nil { + return s.groundManual(ctx, id, *manual, updatedBy) + } + cur, err := s.get(ctx, id) + if errors.Is(err, ErrNotFound) { + 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) + if err != nil { + if ctx.Err() == nil && errors.Is(groundCtx.Err(), context.DeadlineExceeded) { + return Dinner{}, ErrGroundTimeout + } + return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, 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, + }) + if errors.Is(err, sql.ErrNoRows) { + return Dinner{}, s.groundRejected(ctx, id) + } + if err != nil { + return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, 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. +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 { + q := db.New(tx) + cur, err := q.GetDinner(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + 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", + Name: cur.Name, + Ingredients: m.Ingredients, + Instructions: m.Steps, + TotalTime: fmt.Sprintf("PT%dM", *m.Minutes), + }) + if err != nil { + return fmt.Errorf("dinners: encode manual recipe: %w", err) + } + now := store.Now().String() + recipeID := store.NewID() + minutes := sql.NullInt64{Int64: int64(*m.Minutes), Valid: true} + method := sql.NullString{String: *m.Method, Valid: true} + rec, err := q.CreateManualRecipe(ctx, db.CreateManualRecipeParams{ + ID: recipeID, + SourceUrl: manualSourcePrefix + recipeID, + Title: cur.Name, + TotalMinutes: minutes, + RawJsonld: string(ld), + Method: method, + MinutesTotal: minutes, + FetchedAt: now, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + return fmt.Errorf("dinners: store manual recipe: %w", err) + } + for i, line := range m.Ingredients { + if err := q.CreateRecipeIngredient(ctx, db.CreateRecipeIngredientParams{ + ID: store.NewID(), + RecipeID: rec.ID, + Position: int64(i), + RawText: line, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + return fmt.Errorf("dinners: store manual ingredient %d: %w", i+1, err) + } + } + for i, text := range m.Steps { + if err := q.CreateRecipeStep(ctx, db.CreateRecipeStepParams{ + ID: store.NewID(), + RecipeID: rec.ID, + Position: int64(i), + Text: text, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + return fmt.Errorf("dinners: store manual step %d: %w", i+1, err) + } + } + 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, + }) + 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 fromRow(row), nil +} + +// manualJSONLD is the schema.org Recipe stored as a manual recipe's +// raw_jsonld, so it reads like a fetched page's. +type manualJSONLD struct { + Context string `json:"@context"` + Type string `json:"@type"` + Name string `json:"name"` + Ingredients []string `json:"recipeIngredient"` + Instructions []string `json:"recipeInstructions"` + TotalTime string `json:"totalTime"` +} + +// validate checks f and returns the link to ground, or the manual recipe +// with its lines trimmed. +func (f Fix) validate() (link string, manual *ManualRecipe, err error) { + given := 0 + for _, set := range []bool{f.CandidateID != nil, f.RecipeURL != nil, f.Manual != nil} { + if set { + given++ + } + } + if given != 1 { + return "", nil, invalid("ground", "give exactly one of candidate_id, recipe_url and manual") + } + switch { + case f.CandidateID != nil: + u, err := checkRecipeURL(*f.CandidateID) + if err != nil { + return "", nil, invalid("candidate_id", "is not a candidate from recipe-candidates") + } + return u.String(), nil, nil + case f.RecipeURL != nil: + u, err := checkRecipeURL(*f.RecipeURL) + if err != nil { + return "", nil, err + } + return u.String(), nil, nil + } + m, err := f.Manual.validate() + if err != nil { + return "", nil, err + } + return "", &m, nil +} + +// validate checks every field of m and returns it with its lines trimmed. +func (m ManualRecipe) validate() (ManualRecipe, error) { + var problems []Problem + ings, ingProblem := checkLines(m.Ingredients, 1) + if ingProblem != "" { + problems = append(problems, Problem{"manual.ingredients", ingProblem}) + } + steps, stepProblem := checkLines(m.Steps, 0) + if stepProblem != "" { + problems = append(problems, Problem{"manual.steps", stepProblem}) + } + if m.Minutes == nil || *m.Minutes < 1 || *m.Minutes > MaxMinutes { + problems = append(problems, Problem{"manual.minutes", fmt.Sprintf("must be 1 to %d", MaxMinutes)}) + } + if m.Method == nil || !slices.Contains(Methods, *m.Method) { + problems = append(problems, Problem{"manual.method", "must be one of " + strings.Join(Methods, ", ")}) + } + if problems != nil { + return ManualRecipe{}, &ValidationError{Problems: problems} + } + return ManualRecipe{Ingredients: ings, Steps: steps, Minutes: m.Minutes, Method: m.Method}, nil +} + +// checkLines returns lines trimmed, or why they are refused: fewer than +// least, more than MaxManualLines, or one blank or over MaxManualLineChar. +func checkLines(lines []string, least int) ([]string, string) { + if len(lines) < least { + return nil, fmt.Sprintf("must have at least %d line", least) + } + if len(lines) > MaxManualLines { + return nil, fmt.Sprintf("must have at most %d lines", MaxManualLines) + } + out := make([]string, len(lines)) + for i, line := range lines { + line = strings.TrimSpace(line) + switch { + case line == "": + return nil, fmt.Sprintf("line %d is blank", i+1) + case utf8.RuneCountInString(line) > MaxManualLineChar: + return nil, fmt.Sprintf("line %d is over %d characters", i+1, MaxManualLineChar) + } + out[i] = line + } + return out, "" +} + +// get reads dinner id, or ErrNotFound. +func (s *Service) get(ctx context.Context, id string) (db.Dinner, error) { + row, err := db.New(s.st.Reader).GetDinner(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return db.Dinner{}, ErrNotFound + } + if err != nil { + return db.Dinner{}, fmt.Errorf("dinners: read %s: %w", id, err) + } + return row, nil +} + +func nullInt(v *int) sql.NullInt64 { + if v == nil { + return sql.NullInt64{} + } + return sql.NullInt64{Int64: int64(*v), Valid: true} +} diff --git a/internal/dinners/grounding_test.go b/internal/dinners/grounding_test.go new file mode 100644 index 0000000..5cff022 --- /dev/null +++ b/internal/dinners/grounding_test.go @@ -0,0 +1,1315 @@ +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" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +const member = "member-1" + +// Fixture pages: two curry recipes from search and a stew at a pasted link. +const ( + curryA = "https://a.example/chicken-curry" + curryB = "https://b.example/curry" + stew = "https://c.example/recipes/beef-stew" +) + +type env struct { + svc *dinners.Service + st *store.Store + runner *jobs.Runner + search *recipestest.Search + pages *recipestest.Pages + model *llm.Fake +} + +// newEnv returns a dinners service over a started job runner, grounding +// with fake search, pages, images and model. Searching "Chicken curry" +// finds curryA, curryB and a missing page. +func newEnv(t *testing.T) *env { + t.Helper() + e := newUnstartedEnv(t) + e.start(t) + return e +} + +// newUnstartedEnv is newEnv with its job runner not yet started, so jobs +// enqueued stay queued until start. +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) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + 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) + } + search := &recipestest.Search{Results: map[string][]recipes.Candidate{ + "Chicken curry": {{URL: curryA}, {URL: "https://missing.example/x"}, {URL: curryB}}, + }} + pages := &recipestest.Pages{Bodies: map[string][]byte{ + curryA: recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40), + curryB: recipestest.RecipePage("Easy Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25), + stew: recipestest.RecipePage("Beef Stew", "Stew Co", "https://c.example/stew.jpg", 150), + }} + images := &recipestest.Images{JPEGs: map[string][]byte{ + "https://a.example/curry.jpg": recipestest.JPEG(t, 400, 320, 1), + "https://b.example/curry.jpg": recipestest.JPEG(t, 400, 320, 2), + "https://c.example/stew.jpg": recipestest.JPEG(t, 400, 320, 3), + }} + model := &llm.Fake{} + g := recipestest.NewGrounder(t, st, dataDir, search, pages, images, model) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + runner, err := jobs.NewRunner(st, logger, 1) + if err != nil { + t.Fatal(err) + } + svc, err := dinners.NewService(st, g, runner, logger) + if err != nil { + t.Fatal(err) + } + return &env{svc: svc, st: st, runner: runner, search: search, pages: pages, model: model} +} + +// start starts the env's job runner, stopping it when the test ends. +func (e *env) start(t *testing.T) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + if err := e.runner.Start(ctx); err != nil { + cancel() + t.Fatal(err) + } + t.Cleanup(func() { + cancel() + e.runner.Wait() + }) +} + +func ptr[T any](v T) *T { return &v } + +// waitJob waits for job id to finish and returns it. +func (e *env) waitJob(t *testing.T, id string) jobs.Job { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + job, err := e.runner.Get(t.Context(), id) + if err != nil { + t.Fatal(err) + } + if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed { + return job + } + if time.Now().After(deadline) { + t.Fatalf("job %s never finished; last %+v", id, job) + } + time.Sleep(5 * time.Millisecond) + } +} + +func (e *env) dinner(t *testing.T, id string) db.Dinner { + t.Helper() + row, err := db.New(e.st.Reader).GetDinner(t.Context(), id) + if err != nil { + t.Fatal(err) + } + return row +} + +func TestCreateGroundsInBackground(t *testing.T) { + t.Parallel() + e := newEnv(t) + e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40)) + + c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member) + if err != nil { + t.Fatal(err) + } + if c.GroundingStatus != dinners.GroundingPending || c.GroundingJobID == nil { + t.Fatalf("created = %+v, want pending with a job id", c) + } + job := e.waitJob(t, *c.GroundingJobID) + if job.Kind != dinners.KindGroundDinner || job.Status != jobs.StatusSucceeded || + string(job.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"grounded"}` { + t.Fatalf("job = %+v (result %s)", job, job.Result) + } + + row := e.dinner(t, c.ID) + if row.GroundingStatus != "grounded" || !row.RecipeID.Valid || !row.PhotoID.Valid || + row.Method.String != "stovetop" || row.MinutesTotal.Int64 != 40 || row.Difficulty.String != "easy" || row.GroundingError.Valid { + t.Fatalf("dinner = %+v, want grounded with recipe, photo, stovetop, 40 minutes, easy", row) + } + rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String) + if err != nil { + t.Fatal(err) + } + if rec.SourceUrl != curryA || rec.PhotoID != row.PhotoID { + t.Errorf("recipe = %s photo %s, want %s photo %s", rec.SourceUrl, rec.PhotoID.String, curryA, row.PhotoID.String) + } + if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) { + t.Errorf("searched %q, want the dinner's name", got) + } +} + +func TestCreateByRecipeURLGroundsThatURL(t *testing.T) { + t.Parallel() + 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) + if err != nil { + t.Fatal(err) + } + if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded { + t.Fatalf("job = %+v", job) + } + 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 != "grounded" || rec.SourceUrl != stew || row.Method.String != "oven" || row.MinutesTotal.Int64 != 150 { + t.Fatalf("dinner = %+v recipe %s, want grounded in %s", row, rec.SourceUrl, stew) + } + if q := e.search.Queries(); len(q) != 0 { + t.Errorf("searched %q; a recipe_url is grounded directly", q) + } +} + +func TestCreateGroundingFailureRecordsReason(t *testing.T) { + t.Parallel() + tests := []struct { + name string + create dinners.NewRegular + setup func(*env) + 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}, + {"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. + e.model.QueueGenerate(llm.GenerateResult{Err: llm.ErrAuth}) + }, dinners.ReasonAIFailed}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + e := newEnv(t) + if tt.setup != nil { + tt.setup(e) + } + c, err := e.svc.CreateRegular(t.Context(), tt.create, member) + if err != nil { + t.Fatal(err) + } + job := e.waitJob(t, *c.GroundingJobID) + if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != tt.reason { + t.Errorf("job = %+v, want failed with %s", job, tt.reason) + } + row := e.dinner(t, c.ID) + if row.GroundingStatus != "failed" || row.GroundingError.String != tt.reason || row.RecipeID.Valid { + t.Errorf("dinner = %+v, want failed with %s", row, tt.reason) + } + }) + } +} + +func TestCreateReturnsBeforeGroundingCompletes(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) + } + // The search is held, so the job cannot have finished. + if row := e.dinner(t, c.ID); row.GroundingStatus != "pending" { + t.Fatalf("grounding_status = %s right after create, want pending", row.GroundingStatus) + } + if job, err := e.runner.Get(t.Context(), *c.GroundingJobID); err != nil || (job.Status != jobs.StatusQueued && job.Status != jobs.StatusRunning) { + t.Fatalf("job = %+v, %v; want queued or running", job, err) + } + close(e.search.Gate) + if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded { + t.Fatalf("job = %+v", job) + } + if row := e.dinner(t, c.ID); row.GroundingStatus != "grounded" { + t.Errorf("grounding_status = %s, want grounded", row.GroundingStatus) + } +} + +func TestManualFixWhilePendingIsKept(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) + } + if _, err := e.svc.Ground(t.Context(), c.ID, dinners.Fix{Manual: &dinners.ManualRecipe{ + Ingredients: []string{"1 chicken"}, Minutes: ptr(30), Method: ptr("oven"), + }}, member); err != nil { + t.Fatal(err) + } + close(e.search.Gate) + job := e.waitJob(t, *c.GroundingJobID) + if job.Status != jobs.StatusSucceeded || string(job.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"manual"}` { + t.Errorf("job = %+v (result %s), want succeeded reporting manual", job, job.Result) + } + if row := e.dinner(t, c.ID); row.GroundingStatus != "manual" || row.Method.String != "oven" { + t.Errorf("dinner = %+v, want the manual grounding kept", row) + } +} + +// 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() + c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr(name)}, 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) + } + return c.ID +} + +func TestCandidates(t *testing.T) { + t.Parallel() + e := newEnv(t) + // The background grounding stores curryA with its cached photo. + 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 job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded { + t.Fatalf("job = %+v", job) + } + + got, err := e.svc.Candidates(t.Context(), c.ID) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("candidates = %+v, want the two usable pages", got) + } + first, second := got[0], got[1] + wantPhoto := dinners.PhotoURL(e.dinner(t, c.ID).PhotoID.String) + if first.CandidateID != curryA || first.Title != "Chicken Curry" || first.SiteName != "Curry House" || + first.PhotoURL == nil || *first.PhotoURL != wantPhoto || first.Minutes == nil || *first.Minutes != 40 { + t.Errorf("first = %+v, want the stored curryA with photo %s", first, wantPhoto) + } + if second.CandidateID != curryB || second.Title != "Easy Weeknight Curry" || second.SiteName != "B Kitchen" || + second.PhotoURL != nil || second.Minutes == nil || *second.Minutes != 25 { + t.Errorf("second = %+v, want curryB without a photo", second) + } + + if _, err := e.svc.Candidates(t.Context(), "nope"); !errors.Is(err, dinners.ErrNotFound) { + t.Errorf("unknown dinner: err = %v, want ErrNotFound", err) + } +} + +func TestCandidatesSearchFails(t *testing.T) { + t.Parallel() + e := newEnv(t) + e.search.Err = recipes.ErrSearchRefused + id := failedDinner(t, e, "Chicken curry") + if _, err := e.svc.Candidates(t.Context(), id); !errors.Is(err, recipes.ErrSearchFailed) { + t.Errorf("err = %v, want ErrSearchFailed", err) + } +} + +func TestCandidatesNone(t *testing.T) { + t.Parallel() + e := newEnv(t) + id := failedDinner(t, e, "Fish tacos") + got, err := e.svc.Candidates(t.Context(), id) + if err != nil || len(got) != 0 { + t.Fatalf("no search results: candidates = %+v, %v; want none", got, err) + } +} + +func TestFixViaCandidate(t *testing.T) { + t.Parallel() + e := newEnv(t) + id := failedDinner(t, e, "Mystery curry") + e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25)) + + d, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(curryB)}, "member-2") + if err != nil { + t.Fatal(err) + } + if d.GroundingStatus != "grounded" || d.GroundingError != nil || d.RecipeID == nil || d.PhotoURL == nil || + *d.Method != "stovetop" || *d.MinutesTotal != 25 || *d.Difficulty != "easy" || *d.UpdatedBy != "member-2" { + t.Fatalf("dinner = %+v", d) + } + rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), *d.RecipeID) + if err != nil { + t.Fatal(err) + } + if rec.SourceUrl != curryB { + t.Errorf("recipe source = %s, want %s", rec.SourceUrl, curryB) + } +} + +// 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) + id := failedDinner(t, e, "Grandma's stew") + e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150)) + + d, err := e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr(" " + stew + " ")}, member) + if err != nil { + t.Fatal(err) + } + if d.GroundingStatus != "grounded" || d.PhotoURL == nil || *d.Method != "oven" || *d.MinutesTotal != 150 { + t.Fatalf("dinner = %+v", d) + } + + // A link holding no recipe changes nothing. + before := e.dinner(t, id) + _, err = e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr("https://gone.example/x")}, member) + var ungroundable *recipes.ErrUngroundable + if !errors.As(err, &ungroundable) { + t.Fatalf("err = %v, want *recipes.ErrUngroundable", err) + } + if after := e.dinner(t, id); after != before { + t.Errorf("dinner changed by a failed fix: %+v, was %+v", after, before) + } +} + +func TestFixViaManual(t *testing.T) { + t.Parallel() + e := newEnv(t) + id := failedDinner(t, e, "Family chili") + if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Difficulty: ptr("medium")}, member); err != nil { + t.Fatal(err) + } + + d, err := e.svc.Ground(t.Context(), id, dinners.Fix{Manual: &dinners.ManualRecipe{ + Ingredients: []string{" 1 lb beef ", "1 can beans"}, + Steps: []string{"Brown the beef.", "Simmer with the beans."}, + Minutes: ptr(240), + Method: ptr("crockpot"), + }}, member) + if err != nil { + t.Fatal(err) + } + if d.GroundingStatus != "manual" || d.GroundingError != nil || d.RecipeID == nil || d.PhotoURL != nil || + *d.Method != "crockpot" || *d.MinutesTotal != 240 || *d.Difficulty != "medium" { + t.Fatalf("dinner = %+v", d) + } + q := db.New(e.st.Reader) + rec, err := q.GetRecipe(t.Context(), *d.RecipeID) + if err != nil { + t.Fatal(err) + } + if rec.Title != "Family chili" || rec.SourceUrl != "mealquest:manual:"+rec.ID || rec.MinutesTotal.Int64 != 240 || rec.PhotoID.Valid { + t.Errorf("recipe = %+v", rec) + } + ings, err := q.ListRecipeIngredients(t.Context(), rec.ID) + if err != nil { + t.Fatal(err) + } + steps, err := q.ListRecipeSteps(t.Context(), rec.ID) + if err != nil { + t.Fatal(err) + } + if len(ings) != 2 || ings[0].RawText != "1 lb beef" || len(steps) != 2 || steps[1].Text != "Simmer with the beans." { + t.Errorf("ingredients = %+v, steps = %+v", ings, steps) + } +} + +func TestFixValidation(t *testing.T) { + t.Parallel() + e := newEnv(t) + id := failedDinner(t, e, "Pad thai") + good := func() *dinners.ManualRecipe { + return &dinners.ManualRecipe{Ingredients: []string{"noodles"}, Minutes: ptr(20), Method: ptr("stovetop")} + } + tests := map[string]dinners.Fix{ + "nothing": {}, + "two": {CandidateID: ptr(curryA), RecipeURL: ptr(stew)}, + "candidate not URL": {CandidateID: ptr("cand-1")}, + "http link": {RecipeURL: ptr("http://a.example/x")}, + "no ingredients": {Manual: &dinners.ManualRecipe{Minutes: ptr(20), Method: ptr("oven")}}, + "blank ingredient": {Manual: &dinners.ManualRecipe{Ingredients: []string{" "}, Minutes: ptr(20), Method: ptr("oven")}}, + "blank step": {Manual: func() *dinners.ManualRecipe { m := good(); m.Steps = []string{""}; return m }()}, + "no minutes": {Manual: func() *dinners.ManualRecipe { m := good(); m.Minutes = nil; return m }()}, + "zero minutes": {Manual: func() *dinners.ManualRecipe { m := good(); m.Minutes = ptr(0); return m }()}, + "unknown method": {Manual: func() *dinners.ManualRecipe { m := good(); m.Method = ptr("microwave"); return m }()}, + "no method": {Manual: func() *dinners.ManualRecipe { m := good(); m.Method = nil; return m }()}, + "too many lines": {Manual: func() *dinners.ManualRecipe { m := good(); m.Ingredients = make([]string, 101); return m }()}, + "manual and a link": {RecipeURL: ptr(stew), Manual: good()}, + "over-long ing line": {Manual: func() *dinners.ManualRecipe { + m := good() + m.Ingredients = []string{string(make([]byte, 501))} + return m + }()}, + } + before := e.dinner(t, id) + for name, fix := range tests { + _, err := e.svc.Ground(t.Context(), id, fix, member) + var invalid *dinners.ValidationError + if !errors.As(err, &invalid) { + t.Errorf("%s: err = %v, want *ValidationError", name, err) + } + } + if after := e.dinner(t, id); after != before { + t.Errorf("dinner changed by invalid fixes: %+v", after) + } + if _, err := e.svc.Ground(t.Context(), "nope", dinners.Fix{Manual: good()}, member); !errors.Is(err, dinners.ErrNotFound) { + t.Errorf("unknown dinner, manual: err = %v, want ErrNotFound", err) + } + if _, err := e.svc.Ground(t.Context(), "nope", dinners.Fix{RecipeURL: ptr(stew)}, member); !errors.Is(err, dinners.ErrNotFound) { + t.Errorf("unknown dinner, link: err = %v, want ErrNotFound", err) + } +} + +// TestRestartFailsInterruptedGrounding simulates a crash: one dinner's job +// was left running and another dinner was saved before its job was +// enqueued. At the next boot both end failed as interrupted, while a +// dinner whose job is still queued stays pending and is grounded. +func TestRestartFailsInterruptedGrounding(t *testing.T) { + t.Parallel() + e := newUnstartedEnv(t) + e.search.Gate = make(chan struct{}) + e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40)) + + running, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Fish pie")}, member) + if err != nil { + t.Fatal(err) + } + if _, err := e.st.Writer.ExecContext(t.Context(), + `UPDATE jobs SET status = 'running', started_at = updated_at WHERE id = ?`, *running.GroundingJobID); err != nil { + t.Fatal(err) + } + unqueued, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Lentil soup")}, member) + if err != nil { + t.Fatal(err) + } + if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM jobs WHERE id = ?`, *unqueued.GroundingJobID); err != nil { + t.Fatal(err) + } + queued, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member) + if err != nil { + t.Fatal(err) + } + + // Boot: the runner fails the running job, then the service fails the + // dinners nothing is left to ground. The search is held, so the queued + // dinner's job cannot finish meanwhile. + e.start(t) + n, err := e.svc.FailInterruptedGroundings(t.Context()) + if err != nil { + t.Fatal(err) + } + if n != 2 { + t.Errorf("failed %d dinners, want 2", n) + } + if job, err := e.runner.Get(t.Context(), *running.GroundingJobID); err != nil || job.Status != jobs.StatusFailed { + t.Errorf("running job = %+v, %v; want failed", job, err) + } + for _, id := range []string{running.ID, unqueued.ID} { + if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed || row.GroundingError.String != dinners.ReasonInterrupted { + t.Errorf("dinner %s = %s (%s), want failed (interrupted)", id, row.GroundingStatus, row.GroundingError.String) + } + } + if row := e.dinner(t, queued.ID); row.GroundingStatus != dinners.GroundingPending { + t.Errorf("queued dinner = %s, want pending", row.GroundingStatus) + } + close(e.search.Gate) + if job := e.waitJob(t, *queued.GroundingJobID); job.Status != jobs.StatusSucceeded { + t.Fatalf("queued job = %+v", job) + } + if row := e.dinner(t, queued.ID); row.GroundingStatus != dinners.GroundingGrounded { + t.Errorf("queued dinner = %s, want grounded", row.GroundingStatus) + } +} + +// 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) { + t.Parallel() + e := newEnv(t) + 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) + } + c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member) + if err != nil { + t.Fatal(err) + } + if c.ID == "" || c.GroundingJobID != nil || c.GroundingStatus != dinners.GroundingFailed || + c.GroundingError == nil || *c.GroundingError != dinners.ReasonGroundingError { + t.Fatalf("created = %+v, want its id, failed (grounding_error) and no job", c) + } + if row := e.dinner(t, c.ID); row.GroundingStatus != dinners.GroundingFailed { + t.Errorf("stored grounding_status = %s, want failed", row.GroundingStatus) + } +} + +// TestGroundTimesOut: grounding a link by hand that runs past its deadline +// is ErrGroundTimeout and leaves the dinner as it was. It shortens the +// package deadline, so it does not run in parallel. +func TestGroundTimesOut(t *testing.T) { + dinners.SetGroundTimeout(t, 50*time.Millisecond) + e := newEnv(t) + id := failedDinner(t, e, "Beef stew") + e.pages.Gate = make(chan struct{}) + defer close(e.pages.Gate) + + _, err := e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr(stew)}, member) + if !errors.Is(err, dinners.ErrGroundTimeout) { + t.Fatalf("err = %v, want ErrGroundTimeout", err) + } + if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed { + 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) + } +} diff --git a/internal/household/settings.go b/internal/household/settings.go index 1e1df89..345e87a 100644 --- a/internal/household/settings.go +++ b/internal/household/settings.go @@ -68,8 +68,18 @@ 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 @@ -162,7 +172,8 @@ 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. +// nothing, as does completing onboarding with fewer than MinRegulars +// Regulars. 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") @@ -208,6 +219,19 @@ 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) @@ -291,6 +315,9 @@ 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} } diff --git a/internal/household/settings_test.go b/internal/household/settings_test.go index a01120c..b277895 100644 --- a/internal/household/settings_test.go +++ b/internal/household/settings_test.go @@ -3,10 +3,13 @@ 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" ) @@ -34,12 +37,34 @@ 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) + 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) + } + } } func ptr[T any](v T) *T { return &v } @@ -217,6 +242,70 @@ 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() @@ -259,6 +348,7 @@ 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)}, diff --git a/internal/recipes/ground.go b/internal/recipes/ground.go index bb52b71..6b14436 100644 --- a/internal/recipes/ground.go +++ b/internal/recipes/ground.go @@ -25,6 +25,13 @@ var ( // ErrTotalTimeNotPositive rejects a recipe whose page gives a total // time of zero minutes, which normalize_recipe refuses. ErrTotalTimeNotPositive = errors.New("recipes: recipe total time is not positive") + // ErrSearchFailed wraps every failure to search: the search provider + // could not be built (ErrSearchNotConfigured among them) or its search + // failed. + ErrSearchFailed = errors.New("recipes: recipe search failed") + // ErrNormalizeFailed wraps a normalize_recipe failure that is not the + // recipe's: the model provider could not be built or called. + ErrNormalizeFailed = errors.New("recipes: normalize_recipe failed") ) // ErrUngroundable is returned when no recipe page could ground a dish or a @@ -118,43 +125,16 @@ func (g *Grounder) Ground(ctx context.Context, dishName, query string) (Grounded if strings.TrimSpace(dishName) == "" { return Grounded{}, errors.New("recipes: ground: dish name is empty") } - provider, err := g.search.Provider(ctx) + found, err := g.searchRanked(ctx, dishName, query) if err != nil { return Grounded{}, fmt.Errorf("recipes: ground %q: %w", dishName, err) } - cands, err := provider.Search(ctx, query, RankDepth) - if err != nil { - return Grounded{}, fmt.Errorf("recipes: ground %q: search: %w", dishName, err) - } - if len(cands) == 0 { - return Grounded{}, &ErrUngroundable{Target: dishName, Reasons: []error{ErrNoCandidates}} - } - cands = cands[:min(len(cands), RankDepth)] - stored := make(map[string]storedRecipe) - for _, c := range cands { - sr, ok, err := g.storedRecipe(ctx, c.URL) - if err != nil { - return Grounded{}, fmt.Errorf("recipes: ground %q: %w", dishName, err) - } - if ok { - stored[c.URL] = sr - } - } - ranked, rankErr := rank(ctx, dishName, cands, func(ctx context.Context, c Candidate) (Recipe, error) { - if sr, ok := stored[c.URL]; ok { - return sr.recipe, nil - } - return fetchRecipe(ctx, g.pages, c.URL) - }) - if err := ctx.Err(); err != nil { - return Grounded{}, err - } var reasons []error - if rankErr != nil { - reasons = append(reasons, rankErr) + if found.rankErr != nil { + reasons = append(reasons, found.rankErr) } - for _, r := range ranked { - if sr, ok := stored[r.Candidate.URL]; ok { + for _, r := range found.ranked { + if sr, ok := found.stored[r.Candidate.URL]; ok { return sr.grounded, nil } gr, reason, err := g.ground(ctx, r.Candidate.URL, r.Recipe) @@ -169,6 +149,96 @@ func (g *Grounder) Ground(ctx context.Context, dishName, query string) (Grounded return Grounded{}, &ErrUngroundable{Target: dishName, Reasons: reasons} } +// rankedSearch is a search's candidates ranked: the ranked ones best first, +// those already stored by URL, and why any were not ranked. +type rankedSearch struct { + ranked []Ranked + stored map[string]storedRecipe + // rankErr is Rank's error, or ErrNoCandidates when the search found + // nothing. + rankErr error +} + +// searchRanked searches for query and ranks the top RankDepth candidates +// for dishName as Ground describes. A search, database or context failure +// is its error; candidates that do not rank are only rankErr's. +func (g *Grounder) searchRanked(ctx context.Context, dishName, query string) (rankedSearch, error) { + provider, err := g.search.Provider(ctx) + if err != nil { + return rankedSearch{}, fmt.Errorf("%w: %w", ErrSearchFailed, err) + } + cands, err := provider.Search(ctx, query, RankDepth) + if err != nil { + return rankedSearch{}, fmt.Errorf("%w: %w", ErrSearchFailed, err) + } + if len(cands) == 0 { + return rankedSearch{rankErr: ErrNoCandidates}, nil + } + cands = cands[:min(len(cands), RankDepth)] + stored := make(map[string]storedRecipe) + for _, c := range cands { + sr, ok, err := g.storedRecipe(ctx, c.URL) + if err != nil { + return rankedSearch{}, err + } + if ok { + stored[c.URL] = sr + } + } + ranked, rankErr := rank(ctx, dishName, cands, func(ctx context.Context, c Candidate) (Recipe, error) { + if sr, ok := stored[c.URL]; ok { + return sr.recipe, nil + } + return fetchRecipe(ctx, g.pages, c.URL) + }) + if err := ctx.Err(); err != nil { + return rankedSearch{}, err + } + return rankedSearch{ranked: ranked, stored: stored, rankErr: rankErr}, nil +} + +// Offer is a ranked candidate offered for the household to ground a dish +// in, with GroundURL on its URL. +type Offer struct { + URL string + Title string + SiteName string + // PhotoID is the cached photo of a candidate already stored, "" for + // one that is not: a candidate's photo is cached only when it grounds. + PhotoID string + // Minutes is a stored candidate's normalized minutes_total, else its + // page's total time; nil when unknown. + Minutes *int +} + +// Candidates searches for query and returns up to n of the candidates +// Ground would try for dishName, best first. It fetches and extracts pages +// but downloads no photo and runs no model. Candidates that hold no usable +// recipe are not offered, so the list may be empty; a search that fails is +// an error wrapping ErrSearchFailed. +func (g *Grounder) Candidates(ctx context.Context, dishName, query string, n int) ([]Offer, error) { + if strings.TrimSpace(dishName) == "" { + return nil, errors.New("recipes: candidates: dish name is empty") + } + if n < 1 { + return nil, fmt.Errorf("recipes: candidates: n is %d, want at least 1", n) + } + found, err := g.searchRanked(ctx, dishName, query) + if err != nil { + return nil, fmt.Errorf("recipes: candidates for %q: %w", dishName, err) + } + offers := make([]Offer, 0, min(n, len(found.ranked))) + for _, r := range found.ranked[:min(n, len(found.ranked))] { + o := Offer{URL: r.Candidate.URL, Title: r.Recipe.Title, SiteName: r.Recipe.SiteName, Minutes: r.Recipe.TotalMinutes} + if sr, ok := found.stored[r.Candidate.URL]; ok { + o.PhotoID = sr.grounded.PhotoID + o.Minutes = sr.grounded.MinutesTotal + } + offers = append(offers, o) + } + return offers, nil +} + // GroundURL grounds the recipe on the page at rawURL, a pasted URL, as // Ground does a candidate. An already stored rawURL is returned without // fetching it. A page that cannot be fetched, holds no usable recipe, or @@ -196,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 %s: %w", rawURL, err) + return Grounded{}, fmt.Errorf("recipes: ground pasted link: %w", err) } if reason != nil { return ungroundable(reason) @@ -344,7 +414,7 @@ func (g *Grounder) ground(ctx context.Context, sourceURL string, r Recipe) (gr G return Grounded{}, fmt.Errorf("normalize: %w", err), nil } if err != nil { - return Grounded{}, nil, fmt.Errorf("normalize: %w", err) + return Grounded{}, nil, fmt.Errorf("%w: %w", ErrNormalizeFailed, err) } if len(norm.Ingredients) != len(r.Ingredients) { return Grounded{}, nil, fmt.Errorf("normalize: %d ingredients for %d lines", len(norm.Ingredients), len(r.Ingredients)) diff --git a/internal/recipes/ground_test.go b/internal/recipes/ground_test.go index 8bde81a..3932616 100644 --- a/internal/recipes/ground_test.go +++ b/internal/recipes/ground_test.go @@ -516,3 +516,38 @@ func TestGroundManyLimitsConcurrency(t *testing.T) { t.Errorf("max in flight %d of %d calls, want %d of 5", search.maxActive, search.callCount(), GroundConcurrency) } } + +func TestCandidatesOffersBestFirstUpToN(t *testing.T) { + t.Parallel() + var cands []Candidate + pages := map[string][]byte{} + for i, title := range []string{"Fish Tacos", "Fish Taco Bowls", "Baja Fish Tacos", "Tacos"} { + u := fmt.Sprintf("https://site%d.example/tacos", i) + cands = append(cands, Candidate{URL: u}) + pages[u] = groundPage(title, "/img/tacos.jpg") + } + search := &fakeSearch{results: map[string][]Candidate{"fish tacos": cands}} + env := newGroundEnv(t, pages, nil, search) + + got, err := env.g.Candidates(t.Context(), "Fish Tacos", "fish tacos", 3) + if err != nil { + t.Fatal(err) + } + if len(got) != 3 || got[0].URL != cands[0].URL || got[0].Title != "Fish Tacos" || got[0].SiteName != "Example Kitchen" || + got[0].PhotoID != "" || got[0].Minutes == nil || *got[0].Minutes != 40 { + t.Fatalf("Candidates = %+v, want 3 with Fish Tacos first", got) + } + if env.images.requests.Load() != 0 || len(env.llm.Requests()) != 0 { + t.Errorf("Candidates downloaded a photo or ran the model") + } +} + +func TestCandidatesSearchFailureWrapsErrSearchFailed(t *testing.T) { + t.Parallel() + search := &fakeSearch{errs: map[string]error{"stew": ErrSearchRateLimited}} + env := newGroundEnv(t, nil, nil, search) + _, err := env.g.Candidates(t.Context(), "Stew", "stew", 3) + if !errors.Is(err, ErrSearchFailed) || !errors.Is(err, ErrSearchRateLimited) { + t.Errorf("err = %v, want ErrSearchFailed wrapping ErrSearchRateLimited", err) + } +} diff --git a/internal/recipes/photo.go b/internal/recipes/photo.go index b57d223..f5d22db 100644 --- a/internal/recipes/photo.go +++ b/internal/recipes/photo.go @@ -65,22 +65,28 @@ var photoDecoders = map[string]func([]byte) (image.Image, error){ "webp": func(b []byte) (image.Image, error) { return webp.Decode(bytes.NewReader(b)) }, } -// PhotoCache downloads photos through a Fetcher and stores each at every +// ImageFetcher fetches an image; *Fetcher satisfies it. +type ImageFetcher interface { + FetchImage(ctx context.Context, rawURL string) (Page, error) +} + +// PhotoCache downloads photos through an ImageFetcher and stores each at every // PhotoSizes length as {id}-{size}.jpg under PhotoDir, with a photos row. // Photos are deduplicated by the SHA-256 of the downloaded bytes. A // PhotoCache is safe for concurrent use. type PhotoCache struct { - fetcher *Fetcher - store *store.Store + images ImageFetcher + store *store.Store // dir is the absolute photo directory. dir string } -// NewPhotoCache returns a PhotoCache storing photos under dataDir, creating -// the photo directory if it is absent. -func NewPhotoCache(fetcher *Fetcher, st *store.Store, dataDir string) (*PhotoCache, error) { - if fetcher == nil { - return nil, errors.New("recipes: nil fetcher") +// NewPhotoCache returns a PhotoCache downloading images with images and +// storing photos under dataDir, creating the photo directory if it is +// absent. +func NewPhotoCache(images ImageFetcher, st *store.Store, dataDir string) (*PhotoCache, error) { + if images == nil { + return nil, errors.New("recipes: nil image fetcher") } if st == nil { return nil, errors.New("recipes: nil store") @@ -92,7 +98,7 @@ func NewPhotoCache(fetcher *Fetcher, st *store.Store, dataDir string) (*PhotoCac if err := os.MkdirAll(dir, 0o750); err != nil { return nil, fmt.Errorf("recipes: create photo directory: %w", err) } - return &PhotoCache{fetcher: fetcher, store: st, dir: dir}, nil + return &PhotoCache{images: images, store: st, dir: dir}, nil } // DownloadedPhoto is an image DownloadPhoto has downloaded and decoded, @@ -108,7 +114,7 @@ type DownloadedPhoto struct { // whose shorter side is under PhotoMinShortSide. It records nothing, so a // caller can try another image before opening a transaction. func (c *PhotoCache) DownloadPhoto(ctx context.Context, rawURL string) (DownloadedPhoto, error) { - page, err := c.fetcher.FetchImage(ctx, rawURL) + page, err := c.images.FetchImage(ctx, rawURL) if err != nil { return DownloadedPhoto{}, err } diff --git a/internal/recipes/recipestest/recipestest.go b/internal/recipes/recipestest/recipestest.go new file mode 100644 index 0000000..9d22dfa --- /dev/null +++ b/internal/recipes/recipestest/recipestest.go @@ -0,0 +1,183 @@ +// Package recipestest provides fakes for tests that ground dinners outside +// package recipes: a search, page and image fetchers serving fixtures, and +// a Grounder over them that normalizes through the real harness and +// normalize_recipe skill with a scripted model. +package recipestest + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "image" + "image/color" + "image/jpeg" + "sync" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// Search is a recipes.SearchSource and recipes.SearchProvider answering +// each query from Results. Err, when set, fails every search. Gate, when +// set, makes every search wait for it to close. +type Search struct { + Results map[string][]recipes.Candidate + Err error + Gate chan struct{} + + mu sync.Mutex + queries []string +} + +func (s *Search) Provider(context.Context) (recipes.SearchProvider, error) { return s, nil } + +func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Candidate, error) { + s.mu.Lock() + s.queries = append(s.queries, query) + s.mu.Unlock() + if s.Gate != nil { + select { + case <-s.Gate: + case <-ctx.Done(): + return nil, ctx.Err() + } + } + if s.Err != nil { + return nil, s.Err + } + c := s.Results[query] + return c[:min(n, len(c))], nil +} + +// Queries returns every query searched, in order. +func (s *Search) Queries() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.queries...) +} + +// Pages is a recipes.PageFetcher serving Bodies by URL; any other URL is a +// 404. Gate, when set, makes every fetch wait for it to close. +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: + case <-ctx.Done(): + return recipes.Page{}, ctx.Err() + } + } + body, ok := p.Bodies[rawURL] + if !ok { + return recipes.Page{}, &recipes.StatusError{URL: rawURL, Status: 404} + } + 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 { + JPEGs map[string][]byte +} + +func (i *Images) FetchImage(_ context.Context, rawURL string) (recipes.Page, error) { + body, ok := i.JPEGs[rawURL] + if !ok { + return recipes.Page{}, &recipes.StatusError{URL: rawURL, Status: 404} + } + return recipes.Page{URL: rawURL, ContentType: "image/jpeg", Body: body}, nil +} + +// JPEG is a w×h JPEG whose pixels depend on seed, so different seeds give +// different photos. +func JPEG(t testing.TB, w, h int, seed uint8) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := range h { + for x := range w { + img.Set(x, y, color.RGBA{uint8(x), uint8(y), seed, 255}) + } + } + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, nil); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// RecipePage is a page whose schema.org Recipe is title, from site, with +// two ingredients, two steps, a total time of minutes and the image at +// imageURL. +func RecipePage(title, site, imageURL string, minutes int) []byte { + return fmt.Appendf(nil, ` + +`, site, title, imageURL, minutes) +} + +// NormalizedReply is a usable normalize_recipe reply for a RecipePage with +// method and minutes. +func NormalizedReply(method string, minutes int) llm.GenerateResult { + js := fmt.Sprintf(`{"ingredients":[ + {"name":"onion","qty":"1","unit":"","note":""}, + {"name":"garlic","qty":"2","unit":"cloves","note":"minced"} + ],"method":%q,"difficulty":null,"minutes_total":%d}`, method, minutes) + return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}} +} + +// NewGrounder returns a Grounder storing in st, with photos cached under +// dataDir, normalizing through the real harness with model. +func NewGrounder(t testing.TB, st *store.Store, dataDir string, search recipes.SearchSource, pages recipes.PageFetcher, images recipes.ImageFetcher, model llm.Provider) *recipes.Grounder { + t.Helper() + photos, err := recipes.NewPhotoCache(images, st, dataDir) + if err != nil { + t.Fatal(err) + } + guard, err := harness.NewGuard(harness.DefaultMaxCalls) + if err != nil { + t.Fatal(err) + } + cfg := harness.Config{ + Settings: llm.Settings{Provider: "anthropic", Model: "claude-test"}, + NewProvider: func(llm.Settings, string) (llm.Provider, error) { return model, nil }, + Store: st, + Guard: guard, + } + normalize := func(ctx context.Context, scope string, in skills.RecipeText) (skills.NormalizedRecipe, error) { + return skills.NormalizeRecipe(ctx, cfg, scope, in) + } + g, err := recipes.NewGrounder(st, search, pages, photos, normalize) + if err != nil { + t.Fatal(err) + } + return g +} diff --git a/internal/server/aisettings_test.go b/internal/server/aisettings_test.go index 8bbd281..4c58c43 100644 --- a/internal/server/aisettings_test.go +++ b/internal/server/aisettings_test.go @@ -28,7 +28,7 @@ const ( func newAIRouter(t *testing.T) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/dinners.go b/internal/server/dinners.go new file mode 100644 index 0000000..d78cb9d --- /dev/null +++ b/internal/server/dinners.go @@ -0,0 +1,251 @@ +package server + +import ( + "errors" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" +) + +// maxDinnerBody bounds a POST or PATCH /dinners request body. +const maxDinnerBody = 16 << 10 + +// maxGroundBody bounds a POST /dinners/{id}/ground body, which may carry a +// 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. +type dinnerCreate struct { + Name *string `json:"name"` + RecipeURL *string `json:"recipe_url"` +} + +// dinnerPatch is the PATCH /dinners/{id} body. Every field is optional; +// unknown fields and null values are refused. +type dinnerPatch struct { + Name *string `json:"name"` + Method *string `json:"method"` + MinutesTotal *int `json:"minutes_total"` + Difficulty *string `json:"difficulty"` +} + +type dinnerList struct { + Dinners []dinners.Dinner `json:"dinners"` +} + +// dinnerGround is the POST /dinners/{id}/ground body: exactly one of +// candidate_id, recipe_url and manual. +type dinnerGround struct { + CandidateID *string `json:"candidate_id"` + RecipeURL *string `json:"recipe_url"` + Manual *dinnerManualRecipe `json:"manual"` +} + +type dinnerManualRecipe struct { + Ingredients []string `json:"ingredients"` + Steps []string `json:"steps"` + Minutes *int `json:"minutes"` + Method *string `json:"method"` +} + +type candidateList struct { + Candidates []dinners.Candidate `json:"candidates"` +} + +// getDinners answers GET /dinners?status=regular|quest_log. +func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + list, err := svc.List(r.Context(), r.URL.Query().Get("status")) + if err != nil { + writeDinnerError(w, r, logger, "list dinners", err) + return + } + writeJSON(w, r, logger, http.StatusOK, dinnerList{Dinners: list}) + } +} + +// postDinner adds a Regular, recording the session's actor as updated_by, +// and answers 201 with it and its grounding_job_id without waiting for the +// job. When the job cannot be enqueued it still answers 201, with the dinner +// grounding_status failed and grounding_job_id null. +func postDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + actor, ok := actorOrFail(w, r, logger) + if !ok { + return + } + body, err := decodePatch[dinnerCreate](http.MaxBytesReader(w, r.Body, maxDinnerBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + d, err := svc.CreateRegular(r.Context(), dinners.NewRegular(body), actor.Subject) + if err != nil { + writeDinnerError(w, r, logger, "create dinner", err) + return + } + writeJSON(w, r, logger, http.StatusCreated, d) + } +} + +// patchDinner edits a dinner, recording the session's actor as updated_by. +func patchDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + actor, ok := actorOrFail(w, r, logger) + if !ok { + return + } + patch, err := decodePatch[dinnerPatch](http.MaxBytesReader(w, r.Body, maxDinnerBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + d, err := svc.Update(r.Context(), chi.URLParam(r, "id"), dinners.Patch(patch), actor.Subject) + if err != nil { + writeDinnerError(w, r, logger, "update dinner", err) + return + } + writeJSON(w, r, logger, http.StatusOK, d) + } +} + +// getDinnerCandidates answers GET /dinners/{id}/recipe-candidates with up +// to dinners.MaxCandidates recipe pages for the dinner's name. +func getDinnerCandidates(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + list, err := svc.Candidates(r.Context(), chi.URLParam(r, "id")) + if err != nil { + writeDinnerError(w, r, logger, "list recipe candidates", err) + return + } + writeJSON(w, r, logger, http.StatusOK, candidateList{Candidates: list}) + } +} + +// postDinnerGround grounds a dinner by hand in a candidate, a pasted link +// or a recipe typed in, recording the session's actor as updated_by. +func postDinnerGround(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + actor, ok := actorOrFail(w, r, logger) + if !ok { + return + } + body, err := decodePatch[dinnerGround](http.MaxBytesReader(w, r.Body, maxGroundBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + fix := dinners.Fix{CandidateID: body.CandidateID, RecipeURL: body.RecipeURL} + if body.Manual != nil { + m := dinners.ManualRecipe(*body.Manual) + fix.Manual = &m + } + d, err := svc.Ground(r.Context(), chi.URLParam(r, "id"), fix, actor.Subject) + if err != nil { + writeDinnerError(w, r, logger, "ground dinner", err) + return + } + writeJSON(w, r, logger, http.StatusOK, d) + } +} + +// deleteDinner archives a dinner; the row is never deleted. +func deleteDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + actor, ok := actorOrFail(w, r, logger) + if !ok { + return + } + d, err := svc.Archive(r.Context(), chi.URLParam(r, "id"), actor.Subject) + if err != nil { + writeDinnerError(w, r, logger, "archive dinner", err) + return + } + writeJSON(w, r, logger, http.StatusOK, d) + } +} + +// postDinnerFavorite toggles a dinner's is_favorite. +func postDinnerFavorite(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + actor, ok := actorOrFail(w, r, logger) + if !ok { + return + } + d, err := svc.ToggleFavorite(r.Context(), chi.URLParam(r, "id"), actor.Subject) + if err != nil { + writeDinnerError(w, r, logger, "toggle dinner favorite", err) + return + } + writeJSON(w, r, logger, http.StatusOK, d) + } +} + +// actorOrFail returns the session's actor, or answers 500 when the request +// has none, which behind requireSession is a wiring bug. +func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (auth.Actor, bool) { + actor, ok := auth.ActorFrom(r.Context()) + if !ok { + logger.ErrorContext(r.Context(), r.Method+" "+r.URL.Path+" without a session") + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + } + return actor, ok +} + +// 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. +func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) { + var invalid *dinners.ValidationError + var dup *dinners.DuplicateError + var ungroundable *recipes.ErrUngroundable + switch { + case errors.As(err, &ungroundable): + // The reasons quote dependency errors, so they are neither returned + // nor logged. + writeError(w, r, logger, http.StatusUnprocessableEntity, "ungroundable", "no usable recipe was found at that link") + case errors.Is(err, recipes.ErrSearchNotConfigured): + writeError(w, r, logger, http.StatusConflict, "not_configured", "recipe search is not configured") + case errors.Is(err, recipes.ErrSearchFailed): + // The error may quote the search provider's response, so only the + // reason is logged. + logger.WarnContext(r.Context(), action, "reason", "search_failed") + writeError(w, r, logger, http.StatusBadGateway, "search_failed", "recipe search failed") + 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") + case errors.As(err, &invalid): + writeError(w, r, logger, http.StatusBadRequest, "invalid_dinner", invalid.Error()) + case errors.As(err, &dup): + writeError(w, r, logger, http.StatusConflict, "duplicate_dinner", dup.Error()) + case errors.Is(err, dinners.ErrNotFound): + writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found") + default: + logger.ErrorContext(r.Context(), action, "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + } +} diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go new file mode 100644 index 0000000..49e617c --- /dev/null +++ b/internal/server/dinners_test.go @@ -0,0 +1,616 @@ +package server + +import ( + "bytes" + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "testing/fstest" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +const dinnersPath = "/api/v1/dinners" + +// newDinnersRouter returns a router whose dinners service runs over the +// returned store. Its job runner is not started, so grounding jobs stay +// queued. +func newDinnersRouter(t *testing.T) (http.Handler, *store.Store) { + t.Helper() + e := newDinnersEnv(t) + return e.h, e.st +} + +// dinnersEnv is a router whose dinners service, job runner and grounder +// share one store, grounding with fake search, pages, images and model. +type dinnersEnv struct { + h http.Handler + st *store.Store + runner *jobs.Runner + search *recipestest.Search + pages *recipestest.Pages + images *recipestest.Images + model *llm.Fake + // logs holds everything the router and services log. + logs *logBuffer +} + +// logBuffer is a bytes.Buffer safe for concurrent log writes. +type logBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *logBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *logBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func newDinnersEnv(t *testing.T) *dinnersEnv { + t.Helper() + st := newTestStore(t) + e := &dinnersEnv{ + st: st, + search: &recipestest.Search{Results: map[string][]recipes.Candidate{}}, + pages: &recipestest.Pages{Bodies: map[string][]byte{}}, + images: &recipestest.Images{JPEGs: map[string][]byte{}}, + model: &llm.Fake{}, + logs: &logBuffer{}, + } + logger := slog.New(slog.NewTextHandler(e.logs, nil)) + runner, err := jobs.NewRunner(st, logger, 1) + if err != nil { + t.Fatal(err) + } + e.runner = runner + g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model) + svc, err := dinners.NewService(st, g, runner, logger) + if err != nil { + t.Fatal(err) + } + e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), + svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + if err != nil { + t.Fatal(err) + } + return e +} + +// start starts the env's job runner, stopping it when the test ends. +func (e *dinnersEnv) start(t *testing.T) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + if err := e.runner.Start(ctx); err != nil { + cancel() + t.Fatal(err) + } + t.Cleanup(func() { + cancel() + e.runner.Wait() + }) +} + +// callDinners sends a signed-in request with body (none when empty). +func callDinners(t *testing.T, h http.Handler, method, target, body string) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + var r io.Reader + if body != "" { + r = strings.NewReader(body) + } + return serve(t, h, signedIn(method, target, r)) +} + +// createDinner adds a Regular and returns its id. +func createDinner(t *testing.T, h http.Handler, body string) string { + t.Helper() + rec, got := callDinners(t, h, http.MethodPost, dinnersPath, body) + if rec.Code != http.StatusCreated { + t.Fatalf("POST %s = %d: %s", body, rec.Code, rec.Body) + } + return got["id"].(string) +} + +// listNames returns the names GET /dinners?status=status lists, in order. +func listNames(t *testing.T, h http.Handler, status string) []string { + t.Helper() + rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"?status="+status, "") + if rec.Code != http.StatusOK { + t.Fatalf("GET status=%s = %d: %s", status, rec.Code, rec.Body) + } + list, ok := body["dinners"].([]any) + if !ok { + t.Fatalf("GET status=%s has no dinners list: %v", status, body) + } + names := make([]string, len(list)) + for i, d := range list { + names[i] = d.(map[string]any)["name"].(string) + } + return names +} + +func TestCreateDinnerByName(t *testing.T) { + t.Parallel() + router, st := newDinnersRouter(t) + + rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":" Chicken Tikka Masala "}`) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body) + } + for k, v := range map[string]any{ + "name": "Chicken Tikka Masala", + "status": "regular", + "origin": "regular", + "grounding_status": "pending", + "is_favorite": false, + "photo_url": nil, + "method": nil, + "minutes_total": nil, + "difficulty": nil, + "updated_by": testSession.Subject, + } { + if got, ok := body[k]; !ok || got != v { + 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) + } + if normalized != "chicken tikka masala" || updatedBy != testSession.Subject { + t.Errorf("stored normalized_name, updated_by = %q, %q", normalized, updatedBy) + } +} + +func TestCreateDinnerByURL(t *testing.T) { + t.Parallel() + 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+`"}`) + 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", + "status": "regular", + "origin": "regular", + "grounding_status": "pending", + "updated_by": testSession.Subject, + } { + if body[k] != v { + 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+`"}`) + 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"}`}, + {"neither field", `{}`}, + } { + rec, body := callDinners(t, router, http.MethodPost, dinnersPath, tc.body) + if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_dinner" { + t.Errorf("%s: %d %v, want 400 invalid_dinner", tc.name, rec.Code, body) + } + } +} + +// 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) + createDinner(t, router, `{"name":"Chicken Tikka Masala"}`) + + for _, name := range []string{ + "chicken tikka masala", + "Easy Chicken Tikka-Masala!", + "The Best Homemade Chicken Tikka Masala Recipe", + } { + rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"`+name+`"}`) + if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" { + 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"}`) + if rec.Code != http.StatusConflict { + t.Errorf("named duplicate with a link = %d %v, want 409", rec.Code, body) + } + + var n int + if err := st.Reader.QueryRow(`SELECT count(*) FROM dinners`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("dinners rows = %d, want 1", n) + } + + // A different dish sharing words is not a duplicate. + createDinner(t, router, `{"name":"Chicken Tikka Wraps"}`) +} + +func TestCreateDinnerRejected(t *testing.T) { + t.Parallel() + router, _ := newDinnersRouter(t) + for _, tc := range []struct{ name, body, code string }{ + {"empty name", `{"name":" "}`, "invalid_dinner"}, + {"filler only", `{"name":"The Best Easy Recipe"}`, "invalid_dinner"}, + {"too long", `{"name":"` + strings.Repeat("x", dinners.MaxNameChars+1) + `"}`, "invalid_dinner"}, + {"null name", `{"name":null}`, "invalid_request"}, + {"unknown field", `{"name":"Chili","status":"quest_log"}`, "invalid_request"}, + {"not an object", `"Chili"`, "invalid_request"}, + } { + rec, body := callDinners(t, router, http.MethodPost, dinnersPath, tc.body) + if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code { + t.Errorf("%s: %d %v, want 400 %s", tc.name, rec.Code, body, tc.code) + } + } +} + +func TestListDinnersFiltersByStatus(t *testing.T) { + t.Parallel() + router, st := newDinnersRouter(t) + tacos := createDinner(t, router, `{"name":"Tacos"}`) + createDinner(t, router, `{"name":"Lasagna"}`) + pho := createDinner(t, router, `{"name":"Pho"}`) + gone := createDinner(t, router, `{"name":"Meatloaf"}`) + + ctx := context.Background() + if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, pho); err != nil { + t.Fatal(err) + } + if _, err := st.Writer.ExecContext(ctx, + `INSERT INTO photos (id, source_url, local_path, width, height, sha256, fetched_at, created_at, updated_at) + VALUES ('photo1', 'https://example.com/tacos.jpg', 'media/photos/photo1', 1200, 800, 'abc', 't', 't', 't')`); err != nil { + t.Fatal(err) + } + if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET photo_id = 'photo1', grounding_status = 'grounded' WHERE id = ?`, tacos); err != nil { + t.Fatal(err) + } + if rec, _ := callDinners(t, router, http.MethodDelete, dinnersPath+"/"+gone, ""); rec.Code != http.StatusOK { + t.Fatalf("DELETE = %d", rec.Code) + } + + if got := strings.Join(listNames(t, router, "regular"), ","); got != "Lasagna,Tacos" { + t.Errorf("regular = %s, want Lasagna,Tacos", got) + } + if got := strings.Join(listNames(t, router, "quest_log"), ","); got != "Pho" { + t.Errorf("quest_log = %s, want Pho", got) + } + + _, body := callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "") + for _, d := range body["dinners"].([]any) { + d := d.(map[string]any) + switch d["name"] { + case "Tacos": + if d["photo_url"] != "/media/photos/photo1-400.jpg" || d["grounding_status"] != "grounded" { + t.Errorf("Tacos photo_url, grounding_status = %v, %v", d["photo_url"], d["grounding_status"]) + } + case "Lasagna": + if d["photo_url"] != nil || d["grounding_status"] != "pending" { + t.Errorf("Lasagna photo_url, grounding_status = %v, %v", d["photo_url"], d["grounding_status"]) + } + } + } + + // An empty list is [] rather than null. + empty, _ := newDinnersRouter(t) + rec, _ := callDinners(t, empty, http.MethodGet, dinnersPath+"?status=regular", "") + if got := rec.Body.String(); got != `{"dinners":[]}` { + t.Errorf("empty list = %s", got) + } + + for _, q := range []string{"", "?status=archived", "?status=suggested", "?status=bogus"} { + rec, body := callDinners(t, router, http.MethodGet, dinnersPath+q, "") + if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_dinner" { + t.Errorf("GET %q = %d %v, want 400 invalid_dinner", q, rec.Code, body) + } + } +} + +func TestPatchDinner(t *testing.T) { + t.Parallel() + router, st := newDinnersRouter(t) + id := createDinner(t, router, `{"name":"Chili"}`) + createDinner(t, router, `{"name":"Pot Roast"}`) + if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil { + t.Fatal(err) + } + + rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, + `{"name":"White Chicken Chili","method":"crockpot","minutes_total":480,"difficulty":"easy"}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d: %s", rec.Code, rec.Body) + } + for k, v := range map[string]any{ + "name": "White Chicken Chili", + "method": "crockpot", + "minutes_total": float64(480), + "difficulty": "easy", + "status": "regular", + "updated_by": testSession.Subject, + } { + if body[k] != v { + t.Errorf("%s = %#v, want %#v", k, body[k], v) + } + } + var normalized string + if err := st.Reader.QueryRow(`SELECT normalized_name FROM dinners WHERE id = ?`, id).Scan(&normalized); err != nil { + t.Fatal(err) + } + if normalized != "white chicken chili" { + t.Errorf("normalized_name = %q", normalized) + } + + // A partial patch leaves the other fields. + rec, body = callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"difficulty":"medium"}`) + if rec.Code != http.StatusOK || body["method"] != "crockpot" || body["difficulty"] != "medium" || body["name"] != "White Chicken Chili" { + t.Errorf("partial patch = %d %v", rec.Code, body) + } + // Renaming to its own name, respelled, is not a duplicate of itself. + if rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"name":"Easy white chicken chili"}`); rec.Code != http.StatusOK { + t.Errorf("self rename = %d %v", rec.Code, body) + } + // Renaming onto another dinner is. + rec, body = callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"name":"The Best Pot Roast"}`) + if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" { + t.Errorf("rename onto Pot Roast = %d %v, want 409", rec.Code, body) + } +} + +func TestPatchDinnerValidation(t *testing.T) { + t.Parallel() + router, _ := newDinnersRouter(t) + id := createDinner(t, router, `{"name":"Chili"}`) + + for _, tc := range []struct { + name, body, code, in string + }{ + {"bad method", `{"method":"microwave"}`, "invalid_dinner", "method"}, + {"crock pot spelling", `{"method":"crock pot"}`, "invalid_dinner", "method"}, + {"bad difficulty", `{"difficulty":"expert"}`, "invalid_dinner", "difficulty"}, + {"zero minutes", `{"minutes_total":0}`, "invalid_dinner", "minutes_total"}, + {"negative minutes", `{"minutes_total":-5}`, "invalid_dinner", "minutes_total"}, + {"too many minutes", `{"minutes_total":1441}`, "invalid_dinner", "minutes_total"}, + {"empty name", `{"name":""}`, "invalid_dinner", "name"}, + {"every problem", `{"method":"x","difficulty":"y","minutes_total":0}`, "invalid_dinner", "method: must be one of stovetop, oven, crockpot, grill, other; minutes_total"}, + {"empty patch", `{}`, "invalid_dinner", "no fields"}, + {"fractional minutes", `{"minutes_total":30.5}`, "invalid_request", "minutes_total"}, + {"string minutes", `{"minutes_total":"30"}`, "invalid_request", "minutes_total"}, + {"null method", `{"method":null}`, "invalid_request", "method"}, + {"read-only field", `{"status":"archived"}`, "invalid_request", "status"}, + {"favorite field", `{"is_favorite":true}`, "invalid_request", "is_favorite"}, + } { + rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, tc.body) + msg, _ := body["error"].(map[string]any)["message"].(string) + if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code || !strings.Contains(msg, tc.in) { + t.Errorf("%s: %d %v, want 400 %s containing %q", tc.name, rec.Code, body, tc.code, tc.in) + } + } + + _, body := callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "") + d := body["dinners"].([]any)[0].(map[string]any) + if d["method"] != nil || d["difficulty"] != nil || d["minutes_total"] != nil || d["name"] != "Chili" { + t.Errorf("a rejected patch changed the dinner: %v", d) + } + + rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/nope", `{"method":"oven"}`) + if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" { + t.Errorf("unknown id = %d %v, want 404", rec.Code, body) + } +} + +func TestDeleteDinnerArchives(t *testing.T) { + t.Parallel() + router, st := newDinnersRouter(t) + id := createDinner(t, router, `{"name":"Shepherd's Pie"}`) + if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil { + t.Fatal(err) + } + + rec, body := callDinners(t, router, http.MethodDelete, dinnersPath+"/"+id, "") + if rec.Code != http.StatusOK || body["status"] != "archived" || body["updated_by"] != testSession.Subject { + t.Fatalf("DELETE = %d %v", rec.Code, body) + } + var status, name, updatedBy string + if err := st.Reader.QueryRow(`SELECT status, name, updated_by FROM dinners WHERE id = ?`, id).Scan(&status, &name, &updatedBy); err != nil { + t.Fatalf("archived row is gone: %v", err) + } + if status != "archived" || name != "Shepherd's Pie" || updatedBy != testSession.Subject { + t.Errorf("row = %s %q %s", status, name, updatedBy) + } + if got := listNames(t, router, "regular"); len(got) != 0 { + t.Errorf("regular after archive = %v", got) + } + // An archived name may be added again. + createDinner(t, router, `{"name":"Shepherds Pie"}`) + + rec, body = callDinners(t, router, http.MethodDelete, dinnersPath+"/nope", "") + if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" { + t.Errorf("unknown id = %d %v, want 404", rec.Code, body) + } +} + +func TestFavoriteDinnerToggles(t *testing.T) { + t.Parallel() + router, st := newDinnersRouter(t) + id := createDinner(t, router, `{"name":"Fish Tacos"}`) + if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil { + t.Fatal(err) + } + + for _, want := range []bool{true, false, true} { + rec, body := callDinners(t, router, http.MethodPost, dinnersPath+"/"+id+"/favorite", "") + if rec.Code != http.StatusOK || body["is_favorite"] != want || body["updated_by"] != testSession.Subject { + t.Fatalf("favorite = %d %v, want is_favorite %v", rec.Code, body, want) + } + } + var fav int + if err := st.Reader.QueryRow(`SELECT is_favorite FROM dinners WHERE id = ?`, id).Scan(&fav); err != nil { + t.Fatal(err) + } + if fav != 1 { + t.Errorf("stored is_favorite = %d, want 1", fav) + } + + rec, body := callDinners(t, router, http.MethodPost, dinnersPath+"/nope/favorite", "") + if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" { + t.Errorf("unknown id = %d %v, want 404", rec.Code, body) + } +} + +// TestDinnerWritesRequireCSRF sends every dinners write without the CSRF +// header and with a wrong one; each is refused and changes nothing. +func TestDinnerWritesRequireCSRF(t *testing.T) { + t.Parallel() + router, st := newDinnersRouter(t) + id := createDinner(t, router, `{"name":"Chili"}`) + + var before string + snapshot := `SELECT group_concat(id || name || status || is_favorite || coalesce(method, '') || updated_at, '|') FROM dinners` + if err := st.Reader.QueryRow(snapshot).Scan(&before); err != nil { + t.Fatal(err) + } + + writes := []struct{ method, target, body string }{ + {http.MethodPost, dinnersPath, `{"name":"Lasagna"}`}, + {http.MethodPatch, dinnersPath + "/" + id, `{"method":"oven"}`}, + {http.MethodDelete, dinnersPath + "/" + id, ""}, + {http.MethodPost, dinnersPath + "/" + id + "/favorite", ""}, + } + for _, w := range writes { + for _, header := range []*string{nil, ptr("wrong-token")} { + req := httptest.NewRequest(w.method, w.target, strings.NewReader(w.body)) + req.AddCookie(validSessionCookie) + if header != nil { + req.Header.Set(CSRFHeader, *header) + } + rec, body := serve(t, router, req) + if rec.Code != http.StatusForbidden || errorCode(body) != "csrf" { + t.Errorf("%s %s (header %v) = %d %v, want 403 csrf", w.method, w.target, header != nil, rec.Code, body) + } + } + } + + var after string + if err := st.Reader.QueryRow(snapshot).Scan(&after); err != nil { + t.Fatal(err) + } + if after != before { + t.Errorf("refused writes changed dinners:\n before %s\n after %s", before, after) + } + + // Without a session, the list is 401 too. + req := httptest.NewRequest(http.MethodGet, dinnersPath+"?status=regular", nil) + if rec, body := serve(t, router, req); rec.Code != http.StatusUnauthorized || errorCode(body) != "unauthenticated" { + t.Errorf("GET without a session = %d %v, want 401", rec.Code, body) + } +} + +func ptr[T any](v T) *T { return &v } diff --git a/internal/server/grounding_test.go b/internal/server/grounding_test.go new file mode 100644 index 0000000..e7333d9 --- /dev/null +++ b/internal/server/grounding_test.go @@ -0,0 +1,412 @@ +package server + +import ( + "fmt" + "net/http" + "strings" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" +) + +// Fixture pages: two curries found by searching "Chicken curry" and a stew +// at a pasted link. +const ( + curryPage = "https://a.example/chicken-curry" + curry2Page = "https://b.example/curry" + stewPage = "https://c.example/recipes/beef-stew" +) + +// withRecipes adds the fixture pages, their photos and the search for +// "Chicken curry" to e. +func (e *dinnersEnv) withRecipes(t *testing.T) *dinnersEnv { + t.Helper() + e.search.Results["Chicken curry"] = []recipes.Candidate{{URL: curryPage}, {URL: curry2Page}} + e.pages.Bodies[curryPage] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40) + e.pages.Bodies[curry2Page] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25) + e.pages.Bodies[stewPage] = recipestest.RecipePage("Beef Stew", "Stew Co", "https://c.example/stew.jpg", 150) + e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1) + e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2) + e.images.JPEGs["https://c.example/stew.jpg"] = recipestest.JPEG(t, 400, 320, 3) + return e +} + +// waitJob polls GET /jobs/{id} until the job ends and returns its body. +func waitJob(t *testing.T, h http.Handler, id string) map[string]any { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + rec, body := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "") + if rec.Code != http.StatusOK { + t.Fatalf("GET job = %d: %s", rec.Code, rec.Body) + } + if s := body["status"]; s == "succeeded" || s == "failed" { + return body + } + if time.Now().After(deadline) { + t.Fatalf("job %s never finished; last %v", id, body) + } + time.Sleep(5 * time.Millisecond) + } +} + +// listedDinner returns dinner id as GET /dinners?status=regular lists it. +func listedDinner(t *testing.T, h http.Handler, id string) map[string]any { + t.Helper() + rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"?status=regular", "") + if rec.Code != http.StatusOK { + t.Fatalf("GET dinners = %d: %s", rec.Code, rec.Body) + } + for _, d := range body["dinners"].([]any) { + if d := d.(map[string]any); d["id"] == id { + return d + } + } + t.Fatalf("dinner %s not listed", id) + return nil +} + +func TestPostDinnerReturnsBeforeGroundingCompletes(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t).withRecipes(t) + e.search.Gate = make(chan struct{}) + e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40)) + e.start(t) + + rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + jobID, _ := created["grounding_job_id"].(string) + if created["grounding_status"] != "pending" || created["grounding_error"] != nil || jobID == "" { + t.Fatalf("created = %v, want pending with a grounding_job_id", created) + } + // The search is held, so grounding cannot have completed. + _, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+jobID, "") + if s := job["status"]; s != "queued" && s != "running" { + t.Fatalf("job right after POST = %v, want queued or running", job) + } + if d := listedDinner(t, e.h, created["id"].(string)); d["grounding_status"] != "pending" { + t.Fatalf("dinner right after POST = %v, want pending", d) + } + + close(e.search.Gate) + job = waitJob(t, e.h, jobID) + if job["status"] != "succeeded" || job["kind"] != "ground_dinner" { + t.Fatalf("job = %v, want ground_dinner succeeded", job) + } + d := listedDinner(t, e.h, created["id"].(string)) + photo, _ := d["photo_url"].(string) + if d["grounding_status"] != "grounded" || d["recipe_id"] == nil || !strings.HasSuffix(photo, "-400.jpg") || + d["method"] != "stovetop" || d["minutes_total"] != 40.0 || d["difficulty"] != "easy" || d["grounding_error"] != nil { + t.Errorf("dinner = %v, want grounded with recipe, photo, method, minutes and difficulty", d) + } +} + +func TestPostDinnerGroundingFailure(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t).withRecipes(t) + e.start(t) + + rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Nothing matches this"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + job := waitJob(t, e.h, created["grounding_job_id"].(string)) + if job["status"] != "failed" || job["error"] != "no_recipe_found" { + t.Errorf("job = %v, want failed with no_recipe_found", job) + } + d := listedDinner(t, e.h, created["id"].(string)) + if d["grounding_status"] != "failed" || d["grounding_error"] != "no_recipe_found" || d["recipe_id"] != nil { + t.Errorf("dinner = %v, want failed with no_recipe_found", d) + } +} + +func TestGetRecipeCandidates(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t).withRecipes(t) + id := createDinner(t, e.h, `{"name":"Chicken curry"}`) // runner not started: stays pending + + rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "") + if rec.Code != http.StatusOK { + t.Fatalf("GET = %d: %s", rec.Code, rec.Body) + } + list, _ := body["candidates"].([]any) + if len(list) != 2 { + t.Fatalf("candidates = %v, want 2", body) + } + first := list[0].(map[string]any) + want := map[string]any{ + "candidate_id": curryPage, + "title": "Chicken Curry", + "site_name": "Curry House", + "photo_url": nil, + "minutes": 40.0, + } + if len(first) != len(want) { + t.Errorf("candidate keys = %v, want exactly %v", first, want) + } + for k, v := range want { + if got, ok := first[k]; !ok || got != v { + t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v) + } + } + + rec, body = callDinners(t, e.h, http.MethodGet, dinnersPath+"/nope/recipe-candidates", "") + if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" { + t.Errorf("unknown dinner = %d %v, want 404", rec.Code, body) + } +} + +func TestGetRecipeCandidatesSearchFails(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + // The provider's text must reach neither the response nor the logs. + const leak = "provider-said-token-abc123" + e.search.Err = fmt.Errorf("%w: %s", recipes.ErrSearchRefused, leak) + id := createDinner(t, e.h, `{"name":"Chicken curry"}`) + rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "") + if rec.Code != http.StatusBadGateway || errorCode(body) != "search_failed" { + t.Errorf("GET = %d %v, want 502 search_failed", rec.Code, body) + } + if strings.Contains(rec.Body.String(), leak) { + t.Errorf("response quotes the provider: %s", rec.Body) + } + logs := e.logs.String() + if strings.Contains(logs, leak) || !strings.Contains(logs, "reason=search_failed") { + t.Errorf("logs = %q, want reason=search_failed and no provider text", logs) + } +} + +func TestPostGroundViaCandidate(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t).withRecipes(t) + id := createDinner(t, e.h, `{"name":"Chicken curry"}`) + e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25)) + + rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"candidate_id":"`+curry2Page+`"}`) + if rec.Code != http.StatusOK { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + photo, _ := d["photo_url"].(string) + if d["grounding_status"] != "grounded" || d["recipe_id"] == nil || photo == "" || + d["method"] != "stovetop" || d["minutes_total"] != 25.0 || d["difficulty"] != "easy" || d["updated_by"] != testSession.Subject { + t.Errorf("dinner = %v", d) + } + // The candidate is now stored, so it is offered with its photo. + _, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "") + for _, c := range body["candidates"].([]any) { + if c := c.(map[string]any); c["candidate_id"] == curry2Page && c["photo_url"] != photo { + t.Errorf("grounded candidate photo_url = %v, want %s", c["photo_url"], photo) + } + } +} + +func TestPostGroundViaRecipeURL(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)) + + rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+stewPage+`"}`) + if rec.Code != http.StatusOK { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + if d["grounding_status"] != "grounded" || d["photo_url"] == nil || d["method"] != "oven" || d["minutes_total"] != 150.0 { + t.Errorf("dinner = %v", d) + } + + rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"https://gone.example/soup"}`) + if rec.Code != http.StatusUnprocessableEntity || errorCode(body) != "ungroundable" || strings.Contains(rec.Body.String(), "gone.example") { + t.Errorf("unusable link = %d %v, want 422 ungroundable", rec.Code, body) + } +} + +// 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) + id := createDinner(t, e.h, `{"name":"Family chili"}`) + + rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", + `{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`) + if rec.Code != http.StatusOK { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + if d["grounding_status"] != "manual" || d["recipe_id"] == nil || d["photo_url"] != nil || + d["method"] != "crockpot" || d["minutes_total"] != 240.0 || d["grounding_error"] != nil { + t.Errorf("dinner = %v", d) + } + var lines int + if err := e.st.Reader.QueryRow(`SELECT count(*) FROM recipe_ingredients WHERE recipe_id = ?`, d["recipe_id"]).Scan(&lines); err != nil { + t.Fatal(err) + } + if lines != 2 { + t.Errorf("stored %d ingredient lines, want 2", lines) + } +} + +func TestPostGroundRejects(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + id := createDinner(t, e.h, `{"name":"Pad thai"}`) + path := dinnersPath + "/" + id + "/ground" + + for _, tc := range []struct{ name, body, code string }{ + {"empty", `{}`, "invalid_dinner"}, + {"two kinds", `{"candidate_id":"https://a.example/x","recipe_url":"https://a.example/x"}`, "invalid_dinner"}, + {"candidate not a link", `{"candidate_id":"c1"}`, "invalid_dinner"}, + {"http link", `{"recipe_url":"http://a.example/x"}`, "invalid_dinner"}, + {"null", `{"recipe_url":null}`, "invalid_request"}, + {"unknown field", `{"url":"https://a.example/x"}`, "invalid_request"}, + {"unknown manual field", `{"manual":{"ingredients":["x"],"minutes":5,"method":"oven","serves":4}}`, "invalid_request"}, + {"manual without method", `{"manual":{"ingredients":["x"],"minutes":5}}`, "invalid_dinner"}, + {"manual without ingredients", `{"manual":{"ingredients":[],"minutes":5,"method":"oven"}}`, "invalid_dinner"}, + {"manual minutes too many", `{"manual":{"ingredients":["x"],"minutes":5000,"method":"oven"}}`, "invalid_dinner"}, + {"not an object", `[]`, "invalid_request"}, + } { + rec, body := callDinners(t, e.h, http.MethodPost, path, tc.body) + if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code { + t.Errorf("%s: %d %v, want 400 %s", tc.name, rec.Code, body, tc.code) + } + } + if d := listedDinner(t, e.h, id); d["grounding_status"] != "pending" { + t.Errorf("dinner after rejected fixes = %v, want unchanged", d) + } + + rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/nope/ground", + `{"manual":{"ingredients":["x"],"minutes":5,"method":"oven"}}`) + if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" { + t.Errorf("unknown dinner = %d %v, want 404", rec.Code, body) + } +} + +func TestPostDinnerEnqueueFailureAnswersFailedDinner(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + 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) + } + rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + id, _ := created["id"].(string) + if id == "" || created["grounding_status"] != "failed" || created["grounding_error"] != "grounding_error" || created["grounding_job_id"] != nil { + t.Fatalf("created = %v, want its id, failed (grounding_error) and a null grounding_job_id", created) + } + if got := listedDinner(t, e.h, id); got["grounding_status"] != "failed" { + t.Errorf("listed = %v, want failed", got) + } +} diff --git a/internal/server/login_test.go b/internal/server/login_test.go index d7fa939..b801274 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler { t.Fatal(err) } r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/media_test.go b/internal/server/media_test.go index c464737..e1aed7b 100644 --- a/internal/server/media_test.go +++ b/internal/server/media_test.go @@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestRunner(t), + r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index cf52a47..ef97b38 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler { } logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil))) r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/recipesearch_test.go b/internal/server/recipesearch_test.go index 09ec29e..ecc0d69 100644 --- a/internal/server/recipesearch_test.go +++ b/internal/server/recipesearch_test.go @@ -22,7 +22,7 @@ const ( func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/server.go b/internal/server/server.go index b32d9ca..8eaf64c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" @@ -34,12 +35,15 @@ type RouteMounter interface { // /media; settings serves /api/v1/settings/household; search serves // /api/v1/settings/recipe-search and its /test; ai serves // /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt; +// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE +// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET +// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground; // runner serves // POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves // GET /media/photos/{id}-{size}.jpg; assets is the web build // served for every other GET. /health, /auth and the web build are public. // Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader. -func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { +func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { if settings == nil { return nil, errors.New("server: nil household settings service") } @@ -49,6 +53,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a if ai == nil { return nil, errors.New("server: nil AI settings service") } + if dinnerSvc == nil { + return nil, errors.New("server: nil dinners service") + } if runner == nil { return nil, errors.New("server: nil job runner") } @@ -92,6 +99,13 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai)) apiV1.Get("/settings/ai/models", getAIModels(logger, ai)) apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai)) + apiV1.Get("/dinners", getDinners(logger, dinnerSvc)) + apiV1.Post("/dinners", postDinner(logger, dinnerSvc)) + apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc)) + apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc)) + apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc)) + apiV1.Get("/dinners/{id}/recipe-candidates", getDinnerCandidates(logger, dinnerSvc)) + apiV1.Post("/dinners/{id}/ground", postDinnerGround(logger, dinnerSvc)) apiV1.Post("/jobs", postJob(logger, runner)) apiV1.Get("/jobs/{id}", getJob(logger, runner)) media.Get("/photos/{name}", getPhoto(logger, photos)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 1704af7..5f121ce 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -17,9 +17,12 @@ import ( "github.com/go-chi/chi/v5" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" @@ -43,7 +46,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting "index.html": {Data: []byte(fixtureIndex)}, "assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, } - r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), runner, newTestPhotos(t), assets) + r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), runner, newTestPhotos(t), assets) if err != nil { t.Fatal(err) } @@ -139,6 +142,24 @@ func newTestSearch(t *testing.T) *recipes.SearchSettingsService { return search } +// newTestDinners returns a dinners service over a migrated temp database, +// with its own unstarted job runner and a grounder that finds nothing. +func newTestDinners(t *testing.T) *dinners.Service { + t.Helper() + st := newTestStore(t) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + runner, err := jobs.NewRunner(st, logger, 1) + if err != nil { + t.Fatal(err) + } + g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{}) + svc, err := dinners.NewService(st, g, runner, logger) + if err != nil { + t.Fatal(err) + } + return svc +} + // newTestBox returns a secret box under a random key. func newTestBox(t *testing.T) *secretbox.Box { t.Helper() @@ -170,7 +191,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil AI settings service") } } @@ -179,7 +200,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil recipe search settings service") } } @@ -188,7 +209,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted nil photo files") } } @@ -197,16 +218,25 @@ func TestNewRouterRequiresSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil settings service") } } +func TestNewRouterRequiresDinners(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + t.Fatal("NewRouter accepted a nil dinners service") + } +} + func TestNewRouterRequiresJobRunner(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil job runner") } } diff --git a/internal/server/settings.go b/internal/server/settings.go index 489d092..cce8413 100644 --- a/internal/server/settings.go +++ b/internal/server/settings.go @@ -37,6 +37,9 @@ 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 { @@ -110,7 +113,7 @@ func decodePatch[T any](body io.Reader) (T, error) { if errors.As(err, &typeErr) { return patch, fmt.Errorf("%s: want a JSON %s", typeErr.Field, typeErr.Type) } - return patch, fmt.Errorf("invalid settings: %v", err) + return patch, fmt.Errorf("invalid request body: %v", err) } return patch, nil } diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index f0072fe..9570fbc 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -3,6 +3,7 @@ package server import ( "context" "encoding/json" + "fmt" "io" "log/slog" "net/http" @@ -13,6 +14,7 @@ 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" @@ -115,6 +117,63 @@ 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() @@ -136,6 +195,8 @@ 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"}, @@ -216,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st), - newTestSearch(t), newTestAI(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index 6c00c51..94b8ec3 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -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" { - 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", 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,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) } } @@ -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" { - 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", 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,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 !tableExists(t, s, "dinners") { t.Error("table from 0001_init was not created") diff --git a/migrations/0008_dinner_recipe_url.sql b/migrations/0008_dinner_recipe_url.sql new file mode 100644 index 0000000..cb59567 --- /dev/null +++ b/migrations/0008_dinner_recipe_url.sql @@ -0,0 +1,5 @@ +-- 0008_dinner_recipe_url: a Regular added from a pasted recipe link keeps +-- that link until grounding resolves it to a recipe (recipe_id). Nothing +-- else records the link: recipes rows exist only for fetched pages. + +ALTER TABLE dinners ADD COLUMN recipe_url TEXT; diff --git a/migrations/0009_dinner_grounding_error.sql b/migrations/0009_dinner_grounding_error.sql new file mode 100644 index 0000000..7ce9a4b --- /dev/null +++ b/migrations/0009_dinner_grounding_error.sql @@ -0,0 +1,7 @@ +-- 0009_dinner_grounding_error: a dinner whose grounding failed keeps why, +-- as one of the reason codes package dinners defines (no_recipe_found, +-- recipe_unusable, search_not_configured, search_failed, ai_failed, +-- interrupted, grounding_error). It is NULL unless grounding_status is +-- failed. + +ALTER TABLE dinners ADD COLUMN grounding_error TEXT; diff --git a/migrations/0010_dinner_grounding_revision.sql b/migrations/0010_dinner_grounding_revision.sql new file mode 100644 index 0000000..5f13bd9 --- /dev/null +++ b/migrations/0010_dinner_grounding_revision.sql @@ -0,0 +1,7 @@ +-- 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; diff --git a/migrations/0011_ground_dinner_job_revision.sql b/migrations/0011_ground_dinner_job_revision.sql new file mode 100644 index 0000000..dd5de37 --- /dev/null +++ b/migrations/0011_ground_dinner_job_revision.sql @@ -0,0 +1,11 @@ +-- 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; diff --git a/migrations/queries/dinners.sql b/migrations/queries/dinners.sql index 6b4d39b..43e96ae 100644 --- a/migrations/queries/dinners.sql +++ b/migrations/queries/dinners.sql @@ -1,12 +1,12 @@ -- name: CreateDinner :one INSERT INTO dinners ( 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 + difficulty, cuisine, is_favorite, recipe_id, recipe_url, photo_id, + grounding_status, updated_by, created_at, updated_at ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ? + ?, ?, ?, ? ) RETURNING *; @@ -19,6 +19,20 @@ 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; + -- name: UpdateDinner :one -- An unknown id returns sql.ErrNoRows. UPDATE dinners @@ -31,8 +45,10 @@ SET name = ?, difficulty = ?, cuisine = ?, recipe_id = ?, + recipe_url = ?, photo_id = ?, grounding_status = ?, + grounding_revision = ?, updated_by = ?, updated_at = ? WHERE id = ? @@ -55,3 +71,98 @@ SET is_favorite = ?, updated_at = ? WHERE id = ? RETURNING *; + +-- name: ToggleDinnerFavorite :one +-- An unknown id returns sql.ErrNoRows. +UPDATE dinners +SET is_favorite = 1 - is_favorite, + updated_by = ?, + updated_at = ? +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. +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), + 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) +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. +UPDATE dinners +SET recipe_id = sqlc.arg(recipe_id), + photo_id = sqlc.arg(photo_id), + method = COALESCE(method, sqlc.arg(method)), + minutes_total = COALESCE(minutes_total, sqlc.arg(minutes_total)), + difficulty = COALESCE(difficulty, sqlc.arg(difficulty)), + grounding_status = 'grounded', + grounding_error = NULL, + updated_at = sqlc.arg(updated_at) +WHERE id = sqlc.arg(id) + AND status <> 'archived' + AND grounding_status = 'pending' + AND grounding_revision = sqlc.arg(grounding_revision); + +-- 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). +UPDATE dinners +SET grounding_status = 'failed', + grounding_error = ?, + updated_at = ? +WHERE id = ? + AND status <> 'archived' + AND grounding_status = 'pending' + AND grounding_revision = ?; + +-- 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. +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 + ); diff --git a/migrations/queries/recipes.sql b/migrations/queries/recipes.sql index ddb7808..bbca037 100644 --- a/migrations/queries/recipes.sql +++ b/migrations/queries/recipes.sql @@ -69,3 +69,15 @@ ORDER BY position, id; -- name: DeleteRecipeSteps :exec DELETE FROM recipe_steps WHERE recipe_id = ?; + +-- name: CreateManualRecipe :one +-- A recipe the household typed in. Its source_url is a mealquest:manual: +-- URN rather than a page, so it never matches a fetched page. +INSERT INTO recipes ( + id, source_url, title, total_minutes, raw_jsonld, method, + minutes_total, fetched_at, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ? +) +RETURNING *; diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 82d8105..e06992c 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -7,6 +7,7 @@ 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') @@ -27,7 +28,20 @@ function renderAppAt(path: string, location = fakeLocation(path)) { } // The routes that render a built screen rather than a placeholder page. -const SCREENS = { '/settings': true, '/settings/ai': true, '/settings/household': true } +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), +} describe('App routes', () => { it('registers every handoff route plus the extra settings pages', () => { @@ -54,7 +68,7 @@ describe('App routes', () => { '%s renders its placeholder page inside the shell', async (path, title) => { mockViewport(390) - stubFetch(jsonResponse(200, ME)) + stubFetchByPath(SIGNED_IN) renderAt(path) const heading = await screen.findByRole('heading', { level: 1, name: title }) expect(screen.getByRole('main')).toContainElement(heading) @@ -77,7 +91,7 @@ describe('App routes', () => { it('/settings/ai renders the AI assistant screen inside the shell', async () => { mockViewport(390) stubFetchByPath({ - '/api/v1/me': () => jsonResponse(200, ME), + ...SIGNED_IN, '/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), @@ -90,21 +104,73 @@ describe('App routes', () => { it('/settings/household renders the household settings screen inside the shell', async () => { mockViewport(390) - stubFetchByPath({ - '/api/v1/me': () => jsonResponse(200, ME), - '/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), - }) + stubFetchByPath(SIGNED_IN) 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)) + const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS)) renderAt('/plan') expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true') @@ -138,7 +204,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)) + stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS)) renderAt('/') expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message') @@ -158,7 +224,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() { - stubFetch(jsonResponse(200, ME)) + stubFetchByPath(SIGNED_IN) const { unmount } = renderAt('/') expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() unmount() @@ -226,12 +292,17 @@ 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 = stubFetch(jsonResponse(200, ME)) + const fetchMock = stubFetchByPath(SIGNED_IN) onLine = true act(() => { window.dispatchEvent(new Event('online')) }) - await vi.waitFor(() => expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' })) + await vi.waitFor(() => + expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([ + '/api/v1/me', + '/api/v1/settings/household', + ]), + ) await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull()) expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() }) diff --git a/web/src/App.tsx b/web/src/App.tsx index 8c8e2ef..1becaca 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,11 +3,14 @@ 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' @@ -35,16 +38,24 @@ const screens: Partial> = { '/settings': , '/settings/ai': , '/settings/household': , + '/settings/regulars': , } // 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.map(({ path, title }) => ( - } /> - ))} + }> + } /> + }> + {routes + .filter(({ path }) => path !== ONBOARDING_PATH) + .map(({ path, title }) => ( + } /> + ))} + ) diff --git a/web/src/AuthBootstrap.tsx b/web/src/AuthBootstrap.tsx index d9c1aee..774ca5c 100644 --- a/web/src/AuthBootstrap.tsx +++ b/web/src/AuthBootstrap.tsx @@ -41,6 +41,11 @@ export function AuthBootstrap({ children }: { children: ReactNode }) { ) } + return +} + +// LoadingScreen is the full-screen skeleton shown while MealQuest loads. +export function LoadingScreen() { return (
diff --git a/web/src/dinners/api.ts b/web/src/dinners/api.ts new file mode 100644 index 0000000..657d1de --- /dev/null +++ b/web/src/dinners/api.ts @@ -0,0 +1,335 @@ +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(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(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(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(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(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(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(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(`${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(`${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(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)) + }, + }) +} diff --git a/web/src/dinners/letterFolds.json b/web/src/dinners/letterFolds.json new file mode 100644 index 0000000..5be5eaf --- /dev/null +++ b/web/src/dinners/letterFolds.json @@ -0,0 +1,1167 @@ +{ + "\u00c0": "a", + "\u00c1": "a", + "\u00c2": "a", + "\u00c3": "a", + "\u00c4": "a", + "\u00c5": "a", + "\u00c6": "ae", + "\u00c7": "c", + "\u00c8": "e", + "\u00c9": "e", + "\u00ca": "e", + "\u00cb": "e", + "\u00cc": "i", + "\u00cd": "i", + "\u00ce": "i", + "\u00cf": "i", + "\u00d0": "d", + "\u00d1": "n", + "\u00d2": "o", + "\u00d3": "o", + "\u00d4": "o", + "\u00d5": "o", + "\u00d6": "o", + "\u00d8": "o", + "\u00d9": "u", + "\u00da": "u", + "\u00db": "u", + "\u00dc": "u", + "\u00dd": "y", + "\u00de": "th", + "\u00df": "ss", + "\u00e0": "a", + "\u00e1": "a", + "\u00e2": "a", + "\u00e3": "a", + "\u00e4": "a", + "\u00e5": "a", + "\u00e6": "ae", + "\u00e7": "c", + "\u00e8": "e", + "\u00e9": "e", + "\u00ea": "e", + "\u00eb": "e", + "\u00ec": "i", + "\u00ed": "i", + "\u00ee": "i", + "\u00ef": "i", + "\u00f0": "d", + "\u00f1": "n", + "\u00f2": "o", + "\u00f3": "o", + "\u00f4": "o", + "\u00f5": "o", + "\u00f6": "o", + "\u00f8": "o", + "\u00f9": "u", + "\u00fa": "u", + "\u00fb": "u", + "\u00fc": "u", + "\u00fd": "y", + "\u00fe": "th", + "\u00ff": "y", + "\u0100": "a", + "\u0101": "a", + "\u0102": "a", + "\u0103": "a", + "\u0104": "a", + "\u0105": "a", + "\u0106": "c", + "\u0107": "c", + "\u0108": "c", + "\u0109": "c", + "\u010a": "c", + "\u010b": "c", + "\u010c": "c", + "\u010d": "c", + "\u010e": "d", + "\u010f": "d", + "\u0110": "d", + "\u0111": "d", + "\u0112": "e", + "\u0113": "e", + "\u0114": "e", + "\u0115": "e", + "\u0116": "e", + "\u0117": "e", + "\u0118": "e", + "\u0119": "e", + "\u011a": "e", + "\u011b": "e", + "\u011c": "g", + "\u011d": "g", + "\u011e": "g", + "\u011f": "g", + "\u0120": "g", + "\u0121": "g", + "\u0122": "g", + "\u0123": "g", + "\u0124": "h", + "\u0125": "h", + "\u0126": "h", + "\u0127": "h", + "\u0128": "i", + "\u0129": "i", + "\u012a": "i", + "\u012b": "i", + "\u012c": "i", + "\u012d": "i", + "\u012e": "i", + "\u012f": "i", + "\u0130": "i", + "\u0131": "i", + "\u0132": "ij", + "\u0133": "ij", + "\u0134": "j", + "\u0135": "j", + "\u0136": "k", + "\u0137": "k", + "\u0139": "l", + "\u013a": "l", + "\u013b": "l", + "\u013c": "l", + "\u013d": "l", + "\u013e": "l", + "\u013f": "l", + "\u0140": "l", + "\u0141": "l", + "\u0142": "l", + "\u0143": "n", + "\u0144": "n", + "\u0145": "n", + "\u0146": "n", + "\u0147": "n", + "\u0148": "n", + "\u0149": "n", + "\u014c": "o", + "\u014d": "o", + "\u014e": "o", + "\u014f": "o", + "\u0150": "o", + "\u0151": "o", + "\u0152": "oe", + "\u0153": "oe", + "\u0154": "r", + "\u0155": "r", + "\u0156": "r", + "\u0157": "r", + "\u0158": "r", + "\u0159": "r", + "\u015a": "s", + "\u015b": "s", + "\u015c": "s", + "\u015d": "s", + "\u015e": "s", + "\u015f": "s", + "\u0160": "s", + "\u0161": "s", + "\u0162": "t", + "\u0163": "t", + "\u0164": "t", + "\u0165": "t", + "\u0166": "t", + "\u0167": "t", + "\u0168": "u", + "\u0169": "u", + "\u016a": "u", + "\u016b": "u", + "\u016c": "u", + "\u016d": "u", + "\u016e": "u", + "\u016f": "u", + "\u0170": "u", + "\u0171": "u", + "\u0172": "u", + "\u0173": "u", + "\u0174": "w", + "\u0175": "w", + "\u0176": "y", + "\u0177": "y", + "\u0178": "y", + "\u0179": "z", + "\u017a": "z", + "\u017b": "z", + "\u017c": "z", + "\u017d": "z", + "\u017e": "z", + "\u0180": "b", + "\u0181": "b", + "\u0182": "b", + "\u0183": "b", + "\u0187": "c", + "\u0188": "c", + "\u018a": "d", + "\u018b": "d", + "\u018c": "d", + "\u0191": "f", + "\u0192": "f", + "\u0193": "g", + "\u0197": "i", + "\u0198": "k", + "\u0199": "k", + "\u019a": "l", + "\u019d": "n", + "\u019e": "n", + "\u019f": "o", + "\u01a0": "o", + "\u01a1": "o", + "\u01a4": "p", + "\u01a5": "p", + "\u01ab": "t", + "\u01ac": "t", + "\u01ad": "t", + "\u01ae": "t", + "\u01af": "u", + "\u01b0": "u", + "\u01b2": "v", + "\u01b3": "y", + "\u01b4": "y", + "\u01b5": "z", + "\u01b6": "z", + "\u01c5": "d", + "\u01c8": "l", + "\u01cb": "n", + "\u01cd": "a", + "\u01ce": "a", + "\u01cf": "i", + "\u01d0": "i", + "\u01d1": "o", + "\u01d2": "o", + "\u01d3": "u", + "\u01d4": "u", + "\u01d5": "u", + "\u01d6": "u", + "\u01d7": "u", + "\u01d8": "u", + "\u01d9": "u", + "\u01da": "u", + "\u01db": "u", + "\u01dc": "u", + "\u01de": "a", + "\u01df": "a", + "\u01e0": "a", + "\u01e1": "a", + "\u01e2": "ae", + "\u01e3": "ae", + "\u01e4": "g", + "\u01e5": "g", + "\u01e6": "g", + "\u01e7": "g", + "\u01e8": "k", + "\u01e9": "k", + "\u01ea": "o", + "\u01eb": "o", + "\u01ec": "o", + "\u01ed": "o", + "\u01ee": "\u0292", + "\u01ef": "\u0292", + "\u01f0": "j", + "\u01f2": "d", + "\u01f4": "g", + "\u01f5": "g", + "\u01f8": "n", + "\u01f9": "n", + "\u01fa": "a", + "\u01fb": "a", + "\u01fc": "ae", + "\u01fd": "ae", + "\u01fe": "o", + "\u01ff": "o", + "\u0200": "a", + "\u0201": "a", + "\u0202": "a", + "\u0203": "a", + "\u0204": "e", + "\u0205": "e", + "\u0206": "e", + "\u0207": "e", + "\u0208": "i", + "\u0209": "i", + "\u020a": "i", + "\u020b": "i", + "\u020c": "o", + "\u020d": "o", + "\u020e": "o", + "\u020f": "o", + "\u0210": "r", + "\u0211": "r", + "\u0212": "r", + "\u0213": "r", + "\u0214": "u", + "\u0215": "u", + "\u0216": "u", + "\u0217": "u", + "\u0218": "s", + "\u0219": "s", + "\u021a": "t", + "\u021b": "t", + "\u021e": "h", + "\u021f": "h", + "\u0220": "n", + "\u0221": "d", + "\u0224": "z", + "\u0225": "z", + "\u0226": "a", + "\u0227": "a", + "\u0228": "e", + "\u0229": "e", + "\u022a": "o", + "\u022b": "o", + "\u022c": "o", + "\u022d": "o", + "\u022e": "o", + "\u022f": "o", + "\u0230": "o", + "\u0231": "o", + "\u0232": "y", + "\u0233": "y", + "\u0234": "l", + "\u0235": "n", + "\u0236": "t", + "\u023a": "a", + "\u023b": "c", + "\u023c": "c", + "\u023d": "l", + "\u023e": "t", + "\u023f": "s", + "\u0240": "z", + "\u0243": "b", + "\u0246": "e", + "\u0247": "e", + "\u0248": "j", + "\u0249": "j", + "\u024b": "q", + "\u024c": "r", + "\u024d": "r", + "\u024e": "y", + "\u024f": "y", + "\u0253": "b", + "\u0255": "c", + "\u0256": "d", + "\u0257": "d", + "\u0260": "g", + "\u0266": "h", + "\u0268": "i", + "\u026b": "l", + "\u026c": "l", + "\u026d": "l", + "\u0271": "m", + "\u0272": "n", + "\u0273": "n", + "\u027c": "r", + "\u027d": "r", + "\u027e": "r", + "\u0282": "s", + "\u0288": "t", + "\u028b": "v", + "\u0290": "z", + "\u0291": "z", + "\u029d": "j", + "\u02a0": "q", + "\u0386": "\u03b1", + "\u0388": "\u03b5", + "\u0389": "\u03b7", + "\u038a": "\u03b9", + "\u038c": "\u03bf", + "\u038e": "\u03c5", + "\u038f": "\u03c9", + "\u0390": "\u03b9", + "\u03aa": "\u03b9", + "\u03ab": "\u03c5", + "\u03ac": "\u03b1", + "\u03ad": "\u03b5", + "\u03ae": "\u03b7", + "\u03af": "\u03b9", + "\u03b0": "\u03c5", + "\u03ca": "\u03b9", + "\u03cb": "\u03c5", + "\u03cc": "\u03bf", + "\u03cd": "\u03c5", + "\u03ce": "\u03c9", + "\u03d3": "\u03d2", + "\u03d4": "\u03d2", + "\u0400": "\u0435", + "\u0401": "\u0435", + "\u0403": "\u0433", + "\u0407": "\u0456", + "\u040c": "\u043a", + "\u040d": "\u0438", + "\u040e": "\u0443", + "\u0419": "\u0438", + "\u0439": "\u0438", + "\u0450": "\u0435", + "\u0451": "\u0435", + "\u0453": "\u0433", + "\u0457": "\u0456", + "\u045c": "\u043a", + "\u045d": "\u0438", + "\u045e": "\u0443", + "\u0476": "\u0475", + "\u0477": "\u0475", + "\u04c1": "\u0436", + "\u04c2": "\u0436", + "\u04d0": "\u0430", + "\u04d1": "\u0430", + "\u04d2": "\u0430", + "\u04d3": "\u0430", + "\u04d6": "\u0435", + "\u04d7": "\u0435", + "\u04da": "\u04d9", + "\u04db": "\u04d9", + "\u04dc": "\u0436", + "\u04dd": "\u0436", + "\u04de": "\u0437", + "\u04df": "\u0437", + "\u04e2": "\u0438", + "\u04e3": "\u0438", + "\u04e4": "\u0438", + "\u04e5": "\u0438", + "\u04e6": "\u043e", + "\u04e7": "\u043e", + "\u04ea": "\u04e9", + "\u04eb": "\u04e9", + "\u04ec": "\u044d", + "\u04ed": "\u044d", + "\u04ee": "\u0443", + "\u04ef": "\u0443", + "\u04f0": "\u0443", + "\u04f1": "\u0443", + "\u04f2": "\u0443", + "\u04f3": "\u0443", + "\u04f4": "\u0447", + "\u04f5": "\u0447", + "\u04f8": "\u044b", + "\u04f9": "\u044b", + "\u0622": "\u0627", + "\u0623": "\u0627", + "\u0624": "\u0648", + "\u0625": "\u0627", + "\u0626": "\u064a", + "\u06c0": "\u06d5", + "\u06c2": "\u06c1", + "\u06d3": "\u06d2", + "\u0929": "\u0928", + "\u0931": "\u0930", + "\u0934": "\u0933", + "\u0958": "\u0915", + "\u0959": "\u0916", + "\u095a": "\u0917", + "\u095b": "\u091c", + "\u095c": "\u0921", + "\u095d": "\u0922", + "\u095e": "\u092b", + "\u095f": "\u092f", + "\u09dc": "\u09a1", + "\u09dd": "\u09a2", + "\u09df": "\u09af", + "\u0a33": "\u0a32", + "\u0a36": "\u0a38", + "\u0a59": "\u0a16", + "\u0a5a": "\u0a17", + "\u0a5b": "\u0a1c", + "\u0a5e": "\u0a2b", + "\u0b5c": "\u0b21", + "\u0b5d": "\u0b22", + "\u0f43": "\u0f42", + "\u0f4d": "\u0f4c", + "\u0f52": "\u0f51", + "\u0f57": "\u0f56", + "\u0f5c": "\u0f5b", + "\u0f69": "\u0f40", + "\u1026": "\u1025", + "\u1d6c": "b", + "\u1d6d": "d", + "\u1d6e": "f", + "\u1d6f": "m", + "\u1d70": "n", + "\u1d71": "p", + "\u1d72": "r", + "\u1d73": "r", + "\u1d74": "s", + "\u1d75": "t", + "\u1d76": "z", + "\u1d7d": "p", + "\u1d80": "b", + "\u1d81": "d", + "\u1d82": "f", + "\u1d83": "g", + "\u1d84": "k", + "\u1d85": "l", + "\u1d86": "m", + "\u1d87": "n", + "\u1d88": "p", + "\u1d89": "r", + "\u1d8a": "s", + "\u1d8c": "v", + "\u1d8d": "x", + "\u1d8e": "z", + "\u1d8f": "a", + "\u1d91": "d", + "\u1d92": "e", + "\u1d96": "i", + "\u1d99": "u", + "\u1e00": "a", + "\u1e01": "a", + "\u1e02": "b", + "\u1e03": "b", + "\u1e04": "b", + "\u1e05": "b", + "\u1e06": "b", + "\u1e07": "b", + "\u1e08": "c", + "\u1e09": "c", + "\u1e0a": "d", + "\u1e0b": "d", + "\u1e0c": "d", + "\u1e0d": "d", + "\u1e0e": "d", + "\u1e0f": "d", + "\u1e10": "d", + "\u1e11": "d", + "\u1e12": "d", + "\u1e13": "d", + "\u1e14": "e", + "\u1e15": "e", + "\u1e16": "e", + "\u1e17": "e", + "\u1e18": "e", + "\u1e19": "e", + "\u1e1a": "e", + "\u1e1b": "e", + "\u1e1c": "e", + "\u1e1d": "e", + "\u1e1e": "f", + "\u1e1f": "f", + "\u1e20": "g", + "\u1e21": "g", + "\u1e22": "h", + "\u1e23": "h", + "\u1e24": "h", + "\u1e25": "h", + "\u1e26": "h", + "\u1e27": "h", + "\u1e28": "h", + "\u1e29": "h", + "\u1e2a": "h", + "\u1e2b": "h", + "\u1e2c": "i", + "\u1e2d": "i", + "\u1e2e": "i", + "\u1e2f": "i", + "\u1e30": "k", + "\u1e31": "k", + "\u1e32": "k", + "\u1e33": "k", + "\u1e34": "k", + "\u1e35": "k", + "\u1e36": "l", + "\u1e37": "l", + "\u1e38": "l", + "\u1e39": "l", + "\u1e3a": "l", + "\u1e3b": "l", + "\u1e3c": "l", + "\u1e3d": "l", + "\u1e3e": "m", + "\u1e3f": "m", + "\u1e40": "m", + "\u1e41": "m", + "\u1e42": "m", + "\u1e43": "m", + "\u1e44": "n", + "\u1e45": "n", + "\u1e46": "n", + "\u1e47": "n", + "\u1e48": "n", + "\u1e49": "n", + "\u1e4a": "n", + "\u1e4b": "n", + "\u1e4c": "o", + "\u1e4d": "o", + "\u1e4e": "o", + "\u1e4f": "o", + "\u1e50": "o", + "\u1e51": "o", + "\u1e52": "o", + "\u1e53": "o", + "\u1e54": "p", + "\u1e55": "p", + "\u1e56": "p", + "\u1e57": "p", + "\u1e58": "r", + "\u1e59": "r", + "\u1e5a": "r", + "\u1e5b": "r", + "\u1e5c": "r", + "\u1e5d": "r", + "\u1e5e": "r", + "\u1e5f": "r", + "\u1e60": "s", + "\u1e61": "s", + "\u1e62": "s", + "\u1e63": "s", + "\u1e64": "s", + "\u1e65": "s", + "\u1e66": "s", + "\u1e67": "s", + "\u1e68": "s", + "\u1e69": "s", + "\u1e6a": "t", + "\u1e6b": "t", + "\u1e6c": "t", + "\u1e6d": "t", + "\u1e6e": "t", + "\u1e6f": "t", + "\u1e70": "t", + "\u1e71": "t", + "\u1e72": "u", + "\u1e73": "u", + "\u1e74": "u", + "\u1e75": "u", + "\u1e76": "u", + "\u1e77": "u", + "\u1e78": "u", + "\u1e79": "u", + "\u1e7a": "u", + "\u1e7b": "u", + "\u1e7c": "v", + "\u1e7d": "v", + "\u1e7e": "v", + "\u1e7f": "v", + "\u1e80": "w", + "\u1e81": "w", + "\u1e82": "w", + "\u1e83": "w", + "\u1e84": "w", + "\u1e85": "w", + "\u1e86": "w", + "\u1e87": "w", + "\u1e88": "w", + "\u1e89": "w", + "\u1e8a": "x", + "\u1e8b": "x", + "\u1e8c": "x", + "\u1e8d": "x", + "\u1e8e": "y", + "\u1e8f": "y", + "\u1e90": "z", + "\u1e91": "z", + "\u1e92": "z", + "\u1e93": "z", + "\u1e94": "z", + "\u1e95": "z", + "\u1e96": "h", + "\u1e97": "t", + "\u1e98": "w", + "\u1e99": "y", + "\u1e9a": "a", + "\u1e9b": "\u017f", + "\u1e9e": "ss", + "\u1ea0": "a", + "\u1ea1": "a", + "\u1ea2": "a", + "\u1ea3": "a", + "\u1ea4": "a", + "\u1ea5": "a", + "\u1ea6": "a", + "\u1ea7": "a", + "\u1ea8": "a", + "\u1ea9": "a", + "\u1eaa": "a", + "\u1eab": "a", + "\u1eac": "a", + "\u1ead": "a", + "\u1eae": "a", + "\u1eaf": "a", + "\u1eb0": "a", + "\u1eb1": "a", + "\u1eb2": "a", + "\u1eb3": "a", + "\u1eb4": "a", + "\u1eb5": "a", + "\u1eb6": "a", + "\u1eb7": "a", + "\u1eb8": "e", + "\u1eb9": "e", + "\u1eba": "e", + "\u1ebb": "e", + "\u1ebc": "e", + "\u1ebd": "e", + "\u1ebe": "e", + "\u1ebf": "e", + "\u1ec0": "e", + "\u1ec1": "e", + "\u1ec2": "e", + "\u1ec3": "e", + "\u1ec4": "e", + "\u1ec5": "e", + "\u1ec6": "e", + "\u1ec7": "e", + "\u1ec8": "i", + "\u1ec9": "i", + "\u1eca": "i", + "\u1ecb": "i", + "\u1ecc": "o", + "\u1ecd": "o", + "\u1ece": "o", + "\u1ecf": "o", + "\u1ed0": "o", + "\u1ed1": "o", + "\u1ed2": "o", + "\u1ed3": "o", + "\u1ed4": "o", + "\u1ed5": "o", + "\u1ed6": "o", + "\u1ed7": "o", + "\u1ed8": "o", + "\u1ed9": "o", + "\u1eda": "o", + "\u1edb": "o", + "\u1edc": "o", + "\u1edd": "o", + "\u1ede": "o", + "\u1edf": "o", + "\u1ee0": "o", + "\u1ee1": "o", + "\u1ee2": "o", + "\u1ee3": "o", + "\u1ee4": "u", + "\u1ee5": "u", + "\u1ee6": "u", + "\u1ee7": "u", + "\u1ee8": "u", + "\u1ee9": "u", + "\u1eea": "u", + "\u1eeb": "u", + "\u1eec": "u", + "\u1eed": "u", + "\u1eee": "u", + "\u1eef": "u", + "\u1ef0": "u", + "\u1ef1": "u", + "\u1ef2": "y", + "\u1ef3": "y", + "\u1ef4": "y", + "\u1ef5": "y", + "\u1ef6": "y", + "\u1ef7": "y", + "\u1ef8": "y", + "\u1ef9": "y", + "\u1efe": "y", + "\u1eff": "y", + "\u1f00": "\u03b1", + "\u1f01": "\u03b1", + "\u1f02": "\u03b1", + "\u1f03": "\u03b1", + "\u1f04": "\u03b1", + "\u1f05": "\u03b1", + "\u1f06": "\u03b1", + "\u1f07": "\u03b1", + "\u1f08": "\u03b1", + "\u1f09": "\u03b1", + "\u1f0a": "\u03b1", + "\u1f0b": "\u03b1", + "\u1f0c": "\u03b1", + "\u1f0d": "\u03b1", + "\u1f0e": "\u03b1", + "\u1f0f": "\u03b1", + "\u1f10": "\u03b5", + "\u1f11": "\u03b5", + "\u1f12": "\u03b5", + "\u1f13": "\u03b5", + "\u1f14": "\u03b5", + "\u1f15": "\u03b5", + "\u1f18": "\u03b5", + "\u1f19": "\u03b5", + "\u1f1a": "\u03b5", + "\u1f1b": "\u03b5", + "\u1f1c": "\u03b5", + "\u1f1d": "\u03b5", + "\u1f20": "\u03b7", + "\u1f21": "\u03b7", + "\u1f22": "\u03b7", + "\u1f23": "\u03b7", + "\u1f24": "\u03b7", + "\u1f25": "\u03b7", + "\u1f26": "\u03b7", + "\u1f27": "\u03b7", + "\u1f28": "\u03b7", + "\u1f29": "\u03b7", + "\u1f2a": "\u03b7", + "\u1f2b": "\u03b7", + "\u1f2c": "\u03b7", + "\u1f2d": "\u03b7", + "\u1f2e": "\u03b7", + "\u1f2f": "\u03b7", + "\u1f30": "\u03b9", + "\u1f31": "\u03b9", + "\u1f32": "\u03b9", + "\u1f33": "\u03b9", + "\u1f34": "\u03b9", + "\u1f35": "\u03b9", + "\u1f36": "\u03b9", + "\u1f37": "\u03b9", + "\u1f38": "\u03b9", + "\u1f39": "\u03b9", + "\u1f3a": "\u03b9", + "\u1f3b": "\u03b9", + "\u1f3c": "\u03b9", + "\u1f3d": "\u03b9", + "\u1f3e": "\u03b9", + "\u1f3f": "\u03b9", + "\u1f40": "\u03bf", + "\u1f41": "\u03bf", + "\u1f42": "\u03bf", + "\u1f43": "\u03bf", + "\u1f44": "\u03bf", + "\u1f45": "\u03bf", + "\u1f48": "\u03bf", + "\u1f49": "\u03bf", + "\u1f4a": "\u03bf", + "\u1f4b": "\u03bf", + "\u1f4c": "\u03bf", + "\u1f4d": "\u03bf", + "\u1f50": "\u03c5", + "\u1f51": "\u03c5", + "\u1f52": "\u03c5", + "\u1f53": "\u03c5", + "\u1f54": "\u03c5", + "\u1f55": "\u03c5", + "\u1f56": "\u03c5", + "\u1f57": "\u03c5", + "\u1f59": "\u03c5", + "\u1f5b": "\u03c5", + "\u1f5d": "\u03c5", + "\u1f5f": "\u03c5", + "\u1f60": "\u03c9", + "\u1f61": "\u03c9", + "\u1f62": "\u03c9", + "\u1f63": "\u03c9", + "\u1f64": "\u03c9", + "\u1f65": "\u03c9", + "\u1f66": "\u03c9", + "\u1f67": "\u03c9", + "\u1f68": "\u03c9", + "\u1f69": "\u03c9", + "\u1f6a": "\u03c9", + "\u1f6b": "\u03c9", + "\u1f6c": "\u03c9", + "\u1f6d": "\u03c9", + "\u1f6e": "\u03c9", + "\u1f6f": "\u03c9", + "\u1f70": "\u03b1", + "\u1f71": "\u03b1", + "\u1f72": "\u03b5", + "\u1f73": "\u03b5", + "\u1f74": "\u03b7", + "\u1f75": "\u03b7", + "\u1f76": "\u03b9", + "\u1f77": "\u03b9", + "\u1f78": "\u03bf", + "\u1f79": "\u03bf", + "\u1f7a": "\u03c5", + "\u1f7b": "\u03c5", + "\u1f7c": "\u03c9", + "\u1f7d": "\u03c9", + "\u1f80": "\u03b1", + "\u1f81": "\u03b1", + "\u1f82": "\u03b1", + "\u1f83": "\u03b1", + "\u1f84": "\u03b1", + "\u1f85": "\u03b1", + "\u1f86": "\u03b1", + "\u1f87": "\u03b1", + "\u1f88": "\u03b1", + "\u1f89": "\u03b1", + "\u1f8a": "\u03b1", + "\u1f8b": "\u03b1", + "\u1f8c": "\u03b1", + "\u1f8d": "\u03b1", + "\u1f8e": "\u03b1", + "\u1f8f": "\u03b1", + "\u1f90": "\u03b7", + "\u1f91": "\u03b7", + "\u1f92": "\u03b7", + "\u1f93": "\u03b7", + "\u1f94": "\u03b7", + "\u1f95": "\u03b7", + "\u1f96": "\u03b7", + "\u1f97": "\u03b7", + "\u1f98": "\u03b7", + "\u1f99": "\u03b7", + "\u1f9a": "\u03b7", + "\u1f9b": "\u03b7", + "\u1f9c": "\u03b7", + "\u1f9d": "\u03b7", + "\u1f9e": "\u03b7", + "\u1f9f": "\u03b7", + "\u1fa0": "\u03c9", + "\u1fa1": "\u03c9", + "\u1fa2": "\u03c9", + "\u1fa3": "\u03c9", + "\u1fa4": "\u03c9", + "\u1fa5": "\u03c9", + "\u1fa6": "\u03c9", + "\u1fa7": "\u03c9", + "\u1fa8": "\u03c9", + "\u1fa9": "\u03c9", + "\u1faa": "\u03c9", + "\u1fab": "\u03c9", + "\u1fac": "\u03c9", + "\u1fad": "\u03c9", + "\u1fae": "\u03c9", + "\u1faf": "\u03c9", + "\u1fb0": "\u03b1", + "\u1fb1": "\u03b1", + "\u1fb2": "\u03b1", + "\u1fb3": "\u03b1", + "\u1fb4": "\u03b1", + "\u1fb6": "\u03b1", + "\u1fb7": "\u03b1", + "\u1fb8": "\u03b1", + "\u1fb9": "\u03b1", + "\u1fba": "\u03b1", + "\u1fbb": "\u03b1", + "\u1fbc": "\u03b1", + "\u1fc2": "\u03b7", + "\u1fc3": "\u03b7", + "\u1fc4": "\u03b7", + "\u1fc6": "\u03b7", + "\u1fc7": "\u03b7", + "\u1fc8": "\u03b5", + "\u1fc9": "\u03b5", + "\u1fca": "\u03b7", + "\u1fcb": "\u03b7", + "\u1fcc": "\u03b7", + "\u1fd0": "\u03b9", + "\u1fd1": "\u03b9", + "\u1fd2": "\u03b9", + "\u1fd3": "\u03b9", + "\u1fd6": "\u03b9", + "\u1fd7": "\u03b9", + "\u1fd8": "\u03b9", + "\u1fd9": "\u03b9", + "\u1fda": "\u03b9", + "\u1fdb": "\u03b9", + "\u1fe0": "\u03c5", + "\u1fe1": "\u03c5", + "\u1fe2": "\u03c5", + "\u1fe3": "\u03c5", + "\u1fe4": "\u03c1", + "\u1fe5": "\u03c1", + "\u1fe6": "\u03c5", + "\u1fe7": "\u03c5", + "\u1fe8": "\u03c5", + "\u1fe9": "\u03c5", + "\u1fea": "\u03c5", + "\u1feb": "\u03c5", + "\u1fec": "\u03c1", + "\u1ff2": "\u03c9", + "\u1ff3": "\u03c9", + "\u1ff4": "\u03c9", + "\u1ff6": "\u03c9", + "\u1ff7": "\u03c9", + "\u1ff8": "\u03bf", + "\u1ff9": "\u03bf", + "\u1ffa": "\u03c9", + "\u1ffb": "\u03c9", + "\u1ffc": "\u03c9", + "\u212b": "a", + "\u2c60": "l", + "\u2c61": "l", + "\u2c62": "l", + "\u2c63": "p", + "\u2c64": "r", + "\u2c65": "a", + "\u2c66": "t", + "\u2c67": "h", + "\u2c68": "h", + "\u2c69": "k", + "\u2c6a": "k", + "\u2c6b": "z", + "\u2c6c": "z", + "\u2c6e": "m", + "\u2c71": "v", + "\u2c72": "w", + "\u2c73": "w", + "\u2c74": "v", + "\u2c78": "e", + "\u2c7a": "o", + "\u2c7e": "s", + "\u2c7f": "z", + "\u304c": "\u304b", + "\u304e": "\u304d", + "\u3050": "\u304f", + "\u3052": "\u3051", + "\u3054": "\u3053", + "\u3056": "\u3055", + "\u3058": "\u3057", + "\u305a": "\u3059", + "\u305c": "\u305b", + "\u305e": "\u305d", + "\u3060": "\u305f", + "\u3062": "\u3061", + "\u3065": "\u3064", + "\u3067": "\u3066", + "\u3069": "\u3068", + "\u3070": "\u306f", + "\u3071": "\u306f", + "\u3073": "\u3072", + "\u3074": "\u3072", + "\u3076": "\u3075", + "\u3077": "\u3075", + "\u3079": "\u3078", + "\u307a": "\u3078", + "\u307c": "\u307b", + "\u307d": "\u307b", + "\u3094": "\u3046", + "\u309e": "\u309d", + "\u30ac": "\u30ab", + "\u30ae": "\u30ad", + "\u30b0": "\u30af", + "\u30b2": "\u30b1", + "\u30b4": "\u30b3", + "\u30b6": "\u30b5", + "\u30b8": "\u30b7", + "\u30ba": "\u30b9", + "\u30bc": "\u30bb", + "\u30be": "\u30bd", + "\u30c0": "\u30bf", + "\u30c2": "\u30c1", + "\u30c5": "\u30c4", + "\u30c7": "\u30c6", + "\u30c9": "\u30c8", + "\u30d0": "\u30cf", + "\u30d1": "\u30cf", + "\u30d3": "\u30d2", + "\u30d4": "\u30d2", + "\u30d6": "\u30d5", + "\u30d7": "\u30d5", + "\u30d9": "\u30d8", + "\u30da": "\u30d8", + "\u30dc": "\u30db", + "\u30dd": "\u30db", + "\u30f4": "\u30a6", + "\u30f7": "\u30ef", + "\u30f8": "\u30f0", + "\u30f9": "\u30f1", + "\u30fa": "\u30f2", + "\u30fe": "\u30fd", + "\ua740": "k", + "\ua741": "k", + "\ua742": "k", + "\ua743": "k", + "\ua744": "k", + "\ua745": "k", + "\ua748": "l", + "\ua749": "l", + "\ua74a": "o", + "\ua74b": "o", + "\ua74c": "o", + "\ua74d": "o", + "\ua750": "p", + "\ua751": "p", + "\ua752": "p", + "\ua753": "p", + "\ua754": "p", + "\ua755": "p", + "\ua756": "q", + "\ua757": "q", + "\ua758": "q", + "\ua759": "q", + "\ua75e": "v", + "\ua75f": "v", + "\ua78e": "l", + "\ua790": "n", + "\ua791": "n", + "\ua792": "c", + "\ua793": "c", + "\ua794": "c", + "\ua795": "h", + "\ua796": "b", + "\ua797": "b", + "\ua798": "f", + "\ua799": "f", + "\ua7a0": "g", + "\ua7a1": "g", + "\ua7a2": "k", + "\ua7a3": "k", + "\ua7a4": "n", + "\ua7a5": "n", + "\ua7a6": "r", + "\ua7a7": "r", + "\ua7a8": "s", + "\ua7a9": "s", + "\ua7aa": "h", + "\ua7ad": "l", + "\ua7b2": "j", + "\ua7b8": "u", + "\ua7b9": "u", + "\ua7c4": "c", + "\ua7c5": "s", + "\ua7c6": "z", + "\ua7c7": "d", + "\ua7c8": "d", + "\ua7c9": "s", + "\ua7ca": "s", + "\uab34": "e", + "\uab37": "l", + "\uab38": "l", + "\uab39": "l", + "\uab3a": "m", + "\uab3b": "n", + "\uab49": "r", + "\uab4e": "u", + "\uab52": "u", + "\uab56": "x", + "\uab57": "x", + "\uab58": "x", + "\uab59": "x", + "\uab5a": "y", + "\ufb1d": "\u05d9", + "\ufb1f": "\u05f2", + "\ufb2a": "\u05e9", + "\ufb2b": "\u05e9", + "\ufb2c": "\u05e9", + "\ufb2d": "\u05e9", + "\ufb2e": "\u05d0", + "\ufb2f": "\u05d0", + "\ufb30": "\u05d0", + "\ufb31": "\u05d1", + "\ufb32": "\u05d2", + "\ufb33": "\u05d3", + "\ufb34": "\u05d4", + "\ufb35": "\u05d5", + "\ufb36": "\u05d6", + "\ufb38": "\u05d8", + "\ufb39": "\u05d9", + "\ufb3a": "\u05da", + "\ufb3b": "\u05db", + "\ufb3c": "\u05dc", + "\ufb3e": "\u05de", + "\ufb40": "\u05e0", + "\ufb41": "\u05e1", + "\ufb43": "\u05e3", + "\ufb44": "\u05e4", + "\ufb46": "\u05e6", + "\ufb47": "\u05e7", + "\ufb48": "\u05e8", + "\ufb49": "\u05e9", + "\ufb4a": "\u05ea", + "\ufb4b": "\u05d5", + "\ufb4c": "\u05d1", + "\ufb4d": "\u05db", + "\ufb4e": "\u05e4", + "\ud804\udc9a": "\ud804\udc99", + "\ud804\udc9c": "\ud804\udc9b", + "\ud804\udcab": "\ud804\udca5", + "\ud837\udf09": "t", + "\ud837\udf11": "l", + "\ud837\udf13": "l", + "\ud837\udf16": "r", + "\ud837\udf1a": "i", + "\ud837\udf1b": "o", + "\ud837\udf1d": "c", + "\ud837\udf1e": "s" +} diff --git a/web/src/dinners/names.test.ts b/web/src/dinners/names.test.ts new file mode 100644 index 0000000..f2677fd --- /dev/null +++ b/web/src/dinners/names.test.ts @@ -0,0 +1,20 @@ +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) + }) +}) diff --git a/web/src/dinners/names.ts b/web/src/dinners/names.ts new file mode 100644 index 0000000..52cc50d --- /dev/null +++ b/web/src/dinners/names.ts @@ -0,0 +1,58 @@ +// 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 = 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 { + 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 +} diff --git a/web/src/dinners/useRefusals.ts b/web/src/dinners/useRefusals.ts new file mode 100644 index 0000000..485e9e3 --- /dev/null +++ b/web/src/dinners/useRefusals.ts @@ -0,0 +1,26 @@ +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([]) + const attempts = useRef(0) + + function track(change: string, what: string, answer: Promise) { + 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 } +} diff --git a/web/src/onboarding/NotificationsStep.test.tsx b/web/src/onboarding/NotificationsStep.test.tsx new file mode 100644 index 0000000..e606aa9 --- /dev/null +++ b/web/src/onboarding/NotificationsStep.test.tsx @@ -0,0 +1,301 @@ +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> + +beforeEach(() => { + requestPermission = vi.fn<() => Promise>(() => 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((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((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( + + + , + ) + 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( + + + + + } + /> + Home} /> + Regulars step} /> + + + + , + ) + 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( + () => + 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(() => 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() + }) +}) diff --git a/web/src/onboarding/NotificationsStep.tsx b/web/src/onboarding/NotificationsStep.tsx new file mode 100644 index 0000000..f17ddd1 --- /dev/null +++ b/web/src/onboarding/NotificationsStep.tsx @@ -0,0 +1,143 @@ +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 + +// 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 { + 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(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 ( + + + +
+ } + > +

MealQuest can let the household know when there's something to look at:

+
    + {topics.map(({ icon, name, meaning }) => ( +
  • + + + +

    + {name} + {meaning} +

    +
  • + ))} +
+
+ +
+ {problem && ( +

+ {problem} +

+ )} + {fewRegulars && ( + + )} + + ) +} diff --git a/web/src/onboarding/Onboarding.module.css b/web/src/onboarding/Onboarding.module.css new file mode 100644 index 0000000..ef2703c --- /dev/null +++ b/web/src/onboarding/Onboarding.module.css @@ -0,0 +1,254 @@ +/* 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; + } +} diff --git a/web/src/onboarding/OnboardingGate.tsx b/web/src/onboarding/OnboardingGate.tsx new file mode 100644 index 0000000..473b33d --- /dev/null +++ b/web/src/onboarding/OnboardingGate.tsx @@ -0,0 +1,52 @@ +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 + } + return + } + if (offline) return + if (settings.isError) { + return ( +
+ void refetch()}>MealQuest could not load: {settings.error.message} +
+ ) + } + return +} diff --git a/web/src/onboarding/OnboardingPage.test.tsx b/web/src/onboarding/OnboardingPage.test.tsx new file mode 100644 index 0000000..ec00b9d --- /dev/null +++ b/web/src/onboarding/OnboardingPage.test.tsx @@ -0,0 +1,419 @@ +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 = vi.fn((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((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, 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( + + + , + ) +} + +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() + }) +}) diff --git a/web/src/onboarding/OnboardingPage.tsx b/web/src/onboarding/OnboardingPage.tsx new file mode 100644 index 0000000..48e3924 --- /dev/null +++ b/web/src/onboarding/OnboardingPage.tsx @@ -0,0 +1,68 @@ +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 ( + + } /> + } /> + } /> + + ) +} + +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 ( + void navigate('/onboarding/regulars')}> + Let's start + + } + > +

MealQuest plans the household's dinners around what you already love to cook.

+
    + {terms.map(({ icon, name, meaning }) => ( +
  • + + + +

    + {name} + {meaning} +

    +
  • + ))} +
+
+ ) +} diff --git a/web/src/onboarding/OnboardingStep.tsx b/web/src/onboarding/OnboardingStep.tsx new file mode 100644 index 0000000..3e2cd6e --- /dev/null +++ b/web/src/onboarding/OnboardingStep.tsx @@ -0,0 +1,35 @@ +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 ( +
+
+ +

+ Step {step} of {ONBOARDING_STEPS} +

+

{title}

+ {children} +
+ {action &&
{action}
} +
+ ) +} diff --git a/web/src/onboarding/RegularsStep.tsx b/web/src/onboarding/RegularsStep.tsx new file mode 100644 index 0000000..8c991f3 --- /dev/null +++ b/web/src/onboarding/RegularsStep.tsx @@ -0,0 +1,147 @@ +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) { + event.preventDefault() + const name = draft.trim() + if (name === '') return + addRegular(name) + setDraft('') + } + + return ( + void navigate('/onboarding/notifications')} + > + Next: notifications + + } + > +

+ Add the dinners your household makes on repeat. MealQuest builds suggestions from these. +

+
+ setDraft(event.target.value)} + /> + + + {refusals.map((refusal) => ( +

+ {refusal.text} +

+ ))} + +

Your regulars · {list.length}

+ {regulars.isPending && } + {regulars.isError && ( +
+ void regulars.refetch()}> + Your Regulars could not load: {regulars.error.message} + +
+ )} + {list.length > 0 && ( +
    + {list.map((regular: Regular) => ( +
  • + removeRegular(regular)} + /> +
  • + ))} +
+ )} + + {regulars.isSuccess && picks.length > 0 && ( + <> +

Common picks

+
    + {picks.map((pick) => ( +
  • + +
  • + ))} +
+ + )} +
+ ) +} diff --git a/web/src/settings/RegularsSettingsPage.module.css b/web/src/settings/RegularsSettingsPage.module.css new file mode 100644 index 0000000..0954766 --- /dev/null +++ b/web/src/settings/RegularsSettingsPage.module.css @@ -0,0 +1,302 @@ +/* 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; +} diff --git a/web/src/settings/RegularsSettingsPage.test.tsx b/web/src/settings/RegularsSettingsPage.test.tsx new file mode 100644 index 0000000..afae963 --- /dev/null +++ b/web/src/settings/RegularsSettingsPage.test.tsx @@ -0,0 +1,591 @@ +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 + +// 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 = {}) { + const server = { regulars, fetchMock: undefined as unknown as Mock } + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + [`GET ${REGULARS}`]: () => jsonResponse(200, { dinners: server.regulars }), + ...responders, + } + server.fetchMock = vi.fn((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) { + 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) { + 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( + + + + + } /> + + + + , + ) + 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( + + + + + + + , + ) + 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) { + 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((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) + }) +}) diff --git a/web/src/settings/RegularsSettingsPage.tsx b/web/src/settings/RegularsSettingsPage.tsx new file mode 100644 index 0000000..c816b49 --- /dev/null +++ b/web/src/settings/RegularsSettingsPage.tsx @@ -0,0 +1,641 @@ +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 = { + stovetop: 'Stovetop', + oven: 'Oven', + crockpot: 'Crock pot', + grill: 'Grill', + other: 'Other', +} + +const difficultyLabels: Record = { 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 ( +
+
+ void navigate('/settings')}> + + +
+

Settings

+

Regular dinners

+
+
+

Changes apply to everyone in the household.

+ +
+ ) +} + +// 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(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) { + 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 ( + <> +
+
+ setDraft(event.target.value)} + /> + + + +
+ {isLink && ( + <> + setLinkName(event.target.value)} + /> +

+ Name it yourself: a link can carry private details, so it never names a dinner. +

+ + )} + + {refusals.map((refusal) => ( +

+ {refusal.text} +

+ ))} + + {regulars.isPending && } + {regulars.isError && ( + void regulars.refetch()}> + Your Regulars could not load: {regulars.error.message} + + )} + {regulars.isSuccess && ( +
+

+ On rotation · {list.length} +

+ {list.length === 0 ? ( +

No Regulars yet. Add the dinners your household makes on repeat.

+ ) : ( + +
    + {list.map((regular) => ( + + setOpen({ sheet: regular.grounding_status === 'failed' ? 'fix' : 'edit', id: regular.id }) + } + onFavorite={() => onFavorite(regular)} + /> + ))} +
+
+ )} +
+ )} + + {opened && open.sheet === 'edit' && ( + setOpen(null)} + onRemove={() => onRemove(opened)} + /> + )} + {opened && open.sheet === 'fix' && ( + 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 = { + 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 {badge.text} +} + +type RegularRowProps = { + regular: Regular + onOpen: () => void + onFavorite: () => void +} + +function RegularRow({ regular, onOpen, onFavorite }: RegularRowProps) { + const line = details(regular) + return ( +
  • + } + title={regular.name} + meta={ + + {line && {line}} + + + } + onClick={regular.pending ? undefined : onOpen} + trailing={ + + + + } + /> +
  • + ) +} + +type SheetFrameProps = { + title: string + onClose: () => void + children: ReactNode +} + +function SheetFrame({ title, onClose, children }: SheetFrameProps) { + const titleId = useId() + return ( + +
    +

    + {title} +

    + {children} +
    +
    + ) +} + +// 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) { + edit.reset() + setDraft((current) => ({ ...current, ...next })) + } + + function onSave(event: FormEvent) { + event.preventDefault() + if (!canSave) return + edit.mutate({ id: regular.id, patch }, { onSuccess: onClose }) + } + + if (confirming) { + return ( + +

    + It leaves your Regulars, so suggestions and plans stop using it. You can add it again later. +

    +
    + + +
    +
    + ) + } + + return ( + +
    +
    + change({ name: event.target.value })} + /> + {nameError &&

    {nameError}

    } +
    + + change({ minutes: event.target.value })} + /> +
    + {minutesError &&

    {minutesError}

    } + + {edit.isError && ( +

    + Not saved: {edit.error.message} +

    + )} +
    + + +
    +
    +
    +
    + ) +} + +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('pick') + const ground = useGroundRegular() + + function fix(body: RecipeFix) { + ground.mutate({ id: regular.id, fix: body }, { onSuccess: onClose }) + } + + return ( + +

    + We couldn't find a recipe for {regular.name}. Pick a match below, paste a recipe link or type it in. +

    + { + ground.reset() + setPath(next) + }} + /> +
    + {path === 'pick' && fix({ candidate_id })} />} + {path === 'link' && fix({ recipe_url })} />} + {path === 'manual' && fix({ manual })} />} +
    +

    + {ground.isPending && 'Finding recipe… this can take a minute.'} +

    + {ground.isError && ( +

    + Not fixed: {ground.error.message} +

    + )} + +
    + ) +} + +function PickCandidate({ id, onPick }: { id: string; onPick: (candidateId: string) => void }) { + const candidates = useRecipeCandidates(id) + if (candidates.isPending) return + if (candidates.isError) { + return ( + void candidates.refetch()}> + Recipe matches could not load: {candidates.error.message} + + ) + } + if (candidates.data.length === 0) { + return

    No matches were found. Paste a recipe link or type it in.

    + } + return ( +
      + {candidates.data.map((candidate) => ( +
    • + +
    • + ))} +
    + ) +} + +function PasteLink({ onSubmit }: { onSubmit: (recipeUrl: string) => void }) { + const [link, setLink] = useState('') + const url = link.trim() + return ( +
    { + event.preventDefault() + if (url !== '') onSubmit(url) + }} + > + setLink(event.target.value)} + /> + + + ) +} + +function ManualEntry({ onSubmit }: { onSubmit: (manual: ManualRecipe) => void }) { + const [ingredients, setIngredients] = useState('') + const [steps, setSteps] = useState('') + const [minutes, setMinutes] = useState('') + const [method, setMethod] = useState('') + 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) { + event.preventDefault() + if (!canSave) return + onSubmit({ ingredients: ingredientLines, steps: lines(steps), minutes: Number(minutes), method }) + } + + return ( +
    +
    + +