Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 b2e02223c6 Require method and minutes for a typed-in recipe; pause refresh on every save
CI / ci (push) Successful in 2m19s
CI / ci (pull_request) Successful in 2m17s
The manual recipe form now enables Save only once method and minutes are
set, as POST /api/v1/dinners/{id}/ground requires, and always sends both.

useRegulars watches the Regulars changes in flight with useIsMutating, so
the grounding refresh pauses while an edit or a recipe fix is being saved,
not only while an add or favorite changes the list, and resumes after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:21:05 -04:00
dev_agentandClaude Opus 5.5 e3332aba35 Add the Regular dinners settings screen
CI / ci (push) Successful in 2m22s
CI / ci (pull_request) Successful in 2m17s
/settings/regulars lists the household's Regulars with photo, name,
"method · minutes", the favorite heart and a grounding badge ("Finding
recipe…", "Recipe found", "Needs a recipe"). The add field takes a name
or a pasted recipe link. Tapping a Regular opens an edit sheet (rename,
method, minutes, difficulty, remove with confirmation); a Regular that
needs a recipe opens "Fix recipe": pick one of up to three candidates,
paste a link, or type the ingredients and steps in. The list is read
again every 3 seconds while any Regular is still finding its recipe.

The dinners client now parses the full dinner and adds edit, favorite,
ground and recipe-candidate calls; onboarding shares its refusal
tracking through useRefusals.

Maestro task: fa8f88ae-5ade-430e-a069-274aec112f26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:10:53 -04:00
dev_agent 6a3f1f3936 Onboarding step 3 shell (#92)
CI / ci (push) Successful in 2m19s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 17:57:25 -04:00
dev_agent 0a5db6a9e6 Onboarding steps 1 and 2 (mock 1h) with first-run redirect (#91)
CI / ci (push) Successful in 2m24s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 17:34:52 -04:00
dev_agent 423b898f54 Background grounding and recipe fix-up API (#90)
CI / ci (push) Successful in 2m24s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 16:39:10 -04:00
dev_agent 729c5bd0fe Dinners API for Regulars (#89)
CI / ci (push) Successful in 2m15s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 15:57:13 -04:00
90 changed files with 553 additions and 10486 deletions
+2 -20
View File
@@ -27,7 +27,6 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
@@ -107,10 +106,6 @@ func run(cfg config.Config, logger *slog.Logger) error {
if err != nil {
return err
}
suggestSvc, err := suggest.NewService(db, grounder, sideQuests(ai, guard), runner, logger)
if err != nil {
return err
}
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil {
@@ -128,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, dinnerSvc, suggestSvc, 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
}
@@ -252,8 +247,7 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
// 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 and
// suggest.NewService suggest.KindSuggestionBatch).
// starts (dinners.NewService registers dinners.KindGroundDinner).
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
builtins, err := jobs.RegisterBuiltins(runner)
if err != nil {
@@ -274,18 +268,6 @@ func normalizer(ai *aisettings.Service, guard *harness.Guard) recipes.Normalizer
}
}
// sideQuests runs suggest_side_quests with the AI settings current at each
// run, sharing guard.
func sideQuests(ai *aisettings.Service, guard *harness.Guard) suggest.Suggester {
return func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) {
cfg, err := ai.HarnessConfig(ctx, guard)
if err != nil {
return skills.SideQuests{}, err
}
return skills.SuggestSideQuests(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) {
+20 -122
View File
@@ -96,9 +96,9 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
| `recipe_steps` | recipe_id, position, text. |
| `photos` | source_url, local_path, width, height, sha256, fetched_at. |
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised when a pending dinner is renamed, so only the latest `ground_dinner` job may write it), updated_by. |
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), updated_by. |
| `suggestion_batches` | trigger, status, ai_run_id. |
| `suggestions` | dinner_id, batch_id, name (the name the dish was suggested under, kept whatever its dinner is later renamed, so a Pass stays an immutable record every later batch dedupes against), reason, cited_regular_ids (JSON), status, decided_at. |
| `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. |
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. |
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), decided_at, updated_by. |
@@ -127,7 +127,7 @@ suggested (deck) ──Try it──▶ quest_log ──planned date passes──
│ └──────── explicit decision ─────┤
│ ├──▶ regular
│ └──▶ archived
└──Pass──▶ archived (suggested name kept for dedupe)
└──Pass──▶ archived (name kept for dedupe)
```
From `tried`, an explicit decision moves the dinner to `regular`, to
@@ -179,18 +179,16 @@ automatic** (D-021).
response holds the key. `Service.HarnessConfig` reads the settings for
each skill run, so a saved change applies to the next run.
- Dinners (`dinners.Service`). `POST /api/v1/dinners` adds a Regular from
`{name}` or `{name, recipe_url}` (an https:// link, kept in
`recipe_url` for grounding and never returned in a dinner response, as
it may carry a personal token) with status and origin `regular` and
grounding_status `pending`, answering 201 with the dinner. The name is
always the household's: no part of a link is ever used as a dinner's
name, and a link without a name is 400. A name
exactly one of `{name}` or `{recipe_url}` (an https:// link, kept in
`recipe_url` for grounding) with status and origin `regular` and
grounding_status `pending`, answering 201 with the dinner. A link's
dinner is named from its last path segment (separators become spaces,
an extension and all-digit words are dropped: `…/12345/beef-stew/` is
"Beef stew"); a link that names nothing is 400 — add it by name. A name
is trimmed, at most 120 characters and must keep a word after
`harness.Normalize`, which sets `normalized_name`. A name
`harness.Similar` to an unarchived dinner's, or a link one already has
(the source_url of the recipe it is grounded in, else its pasted
`recipe_url`), is 409 `duplicate_dinner`; archived names may be added
again.
`harness.Similar` to an unarchived dinner's, or a link one already has,
is 409 `duplicate_dinner`; archived names may be added again.
`GET /api/v1/dinners?status=regular|quest_log` returns
`{dinners: [...]}` ordered by normalized name, each with `photo_url`
(`/media/photos/{photo_id}-400.jpg`, or null) and `grounding_status`;
@@ -213,22 +211,11 @@ automatic** (D-021).
`failed` with `grounding_error` — also the job's failure code — one of
`no_recipe_found`, `recipe_unusable` (the pasted link), `search_not_configured`,
`search_failed`, `ai_failed`, `interrupted` (the server stopped) or
`grounding_error`. Each job carries the dinner's `grounding_revision`
from when it was enqueued; renaming a `pending` dinner clears its
`recipe_url`, raises the revision and enqueues a job for the new name.
A job for a dinner archived, no longer `pending` or at another revision
does no work, and a job only writes a dinner still unarchived, `pending`
and at its revision, so a fix-up made meanwhile wins and an earlier job
for an old name — queued or running — never decides the outcome.
Archiving or renaming a dinner also stops its running job where it is,
so no further search, page fetch, photo download or model call is made
for it. A
method, minutes_total or difficulty a member saved while the dinner was
`pending` is kept; grounding fills only those left empty. At boot, after
the job runner fails the jobs a previous process left running, every
unarchived dinner still `pending` with no `ground_dinner` job for its revision
queued or running is set `failed` with `interrupted`, so no dinner stays
pending with nothing left to ground it.
`grounding_error`. It only writes a dinner still `pending`, so a fix-up
made meanwhile wins. At boot, after the job runner fails the jobs a
previous process left running, every dinner still `pending` with no
`ground_dinner` job queued or running is set `failed` with `interrupted`,
so no dinner stays pending with nothing left to ground it.
`GET /api/v1/dinners/{id}/recipe-candidates`
searches for the dinner's name and returns `{candidates: [{candidate_id,
title, site_name, photo_url, minutes}]}`, at most 3, ranked as `Ground`
@@ -242,80 +229,14 @@ automatic** (D-021).
— no model runs — as a `recipes` row whose source_url is
`mealquest:manual:{recipe_id}` and whose raw_jsonld is a schema.org
Recipe built from it, and sets `manual` with its method and minutes, no
photo and the dinner's difficulty unchanged. Either clears the dinner's
pasted `recipe_url`: the recipe chosen replaces it. It answers 200 with
the dinner; an invalid body is 400, an unknown dinner 404, a page that cannot
photo and the dinner's difficulty unchanged. It answers 200 with the
dinner; an invalid body is 400, an unknown dinner 404, a page that cannot
ground it 422 `ungroundable` (the dinner is unchanged), recipe search not
set up 409 `not_configured`, a search or model failure 502
`search_failed` / `ai_failed` (only the reason code is logged, never the
provider's text), and grounding a candidate or link that runs past 90
seconds (fetch, photo and `normalize_recipe`, all in the request) 504
`ground_timeout`, leaving the dinner unchanged. An archived dinner is 409
`dinner_archived`. A candidate or link is written only if the dinner is
still as it was before grounding began: one renamed or grounded some
other way meanwhile is 409 `ground_conflict`, and one archived meanwhile
409 `dinner_archived`, either leaving the dinner unchanged. A method,
minutes or difficulty a member edits meanwhile is kept over the recipe's.
Any other failure grounding a candidate or link, or recording it on the
dinner, is 500, logged with a fixed reason and the dinner id only —
never the link, which can carry a personal token, or dependency error
text.
- Side quest suggestions (`suggest.Service`). `POST
/api/v1/suggestions/batches` creates a `manual` batch and enqueues its
`suggestion_batch` job, answering 202 `{batch_id, job_id, trigger,
status}` without waiting; while a batch is `pending` or `running` it
answers 202 with that batch instead, so at most one runs at a time (the
check, the batch and its job share one write transaction). The job reads the
household context, the deck's names and every declined name, runs
`suggest_side_quests` for 5 Side quests (scope: the batch id), then
grounds the survivors in the model's order with `Grounder.GroundMany`,
as many as are still wanted at a time, until 5 are kept or none are
left. A Side quest no recipe grounds is dropped; each round's grounded
ones are stored before the next — a dinner with status `suggested`,
origin `side_quest`, grounding_status `grounded` with the recipe's
recipe_id, photo_id, method, minutes_total and difficulty and the
model's cuisine, and a `pending` suggestion with the name it is
suggested under, the reason and `cited_regular_ids` — unless, since the
job read its input, a dinner it repeats was added, a suggestion it
repeats passed, or a Regular it cites renamed or archived (checked in
the storing transaction). The job publishes
`{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
`grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the
skill or `normalize_recipe` failed; no Side quest is made up instead,
D-017), `search_not_configured` or `search_failed`, and the batch is set
`failed`; Side quests stored before the failure stay in the deck. If
recording the batch's end fails, its job fails; the job runner retries
a job's final status write with backoff until it lands, each attempt
bounded at 5 seconds; at shutdown an attempt that fails or times out is
not retried, and the next start fails the job as interrupted. Recording
the batch's end is bounded the same way (one attempt, 5 seconds, made
even during shutdown), so a busy writer cannot hold a stopping worker;
a batch whose end timed out is left `running` with its job. Each time a
batch is started, before the active batch is selected, every batch
still `pending` or `running` with no job queued or running (the process
stopped, or recording its end failed) is set `failed`, so it does not
block the next.
`GET /api/v1/suggestions/deck` returns `{suggestions: [{id, dinner_id,
name, photo_url, difficulty, method, minutes_total, cuisine, reason,
cited_regular_ids, status, dinner_status, decided_at, created_at}]}`:
the `pending` suggestions whose dinner is still `suggested`, oldest
first. `POST /api/v1/suggestions/{id}/accept` (Try it) sets the
suggestion `accepted` and moves its dinner to `quest_log`; `POST
/api/v1/suggestions/{id}/decline` (Pass) sets it `declined` and archives
the dinner; every later batch, and the household context's passed list,
reads the suggestion's own name as declined, so renaming the archived
dinner changes nothing. Both record
the session's subject as the dinner's `updated_by` and answer 200
`{suggestion, replenish_job_id}`. Repeating a decision changes nothing
and answers 200; the opposite decision is 409 `suggestion_decided`, a
suggestion whose dinner left the deck some other way 409
`dinner_not_suggested`, and an unknown id 404. A decision that leaves
fewer than 5 in the deck starts a `replenish` batch, whose job id it
returns, unless a batch is already pending or running
(`replenish_job_id` is then null). The batch and its job are written in
the decision's own transaction, so a decision is never recorded without
the replenish it calls for; when they cannot be written, neither is the
decision, and a retry decides again.
`ground_timeout`, leaving the dinner unchanged.
- `/health` — liveness.
- Everything else serves the embedded PWA with SPA fallback.
@@ -378,18 +299,6 @@ automatic** (D-021).
dinner when the token-set (Jaccard) similarity of their words is at least
0.85. A `Deduper` over the Regulars, Quest log, pending suggestions and
passed names rejects a suggestion that repeats any of them.
- **Side quest suggestions** (`skills.SuggestSideQuests`, skill
`suggest_side_quests` v1): asks for twice the Side quests needed, at the
household temperature, with the household context plus the pending Side
quests. Malformed replies (bad JSON, a missing field, a bad enum, an
empty name) get the one repair turn. Well-formed Side quests that break a
rule are dropped, not repaired, and listed in `Dropped` with the rule:
one that repeats a Regular, Quest log dinner, pending Side quest, any
passed name (the whole history, not only the 60 in the context) or an
earlier Side quest of the batch; one citing no Regular or a name that is
not a Regular; and one whose reason is over 180 characters or names none
of its cited Regulars. Cited names are rewritten as the household writes
them. Grounding each survivor in a recipe is the caller's next step.
## Recipe grounding
@@ -579,24 +488,13 @@ 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
mock 1h, adding and archiving through the dinners API optimistically) 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`),
+3 -3
View File
@@ -82,9 +82,9 @@ func (s *seeder) pass(name, decidedAt string) {
batch := s.id("batch")
s.exec(`INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
VALUES (?, 'manual', 'done', ?, ?)`, batch, decidedAt, decidedAt)
s.exec(`INSERT INTO suggestions (id, dinner_id, batch_id, name, reason, cited_regular_ids, status,
decided_at, created_at, updated_at) VALUES (?, ?, ?, ?, 'why', '[]', 'declined', ?, ?, ?)`,
s.id("suggestion"), s.dinner(name, "archived", "", "", 0, false), batch, name, decidedAt, decidedAt, decidedAt)
s.exec(`INSERT INTO suggestions (id, dinner_id, batch_id, reason, cited_regular_ids, status,
decided_at, created_at, updated_at) VALUES (?, ?, ?, 'why', '[]', 'declined', ?, ?, ?)`,
s.id("suggestion"), s.dinner(name, "archived", "", "", 0, false), batch, decidedAt, decidedAt, decidedAt)
}
// plan inserts the week plan starting weekStart with one dinner per date;
-60
View File
@@ -1,70 +1,10 @@
package harness
import (
"encoding/json"
"os"
"testing"
"unicode"
"unicode/utf8"
)
// TestSharedDedupeCases runs testdata/dedupe_cases.json, which the PWA's
// copy of Normalize and Similar (web/src/dinners/names.ts) runs too, so the
// two cannot drift apart on these names.
func TestSharedDedupeCases(t *testing.T) {
raw, err := os.ReadFile("testdata/dedupe_cases.json")
if err != nil {
t.Fatal(err)
}
var cases struct {
Normalize []struct{ Name, Want string }
Similar []struct {
A, B string
Want bool
}
}
if err := json.Unmarshal(raw, &cases); err != nil {
t.Fatal(err)
}
if len(cases.Normalize) == 0 || len(cases.Similar) == 0 {
t.Fatal("dedupe_cases.json has no cases")
}
for _, c := range cases.Normalize {
if got := Normalize(c.Name); got != c.Want {
t.Errorf("Normalize(%q) = %q, want %q", c.Name, got, c.Want)
}
}
for _, c := range cases.Similar {
if got := Similar(c.A, c.B); got != c.Want {
t.Errorf("Similar(%q, %q) = %v, want %v", c.A, c.B, got, c.Want)
}
}
}
// TestLetterFoldsMatchPWA checks that the PWA's letter-fold table, which
// gen_folds.py writes beside folds_table.go, is letterFolds exactly; run
// go generate when it fails.
func TestLetterFoldsMatchPWA(t *testing.T) {
raw, err := os.ReadFile("../../../web/src/dinners/letterFolds.json")
if err != nil {
t.Fatal(err)
}
var pwa map[string]string
if err := json.Unmarshal(raw, &pwa); err != nil {
t.Fatal(err)
}
for r, want := range letterFolds {
if got, ok := pwa[string(r)]; !ok || got != want {
t.Errorf("PWA folds %U to %q (present %v), want %q", r, got, ok, want)
}
}
for k := range pwa {
if utf8.RuneCountInString(k) != 1 || letterFolds[[]rune(k)[0]] == "" {
t.Errorf("PWA folds %q, which letterFolds does not", k)
}
}
}
func TestNormalize(t *testing.T) {
tests := []struct {
name, want string
+1 -10
View File
@@ -1,8 +1,5 @@
#!/usr/bin/env python3
"""Generate folds_table.go, the letter-fold table used by harness.Normalize,
and its copy web/src/dinners/letterFolds.json, used by the PWA's
normalizeName (web/src/dinners/names.ts). TestLetterFoldsMatchPWA fails when
the two differ.
"""Generate folds_table.go, the letter-fold table used by harness.Normalize.
Run from this directory with `go generate ./internal/ai/harness` (or
`python3 gen_folds.py`; gofmt must be on PATH). It uses only the Python
@@ -31,14 +28,11 @@ Hangul syllables are left out: their decomposition is into conjoining jamo,
not combining marks.
"""
import json
import re
import subprocess
import sys
import unicodedata
PWA_TABLE = "../../../web/src/dinners/letterFolds.json"
EXTRA = {
"ß": "ss", "ẞ": "ss", "æ": "ae", "Æ": "ae", "œ": "oe", "Œ": "oe",
"þ": "th", "Þ": "th", "ð": "d", "Ð": "d", "ı": "i", "ʼn": "n",
@@ -116,9 +110,6 @@ def main():
with open("folds_table.go", "w", encoding="utf-8") as f:
f.write("\n".join(out) + "\n")
subprocess.run(["gofmt", "-w", "folds_table.go"], check=True)
with open(PWA_TABLE, "w", encoding="ascii") as f:
json.dump({chr(cp): folded for cp, folded in entries}, f, indent=2)
f.write("\n")
if __name__ == "__main__":
-136
View File
@@ -1,136 +0,0 @@
{
"normalize": [
{
"name": "Easy Chicken Tikka-Masala!",
"want": "chicken tikka masala"
},
{
"name": " Homemade Chili ",
"want": "chili"
},
{
"name": "Grandma's Stew",
"want": "grandmas stew"
},
{
"name": "Grandma’s Stew",
"want": "grandmas stew"
},
{
"name": "İskender Kebab",
"want": "iskender kebab"
},
{
"name": "Paella à la Valenciana",
"want": "paella la valenciana"
},
{
"name": "Crème Brûlée",
"want": "creme brulee"
},
{
"name": "Crème Brûlée",
"want": "creme brulee"
},
{
"name": "Smørrebrød",
"want": "smorrebrod"
},
{
"name": "Pierogi z Łososiem",
"want": "pierogi z lososiem"
},
{
"name": "Straße Würstchen",
"want": "strasse wurstchen"
},
{
"name": "Œufs en Cocotte",
"want": "oeufs en cocotte"
},
{
"name": "Æbleskiver",
"want": "aebleskiver"
},
{
"name": "Phở Bò",
"want": "pho bo"
},
{
"name": "The Best Quick Recipe",
"want": ""
},
{
"name": "Chicken stir-fry",
"want": "chicken stir fry"
},
{
"name": "7-layer dip",
"want": "7 layer dip"
},
{
"name": "Ƈhili",
"want": "chili"
},
{
"name": "Ꞔarbanzo Stew",
"want": "carbanzo stew"
},
{
"name": "ΜΟΥΣΑΚΑΣ",
"want": "μουσακασ"
}
],
"similar": [
{
"a": "Homemade chili",
"b": "Chili",
"want": true
},
{
"a": "Easy Spaghetti Bolognese",
"b": "spaghetti bolognese",
"want": true
},
{
"a": "Chicken stir fry",
"b": "Chicken stir-fry",
"want": true
},
{
"a": "Crème brûlée",
"b": "Creme brulee",
"want": true
},
{
"a": "Smørrebrød",
"b": "smorrebrod",
"want": true
},
{
"a": "Beef tacos",
"b": "Fish tacos",
"want": false
},
{
"a": "Chili",
"b": "White chicken chili",
"want": false
},
{
"a": "Pot roast",
"b": "Roast",
"want": false
},
{
"a": "The best",
"b": "Easy recipe",
"want": false
},
{
"a": "Ƈhili",
"b": "Chili",
"want": true
}
]
}
-1
View File
@@ -3,4 +3,3 @@
# version, rename its template to match and add a line for the new version.
echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce
normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
@@ -1,22 +0,0 @@
{{/* suggest_side_quests drafts Side quests: new dinners for the household to try, each based on its Regulars. */}}
{{define "instructions" -}}
You suggest Side quests: new dinners for the household to try, based on the dinners it already makes.
Every Side quest must be a dinner the household has not had: never one of its Regulars, its Quest log, its pending Side quests or the dinners it has passed, and never one of those under another name. Base every Side quest on one or more of the household's Regulars, such as a familiar protein in a new cuisine or familiar flavours cooked a new way. Prefer the favorite Regulars, and vary the cuisines across the batch.
Give for each Side quest:
- name: the dinner's usual name, as a recipe site would title it.
- cuisine: its cuisine, e.g. "Thai" or "Tex-Mex".
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
- est_minutes: your estimate of the whole minutes from starting prep to the table.
- difficulty: "easy", "medium" or "hard" for a home cook.
- reason: one sentence of at most 180 characters saying why the household would like it. It must name at least one of the Regulars in cited_regulars.
- cited_regulars: the Regulars the reason relies on, at least one, each written exactly as its name appears in the Regulars list of the household context, without the details in parentheses. Cite only Regulars from that list.
- search_query: a web search query that finds a recipe for this dinner.
One valid Side quest, for a household with a Regular named "Chicken Fajitas" (an example only, not about this household):
{"name": "Chicken Shawarma Bowls", "cuisine": "Middle Eastern", "method": "oven", "est_minutes": 40, "difficulty": "easy", "reason": "Spiced roast chicken and crisp toppings, like your Chicken Fajitas with a Middle Eastern twist.", "cited_regulars": ["Chicken Fajitas"], "search_query": "chicken shawarma bowls recipe"}
{{- end}}
{{define "user" -}}
Suggest {{.Count}} Side quests.
{{- end}}
-420
View File
@@ -1,420 +0,0 @@
package skills
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"unicode"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// ReasonLimit is the most characters a Side quest's reason, its "Why
// this?", may have.
const ReasonLimit = 180
// SideQuestsRequest is what suggest_side_quests reads.
type SideQuestsRequest struct {
// Context is the household context (see harness.BuildContext). It must
// hold at least one Regular, since every Side quest cites one.
Context harness.HouseholdContext
// Needed is how many Side quests the caller wants to keep. The skill
// asks the model for twice as many, since validation and grounding
// drop some. It must be positive.
Needed int
// Pending holds the names of the Side quests not yet tried or passed.
Pending []string
// Declined holds the name of every passed Side quest, not only the
// recent ones Context holds.
Declined []string
}
// SideQuest is one suggested new dinner. Method, EstMinutes and Difficulty
// are the model's estimates until the dinner is grounded in a recipe.
type SideQuest struct {
Name string `json:"name"`
Cuisine string `json:"cuisine"`
// Method is one of stovetop, oven, crockpot, grill and other.
Method string `json:"method"`
EstMinutes int `json:"est_minutes"`
// Difficulty is easy, medium or hard.
Difficulty string `json:"difficulty"`
// Reason is the "Why this?": at most ReasonLimit characters, naming at
// least one of CitedRegulars.
Reason string `json:"reason"`
// CitedRegulars are names of the household's Regulars, as the
// household writes them.
CitedRegulars []string `json:"cited_regulars"`
// SearchQuery is a web search query that finds a recipe for the dinner.
SearchQuery string `json:"search_query"`
}
// SideQuests is suggest_side_quests' output.
type SideQuests struct {
// Suggestions are the Side quests that passed every rule, in the
// model's order. There may be more or fewer than Needed.
Suggestions []SideQuest `json:"suggestions"`
// Dropped are the Side quests the model gave that broke a rule, in the
// model's order. It is never part of the model's reply.
Dropped []DroppedSideQuest `json:"-"`
}
// DroppedSideQuest is a Side quest dropped by PostProcess.
type DroppedSideQuest struct {
Name string
// Why says which rule it broke.
Why string
}
// SuggestSideQuests runs suggest_side_quests v1 on in and returns the Side
// quests that cite only real Regulars, give a short reason naming one of
// them, and repeat no Regular, Quest log dinner, pending or passed Side
// quest, nor each other. The rest are in Dropped. scope names the batch
// (see harness.Run).
func SuggestSideQuests(ctx context.Context, cfg harness.Config, scope string, in SideQuestsRequest) (SideQuests, error) {
return harness.Run(ctx, cfg, suggestSideQuests{in: in}, scope, in)
}
// suggestSideQuests is the skill. It holds its input because validation
// and post-processing compare the output with it.
type suggestSideQuests struct {
in SideQuestsRequest
}
var _ harness.Skill[SideQuestsRequest, SideQuests] = suggestSideQuests{}
func (suggestSideQuests) Name() string { return "suggest_side_quests" }
func (suggestSideQuests) Version() int { return 1 }
func (suggestSideQuests) Timeout() time.Duration { return harness.DefaultTimeout }
// sideQuestsPrompt is the template data of suggest_side_quests.v1.tmpl.
type sideQuestsPrompt struct {
// Count is how many Side quests to ask for.
Count int
}
// requested is how many Side quests the model is asked for.
func (s suggestSideQuests) requested() int { return 2 * s.in.Needed }
func (suggestSideQuests) PromptInput(in SideQuestsRequest) (harness.PromptInput, error) {
if in.Needed <= 0 {
return harness.PromptInput{}, fmt.Errorf("%d Side quests needed; the number must be positive", in.Needed)
}
if len(in.Context.Regulars) == 0 {
return harness.PromptInput{}, errors.New("household has no Regulars for Side quests to be based on")
}
sections := append(in.Context.Sections(), harness.ContextSection{Title: "Pending Side quests", Lines: in.Pending})
return harness.PromptInput{Data: sideQuestsPrompt{Count: suggestSideQuests{in: in}.requested()}, Context: sections}, nil
}
func (suggestSideQuests) Schema() llm.JSONSchema {
return llm.JSONSchema{Name: "suggest_side_quests", Schema: json.RawMessage(`{
"type": "object",
"properties": {
"suggestions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"cuisine": {"type": "string"},
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
"est_minutes": {"type": "integer"},
"difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
"reason": {"type": "string"},
"cited_regulars": {"type": "array", "items": {"type": "string"}},
"search_query": {"type": "string"}
},
"required": ["name", "cuisine", "method", "est_minutes", "difficulty", "reason", "cited_regulars", "search_query"],
"additionalProperties": false
}
}
},
"required": ["suggestions"],
"additionalProperties": false
}`)}
}
// UnmarshalJSON decodes a Side quest, requiring each of its fields as the
// schema does: encoding/json would otherwise read a missing or null one as
// empty.
func (q *SideQuest) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "name", "cuisine", "method", "est_minutes", "difficulty", "reason", "cited_regulars", "search_query"); err != nil {
return fmt.Errorf("suggestion %s", err)
}
type plain SideQuest
return json.Unmarshal(data, (*plain)(q))
}
// UnmarshalJSON decodes the output, requiring its one field as the schema
// does.
func (out *SideQuests) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "suggestions"); err != nil {
return fmt.Errorf("output %s", err)
}
type plain SideQuests
return json.Unmarshal(data, (*plain)(out))
}
// Validate finds what makes the reply unusable as a whole: a wrong number
// of Side quests or one that is malformed. A well-formed Side quest that
// breaks a rule is dropped by PostProcess instead.
func (s suggestSideQuests) Validate(out SideQuests) []string {
var problems []string
switch n := len(out.Suggestions); {
case n == 0 && len(out.Dropped) == 0:
problems = append(problems, fmt.Sprintf("suggestions is empty; give %d Side quests", s.requested()))
case n > s.requested():
problems = append(problems, fmt.Sprintf("suggestions has %d entries; give at most %d Side quests", n, s.requested()))
}
for i, q := range out.Suggestions {
for _, f := range []struct{ field, value string }{
{"name", q.Name}, {"cuisine", q.Cuisine}, {"reason", q.Reason}, {"search_query", q.SearchQuery},
} {
if strings.TrimSpace(f.value) == "" {
problems = append(problems, fmt.Sprintf("suggestion %d has an empty %s; give one", i+1, f.field))
}
}
if strings.TrimSpace(q.Name) != "" && harness.Normalize(q.Name) == "" {
problems = append(problems, fmt.Sprintf("suggestion %d name %q has only filler words; name the dish", i+1, q.Name))
}
if !slices.Contains(methods, q.Method) {
problems = append(problems, fmt.Sprintf("suggestion %d method %q is not one of %s", i+1, q.Method, strings.Join(methods, ", ")))
}
if q.EstMinutes <= 0 {
problems = append(problems, fmt.Sprintf("suggestion %d est_minutes %d is not positive; give the minutes from prep to table", i+1, q.EstMinutes))
}
if !slices.Contains(difficulties, q.Difficulty) {
problems = append(problems, fmt.Sprintf("suggestion %d difficulty %q is not one of %s", i+1, q.Difficulty, strings.Join(difficulties, ", ")))
}
}
return problems
}
// PostProcess tidies every Side quest's text, writes its cited Regulars as
// the household does, and moves each one that breaks a rule to Dropped
// (see drop).
func (s suggestSideQuests) PostProcess(out SideQuests) (SideQuests, error) {
regulars := regularNames{exact: map[string][]string{}, normalized: map[string][]string{}}
var names []string
for _, r := range s.in.Context.Regulars {
regulars.add(r.Name)
names = append(names, r.Name)
}
regulars.all = names
known := harness.NewDeduper(harness.DedupeSources{
Regulars: names,
QuestLog: s.in.Context.QuestLog,
Pending: s.in.Pending,
Declined: s.in.Declined,
})
result := SideQuests{Suggestions: []SideQuest{}}
for _, q := range out.Suggestions {
q = SideQuest{
Name: oneLine(q.Name),
Cuisine: oneLine(q.Cuisine),
Method: q.Method,
EstMinutes: q.EstMinutes,
Difficulty: q.Difficulty,
Reason: oneLine(q.Reason),
CitedRegulars: q.CitedRegulars,
SearchQuery: oneLine(q.SearchQuery),
}
cited, why := drop(q, regulars, known, result.Suggestions)
if why != "" {
result.Dropped = append(result.Dropped, DroppedSideQuest{Name: q.Name, Why: why})
continue
}
q.CitedRegulars = cited
result.Suggestions = append(result.Suggestions, q)
}
return result, nil
}
// regularNames finds the Regular a cited name means. Names need not be
// unique, even ignoring case, so each key holds every Regular with it.
type regularNames struct {
// exact is keyed by the name in lower case on one line.
exact map[string][]string
// normalized is keyed by harness.Normalize of the name.
normalized map[string][]string
// all holds every Regular's name, in the household's order.
all []string
}
func exactName(name string) string { return strings.ToLower(oneLine(name)) }
func (r regularNames) add(name string) {
r.exact[exactName(name)] = append(r.exact[exactName(name)], name)
if n := harness.Normalize(name); n != "" {
r.normalized[n] = append(r.normalized[n], name)
}
}
// find returns the one Regular cited means, or "" and why there is none.
// A Regular named exactly cited, ignoring case and spacing, comes first;
// failing that, cited may name a Regular by its normalized name.
func (r regularNames) find(cited string) (string, string) {
matches := r.exact[exactName(cited)]
if len(matches) == 0 {
matches = r.normalized[harness.Normalize(cited)]
}
switch len(matches) {
case 0:
return "", fmt.Sprintf("cites %q, which is not a Regular", cited)
case 1:
return matches[0], ""
default:
return "", fmt.Sprintf("cites %q, which could be any of the Regulars %q", cited, matches)
}
}
// drop returns why q must be dropped, or "" and its cited Regulars as the
// household writes them when it may be kept. q is dropped when it repeats
// a known dinner or a Side quest already kept, cites no Regular or one
// regulars finds no single Regular for, or has a reason over ReasonLimit
// characters or naming none of its cited Regulars.
func drop(q SideQuest, regulars regularNames, known *harness.Deduper, kept []SideQuest) ([]string, string) {
if known.IsDuplicate(q.Name) {
return nil, "repeats a Regular, Quest log dinner, pending or passed Side quest"
}
for _, k := range kept {
if harness.Similar(q.Name, k.Name) {
return nil, fmt.Sprintf("repeats the Side quest %q in the same batch", k.Name)
}
}
if len(q.CitedRegulars) == 0 {
return nil, "cites no Regular"
}
var cited []string
for _, c := range q.CitedRegulars {
name, why := regulars.find(c)
if why != "" {
return nil, why
}
cited = append(cited, name)
}
if n := utf8.RuneCountInString(q.Reason); n > ReasonLimit {
return nil, fmt.Sprintf("reason has %d characters, over %d", n, ReasonLimit)
}
named := regulars.named(q.Reason)
if !slices.ContainsFunc(cited, func(name string) bool { return slices.Contains(named, name) }) {
return nil, "reason names none of its cited Regulars"
}
return cited, ""
}
// named returns the Regulars text names, by one rule. A mention of a
// Regular is a place where text spells its name, ignoring case and spacing,
// with no letter, mark or digit just outside it: the start or end of text,
// white space, punctuation or a symbol must border it, so "Beef
// Chili-inspired" and "Beef Chili's" mention "Beef Chili" while "Beef
// Chilis" does not, nor does "Cafe" with a combining accent after it
// mention "Cafe". Punctuation at either end of the name, with any spacing
// beside it, is optional, and a mention takes in as much of it as text
// spells, spaced in any way, with the border still holding after it: "Easy
// Beef Chili!" is mentioned by "Easy Beef Chili." too, by "Easy Beef Chili
// !" with its "!", and by "Easy Beef Chili!x" without its "!", and "Beef
// Chili!!" by "Beef Chili!" with one "!". A
// combining mark is part of its letter, never punctuation. A mention names
// its Regular unless a mention of a different Regular covers the same text
// or more, so "Your Easy Beef Chili." names "Easy Beef Chili" and not "Beef
// Chili", and text that two Regulars spell alike names neither there.
func (r regularNames) named(text string) []string {
type mention struct {
name string
start, end int
}
text = exactName(text)
bordered := func(start, end int) bool {
before, _ := utf8.DecodeLastRuneInString(text[:start])
after, _ := utf8.DecodeRuneInString(text[end:])
return (start == 0 || !isWord(before)) && (end == len(text) || !isWord(after))
}
var mentions []mention
for _, name := range r.all {
full := exactName(name)
core := strings.TrimFunc(full, isOuter)
if core == "" {
continue
}
lead := full[:len(full)-len(strings.TrimLeftFunc(full, isOuter))]
trail := full[len(lead)+len(core):]
for at := 0; ; {
i := strings.Index(text[at:], core)
if i < 0 {
break
}
start, end := at+i, at+i+len(core)
at = start + 1
if !bordered(start, end) {
continue
}
start = spelledBefore(text, start, lead, func(s int) bool { return bordered(s, end) })
end = spelledAfter(text, end, trail, func(e int) bool { return bordered(start, e) })
mentions = append(mentions, mention{name, start, end})
}
}
var names []string
for _, m := range mentions {
covered := slices.ContainsFunc(mentions, func(o mention) bool {
return o.name != m.name && o.start <= m.start && o.end >= m.end
})
if !covered && !slices.Contains(names, m.name) {
names = append(names, m.name)
}
}
return names
}
// spelledAfter returns where the longest start of outer that text spells
// from at ends, ignoring spacing, among those after which bordered holds,
// or at when there are none.
func spelledAfter(text string, at int, outer string, bordered func(int) bool) int {
end := at
for _, c := range strings.Join(strings.Fields(outer), "") {
at = len(text) - len(strings.TrimLeftFunc(text[at:], unicode.IsSpace))
if !strings.HasPrefix(text[at:], string(c)) {
break
}
at += utf8.RuneLen(c)
if bordered(at) {
end = at
}
}
return end
}
// spelledBefore returns where the longest end of outer that text spells up
// to at starts, ignoring spacing, among those before which bordered holds,
// or at when there are none.
func spelledBefore(text string, at int, outer string, bordered func(int) bool) int {
start := at
punct := []rune(strings.Join(strings.Fields(outer), ""))
for i := len(punct) - 1; i >= 0; i-- {
at = len(strings.TrimRightFunc(text[:at], unicode.IsSpace))
if !strings.HasSuffix(text[:at], string(punct[i])) {
break
}
at -= utf8.RuneLen(punct[i])
if bordered(at) {
start = at
}
}
return start
}
// isWord reports whether c is a letter, a mark on one or a digit.
func isWord(c rune) bool { return unicode.IsLetter(c) || unicode.IsMark(c) || unicode.IsDigit(c) }
// isOuter reports whether c may be left off the end of a name: punctuation
// or white space.
func isOuter(c rune) bool { return unicode.IsPunct(c) || unicode.IsSpace(c) }
@@ -1,488 +0,0 @@
package skills
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"slices"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
func date(y int, m time.Month, d int) store.LocalDate {
return store.LocalDateOf(time.Date(y, m, d, 0, 0, 0, 0, time.UTC))
}
// household asks for two Side quests, so four are requested.
var household = SideQuestsRequest{
Context: harness.HouseholdContext{
Today: date(2026, 10, 6),
Timezone: "America/New_York",
Regulars: []harness.RegularDinner{
{Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240},
{Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30, Favorite: true},
{Name: "Spaghetti Bolognese", Cuisine: "Italian", Method: "stovetop", Minutes: 45},
},
QuestLog: []string{"Pad Thai"},
Passed: []string{"Chicken Tikka Masala"},
Plans: []harness.PlannedDinner{
{Date: date(2026, 10, 5), Name: "Chicken Fajitas"},
{Date: date(2026, 10, 6), Name: "Beef Chili"},
},
},
Needed: 2,
Pending: []string{"Shrimp Scampi"},
Declined: []string{"Chicken Tikka Masala", "Fish Tacos"},
}
// sideQuest renders one Side quest of a reply.
func sideQuest(name, reason string, cited ...string) string {
c, _ := json.Marshal(append([]string{}, cited...))
r, _ := json.Marshal(reason)
return fmt.Sprintf(`{"name":%q,"cuisine":"Fusion","method":"stovetop","est_minutes":35,"difficulty":"easy","reason":%s,"cited_regulars":%s,"search_query":%q}`,
name, r, c, strings.ToLower(name)+" recipe")
}
// batch renders a reply holding the Side quests given.
func batch(quests ...string) string {
return `{"suggestions":[` + strings.Join(quests, ",") + `]}`
}
var (
shawarma = sideQuest("Chicken Shawarma Bowls", "Spiced chicken and crisp toppings, like your Chicken Fajitas.", "Chicken Fajitas")
ragu = sideQuest("Beef Ragu Pappardelle", "A slow beef sauce like your Spaghetti Bolognese, on wide noodles.", "Spaghetti Bolognese")
verde = sideQuest("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "beef chili")
bulgogi = sideQuest("Beef Bulgogi", "Sweet, quick-seared beef for fans of Chicken Fajitas and Beef Chili.", "Chicken Fajitas", "Beef Chili")
)
// want returns the Side quest that sideQuest renders, with cited as the
// household writes them.
func want(name, reason string, cited ...string) SideQuest {
return SideQuest{
Name: name, Cuisine: "Fusion", Method: "stovetop", EstMinutes: 35, Difficulty: "easy",
Reason: reason, CitedRegulars: cited, SearchQuery: strings.ToLower(name) + " recipe",
}
}
var (
wantShawarma = want("Chicken Shawarma Bowls", "Spiced chicken and crisp toppings, like your Chicken Fajitas.", "Chicken Fajitas")
wantRagu = want("Beef Ragu Pappardelle", "A slow beef sauce like your Spaghetti Bolognese, on wide noodles.", "Spaghetti Bolognese")
wantVerde = want("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "Beef Chili")
wantBulgogi = want("Beef Bulgogi", "Sweet, quick-seared beef for fans of Chicken Fajitas and Beef Chili.", "Chicken Fajitas", "Beef Chili")
)
// suggest runs SuggestSideQuests on household with f and checks f was
// called exactly calls times, each at the household temperature.
func suggest(t *testing.T, f *llm.Fake, in SideQuestsRequest, calls int) (SideQuests, []llm.Request, error) {
t.Helper()
out, err := SuggestSideQuests(context.Background(), testConfig(t, f), "2026-W41", in)
reqs := f.Requests()
if len(reqs) != calls {
t.Fatalf("Generate called %d times, want %d", len(reqs), calls)
}
if g, _, _ := f.Remaining(); g != 0 {
t.Fatalf("%d scripted Generate results unused", g)
}
for i, req := range reqs {
if req.Temperature == nil || *req.Temperature != 0.7 {
t.Fatalf("request %d temperature = %v, want 0.7", i+1, req.Temperature)
}
if req.Schema == nil || req.Schema.Name != "suggest_side_quests" {
t.Fatalf("request %d schema = %v, want suggest_side_quests", i+1, req.Schema)
}
}
return out, reqs, err
}
func assertSideQuests(t *testing.T, got, want SideQuests) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Fatalf("output = %+v\nwant %+v", got, want)
}
}
func TestSuggestSideQuestsCleanBatch(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(batch(shawarma, ragu, verde, bulgogi)))
out, reqs, err := suggest(t, &f, household, 1)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu, wantVerde, wantBulgogi}})
system, user := reqs[0].System, reqs[0].Messages[0].Content
golden(t, "suggest_side_quests_clean.system.golden", system)
golden(t, "suggest_side_quests_clean.user.golden", user)
for _, part := range []string{
"## Household context",
"- Chicken Fajitas (Tex-Mex, stovetop, 30 min, favorite)",
"### Quest log\n- Pad Thai",
"### Passed, oldest first\n- Chicken Tikka Masala",
"- 2026-10-06 Tue: Beef Chili",
"### Pending Side quests\n- Shrimp Scampi",
"## Output contract",
`"cited_regulars"`,
`"search_query"`,
} {
if !strings.Contains(system, part) {
t.Errorf("system prompt lacks %q", part)
}
}
if strings.Index(system, "## Household context") > strings.Index(system, "## Output contract") {
t.Error("the output contract is not after the household context")
}
if user != "Suggest 4 Side quests." {
t.Errorf("user message = %q, want twice the 2 needed", user)
}
}
func TestSuggestSideQuestsPersonaFirst(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(batch(shawarma)))
cfg := testConfig(t, &f)
cfg.Persona = "You are the household's cheerful kitchen helper."
if _, err := SuggestSideQuests(context.Background(), cfg, "2026-W41", household); err != nil {
t.Fatal(err)
}
if system := f.Requests()[0].System; !strings.HasPrefix(system, cfg.Persona+"\n\n## Instructions\n") {
t.Fatalf("system prompt does not start with the persona then the instructions:\n%s", system)
}
}
func TestSuggestSideQuestsInventedRegularDropped(t *testing.T) {
invented := sideQuest("Meatloaf Wellington", "Your Grandma's Meatloaf, wrapped in pastry.", "Grandma's Meatloaf")
partly := sideQuest("Fajita Meatballs", "Chicken Fajitas meets your Grandma's Meatloaf.", "Chicken Fajitas", "Grandma's Meatloaf")
none := sideQuest("Mushroom Risotto", "Creamy and comforting.")
var f llm.Fake
f.QueueGenerate(reply(batch(invented, shawarma, partly, none)))
out, _, err := suggest(t, &f, household, 1)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{
Suggestions: []SideQuest{wantShawarma},
Dropped: []DroppedSideQuest{
{Name: "Meatloaf Wellington", Why: `cites "Grandma's Meatloaf", which is not a Regular`},
{Name: "Fajita Meatballs", Why: `cites "Grandma's Meatloaf", which is not a Regular`},
{Name: "Mushroom Risotto", Why: "cites no Regular"},
},
})
}
func TestSuggestSideQuestsSharedNormalizedRegularNotRewritten(t *testing.T) {
in := household
in.Context.Regulars = append(slices.Clone(household.Context.Regulars),
harness.RegularDinner{Name: "Easy Beef Chili", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30})
plain := sideQuest("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "beef chili")
easy := sideQuest("Frito Pie", "Your Easy Beef Chili over corn chips.", "EASY beef chili")
either := sideQuest("Tamale Bake", "Your Beef Chili under a cornbread crust.", "Beef Chili!")
var f llm.Fake
f.QueueGenerate(reply(batch(plain, easy, either)))
out, _, err := suggest(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{
Suggestions: []SideQuest{
want("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "Beef Chili"),
want("Frito Pie", "Your Easy Beef Chili over corn chips.", "Easy Beef Chili"),
},
Dropped: []DroppedSideQuest{
{Name: "Tamale Bake", Why: `cites "Beef Chili!", which could be any of the Regulars ["Beef Chili" "Easy Beef Chili"]`},
},
})
}
func TestSuggestSideQuestsReasonNamesResolvedRegular(t *testing.T) {
in := household
in.Context.Regulars = append(slices.Clone(household.Context.Regulars),
harness.RegularDinner{Name: "Easy Beef Chili", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30})
other := sideQuest("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "Easy Beef Chili")
easy := sideQuest("Frito Pie", "Your Easy Beef Chili over corn chips.", "Easy Beef Chili")
plain := sideQuest("Chili Mac", "The Beef Chili, not the Easy Beef Chili, over macaroni.", "Beef Chili")
inside := sideQuest("Tamale Bake", "Your Easy Beef Chili under a cornbread crust.", "Beef Chili")
var f llm.Fake
f.QueueGenerate(reply(batch(other, easy, plain, inside)))
out, _, err := suggest(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{
Suggestions: []SideQuest{
want("Frito Pie", "Your Easy Beef Chili over corn chips.", "Easy Beef Chili"),
want("Chili Mac", "The Beef Chili, not the Easy Beef Chili, over macaroni.", "Beef Chili"),
},
Dropped: []DroppedSideQuest{
{Name: "Chili Verde", Why: "reason names none of its cited Regulars"},
{Name: "Tamale Bake", Why: "reason names none of its cited Regulars"},
},
})
}
func TestSuggestSideQuestsReasonMentionRule(t *testing.T) {
const none = "reason names none of its cited Regulars"
for name, tc := range map[string]struct {
extra []string // Regulars added to household's
reason string
cited string
why string // "" when the Side quest is kept
}{
"exact name": {nil, "Like your Beef Chili, gone green.", "Beef Chili", ""},
"other case and spacing": {nil, "Like your beef CHILI, gone green.", "Beef Chili", ""},
"sentence punctuation": {nil, `Green, like "Beef Chili" (yours).`, "Beef Chili", ""},
"possessive": {nil, "Beef Chili's spices, gone green.", "Beef Chili", ""},
"joined by punctuation": {nil, "A Beef Chili-inspired stew.", "Beef Chili", ""},
"no mention": {nil, "Grilled skewers with a peanut sauce.", "Beef Chili", none},
"letters run on": {[]string{"Ham"}, "Like your Hamburgers, glazed.", "Ham", none},
"stored punctuation written": {[]string{"Easy Beef Chili!"}, "Your Easy Beef Chili! over corn chips.", "Easy Beef Chili!", ""},
"stored punctuation as period": {[]string{"Easy Beef Chili!"}, "Corn chips under your Easy Beef Chili.", "Easy Beef Chili!", ""},
"inside stored punctuation": {[]string{"Easy Beef Chili!"}, "Your Easy Beef Chili! over corn chips.", "Beef Chili", none},
"inside before period": {[]string{"Easy Beef Chili!"}, "Corn chips under your Easy Beef Chili.", "Beef Chili", none},
"longer name cited": {[]string{"Easy Beef Chili"}, "Your Easy Beef Chili over corn chips.", "Easy Beef Chili", ""},
"shorter inside longer": {[]string{"Easy Beef Chili"}, "Your Easy Beef Chili over corn chips.", "Beef Chili", none},
"longer not mentioned": {[]string{"Easy Beef Chili"}, "Your Beef Chili over corn chips.", "Easy Beef Chili", none},
"shorter also on its own": {[]string{"Easy Beef Chili"}, "The Beef Chili, not the Easy Beef Chili.", "Beef Chili", ""},
"punctuation-only difference": {[]string{"Beef Chili!"}, "Your Beef Chili! over corn chips.", "Beef Chili!", ""},
"bare name inside punctuated": {[]string{"Beef Chili!"}, "Your Beef Chili! over corn chips.", "Beef Chili", none},
"spelled alike by both": {[]string{"Beef Chili!"}, "Corn chips under your Beef Chili.", "Beef Chili", none},
"punctuation then a letter": {[]string{"Beef Chili!"}, "Your Beef Chili!X is our inspiration.", "Beef Chili!", none},
"letter before punctuation": {[]string{"¡Beef Chili"}, "Your X¡Beef Chili is our inspiration.", "¡Beef Chili", none},
"spacing before punctuation": {[]string{"Beef Chili!"}, "Your Beef Chili ! over corn chips.", "Beef Chili!", ""},
"spacing after punctuation": {[]string{"¡Beef Chili"}, "Your ¡ Beef Chili is our inspiration.", "¡Beef Chili", ""},
"stored spacing left off": {[]string{"Beef Chili !", "Chili!"}, "Like your Beef Chili!", "Beef Chili !", ""},
"inside unspaced punctuation": {[]string{"Beef Chili !", "Chili!"}, "Like your Beef Chili!", "Chili!", none},
"stored punctuation partly": {[]string{"Beef Chili!!", "Chili!"}, "Like your Beef Chili!", "Beef Chili!!", ""},
"inside partial punctuation": {[]string{"Beef Chili!!", "Chili!"}, "Like your Beef Chili!", "Chili!", none},
"partly then a letter": {[]string{"Beef Chili!!", "Chili!"}, "Your Beef Chili!!X is our inspiration.", "Chili!", none},
"leading punctuation partly": {[]string{"¡¡Beef Chili", "¡Beef"}, "Like your ¡Beef Chili.", "¡¡Beef Chili", ""},
"inside partial leading": {[]string{"¡¡Beef Chili", "¡Beef"}, "Like your ¡Beef Chili.", "¡Beef", none},
"stored symbol left off": {[]string{"Green Chili 🌶"}, "Like your Green Chili, gone green.", "Green Chili 🌶", none},
"stored accent left off": {[]string{"Chicken Cafe\u0301"}, "Like your Chicken Cafe, gone green.", "Chicken Cafe\u0301", none},
"accent written after name": {[]string{"Chicken Cafe"}, "Like your Chicken Cafe\u0301, gone green.", "Chicken Cafe", none},
} {
t.Run(name, func(t *testing.T) {
in := household
in.Context.Regulars = slices.Clone(household.Context.Regulars)
for _, r := range tc.extra {
in.Context.Regulars = append(in.Context.Regulars, harness.RegularDinner{Name: r, Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30})
}
var f llm.Fake
f.QueueGenerate(reply(batch(sideQuest("Frito Pie", tc.reason, tc.cited))))
out, _, err := suggest(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
result := SideQuests{Suggestions: []SideQuest{want("Frito Pie", oneLine(tc.reason), tc.cited)}}
if tc.why != "" {
result = SideQuests{Suggestions: []SideQuest{}, Dropped: []DroppedSideQuest{{Name: "Frito Pie", Why: tc.why}}}
}
assertSideQuests(t, out, result)
})
}
}
func TestSuggestSideQuestsDuplicatesDropped(t *testing.T) {
declined := sideQuest("The Best Homemade Chicken Tikka-Masala", "Spiced like your Chicken Fajitas.", "Chicken Fajitas")
oldDeclined := sideQuest("Fish Tacos!", "Tortillas like your Chicken Fajitas.", "Chicken Fajitas")
regular := sideQuest("Easy Beef Chili", "Just like your Beef Chili.", "Beef Chili")
questLog := sideQuest("Pad Thai", "Noodles like your Spaghetti Bolognese.", "Spaghetti Bolognese")
pending := sideQuest("Shrimp Scampi", "Pasta like your Spaghetti Bolognese.", "Spaghetti Bolognese")
again := sideQuest("Chicken Shawarma Bowls", "Chicken Fajitas, but Middle Eastern.", "Chicken Fajitas")
var f llm.Fake
f.QueueGenerate(reply(batch(declined, oldDeclined, regular, questLog, pending, shawarma, again)))
in := household
in.Needed = 4
out, _, err := suggest(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
known := "repeats a Regular, Quest log dinner, pending or passed Side quest"
assertSideQuests(t, out, SideQuests{
Suggestions: []SideQuest{wantShawarma},
Dropped: []DroppedSideQuest{
{Name: "The Best Homemade Chicken Tikka-Masala", Why: known},
{Name: "Fish Tacos!", Why: known},
{Name: "Easy Beef Chili", Why: known},
{Name: "Pad Thai", Why: known},
{Name: "Shrimp Scampi", Why: known},
{Name: "Chicken Shawarma Bowls", Why: `repeats the Side quest "Chicken Shawarma Bowls" in the same batch`},
},
})
}
func TestSuggestSideQuestsReasonRulesDropped(t *testing.T) {
long := sideQuest("Beef Stroganoff", "Your Beef Chili"+strings.Repeat(" and more", 20)+".", "Beef Chili")
unnamed := sideQuest("Chicken Satay", "Grilled skewers with a peanut sauce.", "Chicken Fajitas")
wrongOne := sideQuest("Beef Tacos", "Like your Beef Chili, in a shell.", "Chicken Fajitas")
var f llm.Fake
f.QueueGenerate(reply(batch(long, unnamed, ragu, wrongOne)))
out, _, err := suggest(t, &f, household, 1)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{
Suggestions: []SideQuest{wantRagu},
Dropped: []DroppedSideQuest{
{Name: "Beef Stroganoff", Why: "reason has 196 characters, over 180"},
{Name: "Chicken Satay", Why: "reason names none of its cited Regulars"},
{Name: "Beef Tacos", Why: "reason names none of its cited Regulars"},
},
})
}
func TestSuggestSideQuestsAllDroppedIsNotRepaired(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(batch(sideQuest("Pad Thai", "Like your Beef Chili.", "Beef Chili"))))
out, _, err := suggest(t, &f, household, 1)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{
Suggestions: []SideQuest{},
Dropped: []DroppedSideQuest{{Name: "Pad Thai", Why: "repeats a Regular, Quest log dinner, pending or passed Side quest"}},
})
}
func TestSuggestSideQuestsMalformedRepairedOnce(t *testing.T) {
for name, tc := range map[string]struct{ first, want string }{
"truncated JSON": {
`{"suggestions":[` + shawarma + `,{"name":"Beef`,
"reply does not decode: unexpected EOF",
},
"omitted field": {
batch(strings.Replace(shawarma, `"cuisine":"Fusion",`, ``, 1)),
`suggestion has no "cuisine" field; give every field the schema requires`,
},
"unknown field": {
batch(strings.Replace(shawarma, `"easy",`, `"easy","spice":"mild",`, 1)),
`suggestion has unknown field "spice"`,
},
"bad method": {
batch(strings.Replace(shawarma, `"stovetop"`, `"air fryer"`, 1)),
`suggestion 1 method "air fryer" is not one of stovetop, oven, crockpot, grill, other`,
},
} {
t.Run(name, func(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(tc.first), reply(batch(shawarma, ragu)))
out, reqs, err := suggest(t, &f, household, 2)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu}})
repair := reqs[1].Messages
if len(repair) != 3 || repair[1].Content != tc.first {
t.Fatalf("repair turn does not quote the first reply: %+v", repair)
}
if !strings.Contains(repair[2].Content, tc.want) {
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, tc.want)
}
})
}
}
func TestSuggestSideQuestsFillerNameRepaired(t *testing.T) {
filler := sideQuest("The.", "Like your Beef Chili.", "Beef Chili")
first := batch(filler, filler)
var f llm.Fake
f.QueueGenerate(reply(first), reply(batch(shawarma, ragu)))
out, reqs, err := suggest(t, &f, household, 2)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu}})
repair := reqs[1].Messages
if len(repair) != 3 || repair[1].Content != first {
t.Fatalf("repair turn does not quote the first reply: %+v", repair)
}
for _, want := range []string{
`suggestion 1 name "The." has only filler words; name the dish`,
`suggestion 2 name "The." has only filler words; name the dish`,
} {
if !strings.Contains(repair[2].Content, want) {
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, want)
}
}
}
func TestSuggestSideQuestsMalformedTwiceFails(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(`{"suggestions":`), reply(`{"suggestions":[]}`))
out, _, err := suggest(t, &f, household, 2)
var bad *harness.BadOutputError
if !errors.As(err, &bad) || !errors.Is(err, llm.ErrBadOutput) {
t.Fatalf("err = %v, want a BadOutputError", err)
}
if !slices.Equal(bad.Repair, []string{"suggestions is empty; give 4 Side quests"}) {
t.Fatalf("repair problems = %q", bad.Repair)
}
if out.Suggestions != nil {
t.Fatalf("output = %+v, want none", out)
}
}
func TestSideQuestsValidate(t *testing.T) {
s := suggestSideQuests{in: household}
if p := s.Validate(SideQuests{Suggestions: []SideQuest{wantShawarma}}); len(p) != 0 {
t.Fatalf("valid output has problems: %q", p)
}
if p := s.Validate(SideQuests{Suggestions: []SideQuest{}, Dropped: []DroppedSideQuest{{Name: "x", Why: "y"}}}); len(p) != 0 {
t.Fatalf("output whose Side quests were all dropped has problems: %q", p)
}
for name, tc := range map[string]struct {
out SideQuests
want []string
}{
"empty": {SideQuests{}, []string{"suggestions is empty; give 4 Side quests"}},
"too many": {
SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu, wantVerde, wantBulgogi, wantShawarma}},
[]string{"suggestions has 5 entries; give at most 4 Side quests"},
},
"malformed": {
SideQuests{Suggestions: []SideQuest{{Name: " ", Method: "air fryer", EstMinutes: 0, Difficulty: "trivial", CitedRegulars: []string{}}}},
[]string{
"suggestion 1 has an empty name; give one",
"suggestion 1 has an empty cuisine; give one",
"suggestion 1 has an empty reason; give one",
"suggestion 1 has an empty search_query; give one",
`suggestion 1 method "air fryer" is not one of stovetop, oven, crockpot, grill, other`,
"suggestion 1 est_minutes 0 is not positive; give the minutes from prep to table",
`suggestion 1 difficulty "trivial" is not one of easy, medium, hard`,
},
},
} {
t.Run(name, func(t *testing.T) {
if got := s.Validate(tc.out); !slices.Equal(got, tc.want) {
t.Fatalf("problems =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(tc.want, "\n"))
}
})
}
}
func TestSideQuestsPromptInputRejects(t *testing.T) {
noRegulars := household
noRegulars.Context.Regulars = nil
none := household
none.Needed = 0
for name, tc := range map[string]struct {
in SideQuestsRequest
want string
}{
"no Regulars": {noRegulars, "household has no Regulars for Side quests to be based on"},
"none needed": {none, "0 Side quests needed; the number must be positive"},
} {
t.Run(name, func(t *testing.T) {
_, err := suggestSideQuests{in: tc.in}.PromptInput(tc.in)
if err == nil || err.Error() != tc.want {
t.Fatalf("err = %v, want %q", err, tc.want)
}
})
}
}
@@ -1,110 +0,0 @@
## Instructions
You suggest Side quests: new dinners for the household to try, based on the dinners it already makes.
Every Side quest must be a dinner the household has not had: never one of its Regulars, its Quest log, its pending Side quests or the dinners it has passed, and never one of those under another name. Base every Side quest on one or more of the household's Regulars, such as a familiar protein in a new cuisine or familiar flavours cooked a new way. Prefer the favorite Regulars, and vary the cuisines across the batch.
Give for each Side quest:
- name: the dinner's usual name, as a recipe site would title it.
- cuisine: its cuisine, e.g. "Thai" or "Tex-Mex".
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
- est_minutes: your estimate of the whole minutes from starting prep to the table.
- difficulty: "easy", "medium" or "hard" for a home cook.
- reason: one sentence of at most 180 characters saying why the household would like it. It must name at least one of the Regulars in cited_regulars.
- cited_regulars: the Regulars the reason relies on, at least one, each written exactly as its name appears in the Regulars list of the household context, without the details in parentheses. Cite only Regulars from that list.
- search_query: a web search query that finds a recipe for this dinner.
One valid Side quest, for a household with a Regular named "Chicken Fajitas" (an example only, not about this household):
{"name": "Chicken Shawarma Bowls", "cuisine": "Middle Eastern", "method": "oven", "est_minutes": 40, "difficulty": "easy", "reason": "Spiced roast chicken and crisp toppings, like your Chicken Fajitas with a Middle Eastern twist.", "cited_regulars": ["Chicken Fajitas"], "search_query": "chicken shawarma bowls recipe"}
## Household context
### Today
- Tuesday 2026-10-06 (America/New_York)
### Regulars
- Beef Chili (Tex-Mex, crockpot, 240 min)
- Chicken Fajitas (Tex-Mex, stovetop, 30 min, favorite)
- Spaghetti Bolognese (Italian, stovetop, 45 min)
### Quest log
- Pad Thai
### Passed, oldest first
- Chicken Tikka Masala
### Recent plans
- 2026-10-05 Mon: Chicken Fajitas
- 2026-10-06 Tue: Beef Chili
### Pending Side quests
- Shrimp Scampi
## Output contract
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "suggest_side_quests" below, and nothing else: no prose and no Markdown code fences.
{
"type": "object",
"properties": {
"suggestions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"cuisine": {
"type": "string"
},
"method": {
"type": "string",
"enum": [
"stovetop",
"oven",
"crockpot",
"grill",
"other"
]
},
"est_minutes": {
"type": "integer"
},
"difficulty": {
"type": "string",
"enum": [
"easy",
"medium",
"hard"
]
},
"reason": {
"type": "string"
},
"cited_regulars": {
"type": "array",
"items": {
"type": "string"
}
},
"search_query": {
"type": "string"
}
},
"required": [
"name",
"cuisine",
"method",
"est_minutes",
"difficulty",
"reason",
"cited_regulars",
"search_query"
],
"additionalProperties": false
}
}
},
"required": [
"suggestions"
],
"additionalProperties": false
}
@@ -1 +0,0 @@
Suggest 4 Side quests.
+1 -2
View File
@@ -190,7 +190,6 @@ func TestDinners(t *testing.T) {
Difficulty: str("medium"),
Cuisine: sql.NullString{},
RecipeID: sql.NullString{},
RecipeUrl: str("https://example.com/tacos"),
PhotoID: str(photo.ID),
GroundingStatus: "failed",
UpdatedBy: str("sam@example.com"),
@@ -268,7 +267,7 @@ func TestDinners(t *testing.T) {
}
var unarchivedIDs []string
for _, d := range unarchived {
unarchivedIDs = append(unarchivedIDs, d.Dinner.ID)
unarchivedIDs = append(unarchivedIDs, d.ID)
}
assertIDs(t, "unarchived", unarchivedIDs, other.ID, third.ID)
_, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt})
+94 -185
View File
@@ -16,7 +16,7 @@ SET status = 'archived',
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
`
type ArchiveDinnerParams struct {
@@ -48,24 +48,10 @@ func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (D
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
const countRegularDinners = `-- name: CountRegularDinners :one
SELECT COUNT(*) FROM dinners
WHERE status = 'regular'
`
// How many Regulars the household has; archived dinners are not counted.
func (q *Queries) CountRegularDinners(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countRegularDinners)
var count int64
err := row.Scan(&count)
return count, err
}
const createDinner = `-- name: CreateDinner :one
INSERT INTO dinners (
id, name, normalized_name, status, origin, method, minutes_total,
@@ -76,7 +62,7 @@ INSERT INTO dinners (
?, ?, ?, ?, ?, ?,
?, ?, ?, ?
)
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
`
type CreateDinnerParams struct {
@@ -139,7 +125,6 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
@@ -150,13 +135,11 @@ SET grounding_status = 'failed',
grounding_error = ?1,
updated_at = ?2
WHERE grounding_status = 'pending'
AND status <> 'archived'
AND NOT EXISTS (
SELECT 1 FROM jobs
WHERE jobs.kind = ?3
AND jobs.status IN ('queued', 'running')
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
AND json_extract(jobs.payload, '$.revision') = dinners.grounding_revision
)
`
@@ -166,11 +149,10 @@ type FailOrphanedPendingDinnerGroundingsParams struct {
Kind string
}
// Fails every unarchived dinner still pending whose grounding job for its
// current grounding_revision is no longer queued or running: the process
// stopped before the job, or before its handler recorded a result. An
// archived dinner is left as it is; archiving stopped its grounding. Run at
// boot, after the job runner fails the jobs a previous process left running.
// Fails every dinner still pending whose grounding job is no longer queued
// or running: the process stopped before the job, or before its handler
// recorded a result. Run at boot, after the job runner fails the jobs a
// previous process left running.
func (q *Queries) FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failOrphanedPendingDinnerGroundings, arg.GroundingError, arg.UpdatedAt, arg.Kind)
if err != nil {
@@ -184,30 +166,19 @@ UPDATE dinners
SET grounding_status = 'failed',
grounding_error = ?,
updated_at = ?
WHERE id = ?
AND status <> 'archived'
AND grounding_status = 'pending'
AND grounding_revision = ?
WHERE id = ? AND grounding_status = 'pending'
`
type FailPendingDinnerGroundingParams struct {
GroundingError sql.NullString
UpdatedAt string
ID string
GroundingRevision int64
GroundingError sql.NullString
UpdatedAt string
ID string
}
// Records why the background grounding of an unarchived dinner still
// pending at the grounding_revision it was grounded for failed; a dinner
// grounded some other way, renamed or archived meanwhile is left as it is
// (0 rows).
// Records why the background grounding of a dinner still pending failed; a
// dinner grounded some other way meanwhile is left as it is (0 rows).
func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failPendingDinnerGrounding,
arg.GroundingError,
arg.UpdatedAt,
arg.ID,
arg.GroundingRevision,
)
result, err := q.db.ExecContext(ctx, failPendingDinnerGrounding, arg.GroundingError, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
@@ -215,7 +186,7 @@ func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendin
}
const getDinner = `-- name: GetDinner :one
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error FROM dinners
WHERE id = ?
`
@@ -241,83 +212,50 @@ func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) {
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
const groundDinner = `-- name: GroundDinner :one
UPDATE dinners
SET recipe_id = ?1,
recipe_url = NULL,
photo_id = ?2,
method = CASE WHEN method IS ?3
THEN ?4 ELSE method END,
minutes_total = CASE WHEN minutes_total IS ?5
THEN ?6 ELSE minutes_total END,
difficulty = CASE WHEN difficulty IS ?7
THEN ?8 ELSE difficulty END,
grounding_status = ?9,
SET recipe_id = ?,
photo_id = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
grounding_status = ?,
grounding_error = NULL,
updated_by = ?10,
updated_at = ?11
WHERE id = ?12
AND status <> 'archived'
AND name = ?13
AND normalized_name = ?14
AND grounding_status = ?15
AND grounding_revision = ?16
AND recipe_id IS ?17
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
`
type GroundDinnerParams struct {
RecipeID sql.NullString
PhotoID sql.NullString
ObservedMethod sql.NullString
Method sql.NullString
ObservedMinutesTotal sql.NullInt64
MinutesTotal sql.NullInt64
ObservedDifficulty sql.NullString
Difficulty sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
UpdatedAt string
ID string
ObservedName string
ObservedNormalizedName string
ObservedGroundingStatus string
ObservedGroundingRevision int64
ObservedRecipeID sql.NullString
RecipeID sql.NullString
PhotoID sql.NullString
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// Records a dinner's grounding by hand (grounded or manual) and clears any
// grounding_error and pasted recipe_url: the recipe chosen replaces the
// link. It writes only an unarchived dinner still as it was observed
// before grounding (same name, normalized_name, grounding_status,
// grounding_revision and recipe_id), so a dinner renamed, archived or
// grounded some other way meanwhile is left as it is. That dinner, or an
// unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
// no longer as observed is kept: a member edited it while it was grounding.
// Records a dinner's grounding (grounded or manual) and clears any
// grounding_error. An unknown id returns sql.ErrNoRows.
func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error) {
row := q.db.QueryRowContext(ctx, groundDinner,
arg.RecipeID,
arg.PhotoID,
arg.ObservedMethod,
arg.Method,
arg.ObservedMinutesTotal,
arg.MinutesTotal,
arg.ObservedDifficulty,
arg.Difficulty,
arg.GroundingStatus,
arg.UpdatedBy,
arg.UpdatedAt,
arg.ID,
arg.ObservedName,
arg.ObservedNormalizedName,
arg.ObservedGroundingStatus,
arg.ObservedGroundingRevision,
arg.ObservedRecipeID,
)
var i Dinner
err := row.Scan(
@@ -339,43 +277,35 @@ func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Din
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
UPDATE dinners
SET recipe_id = ?1,
photo_id = ?2,
method = COALESCE(method, ?3),
minutes_total = COALESCE(minutes_total, ?4),
difficulty = COALESCE(difficulty, ?5),
SET recipe_id = ?,
photo_id = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
grounding_status = 'grounded',
grounding_error = NULL,
updated_at = ?6
WHERE id = ?7
AND status <> 'archived'
AND grounding_status = 'pending'
AND grounding_revision = ?8
updated_at = ?
WHERE id = ? AND grounding_status = 'pending'
`
type GroundPendingDinnerParams struct {
RecipeID sql.NullString
PhotoID sql.NullString
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
UpdatedAt string
ID string
GroundingRevision int64
RecipeID sql.NullString
PhotoID sql.NullString
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
UpdatedAt string
ID string
}
// Records the background grounding of an unarchived dinner still pending at
// the grounding_revision it was grounded for; a dinner grounded some other
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
// minutes_total or difficulty already set is kept: a pending dinner has
// them only when a member edited them while it was grounding.
// Records the background grounding of a dinner still pending; a dinner
// grounded some other way meanwhile is left as it is (0 rows).
func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error) {
result, err := q.db.ExecContext(ctx, groundPendingDinner,
arg.RecipeID,
@@ -385,7 +315,6 @@ func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinn
arg.Difficulty,
arg.UpdatedAt,
arg.ID,
arg.GroundingRevision,
)
if err != nil {
return 0, err
@@ -394,7 +323,7 @@ func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinn
}
const listDinnersByStatus = `-- name: ListDinnersByStatus :many
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error FROM dinners
WHERE status = ?
ORDER BY normalized_name, id
`
@@ -427,7 +356,6 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
); err != nil {
return nil, err
}
@@ -443,50 +371,40 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
}
const listUnarchivedDinners = `-- name: ListUnarchivedDinners :many
SELECT dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision, recipes.source_url AS recipe_source_url
FROM dinners
LEFT JOIN recipes ON recipes.id = dinners.recipe_id
WHERE dinners.status <> 'archived'
ORDER BY dinners.id
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error FROM dinners
WHERE status <> 'archived'
ORDER BY id
`
type ListUnarchivedDinnersRow struct {
Dinner Dinner
RecipeSourceUrl sql.NullString
}
// Every dinner a new or renamed dinner must not repeat, with the
// source_url of the recipe it is grounded in (NULL while it has none).
func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error) {
// Every dinner a new or renamed dinner must not repeat.
func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]Dinner, error) {
rows, err := q.db.QueryContext(ctx, listUnarchivedDinners)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListUnarchivedDinnersRow
var items []Dinner
for rows.Next() {
var i ListUnarchivedDinnersRow
var i Dinner
if err := rows.Scan(
&i.Dinner.ID,
&i.Dinner.Name,
&i.Dinner.NormalizedName,
&i.Dinner.Status,
&i.Dinner.Origin,
&i.Dinner.Method,
&i.Dinner.MinutesTotal,
&i.Dinner.Difficulty,
&i.Dinner.Cuisine,
&i.Dinner.IsFavorite,
&i.Dinner.RecipeID,
&i.Dinner.PhotoID,
&i.Dinner.GroundingStatus,
&i.Dinner.UpdatedBy,
&i.Dinner.CreatedAt,
&i.Dinner.UpdatedAt,
&i.Dinner.RecipeUrl,
&i.Dinner.GroundingError,
&i.Dinner.GroundingRevision,
&i.RecipeSourceUrl,
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
); err != nil {
return nil, err
}
@@ -507,7 +425,7 @@ SET is_favorite = ?,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
`
type SetDinnerFavoriteParams struct {
@@ -545,7 +463,6 @@ func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoritePa
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
@@ -556,7 +473,7 @@ SET is_favorite = 1 - is_favorite,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
`
type ToggleDinnerFavoriteParams struct {
@@ -588,7 +505,6 @@ func (q *Queries) ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavo
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
@@ -604,33 +520,29 @@ SET name = ?,
difficulty = ?,
cuisine = ?,
recipe_id = ?,
recipe_url = ?,
photo_id = ?,
grounding_status = ?,
grounding_revision = ?,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
`
type UpdateDinnerParams struct {
Name string
NormalizedName string
Status string
Origin string
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
Cuisine sql.NullString
RecipeID sql.NullString
RecipeUrl sql.NullString
PhotoID sql.NullString
GroundingStatus string
GroundingRevision int64
UpdatedBy sql.NullString
UpdatedAt string
ID string
Name string
NormalizedName string
Status string
Origin string
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
Cuisine sql.NullString
RecipeID sql.NullString
PhotoID sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// An unknown id returns sql.ErrNoRows.
@@ -645,10 +557,8 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
arg.Difficulty,
arg.Cuisine,
arg.RecipeID,
arg.RecipeUrl,
arg.PhotoID,
arg.GroundingStatus,
arg.GroundingRevision,
arg.UpdatedBy,
arg.UpdatedAt,
arg.ID,
@@ -673,7 +583,6 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
)
return i, err
}
+5 -5
View File
@@ -54,16 +54,16 @@ func (q *Queries) ListPlannedDinners(ctx context.Context, arg ListPlannedDinners
const listRecentDeclinedNames = `-- name: ListRecentDeclinedNames :many
SELECT name
SELECT dinners.name
FROM suggestions
WHERE status = 'declined'
ORDER BY decided_at DESC, id DESC
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.status = 'declined'
ORDER BY suggestions.decided_at DESC, suggestions.id DESC
LIMIT ?
`
// Reads for the AI household context block (internal/ai/harness).
// The names the most recently declined suggestions were suggested under,
// newest first; renaming their dinners does not change them.
// The names of the most recently declined suggestions, newest first.
func (q *Queries) ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listRecentDeclinedNames, limit)
if err != nil {
+18 -20
View File
@@ -51,25 +51,24 @@ type ChatSetting struct {
}
type Dinner struct {
ID string
Name string
NormalizedName string
Status string
Origin string
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
Cuisine sql.NullString
IsFavorite int64
RecipeID sql.NullString
PhotoID sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
CreatedAt string
UpdatedAt string
RecipeUrl sql.NullString
GroundingError sql.NullString
GroundingRevision int64
ID string
Name string
NormalizedName string
Status string
Origin string
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
Cuisine sql.NullString
IsFavorite int64
RecipeID sql.NullString
PhotoID sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
CreatedAt string
UpdatedAt string
RecipeUrl sql.NullString
GroundingError sql.NullString
}
type HouseholdSetting struct {
@@ -289,7 +288,6 @@ type Suggestion struct {
ID string
DinnerID string
BatchID string
Name string
Reason string
CitedRegularIds string
Status string
+13 -65
View File
@@ -21,10 +21,6 @@ type Querier interface {
// Drops references to the runs created before the cutoff, so the purge
// can delete them.
ClearWeekPlanAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
// How many suggestions ListDeck returns.
CountDeck(ctx context.Context) (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
@@ -34,42 +30,22 @@ type Querier interface {
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
// name is the name the dish is suggested under, kept whatever its dinner is
// later renamed.
CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error
CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error)
// Records the household's decision on a pending suggestion; one already
// decided is left as it is (0 rows).
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
DeleteRecipeSteps(ctx context.Context, recipeID string) error
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
// Fails every unarchived dinner still pending whose grounding job for its
// current grounding_revision is no longer queued or running: the process
// stopped before the job, or before its handler recorded a result. An
// archived dinner is left as it is; archiving stopped its grounding. Run at
// boot, after the job runner fails the jobs a previous process left running.
// Fails every dinner still pending whose grounding job is no longer queued
// or running: the process stopped before the job, or before its handler
// recorded a result. Run at boot, after the job runner fails the jobs a
// previous process left running.
FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error)
// Fails every batch still pending or running whose job is no longer queued
// or running: the process stopped, or recording the batch's end failed,
// before its handler recorded a result. Run before reading the active batch.
FailOrphanedSuggestionBatches(ctx context.Context, arg FailOrphanedSuggestionBatchesParams) (int64, error)
// Records why the background grounding of an unarchived dinner still
// pending at the grounding_revision it was grounded for failed; a dinner
// grounded some other way, renamed or archived meanwhile is left as it is
// (0 rows).
// Records why the background grounding of a dinner still pending failed; a
// dinner grounded some other way meanwhile is left as it is (0 rows).
FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error)
// Fails every job left running, at boot before any worker starts.
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
// Records a pending or running batch as done or failed; a batch already
// finished is left as it is (0 rows).
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
// Returns sql.ErrNoRows until the row exists.
GetAISettings(ctx context.Context) (AiSetting, error)
// The batch still pending or running, oldest first. Returns sql.ErrNoRows
// when there is none.
GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
// Returns sql.ErrNoRows until the row exists.
GetChatSettings(ctx context.Context) (ChatSetting, error)
GetDinner(ctx context.Context, id string) (Dinner, error)
@@ -86,55 +62,27 @@ type Querier interface {
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
// Returns sql.ErrNoRows until the row exists.
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error)
// The id of the job that runs batch batch_id.
GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error)
// Records a dinner's grounding by hand (grounded or manual) and clears any
// grounding_error and pasted recipe_url: the recipe chosen replaces the
// link. It writes only an unarchived dinner still as it was observed
// before grounding (same name, normalized_name, grounding_status,
// grounding_revision and recipe_id), so a dinner renamed, archived or
// grounded some other way meanwhile is left as it is. That dinner, or an
// unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
// no longer as observed is kept: a member edited it while it was grounding.
// Records a dinner's grounding (grounded or manual) and clears any
// grounding_error. An unknown id returns sql.ErrNoRows.
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
// Records the background grounding of an unarchived dinner still pending at
// the grounding_revision it was grounded for; a dinner grounded some other
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
// minutes_total or difficulty already set is kept: a pending dinner has
// them only when a member edited them while it was grounding.
// Records the background grounding of a dinner still pending; a dinner
// grounded some other way meanwhile is left as it is (0 rows).
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
// The pending suggestions whose dinner is still suggested, oldest first,
// with their dinners.
ListDeck(ctx context.Context) ([]ListDeckRow, error)
// The name every declined suggestion was suggested under, oldest decision
// first; renaming its dinner does not change it.
ListDeclinedSuggestionNames(ctx context.Context) ([]string, 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.
ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error)
// Reads for the AI household context block (internal/ai/harness).
// The names the most recently declined suggestions were suggested under,
// newest first; renaming their dinners does not change them.
// The names of the most recently declined suggestions, newest first.
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)
// Moves a dinner still suggested to status (quest_log or archived),
// recording updated_by; a dinner no longer suggested is left as it is
// (0 rows).
MoveSuggestedDinner(ctx context.Context, arg MoveSuggestedDinnerParams) (int64, error)
// Every dinner a new or renamed dinner must not repeat.
ListUnarchivedDinners(ctx context.Context) ([]Dinner, error)
// Stores a partial result of a running job.
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
// An unknown id returns sql.ErrNoRows.
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
// Marks a pending batch running; a batch in any other status is left as
// it is (0 rows).
StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error)
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
// An unknown id returns sql.ErrNoRows.
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
-409
View File
@@ -1,409 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: suggestions.sql
package db
import (
"context"
"database/sql"
)
const countDeck = `-- name: CountDeck :one
SELECT COUNT(*)
FROM suggestions
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested'
`
// How many suggestions ListDeck returns.
func (q *Queries) CountDeck(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countDeck)
var count int64
err := row.Scan(&count)
return count, err
}
const createSuggestion = `-- name: CreateSuggestion :exec
INSERT INTO suggestions (
id, dinner_id, batch_id, name, reason, cited_regular_ids, status, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, 'pending', ?, ?
)
`
type CreateSuggestionParams struct {
ID string
DinnerID string
BatchID string
Name string
Reason string
CitedRegularIds string
CreatedAt string
UpdatedAt string
}
// name is the name the dish is suggested under, kept whatever its dinner is
// later renamed.
func (q *Queries) CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error {
_, err := q.db.ExecContext(ctx, createSuggestion,
arg.ID,
arg.DinnerID,
arg.BatchID,
arg.Name,
arg.Reason,
arg.CitedRegularIds,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const createSuggestionBatch = `-- name: CreateSuggestionBatch :one
INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
VALUES (?, ?, 'pending', ?, ?)
RETURNING id, "trigger", status, ai_run_id, created_at, updated_at
`
type CreateSuggestionBatchParams struct {
ID string
Trigger string
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error) {
row := q.db.QueryRowContext(ctx, createSuggestionBatch,
arg.ID,
arg.Trigger,
arg.CreatedAt,
arg.UpdatedAt,
)
var i SuggestionBatch
err := row.Scan(
&i.ID,
&i.Trigger,
&i.Status,
&i.AiRunID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const decideSuggestion = `-- name: DecideSuggestion :execrows
UPDATE suggestions
SET status = ?1, decided_at = ?2, updated_at = ?2
WHERE id = ?3 AND status = 'pending'
`
type DecideSuggestionParams struct {
Status string
Now sql.NullString
ID string
}
// Records the household's decision on a pending suggestion; one already
// decided is left as it is (0 rows).
func (q *Queries) DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error) {
result, err := q.db.ExecContext(ctx, decideSuggestion, arg.Status, arg.Now, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const failOrphanedSuggestionBatches = `-- name: FailOrphanedSuggestionBatches :execrows
UPDATE suggestion_batches
SET status = 'failed', updated_at = ?1
WHERE status IN ('pending', 'running')
AND NOT EXISTS (
SELECT 1 FROM jobs
WHERE jobs.kind = ?2
AND jobs.status IN ('queued', 'running')
AND json_extract(jobs.payload, '$.batch_id') = suggestion_batches.id
)
`
type FailOrphanedSuggestionBatchesParams struct {
UpdatedAt string
Kind string
}
// Fails every batch still pending or running whose job is no longer queued
// or running: the process stopped, or recording the batch's end failed,
// before its handler recorded a result. Run before reading the active batch.
func (q *Queries) FailOrphanedSuggestionBatches(ctx context.Context, arg FailOrphanedSuggestionBatchesParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failOrphanedSuggestionBatches, arg.UpdatedAt, arg.Kind)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const finishSuggestionBatch = `-- name: FinishSuggestionBatch :execrows
UPDATE suggestion_batches
SET status = ?, updated_at = ?
WHERE id = ? AND status IN ('pending', 'running')
`
type FinishSuggestionBatchParams struct {
Status string
UpdatedAt string
ID string
}
// Records a pending or running batch as done or failed; a batch already
// finished is left as it is (0 rows).
func (q *Queries) FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error) {
result, err := q.db.ExecContext(ctx, finishSuggestionBatch, arg.Status, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getActiveSuggestionBatch = `-- name: GetActiveSuggestionBatch :one
SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches
WHERE status IN ('pending', 'running')
ORDER BY id
LIMIT 1
`
// The batch still pending or running, oldest first. Returns sql.ErrNoRows
// when there is none.
func (q *Queries) GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error) {
row := q.db.QueryRowContext(ctx, getActiveSuggestionBatch)
var i SuggestionBatch
err := row.Scan(
&i.ID,
&i.Trigger,
&i.Status,
&i.AiRunID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getSuggestion = `-- name: GetSuggestion :one
SELECT suggestions.id, suggestions.dinner_id, suggestions.batch_id, suggestions.name, suggestions.reason, suggestions.cited_regular_ids, suggestions.status, suggestions.decided_at, suggestions.created_at, suggestions.updated_at, 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
FROM suggestions
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.id = ?
`
type GetSuggestionRow struct {
Suggestion Suggestion
Dinner Dinner
}
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
func (q *Queries) GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error) {
row := q.db.QueryRowContext(ctx, getSuggestion, id)
var i GetSuggestionRow
err := row.Scan(
&i.Suggestion.ID,
&i.Suggestion.DinnerID,
&i.Suggestion.BatchID,
&i.Suggestion.Name,
&i.Suggestion.Reason,
&i.Suggestion.CitedRegularIds,
&i.Suggestion.Status,
&i.Suggestion.DecidedAt,
&i.Suggestion.CreatedAt,
&i.Suggestion.UpdatedAt,
&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,
)
return i, err
}
const getSuggestionBatchJobID = `-- name: GetSuggestionBatchJobID :one
SELECT id FROM jobs
WHERE kind = ?1
AND json_extract(payload, '$.batch_id') = ?2
ORDER BY id
LIMIT 1
`
type GetSuggestionBatchJobIDParams struct {
Kind string
BatchID string
}
// The id of the job that runs batch batch_id.
func (q *Queries) GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error) {
row := q.db.QueryRowContext(ctx, getSuggestionBatchJobID, arg.Kind, arg.BatchID)
var id string
err := row.Scan(&id)
return id, err
}
const listDeck = `-- name: ListDeck :many
SELECT suggestions.id, suggestions.dinner_id, suggestions.batch_id, suggestions.name, suggestions.reason, suggestions.cited_regular_ids, suggestions.status, suggestions.decided_at, suggestions.created_at, suggestions.updated_at, 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
FROM suggestions
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested'
ORDER BY suggestions.created_at, suggestions.id
`
type ListDeckRow struct {
Suggestion Suggestion
Dinner Dinner
}
// The pending suggestions whose dinner is still suggested, oldest first,
// with their dinners.
func (q *Queries) ListDeck(ctx context.Context) ([]ListDeckRow, error) {
rows, err := q.db.QueryContext(ctx, listDeck)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListDeckRow
for rows.Next() {
var i ListDeckRow
if err := rows.Scan(
&i.Suggestion.ID,
&i.Suggestion.DinnerID,
&i.Suggestion.BatchID,
&i.Suggestion.Name,
&i.Suggestion.Reason,
&i.Suggestion.CitedRegularIds,
&i.Suggestion.Status,
&i.Suggestion.DecidedAt,
&i.Suggestion.CreatedAt,
&i.Suggestion.UpdatedAt,
&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,
); 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 listDeclinedSuggestionNames = `-- name: ListDeclinedSuggestionNames :many
SELECT name
FROM suggestions
WHERE status = 'declined'
ORDER BY decided_at, id
`
// The name every declined suggestion was suggested under, oldest decision
// first; renaming its dinner does not change it.
func (q *Queries) ListDeclinedSuggestionNames(ctx context.Context) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listDeclinedSuggestionNames)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
items = append(items, name)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const moveSuggestedDinner = `-- name: MoveSuggestedDinner :execrows
UPDATE dinners
SET status = ?1, updated_by = ?2, updated_at = ?3
WHERE id = ?4 AND status = 'suggested'
`
type MoveSuggestedDinnerParams struct {
Status string
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// Moves a dinner still suggested to status (quest_log or archived),
// recording updated_by; a dinner no longer suggested is left as it is
// (0 rows).
func (q *Queries) MoveSuggestedDinner(ctx context.Context, arg MoveSuggestedDinnerParams) (int64, error) {
result, err := q.db.ExecContext(ctx, moveSuggestedDinner,
arg.Status,
arg.UpdatedBy,
arg.UpdatedAt,
arg.ID,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const startSuggestionBatch = `-- name: StartSuggestionBatch :execrows
UPDATE suggestion_batches
SET status = 'running', updated_at = ?
WHERE id = ? AND status = 'pending'
`
type StartSuggestionBatchParams struct {
UpdatedAt string
ID string
}
// Marks a pending batch running; a batch in any other status is left as
// it is (0 rows).
func (q *Queries) StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error) {
result, err := q.db.ExecContext(ctx, startSuggestionBatch, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+101 -117
View File
@@ -11,11 +11,10 @@ import (
"errors"
"fmt"
"log/slog"
"math"
"net/url"
"slices"
"strings"
"sync"
"unicode"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
@@ -54,9 +53,7 @@ var listable = []string{StatusRegular, StatusQuestLog}
// ErrNotFound is returned for an unknown dinner id.
var ErrNotFound = errors.New("dinner not found")
// Dinner is a dinner as the API shows it. It has no recipe_url: a pasted
// link may carry a personal token, so it stays in the database for
// grounding and never leaves the server.
// Dinner is a dinner as the API shows it.
type Dinner struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -68,6 +65,7 @@ type Dinner struct {
Cuisine *string `json:"cuisine"`
IsFavorite bool `json:"is_favorite"`
RecipeID *string `json:"recipe_id"`
RecipeURL *string `json:"recipe_url"`
PhotoURL *string `json:"photo_url"`
GroundingStatus string `json:"grounding_status"`
// GroundingError is why grounding failed, one of the Reason codes;
@@ -78,10 +76,7 @@ type Dinner struct {
UpdatedAt string `json:"updated_at"`
}
// NewRegular is a Regular to add: its Name, which the household always
// gives, and optionally the RecipeURL to ground it in. The name is never
// taken from the link: a pasted link may carry a personal token anywhere in
// it, and the name is shown in every dinner response.
// NewRegular is a Regular to add: exactly one of Name and RecipeURL.
type NewRegular struct {
Name *string
RecipeURL *string
@@ -135,11 +130,6 @@ type Service struct {
grounder *recipes.Grounder
logger *slog.Logger
groundJob *jobs.Kind[GroundJob]
// groundingsMu guards groundings: the KindGroundDinner jobs running,
// by dinner id, so archiving or renaming a dinner can stop them.
groundingsMu sync.Mutex
groundings map[string]map[*runningGrounding]struct{}
}
// NewService returns a service over st that grounds dinners with grounder
@@ -156,7 +146,7 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner
case logger == nil:
return nil, errors.New("dinners: nil logger")
}
s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{})}
s := &Service{st: st, grounder: grounder, logger: logger}
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
if err != nil {
return nil, err
@@ -173,9 +163,10 @@ type Created struct {
GroundingJobID *string `json:"grounding_job_id"`
}
// CreateRegular adds a Regular named r.Name, keeping r.RecipeURL for
// grounding when given, with status and origin regular and grounding_status
// pending, recording updatedBy, and enqueues the KindGroundDinner job that grounds it. It returns without
// CreateRegular adds a Regular named by r.Name, or by the last path segment
// of r.RecipeURL (whose recipe grounding later replaces), with status and
// origin regular and grounding_status pending, recording updatedBy, and
// enqueues the KindGroundDinner job that grounds it. It returns without
// waiting for the job. A name repeating an unarchived dinner is a
// *DuplicateError. When the job cannot be enqueued, the stored dinner is
// returned with grounding_status failed (ReasonGroundingError) and no job
@@ -185,64 +176,61 @@ func (s *Service) CreateRegular(ctx context.Context, r NewRegular, updatedBy str
if err != nil {
return Created{}, err
}
jobID, err := s.enqueueGrounding(ctx, d.ID, d.GroundingRevision)
if err != nil {
return Created{}, err
}
if jobID == nil {
row, err := db.New(s.st.Reader).GetDinner(context.WithoutCancel(ctx), d.ID)
if err != nil {
return Created{}, fmt.Errorf("dinners: read %s: %w", d.ID, err)
}
return Created{Dinner: fromRow(row)}, nil
}
return Created{Dinner: fromRow(d), GroundingJobID: jobID}, nil
}
// enqueueGrounding enqueues the KindGroundDinner job for pending dinner id
// at grounding_revision revision and returns its id. When the job cannot be
// enqueued, the dinner's grounding is failed with ReasonGroundingError, so
// it is not left pending with no job, and the id is nil.
func (s *Service) enqueueGrounding(ctx context.Context, id string, revision int64) (*string, error) {
jobID, err := s.groundJob.Enqueue(ctx, GroundJob{DinnerID: id, Revision: revision})
jobID, err := s.groundJob.Enqueue(ctx, GroundJob{DinnerID: d.ID})
if err == nil {
return &jobID, nil
return Created{Dinner: d, GroundingJobID: &jobID}, nil
}
// The error is a database error, never a dependency's text.
s.logger.ErrorContext(ctx, "dinner grounding not enqueued", "dinner_id", id, "error", err)
if _, err := db.New(s.st.Writer).FailPendingDinnerGrounding(context.WithoutCancel(ctx), db.FailPendingDinnerGroundingParams{
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
UpdatedAt: store.Now().String(),
ID: id,
GroundingRevision: revision,
// The dinner is stored; record that it will not be grounded so it is
// not left pending with no job. The error is a database error, never a
// dependency's text.
s.logger.ErrorContext(ctx, "dinner grounding not enqueued", "dinner_id", d.ID, "error", err)
write := context.WithoutCancel(ctx)
if _, err := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
UpdatedAt: store.Now().String(),
ID: d.ID,
}); err != nil {
return nil, fmt.Errorf("dinners: record failed grounding of %s: %w", id, err)
return Created{}, fmt.Errorf("dinners: record failed grounding of %s: %w", d.ID, err)
}
return nil, nil
row, err := db.New(s.st.Reader).GetDinner(write, d.ID)
if err != nil {
return Created{}, fmt.Errorf("dinners: read %s: %w", d.ID, err)
}
return Created{Dinner: fromRow(row)}, nil
}
func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy string) (db.Dinner, error) {
func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return db.Dinner{}, errors.New("dinners: create: no member to record as updated_by")
}
if r.Name == nil {
return db.Dinner{}, invalid("name", "give the dinner's name, with or without a recipe_url")
}
name, err := checkName(*r.Name)
if err != nil {
return db.Dinner{}, err
return Dinner{}, errors.New("dinners: create: no member to record as updated_by")
}
var name string
var recipeURL sql.NullString
if r.RecipeURL != nil {
switch {
case r.Name != nil && r.RecipeURL != nil:
return Dinner{}, invalid("name", "give a name or a recipe_url, not both")
case r.Name != nil:
n, err := checkName(*r.Name)
if err != nil {
return Dinner{}, err
}
name = n
case r.RecipeURL != nil:
u, err := checkRecipeURL(*r.RecipeURL)
if err != nil {
return db.Dinner{}, err
return Dinner{}, err
}
n, ok := nameFromURL(u)
if !ok {
return Dinner{}, invalid("recipe_url", "the link does not name a dinner; add it by name instead")
}
name = n
recipeURL = sql.NullString{String: u.String(), Valid: true}
default:
return Dinner{}, invalid("name", "give a name or a recipe_url")
}
var row db.Dinner
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
if err := checkUnique(ctx, q, "", name, recipeURL); err != nil {
return err
@@ -267,9 +255,9 @@ func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy str
return nil
})
if err != nil {
return db.Dinner{}, err
return Dinner{}, err
}
return row, nil
return fromRow(row), nil
}
// List returns the dinners with status, which must be regular or
@@ -291,11 +279,7 @@ func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
// Update applies p to dinner id, recording updatedBy. An invalid patch is a
// *ValidationError and a new name repeating another unarchived dinner a
// *DuplicateError; either changes nothing. Renaming a dinner still pending
// clears its recipe_url, raises its grounding_revision and enqueues a fresh
// KindGroundDinner job that grounds the new name: a job queued or running
// for the old name or link carries the old revision, so it can no longer
// write the dinner, and one running is stopped.
// *DuplicateError; either changes nothing.
func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: update: no member to record as updated_by")
@@ -306,7 +290,6 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
}
var row db.Dinner
var regrounding bool
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetDinner(ctx, id)
@@ -317,22 +300,20 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
return fmt.Errorf("dinners: read %s: %w", id, err)
}
params := db.UpdateDinnerParams{
ID: cur.ID,
Name: cur.Name,
NormalizedName: cur.NormalizedName,
Status: cur.Status,
Origin: cur.Origin,
Method: cur.Method,
MinutesTotal: cur.MinutesTotal,
Difficulty: cur.Difficulty,
Cuisine: cur.Cuisine,
RecipeID: cur.RecipeID,
RecipeUrl: cur.RecipeUrl,
PhotoID: cur.PhotoID,
GroundingStatus: cur.GroundingStatus,
GroundingRevision: cur.GroundingRevision,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
ID: cur.ID,
Name: cur.Name,
NormalizedName: cur.NormalizedName,
Status: cur.Status,
Origin: cur.Origin,
Method: cur.Method,
MinutesTotal: cur.MinutesTotal,
Difficulty: cur.Difficulty,
Cuisine: cur.Cuisine,
RecipeID: cur.RecipeID,
PhotoID: cur.PhotoID,
GroundingStatus: cur.GroundingStatus,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
}
if p.Name != nil {
if err := checkUnique(ctx, q, cur.ID, name, sql.NullString{}); err != nil {
@@ -340,12 +321,6 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
}
params.Name = name
params.NormalizedName = harness.Normalize(name)
if cur.GroundingStatus == GroundingPending && params.NormalizedName != cur.NormalizedName {
// The link was the old dinner's recipe; the new name is
// grounded by name, and a link can be given by hand.
params.RecipeUrl = sql.NullString{}
params.GroundingRevision++
}
}
if p.Method != nil {
params.Method = sql.NullString{String: *p.Method, Valid: true}
@@ -360,31 +335,16 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
if err != nil {
return fmt.Errorf("dinners: update %s: %w", id, err)
}
regrounding = row.GroundingRevision != cur.GroundingRevision
return nil
})
if err != nil {
return Dinner{}, err
}
if regrounding {
s.stopGroundings(row.ID, row.GroundingRevision)
jobID, err := s.enqueueGrounding(ctx, row.ID, row.GroundingRevision)
if err != nil {
return Dinner{}, err
}
if jobID == nil {
row, err = db.New(s.st.Reader).GetDinner(context.WithoutCancel(ctx), row.ID)
if err != nil {
return Dinner{}, fmt.Errorf("dinners: read %s: %w", id, err)
}
}
}
return fromRow(row), nil
}
// Archive sets dinner id's status to archived, recording updatedBy. The row
// is kept, so its name still counts for Side quest dedupe. A
// KindGroundDinner job running for it is stopped.
// is kept, so its name still counts for Side quest dedupe.
func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: archive: no member to record as updated_by")
@@ -400,7 +360,6 @@ func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, er
if err != nil {
return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err)
}
s.stopGroundings(row.ID, math.MaxInt64)
return fromRow(row), nil
}
@@ -487,25 +446,49 @@ func checkRecipeURL(raw string) (*url.URL, error) {
return u, nil
}
// nameFromURL names a dinner from the last path segment of u: separators
// become spaces, a file extension and words of only digits are dropped and
// the first letter is capitalized, so
// https://example.com/recipes/12345/chicken-tikka-masala/ is
// "Chicken tikka masala". ok is false when no usable name is left.
func nameFromURL(u *url.URL) (name string, ok bool) {
segments := strings.FieldsFunc(u.Path, func(r rune) bool { return r == '/' })
if len(segments) == 0 {
return "", false
}
slug := segments[len(segments)-1]
if i := strings.LastIndexByte(slug, '.'); i > 0 {
slug = slug[:i]
}
var words []string
for _, w := range strings.FieldsFunc(slug, func(r rune) bool { return r == '-' || r == '_' || r == '+' || unicode.IsSpace(r) }) {
if strings.IndexFunc(w, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
words = append(words, strings.ToLower(w))
}
}
name = strings.Join(words, " ")
if r, size := utf8.DecodeRuneInString(name); r != utf8.RuneError {
name = string(unicode.ToUpper(r)) + name[size:]
}
if _, err := checkName(name); err != nil {
return "", false
}
return name, true
}
// checkUnique returns a *DuplicateError when name is harness.Similar to an
// unarchived dinner other than self, or recipeURL is one's recipe link: the
// source_url of the recipe it is grounded in, else the recipe_url still
// awaiting grounding.
// unarchived dinner other than self, or recipeURL is one's recipe link.
func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) error {
rows, err := q.ListUnarchivedDinners(ctx)
if err != nil {
return fmt.Errorf("dinners: list for dedupe: %w", err)
}
for _, row := range rows {
if row.Dinner.ID == self {
if row.ID == self {
continue
}
link := row.Dinner.RecipeUrl
if row.RecipeSourceUrl.Valid {
link = row.RecipeSourceUrl
}
if harness.Similar(name, row.Dinner.Name) || (recipeURL.Valid && link == recipeURL) {
return &DuplicateError{Existing: fromRow(row.Dinner)}
if harness.Similar(name, row.Name) || (recipeURL.Valid && row.RecipeUrl == recipeURL) {
return &DuplicateError{Existing: fromRow(row)}
}
}
return nil
@@ -527,6 +510,7 @@ func fromRow(r db.Dinner) Dinner {
Cuisine: nullString(r.Cuisine),
IsFavorite: r.IsFavorite == 1,
RecipeID: nullString(r.RecipeID),
RecipeURL: nullString(r.RecipeUrl),
GroundingStatus: r.GroundingStatus,
GroundingError: nullString(r.GroundingError),
UpdatedBy: nullString(r.UpdatedBy),
+24 -65
View File
@@ -1,74 +1,33 @@
package dinners_test
package dinners
import (
"encoding/json"
"errors"
"strings"
"net/url"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
)
// TestCreateRegularNeverNamesFromLink: a Regular added with a recipe link
// is named only by the household, so no part of the link — host, path,
// query or fragment — is stored as its name or shown in a dinner or a
// duplicate error, even a token of plain letters in the path.
func TestCreateRegularNeverNamesFromLink(t *testing.T) {
func TestNameFromURL(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
const link = "https://c.example/stew-key-abcdefghijklmno?sig=zyxwvut#frag"
leaks := []string{"c.example", "key", "abcdefghijklmno", "sig", "zyxwvut", "frag"}
check := func(what, text string) {
t.Helper()
lower := strings.ToLower(text)
for _, leak := range leaks {
if strings.Contains(lower, leak) {
t.Errorf("%s contains %q: %s", what, leak, text)
}
tests := []struct {
link string
want string // "" means no usable name
}{
{"https://example.com/recipes/12345/chicken-tikka-masala/", "Chicken tikka masala"},
{"https://example.com/recipe/beef_stew.html", "Beef stew"},
{"https://example.com/2024/05/best-ever-chili-123", "Best ever chili"},
{"https://example.com/crème-brûlée", "Crème brûlée"},
{"https://example.com/recipes/chili?utm=1#notes", "Chili"},
{"https://example.com/", ""},
{"https://example.com/recipe/98765", ""},
{"https://example.com/the-best-easy-recipe", ""},
}
for _, tt := range tests {
u, err := url.Parse(tt.link)
if err != nil {
t.Fatal(err)
}
got, ok := nameFromURL(u)
if got != tt.want || ok != (tt.want != "") {
t.Errorf("nameFromURL(%s) = %q, %v; want %q", tt.link, got, ok, tt.want)
}
}
_, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(link)}, member)
var verr *dinners.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("link without a name: err = %v, want a *ValidationError", err)
}
check("validation error", verr.Error())
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr(" Sunday stew "), RecipeURL: ptr(link)}, member)
if err != nil {
t.Fatal(err)
}
if c.Name != "Sunday stew" {
t.Errorf("name = %q, want the household's %q", c.Name, "Sunday stew")
}
created, err := json.Marshal(c)
if err != nil {
t.Fatal(err)
}
check("create response", string(created))
row := e.dinner(t, c.ID)
check("stored name", row.Name+" "+row.NormalizedName)
if row.RecipeUrl.String != link {
t.Errorf("stored recipe_url = %q, want %q kept for grounding", row.RecipeUrl.String, link)
}
list, err := e.svc.List(t.Context(), dinners.StatusRegular)
if err != nil {
t.Fatal(err)
}
listed, err := json.Marshal(list)
if err != nil {
t.Fatal(err)
}
check("list response", string(listed))
// The same link under another name is still a duplicate, and its error
// names the existing dinner by the household's name.
_, err = e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Weeknight chili"), RecipeURL: ptr(link)}, member)
var dup *dinners.DuplicateError
if !errors.As(err, &dup) || dup.Existing.ID != c.ID {
t.Fatalf("same link again: err = %v, want a duplicate of %s", err, c.ID)
}
check("duplicate error", dup.Error())
}
-9
View File
@@ -1,7 +1,6 @@
package dinners
import (
"context"
"testing"
"time"
)
@@ -13,11 +12,3 @@ func SetGroundTimeout(t *testing.T, d time.Duration) {
groundTimeout = d
t.Cleanup(func() { groundTimeout = old })
}
// RunGroundJob runs the KindGroundDinner handler for p in the caller's
// goroutine, as the job runner would, and returns the grounding status it
// reports.
func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error) {
r, err := s.runGroundJob(ctx, p, nil)
return r.GroundingStatus, err
}
+57 -195
View File
@@ -76,29 +76,14 @@ var groundTimeout = GroundTimeout
// runs past GroundTimeout; the dinner is left as it was.
var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long")
// ErrGroundFailed wraps any unexpected failure grounding a dinner by hand.
// Its text may quote the link and dependency errors, so it is never
// logged.
var ErrGroundFailed = errors.New("dinners: grounding the recipe failed")
// ErrArchived is grounding an archived dinner by hand.
var ErrArchived = errors.New("dinner archived")
// ErrGroundConflict is a candidate or link grounded for a dinner that was
// renamed or grounded some other way while it was grounding.
var ErrGroundConflict = errors.New("dinner changed while its recipe was grounding")
// GroundJob is the KindGroundDinner payload: the dinner to ground and its
// grounding_revision when the job was enqueued.
// GroundJob is the KindGroundDinner payload.
type GroundJob struct {
DinnerID string `json:"dinner_id"`
Revision int64 `json:"revision"`
}
// groundResult is the KindGroundDinner result: the dinner's grounding
// status once the job ends. It is not grounded by this job when the dinner
// was grounded by hand before the job finished, renamed (a later job
// grounds it) or archived; the job then reports the status it found.
// status once the job ends. It is not grounded when the dinner was
// grounded by hand before the job finished.
type groundResult struct {
DinnerID string `json:"dinner_id"`
GroundingStatus string `json:"grounding_status"`
@@ -108,38 +93,24 @@ func (r groundResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// runGroundJob grounds a pending dinner as its creation asked: its
// recipe_url with GroundURL, else its name with Ground. Success records
// the recipe and photo with grounding_status grounded, and the recipe's
// method, minutes and difficulty where a member has not set them; failure
// records grounding_status failed with its Reason, which is also the job's
// failure code. A dinner archived, no longer pending, or at a
// grounding_revision other than p.Revision is not grounded at all, and
// either result is recorded only while the dinner is still unarchived,
// pending and at p.Revision: a dinner grounded by hand meanwhile is left as
// it is, and one renamed or archived meanwhile is left pending — for the
// job its rename enqueued, or for none. Archiving or renaming the dinner
// stops the grounding where it is, so no further search, fetch, photo
// download or model call is made for it.
// the recipe, photo, method, minutes and difficulty with grounding_status
// grounded; failure records grounding_status failed with its Reason, which
// is also the job's failure code. A dinner no longer pending (grounded by
// hand meanwhile) is left as it is.
func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundResult) error) (groundResult, error) {
// Tracked before the dinner is read: an archive or rename committed
// after the read finds this job to stop.
groundCtx, done := s.trackGrounding(ctx, p.DinnerID, p.Revision)
defer done()
row, err := db.New(s.st.Reader).GetDinner(ctx, p.DinnerID)
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", p.DinnerID, err)
}
if row.Status == StatusArchived || row.GroundingStatus != GroundingPending || row.GroundingRevision != p.Revision {
if row.GroundingStatus != GroundingPending {
return groundResult{DinnerID: row.ID, GroundingStatus: row.GroundingStatus}, nil
}
var gr recipes.Grounded
if row.RecipeUrl.Valid {
gr, err = s.grounder.GroundURL(groundCtx, row.RecipeUrl.String)
gr, err = s.grounder.GroundURL(ctx, row.RecipeUrl.String)
} else {
gr, err = s.grounder.Ground(groundCtx, row.Name, row.Name)
}
if err != nil && ctx.Err() == nil && errors.Is(context.Cause(groundCtx), errGroundingStopped) {
return s.currentGrounding(ctx, row.ID)
gr, err = s.grounder.Ground(ctx, row.Name, row.Name)
}
if err != nil {
reason := reasonFor(err, row.RecipeUrl.Valid)
@@ -151,10 +122,9 @@ func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundRe
s.logger.InfoContext(ctx, "dinner grounding failed", "dinner_id", row.ID, "reason", reason)
write := context.WithoutCancel(ctx)
if _, failErr := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{
GroundingError: sql.NullString{String: reason, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
GroundingRevision: p.Revision,
GroundingError: sql.NullString{String: reason, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
}); failErr != nil {
return groundResult{}, fmt.Errorf("dinners: ground: record failure of %s: %w", row.ID, failErr)
}
@@ -165,85 +135,31 @@ func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundRe
}
n, err := db.New(s.st.Writer).GroundPendingDinner(ctx, db.GroundPendingDinnerParams{
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
Method: sql.NullString{String: gr.Method, Valid: true},
MinutesTotal: nullInt(gr.MinutesTotal),
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
GroundingRevision: p.Revision,
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
Method: sql.NullString{String: gr.Method, Valid: true},
MinutesTotal: nullInt(gr.MinutesTotal),
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
})
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: record %s: %w", row.ID, err)
}
if n == 0 {
return s.currentGrounding(ctx, row.ID)
cur, err := db.New(s.st.Reader).GetDinner(ctx, row.ID)
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", row.ID, err)
}
return groundResult{DinnerID: cur.ID, GroundingStatus: cur.GroundingStatus}, nil
}
return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil
}
// currentGrounding is the KindGroundDinner result of a job that did not
// ground dinner id: the grounding status the dinner has now.
func (s *Service) currentGrounding(ctx context.Context, id string) (groundResult, error) {
cur, err := db.New(s.st.Reader).GetDinner(ctx, id)
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", id, err)
}
return groundResult{DinnerID: cur.ID, GroundingStatus: cur.GroundingStatus}, nil
}
// errGroundingStopped is the cause a running KindGroundDinner job's
// grounding is cancelled with when its dinner is archived or renamed.
var errGroundingStopped = errors.New("dinners: grounding stopped: dinner archived or renamed")
// runningGrounding is a KindGroundDinner job running for a dinner at
// revision, with the cancel of its grounding.
type runningGrounding struct {
revision int64
cancel context.CancelCauseFunc
}
// trackGrounding returns a context for grounding dinnerID at revision that
// stopGroundings cancels, and the func to call when the job ends.
func (s *Service) trackGrounding(ctx context.Context, dinnerID string, revision int64) (context.Context, func()) {
groundCtx, cancel := context.WithCancelCause(ctx)
r := &runningGrounding{revision: revision, cancel: cancel}
s.groundingsMu.Lock()
if s.groundings[dinnerID] == nil {
s.groundings[dinnerID] = make(map[*runningGrounding]struct{})
}
s.groundings[dinnerID][r] = struct{}{}
s.groundingsMu.Unlock()
return groundCtx, func() {
s.groundingsMu.Lock()
delete(s.groundings[dinnerID], r)
if len(s.groundings[dinnerID]) == 0 {
delete(s.groundings, dinnerID)
}
s.groundingsMu.Unlock()
cancel(nil)
}
}
// stopGroundings cancels, with errGroundingStopped, the grounding of every
// KindGroundDinner job running for dinnerID at a revision below below. Call
// it once the change that makes those jobs stale is committed.
func (s *Service) stopGroundings(dinnerID string, below int64) {
s.groundingsMu.Lock()
defer s.groundingsMu.Unlock()
for r := range s.groundings[dinnerID] {
if r.revision < below {
r.cancel(errGroundingStopped)
}
}
}
// FailInterruptedGroundings fails, with ReasonInterrupted, every unarchived
// dinner still pending whose KindGroundDinner job is no longer queued or
// running: a previous process stopped before enqueuing the job or before
// its handler recorded a result. An archived dinner is left pending, since
// archiving, not a restart, stopped its grounding. Call it once at boot, after the job runner's
// FailInterruptedGroundings fails, with ReasonInterrupted, every dinner
// still pending whose KindGroundDinner job is no longer queued or running:
// a previous process stopped before enqueuing the job or before its
// handler recorded a result. Call it once at boot, after the job runner's
// Start has failed the jobs that process left running, so no dinner stays
// pending with nothing left to ground it. It returns how many it failed.
func (s *Service) FailInterruptedGroundings(ctx context.Context) (int64, error) {
@@ -335,17 +251,11 @@ type ManualRecipe struct {
// Ground grounds dinner id as f says, recording updatedBy. A candidate or
// link is grounded with recipes.Grounder.GroundURL and sets
// grounding_status grounded, with the recipe's photo, method, minutes and
// difficulty; one a member changed while it was grounding is kept. A
// manual recipe is stored as typed — no model runs — and sets
// grounding_status manual with its method and minutes; it has no photo,
// and the dinner keeps its difficulty. An invalid f is a *ValidationError
// and an archived dinner ErrArchived. A failure grounding a candidate or
// link, or recording it on the dinner, wraps ErrGroundFailed; a page that
// cannot ground the dinner is also a *recipes.ErrUngroundable. A dinner
// renamed, archived or grounded some other way while a candidate or link
// was grounding is ErrGroundConflict or ErrArchived. A missing dinner is
// ErrNotFound; any other failure, by candidate, link or manual recipe,
// wraps ErrGroundFailed. None of these changes the dinner.
// difficulty. A manual recipe is stored as typed — no model runs — and
// sets grounding_status manual with its method and minutes; it has no
// photo, and the dinner keeps its difficulty. An invalid f is a
// *ValidationError; a page that cannot ground the dinner is a
// *recipes.ErrUngroundable and changes nothing.
func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: ground: no member to record as updated_by")
@@ -357,16 +267,9 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
if manual != nil {
return s.groundManual(ctx, id, *manual, updatedBy)
}
cur, err := s.get(ctx, id)
if errors.Is(err, ErrNotFound) {
if _, err := s.get(ctx, id); err != nil {
return Dinner{}, err
}
if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
}
if cur.Status == StatusArchived {
return Dinner{}, ErrArchived
}
groundCtx, cancel := context.WithTimeout(ctx, groundTimeout)
defer cancel()
gr, err := s.grounder.GroundURL(groundCtx, link)
@@ -374,57 +277,30 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
if ctx.Err() == nil && errors.Is(groundCtx.Err(), context.DeadlineExceeded) {
return Dinner{}, ErrGroundTimeout
}
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err)
}
row, err := db.New(s.st.Writer).GroundDinner(ctx, db.GroundDinnerParams{
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
Method: sql.NullString{String: gr.Method, Valid: true},
MinutesTotal: nullInt(gr.MinutesTotal),
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
GroundingStatus: GroundingGrounded,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
ID: cur.ID,
ObservedName: cur.Name,
ObservedNormalizedName: cur.NormalizedName,
ObservedGroundingStatus: cur.GroundingStatus,
ObservedGroundingRevision: cur.GroundingRevision,
ObservedRecipeID: cur.RecipeID,
ObservedMethod: cur.Method,
ObservedMinutesTotal: cur.MinutesTotal,
ObservedDifficulty: cur.Difficulty,
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
Method: sql.NullString{String: gr.Method, Valid: true},
MinutesTotal: nullInt(gr.MinutesTotal),
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
GroundingStatus: GroundingGrounded,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
ID: id,
})
if errors.Is(err, sql.ErrNoRows) {
return Dinner{}, s.groundRejected(ctx, id)
return Dinner{}, ErrNotFound
}
if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err)
}
return fromRow(row), nil
}
// groundRejected is the error for dinner id when it was not as observed
// before grounding: ErrNotFound, ErrArchived or ErrGroundConflict. Failing
// to read it wraps ErrGroundFailed.
func (s *Service) groundRejected(ctx context.Context, id string) error {
cur, err := s.get(ctx, id)
if errors.Is(err, ErrNotFound) {
return err
}
if err != nil {
return fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
}
if cur.Status == StatusArchived {
return ErrArchived
}
return ErrGroundConflict
}
// groundManual stores m as dinner id's recipe, with its ingredients and
// steps, and records it on the dinner, all in one transaction. A missing
// or archived dinner is ErrNotFound or ErrArchived; any other failure
// wraps ErrGroundFailed.
// steps, and records it on the dinner, all in one transaction.
func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, updatedBy string) (Dinner, error) {
var row db.Dinner
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
@@ -436,9 +312,6 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
if err != nil {
return fmt.Errorf("dinners: read %s: %w", id, err)
}
if cur.Status == StatusArchived {
return ErrArchived
}
ld, err := json.Marshal(manualJSONLD{
Context: "https://schema.org",
Type: "Recipe",
@@ -494,33 +367,22 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
}
}
row, err = q.GroundDinner(ctx, db.GroundDinnerParams{
RecipeID: sql.NullString{String: rec.ID, Valid: true},
Method: method,
MinutesTotal: minutes,
Difficulty: cur.Difficulty,
GroundingStatus: GroundingManual,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: now,
ID: cur.ID,
ObservedName: cur.Name,
ObservedNormalizedName: cur.NormalizedName,
ObservedGroundingStatus: cur.GroundingStatus,
ObservedGroundingRevision: cur.GroundingRevision,
ObservedRecipeID: cur.RecipeID,
ObservedMethod: cur.Method,
ObservedMinutesTotal: cur.MinutesTotal,
ObservedDifficulty: cur.Difficulty,
RecipeID: sql.NullString{String: rec.ID, Valid: true},
Method: method,
MinutesTotal: minutes,
Difficulty: cur.Difficulty,
GroundingStatus: GroundingManual,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: now,
ID: cur.ID,
})
if err != nil {
return fmt.Errorf("dinners: ground %s: %w", id, err)
}
return nil
})
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrArchived) {
return Dinner{}, err
}
if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
return Dinner{}, err
}
return fromRow(row), nil
}
+2 -722
View File
@@ -2,15 +2,11 @@ package dinners_test
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"io/fs"
"log/slog"
"slices"
"testing"
"testing/fstest"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
@@ -56,13 +52,6 @@ func newEnv(t *testing.T) *env {
func newUnstartedEnv(t *testing.T) *env {
t.Helper()
dataDir := t.TempDir()
return newUnstartedEnvOn(t, openStore(t, dataDir), dataDir)
}
// openStore opens the store in dataDir, closing it when the test ends. It
// is not migrated.
func openStore(t *testing.T, dataDir string) *store.Store {
t.Helper()
st, err := store.Open(t.Context(), dataDir)
if err != nil {
t.Fatal(err)
@@ -72,13 +61,6 @@ func openStore(t *testing.T, dataDir string) *store.Store {
t.Error(err)
}
})
return st
}
// newUnstartedEnvOn is newUnstartedEnv over st, the store in dataDir,
// which it migrates to the latest version.
func newUnstartedEnvOn(t *testing.T, st *store.Store, dataDir string) *env {
t.Helper()
if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
@@ -194,7 +176,7 @@ func TestCreateByRecipeURLGroundsThatURL(t *testing.T) {
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Beef stew"), RecipeURL: ptr(stew)}, member)
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(stew)}, member)
if err != nil {
t.Fatal(err)
}
@@ -223,7 +205,7 @@ func TestCreateGroundingFailureRecordsReason(t *testing.T) {
reason string
}{
{"no candidates", dinners.NewRegular{Name: ptr("Unknown dish")}, nil, dinners.ReasonNoRecipe},
{"unusable link", dinners.NewRegular{Name: ptr("Lost soup"), RecipeURL: ptr("https://gone.example/recipes/lost-soup")}, nil, dinners.ReasonRecipeUnusable},
{"unusable link", dinners.NewRegular{RecipeURL: ptr("https://gone.example/recipes/lost-soup")}, nil, dinners.ReasonRecipeUnusable},
{"search fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) { e.search.Err = recipes.ErrSearchRateLimited }, dinners.ReasonSearchFailed},
{"model fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) {
// One provider failure per candidate tried.
@@ -304,331 +286,6 @@ func TestManualFixWhilePendingIsKept(t *testing.T) {
}
}
// TestRenameWhilePendingGroundsNewName: a Regular renamed while its job is
// still grounding the old name never gets the old name's result, whether
// the old name would be found or not: the rename stops that job where it
// is, and the job the rename enqueues grounds the new name.
func TestRenameWhilePendingGroundsNewName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
oldName string
}{
{"old name found", "Chicken curry"},
{"old name not found", "Unknown dish"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
e.search.Gate = make(chan struct{})
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr(tt.oldName)}, member)
if err != nil {
t.Fatal(err)
}
// Rename only once the job has read the old name and is searching it.
e.waitQuery(t, tt.oldName)
d, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member)
if err != nil {
t.Fatal(err)
}
if d.GroundingStatus != dinners.GroundingPending {
t.Fatalf("renamed = %+v, want still pending", d)
}
// The old name's job ends while its search is still held.
first := e.waitJob(t, *c.GroundingJobID)
if first.Status != jobs.StatusSucceeded || string(first.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"pending"}` {
t.Fatalf("old name's job = %+v (result %s), want succeeded leaving the dinner pending", first, first.Result)
}
close(e.search.Gate)
row := e.waitGrounding(t, c.ID)
if row.GroundingStatus != dinners.GroundingGrounded {
t.Fatalf("dinner = %+v, want grounded for the new name", row)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != stew || rec.PhotoID != row.PhotoID || row.Method.String != "oven" || row.MinutesTotal.Int64 != 150 {
t.Errorf("dinner = %+v recipe %s, want grounded in %s", row, rec.SourceUrl, stew)
}
if got := e.search.Queries(); !slices.Equal(got, []string{tt.oldName, "Beef stew"}) {
t.Errorf("searched %q, want the old name then the new", got)
}
if got := len(e.model.Requests()); got != 1 {
t.Errorf("model called %d times, want once, for the new name only", got)
}
})
}
}
// TestRenameLinkWhilePendingGroundsNewName: a Regular added from a link and
// renamed while still pending drops the link and grounds its new name, not
// the old link's recipe and photo.
func TestRenameLinkWhilePendingGroundsNewName(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry"), RecipeURL: ptr(curryA)}, member)
if err != nil {
t.Fatal(err)
}
d, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member)
if err != nil {
t.Fatal(err)
}
if d.GroundingStatus != dinners.GroundingPending {
t.Fatalf("renamed = %+v, want still pending", d)
}
if row, err := db.New(e.st.Reader).GetDinner(t.Context(), c.ID); err != nil || row.RecipeUrl.Valid {
t.Fatalf("renamed row = %+v (err %v), want no recipe_url", row, err)
}
e.start(t)
row := e.waitGrounding(t, c.ID)
if row.GroundingStatus != dinners.GroundingGrounded {
t.Fatalf("dinner = %+v, want grounded for the new name", row)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != stew || rec.PhotoID != row.PhotoID || row.Method.String != "oven" || row.MinutesTotal.Int64 != 150 {
t.Errorf("dinner = %+v recipe %s, want grounded in %s, not the old link %s", row, rec.SourceUrl, stew, curryA)
}
if got := e.search.Queries(); !slices.Equal(got, []string{"Beef stew"}) {
t.Errorf("searched %q, want only the new name", got)
}
if row.RecipeUrl.Valid {
t.Errorf("recipe_url = %q, want the old link dropped", row.RecipeUrl.String)
}
if _, err := db.New(e.st.Reader).GetRecipeBySourceURL(t.Context(), curryA); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("old link's recipe lookup err = %v, want it never grounded (sql.ErrNoRows)", err)
}
}
// queuedJob reads the payload of queued KindGroundDinner job id.
func (e *env) queuedJob(t *testing.T, id string) dinners.GroundJob {
t.Helper()
var payload string
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT payload FROM jobs WHERE id = ?`, id).Scan(&payload); err != nil {
t.Fatal(err)
}
var p dinners.GroundJob
if err := json.Unmarshal([]byte(payload), &p); err != nil {
t.Fatal(err)
}
return p
}
// TestRenameBeforeJobStartsSkipsStaleJob: a Regular renamed before its
// first job starts is grounded only by the job its rename enqueued. The
// first job, though it would read the new name, does no work and writes
// nothing, so its failure cannot leave the dinner "Needs a recipe" while
// the rename's job finds a recipe.
func TestRenameBeforeJobStartsSkipsStaleJob(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
first := e.queuedJob(t, *c.GroundingJobID)
if first.DinnerID != c.ID || first.Revision != e.dinner(t, c.ID).GroundingRevision {
t.Fatalf("first job = %+v, want dinner %s at its revision", first, c.ID)
}
if _, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
t.Fatal(err)
}
renamed := e.dinner(t, c.ID)
if renamed.GroundingRevision != first.Revision+1 {
t.Fatalf("revision after rename = %d, want %d", renamed.GroundingRevision, first.Revision+1)
}
// The first job would fail if it searched.
e.search.Err = recipes.ErrSearchRateLimited
status, err := e.svc.RunGroundJob(t.Context(), first)
if err != nil || status != dinners.GroundingPending {
t.Fatalf("first job = %q, %v; want pending, no error", status, err)
}
if q := e.search.Queries(); len(q) != 0 {
t.Errorf("first job searched %q, want no work for a stale revision", q)
}
if row := e.dinner(t, c.ID); row != renamed {
t.Fatalf("first job wrote the dinner: %+v, was %+v", row, renamed)
}
e.search.Err = nil
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
status, err = e.svc.RunGroundJob(t.Context(), dinners.GroundJob{DinnerID: c.ID, Revision: renamed.GroundingRevision})
if err != nil || status != dinners.GroundingGrounded {
t.Fatalf("rename's job = %q, %v; want grounded", status, err)
}
row := e.dinner(t, c.ID)
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
if err != nil {
t.Fatal(err)
}
if row.GroundingStatus != dinners.GroundingGrounded || rec.SourceUrl != stew {
t.Errorf("dinner = %+v recipe %s, want grounded in %s", row, rec.SourceUrl, stew)
}
// A stale job finishing last changes nothing either.
if status, err := e.svc.RunGroundJob(t.Context(), first); err != nil || status != dinners.GroundingGrounded {
t.Errorf("first job run last = %q, %v; want grounded reported, no error", status, err)
}
if after := e.dinner(t, c.ID); after != row {
t.Errorf("stale job changed the grounded dinner: %+v, was %+v", after, row)
}
}
// TestEditWhilePendingIsKept: a method or minutes a member saves while a
// Regular is grounding is kept when grounding succeeds; what the member
// did not set comes from the recipe.
func TestEditWhilePendingIsKept(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.search.Gate = make(chan struct{})
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
e.waitQuery(t, "Chicken curry")
if _, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Method: ptr("grill"), MinutesTotal: ptr(15)}, member); err != nil {
t.Fatal(err)
}
close(e.search.Gate)
row := e.waitGrounding(t, c.ID)
if row.GroundingStatus != dinners.GroundingGrounded || !row.RecipeID.Valid || !row.PhotoID.Valid {
t.Fatalf("dinner = %+v, want grounded", row)
}
if row.Method.String != "grill" || row.MinutesTotal.Int64 != 15 || row.Difficulty.String != "easy" {
t.Errorf("dinner = %s, %d minutes, %s; want the member's grill and 15 minutes, the recipe's easy",
row.Method.String, row.MinutesTotal.Int64, row.Difficulty.String)
}
}
// TestArchiveWhilePendingStopsGrounding: a pending Regular archived before
// its job starts is not searched for, and one archived while its job runs
// is stopped there, with no page fetched or model called; neither ends up
// grounded.
func TestArchiveWhilePendingStopsGrounding(t *testing.T) {
t.Parallel()
t.Run("before the job starts", func(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
if _, err := e.svc.Archive(t.Context(), c.ID, member); err != nil {
t.Fatal(err)
}
e.start(t)
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v, want succeeded with nothing to do", job)
}
if q := e.search.Queries(); len(q) != 0 {
t.Errorf("searched %q for an archived dinner", q)
}
if row := e.dinner(t, c.ID); row.Status != dinners.StatusArchived || row.GroundingStatus == dinners.GroundingGrounded || row.RecipeID.Valid {
t.Errorf("dinner = %+v, want archived and not grounded", row)
}
})
t.Run("while the job runs", func(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.search.Gate = make(chan struct{})
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
e.waitQuery(t, "Chicken curry")
if _, err := e.svc.Archive(t.Context(), c.ID, member); err != nil {
t.Fatal(err)
}
// The job ends while its search is still held: archiving stopped it.
job := e.waitJob(t, *c.GroundingJobID)
close(e.search.Gate)
if job.Status != jobs.StatusSucceeded || string(job.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"pending"}` {
t.Fatalf("job = %+v (result %s), want succeeded leaving the dinner pending", job, job.Result)
}
if got := e.pages.Fetched(); len(got) != 0 {
t.Errorf("fetched %q for an archived dinner", got)
}
if got := len(e.model.Requests()); got != 0 {
t.Errorf("model called %d times for an archived dinner", got)
}
if row := e.dinner(t, c.ID); row.Status != dinners.StatusArchived || row.GroundingStatus == dinners.GroundingGrounded || row.RecipeID.Valid {
t.Errorf("dinner = %+v, want archived and not grounded", row)
}
})
}
// TestRenameWhilePendingEnqueueFailure: a pending Regular renamed when the
// job for its new name cannot be enqueued is failed, not left pending.
func TestRenameWhilePendingEnqueueFailure(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.ExecContext(t.Context(),
`CREATE TRIGGER refuse_jobs BEFORE INSERT ON jobs BEGIN SELECT RAISE(ABORT, 'no jobs'); END`); err != nil {
t.Fatal(err)
}
d, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member)
if err != nil {
t.Fatal(err)
}
if d.Name != "Beef stew" || d.GroundingStatus != dinners.GroundingFailed ||
d.GroundingError == nil || *d.GroundingError != dinners.ReasonGroundingError {
t.Fatalf("renamed = %+v, want Beef stew failed (grounding_error)", d)
}
}
// waitQuery waits until the fake search has been asked query.
func (e *env) waitQuery(t *testing.T, query string) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for !slices.Contains(e.search.Queries(), query) {
if time.Now().After(deadline) {
t.Fatalf("search never asked %q; asked %q", query, e.search.Queries())
}
time.Sleep(5 * time.Millisecond)
}
}
// waitGrounding waits for dinner id to be no longer pending and returns it.
func (e *env) waitGrounding(t *testing.T, id string) db.Dinner {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
row := e.dinner(t, id)
if row.GroundingStatus != dinners.GroundingPending {
return row
}
if time.Now().After(deadline) {
t.Fatalf("dinner %s still pending", id)
}
time.Sleep(5 * time.Millisecond)
}
}
// failedDinner creates a Regular whose background grounding fails.
func failedDinner(t *testing.T, e *env, name string) string {
t.Helper()
@@ -721,62 +378,6 @@ func TestFixViaCandidate(t *testing.T) {
}
}
// TestFixReplacesPendingLink: fixing a Regular added from a link that held
// no recipe drops that link, so it no longer blocks adding the link again,
// while the link of the recipe chosen does.
func TestFixReplacesPendingLink(t *testing.T) {
t.Parallel()
const lost = "https://gone.example/recipes/lamb-tagine"
tests := []struct {
name string
fix dinners.Fix
// linkTaken is the recipe link the fixed dinner now has, if any.
linkTaken string
}{
{"candidate", dinners.Fix{CandidateID: ptr(curryB)}, curryB},
{"manual", dinners.Fix{Manual: &dinners.ManualRecipe{Ingredients: []string{"1 lamb"}, Minutes: ptr(90), Method: ptr("oven")}}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Lamb roast"), RecipeURL: ptr(lost)}, member)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusFailed {
t.Fatalf("job = %+v, want failed", job)
}
// Renamed, so only the link could repeat it.
if _, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Weeknight dinner")}, member); err != nil {
t.Fatal(err)
}
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
if _, err := e.svc.Ground(t.Context(), c.ID, tt.fix, member); err != nil {
t.Fatal(err)
}
row, err := db.New(e.st.Reader).GetDinner(t.Context(), c.ID)
if err != nil {
t.Fatal(err)
}
if row.RecipeUrl.Valid {
t.Errorf("recipe_url = %q after the fix, want the unusable link dropped", row.RecipeUrl.String)
}
if _, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Lentil soup"), RecipeURL: ptr(lost)}, member); err != nil {
t.Errorf("adding the dropped link again: %v, want added", err)
}
if tt.linkTaken != "" {
_, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Curry night"), RecipeURL: ptr(tt.linkTaken)}, member)
var dup *dinners.DuplicateError
if !errors.As(err, &dup) || dup.Existing.ID != c.ID {
t.Errorf("adding the fixed dinner's recipe link: err = %v, want a duplicate of %s", err, c.ID)
}
}
})
}
}
func TestFixViaRecipeURL(t *testing.T) {
t.Parallel()
e := newEnv(t)
@@ -952,126 +553,6 @@ func TestRestartFailsInterruptedGrounding(t *testing.T) {
}
}
// TestRestartKeepsArchivedPendingDinner: a pending Regular archived while
// its job runs stays pending, its job succeeded; the boot cleanup leaves it
// as it is rather than failing it as interrupted.
func TestRestartKeepsArchivedPendingDinner(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.search.Gate = make(chan struct{})
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
e.waitQuery(t, "Chicken curry")
if _, err := e.svc.Archive(t.Context(), c.ID, member); err != nil {
t.Fatal(err)
}
job := e.waitJob(t, *c.GroundingJobID)
close(e.search.Gate)
if job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v, want succeeded", job)
}
// The database is now as the next boot finds it.
n, err := e.svc.FailInterruptedGroundings(t.Context())
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("failed %d dinners, want 0", n)
}
if row := e.dinner(t, c.ID); row.Status != dinners.StatusArchived ||
row.GroundingStatus != dinners.GroundingPending || row.GroundingError.Valid {
t.Errorf("dinner = %s, %s (%s), want archived and pending with no error",
row.Status, row.GroundingStatus, row.GroundingError.String)
}
}
// TestUpgradeKeepsLegacyQueuedGrounding: a pending Regular whose
// ground_dinner job was queued before migration 0010, so its payload has
// no revision, is not failed as interrupted at the first boot after the
// upgrade; its job still grounds it. The upgrade starts either from 0009
// or from a database that has already applied 0010.
func TestUpgradeKeepsLegacyQueuedGrounding(t *testing.T) {
t.Parallel()
for name, below := range map[string]string{
"from 0009": "0010",
"from 0010": "0011",
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
upgradeWithLegacyQueuedGrounding(t, below)
})
}
}
// upgradeWithLegacyQueuedGrounding migrates a database to every migration
// named before below, adds a pending Regular with a queued ground_dinner
// job whose payload has no revision, then boots on the latest migrations.
func upgradeWithLegacyQueuedGrounding(t *testing.T, below string) {
ctx := t.Context()
dataDir := t.TempDir()
st := openStore(t, dataDir)
before := fstest.MapFS{}
files, err := fs.Glob(migrations.FS, "*.sql")
if err != nil {
t.Fatal(err)
}
for _, name := range files {
if name >= below {
continue
}
body, err := fs.ReadFile(migrations.FS, name)
if err != nil {
t.Fatal(err)
}
before[name] = &fstest.MapFile{Data: body}
}
if err := store.Migrate(ctx, st.Writer, before); err != nil {
t.Fatal(err)
}
now := store.Now().String()
if _, err := st.Writer.ExecContext(ctx, `INSERT INTO dinners
(id, name, normalized_name, status, origin, is_favorite, grounding_status, updated_by, created_at, updated_at)
VALUES ('dinner-1', 'Chicken curry', 'chicken curry', 'regular', 'regular', 0, 'pending', ?, ?, ?)`,
member, now, now); err != nil {
t.Fatal(err)
}
if _, err := st.Writer.ExecContext(ctx, `INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
VALUES ('job-1', ?, 'queued', '{"dinner_id":"dinner-1"}', ?, ?)`,
dinners.KindGroundDinner, now, now); err != nil {
t.Fatal(err)
}
// Boot: migrate, start the runner, then fail interrupted groundings. The
// search is held, so the job cannot finish before the cleanup runs.
e := newUnstartedEnvOn(t, st, dataDir)
e.search.Gate = make(chan struct{})
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
if p := e.queuedJob(t, "job-1"); p != (dinners.GroundJob{DinnerID: "dinner-1", Revision: 0}) {
t.Errorf("migrated payload = %+v, want dinner-1 at revision 0", p)
}
e.start(t)
n, err := e.svc.FailInterruptedGroundings(ctx)
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("failed %d dinners, want 0", n)
}
if row := e.dinner(t, "dinner-1"); row.GroundingStatus != dinners.GroundingPending {
t.Fatalf("dinner = %s (%s), want still pending", row.GroundingStatus, row.GroundingError.String)
}
close(e.search.Gate)
if job := e.waitJob(t, "job-1"); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v", job)
}
if row := e.dinner(t, "dinner-1"); row.GroundingStatus != dinners.GroundingGrounded {
t.Errorf("dinner = %s, want grounded", row.GroundingStatus)
}
}
// TestCreateEnqueueFailureReturnsFailedDinner: a dinner saved when its job
// cannot be enqueued is returned, with its id, failed and no job id.
func TestCreateEnqueueFailureReturnsFailedDinner(t *testing.T) {
@@ -1112,204 +593,3 @@ func TestGroundTimesOut(t *testing.T) {
t.Errorf("grounding_status = %s, want failed as before", row.GroundingStatus)
}
}
// TestFixInFlightWhenDinnerChanges: a link still grounding when its dinner
// is renamed, archived or grounded some other way writes nothing; the
// change made meanwhile is kept.
func TestFixInFlightWhenDinnerChanges(t *testing.T) {
t.Parallel()
tests := []struct {
name string
change func(t *testing.T, e *env, id string)
want error
// kept checks the dinner shows the change, not the link's recipe.
kept func(row db.Dinner) bool
}{
{"renamed", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Chicken curry")}, member); err != nil {
t.Fatal(err)
}
}, dinners.ErrGroundConflict, func(row db.Dinner) bool {
return row.Name == "Chicken curry" && row.GroundingStatus == dinners.GroundingFailed && !row.RecipeID.Valid
}},
{"renamed to the same normalized name", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Easy Grandma's stew")}, member); err != nil {
t.Fatal(err)
}
}, dinners.ErrGroundConflict, func(row db.Dinner) bool {
return row.Name == "Easy Grandma's stew" && row.GroundingStatus == dinners.GroundingFailed && !row.RecipeID.Valid
}},
{"archived", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Archive(t.Context(), id, member); err != nil {
t.Fatal(err)
}
}, dinners.ErrArchived, func(row db.Dinner) bool {
return row.Status == dinners.StatusArchived && row.GroundingStatus == dinners.GroundingFailed && !row.RecipeID.Valid
}},
{"grounded by hand", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Ground(t.Context(), id, dinners.Fix{Manual: &dinners.ManualRecipe{
Ingredients: []string{"1 lb beef"}, Minutes: ptr(240), Method: ptr("crockpot"),
}}, member); err != nil {
t.Fatal(err)
}
}, dinners.ErrGroundConflict, func(row db.Dinner) bool {
return row.GroundingStatus == dinners.GroundingManual && row.Method.String == "crockpot"
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Grandma's stew")
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
e.pages.Gate = make(chan struct{})
done := make(chan error, 1)
go func() {
_, err := e.svc.Ground(context.Background(), id, dinners.Fix{RecipeURL: ptr(stew)}, member)
done <- err
}()
e.waitFetched(t, stew)
tt.change(t, e, id)
close(e.pages.Gate)
if err := <-done; !errors.Is(err, tt.want) {
t.Fatalf("err = %v, want %v", err, tt.want)
}
if row := e.dinner(t, id); !tt.kept(row) {
t.Errorf("dinner = %+v, want the %s change kept and the link's recipe not written", row, tt.name)
}
})
}
}
// TestFixInFlightKeepsMemberEdits: a method, minutes or difficulty a
// member sets while a link is grounding is kept; a field they left alone
// takes the link's recipe, even one already set before grounding began.
func TestFixInFlightKeepsMemberEdits(t *testing.T) {
t.Parallel()
// Every field is set before grounding, then one is changed in flight.
before := dinners.Patch{Method: ptr("stovetop"), MinutesTotal: ptr(45), Difficulty: ptr("easy")}
tests := []struct {
name string
edit dinners.Patch
field string
}{
{"method", dinners.Patch{Method: ptr("grill")}, "method"},
{"minutes", dinners.Patch{MinutesTotal: ptr(20)}, "minutes_total"},
{"difficulty", dinners.Patch{Difficulty: ptr("medium")}, "difficulty"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Grandma's stew")
if _, err := e.svc.Update(t.Context(), id, before, member); err != nil {
t.Fatal(err)
}
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
e.pages.Gate = make(chan struct{})
done := make(chan error, 1)
go func() {
_, err := e.svc.Ground(context.Background(), id, dinners.Fix{RecipeURL: ptr(stew)}, member)
done <- err
}()
e.waitFetched(t, stew)
if _, err := e.svc.Update(t.Context(), id, tt.edit, member); err != nil {
t.Fatal(err)
}
close(e.pages.Gate)
if err := <-done; err != nil {
t.Fatal(err)
}
row := e.dinner(t, id)
if row.GroundingStatus != dinners.GroundingGrounded || !row.RecipeID.Valid {
t.Fatalf("dinner = %+v, want grounded in the link's recipe", row)
}
var recipe fields
if err := e.st.Reader.QueryRowContext(t.Context(),
`SELECT method, minutes_total, difficulty FROM recipes WHERE id = ?`, row.RecipeID.String,
).Scan(&recipe.method, &recipe.minutes, &recipe.difficulty); err != nil {
t.Fatal(err)
}
old, edited := patchFields(before), patchFields(tt.edit)
want := recipe
switch tt.field {
case "method":
want.method = edited.method
case "minutes_total":
want.minutes = edited.minutes
case "difficulty":
want.difficulty = edited.difficulty
}
if recipe.method == old.method || recipe.minutes == old.minutes || recipe.difficulty == old.difficulty {
t.Fatalf("recipe %+v shares a value with the dinner before grounding %+v", recipe, old)
}
got := fields{row.Method.String, row.MinutesTotal.Int64, row.Difficulty.String}
if got != want {
t.Errorf("method, minutes, difficulty = %+v, want %+v: the %s edit kept, the rest from the recipe", got, want, tt.field)
}
})
}
}
// fields are a dinner's or recipe's method, minutes_total and difficulty.
type fields struct {
method string
minutes int64
difficulty string
}
// patchFields returns the fields p sets, zero where it sets none.
func patchFields(p dinners.Patch) fields {
var f fields
if p.Method != nil {
f.method = *p.Method
}
if p.MinutesTotal != nil {
f.minutes = int64(*p.MinutesTotal)
}
if p.Difficulty != nil {
f.difficulty = *p.Difficulty
}
return f
}
// TestFixArchivedDinner: an archived dinner cannot be grounded by hand.
func TestFixArchivedDinner(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Grandma's stew")
if _, err := e.svc.Archive(t.Context(), id, member); err != nil {
t.Fatal(err)
}
before := e.dinner(t, id)
for _, f := range []dinners.Fix{
{RecipeURL: ptr(stew)},
{Manual: &dinners.ManualRecipe{Ingredients: []string{"1 lb beef"}, Minutes: ptr(240), Method: ptr("crockpot")}},
} {
if _, err := e.svc.Ground(t.Context(), id, f, member); !errors.Is(err, dinners.ErrArchived) {
t.Errorf("fix %+v: err = %v, want ErrArchived", f, err)
}
}
if got := e.pages.Fetched(); len(got) != 0 {
t.Errorf("fetched %q for an archived dinner", got)
}
if after := e.dinner(t, id); after != before {
t.Errorf("archived dinner changed: %+v, was %+v", after, before)
}
}
// waitFetched waits until rawURL has been fetched.
func (e *env) waitFetched(t *testing.T, rawURL string) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for !slices.Contains(e.pages.Fetched(), rawURL) {
if time.Now().After(deadline) {
t.Fatalf("%s never fetched", rawURL)
}
time.Sleep(5 * time.Millisecond)
}
}
+2 -18
View File
@@ -71,15 +71,10 @@ type SettingsPatch struct {
// 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.
// never undone, so false is invalid.
OnboardingCompleted *bool
}
// MinRegulars is how many Regulars the household must have to complete
// onboarding.
const MinRegulars = 3
// Problem is one invalid field of a SettingsPatch, named by its JSON key.
type Problem struct {
Field string
@@ -172,8 +167,7 @@ func (s *SettingsService) Get(ctx context.Context) (Settings, error) {
// Update validates patch, applies it, records updatedBy (the signed-in
// member's subject, which must not be empty) and publishes the new settings
// to every subscriber. An invalid patch is a *ValidationError and changes
// nothing, as does completing onboarding with fewer than MinRegulars
// Regulars.
// nothing.
func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updatedBy string) (Settings, error) {
if updatedBy == "" {
return Settings{}, errors.New("household: update settings: no member to record as updated_by")
@@ -220,16 +214,6 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
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)
+2 -58
View File
@@ -3,13 +3,10 @@ package household
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"sync"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
@@ -37,34 +34,12 @@ func migratedStore(t *testing.T, dir string) *store.Store {
// seededService returns a settings service over a migrated temp database
// seeded with zone America/New_York.
func seededService(t *testing.T) *SettingsService {
t.Helper()
svc, _ := seededServiceStore(t)
return svc
}
// seededServiceStore is seededService with its store.
func seededServiceStore(t *testing.T) (*SettingsService, *store.Store) {
t.Helper()
st := migratedStore(t, t.TempDir())
if err := SeedSettings(context.Background(), st, "America/New_York"); err != nil {
t.Fatal(err)
}
return NewSettingsService(st), st
}
// addDinners stores n dinners with status.
func addDinners(t *testing.T, st *store.Store, status string, n int) {
t.Helper()
for range n {
id := store.NewID()
now := store.Now().String()
if _, err := db.New(st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
ID: id, Name: "Dinner " + id, NormalizedName: "dinner " + strings.ToLower(id),
Status: status, Origin: "regular", GroundingStatus: "pending", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
}
return NewSettingsService(st)
}
func ptr[T any](v T) *T { return &v }
@@ -245,8 +220,7 @@ func TestUpdateAppliesPatchAndRecordsUpdatedBy(t *testing.T) {
func TestUpdateCompletesOnboardingOnce(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc, st := seededServiceStore(t)
addDinners(t, st, "regular", MinRegulars)
svc := seededService(t)
got, err := svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "sam")
if err != nil {
@@ -276,36 +250,6 @@ func TestUpdateCompletesOnboardingOnce(t *testing.T) {
}
}
// 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()
+34 -105
View File
@@ -54,30 +54,14 @@ const (
CodeUnknownKind = "unknown_kind"
)
// Retry backoff. A worker whose claim or final job write fails with a
// database error retries after retryMin, doubling up to retryMax, so a job
// queued when the error clears is claimed without waiting for a wake token,
// and a job the worker has finished is never left running.
// Claim retry backoff. A worker whose claim fails with a database error
// retries after claimRetryMin, doubling up to claimRetryMax, so a job queued
// when the error clears is claimed without waiting for a wake token.
const (
retryMin = 50 * time.Millisecond
retryMax = 5 * time.Second
claimRetryMin = 50 * time.Millisecond
claimRetryMax = 5 * time.Second
)
// FinalWriteTimeout bounds each attempt at a job's final write, and at a
// handler's own last write, so a writer connection that stays busy cannot
// hold a stopping worker, and with it shutdown, open.
const FinalWriteTimeout = 5 * time.Second
// FinalWriteContext returns the context for one attempt at a final write
// of a job: it is not cancelled with ctx, so a job that finishes during
// shutdown is still recorded, but it ends after FinalWriteTimeout. A
// handler makes its own last write, the one recording what the job did,
// with it too, and returns the error when the attempt times out, so its
// worker can stop; the next Start fails the job as interrupted.
func FinalWriteContext(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(ctx), FinalWriteTimeout)
}
// codePattern is the form of every failure code: lower-case snake case.
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
@@ -275,21 +259,6 @@ func (k *Kind[P]) Enqueue(ctx context.Context, payload P) (string, error) {
return k.r.enqueue(ctx, k.name, data)
}
// EnqueueTx stores a queued job with payload in tx and returns its id, so
// the job exists exactly when what else tx writes does. Once tx has
// committed, call Wake so an idle worker runs it.
func (k *Kind[P]) EnqueueTx(ctx context.Context, tx *sql.Tx, payload P) (string, error) {
data, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err)
}
return createJob(ctx, db.New(tx), k.name, data)
}
// Wake tells an idle worker to look for a job, as Enqueue does itself:
// call it after committing the transaction a job was enqueued in.
func (k *Kind[P]) Wake() { k.r.wakeOne() }
// Enqueue stores a queued job of kind with its JSON payload and returns its
// id, as Kind.Enqueue does for a kind known at compile time. It returns
// ErrUnknownKind for a kind with no registered handler and
@@ -311,20 +280,9 @@ func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessa
// enqueue stores a queued job of kind with its encoded payload and wakes a
// worker.
func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (string, error) {
id, err := createJob(ctx, db.New(r.st.Writer), kind, payload)
if err != nil {
return "", err
}
r.wakeOne()
return id, nil
}
// createJob stores a queued job of kind with its encoded payload through q
// and returns its id.
func createJob(ctx context.Context, q *db.Queries, kind string, payload []byte) (string, error) {
id := store.NewID()
now := store.Now().String()
if err := q.CreateJob(ctx, db.CreateJobParams{
if err := db.New(r.st.Writer).CreateJob(ctx, db.CreateJobParams{
ID: id,
Kind: kind,
Payload: string(payload),
@@ -333,15 +291,11 @@ func createJob(ctx context.Context, q *db.Queries, kind string, payload []byte)
}); err != nil {
return "", fmt.Errorf("jobs: enqueue %s: %w", kind, err)
}
return id, nil
}
// wakeOne gives an idle worker a token to look for a queued job.
func (r *Runner) wakeOne() {
select {
case r.wake <- struct{}{}:
default: // every worker already has a token and will look again
}
return id, nil
}
// Start fails every job a previous process left running with
@@ -403,16 +357,16 @@ func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
// already queued is not stranded by a transient error.
func (r *Runner) work(ctx context.Context) {
defer r.wg.Done()
backoff := retryMin
backoff := claimRetryMin
for ctx.Err() == nil {
job, err := r.claim(ctx, sql.NullString{String: store.Now().String(), Valid: true})
switch {
case err == nil:
backoff = retryMin
backoff = claimRetryMin
r.run(ctx, job)
continue
case errors.Is(err, sql.ErrNoRows):
backoff = retryMin
backoff = claimRetryMin
select {
case <-r.wake:
case <-ctx.Done():
@@ -428,7 +382,7 @@ func (r *Runner) work(ctx context.Context) {
case <-ctx.Done():
timer.Stop()
}
backoff = min(2*backoff, retryMax)
backoff = min(2*backoff, claimRetryMax)
}
}
@@ -436,13 +390,14 @@ func (r *Runner) work(ctx context.Context) {
// ctx has been cancelled, so a handler that finishes during shutdown is
// recorded.
func (r *Runner) run(ctx context.Context, job db.Job) {
write := context.WithoutCancel(ctx)
logger := r.logger.With("job_id", job.ID, "kind", job.Kind)
q := db.New(r.st.Writer)
h, ok := r.handlers[job.Kind] // read-only once started
if !ok {
logger.ErrorContext(ctx, "job failed", "code", CodeUnknownKind, "reason", "no handler registered for the kind")
r.fail(ctx, logger, job.ID, CodeUnknownKind)
r.fail(write, logger, job.ID, CodeUnknownKind)
return
}
@@ -464,7 +419,7 @@ func (r *Runner) run(ctx context.Context, job db.Job) {
result, code, reason := h.run(ctx, job.Payload, publish)
if code != "" {
if ctx.Err() != nil {
logger.WarnContext(context.WithoutCancel(ctx), "job interrupted by shutdown; it fails at the next start")
logger.WarnContext(write, "job interrupted by shutdown; it fails at the next start")
return
}
level := slog.LevelInfo
@@ -472,61 +427,35 @@ func (r *Runner) run(ctx context.Context, job db.Job) {
level = slog.LevelError
}
logger.Log(ctx, level, "job failed", "code", code, "reason", reason)
r.fail(ctx, logger, job.ID, code)
r.fail(write, logger, job.ID, code)
return
}
r.record(ctx, logger, "record job success", func(write context.Context) (int64, error) {
return q.SucceedJob(write, db.SucceedJobParams{
Result: sql.NullString{String: string(result), Valid: true},
Now: sql.NullString{String: store.Now().String(), Valid: true},
ID: job.ID,
})
n, err := q.SucceedJob(write, db.SucceedJobParams{
Result: sql.NullString{String: string(result), Valid: true},
Now: sql.NullString{String: store.Now().String(), Valid: true},
ID: job.ID,
})
if err == nil && n != 1 {
err = errors.New("job is not running")
}
if err != nil {
logger.ErrorContext(write, "record job success", "error", err)
}
}
// fail records the job as failed with code. The record errors it logs come
// from the database, never from a handler.
func (r *Runner) fail(ctx context.Context, logger *slog.Logger, id, code string) {
r.record(ctx, logger, "record job failure", func(write context.Context) (int64, error) {
return db.New(r.st.Writer).FailJob(write, db.FailJobParams{
Error: sql.NullString{String: code, Valid: true},
Now: sql.NullString{String: store.Now().String(), Valid: true},
ID: id,
})
n, err := db.New(r.st.Writer).FailJob(ctx, db.FailJobParams{
Error: sql.NullString{String: code, Valid: true},
Now: sql.NullString{String: store.Now().String(), Valid: true},
ID: id,
})
}
// record makes a job's final write, which must change the one running job.
// A database error is retried with backoff until the write lands, so a
// worker never moves on from a job it has finished while leaving it
// running. Each attempt is made with FinalWriteContext, so even when ctx
// has been cancelled, and is bounded by FinalWriteTimeout; once ctx is
// cancelled, a failed or timed-out attempt is not retried and the job
// stays running, so the next Start fails it as interrupted.
func (r *Runner) record(ctx context.Context, logger *slog.Logger, what string, write func(context.Context) (int64, error)) {
backoff := retryMin
for {
attempt, cancel := FinalWriteContext(ctx)
n, err := write(attempt)
cancel()
if err == nil {
if n != 1 {
logger.ErrorContext(ctx, what, "error", "job is not running")
}
return
}
if ctx.Err() != nil {
logger.ErrorContext(context.WithoutCancel(ctx), what+" failed during shutdown; it fails at the next start", "error", err)
return
}
logger.ErrorContext(ctx, what+" failed; retrying", "error", err, "retry_in", backoff)
timer := time.NewTimer(backoff)
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
}
backoff = min(2*backoff, retryMax)
if err == nil && n != 1 {
err = errors.New("job is not running")
}
if err != nil {
logger.ErrorContext(ctx, "record job failure", "error", err)
}
}
-132
View File
@@ -187,46 +187,6 @@ func TestJobSucceeds(t *testing.T) {
}
}
func TestEnqueueTxStoresJobOnlyWithItsTransaction(t *testing.T) {
t.Parallel()
st := migratedStore(t)
r := newRunner(t, st, 1)
count := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
return countResult{Done: p.Steps}, nil
})
start(t, r)
ctx := context.Background()
var rolledBack string
refused := errors.New("refused")
if err := st.WithTx(ctx, func(tx *sql.Tx) error {
var err error
rolledBack, err = count.EnqueueTx(ctx, tx, countPayload{Steps: 1})
if err != nil {
return err
}
return refused
}); !errors.Is(err, refused) {
t.Fatalf("WithTx = %v, want the refusal", err)
}
if _, err := r.Get(ctx, rolledBack); !errors.Is(err, ErrNotFound) {
t.Errorf("job of a rolled-back transaction: Get = %v, want ErrNotFound", err)
}
var id string
if err := st.WithTx(ctx, func(tx *sql.Tx) error {
var err error
id, err = count.EnqueueTx(ctx, tx, countPayload{Steps: 2})
return err
}); err != nil {
t.Fatal(err)
}
count.Wake()
if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded || string(job.Result) != `{"done":2}` {
t.Errorf("job = %+v (result %s), want succeeded with {\"done\":2}", job, job.Result)
}
}
func TestJobFailsWithFailureCode(t *testing.T) {
t.Parallel()
st := migratedStore(t)
@@ -609,98 +569,6 @@ func TestClaimErrorIsRetried(t *testing.T) {
}
}
func TestFinalWriteErrorIsRetried(t *testing.T) {
st := migratedStore(t)
logs := &logBuffer{}
r := newRunnerLogging(t, st, 1, logs)
k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
return countResult{Done: p.Steps}, nil
})
if _, err := st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_end BEFORE UPDATE OF status ON jobs WHEN NEW.status IN ('succeeded', 'failed') BEGIN SELECT RAISE(ABORT, 'end refused'); END`); err != nil {
t.Fatal(err)
}
start(t, r)
id := enqueue(t, k, countPayload{Steps: 3})
deadline := time.Now().Add(5 * time.Second)
for !strings.Contains(logs.String(), "record job success") {
if time.Now().After(deadline) {
t.Fatal("job success never recorded")
}
time.Sleep(5 * time.Millisecond)
}
if _, err := st.Writer.ExecContext(t.Context(), `DROP TRIGGER refuse_end`); err != nil {
t.Fatal(err)
}
job := waitFor(t, r, id, finished)
if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
}
if out := logs.String(); !strings.Contains(out, "record job success failed; retrying") {
t.Fatalf("final write error not logged as retried: %s", out)
}
}
func TestBlockedFinalWriteDoesNotHoldShutdown(t *testing.T) {
st := migratedStore(t)
r := newRunner(t, st, 1)
started, finish := make(chan struct{}), make(chan struct{})
k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
close(started)
<-finish
return countResult{Done: p.Steps}, nil
})
stop := start(t, r)
id := enqueue(t, k, countPayload{Steps: 3})
<-started
// Hold the only writer connection, so the job's final write waits for it.
conn, err := st.Writer.Conn(t.Context())
if err != nil {
t.Fatal(err)
}
waits := st.Writer.Stats().WaitCount
close(finish)
deadline := time.Now().Add(5 * time.Second)
for st.Writer.Stats().WaitCount == waits {
if time.Now().After(deadline) {
t.Fatal("final write never waited for the writer")
}
time.Sleep(5 * time.Millisecond)
}
stopped := make(chan struct{})
go func() {
stop()
close(stopped)
}()
// The final write's attempt ends after 5 seconds; well past that, the
// worker is waiting for the writer with no bound.
select {
case <-stopped:
case <-time.After(15 * time.Second):
conn.Close()
<-stopped
t.Fatal("worker waited for a held writer through shutdown")
}
if err := conn.Close(); err != nil {
t.Fatal(err)
}
if job := waitFor(t, r, id, func(Job) bool { return true }); job.Status != StatusRunning {
t.Fatalf("after shutdown job = %+v, want left running", job)
}
second := newRunner(t, st, 1)
register(t, second, "count", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
return countResult{}, nil
})
start(t, second)
if job := waitFor(t, second, id, finished); job.Status != StatusFailed || job.Error == nil || *job.Error != CodeInterrupted {
t.Errorf("interrupted job = %+v, want failed with %q", job, CodeInterrupted)
}
}
func TestRunnerEnqueueByKindName(t *testing.T) {
t.Parallel()
st := migratedStore(t)
+1 -1
View File
@@ -266,7 +266,7 @@ func (g *Grounder) GroundURL(ctx context.Context, rawURL string) (Grounded, erro
}
gr, reason, err := g.ground(ctx, rawURL, r)
if err != nil {
return Grounded{}, fmt.Errorf("recipes: ground pasted link: %w", err)
return Grounded{}, fmt.Errorf("recipes: ground %s: %w", rawURL, err)
}
if reason != nil {
return ungroundable(reason)
@@ -66,15 +66,9 @@ func (s *Search) Queries() []string {
type Pages struct {
Bodies map[string][]byte
Gate chan struct{}
mu sync.Mutex
fetched []string
}
func (p *Pages) Fetch(ctx context.Context, rawURL string) (recipes.Page, error) {
p.mu.Lock()
p.fetched = append(p.fetched, rawURL)
p.mu.Unlock()
if p.Gate != nil {
select {
case <-p.Gate:
@@ -89,13 +83,6 @@ func (p *Pages) Fetch(ctx context.Context, rawURL string) (recipes.Page, error)
return recipes.Page{URL: rawURL, ContentType: "text/html", Body: body}, nil
}
// Fetched returns every URL fetched, in order.
func (p *Pages) Fetched() []string {
p.mu.Lock()
defer p.mu.Unlock()
return append([]string(nil), p.fetched...)
}
// Images is a recipes.ImageFetcher serving JPEGs by URL; any other URL is
// a 404.
type Images struct {
+1 -1
View File
@@ -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), newTestDinners(t), newTestSuggest(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)
+2 -17
View File
@@ -19,8 +19,7 @@ const maxDinnerBody = 16 << 10
// manual recipe of dinners.MaxManualLines lines of each kind.
const maxGroundBody = 512 << 10
// dinnerCreate is the POST /dinners body: a name, with or without a
// recipe_url.
// dinnerCreate is the POST /dinners body: a name or a recipe_url.
type dinnerCreate struct {
Name *string `json:"name"`
RecipeURL *string `json:"recipe_url"`
@@ -201,11 +200,7 @@ func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (a
// writeDinnerError maps a dinners.Service error to its API error. A page
// that cannot ground the dinner is 422 ungroundable; recipe search not set
// up is 409 not_configured; a search or model failure is 502; grounding by
// hand that runs past dinners.GroundTimeout is 504 ground_timeout; grounding
// an archived dinner is 409 dinner_archived and one changed while grounding
// 409 ground_conflict. Any other grounding failure, by candidate, link or
// manual recipe, wraps dinners.ErrGroundFailed and is 500, logged by reason
// and dinner id only.
// hand that runs past dinners.GroundTimeout is 504 ground_timeout.
func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var invalid *dinners.ValidationError
var dup *dinners.DuplicateError
@@ -225,16 +220,6 @@ func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logge
case errors.Is(err, recipes.ErrNormalizeFailed):
logger.WarnContext(r.Context(), action, "reason", "normalize_recipe failed")
writeError(w, r, logger, http.StatusBadGateway, "ai_failed", "the AI provider failed to read the recipe")
case errors.Is(err, dinners.ErrGroundFailed):
// The error may quote the pasted link, which can carry a personal
// token, and dependency errors, so only the reason and dinner are
// logged.
logger.ErrorContext(r.Context(), action, "reason", "ground_failed", "dinner_id", chi.URLParam(r, "id"))
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
case errors.Is(err, dinners.ErrArchived):
writeError(w, r, logger, http.StatusConflict, "dinner_archived", "the dinner is archived")
case errors.Is(err, dinners.ErrGroundConflict):
writeError(w, r, logger, http.StatusConflict, "ground_conflict", "the dinner changed while its recipe was grounding; reload and try again")
case errors.Is(err, dinners.ErrGroundTimeout):
logger.WarnContext(r.Context(), action, "reason", "ground_timeout")
writeError(w, r, logger, http.StatusGatewayTimeout, "ground_timeout", "grounding the recipe took too long; try again")
+15 -90
View File
@@ -86,7 +86,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
t.Fatal(err)
}
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
svc, newTestSuggest(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
@@ -159,6 +159,7 @@ func TestCreateDinnerByName(t *testing.T) {
"origin": "regular",
"grounding_status": "pending",
"is_favorite": false,
"recipe_url": nil,
"photo_url": nil,
"method": nil,
"minutes_total": nil,
@@ -169,9 +170,6 @@ func TestCreateDinnerByName(t *testing.T) {
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
}
}
if got, ok := body["recipe_url"]; ok {
t.Errorf("recipe_url = %#v, want no recipe_url in a dinner response", got)
}
var normalized, updatedBy string
if err := st.Reader.QueryRow(`SELECT normalized_name, updated_by FROM dinners WHERE id = ?`, body["id"]).Scan(&normalized, &updatedBy); err != nil {
t.Fatal(err)
@@ -186,12 +184,13 @@ func TestCreateDinnerByURL(t *testing.T) {
router, _ := newDinnersRouter(t)
link := "https://www.example.com/recipes/12345/beef-and-broccoli-stir-fry/"
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Stir fry night","recipe_url":"`+link+`"}`)
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"`+link+`"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
}
for k, v := range map[string]any{
"name": "Stir fry night",
"name": "Beef and broccoli stir fry",
"recipe_url": link,
"status": "regular",
"origin": "regular",
"grounding_status": "pending",
@@ -201,27 +200,24 @@ func TestCreateDinnerByURL(t *testing.T) {
t.Errorf("%s = %#v, want %#v", k, body[k], v)
}
}
// The link stays on the server for grounding; responses never carry it.
if got, ok := body["recipe_url"]; ok {
t.Errorf("recipe_url = %#v, want no recipe_url in a dinner response", got)
}
// The same link again is a duplicate even though its name would differ.
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Something else entirely"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("unrelated name = %d: %s", rec.Code, rec.Body)
}
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Beef and broccoli","recipe_url":"`+link+`"}`)
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"`+link+`"}`)
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
t.Errorf("same link again = %d %v, want 409 duplicate_dinner", rec.Code, body)
}
for _, tc := range []struct{ name, body string }{
{"plain http", `{"name":"Chili","recipe_url":"http://example.com/recipes/chili"}`},
{"not a URL", `{"name":"Chili","recipe_url":"chili"}`},
{"credentials", `{"name":"Chili","recipe_url":"https://me:pw@example.com/recipes/chili"}`},
{"link without a name", `{"recipe_url":"https://example.com/recipes/chili"}`},
{"filler name with a link", `{"name":"The Best Easy Recipe","recipe_url":"https://example.com/recipes/chili"}`},
{"plain http", `{"recipe_url":"http://example.com/recipes/chili"}`},
{"not a URL", `{"recipe_url":"chili"}`},
{"credentials", `{"recipe_url":"https://me:pw@example.com/recipes/chili"}`},
{"no name in path", `{"recipe_url":"https://example.com/"}`},
{"numeric slug", `{"recipe_url":"https://example.com/recipe/98765"}`},
{"both fields", `{"name":"Chili","recipe_url":"https://example.com/recipes/chili"}`},
{"neither field", `{}`},
} {
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, tc.body)
@@ -231,77 +227,6 @@ func TestCreateDinnerByURL(t *testing.T) {
}
}
// TestDinnerResponsesHideRecipeURL: a pasted link may carry a personal
// token, so neither the create response nor a later list read shows any
// part of it, while the stored link still awaits grounding.
func TestDinnerResponsesHideRecipeURL(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
const link = "https://c.example/recipes/stew?access_token=personal-token-abc123"
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Stew","recipe_url":"`+link+`"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
}
created := rec.Body.String()
rec, _ = callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET status=regular = %d: %s", rec.Code, rec.Body)
}
for _, resp := range []struct{ name, body string }{{"create", created}, {"list", rec.Body.String()}} {
for _, secret := range []string{link, "c.example", "access_token", "personal-token-abc123", "recipe_url"} {
if strings.Contains(resp.body, secret) {
t.Errorf("%s response contains %q: %s", resp.name, secret, resp.body)
}
}
}
var stored string
if err := st.Reader.QueryRow(`SELECT recipe_url FROM dinners WHERE id = ?`, body["id"]).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored != link {
t.Errorf("stored recipe_url = %q, want %q kept for grounding", stored, link)
}
}
// TestDinnerResponsesHideTokenInLinkPath: a personal token can sit
// anywhere in a link's path, even as plain letters, so a dinner added from
// a link is named only by the household and neither the create response, a
// later list read nor the duplicate error for the same link shows any of
// the link.
func TestDinnerResponsesHideTokenInLinkPath(t *testing.T) {
t.Parallel()
router, _ := newDinnersRouter(t)
const link = "https://c.example/recipes/stew-key-abcdefghijklmno"
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Sunday stew","recipe_url":"`+link+`"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
}
if body["name"] != "Sunday stew" {
t.Errorf("name = %v, want Sunday stew", body["name"])
}
created := rec.Body.String()
rec, _ = callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET status=regular = %d: %s", rec.Code, rec.Body)
}
listed := rec.Body.String()
rec, _ = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Beef stew","recipe_url":"`+link+`"}`)
if rec.Code != http.StatusConflict {
t.Fatalf("same link again = %d, want 409: %s", rec.Code, rec.Body)
}
for _, resp := range []struct{ name, body string }{{"create", created}, {"list", listed}, {"duplicate", rec.Body.String()}} {
lower := strings.ToLower(resp.body)
for _, secret := range []string{"c.example", "recipes", "key", "abcdefghijklmno"} {
if strings.Contains(lower, secret) {
t.Errorf("%s response contains %q: %s", resp.name, secret, resp.body)
}
}
}
}
func TestCreateDinnerDuplicate(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
@@ -317,10 +242,10 @@ func TestCreateDinnerDuplicate(t *testing.T) {
t.Errorf("%q: %d %v, want 409 duplicate_dinner", name, rec.Code, body)
}
}
// A name repeating the dish is a duplicate with a link too.
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Chicken tikka masala","recipe_url":"https://example.com/other-curry.html"}`)
// A slug naming the same dish is a duplicate too.
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"https://example.com/easy-chicken-tikka-masala.html"}`)
if rec.Code != http.StatusConflict {
t.Errorf("named duplicate with a link = %d %v, want 409", rec.Code, body)
t.Errorf("URL slug duplicate = %d %v, want 409", rec.Code, body)
}
var n int
-109
View File
@@ -224,115 +224,6 @@ func TestPostGroundViaRecipeURL(t *testing.T) {
}
}
// TestPostGroundViaRecipeURLStorageFailure: a pasted link that fails for a
// reason other than its recipe is a 500 logged by reason and dinner id,
// never with the link, which can carry a personal token, or the dependency
// error.
func TestPostGroundViaRecipeURLStorageFailure(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
const token = "personal-token-abc123"
link := stewPage + "?access_token=" + token
e.pages.Bodies[link] = e.pages.Bodies[stewPage]
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
if _, err := e.st.Writer.ExecContext(t.Context(),
`CREATE TRIGGER refuse_recipes BEFORE INSERT ON recipes BEGIN SELECT RAISE(ABORT, 'disk full'); END`); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+link+`"}`)
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" || strings.Contains(rec.Body.String(), token) {
t.Errorf("POST = %d %s, want 500 internal without the link", rec.Code, rec.Body)
}
logs := e.logs.String()
if strings.Contains(logs, token) || strings.Contains(logs, "c.example") || strings.Contains(logs, "disk full") ||
!strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the link or dependency error", logs)
}
}
// TestPostGroundViaRecipeURLDinnerWriteFailure: a pasted link whose recipe
// is stored but whose dinner cannot be written is a 500 logged by reason
// and dinner id, never with the dependency error.
func TestPostGroundViaRecipeURLDinnerWriteFailure(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
if _, err := e.st.Writer.ExecContext(t.Context(),
`CREATE TRIGGER refuse_grounding BEFORE UPDATE OF recipe_id ON dinners WHEN NEW.recipe_id IS NOT NULL BEGIN SELECT RAISE(ABORT, 'disk full'); END`); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+stewPage+`"}`)
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
t.Errorf("POST = %d %s, want 500 internal", rec.Code, rec.Body)
}
var stored int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM recipes WHERE source_url = ?`, stewPage).Scan(&stored); err != nil || stored != 1 {
t.Fatalf("recipes stored for the link = %d (%v), want 1: the failure must be the dinner write", stored, err)
}
logs := e.logs.String()
if strings.Contains(logs, "disk full") || !strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the dependency error", logs)
}
}
// TestPostGroundUnexpectedFailures: grounding by hand that fails reading
// the dinner, or storing a manual recipe, is a 500 logged by reason and
// dinner id, never with the database error.
func TestPostGroundUnexpectedFailures(t *testing.T) {
t.Parallel()
const (
// Renaming a column every dinner read selects fails the read.
breakRead = `ALTER TABLE dinners RENAME COLUMN cuisine TO cuisine_gone`
// The trigger's message stands in for the database error.
breakStore = `CREATE TRIGGER refuse_recipes BEFORE INSERT ON recipes BEGIN SELECT RAISE(ABORT, 'disk full'); END`
manual = `{"manual":{"ingredients":["1 lb beef"],"steps":["Brown."],"minutes":240,"method":"crockpot"}}`
)
for _, tc := range []struct {
name, sabotage, fix, leak string
}{
{"link read", breakRead, `{"recipe_url":"` + stewPage + `"}`, "cuisine"},
{"manual read", breakRead, manual, "cuisine"},
{"manual store", breakStore, manual, "disk full"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
if _, err := e.st.Writer.ExecContext(t.Context(), tc.sabotage); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", tc.fix)
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
t.Errorf("POST = %d %s, want 500 internal", rec.Code, rec.Body)
}
logs := e.logs.String()
if strings.Contains(logs, tc.leak) || !strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the database error", logs)
}
})
}
}
// TestPostGroundArchived: grounding an archived dinner by hand is a 409.
func TestPostGroundArchived(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Family chili"}`)
if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
`{"manual":{"ingredients":["1 lb beef"],"minutes":240,"method":"crockpot"}}`)
if rec.Code != http.StatusConflict || errorCode(body) != "dinner_archived" {
t.Errorf("POST = %d %v, want 409 dinner_archived", rec.Code, body)
}
}
func TestPostGroundViaManual(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
+1 -1
View File
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
t.Fatal(err)
}
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), 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)
}
+1 -1
View File
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), 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)
+1 -1
View File
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
}
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), 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)
}
+1 -1
View File
@@ -22,7 +22,7 @@ const (
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), 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)
+2 -12
View File
@@ -17,7 +17,6 @@ import (
"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/suggest"
)
// reservedPrefixes are owned by the API router and are never answered with the
@@ -39,14 +38,12 @@ type RouteMounter interface {
// 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;
// suggestSvc serves POST /api/v1/suggestions/batches, GET
// /api/v1/suggestions/deck and POST /api/v1/suggestions/{id}/accept and
// /decline; runner serves
// runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
// GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, 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")
}
@@ -59,9 +56,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
if dinnerSvc == nil {
return nil, errors.New("server: nil dinners service")
}
if suggestSvc == nil {
return nil, errors.New("server: nil suggestions service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
@@ -112,10 +106,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
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("/suggestions/batches", postSuggestionBatch(logger, suggestSvc))
apiV1.Get("/suggestions/deck", getSuggestionDeck(logger, suggestSvc))
apiV1.Post("/suggestions/{id}/accept", postSuggestionDecision(logger, suggestSvc, true))
apiV1.Post("/suggestions/{id}/decline", postSuggestionDecision(logger, suggestSvc, false))
apiV1.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
media.Get("/photos/{name}", getPhoto(logger, photos))
+7 -41
View File
@@ -5,7 +5,6 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"io"
"log/slog"
"maps"
@@ -19,7 +18,6 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
@@ -27,7 +25,6 @@ import (
"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/internal/suggest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
@@ -49,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), newTestDinners(t), newTestSuggest(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)
}
@@ -163,28 +160,6 @@ func newTestDinners(t *testing.T) *dinners.Service {
return svc
}
// newTestSuggest returns a suggestions service over a migrated temp
// database, with its own unstarted job runner, a grounder that finds
// nothing and a suggester that always fails.
func newTestSuggest(t *testing.T) *suggest.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{})
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
return skills.SideQuests{}, errors.New("no model in this test")
}
svc, err := suggest.NewService(st, g, fail, 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()
@@ -216,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, newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil AI settings service")
}
}
@@ -225,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), newTestDinners(t), newTestSuggest(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")
}
}
@@ -234,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), newTestDinners(t), newTestSuggest(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")
}
}
@@ -243,7 +218,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), 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")
}
}
@@ -252,25 +227,16 @@ func TestNewRouterRequiresDinners(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil dinners service")
}
}
func TestNewRouterRequiresSuggestions(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil suggestions service")
}
}
func TestNewRouterRequiresJobRunner(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), 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")
}
}
+2 -48
View File
@@ -3,7 +3,6 @@ package server
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
@@ -14,7 +13,6 @@ import (
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
@@ -117,30 +115,10 @@ 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,
rec, body := serve(t, newTestRouter(t, "dev"), 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)
@@ -150,30 +128,6 @@ func TestPatchHouseholdSettingsCompletesOnboarding(t *testing.T) {
}
}
// 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()
@@ -277,7 +231,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), 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)
}
-82
View File
@@ -1,82 +0,0 @@
package server
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
)
type deck struct {
Suggestions []suggest.Suggestion `json:"suggestions"`
}
// postSuggestionBatch starts a manual suggestion batch and answers 202 with
// its batch_id and the job_id that GET /jobs/{id} reports on, without
// waiting for the job. When a batch is already pending or running it
// answers 202 with that batch instead of starting another.
func postSuggestionBatch(logger *slog.Logger, svc *suggest.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
b, err := svc.StartBatch(r.Context(), suggest.TriggerManual)
if err != nil {
writeSuggestionError(w, r, logger, "start suggestion batch", err)
return
}
writeJSON(w, r, logger, http.StatusAccepted, b)
}
}
// getSuggestionDeck answers GET /suggestions/deck with the pending
// suggestions, oldest first.
func getSuggestionDeck(logger *slog.Logger, svc *suggest.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
list, err := svc.Deck(r.Context())
if err != nil {
writeSuggestionError(w, r, logger, "list suggestion deck", err)
return
}
writeJSON(w, r, logger, http.StatusOK, deck{Suggestions: list})
}
}
// postSuggestionDecision records Try it (accept) or Pass (decline) on a
// suggestion, recording the session's actor as its dinner's updated_by.
func postSuggestionDecision(logger *slog.Logger, svc *suggest.Service, accept bool) http.HandlerFunc {
decide, action := svc.Decline, "decline suggestion"
if accept {
decide, action = svc.Accept, "accept suggestion"
}
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
d, err := decide(r.Context(), chi.URLParam(r, "id"), actor.Subject)
if err != nil {
writeSuggestionError(w, r, logger, action, err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// writeSuggestionError maps a suggest.Service error to its API error: an
// unknown suggestion is 404, and a decision on one already decided the
// other way, or whose dinner left the deck, is 409.
func writeSuggestionError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var decided *suggest.DecidedError
switch {
case errors.As(err, &decided):
writeError(w, r, logger, http.StatusConflict, "suggestion_decided", decided.Error())
case errors.Is(err, suggest.ErrDinnerNotSuggested):
writeError(w, r, logger, http.StatusConflict, "dinner_not_suggested", err.Error())
case errors.Is(err, suggest.ErrNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "suggestion not found")
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
-189
View File
@@ -1,189 +0,0 @@
package server
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"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/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"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/internal/suggest"
)
const suggestionsPath = "/api/v1/suggestions"
// newSuggestionsRouter returns a router whose suggestions service has an
// unstarted job runner, so batches stay pending, and a deck seeded with
// pending suggestions of dinners named names, in order, whose ids it
// returns.
func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.Store, []string) {
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{})
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
return skills.SideQuests{}, errors.New("no model in this test")
}
svc, err := suggest.NewService(st, g, fail, runner, logger)
if err != nil {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
q := db.New(st.Writer)
ctx := t.Context()
now := store.Now().String()
batch, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
if err != nil {
t.Fatal(err)
}
if _, err := q.FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: batch.ID}); err != nil {
t.Fatal(err)
}
var ids []string
for i, name := range names {
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "suggested", Origin: "side_quest",
Difficulty: sql.NullString{String: "easy", Valid: true}, GroundingStatus: "grounded", CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
id := store.NewID()
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
ID: id, DinnerID: d.ID, BatchID: batch.ID, Name: name, Reason: fmt.Sprintf("Reason %d", i), CitedRegularIds: "[]",
CreatedAt: store.Now().String(), UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
ids = append(ids, id)
}
return h, st, ids
}
func TestSuggestionDeck(t *testing.T) {
t.Parallel()
h, _, ids := newSuggestionsRouter(t, "Pho", "Ramen")
rec, body := callDinners(t, h, http.MethodGet, suggestionsPath+"/deck", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET deck = %d: %s", rec.Code, rec.Body)
}
list, _ := body["suggestions"].([]any)
if len(list) != 2 {
t.Fatalf("deck = %v, want 2 suggestions", body)
}
for i, name := range []string{"Pho", "Ramen"} {
s := list[i].(map[string]any)
if s["id"] != ids[i] || s["name"] != name || s["difficulty"] != "easy" || s["reason"] != fmt.Sprintf("Reason %d", i) || s["status"] != "pending" {
t.Errorf("deck[%d] = %v, want %s", i, s, name)
}
if _, ok := s["photo_url"]; !ok {
t.Errorf("deck[%d] has no photo_url key", i)
}
}
}
func TestSuggestionDecisions(t *testing.T) {
t.Parallel()
h, _, ids := newSuggestionsRouter(t, "Pho", "Ramen", "Laksa", "Bibimbap", "Katsu", "Tonkatsu", "Udon")
for _, tc := range []struct {
target string
status int
code string
sugStatus string
dinnerStatus string
}{
{ids[0] + "/accept", http.StatusOK, "", "accepted", "quest_log"},
{ids[0] + "/accept", http.StatusOK, "", "accepted", "quest_log"},
{ids[0] + "/decline", http.StatusConflict, "suggestion_decided", "", ""},
{ids[1] + "/decline", http.StatusOK, "", "declined", "archived"},
{ids[1] + "/decline", http.StatusOK, "", "declined", "archived"},
{ids[1] + "/accept", http.StatusConflict, "suggestion_decided", "", ""},
{"nope/accept", http.StatusNotFound, "not_found", "", ""},
{"nope/decline", http.StatusNotFound, "not_found", "", ""},
} {
rec, body := callDinners(t, h, http.MethodPost, suggestionsPath+"/"+tc.target, "")
if rec.Code != tc.status {
t.Fatalf("POST %s = %d, want %d: %s", tc.target, rec.Code, tc.status, rec.Body)
}
if tc.code != "" {
if errorCode(body) != tc.code {
t.Errorf("POST %s error = %v, want %s", tc.target, body, tc.code)
}
continue
}
s, _ := body["suggestion"].(map[string]any)
if s["status"] != tc.sugStatus || s["dinner_status"] != tc.dinnerStatus {
t.Errorf("POST %s = %v, want %s with dinner %s", tc.target, body, tc.sugStatus, tc.dinnerStatus)
}
if body["replenish_job_id"] != nil {
t.Errorf("POST %s replenished with 5 left: %v", tc.target, body["replenish_job_id"])
}
}
// A third decision leaves 4: a replenish batch starts.
rec, body := callDinners(t, h, http.MethodPost, suggestionsPath+"/"+ids[2]+"/decline", "")
if rec.Code != http.StatusOK {
t.Fatalf("decline = %d: %s", rec.Code, rec.Body)
}
if id, _ := body["replenish_job_id"].(string); id == "" {
t.Errorf("decline leaving 4 = %v, want a replenish_job_id", body)
}
}
func TestPostSuggestionBatch(t *testing.T) {
t.Parallel()
h, st, _ := newSuggestionsRouter(t)
rec, first := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "")
if rec.Code != http.StatusAccepted {
t.Fatalf("POST batches = %d: %s", rec.Code, rec.Body)
}
if first["batch_id"] == nil || first["job_id"] == nil || first["trigger"] != "manual" || first["status"] != "pending" {
t.Errorf("POST batches = %v, want a pending manual batch with its job", first)
}
var kind string
if err := st.Reader.QueryRow(`SELECT kind FROM jobs WHERE id = ?`, first["job_id"]).Scan(&kind); err != nil || kind != suggest.KindSuggestionBatch {
t.Errorf("job kind = %q (%v), want %s", kind, err, suggest.KindSuggestionBatch)
}
rec, again := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "")
if rec.Code != http.StatusAccepted || again["batch_id"] != first["batch_id"] || again["job_id"] != first["job_id"] {
t.Errorf("second POST batches = %d %v, want the pending batch %v", rec.Code, again, first)
}
}
func TestSuggestionWritesNeedCSRF(t *testing.T) {
t.Parallel()
h, _, ids := newSuggestionsRouter(t, "Pho")
for _, target := range []string{"/batches", "/" + ids[0] + "/accept", "/" + ids[0] + "/decline"} {
req := httptest.NewRequest(http.MethodPost, suggestionsPath+target, nil)
req.AddCookie(validSessionCookie)
rec, body := serve(t, h, req)
if rec.Code != http.StatusForbidden || errorCode(body) != "csrf" {
t.Errorf("POST %s without CSRF = %d %v, want 403 csrf", target, rec.Code, body)
}
}
}
+4 -80
View File
@@ -3,7 +3,6 @@ package store
import (
"context"
"io/fs"
"reflect"
"slices"
"strings"
"testing"
@@ -176,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name" {
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,0012_suggestion_name", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error", got)
}
}
@@ -197,8 +196,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name" {
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,0012_suggestion_name", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error", got)
}
if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created")
@@ -558,78 +557,3 @@ func TestMigrateAIRunsStatus(t *testing.T) {
})
}
}
// TestMigrateSuggestionName upgrades a database at 0011 holding a pending
// and a declined suggestion and checks 0012 keeps both, gives each its
// dinner's name, and refuses a suggestion with no name.
func TestMigrateSuggestionName(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
before := fstest.MapFS{}
for _, name := range []string{
"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql", "0003_jobs_status.sql",
"0004_ai_provider_presets.sql", "0005_ai_provider_xai.sql", "0006_ai_runs_status.sql", "0007_recipe_grounding.sql",
"0008_dinner_recipe_url.sql", "0009_dinner_grounding_error.sql", "0010_dinner_grounding_revision.sql",
"0011_ground_dinner_job_revision.sql",
} {
body, err := fs.ReadFile(migrations.FS, name)
if err != nil {
t.Fatal(err)
}
before[name] = &fstest.MapFile{Data: body}
}
if err := Migrate(ctx, s.Writer, before); err != nil {
t.Fatal(err)
}
for _, stmt := range []string{
`INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at)
VALUES ('dinner-1', 'Pho', 'pho', 'suggested', 'side_quest', 0, 'grounded', 'c', 'u'),
('dinner-2', 'Ramen', 'ramen', 'archived', 'side_quest', 0, 'grounded', 'c', 'u')`,
`INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
VALUES ('batch-1', 'manual', 'done', 'c', 'u')`,
`INSERT INTO suggestions (id, dinner_id, batch_id, reason, cited_regular_ids, status, decided_at, created_at, updated_at)
VALUES ('sugg-1', 'dinner-1', 'batch-1', 'why 1', '[]', 'pending', NULL, 'c1', 'u1'),
('sugg-2', 'dinner-2', 'batch-1', 'why 2', '["dinner-1"]', 'declined', 'd2', 'c2', 'u2')`,
} {
if _, err := s.Writer.Exec(stmt); err != nil {
t.Fatalf("seed: %v", err)
}
}
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
rows, err := s.Reader.Query(`SELECT id, dinner_id, batch_id, name, reason, cited_regular_ids, status, COALESCE(decided_at, ''), created_at, updated_at
FROM suggestions ORDER BY id`)
if err != nil {
t.Fatal(err)
}
var got [][]string
for rows.Next() {
r := make([]string, 10)
if err := rows.Scan(&r[0], &r[1], &r[2], &r[3], &r[4], &r[5], &r[6], &r[7], &r[8], &r[9]); err != nil {
t.Fatal(err)
}
got = append(got, r)
}
if err := rows.Close(); err != nil {
t.Fatal(err)
}
want := [][]string{
{"sugg-1", "dinner-1", "batch-1", "Pho", "why 1", "[]", "pending", "", "c1", "u1"},
{"sugg-2", "dinner-2", "batch-1", "Ramen", "why 2", `["dinner-1"]`, "declined", "d2", "c2", "u2"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("suggestions after 0012 =\n %v\nwant\n %v", got, want)
}
if _, err := s.Writer.Exec(`UPDATE suggestions SET name = '' WHERE id = 'sugg-1'`); err == nil || !strings.Contains(err.Error(), "CHECK constraint failed") {
t.Errorf("empty suggestion name: error %v, want CHECK constraint failure", err)
}
if _, err := s.Writer.Exec(`UPDATE suggestions SET dinner_id = 'nope' WHERE id = 'sugg-1'`); err == nil || !strings.Contains(err.Error(), "FOREIGN KEY constraint failed") {
t.Errorf("unknown dinner: error %v, want FOREIGN KEY constraint failure", err)
}
if tableExists(t, s, "suggestions_copy") {
t.Error("suggestions_copy left behind")
}
}
+1 -1
View File
@@ -95,7 +95,7 @@ func TestRepositoryInsertReadUpdate(t *testing.T) {
parents: []string{"dinners", "suggestion_batches"},
row: repoRow{"suggestions", map[string]any{
"id": "sugg-1", "dinner_id": "dinner-1", "batch_id": "batch-1",
"name": "White Chicken Chili", "reason": "You make chili often.", "cited_regular_ids": `["dinner-1"]`,
"reason": "You make chili often.", "cited_regular_ids": `["dinner-1"]`,
"status": "pending", "decided_at": nil,
"created_at": repoTime, "updated_at": repoTime,
}},
-1
View File
@@ -110,7 +110,6 @@ func testRow(table string, i int) map[string]any {
case "suggestions":
r["dinner_id"] = rowID("dinners", 0)
r["batch_id"] = rowID("suggestion_batches", 0)
r["name"] = fmt.Sprintf("Side quest %d", i)
r["reason"] = "Like your chili."
r["cited_regular_ids"] = "[]"
r["status"] = "pending"
-309
View File
@@ -1,309 +0,0 @@
package suggest
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"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/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/store"
)
// BatchJob is the KindSuggestionBatch payload: the batch to run.
type BatchJob struct {
BatchID string `json:"batch_id"`
}
// Batch stages, as the job's result reports them.
const (
StageSuggesting = "suggesting"
StageGrounding = "grounding"
StageDone = "done"
)
// batchResult is the KindSuggestionBatch result, published as the batch
// goes: its stage, the suggestions stored so far, and how many Side
// quests were dropped, by suggest_side_quests' rules, because no recipe
// grounds them, or because they repeat a dinner added meanwhile.
type batchResult struct {
BatchID string `json:"batch_id"`
Stage string `json:"stage"`
SuggestionIDs []string `json:"suggestion_ids"`
Dropped int `json:"dropped"`
}
func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// runBatch runs batch p.BatchID: it asks suggest_side_quests for BatchSize
// Side quests given the household context, the deck and every declined
// name, then grounds them in the model's order, at most the number still
// wanted at a time, until BatchSize are kept or none are left. A Side quest
// no recipe grounds is dropped; any other grounding failure fails the
// batch, as does suggest_side_quests failing, with no Side quest made up
// instead. Each round's grounded Side quests are stored before the next
// round starts, so the deck fills as the batch goes. The batch ends done
// or failed; Side quests stored before a failure stay in the deck. When
// recording the batch's end fails, so does the job; when it times out at
// shutdown, the job is left running and the batch with it, for the next
// start to fail.
func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) {
n, err := db.New(s.st.Writer).StartSuggestionBatch(ctx, db.StartSuggestionBatchParams{
UpdatedAt: store.Now().String(),
ID: p.BatchID,
})
if err != nil {
return batchResult{}, fmt.Errorf("suggest: start batch %s: %w", p.BatchID, err)
}
if n == 0 {
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID)
}
res, err := s.fill(ctx, p.BatchID, publish)
if err == nil {
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
return batchResult{}, err
}
return res, nil
}
finishErr := s.finishBatch(ctx, p.BatchID, BatchFailed)
if ctx.Err() != nil {
return batchResult{}, errors.Join(ctx.Err(), finishErr)
}
var failure *jobs.Failure
if errors.As(err, &failure) {
// Only the code is logged: the error may quote a model's or
// search provider's response.
s.logger.InfoContext(ctx, "suggestion batch failed", "batch_id", p.BatchID, "reason", failure.Code)
}
return batchResult{}, errors.Join(err, finishErr)
}
// fill does runBatch's work, returning a *jobs.Failure for a failure the
// household may see.
func (s *Service) fill(ctx context.Context, batchID string, publish func(batchResult) error) (batchResult, error) {
res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
if err := publish(res); err != nil {
return batchResult{}, err
}
in, err := s.request(ctx)
if err != nil {
return batchResult{}, err
}
out, err := s.suggest(ctx, batchID, in)
if err != nil {
if ctx.Err() != nil {
return batchResult{}, ctx.Err()
}
return batchResult{}, &jobs.Failure{Code: ReasonAIFailed}
}
res.Stage = StageGrounding
res.Dropped = len(out.Dropped)
if err := publish(res); err != nil {
return batchResult{}, err
}
left := out.Suggestions
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 {
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))]
left = left[len(round):]
grounded, err := s.ground(ctx, round)
if err != nil {
return batchResult{}, err
}
res.Dropped += len(round) - len(grounded)
ids, err := s.store(ctx, batchID, grounded)
if err != nil {
return batchResult{}, err
}
res.Dropped += len(grounded) - len(ids)
res.SuggestionIDs = append(res.SuggestionIDs, ids...)
if err := publish(res); err != nil {
return batchResult{}, err
}
}
res.Stage = StageDone
return res, nil
}
// request reads what suggest_side_quests is given: the household context,
// the deck's names and every declined name. A household with no Regulars
// fails with ReasonNoRegulars.
func (s *Service) request(ctx context.Context) (skills.SideQuestsRequest, error) {
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
if err != nil {
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: %w", err)
}
if len(hctx.Regulars) == 0 {
return skills.SideQuestsRequest{}, &jobs.Failure{Code: ReasonNoRegulars}
}
q := db.New(s.st.Reader)
deck, err := q.ListDeck(ctx)
if err != nil {
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list deck: %w", err)
}
pending := []string{}
for _, row := range deck {
pending = append(pending, row.Dinner.Name)
}
declined, err := q.ListDeclinedSuggestionNames(ctx)
if err != nil {
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list declined names: %w", err)
}
return skills.SideQuestsRequest{Context: hctx, Needed: BatchSize, Pending: pending, Declined: declined}, nil
}
// groundedQuest is a Side quest with the recipe that grounds it.
type groundedQuest struct {
quest skills.SideQuest
grounded recipes.Grounded
}
// ground grounds quests by their search queries and returns those a
// recipe grounds, in order. A Side quest no recipe grounds is left out;
// any other failure fails the batch.
func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]groundedQuest, error) {
reqs := make([]recipes.GroundRequest, len(quests))
for i, q := range quests {
reqs[i] = recipes.GroundRequest{DishName: q.Name, Query: q.SearchQuery}
}
results := s.grounder.GroundMany(ctx, reqs)
if err := ctx.Err(); err != nil {
return nil, err
}
var out []groundedQuest
for i, r := range results {
var ungroundable *recipes.ErrUngroundable
switch {
case r.Err == nil:
out = append(out, groundedQuest{quest: quests[i], grounded: r.Grounded})
case errors.As(r.Err, &ungroundable):
case errors.Is(r.Err, recipes.ErrSearchNotConfigured):
return nil, &jobs.Failure{Code: ReasonSearchNotConfigured}
case errors.Is(r.Err, recipes.ErrSearchFailed):
return nil, &jobs.Failure{Code: ReasonSearchFailed}
case errors.Is(r.Err, recipes.ErrNormalizeFailed):
return nil, &jobs.Failure{Code: ReasonAIFailed}
default:
return nil, fmt.Errorf("suggest: ground %q: %w", quests[i].Name, r.Err)
}
}
return out, nil
}
// store stores each grounded Side quest of batchID as a dinner with status
// suggested and origin side_quest, grounded in its recipe, and a pending
// suggestion citing its Regulars by id, all in one transaction, and
// returns the suggestion ids. A Side quest is left out when, since
// suggest_side_quests ran, a dinner it repeats was added, a suggestion it
// repeats passed, or a Regular it cites renamed or archived. Declined
// names are read in the same transaction as the store, so a Pass committed
// before it is never missed.
func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, error) {
ids := []string{}
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
existing, err := q.ListUnarchivedDinners(ctx)
if err != nil {
return fmt.Errorf("suggest: list dinners for dedupe: %w", err)
}
names, err := q.ListDeclinedSuggestionNames(ctx)
if err != nil {
return fmt.Errorf("suggest: list declined names for dedupe: %w", err)
}
regulars := map[string][]string{}
for _, row := range existing {
names = append(names, row.Dinner.Name)
if row.Dinner.Status == dinners.StatusRegular {
regulars[row.Dinner.Name] = append(regulars[row.Dinner.Name], row.Dinner.ID)
}
}
for _, gq := range quests {
cited, ok := citedIDs(gq.quest.CitedRegulars, regulars)
if !ok || repeats(gq.quest.Name, names) {
s.logger.InfoContext(ctx, "suggested dinner dropped: changed while grounding", "batch_id", batchID)
continue
}
citedJSON, err := json.Marshal(cited)
if err != nil {
return fmt.Errorf("suggest: encode cited Regulars: %w", err)
}
now := store.Now().String()
gr := gq.grounded
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(),
Name: gq.quest.Name,
NormalizedName: harness.Normalize(gq.quest.Name),
Status: dinners.StatusSuggested,
Origin: "side_quest",
Method: sql.NullString{String: gr.Method, Valid: true},
MinutesTotal: nullInt(gr.MinutesTotal),
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
Cuisine: sql.NullString{String: gq.quest.Cuisine, Valid: true},
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
GroundingStatus: dinners.GroundingGrounded,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return fmt.Errorf("suggest: create dinner: %w", err)
}
id := store.NewID()
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
ID: id,
DinnerID: d.ID,
BatchID: batchID,
Name: d.Name,
Reason: gq.quest.Reason,
CitedRegularIds: string(citedJSON),
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("suggest: create suggestion: %w", err)
}
names = append(names, d.Name)
ids = append(ids, id)
}
return nil
})
if err != nil {
return nil, err
}
return ids, nil
}
// citedIDs returns the ids of the Regulars named cited, false when one
// names no Regular or more than one.
func citedIDs(cited []string, regulars map[string][]string) ([]string, bool) {
ids := make([]string, len(cited))
for i, name := range cited {
if len(regulars[name]) != 1 {
return nil, false
}
ids[i] = regulars[name][0]
}
return ids, true
}
// repeats reports whether name is harness.Similar to any of names.
func repeats(name string, names []string) bool {
for _, n := range names {
if harness.Similar(name, n) {
return true
}
}
return false
}
func nullInt(v *int) sql.NullInt64 {
if v == nil {
return sql.NullInt64{}
}
return sql.NullInt64{Int64: int64(*v), Valid: true}
}
-419
View File
@@ -1,419 +0,0 @@
// Package suggest keeps the household's Side quest deck: batches of new
// dinners the AI suggests, each grounded in a recipe, and the household's
// Try it / Pass decision on each. A batch runs as a job: suggest_side_quests
// proposes Side quests, each is grounded with the recipe grounding pipeline
// (dropping those no recipe grounds), and every one kept is stored as a
// dinner with status suggested and origin side_quest and a pending
// suggestion. Only one batch is pending or running at a time.
package suggest
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"slices"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"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/store"
)
// KindSuggestionBatch is the job that runs a suggestion batch.
const KindSuggestionBatch = "suggestion_batch"
const (
// BatchSize is how many Side quests a batch keeps at most.
BatchSize = 5
// ReplenishBelow is the deck size under which a decision starts a
// replenish batch.
ReplenishBelow = 5
// ContextBudget is the character budget of the household context
// suggest_side_quests is given (see harness.BuildContext).
ContextBudget = 16000
)
// Batch triggers (the suggestion_batches.trigger column).
const (
TriggerScheduled = "scheduled"
TriggerManual = "manual"
TriggerReplenish = "replenish"
)
// Batch statuses (the suggestion_batches.status column).
const (
BatchPending = "pending"
BatchRunning = "running"
BatchDone = "done"
BatchFailed = "failed"
)
// Suggestion statuses (the suggestions.status column).
const (
StatusPending = "pending"
StatusAccepted = "accepted"
StatusDeclined = "declined"
)
// Reasons a batch failed, stored as the KindSuggestionBatch job's failure
// code. Any other failure, such as the database's, is the runner's
// internal_error.
const (
// ReasonNoRegulars: the household has no Regulars for Side quests to
// be based on.
ReasonNoRegulars = "no_regulars"
// ReasonAIFailed: suggest_side_quests, or normalize_recipe while
// grounding, failed; no Side quest is made up instead (D-017).
ReasonAIFailed = "ai_failed"
// ReasonSearchNotConfigured: recipe search is not set up.
ReasonSearchNotConfigured = "search_not_configured"
// ReasonSearchFailed: the search provider failed.
ReasonSearchFailed = "search_failed"
)
// Reasons are every batch failure reason.
var Reasons = []string{ReasonNoRegulars, ReasonAIFailed, ReasonSearchNotConfigured, ReasonSearchFailed}
var triggers = []string{TriggerScheduled, TriggerManual, TriggerReplenish}
// ErrNotFound is returned for an unknown suggestion id.
var ErrNotFound = errors.New("suggestion not found")
// ErrDinnerNotSuggested is a decision on a pending suggestion whose dinner
// was archived or moved out of the deck some other way.
var ErrDinnerNotSuggested = errors.New("the suggestion's dinner is no longer suggested")
// DecidedError is a decision on a suggestion already decided the other
// way.
type DecidedError struct {
// Status is the suggestion's decision: accepted or declined.
Status string
}
func (e *DecidedError) Error() string {
return "the suggestion was already " + e.Status
}
// Suggester runs suggest_side_quests on in; scope names the batch. In
// production it is skills.SuggestSideQuests with the household's
// harness.Config.
type Suggester func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error)
// Service runs suggestion batches and records decisions.
type Service struct {
st *store.Store
grounder *recipes.Grounder
suggest Suggester
logger *slog.Logger
batchJob *jobs.Kind[BatchJob]
}
// NewService returns a service over st that suggests with suggest, grounds
// with grounder and registers the KindSuggestionBatch job on runner, which
// must not have started yet.
func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("suggest: nil store")
case grounder == nil:
return nil, errors.New("suggest: nil grounder")
case suggest == nil:
return nil, errors.New("suggest: nil suggester")
case runner == nil:
return nil, errors.New("suggest: nil job runner")
case logger == nil:
return nil, errors.New("suggest: nil logger")
}
s := &Service{st: st, grounder: grounder, suggest: suggest, logger: logger}
kind, err := jobs.Register(runner, KindSuggestionBatch, s.runBatch, Reasons...)
if err != nil {
return nil, err
}
s.batchJob = kind
return s, nil
}
// Batch is a suggestion batch with the id of the job running it.
type Batch struct {
ID string `json:"batch_id"`
JobID string `json:"job_id"`
Trigger string `json:"trigger"`
Status string `json:"status"`
// Created says whether this call created the batch rather than found
// it already pending or running.
Created bool `json:"-"`
}
// StartBatch creates a batch with trigger and enqueues the
// KindSuggestionBatch job that runs it, unless a batch is already pending
// or running, which it returns instead with Created false.
func (s *Service) StartBatch(ctx context.Context, trigger string) (Batch, error) {
if !slices.Contains(triggers, trigger) {
return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", trigger)
}
var b Batch
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
var err error
b, err = s.startBatch(ctx, tx, trigger)
return err
})
if err != nil {
return Batch{}, err
}
if b.Created {
s.batchJob.Wake()
}
return b, nil
}
// startBatch does StartBatch's work in tx: the batch and the job that runs
// it are committed together, so a batch is never seen without its job. A
// batch still pending or running whose job is no longer queued or running
// is failed first, so a batch whose job ended without recording its end
// (the process stopped, or the write failed) never blocks a later one. The
// caller wakes the job runner once tx has committed and Created is true.
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) {
q := db.New(tx)
n, err := q.FailOrphanedSuggestionBatches(ctx, db.FailOrphanedSuggestionBatchesParams{
UpdatedAt: store.Now().String(),
Kind: KindSuggestionBatch,
})
if err != nil {
return Batch{}, fmt.Errorf("suggest: fail orphaned batches: %w", err)
}
if n > 0 {
s.logger.WarnContext(ctx, "failed suggestion batches whose job ended", "count", n)
}
cur, err := q.GetActiveSuggestionBatch(ctx)
if err == nil {
jobID, err := q.GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: cur.ID})
if err != nil {
return Batch{}, fmt.Errorf("suggest: read job of batch %s: %w", cur.ID, err)
}
return Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return Batch{}, fmt.Errorf("suggest: read active batch: %w", err)
}
now := store.Now().String()
row, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{
ID: store.NewID(),
Trigger: trigger,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return Batch{}, fmt.Errorf("suggest: create batch: %w", err)
}
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID})
if err != nil {
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err)
}
return Batch{ID: row.ID, JobID: jobID, Trigger: row.Trigger, Status: row.Status, Created: true}, nil
}
// finishBatch records running batch id as status, with
// jobs.FinalWriteContext: the write is made even when ctx has been
// cancelled, but a writer that stays busy cannot hold its worker past
// jobs.FinalWriteTimeout. Its job's handler returns any error, so the job
// never ends succeeded with the batch still running; the runner keeps only
// a code, so the error is logged too.
func (s *Service) finishBatch(ctx context.Context, id, status string) error {
write, cancel := jobs.FinalWriteContext(ctx)
defer cancel()
n, err := db.New(s.st.Writer).FinishSuggestionBatch(write, db.FinishSuggestionBatchParams{
Status: status,
UpdatedAt: store.Now().String(),
ID: id,
})
if err == nil && n != 1 {
err = errors.New("batch is not running")
}
if err != nil {
s.logger.ErrorContext(ctx, "record suggestion batch status", "batch_id", id, "status", status, "error", err)
return fmt.Errorf("suggest: record batch %s %s: %w", id, status, err)
}
return nil
}
// Suggestion is a Side quest suggestion as the API shows it, with its
// dinner.
type Suggestion struct {
ID string `json:"id"`
DinnerID string `json:"dinner_id"`
Name string `json:"name"`
PhotoURL *string `json:"photo_url"`
Difficulty *string `json:"difficulty"`
Method *string `json:"method"`
MinutesTotal *int64 `json:"minutes_total"`
Cuisine *string `json:"cuisine"`
// Reason is the "Why this?".
Reason string `json:"reason"`
CitedRegularIDs []string `json:"cited_regular_ids"`
Status string `json:"status"`
DinnerStatus string `json:"dinner_status"`
DecidedAt *string `json:"decided_at"`
CreatedAt string `json:"created_at"`
}
// Deck returns the pending suggestions whose dinner is still suggested,
// oldest first.
func (s *Service) Deck(ctx context.Context) ([]Suggestion, error) {
rows, err := db.New(s.st.Reader).ListDeck(ctx)
if err != nil {
return nil, fmt.Errorf("suggest: list deck: %w", err)
}
out := make([]Suggestion, len(rows))
for i, row := range rows {
out[i], err = fromRow(row.Suggestion, row.Dinner)
if err != nil {
return nil, err
}
}
return out, nil
}
// Decision is a suggestion once decided, with the id of the replenish
// batch's job when the decision started one.
type Decision struct {
Suggestion Suggestion `json:"suggestion"`
ReplenishJobID *string `json:"replenish_job_id"`
}
// Accept records Try it on suggestion id, moving its dinner to the Quest
// log and recording updatedBy on it.
func (s *Service) Accept(ctx context.Context, id, updatedBy string) (Decision, error) {
return s.decide(ctx, id, StatusAccepted, dinners.StatusQuestLog, updatedBy)
}
// Decline records Pass on suggestion id, archiving its dinner and
// recording updatedBy on it. The suggestion keeps the name its dish was
// suggested under, whatever the dinner is later renamed, and every later
// batch reads it as declined, so the dish is never suggested again.
func (s *Service) Decline(ctx context.Context, id, updatedBy string) (Decision, error) {
return s.decide(ctx, id, StatusDeclined, dinners.StatusArchived, updatedBy)
}
// decide records decision on suggestion id and moves its dinner to
// dinnerStatus. Repeating a decision changes nothing and returns the
// suggestion as it is; the opposite decision is a *DecidedError. A
// decision that leaves the deck under ReplenishBelow starts a replenish
// batch unless one is already pending or running, in the decision's own
// transaction: the decision is never recorded without the replenish it
// calls for, and a failure to start the batch records neither.
func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, updatedBy string) (Decision, error) {
if updatedBy == "" {
return Decision{}, errors.New("suggest: decide: no member to record as updated_by")
}
var row db.GetSuggestionRow
var replenish *Batch
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetSuggestion(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("suggest: read suggestion %s: %w", id, err)
}
switch cur.Suggestion.Status {
case decision:
row = cur
return nil
case StatusPending:
default:
return &DecidedError{Status: cur.Suggestion.Status}
}
if cur.Dinner.Status != dinners.StatusSuggested {
return ErrDinnerNotSuggested
}
now := store.Now().String()
if _, err := q.DecideSuggestion(ctx, db.DecideSuggestionParams{
Status: decision,
Now: sql.NullString{String: now, Valid: true},
ID: id,
}); err != nil {
return fmt.Errorf("suggest: decide %s: %w", id, err)
}
if _, err := q.MoveSuggestedDinner(ctx, db.MoveSuggestedDinnerParams{
Status: dinnerStatus,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: now,
ID: cur.Dinner.ID,
}); err != nil {
return fmt.Errorf("suggest: move dinner %s: %w", cur.Dinner.ID, err)
}
row, err = q.GetSuggestion(ctx, id)
if err != nil {
return fmt.Errorf("suggest: read suggestion %s: %w", id, err)
}
left, err := q.CountDeck(ctx)
if err != nil {
return fmt.Errorf("suggest: count deck: %w", err)
}
if left >= ReplenishBelow {
return nil
}
b, err := s.startBatch(ctx, tx, TriggerReplenish)
if err != nil {
return err
}
replenish = &b
return nil
})
if err != nil {
return Decision{}, err
}
created := replenish != nil && replenish.Created
if created {
s.batchJob.Wake()
}
sug, err := fromRow(row.Suggestion, row.Dinner)
if err != nil {
return Decision{}, err
}
d := Decision{Suggestion: sug}
if created {
d.ReplenishJobID = &replenish.JobID
}
return d, nil
}
func fromRow(sg db.Suggestion, d db.Dinner) (Suggestion, error) {
out := Suggestion{
ID: sg.ID,
DinnerID: d.ID,
Name: d.Name,
Difficulty: nullString(d.Difficulty),
Method: nullString(d.Method),
Cuisine: nullString(d.Cuisine),
Reason: sg.Reason,
Status: sg.Status,
DinnerStatus: d.Status,
DecidedAt: nullString(sg.DecidedAt),
CreatedAt: sg.CreatedAt,
}
if err := json.Unmarshal([]byte(sg.CitedRegularIds), &out.CitedRegularIDs); err != nil || out.CitedRegularIDs == nil {
return Suggestion{}, fmt.Errorf("suggest: suggestion %s cited_regular_ids is not a JSON array: %q", sg.ID, sg.CitedRegularIds)
}
if d.MinutesTotal.Valid {
out.MinutesTotal = &d.MinutesTotal.Int64
}
if d.PhotoID.Valid {
u := dinners.PhotoURL(d.PhotoID.String)
out.PhotoURL = &u
}
return out, nil
}
func nullString(s sql.NullString) *string {
if !s.Valid {
return nil
}
return &s.String
}
File diff suppressed because it is too large Load Diff
@@ -1,7 +0,0 @@
-- 0010_dinner_grounding_revision: each ground_dinner job carries the
-- dinner's grounding_revision when it was enqueued, and records its result
-- only while the dinner still has that revision. Renaming a pending dinner
-- raises the revision before enqueuing the job for the new name, so an
-- earlier job — queued or running — can no longer write the dinner.
ALTER TABLE dinners ADD COLUMN grounding_revision INTEGER NOT NULL DEFAULT 0;
@@ -1,11 +0,0 @@
-- 0011_ground_dinner_job_revision: every dinner starts at grounding
-- revision 0 (0010), so a ground_dinner job enqueued before 0010, whose
-- payload has only dinner_id, is for revision 0. Recording it lets the boot
-- cleanup, which matches a pending dinner to its job by revision, see a job
-- still queued for its dinner. This is a migration of its own, not part of
-- 0010, so it also runs on a database that has already applied 0010.
UPDATE jobs
SET payload = json_set(payload, '$.revision', 0)
WHERE kind = 'ground_dinner'
AND json_type(payload, '$.revision') IS NULL;
-37
View File
@@ -1,37 +0,0 @@
-- 0012_suggestion_name: a suggestion keeps the name its dish was suggested
-- under. Its dinner can be renamed, even once archived by a Pass, so the
-- declined names every later batch refuses are read from here, never from
-- the dinner. A suggestion stored before this takes its dinner's name now.
-- SQLite cannot add a NOT NULL column without a default, so the table is
-- rebuilt as 0005 does: copy the rows aside, drop the table, create it
-- again, copy the rows back and drop the copy. Nothing references
-- suggestions.
CREATE TABLE suggestions_copy AS SELECT * FROM suggestions;
DROP TABLE suggestions;
CREATE TABLE suggestions (
id TEXT NOT NULL PRIMARY KEY,
dinner_id TEXT NOT NULL REFERENCES dinners (id),
batch_id TEXT NOT NULL REFERENCES suggestion_batches (id),
name TEXT NOT NULL CHECK (name <> ''),
reason TEXT NOT NULL,
cited_regular_ids TEXT NOT NULL, -- JSON array of dinner ids
status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'declined')),
decided_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO suggestions (
id, dinner_id, batch_id, name, reason, cited_regular_ids, status, decided_at, created_at, updated_at
)
SELECT
suggestions_copy.id, suggestions_copy.dinner_id, suggestions_copy.batch_id, dinners.name,
suggestions_copy.reason, suggestions_copy.cited_regular_ids, suggestions_copy.status,
suggestions_copy.decided_at, suggestions_copy.created_at, suggestions_copy.updated_at
FROM suggestions_copy
JOIN dinners ON dinners.id = suggestions_copy.dinner_id;
DROP TABLE suggestions_copy;
+31 -71
View File
@@ -19,19 +19,11 @@ SELECT * FROM dinners
WHERE status = ?
ORDER BY normalized_name, id;
-- name: CountRegularDinners :one
-- How many Regulars the household has; archived dinners are not counted.
SELECT COUNT(*) FROM dinners
WHERE status = 'regular';
-- name: ListUnarchivedDinners :many
-- Every dinner a new or renamed dinner must not repeat, with the
-- source_url of the recipe it is grounded in (NULL while it has none).
SELECT sqlc.embed(dinners), recipes.source_url AS recipe_source_url
FROM dinners
LEFT JOIN recipes ON recipes.id = dinners.recipe_id
WHERE dinners.status <> 'archived'
ORDER BY dinners.id;
-- Every dinner a new or renamed dinner must not repeat.
SELECT * FROM dinners
WHERE status <> 'archived'
ORDER BY id;
-- name: UpdateDinner :one
-- An unknown id returns sql.ErrNoRows.
@@ -45,10 +37,8 @@ SET name = ?,
difficulty = ?,
cuisine = ?,
recipe_id = ?,
recipe_url = ?,
photo_id = ?,
grounding_status = ?,
grounding_revision = ?,
updated_by = ?,
updated_at = ?
WHERE id = ?
@@ -82,87 +72,57 @@ WHERE id = ?
RETURNING *;
-- name: GroundDinner :one
-- Records a dinner's grounding by hand (grounded or manual) and clears any
-- grounding_error and pasted recipe_url: the recipe chosen replaces the
-- link. It writes only an unarchived dinner still as it was observed
-- before grounding (same name, normalized_name, grounding_status,
-- grounding_revision and recipe_id), so a dinner renamed, archived or
-- grounded some other way meanwhile is left as it is. That dinner, or an
-- unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
-- no longer as observed is kept: a member edited it while it was grounding.
-- Records a dinner's grounding (grounded or manual) and clears any
-- grounding_error. An unknown id returns sql.ErrNoRows.
UPDATE dinners
SET recipe_id = sqlc.arg(recipe_id),
recipe_url = NULL,
photo_id = sqlc.arg(photo_id),
method = CASE WHEN method IS sqlc.narg(observed_method)
THEN sqlc.arg(method) ELSE method END,
minutes_total = CASE WHEN minutes_total IS sqlc.narg(observed_minutes_total)
THEN sqlc.arg(minutes_total) ELSE minutes_total END,
difficulty = CASE WHEN difficulty IS sqlc.narg(observed_difficulty)
THEN sqlc.arg(difficulty) ELSE difficulty END,
grounding_status = sqlc.arg(grounding_status),
SET recipe_id = ?,
photo_id = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
grounding_status = ?,
grounding_error = NULL,
updated_by = sqlc.arg(updated_by),
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id)
AND status <> 'archived'
AND name = sqlc.arg(observed_name)
AND normalized_name = sqlc.arg(observed_normalized_name)
AND grounding_status = sqlc.arg(observed_grounding_status)
AND grounding_revision = sqlc.arg(observed_grounding_revision)
AND recipe_id IS sqlc.narg(observed_recipe_id)
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING *;
-- name: GroundPendingDinner :execrows
-- Records the background grounding of an unarchived dinner still pending at
-- the grounding_revision it was grounded for; a dinner grounded some other
-- way, renamed or archived meanwhile is left as it is (0 rows). A method,
-- minutes_total or difficulty already set is kept: a pending dinner has
-- them only when a member edited them while it was grounding.
-- Records the background grounding of a dinner still pending; a dinner
-- grounded some other way meanwhile is left as it is (0 rows).
UPDATE dinners
SET recipe_id = sqlc.arg(recipe_id),
photo_id = sqlc.arg(photo_id),
method = COALESCE(method, sqlc.arg(method)),
minutes_total = COALESCE(minutes_total, sqlc.arg(minutes_total)),
difficulty = COALESCE(difficulty, sqlc.arg(difficulty)),
SET recipe_id = ?,
photo_id = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
grounding_status = 'grounded',
grounding_error = NULL,
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id)
AND status <> 'archived'
AND grounding_status = 'pending'
AND grounding_revision = sqlc.arg(grounding_revision);
updated_at = ?
WHERE id = ? AND grounding_status = 'pending';
-- name: FailPendingDinnerGrounding :execrows
-- Records why the background grounding of an unarchived dinner still
-- pending at the grounding_revision it was grounded for failed; a dinner
-- grounded some other way, renamed or archived meanwhile is left as it is
-- (0 rows).
-- Records why the background grounding of a dinner still pending failed; a
-- dinner grounded some other way meanwhile is left as it is (0 rows).
UPDATE dinners
SET grounding_status = 'failed',
grounding_error = ?,
updated_at = ?
WHERE id = ?
AND status <> 'archived'
AND grounding_status = 'pending'
AND grounding_revision = ?;
WHERE id = ? AND grounding_status = 'pending';
-- name: FailOrphanedPendingDinnerGroundings :execrows
-- Fails every unarchived dinner still pending whose grounding job for its
-- current grounding_revision is no longer queued or running: the process
-- stopped before the job, or before its handler recorded a result. An
-- archived dinner is left as it is; archiving stopped its grounding. Run at
-- boot, after the job runner fails the jobs a previous process left running.
-- Fails every dinner still pending whose grounding job is no longer queued
-- or running: the process stopped before the job, or before its handler
-- recorded a result. Run at boot, after the job runner fails the jobs a
-- previous process left running.
UPDATE dinners
SET grounding_status = 'failed',
grounding_error = sqlc.arg(grounding_error),
updated_at = sqlc.arg(updated_at)
WHERE grounding_status = 'pending'
AND status <> 'archived'
AND NOT EXISTS (
SELECT 1 FROM jobs
WHERE jobs.kind = sqlc.arg(kind)
AND jobs.status IN ('queued', 'running')
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
AND json_extract(jobs.payload, '$.revision') = dinners.grounding_revision
);
+5 -5
View File
@@ -1,12 +1,12 @@
-- Reads for the AI household context block (internal/ai/harness).
-- name: ListRecentDeclinedNames :many
-- The names the most recently declined suggestions were suggested under,
-- newest first; renaming their dinners does not change them.
SELECT name
-- The names of the most recently declined suggestions, newest first.
SELECT dinners.name
FROM suggestions
WHERE status = 'declined'
ORDER BY decided_at DESC, id DESC
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.status = 'declined'
ORDER BY suggestions.decided_at DESC, suggestions.id DESC
LIMIT ?;
-- name: ListPlannedDinners :many
-103
View File
@@ -1,103 +0,0 @@
-- name: CreateSuggestionBatch :one
INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
VALUES (?, ?, 'pending', ?, ?)
RETURNING *;
-- name: GetActiveSuggestionBatch :one
-- The batch still pending or running, oldest first. Returns sql.ErrNoRows
-- when there is none.
SELECT * FROM suggestion_batches
WHERE status IN ('pending', 'running')
ORDER BY id
LIMIT 1;
-- name: GetSuggestionBatchJobID :one
-- The id of the job that runs batch batch_id.
SELECT id FROM jobs
WHERE kind = sqlc.arg(kind)
AND json_extract(payload, '$.batch_id') = sqlc.arg(batch_id)
ORDER BY id
LIMIT 1;
-- name: StartSuggestionBatch :execrows
-- Marks a pending batch running; a batch in any other status is left as
-- it is (0 rows).
UPDATE suggestion_batches
SET status = 'running', updated_at = ?
WHERE id = ? AND status = 'pending';
-- name: FinishSuggestionBatch :execrows
-- Records a pending or running batch as done or failed; a batch already
-- finished is left as it is (0 rows).
UPDATE suggestion_batches
SET status = ?, updated_at = ?
WHERE id = ? AND status IN ('pending', 'running');
-- name: FailOrphanedSuggestionBatches :execrows
-- Fails every batch still pending or running whose job is no longer queued
-- or running: the process stopped, or recording the batch's end failed,
-- before its handler recorded a result. Run before reading the active batch.
UPDATE suggestion_batches
SET status = 'failed', updated_at = sqlc.arg(updated_at)
WHERE status IN ('pending', 'running')
AND NOT EXISTS (
SELECT 1 FROM jobs
WHERE jobs.kind = sqlc.arg(kind)
AND jobs.status IN ('queued', 'running')
AND json_extract(jobs.payload, '$.batch_id') = suggestion_batches.id
);
-- name: CreateSuggestion :exec
-- name is the name the dish is suggested under, kept whatever its dinner is
-- later renamed.
INSERT INTO suggestions (
id, dinner_id, batch_id, name, reason, cited_regular_ids, status, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, 'pending', ?, ?
);
-- name: GetSuggestion :one
-- A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
SELECT sqlc.embed(suggestions), sqlc.embed(dinners)
FROM suggestions
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.id = ?;
-- name: ListDeck :many
-- The pending suggestions whose dinner is still suggested, oldest first,
-- with their dinners.
SELECT sqlc.embed(suggestions), sqlc.embed(dinners)
FROM suggestions
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested'
ORDER BY suggestions.created_at, suggestions.id;
-- name: CountDeck :one
-- How many suggestions ListDeck returns.
SELECT COUNT(*)
FROM suggestions
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested';
-- name: ListDeclinedSuggestionNames :many
-- The name every declined suggestion was suggested under, oldest decision
-- first; renaming its dinner does not change it.
SELECT name
FROM suggestions
WHERE status = 'declined'
ORDER BY decided_at, id;
-- name: DecideSuggestion :execrows
-- Records the household's decision on a pending suggestion; one already
-- decided is left as it is (0 rows).
UPDATE suggestions
SET status = sqlc.arg(status), decided_at = sqlc.arg(now), updated_at = sqlc.arg(now)
WHERE id = sqlc.arg(id) AND status = 'pending';
-- name: MoveSuggestedDinner :execrows
-- Moves a dinner still suggested to status (quest_log or archived),
-- recording updated_by; a dinner no longer suggested is left as it is
-- (0 rows).
UPDATE dinners
SET status = sqlc.arg(status), updated_by = sqlc.arg(updated_by), updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id) AND status = 'suggested';
-14
View File
@@ -30,7 +30,6 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
// The routes that render a built screen rather than a placeholder page.
const SCREENS = {
'/onboarding': true,
'/discover': true,
'/settings': true,
'/settings/ai': true,
'/settings/household': true,
@@ -103,19 +102,6 @@ describe('App routes', () => {
expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument()
})
it('/discover renders the Side quests deck inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
...SIGNED_IN,
'/api/v1/suggestions/deck': () => jsonResponse(200, { suggestions: [] }),
'/api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }),
})
renderAt('/discover')
const heading = await screen.findByRole('heading', { level: 1, name: 'Side quests' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByText('Quest log · 0')).toBeInTheDocument()
})
it('/settings/household renders the household settings screen inside the shell', async () => {
mockViewport(390)
stubFetchByPath(SIGNED_IN)
-2
View File
@@ -3,7 +3,6 @@ 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 { DiscoverPage } from './discover/DiscoverPage.tsx'
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
@@ -36,7 +35,6 @@ export const routes = [
// The screens built so far; every other route shows its placeholder page.
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/discover': <DiscoverPage />,
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
'/settings/household': <HouseholdSettingsPage />,
+8 -77
View File
@@ -14,7 +14,7 @@ function job(status: string, result: Draft | null, error: string | null = null)
function renderJob(jobId: string | null) {
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
const wrapper = ({ children }: { children: ReactNode }) => <ApiProvider client={api}>{children}</ApiProvider>
return renderHook(({ id }) => useJob<Draft>('plan.draft', id), { wrapper, initialProps: { id: jobId } })
return renderHook(({ id }) => useJob<Draft>(id), { wrapper, initialProps: { id: jobId } })
}
// advance runs the timers due in ms and lets the polls they start answer.
@@ -42,7 +42,7 @@ describe('useJob', () => {
job('succeeded', { dinners: ['Tacos', 'Chili', 'Soup'] }),
)
const { result } = renderJob('j1')
expect(result.current).toMatchObject({ status: undefined, result: null, error: null })
expect(result.current).toEqual({ status: undefined, result: null, error: null })
await advance(0)
expect(result.current.status).toBe('queued')
@@ -51,7 +51,7 @@ describe('useJob', () => {
await advance(499)
expect(fetchMock).toHaveBeenCalledTimes(1)
await advance(1)
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
expect(result.current).toEqual({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
await advance(999)
expect(fetchMock).toHaveBeenCalledTimes(2)
@@ -62,7 +62,7 @@ describe('useJob', () => {
expect(fetchMock).toHaveBeenCalledTimes(4)
await advance(2000)
expect(fetchMock).toHaveBeenCalledTimes(5)
expect(result.current).toMatchObject({
expect(result.current).toEqual({
status: 'succeeded',
result: { dinners: ['Tacos', 'Chili', 'Soup'] },
error: null,
@@ -106,76 +106,10 @@ describe('useJob', () => {
expect(fetchMock).toHaveBeenCalledTimes(2)
await advance(1)
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(result.current).toMatchObject({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null })
expect(result.current).toEqual({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null })
expect(vi.getTimerCount()).toBe(0)
})
it.each([
['an unknown status', { id: 'j1', kind: 'plan.draft', status: 'stuck', result: null, error: null }],
['another job', { id: 'j2', kind: 'plan.draft', status: 'running', result: null, error: null }],
['another kind', { id: 'j1', kind: 'suggestion_batch', status: 'running', result: null, error: null }],
['no result', { id: 'j1', kind: 'plan.draft', status: 'running', error: null }],
['an error that is not text', { id: 'j1', kind: 'plan.draft', status: 'failed', result: null, error: 500 }],
['not a job', ['j1']],
])('exposes an answer with %s as the error and stops, keeping the last status', async (_, body) => {
const fetchMock = stubFetch(job('running', { dinners: ['Tacos'] }), jsonResponse(200, body))
const { result } = renderJob('j1')
await advance(0)
await advance(500)
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] } })
expect(result.current.error?.message).toBe('GET /api/v1/jobs/j1: response is not the plan.draft job j1')
expect(vi.getTimerCount()).toBe(0)
await advance(10_000)
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('polls again at once on poll after stopping on an answer that is not the job, then keeps polling', async () => {
const fetchMock = stubFetch(
jsonResponse(200, { id: 'j1', kind: 'plan.draft', status: 'stuck', result: null, error: null }),
job('running', { dinners: ['Tacos'] }),
job('succeeded', { dinners: ['Tacos', 'Chili'] }),
)
const { result } = renderJob('j1')
await advance(0)
expect(result.current.error).not.toBeNull()
expect(vi.getTimerCount()).toBe(0)
act(() => result.current.poll())
await advance(0)
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
await advance(500)
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(result.current).toMatchObject({ status: 'succeeded', error: null })
})
it('polls at once on poll during the backoff after a failed poll, starting the backoff again', async () => {
const fetchMock = stubFetch(
job('running', null),
errorResponse(503, 'unavailable', true),
errorResponse(503, 'unavailable', true),
job('running', { dinners: ['Tacos'] }),
job('succeeded', { dinners: ['Tacos'] }),
)
const { result } = renderJob('j1')
await advance(0)
await advance(500)
await advance(1000)
expect(fetchMock).toHaveBeenCalledTimes(3)
// The next poll was due 2 s later; poll asks now.
act(() => result.current.poll())
await advance(0)
expect(fetchMock).toHaveBeenCalledTimes(4)
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
expect(vi.getTimerCount()).toBe(1)
await advance(500)
expect(fetchMock).toHaveBeenCalledTimes(5)
expect(result.current.status).toBe('succeeded')
})
it('stops polling on unmount after a failed poll', async () => {
const fetchMock = stubFetch(errorResponse(503, 'unavailable', true))
const { result, unmount } = renderJob('j1')
@@ -191,15 +125,12 @@ describe('useJob', () => {
})
it('stops polling the old job when the job id changes', async () => {
const fetchMock = stubFetch(
job('running', null),
jsonResponse(200, { id: 'j2', kind: 'plan.draft', status: 'running', result: null, error: null }),
)
const fetchMock = stubFetch(job('running', null), job('running', null))
const { result, rerender } = renderJob('j1')
await advance(0)
rerender({ id: 'j2' })
expect(result.current).toMatchObject({ status: undefined, result: null, error: null })
expect(result.current).toEqual({ status: undefined, result: null, error: null })
await advance(0)
expect(request(fetchMock, 1).path).toBe('/api/v1/jobs/j2')
expect(vi.getTimerCount()).toBe(1)
@@ -234,6 +165,6 @@ describe('useJob', () => {
const { result } = renderJob(null)
await advance(5000)
expect(fetchMock).not.toHaveBeenCalled()
expect(result.current).toMatchObject({ status: undefined, result: null, error: null })
expect(result.current).toEqual({ status: undefined, result: null, error: null })
})
})
+28 -73
View File
@@ -1,11 +1,8 @@
import { useCallback, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { useApi } from './ApiContext.tsx'
import { isRecord } from './errors.ts'
export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed'
const STATUSES: readonly string[] = ['queued', 'running', 'succeeded', 'failed'] satisfies JobStatus[]
// Job is the GET /api/v1/jobs/{id} response. result may be set while the job
// is running (a partial result); error is set only when it failed.
export type Job<R> = {
@@ -21,56 +18,28 @@ export type JobState<R> = {
status: JobStatus | undefined
// The latest result, partial while the job runs.
result: R | null
// The job's failure, the latest polling request's own failure (cleared
// by the next poll that answers), or an answer that is not this job.
// The job's failure, or the latest polling request's own failure (cleared
// by the next poll that answers).
error: Error | null
// poll asks for the job now, starting the backoff again — also after
// polling stopped on an answer that is not this job.
poll: () => void
}
// Polling starts at FIRST_DELAY_MS and doubles up to MAX_DELAY_MS.
export const FIRST_DELAY_MS = 500
export const MAX_DELAY_MS = 2000
type JobData<R> = Omit<JobState<R>, 'poll'>
const idle = { status: undefined, result: null, error: null }
// parseJob checks a GET /api/v1/jobs/{id} answer is the kind of job asked
// for, with a known status. result is passed on unread for the caller to
// check.
function parseJob<R>(where: string, kind: string, jobId: string, body: unknown): Job<R> {
if (
!isRecord(body) ||
body.id !== jobId ||
body.kind !== kind ||
typeof body.status !== 'string' ||
!STATUSES.includes(body.status) ||
!('result' in body) ||
(body.error !== null && typeof body.error !== 'string')
) {
throw new Error(`${where}: response is not the ${kind} job ${jobId}`)
}
return { id: jobId, kind, status: body.status as JobStatus, result: body.result as R | null, error: body.error as string | null }
}
function isTerminal(status: JobStatus): boolean {
return status === 'succeeded' || status === 'failed'
}
// useJob polls GET /api/v1/jobs/{jobId}, a job of the given kind, until it
// succeeds or fails, backing off from 500 ms to 2 s. A failed poll is
// exposed as the error and polling continues. An answer that is not this
// job is exposed as the error and stops polling until poll is called.
// Polling also stops at a terminal status, when jobId changes and on
// unmount. A null jobId polls nothing.
export function useJob<R>(kind: string, jobId: string | null): JobState<R> {
// useJob polls GET /api/v1/jobs/{jobId} until the job succeeds or fails,
// backing off from 500 ms to 2 s. A failed poll is exposed as the error and
// polling continues. Polling stops only at a terminal status, when jobId
// changes and on unmount. A null jobId polls nothing.
export function useJob<R>(jobId: string | null): JobState<R> {
const { apiFetch } = useApi()
const [state, setState] = useState<{ jobId: string | null; job: JobData<R> }>({ jobId, job: idle })
// Each poll call bumps round, which restarts the polling effect.
const [round, setRound] = useState(0)
const poll = useCallback(() => setRound((r) => r + 1), [])
const [state, setState] = useState<{ jobId: string | null; job: JobState<R> }>({ jobId, job: idle })
useEffect(() => {
if (jobId === null) return
@@ -78,51 +47,37 @@ export function useJob<R>(kind: string, jobId: string | null): JobState<R> {
let timer: ReturnType<typeof setTimeout> | undefined
let delay = FIRST_DELAY_MS
async function fetchJob() {
const path = `/api/v1/jobs/${encodeURIComponent(jobId!)}`
let body: unknown
function next() {
timer = setTimeout(poll, delay)
delay = Math.min(delay * 2, MAX_DELAY_MS)
}
async function poll() {
try {
body = await apiFetch<unknown>(path)
const job = await apiFetch<Job<R>>(`/api/v1/jobs/${encodeURIComponent(jobId!)}`)
if (!active) return
const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null
setState({ jobId, job: { status: job.status, result: job.result, error } })
if (isTerminal(job.status)) return
} catch (error) {
if (!active) return
// A failed poll is not the job's end: keep the last known status and
// result, expose the request's error, and poll again.
fail(error instanceof Error ? error : new Error(String(error)))
next()
return
setState((prev) => ({
jobId,
job: { ...(prev.jobId === jobId ? prev.job : idle), error: error instanceof Error ? error : new Error(String(error)) },
}))
}
if (!active) return
let job: Job<R>
try {
job = parseJob<R>(`GET ${path}`, kind, jobId!, body)
} catch (error) {
// An answer that is not this job will not become one by asking
// again: expose it and stop.
fail(error as Error)
return
}
const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null
setState({ jobId, job: { status: job.status, result: job.result, error } })
if (!isTerminal(job.status)) next()
next()
}
// fail keeps the last known status and result, with error.
function fail(error: Error) {
setState((prev) => ({ jobId, job: { ...(prev.jobId === jobId ? prev.job : idle), error } }))
}
function next() {
timer = setTimeout(fetchJob, delay)
delay = Math.min(delay * 2, MAX_DELAY_MS)
}
void fetchJob()
void poll()
return () => {
active = false
clearTimeout(timer)
}
}, [apiFetch, kind, jobId, round])
}, [apiFetch, jobId])
// State left from a previous jobId is not this job's.
return { ...(state.jobId === jobId ? state.job : idle), poll }
return state.jobId === jobId ? state.job : idle
}
+9 -10
View File
@@ -33,10 +33,8 @@ export type Regular = {
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 }
// NewRegular is a POST /api/v1/dinners body: a name or a recipe link.
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 }>
@@ -79,7 +77,7 @@ 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
export function oneOf<T extends string>(where: string, field: string, values: readonly T[], value: unknown): T {
function oneOf<T extends string>(where: string, field: string, values: readonly T[], value: unknown): T {
if (typeof value !== 'string' || !(values as readonly string[]).includes(value)) {
throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not one of ${values.join(', ')}`)
}
@@ -99,7 +97,7 @@ 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.
export function photoId(where: string, value: unknown): string | undefined {
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`)
@@ -197,9 +195,10 @@ function settle(queryClient: QueryClient) {
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.
// useAddRegular adds a Regular by name or recipe link (POST
// /api/v1/dinners). It shows in the list at once — a link under the link
// itself until the server names it — and is taken out again if the server
// refuses it.
export function useAddRegular() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
@@ -211,7 +210,7 @@ export function useAddRegular() {
const id = `pending-${++pendingIds}`
const optimistic: CachedRegular = {
id,
name: add.name,
name: 'name' in add ? add.name : add.recipe_url,
method: null,
minutes_total: null,
difficulty: null,
File diff suppressed because it is too large Load Diff
-20
View File
@@ -1,20 +0,0 @@
import { describe, expect, it } from 'vitest'
import cases from '../../../internal/ai/harness/testdata/dedupe_cases.json'
import { normalizeName, similarNames } from './names.ts'
// The cases internal/ai/harness's tests run against Normalize and Similar.
describe('dinner names, as the server compares them', () => {
it('has cases to run', () => {
expect(cases.normalize.length).toBeGreaterThan(0)
expect(cases.similar.length).toBeGreaterThan(0)
})
it.each(cases.normalize)('normalizes "$name" to "$want"', ({ name, want }) => {
expect(normalizeName(name)).toBe(want)
})
it.each(cases.similar)('"$a" and "$b" similar: $want', ({ a, b, want }) => {
expect(similarNames(a, b)).toBe(want)
expect(similarNames(b, a)).toBe(want)
})
})
-58
View File
@@ -1,58 +0,0 @@
// The PWA's copy of the server's dinner-name rules (internal/ai/harness
// Normalize and Similar), so a screen can tell beforehand that the dinners
// API would refuse a name as a duplicate. The server still decides.
// internal/ai/harness/testdata/dedupe_cases.json is run against both.
import folds from './letterFolds.json'
// SIMILAR_THRESHOLD is harness.SimilarThreshold.
const SIMILAR_THRESHOLD = 0.85
// fillerWords is harness's fillerWords.
const fillerWords = new Set(['easy', 'best', 'homemade', 'quick', 'simple', 'the', 'a', 'recipe', 'classic'])
// letterFolds is harness's letterFolds: gen_folds.py writes both tables,
// and harness's TestLetterFoldsMatchPWA fails when they differ.
const letterFolds: Record<string, string> = folds
const combiningMark = /\p{Mn}/u
const wordChar = /[\p{L}\p{Nd}]/u
// normalizeName is harness.Normalize: lowercased, accents dropped, letters
// folded, apostrophes removed, other punctuation turned into spaces and
// filler words dropped. "Easy Chicken Tikka-Masala!" is
// "chicken tikka masala".
export function normalizeName(name: string): string {
let out = ''
// Lowercased letter by letter, as Go's strings.ToLower does, so no
// letter is lowered by its context (a final Σ is σ, not ς).
for (const letter of name) {
for (const ch of letter.toLowerCase()) {
if (combiningMark.test(ch) || ch === "'" || ch === '’') continue
const fold = letterFolds[ch]
if (fold !== undefined) out += fold
else if (wordChar.test(ch)) out += ch
else out += ' '
}
}
return out
.split(' ')
.filter((word) => word !== '' && !fillerWords.has(word))
.join(' ')
}
function words(name: string): Set<string> {
const normalized = normalizeName(name)
return new Set(normalized === '' ? [] : normalized.split(' '))
}
// similarNames is harness.Similar: whether a and b name the same dinner, by
// the share of the words of their normalized forms they have in common.
export function similarNames(a: string, b: string): boolean {
const left = words(a)
const right = words(b)
if (left.size === 0 || right.size === 0) return false
let shared = 0
for (const word of left) if (right.has(word)) shared++
return shared / (left.size + right.size - shared) >= SIMILAR_THRESHOLD
}
-75
View File
@@ -1,75 +0,0 @@
import type { DOMAttributes } from 'react'
import { DIFFICULTIES, type Difficulty } from '../dinners/api.ts'
import { Photo } from '../ui/Photo.tsx'
import type { SideQuest } from './api.ts'
import styles from './DiscoverPage.module.css'
// DECK_DEPTH is the depths the deck shows, top card first.
export const DECK_DEPTH = [0, 1, 2] as const
const DIFFICULTY_LABEL: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
// SwipeHandlers are the pointer handlers that drag the top card.
export type SwipeHandlers = Pick<
DOMAttributes<HTMLElement>,
'onPointerDown' | 'onPointerMove' | 'onPointerUp' | 'onPointerCancel' | 'onLostPointerCapture'
>
export type DeckCardProps = {
quest: SideQuest
// 0 is the top card; 1 and 2 stack behind it.
depth: number
// swipe makes the card draggable; only the top card gets it.
swipe?: SwipeHandlers
}
// DeckCard is one Side quest in the deck. Only the top card is read out and
// reachable; the cards behind it are the stack's edge, kept rendered so they
// can be promoted. Its "Try it!" and "Pass" stamps stay hidden until a
// decision or a drag shows them, and its shade until Pass folds it (see
// motion.ts).
export function DeckCard({ quest, depth, swipe }: DeckCardProps) {
const top = depth === 0
const titleId = `side-quest-${quest.id}`
return (
<article
className={`${styles.card} ${styles[`depth${depth}`]}${swipe === undefined ? '' : ` ${styles.draggable}`}`}
aria-labelledby={top ? titleId : undefined}
aria-hidden={top ? undefined : true}
inert={!top}
{...swipe}
>
<Photo id={quest.photo_id} name={quest.name} radius="24px" inset="10px" sizes="400px" className={styles.photo} />
<div className={styles.content}>
<h2 id={titleId} className={styles.name}>
{quest.name}
</h2>
{quest.difficulty !== null && <DifficultyDots difficulty={quest.difficulty} />}
<p className={styles.why}>
<strong>Why this?</strong> {quest.reason}
</p>
</div>
<span className={`${styles.stamp} ${styles.tryStamp}`} data-part="try-stamp" aria-hidden="true">
Try it!
</span>
<span className={`${styles.stamp} ${styles.passStamp}`} data-part="pass-stamp" aria-hidden="true">
Pass
</span>
<span className={styles.shade} data-part="shade" />
</article>
)
}
function DifficultyDots({ difficulty }: { difficulty: Difficulty }) {
const level = DIFFICULTIES.indexOf(difficulty) + 1
return (
<p className={styles.difficulty}>
<span className={styles.dots} aria-hidden="true">
{DIFFICULTIES.map((d, i) => (
<span key={d} data-filled={i < level} className={i < level ? styles.dotFilled : styles.dot} />
))}
</span>
{DIFFICULTY_LABEL[difficulty]}
</p>
)
}
-261
View File
@@ -1,261 +0,0 @@
/* Discover · Side quests (mock 1a). */
.page {
box-sizing: border-box;
max-width: 440px;
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 8px;
}
.header {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.kicker {
margin: 0;
font-family: var(--font-body);
font-size: 11px;
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: 30px;
font-weight: 400;
line-height: 1.1;
}
/* The Quest log count pill, in a 44 px tall hit box. */
.questLogButton {
flex: none;
display: inline-flex;
align-items: center;
min-width: 44px;
min-height: 44px;
padding: 0;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
cursor: pointer;
}
.questLog {
flex: none;
padding: 7px 12px;
border-radius: var(--radius-pill);
background: var(--color-accent-2-200);
color: var(--color-accent-2-800);
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
}
/* The Quest log's dinner names, under the header while the pill is open. */
.questLogList {
flex: 1 0 100%;
font-family: var(--font-body);
font-size: 14px;
}
.questLogNames {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 6px;
}
.questLogEmpty {
margin: 0;
color: var(--color-neutral-700);
}
/* The deck: three cards stacked in the same box, the top one in front. */
.deck {
position: relative;
height: 440px;
perspective: 1000px;
}
.card {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
border-radius: var(--radius-card);
overflow: hidden;
color: var(--color-text);
}
.depth0 {
z-index: 3;
transform: none;
background: var(--color-neutral-100);
box-shadow: var(--shadow-lg);
}
/* The top card follows a drag instead of scrolling the page. */
.draggable {
touch-action: none;
cursor: grab;
}
.depth1 {
z-index: 2;
transform: translateY(8px) scale(0.95);
background: var(--color-surface);
}
.depth2 {
z-index: 1;
transform: translateY(16px) scale(0.9);
background: var(--color-neutral-300);
}
.photo {
flex: none;
width: calc(100% - 20px);
height: 220px;
}
.content {
display: flex;
flex-direction: column;
gap: 10px;
padding: 4px 20px 0;
}
.name {
margin: 0;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 400;
line-height: 1.1;
}
.difficulty {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.dots {
display: flex;
gap: 3px;
}
.dot,
.dotFilled {
width: 8px;
height: 8px;
border-radius: var(--radius-pill);
background: var(--color-neutral-300);
}
.dotFilled {
background: var(--color-accent);
}
/* The "Why this?" box. */
.why {
margin: 0;
padding: 12px 14px;
border-radius: 18px;
background: var(--color-accent-2-100);
color: var(--color-accent-2-900);
font-family: var(--font-body);
font-size: 13px;
line-height: 1.45;
}
/* The decision stamps, hidden until a decision shows them. */
.stamp {
position: absolute;
top: 26px;
padding: 6px 14px;
border: 3px solid;
border-radius: 14px;
font-family: var(--font-heading);
font-size: 24px;
opacity: 0;
pointer-events: none;
}
.tryStamp {
left: 22px;
transform: rotate(-12deg);
border-color: var(--color-accent-2-700);
background: var(--color-accent-2-100);
color: var(--color-accent-2-800);
}
.passStamp {
right: 22px;
transform: rotate(12deg);
border-color: var(--color-neutral-800);
background: var(--color-neutral-100);
color: var(--color-neutral-900);
}
/* The shade Pass darkens the card with as it folds. */
.shade {
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--color-text);
opacity: 0;
pointer-events: none;
}
/* Pass and Try it: IconButtons with their 11 px labels shown. */
.actions {
display: flex;
justify-content: center;
gap: 28px;
margin-top: 4px;
}
/* Skeleton cards while the deck loads or a batch finds Side quests. */
.loading {
display: flex;
flex-direction: column;
gap: 22px;
}
.deck > .skeletonCard {
position: absolute;
inset: 0;
height: auto;
border-radius: var(--radius-card);
}
.skeletonCard.depth0,
.skeletonCard.depth1,
.skeletonCard.depth2 {
background: var(--color-surface);
box-shadow: none;
}
.finding {
margin: 0;
text-align: center;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
File diff suppressed because it is too large Load Diff
-378
View File
@@ -1,378 +0,0 @@
import { useQueryClient } from '@tanstack/react-query'
import { Heart, X } from 'lucide-react'
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type Ref } from 'react'
import { useJob } from '../api/index.ts'
import { Card } from '../ui/Card.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import {
BATCH_JOB_KIND,
DECK_KEY,
parseBatchResult,
useDeck,
useDecide,
useQuestLog,
useStartBatch,
type Choice,
} from './api.ts'
import { DeckCard, DECK_DEPTH, type SwipeHandlers } from './DeckCard.tsx'
import {
bump,
fadeOut,
follow,
isAbort,
pass,
prefersReducedMotion,
promote,
snapBack,
tryIt,
unfollow,
type Exit,
} from './motion.ts'
import styles from './DiscoverPage.module.css'
// SWIPE_THRESHOLD is how far, in px, the top card must be dragged left or
// right for its release to Pass or Try it.
const SWIPE_THRESHOLD = 110
// A Drag is the pointer dragging the top card: where it went down, and the
// card's drag transform so far.
type Drag = { pointerId: number; x: number; y: number; dx: number; transform: string | null }
// isEditable says whether a key press belongs to a field being typed in,
// which the deck's ← / → keys leave alone.
function isEditable(target: EventTarget | null): boolean {
return (
target instanceof HTMLElement &&
(target.isContentEditable || target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement)
)
}
// QuestLogPill is the "Quest log · N" pill. It shows and hides the names
// of the dinners in the Quest log; the Quest log sheet replaces this list.
// ref is the visible pill, which Try it bumps.
function QuestLogPill({ dinners, ref }: { dinners: { id: string; name: string }[]; ref: Ref<HTMLSpanElement> }) {
const [open, setOpen] = useState(false)
const listId = useId()
return (
<>
<button
type="button"
className={styles.questLogButton}
aria-expanded={open}
aria-controls={open ? listId : undefined}
onClick={() => setOpen(!open)}
>
<span ref={ref} className={styles.questLog}>Quest log · {dinners.length}</span>
</button>
{open && (
<Card id={listId} tone="muted" className={styles.questLogList}>
{dinners.length === 0 ? (
<p className={styles.questLogEmpty}>No dinners in the Quest log yet. Try it on a Side quest to add one.</p>
) : (
<ul aria-label="Quest log" className={styles.questLogNames}>
{dinners.map((dinner) => (
<li key={dinner.id}>{dinner.name}</li>
))}
</ul>
)}
</Card>
)}
</>
)
}
// DiscoverPage is mock 1a: the deck of Side quests to Try it or Pass, one
// at a time from the top card. A decision from the buttons, the ← / → keys
// or a swipe of the top card throws it out (motion.ts) and sends the
// decision once it is gone; Retry sends a refused one again. Input is
// locked from the press until the decision settles: the lock is a ref, set
// before anything starts, so a second press before the next render does
// nothing, and busy mirrors it to disable the buttons.
export function DiscoverPage() {
const queryClient = useQueryClient()
const deck = useDeck()
const questLog = useQuestLog()
const decision = useDecide()
const startBatch = useStartBatch()
const job = useJob<unknown>(BATCH_JOB_KIND, startBatch.data?.job_id ?? null)
const top = deck.data?.[0]
const [busy, setBusy] = useState(false)
const jobRunning = startBatch.isSuccess && job.status !== 'succeeded' && job.status !== 'failed'
const finding = startBatch.isPending || jobRunning
// The batch's result as far as it has gone, or why it cannot be read.
const batch = useMemo(() => {
if (job.result === null) return { stored: 0, error: null }
try {
return { stored: parseBatchResult(`GET /api/v1/jobs/${startBatch.data?.job_id}`, job.result).suggestion_ids.length, error: null }
} catch (error) {
return { stored: 0, error: error as Error }
}
}, [job.result, startBatch.data?.job_id])
// The job's failure, the latest poll's failure, an answer that is not the
// batch's job, or an unreadable result.
const batchError = job.error ?? batch.error
const deckRef = useRef<HTMLDivElement>(null)
const passRef = useRef<HTMLButtonElement>(null)
const tryRef = useRef<HTMLButtonElement>(null)
const questLogRef = useRef<HTMLSpanElement>(null)
// The top card's exit while it runs or its decision is unsettled, and the
// Side quest the last decision sent was for, until the deck moves past it.
const exit = useRef<Exit | null>(null)
const leaving = useRef<string | null>(null)
const drag = useRef<Drag | null>(null)
// The top card's snap-back while it may still run; a new drag or a
// decision stops it so it does not fight the card's new motion.
const springing = useRef<Animation | null>(null)
const stopSpring = useCallback(() => {
springing.current?.cancel()
springing.current = null
}, [])
const { mutate: mutateDecision } = decision
const deciding = useRef(false)
const lock = useCallback(() => {
if (deciding.current) return false
deciding.current = true
setBusy(true)
return true
}, [])
// send records a decision. A refused one puts the card back.
const send = useCallback(
(variables: { id: string; choice: Choice }) => {
leaving.current = variables.id
mutateDecision(variables, {
onError: () => {
exit.current?.cancel()
exit.current = null
leaving.current = null
},
onSettled: () => {
deciding.current = false
setBusy(false)
},
})
},
[mutateDecision],
)
const submit = useCallback(
(variables: { id: string; choice: Choice }) => {
if (lock()) send(variables)
},
[lock, send],
)
// decide bumps the button, throws the top card out — or, motion reduced,
// fades it — then sends the decision; Try it bumps the Quest log pill too.
// A swipe's decision comes from the card's drag transform: it is thrown
// from there without a bump or wiggle. A button or key decision ends a
// drag in progress, putting the card back first.
const decide = useCallback(
(choice: Choice, from?: string) => {
if (top === undefined || !lock()) return
stopSpring()
const card = deckRef.current!.querySelector('article')!
if (from === undefined && drag.current !== null) {
drag.current = null
unfollow(card)
}
const reduced = prefersReducedMotion()
let run: Exit
if (reduced) {
run = fadeOut(card)
} else if (from === undefined) {
bump((choice === 'try' ? tryRef : passRef).current!)
run = choice === 'try' ? tryIt(card) : pass(card)
} else {
run = choice === 'try' ? tryIt(card, from) : pass(card, from)
}
if (from !== undefined) {
// Putting a swiped card back puts it back at rest.
const thrown = run
run = {
finished: thrown.finished,
cancel: () => {
thrown.cancel()
unfollow(card)
},
}
}
exit.current = run
run.finished.then(
() => {
// The Quest log pill is missing while the Quest log fails to load.
if (choice === 'try' && !reduced && questLogRef.current !== null) bump(questLogRef.current)
send({ id: top.id, choice })
},
(error: unknown) => {
if (!isAbort(error)) throw error
},
)
},
[top, lock, send, stopSpring],
)
// swipe drags the top card: it follows the pointer, captured from the
// press, until the release decides past the threshold or springs it back
// short of it. The release decides from where the pointer let go, which
// may differ from its last move. A cancel, or losing the capture before
// the release, springs it back from its last move. One pointer drags at
// a time: a press while a drag is in progress is ignored, as is input
// while busy, and a drag held into a busy spell (a Retry) is put back on
// release.
const swipe = useMemo<SwipeHandlers>(() => {
function track(card: HTMLElement, current: Drag, event: PointerEvent<HTMLElement>) {
current.dx = event.clientX - current.x
current.transform = follow(card, current.dx, event.clientY - current.y)
}
function release(event: PointerEvent<HTMLElement>, decides: boolean) {
const card = event.currentTarget
const current = drag.current
if (current === null || current.pointerId !== event.pointerId) return
drag.current = null
const moved = current.transform !== null || event.clientX !== current.x || event.clientY !== current.y
if (decides && moved && !deciding.current) track(card, current, event)
if (current.transform === null) return
if (deciding.current) unfollow(card)
else if (decides && current.dx > SWIPE_THRESHOLD) decide('try', current.transform)
else if (decides && current.dx < -SWIPE_THRESHOLD) decide('pass', current.transform)
else if (prefersReducedMotion()) unfollow(card)
else springing.current = snapBack(card, current.transform)
}
return {
onPointerDown: (event) => {
if (drag.current !== null || deciding.current || !event.isPrimary || event.button !== 0) return
stopSpring()
event.currentTarget.setPointerCapture(event.pointerId)
drag.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, dx: 0, transform: null }
},
onPointerMove: (event) => {
const current = drag.current
if (current === null || current.pointerId !== event.pointerId || deciding.current) return
track(event.currentTarget, current, event)
},
onPointerUp: (event) => release(event, true),
onPointerCancel: (event) => release(event, false),
// After a release the drag is already over, so this only ends a drag
// whose capture was lost before it.
onLostPointerCapture: (event) => release(event, false),
}
}, [decide, stopSpring])
// Leaving the page stops an exit before its decision is sent.
useEffect(() => () => exit.current?.cancel(), [])
const topId = top?.id
// A drag belongs to the card it started on, which a new top card ends.
useLayoutEffect(() => {
drag.current = null
}, [topId])
// Once the deck moves past the card a decision was sent for, the cards
// behind it move up a depth.
useLayoutEffect(() => {
if (leaving.current === null || leaving.current === topId) return
leaving.current = null
exit.current = null
if (topId === undefined || prefersReducedMotion()) return
promote([...deckRef.current!.querySelectorAll<HTMLElement>(':scope > article')])
}, [topId])
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
if (isEditable(event.target)) return
if (event.key === 'ArrowLeft') decide('pass')
else if (event.key === 'ArrowRight') decide('try')
else return
event.preventDefault()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [decide])
// The batch stores its Side quests as it goes, so the deck is read again
// whenever it reports more, and once it ends.
const { stored } = batch
useEffect(() => {
if (job.status === undefined) return
void queryClient.invalidateQueries({ queryKey: DECK_KEY })
}, [queryClient, job.status, stored])
let body
if (deck.isError) {
body = <ErrorBanner onRetry={() => void deck.refetch()}>Couldn't load your side quests. {deck.error.message}</ErrorBanner>
} else if (deck.isPending || (deck.data.length === 0 && finding)) {
body = (
<section aria-busy="true" aria-label="Side quests loading" className={styles.loading}>
<div className={styles.deck}>
{DECK_DEPTH.map((depth) => (
<Skeleton key={depth} data-testid="skeleton-card" className={`${styles.skeletonCard} ${styles[`depth${depth}`]}`} />
))}
</div>
{finding && <p className={styles.finding}>Finding new side quests…</p>}
{jobRunning && batchError !== null && (
<ErrorBanner onRetry={job.poll}>Couldn't check on new side quests. {batchError.message}</ErrorBanner>
)}
</section>
)
} else if (top === undefined) {
body = (
<>
{startBatch.isError && (
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't ask for more side quests. {startBatch.error.message}</ErrorBanner>
)}
{batchError !== null && (
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't find new side quests. {batchError.message}</ErrorBanner>
)}
<EmptyState title="All caught up" action={{ label: 'Get more now', onClick: () => startBatch.mutate() }}>
New side quests arrive Sunday.
</EmptyState>
</>
)
} else {
body = (
<>
<div ref={deckRef} className={styles.deck}>
{deck.data.slice(0, DECK_DEPTH.length).map((quest, depth) => (
<DeckCard key={quest.id} quest={quest} depth={depth} swipe={depth === 0 ? swipe : undefined} />
))}
</div>
<div className={styles.actions}>
<IconButton ref={passRef} label="Pass" size={62} tone="neutral" showLabel disabled={busy} onClick={() => decide('pass')}>
<Icon icon={X} size="nav" />
</IconButton>
<IconButton ref={tryRef} label="Try it" size={62} tone="teal" showLabel disabled={busy} onClick={() => decide('try')}>
<Icon icon={Heart} size="nav" />
</IconButton>
</div>
{decision.isError && (
<ErrorBanner onRetry={() => submit(decision.variables!)}>
Couldn't save that choice. {decision.error.message}
</ErrorBanner>
)}
</>
)
}
return (
<div className={styles.page}>
<header className={styles.header}>
<div>
<p className={styles.kicker}>Discover</p>
<h1 className={styles.title}>Side quests</h1>
</div>
{questLog.isSuccess && <QuestLogPill ref={questLogRef} dinners={questLog.data} />}
</header>
{questLog.isError && (
<ErrorBanner onRetry={() => void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message}</ErrorBanner>
)}
{body}
</div>
)
}
-156
View File
@@ -1,156 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { DIFFICULTIES, DINNERS_PATH, oneOf, photoId, type Difficulty } from '../dinners/api.ts'
export const SUGGESTIONS_PATH = '/api/v1/suggestions'
export const DECK_PATH = `${SUGGESTIONS_PATH}/deck`
export const BATCHES_PATH = `${SUGGESTIONS_PATH}/batches`
export const QUEST_LOG_PATH = `${DINNERS_PATH}?status=quest_log`
// DECK_KEY is the deck's query; QUEST_LOG_KEY the Quest log's.
export const DECK_KEY = ['suggestions', 'deck'] as const
export const QUEST_LOG_KEY = ['dinners', 'quest_log'] as const
// SideQuest is a pending suggestion as the deck shows it. photo_id is the
// stored photo's id, from photo_url; reason is the "Why this?".
export type SideQuest = {
id: string
name: string
photo_id: string | undefined
difficulty: Difficulty | null
reason: string
}
// Choice is the household's decision on a Side quest: Try it or Pass.
export type Choice = 'try' | 'pass'
// Batch is a POST /api/v1/suggestions/batches answer: the batch and the job
// running it, which GET /api/v1/jobs/{id} reports on.
export type Batch = { batch_id: string; job_id: string }
// BATCH_JOB_KIND is the kind of the job running a suggestion batch.
export const BATCH_JOB_KIND = 'suggestion_batch'
// BatchResult is the suggestion batch job's result, published as it goes.
export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number }
// QuestLogDinner is a dinner in the Quest log, as the Quest log pill lists it.
export type QuestLogDinner = { id: string; name: string }
// DECISION_STATUS is the status a suggestion has once each choice is saved.
const DECISION_STATUS: Record<Choice, string> = { try: 'accepted', pass: 'declined' }
// parseBatchResult checks a suggestion batch job's result, which useJob
// passes on unread.
export function parseBatchResult(where: string, body: unknown): BatchResult {
if (
!isRecord(body) ||
typeof body.batch_id !== 'string' ||
typeof body.stage !== 'string' ||
!Array.isArray(body.suggestion_ids) ||
!body.suggestion_ids.every((id) => typeof id === 'string') ||
typeof body.dropped !== 'number'
) {
throw new Error(`${where}: result is not a suggestion batch`)
}
return { batch_id: body.batch_id, stage: body.stage, suggestion_ids: body.suggestion_ids as string[], dropped: body.dropped }
}
function parseSideQuest(where: string, body: unknown): SideQuest {
if (
!isRecord(body) ||
typeof body.id !== 'string' ||
typeof body.name !== 'string' ||
typeof body.reason !== 'string'
) {
throw new Error(`${where}: response is not a suggestion`)
}
return {
id: body.id,
name: body.name,
photo_id: photoId(where, body.photo_url),
difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty),
reason: body.reason,
}
}
// useDeck is the pending Side quests, oldest first (GET
// /api/v1/suggestions/deck).
export function useDeck() {
const { apiFetch } = useApi()
return useQuery({
queryKey: DECK_KEY,
queryFn: async () => {
const where = `GET ${DECK_PATH}`
const body = await apiFetch<unknown>(DECK_PATH)
if (!isRecord(body) || !Array.isArray(body.suggestions)) throw new Error(`${where}: response has no suggestions list`)
return body.suggestions.map((suggestion) => parseSideQuest(where, suggestion))
},
})
}
// useQuestLog is the dinners in the Quest log, in the server's order (GET
// /api/v1/dinners?status=quest_log).
export function useQuestLog() {
const { apiFetch } = useApi()
return useQuery({
queryKey: QUEST_LOG_KEY,
queryFn: async (): Promise<QuestLogDinner[]> => {
const where = `GET ${QUEST_LOG_PATH}`
const body = await apiFetch<unknown>(QUEST_LOG_PATH)
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
return body.dinners.map((dinner) => {
if (!isRecord(dinner) || typeof dinner.id !== 'string' || typeof dinner.name !== 'string') {
throw new Error(`${where}: response is not a dinner`)
}
return { id: dinner.id, name: dinner.name }
})
},
})
}
// useStartBatch asks for more Side quests now (POST
// /api/v1/suggestions/batches). The server answers with the batch already
// running when there is one.
export function useStartBatch() {
const { apiFetch } = useApi()
return useMutation({
mutationFn: async (): Promise<Batch> => {
const body = await apiFetch<unknown>(BATCHES_PATH, { method: 'POST' })
if (!isRecord(body) || typeof body.batch_id !== 'string' || typeof body.job_id !== 'string') {
throw new Error(`POST ${BATCHES_PATH}: response is not a batch`)
}
return { batch_id: body.batch_id, job_id: body.job_id }
},
})
}
// useDecide records Try it (POST /api/v1/suggestions/{id}/accept) or Pass
// (POST /api/v1/suggestions/{id}/decline), then reads the deck and the
// Quest log again. The answer must be that suggestion, decided that way.
export function useDecide() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, choice }: { id: string; choice: Choice }) => {
const action = choice === 'try' ? 'accept' : 'decline'
const path = `${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}`
const body = await apiFetch<unknown>(path, { method: 'POST' })
if (
!isRecord(body) ||
!isRecord(body.suggestion) ||
body.suggestion.id !== id ||
body.suggestion.status !== DECISION_STATUS[choice]
) {
throw new Error(`POST ${path}: response is not the ${DECISION_STATUS[choice]} suggestion`)
}
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: DECK_KEY }),
queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
])
},
})
}
-221
View File
@@ -1,221 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { stubAnimate, type FakeAnimation } from '../test/animate.ts'
import { bump, dragTransform, fadeOut, follow, isAbort, pass, promote, snapBack, tryIt, unfollow } from './motion.ts'
let animations: FakeAnimation[]
beforeEach(() => {
animations = stubAnimate({ autoFinish: false })
})
afterEach(() => {
Reflect.deleteProperty(Element.prototype, 'animate')
})
// card builds a deck card with the parts DeckCard marks.
function card() {
const article = document.createElement('article')
article.innerHTML = '<span data-part="try-stamp"></span><span data-part="pass-stamp"></span><span data-part="shade"></span>'
return {
article,
stamp: article.querySelector<HTMLElement>('[data-part="try-stamp"]')!,
passStamp: article.querySelector<HTMLElement>('[data-part="pass-stamp"]')!,
shade: article.querySelector('[data-part="shade"]')!,
}
}
// settled says whether a promise has settled after pending callbacks run.
async function settled(promise: Promise<unknown>) {
let done = false
void promise.then(
() => (done = true),
() => (done = true),
)
await new Promise((resolve) => setTimeout(resolve, 0))
return done
}
const FLY_OFF = { transform: 'translate(150%,-60px) rotate(28deg)', opacity: 0 }
describe('motion', () => {
it('bumps: scale 1 → .88 → 1.06 → 1, 300 ms ease-out', () => {
const button = document.createElement('button')
bump(button)
expect(animations).toHaveLength(1)
expect(animations[0]!.target).toBe(button)
expect(animations[0]!.keyframes).toEqual([
{ transform: 'scale(1)' },
{ transform: 'scale(.88)' },
{ transform: 'scale(1.06)' },
{ transform: 'scale(1)' },
])
expect(animations[0]!.options).toEqual({ duration: 300, easing: 'ease-out' })
})
it('Try it pops the stamp and wiggles, then throws the card right', async () => {
const { article, stamp } = card()
const exit = tryIt(article)
const [pop, wiggle] = animations
expect(pop!.target).toBe(stamp)
expect(pop!.keyframes).toEqual([
{ opacity: 0, transform: 'rotate(-12deg) scale(1.5)' },
{ opacity: 1, transform: 'rotate(-12deg) scale(1)' },
])
expect(pop!.options).toEqual({ duration: 220, easing: 'cubic-bezier(.34,1.4,.64,1)', fill: 'forwards' })
expect(wiggle!.target).toBe(article)
expect(wiggle!.keyframes).toEqual(['0deg', '-4deg', '4deg', '-3deg', '2deg', '0deg'].map((a) => ({ transform: `rotate(${a})` })))
expect(wiggle!.options).toEqual({ duration: 420, easing: 'ease-in-out' })
expect(animations).toHaveLength(2)
// The throw waits for the wiggle.
pop!.finish()
wiggle!.finish()
await expect.poll(() => animations.length).toBe(3)
const fly = animations[2]!
expect(fly.target).toBe(article)
expect(fly.keyframes).toEqual([
{ transform: 'translate(0px,0px) rotate(0deg)' },
{ transform: 'translate(-14px,4px) rotate(-3deg)', offset: 0.18 },
FLY_OFF,
])
expect(fly.options).toEqual({ duration: 480, easing: 'cubic-bezier(.55,0,.8,.4)', fill: 'forwards' })
expect(await settled(exit.finished)).toBe(false)
fly.finish()
await expect(exit.finished).resolves.toBeUndefined()
})
it('Try it from a dragged transform skips the wiggle and throws in 340 ms', async () => {
const { article } = card()
const exit = tryIt(article, 'translate(120px,9px) rotate(6.7deg)')
await expect.poll(() => animations.length).toBe(2)
const fly = animations[1]!
expect(fly.target).toBe(article)
expect(fly.keyframes).toEqual([{ transform: 'translate(120px,9px) rotate(6.7deg)' }, FLY_OFF])
expect(fly.options).toEqual({ duration: 340, easing: 'cubic-bezier(.55,0,.8,.4)', fill: 'forwards' })
fly.finish()
await expect(exit.finished).resolves.toBeUndefined()
})
it('Pass folds the card over its left edge under a darkening shade', async () => {
const { article, shade } = card()
const exit = pass(article)
const [darken, fold] = animations
expect(animations).toHaveLength(2)
expect(darken!.target).toBe(shade)
expect(darken!.keyframes).toEqual([{ opacity: 0 }, { opacity: 0.4, offset: 0.45 }, { opacity: 0.55 }])
expect(darken!.options).toEqual({ duration: 720, fill: 'forwards' })
expect(fold!.target).toBe(article)
expect(fold!.keyframes).toEqual([
{ transformOrigin: '0% 50%', transform: 'translate(0px,0px) rotate(0deg) rotateY(0deg)', easing: 'cubic-bezier(.45,0,.85,.55)' },
{
transformOrigin: '0% 50%',
transform: 'translate(0px,0px) rotate(0deg) rotateY(-80deg)',
offset: 0.45,
easing: 'cubic-bezier(.4,0,.9,.6)',
},
{ transformOrigin: '0% 50%', transform: 'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)', opacity: 0 },
])
expect(fold!.options).toEqual({ duration: 720, fill: 'forwards' })
darken!.finish()
expect(await settled(exit.finished)).toBe(false)
fold!.finish()
await expect(exit.finished).resolves.toBeUndefined()
})
it('Pass from a dragged transform folds from there', () => {
const { article } = card()
pass(article, 'translate(-130px,6px) rotate(-7deg)')
const fold = animations[1]!
expect(fold.keyframes.map((k) => k.transform)).toEqual([
'translate(-130px,6px) rotate(-7deg) rotateY(0deg)',
'translate(-130px,6px) rotate(-7deg) rotateY(-80deg)',
'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)',
])
})
it('fades out in 200 ms with reduced motion', async () => {
const { article } = card()
const exit = fadeOut(article)
expect(animations).toHaveLength(1)
expect(animations[0]!.target).toBe(article)
expect(animations[0]!.keyframes).toEqual([{ opacity: 1 }, { opacity: 0 }])
expect(animations[0]!.options).toEqual({ duration: 200, fill: 'forwards' })
animations[0]!.finish()
await expect(exit.finished).resolves.toBeUndefined()
})
it('promotes the top two cards from the depth behind, 300 ms', () => {
const cards = [card().article, card().article, card().article]
promote(cards)
expect(animations.map((a) => a.target)).toEqual([cards[0], cards[1]])
expect(animations.map((a) => a.keyframes)).toEqual([
[{ transform: 'translateY(8px) scale(0.95)' }, { transform: 'translateY(0px) scale(1)' }],
[{ transform: 'translateY(16px) scale(0.9)' }, { transform: 'translateY(8px) scale(0.95)' }],
])
for (const a of animations) expect(a.options).toEqual({ duration: 300, easing: 'cubic-bezier(.2,.8,.2,1)' })
})
it('cancel stops an exit and never starts its next step', async () => {
const { article } = card()
const exit = tryIt(article)
animations[1]!.finish()
exit.cancel()
const error: unknown = await exit.finished.catch((e: unknown) => e)
expect(isAbort(error)).toBe(true)
expect(animations).toHaveLength(2)
expect(animations.map((a) => a.playState)).toEqual(['idle', 'idle'])
})
it('drags: translate(dx, dy·0.3) rotate(dx/18 deg)', () => {
expect(dragTransform(90, 20)).toBe('translate(90px,6px) rotate(5deg)')
expect(dragTransform(-36, -10)).toBe('translate(-36px,-3px) rotate(-2deg)')
})
it.each([
[50, '0.5', '0'],
[-50, '0', '0.5'],
[160, '1', '0'],
[-160, '0', '1'],
[0, '0', '0'],
])('follows a drag of %i px with the stamps at Try it! %s, Pass %s', (dx, tryOpacity, passOpacity) => {
const { article, stamp, passStamp } = card()
expect(follow(article, dx, 0)).toBe(dragTransform(dx, 0))
expect(article.style.transform).toBe(dragTransform(dx, 0))
expect(stamp.style.opacity).toBe(tryOpacity)
expect(passStamp.style.opacity).toBe(passOpacity)
expect(animations).toEqual([])
})
it('unfollows at once, back to the stylesheet', () => {
const { article, stamp, passStamp } = card()
follow(article, -80, 12)
unfollow(article)
expect(article.getAttribute('style')).toBe('')
expect(stamp.getAttribute('style')).toBe('')
expect(passStamp.getAttribute('style')).toBe('')
expect(animations).toEqual([])
})
it('snaps back from the drag transform to rest, 420 ms spring, and hides the stamps', () => {
const { article, stamp, passStamp } = card()
const from = follow(article, 70, 30)
snapBack(article, from)
expect(article.style.transform).toBe('')
expect(stamp.style.opacity).toBe('')
expect(passStamp.style.opacity).toBe('')
expect(animations).toHaveLength(1)
expect(animations[0]!.target).toBe(article)
expect(animations[0]!.keyframes).toEqual([{ transform: 'translate(70px,9px) rotate(3.888888888888889deg)' }, { transform: 'translate(0px,0px) rotate(0deg)' }])
expect(animations[0]!.options).toEqual({ duration: 420, easing: 'cubic-bezier(.34,1.4,.64,1)' })
})
it('fails fast on a card without its parts', () => {
expect(() => tryIt(document.createElement('article'))).toThrow('deck card has no try-stamp')
expect(() => pass(document.createElement('article'))).toThrow('deck card has no shade')
expect(() => follow(document.createElement('article'), 10, 0)).toThrow('deck card has no try-stamp')
})
})
-178
View File
@@ -1,178 +0,0 @@
// The deck's motion, from the handoff's Motion §2 (Try it), §3 (Pass),
// §4 (swipe), §5 (deck advance) and §6 (reduced motion), with the Web
// Animations API. The exits take the card's starting transform so the swipe
// gesture can throw a dragged card from where it was let go.
const SPRING = 'cubic-bezier(.34,1.4,.64,1)'
const THROW = 'cubic-bezier(.55,0,.8,.4)'
const SOFT = 'cubic-bezier(.2,.8,.2,1)'
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
// REST is the top card's transform when nothing moves it, written as the
// translate + rotate a drag gives it so the exits interpolate function by
// function.
export const REST = 'translate(0px,0px) rotate(0deg)'
// DEPTH_TRANSFORM is each deck depth's transform, as the stylesheet places it.
export const DEPTH_TRANSFORM = ['translateY(0px) scale(1)', 'translateY(8px) scale(0.95)', 'translateY(16px) scale(0.9)'] as const
const FLY_OFF = 'translate(150%,-60px) rotate(28deg)'
const PASS_ORIGIN = '0% 50%'
// An Exit is a card leaving the deck: finished resolves once it is off
// screen and rejects with an AbortError if cancel stops it first. cancel
// also undoes a finished exit, putting the card back.
export type Exit = {
finished: Promise<void>
cancel: () => void
}
export function prefersReducedMotion(): boolean {
return window.matchMedia(REDUCED_MOTION_QUERY).matches
}
// isAbort says whether an exit's finished rejected because it was canceled.
export function isAbort(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError'
}
// bump is the press bump of the Try it and Pass buttons and the Quest log pill.
export function bump(element: Element): Animation {
return element.animate(
[{ transform: 'scale(1)' }, { transform: 'scale(.88)' }, { transform: 'scale(1.06)' }, { transform: 'scale(1)' }],
{ duration: 300, easing: 'ease-out' },
)
}
// tryIt pops the "Try it!" stamp and throws the card off to the right. From
// rest it wiggles first and winds up before the throw; from a dragged
// transform it skips both and throws faster.
export function tryIt(card: HTMLElement, from?: string): Exit {
const exit = recorder()
exit.run(part(card, 'try-stamp'), [
{ opacity: 0, transform: 'rotate(-12deg) scale(1.5)' },
{ opacity: 1, transform: 'rotate(-12deg) scale(1)' },
], { duration: 220, easing: SPRING, fill: 'forwards' })
const wiggled =
from === undefined
? exit.run(card, [
{ transform: 'rotate(0deg)' },
{ transform: 'rotate(-4deg)' },
{ transform: 'rotate(4deg)' },
{ transform: 'rotate(-3deg)' },
{ transform: 'rotate(2deg)' },
{ transform: 'rotate(0deg)' },
], { duration: 420, easing: 'ease-in-out' }).finished
: Promise.resolve()
const finished = wiggled.then(() =>
from === undefined
? exit.run(card, [
{ transform: REST },
{ transform: 'translate(-14px,4px) rotate(-3deg)', offset: 0.18 },
{ transform: FLY_OFF, opacity: 0 },
], { duration: 480, easing: THROW, fill: 'forwards' }).finished
: exit.run(card, [{ transform: from }, { transform: FLY_OFF, opacity: 0 }], { duration: 340, easing: THROW, fill: 'forwards' })
.finished,
)
return exit.done(finished)
}
// pass folds the card over its left edge like a page, darkening it, then
// drops it off to the left. A dragged card folds from where it was let go.
export function pass(card: HTMLElement, from: string = REST): Exit {
const exit = recorder()
exit.run(part(card, 'shade'), [{ opacity: 0 }, { opacity: 0.4, offset: 0.45 }, { opacity: 0.55 }], {
duration: 720,
fill: 'forwards',
})
const finished = exit.run(card, [
{ transformOrigin: PASS_ORIGIN, transform: `${from} rotateY(0deg)`, easing: 'cubic-bezier(.45,0,.85,.55)' },
{ transformOrigin: PASS_ORIGIN, transform: `${from} rotateY(-80deg)`, offset: 0.45, easing: 'cubic-bezier(.4,0,.9,.6)' },
{ transformOrigin: PASS_ORIGIN, transform: 'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)', opacity: 0 },
], { duration: 720, fill: 'forwards' }).finished
return exit.done(finished)
}
// dragTransform is the top card's transform dragged dx, dy from where the
// pointer went down.
export function dragTransform(dx: number, dy: number): string {
return `translate(${dx}px,${dy * 0.3}px) rotate(${dx / 18}deg)`
}
// follow moves a dragged card to its drag transform and shows each stamp as
// far as the drag has gone toward its side, fully at 100 px. It returns the
// transform, for the exit or snap-back the release starts.
export function follow(card: HTMLElement, dx: number, dy: number): string {
const transform = dragTransform(dx, dy)
card.style.transform = transform
part(card, 'try-stamp').style.opacity = String(clamp(dx / 100))
part(card, 'pass-stamp').style.opacity = String(clamp(-dx / 100))
return transform
}
// unfollow puts a dragged card and its stamps back where the stylesheet
// places them, at once.
export function unfollow(card: HTMLElement) {
card.style.removeProperty('transform')
part(card, 'try-stamp').style.removeProperty('opacity')
part(card, 'pass-stamp').style.removeProperty('opacity')
}
// snapBack springs a card let go short of a decision back to rest from its
// drag transform, and hides its stamps.
export function snapBack(card: HTMLElement, from: string): Animation {
unfollow(card)
return card.animate([{ transform: from }, { transform: REST }], { duration: 420, easing: SPRING })
}
// fadeOut is both exits under prefers-reduced-motion: reduce.
export function fadeOut(card: HTMLElement): Exit {
const exit = recorder()
return exit.done(exit.run(card, [{ opacity: 1 }, { opacity: 0 }], { duration: 200, fill: 'forwards' }).finished)
}
// promote moves the cards left after an exit, top first, from the depth
// behind to their own.
export function promote(cards: HTMLElement[]): Animation[] {
return cards.slice(0, DEPTH_TRANSFORM.length - 1).map((card, depth) =>
card.animate([{ transform: DEPTH_TRANSFORM[depth + 1] }, { transform: DEPTH_TRANSFORM[depth] }], {
duration: 300,
easing: SOFT,
}),
)
}
function clamp(value: number): number {
return Math.min(1, Math.max(0, value))
}
// part is the card's element marked data-part="name", which DeckCard renders.
function part(card: HTMLElement, name: string): HTMLElement {
const element = card.querySelector<HTMLElement>(`[data-part="${name}"]`)
if (element === null) throw new Error(`deck card has no ${name}`)
return element
}
// recorder keeps every animation an exit starts, so cancel can stop them all.
// A step that would start after cancel aborts instead.
function recorder() {
const animations: Animation[] = []
let canceled = false
return {
run(element: Element, keyframes: Keyframe[], options: KeyframeAnimationOptions): Animation {
if (canceled) throw new DOMException('The exit was canceled.', 'AbortError')
const animation = element.animate(keyframes, options)
animations.push(animation)
return animation
},
done(finished: Promise<unknown>): Exit {
return {
finished: finished.then(() => undefined),
cancel: () => {
canceled = true
animations.forEach((animation) => animation.cancel())
},
}
},
}
}
@@ -100,7 +100,6 @@ async function renderStep(enableNotifications?: EnableNotifications) {
element={<NotificationsStep enableNotifications={enableNotifications} />}
/>
<Route path="/" element={<h1>Home</h1>} />
<Route path="/onboarding/regulars" element={<h1>Regulars step</h1>} />
</Routes>
</AuthBootstrap>
</ApiProviders>
@@ -258,41 +257,6 @@ describe('onboarding step 3', () => {
})
})
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')
+4 -18
View File
@@ -2,7 +2,7 @@ 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 { useCompleteOnboarding } from '../settings/api.ts'
import { Button } from '../ui/Button.tsx'
import { Icon } from '../ui/Icon.tsx'
import { OnboardingStep } from './OnboardingStep.tsx'
@@ -50,15 +50,12 @@ export type NotificationsStepProps = {
// 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.
// onboarding is finished and goes Home.
export function NotificationsStep({ enableNotifications = notificationsUnavailable }: NotificationsStepProps) {
const navigate = useNavigate()
const complete = useCompleteOnboarding()
const [enabling, setEnabling] = useState(false)
const [problem, setProblem] = useState<string | null>(null)
const [fewRegulars, setFewRegulars] = useState(false)
const busy = enabling || complete.isPending
async function finish() {
@@ -66,19 +63,13 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
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()
setProblem(null)
setEnabling(true)
try {
await enableNotifications()
@@ -92,7 +83,7 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
}
function later() {
clearProblem()
setProblem(null)
void finish()
}
@@ -133,11 +124,6 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
{problem}
</p>
)}
{fewRegulars && (
<Button variant="outline" className={styles.recover} onClick={() => void navigate('/onboarding/regulars')}>
Add Regulars
</Button>
)}
</OnboardingStep>
)
}
-5
View File
@@ -229,11 +229,6 @@
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;
}
@@ -146,14 +146,6 @@ describe('onboarding step 2 (mock 1h)', () => {
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)
+6 -4
View File
@@ -2,7 +2,6 @@ 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'
@@ -17,8 +16,7 @@ import styles from './Onboarding.module.css'
// 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.
// commonPicks are offered as one-tap Regulars until the household has them.
export const commonPicks = [
'Beef tacos',
'Spaghetti bolognese',
@@ -30,6 +28,10 @@ export const commonPicks = [
'Fried rice',
]
function sameName(a: string, b: string): boolean {
return a.trim().toLowerCase() === b.trim().toLowerCase()
}
// 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.
@@ -41,7 +43,7 @@ export function RegularsStep() {
const [draft, setDraft] = useState('')
const { refusals, track } = useRefusals()
const list = regulars.data ?? []
const picks = commonPicks.filter((pick) => !list.some((regular) => similarNames(regular.name, pick)))
const picks = commonPicks.filter((pick) => !list.some((regular) => sameName(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)
@@ -43,13 +43,6 @@
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;
+8 -20
View File
@@ -192,33 +192,21 @@ describe('RegularsSettingsPage', () => {
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'
it('adds a Regular from a pasted recipe link', async () => {
const link = 'https://example.com/recipes/12345/beef-stew/'
const server = stubServer([], {
'POST /api/v1/dinners': (body) => {
const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' })
'POST /api/v1/dinners': () => {
const created = dinnerBody('d9', 'Beef stew', { grounding_status: 'pending', recipe_url: link })
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)
add(link)
expect(await screen.findByText('Beef stew')).toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual([{ request: 'POST /api/v1/dinners', body: { recipe_url: link } }])
expect(within(row('Beef stew')).getByText('Finding recipe…')).toBeInTheDocument()
})
it('takes back an add the server refuses and says why', async () => {
+19 -44
View File
@@ -49,14 +49,12 @@ export const methodLabels: Record<Method, string> = {
const difficultyLabels: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
// A pasted link is added as a recipe link under a name the household gives;
// anything else is a dinner name.
// A pasted link is added as a recipe link; 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
// field at the top adds one by name or pasted recipe link; tapping one
// edits or removes it, or fixes its recipe when none was found.
export function RegularsSettingsPage() {
const navigate = useNavigate()
@@ -87,24 +85,19 @@ function RegularsManager() {
const favorite = useToggleFavorite()
const { refusals, track } = useRefusals()
const [draft, setDraft] = useState('')
const [linkName, setLinkName] = useState('')
const [open, setOpen] = useState<Open>(null)
const headingId = useId()
const linkHintId = useId()
const isLink = LINK.test(draft.trim())
const ready = draft.trim() !== '' && (!isLink || linkName.trim() !== '')
const list = regulars.data ?? []
// A Regular removed meanwhile closes its sheet.
const opened = open && list.find((regular) => regular.id === open.id && !regular.pending)
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!ready) return
const text = draft.trim()
const body: NewRegular = isLink ? { name: linkName.trim(), recipe_url: text } : { name: text }
track(`add:${body.name.toLowerCase()}`, `add ${body.name}`, add.mutateAsync(body))
if (text === '') return
const body: NewRegular = LINK.test(text) ? { recipe_url: text } : { name: text }
track(`add:${text.toLowerCase()}`, `add ${text}`, add.mutateAsync(body))
setDraft('')
setLinkName('')
}
function onRemove(regular: Regular) {
@@ -118,38 +111,20 @@ function RegularsManager() {
return (
<>
<form className={styles.addForm} onSubmit={onSubmit}>
<div className={styles.add}>
<TextInput
label="Add a Regular"
labelHidden
className={styles.addInput}
placeholder="Dinner name or recipe link"
autoComplete="off"
enterKeyHint="done"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<IconButton type="submit" label="Add Regular" tone="accent" disabled={!ready}>
<Icon icon={Plus} size="nav" />
</IconButton>
</div>
{isLink && (
<>
<TextInput
label="Dinner name"
placeholder="What do you call this dinner?"
autoComplete="off"
enterKeyHint="done"
aria-describedby={linkHintId}
value={linkName}
onChange={(event) => setLinkName(event.target.value)}
/>
<p id={linkHintId} className={styles.hint}>
Name it yourself: a link can carry private details, so it never names a dinner.
</p>
</>
)}
<form className={styles.add} onSubmit={onSubmit}>
<TextInput
label="Add a Regular"
labelHidden
className={styles.addInput}
placeholder="Dinner name or recipe link"
autoComplete="off"
enterKeyHint="done"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<IconButton type="submit" label="Add Regular" tone="accent" disabled={draft.trim() === ''}>
<Icon icon={Plus} size="nav" />
</IconButton>
</form>
{refusals.map((refusal) => (
<p key={refusal.key} role="alert" className={styles.problem}>
-12
View File
@@ -441,18 +441,6 @@ export function householdSettingsProblems(error: Error): HouseholdSettingsProble
return problems
}
// needsMoreRegulars reports whether error is the server refusing to finish
// onboarding because the household has fewer than three Regulars
// (internal/household MinRegulars) — the only refusal of
// {"onboarding_completed": true}.
export function needsMoreRegulars(error: Error): boolean {
return (
error instanceof ApiError &&
error.code === 'invalid_settings' &&
error.message.startsWith(`${invalidHouseholdPrefix}onboarding_completed: `)
)
}
// aiStatus is the AI assistant row's status line: the provider and the
// model's display name, or that no model is chosen yet (the seed).
export function aiStatus(settings: Pick<AISettings, 'provider' | 'model'>): string {
-47
View File
@@ -1,47 +0,0 @@
// Runs the service worker's activate handler against mocked Cache Storage,
// with the Workbox precache and router stubbed out. tsconfig.sw.json checks
// this file with the worker's types, like src/sw.ts itself.
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('workbox-precaching', () => ({
PrecacheFallbackPlugin: class {},
matchPrecache: vi.fn(),
precacheAndRoute: vi.fn(),
}))
vi.mock('workbox-routing', () => ({ registerRoute: vi.fn() }))
describe('service worker activation', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('deletes the v1 API cache and keeps the precache, photo and font caches', async () => {
// A household on the v1 worker: its API cache held dinners with the
// pasted recipe_url; its photos and fonts are not in the precache.
const names = [
'mealquest-precache-v1',
'mealquest-api-v1',
'mealquest-media-v1',
'mealquest-fonts-v1',
'other-app-cache',
]
const cacheStorage = {
keys: vi.fn(async () => names),
delete: vi.fn(async () => true),
}
vi.stubGlobal('caches', cacheStorage)
vi.stubGlobal('__WB_MANIFEST', [])
await import('./sw')
const pending: Promise<unknown>[] = []
const activate = Object.assign(new Event('activate'), {
waitUntil: (promise: Promise<unknown>) => pending.push(promise),
})
self.dispatchEvent(activate)
await Promise.all(pending)
expect(pending).toHaveLength(1)
expect(cacheStorage.delete).toHaveBeenCalledWith('mealquest-api-v1')
expect(cacheStorage.delete).toHaveBeenCalledTimes(1)
})
})
+2 -2
View File
@@ -18,7 +18,7 @@ import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
import { createApiStrategy } from './sw/apiStrategy'
import {
cacheNames,
cacheVersions,
cacheVersion,
routeFor,
staleCaches,
type Route,
@@ -29,7 +29,7 @@ declare const self: ServiceWorkerGlobalScope & {
}
// The precache becomes mealquest-precache-v1.
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersions.precache })
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersion })
const day = 24 * 60 * 60
+1 -13
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { cacheFallbackHeader, cacheNames, cacheVersions, markCacheFallback, routeFor, staleCaches, type Route } from './routes'
import { cacheFallbackHeader, cacheNames, markCacheFallback, routeFor, staleCaches, type Route } from './routes'
const origin = 'https://mealquest.test'
@@ -146,18 +146,6 @@ describe('cache names', () => {
it('all start with mealquest-', () => {
for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/)
})
it('retire only the v1 API cache', () => {
// v1 API responses held dinners with the pasted recipe_url, which can
// carry a personal token; offline, the worker would still serve them.
// The precache, photos and fonts keep their v1 caches.
expect(cacheNames).toEqual({
api: 'mealquest-api-v2',
media: 'mealquest-media-v1',
fonts: 'mealquest-fonts-v1',
})
expect(cacheVersions.precache).toBe('v1')
})
})
describe('staleCaches', () => {
+5 -14
View File
@@ -24,23 +24,14 @@ export interface RouteRequest {
mode: RequestMode
}
// Each cache carries its own version. Bumping one renames only that cache;
// activate then deletes its old name and keeps every other cache, so saved
// photos and fonts stay available offline.
export const cacheVersions = {
precache: 'v1',
// v2 retires the v1 API cache, whose dinner responses still carried the
// pasted recipe_url.
api: 'v2',
media: 'v1',
fonts: 'v1',
} as const
// Bumping the version renames every cache; activate then deletes the old ones.
export const cacheVersion = 'v1'
export const cachePrefix = 'mealquest-'
export const cacheNames = {
api: `${cachePrefix}api-${cacheVersions.api}`,
media: `${cachePrefix}media-${cacheVersions.media}`,
fonts: `${cachePrefix}fonts-${cacheVersions.fonts}`,
api: `${cachePrefix}api-${cacheVersion}`,
media: `${cachePrefix}media-${cacheVersion}`,
fonts: `${cachePrefix}fonts-${cacheVersion}`,
} as const
export const networkTimeoutSeconds = 3
-1
View File
@@ -2,7 +2,6 @@
// source: they pin the parts routes.ts cannot express — the API cache's
// strategy, the font strategy's expiration, the font route's place ahead of
// the precache route and network-only requests left to the browser.
// src/sw.test.ts runs the worker's activation against mocked Cache Storage.
import { describe, expect, it } from 'vitest'
import source from '../sw.ts?raw'
-52
View File
@@ -1,52 +0,0 @@
import { vi } from 'vitest'
// A FakeAnimation is one Element.animate call: its target and arguments, and
// a finished promise the test settles with finish (or cancel, which rejects
// it with an AbortError while it runs).
export type FakeAnimation = {
target: Element
keyframes: Keyframe[]
options: KeyframeAnimationOptions
finished: Promise<FakeAnimation>
finish: () => void
cancel: () => void
playState: 'running' | 'finished' | 'idle'
}
// stubAnimate replaces Element.prototype.animate (jsdom has no Web Animations
// API) and returns every animation it starts, in order. With autoFinish each
// animation finishes as it starts; otherwise the test finishes it. Remove the
// stub with Reflect.deleteProperty(Element.prototype, 'animate').
export function stubAnimate({ autoFinish }: { autoFinish: boolean }): FakeAnimation[] {
const animations: FakeAnimation[] = []
Element.prototype.animate = vi.fn(function (this: Element, keyframes, options) {
let resolve!: (animation: FakeAnimation) => void
let reject!: (error: DOMException) => void
const animation: FakeAnimation = {
target: this,
keyframes: keyframes as Keyframe[],
options: options as KeyframeAnimationOptions,
finished: new Promise((res, rej) => {
resolve = res
reject = rej
}),
playState: 'running',
finish() {
if (animation.playState !== 'running') throw new Error('finish on an animation that is not running')
animation.playState = 'finished'
resolve(animation)
},
cancel() {
if (animation.playState === 'running') reject(new DOMException('The animation was canceled.', 'AbortError'))
animation.playState = 'idle'
},
}
// Browsers mark finished as handled, so a canceled animation nobody waits
// on is not an unhandled rejection.
animation.finished.catch(() => {})
animations.push(animation)
if (autoFinish) animation.finish()
return animation as unknown as Animation
}) as Element['animate']
return animations
}
+2
View File
@@ -10,6 +10,7 @@ export type DinnerBody = {
cuisine: string | null
is_favorite: boolean
recipe_id: string | null
recipe_url: string | null
photo_url: string | null
grounding_status: string
grounding_error: string | null
@@ -32,6 +33,7 @@ export function dinnerBody(id: string, name: string, overrides: Partial<DinnerBo
cuisine: null,
is_favorite: false,
recipe_id: null,
recipe_url: null,
photo_url: null,
grounding_status: 'grounded',
grounding_error: null,
-19
View File
@@ -29,20 +29,6 @@
height: 62px;
}
/* A shown label sits 6 px under the circle, and the button grows to hold it. */
.labelled {
flex-direction: column;
gap: 6px;
width: auto;
min-width: 62px;
height: auto;
min-height: 62px;
color: var(--color-neutral-700);
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
}
.face {
display: inline-flex;
align-items: center;
@@ -71,11 +57,6 @@
color: var(--color-text);
}
.neutral {
background: var(--color-neutral-100);
color: var(--color-text);
}
.accent {
background: var(--color-accent);
color: var(--on-accent);
-18
View File
@@ -1,6 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { declaredStyle } from '../test/cssRules.ts'
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
import { IconButton } from './IconButton.tsx'
@@ -32,21 +31,4 @@ describe('IconButton', () => {
expectMinHitArea(button)
expect(hitArea(button)).toEqual({ width: 62, height: 62 })
})
it('shows its label as an 11 px caption under the circle, which names it', () => {
render(
<IconButton label="Pass" size={62} tone="neutral" showLabel>
<svg />
</IconButton>,
)
const button = screen.getByRole('button', { name: 'Pass' })
expect(button).not.toHaveAttribute('aria-label')
expect(button).toHaveTextContent('Pass')
expect(getComputedStyle(button).fontSize).toBe('11px')
expect(getComputedStyle(button).flexDirection).toBe('column')
expectMinHitArea(button)
const face = button.firstElementChild!
expect(hitArea(face)).toEqual({ width: 62, height: 62 })
expect(declaredStyle(face).background).toBe('var(--color-neutral-100)')
})
})
+5 -11
View File
@@ -2,38 +2,32 @@ import type { ComponentPropsWithRef } from 'react'
import styles from './IconButton.module.css'
export type IconButtonSize = 40 | 62
export type IconButtonTone = 'surface' | 'neutral' | 'accent' | 'teal'
export type IconButtonTone = 'surface' | 'accent' | 'teal'
export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'> & {
// Accessible name; an icon alone never names a button.
label: string
size?: IconButtonSize
tone?: IconButtonTone
// Shows the label as an 11 px caption under the circle, which then names
// the button.
showLabel?: boolean
}
// Round icon button, named by its label and showing it only with showLabel.
// The 40 px size keeps a 44 px hit box (see the CSS); the 62 px size is the
// round action button with --shadow-md.
// Round icon-only button. The 40 px size keeps a 44 px hit box (see the CSS);
// the 62 px size is the round action button with --shadow-md.
export function IconButton({
label,
size = 40,
tone = 'surface',
showLabel = false,
type = 'button',
className,
children,
...rest
}: IconButtonProps) {
const classes = [styles.button, styles[`size${size}`], showLabel && styles.labelled, className].filter(Boolean).join(' ')
const classes = [styles.button, styles[`size${size}`], className].filter(Boolean).join(' ')
return (
<button type={type} aria-label={showLabel ? undefined : label} className={classes} {...rest}>
<button type={type} aria-label={label} className={classes} {...rest}>
<span className={[styles.face, styles[tone]].join(' ')} aria-hidden="true">
{children}
</span>
{showLabel && label}
</button>
)
}
+1 -1
View File
@@ -19,5 +19,5 @@
"noEmit": true
},
"include": ["src", "vite.config.ts", "manifest.json"],
"exclude": ["src/sw.ts", "src/sw.test.ts"]
"exclude": ["src/sw.ts"]
}
+1 -1
View File
@@ -4,6 +4,6 @@
"lib": ["ES2022", "WebWorker"],
"types": []
},
"include": ["src/sw.ts", "src/sw.test.ts"],
"include": ["src/sw.ts"],
"exclude": []
}