Compare commits

..
Author SHA1 Message Date
orchestrator-agent 63ed5a67a8 Onboarding step 3 push opt-in and test-push endpoint (Feature 19) (#168)
CI / ci (push) Successful in 7m17s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 17:37:53 -04:00
orchestrator-agent 51e0213e92 Wire plan_ready, list_ready, new_suggestions and Send to Google Chat (Feature 19) (#167)
CI / ci (push) Successful in 7m2s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 16:42:56 -04:00
orchestrator-agent bbe031ec8d Notification preferences API and screen (1n) (Feature 19) (#166)
CI / ci (push) Successful in 5m53s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 15:19:36 -04:00
orchestrator-agent fc5b926edf Google Chat webhook sender and settings API (Feature 19) (#165)
CI / ci (push) Successful in 5m47s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 14:35:32 -04:00
orchestrator-agent d5ab945ee7 Web Push sender (Feature 19) (#164)
CI / ci (push) Successful in 5m31s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 13:45:21 -04:00
orchestrator-agent 104396da55 notify package and durable outbox worker (Feature 19) (#161)
CI / ci (push) Successful in 5m18s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 13:05:39 -04:00
orchestrator-agent c8e5829a77 Service worker push handlers and client subscribe (Feature 19) (#156)
CI / ci (push) Successful in 6m37s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 11:14:20 -04:00
dev_agent 7a2f80cc09 Merge remote-tracking branch 'origin/dev' into HEAD
CI / ci (push) Successful in 5m21s
2026-10-09 10:11:09 -04:00
orchestrator-agent 3ba2be05e9 VAPID config and push subscription API (Feature 19) (#153)
CI / ci (push) Failing after 6m4s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 08:42:12 -04:00
121 changed files with 219 additions and 15983 deletions
+2 -39
View File
@@ -23,9 +23,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
@@ -167,16 +165,6 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
ideasSvc, err := ideas.NewService(db, grounder, ideaFinder(ai, guard), quickIdeaFinder(ai, guard), runner, logger)
if err != nil {
return err
}
homeSvc, err := home.NewService(settings, planSvc, suggestSvc, shopSvc, notifier)
if err != nil {
return err
}
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil {
return err
@@ -193,7 +181,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, homeSvc, runner, photos, frontend.Assets())
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, runner, photos, frontend.Assets())
if err != nil {
return err
}
@@ -323,8 +311,7 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
// service registers its own kinds when it is built, before the runner
// starts (dinners.NewService registers dinners.KindGroundDinner and
// suggest.NewService suggest.KindSuggestionBatch, plan.NewService
// plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun,
// ideas.NewService ideas.KindFromIngredients and ideas.KindQuick).
// plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun).
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
builtins, err := jobs.RegisterBuiltins(runner)
if err != nil {
@@ -381,30 +368,6 @@ func archiveTitler(ai *aisettings.Service, guard *harness.Guard) shopping.Titler
}
}
// ideaFinder runs ideas_from_ingredients with the AI settings current at
// each run, sharing guard.
func ideaFinder(ai *aisettings.Service, guard *harness.Guard) ideas.Generator {
return func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
cfg, err := ai.HarnessConfig(ctx, guard)
if err != nil {
return skills.Ideas{}, err
}
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
}
}
// quickIdeaFinder runs quick_ideas with the AI settings current at each
// run, sharing guard.
func quickIdeaFinder(ai *aisettings.Service, guard *harness.Guard) ideas.QuickGenerator {
return func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
cfg, err := ai.HarnessConfig(ctx, guard)
if err != nil {
return skills.QuickIdeaList{}, err
}
return skills.QuickIdeas(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) {
+3 -192
View File
@@ -206,30 +206,6 @@ automatic** (D-021).
sets status `archived` and keeps the row; `POST
/api/v1/dinners/{id}/favorite` toggles `is_favorite`. Every write records
the session's subject as `updated_by`; an unknown id is 404.
`GET /api/v1/dinners/{id}` returns the dinner with its recipe, read in
one transaction: `{id, name, status, origin, method, minutes_total,
difficulty, cuisine, is_favorite, photo_urls, ingredients, steps,
source, grounding_status, grounding_error, why_picked}`. `photo_urls` is
`{"400": url, "1200": url}` (`/media/photos/{photo_id}-{size}.jpg`) or
null with no photo; `ingredients` is `[{name, qty, unit, note,
raw_text}]` and `steps` the step texts, both in recipe position order and
empty (`[]`) with no recipe, as for a dinner whose grounding is
`pending` or `failed`; a recipe entered by hand has only `raw_text`.
Two items at one position leave recipe order unknown, so such a recipe
is 500 rather than shown out of order. `source` is `{site_name}` of the
page the recipe was fetched from, null for a manual recipe or none; it
has no `url`, as the page's link may carry a personal token.
`site_name` is the name the page gives its site (publisher or
`og:site_name`) and null otherwise: a page naming no site is stored
under its host, which may be the pasted link's — a token in a
subdomain — or a redirect's, so any stored name that could be a host
(the link's host, an IP address or a dotted host name, even a real
name like "Food.com") is withheld. The detail screen shows "Recipe
from {site}" only when `site_name` is not null. `why_picked` is the reason of the
suggestion that created the dinner — its pending or accepted one, as
declining a suggestion archives its dinner — null for one no suggestion
created; a dinner with more than one such suggestion is 500, as nothing
records which created it. An unknown or archived dinner is 404.
- Dinner grounding. `POST /api/v1/dinners` also enqueues a `ground_dinner`
job and answers with its `grounding_job_id` without waiting for it
(`GET /api/v1/jobs/{id}` reports it); if the job cannot be enqueued it
@@ -334,89 +310,6 @@ automatic** (D-021).
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.
- Cook from what I have (`ideas.Service`, Feature 18). `POST
/api/v1/ideas/from-ingredients {ingredients[], quick_only}` (both
required) checks the list with `skills.CleanIngredients` — 1 to 20
entries, each non-blank, at most 80 characters, with a non-empty
`harness.Normalize` form that no earlier entry shares — answering 400
`invalid_request` otherwise, then enqueues an `ideas_from_ingredients`
job (service-only: `POST /jobs` refuses the kind) and answers 202
`{jobId}` without waiting. The job runs `ideas_from_ingredients` (scope:
`skills.IdeasScope`, the flag and the normalized list in order), then
grounds its ideas in the model's order with `Grounder.GroundEach`, as
many as are still wanted at a time, until 5 are kept or none are left.
An idea no recipe grounds is dropped; with `quick_only`, so is one whose
grounded recipe is not a Quick dinner (`ideas.IsQuick`: method
`crockpot`, or `minutes_total` ≤ 30 — the recipe's, never the model's
`est_minutes`). Ideas are not stored as dinners. The job publishes
`{stage, ingredients, quick_only, ideas: [{name, uses, missing, summary,
method, minutes_total, difficulty, quick, recipe_id, photo_url,
source_url, site_name}], dropped}` at each stage (`suggesting`,
`grounding`, `done`) and again as each idea is kept, the moment its
grounding finishes, so the result fills idea by idea; the ideas shown
are always in the model's order, an earlier idea that grounds later
taking its place before those shown. It fails with `no_ideas_found`
(every idea was dropped), `ai_failed` (the skill or `normalize_recipe`
failed; no idea is made up instead, D-017), `search_not_configured` or
`search_failed`. A failed job shows no ideas, even those kept before the
failure: the kind registers a `FailedResult` view, so `GET
/api/v1/jobs/{id}` empties the `ideas` of any failed job's result on
read, however it failed (including `interrupted by restart` and an
`internal_error` whose clearing write failed). Before it fails it also
publishes its result again with no ideas, to tidy what is stored.
- Quick dinners (`ideas.Service`, Feature 18). `POST /api/v1/ideas/quick
{kind, exclude[]}` (both required; `exclude` is `[]` on a first page)
checks `kind` with `skills.CheckQuickKind` — `crockpot` or `under30`,
no default — and `exclude` with `skills.CleanExclude` — at most 60
names, each non-blank, at most 120 characters, with a non-empty
`harness.Normalize` form no earlier name shares — answering 400
`invalid_request` otherwise, then enqueues a `quick_ideas` job
(service-only) and answers 202 `{jobId}` without waiting. The job reads
the household context (`harness.BuildContext`, budget 16000), runs
`quick_ideas` (scope: `skills.QuickIdeasScope`, the kind and the
normalized excluded names in order), then grounds its ideas exactly as
the ingredients job does (the same `Grounder.GroundEach` rounds, model
order and failure codes) until 6 are kept or none are left. The kind is
enforced on the grounded recipe, never the model's estimate
(`ideas.OfQuickKind`): `crockpot` keeps method `crockpot`
(`normalize_recipe` writes a slow cooker as `crockpot`), `under30`
keeps a known `minutes_total` ≤ 30; every other idea is dropped. The
model is asked for 12, more than a page, so dropped ideas can be
replaced; when fewer than 6 survive the page shows only those, never
padded with an idea not of the kind. The job publishes `{stage, kind,
exclude, ideas: [{name, method, minutes_total, difficulty, recipe_id,
photo_url, source_url, site_name}], dropped}` at each stage and as each
idea is kept, fails with the same reasons, and a failed job shows no
ideas through the same `FailedResult` view. "↻ Show me more" posts
again with every name the tab has shown so far in `exclude`.
- Idea actions (Feature 18). Both act on idea `{i}` (0, 1, …; anything
else is 400 `invalid_request`) of a **succeeded** `ideas_from_ingredients`
or `quick_ideas` job, read from its stored result
(`ideas.Service.Idea`): its name and `recipe_id`, never grounded again.
A job still queued or running is 409 `ideas_not_ready` (its ideas may
still move); an unknown job, another kind, a failed job or an index past
its ideas is 404 `not_found`. `POST
/api/v1/ideas/{jobId}/items/{i}/quest-log` (no body) runs
`dinners.Service.AddToQuestLog`: in one transaction, an unarchived
dinner the idea repeats — the same rule that refuses a duplicate Regular
(`harness.Similar` name, or the recipe's link) — is answered 200 as it
is (a Regular stays a Regular); one awaiting the household's choice
(`suggested`, `tried`) is 409 `duplicate_dinner` and changes nothing;
otherwise it creates a dinner with status `quest_log`, origin
`ingredients` or `quick`, grounded in the idea's recipe (its method,
minutes, difficulty and photo), answered 201. Repeating the call
answers the same dinner. `POST /api/v1/ideas/{jobId}/items/{i}/cook-tonight`
(no body) takes today as the household-local date in the household
timezone and its Monday (`plan.Service.Tonight`); when that week has no
plan it answers 409 `no_plan` and changes nothing — no dinner, no plan
(a plan holds all seven days; the household plans the week first).
Otherwise it adds or finds the dinner as `quest-log` does and puts it
on today through `plan.Service.Swap`, exactly like a Draft board swap
(plan marked edited, revision raised, a synced plan becomes `changed`,
the day's calendar event id cleared, `is_new` for a Quest log dinner),
answering 200 `{dinner, plan}`. The week is always today's own, so no
other day is written; a dinner already on another day of the week is
409 `dinner_already_planned`.
`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}],
@@ -579,32 +472,6 @@ automatic** (D-021).
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.
- **Ideas from ingredients** (`skills.IdeasFromIngredients`, skill
`ideas_from_ingredients` v1): from the household's ingredient list (the
"Ingredients on hand" context section) and the Quick only flag, asks for
8 ideas `{name, uses[], missing[], est_minutes, method, difficulty,
search_query}` at the household temperature. Malformed replies (a
missing field, a bad enum, an empty name, more than 8 ideas) get the one
repair turn. The invariant is enforced in Go: a `uses` entry is kept
only when its `harness.Normalize` form is a listed ingredient's, and is
then written as the household wrote it, once, in the household's order;
`missing` loses blanks, repeats and anything listed. An idea left using
none of the listed ingredients, or repeating an earlier idea
(`harness.Similar`), is dropped into `Dropped`. Each kept idea's summary
line (`skills.IdeaSummary`) is computed from the corrected lists: "Uses
all 5 · missing: 1 lime" when every ingredient is used, "Uses 3 of 5 ·
missing: …" otherwise, with "nothing missing" in place of the list when
nothing is.
- **Quick ideas** (`skills.QuickIdeas`, skill `quick_ideas` v1): from the
household context, a kind (`crockpot` or `under30`, which picks the
prompt's rule) and the names already shown (the "Already shown" context
section), asks for 12 ideas `{name, est_minutes, method, difficulty,
search_query}` at the household temperature. Malformed replies (a
missing field, a bad enum, an empty name, more than 12 ideas) get the
one repair turn. An idea repeating an excluded name or an earlier idea
(`harness.Similar`, on the normalized names) is dropped into `Dropped`,
so an excluded name is never returned. Whether an idea is of the kind
is left to the grounded recipe (see Quick dinners above).
- **Supply run titles** (`skills.TitleArchive`, skill `archive_title`
v1): from the names of the week's planned dinners in day order, writes
`{title}`, the Supply run's post title in the Supply archive, at the
@@ -760,8 +627,7 @@ untitled (the job fails `title_failed`), and stores nothing. The
candidates `Ground` would try, without grounding any. Search failures
wrap `recipes.ErrSearchFailed` and model provider failures
`recipes.ErrNormalizeFailed`. `GroundMany` grounds items 3 at a time and returns one result per
item, so one failure never affects its siblings; `GroundEach` grounds
the same way but reports each item, one at a time, as it finishes. A recipe is usable only
item, so one failure never affects its siblings. A recipe is usable only
with ingredients, an image and, if given, a positive total time.
## Scheduler
@@ -793,13 +659,13 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
- Where each event is emitted, with its idempotency key. Each is stored by
`notify.EmitTx` in the transaction of the change it is about, so the
change and its notification commit together or not at all:
- `plan_ready:{weekStart}` (URL `/plan?week={weekStart}`) when a draft job stores the
- `plan_ready:{weekStart}` (URL `/plan`) when a draft job stores the
AI's draft of a week. Only the first draft of a week notifies: a
redraft (Rebuild week) has the same key, and a manual swap never
drafts. Its title is "Next week's plan is ready" (or "This week's",
or "The plan for the week of Oct 26") and its body the drafted
dinners Monday to Sunday.
- `list_ready:{weekStart}` (URL `/plan/shopping?week={weekStart}`) when a week's Supply
- `list_ready:{weekStart}` (URL `/plan/shopping`) when a week's Supply
run is first created, by `POST /shopping/{weekStart}/generate` or
after Push to Calendar; remaking it does not notify.
- `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or
@@ -1089,61 +955,6 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
for any other `by`. A frozen list is read through `GET
/api/v1/shopping/{weekStart}` with `status` `archived`.
## Home API (Feature 21)
- `GET /api/v1/home` (`home.Service`) aggregates the Home screen from the
services that own each value, at "now" in the household's zone (never
UTC), and answers:
```json
{
"dateLabel": "Friday, Oct 2",
"timezone": "America/Chicago",
"tonight": {"id": "…", "name": "…", "photo": "/media/…", "origin": "regular", "minutes_total": 40},
"sideQuestCount": 10,
"shoppingList": {"weekStart": "2026-10-05", "label": "Week 41 · Oct 5 – 11", "ready": true, "sentToChatAt": null},
"recentUpdates": {"unread": true}
}
```
`tonight` is today's day of the current week's plan (`plan.Get`), null
when the week has no plan; it changes at the household's local
midnight, so the PWA reads Home again at the next local midnight of
`timezone`, the household's IANA zone. `photo` and `minutes_total` are
null when unknown. `sideQuestCount` is the pending cards of the deck,
read without writing (`suggest.PendingCount`): a Home read never fails
orphaned batches the way a `suggest.Deck` read does.
`shoppingList` is the upcoming week's Supply run when it has one, else
the current week's (`shopping.Get`); with neither, it is the current
week with `ready` false. `recentUpdates.unread` is true when a
notification was stored in `notification_outbox` after
`household_settings.updates_seen_at`, or at all while that is null
(`notify.StoredSince`); an event whose channels are all switched off is
never stored and never counts.
- `GET /api/v1/updates` (`home.Service.Updates`, over `notify.Recent`)
answers the Recent updates sheet: the last 10 distinct events in
`notification_outbox`, newest first, one per event key whatever its
channels or delivery status (an event stored for push and Google Chat is
one update):
```json
{"updates": [{"type": "plan_ready", "title": "…", "body": "…", "url": "/plan?week=2026-10-12", "created_at": "2026-10-09T14:00:00.000Z"}]}
```
Every field comes from the stored payload, which never holds a webhook
URL or secret; `url` is the same-origin path the event opens.
- `POST /api/v1/updates/seen` with `{"through": "<created_at>"}` (CSRF
header required) records that the household has seen its updates
through the newest one it was shown, and answers 204. The last-seen
instant is `household_settings.updates_seen_at` — the one place it is
stored. It only moves forward (`MarkUpdatesSeen`), so a `through` older
than the stored one is accepted and changes nothing, and an event
stored after `through` keeps `unread` true. It is not a settings edit:
`updated_by` and `updated_at` are left as they are and settings
subscribers are not told. 400 `invalid_request` for a body that is not
exactly that object, a `through` that is not RFC 3339, or one after
now.
## Frontend
- React 19, TypeScript, Vite, React Router 7, TanStack Query 5,
@@ -1,356 +0,0 @@
package skills
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
const (
// IdeasRequested is how many ideas ideas_from_ingredients asks the
// model for; validation and grounding drop some.
IdeasRequested = 8
// IdeasKept is the most ideas the household is shown.
IdeasKept = 5
// MaxIngredients is the most ingredients the household may list.
MaxIngredients = 20
// IngredientLimit is the most characters one listed ingredient may
// have.
IngredientLimit = 80
)
// IdeasRequest is what ideas_from_ingredients reads.
type IdeasRequest struct {
// Ingredients are the ingredients the household has on hand, in its
// order, as CleanIngredients returns them.
Ingredients []string
// QuickOnly asks the model for Quick dinners only. It is a hint: the
// caller enforces it after grounding, from the grounded recipe.
QuickOnly bool
}
// Idea is one dinner idea. Method, EstMinutes and Difficulty are the
// model's estimates until the idea is grounded in a recipe.
type Idea struct {
Name string `json:"name"`
// Uses are the household's ingredients the idea uses, as the household
// wrote them and in its order. PostProcess drops every entry that is
// not one of them.
Uses []string `json:"uses"`
// Missing are the ingredients the idea needs that the household does
// not have, in the model's order.
Missing []string `json:"missing"`
EstMinutes int `json:"est_minutes"`
// Method is one of stovetop, oven, crockpot, grill and other.
Method string `json:"method"`
// Difficulty is easy, medium or hard.
Difficulty string `json:"difficulty"`
// SearchQuery is a web search query that finds a recipe for the dinner.
SearchQuery string `json:"search_query"`
// Summary is the line the household reads under the idea, computed
// by IdeaSummary from Uses and Missing. It is never part of the
// model's reply.
Summary string `json:"-"`
}
// Ideas is ideas_from_ingredients' output.
type Ideas struct {
// Ideas are the ideas that passed every rule, in the model's order.
Ideas []Idea `json:"ideas"`
// Dropped are the ideas the model gave that broke a rule, in the
// model's order. It is never part of the model's reply.
Dropped []DroppedIdea `json:"-"`
}
// DroppedIdea is an idea dropped by PostProcess.
type DroppedIdea struct {
Name string
// Why says which rule it broke.
Why string
}
// IdeasFromIngredients runs ideas_from_ingredients v1 on in and returns
// ideas whose uses hold only the household's own ingredients: an entry
// naming anything else is removed, and an idea left using none of them is
// dropped, as is one repeating an earlier idea. Each kept idea carries its
// summary line, computed from the corrected uses and missing lists. scope
// names the request (see harness.Run; IdeasScope). An ingredient list
// CleanIngredients refuses fails before any LLM call.
func IdeasFromIngredients(ctx context.Context, cfg harness.Config, scope string, in IdeasRequest) (Ideas, error) {
ings, err := CleanIngredients(in.Ingredients)
if err != nil {
return Ideas{}, fmt.Errorf("skills: ideas_from_ingredients: %w", err)
}
in.Ingredients = ings
return harness.Run(ctx, cfg, ideasFromIngredients{in: in}, scope, in)
}
// IdeasScope is the harness scope of a request: its normalized
// ingredients, in order, and whether it is for Quick dinners only, so two
// requests share a run only when the model would be asked the same.
func IdeasScope(in IdeasRequest) string {
parts := make([]string, len(in.Ingredients))
for i, ing := range in.Ingredients {
parts[i] = harness.Normalize(ing)
}
return "quick_only=" + strconv.FormatBool(in.QuickOnly) + ";" + strings.Join(parts, "|")
}
// CleanIngredients returns the household's ingredient list with each entry
// on one line, or why it cannot be used: it is empty or longer than
// MaxIngredients, or an entry is blank, longer than IngredientLimit
// characters, has nothing left once normalized (harness.Normalize), or
// normalizes the same as an earlier one.
func CleanIngredients(list []string) ([]string, error) {
if len(list) == 0 {
return nil, errors.New("list at least one ingredient")
}
if len(list) > MaxIngredients {
return nil, fmt.Errorf("%d ingredients listed; list at most %d", len(list), MaxIngredients)
}
out := make([]string, len(list))
seen := map[string]string{}
for i, ing := range list {
ing = oneLine(ing)
switch {
case ing == "":
return nil, fmt.Errorf("ingredient %d is blank", i+1)
case utf8.RuneCountInString(ing) > IngredientLimit:
return nil, fmt.Errorf("ingredient %d is over %d characters", i+1, IngredientLimit)
}
n := harness.Normalize(ing)
if n == "" {
return nil, fmt.Errorf("ingredient %d, %q, names no ingredient", i+1, ing)
}
if first, ok := seen[n]; ok {
return nil, fmt.Errorf("ingredient %d, %q, repeats %q", i+1, ing, first)
}
seen[n] = ing
out[i] = ing
}
return out, nil
}
// IdeaSummary is the line shown under an idea: how many of the household's
// total ingredients it uses, then what is missing, in the order given.
// "Uses all 5 · missing: 1 lime" when every ingredient is used, "Uses 3 of
// 5 · missing: 1 lime, cumin" when some are. With nothing missing the
// second part is "nothing missing", so an idea using all 5 and needing
// nothing else prints "Uses all 5 · nothing missing".
func IdeaSummary(uses, missing []string, total int) string {
used := fmt.Sprintf("Uses %d of %d", len(uses), total)
if len(uses) == total {
used = fmt.Sprintf("Uses all %d", total)
}
if len(missing) == 0 {
return used + " · nothing missing"
}
return used + " · missing: " + strings.Join(missing, ", ")
}
// ideasFromIngredients is the skill. It holds its input because
// post-processing compares the output with it.
type ideasFromIngredients struct {
in IdeasRequest
}
var _ harness.Skill[IdeasRequest, Ideas] = ideasFromIngredients{}
func (ideasFromIngredients) Name() string { return "ideas_from_ingredients" }
func (ideasFromIngredients) Version() int { return 1 }
func (ideasFromIngredients) Timeout() time.Duration { return harness.DefaultTimeout }
// ideasPrompt is the template data of ideas_from_ingredients.v1.tmpl.
type ideasPrompt struct {
// Count is how many ideas to ask for.
Count int
QuickOnly bool
}
// PromptInput puts the ingredients, already cleaned by
// IdeasFromIngredients, in the household context block.
func (ideasFromIngredients) PromptInput(in IdeasRequest) (harness.PromptInput, error) {
return harness.PromptInput{
Data: ideasPrompt{Count: IdeasRequested, QuickOnly: in.QuickOnly},
Context: []harness.ContextSection{{Title: "Ingredients on hand", Lines: in.Ingredients}},
}, nil
}
func (ideasFromIngredients) Schema() llm.JSONSchema {
return llm.JSONSchema{Name: "ideas_from_ingredients", Schema: json.RawMessage(`{
"type": "object",
"properties": {
"ideas": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"uses": {"type": "array", "items": {"type": "string"}},
"missing": {"type": "array", "items": {"type": "string"}},
"est_minutes": {"type": "integer"},
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
"difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
"search_query": {"type": "string"}
},
"required": ["name", "uses", "missing", "est_minutes", "method", "difficulty", "search_query"],
"additionalProperties": false
}
}
},
"required": ["ideas"],
"additionalProperties": false
}`)}
}
// UnmarshalJSON decodes an idea, requiring each of its fields as the
// schema does: encoding/json would otherwise read a missing or null one as
// empty.
func (i *Idea) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "name", "uses", "missing", "est_minutes", "method", "difficulty", "search_query"); err != nil {
return fmt.Errorf("idea %s", err)
}
type plain Idea
return json.Unmarshal(data, (*plain)(i))
}
// UnmarshalJSON decodes the output, requiring its one field as the schema
// does.
func (out *Ideas) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "ideas"); err != nil {
return fmt.Errorf("output %s", err)
}
type plain Ideas
return json.Unmarshal(data, (*plain)(out))
}
// Validate finds what makes the reply unusable as a whole: no ideas, too
// many, or one that is malformed. A well-formed idea that breaks a rule is
// dropped by PostProcess instead.
func (ideasFromIngredients) Validate(out Ideas) []string {
var problems []string
switch n := len(out.Ideas); {
case n == 0 && len(out.Dropped) == 0:
problems = append(problems, fmt.Sprintf("ideas is empty; give %d ideas", IdeasRequested))
case n > IdeasRequested:
problems = append(problems, fmt.Sprintf("ideas has %d entries; give at most %d ideas", n, IdeasRequested))
}
for i, idea := range out.Ideas {
for _, f := range []struct{ field, value string }{{"name", idea.Name}, {"search_query", idea.SearchQuery}} {
if strings.TrimSpace(f.value) == "" {
problems = append(problems, fmt.Sprintf("idea %d has an empty %s; give one", i+1, f.field))
}
}
if strings.TrimSpace(idea.Name) != "" && harness.Normalize(idea.Name) == "" {
problems = append(problems, fmt.Sprintf("idea %d name %q has only filler words; name the dish", i+1, idea.Name))
}
if !slices.Contains(methods, idea.Method) {
problems = append(problems, fmt.Sprintf("idea %d method %q is not one of %s", i+1, idea.Method, strings.Join(methods, ", ")))
}
if idea.EstMinutes <= 0 {
problems = append(problems, fmt.Sprintf("idea %d est_minutes %d is not positive; give the minutes from prep to table", i+1, idea.EstMinutes))
}
if !slices.Contains(difficulties, idea.Difficulty) {
problems = append(problems, fmt.Sprintf("idea %d difficulty %q is not one of %s", i+1, idea.Difficulty, strings.Join(difficulties, ", ")))
}
}
return problems
}
// PostProcess corrects every idea against the household's ingredients and
// moves each that breaks a rule to Dropped. Uses keeps only entries whose
// normalized form (harness.Normalize) is that of a listed ingredient,
// written as the household wrote it, once each and in the household's
// order; anything else the model claimed is removed, never trusted.
// Missing loses blank entries, repeats and anything the household listed.
// An idea is dropped when it repeats an earlier idea or uses none of the
// household's ingredients. The summary line is computed from the
// corrected lists.
func (s ideasFromIngredients) PostProcess(out Ideas) (Ideas, error) {
listed := map[string]int{}
for i, ing := range s.in.Ingredients {
listed[harness.Normalize(ing)] = i
}
result := Ideas{Ideas: []Idea{}}
for _, idea := range out.Ideas {
idea = Idea{
Name: oneLine(idea.Name),
Uses: s.uses(idea.Uses, listed),
Missing: missing(idea.Missing, listed),
EstMinutes: idea.EstMinutes,
Method: idea.Method,
Difficulty: idea.Difficulty,
SearchQuery: oneLine(idea.SearchQuery),
}
if why := dropIdea(idea, result.Ideas); why != "" {
result.Dropped = append(result.Dropped, DroppedIdea{Name: idea.Name, Why: why})
continue
}
idea.Summary = IdeaSummary(idea.Uses, idea.Missing, len(s.in.Ingredients))
result.Ideas = append(result.Ideas, idea)
}
return result, nil
}
// uses returns the household's ingredients that claimed names, as the
// household wrote them and in its order. A claimed entry that names none
// of them is left out.
func (s ideasFromIngredients) uses(claimed []string, listed map[string]int) []string {
used := make([]bool, len(s.in.Ingredients))
for _, c := range claimed {
if i, ok := listed[harness.Normalize(c)]; ok {
used[i] = true
}
}
out := []string{}
for i, ing := range s.in.Ingredients {
if used[i] {
out = append(out, ing)
}
}
return out
}
// missing returns claimed on one line each, in order, without blank
// entries, repeats or any the household listed.
func missing(claimed []string, listed map[string]int) []string {
out := []string{}
seen := map[string]bool{}
for _, c := range claimed {
c = oneLine(c)
n := harness.Normalize(c)
if n == "" || seen[n] {
continue
}
if _, ok := listed[n]; ok {
continue
}
seen[n] = true
out = append(out, c)
}
return out
}
// dropIdea returns why idea must be dropped, or "" when it may be kept.
func dropIdea(idea Idea, kept []Idea) string {
for _, k := range kept {
if harness.Similar(idea.Name, k.Name) {
return fmt.Sprintf("repeats the idea %q", k.Name)
}
}
if len(idea.Uses) == 0 {
return "uses none of the household's ingredients"
}
return ""
}
@@ -1,269 +0,0 @@
package skills
import (
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// pantry is the household's ingredient list in its order.
var pantry = IdeasRequest{Ingredients: []string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}}
// idea renders one idea of an ideas_from_ingredients reply.
func idea(name, method string, minutes int, uses, missing []string) string {
u, _ := json.Marshal(append([]string{}, uses...))
m, _ := json.Marshal(append([]string{}, missing...))
return fmt.Sprintf(`{"name":%q,"uses":%s,"missing":%s,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
name, u, m, minutes, method, strings.ToLower(name)+" recipe")
}
// ideasReply renders a reply holding the ideas given.
func ideasReply(ideas ...string) string {
return `{"ideas":[` + strings.Join(ideas, ",") + `]}`
}
// ideas runs IdeasFromIngredients on in with f and checks f was called
// exactly calls times, each at the household temperature.
func ideas(t *testing.T, f *llm.Fake, in IdeasRequest, calls int) (Ideas, []llm.Request, error) {
t.Helper()
out, err := IdeasFromIngredients(context.Background(), testConfig(t, f), IdeasScope(in), 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 != "ideas_from_ingredients" {
t.Fatalf("request %d schema = %v, want ideas_from_ingredients", i+1, req.Schema)
}
}
return out, reqs, err
}
func TestIdeasFromIngredientsPrompt(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "stovetop", 35, []string{"Chicken thighs", "rice"}, nil))))
_, reqs, err := ideas(t, &f, pantry, 1)
if err != nil {
t.Fatal(err)
}
system, user := reqs[0].System, reqs[0].Messages[0].Content
golden(t, "ideas_from_ingredients_any.system.golden", system)
golden(t, "ideas_from_ingredients_any.user.golden", user)
for _, part := range []string{
"## Household context",
"### Ingredients on hand\n- Chicken thighs\n- rice\n- cilantro\n- black beans\n- red onion",
"## Output contract",
`"uses"`,
`"missing"`,
} {
if !strings.Contains(system, part) {
t.Errorf("system prompt lacks %q", part)
}
}
if strings.Contains(system, "Quick dinners only") {
t.Error("an Any time request asks for Quick dinners only")
}
if user != "Suggest 8 dinner ideas from the ingredients on hand." {
t.Errorf("user message = %q", user)
}
}
func TestIdeasFromIngredientsQuickOnlyPrompt(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(idea("Chicken Fried Rice", "stovetop", 25, []string{"Chicken thighs", "rice"}, nil))))
in := pantry
in.QuickOnly = true
_, reqs, err := ideas(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(reqs[0].System, "The household wants Quick dinners only: each idea must be cooked in a crock pot, or take 30 minutes or less from prep to table.") {
t.Errorf("Quick only request does not ask for Quick dinners:\n%s", reqs[0].System)
}
}
func TestIdeasFromIngredientsSummaryLine(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(
// Every ingredient, named in another order and spelling: uses is
// in the household's order, as the household wrote it.
idea("Chicken Burrito Bowls", "stovetop", 35, []string{"red onion", "Black Beans", "chicken thighs", "Rice", "cilantro!"}, []string{"1 lime"}),
// Some of them.
idea("Cilantro Chicken Rice", "stovetop", 30, []string{"rice", "cilantro", "Chicken thighs"}, []string{"1 lime", "cumin"}),
// Every ingredient, nothing missing.
idea("Black Bean Chicken Skillet", "stovetop", 30, []string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, nil),
// Some, nothing missing.
idea("Rice and Beans", "stovetop", 25, []string{"rice", "black beans"}, []string{}),
)))
out, _, err := ideas(t, &f, pantry, 1)
if err != nil {
t.Fatal(err)
}
if len(out.Dropped) != 0 {
t.Fatalf("dropped %+v", out.Dropped)
}
got := map[string][2]any{}
for _, i := range out.Ideas {
got[i.Name] = [2]any{i.Uses, i.Summary}
}
want := map[string][2]any{
"Chicken Burrito Bowls": {[]string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, "Uses all 5 · missing: 1 lime"},
"Cilantro Chicken Rice": {[]string{"Chicken thighs", "rice", "cilantro"}, "Uses 3 of 5 · missing: 1 lime, cumin"},
"Black Bean Chicken Skillet": {[]string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, "Uses all 5 · nothing missing"},
"Rice and Beans": {[]string{"rice", "black beans"}, "Uses 2 of 5 · nothing missing"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("uses and summaries = %v\nwant %v", got, want)
}
}
func TestIdeasFromIngredientsDropsHallucinatedUses(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(
// The model claims parmesan and lime, which the household never
// listed, and repeats rice.
idea("Cheesy Chicken Rice", "oven", 40, []string{"Chicken thighs", "parmesan", "rice", "lime", "rice"}, []string{"1 cup broth"}),
// Uses only ingredients the household never listed.
idea("Lemon Pasta", "stovetop", 20, []string{"pasta", "lemon"}, []string{"garlic"}),
// Lists an ingredient on hand as missing.
idea("Chicken Tacos", "stovetop", 25, []string{"Chicken thighs"}, []string{"tortillas", "Cilantro", "tortillas"}),
)))
out, _, err := ideas(t, &f, pantry, 1)
if err != nil {
t.Fatal(err)
}
want := Ideas{
Ideas: []Idea{
{
Name: "Cheesy Chicken Rice", Uses: []string{"Chicken thighs", "rice"}, Missing: []string{"1 cup broth"},
EstMinutes: 40, Method: "oven", Difficulty: "easy", SearchQuery: "cheesy chicken rice recipe",
Summary: "Uses 2 of 5 · missing: 1 cup broth",
},
{
Name: "Chicken Tacos", Uses: []string{"Chicken thighs"}, Missing: []string{"tortillas"},
EstMinutes: 25, Method: "stovetop", Difficulty: "easy", SearchQuery: "chicken tacos recipe",
Summary: "Uses 1 of 5 · missing: tortillas",
},
},
Dropped: []DroppedIdea{{Name: "Lemon Pasta", Why: "uses none of the household's ingredients"}},
}
if !reflect.DeepEqual(out, want) {
t.Fatalf("output = %+v\nwant %+v", out, want)
}
}
func TestIdeasFromIngredientsDropsRepeatedIdea(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(
idea("Chicken Burrito Bowls", "stovetop", 35, []string{"rice"}, nil),
idea("Easy Chicken Burrito Bowls", "stovetop", 30, []string{"rice"}, nil),
)))
out, _, err := ideas(t, &f, pantry, 1)
if err != nil {
t.Fatal(err)
}
if len(out.Ideas) != 1 || out.Ideas[0].Name != "Chicken Burrito Bowls" {
t.Fatalf("ideas = %+v", out.Ideas)
}
if want := []DroppedIdea{{Name: "Easy Chicken Burrito Bowls", Why: `repeats the idea "Chicken Burrito Bowls"`}}; !reflect.DeepEqual(out.Dropped, want) {
t.Fatalf("dropped = %+v, want %+v", out.Dropped, want)
}
}
func TestIdeasFromIngredientsRepairsMalformedReply(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "microwave", 0, []string{"rice"}, nil))))
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "stovetop", 35, []string{"rice"}, nil))))
out, reqs, err := ideas(t, &f, pantry, 2)
if err != nil {
t.Fatal(err)
}
if len(out.Ideas) != 1 {
t.Fatalf("ideas = %+v", out.Ideas)
}
repair := reqs[1].Messages[len(reqs[1].Messages)-1].Content
for _, part := range []string{`idea 1 method "microwave" is not one of`, "idea 1 est_minutes 0 is not positive"} {
if !strings.Contains(repair, part) {
t.Errorf("repair turn lacks %q:\n%s", part, repair)
}
}
}
func TestIdeasFromIngredientsRefusesTooManyIdeas(t *testing.T) {
var f llm.Fake
var nine []string
for i := range 9 {
nine = append(nine, idea(fmt.Sprintf("Dish %d", i+1), "stovetop", 30, []string{"rice"}, nil))
}
f.QueueGenerate(reply(ideasReply(nine...)))
f.QueueGenerate(reply(ideasReply(nine...)))
if _, _, err := ideas(t, &f, pantry, 2); err == nil || !strings.Contains(err.Error(), "give at most 8 ideas") {
t.Fatalf("err = %v, want a bad output naming the limit", err)
}
}
func TestCleanIngredients(t *testing.T) {
got, err := CleanIngredients([]string{" 1 lime ", "Chicken\tthighs"})
if err != nil {
t.Fatal(err)
}
if want := []string{"1 lime", "Chicken thighs"}; !reflect.DeepEqual(got, want) {
t.Fatalf("CleanIngredients = %q, want %q", got, want)
}
tooMany := make([]string, MaxIngredients+1)
for i := range tooMany {
tooMany[i] = fmt.Sprintf("thing %d", i)
}
for _, tc := range []struct {
name string
in []string
want string
}{
{"empty", nil, "list at least one ingredient"},
{"too many", tooMany, "list at most 20"},
{"blank", []string{"rice", " "}, "ingredient 2 is blank"},
{"too long", []string{strings.Repeat("a", IngredientLimit+1)}, "over 80 characters"},
{"filler only", []string{"the"}, "names no ingredient"},
{"repeat", []string{"Red onion", "red-onion"}, `repeats "Red onion"`},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := CleanIngredients(tc.in); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want %q", err, tc.want)
}
})
}
}
func TestIdeasFromIngredientsRefusesBadListBeforeCalling(t *testing.T) {
var f llm.Fake
if _, _, err := ideas(t, &f, IdeasRequest{Ingredients: []string{"rice", "Rice"}}, 0); err == nil {
t.Fatal("a repeated ingredient was accepted")
}
}
func TestIdeasScopeIdentifiesTheRequest(t *testing.T) {
a := IdeasScope(IdeasRequest{Ingredients: []string{"Rice", "black beans"}})
if b := IdeasScope(IdeasRequest{Ingredients: []string{"rice", "Black Beans"}}); a != b {
t.Errorf("scopes of the same list differ: %q, %q", a, b)
}
for _, other := range []IdeasRequest{
{Ingredients: []string{"Rice", "black beans"}, QuickOnly: true},
{Ingredients: []string{"black beans", "Rice"}},
{Ingredients: []string{"Rice"}},
} {
if IdeasScope(other) == a {
t.Errorf("%+v shares the scope %q", other, a)
}
}
}
-2
View File
@@ -6,5 +6,3 @@ normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32
archive_title v1 b4f30562a8614dac09a69d512dc361883b917ee4228a0ce3b4583c7fd3f6101a
ideas_from_ingredients v1 49df3e134f07fe2c0dbbda7bd3d7d1364731e5851bf6f6a526ec04b703480924
quick_ideas v1 c78d1b9917a150bcf8df1351e0b4c2e76eaa10097819782c2c74695b0ae3cf05
@@ -1,25 +0,0 @@
{{/* ideas_from_ingredients drafts dinner ideas the household can cook from the ingredients it has on hand. */}}
{{define "instructions" -}}
You suggest dinner ideas the household can cook from the ingredients it has on hand, listed under "Ingredients on hand" in the household context.
Prefer ideas that use as many of those ingredients as possible and need as few others as possible. Pantry staples such as salt, pepper, oil and water need not be listed as missing. Vary the ideas: no two may be the same dish.
Give for each idea:
- name: the dinner's usual name, as a recipe site would title it.
- uses: the ingredients on hand the idea uses, each written exactly as it appears in the "Ingredients on hand" list. List only ingredients from that list, never one it does not hold.
- missing: the ingredients the idea needs that are not on hand, each with a quantity where it helps, e.g. "1 lime". Leave it empty when nothing is missing.
- est_minutes: your estimate of the whole minutes from starting prep to the table.
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
- difficulty: "easy", "medium" or "hard" for a home cook.
- search_query: a web search query that finds a recipe for this dinner.
{{- if .QuickOnly}}
The household wants Quick dinners only: each idea must be cooked in a crock pot, or take 30 minutes or less from prep to table.
{{- end}}
One valid idea, for a household with "chicken thighs", "rice" and "cilantro" on hand (an example only, not about this household):
{"name": "Cilantro Lime Chicken and Rice", "uses": ["chicken thighs", "rice", "cilantro"], "missing": ["1 lime"], "est_minutes": 35, "method": "stovetop", "difficulty": "easy", "search_query": "cilantro lime chicken and rice recipe"}
{{- end}}
{{define "user" -}}
Suggest {{.Count}} dinner ideas from the ingredients on hand.
{{- end}}
@@ -1,34 +0,0 @@
{{/* quick_ideas drafts Quick dinner ideas of one kind, crock pot or 30 minutes or less, picked from the household's tastes. */}}
{{define "instructions" -}}
You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log.
{{- if .Crockpot}}
Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".
{{- else}}
Every idea must take 30 minutes or less from starting prep to the table.
{{- end}}
Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish.
Give for each idea:
- name: the dinner's usual name, as a recipe site would title it.
- est_minutes: your estimate of the whole minutes from starting prep to the table.
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
- difficulty: "easy", "medium" or "hard" for a home cook.
- search_query: a web search query that finds a recipe for this dinner.
One valid idea (an example only, not about this household):
{{- if .Crockpot}}
{"name": "Slow Cooker Chicken Tortilla Soup", "est_minutes": 375, "method": "crockpot", "difficulty": "easy", "search_query": "slow cooker chicken tortilla soup recipe"}
{{- else}}
{"name": "Garlic Butter Shrimp Pasta", "est_minutes": 25, "method": "stovetop", "difficulty": "easy", "search_query": "garlic butter shrimp pasta recipe"}
{{- end}}
{{- end}}
{{define "user" -}}
{{- if .Crockpot -}}
Suggest {{.Count}} crock pot dinner ideas.
{{- else -}}
Suggest {{.Count}} dinner ideas ready in 30 minutes or less.
{{- end}}
{{- end}}
-297
View File
@@ -1,297 +0,0 @@
package skills
import (
"context"
"encoding/json"
"fmt"
"slices"
"strings"
"time"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// Kinds of Quick dinner quick_ideas suggests: the two tabs of the Quick
// dinners screen.
const (
// QuickCrockpot is a dinner cooked in a crock pot (slow cooker).
QuickCrockpot = "crockpot"
// QuickUnder30 is a dinner 30 minutes or less from prep to table.
QuickUnder30 = "under30"
)
// QuickKinds are every kind of Quick dinner, in the screen's tab order.
var QuickKinds = []string{QuickCrockpot, QuickUnder30}
const (
// QuickIdeasRequested is how many ideas quick_ideas asks the model
// for: more than a page holds, since validation, the exclude list,
// grounding and the kind's check drop some.
QuickIdeasRequested = 12
// QuickIdeasKept is the most ideas one page of Quick dinners shows.
QuickIdeasKept = 6
// MaxExclude is the most names already shown a request may exclude.
MaxExclude = 60
// ExcludeLimit is the most characters one excluded name may have.
ExcludeLimit = 120
)
// QuickIdeasRequest is what quick_ideas reads.
type QuickIdeasRequest struct {
// Context is the household context (see harness.BuildContext): the
// ideas are picked from the household's tastes.
Context harness.HouseholdContext
// Kind is QuickCrockpot or QuickUnder30. It is a hint to the model:
// the caller enforces it after grounding, from the grounded recipe.
Kind string
// Exclude are the names already shown to the household, as
// CleanExclude returns them. No idea repeating one is returned.
Exclude []string
}
// QuickIdea is one Quick dinner idea. Method, EstMinutes and Difficulty
// are the model's estimates until the idea is grounded in a recipe.
type QuickIdea struct {
Name string `json:"name"`
EstMinutes int `json:"est_minutes"`
// Method is one of stovetop, oven, crockpot, grill and other.
Method string `json:"method"`
// Difficulty is easy, medium or hard.
Difficulty string `json:"difficulty"`
// SearchQuery is a web search query that finds a recipe for the dinner.
SearchQuery string `json:"search_query"`
}
// QuickIdeaList is quick_ideas' output.
type QuickIdeaList struct {
// Ideas are the ideas that passed every rule, in the model's order.
Ideas []QuickIdea `json:"ideas"`
// Dropped are the ideas the model gave that broke a rule, in the
// model's order. It is never part of the model's reply.
Dropped []DroppedIdea `json:"-"`
}
// QuickIdeas runs quick_ideas v1 on in and returns ideas that repeat no
// excluded name nor each other (harness.Similar, on the normalized
// names); the rest are in Dropped. Whether an idea is of in.Kind is not
// decided here: the model's estimates never decide it, the grounded
// recipe does. scope names the request (see harness.Run; QuickIdeasScope).
// A kind CheckQuickKind refuses, or an exclude list CleanExclude refuses,
// fails before any LLM call.
func QuickIdeas(ctx context.Context, cfg harness.Config, scope string, in QuickIdeasRequest) (QuickIdeaList, error) {
if err := CheckQuickKind(in.Kind); err != nil {
return QuickIdeaList{}, fmt.Errorf("skills: quick_ideas: %w", err)
}
exclude, err := CleanExclude(in.Exclude)
if err != nil {
return QuickIdeaList{}, fmt.Errorf("skills: quick_ideas: %w", err)
}
in.Exclude = exclude
return harness.Run(ctx, cfg, quickIdeas{in: in}, scope, in)
}
// QuickIdeasScope is the harness scope of a request: its kind and its
// normalized excluded names, in order, so two requests share a run only
// when they ask for the same page.
func QuickIdeasScope(in QuickIdeasRequest) string {
parts := make([]string, len(in.Exclude))
for i, name := range in.Exclude {
parts[i] = harness.Normalize(name)
}
return "kind=" + in.Kind + ";" + strings.Join(parts, "|")
}
// CheckQuickKind returns why kind is not one of QuickKinds, or nil. There
// is no default kind.
func CheckQuickKind(kind string) error {
if !slices.Contains(QuickKinds, kind) {
return fmt.Errorf("kind %q is not one of %s", kind, strings.Join(QuickKinds, ", "))
}
return nil
}
// CleanExclude returns the excluded names with each on one line, never
// nil, or why they cannot be used: there are more than MaxExclude, or a
// name is blank, longer than ExcludeLimit characters, has nothing left
// once normalized (harness.Normalize), or normalizes the same as an
// earlier one. An empty list, the first page's, is valid.
func CleanExclude(list []string) ([]string, error) {
if len(list) > MaxExclude {
return nil, fmt.Errorf("%d names excluded; exclude at most %d", len(list), MaxExclude)
}
out := make([]string, len(list))
seen := map[string]string{}
for i, name := range list {
name = oneLine(name)
switch {
case name == "":
return nil, fmt.Errorf("excluded name %d is blank", i+1)
case utf8.RuneCountInString(name) > ExcludeLimit:
return nil, fmt.Errorf("excluded name %d is over %d characters", i+1, ExcludeLimit)
}
n := harness.Normalize(name)
if n == "" {
return nil, fmt.Errorf("excluded name %d, %q, names no dinner", i+1, name)
}
if first, ok := seen[n]; ok {
return nil, fmt.Errorf("excluded name %d, %q, repeats %q", i+1, name, first)
}
seen[n] = name
out[i] = name
}
return out, nil
}
// quickIdeas is the skill. It holds its input because post-processing
// compares the output with it.
type quickIdeas struct {
in QuickIdeasRequest
}
var _ harness.Skill[QuickIdeasRequest, QuickIdeaList] = quickIdeas{}
func (quickIdeas) Name() string { return "quick_ideas" }
func (quickIdeas) Version() int { return 1 }
func (quickIdeas) Timeout() time.Duration { return harness.DefaultTimeout }
// quickIdeasPrompt is the template data of quick_ideas.v1.tmpl.
type quickIdeasPrompt struct {
// Count is how many ideas to ask for.
Count int
// Crockpot is set for QuickCrockpot, unset for QuickUnder30.
Crockpot bool
}
// PromptInput puts the household context, then the names already shown,
// in the household context block.
func (quickIdeas) PromptInput(in QuickIdeasRequest) (harness.PromptInput, error) {
if err := CheckQuickKind(in.Kind); err != nil {
return harness.PromptInput{}, err
}
sections := append(in.Context.Sections(), harness.ContextSection{Title: "Already shown", Lines: in.Exclude})
return harness.PromptInput{
Data: quickIdeasPrompt{Count: QuickIdeasRequested, Crockpot: in.Kind == QuickCrockpot},
Context: sections,
}, nil
}
func (quickIdeas) Schema() llm.JSONSchema {
return llm.JSONSchema{Name: "quick_ideas", Schema: json.RawMessage(`{
"type": "object",
"properties": {
"ideas": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"est_minutes": {"type": "integer"},
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
"difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
"search_query": {"type": "string"}
},
"required": ["name", "est_minutes", "method", "difficulty", "search_query"],
"additionalProperties": false
}
}
},
"required": ["ideas"],
"additionalProperties": false
}`)}
}
// UnmarshalJSON decodes an idea, requiring each of its fields as the
// schema does: encoding/json would otherwise read a missing or null one as
// empty.
func (i *QuickIdea) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "name", "est_minutes", "method", "difficulty", "search_query"); err != nil {
return fmt.Errorf("idea %s", err)
}
type plain QuickIdea
return json.Unmarshal(data, (*plain)(i))
}
// UnmarshalJSON decodes the output, requiring its one field as the schema
// does.
func (out *QuickIdeaList) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "ideas"); err != nil {
return fmt.Errorf("output %s", err)
}
type plain QuickIdeaList
return json.Unmarshal(data, (*plain)(out))
}
// Validate finds what makes the reply unusable as a whole: no ideas, too
// many, or one that is malformed. A well-formed idea that breaks a rule is
// dropped by PostProcess instead.
func (quickIdeas) Validate(out QuickIdeaList) []string {
var problems []string
switch n := len(out.Ideas); {
case n == 0 && len(out.Dropped) == 0:
problems = append(problems, fmt.Sprintf("ideas is empty; give %d ideas", QuickIdeasRequested))
case n > QuickIdeasRequested:
problems = append(problems, fmt.Sprintf("ideas has %d entries; give at most %d ideas", n, QuickIdeasRequested))
}
for i, idea := range out.Ideas {
for _, f := range []struct{ field, value string }{{"name", idea.Name}, {"search_query", idea.SearchQuery}} {
if strings.TrimSpace(f.value) == "" {
problems = append(problems, fmt.Sprintf("idea %d has an empty %s; give one", i+1, f.field))
}
}
if strings.TrimSpace(idea.Name) != "" && harness.Normalize(idea.Name) == "" {
problems = append(problems, fmt.Sprintf("idea %d name %q has only filler words; name the dish", i+1, idea.Name))
}
if !slices.Contains(methods, idea.Method) {
problems = append(problems, fmt.Sprintf("idea %d method %q is not one of %s", i+1, idea.Method, strings.Join(methods, ", ")))
}
if idea.EstMinutes <= 0 {
problems = append(problems, fmt.Sprintf("idea %d est_minutes %d is not positive; give the minutes from prep to table", i+1, idea.EstMinutes))
}
if !slices.Contains(difficulties, idea.Difficulty) {
problems = append(problems, fmt.Sprintf("idea %d difficulty %q is not one of %s", i+1, idea.Difficulty, strings.Join(difficulties, ", ")))
}
}
return problems
}
// PostProcess puts every idea's text on one line and moves each that
// repeats an excluded name or an earlier idea (harness.Similar, which
// compares normalized names) to Dropped. The model's method and minutes
// are kept as estimates and never decide the kind.
func (s quickIdeas) PostProcess(out QuickIdeaList) (QuickIdeaList, error) {
result := QuickIdeaList{Ideas: []QuickIdea{}}
for _, idea := range out.Ideas {
idea = QuickIdea{
Name: oneLine(idea.Name),
EstMinutes: idea.EstMinutes,
Method: idea.Method,
Difficulty: idea.Difficulty,
SearchQuery: oneLine(idea.SearchQuery),
}
if why := s.drop(idea, result.Ideas); why != "" {
result.Dropped = append(result.Dropped, DroppedIdea{Name: idea.Name, Why: why})
continue
}
result.Ideas = append(result.Ideas, idea)
}
return result, nil
}
// drop returns why idea must be dropped, or "" when it may be kept.
func (s quickIdeas) drop(idea QuickIdea, kept []QuickIdea) string {
for _, name := range s.in.Exclude {
if harness.Similar(idea.Name, name) {
return fmt.Sprintf("repeats %q, already shown", name)
}
}
for _, k := range kept {
if harness.Similar(idea.Name, k.Name) {
return fmt.Sprintf("repeats the idea %q", k.Name)
}
}
return ""
}
-235
View File
@@ -1,235 +0,0 @@
package skills
import (
"context"
"fmt"
"reflect"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// quickTastes is the household context of the quick_ideas tests.
var quickTastes = harness.HouseholdContext{
Today: date(2026, 10, 8),
Timezone: "America/New_York",
Regulars: []harness.RegularDinner{
{Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240, Favorite: true},
{Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30},
},
QuestLog: []string{"Pad Thai"},
}
// quickIdea renders one idea of a quick_ideas reply.
func quickIdea(name, method string, minutes int) string {
return fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
name, minutes, method, strings.ToLower(name)+" recipe")
}
// quick runs QuickIdeas on in with f and checks f was called exactly
// calls times, each at the household temperature.
func quick(t *testing.T, f *llm.Fake, in QuickIdeasRequest, calls int) (QuickIdeaList, []llm.Request, error) {
t.Helper()
out, err := QuickIdeas(context.Background(), testConfig(t, f), QuickIdeasScope(in), 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 != "quick_ideas" {
t.Fatalf("request %d schema = %v, want quick_ideas", i+1, req.Schema)
}
}
return out, reqs, err
}
func TestQuickIdeasPrompt(t *testing.T) {
for _, tc := range []struct {
kind, golden, user, rule string
}{
{QuickCrockpot, "quick_ideas_crockpot", "Suggest 12 crock pot dinner ideas.",
`Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".`},
{QuickUnder30, "quick_ideas_under30", "Suggest 12 dinner ideas ready in 30 minutes or less.",
"Every idea must take 30 minutes or less from starting prep to the table."},
} {
t.Run(tc.kind, func(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(quickIdea("Chicken Fried Rice", "stovetop", 25))))
in := QuickIdeasRequest{Context: quickTastes, Kind: tc.kind, Exclude: []string{"Beef Stew", "Shrimp Tacos"}}
_, reqs, err := quick(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
system, user := reqs[0].System, reqs[0].Messages[0].Content
golden(t, tc.golden+".system.golden", system)
golden(t, tc.golden+".user.golden", user)
for _, part := range []string{
"## Household context",
"### Regulars\n- Beef Chili",
"### Quest log\n- Pad Thai",
"### Already shown\n- Beef Stew\n- Shrimp Tacos",
tc.rule,
"## Output contract",
} {
if !strings.Contains(system, part) {
t.Errorf("system prompt lacks %q", part)
}
}
if user != tc.user {
t.Errorf("user message = %q, want %q", user, tc.user)
}
})
}
}
func TestQuickIdeasNeverReturnExcludedNames(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(
// The excluded names, spelled another way: the normalized name
// decides.
quickIdea("Easy Beef Stew!", "crockpot", 300),
quickIdea("shrimp-tacos", "stovetop", 20),
quickIdea("Pulled Pork", "crockpot", 480),
// A repeat of an idea already kept.
quickIdea("The Pulled Pork", "crockpot", 420),
quickIdea("Chicken Fried Rice", "stovetop", 25),
)))
in := QuickIdeasRequest{Context: quickTastes, Kind: QuickCrockpot, Exclude: []string{"Beef Stew", "Shrimp Tacos"}}
out, _, err := quick(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
var kept []string
for _, i := range out.Ideas {
kept = append(kept, i.Name)
}
// Chicken Fried Rice is not a crock pot dinner, but the grounded
// recipe, not the model's estimate, decides that later.
if want := []string{"Pulled Pork", "Chicken Fried Rice"}; !reflect.DeepEqual(kept, want) {
t.Fatalf("ideas = %q, want %q", kept, want)
}
want := []DroppedIdea{
{Name: "Easy Beef Stew!", Why: `repeats "Beef Stew", already shown`},
{Name: "shrimp-tacos", Why: `repeats "Shrimp Tacos", already shown`},
{Name: "The Pulled Pork", Why: `repeats the idea "Pulled Pork"`},
}
if !reflect.DeepEqual(out.Dropped, want) {
t.Fatalf("dropped = %+v\nwant %+v", out.Dropped, want)
}
}
func TestQuickIdeasRepairsMalformedReply(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(quickIdea("Pulled Pork", "microwave", 0))))
f.QueueGenerate(reply(ideasReply(quickIdea("Pulled Pork", "crockpot", 480))))
out, reqs, err := quick(t, &f, QuickIdeasRequest{Context: quickTastes, Kind: QuickCrockpot}, 2)
if err != nil {
t.Fatal(err)
}
if len(out.Ideas) != 1 {
t.Fatalf("ideas = %+v", out.Ideas)
}
repair := reqs[1].Messages[len(reqs[1].Messages)-1].Content
for _, part := range []string{`idea 1 method "microwave" is not one of`, "idea 1 est_minutes 0 is not positive"} {
if !strings.Contains(repair, part) {
t.Errorf("repair turn lacks %q:\n%s", part, repair)
}
}
}
func TestQuickIdeasRefusesTooManyIdeas(t *testing.T) {
var f llm.Fake
var many []string
for i := range QuickIdeasRequested + 1 {
many = append(many, quickIdea(fmt.Sprintf("Dish %d", i+1), "stovetop", 20))
}
f.QueueGenerate(reply(ideasReply(many...)))
f.QueueGenerate(reply(ideasReply(many...)))
if _, _, err := quick(t, &f, QuickIdeasRequest{Context: quickTastes, Kind: QuickUnder30}, 2); err == nil || !strings.Contains(err.Error(), "give at most 12 ideas") {
t.Fatalf("err = %v, want a bad output naming the limit", err)
}
}
func TestQuickIdeasRefuseBadRequestBeforeCalling(t *testing.T) {
for _, in := range []QuickIdeasRequest{
{Context: quickTastes, Kind: ""},
{Context: quickTastes, Kind: "quick"},
{Context: quickTastes, Kind: QuickUnder30, Exclude: []string{"Beef Stew", "beef-stew"}},
} {
var f llm.Fake
if _, _, err := quick(t, &f, in, 0); err == nil {
t.Fatalf("%+v was accepted", in)
}
}
}
func TestCleanExclude(t *testing.T) {
got, err := CleanExclude([]string{" Beef Stew ", "Shrimp\tTacos"})
if err != nil {
t.Fatal(err)
}
if want := []string{"Beef Stew", "Shrimp Tacos"}; !reflect.DeepEqual(got, want) {
t.Fatalf("CleanExclude = %q, want %q", got, want)
}
if got, err := CleanExclude(nil); err != nil || got == nil || len(got) != 0 {
t.Fatalf("CleanExclude(nil) = %q, %v, want an empty list", got, err)
}
tooMany := make([]string, MaxExclude+1)
for i := range tooMany {
tooMany[i] = fmt.Sprintf("dish %d", i)
}
for _, tc := range []struct {
name string
in []string
want string
}{
{"too many", tooMany, "exclude at most 60"},
{"blank", []string{"Beef Stew", " "}, "excluded name 2 is blank"},
{"too long", []string{strings.Repeat("a", ExcludeLimit+1)}, "over 120 characters"},
{"filler only", []string{"Easy Recipe"}, "names no dinner"},
{"repeat", []string{"Beef Stew", "easy beef-stew"}, `repeats "Beef Stew"`},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := CleanExclude(tc.in); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want %q", err, tc.want)
}
})
}
}
func TestCheckQuickKind(t *testing.T) {
for _, kind := range QuickKinds {
if err := CheckQuickKind(kind); err != nil {
t.Errorf("CheckQuickKind(%q) = %v", kind, err)
}
}
for _, kind := range []string{"", "Crockpot", "under_30", "quick"} {
if err := CheckQuickKind(kind); err == nil || !strings.Contains(err.Error(), "is not one of crockpot, under30") {
t.Errorf("CheckQuickKind(%q) = %v, want a refusal naming the kinds", kind, err)
}
}
}
func TestQuickIdeasScopeIdentifiesTheRequest(t *testing.T) {
a := QuickIdeasScope(QuickIdeasRequest{Kind: QuickUnder30, Exclude: []string{"Beef Stew"}})
if b := QuickIdeasScope(QuickIdeasRequest{Kind: QuickUnder30, Exclude: []string{"beef-stew"}}); a != b {
t.Errorf("scopes of the same request differ: %q, %q", a, b)
}
for _, other := range []QuickIdeasRequest{
{Kind: QuickCrockpot, Exclude: []string{"Beef Stew"}},
{Kind: QuickUnder30},
{Kind: QuickUnder30, Exclude: []string{"Beef Stew", "Shrimp Tacos"}},
} {
if QuickIdeasScope(other) == a {
t.Errorf("%+v shares the scope %q", other, a)
}
}
}
@@ -1,94 +0,0 @@
## Instructions
You suggest dinner ideas the household can cook from the ingredients it has on hand, listed under "Ingredients on hand" in the household context.
Prefer ideas that use as many of those ingredients as possible and need as few others as possible. Pantry staples such as salt, pepper, oil and water need not be listed as missing. Vary the ideas: no two may be the same dish.
Give for each idea:
- name: the dinner's usual name, as a recipe site would title it.
- uses: the ingredients on hand the idea uses, each written exactly as it appears in the "Ingredients on hand" list. List only ingredients from that list, never one it does not hold.
- missing: the ingredients the idea needs that are not on hand, each with a quantity where it helps, e.g. "1 lime". Leave it empty when nothing is missing.
- est_minutes: your estimate of the whole minutes from starting prep to the table.
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
- difficulty: "easy", "medium" or "hard" for a home cook.
- search_query: a web search query that finds a recipe for this dinner.
One valid idea, for a household with "chicken thighs", "rice" and "cilantro" on hand (an example only, not about this household):
{"name": "Cilantro Lime Chicken and Rice", "uses": ["chicken thighs", "rice", "cilantro"], "missing": ["1 lime"], "est_minutes": 35, "method": "stovetop", "difficulty": "easy", "search_query": "cilantro lime chicken and rice recipe"}
## Household context
### Ingredients on hand
- Chicken thighs
- rice
- cilantro
- black beans
- red onion
## Output contract
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "ideas_from_ingredients" below, and nothing else: no prose and no Markdown code fences.
{
"type": "object",
"properties": {
"ideas": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"uses": {
"type": "array",
"items": {
"type": "string"
}
},
"missing": {
"type": "array",
"items": {
"type": "string"
}
},
"est_minutes": {
"type": "integer"
},
"method": {
"type": "string",
"enum": [
"stovetop",
"oven",
"crockpot",
"grill",
"other"
]
},
"difficulty": {
"type": "string",
"enum": [
"easy",
"medium",
"hard"
]
},
"search_query": {
"type": "string"
}
},
"required": [
"name",
"uses",
"missing",
"est_minutes",
"method",
"difficulty",
"search_query"
],
"additionalProperties": false
}
}
},
"required": [
"ideas"
],
"additionalProperties": false
}
@@ -1 +0,0 @@
Suggest 8 dinner ideas from the ingredients on hand.
@@ -1,93 +0,0 @@
## Instructions
You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log.
Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".
Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish.
Give for each idea:
- name: the dinner's usual name, as a recipe site would title it.
- est_minutes: your estimate of the whole minutes from starting prep to the table.
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
- difficulty: "easy", "medium" or "hard" for a home cook.
- search_query: a web search query that finds a recipe for this dinner.
One valid idea (an example only, not about this household):
{"name": "Slow Cooker Chicken Tortilla Soup", "est_minutes": 375, "method": "crockpot", "difficulty": "easy", "search_query": "slow cooker chicken tortilla soup recipe"}
## Household context
### Today
- Thursday 2026-10-08 (America/New_York)
### Regulars
- Beef Chili (Tex-Mex, crockpot, 240 min, favorite)
- Chicken Fajitas (Tex-Mex, stovetop, 30 min)
### Quest log
- Pad Thai
### Passed, oldest first
(none)
### Recent plans
(none)
### Already shown
- Beef Stew
- Shrimp Tacos
## Output contract
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "quick_ideas" below, and nothing else: no prose and no Markdown code fences.
{
"type": "object",
"properties": {
"ideas": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"est_minutes": {
"type": "integer"
},
"method": {
"type": "string",
"enum": [
"stovetop",
"oven",
"crockpot",
"grill",
"other"
]
},
"difficulty": {
"type": "string",
"enum": [
"easy",
"medium",
"hard"
]
},
"search_query": {
"type": "string"
}
},
"required": [
"name",
"est_minutes",
"method",
"difficulty",
"search_query"
],
"additionalProperties": false
}
}
},
"required": [
"ideas"
],
"additionalProperties": false
}
@@ -1 +0,0 @@
Suggest 12 crock pot dinner ideas.
@@ -1,93 +0,0 @@
## Instructions
You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log.
Every idea must take 30 minutes or less from starting prep to the table.
Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish.
Give for each idea:
- name: the dinner's usual name, as a recipe site would title it.
- est_minutes: your estimate of the whole minutes from starting prep to the table.
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
- difficulty: "easy", "medium" or "hard" for a home cook.
- search_query: a web search query that finds a recipe for this dinner.
One valid idea (an example only, not about this household):
{"name": "Garlic Butter Shrimp Pasta", "est_minutes": 25, "method": "stovetop", "difficulty": "easy", "search_query": "garlic butter shrimp pasta recipe"}
## Household context
### Today
- Thursday 2026-10-08 (America/New_York)
### Regulars
- Beef Chili (Tex-Mex, crockpot, 240 min, favorite)
- Chicken Fajitas (Tex-Mex, stovetop, 30 min)
### Quest log
- Pad Thai
### Passed, oldest first
(none)
### Recent plans
(none)
### Already shown
- Beef Stew
- Shrimp Tacos
## Output contract
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "quick_ideas" below, and nothing else: no prose and no Markdown code fences.
{
"type": "object",
"properties": {
"ideas": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"est_minutes": {
"type": "integer"
},
"method": {
"type": "string",
"enum": [
"stovetop",
"oven",
"crockpot",
"grill",
"other"
]
},
"difficulty": {
"type": "string",
"enum": [
"easy",
"medium",
"hard"
]
},
"search_query": {
"type": "string"
}
},
"required": [
"name",
"est_minutes",
"method",
"difficulty",
"search_query"
],
"additionalProperties": false
}
}
},
"required": [
"ideas"
],
"additionalProperties": false
}
@@ -1 +0,0 @@
Suggest 12 dinner ideas ready in 30 minutes or less.
+2 -2
View File
@@ -342,8 +342,8 @@ func TestSendThreadsByWeek(t *testing.T) {
t.Errorf("plan text = %q", reqs[0].Body["text"])
}
// The cards' "Open" buttons link under the public URL to the event's week.
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan?week=2026-10-12", 1: "https://mealquest.example/plan/shopping?week=2026-10-12"} {
// The cards' "Open" buttons link under the public URL.
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan", 1: "https://mealquest.example/plan/shopping"} {
data, _ := json.Marshal(reqs[i].Body["cardsV2"])
var cards []cardV2
if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 {
+2 -3
View File
@@ -2,7 +2,6 @@ package chat
import (
"fmt"
"net/url"
"strings"
"time"
@@ -47,7 +46,7 @@ func PlanReady(week, thisWeek store.LocalDate, dinners []string) notify.Event {
Key: string(notify.PlanReady) + ":" + week.String(),
Title: planTitle(week, thisWeek),
Body: nameList(dinners),
URL: "/plan?" + url.Values{"week": {week.String()}}.Encode(),
URL: "/plan",
Week: week,
}
}
@@ -60,7 +59,7 @@ func ListReady(week store.LocalDate, needed, dinners int) notify.Event {
Key: string(notify.ListReady) + ":" + week.String(),
Title: "This week's Supply run is ready",
Body: fmt.Sprintf("%s to buy for %s", count(needed, "item", "items"), count(dinners, "dinner", "dinners")),
URL: "/plan/shopping?" + url.Values{"week": {week.String()}}.Encode(),
URL: "/plan/shopping",
Week: week,
}
}
+6 -6
View File
@@ -38,7 +38,7 @@ func TestMessages(t *testing.T) {
name: "plan ready",
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}),
key: "plan_ready:2026-10-12",
url: "/plan?week=2026-10-12",
url: "/plan",
week: "2026-10-12",
title: "This week's plan is ready",
body: "Tacos, Bolognese, Tikka masala + 4 more",
@@ -48,7 +48,7 @@ func TestMessages(t *testing.T) {
name: "plan ready with three dinners",
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala"}),
key: "plan_ready:2026-10-12",
url: "/plan?week=2026-10-12",
url: "/plan",
week: "2026-10-12",
title: "This week's plan is ready",
body: "Tacos, Bolognese, Tikka masala",
@@ -58,7 +58,7 @@ func TestMessages(t *testing.T) {
name: "plan ready for next week",
ev: PlanReady(week, mustDate(t, "2026-10-05"), []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo"}),
key: "plan_ready:2026-10-12",
url: "/plan?week=2026-10-12",
url: "/plan",
week: "2026-10-12",
title: "Next week's plan is ready",
body: "Tacos, Bolognese, Tikka masala + 1 more",
@@ -68,7 +68,7 @@ func TestMessages(t *testing.T) {
name: "plan ready for a later week",
ev: PlanReady(mustDate(t, "2026-10-26"), mustDate(t, "2026-10-05"), []string{"Tacos"}),
key: "plan_ready:2026-10-26",
url: "/plan?week=2026-10-26",
url: "/plan",
week: "2026-10-26",
title: "The plan for the week of Oct 26 is ready",
body: "Tacos",
@@ -78,7 +78,7 @@ func TestMessages(t *testing.T) {
name: "list ready",
ev: ListReady(week, 19, 7),
key: "list_ready:2026-10-12",
url: "/plan/shopping?week=2026-10-12",
url: "/plan/shopping",
week: "2026-10-12",
title: "This week's Supply run is ready",
body: "19 items to buy for 7 dinners",
@@ -88,7 +88,7 @@ func TestMessages(t *testing.T) {
name: "list ready singular",
ev: ListReady(week, 1, 1),
key: "list_ready:2026-10-12",
url: "/plan/shopping?week=2026-10-12",
url: "/plan/shopping",
week: "2026-10-12",
title: "This week's Supply run is ready",
body: "1 item to buy for 1 dinner",
-63
View File
@@ -97,22 +97,6 @@ func (q *Queries) GetNotificationPref(ctx context.Context, event string) (Notifi
return i, err
}
const hasNotificationSince = `-- name: HasNotificationSince :one
SELECT EXISTS (
SELECT 1 FROM notification_outbox
WHERE CAST(?1 AS TEXT) IS NULL OR created_at > CAST(?1 AS TEXT)
) AS found
`
// Reports whether a notification was stored after since, or at all when
// since is NULL.
func (q *Queries) HasNotificationSince(ctx context.Context, since sql.NullString) (bool, error) {
row := q.db.QueryRowContext(ctx, hasNotificationSince, since)
var found bool
err := row.Scan(&found)
return found, err
}
const insertNotification = `-- name: InsertNotification :execrows
INSERT INTO notification_outbox (
id, event_key, channel, payload, status, attempts, next_attempt_at, created_at, updated_at
@@ -182,53 +166,6 @@ func (q *Queries) ListNotificationPrefs(ctx context.Context) ([]NotificationPref
return items, nil
}
const listRecentNotifications = `-- name: ListRecentNotifications :many
SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o
WHERE o.id = (
SELECT i.id FROM notification_outbox i
WHERE i.event_key = o.event_key
ORDER BY i.created_at DESC, i.id DESC
LIMIT 1
)
ORDER BY o.created_at DESC, o.event_key DESC
LIMIT ?1
`
type ListRecentNotificationsRow struct {
EventKey string
Payload string
CreatedAt string
}
// Returns the last lim distinct events stored, newest first: one row per
// event key, the last stored for it whatever its channel, so an event
// stored for push and Google Chat is one row. The last, because a channel
// switched on later can store the same event key again, and
// HasNotificationSince counts that row: an event is as new as its newest
// row, so marking it seen also covers that row.
func (q *Queries) ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error) {
rows, err := q.db.QueryContext(ctx, listRecentNotifications, lim)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListRecentNotificationsRow
for rows.Next() {
var i ListRecentNotificationsRow
if err := rows.Scan(&i.EventKey, &i.Payload, &i.CreatedAt); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markNotificationSent = `-- name: MarkNotificationSent :execrows
UPDATE notification_outbox
SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?1
-21
View File
@@ -120,13 +120,6 @@ type Querier interface {
// Returns sql.ErrNoRows until the row exists.
GetChatSettings(ctx context.Context) (ChatSetting, error)
GetDinner(ctx context.Context, id string) (Dinner, error)
// The reason of each suggestion of dinner_id that can have created it,
// none for a dinner no suggestion created. A suggestion is only ever
// created with a new dinner, and declining one archives its dinner, so a
// live dinner's creating suggestion is pending (the dinner still
// suggested) or accepted; a declined one did not create it. More than one
// row means nothing records which did.
GetDinnerSuggestionReasons(ctx context.Context, dinnerID string) ([]string, error)
// Returns sql.ErrNoRows until the row exists.
GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error)
// Returns sql.ErrNoRows until the row exists.
@@ -179,9 +172,6 @@ type Querier interface {
// them only when a member edited them while it was grounding. Like
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
// Reports whether a notification was stored after since, or at all when
// since is NULL.
HasNotificationSince(ctx context.Context, since sql.NullString) (bool, error)
// Stores a pending notification for one channel, due at once. A row already
// stored for the event key and channel, in any status, is left unchanged and
// no row is inserted.
@@ -212,13 +202,6 @@ type Querier interface {
// Every stored subscription with its keys, for the push sender, oldest
// first.
ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error)
// Returns the last lim distinct events stored, newest first: one row per
// event key, the last stored for it whatever its channel, so an event
// stored for push and Google Chat is one row. The last, because a channel
// switched on later can store the same event key again, and
// HasNotificationSince counts that row: an event is as new as its newest
// row, so marking it seen also covers that row.
ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error)
// Reads for the AI household context block (internal/ai/harness).
// The most recent names the household turned down, newest first, as
// ListPassedNames has them.
@@ -261,10 +244,6 @@ type Querier interface {
// Records list id sent to Google Chat at sent_to_chat_at, its send
// confirmed, unless it is frozen into the Supply archive.
MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error)
// Moves updates_seen_at forward to seen_at; affects no row when it is
// already at or after seen_at, so it never moves back. It is not a settings
// edit: updated_by and updated_at are left as they are.
MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error)
// Records an edit of plan id, raising its revision: a synced plan becomes
// changed, any other keeps its status.
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
-17
View File
@@ -161,23 +161,6 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett
return i, err
}
const markUpdatesSeen = `-- name: MarkUpdatesSeen :execrows
UPDATE household_settings
SET updates_seen_at = CAST(?1 AS TEXT)
WHERE id = 1 AND (updates_seen_at IS NULL OR updates_seen_at < CAST(?1 AS TEXT))
`
// Moves updates_seen_at forward to seen_at; affects no row when it is
// already at or after seen_at, so it never moves back. It is not a settings
// edit: updated_by and updated_at are left as they are.
func (q *Queries) MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error) {
result, err := q.db.ExecContext(ctx, markUpdatesSeen, seenAt)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const recordChatTest = `-- name: RecordChatTest :execrows
UPDATE chat_settings
SET last_test_at = ?, last_test_status = ?, updated_at = ?
-35
View File
@@ -187,41 +187,6 @@ func (q *Queries) GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch
return i, err
}
const getDinnerSuggestionReasons = `-- name: GetDinnerSuggestionReasons :many
SELECT reason FROM suggestions
WHERE dinner_id = ? AND status IN ('pending', 'accepted')
ORDER BY id
`
// The reason of each suggestion of dinner_id that can have created it,
// none for a dinner no suggestion created. A suggestion is only ever
// created with a new dinner, and declining one archives its dinner, so a
// live dinner's creating suggestion is pending (the dinner still
// suggested) or accepted; a declined one did not create it. More than one
// row means nothing records which did.
func (q *Queries) GetDinnerSuggestionReasons(ctx context.Context, dinnerID string) ([]string, error) {
rows, err := q.db.QueryContext(ctx, getDinnerSuggestionReasons, dinnerID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var reason string
if err := rows.Scan(&reason); err != nil {
return nil, err
}
items = append(items, reason)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getLatestSuggestionBatch = `-- name: GetLatestSuggestionBatch :one
SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches
ORDER BY id DESC
-293
View File
@@ -1,293 +0,0 @@
package dinners
import (
"context"
"database/sql"
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
)
// Detail is a dinner with its recipe, as the recipe detail screen and
// Kitchen reading mode show it.
type Detail struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Origin string `json:"origin"`
Method *string `json:"method"`
MinutesTotal *int64 `json:"minutes_total"`
Difficulty *string `json:"difficulty"`
Cuisine *string `json:"cuisine"`
IsFavorite bool `json:"is_favorite"`
// PhotoURLs is the cached photo at each stored width, keyed by width
// ("400", "1200"); null when the dinner has no photo.
PhotoURLs map[string]string `json:"photo_urls"`
// Ingredients and Steps are the recipe's, in recipe order; empty when
// the dinner has no recipe.
Ingredients []Ingredient `json:"ingredients"`
Steps []string `json:"steps"`
// Source is the site of the recipe page the dinner is grounded in;
// null for a recipe entered by hand or a dinner with no recipe.
Source *Source `json:"source"`
GroundingStatus string `json:"grounding_status"`
// GroundingError is why grounding failed, one of the Reason codes;
// null unless GroundingStatus is failed.
GroundingError *string `json:"grounding_error"`
// WhyPicked is the reason of the suggestion that created the dinner
// (see GetDinnerSuggestionReasons); null for a dinner no suggestion
// created.
WhyPicked *string `json:"why_picked"`
}
// Ingredient is one recipe line. RawText is the line as the recipe gives
// it; Name, Qty, Unit and Note are normalize_recipe's reading of it, each
// null when it has none, and Name, Qty and Unit always null for a recipe
// entered by hand (see IsManualRecipe).
type Ingredient struct {
Name *string `json:"name"`
Qty *string `json:"qty"`
Unit *string `json:"unit"`
Note *string `json:"note"`
RawText string `json:"raw_text"`
}
// Source is the site a recipe was fetched from. SiteName is the name the
// page itself gives its site, and null when it gives none or the stored
// name could have come from a link (see pageSiteName). URL is the page's
// link only when it is verifiably free of secrets (see safeSourceURL), and
// null otherwise: a link pasted or offered as a candidate may carry a
// personal token, so a client links the credit only when URL is not null.
// Source is set for every fetched recipe, so a client always credits it:
// by SiteName, else by URL's host, else without naming the site.
type Source struct {
SiteName *string `json:"site_name"`
URL *string `json:"url"`
}
// Get returns dinner id with its recipe and the reason it was suggested,
// read in one read transaction. An unknown or archived dinner is
// ErrNotFound: an archived dinner has no detail to show.
func (s *Service) Get(ctx context.Context, id string) (d Detail, err error) {
tx, err := s.st.Reader.BeginTx(ctx, nil)
if err != nil {
return Detail{}, fmt.Errorf("dinners: begin read: %w", err)
}
defer func() {
err = errors.Join(err, tx.Rollback())
}()
q := db.New(tx)
row, err := q.GetDinner(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return Detail{}, ErrNotFound
}
if err != nil {
return Detail{}, fmt.Errorf("dinners: read %s: %w", id, err)
}
if row.Status == StatusArchived {
return Detail{}, ErrNotFound
}
d = Detail{
ID: row.ID,
Name: row.Name,
Status: row.Status,
Origin: row.Origin,
Method: nullString(row.Method),
Difficulty: nullString(row.Difficulty),
Cuisine: nullString(row.Cuisine),
IsFavorite: row.IsFavorite == 1,
Ingredients: []Ingredient{},
Steps: []string{},
GroundingStatus: row.GroundingStatus,
GroundingError: nullString(row.GroundingError),
}
if row.MinutesTotal.Valid {
d.MinutesTotal = &row.MinutesTotal.Int64
}
if row.PhotoID.Valid {
d.PhotoURLs = make(map[string]string, len(recipes.PhotoSizes))
for _, size := range recipes.PhotoSizes {
d.PhotoURLs[strconv.Itoa(size)] = photoURL(row.PhotoID.String, size)
}
}
if (row.GroundingStatus == GroundingGrounded || row.GroundingStatus == GroundingManual) && !row.RecipeID.Valid {
return Detail{}, fmt.Errorf("dinners: %s is %s with no recipe", id, row.GroundingStatus)
}
if row.RecipeID.Valid {
if err := readRecipe(ctx, q, row.RecipeID.String, &d); err != nil {
return Detail{}, fmt.Errorf("dinners: read recipe of %s: %w", id, err)
}
}
reasons, err := q.GetDinnerSuggestionReasons(ctx, id)
if err != nil {
return Detail{}, fmt.Errorf("dinners: read suggestion reason of %s: %w", id, err)
}
switch len(reasons) {
case 0:
case 1:
d.WhyPicked = &reasons[0]
default:
// Nothing records which of them created the dinner, and any
// one picked would be a guess.
return Detail{}, fmt.Errorf("dinners: %s has %d suggestions that could have created it", id, len(reasons))
}
return d, nil
}
// readRecipe sets d's Source, Ingredients and Steps from recipe recipeID.
func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail) error {
rec, err := q.GetRecipe(ctx, recipeID)
if err != nil {
return fmt.Errorf("recipe %s: %w", recipeID, err)
}
if !IsManualRecipe(rec.SourceUrl) {
src, err := SourceOf(rec.SiteName, rec.SourceUrl)
if err != nil {
return err
}
d.Source = &src
}
ings, err := q.ListRecipeIngredients(ctx, recipeID)
if err != nil {
return fmt.Errorf("ingredients: %w", err)
}
for i, ing := range ings {
if i > 0 && ing.Position == ings[i-1].Position {
return duplicatePosition("ingredient", ing.Position)
}
d.Ingredients = append(d.Ingredients, Ingredient{
Name: nullString(ing.Name),
Qty: nullString(ing.Qty),
Unit: nullString(ing.Unit),
Note: nullString(ing.Note),
RawText: ing.RawText,
})
}
steps, err := q.ListRecipeSteps(ctx, recipeID)
if err != nil {
return fmt.Errorf("steps: %w", err)
}
for i, step := range steps {
if i > 0 && step.Position == steps[i-1].Position {
return duplicatePosition("step", step.Position)
}
d.Steps = append(d.Steps, step.Text)
}
return nil
}
// SourceOf is the Source of a recipe fetched from sourceURL whose stored
// site name is siteName: the name only when the page gave it (see
// pageSiteName) and the link only when it is free of secrets (see
// safeSourceURL). It is the one way a fetched recipe's source reaches a
// client. A sourceURL that does not parse is an error, whether or not a
// site name is stored.
func SourceOf(siteName sql.NullString, sourceURL string) (Source, error) {
link, err := url.Parse(sourceURL)
if err != nil {
// Not wrapped: url.Parse's error quotes the link.
return Source{}, errors.New("source url does not parse")
}
return Source{SiteName: pageSiteName(siteName, link), URL: safeSourceURL(sourceURL)}, nil
}
// pageSiteName is the stored site name of a recipe fetched from link
// when it is one the page gave, else nil. A page that names no site is
// stored under recipes.HostSiteName of the URL it was served from: the
// pasted link's host, which may hold a personal token (a private
// subdomain), or that of a page the link redirected to, which is not
// stored. Nothing records which a stored name is, so any name that could
// be a host — the link's own host, an IP address, or a dotted host name —
// is treated as one and withheld. A site that names itself as a domain,
// such as "Food.com", loses its name with them: a credit that does not
// name the site is better than one that leaks a link.
func pageSiteName(stored sql.NullString, link *url.URL) *string {
if !stored.Valid {
return nil
}
name := stored.String
if strings.EqualFold(name, recipes.HostSiteName(link)) || net.ParseIP(name) != nil {
return nil
}
if u, err := url.Parse("https://" + name + "/"); err == nil && u.Host == name && strings.Contains(name, ".") {
return nil
}
return &name
}
// safeSourceURL is sourceURL when it is verifiably free of secrets — an
// https link to a host, with no user or password, no query (not even a
// bare "?") and no fragment (not even a bare "#") — else nil. Those are
// the places a link carries a token; a link failing any check, or not
// parsing, is withheld rather than trimmed, since a trimmed link is not the
// page the recipe came from. The client (sourceUrl in
// web/src/dinners/detail.ts) parses the link with the browser's URL parser
// and refuses the whole recipe when it fails, so the host and port must
// also be ones it parses: see browserHost.
func safeSourceURL(sourceURL string) *string {
u, err := url.Parse(sourceURL)
if err != nil || u.Scheme != "https" || u.Opaque != "" || u.Host == "" || u.User != nil ||
u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(sourceURL, "#") {
return nil
}
// url.Parse decodes a percent-encoded host; the link must spell its
// host out as it is checked.
authority, _, _ := strings.Cut(sourceURL[len(u.Scheme)+len("://"):], "/")
if authority != u.Host || !browserHost(u) {
return nil
}
if port := u.Port(); port != "" {
if n, err := strconv.ParseUint(port, 10, 16); err != nil || n == 0 {
return nil
}
}
return &sourceURL
}
// browserHost says whether u's host is one the browser's URL parser (the
// WHATWG URL Standard) accepts as it stands, where url.Parse accepts more,
// such as "[bad]". The rule is narrower than the browser's: a bracketed
// host is an IPv6 address without a zone; any other is ASCII letters,
// digits and hyphens in dot-separated labels, none empty but a trailing
// one, none punycode ("xn--"), since the browser refuses invalid punycode
// and checking it is not worth a decoder; and a host whose last label is
// a number, which the browser reads as an IPv4 address, must be a dotted
// quad. A link to any other host is withheld.
func browserHost(u *url.URL) bool {
host := u.Hostname()
if strings.HasPrefix(u.Host, "[") {
ip := net.ParseIP(host)
return ip != nil && strings.Contains(host, ":")
}
labels := strings.Split(strings.TrimSuffix(host, "."), ".")
for _, label := range labels {
if label == "" || len(label) >= 4 && strings.EqualFold(label[:4], "xn--") {
return false
}
for _, c := range label {
if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || c == '-') {
return false
}
}
}
last := labels[len(labels)-1]
if strings.Trim(last, "0123456789") == "" || len(last) >= 2 && strings.EqualFold(last[:2], "0x") {
ip := net.ParseIP(host)
return ip != nil && ip.To4() != nil && !strings.Contains(host, ":")
}
return true
}
// duplicatePosition is the error for two recipe items of kind at position.
// Position is the only record of recipe order, and the id that breaks the
// tie says nothing about it, so a recipe with a duplicate is refused rather
// than shown in an order nobody can trust.
func duplicatePosition(kind string, position int64) error {
return fmt.Errorf("two %ss at position %d: recipe order is unknown", kind, position)
}
+4 -106
View File
@@ -288,89 +288,6 @@ func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy str
return row, nil
}
// IdeaOrigins are the origins of a dinner added from an idea: ingredients
// for Cook from what I have, quick for Quick dinners.
var IdeaOrigins = []string{"ingredients", "quick"}
// NewQuestLog is a dinner to add to the Quest log from an idea: its name,
// the recipe the idea was already grounded in, and its origin, one of
// IdeaOrigins.
type NewQuestLog struct {
Name string
RecipeID string
Origin string
}
// AddToQuestLog adds n to the Quest log, recording updatedBy: a dinner
// with status quest_log and origin n.Origin, grounded in recipe n.RecipeID
// (its method, minutes, difficulty and photo), and returns it with
// created true. When an unarchived dinner already repeats it, by the same
// rule that refuses a duplicate Regular (a harness.Similar name, or the
// recipe's link), that dinner is returned as it is, with created false,
// so adding the same idea again never makes a second dinner: a Regular
// stays a Regular and a Quest log dinner stays in the Quest log. A
// repeated dinner still awaiting the household's choice (a suggested Side
// quest, or one tried and awaiting its Rotation check) is a
// *DuplicateError and changes nothing: adding it would make that choice
// for them. An unknown recipe is an error.
func (s *Service) AddToQuestLog(ctx context.Context, n NewQuestLog, updatedBy string) (d Dinner, created bool, err error) {
if updatedBy == "" {
return Dinner{}, false, errors.New("dinners: add to Quest log: no member to record as updated_by")
}
if !slices.Contains(IdeaOrigins, n.Origin) {
return Dinner{}, false, fmt.Errorf("dinners: add to Quest log: origin %q is not one of %s", n.Origin, strings.Join(IdeaOrigins, ", "))
}
name, err := checkName(n.Name)
if err != nil {
return Dinner{}, false, err
}
var row db.Dinner
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
recipe, err := q.GetRecipe(ctx, n.RecipeID)
if err != nil {
return fmt.Errorf("dinners: add to Quest log: read recipe %s: %w", n.RecipeID, err)
}
existing, found, err := findRepeat(ctx, q, "", name, sql.NullString{String: recipe.SourceUrl, Valid: true})
if err != nil {
return err
}
if found {
if existing.Status != StatusRegular && existing.Status != StatusQuestLog {
return &DuplicateError{Existing: fromRow(existing)}
}
row = existing
return nil
}
now := store.Now().String()
row, err = q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: StatusQuestLog,
Origin: n.Origin,
Method: recipe.Method,
MinutesTotal: recipe.MinutesTotal,
Difficulty: recipe.Difficulty,
RecipeID: sql.NullString{String: recipe.ID, Valid: true},
PhotoID: recipe.PhotoID,
GroundingStatus: GroundingGrounded,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return fmt.Errorf("dinners: add to Quest log: %w", err)
}
created = true
return nil
})
if err != nil {
return Dinner{}, false, err
}
return fromRow(row), created, nil
}
// List returns the dinners with status, which must be regular or
// quest_log, ordered by normalized name. The Quest log is the dinners
// accepted and not yet scheduled, so a quest_log dinner on the plan for the
@@ -634,23 +551,9 @@ func checkRecipeURL(raw string) (*url.URL, error) {
// source_url of the recipe it is grounded in, else the recipe_url still
// awaiting grounding.
func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) error {
row, found, err := findRepeat(ctx, q, self, name, recipeURL)
if err != nil {
return err
}
if found {
return &DuplicateError{Existing: fromRow(row)}
}
return nil
}
// findRepeat returns the first unarchived dinner other than self that
// name or recipeURL repeats, as checkUnique decides it, and whether there
// is one.
func findRepeat(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) (db.Dinner, bool, error) {
rows, err := q.ListUnarchivedDinners(ctx)
if err != nil {
return db.Dinner{}, false, fmt.Errorf("dinners: list for dedupe: %w", err)
return fmt.Errorf("dinners: list for dedupe: %w", err)
}
for _, row := range rows {
if row.Dinner.ID == self {
@@ -661,20 +564,15 @@ func findRepeat(ctx context.Context, q *db.Queries, self, name string, recipeURL
link = row.RecipeSourceUrl
}
if harness.Similar(name, row.Dinner.Name) || (recipeURL.Valid && link == recipeURL) {
return row.Dinner, true, nil
return &DuplicateError{Existing: fromRow(row.Dinner)}
}
}
return db.Dinner{}, false, nil
return nil
}
// PhotoURL is where the API serves a list-sized copy of photo id.
func PhotoURL(id string) string {
return photoURL(id, photoListWidth)
}
// photoURL is where the API serves photo id at stored width size.
func photoURL(id string, size int) string {
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, size)
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, photoListWidth)
}
func fromRow(r db.Dinner) Dinner {
-104
View File
@@ -1,7 +1,6 @@
package dinners_test
import (
"database/sql"
"encoding/json"
"errors"
"strings"
@@ -75,106 +74,3 @@ func TestCreateRegularNeverNamesFromLink(t *testing.T) {
}
check("duplicate error", dup.Error())
}
// TestPageSiteNameWithholdsHosts: a stored site name that could be a host —
// the link's own, in any case and without "www.", an IP address or a dotted
// host name — is withheld; a name the page gave, dotless or with spaces, is
// kept.
func TestPageSiteNameWithholdsHosts(t *testing.T) {
t.Parallel()
const link = "https://www.TokenHost/recipes/stew"
for _, tc := range []struct {
stored string
valid bool
want *string
}{
{"", false, nil},
{"tokenhost", true, nil},
{"TOKENHOST", true, nil},
{"private-token.example.com", true, nil},
{"Food.com", true, nil},
{"203.0.113.7", true, nil},
{"2001:db8::1", true, nil},
{"Delish", true, ptr("Delish")},
{"Bon Appétit", true, ptr("Bon Appétit")},
{"Mr. Food Test Kitchen", true, ptr("Mr. Food Test Kitchen")},
} {
src, err := dinners.SourceOf(sql.NullString{String: tc.stored, Valid: tc.valid}, link)
if err != nil {
t.Fatalf("SourceOf(%q): %v", tc.stored, err)
}
if got := src.SiteName; (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) {
t.Errorf("SourceOf(%q) site name = %v, want %v", tc.stored, deref(got), deref(tc.want))
}
}
// A link that does not parse is an error with or without a site name.
for _, site := range []sql.NullString{{String: "Delish", Valid: true}, {}} {
if _, err := dinners.SourceOf(site, "https://secret-token\x7f.example/%zz"); err == nil || strings.Contains(err.Error(), "secret-token") {
t.Errorf("SourceOf(%v) of an unparseable link: err = %v, want one not quoting the link", site, err)
}
}
}
// TestSafeSourceURLOnlyWithoutSecrets: a recipe link is shown only when it
// is https to a host with no user, password, query or fragment, each of
// which can carry a token; any other link, or one that does not parse, is
// withheld whole.
func TestSafeSourceURLOnlyWithoutSecrets(t *testing.T) {
t.Parallel()
for _, link := range []string{
"https://www.example.com/recipes/chicken-curry",
"https://example.com",
"https://example.com:8443/stew/",
"https://[2001:db8::1]/curry",
"https://1.2.3.4/curry",
"https://Kitchen-2.Example.com./curry",
} {
if got := dinners.SafeSourceURL(link); got == nil || *got != link {
t.Errorf("SafeSourceURL(%q) = %v, want the link", link, deref(got))
}
}
for _, link := range []string{
"https://example.com/curry?access_token=query-token",
"https://example.com/curry?",
"https://user:password@example.com/curry",
"https://token@example.com/curry",
"https://@example.com/curry",
"http://example.com/curry",
"https://example.com/curry#token=fragment-token",
"https://example.com/curry#",
"ftp://example.com/curry",
"https:example.com/curry",
"https:///curry",
"https://example.com:99999/curry",
"https://example.com:0/curry",
"//example.com/curry",
"https://secret-token\x7f.example/%zz",
"",
// Hosts url.Parse accepts but the browser's URL parser refuses
// (or reads as another host), which would fail the whole recipe
// screen.
"https://[bad]/curry",
"https://[fe80::1%25en0]/curry",
"https://[1.2.3.4]/curry",
"https://1.2.3.999/curry",
"https://1.2.3.4.5/curry",
"https://999/curry",
"https://example.0x/curry",
"https://xn--a/curry",
"https://ex%61mple.com/curry",
"https://ex%2fample.com/curry",
"https://a..b/curry",
"https://café.example/curry",
} {
if got := dinners.SafeSourceURL(link); got != nil {
t.Errorf("SafeSourceURL(%q) = %q, want nil", link, *got)
}
}
}
func deref(s *string) any {
if s == nil {
return nil
}
return *s
}
-3
View File
@@ -71,6 +71,3 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error)
r, err := s.runGroundJob(ctx, p, nil)
return r.GroundingStatus, err
}
// SafeSourceURL is safeSourceURL.
var SafeSourceURL = safeSourceURL
-6
View File
@@ -1,6 +0,0 @@
package home
import "time"
// SetClock makes now the clock s reads "now" and "tonight" on.
func SetClock(s *Service, now func() time.Time) { s.now = now }
-253
View File
@@ -1,253 +0,0 @@
// Package home reads the Home screen: tonight's dinner, the Side quests to
// swipe, the Supply run and whether there are recent updates. It owns no
// data: every value is read through the service that owns it, and "now"
// and "tonight" are in the household's zone, never UTC.
package home
import (
"context"
"errors"
"fmt"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
)
// Service reads the Home screen.
type Service struct {
settings *household.SettingsService
plans *plan.Service
suggest *suggest.Service
shopping *shopping.Service
notifier *notify.Service
// now is the clock "now" and "tonight" are read on; tests replace it.
now func() time.Time
}
// NewService returns a service reading through the given services.
func NewService(settings *household.SettingsService, plans *plan.Service, suggestSvc *suggest.Service, shop *shopping.Service, notifier *notify.Service) (*Service, error) {
switch {
case settings == nil:
return nil, errors.New("home: nil household settings service")
case plans == nil:
return nil, errors.New("home: nil plan service")
case suggestSvc == nil:
return nil, errors.New("home: nil suggestions service")
case shop == nil:
return nil, errors.New("home: nil shopping service")
case notifier == nil:
return nil, errors.New("home: nil notifier")
}
return &Service{settings: settings, plans: plans, suggest: suggestSvc, shopping: shop, notifier: notifier, now: time.Now}, nil
}
// Home is the Home screen as the API shows it.
type Home struct {
// DateLabel is today in the household's zone, e.g. "Friday, Oct 2".
DateLabel string `json:"dateLabel"`
// Timezone is the household's IANA zone, whose next local midnight
// is when Home is read again for the new day.
Timezone string `json:"timezone"`
// Tonight is the dinner planned for today on the current week's plan;
// nil when the week has no plan.
Tonight *Dinner `json:"tonight"`
// SideQuestCount is how many Side quests are pending on the deck.
SideQuestCount int `json:"sideQuestCount"`
// ShoppingList is the Supply run of the upcoming week when it has one,
// else of the current week.
ShoppingList ShoppingStatus `json:"shoppingList"`
RecentUpdates RecentUpdates `json:"recentUpdates"`
}
// Dinner is tonight's dinner.
type Dinner struct {
ID string `json:"id"`
Name string `json:"name"`
Photo *string `json:"photo"`
// Origin is regular or side_quest.
Origin string `json:"origin"`
MinutesTotal *int64 `json:"minutes_total"`
}
// ShoppingStatus is whether a week's Supply run is ready.
type ShoppingStatus struct {
// WeekStart is the Monday of the week, YYYY-MM-DD.
WeekStart string `json:"weekStart"`
// Label is the week label, e.g. "Week 41 · Oct 5 – 11".
Label string `json:"label"`
// Ready is true when the week has a Supply run.
Ready bool `json:"ready"`
// SentToChatAt is when the Supply run was sent to Google Chat; nil
// until it is, and when there is no Supply run.
SentToChatAt *string `json:"sentToChatAt"`
}
// RecentUpdates says whether there is something new for the bell.
type RecentUpdates struct {
// Unread is true when a notification was stored after the household
// last saw its updates (household_settings.updates_seen_at), or at all
// when it never has.
Unread bool `json:"unread"`
}
// Get reads the Home screen at the service's "now". It only reads: it
// writes nothing, not even the batch bookkeeping a Deck read does.
func (s *Service) Get(ctx context.Context) (Home, error) {
settings, err := s.settings.Get(ctx)
if err != nil {
return Home{}, fmt.Errorf("home: read household settings: %w", err)
}
zone, err := household.LoadZone(settings.Timezone)
if err != nil {
return Home{}, fmt.Errorf("home: household settings: %w", err)
}
now := s.now().In(zone)
today := household.Today(now, zone)
thisWeek := household.WeekStart(today)
tonight, err := s.tonight(ctx, thisWeek, today)
if err != nil {
return Home{}, err
}
sideQuests, err := s.suggest.PendingCount(ctx)
if err != nil {
return Home{}, fmt.Errorf("home: read deck: %w", err)
}
list, err := s.shoppingStatus(ctx, thisWeek)
if err != nil {
return Home{}, err
}
unread, err := s.unread(ctx, settings.UpdatesSeenAt)
if err != nil {
return Home{}, err
}
return Home{
DateLabel: now.Format("Monday, Jan 2"),
Timezone: settings.Timezone,
Tonight: tonight,
SideQuestCount: sideQuests,
ShoppingList: list,
RecentUpdates: RecentUpdates{Unread: unread},
}, nil
}
// tonight returns the dinner planned on today in the plan of the week
// starting thisWeek, or nil when the week has no plan.
func (s *Service) tonight(ctx context.Context, thisWeek, today store.LocalDate) (*Dinner, error) {
p, err := s.plans.Get(ctx, thisWeek)
if errors.Is(err, plan.ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("home: read plan: %w", err)
}
for _, d := range p.Days {
if d.Date == today.String() {
return &Dinner{
ID: d.Dinner.ID, Name: d.Dinner.Name, Photo: d.Dinner.Photo,
Origin: d.Dinner.Origin, MinutesTotal: d.Dinner.Minutes,
}, nil
}
}
return nil, fmt.Errorf("home: plan of week %s has no day %s", thisWeek, today)
}
// shoppingStatus returns the status of the upcoming week's Supply run when
// it has one, else of the current week's, ready or not.
func (s *Service) shoppingStatus(ctx context.Context, thisWeek store.LocalDate) (ShoppingStatus, error) {
y, m, d := thisWeek.Date()
nextWeek := store.LocalDateOf(time.Date(y, m, d+7, 0, 0, 0, 0, time.UTC))
for _, week := range []store.LocalDate{nextWeek, thisWeek} {
l, err := s.shopping.Get(ctx, week)
if errors.Is(err, shopping.ErrListNotFound) {
continue
}
if err != nil {
return ShoppingStatus{}, fmt.Errorf("home: read Supply run: %w", err)
}
return weekStatus(week, true, l.SentToChatAt)
}
return weekStatus(thisWeek, false, nil)
}
func weekStatus(week store.LocalDate, ready bool, sentToChatAt *string) (ShoppingStatus, error) {
label, err := household.WeekLabel(week)
if err != nil {
return ShoppingStatus{}, fmt.Errorf("home: %w", err)
}
return ShoppingStatus{WeekStart: week.String(), Label: label, Ready: ready, SentToChatAt: sentToChatAt}, nil
}
// unread reports whether a notification was stored after seenAt, the
// stored updates_seen_at, or at all when it is nil.
func (s *Service) unread(ctx context.Context, seenAt *string) (bool, error) {
var since *time.Time
if seenAt != nil {
var ts store.Timestamp
if err := ts.Scan(*seenAt); err != nil {
return false, fmt.Errorf("home: household settings updates_seen_at: %w", err)
}
since = &ts.Time
}
unread, err := s.notifier.StoredSince(ctx, since)
if err != nil {
return false, fmt.Errorf("home: %w", err)
}
return unread, nil
}
// RecentLimit is how many events the Recent updates sheet lists.
const RecentLimit = 10
// Update is one event in the Recent updates sheet.
type Update struct {
// Type is the event type, e.g. plan_ready.
Type string `json:"type"`
Title string `json:"title"`
Body string `json:"body"`
// URL is the path-absolute, same-origin path a tap on the update opens.
URL string `json:"url"`
// CreatedAt is when the event was stored, UTC RFC 3339 with
// milliseconds; it is what POST /updates/seen takes back.
CreatedAt string `json:"created_at"`
}
// Updates returns the last RecentLimit distinct notification events, newest
// first, whatever channels they went to.
func (s *Service) Updates(ctx context.Context) ([]Update, error) {
events, err := s.notifier.Recent(ctx, RecentLimit)
if err != nil {
return nil, fmt.Errorf("home: %w", err)
}
out := make([]Update, len(events))
for i, ev := range events {
out[i] = Update{
Type: string(ev.Type), Title: ev.Title, Body: ev.Body, URL: ev.URL,
CreatedAt: store.Timestamp{Time: ev.CreatedAt}.String(),
}
}
return out, nil
}
// ErrSeenInFuture is returned by MarkUpdatesSeen for a time after now.
var ErrSeenInFuture = errors.New("home: updates cannot be seen through a time after now")
// MarkUpdatesSeen records that the household has seen its updates through
// through, the created_at of the newest update it was shown, so
// recentUpdates.unread is false until an event is stored after it. A time
// before the one already recorded changes nothing; one after now is
// ErrSeenInFuture, since it would hide events not yet stored.
func (s *Service) MarkUpdatesSeen(ctx context.Context, through time.Time) error {
if through.After(s.now()) {
return ErrSeenInFuture
}
if err := s.settings.MarkUpdatesSeen(ctx, through); err != nil {
return fmt.Errorf("home: %w", err)
}
return nil
}
-598
View File
@@ -1,598 +0,0 @@
package home_test
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// zone is the household's zone in these tests: not UTC, so a late local
// evening is already the next day in UTC.
const zone = "America/Chicago"
// weekDates is the week of 2026-10-12, Monday first.
var weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
type env struct {
svc *home.Service
st *store.Store
settings *household.SettingsService
notifier *notify.Service
loc *time.Location
}
// newEnv returns a home service over a migrated temp database whose
// household is in zone, reading "now" from a clock set to at.
func newEnv(t *testing.T) *env {
t.Helper()
ctx := t.Context()
st, err := store.Open(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if err := household.SeedSettings(ctx, st, zone); err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
notifier := notifytest.New(t, st)
pub := &url.URL{Scheme: "https", Host: "mealquest.example"}
box, err := secretbox.New(base64.StdEncoding.EncodeToString(make([]byte, secretbox.KeySize)))
if err != nil {
t.Fatal(err)
}
google := gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath)
googleSvc, err := gcal.NewService(st, box, gcal.Config{
ClientID: gcaltest.ClientID, ClientSecret: gcaltest.ClientSecret, PublicURL: pub,
SecretKey: []byte("server-secret-key"), Endpoint: google.Endpoint(), RevokeURL: google.RevokeURL(),
CalendarAPI: google.CalendarAPI(),
})
if err != nil {
t.Fatal(err)
}
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no model in this test")
}
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, pub, logger)
if err != nil {
t.Fatal(err)
}
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
return skills.SideQuests{}, errors.New("no model in this test")
}
suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger)
if err != nil {
t.Fatal(err)
}
chatSvc, err := chat.NewService(st, box, pub, http.DefaultTransport)
if err != nil {
t.Fatal(err)
}
title := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
return skills.ArchiveTitle{}, errors.New("no model in this test")
}
shop, err := shopping.NewService(st, title, runner, notifier, chatSvc, logger)
if err != nil {
t.Fatal(err)
}
settings := household.NewSettingsService(st)
svc, err := home.NewService(settings, plans, suggestSvc, shop, notifier)
if err != nil {
t.Fatal(err)
}
loc, err := time.LoadLocation(zone)
if err != nil {
t.Fatal(err)
}
return &env{svc: svc, st: st, settings: settings, notifier: notifier, loc: loc}
}
// at reads the Home screen with the clock at the household-local wall
// time "YYYY-MM-DD HH:MM".
func (e *env) at(t *testing.T, local string) home.Home {
t.Helper()
now, err := time.ParseInLocation("2006-01-02 15:04", local, e.loc)
if err != nil {
t.Fatal(err)
}
home.SetClock(e.svc, func() time.Time { return now })
h, err := e.svc.Get(t.Context())
if err != nil {
t.Fatal(err)
}
return h
}
// dinner stores a dinner and returns its id.
func (e *env) dinner(t *testing.T, name, status, origin string, minutes int64) string {
t.Helper()
now := store.Now().String()
d, err := db.New(e.st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: status, Origin: origin,
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
return d.ID
}
// planWeek stores the plan of the week of 2026-10-12: the dinner named for
// its date each day, a Regular of 30 minutes, but Wednesday a Side quest of
// unknown minutes. It returns the plan's id.
func (e *env) planWeek(t *testing.T) string {
t.Helper()
ctx := t.Context()
q := db.New(e.st.Writer)
now := store.Now().String()
planID := store.NewID()
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: weekDates[0], CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
for _, date := range weekDates {
id := e.dinner(t, "Dinner "+date, "regular", "regular", 30)
if date == "2026-10-14" {
id = e.dinner(t, "Dinner "+date, "quest_log", "side_quest", 0)
}
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date,
DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
}
return planID
}
// Tonight's dinner and the date label follow the household's local
// midnight, not UTC's: late on Tuesday evening in Chicago it is already
// Wednesday in UTC, and Home still shows Tuesday's dinner.
func TestTonightSwitchesAtLocalMidnight(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.planWeek(t)
for _, tc := range []struct {
local, label, dinner string
}{
// 23:59 CDT is 04:59 UTC the next day.
{"2026-10-13 23:59", "Tuesday, Oct 13", "Dinner 2026-10-13"},
{"2026-10-14 00:00", "Wednesday, Oct 14", "Dinner 2026-10-14"},
// 19:00 CDT on Sunday is Monday 00:00 UTC, a week with no plan.
{"2026-10-18 19:00", "Sunday, Oct 18", "Dinner 2026-10-18"},
} {
h := e.at(t, tc.local)
if h.DateLabel != tc.label {
t.Errorf("at %s: dateLabel = %q, want %q", tc.local, h.DateLabel, tc.label)
}
if h.Tonight == nil || h.Tonight.Name != tc.dinner {
t.Errorf("at %s: tonight = %+v, want %s", tc.local, h.Tonight, tc.dinner)
}
}
// Late Sunday evening of the week before, the plan's week has not
// begun locally, though in UTC it has.
if h := e.at(t, "2026-10-11 23:30"); h.Tonight != nil || h.DateLabel != "Sunday, Oct 11" {
t.Errorf("at 2026-10-11 23:30: tonight = %+v, dateLabel = %q, want no dinner on Sunday, Oct 11", h.Tonight, h.DateLabel)
}
}
// Tonight carries the dinner's id, name, photo, origin and minutes.
func TestTonightDinner(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.planWeek(t)
h := e.at(t, "2026-10-13 18:00")
if h.Tonight == nil || h.Tonight.ID == "" || h.Tonight.Origin != "regular" || h.Tonight.Photo != nil ||
h.Tonight.MinutesTotal == nil || *h.Tonight.MinutesTotal != 30 {
t.Errorf("tonight = %+v, want Tuesday's 30-minute Regular with no photo", h.Tonight)
}
h = e.at(t, "2026-10-14 18:00")
if h.Tonight == nil || h.Tonight.Origin != "side_quest" || h.Tonight.MinutesTotal != nil {
t.Errorf("tonight = %+v, want Wednesday's Side quest of unknown minutes", h.Tonight)
}
}
// With no plan, no suggestions, no Supply run and no notifications, Home
// says so: no dinner tonight, nothing to swipe, the current week's Supply
// run not ready and nothing unread.
func TestHomeEmpty(t *testing.T) {
t.Parallel()
e := newEnv(t)
h := e.at(t, "2026-10-13 23:59")
want := home.Home{
DateLabel: "Tuesday, Oct 13",
Timezone: zone,
ShoppingList: home.ShoppingStatus{
WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18",
},
}
if h != want {
t.Errorf("home = %+v, want %+v", h, want)
}
}
// The Side quest count is the pending cards of the deck: a decided
// suggestion is not counted.
func TestSideQuestCount(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
q := db.New(e.st.Writer)
now := store.Now().String()
batch, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
if err != nil {
t.Fatal(err)
}
if _, err := q.FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: batch.ID}); err != nil {
t.Fatal(err)
}
for _, name := range []string{"Pho", "Ramen", "Tagine"} {
id := e.dinner(t, name, "suggested", "side_quest", 0)
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: name, Reason: "Because", CitedRegularIds: "[]",
CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
}
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 3 {
t.Errorf("sideQuestCount = %d, want 3", h.SideQuestCount)
}
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE suggestions SET status = 'declined', decided_at = ? WHERE name = 'Pho'`, now); err != nil {
t.Fatal(err)
}
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 2 {
t.Errorf("sideQuestCount after a Pass = %d, want 2", h.SideQuestCount)
}
}
// Reading Home writes nothing: a batch still pending whose job is gone,
// which a Deck read would fail, is left as it was, and the read succeeds.
func TestGetDoesNotWriteBatches(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
created := "2026-10-13T12:00:00.000Z"
batch, err := db.New(e.st.Writer).CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: created, UpdatedAt: created})
if err != nil {
t.Fatal(err)
}
if batch.Status != "pending" {
t.Fatalf("new batch status = %q, want pending", batch.Status)
}
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 0 {
t.Errorf("sideQuestCount = %d, want 0", h.SideQuestCount)
}
var status, updatedAt string
if err := e.st.Reader.QueryRowContext(ctx, `SELECT status, updated_at FROM suggestion_batches WHERE id = ?`, batch.ID).Scan(&status, &updatedAt); err != nil {
t.Fatal(err)
}
if status != "pending" || updatedAt != created {
t.Errorf("batch after a Home read = %s at %s, want pending at %s", status, updatedAt, created)
}
}
// The Supply run shown is the upcoming week's when it has one, else the
// current week's; the weeks are the household's local weeks.
func TestShoppingStatus(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
planID := e.planWeek(t)
list := func(weekStart string, sent sql.NullString) {
t.Helper()
now := store.Now().String()
if _, err := e.st.Writer.ExecContext(ctx,
`INSERT INTO shopping_lists (id, week_start, plan_id, status, sent_to_chat_at, created_at, updated_at) VALUES (?, ?, ?, 'current', ?, ?, ?)`,
store.NewID(), weekStart, planID, sent, now, now); err != nil {
t.Fatal(err)
}
}
list("2026-10-12", sql.NullString{})
want := home.ShoppingStatus{WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18", Ready: true}
if h := e.at(t, "2026-10-16 09:00"); h.ShoppingList != want {
t.Errorf("shoppingList = %+v, want %+v", h.ShoppingList, want)
}
sent := "2026-10-17T13:00:00.000Z"
list("2026-10-19", sql.NullString{String: sent, Valid: true})
h := e.at(t, "2026-10-16 09:00")
if s := h.ShoppingList; s.WeekStart != "2026-10-19" || s.Label != "Week 43 · Oct 19 – 25" || !s.Ready || s.SentToChatAt == nil || *s.SentToChatAt != sent {
t.Errorf("shoppingList = %+v, want the upcoming week's, sent at %s", s, sent)
}
// Late Sunday evening locally is Monday in UTC: the current week is
// still the week of 2026-10-12, so its upcoming week's list shows.
if s := e.at(t, "2026-10-18 23:30").ShoppingList; s.WeekStart != "2026-10-19" {
t.Errorf("late Sunday: shoppingList week = %s, want 2026-10-19", s.WeekStart)
}
// On Monday the week of 2026-10-19 is current and has no upcoming
// list.
if s := e.at(t, "2026-10-19 08:00").ShoppingList; s.WeekStart != "2026-10-19" || !s.Ready {
t.Errorf("Monday: shoppingList = %+v, want the current week's", s)
}
// A week later neither the current nor the upcoming week has a list.
want = home.ShoppingStatus{WeekStart: "2026-10-26", Label: "Week 44 · Oct 26 – Nov 1"}
if s := e.at(t, "2026-10-26 08:00").ShoppingList; s != want {
t.Errorf("no list: shoppingList = %+v, want %+v", s, want)
}
}
// Recent updates are unread when a notification was stored after the
// household last saw its updates, or ever when it never has.
func TestRecentUpdatesUnread(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread {
t.Error("unread with no notifications")
}
if err := e.notifier.Emit(ctx, notify.Event{Type: notify.NewSuggestions, Key: "new_suggestions:1", Title: "New Side quests", URL: "/discover"}); err != nil {
t.Fatal(err)
}
if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread {
t.Error("not unread with a notification never seen")
}
seen := func(at time.Time) {
t.Helper()
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updates_seen_at = ?`, store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()); err != nil {
t.Fatal(err)
}
}
seen(time.Now().Add(time.Hour))
if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread {
t.Error("unread with every notification seen")
}
seen(time.Now().Add(-time.Hour))
if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread {
t.Error("not unread with a notification stored after updates were seen")
}
}
func TestNewServiceRequiresServices(t *testing.T) {
t.Parallel()
if _, err := home.NewService(nil, nil, nil, nil, nil); err == nil {
t.Fatal("NewService accepted nil services")
}
}
// store stores event key of type typ in the outbox at instant at, for each
// of channels, as notify stores an event.
func (e *env) store(t *testing.T, typ notify.EventType, key string, at time.Time, channels ...notify.Channel) {
t.Helper()
payload, err := json.Marshal(notify.Payload{Event: typ, Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key, Tag: key})
if err != nil {
t.Fatal(err)
}
ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()
for _, ch := range channels {
if _, err := db.New(e.st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{
ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload),
NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts,
}); err != nil {
t.Fatal(err)
}
}
}
// Updates are the last ten distinct events, newest first: an event stored
// for push and Google Chat is listed once, whatever became of its delivery.
func TestUpdates(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
got, err := e.svc.Updates(ctx)
if err != nil {
t.Fatal(err)
}
if got == nil || len(got) != 0 {
t.Errorf("updates with no notifications = %#v, want an empty list", got)
}
base := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
for i := range 12 {
key := fmt.Sprintf("plan_ready:%02d", i)
e.store(t, notify.PlanReady, key, base.Add(time.Duration(i)*time.Minute), notify.ChannelPush, notify.ChannelChat)
}
// A delivered and a failed row of one event are still one event.
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'sent' WHERE event_key = 'plan_ready:11' AND channel = 'push'`); err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'failed' WHERE event_key = 'plan_ready:11' AND channel = 'chat'`); err != nil {
t.Fatal(err)
}
got, err = e.svc.Updates(ctx)
if err != nil {
t.Fatal(err)
}
if len(got) != home.RecentLimit {
t.Fatalf("updates = %d, want %d: %+v", len(got), home.RecentLimit, got)
}
for i, u := range got {
n := 11 - i
key := fmt.Sprintf("plan_ready:%02d", n)
want := home.Update{
Type: "plan_ready", Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key,
CreatedAt: store.Timestamp{Time: base.Add(time.Duration(n) * time.Minute)}.String(),
}
if u != want {
t.Errorf("updates[%d] = %+v, want %+v", i, u, want)
}
}
}
// An event emitted to push and Google Chat is one update.
func TestUpdatesEmittedToTwoChannels(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
if err := e.notifier.Emit(ctx, notify.Event{Type: notify.PlanReady, Key: "plan_ready:2026-10-12", Title: "Your week is drafted", Body: "Take a look", URL: "/plan"}); err != nil {
t.Fatal(err)
}
var rows int
if err := e.st.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM notification_outbox`).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 2 {
t.Fatalf("outbox rows = %d, want push and chat", rows)
}
got, err := e.svc.Updates(ctx)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Type != "plan_ready" || got[0].Title != "Your week is drafted" || got[0].Body != "Take a look" || got[0].URL != "/plan" {
t.Errorf("updates = %+v, want the one plan_ready event", got)
}
}
// Marking updates seen through the newest clears unread until a newer event
// is stored; marking them seen through an older time does not move the
// mark back, and a time after now is refused.
func TestMarkUpdatesSeen(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
unread := func() bool {
t.Helper()
return e.at(t, "2026-10-13 12:00").RecentUpdates.Unread
}
first := time.Date(2026, 10, 13, 16, 0, 0, 0, time.UTC)
e.store(t, notify.NewSuggestions, "new_suggestions:1", first, notify.ChannelPush)
if !unread() {
t.Fatal("not unread with an event never seen")
}
// The settings were last edited well before: marking updates seen must
// keep that time and tell no settings subscriber.
edited := (store.Timestamp{Time: time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)}).String()
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updated_at = ?`, edited); err != nil {
t.Fatal(err)
}
var notified int
unsubscribe := e.settings.Subscribe(func(household.Settings) { notified++ })
defer unsubscribe()
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
t.Fatal(err)
}
if unread() {
t.Error("unread after marking the newest event seen")
}
second := first.Add(time.Millisecond)
e.store(t, notify.PlanReady, "plan_ready:2", second, notify.ChannelPush, notify.ChannelChat)
if !unread() {
t.Error("not unread with an event stored after the mark")
}
// Seen again through the older event, as a sheet opened before the
// newer one arrived would: nothing moves, so it stays unread.
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
t.Fatal(err)
}
if !unread() {
t.Error("an older mark hid the newer event")
}
if err := e.svc.MarkUpdatesSeen(ctx, second); err != nil {
t.Fatal(err)
}
if unread() {
t.Error("unread after marking the newer event seen")
}
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
t.Fatal(err)
}
settings, err := e.settings.Get(ctx)
if err != nil {
t.Fatal(err)
}
if want := (store.Timestamp{Time: second}).String(); settings.UpdatesSeenAt == nil || *settings.UpdatesSeenAt != want {
t.Errorf("updates_seen_at = %v, want %s: an older mark moved it back", settings.UpdatesSeenAt, want)
}
if settings.UpdatedBy != nil {
t.Errorf("updated_by = %q, want unset: marking updates seen is not a settings edit", *settings.UpdatedBy)
}
if settings.UpdatedAt != edited {
t.Errorf("updated_at = %s, want %s: marking updates seen is not a settings edit", settings.UpdatedAt, edited)
}
if notified != 0 {
t.Errorf("settings subscribers told %d times, want none: marking updates seen is not a settings edit", notified)
}
// The clock reads 17:00 UTC.
if err := e.svc.MarkUpdatesSeen(ctx, first.Add(2*time.Hour)); !errors.Is(err, home.ErrSeenInFuture) {
t.Errorf("mark seen through an hour from now = %v, want ErrSeenInFuture", err)
}
}
// An event stored again for a channel switched on later is as new as that
// row: the update shows its time, and marking it seen clears unread.
func TestUpdateStoredAgainForAnotherChannel(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
first := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
later := first.Add(30 * time.Minute)
e.store(t, notify.PlanReady, "plan_ready:1", first, notify.ChannelPush)
e.store(t, notify.PlanReady, "plan_ready:1", later, notify.ChannelChat)
got, err := e.svc.Updates(ctx)
if err != nil {
t.Fatal(err)
}
if want := (store.Timestamp{Time: later}).String(); len(got) != 1 || got[0].CreatedAt != want {
t.Fatalf("updates = %+v, want the one event at %s", got, want)
}
e.at(t, "2026-10-13 12:00")
if err := e.svc.MarkUpdatesSeen(ctx, later); err != nil {
t.Fatal(err)
}
if e.at(t, "2026-10-13 12:00").RecentUpdates.Unread {
t.Error("unread after the event was marked seen through its listed time")
}
}
-16
View File
@@ -8,7 +8,6 @@ import (
"regexp"
"strings"
"sync"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -201,21 +200,6 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
return settings, nil
}
// MarkUpdatesSeen records that the household has seen its Recent updates
// through seenAt: updates_seen_at moves forward to seenAt, and is left as it
// is when it is already at or after it. It is not a settings edit, so
// updated_by and updated_at are kept and subscribers are not told.
func (s *SettingsService) MarkUpdatesSeen(ctx context.Context, seenAt time.Time) error {
if seenAt.IsZero() {
return errors.New("household: mark updates seen: zero time")
}
_, err := db.New(s.st.Writer).MarkUpdatesSeen(ctx, store.Timestamp{Time: seenAt.UTC().Truncate(time.Millisecond)}.String())
if err != nil {
return fmt.Errorf("household: mark updates seen: %w", err)
}
return nil
}
// applyPatch applies a validated patch to the household settings row in q's
// transaction, recording updatedBy, and returns the new row.
func applyPatch(ctx context.Context, q *db.Queries, patch SettingsPatch, updatedBy string) (db.HouseholdSetting, error) {
-747
View File
@@ -1,747 +0,0 @@
// Package ideas finds dinner ideas for Cook from what I have and Quick
// dinners: from a list of ingredients on hand, ideas_from_ingredients
// proposes dinners, and for a kind of Quick dinner, quick_ideas does; each
// is grounded with the recipe grounding pipeline (dropping those no recipe
// grounds, and Quick dinners whose grounded recipe is not of the kind), and
// the ideas kept are reported as a job's result, which fills as each idea
// grounds. A job that keeps no idea, or fails, shows no idea: nothing is
// made up instead (D-017). Ideas are not stored as dinners: the household
// acts on one later.
package ideas
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"slices"
"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/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"
)
// Job kinds.
const (
// KindFromIngredients is the job that finds ideas from ingredients.
KindFromIngredients = "ideas_from_ingredients"
// KindQuick is the job that finds a page of Quick dinner ideas.
KindQuick = "quick_ideas"
)
// ContextBudget is the character budget of the household context
// quick_ideas is given (see harness.BuildContext).
const ContextBudget = 16000
// QuickMinutes is the most minutes from prep to table of a Quick dinner
// not cooked in a crock pot.
const QuickMinutes = 30
// Job stages, as the job's result reports them.
const (
StageSuggesting = "suggesting"
StageGrounding = "grounding"
StageDone = "done"
)
// Reasons the job failed, stored as its failure code. Any other failure,
// such as the database's, is the runner's internal_error.
const (
// ReasonAIFailed: ideas_from_ingredients or quick_ideas, or
// normalize_recipe while grounding, failed; no idea 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"
// ReasonNoIdeasFound: every idea was dropped, by the skill's rules,
// because no recipe grounds it, or because its grounded recipe is not
// a Quick dinner (a Quick only request) or not of the kind asked for (a
// Quick dinners request).
ReasonNoIdeasFound = "no_ideas_found"
)
// Reasons are every failure reason of both jobs.
var Reasons = []string{ReasonAIFailed, ReasonSearchNotConfigured, ReasonSearchFailed, ReasonNoIdeasFound}
// InvalidError is a request the household must correct, such as an empty
// ingredient list or an unknown kind of Quick dinner.
type InvalidError struct {
Message string
}
func (e *InvalidError) Error() string { return e.Message }
// Generator runs ideas_from_ingredients on in; scope is
// skills.IdeasScope(in). In production it is skills.IdeasFromIngredients
// with the household's harness.Config.
type Generator func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error)
// QuickGenerator runs quick_ideas on in; scope is skills.QuickIdeasScope(in).
// In production it is skills.QuickIdeas with the household's
// harness.Config.
type QuickGenerator func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error)
// Service starts and runs idea jobs.
type Service struct {
st *store.Store
grounder *recipes.Grounder
runner *jobs.Runner
generate Generator
quick QuickGenerator
logger *slog.Logger
job *jobs.Kind[FromIngredientsJob]
quickJob *jobs.Kind[QuickJob]
}
// NewService returns a service that reads the household context from st,
// finds ideas with generate and Quick dinner ideas with quick, grounds
// them with grounder and registers the KindFromIngredients and KindQuick
// jobs on runner, which must not have started yet. The jobs are started
// only by StartFromIngredients and StartQuick, which check the request
// first.
func NewService(st *store.Store, grounder *recipes.Grounder, generate Generator, quick QuickGenerator, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("ideas: nil store")
case grounder == nil:
return nil, errors.New("ideas: nil grounder")
case generate == nil:
return nil, errors.New("ideas: nil generator")
case quick == nil:
return nil, errors.New("ideas: nil quick generator")
case runner == nil:
return nil, errors.New("ideas: nil job runner")
case logger == nil:
return nil, errors.New("ideas: nil logger")
}
s := &Service{st: st, grounder: grounder, runner: runner, generate: generate, quick: quick, logger: logger}
var err error
if s.job, err = register(runner, KindFromIngredients, s.run, shownJobResult[Result]); err != nil {
return nil, err
}
if s.quickJob, err = register(runner, KindQuick, s.runQuick, shownJobResult[QuickResult]); err != nil {
return nil, err
}
return s, nil
}
// register registers the service-only job name, run by run, whose jobs
// show their result through show.
func register[P any, R jobs.Public](runner *jobs.Runner, name string, run jobs.Handler[P, R], show func(jobs.Status, json.RawMessage) (json.RawMessage, error)) (*jobs.Kind[P], error) {
kind, err := jobs.Register(runner, name, run, Reasons...)
if err != nil {
return nil, err
}
if err := kind.ServiceOnly(); err != nil {
return nil, err
}
if err := kind.ShownResult(show); err != nil {
return nil, err
}
return kind, nil
}
// FromIngredientsJob is the KindFromIngredients payload: the ingredients
// on hand, as skills.CleanIngredients returns them, and whether only
// Quick dinners are wanted.
type FromIngredientsJob struct {
Ingredients []string `json:"ingredients"`
QuickOnly bool `json:"quick_only"`
}
// Started is the job a request started.
type Started struct {
JobID string `json:"jobId"`
}
// StartFromIngredients enqueues a KindFromIngredients job for ingredients
// and returns its id. An ingredient list skills.CleanIngredients refuses
// is an *InvalidError, and starts nothing.
func (s *Service) StartFromIngredients(ctx context.Context, ingredients []string, quickOnly bool) (Started, error) {
ings, err := skills.CleanIngredients(ingredients)
if err != nil {
return Started{}, &InvalidError{Message: "ingredients: " + err.Error()}
}
id, err := s.job.Enqueue(ctx, FromIngredientsJob{Ingredients: ings, QuickOnly: quickOnly})
if err != nil {
return Started{}, fmt.Errorf("ideas: enqueue: %w", err)
}
return Started{JobID: id}, nil
}
// Idea is a grounded idea as the job's result shows it. Method,
// MinutesTotal and Difficulty are the grounded recipe's, never the
// model's estimates.
type Idea struct {
Name string `json:"name"`
// Uses are the household's ingredients the idea uses, as the household
// wrote them and in its order.
Uses []string `json:"uses"`
// Missing are the ingredients the idea needs that the household does
// not have.
Missing []string `json:"missing"`
// Summary is the line shown under the idea, e.g. "Uses 3 of 5 ·
// missing: 1 lime" (see skills.IdeaSummary).
Summary string `json:"summary"`
// Method is stovetop, oven, crockpot, grill or other.
Method string `json:"method"`
// MinutesTotal is prep to table, null when the recipe does not say.
MinutesTotal *int `json:"minutes_total"`
// Difficulty is easy, medium or hard.
Difficulty string `json:"difficulty"`
// Quick says whether the idea is a Quick dinner (see IsQuick).
Quick bool `json:"quick"`
RecipeID string `json:"recipe_id"`
PhotoURL string `json:"photo_url"`
// SourceURL and SiteName are the grounded recipe's source as
// dinners.SourceOf gives it: each null when it could leak a secret.
SourceURL *string `json:"source_url"`
SiteName *string `json:"site_name"`
}
// Result is the KindFromIngredients result, published as the job goes:
// its stage, the request, the ideas grounded so far in the model's order
// (an idea that grounds sooner than one before it is shown first, and the
// earlier one is put back in its place when it grounds),
// and how many ideas were dropped, by ideas_from_ingredients' rules,
// because no recipe grounds them, or, for a Quick only request, because
// the grounded recipe is not a Quick dinner.
type Result struct {
Stage string `json:"stage"`
Ingredients []string `json:"ingredients"`
QuickOnly bool `json:"quick_only"`
Ideas []Idea `json:"ideas"`
Dropped int `json:"dropped"`
}
func (r Result) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// IsQuick reports whether a recipe cooked by method in minutes (nil when
// unknown) is a Quick dinner: cooked in a crock pot, or 30 minutes or less
// from prep to table.
func IsQuick(method string, minutes *int) bool {
return method == "crockpot" || (minutes != nil && *minutes <= QuickMinutes)
}
// run runs a KindFromIngredients job: it asks ideas_from_ingredients for
// skills.IdeasRequested ideas, then grounds them in the model's order, at
// most the number still wanted at a time, until skills.IdeasKept are kept
// or none are left. An idea no recipe grounds is dropped, and so, for a
// Quick only request, is one whose grounded recipe is not a Quick dinner;
// when every idea is dropped the job fails with ReasonNoIdeasFound. Any
// other grounding failure fails the job, as does ideas_from_ingredients
// failing, with no idea made up instead. The result is published at each
// stage and again as each idea is kept, so it fills as the ideas ground.
// Before the job fails, its result is published again with no ideas (see
// finish).
func (s *Service) run(ctx context.Context, p FromIngredientsJob, publish func(Result) error) (Result, error) {
res, err := s.fill(ctx, p, publish)
return finish(ctx, s.logger, "ideas from ingredients", res, err, publish)
}
// shownResult is a job result that shows ideas.
type shownResult[R any] interface {
jobs.Public
// shown is how many ideas the result shows.
shown() int
// cleared is the result showing no ideas.
cleared() R
// withSafeSources is the result with each idea's source as
// shownSource gives it.
withSafeSources() (R, error)
}
func (r Result) shown() int { return len(r.Ideas) }
func (r Result) cleared() Result {
r.Ideas = []Idea{}
return r
}
func (r Result) withSafeSources() (Result, error) {
ideas := make([]Idea, len(r.Ideas))
for i, idea := range r.Ideas {
src, err := shownSource(idea.SourceURL, idea.SiteName)
if err != nil {
return Result{}, fmt.Errorf("ideas: idea %d: %w", i, err)
}
idea.SourceURL, idea.SiteName = src.URL, src.SiteName
ideas[i] = idea
}
r.Ideas = ideas
return r, nil
}
// finish ends a job whose work returned res and err. A job that failed
// has its result published again with no ideas, to tidy the stored
// result. That write is not what keeps a failed job from showing ideas,
// since it may fail and a job interrupted by restart never makes it:
// shownJobResult, applied whenever a failed job is read, is.
func finish[R shownResult[R]](ctx context.Context, logger *slog.Logger, what string, res R, err error, publish func(R) error) (R, error) {
var zero R
if err == nil {
return res, nil
}
if ctx.Err() != nil {
return zero, err
}
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.
logger.InfoContext(ctx, what+" failed", "reason", failure.Code)
}
if res.shown() > 0 {
if perr := publish(res.cleared()); perr != nil {
return zero, fmt.Errorf("ideas: clear failed job's ideas: %w", perr)
}
}
return zero, err
}
// fill does run's work, returning a *jobs.Failure for a failure the
// household may see. On failure it returns the result last published with
// the error, so run can clear its ideas.
func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(Result) error) (Result, error) {
res := Result{Stage: StageSuggesting, Ingredients: p.Ingredients, QuickOnly: p.QuickOnly, Ideas: []Idea{}}
if err := publish(res); err != nil {
return res, err
}
in := skills.IdeasRequest{Ingredients: p.Ingredients, QuickOnly: p.QuickOnly}
out, err := s.generate(ctx, skills.IdeasScope(in), in)
if err != nil {
if ctx.Err() != nil {
return res, ctx.Err()
}
return res, &jobs.Failure{Code: ReasonAIFailed}
}
res.Stage = StageGrounding
res.Dropped = len(out.Dropped)
if err := publish(res); err != nil {
return res, err
}
reqs := make([]recipes.GroundRequest, len(out.Ideas))
for i, idea := range out.Ideas {
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
}
err = keepGrounded(ctx, s.grounder, reqs, skills.IdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (Idea, bool, error) {
idea, err := ideaOf(out.Ideas[i], gr)
return idea, !p.QuickOnly || idea.Quick, err
}, func() error { return publish(res) })
if err != nil {
return res, err
}
if len(res.Ideas) == 0 {
return res, &jobs.Failure{Code: ReasonNoIdeasFound}
}
res.Stage = StageDone
return res, nil
}
// keepGrounded grounds reqs with g in their order, at most the number
// still wanted at a time, until want ideas are shown or none are left. As
// each grounds, keep makes its idea from request i's grounding and says
// whether it is kept: a kept idea is put in *shown in its place in reqs'
// order (an idea that grounds sooner than one before it is shown first,
// and the earlier one is put back in its place when it grounds) and
// publish is called. An idea no recipe grounds, or that keep refuses,
// counts in *dropped. Any other grounding failure, or keep or publish
// failing, is returned (see ground).
func keepGrounded[T any](ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, want int, shown *[]T, dropped *int, keep func(i int, gr recipes.Grounded) (T, bool, error), publish func() error) error {
// order[k] is the index in reqs of (*shown)[k], so each idea is put in
// its place in reqs' order as it grounds.
var order []int
for next := 0; len(*shown) < want && next < len(reqs); {
first := next
round := reqs[first:min(first+want-len(*shown), len(reqs))]
next += len(round)
err := ground(ctx, g, round, func(i int, gr *recipes.Grounded) error {
if gr == nil {
*dropped++
return nil
}
idea, ok, err := keep(first+i, *gr)
if err != nil {
return err
}
if !ok {
*dropped++
return nil
}
at := len(order)
for at > 0 && order[at-1] > first+i {
at--
}
order = slices.Insert(order, at, first+i)
*shown = slices.Insert(*shown, at, idea)
return publish()
})
if err != nil {
return err
}
}
return nil
}
// ground grounds reqs with g and calls kept, one call at a time, as each
// request grounds, with its index in reqs and its grounding, or nil when
// no recipe grounds it. Any other grounding failure, or kept failing,
// stops the groundings still running and is returned, as a *jobs.Failure
// when the household may see it.
func ground(ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, kept func(i int, gr *recipes.Grounded) error) error {
groundCtx, cancel := context.WithCancel(ctx)
defer cancel()
var failed error
g.GroundEach(groundCtx, reqs, func(i int, r recipes.GroundResult) {
if failed != nil {
return
}
var ungroundable *recipes.ErrUngroundable
switch {
case r.Err == nil:
failed = kept(i, &r.Grounded)
case errors.As(r.Err, &ungroundable):
failed = kept(i, nil)
case errors.Is(r.Err, recipes.ErrSearchNotConfigured):
failed = &jobs.Failure{Code: ReasonSearchNotConfigured}
case errors.Is(r.Err, recipes.ErrSearchFailed):
failed = &jobs.Failure{Code: ReasonSearchFailed}
case errors.Is(r.Err, recipes.ErrNormalizeFailed):
failed = &jobs.Failure{Code: ReasonAIFailed}
default:
failed = fmt.Errorf("ideas: ground %q: %w", reqs[i].DishName, r.Err)
}
if failed != nil {
cancel()
}
})
if err := ctx.Err(); err != nil {
return err
}
return failed
}
// shownJobResult is a job's stored result as the job shows it. A failed
// job's shows no ideas, however the job failed, so a failed job never
// shows ideas kept before the failure. Any other job's shows each idea's
// source as shownSource gives it, so a result stored before sources were
// made safe never shows a link or name that could leak a secret.
func shownJobResult[R shownResult[R]](status jobs.Status, stored json.RawMessage) (json.RawMessage, error) {
var res R
if err := json.Unmarshal(stored, &res); err != nil {
return nil, fmt.Errorf("ideas: decode %s job's result: %w", status, err)
}
if status == jobs.StatusFailed {
return res.cleared().PublicJSON()
}
safe, err := res.withSafeSources()
if err != nil {
return nil, err
}
return safe.PublicJSON()
}
// ideaOf is idea as grounded by gr.
func ideaOf(idea skills.Idea, gr recipes.Grounded) (Idea, error) {
src, err := sourceOf(gr)
if err != nil {
return Idea{}, err
}
return Idea{
Name: idea.Name,
Uses: idea.Uses,
Missing: idea.Missing,
Summary: idea.Summary,
Method: gr.Method,
MinutesTotal: gr.MinutesTotal,
Difficulty: gr.Difficulty,
Quick: IsQuick(gr.Method, gr.MinutesTotal),
RecipeID: gr.RecipeID,
PhotoURL: dinners.PhotoURL(gr.PhotoID),
SourceURL: src.URL,
SiteName: src.SiteName,
}, nil
}
// sourceOf is gr's recipe source as a client may see it (see
// dinners.SourceOf): a grounded recipe's site name is stored only when
// not empty.
func sourceOf(gr recipes.Grounded) (dinners.Source, error) {
src, err := dinners.SourceOf(sql.NullString{String: gr.SiteName, Valid: gr.SiteName != ""}, gr.SourceURL)
if err != nil {
return dinners.Source{}, fmt.Errorf("ideas: recipe %s: %w", gr.RecipeID, err)
}
return src, nil
}
// shownSource is the source of an idea as stored in a job's result, with
// link sourceURL and site name siteName, as a client may see it (see
// dinners.SourceOf). A result stored now already holds it, and one stored
// before sources were made safe holds the grounded recipe's own, with ""
// for no site name, as sourceOf reads it; a null link, which only a result
// stored now holds, is checked as "".
func shownSource(sourceURL, siteName *string) (dinners.Source, error) {
var link string
if sourceURL != nil {
link = *sourceURL
}
var site sql.NullString
if siteName != nil {
site = sql.NullString{String: *siteName, Valid: *siteName != ""}
}
return dinners.SourceOf(site, link)
}
// QuickJob is the KindQuick payload: the kind of Quick dinner wanted,
// skills.QuickCrockpot or skills.QuickUnder30, and the names already shown
// to the household, as skills.CleanExclude returns them.
type QuickJob struct {
Kind string `json:"kind"`
Exclude []string `json:"exclude"`
}
// StartQuick enqueues a KindQuick job for a page of Quick dinner ideas of
// kind, none repeating a name in exclude, and returns its id. A kind
// skills.CheckQuickKind refuses, with no default, or an exclude list
// skills.CleanExclude refuses, is an *InvalidError, and starts nothing.
func (s *Service) StartQuick(ctx context.Context, kind string, exclude []string) (Started, error) {
if err := skills.CheckQuickKind(kind); err != nil {
return Started{}, &InvalidError{Message: "kind: " + err.Error()}
}
names, err := skills.CleanExclude(exclude)
if err != nil {
return Started{}, &InvalidError{Message: "exclude: " + err.Error()}
}
id, err := s.quickJob.Enqueue(ctx, QuickJob{Kind: kind, Exclude: names})
if err != nil {
return Started{}, fmt.Errorf("ideas: enqueue: %w", err)
}
return Started{JobID: id}, nil
}
// QuickIdea is a grounded Quick dinner idea as the job's result shows it.
// Method, MinutesTotal and Difficulty are the grounded recipe's, never the
// model's estimates, and always make it of the kind asked for (see
// OfQuickKind).
type QuickIdea struct {
Name string `json:"name"`
// Method is stovetop, oven, crockpot, grill or other: crockpot for
// skills.QuickCrockpot.
Method string `json:"method"`
// MinutesTotal is prep to table, null when the recipe does not say:
// never null, and at most QuickMinutes, for skills.QuickUnder30.
MinutesTotal *int `json:"minutes_total"`
// Difficulty is easy, medium or hard.
Difficulty string `json:"difficulty"`
RecipeID string `json:"recipe_id"`
PhotoURL string `json:"photo_url"`
// SourceURL and SiteName are as in Idea.
SourceURL *string `json:"source_url"`
SiteName *string `json:"site_name"`
}
// QuickResult is the KindQuick result, published as the job goes: its
// stage, the request, the ideas grounded so far in the model's order (as
// in Result), and how many ideas were dropped, by quick_ideas' rules
// (repeating a name already shown or an earlier idea), because no recipe
// grounds them, or because the grounded recipe is not of the kind.
type QuickResult struct {
Stage string `json:"stage"`
Kind string `json:"kind"`
Exclude []string `json:"exclude"`
Ideas []QuickIdea `json:"ideas"`
Dropped int `json:"dropped"`
}
func (r QuickResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
func (r QuickResult) shown() int { return len(r.Ideas) }
func (r QuickResult) cleared() QuickResult {
r.Ideas = []QuickIdea{}
return r
}
func (r QuickResult) withSafeSources() (QuickResult, error) {
ideas := make([]QuickIdea, len(r.Ideas))
for i, idea := range r.Ideas {
src, err := shownSource(idea.SourceURL, idea.SiteName)
if err != nil {
return QuickResult{}, fmt.Errorf("ideas: idea %d: %w", i, err)
}
idea.SourceURL, idea.SiteName = src.URL, src.SiteName
ideas[i] = idea
}
r.Ideas = ideas
return r, nil
}
// OfQuickKind reports whether a recipe cooked by method in minutes (nil
// when unknown) is a Quick dinner of kind: for skills.QuickCrockpot,
// cooked in a crock pot (normalize_recipe writes a slow cooker as
// crockpot); for skills.QuickUnder30, known to be 30 minutes or less from
// prep to table. Any other kind is none.
func OfQuickKind(kind, method string, minutes *int) bool {
switch kind {
case skills.QuickCrockpot:
return method == "crockpot"
case skills.QuickUnder30:
return minutes != nil && *minutes <= QuickMinutes
}
return false
}
// runQuick runs a KindQuick job: from the household context, it asks
// quick_ideas for skills.QuickIdeasRequested ideas of the kind, more than
// a page holds, then grounds them in the model's order, at most the number
// still wanted at a time, until skills.QuickIdeasKept are kept or none are
// left. An idea no recipe grounds is dropped, and so is one whose grounded
// recipe is not of the kind (OfQuickKind): the model's estimate never
// keeps an idea. When fewer than skills.QuickIdeasKept survive, the page
// shows only those: fewer is honest, and it is never padded with an idea
// not of the kind. When every idea is dropped the job fails with
// ReasonNoIdeasFound. Any other grounding failure fails the job, as does
// quick_ideas failing, with no idea made up instead. The result is
// published at each stage and again as each idea is kept, and a failed job
// shows no ideas, as for KindFromIngredients (see finish).
func (s *Service) runQuick(ctx context.Context, p QuickJob, publish func(QuickResult) error) (QuickResult, error) {
res, err := s.fillQuick(ctx, p, publish)
return finish(ctx, s.logger, "quick ideas", res, err, publish)
}
// fillQuick does runQuick's work as fill does run's.
func (s *Service) fillQuick(ctx context.Context, p QuickJob, publish func(QuickResult) error) (QuickResult, error) {
res := QuickResult{Stage: StageSuggesting, Kind: p.Kind, Exclude: p.Exclude, Ideas: []QuickIdea{}}
if err := publish(res); err != nil {
return res, err
}
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
if err != nil {
return res, fmt.Errorf("ideas: %w", err)
}
in := skills.QuickIdeasRequest{Context: hctx, Kind: p.Kind, Exclude: p.Exclude}
out, err := s.quick(ctx, skills.QuickIdeasScope(in), in)
if err != nil {
if ctx.Err() != nil {
return res, ctx.Err()
}
return res, &jobs.Failure{Code: ReasonAIFailed}
}
res.Stage = StageGrounding
res.Dropped = len(out.Dropped)
if err := publish(res); err != nil {
return res, err
}
reqs := make([]recipes.GroundRequest, len(out.Ideas))
for i, idea := range out.Ideas {
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
}
err = keepGrounded(ctx, s.grounder, reqs, skills.QuickIdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (QuickIdea, bool, error) {
idea, err := quickIdeaOf(out.Ideas[i], gr)
return idea, OfQuickKind(p.Kind, gr.Method, gr.MinutesTotal), err
}, func() error { return publish(res) })
if err != nil {
return res, err
}
if len(res.Ideas) == 0 {
return res, &jobs.Failure{Code: ReasonNoIdeasFound}
}
res.Stage = StageDone
return res, nil
}
// quickIdeaOf is idea as grounded by gr.
func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) (QuickIdea, error) {
src, err := sourceOf(gr)
if err != nil {
return QuickIdea{}, err
}
return QuickIdea{
Name: idea.Name,
Method: gr.Method,
MinutesTotal: gr.MinutesTotal,
Difficulty: gr.Difficulty,
RecipeID: gr.RecipeID,
PhotoURL: dinners.PhotoURL(gr.PhotoID),
SourceURL: src.URL,
SiteName: src.SiteName,
}, nil
}
// Origins of a dinner made from an idea (the dinners.origin column).
const (
// OriginIngredients is an idea from Cook from what I have.
OriginIngredients = "ingredients"
// OriginQuick is an idea from Quick dinners.
OriginQuick = "quick"
)
// ErrIdeaNotFound is an idea the household cannot act on: an unknown job,
// a job that does not find ideas, a failed job (which shows no ideas), or
// an index past the job's ideas.
var ErrIdeaNotFound = errors.New("idea not found")
// ErrIdeasNotReady is an idea of a job still queued or running. Its ideas
// may still move: an idea that grounds later is put back in its place
// before those shown already, so an index names an idea only once the job
// has succeeded.
var ErrIdeasNotReady = errors.New("the ideas are still being found; act on one when the job has succeeded")
// Chosen is the idea the household acts on: its name, the recipe it was
// grounded in, and the origin of a dinner made from it.
type Chosen struct {
Name string
RecipeID string
Origin string
}
// Idea returns idea i, counted from 0, of the succeeded job jobID, as its
// stored result shows it: a dinner made from it uses the recipe it was
// grounded in, never grounding it again. A job still queued or running is
// ErrIdeasNotReady; any idea not shown by a succeeded KindFromIngredients
// or KindQuick job is ErrIdeaNotFound.
func (s *Service) Idea(ctx context.Context, jobID string, i int) (Chosen, error) {
job, err := s.runner.Get(ctx, jobID)
if errors.Is(err, jobs.ErrNotFound) {
return Chosen{}, ErrIdeaNotFound
}
if err != nil {
return Chosen{}, fmt.Errorf("ideas: read job: %w", err)
}
if job.Kind != KindFromIngredients && job.Kind != KindQuick {
return Chosen{}, ErrIdeaNotFound
}
switch job.Status {
case jobs.StatusQueued, jobs.StatusRunning:
return Chosen{}, ErrIdeasNotReady
case jobs.StatusSucceeded:
default:
return Chosen{}, ErrIdeaNotFound
}
if job.Kind == KindQuick {
var res QuickResult
if err := json.Unmarshal(job.Result, &res); err != nil {
return Chosen{}, fmt.Errorf("ideas: decode %s result: %w", job.Kind, err)
}
if i < 0 || i >= len(res.Ideas) {
return Chosen{}, ErrIdeaNotFound
}
return Chosen{Name: res.Ideas[i].Name, RecipeID: res.Ideas[i].RecipeID, Origin: OriginQuick}, nil
}
var res Result
if err := json.Unmarshal(job.Result, &res); err != nil {
return Chosen{}, fmt.Errorf("ideas: decode %s result: %w", job.Kind, err)
}
if i < 0 || i >= len(res.Ideas) {
return Chosen{}, ErrIdeaNotFound
}
return Chosen{Name: res.Ideas[i].Name, RecipeID: res.Ideas[i].RecipeID, Origin: OriginIngredients}, nil
}
-575
View File
@@ -1,575 +0,0 @@
package ideas
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"reflect"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// pantry is the household's ingredient list.
var pantry = []string{"Chicken thighs", "rice", "black beans", "red onion", "cilantro"}
type env struct {
svc *Service
runner *jobs.Runner
search *recipestest.Search
pages *recipestest.Pages
images *recipestest.Images
// model answers ideas_from_ingredients and quick_ideas; normalize
// answers normalize_recipe while grounding.
model *llm.Fake
normalize *llm.Fake
// fail, when set, fails ideas_from_ingredients and quick_ideas without
// a model call.
fail error
st *store.Store
}
// newEnv returns an ideas service over a migrated store with the
// household's settings seeded and an unstarted
// job runner. It finds ideas through the real harness and
// ideas_from_ingredients or quick_ideas with the scripted e.model, and
// grounds with fake
// search, pages and images and the scripted e.normalize.
func newEnv(t *testing.T) *env {
t.Helper()
ctx := t.Context()
dataDir := t.TempDir()
st, err := store.Open(ctx, dataDir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
t.Fatal(err)
}
e := &env{
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
images: &recipestest.Images{JPEGs: map[string][]byte{}},
model: &llm.Fake{},
normalize: &llm.Fake{},
st: st,
}
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.normalize)
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
t.Fatal(err)
}
cfg := harness.Config{
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return e.model, nil },
Store: st,
Guard: guard,
}
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
if e.fail != nil {
return skills.Ideas{}, e.fail
}
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
}
quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
if e.fail != nil {
return skills.QuickIdeaList{}, e.fail
}
return skills.QuickIdeas(ctx, cfg, scope, in)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
e.runner, err = jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
e.svc, err = NewService(st, g, generate, quick, e.runner, logger)
if err != nil {
t.Fatal(err)
}
return e
}
// recipe makes the search for name find one page that grounds it, titled
// title, with a total time of minutes, and scripts the normalize_recipe
// reply grounding it takes. The page's total time is the grounded
// minutes, and "Slow Cooker" in title makes the grounded method crockpot,
// whatever the reply says.
func (e *env) recipe(t *testing.T, name, title string, minutes int) {
t.Helper()
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
e.search.Results[name] = []recipes.Candidate{{URL: page}}
e.pages.Bodies[page] = recipestest.RecipePage(title, "R Kitchen", photo, minutes)
e.images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(e.images.JPEGs)+1))
e.normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
}
// idea renders one idea of an ideas_from_ingredients reply whose search
// query is its name.
func idea(name, method string, minutes int, uses ...string) string {
u, _ := json.Marshal(append([]string{}, uses...))
return fmt.Sprintf(`{"name":%q,"uses":%s,"missing":["1 lime"],"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
name, u, minutes, method, name)
}
// reply scripts one ideas_from_ingredients reply holding ideas.
func (e *env) reply(ideas ...string) {
js := `{"ideas":[` + strings.Join(ideas, ",") + `]}`
e.model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
}
// recorder collects every result a job publishes.
type recorder struct {
mu sync.Mutex
published []Result
}
// latest returns the last result published.
func (r *recorder) latest() (Result, bool) {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.published) == 0 {
return Result{}, false
}
return r.published[len(r.published)-1], true
}
func (r *recorder) publish(res Result) error {
r.mu.Lock()
defer r.mu.Unlock()
// The result's slices are copied, as the runner stores each publish
// as JSON at once.
res.Ideas = append([]Idea(nil), res.Ideas...)
r.published = append(r.published, res)
return nil
}
// run runs the job's handler directly on ingredients, recording each
// publish.
func (e *env) run(t *testing.T, ingredients []string, quickOnly bool) (Result, []Result, error) {
t.Helper()
ings, err := skills.CleanIngredients(ingredients)
if err != nil {
t.Fatal(err)
}
var rec recorder
res, err := e.svc.run(t.Context(), FromIngredientsJob{Ingredients: ings, QuickOnly: quickOnly}, rec.publish)
return res, rec.published, err
}
// within reports whether every name in sub appears in list, in list's
// order.
func within(sub, list []string) bool {
for _, name := range list {
if len(sub) > 0 && sub[0] == name {
sub = sub[1:]
}
}
return len(sub) == 0
}
func names(list []Idea) []string {
out := []string{}
for _, i := range list {
out = append(out, i.Name)
}
return out
}
func TestResultFillsAsEachIdeaGrounds(t *testing.T) {
e := newEnv(t)
for _, name := range []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos", "Rice Pilaf", "Bean Salad"} {
e.recipe(t, name, name, 35)
}
// Eight ideas: two no recipe grounds, so the sixth and seventh are
// grounded in a second round, and the eighth never is.
e.reply(
idea("Burrito Bowls", "stovetop", 35, "rice", "black beans"),
idea("Mystery Stew", "stovetop", 35, "rice"),
idea("Fried Rice", "stovetop", 20, "rice", "Chicken thighs"),
idea("Black Bean Soup", "stovetop", 40, "black beans", "red onion"),
idea("Unknown Casserole", "oven", 50, "rice"),
idea("Chicken Tacos", "stovetop", 25, "Chicken thighs", "cilantro"),
idea("Rice Pilaf", "stovetop", 30, "rice"),
idea("Bean Salad", "other", 15, "black beans"),
)
res, published, err := e.run(t, pantry, false)
if err != nil {
t.Fatal(err)
}
want := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos", "Rice Pilaf"}
if got := names(res.Ideas); !reflect.DeepEqual(got, want) {
t.Fatalf("ideas = %q, want %q", got, want)
}
if res.Stage != StageDone || res.Dropped != 2 {
t.Fatalf("result stage %s dropped %d, want done with 2 dropped", res.Stage, res.Dropped)
}
if !reflect.DeepEqual(res.Ingredients, pantry) || res.QuickOnly {
t.Fatalf("result request = %q quick %v", res.Ingredients, res.QuickOnly)
}
if got := e.search.Queries(); len(got) != 7 || strings.Contains(strings.Join(got, ","), "Bean Salad") {
t.Errorf("searched %q, want the first seven ideas only", got)
}
// Each publish: the stage, then one more idea at a time, each idea
// grounded when it appears.
var stages []string
var counts []int
for _, p := range published {
stages = append(stages, p.Stage)
counts = append(counts, len(p.Ideas))
for _, i := range p.Ideas {
if i.RecipeID == "" || i.PhotoURL == "" || i.Method == "" {
t.Errorf("published idea %q is not grounded: %+v", i.Name, i)
}
}
}
wantStages := []string{StageSuggesting, StageGrounding, StageGrounding, StageGrounding, StageGrounding, StageGrounding, StageGrounding}
if !reflect.DeepEqual(stages, wantStages) || !reflect.DeepEqual(counts, []int{0, 0, 1, 2, 3, 4, 5}) {
t.Fatalf("published stages %q with %v ideas, want %q with 0, 0, 1, 2, 3, 4, 5", stages, counts, wantStages)
}
// Ideas ground concurrently, so one may appear before an earlier one,
// but each publish keeps every idea already shown, in the model's
// order.
for i := 3; i < len(published); i++ {
prev, cur := names(published[i-1].Ideas), names(published[i].Ideas)
if !within(prev, cur) || !within(cur, want) {
t.Errorf("publish %d shows %q after %q, want earlier ideas kept in the model's order %q", i+1, cur, prev, want)
}
}
first := res.Ideas[0]
if first.Summary != "Uses 2 of 5 · missing: 1 lime" || !reflect.DeepEqual(first.Uses, []string{"rice", "black beans"}) {
t.Errorf("first idea = %+v", first)
}
}
func TestQuickOnlyChecksTheGroundedRecipe(t *testing.T) {
for _, quickOnly := range []bool{true, false} {
t.Run(fmt.Sprintf("quick_only=%v", quickOnly), func(t *testing.T) {
e := newEnv(t)
// The model estimates every idea quick; the grounded recipes
// decide.
e.recipe(t, "Fried Rice", "Fried Rice", 25)
e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
e.recipe(t, "Bean Enchiladas", "Bean Enchiladas", 55)
e.recipe(t, "Rice Bowls", "Rice Bowls", 30)
e.recipe(t, "Bean Bake", "Bean Bake", 31)
e.reply(
idea("Fried Rice", "stovetop", 20, "rice"),
idea("Chicken Chili", "stovetop", 25, "Chicken thighs", "black beans"),
idea("Bean Enchiladas", "oven", 25, "black beans"),
idea("Rice Bowls", "stovetop", 15, "rice"),
idea("Bean Bake", "oven", 30, "black beans"),
)
res, _, err := e.run(t, pantry, quickOnly)
if err != nil {
t.Fatal(err)
}
quick := map[string]bool{}
for _, i := range res.Ideas {
quick[i.Name] = i.Quick
}
want := map[string]bool{"Fried Rice": true, "Chicken Chili": true, "Rice Bowls": true}
wantDropped := 2
if !quickOnly {
want["Bean Enchiladas"], want["Bean Bake"] = false, false
wantDropped = 0
}
if !reflect.DeepEqual(quick, want) || res.Dropped != wantDropped {
t.Fatalf("ideas quick = %v dropped %d, want %v dropped %d", quick, res.Dropped, want, wantDropped)
}
for _, i := range res.Ideas {
if i.Name == "Chicken Chili" && (i.Method != "crockpot" || i.MinutesTotal == nil || *i.MinutesTotal != 240) {
t.Errorf("Chicken Chili = %s %v, want the grounded crockpot 240", i.Method, i.MinutesTotal)
}
}
})
}
}
func TestQuickOnlyGroundsMoreToReplaceSlowIdeas(t *testing.T) {
e := newEnv(t)
slow := []string{"Roast One", "Roast Two", "Roast Three", "Roast Four", "Roast Five"}
var reply []string
for _, name := range slow {
e.recipe(t, name, name, 90)
reply = append(reply, idea(name, "oven", 20, "Chicken thighs"))
}
e.recipe(t, "Fried Rice", "Fried Rice", 20)
reply = append(reply, idea("Fried Rice", "stovetop", 20, "rice"))
e.reply(reply...)
res, _, err := e.run(t, pantry, true)
if err != nil {
t.Fatal(err)
}
if got := names(res.Ideas); !reflect.DeepEqual(got, []string{"Fried Rice"}) || res.Dropped != 5 {
t.Fatalf("ideas = %q dropped %d, want Fried Rice with 5 dropped", got, res.Dropped)
}
}
func TestHallucinatedUsesNeverReachTheResult(t *testing.T) {
e := newEnv(t)
e.recipe(t, "Burrito Bowls", "Burrito Bowls", 35)
e.reply(
idea("Burrito Bowls", "stovetop", 35, "rice", "sour cream", "black beans", "cheddar"),
idea("Parmesan Pasta", "stovetop", 20, "pasta", "parmesan"),
)
res, _, err := e.run(t, pantry, false)
if err != nil {
t.Fatal(err)
}
if len(res.Ideas) != 1 || !reflect.DeepEqual(res.Ideas[0].Uses, []string{"rice", "black beans"}) {
t.Fatalf("ideas = %+v, want Burrito Bowls using rice and black beans", res.Ideas)
}
if res.Ideas[0].Summary != "Uses 2 of 5 · missing: 1 lime" || res.Dropped != 1 {
t.Fatalf("summary %q dropped %d", res.Ideas[0].Summary, res.Dropped)
}
if got := e.search.Queries(); !reflect.DeepEqual(got, []string{"Burrito Bowls"}) {
t.Errorf("searched %q, want only the idea kept", got)
}
}
func TestFailures(t *testing.T) {
for _, tc := range []struct {
name string
setup func(e *env)
code string
}{
{"ai fails", func(e *env) { e.fail = errors.New("provider down") }, ReasonAIFailed},
{"bad output twice", func(e *env) { e.reply(); e.reply() }, ReasonAIFailed},
{"search fails", func(e *env) {
e.search.Err = errors.New("boom")
e.reply(idea("Fried Rice", "stovetop", 20, "rice"))
}, ReasonSearchFailed},
} {
t.Run(tc.name, func(t *testing.T) {
e := newEnv(t)
tc.setup(e)
_, published, err := e.run(t, pantry, false)
var failure *jobs.Failure
if !errors.As(err, &failure) || failure.Code != tc.code {
t.Fatalf("err = %v, want failure %s", err, tc.code)
}
for _, p := range published {
if len(p.Ideas) != 0 {
t.Fatalf("a failed job published ideas: %+v", p.Ideas)
}
}
})
}
}
func TestEachIdeaShowsAsSoonAsItGrounds(t *testing.T) {
e := newEnv(t)
for _, name := range []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup"} {
e.recipe(t, name, name, 35)
}
// The first idea's search waits, so the two after it ground first.
gate := make(chan struct{})
e.search.Gates = map[string]chan struct{}{"Burrito Bowls": gate}
e.reply(
idea("Burrito Bowls", "stovetop", 35, "rice", "black beans"),
idea("Fried Rice", "stovetop", 20, "rice"),
idea("Black Bean Soup", "stovetop", 40, "black beans"),
)
ings, err := skills.CleanIngredients(pantry)
if err != nil {
t.Fatal(err)
}
var rec recorder
type outcome struct {
res Result
err error
}
done := make(chan outcome, 1)
go func() {
res, err := e.svc.run(t.Context(), FromIngredientsJob{Ingredients: ings}, rec.publish)
done <- outcome{res, err}
}()
deadline := time.Now().Add(10 * time.Second)
for {
if latest, ok := rec.latest(); ok && len(latest.Ideas) == 2 {
if got := names(latest.Ideas); !reflect.DeepEqual(got, []string{"Fried Rice", "Black Bean Soup"}) {
t.Fatalf("while the first idea grounds, ideas = %q, want the two grounded", got)
}
if latest.Stage != StageGrounding {
t.Fatalf("stage = %s, want grounding", latest.Stage)
}
break
}
if time.Now().After(deadline) {
t.Fatal("the ideas that grounded were not shown while the first idea was still grounding")
}
time.Sleep(5 * time.Millisecond)
}
close(gate)
out := <-done
if out.err != nil {
t.Fatal(out.err)
}
want := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup"}
if got := names(out.res.Ideas); !reflect.DeepEqual(got, want) || out.res.Stage != StageDone {
t.Fatalf("result = %s %q, want done with %q in the model's order", out.res.Stage, got, want)
}
}
func TestNoIdeaKeptFailsTheJob(t *testing.T) {
for _, tc := range []struct {
name string
quickOnly bool
setup func(t *testing.T, e *env)
}{
{"no recipe grounds any", false, func(t *testing.T, e *env) {
e.reply(idea("Mystery Stew", "stovetop", 30, "rice"), idea("Unknown Casserole", "oven", 50, "rice"))
}},
{"no grounded recipe is quick", true, func(t *testing.T, e *env) {
e.recipe(t, "Roast Chicken", "Roast Chicken", 90)
e.reply(idea("Roast Chicken", "oven", 25, "Chicken thighs"))
}},
} {
t.Run(tc.name, func(t *testing.T) {
e := newEnv(t)
tc.setup(t, e)
_, published, err := e.run(t, pantry, tc.quickOnly)
var failure *jobs.Failure
if !errors.As(err, &failure) || failure.Code != ReasonNoIdeasFound {
t.Fatalf("err = %v, want failure %s", err, ReasonNoIdeasFound)
}
for _, p := range published {
if len(p.Ideas) != 0 || p.Stage == StageDone {
t.Fatalf("a job that kept no idea published %+v", p)
}
}
})
}
}
func TestLaterRoundFailureClearsPublishedIdeas(t *testing.T) {
e := newEnv(t)
kept := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos"}
for _, name := range kept {
e.recipe(t, name, name, 35)
}
// The first round keeps four ideas; the second round's search fails.
e.search.Fail = map[string]error{"Rice Pilaf": errors.New("boom")}
e.reply(
idea("Burrito Bowls", "stovetop", 35, "rice"),
idea("Mystery Stew", "stovetop", 35, "rice"),
idea("Fried Rice", "stovetop", 20, "rice"),
idea("Black Bean Soup", "stovetop", 40, "black beans"),
idea("Chicken Tacos", "stovetop", 25, "Chicken thighs"),
idea("Rice Pilaf", "stovetop", 30, "rice"),
)
_, published, err := e.run(t, pantry, false)
var failure *jobs.Failure
if !errors.As(err, &failure) || failure.Code != ReasonSearchFailed {
t.Fatalf("err = %v, want failure %s", err, ReasonSearchFailed)
}
most := 0
for _, p := range published {
most = max(most, len(p.Ideas))
}
if most != len(kept) {
t.Fatalf("at most %d ideas published, want the first round's %d before the failure", most, len(kept))
}
if last := published[len(published)-1]; len(last.Ideas) != 0 {
t.Fatalf("last publish before the failure shows %q, want no ideas", names(last.Ideas))
}
}
func TestStartFromIngredientsRunsTheJob(t *testing.T) {
e := newEnv(t)
e.recipe(t, "Fried Rice", "Fried Rice", 20)
e.reply(idea("Fried Rice", "stovetop", 20, "rice"))
ctx, cancel := context.WithCancel(context.Background())
if err := e.runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
e.runner.Wait()
})
if _, err := e.svc.StartFromIngredients(t.Context(), []string{"rice", "Rice"}, false); err == nil {
t.Fatal("a repeated ingredient was accepted")
} else if invalid := (*InvalidError)(nil); !errors.As(err, &invalid) {
t.Fatalf("err = %v, want an *InvalidError", err)
}
started, err := e.svc.StartFromIngredients(t.Context(), []string{" rice ", "Chicken thighs"}, true)
if err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(10 * time.Second)
var job jobs.Job
for {
job, err = e.runner.Get(t.Context(), started.JobID)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
break
}
if time.Now().After(deadline) {
t.Fatalf("job still %s", job.Status)
}
time.Sleep(5 * time.Millisecond)
}
if job.Status != jobs.StatusSucceeded || job.Kind != KindFromIngredients {
t.Fatalf("job = %+v", job)
}
var res Result
if err := json.Unmarshal(job.Result, &res); err != nil {
t.Fatal(err)
}
if res.Stage != StageDone || !reflect.DeepEqual(res.Ingredients, []string{"rice", "Chicken thighs"}) || !res.QuickOnly || len(res.Ideas) != 1 {
t.Fatalf("result = %+v", res)
}
if got := res.Ideas[0]; got.Summary != "Uses 1 of 2 · missing: 1 lime" || !got.Quick {
t.Errorf("idea = %+v", got)
}
}
func TestIsQuick(t *testing.T) {
m := func(n int) *int { return &n }
for _, tc := range []struct {
method string
minutes *int
want bool
}{
{"crockpot", m(480), true},
{"crockpot", nil, true},
{"stovetop", m(30), true},
{"oven", m(31), false},
{"stovetop", nil, false},
} {
if got := IsQuick(tc.method, tc.minutes); got != tc.want {
t.Errorf("IsQuick(%s, %v) = %v, want %v", tc.method, tc.minutes, got, tc.want)
}
}
}
-400
View File
@@ -1,400 +0,0 @@
package ideas
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"slices"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
)
// quickIdea renders one idea of a quick_ideas reply whose search query is
// its name.
func quickIdea(name, method string, minutes int) string {
return fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
name, minutes, method, name)
}
// quickRecorder collects every result a quick job publishes.
type quickRecorder struct {
mu sync.Mutex
published []QuickResult
}
func (r *quickRecorder) latest() (QuickResult, bool) {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.published) == 0 {
return QuickResult{}, false
}
return r.published[len(r.published)-1], true
}
func (r *quickRecorder) publish(res QuickResult) error {
r.mu.Lock()
defer r.mu.Unlock()
res.Ideas = append([]QuickIdea(nil), res.Ideas...)
r.published = append(r.published, res)
return nil
}
// runQuick runs the quick job's handler directly, recording each publish.
func (e *env) runQuick(t *testing.T, kind string, exclude ...string) (QuickResult, []QuickResult, error) {
t.Helper()
names, err := skills.CleanExclude(exclude)
if err != nil {
t.Fatal(err)
}
var rec quickRecorder
res, err := e.svc.runQuick(t.Context(), QuickJob{Kind: kind, Exclude: names}, rec.publish)
return res, rec.published, err
}
func quickNames(list []QuickIdea) []string {
out := []string{}
for _, i := range list {
out = append(out, i.Name)
}
return out
}
func TestUnder30DropsIdeasOverThirtyMinutes(t *testing.T) {
e := newEnv(t)
// The model estimates every idea under 30 minutes; the grounded
// recipes decide.
e.recipe(t, "Chicken Fried Rice", "Chicken Fried Rice", 25)
e.recipe(t, "Beef Stroganoff", "Beef Stroganoff", 45)
e.recipe(t, "Shrimp Scampi", "Shrimp Scampi", 30)
e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
e.reply(
quickIdea("Chicken Fried Rice", "stovetop", 20),
quickIdea("Beef Stroganoff", "stovetop", 25),
quickIdea("Shrimp Scampi", "stovetop", 20),
quickIdea("Chicken Chili", "crockpot", 25),
)
res, _, err := e.runQuick(t, skills.QuickUnder30)
if err != nil {
t.Fatal(err)
}
if got, want := quickNames(res.Ideas), []string{"Chicken Fried Rice", "Shrimp Scampi"}; !reflect.DeepEqual(got, want) {
t.Fatalf("ideas = %q, want %q: the 45-minute and the crock pot ideas dropped", got, want)
}
if res.Dropped != 2 || res.Stage != StageDone || res.Kind != skills.QuickUnder30 {
t.Fatalf("result = %+v, want done with 2 dropped", res)
}
for _, i := range res.Ideas {
if i.MinutesTotal == nil || *i.MinutesTotal > QuickMinutes || i.RecipeID == "" || i.PhotoURL == "" {
t.Errorf("idea %+v is not a grounded dinner of 30 minutes or less", i)
}
}
}
func TestCrockpotDropsStovetopIdeas(t *testing.T) {
e := newEnv(t)
// The model calls every idea a crock pot dinner; only the recipes
// cooked in a slow cooker are.
e.recipe(t, "Pulled Pork", "Slow Cooker Pulled Pork", 480)
e.recipe(t, "Beef Stew", "Beef Stew", 150)
e.recipe(t, "Chicken Tortilla Soup", "Crock Pot Chicken Tortilla Soup", 375)
e.recipe(t, "Chicken Fried Rice", "Chicken Fried Rice", 25)
e.reply(
quickIdea("Pulled Pork", "crockpot", 480),
quickIdea("Beef Stew", "crockpot", 360),
quickIdea("Chicken Tortilla Soup", "crockpot", 375),
quickIdea("Chicken Fried Rice", "crockpot", 240),
)
res, _, err := e.runQuick(t, skills.QuickCrockpot)
if err != nil {
t.Fatal(err)
}
if got, want := quickNames(res.Ideas), []string{"Pulled Pork", "Chicken Tortilla Soup"}; !reflect.DeepEqual(got, want) {
t.Fatalf("ideas = %q, want %q: the stovetop ideas dropped", got, want)
}
if res.Dropped != 2 {
t.Fatalf("dropped %d, want 2", res.Dropped)
}
for _, i := range res.Ideas {
if i.Method != "crockpot" {
t.Errorf("idea %q method %s, want the grounded crockpot", i.Name, i.Method)
}
}
if m := res.Ideas[0].MinutesTotal; m == nil || *m != 480 {
t.Errorf("Pulled Pork minutes_total = %v, want the recipe's 480", m)
}
}
func TestExcludedNamesAreNeverReturned(t *testing.T) {
e := newEnv(t)
// Every idea has a recipe that would ground it as a Quick dinner.
for _, name := range []string{"Easy Chicken Fried Rice", "shrimp-tacos", "Garlic Shrimp Pasta"} {
e.recipe(t, name, name, 20)
}
e.reply(
quickIdea("Easy Chicken Fried Rice", "stovetop", 20),
quickIdea("shrimp-tacos", "stovetop", 15),
quickIdea("Garlic Shrimp Pasta", "stovetop", 25),
)
res, _, err := e.runQuick(t, skills.QuickUnder30, "Chicken Fried Rice", "Shrimp Tacos")
if err != nil {
t.Fatal(err)
}
if got := quickNames(res.Ideas); !reflect.DeepEqual(got, []string{"Garlic Shrimp Pasta"}) || res.Dropped != 2 {
t.Fatalf("ideas = %q dropped %d, want only Garlic Shrimp Pasta with 2 dropped", got, res.Dropped)
}
if got := e.search.Queries(); !reflect.DeepEqual(got, []string{"Garlic Shrimp Pasta"}) {
t.Errorf("searched %q, want the excluded names never grounded", got)
}
if !reflect.DeepEqual(res.Exclude, []string{"Chicken Fried Rice", "Shrimp Tacos"}) {
t.Errorf("result exclude = %q", res.Exclude)
}
// The model was told what was already shown.
if system := e.model.Requests()[0].System; !strings.Contains(system, "### Already shown\n- Chicken Fried Rice\n- Shrimp Tacos") {
t.Errorf("prompt does not list the names already shown:\n%s", system)
}
}
func TestQuickPageStopsAtSixAndShowsFewerHonestly(t *testing.T) {
t.Run("twelve ideas, six shown", func(t *testing.T) {
e := newEnv(t)
var reply []string
for i := range skills.QuickIdeasRequested {
name := fmt.Sprintf("Skillet Dish %c", 'A'+i)
e.recipe(t, name, name, 20)
reply = append(reply, quickIdea(name, "stovetop", 20))
}
e.reply(reply...)
res, _, err := e.runQuick(t, skills.QuickUnder30)
if err != nil {
t.Fatal(err)
}
if len(res.Ideas) != skills.QuickIdeasKept || res.Dropped != 0 {
t.Fatalf("ideas = %q dropped %d, want %d", quickNames(res.Ideas), res.Dropped, skills.QuickIdeasKept)
}
if got := e.search.Queries(); len(got) != skills.QuickIdeasKept {
t.Errorf("searched %d ideas, want only the %d a page holds", len(got), skills.QuickIdeasKept)
}
})
t.Run("over-generated ideas replace dropped ones", func(t *testing.T) {
e := newEnv(t)
var reply []string
// The first six ground too slow, so the next six are grounded to
// fill the page; only three of those are quick.
for i := range skills.QuickIdeasRequested {
name := fmt.Sprintf("Skillet Dish %c", 'A'+i)
minutes := 20
if i < 6 || i%2 == 1 {
minutes = 50
}
e.recipe(t, name, name, minutes)
reply = append(reply, quickIdea(name, "stovetop", 20))
}
e.reply(reply...)
res, _, err := e.runQuick(t, skills.QuickUnder30)
if err != nil {
t.Fatal(err)
}
// Fewer than six survive: the page shows three, never padded.
if got, want := quickNames(res.Ideas), []string{"Skillet Dish G", "Skillet Dish I", "Skillet Dish K"}; !reflect.DeepEqual(got, want) || res.Dropped != 9 || res.Stage != StageDone {
t.Fatalf("result %s ideas %q dropped %d, want done with %q and 9 dropped", res.Stage, got, res.Dropped, want)
}
})
}
func TestQuickResultFillsAsEachIdeaGrounds(t *testing.T) {
e := newEnv(t)
for _, name := range []string{"Chicken Fried Rice", "Shrimp Scampi", "Garlic Shrimp Pasta"} {
e.recipe(t, name, name, 25)
}
// The first idea's search waits, so the two after it ground first.
gate := make(chan struct{})
e.search.Gates = map[string]chan struct{}{"Chicken Fried Rice": gate}
e.reply(
quickIdea("Chicken Fried Rice", "stovetop", 20),
quickIdea("Shrimp Scampi", "stovetop", 20),
quickIdea("Garlic Shrimp Pasta", "stovetop", 25),
)
var rec quickRecorder
type outcome struct {
res QuickResult
err error
}
done := make(chan outcome, 1)
go func() {
res, err := e.svc.runQuick(t.Context(), QuickJob{Kind: skills.QuickUnder30, Exclude: []string{}}, rec.publish)
done <- outcome{res, err}
}()
deadline := time.Now().Add(10 * time.Second)
for {
if latest, ok := rec.latest(); ok && len(latest.Ideas) == 2 {
if got := quickNames(latest.Ideas); !reflect.DeepEqual(got, []string{"Shrimp Scampi", "Garlic Shrimp Pasta"}) {
t.Fatalf("while the first idea grounds, ideas = %q, want the two grounded", got)
}
if latest.Stage != StageGrounding {
t.Fatalf("stage = %s, want grounding", latest.Stage)
}
break
}
if time.Now().After(deadline) {
t.Fatal("the ideas that grounded were not shown while the first idea was still grounding")
}
time.Sleep(5 * time.Millisecond)
}
close(gate)
out := <-done
if out.err != nil {
t.Fatal(out.err)
}
want := []string{"Chicken Fried Rice", "Shrimp Scampi", "Garlic Shrimp Pasta"}
if got := quickNames(out.res.Ideas); !reflect.DeepEqual(got, want) || out.res.Stage != StageDone {
t.Fatalf("result = %s %q, want done with %q in the model's order", out.res.Stage, got, want)
}
var stages []string
var counts []int
for _, p := range rec.published {
stages = append(stages, p.Stage)
counts = append(counts, len(p.Ideas))
}
if !reflect.DeepEqual(stages, []string{StageSuggesting, StageGrounding, StageGrounding, StageGrounding, StageGrounding}) || !reflect.DeepEqual(counts, []int{0, 0, 1, 2, 3}) {
t.Fatalf("published stages %q with %v ideas, want suggesting, then grounding with 0, 1, 2, 3", stages, counts)
}
}
func TestQuickFailures(t *testing.T) {
for _, tc := range []struct {
name string
setup func(t *testing.T, e *env)
code string
}{
{"ai fails", func(t *testing.T, e *env) { e.fail = errors.New("provider down") }, ReasonAIFailed},
{"no idea is of the kind", func(t *testing.T, e *env) {
e.recipe(t, "Beef Stew", "Beef Stew", 150)
e.reply(quickIdea("Beef Stew", "crockpot", 360))
}, ReasonNoIdeasFound},
{"every idea already shown", func(t *testing.T, e *env) {
e.reply(quickIdea("Pulled Pork", "crockpot", 480))
}, ReasonNoIdeasFound},
{"a later round fails after ideas were shown", func(t *testing.T, e *env) {
e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
e.recipe(t, "Beef Stew", "Beef Stew", 150)
e.search.Fail = map[string]error{"Lentil Soup": errors.New("boom")}
reply := []string{quickIdea("Chicken Chili", "crockpot", 240)}
for i := range 5 {
name := fmt.Sprintf("Mystery Stew %d", i+1)
reply = append(reply, quickIdea(name, "crockpot", 300))
}
e.reply(append(reply, quickIdea("Lentil Soup", "crockpot", 300))...)
}, ReasonSearchFailed},
} {
t.Run(tc.name, func(t *testing.T) {
e := newEnv(t)
tc.setup(t, e)
_, published, err := e.runQuick(t, skills.QuickCrockpot, "Pulled Pork")
var failure *jobs.Failure
if !errors.As(err, &failure) || failure.Code != tc.code {
t.Fatalf("err = %v, want failure %s", err, tc.code)
}
if last := published[len(published)-1]; len(last.Ideas) != 0 || last.Stage == StageDone {
t.Fatalf("last publish of a failed job = %+v, want no ideas", last)
}
})
}
}
func TestStartQuickRunsTheJob(t *testing.T) {
e := newEnv(t)
e.recipe(t, "Pulled Pork", "Slow Cooker Pulled Pork", 480)
e.reply(quickIdea("Pulled Pork", "crockpot", 480))
ctx, cancel := context.WithCancel(context.Background())
if err := e.runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
e.runner.Wait()
})
for _, tc := range []struct {
kind string
exclude []string
want string
}{
{"", nil, `kind: kind "" is not one of crockpot, under30`},
{"quick", nil, `kind: kind "quick" is not one of crockpot, under30`},
{skills.QuickCrockpot, []string{"Beef Stew", "beef stew"}, `exclude: excluded name 2, "beef stew", repeats "Beef Stew"`},
} {
_, err := e.svc.StartQuick(t.Context(), tc.kind, tc.exclude)
var invalid *InvalidError
if !errors.As(err, &invalid) || invalid.Message != tc.want {
t.Errorf("StartQuick(%q, %q) = %v, want %q", tc.kind, tc.exclude, err, tc.want)
}
}
started, err := e.svc.StartQuick(t.Context(), skills.QuickCrockpot, []string{" Beef Stew "})
if err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(10 * time.Second)
var job jobs.Job
for {
job, err = e.runner.Get(t.Context(), started.JobID)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
break
}
if time.Now().After(deadline) {
t.Fatalf("job still %s", job.Status)
}
time.Sleep(5 * time.Millisecond)
}
if job.Status != jobs.StatusSucceeded || job.Kind != KindQuick {
t.Fatalf("job = %+v", job)
}
var res QuickResult
if err := json.Unmarshal(job.Result, &res); err != nil {
t.Fatal(err)
}
if res.Stage != StageDone || res.Kind != skills.QuickCrockpot || !reflect.DeepEqual(res.Exclude, []string{"Beef Stew"}) || len(res.Ideas) != 1 {
t.Fatalf("result = %+v", res)
}
if got := res.Ideas[0]; got.Name != "Pulled Pork" || got.Method != "crockpot" || got.SiteName == nil || *got.SiteName != "R Kitchen" {
t.Errorf("idea = %+v", got)
}
}
func TestOfQuickKind(t *testing.T) {
m := func(n int) *int { return &n }
for _, tc := range []struct {
kind, method string
minutes *int
want bool
}{
{skills.QuickCrockpot, "crockpot", m(480), true},
{skills.QuickCrockpot, "crockpot", nil, true},
{skills.QuickCrockpot, "stovetop", m(20), false},
{skills.QuickUnder30, "stovetop", m(30), true},
{skills.QuickUnder30, "oven", m(31), false},
{skills.QuickUnder30, "stovetop", nil, false},
{skills.QuickUnder30, "crockpot", m(240), false},
{"quick", "crockpot", m(20), false},
} {
if got := OfQuickKind(tc.kind, tc.method, tc.minutes); got != tc.want {
t.Errorf("OfQuickKind(%s, %s, %v) = %v, want %v", tc.kind, tc.method, tc.minutes, got, tc.want)
}
}
if !slices.Equal(skills.QuickKinds, []string{skills.QuickCrockpot, skills.QuickUnder30}) {
t.Errorf("kinds = %q", skills.QuickKinds)
}
}
+3 -43
View File
@@ -135,14 +135,12 @@ type Job struct {
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
// handler is a registered kind: run runs a job, check reports whether a
// payload decodes into the kind's payload type, serviceOnly is set when
// Runner.Enqueue refuses the kind, and shownResult, when set, is what Get
// shows of a job's stored result (see Kind.ShownResult).
// payload decodes into the kind's payload type, and serviceOnly is set when
// Runner.Enqueue refuses the kind.
type handler struct {
run runFunc
check func(payload []byte) bool
serviceOnly bool
shownResult func(status Status, stored json.RawMessage) (json.RawMessage, error)
}
// Runner owns the job handlers and the worker pool.
@@ -309,28 +307,6 @@ func (k *Kind[P]) ServiceOnly() error {
return nil
}
// ShownResult makes Get return, for a job of the kind with a stored
// result, show's view of that result, given the job's status, instead of
// the result itself. It is applied on every read, so it holds for every
// stored result: however a job failed (by its handler, as interrupted by
// restart, or after a write meant to tidy the result failed) and whatever
// version of the kind stored it. A show error fails Get. A kind that never
// calls it shows a job's result as stored. It must be called before Start.
func (k *Kind[P]) ShownResult(show func(status Status, stored json.RawMessage) (json.RawMessage, error)) error {
if show == nil {
return fmt.Errorf("jobs: shown result %q: nil func", k.name)
}
k.r.mu.Lock()
defer k.r.mu.Unlock()
if k.r.started {
return fmt.Errorf("jobs: shown result %q: runner already started", k.name)
}
h := k.r.handlers[k.name]
h.shownResult = show
k.r.handlers[k.name] = h
return nil
}
// 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() }
@@ -426,8 +402,7 @@ func (r *Runner) Wait() {
r.wg.Wait()
}
// Get returns the job with id, or ErrNotFound. A job's result is shown
// through its kind's ShownResult, when the kind set one.
// Get returns the job with id, or ErrNotFound.
func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
row, err := db.New(r.st.Reader).GetJob(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
@@ -443,21 +418,6 @@ func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
if row.Error.Valid {
job.Error = &row.Error.String
}
if job.Result != nil {
r.mu.Lock()
show := r.handlers[job.Kind].shownResult
r.mu.Unlock()
if show != nil {
shown, err := show(job.Status, job.Result)
if err != nil {
return Job{}, fmt.Errorf("jobs: show %s %s result: %w", job.Status, job.Kind, err)
}
if !json.Valid(shown) {
return Job{}, fmt.Errorf("jobs: show %s %s result: not one JSON value", job.Status, job.Kind)
}
job.Result = shown
}
}
return job, nil
}
-52
View File
@@ -772,55 +772,3 @@ func TestServiceOnlyKind(t *testing.T) {
t.Error("ServiceOnly after Start succeeded, want an error")
}
}
func TestShownResultAppliesOnlyToItsKindsJobs(t *testing.T) {
t.Parallel()
st := migratedStore(t)
r := newRunner(t, st, 1)
h := func(_ context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
if err := publish(countResult{Done: 5}); err != nil {
return countResult{}, err
}
if p.Steps == 0 {
return countResult{}, &Failure{Code: "stopped"}
}
return countResult{Done: p.Steps}, nil
}
hidden := register(t, r, "hidden", h, "stopped")
plain := register(t, r, "plain", h, "stopped")
if err := hidden.ShownResult(func(status Status, stored json.RawMessage) (json.RawMessage, error) {
if status == StatusFailed {
return json.RawMessage(`{"done":0}`), nil
}
var res countResult
if err := json.Unmarshal(stored, &res); err != nil {
return nil, err
}
return json.Marshal(countResult{Done: res.Done * 10})
}); err != nil {
t.Fatal(err)
}
if err := hidden.ShownResult(nil); err == nil {
t.Error("ShownResult(nil) succeeded, want an error")
}
start(t, r)
if err := hidden.ShownResult(func(_ Status, s json.RawMessage) (json.RawMessage, error) { return s, nil }); err == nil {
t.Error("ShownResult after Start succeeded, want an error")
}
for _, tc := range []struct {
kind *Kind[countPayload]
steps int
want string
}{
{hidden, 0, `{"done":0}`}, // failed: shown through ShownResult
{hidden, 3, `{"done":30}`}, // succeeded: shown through ShownResult
{plain, 0, `{"done":5}`}, // failed, kind without ShownResult: as stored
{plain, 3, `{"done":3}`}, // succeeded, kind without ShownResult: as stored
} {
job := waitFor(t, r, enqueue(t, tc.kind, countPayload{Steps: tc.steps}), finished)
if string(job.Result) != tc.want {
t.Errorf("%s job with %d steps: %s result = %s, want %s", job.Kind, tc.steps, job.Status, job.Result, tc.want)
}
}
}
-55
View File
@@ -189,61 +189,6 @@ func (s *Service) EmitTx(ctx context.Context, tx *sql.Tx, ev Event) error {
})
}
// StoredSince reports whether a notification was stored after since, or
// at all when since is nil. An event counts once it is stored for any
// channel, whatever became of its delivery; one whose preference switches
// every channel off is never stored and never counts.
func (s *Service) StoredSince(ctx context.Context, since *time.Time) (bool, error) {
var after sql.NullString
if since != nil {
after = sql.NullString{String: timestamp(*since), Valid: true}
}
found, err := db.New(s.st.Reader).HasNotificationSince(ctx, after)
if err != nil {
return false, fmt.Errorf("notify: read notifications since %s: %w", after.String, err)
}
return found, nil
}
// Stored is one event as it was stored, whatever channels it went to.
type Stored struct {
Type EventType
Title string
Body string
// URL is the path-absolute, same-origin path the event opens.
URL string
// CreatedAt is when the event was last stored, for any channel: the
// instant StoredSince must be given for it not to count.
CreatedAt time.Time
}
// Recent returns the last n distinct events stored, newest first. An event
// stored for more than one channel is one event, as new as its newest row;
// one whose preference switches every channel off is never stored and
// never listed.
func (s *Service) Recent(ctx context.Context, n int) ([]Stored, error) {
if n <= 0 {
return nil, fmt.Errorf("notify: list %d recent events: want a positive count", n)
}
rows, err := db.New(s.st.Reader).ListRecentNotifications(ctx, int64(n))
if err != nil {
return nil, fmt.Errorf("notify: list recent events: %w", err)
}
out := make([]Stored, 0, len(rows))
for _, r := range rows {
var p Payload
if err := json.Unmarshal([]byte(r.Payload), &p); err != nil {
return nil, fmt.Errorf("notify: decode payload of %s: %w", r.EventKey, err)
}
created, err := parseTimestamp(r.CreatedAt)
if err != nil {
return nil, fmt.Errorf("notify: created_at of %s: %w", r.EventKey, err)
}
out = append(out, Stored{Type: p.Event, Title: p.Title, Body: p.Body, URL: p.URL, CreatedAt: created})
}
return out, nil
}
// emitTx stores ev in tx for each channel that channels chooses from the
// preference of ev's type.
func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels func(db.NotificationPref) ([]Channel, error)) error {
-34
View File
@@ -453,40 +453,6 @@ func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, din
return p, nil
}
// Tonight returns today, the household-local date now in the household's
// zone, and the Monday starting its week, when that week has a plan: Swap
// of that week and day sets tonight's dinner. A week with no plan is
// ErrNotFound; no plan is made for it, since a plan holds all seven days.
// The week is always today's own, so Swap never writes a different day.
func (s *Service) Tonight(ctx context.Context) (week, today store.LocalDate, err error) {
tx, err := s.st.Reader.BeginTx(ctx, nil)
if err != nil {
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: begin read: %w", err)
}
defer func() {
err = errors.Join(err, tx.Rollback())
}()
q := db.New(tx)
settings, err := q.GetHouseholdSettings(ctx)
if err != nil {
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: read household settings: %w", err)
}
zone, err := household.LoadZone(settings.Timezone)
if err != nil {
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: household settings: %w", err)
}
today = household.Today(time.Now(), zone)
week = household.WeekStart(today)
_, err = q.GetWeekPlan(ctx, week.String())
if errors.Is(err, sql.ErrNoRows) {
return store.LocalDate{}, store.LocalDate{}, ErrNotFound
}
if err != nil {
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: read week %s: %w", week, err)
}
return week, today, nil
}
// isNew is plan_days.is_new for a dinner with status: 1 for the Quest log.
func isNew(status string) int64 {
if status == dinners.StatusQuestLog {
+2 -2
View File
@@ -375,9 +375,9 @@ func TestDraftEmitsPlanReady(t *testing.T) {
}
for _, r := range rows {
p := r.Payload
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan?week=2026-10-12" || p.Week != "2026-10-12" ||
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan" || p.Week != "2026-10-12" ||
p.Title != want.Title || p.Body != "Beef Chili, Pot Roast, Pad Thai + 4 more" {
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan?week=2026-10-12", r.Channel, r, want.Title)
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan", r.Channel, r, want.Title)
}
}
+1 -1
View File
@@ -335,7 +335,7 @@ func TestOnlyKeepPromotesToRegular(t *testing.T) {
// Each non-test Go file naming the regular status, and how often.
uses := map[string]int{
"dinners/dinners.go": 6, // the constant (2), listable, CreateRegular's status and origin, AddToQuestLog reusing a Regular as it is
"dinners/dinners.go": 5, // the constant (2), listable, CreateRegular's status and origin
"plan/plan.go": 1, // plannable
"plan/rotation.go": 1, // decisionStatus[DecisionKeep]
"suggest/batch.go": 1, // reads which dinners are Regulars to cite
+2 -8
View File
@@ -29,7 +29,7 @@ type Recipe struct {
// Title is the recipe's name.
Title string
// SiteName is the publisher's name, else the page's og:site_name, else
// HostSiteName of the page's URL.
// its host without a leading "www.".
SiteName string
// ImageURL is the largest image, absolute; empty when the recipe has
// none.
@@ -232,13 +232,7 @@ func siteName(publisher any, g graph, ogSiteName string, base *url.URL) (string,
if ogSiteName != "" {
return ogSiteName, nil
}
return HostSiteName(base), nil
}
// HostSiteName is the site name of a page that names none: its host
// without a leading "www.".
func HostSiteName(u *url.URL) string {
return strings.TrimPrefix(u.Hostname(), "www.")
return strings.TrimPrefix(base.Hostname(), "www."), nil
}
// imageURL picks the largest of the recipe's images by width × height;
+4 -18
View File
@@ -292,22 +292,7 @@ type GroundResult struct {
// affect the others.
func (g *Grounder) GroundMany(ctx context.Context, reqs []GroundRequest) []GroundResult {
results := make([]GroundResult, len(reqs))
g.GroundEach(ctx, reqs, func(i int, r GroundResult) { results[i] = r })
return results
}
// GroundEach grounds each request as GroundMany does, but calls done with
// each item's index and result as soon as that item finishes, one call at
// a time. It returns once every item has finished and every call has
// returned.
func (g *Grounder) GroundEach(ctx context.Context, reqs []GroundRequest, done func(i int, r GroundResult)) {
slots := make(chan struct{}, GroundConcurrency)
var mu sync.Mutex
report := func(i int, r GroundResult) {
mu.Lock()
defer mu.Unlock()
done(i, r)
}
var wg sync.WaitGroup
for i, req := range reqs {
wg.Add(1)
@@ -316,15 +301,16 @@ func (g *Grounder) GroundEach(ctx context.Context, reqs []GroundRequest, done fu
select {
case slots <- struct{}{}:
case <-ctx.Done():
report(i, GroundResult{Err: ctx.Err()})
results[i] = GroundResult{Err: ctx.Err()}
return
}
defer func() { <-slots }()
gr, err := g.Ground(ctx, req.DishName, req.Query)
<-slots
report(i, GroundResult{Grounded: gr, Err: err})
results[i] = GroundResult{Grounded: gr, Err: err}
}()
}
wg.Wait()
return results
}
// stored returns the grounding of the recipe stored for sourceURL, and
+4 -14
View File
@@ -23,16 +23,12 @@ import (
)
// Search is a recipes.SearchSource and recipes.SearchProvider answering
// each query from Results. Err, when set, fails every search, and Fail
// fails the searches for its queries. Gate, when set, makes every search
// wait for it to close, and Gates makes the searches for its queries wait
// for theirs.
// each query from Results. Err, when set, fails every search. Gate, when
// set, makes every search wait for it to close.
type Search struct {
Results map[string][]recipes.Candidate
Err error
Fail map[string]error
Gate chan struct{}
Gates map[string]chan struct{}
mu sync.Mutex
queries []string
@@ -44,12 +40,9 @@ func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Can
s.mu.Lock()
s.queries = append(s.queries, query)
s.mu.Unlock()
for _, gate := range []chan struct{}{s.Gate, s.Gates[query]} {
if gate == nil {
continue
}
if s.Gate != nil {
select {
case <-gate:
case <-s.Gate:
case <-ctx.Done():
return nil, ctx.Err()
}
@@ -57,9 +50,6 @@ func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Can
if s.Err != nil {
return nil, s.Err
}
if err := s.Fail[query]; err != nil {
return nil, err
}
c := s.Results[query]
return c[:min(n, len(c))], nil
}
+1 -1
View File
@@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t),
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+2 -2
View File
@@ -86,7 +86,7 @@ func (b *lockedBuffer) String() string {
func newChatRouter(t *testing.T, svc *chat.Service, logs io.Writer) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
@@ -233,7 +233,7 @@ func TestChatWebhookNeedsCSRF(t *testing.T) {
func TestNewRouterRequiresChat(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil Google Chat service")
}
}
-469
View File
@@ -1,469 +0,0 @@
package server
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"testing"
"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/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// getDetail answers GET /dinners/{id} for id, failing unless it is 200, and
// returns the body both raw (to tell null from absent) and decoded.
func getDetail(t *testing.T, h http.Handler, id string) (map[string]any, dinners.Detail) {
t.Helper()
rec, raw := callDinners(t, h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET dinner = %d: %s", rec.Code, rec.Body)
}
var d dinners.Detail
dec := json.NewDecoder(rec.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&d); err != nil {
t.Fatalf("decode detail: %v", err)
}
return raw, d
}
// wantNull fails unless each key is present in raw and null.
func wantNull(t *testing.T, raw map[string]any, keys ...string) {
t.Helper()
for _, k := range keys {
if v, ok := raw[k]; !ok || v != nil {
t.Errorf("%s = %v (present %v), want null", k, v, ok)
}
}
}
// TestGetDinnerGroundedSideQuest: a Side quest grounded in a fetched page
// shows its photo at both stored sizes, its recipe's ingredients and steps
// in recipe order (not row order), its source site and the reason it was
// suggested.
func TestGetDinnerGroundedSideQuest(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
e.start(t)
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "succeeded" {
t.Fatalf("grounding job = %v", job)
}
id := created["id"].(string)
// Make it an accepted Side quest with the suggestion that created it,
// and reverse its recipe's positions so row order disagrees with
// recipe order.
var recipeID, photoID string
if err := e.st.Reader.QueryRow(`SELECT recipe_id, photo_id FROM dinners WHERE id = ?`, id).Scan(&recipeID, &photoID); err != nil {
t.Fatal(err)
}
for _, stmt := range []string{
`UPDATE dinners SET status = 'quest_log', origin = 'side_quest', is_favorite = 1 WHERE id = ?`,
`UPDATE recipe_ingredients SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
`UPDATE recipe_steps SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
} {
if _, err := e.st.Writer.Exec(stmt, id); err != nil {
t.Fatal(err)
}
}
q := db.New(e.st.Writer)
now := store.Now().String()
batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
if err != nil {
t.Fatal(err)
}
const reason = "Like your Tikka masala, but quicker."
if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{
ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: "Chicken curry",
Reason: reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
_, got := getDetail(t, e.h, id)
str := func(s string) *string { return &s }
minutes := int64(40)
want := dinners.Detail{
ID: id,
Name: "Chicken curry",
Status: "quest_log",
Origin: "side_quest",
Method: str("stovetop"),
MinutesTotal: &minutes,
Difficulty: str("easy"),
IsFavorite: true,
PhotoURLs: map[string]string{
"400": "/media/photos/" + photoID + "-400.jpg",
"1200": "/media/photos/" + photoID + "-1200.jpg",
},
Ingredients: []dinners.Ingredient{
{Name: str("garlic"), Qty: str("2"), Unit: str("cloves"), Note: str("minced"), RawText: "2 cloves garlic, minced"},
{Name: str("onion"), Qty: str("1"), RawText: "1 onion"},
},
Steps: []string{"Fry with the garlic.", "Chop the onion."},
Source: &dinners.Source{SiteName: str("Curry House"), URL: str(curryPage)},
GroundingStatus: "grounded",
WhyPicked: str(reason),
}
if !reflect.DeepEqual(got, want) {
gj, _ := json.Marshal(got)
wj, _ := json.Marshal(want)
t.Errorf("detail =\n %s\nwant\n %s", gj, wj)
}
// Each photo URL is served.
for _, u := range got.PhotoURLs {
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, signedIn(http.MethodGet, u, nil))
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/jpeg" {
t.Errorf("GET %s = %d %s", u, rec.Code, rec.Header().Get("Content-Type"))
}
}
}
// TestGetDinnerManualRegular: a Regular added by hand and given a recipe
// typed in has no photo, no source and no reason, and its ingredients are
// the typed lines with nothing normalized.
func TestGetDinnerManualRegular(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Family chili"}`)
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body)
}
raw, got := getDetail(t, e.h, id)
wantNull(t, raw, "photo_urls", "source", "why_picked", "difficulty", "grounding_error")
str := func(s string) *string { return &s }
minutes := int64(240)
want := dinners.Detail{
ID: id,
Name: "Family chili",
Status: "regular",
Origin: "regular",
Method: str("crockpot"),
MinutesTotal: &minutes,
Ingredients: []dinners.Ingredient{{RawText: "1 lb beef"}, {RawText: "1 can beans"}},
Steps: []string{"Brown.", "Simmer."},
GroundingStatus: "manual",
}
if !reflect.DeepEqual(got, want) {
gj, _ := json.Marshal(got)
wj, _ := json.Marshal(want)
t.Errorf("detail =\n %s\nwant\n %s", gj, wj)
}
}
// TestGetDinnerFailedGrounding: a dinner whose grounding failed shows its
// real state — failed with its reason, no photo, no source and empty
// ingredients and steps — never a pretend recipe.
func TestGetDinnerFailedGrounding(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
e.start(t)
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Nothing matches this"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "failed" {
t.Fatalf("grounding job = %v, want failed", job)
}
raw, got := getDetail(t, e.h, created["id"].(string))
wantNull(t, raw, "photo_urls", "source", "why_picked", "method", "minutes_total", "difficulty")
if ings, ok := raw["ingredients"].([]any); !ok || len(ings) != 0 {
t.Errorf("ingredients = %v, want []", raw["ingredients"])
}
if steps, ok := raw["steps"].([]any); !ok || len(steps) != 0 {
t.Errorf("steps = %v, want []", raw["steps"])
}
if got.GroundingStatus != "failed" || got.GroundingError == nil || *got.GroundingError != dinners.ReasonNoRecipe ||
got.Name != "Nothing matches this" || got.Status != "regular" || got.Origin != "regular" {
t.Errorf("detail = %+v, want the failed Regular with no_recipe_found", got)
}
}
// TestGetDinnerNotFound: an unknown dinner and an archived one are both
// 404, and the detail needs a session like every /api/v1 route.
func TestGetDinnerNotFound(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Old favourite"}`)
if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body)
}
for _, target := range []string{id, store.NewID()} {
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+target, "")
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("GET %s = %d %v, want 404 not_found", target, rec.Code, body)
}
}
live := createDinner(t, e.h, `{"name":"Taco night"}`)
req := httptest.NewRequest(http.MethodGet, dinnersPath+"/"+live, nil)
if rec, body := serve(t, e.h, req); rec.Code != http.StatusUnauthorized || errorCode(body) != "unauthenticated" {
t.Errorf("GET without a session = %d %v, want 401", rec.Code, body)
}
}
// TestGetDinnerSourceURLOnlyWhenSafe: a recipe grounded in a clean https
// link shows it as source.url; one whose stored link carries a token in its
// query or userinfo, is http, or has a fragment shows its site name but
// source.url null and nothing of the link.
func TestGetDinnerSourceURLOnlyWhenSafe(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
const (
clean = "https://kitchen.example/recipes/chicken-curry"
queryToken = "query-token-def456"
queryPage = "https://secret-query.example/curry?access_token=" + queryToken
)
e.pages.Bodies[clean] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
e.pages.Bodies[queryPage] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25)
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
ground := func(dish, page string) (id string) {
t.Helper()
id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+page+`"}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground %s = %d: %s", page, rec.Code, rec.Body)
}
var stored string
if err := e.st.Reader.QueryRow(`SELECT source_url FROM recipes WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored != page {
t.Fatalf("stored source_url = %q, want %q", stored, page)
}
return id
}
source := func(what, id string) map[string]any {
t.Helper()
raw, _ := getDetail(t, e.h, id)
src, ok := raw["source"].(map[string]any)
if !ok {
t.Fatalf("source of %s = %v, want an object", what, raw["source"])
}
return src
}
cleanID := ground("Chicken curry", clean)
if src := source("a clean link", cleanID); !reflect.DeepEqual(src, map[string]any{"site_name": "Curry House", "url": clean}) {
t.Errorf("source of a clean link = %v, want site_name Curry House and url %s", src, clean)
}
queryID := ground("Weeknight curry", queryPage)
withheld := func(what, id, site string, secrets ...string) {
t.Helper()
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d: %s", what, rec.Code, rec.Body)
}
for _, secret := range secrets {
if strings.Contains(rec.Body.String(), secret) {
t.Errorf("detail of %s contains %q: %s", what, secret, rec.Body)
}
}
if src := source(what, id); !reflect.DeepEqual(src, map[string]any{"site_name": site, "url": nil}) {
t.Errorf("source of %s = %v, want site_name %q and url null", what, src, site)
}
}
withheld("a link with a query token", queryID, "B Kitchen", queryPage, "secret-query.example", queryToken, "access_token")
// The other unsafe shapes, stored as a link grounded elsewhere would
// leave them.
for _, tc := range []struct{ what, link string }{
{"a link with a userinfo token", "https://user-token-ghi789@kitchen.example/recipes/chicken-curry"},
{"a link with a userinfo password", "https://cook:password-jkl012@kitchen.example/recipes/chicken-curry"},
{"an http link", "http://kitchen.example/recipes/chicken-curry"},
{"a link with a fragment", "https://kitchen.example/recipes/chicken-curry#token=fragment-mno345"},
} {
if _, err := e.st.Writer.Exec(`UPDATE recipes SET source_url = ? WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, tc.link, cleanID); err != nil {
t.Fatal(err)
}
withheld(tc.what, cleanID, "Curry House", tc.link, "kitchen.example", "-token-", "password", "fragment")
}
// web/src/dinners/testdata/source_url_cases.json pins source as this
// handler sends it for each stored link; the recipe screen's tests
// render each one, so a link the server sends and the client refuses
// fails one side or the other.
fixture, err := os.ReadFile("../../web/src/dinners/testdata/source_url_cases.json")
if err != nil {
t.Fatal(err)
}
var shared struct {
Cases []struct {
Stored string
Source map[string]any
}
}
if err := json.Unmarshal(fixture, &shared); err != nil {
t.Fatal(err)
}
if len(shared.Cases) == 0 {
t.Fatal("source_url_cases.json has no cases")
}
for _, c := range shared.Cases {
if _, err := e.st.Writer.Exec(`UPDATE recipes SET source_url = ? WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, c.Stored, cleanID); err != nil {
t.Fatal(err)
}
if src := source(c.Stored, cleanID); !reflect.DeepEqual(src, c.Source) {
got, _ := json.Marshal(src)
want, _ := json.Marshal(c.Source)
t.Errorf("source of %s = %s, want %s", c.Stored, got, want)
}
}
}
// TestGetDinnerSiteNameNeverFromLink: a page that names no site is stored
// under its host, so a recipe grounded in a pasted link whose host carries a
// token has site_name null and nothing from the link in its detail; a page
// that names its site keeps that name, even on a token-bearing host; and a
// stored name shaped like another host, as a redirect leaves, is withheld.
// The links carry a query, so source.url is withheld too and nothing of the
// link may appear.
func TestGetDinnerSiteNameNeverFromLink(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
const (
bareToken = "private-token-abc123"
namedToken = "named-token-def456"
bare = "https://" + bareToken + ".example.com/recipe?print=1"
named = "https://www." + namedToken + ".example.com/recipe?print=1"
)
e.pages.Bodies[bare] = recipestest.RecipePage("Chicken Curry", "", "https://a.example/curry.jpg", 40)
e.pages.Bodies[named] = recipestest.RecipePage("Weeknight Curry", "Curry House", "https://b.example/curry.jpg", 25)
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
ground := func(dish, page string) (id, recipeID string) {
t.Helper()
id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+page+`"}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground %s = %d: %s", page, rec.Code, rec.Body)
}
if err := e.st.Reader.QueryRow(`SELECT recipe_id FROM dinners WHERE id = ?`, id).Scan(&recipeID); err != nil {
t.Fatal(err)
}
return id, recipeID
}
storedSite := func(recipeID string) string {
t.Helper()
var site string
if err := e.st.Reader.QueryRow(`SELECT site_name FROM recipes WHERE id = ?`, recipeID).Scan(&site); err != nil {
t.Fatal(err)
}
return site
}
check := func(what, id string, leaks []string, want any) {
t.Helper()
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d: %s", what, rec.Code, rec.Body)
}
for _, leak := range leaks {
if strings.Contains(strings.ToLower(rec.Body.String()), leak) {
t.Errorf("detail of %s contains %q: %s", what, leak, rec.Body)
}
}
raw, _ := getDetail(t, e.h, id)
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": want, "url": nil}) {
t.Errorf("source of %s = %v, want site_name %v and url null", what, raw["source"], want)
}
}
bareID, bareRecipe := ground("Chicken curry", bare)
if got := storedSite(bareRecipe); got != bareToken+".example.com" {
t.Fatalf("stored site_name = %q, want the link's host", got)
}
check("a page naming no site", bareID, []string{bare, bareToken, "example.com"}, nil)
namedID, namedRecipe := ground("Weeknight curry", named)
if got := storedSite(namedRecipe); got != "Curry House" {
t.Fatalf("stored site_name = %q, want the page's", got)
}
check("a page naming its site", namedID, []string{named, namedToken, "example.com"}, "Curry House")
const redirectHost = "redirect-token-ghi789.example.net"
if _, err := e.st.Writer.Exec(`UPDATE recipes SET site_name = ? WHERE id = ?`, redirectHost, namedRecipe); err != nil {
t.Fatal(err)
}
check("a page named by a redirect's host", namedID, []string{redirectHost, "redirect-token", namedToken}, nil)
}
// TestGetDinnerWhyPickedIsCreatingSuggestion: why_picked is the reason of
// the suggestion that created the dinner, never that of an earlier
// suggestion row for it that was declined.
func TestGetDinnerWhyPickedIsCreatingSuggestion(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
if _, err := e.st.Writer.Exec(`UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, id); err != nil {
t.Fatal(err)
}
q := db.New(e.st.Writer)
now := store.Now().String()
batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
if err != nil {
t.Fatal(err)
}
const reason = "Like your Tikka masala, but quicker."
// The declined row is created first, so it is the earliest by both
// created_at and id.
for _, s := range []struct{ reason, status string }{
{"Passed on last week.", "declined"},
{reason, "accepted"},
} {
sid := store.NewID()
if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{
ID: sid, DinnerID: id, BatchID: batch.ID, Name: "Chicken curry",
Reason: s.reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
if n, err := q.DecideSuggestion(t.Context(), db.DecideSuggestionParams{
Status: s.status, Now: sql.NullString{String: now, Valid: true}, ID: sid,
}); err != nil || n != 1 {
t.Fatalf("decide %s = %d, %v", s.status, n, err)
}
}
if _, got := getDetail(t, e.h, id); got.WhyPicked == nil || *got.WhyPicked != reason {
t.Errorf("why_picked = %v, want %q", got.WhyPicked, reason)
}
}
// TestGetDinnerDuplicatePositionFails: a recipe with two ingredients or two
// steps at one position has no order anyone can trust, so its detail is a
// 500 rather than the recipe in some order.
func TestGetDinnerDuplicatePositionFails(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
for _, table := range []string{"recipe_ingredients", "recipe_steps"} {
id := createDinner(t, e.h, `{"name":"Family chili `+table+`"}`)
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body)
}
if _, err := e.st.Writer.Exec(`UPDATE `+table+` SET position = 0 WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id); err != nil {
t.Fatal(err)
}
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "Simmer.") || strings.Contains(rec.Body.String(), "1 lb beef") {
t.Errorf("GET with duplicate %s positions = %d: %s, want 500 and no recipe", table, rec.Code, rec.Body)
}
}
}
-18
View File
@@ -70,24 +70,6 @@ func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
}
}
// getDinner answers GET /dinners/{id} with the dinner, its recipe and the
// reason it was suggested. An archived dinner is 404, like an unknown one,
// not a 200 with its status: it has no detail to show. source never has the
// recipe page's link, and source.site_name is null unless the page itself
// named its site — a name that could be a host, the link's or a redirect's,
// is withheld (dinners.Source) — so the detail screen shows "Recipe from
// {site}" only when site_name is not null.
func getDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
d, err := svc.Get(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeDinnerError(w, r, logger, "get dinner", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// postDinner adds a Regular, recording the session's actor as updated_by,
// and answers 201 with it and its grounding_job_id without waiting for the
// job. When the job cannot be enqueued it still answers 201, with the dinner
+2 -8
View File
@@ -86,19 +86,13 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
t.Fatal(err)
}
e.runner = runner
// The router serves the photos the grounder caches.
dataDir := t.TempDir()
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.model)
photos, err := recipes.NewPhotoCache(e.images, st, dataDir)
if err != nil {
t.Fatal(err)
}
g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model)
svc, err := dinners.NewService(st, g, runner, logger)
if err != nil {
t.Fatal(err)
}
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv {
}
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t),
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestRunner(t), newTestPhotos(t),
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
-85
View File
@@ -1,85 +0,0 @@
package server
import (
"errors"
"log/slog"
"net/http"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
)
// getHome answers GET /home with the Home screen at the household's local
// "now".
func getHome(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h, err := svc.Get(r.Context())
if err != nil {
logger.ErrorContext(r.Context(), "read home", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusOK, h)
}
}
// updatesBody is the GET /updates answer.
type updatesBody struct {
Updates []home.Update `json:"updates"`
}
// getUpdates answers GET /updates with the Recent updates: the last
// home.RecentLimit distinct notification events, newest first.
func getUpdates(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updates, err := svc.Updates(r.Context())
if err != nil {
logger.ErrorContext(r.Context(), "read recent updates", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusOK, updatesBody{Updates: updates})
}
}
// maxUpdatesSeenBody bounds a POST /updates/seen request body.
const maxUpdatesSeenBody = 1 << 10
// updatesSeenRequest is the POST /updates/seen body: through is the
// created_at of the newest update the household was shown.
type updatesSeenRequest struct {
Through *string `json:"through"`
}
// postUpdatesSeen answers POST /updates/seen by recording that the
// household has seen its updates through the body's through, and answers
// 204. A through before the one already recorded changes nothing.
func postUpdatesSeen(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
req, err := decodePatch[updatesSeenRequest](http.MaxBytesReader(w, r.Body, maxUpdatesSeenBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if req.Through == nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: required")
return
}
through, err := time.Parse(time.RFC3339Nano, *req.Through)
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: want an RFC 3339 timestamp")
return
}
err = svc.MarkUpdatesSeen(r.Context(), through)
if errors.Is(err, home.ErrSeenInFuture) {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: must not be after now")
return
}
if err != nil {
logger.ErrorContext(r.Context(), "mark recent updates seen", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
-245
View File
@@ -1,245 +0,0 @@
package server
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// GET /home answers the Home screen in its JSON shape; newTestHome's
// household (America/New_York) has no plan, no suggestions, no Supply run
// and no notifications.
func TestGetHome(t *testing.T) {
t.Parallel()
h := newTestRouter(t, "dev")
loc, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatal(err)
}
// want is the answer at instant now; the request is read between two
// instants, so it matches the answer at one of them.
want := func(now time.Time) string {
week := household.WeekStart(household.Today(now, loc))
label, err := household.WeekLabel(week)
if err != nil {
t.Fatal(err)
}
body, err := json.Marshal(map[string]any{
"dateLabel": now.In(loc).Format("Monday, Jan 2"),
"timezone": loc.String(),
"tonight": nil,
"sideQuestCount": 0,
"shoppingList": map[string]any{"weekStart": week.String(), "label": label, "ready": false, "sentToChatAt": nil},
"recentUpdates": map[string]any{"unread": false},
})
if err != nil {
t.Fatal(err)
}
return string(body)
}
before := time.Now()
rec, _ := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil))
after := time.Now()
if rec.Code != http.StatusOK {
t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body)
}
var got any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
canonical, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
if string(canonical) != want(before) && string(canonical) != want(after) {
t.Errorf("GET /api/v1/home = %s, want %s", canonical, want(after))
}
}
func TestGetHomeNeedsSession(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/home", nil))
if rec.Code != http.StatusUnauthorized {
t.Errorf("GET /api/v1/home without a session = %d, want 401", rec.Code)
}
}
// homeRouter returns a router whose Home reads st, and st.
func homeRouter(t *testing.T) (http.Handler, *store.Store) {
t.Helper()
st := newTestStore(t)
svc := newTestHomeOn(t, st)
return newTestRouterWithHome(t, "dev", newTestSettings(t), newTestRunner(t), svc), st
}
// storeEvent stores event key in st's outbox at instant at for each of
// channels, as notify stores an event, and returns at as stored.
func storeEvent(t *testing.T, st *store.Store, typ notify.EventType, key, title string, at time.Time, channels ...notify.Channel) string {
t.Helper()
payload, err := json.Marshal(notify.Payload{Event: typ, Title: title, Body: "Body of " + key, URL: "/plan", Tag: key})
if err != nil {
t.Fatal(err)
}
ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()
for _, ch := range channels {
if _, err := db.New(st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{
ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload),
NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts,
}); err != nil {
t.Fatal(err)
}
}
return ts
}
// unread reads recentUpdates.unread from GET /home.
func unread(t *testing.T, h http.Handler) bool {
t.Helper()
rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body)
}
updates, ok := body["recentUpdates"].(map[string]any)
if !ok {
t.Fatalf("GET /api/v1/home has no recentUpdates: %s", rec.Body)
}
return updates["unread"] == true
}
// markSeen posts body to /updates/seen and returns the answer.
func markSeen(t *testing.T, h http.Handler, body string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)))
return rec
}
// GET /updates lists the last distinct events newest first, one per event
// whatever its channels, in their JSON shape.
func TestGetUpdates(t *testing.T) {
t.Parallel()
h, st := homeRouter(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil))
if rec.Code != http.StatusOK || rec.Body.String() != `{"updates":[]}` {
t.Errorf("GET /api/v1/updates with none = %d %s, want 200 {\"updates\":[]}", rec.Code, rec.Body)
}
now := time.Now()
older := storeEvent(t, st, notify.NewSuggestions, "new_suggestions:1", "New Side quests", now.Add(-2*time.Minute), notify.ChannelPush)
newer := storeEvent(t, st, notify.PlanReady, "plan_ready:2026-10-12", "Your week is drafted", now.Add(-time.Minute), notify.ChannelPush, notify.ChannelChat)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil))
want := `{"updates":[` +
`{"type":"plan_ready","title":"Your week is drafted","body":"Body of plan_ready:2026-10-12","url":"/plan","created_at":"` + newer + `"},` +
`{"type":"new_suggestions","title":"New Side quests","body":"Body of new_suggestions:1","url":"/plan","created_at":"` + older + `"}]}`
if rec.Code != http.StatusOK || rec.Body.String() != want {
t.Errorf("GET /api/v1/updates = %d %s, want 200 %s", rec.Code, rec.Body, want)
}
}
// Unread is true while an event newer than the last seen one exists,
// clears once POST /updates/seen marks the newest seen, and returns when a
// newer event is stored; marking an older one seen again moves nothing.
func TestUpdatesSeen(t *testing.T) {
t.Parallel()
h, st := homeRouter(t)
now := time.Now()
if unread(t, h) {
t.Error("unread with no events")
}
first := storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", now.Add(-2*time.Minute), notify.ChannelPush, notify.ChannelChat)
if !unread(t, h) {
t.Error("not unread with an event never seen")
}
if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body)
}
if unread(t, h) {
t.Error("unread after the newest event was marked seen")
}
second := storeEvent(t, st, notify.ListReady, "list_ready:2", "Supply run ready", now.Add(-time.Minute), notify.ChannelPush)
if !unread(t, h) {
t.Error("not unread with an event newer than the last seen")
}
// Already seen: marking the older event seen again is accepted and
// changes nothing.
if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent {
t.Fatalf("POST /api/v1/updates/seen of an older event = %d %s, want 204", rec.Code, rec.Body)
}
if !unread(t, h) {
t.Error("marking an older event seen hid a newer one")
}
if rec := markSeen(t, h, `{"through":"`+second+`"}`); rec.Code != http.StatusNoContent {
t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body)
}
if unread(t, h) {
t.Error("unread after the newer event was marked seen")
}
}
// A malformed POST /updates/seen is 400 and records nothing.
func TestUpdatesSeenRejectsMalformed(t *testing.T) {
t.Parallel()
h, st := homeRouter(t)
storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", time.Now().Add(-time.Minute), notify.ChannelPush)
for _, body := range []string{
``,
`[]`,
`{}`,
`{"through":null}`,
`{"through":42}`,
`{"through":"yesterday"}`,
`{"through":"2026-10-13 12:00:00"}`,
`{"through":"2026-10-13T12:00:00.000Z","extra":true}`,
`{"through":"2026-10-13T12:00:00.000Z"} {}`,
`{"through":"` + time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano) + `"}`,
} {
rec := markSeen(t, h, body)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"invalid_request"`) {
t.Errorf("POST /api/v1/updates/seen %q = %d %s, want 400 invalid_request", body, rec.Code, rec.Body)
}
}
if !unread(t, h) {
t.Error("a malformed POST /updates/seen marked updates seen")
}
}
// Both endpoints need a session, and POST /updates/seen the CSRF token.
func TestUpdatesNeedSessionAndCSRF(t *testing.T) {
t.Parallel()
h, _ := homeRouter(t)
body := `{"through":"2026-10-01T12:00:00.000Z"}`
for _, req := range []*http.Request{
httptest.NewRequest(http.MethodGet, "/api/v1/updates", nil),
httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)),
} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s %s without a session = %d, want 401", req.Method, req.URL.Path, rec.Code)
}
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body))
req.AddCookie(validSessionCookie)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("POST /api/v1/updates/seen without the CSRF token = %d, want 403", rec.Code)
}
}
-417
View File
@@ -1,417 +0,0 @@
package server
import (
"context"
"database/sql"
"io"
"log/slog"
"net/http"
"net/url"
"testing"
"testing/fstest"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"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/ideas"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// actionsZone is the household zone of newIdeaActionsRouter.
const actionsZone = "America/New_York"
// newIdeaActionsRouter returns a started router whose ideas, dinners and
// plan services share one store, whose household settings are seeded, so
// an idea's actions read the job, make the dinner and swap the plan of the
// same household. The ideas service runs both skills through the real
// harness with the scripted model and grounds with the scripted fakes.
func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images) (http.Handler, *store.Store) {
t.Helper()
st := newTestStore(t)
if err := household.SeedSettings(t.Context(), st, actionsZone); err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
g := recipestest.NewGrounder(t, st, t.TempDir(), search, pages, images, normalize)
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
t.Fatal(err)
}
cfg := harness.Config{
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return model, nil },
Store: st,
Guard: guard,
}
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
}
quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
return skills.QuickIdeas(ctx, cfg, scope, in)
}
ideasSvc, err := ideas.NewService(st, g, generate, quick, runner, logger)
if err != nil {
t.Fatal(err)
}
dinnerSvc, err := dinners.NewService(st, g, runner, logger)
if err != nil {
t.Fatal(err)
}
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
t.Error("draft_week ran: an idea action must never draft a plan")
return skills.Week{}, context.Canceled
}
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
if err != nil {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), newTestPush(t), ideasSvc, newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
startRunner(t, runner)
return h, st
}
// finishedIdeasJob runs an ideas from ingredients job that keeps names, in
// order, and returns the router, store, job id and its ideas' recipe ids.
func finishedIdeasJob(t *testing.T, names ...string) (http.Handler, *store.Store, string, []string) {
t.Helper()
model, normalize, search, pages, images := scriptIdeas(t, names, names, nil)
h, st := newIdeaActionsRouter(t, model, normalize, search, pages, images)
id := startIdeasJob(t, h)
job := waitJob(t, h, id)
if job["status"] != "succeeded" {
t.Fatalf("ideas job = %v", job)
}
var recipeIDs []string
for _, i := range job["result"].(map[string]any)["ideas"].([]any) {
recipeIDs = append(recipeIDs, i.(map[string]any)["recipe_id"].(string))
}
if len(recipeIDs) != len(names) {
t.Fatalf("ideas job kept %d ideas, want %d", len(recipeIDs), len(names))
}
return h, st, id, recipeIDs
}
func ideaPath(jobID, i, action string) string {
return "/api/v1/ideas/" + jobID + "/items/" + i + "/" + action
}
// dinnerCount is how many dinners the store holds, archived or not.
func dinnerCount(t *testing.T, st *store.Store) int {
t.Helper()
var n int
if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM dinners`).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
// thisWeek is today in the household's zone and the Monday of its week.
func thisWeek(t *testing.T) (week, today store.LocalDate) {
t.Helper()
zone, err := household.LoadZone(actionsZone)
if err != nil {
t.Fatal(err)
}
today = household.Today(time.Now(), zone)
return household.WeekStart(today), today
}
// planThisWeek stores a plan of this week with a Regular on every day and
// the given status, and returns the week's Monday and today.
func planThisWeek(t *testing.T, st *store.Store, status string) (week, today store.LocalDate) {
t.Helper()
ctx := t.Context()
week, today = thisWeek(t)
days, err := household.WeekDays(week)
if err != nil {
t.Fatal(err)
}
q := db.New(st.Writer)
now := store.Now().String()
planID := store.NewID()
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: week.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
for _, date := range days {
name := "Regular " + date.String()
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "regular", Origin: "regular",
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date.String(),
DinnerID: sql.NullString{String: d.ID, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
}
if _, err := st.Writer.ExecContext(ctx, `UPDATE week_plans SET status = ? WHERE id = ?`, status, planID); err != nil {
t.Fatal(err)
}
return week, today
}
// planRevision is the revision of the plan of week.
func planRevision(t *testing.T, st *store.Store, week store.LocalDate) int64 {
t.Helper()
row, err := db.New(st.Reader).GetWeekPlan(t.Context(), week.String())
if err != nil {
t.Fatal(err)
}
return row.Revision
}
// Adding an idea to the Quest log makes a quest_log dinner grounded in the
// idea's own recipe, with origin ingredients, without grounding it again;
// asking again answers the same dinner and makes no second one.
func TestPostIdeaQuestLogAddsGroundedDinnerOnce(t *testing.T) {
t.Parallel()
h, st, id, recipeIDs := finishedIdeasJob(t, "Chicken Fried Rice", "Rice Pudding")
rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "")
if rec.Code != http.StatusCreated {
t.Fatalf("POST quest-log = %d: %s", rec.Code, rec.Body)
}
if d["name"] != "Rice Pudding" || d["status"] != "quest_log" || d["origin"] != "ingredients" ||
d["grounding_status"] != "grounded" || d["recipe_id"] != recipeIDs[1] || d["photo_url"] == nil ||
d["method"] != "stovetop" || d["minutes_total"] != 25.0 || d["updated_by"] != testSession.Subject {
t.Fatalf("dinner = %v, want Rice Pudding in the Quest log grounded in %s", d, recipeIDs[1])
}
rec, again := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "")
if rec.Code != http.StatusOK || again["id"] != d["id"] || again["status"] != "quest_log" {
t.Fatalf("POST quest-log again = %d %v, want 200 with dinner %v", rec.Code, again, d["id"])
}
if n := dinnerCount(t, st); n != 1 {
t.Fatalf("dinners = %d, want 1", n)
}
if names := listNames(t, h, "quest_log"); len(names) != 1 || names[0] != "Rice Pudding" {
t.Fatalf("Quest log = %q", names)
}
}
// A Quick dinners idea makes a dinner with origin quick.
func TestPostIdeaQuestLogQuickOrigin(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := newQuickFakes()
quickRecipe(t, normalize, search, pages, images, "Chicken Fried Rice", "Chicken Fried Rice", 25)
quickReply(model, [3]any{"Chicken Fried Rice", "stovetop", 20})
h, _ := newIdeaActionsRouter(t, model, normalize, search, pages, images)
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": []}`)
if rec.Code != http.StatusAccepted {
t.Fatalf("POST quick = %d: %s", rec.Code, rec.Body)
}
id := body["jobId"].(string)
if job := waitJob(t, h, id); job["status"] != "succeeded" {
t.Fatalf("quick job = %v", job)
}
rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "quest-log"), "")
if rec.Code != http.StatusCreated || d["name"] != "Chicken Fried Rice" || d["status"] != "quest_log" || d["origin"] != "quick" {
t.Fatalf("POST quest-log = %d %v, want a quick Quest log dinner", rec.Code, d)
}
}
// An idea repeating a dinner the household already has is that dinner, as
// it is: a Regular stays a Regular. One awaiting the household's choice
// is refused and nothing changes.
func TestPostIdeaQuestLogReusesExistingDinner(t *testing.T) {
t.Parallel()
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice", "Rice Pudding")
regular := createDinner(t, h, `{"name": "chicken fried rice"}`)
rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "quest-log"), "")
if rec.Code != http.StatusOK || d["id"] != regular || d["status"] != "regular" || d["origin"] != "regular" {
t.Fatalf("POST quest-log = %d %v, want 200 with Regular %s as it is", rec.Code, d, regular)
}
now := store.Now().String()
if _, err := db.New(st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{
ID: store.NewID(), Name: "Rice pudding", NormalizedName: harness.Normalize("Rice pudding"), Status: "suggested",
Origin: "side_quest", GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "")
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
t.Fatalf("POST quest-log of a suggested Side quest = %d %v, want 409 duplicate_dinner", rec.Code, body)
}
if n := dinnerCount(t, st); n != 2 {
t.Fatalf("dinners = %d, want the Regular and the Side quest only", n)
}
}
// Cook tonight puts the idea's dinner on today of this week's plan through
// the plan's swap: the day is new, the plan's revision rises and a synced
// plan becomes changed. Asking again plans the same dinner.
func TestPostIdeaCookTonightSwapsToday(t *testing.T) {
t.Parallel()
h, st, id, recipeIDs := finishedIdeasJob(t, "Chicken Fried Rice")
week, today := planThisWeek(t, st, plan.StatusSynced)
before := planRevision(t, st, week)
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
if rec.Code != http.StatusOK {
t.Fatalf("POST cook-tonight = %d: %s", rec.Code, rec.Body)
}
d := body["dinner"].(map[string]any)
if d["name"] != "Chicken Fried Rice" || d["status"] != "quest_log" || d["origin"] != "ingredients" || d["recipe_id"] != recipeIDs[0] {
t.Fatalf("dinner = %v", d)
}
assertTonight := func(body map[string]any) {
t.Helper()
p := body["plan"].(map[string]any)
if p["week_start"] != week.String() || p["status"] != plan.StatusChanged {
t.Fatalf("plan = %v, want week %s changed", p, week)
}
found := false
for _, day := range p["days"].([]any) {
day := day.(map[string]any)
planned := day["dinner"].(map[string]any)["id"] == d["id"]
if day["date"] == today.String() {
found = true
if !planned || day["is_new"] != true {
t.Fatalf("today %s = %v, want %v new", today, day, d["id"])
}
} else if planned {
t.Fatalf("dinner also on %v", day["date"])
}
}
if !found {
t.Fatalf("plan has no day %s", today)
}
}
assertTonight(body)
if after := planRevision(t, st, week); after != before+1 {
t.Fatalf("plan revision = %d, want %d", after, before+1)
}
rec, body = callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
if rec.Code != http.StatusOK || body["dinner"].(map[string]any)["id"] != d["id"] {
t.Fatalf("POST cook-tonight again = %d %v", rec.Code, body)
}
assertTonight(body)
if n := dinnerCount(t, st); n != 8 {
t.Fatalf("dinners = %d, want the seven Regulars and one idea", n)
}
}
// Cook tonight reuses a dinner the household already has.
func TestPostIdeaCookTonightReusesExistingDinner(t *testing.T) {
t.Parallel()
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice")
_, today := planThisWeek(t, st, plan.StatusDraft)
regular := createDinner(t, h, `{"name": "Chicken fried rice"}`)
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
if rec.Code != http.StatusOK || body["dinner"].(map[string]any)["id"] != regular {
t.Fatalf("POST cook-tonight = %d %v, want Regular %s", rec.Code, body, regular)
}
for _, day := range body["plan"].(map[string]any)["days"].([]any) {
day := day.(map[string]any)
if day["date"] == today.String() && (day["dinner"].(map[string]any)["id"] != regular || day["is_new"] != false) {
t.Fatalf("today = %v, want Regular %s", day, regular)
}
}
}
// With no plan for this week, cook tonight answers 409 no_plan and changes
// nothing: no dinner is made and no plan is drafted or stored.
func TestPostIdeaCookTonightWithoutPlan(t *testing.T) {
t.Parallel()
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice")
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
if rec.Code != http.StatusConflict || errorCode(body) != "no_plan" {
t.Fatalf("POST cook-tonight = %d %v, want 409 no_plan", rec.Code, body)
}
if n := dinnerCount(t, st); n != 0 {
t.Fatalf("dinners = %d, want none", n)
}
var plans, drafts int
if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM week_plans`).Scan(&plans); err != nil {
t.Fatal(err)
}
if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&drafts); err != nil {
t.Fatal(err)
}
if plans != 0 || drafts != 0 {
t.Fatalf("plans = %d, drafts = %d, want none", plans, drafts)
}
}
// An idea that is not an index of a succeeded ideas job is refused.
func TestPostIdeaActionsRefuse(t *testing.T) {
t.Parallel()
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice")
planThisWeek(t, st, plan.StatusDraft)
// A Regular's grounding job is a job, but finds no ideas.
rec, body := callDinners(t, h, http.MethodPost, dinnersPath, `{"name": "Beef Stew"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("POST /dinners = %d: %s", rec.Code, rec.Body)
}
other := body["grounding_job_id"].(string)
for _, action := range []string{"quest-log", "cook-tonight"} {
for _, tc := range []struct {
job, i string
status int
code string
}{
{id, "x", http.StatusBadRequest, "invalid_request"},
{id, "-1", http.StatusBadRequest, "invalid_request"},
{id, "01", http.StatusBadRequest, "invalid_request"},
{id, "1", http.StatusNotFound, "not_found"},
{"unknown", "0", http.StatusNotFound, "not_found"},
{other, "0", http.StatusNotFound, "not_found"},
} {
rec, body := callDinners(t, h, http.MethodPost, ideaPath(tc.job, tc.i, action), "")
if rec.Code != tc.status || errorCode(body) != tc.code {
t.Errorf("POST %s %s/%s = %d %v, want %d %s", action, tc.job, tc.i, rec.Code, body, tc.status, tc.code)
}
}
}
if n := dinnerCount(t, st); n != 8 {
t.Fatalf("dinners = %d, want the plan's seven Regulars and Beef Stew only", n)
}
}
// While the job still finds ideas, an idea is not acted on: its index may
// still move.
func TestPostIdeaActionsWhileJobRuns(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := scriptIdeas(t, []string{"Chicken Fried Rice"}, []string{"Chicken Fried Rice"}, nil)
gate := make(chan struct{})
search.Gates = map[string]chan struct{}{"Chicken Fried Rice": gate}
h, st := newIdeaActionsRouter(t, model, normalize, search, pages, images)
id := startIdeasJob(t, h)
for _, action := range []string{"quest-log", "cook-tonight"} {
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", action), "")
if rec.Code != http.StatusConflict || errorCode(body) != "ideas_not_ready" {
t.Errorf("POST %s = %d %v, want 409 ideas_not_ready", action, rec.Code, body)
}
}
close(gate)
waitJob(t, h, id)
if n := dinnerCount(t, st); n != 0 {
t.Fatalf("dinners = %d, want none", n)
}
}
-224
View File
@@ -1,224 +0,0 @@
package server
import (
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
)
// maxIdeasBody bounds a POST /ideas/from-ingredients or /ideas/quick body.
const maxIdeasBody = 16 << 10
// ideasFromIngredients is the POST /ideas/from-ingredients body. Both
// fields are required.
type ideasFromIngredients struct {
Ingredients []string `json:"ingredients"`
QuickOnly *bool `json:"quick_only"`
}
// postIdeasFromIngredients starts finding ideas from the ingredients on
// hand and answers 202 with the jobId that GET /jobs/{id} reports on,
// without waiting for the job. An ingredient list the household must
// correct is 400.
func postIdeasFromIngredients(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := decodePatch[ideasFromIngredients](http.MaxBytesReader(w, r.Body, maxIdeasBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
switch {
case body.Ingredients == nil:
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "ingredients: required")
return
case body.QuickOnly == nil:
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "quick_only: required")
return
}
started, err := svc.StartFromIngredients(r.Context(), body.Ingredients, *body.QuickOnly)
var invalid *ideas.InvalidError
switch {
case errors.As(err, &invalid):
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", invalid.Message)
return
case err != nil:
logger.ErrorContext(r.Context(), "start ideas from ingredients", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusAccepted, started)
}
}
// ideasQuick is the POST /ideas/quick body. Both fields are required:
// exclude is [] on a first page.
type ideasQuick struct {
Kind *string `json:"kind"`
Exclude []string `json:"exclude"`
}
// postIdeasQuick starts finding a page of Quick dinner ideas of a kind,
// crockpot or under30, none named in exclude, and answers 202 with the
// jobId that GET /jobs/{id} reports on, without waiting for the job. A
// missing or unknown kind, or an exclude list the household must correct,
// is 400.
func postIdeasQuick(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := decodePatch[ideasQuick](http.MaxBytesReader(w, r.Body, maxIdeasBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
switch {
case body.Kind == nil:
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "kind: required")
return
case body.Exclude == nil:
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "exclude: required")
return
}
started, err := svc.StartQuick(r.Context(), *body.Kind, body.Exclude)
var invalid *ideas.InvalidError
switch {
case errors.As(err, &invalid):
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", invalid.Message)
return
case err != nil:
logger.ErrorContext(r.Context(), "start quick ideas", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusAccepted, started)
}
}
// chosenIdeaOrFail returns the idea {i} of ideas job {jobId}, or answers
// 400 invalid_request when i is not an index (0, 1, ...), 404 not_found
// for an idea no succeeded ideas job shows, or 409 ideas_not_ready while
// the job is still finding ideas.
func chosenIdeaOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger, svc *ideas.Service) (ideas.Chosen, bool) {
raw := chi.URLParam(r, "i")
i, err := strconv.Atoi(raw)
if err != nil || i < 0 || strconv.Itoa(i) != raw {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "i: must be an idea's index, 0 or more")
return ideas.Chosen{}, false
}
idea, err := svc.Idea(r.Context(), chi.URLParam(r, "jobId"), i)
switch {
case errors.Is(err, ideas.ErrIdeaNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "idea not found")
return ideas.Chosen{}, false
case errors.Is(err, ideas.ErrIdeasNotReady):
writeError(w, r, logger, http.StatusConflict, "ideas_not_ready", err.Error())
return ideas.Chosen{}, false
case err != nil:
logger.ErrorContext(r.Context(), "read idea", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return ideas.Chosen{}, false
}
return idea, true
}
// postIdeaQuestLog adds idea {i} of ideas job {jobId} to the Quest log,
// recording the session's actor as updated_by, as a dinner grounded in
// the recipe the idea was grounded in, with origin ingredients or quick.
// It answers 201 with the dinner made, or 200 with the dinner the idea
// repeats (see dinners.Service.AddToQuestLog), so asking again answers the
// same dinner. A repeated dinner awaiting the household's choice is 409
// duplicate_dinner.
func postIdeaQuestLog(logger *slog.Logger, ideasSvc *ideas.Service, dinnerSvc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
idea, ok := chosenIdeaOrFail(w, r, logger, ideasSvc)
if !ok {
return
}
d, created, err := dinnerSvc.AddToQuestLog(r.Context(), dinners.NewQuestLog(idea), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "add idea to Quest log", err)
return
}
status := http.StatusOK
if created {
status = http.StatusCreated
}
writeJSON(w, r, logger, status, d)
}
}
// cookedTonight is the POST /ideas/{jobId}/items/{i}/cook-tonight answer:
// the idea's dinner and the week's plan with it tonight.
type cookedTonight struct {
Dinner dinners.Dinner `json:"dinner"`
Plan plan.Plan `json:"plan"`
}
// postIdeaCookTonight plans idea {i} of ideas job {jobId} tonight: today,
// in the household's zone, of the plan of today's week. The idea's dinner
// is added or found as postIdeaQuestLog does, then swapped onto today
// through plan.Service.Swap, exactly as a swap on the Draft board, so the
// plan is marked edited and a synced plan becomes changed. It answers 200
// with the dinner and the plan as swapped; asking again plans the same
// dinner again.
//
// When today's week has no plan it answers 409 no_plan and changes
// nothing: no dinner is added and no plan is made, since a plan holds all
// seven days; the household plans the week first. The week is always
// today's own, so no other day is ever written. A dinner already on
// another day of the week is 409 dinner_already_planned, after the dinner
// is added or found.
func postIdeaCookTonight(logger *slog.Logger, ideasSvc *ideas.Service, dinnerSvc *dinners.Service, planSvc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
idea, ok := chosenIdeaOrFail(w, r, logger, ideasSvc)
if !ok {
return
}
week, today, err := planSvc.Tonight(r.Context())
if errors.Is(err, plan.ErrNotFound) {
writeNoPlan(w, r, logger)
return
}
if err != nil {
writePlanError(w, r, logger, "find tonight's plan", err)
return
}
d, _, err := dinnerSvc.AddToQuestLog(r.Context(), dinners.NewQuestLog(idea), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "add idea to cook tonight", err)
return
}
p, err := planSvc.Swap(r.Context(), week, today.String(), d.ID)
if errors.Is(err, plan.ErrNotFound) {
// Plans are never deleted, so the plan Tonight found is still
// there; this is said loudly rather than answered as success.
logger.ErrorContext(r.Context(), "plan of tonight's week gone before the swap", "week", week.String())
writeNoPlan(w, r, logger)
return
}
if err != nil {
writePlanError(w, r, logger, "cook idea tonight", err)
return
}
writeJSON(w, r, logger, http.StatusOK, cookedTonight{Dinner: d, Plan: p})
}
}
// writeNoPlan answers 409 no_plan: today's week has no plan to put
// tonight's dinner on, so the household plans the week first.
func writeNoPlan(w http.ResponseWriter, r *http.Request, logger *slog.Logger) {
writeError(w, r, logger, http.StatusConflict, "no_plan", "this week has no plan yet; plan this week first")
}
-350
View File
@@ -1,350 +0,0 @@
package server
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"testing"
"testing/fstest"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const ideasPath = "/api/v1/ideas/from-ingredients"
// newIdeasRouter returns a router whose ideas service runs
// ideas_from_ingredients and quick_ideas through the real harness with the
// scripted model and grounds with fake search, pages and images and the
// scripted normalize, with its job runner and store, whose household
// settings are seeded. The runner is started when start is set.
func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images, start bool) (http.Handler, *jobs.Runner, *store.Store) {
t.Helper()
st := newTestStore(t)
if err := household.SeedSettings(t.Context(), st, "America/New_York"); err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
g := recipestest.NewGrounder(t, st, t.TempDir(), search, pages, images, normalize)
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
t.Fatal(err)
}
cfg := harness.Config{
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return model, nil },
Store: st,
Guard: guard,
}
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
}
quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
return skills.QuickIdeas(ctx, cfg, scope, in)
}
svc, err := ideas.NewService(st, g, generate, quick, runner, logger)
if err != nil {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), svc, newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
if start {
startRunner(t, runner)
}
return h, runner, st
}
// startRunner starts runner until the test ends.
func startRunner(t *testing.T, runner *jobs.Runner) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
if err := runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
runner.Wait()
})
}
// scriptIdeas scripts ideas_from_ingredients to propose names, in order;
// a recipe grounds those in grounds, the others ground none, and search
// fails as fail says. It returns the router's fakes.
func scriptIdeas(t *testing.T, names, grounds []string, fail map[string]error) (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) {
t.Helper()
model, normalize := &llm.Fake{}, &llm.Fake{}
search := &recipestest.Search{Results: map[string][]recipes.Candidate{}, Fail: fail}
pages := &recipestest.Pages{Bodies: map[string][]byte{}}
images := &recipestest.Images{JPEGs: map[string][]byte{}}
for i, name := range grounds {
page, photo := fmt.Sprintf("https://r.example/%d", i), fmt.Sprintf("https://r.example/%d.jpg", i)
search.Results[name] = []recipes.Candidate{{URL: page}}
pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 25)
images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(i+1))
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
}
list := make([]string, len(names))
for i, name := range names {
list[i] = fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":30,"method":"stovetop","difficulty":"easy","search_query":%q}`, name, name)
}
js := `{"ideas":[` + strings.Join(list, ",") + `]}`
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
return model, normalize, search, pages, images
}
// startIdeasJob posts a request for ideas from rice and returns the job id.
func startIdeasJob(t *testing.T, h http.Handler) string {
t.Helper()
rec, body := callDinners(t, h, http.MethodPost, ideasPath, `{"ingredients": ["rice"], "quick_only": false}`)
if rec.Code != http.StatusAccepted {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
return body["jobId"].(string)
}
// assertFailedWithoutIdeas checks that GET /jobs/{id} reports job failed
// with code and a result showing no ideas.
func assertFailedWithoutIdeas(t *testing.T, job map[string]any, code string) {
t.Helper()
if job["status"] != "failed" || job["error"] != code {
t.Fatalf("GET job = %v, want failed with %s", job, code)
}
res, _ := job["result"].(map[string]any)
if res == nil {
t.Fatalf("failed job result = %v, want one showing no ideas", job["result"])
}
if list, ok := res["ideas"].([]any); !ok || len(list) != 0 {
t.Fatalf("failed job ideas = %v, want none", res["ideas"])
}
}
// storedIdeas is how many ideas job id's stored result holds.
func storedIdeas(t *testing.T, st *store.Store, id string) int {
t.Helper()
row, err := db.New(st.Reader).GetJob(t.Context(), id)
if err != nil {
t.Fatal(err)
}
var res ideas.Result
if err := json.Unmarshal([]byte(row.Result.String), &res); err != nil {
t.Fatal(err)
}
return len(res.Ideas)
}
func TestPostIdeasFromIngredientsRefusesBadRequests(t *testing.T) {
t.Parallel()
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
for _, tc := range []struct{ body, message string }{
{`{"quick_only": false}`, "ingredients: required"},
{`{"ingredients": ["rice"]}`, "quick_only: required"},
{`{"ingredients": [], "quick_only": false}`, "ingredients: list at least one ingredient"},
{`{"ingredients": ["rice", " "], "quick_only": false}`, "ingredients: ingredient 2 is blank"},
{`{"ingredients": ["Rice", "rice"], "quick_only": false}`, `ingredients: ingredient 2, "rice", repeats "Rice"`},
{`{"ingredients": ["rice"], "quick_only": "yes"}`, "quick_only: want a JSON bool"},
{`{"ingredients": ["rice"], "quick_only": false, "extra": 1}`, `invalid request body: json: unknown field "extra"`},
} {
rec, body := callDinners(t, h, http.MethodPost, ideasPath, tc.body)
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" {
t.Errorf("POST %s = %d %v, want 400 invalid_request", tc.body, rec.Code, body)
continue
}
if msg := body["error"].(map[string]any)["message"]; msg != tc.message {
t.Errorf("POST %s message = %q, want %q", tc.body, msg, tc.message)
}
}
}
func TestPostIdeasFromIngredientsRunsTheJob(t *testing.T) {
t.Parallel()
model, normalize := &llm.Fake{}, &llm.Fake{}
search := &recipestest.Search{Results: map[string][]recipes.Candidate{"chicken fried rice": {{URL: "https://r.example/fried-rice"}}}}
pages := &recipestest.Pages{Bodies: map[string][]byte{
"https://r.example/fried-rice": recipestest.RecipePage("Chicken Fried Rice", "R Kitchen", "https://r.example/fried-rice.jpg", 25),
}}
images := &recipestest.Images{JPEGs: map[string][]byte{"https://r.example/fried-rice.jpg": recipestest.JPEG(t, 400, 320, 1)}}
js := `{"ideas":[{"name":"Chicken Fried Rice","uses":["rice","chicken thighs","peas"],"missing":["2 eggs"],"est_minutes":25,"method":"stovetop","difficulty":"easy","search_query":"chicken fried rice"}]}`
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
h, runner, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
rec, body := callDinners(t, h, http.MethodPost, ideasPath, `{"ingredients": ["Chicken thighs", "rice", "scallions"], "quick_only": true}`)
if rec.Code != http.StatusAccepted {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
id, _ := body["jobId"].(string)
if id == "" || len(body) != 1 {
t.Fatalf("POST answered %v, want only a jobId", body)
}
deadline := time.Now().Add(10 * time.Second)
for {
job, err := runner.Get(t.Context(), id)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
break
}
if time.Now().After(deadline) {
t.Fatalf("job still %s", job.Status)
}
time.Sleep(5 * time.Millisecond)
}
rec, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
if rec.Code != http.StatusOK || job["status"] != "succeeded" || job["kind"] != "ideas_from_ingredients" {
t.Fatalf("GET job = %d %v", rec.Code, job)
}
res := job["result"].(map[string]any)
if res["stage"] != "done" || res["quick_only"] != true || res["dropped"] != 0.0 {
t.Fatalf("result = %v", res)
}
list := res["ideas"].([]any)
if len(list) != 1 {
t.Fatalf("ideas = %v, want 1", list)
}
got := list[0].(map[string]any)
for key, want := range map[string]any{
"name": "Chicken Fried Rice",
"summary": "Uses 2 of 3 · missing: 2 eggs",
"method": "stovetop",
"minutes_total": 25.0,
"quick": true,
"site_name": "R Kitchen",
"source_url": "https://r.example/fried-rice",
} {
if got[key] != want {
t.Errorf("idea %s = %v, want %v", key, got[key], want)
}
}
if uses, _ := json.Marshal(got["uses"]); string(uses) != `["Chicken thighs","rice"]` {
t.Errorf("idea uses = %s, want the household's two listed ingredients", uses)
}
if got["recipe_id"] == "" || got["photo_url"] == "" {
t.Errorf("idea = %v, want a recipe and photo", got)
}
}
func TestFailedIdeasJobShowsNoIdeas(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
// grounds are the ideas a recipe grounds; the others ground none.
grounds []string
ideas []string
fail map[string]error
code string
}{
{"no idea kept", nil, []string{"Mystery Stew"}, nil, "no_ideas_found"},
{"a later round fails after ideas were shown",
[]string{"Fried Rice", "Rice Bowls", "Rice Soup", "Rice Salad"},
[]string{"Fried Rice", "Mystery Stew", "Rice Bowls", "Rice Soup", "Rice Salad", "Rice Pilaf"},
map[string]error{"Rice Pilaf": errors.New("boom")}, "search_failed"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := scriptIdeas(t, tc.ideas, tc.grounds, tc.fail)
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
assertFailedWithoutIdeas(t, waitJob(t, h, startIdeasJob(t, h)), tc.code)
})
}
}
// A job left running with ideas shown is failed at the next start as
// interrupted, without its handler running again; it still shows no ideas.
func TestIdeasJobInterruptedByRestartShowsNoIdeas(t *testing.T) {
t.Parallel()
h, runner, st := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
id := startIdeasJob(t, h)
// The previous process claimed the job and showed an idea, then stopped.
q := db.New(st.Writer)
now := store.Now().String()
if _, err := q.ClaimNextJob(t.Context(), sql.NullString{String: now, Valid: true}); err != nil {
t.Fatal(err)
}
partial, err := ideas.Result{
Stage: ideas.StageGrounding,
Ingredients: []string{"rice"},
Ideas: []ideas.Idea{{Name: "Fried Rice", Uses: []string{"rice"}, Missing: []string{}, Method: "stovetop", Difficulty: "easy"}},
}.PublicJSON()
if err != nil {
t.Fatal(err)
}
if n, err := q.PublishJobResult(t.Context(), db.PublishJobResultParams{
Result: sql.NullString{String: string(partial), Valid: true}, UpdatedAt: now, ID: id,
}); err != nil || n != 1 {
t.Fatalf("publish partial result = %d, %v", n, err)
}
startRunner(t, runner)
_, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
assertFailedWithoutIdeas(t, job, jobs.CodeInterrupted)
if res := job["result"].(map[string]any); res["stage"] != "grounding" {
t.Errorf("result = %v, want the rest of the partial result kept", res)
}
if n := storedIdeas(t, st, id); n != 1 {
t.Fatalf("stored ideas = %d, want the interrupted job's 1 still stored", n)
}
}
// When the write clearing a failed job's ideas itself fails, the job fails
// with the runner's internal_error and its stored result keeps the ideas;
// it still shows none.
func TestIdeasJobWhoseClearingWriteFailsShowsNoIdeas(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := scriptIdeas(t,
[]string{"Fried Rice", "Mystery Stew", "Rice Bowls", "Rice Soup", "Rice Salad", "Rice Pilaf"},
[]string{"Fried Rice", "Rice Bowls", "Rice Soup", "Rice Salad"},
map[string]error{"Rice Pilaf": errors.New("boom")})
h, runner, st := newIdeasRouter(t, model, normalize, search, pages, images, false)
// Refuse any write that empties a running job's shown ideas.
if _, err := st.Writer.ExecContext(t.Context(), `
CREATE TRIGGER refuse_clearing_ideas BEFORE UPDATE OF result ON jobs
WHEN NEW.status = 'running'
AND json_array_length(OLD.result, '$.ideas') > 0
AND json_array_length(NEW.result, '$.ideas') = 0
BEGIN SELECT RAISE(ABORT, 'clearing refused'); END`); err != nil {
t.Fatal(err)
}
startRunner(t, runner)
id := startIdeasJob(t, h)
assertFailedWithoutIdeas(t, waitJob(t, h, id), jobs.CodeInternal)
if n := storedIdeas(t, st, id); n != 4 {
t.Fatalf("stored ideas = %d, want the 4 the refused write left", n)
}
}
func TestJobsEndpointRefusesIdeasKind(t *testing.T) {
t.Parallel()
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
rec, body := callDinners(t, h, http.MethodPost, "/api/v1/jobs", `{"kind": "ideas_from_ingredients", "payload": {"ingredients": [], "quick_only": false}}`)
if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" {
t.Fatalf("POST /jobs = %d %v, want 400 service_only_kind", rec.Code, body)
}
}
-197
View File
@@ -1,197 +0,0 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"reflect"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
)
// sourceCase is a recipe page an idea grounds in: its link and the site
// name its page gives (empty for none, so the host is stored instead).
type sourceCase struct {
dish, link, site string
// want is the idea's source_url and site_name; nil is null.
wantURL, wantSite any
}
const (
cleanSourceLink = "https://kitchen.example/recipes/chicken-curry"
queryToken = "query-token-abc123"
queryHost = "query-host-q1.example"
userinfoToken = "user-token-def456"
userinfoHost = "userinfo-host-u2.example"
)
// sourceCases are a clean link, credited by its page's name, and links
// carrying a token in the query and the userinfo whose pages name no site,
// so the stored name is the link's host.
var sourceCases = []sourceCase{
{"Chicken Curry", cleanSourceLink, "Curry House", cleanSourceLink, "Curry House"},
{"Beef Stew", "https://" + queryHost + "/stew?access_token=" + queryToken, "", nil, nil},
{"Bean Chili", "https://" + userinfoToken + "@" + userinfoHost + "/chili", "", nil, nil},
}
// sourceSecrets are what no idea job response may contain.
var sourceSecrets = []string{queryToken, "access_token", queryHost, userinfoToken, userinfoHost}
// sourceFakes makes the search for each case's dish find its page and
// scripts the normalize_recipe reply grounding it takes.
func sourceFakes(t *testing.T, minutes int) (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) {
t.Helper()
model, normalize, search, pages, images := newQuickFakes()
for i, c := range sourceCases {
photo := fmt.Sprintf("https://img.example/%d.jpg", i)
search.Results[c.dish] = []recipes.Candidate{{URL: c.link}}
pages.Bodies[c.link] = recipestest.RecipePage(c.dish, c.site, photo, minutes)
images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(i+1))
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
}
return model, normalize, search, pages, images
}
// checkIdeaSources checks the finished job id: each idea shows its case's
// safe source, and the response holds nothing of a token-bearing link.
func checkIdeaSources(t *testing.T, h http.Handler, id string) {
t.Helper()
job := waitJob(t, h, id)
if job["status"] != "succeeded" {
t.Fatalf("GET job = %v", job)
}
rec, _ := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
for _, secret := range sourceSecrets {
if strings.Contains(rec.Body.String(), secret) {
t.Errorf("job response contains %q: %s", secret, rec.Body)
}
}
list := job["result"].(map[string]any)["ideas"].([]any)
if len(list) != len(sourceCases) {
t.Fatalf("ideas = %v, want %d", list, len(sourceCases))
}
for i, c := range sourceCases {
idea := list[i].(map[string]any)
if idea["name"] != c.dish {
t.Fatalf("idea %d = %v, want %s", i, idea, c.dish)
}
for key, want := range map[string]any{"source_url": c.wantURL, "site_name": c.wantSite} {
got, ok := idea[key]
if !ok || !reflect.DeepEqual(got, want) {
t.Errorf("%s %s = %v (present %t), want %v", c.dish, key, got, ok, want)
}
}
if idea["recipe_id"] == "" || idea["photo_url"] == "" {
t.Errorf("idea %s = %v, want a recipe and photo", c.dish, idea)
}
}
}
// An ideas_from_ingredients job shows a clean link and its page's name,
// and neither a token-bearing link nor a name taken from its host.
func TestIdeasJobShowsOnlySafeSources(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := sourceFakes(t, 25)
list := make([]string, len(sourceCases))
for i, c := range sourceCases {
list[i] = fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":25,"method":"stovetop","difficulty":"easy","search_query":%q}`, c.dish, c.dish)
}
js := `{"ideas":[` + strings.Join(list, ",") + `]}`
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
checkIdeaSources(t, h, startIdeasJob(t, h))
}
// A quick_ideas job shows sources as an ideas_from_ingredients job does.
func TestQuickJobShowsOnlySafeSources(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := sourceFakes(t, 25)
ideas := make([][3]any, len(sourceCases))
for i, c := range sourceCases {
ideas[i] = [3]any{c.dish, "stovetop", 20}
}
quickReply(model, ideas...)
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": []}`)
if rec.Code != http.StatusAccepted {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
checkIdeaSources(t, h, body["jobId"].(string))
}
// legacyIdea is an idea as the result builders stored it before sources
// were made safe: the grounded recipe's raw link and stored site name,
// which is the link's host when its page named no site.
func legacyIdea(name, link, site string) map[string]any {
return map[string]any{
"name": name, "method": "stovetop", "minutes_total": 25, "difficulty": "easy",
"recipe_id": "01JLEGACY" + strings.ToUpper(strings.ReplaceAll(name, " ", "")), "photo_url": "/api/v1/photos/legacy",
"source_url": link, "site_name": site,
}
}
// A job of either idea kind whose result was stored before sources were
// made safe is served with the sources a job stored now holds: neither a
// token-bearing link nor a name taken from its host.
func TestJobServesLegacyIdeaResultsWithOnlySafeSources(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := newQuickFakes()
h, _, st := newIdeasRouter(t, model, normalize, search, pages, images, false)
legacy := []any{
legacyIdea("Chicken Curry", cleanSourceLink, "Curry House"),
legacyIdea("Beef Stew", "https://"+queryHost+"/stew?access_token="+queryToken, queryHost),
legacyIdea("Bean Chili", "https://"+userinfoToken+"@"+userinfoHost+"/chili", userinfoHost),
}
for kind, result := range map[string]map[string]any{
ideas.KindFromIngredients: {"stage": "done", "ingredients": []string{"rice"}, "quick_only": false, "ideas": legacy, "dropped": 0},
ideas.KindQuick: {"stage": "done", "kind": "under30", "exclude": []string{}, "ideas": legacy, "dropped": 0},
} {
js, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
id := "legacy-" + kind
if _, err := st.Writer.ExecContext(t.Context(),
`INSERT INTO jobs (id, kind, status, payload, result, created_at, updated_at) VALUES (?, ?, 'succeeded', '{}', ?, '2026-10-01T00:00:00Z', '2026-10-01T00:00:00Z')`,
id, kind, string(js)); err != nil {
t.Fatal(err)
}
checkIdeaSources(t, h, id)
}
}
// An idea job whose grounded recipe's stored link does not parse fails,
// even when no site name is stored, rather than show the idea without a
// source.
func TestIdeaJobFailsOnUnparseableSourceWithoutSiteName(t *testing.T) {
t.Parallel()
const badLink = "https://kitchen.example/%zz"
model, normalize, search, pages, images := sourceFakes(t, 25)
idea := fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":25,"method":"stovetop","difficulty":"easy","search_query":%q}`, sourceCases[0].dish, sourceCases[0].dish)
js := `{"ideas":[` + idea + `]}`
for range 2 {
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
}
h, _, st := newIdeasRouter(t, model, normalize, search, pages, images, true)
if job := waitJob(t, h, startIdeasJob(t, h)); job["status"] != "succeeded" {
t.Fatalf("grounding job = %v", job)
}
// The recipe is now stored under badLink with no site name, and the
// search finds it there.
res, err := st.Writer.ExecContext(t.Context(), `UPDATE recipes SET source_url = ?, site_name = NULL WHERE source_url = ?`, badLink, cleanSourceLink)
if err != nil {
t.Fatal(err)
}
if n, err := res.RowsAffected(); err != nil || n != 1 {
t.Fatalf("updated %d recipes (%v), want 1", n, err)
}
search.Results[sourceCases[0].dish] = []recipes.Candidate{{URL: badLink}}
job := waitJob(t, h, startIdeasJob(t, h))
assertFailedWithoutIdeas(t, job, jobs.CodeInternal)
}
+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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t),
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), 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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -107,7 +107,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc,
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+2 -2
View File
@@ -55,7 +55,7 @@ func newTestPushWith(t *testing.T) (*push.Service, *store.Store, vapid.KeyPair)
func newPushRouter(t *testing.T, svc *push.Service) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestIdeas(t), newTestHome(t), newTestRunner(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
@@ -103,7 +103,7 @@ func TestNewRouterRequiresPush(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil push service")
}
}
-181
View File
@@ -1,181 +0,0 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
)
const quickPath = "/api/v1/ideas/quick"
// quickRecipe makes the search for name find a page titled title with a
// total time of minutes, and scripts the normalize_recipe reply grounding
// it takes. "Slow Cooker" in title grounds the method as crockpot.
func quickRecipe(t *testing.T, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images, name, title string, minutes int) {
t.Helper()
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
search.Results[name] = []recipes.Candidate{{URL: page}}
pages.Bodies[page] = recipestest.RecipePage(title, "R Kitchen", photo, minutes)
images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(images.JPEGs)+1))
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
}
// quickReply scripts one quick_ideas reply holding the ideas named, each
// searched by its name.
func quickReply(model *llm.Fake, ideas ...[3]any) {
list := make([]string, len(ideas))
for i, idea := range ideas {
list[i] = fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"medium","search_query":%q}`, idea[0], idea[2], idea[1], idea[0])
}
js := `{"ideas":[` + strings.Join(list, ",") + `]}`
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
}
func newQuickFakes() (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) {
return &llm.Fake{}, &llm.Fake{}, &recipestest.Search{Results: map[string][]recipes.Candidate{}},
&recipestest.Pages{Bodies: map[string][]byte{}}, &recipestest.Images{JPEGs: map[string][]byte{}}
}
func TestPostIdeasQuickRefusesBadRequests(t *testing.T) {
t.Parallel()
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
for _, tc := range []struct{ body, message string }{
{`{"exclude": []}`, "kind: required"},
{`{"kind": "crockpot"}`, "exclude: required"},
{`{"kind": "", "exclude": []}`, `kind: kind "" is not one of crockpot, under30`},
{`{"kind": "quick", "exclude": []}`, `kind: kind "quick" is not one of crockpot, under30`},
{`{"kind": "Crockpot", "exclude": []}`, `kind: kind "Crockpot" is not one of crockpot, under30`},
{`{"kind": "under30", "exclude": ["Beef Stew", " "]}`, "exclude: excluded name 2 is blank"},
{`{"kind": "under30", "exclude": ["Beef Stew", "beef-stew"]}`, `exclude: excluded name 2, "beef-stew", repeats "Beef Stew"`},
{`{"kind": 30, "exclude": []}`, "kind: want a JSON string"},
{`{"kind": "under30", "exclude": [], "extra": 1}`, `invalid request body: json: unknown field "extra"`},
} {
rec, body := callDinners(t, h, http.MethodPost, quickPath, tc.body)
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" {
t.Errorf("POST %s = %d %v, want 400 invalid_request", tc.body, rec.Code, body)
continue
}
if msg := body["error"].(map[string]any)["message"]; msg != tc.message {
t.Errorf("POST %s message = %q, want %q", tc.body, msg, tc.message)
}
}
}
// The job answers 202 at once and its result fills idea by idea: one
// idea is shown while another still grounds. The kind is checked against
// the grounded recipe and excluded names never come back.
func TestPostIdeasQuickRunsTheJobWithProgressiveResults(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := newQuickFakes()
quickRecipe(t, normalize, search, pages, images, "Chicken Fried Rice", "Chicken Fried Rice", 25)
quickRecipe(t, normalize, search, pages, images, "Beef Stroganoff", "Beef Stroganoff", 45)
quickRecipe(t, normalize, search, pages, images, "Garlic Shrimp Pasta", "Garlic Shrimp Pasta", 30)
gate := make(chan struct{})
search.Gates = map[string]chan struct{}{"Garlic Shrimp Pasta": gate}
quickReply(model,
[3]any{"Easy Shrimp Tacos", "stovetop", 15},
[3]any{"Chicken Fried Rice", "stovetop", 20},
[3]any{"Beef Stroganoff", "stovetop", 25},
[3]any{"Garlic Shrimp Pasta", "stovetop", 20},
)
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": ["Shrimp Tacos"]}`)
if rec.Code != http.StatusAccepted {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
id, _ := body["jobId"].(string)
if id == "" || len(body) != 1 {
t.Fatalf("POST answered %v, want only a jobId", body)
}
// While Garlic Shrimp Pasta still grounds, Chicken Fried Rice shows.
deadline := time.Now().Add(10 * time.Second)
for {
_, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
if res, _ := job["result"].(map[string]any); res != nil {
if list, _ := res["ideas"].([]any); len(list) == 1 {
if job["status"] != "running" || res["stage"] != "grounding" || list[0].(map[string]any)["name"] != "Chicken Fried Rice" {
t.Fatalf("partial job = %v", job)
}
break
}
}
if time.Now().After(deadline) {
t.Fatal("no idea was shown while another still grounded")
}
time.Sleep(5 * time.Millisecond)
}
close(gate)
job := waitJob(t, h, id)
if job["status"] != "succeeded" || job["kind"] != "quick_ideas" {
t.Fatalf("GET job = %v", job)
}
res := job["result"].(map[string]any)
if res["stage"] != "done" || res["kind"] != "under30" || res["dropped"] != 2.0 {
t.Fatalf("result = %v, want done with the excluded and the 45-minute ideas dropped", res)
}
if ex, _ := json.Marshal(res["exclude"]); string(ex) != `["Shrimp Tacos"]` {
t.Errorf("result exclude = %s", ex)
}
var names []string
for _, i := range res["ideas"].([]any) {
names = append(names, i.(map[string]any)["name"].(string))
}
if want := []string{"Chicken Fried Rice", "Garlic Shrimp Pasta"}; !reflect.DeepEqual(names, want) {
t.Fatalf("ideas = %q, want %q", names, want)
}
got := res["ideas"].([]any)[1].(map[string]any)
for key, want := range map[string]any{
"method": "stovetop",
"minutes_total": 30.0,
"site_name": "R Kitchen",
"source_url": "https://r.example/garlic-shrimp-pasta",
} {
if got[key] != want {
t.Errorf("idea %s = %v, want %v", key, got[key], want)
}
}
if got["recipe_id"] == "" || got["photo_url"] == "" || got["difficulty"] == nil {
t.Errorf("idea = %v, want a recipe, photo and difficulty", got)
}
}
func TestFailedQuickJobShowsNoIdeas(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := newQuickFakes()
quickRecipe(t, normalize, search, pages, images, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
search.Fail = map[string]error{"Lentil Soup": errors.New("boom")}
ideas := [][3]any{{"Chicken Chili", "crockpot", 240}}
for i := range 5 {
ideas = append(ideas, [3]any{fmt.Sprintf("Mystery Stew %d", i+1), "crockpot", 300})
}
quickReply(model, append(ideas, [3]any{"Lentil Soup", "crockpot", 300})...)
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "crockpot", "exclude": []}`)
if rec.Code != http.StatusAccepted {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
assertFailedWithoutIdeas(t, waitJob(t, h, body["jobId"].(string)), "search_failed")
}
func TestJobsEndpointRefusesQuickKind(t *testing.T) {
t.Parallel()
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
rec, body := callDinners(t, h, http.MethodPost, "/api/v1/jobs", `{"kind": "quick_ideas", "payload": {"kind": "crockpot", "exclude": []}}`)
if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" {
t.Fatalf("POST /jobs = %d %v, want 400 service_only_kind", rec.Code, body)
}
}
+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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+3 -23
View File
@@ -16,9 +16,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
@@ -45,7 +43,7 @@ type RouteMounter interface {
// /api/v1/settings/recipe-search and its /test; ai serves
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
// chatSvc serves GET and PUT /api/v1/settings/chat-webhook and its /test;
// dinnerSvc serves GET and POST /api/v1/dinners, GET, PATCH and DELETE
// 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
@@ -64,16 +62,12 @@ type RouteMounter interface {
// /api/v1/integrations/google; pushSvc serves GET
// /api/v1/push/vapid-public-key, POST and DELETE
// /api/v1/push/subscriptions and POST
// /api/v1/settings/notifications/test-push; ideasSvc serves POST
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with
// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with
// planSvc too, /cook-tonight; homeSvc serves GET /api/v1/home, GET
// /api/v1/updates and POST /api/v1/updates/seen; runner serves
// /api/v1/settings/notifications/test-push; runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
// GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, ideasSvc *ideas.Service, homeSvc *home.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
@@ -104,12 +98,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
if pushSvc == nil {
return nil, errors.New("server: nil push service")
}
if ideasSvc == nil {
return nil, errors.New("server: nil ideas service")
}
if homeSvc == nil {
return nil, errors.New("server: nil home service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
@@ -142,9 +130,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
sub.NotFound(notFound)
}
apiV1.Get("/me", me(logger))
apiV1.Get("/home", getHome(logger, homeSvc))
apiV1.Get("/updates", getUpdates(logger, homeSvc))
apiV1.Post("/updates/seen", postUpdatesSeen(logger, homeSvc))
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings))
@@ -164,7 +149,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Post("/settings/chat-webhook/test", postChatWebhookTest(logger, chatSvc))
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc))
apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc))
apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc))
apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc))
@@ -197,10 +181,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Get("/push/vapid-public-key", getVAPIDPublicKey(logger, pushSvc))
apiV1.Post("/push/subscriptions", postPushSubscription(logger, pushSvc))
apiV1.Delete("/push/subscriptions", deletePushSubscription(logger, pushSvc))
apiV1.Post("/ideas/from-ingredients", postIdeasFromIngredients(logger, ideasSvc))
apiV1.Post("/ideas/quick", postIdeasQuick(logger, ideasSvc))
apiV1.Post("/ideas/{jobId}/items/{i}/quest-log", postIdeaQuestLog(logger, ideasSvc, dinnerSvc))
apiV1.Post("/ideas/{jobId}/items/{i}/cook-tonight", postIdeaCookTonight(logger, ideasSvc, dinnerSvc, planSvc))
apiV1.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
media.Get("/photos/{name}", getPhoto(logger, photos))
+11 -127
View File
@@ -22,12 +22,9 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
@@ -52,19 +49,13 @@ func newTestRouter(t *testing.T, version string) http.Handler {
}
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler {
t.Helper()
return newTestRouterWithHome(t, version, settings, runner, newTestHome(t))
}
// newTestRouterWithHome is newTestRouterWith serving Home from homeSvc.
func newTestRouterWithHome(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner, homeSvc *home.Service) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
assets := fstest.MapFS{
"index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
}
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), homeSvc, runner, newTestPhotos(t), assets)
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), assets)
if err != nil {
t.Fatal(err)
}
@@ -203,30 +194,6 @@ func newTestSuggest(t *testing.T) *suggest.Service {
return svc
}
// newTestIdeas returns an ideas service with its own unstarted job runner
// and generators that always fail.
func newTestIdeas(t *testing.T) *ideas.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.IdeasRequest) (skills.Ideas, error) {
return skills.Ideas{}, errors.New("no model in this test")
}
failQuick := func(context.Context, string, skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
return skills.QuickIdeaList{}, errors.New("no model in this test")
}
svc, err := ideas.NewService(st, g, fail, failQuick, runner, logger)
if err != nil {
t.Fatal(err)
}
return svc
}
// newTestPlan returns a plan service over a migrated temp database, with
// its own unstarted job runner and a drafter that always fails.
func newTestPlan(t *testing.T) *plan.Service {
@@ -265,71 +232,6 @@ func newTestShopping(t *testing.T) *shopping.Service {
return svc
}
// newTestHome returns a home service whose services all share one
// migrated temp database with zone America/New_York, with an unstarted job
// runner, no model, and Google and Google Chat endpoints that refuse every
// connection. One database keeps the routers that never ask for Home cheap.
func newTestHome(t *testing.T) *home.Service {
t.Helper()
return newTestHomeOn(t, newTestStore(t))
}
// newTestHomeOn returns a home service over st, a migrated database, which
// it seeds with zone America/New_York and the seed notification preferences.
func newTestHomeOn(t *testing.T, st *store.Store) *home.Service {
t.Helper()
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
notifier := notifytest.New(t, st)
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no model in this test")
}
pub := &url.URL{Scheme: "https", Host: "mealquest.example"}
googleSvc, err := gcal.NewService(st, newTestBox(t), gcal.Config{
ClientID: "client-id",
ClientSecret: "client-secret",
PublicURL: pub,
SecretKey: []byte("server-secret-key"),
Endpoint: oauth2.Endpoint{AuthURL: "http://127.0.0.1:1/auth", TokenURL: "http://127.0.0.1:1/token"},
RevokeURL: "http://127.0.0.1:1/revoke",
CalendarAPI: "http://127.0.0.1:1/calendar/v3",
})
if err != nil {
t.Fatal(err)
}
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, pub, logger)
if err != nil {
t.Fatal(err)
}
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
return skills.SideQuests{}, errors.New("no model in this test")
}
suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger)
if err != nil {
t.Fatal(err)
}
chatSvc, err := chat.NewService(st, newTestBox(t), pub, refuseTransport{})
if err != nil {
t.Fatal(err)
}
shop, err := shopping.NewService(st, untitled, runner, notifier, chatSvc, logger)
if err != nil {
t.Fatal(err)
}
svc, err := home.NewService(household.NewSettingsService(st), planSvc, suggestSvc, shop, notifier)
if err != nil {
t.Fatal(err)
}
return svc
}
// untitled is a shopping.Titler with no model: every Supply run it is
// asked to title stays untitled.
func untitled(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
@@ -390,7 +292,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil AI settings service")
}
}
@@ -399,7 +301,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil recipe search settings service")
}
}
@@ -408,7 +310,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted nil photo files")
}
}
@@ -417,7 +319,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
@@ -426,7 +328,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil dinners service")
}
}
@@ -435,7 +337,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil suggestions service")
}
}
@@ -444,7 +346,7 @@ func TestNewRouterRequiresPlan(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil plan service")
}
}
@@ -453,34 +355,16 @@ func TestNewRouterRequiresShopping(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil shopping service")
}
}
func TestNewRouterRequiresIdeas(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil ideas service")
}
}
func TestNewRouterRequiresHome(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil home service")
}
}
func TestNewRouterRequiresGoogle(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil Google Calendar service")
}
}
@@ -489,7 +373,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner")
}
}
+1 -1
View File
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -46,7 +46,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+2 -2
View File
@@ -37,9 +37,9 @@ func TestGenerateEmitsListReadyOnce(t *testing.T) {
}
for _, r := range rows {
p := r.Payload
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping?week=2026-10-12" || p.Week != "2026-10-12" ||
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping" || p.Week != "2026-10-12" ||
p.Title != "This week's Supply run is ready" || p.Body != "3 items to buy for 2 dinners" {
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping?week=2026-10-12", r.Channel, r)
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping", r.Channel, r)
}
}
-11
View File
@@ -346,17 +346,6 @@ func (s *Service) Deck(ctx context.Context) (Deck, error) {
return deck, nil
}
// PendingCount is how many Side quests are pending on the deck. Unlike
// Deck it only reads: it does not fail orphaned batches, so a reader such
// as Home never writes.
func (s *Service) PendingCount(ctx context.Context) (int, error) {
rows, err := db.New(s.st.Reader).ListDeck(ctx)
if err != nil {
return 0, fmt.Errorf("suggest: list deck: %w", err)
}
return len(rows), nil
}
// Decision is a suggestion once decided, with the id of the replenish
// batch's job when the decision called for one: the batch it started, or
// the one already pending or running that it joined.
-25
View File
@@ -66,28 +66,3 @@ SELECT * FROM notification_prefs;
UPDATE notification_prefs
SET push = ?, chat = ?, updated_at = ?
WHERE event = ?;
-- name: HasNotificationSince :one
-- Reports whether a notification was stored after since, or at all when
-- since is NULL.
SELECT EXISTS (
SELECT 1 FROM notification_outbox
WHERE CAST(sqlc.narg(since) AS TEXT) IS NULL OR created_at > CAST(sqlc.narg(since) AS TEXT)
) AS found;
-- name: ListRecentNotifications :many
-- Returns the last lim distinct events stored, newest first: one row per
-- event key, the last stored for it whatever its channel, so an event
-- stored for push and Google Chat is one row. The last, because a channel
-- switched on later can store the same event key again, and
-- HasNotificationSince counts that row: an event is as new as its newest
-- row, so marking it seen also covers that row.
SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o
WHERE o.id = (
SELECT i.id FROM notification_outbox i
WHERE i.event_key = o.event_key
ORDER BY i.created_at DESC, i.id DESC
LIMIT 1
)
ORDER BY o.created_at DESC, o.event_key DESC
LIMIT sqlc.arg(lim);
-8
View File
@@ -154,11 +154,3 @@ RETURNING *;
UPDATE chat_settings
SET last_test_at = ?, last_test_status = ?, updated_at = ?
WHERE id = 1 AND webhook_url_ct = ?;
-- name: MarkUpdatesSeen :execrows
-- Moves updates_seen_at forward to seen_at; affects no row when it is
-- already at or after seen_at, so it never moves back. It is not a settings
-- edit: updated_by and updated_at are left as they are.
UPDATE household_settings
SET updates_seen_at = CAST(sqlc.arg(seen_at) AS TEXT)
WHERE id = 1 AND (updates_seen_at IS NULL OR updates_seen_at < CAST(sqlc.arg(seen_at) AS TEXT));
-11
View File
@@ -121,14 +121,3 @@ WHERE id = sqlc.arg(id) AND status = 'pending';
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';
-- name: GetDinnerSuggestionReasons :many
-- The reason of each suggestion of dinner_id that can have created it,
-- none for a dinner no suggestion created. A suggestion is only ever
-- created with a new dinner, and declining one archives its dinner, so a
-- live dinner's creating suggestion is pending (the dinner still
-- suggested) or accepted; a declined one did not create it. More than one
-- row means nothing records which did.
SELECT reason FROM suggestions
WHERE dinner_id = ? AND status IN ('pending', 'accepted')
ORDER BY id;
+26 -49
View File
@@ -5,7 +5,7 @@ import { createNetworkStatus } from './pwa/networkStatus.ts'
import { mockViewport } from './test/matchMedia.ts'
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
import App, { routes } from './App.tsx'
import { AI_SETTINGS, HOME, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
import { InDataRouter } from './test/dataRouter.tsx'
import { dinnerBody } from './test/dinners.ts'
import { defaultWeek } from './plan/week.ts'
@@ -30,15 +30,11 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
// The routes that render a built screen rather than a placeholder page.
const SCREENS = {
'/': true,
'/onboarding': true,
'/plan': true,
'/plan/shopping': true,
'/plan/shopping/archive': true,
'/discover': true,
'/quick': true,
'/cook-from-pantry': true,
'/dinner/:id': true,
'/settings': true,
'/settings/ai': true,
'/settings/household': true,
@@ -86,16 +82,6 @@ describe('App routes', () => {
},
)
it('/ renders the Home screen inside the shell', async () => {
mockViewport(390)
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME), '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }) })
renderAt('/')
const heading = await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
})
it('/settings renders the settings home inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
@@ -220,19 +206,19 @@ describe('App first-run redirect', () => {
it('opens the routes once onboarding is finished', async () => {
mockViewport(390)
stubFetchByPath(SIGNED_IN)
renderAt('/settings/notifications')
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
renderAt('/quick')
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
})
it('reports a household settings failure with a retry instead of rendering the routes', async () => {
mockViewport(390)
stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS))
renderAt('/settings/notifications')
renderAt('/quick')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
screen.getByRole('button', { name: 'Retry' }).click()
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
})
})
@@ -240,12 +226,12 @@ describe('App bootstrap', () => {
it('shows the skeleton until /me loads, then the routes', async () => {
mockViewport(390)
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
renderAt('/settings/notifications')
renderAt('/quick')
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
})
@@ -273,19 +259,12 @@ describe('App bootstrap', () => {
it('reports a /me failure with a retry instead of rendering the routes', async () => {
mockViewport(390)
const mes = [errorResponse(500, 'internal'), jsonResponse(200, ME)]
stubFetchByPath({
...SIGNED_IN,
'/api/v1/me': () => mes.shift()!,
'/api/v1/home': () => jsonResponse(200, HOME),
'/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }),
})
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
renderAt('/')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
screen.getByRole('button', { name: 'Retry' }).click()
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
})
})
@@ -300,9 +279,9 @@ describe('App offline bootstrap', () => {
// visitOnline is an earlier online visit on this device: /me answers with a
// member, then the app is closed.
async function visitOnline() {
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME), '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }) })
stubFetchByPath(SIGNED_IN)
const { unmount } = renderAt('/')
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
unmount()
}
@@ -313,17 +292,15 @@ describe('App offline bootstrap', () => {
})
// A test that emits an offline event leaves TanStack Query's online
// manager, shared by every test, offline until an online event. The
// manager listens for it only while a query client is mounted, so the
// event goes out before cleanup unmounts the app.
// manager, shared by every test, offline until an online event.
afterEach(() => {
window.dispatchEvent(new Event('online'))
cleanup()
window.dispatchEvent(new Event('online'))
})
it.each([
['/', "Tonight's quest"],
['/settings/notifications', 'Notifications'],
['/quick', 'Quick dinners'],
['/dinner/01J9ZK', 'Dinner'],
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
await visitOnline()
goOffline()
@@ -346,7 +323,7 @@ describe('App offline bootstrap', () => {
() => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))),
).mockRejectedValue(new TypeError('Failed to fetch')),
)
renderAt('/settings/notifications')
renderAt('/quick')
await vi.waitFor(() => expect(failMe).toBeDefined())
onLine = false
act(() => {
@@ -354,7 +331,7 @@ describe('App offline bootstrap', () => {
})
act(() => failMe())
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
@@ -363,15 +340,15 @@ describe('App offline bootstrap', () => {
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
await visitOnline()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
renderAt('/settings/notifications')
renderAt('/quick')
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
})
it('reports the failure offline when this device never verified a session', async () => {
goOffline()
renderAt('/settings/notifications')
renderAt('/quick')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
@@ -380,12 +357,12 @@ describe('App offline bootstrap', () => {
it('reports the failure offline after the session was refused', async () => {
await visitOnline()
stubFetch(errorResponse(401, 'unauthenticated'))
const { location, unmount } = renderAt('/settings/notifications')
const { location, unmount } = renderAt('/quick')
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
unmount()
goOffline()
renderAt('/settings/notifications')
renderAt('/quick')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
})
@@ -393,13 +370,13 @@ describe('App offline bootstrap', () => {
it('loads /me again on reconnect and refuses writes until it has', async () => {
await visitOnline()
goOffline()
const api = createApiClient(fakeLocation('/settings/notifications'), createNetworkStatus(window), createVerifiedSession(localStorage))
const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<InDataRouter path="/settings/notifications">
<InDataRouter path="/quick">
<App api={api} serviceWorker={undefined} />
</InDataRouter>,
)
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
const fetchMock = stubFetchByPath(SIGNED_IN)
@@ -414,7 +391,7 @@ describe('App offline bootstrap', () => {
]),
)
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
expect(screen.getByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
})
it('/settings/calendar renders the Google Calendar screen inside the shell', async () => {
-8
View File
@@ -3,11 +3,7 @@ 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 { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx'
import { DiscoverPage } from './discover/DiscoverPage.tsx'
import { HomePage } from './home/HomePage.tsx'
import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx'
import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx'
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
@@ -46,14 +42,10 @@ export const routes = [
// The screens built so far; every other route shows its placeholder page.
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/': <HomePage />,
'/plan': <PlanPage />,
'/plan/shopping': <SupplyRunPage />,
'/plan/shopping/archive': <SupplyArchivePage />,
'/discover': <DiscoverPage />,
'/quick': <QuickDinnersPage />,
'/cook-from-pantry': <CookFromPantryPage />,
'/dinner/:id': <DinnerDetailPage />,
'/settings': <SettingsPage />,
'/settings/notifications': <NotificationsPage />,
'/settings/ai': <AISettingsPage />,
@@ -1,7 +0,0 @@
/* A week with no plan: why there's no day, over the link to draft it. */
.noPlan {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 14px;
}
-173
View File
@@ -1,173 +0,0 @@
import { useId, useState } from 'react'
import { Link } from 'react-router'
import { usePlan, useWeekSwap, type PlanDinner, type Swap } from '../plan/api.ts'
import { cooking } from '../plan/PlanPage.tsx'
import swapStyles from '../plan/SwapSheet.module.css'
import { addDays, dayOfMonth, localNow, weekdayName, weekLabel, weekStart as weekOf } from '../plan/week.ts'
import { useHouseholdSettings } from '../settings/api.ts'
import buttonStyles from '../ui/Button.module.css'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { ListRow } from '../ui/ListRow.tsx'
import { Photo } from '../ui/Photo.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import type { DinnerDetail } from './detail.ts'
import styles from './AddToWeekSheet.module.css'
// PLAN is the Draft board, where a week with no plan is drafted.
export const PLAN = '/plan'
// Which is the week a dinner is added to: the household's current week or
// the one after.
export type Which = 'this' | 'next'
export const weekNames: Record<Which, string> = { this: 'this week', next: 'next week' }
// Added is a swap sent from the sheet: the week it went to and the swap.
export type Added = { week: string; which: Which; swap: Swap }
// planDinner is the dinner as it is planned on a day.
function planDinner(dinner: DinnerDetail): PlanDinner {
return { id: dinner.id, name: dinner.name, photo_id: dinner.photo_id, minutes: dinner.minutes_total, method: dinner.method }
}
type WeekDaysProps = {
dinner: DinnerDetail
week: string
which: Which
onAdd: (added: Added) => void
}
// WeekDays is the seven days of the week starting on week, each with its
// dinner; choosing one swaps the dinner in as the Draft board swaps a day
// (useWeekSwap). A week with no plan offers to draft it on the Draft board:
// nothing here makes a plan up. A dinner already on the week can't be added
// again, nor can one while a swap of the week is still being saved or the
// week is being pushed to Calendar.
function WeekDays({ dinner, week, which, onAdd }: WeekDaysProps) {
const plan = usePlan(week)
const { swap, push, refused, swapIn } = useWeekSwap(week)
if (plan.isError) {
return <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load {weekNames[which]}'s plan. {plan.error.message}</ErrorBanner>
}
if (plan.isPending) {
return (
<div aria-busy="true" aria-label="Plan loading" className={swapStyles.rows}>
{Array.from({ length: 7 }, (_, i) => (
<Skeleton key={i} className={swapStyles.skeletonRow} />
))}
</div>
)
}
if (plan.data === null) {
return (
<div className={styles.noPlan}>
<p className={swapStyles.empty}>There's no plan for {weekNames[which]} yet, so there's no day to add it to.</p>
<Link to={PLAN} className={[buttonStyles.button, buttonStyles.primary].join(' ')}>
Draft {weekNames[which]} first
</Link>
</div>
)
}
const days = plan.data.days
const planned = days.find((day) => day.dinner.id === dinner.id)
const choosable = planned === undefined && !refused
function add(date: string) {
const chosen: Swap = { date, dinner: planDinner(dinner), fromQuestLog: dinner.status === 'quest_log' }
swapIn(chosen)
onAdd({ week, which, swap: chosen })
}
return (
<>
{planned !== undefined ? (
<p className={swapStyles.empty}>
{dinner.name} is already on {weekdayName(planned.date)} {weekNames[which]}.
</p>
) : swap.pending ? (
<p className={swapStyles.empty}>A change to {weekNames[which]} is still being saved.</p>
) : (
push.pending && <p className={swapStyles.empty}>The plan for {weekNames[which]} is being pushed to Calendar.</p>
)}
<ul aria-label={`Days of ${weekNames[which]}`} className={swapStyles.rows}>
{days.map((day) => (
<li key={day.date}>
<ListRow
className={choosable ? undefined : swapStyles.already}
leading={
// The row's dinner names what the photo shows.
<span aria-hidden="true">
<Photo id={day.dinner.photo_id} name={day.dinner.name} radius="var(--radius-md)" sizes="48px" className={swapStyles.photo} />
</span>
}
title={`${weekdayName(day.date)} ${dayOfMonth(day.date)}`}
meta={[day.dinner.name, cooking(day.dinner)].filter(Boolean).join(' · ')}
onClick={choosable ? () => add(day.date) : undefined}
trailing={day === planned && <Tag>This dinner</Tag>}
/>
</li>
))}
</ul>
</>
)
}
export type AddToWeekSheetProps = {
dinner: DinnerDetail
onAdd: (added: Added) => void
onClose: () => void
}
// AddToWeekSheet is the sheet Add to this week opens: the seven days of the
// household's current week, Monday to Sunday in its timezone, each with its
// dinner, and a toggle to next week. Choosing a day replaces its dinner.
export function AddToWeekSheet({ dinner, onAdd, onClose }: AddToWeekSheetProps) {
const titleId = useId()
const settings = useHouseholdSettings()
const [which, setWhich] = useState<Which>('this')
let body
if (settings.isError) {
body = (
<ErrorBanner onRetry={() => void settings.refetch()}>
Couldn't load the household settings. {settings.error.message}
</ErrorBanner>
)
} else if (settings.isPending) {
body = <Skeleton className={swapStyles.skeletonRow} />
} else {
const thisWeek = weekOf(localNow(settings.data.timezone, new Date()).date)
const week = which === 'this' ? thisWeek : addDays(thisWeek, 7)
body = (
<>
<p className={swapStyles.replaces}>
{weekLabel(week).dates} · replaces the day's dinner
</p>
<Segmented
label="Week"
options={[
{ value: 'this', label: 'This week' },
{ value: 'next', label: 'Next week' },
]}
value={which}
onValueChange={setWhich}
/>
<WeekDays key={week} dinner={dinner} week={week} which={which} onAdd={onAdd} />
</>
)
}
return (
<Sheet open onClose={onClose} aria-labelledby={titleId}>
<div className={swapStyles.sheet}>
<h2 id={titleId} className={swapStyles.title}>
Add {dinner.name}
</h2>
{body}
</div>
</Sheet>
)
}
@@ -1,406 +0,0 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { PlanPage } from '../plan/PlanPage.tsx'
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
const DINNER = '/api/v1/dinners/d1'
const FAVORITE = `${DINNER}/favorite`
const THIS_WEEK = '/api/v1/plans/2026-10-05'
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
const THURSDAY = `${THIS_WEEK}/days/2026-10-08`
// detail is the Korean beef bowls, a Quest-log Side quest, as GET
// /api/v1/dinners/{id} sends it.
function detail(overrides: Record<string, unknown> = {}) {
return {
id: 'd1',
name: 'Korean beef bowls',
status: 'quest_log',
origin: 'suggested',
method: 'stovetop',
minutes_total: 30,
difficulty: 'easy',
cuisine: 'korean',
is_favorite: false,
photo_urls: null,
ingredients: [{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }],
steps: ['Brown the beef with the sauce.'],
source: null,
grounding_status: 'grounded',
grounding_error: null,
why_picked: null,
...overrides,
}
}
// listed is the bowls as the dinners list, and so the favorite toggle,
// answers: no recipe.
function listed(is_favorite: boolean) {
return {
id: 'd1',
name: 'Korean beef bowls',
method: 'stovetop',
minutes_total: 30,
difficulty: 'easy',
is_favorite,
photo_url: null,
grounding_status: 'grounded',
}
}
const THIS_NAMES = ['Beef tacos', 'Spaghetti bolognese', 'Lemon orzo', 'Chicken stir-fry', 'Pizza night', 'Fish tacos', 'Sheet-pan salmon']
const NEXT_NAMES = ['Chili', 'Pad thai', 'Burgers', 'Ramen', 'Pizza night', 'Pot roast', 'Fried rice']
// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it, with
// dinner ids p0 to p6 unless ids says otherwise.
function planBody(weekStart: string, names: string[], ids = names.map((_, i) => `p${i}`)) {
const start = new Date(`${weekStart}T00:00:00Z`).getTime()
return {
id: `plan-${weekStart}`,
week_start: weekStart,
status: 'draft',
ai_note: null,
calendar_synced_at: null as string | null,
days: names.map((name, i) => ({
date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
dinner: { id: ids[i], name, photo: null, minutes: 30, method: 'stovetop' },
is_new: false,
start_by: null,
})),
}
}
// THURSDAY_SWAPPED is this week's plan as the swap of Thursday answers it.
function thursdaySwapped() {
const ids = THIS_NAMES.map((_, i) => (i === 3 ? 'd1' : `p${i}`))
const names = THIS_NAMES.map((name, i) => (i === 3 ? 'Korean beef bowls' : name))
const plan = planBody('2026-10-05', names, ids)
plan.days[3]!.is_new = true
return plan
}
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
// stubServer answers "METHOD path" requests from responders, with /me, the
// household settings and the dinner; any other request fails the test.
function stubServer(responders: Record<string, Responder> = {}) {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
[`GET ${DINNER}`]: () => jsonResponse(200, detail()),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// sent lists the requests sent with method, as "path body".
function sent(fetchMock: Mock<typeof fetch>, method: string) {
return fetchMock.mock.calls
.filter(([, init]) => init?.method === method)
.map(([input, init]) => `${String(input)} ${String(init?.body)}`)
}
function reads(fetchMock: Mock<typeof fetch>, path: string) {
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === path).length
}
// deferred is a response the test sends when it chooses.
function deferred() {
let send!: (response: Response) => void
const response = new Promise<Response>((resolve) => {
send = resolve
})
return { response, send }
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the bowls' detail screen, with plan for the Plan: a probe unless told
// otherwise. It answers with the router.
async function renderPage(plan = <h1>Plan probe</h1>) {
const api = createApiClient(fakeLocation('/dinner/d1'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const router = createMemoryRouter(
[
{ path: '/dinner/:id', element: <DinnerDetailPage /> },
{ path: '/plan', element: plan },
],
{ initialEntries: ['/dinner/d1'] },
)
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<RouterProvider router={router} />
</QueryClientProvider>
</ApiProvider>,
)
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
return router
}
// openSheet opens Add to this week and waits for the week's days.
async function openSheet(listName = 'Days of this week') {
fireEvent.click(screen.getByRole('button', { name: 'Add to this week' }))
const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' })
const days = await within(sheet).findByRole('list', { name: listName })
return { sheet, days }
}
// dayRows is each day's row text, as "Monday 5 · Beef tacos · Stovetop · 30 min",
// with plain spaces.
function dayRows(days: HTMLElement) {
return within(days)
.getAllByRole('listitem')
.map((row) => [...row.querySelectorAll('span')].filter((span) => span.children.length === 0 && !span.closest('[aria-hidden="true"]')).map((span) => span.textContent).join(' · ').replace(/\s/g, ' '))
}
beforeEach(() => {
mockViewport(390)
// Wednesday Oct 7, noon in New York.
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
Reflect.deleteProperty(window, 'matchMedia')
vi.unstubAllGlobals()
})
describe('Add to this week', () => {
it('lists the seven days of this week with their dinners, and next week by the toggle', async () => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
[`GET ${NEXT_WEEK}`]: () => jsonResponse(200, planBody('2026-10-12', NEXT_NAMES)),
})
await renderPage()
// Nothing about the week is read until the sheet opens.
expect(reads(fetchMock, THIS_WEEK)).toBe(0)
const { sheet, days } = await openSheet()
expect(within(sheet).getByText("Oct 5 – 11 · replaces the day's dinner")).toBeInTheDocument()
expect(dayRows(days)).toEqual([
'Monday 5 · Beef tacos · Stovetop · 30 min',
'Tuesday 6 · Spaghetti bolognese · Stovetop · 30 min',
'Wednesday 7 · Lemon orzo · Stovetop · 30 min',
'Thursday 8 · Chicken stir-fry · Stovetop · 30 min',
'Friday 9 · Pizza night · Stovetop · 30 min',
'Saturday 10 · Fish tacos · Stovetop · 30 min',
'Sunday 11 · Sheet-pan salmon · Stovetop · 30 min',
])
expect(within(days).getAllByRole('button')).toHaveLength(7)
fireEvent.click(within(sheet).getByRole('radio', { name: 'Next week' }))
const next = await within(sheet).findByRole('list', { name: 'Days of next week' })
expect(within(sheet).getByText("Oct 12 – 18 · replaces the day's dinner")).toBeInTheDocument()
expect(dayRows(next)[0]).toBe('Monday 12 · Chili · Stovetop · 30 min')
expect(dayRows(next)[6]).toBe('Sunday 18 · Fried rice · Stovetop · 30 min')
fireEvent.click(within(sheet).getByRole('radio', { name: 'This week' }))
expect(await within(sheet).findByRole('list', { name: 'Days of this week' })).toBeInTheDocument()
})
it("takes this week in the household's timezone, not the device's UTC date", async () => {
// Sunday Oct 11, 10 pm in New York, already Monday Oct 12 in UTC.
vi.setSystemTime(new Date('2026-10-12T02:00:00Z'))
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)) })
await renderPage()
const { days } = await openSheet()
expect(dayRows(days)[6]).toBe('Sunday 11 · Sheet-pan salmon · Stovetop · 30 min')
expect(reads(fetchMock, THIS_WEEK)).toBe(1)
expect(reads(fetchMock, NEXT_WEEK)).toBe(0)
})
it('swaps the day chosen through the plan API, confirms it and reads the plan again', async () => {
const swap = deferred()
let plan = planBody('2026-10-05', THIS_NAMES)
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
[`PUT ${THURSDAY}`]: () => swap.response,
})
await renderPage()
const { days } = await openSheet()
fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await waitFor(() => expect(sent(fetchMock, 'PUT')).toHaveLength(1))
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`])
expect(screen.getByRole('status')).toHaveTextContent('Adding to Thursday…')
plan = thursdaySwapped()
await act(async () => swap.send(jsonResponse(200, plan)))
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
// The plan query is invalidated: the week is read again from the server.
await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(2))
// The sheet opened again shows the plan as swapped.
const again = await openSheet()
expect(dayRows(again.days)[3]).toBe('Thursday 8 · Korean beef bowls · Stovetop · 30 min · This dinner')
expect(within(again.sheet).getByText('Korean beef bowls is already on Thursday this week.')).toBeInTheDocument()
expect(within(again.days).queryAllByRole('button')).toHaveLength(0)
})
it('offers to draft a week with no plan on the Plan, and swaps nothing', async () => {
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found') })
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Add to this week' }))
const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' })
const draft = await within(sheet).findByRole('link', { name: 'Draft this week first' })
expect(draft).toHaveAttribute('href', '/plan')
expect(within(sheet).getByText("There's no plan for this week yet, so there's no day to add it to.")).toBeInTheDocument()
expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
fireEvent.click(draft)
expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument()
expect(sent(fetchMock, 'PUT')).toEqual([])
})
it('shows a refused swap with the error, puts the day back and retries it', async () => {
let refuse = true
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
[`PUT ${THURSDAY}`]: () => (refuse ? errorResponse(500, 'internal') : jsonResponse(200, thursdaySwapped())),
})
await renderPage()
const { days } = await openSheet()
fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't add it to Thursday this week. internal message")
const reopened = await openSheet()
expect(dayRows(reopened.days)[3]).toBe('Thursday 8 · Chicken stir-fry · Stovetop · 30 min')
fireEvent.keyDown(reopened.sheet, { key: 'Escape' })
refuse = false
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`, `${THURSDAY} {"dinner_id":"d1"}`])
})
it("leaves a push's Supply run outcome behind on the Plan, as a swap on the Draft board does", async () => {
let plan = planBody('2026-10-05', THIS_NAMES)
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
[`POST ${THIS_WEEK}/push-calendar`]: () => {
plan = { ...plan, status: 'synced', calendar_synced_at: '2026-10-07T16:00:00Z' }
return jsonResponse(200, { ...plan, supply_run: { status: 'generated', error: null } })
},
[`PUT ${THURSDAY}`]: () => {
plan = { ...thursdaySwapped(), status: 'changed', calendar_synced_at: '2026-10-07T16:00:00Z' }
return jsonResponse(200, plan)
},
'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'),
})
const router = await renderPage(<PlanPage />)
// The week is pushed on the Plan, which says its Supply run was made.
await act(() => router.navigate('/plan'))
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
// Coming back from the dinner with nothing swapped, it still says so.
await act(() => router.navigate('/dinner/d1'))
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
await act(() => router.navigate('/plan'))
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
// Thursday is swapped from the dinner's Add to this week.
await act(() => router.navigate('/dinner/d1'))
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
const { days } = await openSheet()
fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
// The Plan shows the swapped plan, not changed since its push, and no
// longer says the earlier plan's Supply run was made.
await act(() => router.navigate('/plan'))
expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
expect(screen.getByText('Korean beef bowls')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.queryByText("Made this week's Supply run from the plan.")).not.toBeInTheDocument()
expect(sent(fetchMock, 'POST')).toEqual([`${THIS_WEEK}/push-calendar undefined`])
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`])
})
it('refuses a swap while the week is being pushed to Calendar', async () => {
const push = deferred()
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
[`POST ${THIS_WEEK}/push-calendar`]: () => push.response,
'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'),
})
const router = await renderPage(<PlanPage />)
await act(() => router.navigate('/plan'))
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1))
await act(() => router.navigate('/dinner/d1'))
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
const { sheet, days } = await openSheet()
expect(within(sheet).getByText('The plan for this week is being pushed to Calendar.')).toBeInTheDocument()
expect(within(days).queryAllByRole('button')).toHaveLength(0)
expect(sent(fetchMock, 'PUT')).toEqual([])
})
})
describe('the favorite heart', () => {
it('fills at once, keeps the recipe and takes the server answer', async () => {
const favorite = deferred()
const fetchMock = stubServer({ [`POST ${FAVORITE}`]: () => favorite.response })
await renderPage()
const heart = screen.getByRole('button', { name: 'Favorite' })
expect(heart).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(heart)
await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'true'))
expect(sent(fetchMock, 'POST')).toEqual([`${FAVORITE} undefined`])
await act(async () => favorite.send(jsonResponse(200, listed(true))))
await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1))
expect(heart).toHaveAttribute('aria-pressed', 'true')
// The list-shaped answer did not replace the detail: the recipe stays.
expect(screen.getByText('Ground beef')).toBeInTheDocument()
expect(screen.getByText('Brown the beef with the sauce.')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('empties again with the error when the server refuses, and retries', async () => {
const refused = deferred()
let refuse = true
const fetchMock = stubServer({
[`GET ${DINNER}`]: () => jsonResponse(200, detail({ is_favorite: true })),
[`POST ${FAVORITE}`]: () => (refuse ? refused.response : jsonResponse(200, listed(false))),
})
await renderPage()
const heart = screen.getByRole('button', { name: 'Favorite' })
expect(heart).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(heart)
await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false'))
await act(async () => refused.send(errorResponse(500, 'internal')))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't unfavorite Korean beef bowls. internal message")
expect(heart).toHaveAttribute('aria-pressed', 'true')
refuse = false
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false'))
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
expect(sent(fetchMock, 'POST')).toHaveLength(2)
})
})
@@ -1,184 +0,0 @@
// A dinner's detail offline once visited: the page reads through the service
// worker's real API strategy (src/test/serviceWorker.ts), so what it shows
// offline is what the worker saved while it was online. jsdom has no service
// worker, so the strategy runs directly against an in-memory cache.
import { render, screen, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { OfflinePill } from '../pwa/OfflinePill.tsx'
import { AppShell } from '../shell/AppShell.tsx'
import { cacheNames, routeFor } from '../sw/routes.ts'
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { errorResponse, jsonResponse } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { origin, stubFetchThroughWorker, stubWorkerCaches, type FakeCache } from '../test/serviceWorker.ts'
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
// grounded is a dinner as GET /api/v1/dinners/{id} sends it, its recipe found.
function grounded(id: string, name: string) {
return {
id,
name,
status: 'quest_log',
origin: 'suggested',
method: 'stovetop',
minutes_total: 30,
difficulty: 'easy',
cuisine: 'korean',
is_favorite: false,
photo_urls: { '400': `/media/photos/${id}-400.jpg`, '1200': `/media/photos/${id}-1200.jpg` },
ingredients: [
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
],
steps: ['Cook the rice.', 'Brown the beef with the sauce.'],
source: { site_name: 'Serious Eats' },
grounding_status: 'grounded',
grounding_error: null,
why_picked: null,
}
}
// server is what the server sends while it can be reached. Kitchen reading
// mode also reads the household settings, which the worker never saves, and
// the week's plans to find the dinner's day, of which there are none here.
const server: Record<string, unknown> = {
'/api/v1/settings/household': HOUSEHOLD_SETTINGS,
'/api/v1/dinners/d1': grounded('d1', 'Korean beef bowls'),
'/api/v1/dinners/d2': grounded('d2', 'Chicken tikka masala'),
'/api/v1/dinners/d3': {
...grounded('d3', 'Pork carnitas'),
photo_urls: null,
ingredients: [],
steps: [],
source: null,
grounding_status: 'pending',
},
}
let cache: FakeCache
let reachable: boolean
// networkReads is every path the network was asked for, reached or not.
let networkReads: string[]
beforeEach(() => {
cache = stubWorkerCaches()
reachable = true
networkReads = []
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable)
stubFetchThroughWorker(async (request) => {
const path = new URL(request.url).pathname
networkReads.push(path)
if (!reachable) throw new TypeError('Failed to fetch')
if (path.startsWith('/api/v1/plans/')) return errorResponse(404, 'not_found')
if (!Object.hasOwn(server, path)) throw new Error(`unexpected network fetch: ${request.url}`)
return jsonResponse(200, server[path])
})
})
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
// open loads /dinner/{id} as a fresh page load would: a new API client,
// network status and query cache, with the offline indicator App shows.
function open(id: string) {
const router = createMemoryRouter(
[{ element: <AppShell />, children: [{ path: '/dinner/:id', element: <DinnerDetailPage /> }] }],
{ initialEntries: [`/dinner/${id}`] },
)
return render(
<ApiProviders>
<RouterProvider router={router} />
<OfflinePill />
</ApiProviders>,
)
}
// visit opens a dinner while online and leaves once its title shows.
async function visit(id: string, name: string) {
const page = open(id)
expect(await screen.findByRole('heading', { level: 1, name })).toBeInTheDocument()
expect(screen.queryByText('Offline — showing saved data')).not.toBeInTheDocument()
page.unmount()
}
describe('dinner detail offline', () => {
it('saves a visited dinner in the API cache the worker reads offline', async () => {
mockViewport(390)
expect(routeFor({ method: 'GET', url: new URL('/api/v1/dinners/d1', origin), mode: 'cors' }, origin)).toEqual({
strategy: 'network-first',
cacheName: cacheNames.api,
})
await visit('d1', 'Korean beef bowls')
expect([...cache.entries.keys()]).toEqual([`${origin}/api/v1/dinners/d1`])
})
it('shows a visited dinner offline from its saved copy: photo, ingredients and steps, with the offline indicator', async () => {
mockViewport(390)
await visit('d1', 'Korean beef bowls')
reachable = false
open('d1')
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg')
const [ingredients, steps] = screen.getAllByRole('list')
expect(within(ingredients!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([
'Ground beef1 lb',
'Jasmine rice2 cups',
])
expect(within(steps!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([
'1Cook the rice.',
'2Brown the beef with the sauce.',
])
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument()
})
it('shows a visited dinner offline in Kitchen reading mode on a tablet', async () => {
mockViewport(1180)
await visit('d1', 'Korean beef bowls')
reachable = false
open('d1')
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg')
expect(within(screen.getByRole('region', { name: 'Ingredients' })).getAllByRole('checkbox')).toHaveLength(2)
expect(screen.getByRole('listitem', { current: 'step' })).toHaveTextContent('Cook the rice.')
})
it('shows an error, not a blank page or placeholder, for a dinner never visited', async () => {
mockViewport(390)
await visit('d1', 'Korean beef bowls')
reachable = false
open('d2')
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.")
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument()
expect(screen.queryByText(/Korean beef bowls|Chicken tikka masala/)).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument()
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
})
it('keeps a saved copy still finding its recipe as that, not a finished recipe', async () => {
mockViewport(390)
await visit('d3', 'Pork carnitas')
reachable = false
open('d3')
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
// Read once online and tried once offline before the saved copy answered.
expect(networkReads.filter((path) => path.startsWith('/api/v1/dinners/'))).toEqual([
'/api/v1/dinners/d3',
'/api/v1/dinners/d3',
])
})
})
-547
View File
@@ -1,547 +0,0 @@
/* Mock 1k on mobile: the photo and the sheet run full-bleed past the
shell's 20 px side padding. From tablet up the page is Kitchen reading
mode (mock 2a, below). The page is at least as tall as the shell's scroll
area (a stretched flex item, so its height resolves the percentage) and
the sheet, or the reading view, grows into any room left, so the action
bar rests at the bottom of a short recipe. */
.page {
display: flex;
flex-direction: column;
min-height: 100%;
font-family: var(--font-body);
color: var(--color-text);
}
/* Kitchen reading mode is no wider than the handoff's tablet frame. */
.tablet,
.desktop {
width: 100%;
max-width: 1180px;
margin: 0 auto;
}
.hero {
position: relative;
height: 250px;
}
.mobile .hero,
.mobile .skeletonHero {
margin: 0 -20px;
}
/* Without a dinner to show, the back button sits alone above the message. */
.backRow {
padding-top: calc(16px + env(safe-area-inset-top));
}
.backRow .back {
position: static;
}
.photo {
width: 100%;
height: 100%;
}
.back {
position: absolute;
top: calc(16px + env(safe-area-inset-top));
left: 20px;
}
/* The sheet overlaps the photo by 28 px. */
.sheet {
position: relative;
flex-grow: 1;
margin-top: -28px;
display: flex;
flex-direction: column;
gap: 10px;
padding: 22px 20px 0;
border-radius: 32px 32px 0 0;
background: var(--color-bg);
}
.mobile .sheet {
margin-left: -20px;
margin-right: -20px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 28px;
font-weight: 400;
line-height: 1.05;
}
.why {
margin: 0;
padding: 12px 14px;
border-radius: 18px;
background: var(--color-accent-2-100);
color: var(--color-accent-2-900);
font-size: 13px;
line-height: 1.45;
}
.section {
display: flex;
flex-direction: column;
margin-top: 6px;
}
.sectionTitle {
margin: 0 0 4px;
font-family: var(--font-heading);
font-size: 18px;
font-weight: 400;
line-height: 1.15;
}
.ingredients,
.steps {
margin: 0;
padding: 0;
list-style: none;
font-size: 14px;
line-height: 1.45;
}
.ingredient {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 7px 0;
border-bottom: 1px solid var(--color-divider);
}
.ingredient:last-child {
border-bottom: 0;
}
.note,
.qty {
color: var(--color-neutral-700);
}
.qty {
flex: none;
text-align: right;
}
.step {
display: flex;
gap: 12px;
padding: 8px 0;
}
.number {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-pill);
background: var(--color-accent-100);
color: var(--color-accent-800);
font-size: 12px;
font-weight: 700;
}
.source {
margin: 4px 0 0;
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.source a {
color: inherit;
}
.missing {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
margin: 6px 0 0;
padding: 12px 14px;
border-radius: 18px;
background: var(--color-neutral-100);
color: var(--color-neutral-800);
font-size: 13px;
line-height: 1.45;
}
.missing p {
margin: 0;
}
/* A 44 px tall tap target for the link. */
.fix {
display: inline-flex;
align-items: center;
min-height: 44px;
color: var(--color-accent-700);
font-weight: 700;
}
/* At the bottom of the page, which fills the scroll area, and held at the
bottom of the screen over a long recipe. Sticky offsets count from inside
the scroll area's padding, which on mobile already keeps clear of the nav
bar. */
.bar {
position: sticky;
bottom: 0;
z-index: 1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 24px;
padding: 10px 0;
background: var(--color-bg);
}
.add {
flex: 1;
min-height: 52px;
font-size: 16px;
}
/* What became of an add or a favorite: a full row above the buttons. */
.status {
order: -1;
flex-basis: 100%;
}
.added {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 600;
color: var(--color-accent-2-800);
}
.filled {
color: var(--color-accent);
fill: currentColor;
}
.skeletonHero {
height: 250px;
border-radius: 0;
}
.skeletonTitle {
height: 32px;
width: 70%;
}
/* Mock 2a, Kitchen reading mode: the header over two columns, the
ingredients card (400 px) and the steps. Ingredient and step text scale
with the text size chosen: the handoff's sizes are medium. */
.reading {
flex-grow: 1;
display: flex;
flex-direction: column;
gap: 22px;
}
.small {
--reading-scale: 0.85;
}
.medium {
--reading-scale: 1;
}
.large {
--reading-scale: 1.25;
}
.readingHeader {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 18px;
}
.readingHeader .back {
position: static;
flex: none;
}
.heading {
flex: 1 1 240px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.kicker {
margin: 0;
font-size: 13px;
font-weight: 700;
color: var(--color-neutral-700);
}
.readingTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 36px;
font-weight: 400;
line-height: 1.05;
}
.keepOn {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 700;
}
.keepOnHint {
margin: 0;
font-size: 14px;
line-height: 1.4;
color: var(--color-neutral-700);
}
/* The A / A / A control: an outlined pill, the size chosen in grape, each
A drawn at the size it stands for. */
.readingHeader .textSize {
gap: 0;
padding: 0;
border: 1px solid var(--color-divider);
background: transparent;
overflow: hidden;
}
.readingHeader .textSize > * {
padding: 0 14px;
border-radius: 0;
color: var(--color-text);
}
.readingHeader .textSize > [aria-checked="true"] {
background: var(--color-accent);
color: var(--on-accent);
box-shadow: none;
}
.readingHeader .textSize > :nth-child(1) {
font-size: 13px;
}
.readingHeader .textSize > :nth-child(2) {
font-size: 17px;
}
.readingHeader .textSize > :nth-child(3) {
font-size: 21px;
}
.columns {
flex-grow: 1;
display: grid;
grid-template-columns: 400px minmax(0, 1fr);
gap: 28px;
}
/* Below a 1024 px viewport the shell leaves too little beside a 400 px
card for the steps, so the columns share the width instead. */
@media (max-width: 1023px) {
.columns {
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
}
}
.oneColumn {
flex-grow: 1;
display: flex;
flex-direction: column;
}
.columns .ingredientsCard {
display: flex;
flex-direction: column;
gap: 6px;
padding: 22px;
border-radius: 30px;
}
.cardHead {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-bottom: 6px;
}
.readingSectionTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
line-height: 1.15;
}
.setOutCount {
flex: none;
white-space: nowrap;
font-size: 13px;
font-weight: 700;
color: var(--color-neutral-700);
}
.checklist {
margin: 0;
padding: 0;
list-style: none;
}
.checkRow {
display: flex;
align-items: center;
gap: 14px;
padding: 4px 0;
border-bottom: 1px solid var(--color-divider);
}
.checkRow .check {
flex: 1;
min-width: 0;
gap: 14px;
}
.checkName {
font-size: calc(18px * var(--reading-scale));
font-weight: 400;
line-height: 1.3;
}
.setOut {
color: var(--color-neutral-600);
text-decoration: line-through;
}
.checkQty {
flex: none;
text-align: right;
font-size: calc(15px * var(--reading-scale));
color: var(--color-neutral-700);
}
.method {
flex-grow: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.intro {
display: flex;
align-items: center;
gap: 16px;
}
.introPhoto {
flex: 1;
min-width: 0;
height: 96px;
}
.introWhy {
flex: 1.6;
padding: 14px 18px;
border-radius: 22px;
font-size: 14px;
}
.readingSteps {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 4px;
}
.stepList {
display: flex;
flex-direction: column;
gap: 14px;
margin: 0;
padding: 0;
list-style: none;
}
.readingStep {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 6px 18px;
border-radius: 26px;
font-size: calc(18px * var(--reading-scale));
line-height: 1.45;
}
.stepCircle {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--radius-pill);
background: var(--color-surface);
font-family: var(--font-heading);
font-size: 18px;
}
.stepText {
padding-top: 6px;
}
.stepDone {
color: var(--color-neutral-600);
}
.stepCurrent {
padding: 18px;
background: var(--color-accent-100);
box-shadow: inset 0 0 0 2px var(--color-accent-300);
font-size: calc(22px * var(--reading-scale));
}
.stepCurrent .stepCircle {
background: var(--color-accent);
color: var(--on-accent);
}
/* The action bar rests at the bottom of the steps column, with Previous
and Next step at its right. */
.reading .bar {
margin-top: auto;
}
.reading .add {
flex: none;
}
.stepButtons {
display: flex;
gap: 10px;
margin-left: auto;
}
-473
View File
@@ -1,473 +0,0 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { AppShell } from '../shell/AppShell.tsx'
import { cacheFallbackHeader } from '../sw/routes.ts'
import { ApiProviders } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { errorResponse, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { GROUNDING_POLL_MS } from './api.ts'
import { DinnerDetailPage, REGULAR_DINNERS } from './DinnerDetailPage.tsx'
import sourceCases from './testdata/source_url_cases.json'
const DINNER = '/api/v1/dinners/d1'
// detail is a dinner as GET /api/v1/dinners/{id} sends it: mock 1k's
// Korean beef bowls, a Side quest grounded in a page that named its site.
function detail(overrides: Record<string, unknown> = {}) {
return {
id: 'd1',
name: 'Korean beef bowls',
status: 'quest_log',
origin: 'suggested',
method: 'stovetop',
minutes_total: 30,
difficulty: 'easy',
cuisine: 'korean',
is_favorite: false,
photo_urls: { '400': '/media/photos/p1-400.jpg', '1200': '/media/photos/p1-1200.jpg' },
ingredients: [
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
{ name: 'Soy sauce', qty: '¼', unit: 'cup', note: null, raw_text: '¼ cup soy sauce' },
{ name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' },
],
steps: ['Cook the rice.', 'Brown the beef with the sauce.', 'Serve over rice with the onions.'],
source: { site_name: 'Serious Eats' },
grounding_status: 'grounded',
grounding_error: null,
why_picked: 'close cousin of your Beef tacos and Chicken stir-fry.',
...overrides,
}
}
// A recipe the household typed in: only raw_text, no source.
const MANUAL = {
status: 'regular',
origin: 'regular',
photo_urls: null,
ingredients: [
{ name: null, qty: null, unit: null, note: null, raw_text: '2 lb chuck roast' },
{ name: null, qty: null, unit: null, note: null, raw_text: 'A splash of red wine' },
],
steps: ['Sear the roast.', 'Slow cook 8 hours.'],
source: null,
grounding_status: 'manual',
why_picked: null,
}
// A Regular whose recipe could not be found: no recipe at all.
const FAILED = {
status: 'regular',
origin: 'regular',
photo_urls: null,
minutes_total: null,
difficulty: null,
ingredients: [],
steps: [],
source: null,
grounding_status: 'failed',
grounding_error: 'no_recipe_found',
why_picked: null,
}
// renderAt renders the detail screen at the last of entries, inside the
// app shell's scroll area as App routes it, with probes for where its links
// and back button go.
function renderAt(entries: string[]) {
const router = createMemoryRouter(
[
{ element: <AppShell />, children: [{ path: '/dinner/:id', element: <DinnerDetailPage /> }] },
{ path: '/', element: <h1>Home probe</h1> },
{ path: '/plan', element: <h1>Plan probe</h1> },
{ path: REGULAR_DINNERS, element: <h1>Regular dinners probe</h1> },
],
{ initialEntries: entries, initialIndex: entries.length - 1 },
)
render(
<ApiProviders>
<RouterProvider router={router} />
</ApiProviders>,
)
return router
}
function serve(body: unknown) {
return stubFetchByPath({ [DINNER]: () => jsonResponse(200, body) })
}
beforeEach(() => {
mockViewport(390)
})
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
vi.unstubAllGlobals()
})
describe('DinnerDetailPage', () => {
describe('a grounded Side quest', () => {
it('shows its photo washed and full-bleed, tags, title and why it was picked', async () => {
serve(detail())
renderAt(['/dinner/d1'])
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
const photo = screen.getByRole('img', { name: 'Korean beef bowls' })
expect(photo).toHaveAttribute('src', '/media/photos/p1-400.jpg')
expect(photo).toHaveAttribute('srcset', '/media/photos/p1-400.jpg 400w, /media/photos/p1-1200.jpg 1200w')
expect(declaredStyle(photo.parentElement!).height).toBe('250px')
expect(declaredStyle(photo.parentElement!).margin).toBe('0px -20px')
expect(screen.getByText('Side quest')).toBeInTheDocument()
expect(screen.getByText('Easy')).toBeInTheDocument()
expect(screen.getByText('30 min')).toBeInTheDocument()
expect(screen.queryByText('Regular')).not.toBeInTheDocument()
expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent(
'Why it was picked: close cousin of your Beef tacos and Chicken stir-fry.',
)
})
it('lists ingredients in recipe order with quantities on the right, then numbered steps', async () => {
serve(detail())
renderAt(['/dinner/d1'])
expect(await screen.findByRole('heading', { level: 2, name: 'Ingredients' })).toBeInTheDocument()
const rows = within(screen.getAllByRole('list')[0]!).getAllByRole('listitem')
expect(rows.map((row) => Array.from(row.children, (cell) => cell.textContent))).toEqual([
['Ground beef', '1 lb'],
['Jasmine rice', '2 cups'],
['Soy sauce', '¼ cup'],
['Green onions, sliced', '1 bunch'],
])
const qty = rows[0]!.lastElementChild!
expect(declaredStyle(qty).textAlign).toBe('right')
expect(declaredStyle(rows[0]!).justifyContent).toBe('space-between')
expect(declaredStyle(rows[0]!).borderBottom).toBe('1px solid var(--color-divider)')
expect(screen.getByRole('heading', { level: 2, name: 'Steps' })).toBeInTheDocument()
const steps = screen.getAllByRole('list')[1]!
expect(steps.tagName).toBe('OL')
expect(within(steps).getAllByRole('listitem').map((step) => step.textContent)).toEqual([
'1Cook the rice.',
'2Brown the beef with the sauce.',
'3Serve over rice with the onions.',
])
})
it('credits the site the recipe came from, with no link to it', async () => {
serve(detail())
renderAt(['/dinner/d1'])
const credit = await screen.findByText('Recipe from Serious Eats')
expect(credit.closest('a')).toBeNull()
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
})
it('links the credit to the recipe in a new tab once the server sends a safe url', async () => {
serve(detail({ source: { site_name: 'Serious Eats', url: 'https://www.seriouseats.com/korean-beef-bowls' } }))
renderAt(['/dinner/d1'])
const credit = await screen.findByRole('link', { name: 'Recipe from Serious Eats' })
expect(credit).toHaveAttribute('href', 'https://www.seriouseats.com/korean-beef-bowls')
expect(credit).toHaveAttribute('target', '_blank')
expect(credit).toHaveAttribute('rel', 'noopener noreferrer')
})
it.each([
['http', 'http://www.seriouseats.com/korean-beef-bowls'],
['a password', 'https://user:secret@www.seriouseats.com/korean-beef-bowls'],
['a query', 'https://www.seriouseats.com/korean-beef-bowls?token=abc'],
['a fragment', 'https://www.seriouseats.com/korean-beef-bowls#token=abc'],
])('refuses a source url with %s', async (_, url) => {
serve(detail({ source: { site_name: 'Serious Eats', url } }))
renderAt(['/dinner/d1'])
expect(await screen.findByRole('alert')).toHaveTextContent('source url is not an https link')
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
})
// Each source exactly as the Go handler sends it for a stored link
// (internal/server's TestGetDinnerSourceURLOnlyWhenSafe checks the same
// file), so every url the server sends must parse here.
it.each(sourceCases.cases)('renders the source the server sends for $stored', async ({ source }) => {
serve(detail({ source }))
renderAt(['/dinner/d1'])
const credit = await screen.findByText('Recipe from Curry House')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
if (source.url === null) {
expect(credit.closest('a')).toBeNull()
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
} else {
expect(screen.getByRole('link', { name: 'Recipe from Curry House' })).toHaveAttribute('href', new URL(source.url).href)
}
})
it("links the credit by the url's host when the server names no site", async () => {
serve(detail({ source: { site_name: null, url: 'https://Kitchen.Example:8443/korean-beef-bowls' } }))
renderAt(['/dinner/d1'])
const credit = await screen.findByRole('link', { name: 'Recipe from kitchen.example:8443' })
expect(credit).toHaveAttribute('href', 'https://kitchen.example:8443/korean-beef-bowls')
expect(credit).toHaveAttribute('target', '_blank')
expect(credit).toHaveAttribute('rel', 'noopener noreferrer')
})
// The server withholds both when the stored name could be the link's
// host and the link could carry a token: the credit names neither.
it('credits the web in plain text when the server sends neither site nor url', async () => {
serve(detail({ source: { site_name: null, url: null } }))
renderAt(['/dinner/d1'])
const credit = await screen.findByText('Recipe from the web')
expect(credit.closest('a')).toBeNull()
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
})
it('holds Add to this week and the heart at the bottom', async () => {
serve(detail({ is_favorite: true }))
renderAt(['/dinner/d1'])
const add = await screen.findByRole('button', { name: 'Add to this week' })
expect(add).toBeEnabled()
const heart = screen.getByRole('button', { name: 'Favorite' })
expect(heart).toBeEnabled()
expect(heart).toHaveAttribute('aria-pressed', 'true')
const bar = heart.parentElement!
expect(bar).toContainElement(add)
expect(declaredStyle(bar).position).toBe('sticky')
expect(declaredStyle(bar).bottom).toBe('0px')
})
})
// jsdom does no layout, so no test here can measure where the bar lands.
// This pins the CSS chain that puts it at the bottom of the screen on a
// short recipe: the shell's scroll area is a full-height flex item, the
// page fills it as a column, the sheet takes the room left and the bar is
// the page's last child. Drop any link and the bar sits right after the
// content again, and this fails.
it('fills the scroll area so the bar rests at the bottom of a short recipe', async () => {
serve(detail(FAILED))
renderAt(['/dinner/d1'])
const bar = (await screen.findByRole('button', { name: 'Favorite' })).parentElement!
const page = bar.parentElement!
const main = page.parentElement!
const shell = main.parentElement!
expect(main.tagName).toBe('MAIN')
expect(declaredStyle(shell).height).toBe('100dvh')
expect(declaredStyle(shell).display).toBe('flex')
expect(declaredStyle(shell).alignItems).toBe('')
expect(declaredStyle(main).overflowY).toBe('auto')
expect(declaredStyle(page).minHeight).toBe('100%')
expect(declaredStyle(page).display).toBe('flex')
expect(declaredStyle(page).flexDirection).toBe('column')
expect(page.lastElementChild).toBe(bar)
const sheet = screen.getByRole('article')
expect(sheet.parentElement).toBe(page)
expect(sheet.nextElementSibling).toBe(bar)
expect(declaredStyle(sheet).flexGrow).toBe('1')
expect(declaredStyle(bar).position).toBe('sticky')
})
it('shows the striped placeholder when the dinner has no photo', async () => {
serve(detail({ photo_urls: null }))
renderAt(['/dinner/d1'])
const placeholder = await screen.findByRole('img', { name: 'Korean beef bowls' })
expect(placeholder.tagName).toBe('DIV')
expect(placeholder).toHaveTextContent('dish photo')
})
it('shows a manual Regular line by line, as typed, with no credit', async () => {
serve(detail(MANUAL))
renderAt(['/dinner/d1'])
expect(await screen.findByText('Regular')).toBeInTheDocument()
expect(screen.queryByText('Side quest')).not.toBeInTheDocument()
const [ingredients, steps] = screen.getAllByRole('list')
expect(within(ingredients!).getAllByRole('listitem').map((row) => row.firstElementChild!.textContent)).toEqual([
'2 lb chuck roast',
'A splash of red wine',
])
expect(within(steps!).getAllByRole('listitem')).toHaveLength(2)
expect(screen.queryByText(/Why it was picked/)).not.toBeInTheDocument()
expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveTextContent('dish photo')
})
it('links a dinner whose recipe was not found to Settings → Regular dinners, with no recipe', async () => {
serve(detail(FAILED))
const router = renderAt(['/dinner/d1'])
const fix = await screen.findByRole('link', { name: 'Fix recipe' })
expect(screen.getByText(/couldn't find a recipe/)).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Steps' })).not.toBeInTheDocument()
expect(screen.queryByRole('list')).not.toBeInTheDocument()
fireEvent.click(fix)
expect(await screen.findByRole('heading', { name: 'Regular dinners probe' })).toBeInTheDocument()
expect(router.state.location.pathname).toBe(REGULAR_DINNERS)
})
it('says it is still finding a recipe while grounding is pending', async () => {
serve(detail({ ...FAILED, grounding_status: 'pending', grounding_error: null }))
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Fix recipe' })).not.toBeInTheDocument()
})
describe('while the recipe is being found', () => {
const PENDING = detail({ ...FAILED, grounding_status: 'pending', grounding_error: null })
const GROUNDED = detail({ grounding_status: 'grounded' })
let onLine: boolean
let body: unknown
let fromCache: boolean
let fetchMock: Mock<typeof fetch>
const reads = () => fetchMock.mock.calls.length
const tick = (intervals = 1) => act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * intervals))
function setOnline(value: boolean) {
onLine = value
act(() => {
window.dispatchEvent(new Event(value ? 'online' : 'offline'))
})
}
beforeEach(() => {
// Only intervals are faked: TanStack Query polls with setInterval, and
// Testing Library's waits keep their real timers.
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
onLine = true
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine)
body = PENDING
fromCache = false
fetchMock = stubFetchByPath({
[DINNER]: () =>
new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json', ...(fromCache ? { [cacheFallbackHeader]: '1' } : {}) },
}),
})
})
// An offline event leaves TanStack Query's online manager, shared by
// every test, offline until an online event.
afterEach(() => {
window.dispatchEvent(new Event('online'))
vi.useRealTimers()
vi.restoreAllMocks()
})
it('reads the dinner again until the recipe lands, then stops', async () => {
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(reads()).toBe(1)
await tick()
await waitFor(() => expect(reads()).toBe(2))
body = GROUNDED
await tick()
expect(await screen.findByRole('heading', { level: 2, name: 'Ingredients' })).toBeInTheDocument()
expect(reads()).toBe(3)
await tick(3)
expect(reads()).toBe(3)
})
it('pauses while the browser is offline and resumes when it is back', async () => {
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(reads()).toBe(1)
setOnline(false)
await tick(3)
expect(reads()).toBe(1)
setOnline(true)
await tick()
await waitFor(() => expect(reads()).toBe(2))
})
it('reads a saved pending copy offline once, then waits for the connection', async () => {
onLine = false
fromCache = true
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(reads()).toBe(1)
await tick(3)
expect(reads()).toBe(1)
// The connection comes back: the saved copy is read again at once,
// and polling goes on once the server answers.
fromCache = false
setOnline(true)
await waitFor(() => expect(reads()).toBe(2))
await tick()
await waitFor(() => expect(reads()).toBe(3))
})
it('stops polling on a saved copy even while the browser says it is online', async () => {
fromCache = true
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
await tick(3)
expect(reads()).toBe(1)
})
it('stops polling when the page is left', async () => {
const router = renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(reads()).toBe(1)
await act(() => router.navigate('/'))
expect(screen.getByRole('heading', { name: 'Home probe' })).toBeInTheDocument()
await tick(3)
expect(reads()).toBe(1)
})
})
it('says an unknown or removed dinner is not found', async () => {
stubFetchByPath({ [DINNER]: () => errorResponse(404, 'not_found') })
renderAt(['/dinner/d1'])
expect(await screen.findByRole('heading', { name: 'Dinner not found' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Go Home' })).toHaveAttribute('href', '/')
})
it('reports any other failure with a retry', async () => {
stubFetchByPath({ [DINNER]: () => errorResponse(500, 'internal') })
renderAt(['/dinner/d1'])
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.")
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
})
describe('the back button', () => {
it('goes back in the app', async () => {
serve(detail())
const router = renderAt(['/plan', '/dinner/d1'])
fireEvent.click(await screen.findByRole('button', { name: 'Back' }))
expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/plan')
})
it('goes Home when the dinner was opened from a link', async () => {
serve(detail())
const router = renderAt(['/dinner/d1'])
fireEvent.click(await screen.findByRole('button', { name: 'Back' }))
expect(await screen.findByRole('heading', { name: 'Home probe' })).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/')
})
})
})
-582
View File
@@ -1,582 +0,0 @@
import { useQueryClient } from '@tanstack/react-query'
import { Check, ChevronLeft, ChevronRight, Heart } from 'lucide-react'
import { useCallback, useEffect, useId, useState, type ReactNode } from 'react'
import { Link, useLocation, useNavigate, useParams } from 'react-router'
import { ApiError } from '../api/errors.ts'
import { planKey, usePlan, useWeekSwap } from '../plan/api.ts'
import { duration } from '../plan/PlanPage.tsx'
import { addDays, dayAndDate, localNow, weekdayName, weekStart } from '../plan/week.ts'
import { useHouseholdSettings } from '../settings/api.ts'
import { useBreakpoint } from '../shell/useBreakpoint.ts'
import { Button } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
import { Card } from '../ui/Card.tsx'
import { Checkbox } from '../ui/Checkbox.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Photo } from '../ui/Photo.tsx'
import { Segmented, type SegmentedOption } from '../ui/Segmented.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import { Toggle } from '../ui/Toggle.tsx'
import { AddToWeekSheet, weekNames, type Added } from './AddToWeekSheet.tsx'
import { useToggleFavorite, type Difficulty } from './api.ts'
import { useDinnerDetail, type DinnerDetail, type Ingredient, type RecipeSource } from './detail.ts'
import styles from './DinnerDetailPage.module.css'
import { useKeepScreenOn } from './keepScreenOn.ts'
import { textSizeFailureText, useTextSize, type TextSize } from './textSize.ts'
// REGULAR_DINNERS is Settings → Regular dinners, where a recipe is fixed.
export const REGULAR_DINNERS = '/settings/regulars'
const difficultyLabels: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
// BackButton goes back in the app's history, or, opened from a link with
// no history in the app, to Home.
function BackButton() {
const navigate = useNavigate()
const location = useLocation()
// The first entry of a session has the key "default".
const back = () => (location.key === 'default' ? void navigate('/', { replace: true }) : void navigate(-1))
return (
<IconButton label="Back" tone="neutral" className={styles.back} onClick={back}>
<Icon icon={ChevronLeft} size="inline" />
</IconButton>
)
}
// quantity is an ingredient's amount as the right-hand column shows it,
// "1 lb", or empty when the recipe gives none.
function quantity(ingredient: Ingredient): string {
return [ingredient.qty, ingredient.unit].filter(Boolean).join(' ')
}
// IngredientRow is one ingredient: its name with any note, or the line as
// the recipe gives it when there is no name, and its amount on the right.
function IngredientRow({ ingredient }: { ingredient: Ingredient }) {
return (
<li className={styles.ingredient}>
<span>
{ingredient.name ?? ingredient.raw_text}
{ingredient.name !== null && ingredient.note !== null && <span className={styles.note}>, {ingredient.note}</span>}
</span>
<span className={styles.qty}>{quantity(ingredient)}</span>
</li>
)
}
// SourceCredit is "Recipe from {site}" for a recipe fetched from the web: a
// link opening the recipe's page in a new tab when the server sends a url
// safe to open, else plain text. The site is the name the page gave, else
// the url's host — already in the link, so naming it shows nothing more —
// else, with neither to show, just "the web": a fetched recipe always
// credits where it came from, without a name the server withheld.
function SourceCredit({ siteName, url }: RecipeSource) {
const credit = `Recipe from ${siteName ?? (url === null ? 'the web' : new URL(url).host)}`
return (
<p className={styles.source}>
{url === null ? (
credit
) : (
<a href={url} target="_blank" rel="noopener noreferrer">
{credit}
</a>
)}
</p>
)
}
// hasRecipe says whether the dinner's recipe was found or typed in, rather
// than still being searched for or not found.
function hasRecipe(dinner: DinnerDetail): boolean {
return dinner.grounding_status !== 'pending' && dinner.grounding_status !== 'failed'
}
// RecipeMissing is what stands in for a recipe still being searched for, or
// the link to fix one that could not be found.
function RecipeMissing({ dinner }: { dinner: DinnerDetail }) {
if (dinner.grounding_status === 'failed') {
return (
<section className={styles.missing} aria-label="No recipe">
<p>We couldn't find a recipe for this dinner, so there are no ingredients or steps yet.</p>
<Link to={REGULAR_DINNERS} className={styles.fix}>
Fix recipe
</Link>
</section>
)
}
return <p className={styles.missing}>Still finding the recipe — its ingredients and steps will show here.</p>
}
// Recipe is the dinner's ingredients and steps, or what stands in for them.
function Recipe({ dinner }: { dinner: DinnerDetail }) {
if (!hasRecipe(dinner)) return <RecipeMissing dinner={dinner} />
return (
<>
{dinner.ingredients.length > 0 && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Ingredients</h2>
<ul className={styles.ingredients}>
{dinner.ingredients.map((ingredient, i) => (
<IngredientRow key={i} ingredient={ingredient} />
))}
</ul>
</section>
)}
{dinner.steps.length > 0 && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Steps</h2>
<ol className={styles.steps}>
{dinner.steps.map((step, i) => (
<li key={i} className={styles.step}>
<span className={styles.number} aria-hidden="true">
{i + 1}
</span>
<span>{step}</span>
</li>
))}
</ol>
</section>
)}
{dinner.source !== null && <SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />}
</>
)
}
// AddedStatus says what became of a dinner added to a day: being saved,
// added, or why it couldn't be, with Retry, which sends the same swap again
// as the Draft board would (useWeekSwap), and is not offered while that is
// refused. The week's plan stays read while it shows, so a retry finds the
// day to swap; once added, the plan is read again from the server.
function AddedStatus({ added }: { added: Added }) {
const queryClient = useQueryClient()
usePlan(added.week)
const { swap, refused, swapIn } = useWeekSwap(added.week)
const { date } = added.swap
const done = swap.succeeded === added.swap
const failed = swap.failed?.swap === added.swap ? swap.failed.error : null
useEffect(() => {
if (done) void queryClient.invalidateQueries({ queryKey: planKey(added.week) })
}, [done, queryClient, added.week])
if (failed !== null) {
return (
<div className={styles.status}>
<ErrorBanner onRetry={refused ? undefined : () => swapIn(added.swap)}>
Couldn't add it to {weekdayName(date)} {weekNames[added.which]}. {failed.message}
</ErrorBanner>
</div>
)
}
return (
<p role="status" className={[styles.status, styles.added].join(' ')}>
{done ? `Added to ${weekdayName(date)} ${weekNames[added.which]}.` : `Adding to ${weekdayName(date)}…`}
</p>
)
}
// AddToWeek is Add to this week: it opens the sheet of the week's days, and
// says what became of the dinner added.
function AddToWeek({ dinner }: { dinner: DinnerDetail }) {
const [open, setOpen] = useState(false)
const [added, setAdded] = useState<Added | null>(null)
function add(chosen: Added) {
setOpen(false)
setAdded(chosen)
}
return (
<>
{added !== null && <AddedStatus key={`${added.week} ${added.swap.date}`} added={added} />}
<Button className={styles.add} onClick={() => setOpen(true)}>
Add to this week
</Button>
{open && <AddToWeekSheet dinner={dinner} onAdd={add} onClose={() => setOpen(false)} />}
</>
)
}
// FavoriteHeart toggles whether the dinner is a favorite, at once, changing
// back with the error and Retry if the server refuses. One toggle is sent at
// a time.
function FavoriteHeart({ dinner }: { dinner: DinnerDetail }) {
const favorite = useToggleFavorite()
function toggle() {
if (!favorite.isPending) favorite.mutate({ id: dinner.id, is_favorite: dinner.is_favorite })
}
return (
<>
{favorite.isError && (
<div className={styles.status}>
<ErrorBanner onRetry={toggle}>
Couldn't {dinner.is_favorite ? 'unfavorite' : 'favorite'} {dinner.name}. {favorite.error.message}
</ErrorBanner>
</div>
)}
<IconButton label="Favorite" size={52} aria-pressed={dinner.is_favorite} onClick={toggle}>
<Icon icon={Heart} size="nav" className={dinner.is_favorite ? styles.filled : undefined} />
</IconButton>
</>
)
}
// ActionBar is the bar at the bottom of the screen, the page's last child:
// the page fills the scroll area so a short recipe still leaves it at the
// bottom, and it sticks there over a long one. It holds Add to this week
// and the favorite heart, with what became of either above them, and in
// Kitchen reading mode the step buttons after them.
function ActionBar({ dinner, children }: { dinner: DinnerDetail; children?: ReactNode }) {
return (
<div className={styles.bar}>
<AddToWeek dinner={dinner} />
<FavoriteHeart dinner={dinner} />
{children}
</div>
)
}
// Detail is a dinner as mock 1k shows it: the photo full-bleed under a sheet
// with its tags, title, why it was picked and its recipe, over the action bar.
function Detail({ dinner }: { dinner: DinnerDetail }) {
const tags = [
dinner.difficulty !== null && difficultyLabels[dinner.difficulty],
dinner.minutes_total !== null && duration(dinner.minutes_total),
].filter((tag) => tag !== false)
return (
<>
<div className={styles.hero}>
<Photo id={dinner.photo_id} name={dinner.name} radius="0" sizes="100vw" className={styles.photo} />
<BackButton />
</div>
<article className={styles.sheet}>
<div className={styles.tags}>
<Tag tone="grape">{dinner.status === 'regular' ? 'Regular' : 'Side quest'}</Tag>
{tags.map((tag) => (
<Tag key={tag} tone="neutral">
{tag}
</Tag>
))}
</div>
<h1 className={styles.title}>{dinner.name}</h1>
{dinner.why_picked !== null && (
<p className={styles.why}>
<b>Why it was picked:</b> {dinner.why_picked}
</p>
)}
<Recipe dinner={dinner} />
</article>
<ActionBar dinner={dinner} />
</>
)
}
// statusName is the dinner as the household calls it: a Regular, or a Side
// quest for any dinner not yet one.
function statusName(dinner: DinnerDetail): string {
return dinner.status === 'regular' ? 'Regular' : 'Side quest'
}
// PlannedDay is the kicker of a dinner on a plan, "Saturday, Oct 10 · Side
// quest", whenever this week's or next week's plan has it. Of the days that
// do, it is the first from today on in the household's timezone; when every
// one is already past, it is the latest of them. There is none while the
// plans are read, nor when neither plan has the dinner.
function PlannedDay({ dinner, timezone }: { dinner: DinnerDetail; timezone: string }) {
const today = localNow(timezone, new Date()).date
const thisWeek = weekStart(today)
const plans = [usePlan(thisWeek), usePlan(addDays(thisWeek, 7))]
const failed = plans.find((plan) => plan.isError)
if (failed !== undefined) {
return (
<ErrorBanner onRetry={() => plans.forEach((plan) => plan.isError && void plan.refetch())}>
Couldn't check the plan for this dinner's day. {failed.error.message}
</ErrorBanner>
)
}
const planned = plans.flatMap((plan) => plan.data?.days ?? []).filter((day) => day.dinner.id === dinner.id)
const day = planned.find((day) => day.date >= today) ?? planned.at(-1)
if (day === undefined) return null
return (
<p className={styles.kicker}>
{dayAndDate(day.date)} · {statusName(dinner)}
</p>
)
}
// Kicker is PlannedDay, once the household's timezone is read.
function Kicker({ dinner }: { dinner: DinnerDetail }) {
const settings = useHouseholdSettings()
if (settings.isError) {
return (
<ErrorBanner onRetry={() => void settings.refetch()}>
Couldn't load the household settings to find this dinner's day. {settings.error.message}
</ErrorBanner>
)
}
if (settings.isPending) return null
return <PlannedDay dinner={dinner} timezone={settings.data.timezone} />
}
const textSizes: SegmentedOption<TextSize>[] = [
{ value: 'small', label: 'A', ariaLabel: 'Small text' },
{ value: 'medium', label: 'A', ariaLabel: 'Medium text' },
{ value: 'large', label: 'A', ariaLabel: 'Large text' },
]
// ReadingHeader is Kitchen reading mode's header: back, the day the dinner
// is planned for, its title, Keep screen on and the text size.
function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: ReturnType<typeof useTextSize> }) {
const keepOnId = useId()
const keepOnHintId = useId()
const keepScreenOn = useKeepScreenOn()
const { failure } = textSize
return (
<>
<header className={styles.readingHeader}>
<BackButton />
<div className={styles.heading}>
<Kicker dinner={dinner} />
<h1 className={styles.readingTitle}>{dinner.name}</h1>
</div>
<div className={styles.keepOn}>
<span id={keepOnId}>Keep screen on</span>
<Toggle
checked={keepScreenOn.on}
onCheckedChange={keepScreenOn.setOn}
aria-labelledby={keepOnId}
disabled={!keepScreenOn.supported}
aria-describedby={keepScreenOn.supported ? undefined : keepOnHintId}
/>
</div>
<Segmented
label="Text size"
options={textSizes}
value={textSize.size}
onValueChange={textSize.choose}
className={styles.textSize}
/>
</header>
{!keepScreenOn.supported && (
<p id={keepOnHintId} className={styles.keepOnHint}>
This browser can't keep the screen on, so it may dim while you cook.
</p>
)}
{keepScreenOn.failure !== null && (
<ErrorBanner onRetry={() => keepScreenOn.setOn(true)}>
Couldn't keep the screen on: {keepScreenOn.failure}
</ErrorBanner>
)}
{failure !== null && (
<ErrorBanner onRetry={textSize.retry}>
{textSizeFailureText[failure.action]}: {failure.message}
</ErrorBanner>
)}
</>
)
}
// IngredientsCard is the ingredients with a checkbox each, struck through
// once set out, and how many are. Which are set out is this visit's alone:
// it is never sent to the server and is not the Supply run's "have it".
function IngredientsCard({ ingredients }: { ingredients: Ingredient[] }) {
const titleId = useId()
const [setOut, setSetOut] = useState<ReadonlySet<number>>(() => new Set())
const count = ingredients.filter((_, i) => setOut.has(i)).length
function toggle(i: number) {
setSetOut((before) => {
const after = new Set(before)
if (!after.delete(i)) after.add(i)
return after
})
}
return (
<Card role="region" aria-labelledby={titleId} className={styles.ingredientsCard}>
<div className={styles.cardHead}>
<h2 id={titleId} className={styles.readingSectionTitle}>
Ingredients
</h2>
<span className={styles.setOutCount}>
{count} of {ingredients.length} set out
</span>
</div>
<ul className={styles.checklist}>
{ingredients.map((ingredient, i) => (
<li key={i} className={styles.checkRow}>
<Checkbox
className={styles.check}
checked={setOut.has(i)}
onChange={() => toggle(i)}
label={
<span className={[styles.checkName, setOut.has(i) && styles.setOut].filter(Boolean).join(' ')}>
{ingredient.name ?? ingredient.raw_text}
{ingredient.name !== null && ingredient.note !== null && `, ${ingredient.note}`}
</span>
}
/>
<span className={styles.checkQty}>{quantity(ingredient)}</span>
</li>
))}
</ul>
</Card>
)
}
// Steps is the recipe's steps: those before current done, muted with a
// check; current highlighted in larger text; the rest still to come.
function Steps({ steps, current }: { steps: string[]; current: number }) {
const titleId = useId()
return (
<section aria-labelledby={titleId} className={styles.readingSteps}>
<h2 id={titleId} className={styles.readingSectionTitle}>
Steps
</h2>
<ol className={styles.stepList}>
{steps.map((step, i) => (
<li
key={i}
aria-current={i === current ? 'step' : undefined}
className={[styles.readingStep, i < current ? styles.stepDone : i === current ? styles.stepCurrent : undefined]
.filter(Boolean)
.join(' ')}
>
<span className={styles.stepCircle} aria-hidden="true">
{i < current ? <Icon icon={Check} /> : i + 1}
</span>
<span className={styles.stepText}>{step}</span>
</li>
))}
</ol>
</section>
)
}
// typing says whether target is a field taking text, where the arrow keys
// move the caret and are not the screen's to take.
function typing(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
return target.isContentEditable || target.matches('textarea, select, input:not([type="checkbox"], [type="radio"])')
}
// useCurrentStep is the step being cooked, of count, from the first, with
// move, which goes back or on by a step and stops at either end. The ← and →
// keys move it too, unless a text field has focus, a sheet is open over the
// screen or the key was already taken (a segmented control's arrows).
function useCurrentStep(count: number): [number, (by: number) => void] {
const [step, setStep] = useState(0)
const last = count - 1
const move = useCallback(
(by: number) => setStep((before) => Math.max(0, Math.min(Math.min(before, last) + by, last))),
[last],
)
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
const by = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
if (by === 0 || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
if (typing(event.target) || (event.target instanceof Element && event.target.closest('[role="dialog"]') !== null)) return
event.preventDefault()
move(by)
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [move])
return [Math.max(0, Math.min(step, last)), move]
}
// KitchenReading is a dinner as mock 2a shows it on a tablet: the header,
// then the ingredients to check off beside the photo, why it was picked and
// the steps, one at a time, over the action bar with Previous and Next step.
// What is checked and the step reached last only as long as this visit.
function KitchenReading({ dinner }: { dinner: DinnerDetail }) {
const textSize = useTextSize()
const [current, move] = useCurrentStep(dinner.steps.length)
const recipe = hasRecipe(dinner)
const last = dinner.steps.length - 1
return (
<div className={[styles.reading, styles[textSize.size]].join(' ')}>
<ReadingHeader dinner={dinner} textSize={textSize} />
<div className={recipe && dinner.ingredients.length > 0 ? styles.columns : styles.oneColumn}>
{recipe && dinner.ingredients.length > 0 && <IngredientsCard ingredients={dinner.ingredients} />}
<div className={styles.method}>
<div className={styles.intro}>
<Photo id={dinner.photo_id} name={dinner.name} radius="24px" sizes="400px" className={styles.introPhoto} />
{dinner.why_picked !== null && (
<p className={[styles.why, styles.introWhy].join(' ')}>
<b>Why it was picked:</b> {dinner.why_picked}
</p>
)}
</div>
{!recipe && <RecipeMissing dinner={dinner} />}
{recipe && dinner.steps.length > 0 && <Steps steps={dinner.steps} current={current} />}
{recipe && dinner.source !== null && (
<SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />
)}
<ActionBar dinner={dinner}>
{recipe && dinner.steps.length > 0 && (
<div className={styles.stepButtons}>
<Button variant="outline" disabled={current === 0} onClick={() => move(-1)}>
<Icon icon={ChevronLeft} />
Previous
</Button>
<Button disabled={current === last} onClick={() => move(1)}>
<Icon icon={ChevronRight} />
Next step
</Button>
</div>
)}
</ActionBar>
</div>
</div>
</div>
)
}
// DinnerDetailPage is a dinner with its recipe: on a phone (mock 1k) its
// photo, or the striped placeholder, under a sheet with Regular or Side
// quest, difficulty and time, why it was picked, the ingredients and the
// steps; from tablet up, Kitchen reading mode (mock 2a). A dinner whose
// recipe could not be found links to Settings → Regular dinners to fix it;
// an unknown or removed dinner says it is not found.
export function DinnerDetailPage() {
const { id = '' } = useParams()
const layout = useBreakpoint()
const dinner = useDinnerDetail(id)
let body
if (dinner.isError) {
body = (
<>
<div className={styles.backRow}>
<BackButton />
</div>
{dinner.error instanceof ApiError && dinner.error.status === 404 ? (
<>
<EmptyState title="Dinner not found">This dinner isn't in our kitchen — it may have been removed.</EmptyState>
<Link to="/" className={[buttonStyles.button, buttonStyles.outline].join(' ')}>
Go Home
</Link>
</>
) : (
<ErrorBanner onRetry={() => void dinner.refetch()}>Couldn't load this dinner. {dinner.error.message}</ErrorBanner>
)}
</>
)
} else if (dinner.isPending) {
body = (
<section aria-busy="true" aria-label="Dinner loading">
<Skeleton className={styles.skeletonHero} />
<div className={styles.sheet}>
<Skeleton className={styles.skeletonTitle} />
<Skeleton />
<Skeleton />
</div>
</section>
)
} else {
// Keyed by the dinner, so moving to another dinner starts its visit
// afresh: nothing checked, on the first step.
body =
layout === 'mobile' ? <Detail dinner={dinner.data} /> : <KitchenReading key={dinner.data.id} dinner={dinner.data} />
}
return <div className={`${styles.page} ${styles[layout]}`}>{body}</div>
}
-604
View File
@@ -1,604 +0,0 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { errorResponse, jsonResponse } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
import { TEXT_SIZE_KEY } from './textSize.ts'
const DINNER = '/api/v1/dinners/d1'
const SETTINGS = '/api/v1/settings/household'
const THIS_WEEK = '/api/v1/plans/2026-10-05'
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
// detail is the Korean beef bowls, a Quest-log Side quest, as GET
// /api/v1/dinners/{id} sends it.
function detail(overrides: Record<string, unknown> = {}) {
return {
id: 'd1',
name: 'Korean beef bowls',
status: 'quest_log',
origin: 'suggested',
method: 'stovetop',
minutes_total: 30,
difficulty: 'easy',
cuisine: 'korean',
is_favorite: false,
photo_urls: null,
ingredients: [
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
{ name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' },
],
steps: [
'Rinse the rice and start it cooking.',
'Brown the beef with the garlic and ginger.',
'Stir in the sauce and simmer until glossy.',
],
source: null,
grounding_status: 'grounded',
grounding_error: null,
why_picked: 'close cousin of your Beef tacos.',
...overrides,
}
}
// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it: p0 to
// p6, except the bowls (d1) on each day of bowlsOn, counting from Monday = 0.
function planBody(weekStart: string, ...bowlsOn: number[]) {
const start = new Date(`${weekStart}T00:00:00Z`).getTime()
return {
id: `plan-${weekStart}`,
week_start: weekStart,
status: 'draft',
ai_note: null,
calendar_synced_at: null,
days: Array.from({ length: 7 }, (_, i) => ({
date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
dinner:
bowlsOn.includes(i)
? { id: 'd1', name: 'Korean beef bowls', photo: null, minutes: 30, method: 'stovetop' }
: { id: `p${i}`, name: `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop' },
is_new: bowlsOn.includes(i),
start_by: null,
})),
}
}
// serve answers the screen's reads: the dinner, the household settings and
// both weeks' plans, unless responses says otherwise.
function serve(responses: Record<string, () => Response> = {}): Mock<typeof fetch> {
const all: Record<string, () => Response> = {
[DINNER]: () => jsonResponse(200, detail()),
[SETTINGS]: () => jsonResponse(200, HOUSEHOLD_SETTINGS),
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 5)),
[NEXT_WEEK]: () => errorResponse(404, 'not_found'),
...responses,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const path = String(input)
const respond = Object.hasOwn(all, path) ? all[path] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`)
return Promise.resolve(respond())
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
function reads(fetchMock: Mock<typeof fetch>, path: string) {
return fetchMock.mock.calls.filter(([input]) => String(input) === path).length
}
// renderPage renders the bowls' detail screen and waits for its title.
async function renderPage() {
const router = createMemoryRouter([{ path: '/dinner/:id', element: <DinnerDetailPage /> }], {
initialEntries: ['/dinner/d1'],
})
const view = render(
<ApiProviders>
<RouterProvider router={router} />
</ApiProviders>,
)
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
return view
}
function steps() {
return within(screen.getByRole('region', { name: 'Steps' })).getAllByRole('listitem')
}
// currentStep is the text of the step marked current.
function currentStep() {
const current = steps().filter((step) => step.getAttribute('aria-current') === 'step')
expect(current).toHaveLength(1)
return current[0]!.textContent
}
// FakeSentinel is a WakeLockSentinel as far as the screen uses one.
type FakeSentinel = EventTarget & { released: boolean; release: Mock<() => Promise<void>> }
// letGo releases a lock as the browser does, telling its listeners so.
function letGo(sentinel: FakeSentinel) {
if (sentinel.released) return
sentinel.released = true
sentinel.dispatchEvent(new Event('release'))
}
// stubWakeLock gives jsdom, which has none, a Screen Wake Lock. Each request
// is granted at once, or, given hold, only when the test calls grant.
function stubWakeLock({ hold = false } = {}) {
const sentinels: FakeSentinel[] = []
const pending: (() => void)[] = []
const request = vi.fn((type: string) => {
expect(type).toBe('screen')
const sentinel: FakeSentinel = Object.assign(new EventTarget(), {
released: false,
release: vi.fn(() => {
letGo(sentinel)
return Promise.resolve()
}),
})
sentinels.push(sentinel)
if (!hold) return Promise.resolve(sentinel)
return new Promise<FakeSentinel>((resolve) => pending.push(() => resolve(sentinel)))
})
Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request } })
return { request, sentinels, grant: () => pending.shift()!() }
}
// setVisibility shows or hides the page as the browser would, telling the
// page so.
function setVisibility(state: DocumentVisibilityState) {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
document.dispatchEvent(new Event('visibilitychange'))
}
beforeEach(() => {
mockViewport(1180)
// Wednesday Oct 7, noon in New York.
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
Reflect.deleteProperty(window, 'matchMedia')
Reflect.deleteProperty(navigator, 'wakeLock')
Reflect.deleteProperty(document, 'visibilityState')
vi.unstubAllGlobals()
})
describe('Kitchen reading mode', () => {
it('is the tablet layout of the same route: header with back, title, Keep screen on and text size', async () => {
stubWakeLock()
serve()
await renderPage()
const title = screen.getByRole('heading', { level: 1, name: 'Korean beef bowls' })
expect(declaredStyle(title).fontSize).toBe('36px')
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
// Not the phone's sheet: no photo hero tags.
expect(screen.queryByText('Easy')).not.toBeInTheDocument()
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
expect(keepOn).toHaveAttribute('aria-checked', 'false')
fireEvent.click(keepOn)
expect(keepOn).toHaveAttribute('aria-checked', 'true')
const sizes = screen.getByRole('radiogroup', { name: 'Text size' })
expect(within(sizes).getAllByRole('radio').map((radio) => radio.getAttribute('aria-label'))).toEqual([
'Small text',
'Medium text',
'Large text',
])
expect(within(sizes).getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true')
expect(declaredStyle(screen.getByRole('region', { name: 'Ingredients' }).parentElement!).gridTemplateColumns).toBe(
'400px minmax(0, 1fr)',
)
expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent('Why it was picked: close cousin')
})
describe('kicker', () => {
it('names the day the plan has the dinner on, and Side quest', async () => {
serve()
await renderPage()
expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument()
})
it("names a day of next week's plan, and Regular, for a Regular", async () => {
serve({
[DINNER]: () => jsonResponse(200, detail({ status: 'regular', origin: 'regular' })),
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05')),
[NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12', 0)),
})
await renderPage()
expect(await screen.findByText('Monday, Oct 12 · Regular')).toBeInTheDocument()
})
it("names a day of this week's plan already past when that is the only day", async () => {
// Today is Wednesday; the bowls were on Monday, and are on no day of next week.
serve({
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0)),
[NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12')),
})
await renderPage()
expect(await screen.findByText('Monday, Oct 5 · Side quest')).toBeInTheDocument()
})
it('names the first day from today on over a day already past', async () => {
serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1, 4, 6)) })
await renderPage()
expect(await screen.findByText('Friday, Oct 9 · Side quest')).toBeInTheDocument()
expect(screen.queryByText(/Monday|Tuesday|Sunday/)).not.toBeInTheDocument()
})
it('names the latest day already past when every day is past', async () => {
serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1)) })
await renderPage()
expect(await screen.findByText('Tuesday, Oct 6 · Side quest')).toBeInTheDocument()
expect(screen.queryByText(/Monday/)).not.toBeInTheDocument()
})
it('is absent when neither week has a plan', async () => {
const fetchMock = serve({ [THIS_WEEK]: () => errorResponse(404, 'not_found') })
await renderPage()
await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(1))
await waitFor(() => expect(reads(fetchMock, NEXT_WEEK)).toBe(1))
await act(async () => {})
expect(screen.queryByText(/· Side quest/)).not.toBeInTheDocument()
expect(screen.queryByText(/Oct/)).not.toBeInTheDocument()
})
it('says so when the plan cannot be read, with Retry', async () => {
let failing = true
serve({
[THIS_WEEK]: () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, planBody('2026-10-05', 5))),
})
await renderPage()
expect(await screen.findByText(/Couldn't check the plan for this dinner's day/)).toBeInTheDocument()
failing = false
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument()
})
})
describe('ingredients', () => {
it('checks ingredients off with 22 px checkboxes, struck through, counting those set out', async () => {
const fetchMock = serve()
await renderPage()
const card = screen.getByRole('region', { name: 'Ingredients' })
expect(within(card).getByText('0 of 3 set out')).toBeInTheDocument()
const beef = within(card).getByRole('checkbox', { name: 'Ground beef' })
expect(declaredStyle(beef).width).toBe('22px')
expect(declaredStyle(beef).height).toBe('22px')
expect(within(card).getByRole('checkbox', { name: 'Green onions, sliced' })).not.toBeChecked()
fireEvent.click(beef)
expect(beef).toBeChecked()
expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('line-through')
fireEvent.click(within(card).getByRole('checkbox', { name: 'Jasmine rice' }))
expect(within(card).getByText('2 of 3 set out')).toBeInTheDocument()
fireEvent.click(beef)
expect(beef).not.toBeChecked()
expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('')
expect(within(card).getByText('1 of 3 set out')).toBeInTheDocument()
// Local to the screen: nothing is sent.
expect(fetchMock.mock.calls.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')).toEqual([])
})
it('starts afresh on the next visit', async () => {
serve()
const view = await renderPage()
fireEvent.click(screen.getByRole('checkbox', { name: 'Ground beef' }))
fireEvent.click(screen.getByRole('button', { name: 'Next step' }))
view.unmount()
await renderPage()
expect(screen.getByText('0 of 3 set out')).toBeInTheDocument()
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
})
})
describe('steps', () => {
it('highlights the current step in 22 px text, others 18 px, and moves by Previous and Next step', async () => {
serve()
await renderPage()
const previous = screen.getByRole('button', { name: 'Previous' })
const next = screen.getByRole('button', { name: 'Next step' })
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
expect(previous).toBeDisabled()
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(22px * var(--reading-scale))')
expect(declaredStyle(steps()[0]!).background).toBe('var(--color-accent-100)')
expect(declaredStyle(steps()[0]!).boxShadow).toBe('inset 0 0 0 2px var(--color-accent-300)')
expect(declaredStyle(steps()[1]!).fontSize).toBe('calc(18px * var(--reading-scale))')
fireEvent.click(next)
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
// The step done is muted, its number a check.
expect(steps()[0]!.textContent).toBe('Rinse the rice and start it cooking.')
expect(steps()[0]!.querySelector('svg')).not.toBeNull()
expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-600)')
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(18px * var(--reading-scale))')
fireEvent.click(next)
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
expect(next).toBeDisabled()
fireEvent.click(next)
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
fireEvent.click(previous)
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
})
it('moves by the ← and → keys, stopping at either end', async () => {
serve()
await renderPage()
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
// From a checked ingredient too.
const beef = screen.getByRole('checkbox', { name: 'Ground beef' })
beef.focus()
fireEvent.keyDown(beef, { key: 'ArrowRight' })
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
})
it('leaves the keys alone in a text field, with a modifier, or in the text size control', async () => {
serve()
await renderPage()
const field = document.createElement('input')
document.body.append(field)
field.focus()
fireEvent.keyDown(field, { key: 'ArrowRight' })
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
field.remove()
fireEvent.keyDown(document.body, { key: 'ArrowRight', altKey: true })
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
const medium = screen.getByRole('radio', { name: 'Medium text' })
fireEvent.keyDown(medium, { key: 'ArrowRight' })
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
})
})
describe('text size', () => {
it('is remembered on this device', async () => {
serve()
const view = await renderPage()
fireEvent.click(screen.getByRole('radio', { name: 'Large text' }))
expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('large')
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
view.unmount()
await renderPage()
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'false')
expect(declaredStyle(screen.getByRole('heading', { level: 1 }).closest('header')!.parentElement!).getPropertyValue('--reading-scale')).toBe(
'1.25',
)
})
it('says so when the size saved is not one MealQuest saved, until one is chosen', async () => {
localStorage.setItem(TEXT_SIZE_KEY, 'huge')
serve()
await renderPage()
expect(screen.getByText(/Could not read the text size chosen on this device/)).toHaveTextContent('"huge"')
expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true')
fireEvent.click(screen.getByRole('radio', { name: 'Small text' }))
expect(screen.queryByText(/Could not read the text size/)).not.toBeInTheDocument()
expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('small')
})
})
describe('source credit', () => {
it.each([
['a site and a url', { site_name: 'Serious Eats', url: 'https://www.seriouseats.com/bowls' }, 'Recipe from Serious Eats'],
['only a url', { site_name: null, url: 'https://www.seriouseats.com/bowls' }, 'Recipe from www.seriouseats.com'],
])('links the recipe page in a new tab for %s', async (_, source, name) => {
serve({ [DINNER]: () => jsonResponse(200, detail({ source })) })
await renderPage()
const credit = screen.getByRole('link', { name })
expect(credit).toHaveAttribute('href', 'https://www.seriouseats.com/bowls')
expect(credit).toHaveAttribute('target', '_blank')
expect(credit).toHaveAttribute('rel', 'noopener noreferrer')
})
it.each([
['only a site', { site_name: 'Serious Eats', url: null }, 'Recipe from Serious Eats'],
['neither site nor url', { site_name: null, url: null }, 'Recipe from the web'],
])('is plain text for %s', async (_, source, text) => {
serve({ [DINNER]: () => jsonResponse(200, detail({ source })) })
await renderPage()
expect(screen.getByText(text).closest('a')).toBeNull()
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
})
it('is absent for a recipe typed in', async () => {
serve({ [DINNER]: () => jsonResponse(200, detail({ source: null, grounding_status: 'manual' })) })
await renderPage()
expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument()
})
})
it('shows what stands in for a recipe not found, with no step buttons', async () => {
serve({
[DINNER]: () =>
jsonResponse(200, detail({ ingredients: [], steps: [], grounding_status: 'failed', grounding_error: 'no_recipe_found' })),
})
await renderPage()
expect(screen.getByRole('link', { name: 'Fix recipe' })).toBeInTheDocument()
expect(screen.queryByRole('region', { name: 'Ingredients' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Next step' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add to this week' })).toBeInTheDocument()
})
describe('Keep screen on', () => {
it('holds a screen wake lock while on and releases it when turned off', async () => {
const wakeLock = stubWakeLock()
serve()
await renderPage()
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
expect(keepOn).toBeEnabled()
expect(wakeLock.request).not.toHaveBeenCalled()
fireEvent.click(keepOn)
expect(keepOn).toHaveAttribute('aria-checked', 'true')
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
expect(wakeLock.sentinels[0]!.release).not.toHaveBeenCalled()
fireEvent.click(keepOn)
expect(keepOn).toHaveAttribute('aria-checked', 'false')
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
// On again: a fresh lock.
fireEvent.click(keepOn)
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
expect(wakeLock.sentinels[1]!.released).toBe(false)
})
it('releases the lock when the screen is left', async () => {
const wakeLock = stubWakeLock()
serve()
const view = await renderPage()
fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' }))
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
view.unmount()
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
})
it('releases a lock granted only after the screen was left', async () => {
const wakeLock = stubWakeLock({ hold: true })
serve()
const view = await renderPage()
fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' }))
expect(wakeLock.request).toHaveBeenCalledTimes(1)
view.unmount()
await act(async () => wakeLock.grant())
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
})
it('asks for the lock again when the page is visible again while on, and not while off', async () => {
const wakeLock = stubWakeLock()
serve()
await renderPage()
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
fireEvent.click(keepOn)
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
// Hidden, the browser lets the lock go; nothing is asked while hidden.
act(() => setVisibility('hidden'))
act(() => letGo(wakeLock.sentinels[0]!))
expect(wakeLock.request).toHaveBeenCalledTimes(1)
act(() => setVisibility('visible'))
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
expect(keepOn).toHaveAttribute('aria-checked', 'true')
// Visible again with the lock still held: no second lock.
act(() => setVisibility('visible'))
expect(wakeLock.request).toHaveBeenCalledTimes(2)
fireEvent.click(keepOn)
act(() => setVisibility('hidden'))
act(() => setVisibility('visible'))
expect(wakeLock.request).toHaveBeenCalledTimes(2)
})
it('asks for the lock again when the browser lets it go while the page is visible', async () => {
const wakeLock = stubWakeLock()
serve()
await renderPage()
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
fireEvent.click(keepOn)
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
act(() => letGo(wakeLock.sentinels[0]!))
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
await act(async () => {})
expect(keepOn).toHaveAttribute('aria-checked', 'true')
// The fresh lock is watched too.
act(() => letGo(wakeLock.sentinels[1]!))
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(3))
expect(wakeLock.sentinels[2]!.released).toBe(false)
expect(keepOn).toHaveAttribute('aria-checked', 'true')
})
it('asks for the lock again when the page was hidden and shown while a request was pending', async () => {
const wakeLock = stubWakeLock({ hold: true })
serve()
await renderPage()
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
fireEvent.click(keepOn)
expect(wakeLock.request).toHaveBeenCalledTimes(1)
// Hidden and shown again before the first lock is granted, which the
// browser has let go by the time it is.
act(() => setVisibility('hidden'))
act(() => setVisibility('visible'))
expect(wakeLock.request).toHaveBeenCalledTimes(1)
letGo(wakeLock.sentinels[0]!)
await act(async () => wakeLock.grant())
expect(wakeLock.request).toHaveBeenCalledTimes(2)
await act(async () => wakeLock.grant())
expect(wakeLock.sentinels[1]!.released).toBe(false)
expect(wakeLock.request).toHaveBeenCalledTimes(2)
expect(keepOn).toHaveAttribute('aria-checked', 'true')
})
it('reports a lock the browser refuses and turns the switch back off', async () => {
const wakeLock = stubWakeLock()
wakeLock.request.mockRejectedValueOnce(new DOMException('Blocked by permissions policy', 'NotAllowedError'))
serve()
await renderPage()
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
fireEvent.click(keepOn)
expect(await screen.findByRole('alert')).toHaveTextContent(
"Couldn't keep the screen on: NotAllowedError: Blocked by permissions policy",
)
expect(keepOn).toHaveAttribute('aria-checked', 'false')
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(keepOn).toHaveAttribute('aria-checked', 'true')
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('is disabled, and says why, in a browser that cannot keep the screen on', async () => {
serve()
await renderPage()
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
expect(keepOn).toBeDisabled()
expect(keepOn).toHaveAttribute('aria-checked', 'false')
expect(keepOn).toHaveAccessibleDescription(
"This browser can't keep the screen on, so it may dim while you cook.",
)
fireEvent.click(keepOn)
expect(keepOn).toHaveAttribute('aria-checked', 'false')
})
})
})
+14 -35
View File
@@ -1,7 +1,6 @@
import { useIsMutating, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import type { DinnerDetail } from './detail.ts'
export const DINNERS_PATH = '/api/v1/dinners'
export const REGULARS_PATH = `${DINNERS_PATH}?status=regular`
@@ -13,7 +12,7 @@ export const GROUNDING_POLL_MS = 3000
// Methods and Difficulties are the values the dinners API allows.
export const METHODS = ['stovetop', 'oven', 'crockpot', 'grill', 'other'] as const
export const DIFFICULTIES = ['easy', 'medium', 'hard'] as const
export const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const
const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const
export type Method = (typeof METHODS)[number]
export type Difficulty = (typeof DIFFICULTIES)[number]
@@ -147,11 +146,6 @@ export function dinnerPath(id: string): string {
return `${DINNERS_PATH}/${encodeURIComponent(id)}`
}
// dinnerDetailKey is the query of dinner id with its recipe (useDinnerDetail).
export function dinnerDetailKey(id: string) {
return ['dinners', 'detail', id] as const
}
// useRegulars is the household's Regulars, in the server's order. While any
// of them is still finding its recipe it is read again every
// GROUNDING_POLL_MS, except while any change is in flight (whose settling
@@ -281,43 +275,28 @@ export function useEditRegular() {
})
}
// Favorite is the dinner whose heart is toggled, as it was before the toggle.
export type Favorite = { id: string; is_favorite: boolean }
// setFavorite shows dinner id's heart as is_favorite wherever it is cached:
// in the Regulars and in its detail. A cache not yet read is left unread.
function setFavorite(queryClient: QueryClient, id: string, is_favorite: boolean) {
queryClient.setQueryData<CachedRegular[]>(REGULARS_LIST_KEY, (list) =>
list?.map((r) => (r.id === id ? { ...r, is_favorite } : r)),
)
queryClient.setQueryData<DinnerDetail>(dinnerDetailKey(id), (dinner) => dinner && { ...dinner, is_favorite })
}
// useToggleFavorite toggles a dinner's favorite heart (POST
// /api/v1/dinners/{id}/favorite), a Regular's or any dinner's on its detail
// screen. The heart changes at once and changes back if the server refuses.
// The server answers with the dinner as the dinners list sends it, which
// replaces the Regular; the detail keeps its recipe and takes only the heart.
// useToggleFavorite toggles a Regular's favorite heart (POST
// /api/v1/dinners/{id}/favorite). The heart changes at once and changes
// back if the server refuses.
export function useToggleFavorite() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
const set = (id: string, is_favorite: boolean) =>
setRegulars(queryClient, (list) => list.map((r) => (r.id === id ? { ...r, is_favorite } : r)))
return useMutation({
mutationKey: REGULARS_MUTATION_KEY,
mutationFn: async (dinner: Favorite) =>
mutationFn: async (regular: Regular) =>
parseDinner(
`POST ${DINNERS_PATH}/{id}/favorite`,
await apiFetch<unknown>(`${dinnerPath(dinner.id)}/favorite`, { method: 'POST' }),
await apiFetch<unknown>(`${dinnerPath(regular.id)}/favorite`, { method: 'POST' }),
),
onMutate: async (dinner) => {
await queryClient.cancelQueries({ queryKey: dinnerDetailKey(dinner.id) })
await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY })
setFavorite(queryClient, dinner.id, !dinner.is_favorite)
onMutate: async (regular) => {
await changeOptimistically(queryClient, (list) =>
list.map((r) => (r.id === regular.id ? { ...r, is_favorite: !regular.is_favorite } : r)),
)
},
onSuccess: (dinner) => {
queryClient.setQueryData<CachedRegular[]>(REGULARS_LIST_KEY, (list) => list?.map((r) => (r.id === dinner.id ? dinner : r)))
queryClient.setQueryData<DinnerDetail>(dinnerDetailKey(dinner.id), (detail) => detail && { ...detail, is_favorite: dinner.is_favorite })
},
onError: (_error, dinner) => setFavorite(queryClient, dinner.id, dinner.is_favorite),
onSuccess: (dinner) => replace(queryClient, dinner),
onError: (_error, regular) => set(regular.id, regular.is_favorite),
onSettled: () => settle(queryClient),
})
}
-178
View File
@@ -1,178 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useSyncExternalStore } from 'react'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import {
DIFFICULTIES,
dinnerDetailKey,
dinnerPath,
DINNERS_PATH,
GROUNDING_POLL_MS,
GROUNDING_STATUSES,
METHODS,
oneOf,
photoId,
type Difficulty,
type GroundingStatus,
type Method,
} from './api.ts'
// The statuses a dinner with detail can have; an archived dinner is 404.
const STATUSES = ['suggested', 'quest_log', 'tried', 'regular'] as const
export type DinnerStatus = (typeof STATUSES)[number]
// Ingredient is one recipe line. raw_text is the line as the recipe gives
// it; name, qty, unit and note are the server's reading of it, each null
// when it has none — always name, qty and unit for a recipe typed in.
export type Ingredient = {
name: string | null
qty: string | null
unit: string | null
note: string | null
raw_text: string
}
// RecipeSource is where a fetched recipe came from. siteName is the site,
// or null when there is none to name: a page that named no site, or a name
// the server withheld because it could have come from the link. url is the
// recipe's page, or null when the server withheld the stored link because
// it could carry a token; the credit then never names its host.
export type RecipeSource = { siteName: string | null; url: string | null }
// DinnerDetail is a dinner with its recipe (GET /api/v1/dinners/{id}).
// source is null for a recipe typed in. cached says the service worker
// answered from its saved copy because the server could not be reached.
export type DinnerDetail = {
id: string
name: string
status: DinnerStatus
method: Method | null
minutes_total: number | null
difficulty: Difficulty | null
is_favorite: boolean
photo_id: string | undefined
ingredients: Ingredient[]
steps: string[]
source: RecipeSource | null
grounding_status: GroundingStatus
why_picked: string | null
cached: boolean
}
function text(where: string, field: string, value: unknown): string {
if (typeof value !== 'string') throw new Error(`${where}: ${field} is not a string`)
return value
}
function nullableText(where: string, field: string, value: unknown): string | null {
return value === null ? null : text(where, field, value)
}
function parseIngredient(where: string, body: unknown): Ingredient {
if (!isRecord(body)) throw new Error(`${where}: ingredient is not an object`)
return {
name: nullableText(where, 'ingredient name', body.name),
qty: nullableText(where, 'ingredient qty', body.qty),
unit: nullableText(where, 'ingredient unit', body.unit),
note: nullableText(where, 'ingredient note', body.note),
raw_text: text(where, 'ingredient raw_text', body.raw_text),
}
}
// detailPhotoId is the photo id of photo_urls, which the server sends as
// both cached sizes of one photo, or null.
function detailPhotoId(where: string, value: unknown): string | undefined {
if (value === null) return undefined
if (!isRecord(value)) throw new Error(`${where}: photo_urls is not an object`)
const id = photoId(where, value['400'])
if (id === undefined || value['1200'] !== `/media/photos/${encodeURIComponent(id)}-1200.jpg`) {
throw new Error(`${where}: photo_urls is not both sizes of one stored photo`)
}
return id
}
// sourceUrl is source.url, null when the server withholds it, or a link safe
// to open: https, with no user, password, query or fragment.
function sourceUrl(where: string, value: unknown): string | null {
if (value === undefined || value === null) return null
const href = text(where, 'source url', value)
let link: URL
try {
link = new URL(href)
} catch (error) {
throw new Error(`${where}: source url is not a URL`, { cause: error })
}
if (link.protocol !== 'https:' || link.username !== '' || link.password !== '' || link.search !== '' || link.hash !== '') {
throw new Error(`${where}: source url is not an https link without credentials, query or fragment`)
}
return link.href
}
function parseSource(where: string, value: unknown): RecipeSource | null {
if (value === null) return null
if (!isRecord(value)) throw new Error(`${where}: source is not an object`)
return { siteName: nullableText(where, 'source site_name', value.site_name), url: sourceUrl(where, value.url) }
}
// parseDinnerDetail checks a dinner with its recipe as the API sends it;
// cached says whether the service worker answered from its saved copy.
export function parseDinnerDetail(where: string, body: unknown, cached: boolean): DinnerDetail {
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
throw new Error(`${where}: response is not a dinner`)
}
if (typeof body.is_favorite !== 'boolean') throw new Error(`${where}: is_favorite is not a boolean`)
if (!Array.isArray(body.ingredients) || !Array.isArray(body.steps)) throw new Error(`${where}: response has no recipe lists`)
const minutes = body.minutes_total
if (minutes !== null && (typeof minutes !== 'number' || !Number.isInteger(minutes))) {
throw new Error(`${where}: minutes_total is not a whole number`)
}
return {
id: body.id,
name: body.name,
status: oneOf(where, 'status', STATUSES, body.status),
method: body.method === null ? null : oneOf(where, 'method', METHODS, body.method),
minutes_total: minutes,
difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty),
is_favorite: body.is_favorite,
photo_id: detailPhotoId(where, body.photo_urls),
ingredients: body.ingredients.map((ingredient) => parseIngredient(where, ingredient)),
steps: body.steps.map((step) => text(where, 'step', step)),
source: parseSource(where, body.source),
grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status),
why_picked: nullableText(where, 'why_picked', body.why_picked),
cached,
}
}
// useDinnerDetail is dinner id with its recipe (GET /api/v1/dinners/{id}).
// While the dinner is still finding its recipe it is read again every
// GROUNDING_POLL_MS, but only while the server can be reached: polling pauses
// while the browser is offline or the last read was the service worker's
// saved copy, which would only answer the same again, and a saved pending
// copy is read once more when the connection comes back. Polling stops on
// unmount. An unknown or archived dinner fails with the API's 404. It runs
// even while the browser reports offline, so the copy the service worker
// saved is shown, or the read fails, instead of waiting for the network.
export function useDinnerDetail(id: string) {
const { apiRead, network } = useApi()
const offline = useSyncExternalStore(network.subscribe, network.offline)
const query = useQuery({
queryKey: dinnerDetailKey(id),
queryFn: async () => {
const read = await apiRead<unknown>(dinnerPath(id))
return parseDinnerDetail(`GET ${DINNERS_PATH}/{id}`, read.body, read.cached)
},
networkMode: 'always',
refetchInterval: (query) => {
const dinner = query.state.data
return dinner?.grounding_status === 'pending' && !dinner.cached && !offline ? GROUNDING_POLL_MS : false
},
})
const { data, refetch } = query
const stalled = data?.grounding_status === 'pending' && data.cached
useEffect(() => {
if (stalled && !offline) void refetch()
}, [stalled, offline, refetch])
return query
}
-86
View File
@@ -1,86 +0,0 @@
import { useEffect, useState } from 'react'
// KeepScreenOn is Kitchen reading mode's Keep screen on switch: whether it is
// on, setOn, whether this browser can keep the screen on at all, and why the
// screen could not be kept on, if it could not.
export type KeepScreenOn = {
on: boolean
setOn: (on: boolean) => void
supported: boolean
failure: string | null
}
// useKeepScreenOn holds a Screen Wake Lock while the switch is on, and only
// then. It starts off on each visit. The browser lets the lock go whenever
// the page is hidden, so it is asked for again each time the page is visible
// while the switch is on. Turning the switch off or leaving the screen
// releases it, including a lock still being granted as the screen is left.
//
// A browser without the Screen Wake Lock API (or a page not served over
// HTTPS) cannot keep the screen on: supported is false and the switch must
// not be offered as working. A request the browser refuses turns the switch
// back off and is reported as failure; turning it on again asks again.
export function useKeepScreenOn(): KeepScreenOn {
const [on, setOnState] = useState(false)
const [failure, setFailure] = useState<string | null>(null)
const supported = 'wakeLock' in navigator
useEffect(() => {
if (!on) return
const wakeLock = navigator.wakeLock
let sentinel: WakeLockSentinel | null = null
let requesting = false
let left = false
// The browser may let a lock go while the switch is on: always when the
// page is hidden, and, the specification allows, while it is still
// visible. Either way the lock is asked for again at once if the page is
// visible, or when it is visible again if not, so the switch never claims
// a lock that is not held.
function onRelease() {
if (!left) acquire()
}
function acquire() {
if (document.visibilityState !== 'visible' || requesting) return
if (sentinel !== null && !sentinel.released) return
requesting = true
wakeLock.request('screen').then(
(granted) => {
requesting = false
if (left) {
void granted.release()
return
}
sentinel = granted
granted.addEventListener('release', onRelease)
// While the request was pending the page may have been hidden and
// shown again (that visibilitychange was ignored) and the lock let
// go before it could be listened for, so check again now.
acquire()
},
(err: unknown) => {
requesting = false
if (left) return
setFailure(String(err))
setOnState(false)
},
)
}
acquire()
document.addEventListener('visibilitychange', acquire)
return () => {
left = true
document.removeEventListener('visibilitychange', acquire)
if (sentinel !== null) void sentinel.release()
}
}, [on])
function setOn(next: boolean) {
if (next) setFailure(null)
setOnState(next)
}
return { on, setOn, supported, failure }
}
-52
View File
@@ -1,52 +0,0 @@
{
"cases": [
{
"stored": "https://kitchen.example/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": "https://kitchen.example/recipes/chicken-curry" }
},
{
"stored": "https://Kitchen-2.Example.com.:8443/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": "https://Kitchen-2.Example.com.:8443/recipes/chicken-curry" }
},
{
"stored": "https://[2001:db8::1]/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": "https://[2001:db8::1]/recipes/chicken-curry" }
},
{
"stored": "https://192.0.2.7/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": "https://192.0.2.7/recipes/chicken-curry" }
},
{
"stored": "https://[bad]/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": null }
},
{
"stored": "https://[fe80::1%25en0]/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": null }
},
{
"stored": "https://192.0.2.999/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": null }
},
{
"stored": "https://xn--a.example/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": null }
},
{
"stored": "https://kitchen.example/recipes/chicken-curry?access_token=query-token",
"source": { "site_name": "Curry House", "url": null }
},
{
"stored": "https://user-token@kitchen.example/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": null }
},
{
"stored": "http://kitchen.example/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": null }
},
{
"stored": "https://kitchen.example/recipes/chicken-curry#token=fragment-token",
"source": { "site_name": "Curry House", "url": null }
}
]
}
-63
View File
@@ -1,63 +0,0 @@
import { useState } from 'react'
// TEXT_SIZE_KEY is the localStorage key remembering, on this device, the text
// size chosen in Kitchen reading mode.
export const TEXT_SIZE_KEY = 'mealquest.readingTextSize'
export const TEXT_SIZES = ['small', 'medium', 'large'] as const
export type TextSize = (typeof TEXT_SIZES)[number]
// DEFAULT_TEXT_SIZE is the size of a device that has never chosen one: the
// handoff's 18 px ingredients and steps.
export const DEFAULT_TEXT_SIZE: TextSize = 'medium'
// TextSizeState is the text size, and the storage failure, if any, that
// stopped us reading or saving it.
type TextSizeState = {
size: TextSize
failure: { action: 'read' | 'write'; message: string } | null
}
function isTextSize(value: string): value is TextSize {
return (TEXT_SIZES as readonly string[]).includes(value)
}
// Storage can be unavailable (blocked by the browser or privacy settings), in
// which case using it throws, and can hold a value MealQuest did not save.
// Either is reported, never treated as a remembered size.
function readTextSize(): TextSizeState {
try {
const saved = window.localStorage.getItem(TEXT_SIZE_KEY)
if (saved === null) return { size: DEFAULT_TEXT_SIZE, failure: null }
if (!isTextSize(saved)) throw new Error(`"${saved}" is not a text size MealQuest saved`)
return { size: saved, failure: null }
} catch (err) {
return { size: DEFAULT_TEXT_SIZE, failure: { action: 'read', message: String(err) } }
}
}
function writeTextSize(size: TextSize): TextSizeState {
try {
window.localStorage.setItem(TEXT_SIZE_KEY, size)
return { size, failure: null }
} catch (err) {
return { size, failure: { action: 'write', message: String(err) } }
}
}
export const textSizeFailureText = {
read: 'Could not read the text size chosen on this device',
write: 'Could not remember the text size on this device',
}
// useTextSize is Kitchen reading mode's text size on this device: the size,
// the storage failure if any, choose, which saves a size, and retry, which
// reads or saves again whatever failed. A size that could not be saved is
// still used until the screen is left.
export function useTextSize() {
const [state, setState] = useState<TextSizeState>(readTextSize)
const choose = (size: TextSize) => setState(writeTextSize(size))
const retry = () => setState(state.failure?.action === 'write' ? writeTextSize(state.size) : readTextSize())
return { ...state, choose, retry }
}
-193
View File
@@ -1,193 +0,0 @@
/* Home, handoff mock 1g. */
.page {
box-sizing: border-box;
max-width: 560px;
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 6px;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 2px;
}
.date {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 28px;
font-weight: 400;
line-height: 1.1;
}
/* The bell's 8 px grape unread dot sits on the 40 px face, which is
centred in the IconButton's 44 px hit box. */
.bell {
position: relative;
}
.bell .bellIcon {
width: 19px;
height: 19px;
}
.dot {
position: absolute;
top: 10px;
right: 11px;
width: 8px;
height: 8px;
border-radius: var(--radius-pill);
background: var(--color-accent);
}
.card {
display: flex;
flex-direction: column;
border-radius: 32px;
background: var(--color-surface);
overflow: hidden;
}
.photo {
height: 150px;
}
.cardFoot {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 10px;
padding: 2px 18px 16px;
}
.cardText {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.name {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
line-height: 1.1;
}
.meta {
font-family: var(--font-body);
font-size: 12px;
color: var(--color-neutral-700);
}
/* The dark "Recipe" pill: the mock's 38 px pill at the 44 px tap minimum. */
.recipe {
flex: none;
min-height: 44px;
padding: 0 16px;
font-size: 13px;
text-decoration: none;
}
.tiles {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.tile {
box-sizing: border-box;
height: 104px;
padding: 14px;
display: flex;
flex-direction: column;
justify-content: space-between;
border-radius: 26px;
text-decoration: none;
}
.quick {
background: var(--color-accent);
color: var(--on-accent);
}
.pantry {
background: var(--color-accent-2-300);
color: var(--color-accent-2-900);
}
.tileLabel {
font-family: var(--font-heading);
font-size: 16px;
line-height: 1.1;
}
.statuses {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.status {
box-sizing: border-box;
min-height: 44px;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 20px;
background: var(--color-neutral-100);
color: var(--color-text);
text-decoration: none;
}
.status .statusIcon {
width: 18px;
height: 18px;
color: var(--color-accent-700);
}
.statusText {
flex: 1;
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
}
.tile:focus-visible,
.status:focus-visible {
outline: 3px solid var(--color-accent);
outline-offset: 2px;
}
.skeletonDate {
width: 96px;
height: 14px;
margin-bottom: 2px;
}
.skeletonCard {
height: 238px;
border-radius: 32px;
}
.skeletonStatus {
height: 44px;
border-radius: 20px;
}
-319
View File
@@ -1,319 +0,0 @@
import { act, fireEvent, render, screen, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProviders, HOME } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { errorResponse, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { HOME_PATH } from './api.ts'
import { HomePage } from './HomePage.tsx'
import { ROTATION_REVIEWS_PATH } from './rotation.ts'
// NOTHING_YET is the home of a household with no plan, no Side quests, no
// Supply run and nothing unread.
const NOTHING_YET = {
...HOME,
tonight: null,
sideQuestCount: 0,
shoppingList: { weekStart: '2026-09-28', label: 'Week 40 · Sep 28 – Oct 4', ready: false, sentToChatAt: null },
recentUpdates: { unread: false },
}
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
// Where shows the route a link went to.
function Where() {
const location = useLocation()
return <p data-testid="where">{location.pathname + location.search}</p>
}
function renderHome(body: unknown) {
stubFetchByPath({ [HOME_PATH]: () => jsonResponse(200, body), [ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }) })
return renderRouter()
}
function renderRouter() {
const router = createMemoryRouter(
[
{ path: '/', element: <HomePage /> },
{ path: '*', element: <Where /> },
],
{ initialEntries: ['/'] },
)
render(
<ApiProviders>
<RouterProvider router={router} />
</ApiProviders>,
)
}
describe('HomePage', () => {
it('renders mock 1g from GET /api/v1/home', async () => {
renderHome(HOME)
expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
const tonight = screen.getByRole('region', { name: 'Tonight' })
expect(within(tonight).getByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
expect(within(tonight).getByText('Regular · 40 min')).toBeInTheDocument()
expect(within(tonight).getByRole('img', { name: 'Pizza night' })).toHaveAttribute('src', '/media/photos/p1-400.jpg')
expect(within(tonight).getByRole('link', { name: 'Recipe' })).toHaveAttribute('href', '/dinner/d1')
expect(screen.getByRole('link', { name: 'Quick dinner' })).toHaveAttribute('href', '/quick')
expect(screen.getByRole('link', { name: 'Cook from what I have' })).toHaveAttribute('href', '/cook-from-pantry')
expect(screen.getByRole('link', { name: '10 new side quests to swipe' })).toHaveAttribute('href', '/discover')
expect(screen.getByRole('link', { name: 'Shopping list ready · Oct 5–11' })).toHaveAttribute(
'href',
'/plan/shopping?week=2026-10-05',
)
expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument()
})
it("reads Home again at the household's next local midnight, so a Home left open moves to the new day", async () => {
// setTimeout moves with real time, and the test jumps to midnight.
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'], shouldAdvanceTime: true })
// 23:58 on Friday Oct 2 in New York, the household's zone.
vi.setSystemTime(new Date('2026-10-03T03:58:00Z'))
const days = [HOME, { ...HOME, dateLabel: 'Saturday, Oct 3', tonight: null }]
let reads = 0
stubFetchByPath({
[HOME_PATH]: () => jsonResponse(200, days[Math.min(reads++, 1)]),
[ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }),
})
renderRouter()
expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument()
expect(reads).toBe(1)
// A minute before midnight Home is not read again.
await act(() => vi.advanceTimersByTimeAsync(60_000))
expect(reads).toBe(1)
// At midnight it is, and shows the new day.
await act(() => vi.advanceTimersByTimeAsync(60_000))
expect(await screen.findByText('Saturday, Oct 3')).toBeInTheDocument()
expect(reads).toBe(2)
expect(screen.getByRole('heading', { name: 'No quest planned tonight' })).toBeInTheDocument()
})
it('shows the grape unread dot on the bell when there are unread updates', async () => {
renderHome(HOME)
const bell = await screen.findByRole('button', { name: 'Recent updates, unread' })
const dot = within(bell).getByTestId('unread-dot')
expect(declaredStyle(dot).background).toBe('var(--color-accent)')
expect(declaredStyle(dot).width).toBe('8px')
expectMinHitArea(bell)
})
it('shows the bell without a dot when every update is seen', async () => {
renderHome(NOTHING_YET)
const bell = await screen.findByRole('button', { name: 'Recent updates' })
expect(within(bell).queryByTestId('unread-dot')).not.toBeInTheDocument()
})
it('labels a Side quest tonight "Side quest"', async () => {
renderHome({ ...HOME, tonight: { ...HOME.tonight, origin: 'side_quest', minutes_total: 30 } })
expect(await screen.findByText('Side quest · 30 min')).toBeInTheDocument()
})
it('shows only the origin when tonight has no minutes', async () => {
renderHome({ ...HOME, tonight: { ...HOME.tonight, minutes_total: null, photo: null } })
const tonight = await screen.findByRole('region', { name: 'Tonight' })
expect(within(tonight).getByText('Regular')).toBeInTheDocument()
// No photo: the striped placeholder, named for the dinner.
expect(within(tonight).getByRole('img', { name: 'Pizza night' })).toHaveTextContent('dish photo')
})
it('offers to plan the week when tonight has no plan, and hides the rows that do not apply', async () => {
renderHome(NOTHING_YET)
expect(await screen.findByRole('heading', { name: 'No quest planned tonight' })).toBeInTheDocument()
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /side quests? to swipe/ })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
// The tiles stay.
expect(screen.getByRole('link', { name: 'Quick dinner' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Plan this week' }))
expect(await screen.findByTestId('where')).toHaveTextContent('/plan')
})
it('hides the Side quests row at zero and keeps a ready Supply run', async () => {
renderHome({ ...HOME, sideQuestCount: 0 })
expect(await screen.findByRole('link', { name: 'Shopping list ready · Oct 5–11' })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /side quests? to swipe/ })).not.toBeInTheDocument()
})
it('hides the Supply run row when no list is ready', async () => {
renderHome({ ...HOME, shoppingList: NOTHING_YET.shoppingList })
expect(await screen.findByRole('link', { name: '10 new side quests to swipe' })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
})
it('counts a single Side quest in the singular', async () => {
renderHome({ ...HOME, sideQuestCount: 1 })
expect(await screen.findByRole('link', { name: '1 new side quest to swipe' })).toBeInTheDocument()
})
it('shows a week spanning two months in full', async () => {
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, weekStart: '2026-09-28', label: 'Week 40 · Sep 28 – Oct 4' } })
expect(await screen.findByRole('link', { name: 'Shopping list ready · Sep 28–Oct 4' })).toBeInTheDocument()
})
it.each([
['Recipe', '/dinner/d1'],
['Quick dinner', '/quick'],
['Cook from what I have', '/cook-from-pantry'],
['10 new side quests to swipe', '/discover'],
['Shopping list ready · Oct 5–11', '/plan/shopping?week=2026-10-05'],
])('%s goes to %s', async (name, to) => {
renderHome(HOME)
fireEvent.click(await screen.findByRole('link', { name }))
expect(await screen.findByTestId('where')).toHaveTextContent(to)
})
it('styles the card, tiles and rows as mock 1g with tap targets of at least 44 px', async () => {
renderHome(HOME)
const tonight = await screen.findByRole('region', { name: 'Tonight' })
expect(declaredStyle(tonight).borderRadius).toBe('32px')
expect(declaredStyle(within(tonight).getByRole('img')).height).toBe('150px')
expect(declaredStyle(within(tonight).getByRole('heading')).fontSize).toBe('22px')
const quick = screen.getByRole('link', { name: 'Quick dinner' })
expect(declaredStyle(quick).height).toBe('104px')
expect(declaredStyle(quick).background).toBe('var(--color-accent)')
expect(declaredStyle(quick).color).toBe('var(--on-accent)')
const pantry = screen.getByRole('link', { name: 'Cook from what I have' })
expect(declaredStyle(pantry).height).toBe('104px')
expect(declaredStyle(pantry).background).toBe('var(--color-accent-2-300)')
expect(declaredStyle(screen.getByText('Friday, Oct 2')).fontSize).toBe('12px')
expectMinHitArea(within(tonight).getByRole('link', { name: 'Recipe' }))
// The tiles are 104 px tall (above); the status rows fill the width at 44 px or more.
for (const row of within(screen.getByRole('list', { name: 'Status' })).getAllByRole('link')) {
expect(declaredStyle(row).minHeight).toBe('44px')
}
})
it('shows skeletons while the home loads', async () => {
let answer!: (response: Response) => void
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>(() => new Promise((resolve) => (answer = resolve))),
)
renderRouter()
expect(screen.getByTestId('skeleton-tonight')).toBeInTheDocument()
expect(screen.getByTestId('skeleton-date')).toBeInTheDocument()
expect(screen.getAllByTestId('skeleton-status')).toHaveLength(2)
expect(screen.getByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
answer(jsonResponse(200, HOME))
expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument()
expect(screen.queryByTestId('skeleton-tonight')).not.toBeInTheDocument()
expect(screen.queryByTestId('skeleton-status')).not.toBeInTheDocument()
})
it('reports a failure with Retry, and no made-up content', async () => {
const homes = [errorResponse(500, 'internal'), jsonResponse(200, HOME)]
stubFetchByPath({
[HOME_PATH]: () => homes.shift()!,
[ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }),
})
renderRouter()
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load tonight's quest. internal message")
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument()
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('reports a home it cannot read rather than guessing', async () => {
renderHome({ ...HOME, tonight: { ...HOME.tonight, origin: 'leftovers' } })
expect(await screen.findByRole('alert')).toHaveTextContent('origin "leftovers" is not one of regular, side_quest')
})
it.each([0, -1, 2.5])('reports tonight with minutes_total %s rather than showing it', async (minutes) => {
renderHome({ ...HOME, tonight: { ...HOME.tonight, minutes_total: minutes } })
expect(await screen.findByRole('alert')).toHaveTextContent(
`tonight's minutes_total ${minutes} is not a positive whole number`,
)
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
expect(screen.queryByText(/min$/)).not.toBeInTheDocument()
})
it('reports tonight without a minutes_total rather than showing it as unknown', async () => {
const { minutes_total: _, ...tonight } = HOME.tonight
renderHome({ ...HOME, tonight })
expect(await screen.findByRole('alert')).toHaveTextContent('tonight has no minutes_total')
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
expect(screen.queryByText('Regular')).not.toBeInTheDocument()
})
it.each([
['a missing', undefined],
['an empty', ''],
])('reports a ready Supply run with %s label rather than showing it', async (_, label) => {
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, label } })
expect(await screen.findByRole('alert')).toHaveTextContent('the ready shoppingList has no label')
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
})
it('reports a ready Supply run whose label is not its week rather than showing it', async () => {
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, label: 'Week 40 · Sep 28 – Oct 4' } })
expect(await screen.findByRole('alert')).toHaveTextContent(
'shoppingList label "Week 40 · Sep 28 – Oct 4" is not the week of 2026-10-05',
)
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
})
it.each([
['a missing', undefined],
['an empty', ''],
])('reports an unready Supply run with %s label rather than showing the home', async (_, label) => {
renderHome({ ...HOME, shoppingList: { ...NOTHING_YET.shoppingList, label } })
expect(await screen.findByRole('alert')).toHaveTextContent('the unready shoppingList has no label')
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
})
it('reports an unready Supply run whose label is not its week rather than showing the home', async () => {
renderHome({ ...HOME, shoppingList: { ...NOTHING_YET.shoppingList, label: 'Week 41 · Oct 5 – 11' } })
expect(await screen.findByRole('alert')).toHaveTextContent(
'shoppingList label "Week 41 · Oct 5 – 11" is not the week of 2026-09-28',
)
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
})
it('reports a Supply run whose weekStart is not a date rather than showing it', async () => {
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, weekStart: 'Oct 5' } })
expect(await screen.findByRole('alert')).toHaveTextContent('not a YYYY-MM-DD date')
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
})
})
-158
View File
@@ -1,158 +0,0 @@
import { Bell, ChevronRight, Compass, Search, ShoppingCart, Zap, type LucideIcon } from 'lucide-react'
import { useState } from 'react'
import { Link, useNavigate } from 'react-router'
import { duration } from '../plan/PlanPage.tsx'
import { supplyRunScreen } from '../shopping/api.ts'
import buttonStyles from '../ui/Button.module.css'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Photo } from '../ui/Photo.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { useHome, type Home, type Origin, type Tonight } from './api.ts'
import styles from './HomePage.module.css'
import { RotationCheck } from './RotationCheck.tsx'
import { UpdatesSheet } from './UpdatesSheet.tsx'
const ORIGIN_LABEL: Record<Origin, string> = { regular: 'Regular', side_quest: 'Side quest' }
// meta is the Tonight card's "Regular · 40 min" line, the time only when
// the recipe says it.
function meta({ origin, minutes_total }: Tonight): string {
return minutes_total === null ? ORIGIN_LABEL[origin] : `${ORIGIN_LABEL[origin]} · ${duration(minutes_total)}`
}
// TonightCard is the dinner planned for today: its washed photo, name and
// meta line, and the dark Recipe pill to its recipe.
function TonightCard({ tonight }: { tonight: Tonight }) {
return (
<section aria-label="Tonight" className={styles.card}>
<Photo id={tonight.photo_id} name={tonight.name} inset="10px" sizes="400px" className={styles.photo} />
<div className={styles.cardFoot}>
<div className={styles.cardText}>
<h2 className={styles.name}>{tonight.name}</h2>
<span className={styles.meta}>{meta(tonight)}</span>
</div>
<Link to={`/dinner/${encodeURIComponent(tonight.id)}`} className={[buttonStyles.button, buttonStyles.dark, styles.recipe].join(' ')}>
Recipe
</Link>
</div>
</section>
)
}
// NoPlan stands in for the Tonight card when today has no plan.
function NoPlan() {
const navigate = useNavigate()
return (
<div className={styles.card}>
<EmptyState title="No quest planned tonight" action={{ label: 'Plan this week', onClick: () => void navigate('/plan') }}>
Draft this week's dinners and tonight's quest shows up here.
</EmptyState>
</div>
)
}
// StatusRow is one neutral row under the tiles: a grape-700 icon, its line
// and a chevron, linking to the screen it is about.
function StatusRow({ to, icon, children }: { to: string; icon: LucideIcon; children: string }) {
return (
<li>
<Link to={to} className={styles.status}>
<Icon icon={icon} className={styles.statusIcon} />
<span className={styles.statusText}>{children}</span>
<Icon icon={ChevronRight} />
</Link>
</li>
)
}
// StatusRows are the rows that apply: Side quests waiting in the deck, and
// a Supply run ready. With neither, there are no rows.
function StatusRows({ home }: { home: Home }) {
const count = home.sideQuestCount
const list = home.shoppingList
if (count === 0 && !list.ready) return null
return (
<ul aria-label="Status" className={styles.statuses}>
{count > 0 && (
<StatusRow to="/discover" icon={Compass}>
{`${count} new side ${count === 1 ? 'quest' : 'quests'} to swipe`}
</StatusRow>
)}
{list.ready && (
<StatusRow to={supplyRunScreen(list.weekStart)} icon={ShoppingCart}>
{`Shopping list ready · ${list.dates}`}
</StatusRow>
)}
</ul>
)
}
// Tiles are the two shortcuts, Quick dinner and Cook from what I have.
function Tiles() {
return (
<div className={styles.tiles}>
<Link to="/quick" className={[styles.tile, styles.quick].join(' ')}>
<Icon icon={Zap} size="nav" />
<span className={styles.tileLabel}>Quick dinner</span>
</Link>
<Link to="/cook-from-pantry" className={[styles.tile, styles.pantry].join(' ')}>
<Icon icon={Search} size="nav" />
<span className={styles.tileLabel}>Cook from what I have</span>
</Link>
</div>
)
}
// HomePage is mock 1g, Home: today's date over "Tonight's quest" and the
// Recent updates bell, the Tonight card (or the way to plan the week), the
// Quick dinner and Cook from what I have tiles, and the status rows, all
// from GET /api/v1/home. Above the tiles is the Rotation check, "Keep it in
// rotation?", while a tried dinner waits for the household's answer.
//
// The bell carries a grape dot while there are unread updates and opens the
// Recent updates sheet, which marks them seen.
export function HomePage() {
const home = useHome()
const data = home.data
const [updatesOpen, setUpdatesOpen] = useState(false)
return (
<div className={styles.page} aria-busy={home.isPending}>
<header className={styles.header}>
<div>
{data !== undefined ? (
<p className={styles.date}>{data.dateLabel}</p>
) : (
home.isPending && <Skeleton data-testid="skeleton-date" className={styles.skeletonDate} />
)}
<h1 className={styles.title}>Tonight's quest</h1>
</div>
<IconButton
label={data?.unread ? 'Recent updates, unread' : 'Recent updates'}
className={styles.bell}
onClick={() => setUpdatesOpen(true)}
>
<Icon icon={Bell} className={styles.bellIcon} />
{data?.unread && <span data-testid="unread-dot" className={styles.dot} />}
</IconButton>
</header>
{home.isPending && <Skeleton data-testid="skeleton-tonight" className={styles.skeletonCard} />}
{home.isError && (
<ErrorBanner onRetry={() => void home.refetch()}>Couldn't load tonight's quest. {home.error.message}</ErrorBanner>
)}
{data !== undefined && (data.tonight === null ? <NoPlan /> : <TonightCard tonight={data.tonight} />)}
<RotationCheck />
<Tiles />
{home.isPending && (
<div className={styles.statuses} aria-hidden="true">
<Skeleton data-testid="skeleton-status" className={styles.skeletonStatus} />
<Skeleton data-testid="skeleton-status" className={styles.skeletonStatus} />
</div>
)}
{data !== undefined && <StatusRows home={data} />}
<UpdatesSheet open={updatesOpen} onClose={() => setUpdatesOpen(false)} />
</div>
)
}
-79
View File
@@ -1,79 +0,0 @@
/* The Rotation check on Home: "Keep it in rotation?" above the tiles. */
.card {
display: flex;
flex-direction: column;
border-radius: 32px;
background: var(--color-surface);
overflow: hidden;
}
.photo {
height: 120px;
}
.body {
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px 18px 12px;
}
.kicker {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.question {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
line-height: 1.1;
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 6px;
}
/* "We didn't make it": a text link at the 44 px tap minimum. */
.notMade {
align-self: center;
min-width: 44px;
min-height: 44px;
padding: 0 4px;
border: 0;
background: transparent;
color: var(--color-accent-700);
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
text-decoration: underline;
cursor: pointer;
}
.notMade:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.notMade:focus-visible {
outline: 3px solid var(--color-accent);
outline-offset: 2px;
}
.confirmation {
margin: 0;
padding: 12px 14px;
border-radius: 20px;
background: var(--color-accent-2-300);
color: var(--color-accent-2-900);
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
}
-320
View File
@@ -1,320 +0,0 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clickPath, openNotification, parsePushPayload, type ClickWindow } from '../push/notification.ts'
import { QueryClientProvider } from '@tanstack/react-query'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession, ME_PATH } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOME } from '../test/ApiProviders.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { HOME_PATH } from './api.ts'
import { HomePage } from './HomePage.tsx'
import { rotationReviewPath, ROTATION_REVIEWS_PATH } from './rotation.ts'
// SHAKSHUKA and RAMEN are pending Rotation checks, oldest first, as GET
// /api/v1/rotation-reviews sends them.
const SHAKSHUKA = { dinner: { id: 'd7', name: 'Shakshuka', photo: '/media/photos/p7-400.jpg' }, plan_date: '2026-10-01' }
const RAMEN = { dinner: { id: 'd8', name: 'Miso ramen', photo: null }, plan_date: '2026-10-02' }
afterEach(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(navigator, 'serviceWorker')
})
type Deferred = { resolve: (response: Response) => void; path: string; body: unknown }
// stubApi answers Home's reads like the server: the pending Rotation checks
// are reviews, and each POST /api/v1/dinners/{id}/rotation-review waits in
// decisions until the test answers it; a 200 answer takes the check off the
// server's list, as the server does.
function stubApi(reviews: (typeof SHAKSHUKA | typeof RAMEN)[]) {
const server = [...reviews]
const decisions: Deferred[] = []
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const path = String(input)
if (path === ME_PATH) return Promise.resolve(jsonResponse(200, ME))
if (path === HOME_PATH) return Promise.resolve(jsonResponse(200, HOME))
if (path === ROTATION_REVIEWS_PATH) return Promise.resolve(jsonResponse(200, { reviews: server }))
const review = server.find((r) => rotationReviewPath(r.dinner.id) === path)
if (init?.method === 'POST' && review !== undefined) {
expect(new Headers(init.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
const body: unknown = JSON.parse(String(init.body))
return new Promise<Response>((resolve) =>
decisions.push({
path,
body,
resolve: (response) => {
if (response.ok) server.splice(server.indexOf(review), 1)
resolve(response)
},
}),
)
}
throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`)
})
vi.stubGlobal('fetch', fetchMock)
return { fetchMock, decisions, server }
}
// decided is the server's answer to a decision on review.
function decided(review: typeof SHAKSHUKA | typeof RAMEN, decision: string, dinnerStatus: string) {
return jsonResponse(200, {
dinner: review.dinner,
plan_date: review.plan_date,
decision,
dinner_status: dinnerStatus,
decided_at: '2026-10-02T13:00:00Z',
updated_by: 'sub-1',
})
}
// renderHome loads /me (for the CSRF token an answer needs) and renders Home
// at path.
async function renderHome(path = '/') {
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const router = createMemoryRouter([{ path: '/', element: <HomePage /> }], { initialEntries: [path] })
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<RouterProvider router={router} />
</QueryClientProvider>
</ApiProvider>,
)
}
const card = () => screen.findByRole('region', { name: 'Keep it in rotation?' })
const posts = (fetchMock: ReturnType<typeof stubApi>['fetchMock']) =>
fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST')
describe('RotationCheck', () => {
it('shows no card when nothing waits for an answer', async () => {
const { fetchMock } = stubApi([])
await renderHome()
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(ROTATION_REVIEWS_PATH, expect.anything()))
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Quick dinner' })).toBeInTheDocument()
})
it('asks about the oldest pending check above the tiles, and decides nothing by itself', async () => {
const { fetchMock } = stubApi([SHAKSHUKA, RAMEN])
await renderHome()
const check = await card()
expect(within(check).getByText('Last night: Shakshuka')).toBeInTheDocument()
expect(within(check).getByRole('heading', { level: 2, name: 'Keep it in rotation?' })).toBeInTheDocument()
expect(within(check).getByRole('img', { name: 'Shakshuka' })).toHaveAttribute('src', '/media/photos/p7-400.jpg')
expect(screen.queryByText('Last night: Miso ramen')).not.toBeInTheDocument()
const keep = within(check).getByRole('button', { name: 'Keep it' })
const drop = within(check).getByRole('button', { name: 'Not for us' })
const notMade = within(check).getByRole('button', { name: "We didn't make it" })
expect(keep.className).toMatch(/primary/)
expect(drop.className).toMatch(/outline/)
expect(notMade.className).toMatch(/notMade/)
for (const button of [keep, drop, notMade]) expectMinHitArea(button)
const tiles = screen.getByRole('link', { name: 'Quick dinner' })
expect(check.compareDocumentPosition(tiles) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(screen.getByRole('region', { name: 'Tonight' }).compareDocumentPosition(check) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
// The card waits for the household: nothing is sent unasked.
await act(() => new Promise((resolve) => setTimeout(resolve, 50)))
expect(posts(fetchMock)).toHaveLength(0)
expect(await card()).toBeInTheDocument()
})
it.each([
['Keep it', 'keep', 'regular', 'Added to your Regulars'],
['Not for us', 'drop', 'archived', "Got it — we won't suggest it again"],
["We didn't make it", 'not_made', 'quest_log', 'Back in your Quest log'],
])('"%s" sends %s at once and confirms "%s"', async (label, decision, status, confirmation) => {
const { fetchMock, decisions } = stubApi([SHAKSHUKA])
await renderHome()
fireEvent.click(within(await card()).getByRole('button', { name: label }))
// Applied before the server answers.
expect(await screen.findByRole('status')).toHaveTextContent(confirmation)
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
await waitFor(() => expect(decisions).toHaveLength(1))
expect(decisions[0]).toMatchObject({ path: '/api/v1/dinners/d7/rotation-review', body: { decision } })
expect(posts(fetchMock)).toHaveLength(1)
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, decision, status)))
// Rotation checks are read again once saved; none is left.
await waitFor(() =>
expect(fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length).toBeGreaterThanOrEqual(2),
)
expect(screen.getByRole('status')).toHaveTextContent(confirmation)
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('reveals the next pending check at once, its answers locked until the first is saved', async () => {
const { decisions } = stubApi([SHAKSHUKA, RAMEN])
await renderHome()
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
expect(await screen.findByText('Last night: Miso ramen')).toBeInTheDocument()
const next = await card()
expect(within(next).getByRole('img', { name: 'Miso ramen' })).toHaveTextContent('dish photo')
expect(screen.getByRole('status')).toHaveTextContent('Added to your Regulars')
for (const button of within(next).getAllByRole('button')) expect(button).toBeDisabled()
await waitFor(() => expect(decisions).toHaveLength(1))
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, 'keep', 'regular')))
await waitFor(() => expect(within(next).getByRole('button', { name: 'Not for us' })).toBeEnabled())
fireEvent.click(within(next).getByRole('button', { name: 'Not for us' }))
expect(await screen.findByRole('status')).toHaveTextContent("Got it — we won't suggest it again")
await waitFor(() => expect(decisions).toHaveLength(2))
expect(decisions[1]).toMatchObject({ path: '/api/v1/dinners/d8/rotation-review', body: { decision: 'drop' } })
act(() => decisions[1]!.resolve(decided(RAMEN, 'drop', 'archived')))
await waitFor(() => expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument())
expect(screen.queryByText(/Last night:/)).not.toBeInTheDocument()
})
it('puts the check back with an ErrorBanner when the answer fails', async () => {
const { decisions } = stubApi([SHAKSHUKA, RAMEN])
await renderHome()
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
expect(await screen.findByText('Last night: Miso ramen')).toBeInTheDocument()
await waitFor(() => expect(decisions).toHaveLength(1))
act(() => decisions[0]!.resolve(errorResponse(500, 'internal')))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't save that answer. internal message")
const check = await card()
expect(within(check).getByText('Last night: Shakshuka')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(within(check).getByRole('button', { name: 'Keep it' })).toBeEnabled()
})
it('reports an answer that is not that check decided that way, and shows what the server has', async () => {
const { decisions } = stubApi([SHAKSHUKA])
await renderHome()
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
await waitFor(() => expect(decisions).toHaveLength(1))
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, 'drop', 'archived')))
expect(await screen.findByRole('alert')).toHaveTextContent('response is not the Rotation check decided keep')
expect(screen.queryByRole('status')).not.toBeInTheDocument()
// The server closed the check, so the list read again has none.
await waitFor(() => expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument())
})
it('reports Rotation checks it cannot load, with Retry', async () => {
const answers = [errorResponse(500, 'internal'), jsonResponse(200, { reviews: [SHAKSHUKA] })]
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>((input) =>
Promise.resolve(
String(input) === ME_PATH
? jsonResponse(200, ME)
: String(input) === HOME_PATH
? jsonResponse(200, HOME)
: answers.shift()!,
),
),
)
await renderHome()
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load the Rotation check. internal message")
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
})
it('keeps a card already shown, with an ErrorBanner beside it, when reading the checks again fails', async () => {
// navigator.serviceWorker is where the worker's postMessage to this
// window arrives; a notification tap there reads the checks again.
const container = new EventTarget()
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
const answers = [jsonResponse(200, { reviews: [SHAKSHUKA] }), errorResponse(500, 'internal')]
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>((input) =>
Promise.resolve(
String(input) === ME_PATH
? jsonResponse(200, ME)
: String(input) === HOME_PATH
? jsonResponse(200, HOME)
: answers.shift()!,
),
),
)
await renderHome()
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
const origin = 'https://mealquest.test'
const win = {
url: `${origin}/`,
focused: true,
focus: vi.fn(async () => {}),
navigate: vi.fn(async () => {}),
postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })),
} satisfies ClickWindow
await act(() =>
openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin),
)
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load the Rotation check. internal message")
const shown = screen.getByRole('region', { name: 'Keep it in rotation?' })
expect(within(shown).getByText('Last night: Shakshuka')).toBeInTheDocument()
expect(within(shown).getByRole('button', { name: 'Keep it' })).toBeEnabled()
})
it('opens Home with the card from a rotation_review push notification', async () => {
stubApi([SHAKSHUKA])
const origin = 'https://mealquest.test'
const { notice } = parsePushPayload(
JSON.stringify({ event: 'rotation_review', title: 'Keep it in rotation?', body: 'How was Shakshuka?', url: '/' }),
origin,
)
const path = clickPath({ url: notice.url }, origin)
expect(path).toBe('/')
await renderHome(path)
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
})
it('shows the card when a rotation_review notification focuses Home already open, without a remount', async () => {
// navigator.serviceWorker is where the worker's postMessage to this
// window arrives.
const container = new EventTarget()
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
const { fetchMock, server } = stubApi([])
const reads = () => fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length
await renderHome('/')
const home = await screen.findByRole('heading', { name: 'Pizza night' })
await waitFor(() => expect(reads()).toBe(1))
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
// Within the cached list's staleTime the Rotation check comes due and
// its notification is tapped while Home is open at /.
server.push(SHAKSHUKA)
const origin = 'https://mealquest.test'
const win = {
url: `${origin}/`,
focused: true,
focus: vi.fn(async () => {}),
navigate: vi.fn(async () => {}),
postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })),
} satisfies ClickWindow
await act(() =>
openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin),
)
expect(win.navigate).not.toHaveBeenCalled()
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
expect(reads()).toBe(2)
expect(screen.getByRole('heading', { name: 'Pizza night' })).toBe(home)
})
})
-79
View File
@@ -1,79 +0,0 @@
import { Button } from '../ui/Button.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Photo } from '../ui/Photo.tsx'
import { useDecideRotationReview, useRotationReviews, type Decision, type RotationReview } from './rotation.ts'
import styles from './RotationCheck.module.css'
// CONFIRMATION is what Home says once the household has answered.
const CONFIRMATION: Record<Decision, string> = {
keep: 'Added to your Regulars',
drop: "Got it — we won't suggest it again",
not_made: 'Back in your Quest log',
}
type RotationCardProps = { review: RotationReview; disabled: boolean; onDecide: (decision: Decision) => void }
// RotationCard asks "Keep it in rotation?" about a tried dinner: its washed
// photo, "Last night: {dinner}", and the three answers.
function RotationCard({ review, disabled, onDecide }: RotationCardProps) {
const { dinner } = review
return (
<section aria-label="Keep it in rotation?" className={styles.card}>
<Photo id={dinner.photo_id} name={dinner.name} inset="10px" sizes="400px" className={styles.photo} />
<div className={styles.body}>
<p className={styles.kicker}>Last night: {dinner.name}</p>
<h2 className={styles.question}>Keep it in rotation?</h2>
<div className={styles.actions}>
<Button disabled={disabled} onClick={() => onDecide('keep')}>
Keep it
</Button>
<Button variant="outline" disabled={disabled} onClick={() => onDecide('drop')}>
Not for us
</Button>
</div>
<button type="button" className={styles.notMade} disabled={disabled} onClick={() => onDecide('not_made')}>
We didn't make it
</button>
</div>
</section>
)
}
// RotationCheck is the Rotation check on Home: the card for the oldest
// pending check (GET /api/v1/rotation-reviews), which stays until the
// household answers it — nothing is decided for them. An answer is applied
// at once: Home confirms it and shows the next pending check, if any, while
// the answer is saved; the answers are locked until it settles. A failed
// answer puts its check back with an ErrorBanner. A failed read shows an
// ErrorBanner, beside the checks last read: a card already shown stays
// until it is answered.
export function RotationCheck() {
const reviews = useRotationReviews()
const decide = useDecideRotationReview()
const deciding = decide.isPending ? decide.variables.review.dinner.id : null
const next = reviews.data?.find((review) => review.dinner.id !== deciding)
const answered = decide.isPending || decide.isSuccess ? decide.variables : undefined
return (
<>
{answered !== undefined && (
<p role="status" className={styles.confirmation}>
{CONFIRMATION[answered.decision]}
</p>
)}
{reviews.isError && (
<ErrorBanner onRetry={() => void reviews.refetch()}>
Couldn't load the Rotation check. {reviews.error.message}
</ErrorBanner>
)}
{decide.isError && <ErrorBanner>Couldn't save that answer. {decide.error.message}</ErrorBanner>}
{next !== undefined && (
<RotationCard
key={next.dinner.id}
review={next}
disabled={decide.isPending}
onDecide={(decision) => decide.mutate({ review: next, decision })}
/>
)}
</>
)
}
-49
View File
@@ -1,49 +0,0 @@
/* The Recent updates sheet the Home bell opens. */
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 560px;
margin: 0 auto;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.rows {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.icon {
color: var(--color-accent-700);
}
.time {
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
white-space: nowrap;
}
.skeletonRow {
height: 56px;
border-radius: var(--radius-md);
}
.empty {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-neutral-700);
}
-383
View File
@@ -1,383 +0,0 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOME } from '../test/ApiProviders.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { HOME_KEY, HOME_PATH, UPDATES_PATH, UPDATES_SEEN_PATH } from './api.ts'
import { ROTATION_REVIEWS_PATH } from './rotation.ts'
import { HomePage } from './HomePage.tsx'
afterEach(() => {
vi.unstubAllGlobals()
})
// minutesAgo is the stored form of the instant n minutes before now.
function minutesAgo(n: number): string {
return new Date(Date.now() - n * 60_000).toISOString()
}
// UPDATES are a GET /api/v1/updates body, newest first: the week drafted
// five minutes ago, new Side quests three hours ago and the Supply run two
// days ago.
const UPDATES = {
updates: [
{
type: 'plan_ready',
title: 'Your week is drafted',
body: 'Take a look at the Draft board.',
url: '/plan?week=2026-10-12',
created_at: minutesAgo(5),
},
{ type: 'new_suggestions', title: '5 new Side quests', body: '', url: '/discover', created_at: minutesAgo(3 * 60) },
{
type: 'list_ready',
title: 'Supply run ready',
body: 'Week 41 · Oct 5 – 11',
url: '/plan/shopping?week=2026-10-05',
created_at: minutesAgo(2 * 24 * 60),
},
],
}
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
// stubServer answers "METHOD path" requests from responders, with /me; any
// other request fails the test. Home is unread until the updates are marked
// seen, as the server answers.
function stubServer(responders: Record<string, Responder> = {}) {
let seen = false
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
[`GET ${HOME_PATH}`]: () => jsonResponse(200, { ...HOME, recentUpdates: { unread: !seen } }),
[`GET ${UPDATES_PATH}`]: () => jsonResponse(200, UPDATES),
[`GET ${ROTATION_REVIEWS_PATH}`]: () => jsonResponse(200, { reviews: [] }),
[`POST ${UPDATES_SEEN_PATH}`]: () => {
seen = true
return new Response(null, { status: 204 })
},
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// posts lists the bodies of the POST /updates/seen requests sent, each of
// which must carry the CSRF token.
function posts(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([input, init]) => init?.method === 'POST' && String(input) === UPDATES_SEEN_PATH)
.map(([, init]) => {
expect(new Headers(init?.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
return String(init?.body)
})
}
function homeReads(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls.filter(([input, init]) => String(input) === HOME_PATH && (init?.method ?? 'GET') === 'GET')
.length
}
function updatesReads(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls.filter(
([input, init]) => String(input) === UPDATES_PATH && (init?.method ?? 'GET') === 'GET',
).length
}
// Where shows the route a row went to.
function Where() {
const location = useLocation()
return <p data-testid="where">{location.pathname + location.search}</p>
}
// renderHome loads /me (for the CSRF token the POST needs) and renders Home,
// answering its query client.
async function renderHome() {
const api = createApiClient(fakeLocation('/'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const router = createMemoryRouter(
[
{ path: '/', element: <HomePage /> },
{ path: '*', element: <Where /> },
],
{ initialEntries: ['/'] },
)
const queryClient = createQueryClient()
render(
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</ApiProvider>,
)
return queryClient
}
// openSheet taps the unread bell and answers the open sheet.
async function openSheet() {
fireEvent.click(await screen.findByRole('button', { name: 'Recent updates, unread' }))
return screen.getByRole('dialog', { name: 'Recent updates' })
}
async function rows(sheet: HTMLElement) {
return within(await within(sheet).findByRole('list', { name: 'Recent updates' })).getAllByRole('listitem')
}
describe('Recent updates sheet', () => {
it('lists the updates newest first with how long ago each came', async () => {
stubServer()
await renderHome()
const listed = await rows(await openSheet())
expect(listed).toHaveLength(3)
expect(listed[0]).toHaveTextContent('Your week is drafted')
expect(listed[0]).toHaveTextContent('Take a look at the Draft board.')
expect(listed[0]).toHaveTextContent('5 min ago')
expect(listed[1]).toHaveTextContent('5 new Side quests')
expect(listed[1]).toHaveTextContent('3 hr ago')
expect(listed[2]).toHaveTextContent('Supply run ready')
expect(listed[2]).toHaveTextContent('2 days ago')
expect(within(listed[0]!).getByText('5 min ago')).toHaveAttribute('dateTime', UPDATES.updates[0]!.created_at)
})
it('marks the updates seen through the newest, and the grape dot clears and stays cleared', async () => {
const fetchMock = stubServer()
await renderHome()
expect(await screen.findByTestId('unread-dot')).toBeInTheDocument()
await openSheet()
await waitFor(() => expect(posts(fetchMock)).toEqual([JSON.stringify({ through: UPDATES.updates[0]!.created_at })]))
expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument()
// Home is read again once the mark is saved, and the server says
// nothing is unread: the dot does not come back.
await waitFor(() => expect(homeReads(fetchMock)).toBe(2))
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
expect(await screen.findByRole('button', { name: 'Recent updates' })).toBeInTheDocument()
expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument()
})
it('reads the updates afresh on each opening, so one stored since the last opening is listed and marked seen', async () => {
// The server holds the stored events, newest first, and says Home is
// unread while the newest is after the time marked seen.
const stored = [...UPDATES.updates]
let seenThrough = ''
const fetchMock = stubServer({
[`GET ${HOME_PATH}`]: () => jsonResponse(200, { ...HOME, recentUpdates: { unread: stored[0]!.created_at > seenThrough } }),
[`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: stored }),
[`POST ${UPDATES_SEEN_PATH}`]: (init) => {
seenThrough = (JSON.parse(String(init?.body)) as { through: string }).through
return new Response(null, { status: 204 })
},
})
const queryClient = await renderHome()
await rows(await openSheet())
await waitFor(() => expect(posts(fetchMock)).toHaveLength(1))
await waitFor(() => expect(homeReads(fetchMock)).toBe(2))
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
// A new event is stored a moment later, well within the query client's
// staleTime, and Home is read again, as on coming back to it.
const reminder = {
type: 'day_of_reminder',
title: 'Tacos tonight',
body: 'Dinner at 6:00.',
url: '/plan',
created_at: new Date().toISOString(),
}
stored.unshift(reminder)
await queryClient.invalidateQueries({ queryKey: HOME_KEY })
expect(await screen.findByTestId('unread-dot')).toBeInTheDocument()
const listed = await rows(await openSheet())
expect(listed).toHaveLength(4)
expect(listed[0]).toHaveTextContent('Tacos tonight')
await waitFor(() =>
expect(posts(fetchMock)).toEqual([
JSON.stringify({ through: UPDATES.updates[0]!.created_at }),
JSON.stringify({ through: reminder.created_at }),
]),
)
await waitFor(() => expect(homeReads(fetchMock)).toBe(4))
expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument()
})
it('on an immediate reopen waits for that opening’s read, and marks the newer event seen, not the list held from before', async () => {
const stored = [...UPDATES.updates]
let reads = 0
let answer: (() => void) | undefined
const fetchMock = stubServer({
[`GET ${UPDATES_PATH}`]: () => {
const body = { updates: [...stored] }
reads++
// Every reading after the first is held until the test answers it.
if (reads === 1) return jsonResponse(200, body)
return new Promise<Response>((resolve) => (answer = () => resolve(jsonResponse(200, body))))
},
})
await renderHome()
await rows(await openSheet())
await waitFor(() => expect(posts(fetchMock)).toHaveLength(1))
// Closed and opened again at once, before any cache-collection timer
// could run, with a new event stored in between.
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
const reminder = {
type: 'day_of_reminder',
title: 'Tacos tonight',
body: 'Dinner at 6:00.',
url: '/plan',
created_at: new Date().toISOString(),
}
stored.unshift(reminder)
fireEvent.click(screen.getByRole('button', { name: /^Recent updates/ }))
const sheet = screen.getByRole('dialog', { name: 'Recent updates' })
// The list held from the first opening is neither shown nor marked
// seen while this opening's read is in flight.
await waitFor(() => expect(updatesReads(fetchMock)).toBe(2))
expect(within(sheet).getByLabelText('Updates loading')).toBeInTheDocument()
expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
expect(posts(fetchMock)).toHaveLength(1)
answer!()
const listed = await rows(sheet)
expect(listed).toHaveLength(4)
expect(listed[0]).toHaveTextContent('Tacos tonight')
await waitFor(() =>
expect(posts(fetchMock)).toEqual([
JSON.stringify({ through: UPDATES.updates[0]!.created_at }),
JSON.stringify({ through: reminder.created_at }),
]),
)
})
it('closing the sheet while its first read is in flight cancels that read, and a reopen reads afresh', async () => {
const stored = [...UPDATES.updates]
let firstAborted = false
let answerFirst: (() => void) | undefined
let reads = 0
const fetchMock = stubServer({
[`GET ${UPDATES_PATH}`]: (init) => {
const body = { updates: [...stored] }
reads++
if (reads > 1) return jsonResponse(200, body)
// The first reading is held open, and fails as fetch does if it is
// aborted.
return new Promise<Response>((resolve, reject) => {
answerFirst = () => resolve(jsonResponse(200, body))
init?.signal?.addEventListener('abort', () => {
firstAborted = true
reject(init.signal!.reason as Error)
})
})
},
})
await renderHome()
await openSheet()
await waitFor(() => expect(updatesReads(fetchMock)).toBe(1))
// Closed before the first read answers, a new event stored, and opened
// again at once.
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
const reminder = {
type: 'day_of_reminder',
title: 'Tacos tonight',
body: 'Dinner at 6:00.',
url: '/plan',
created_at: new Date().toISOString(),
}
stored.unshift(reminder)
fireEvent.click(screen.getByRole('button', { name: /^Recent updates/ }))
const sheet = screen.getByRole('dialog', { name: 'Recent updates' })
// The first read was cancelled, and the reopen sent its own.
await waitFor(() => expect(updatesReads(fetchMock)).toBe(2))
expect(firstAborted).toBe(true)
// Answering the cancelled read changes nothing.
answerFirst!()
const listed = await rows(sheet)
expect(listed).toHaveLength(4)
expect(listed[0]).toHaveTextContent('Tacos tonight')
await waitFor(() => expect(posts(fetchMock)).toEqual([JSON.stringify({ through: reminder.created_at })]))
})
it.each([
[0, '/plan?week=2026-10-12'],
[1, '/discover'],
[2, '/plan/shopping?week=2026-10-05'],
])('row %i goes to %s and closes the sheet', async (i, to) => {
stubServer()
await renderHome()
const listed = await rows(await openSheet())
fireEvent.click(within(listed[i]!).getByRole('button'))
expect(await screen.findByTestId('where')).toHaveTextContent(to)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('says there are no updates yet, and marks nothing seen', async () => {
const fetchMock = stubServer({ [`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: [] }) })
await renderHome()
const sheet = await openSheet()
expect(await within(sheet).findByText(/No updates yet/)).toBeInTheDocument()
expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
expect(posts(fetchMock)).toEqual([])
})
it('puts the dot back and says so when marking the updates seen fails', async () => {
let refuse!: (response: Response) => void
const fetchMock = stubServer({
[`POST ${UPDATES_SEEN_PATH}`]: () => new Promise<Response>((resolve) => (refuse = resolve)),
})
await renderHome()
const sheet = await openSheet()
await waitFor(() => expect(posts(fetchMock)).toHaveLength(1))
// Cleared at once, while the mark is in flight.
expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument()
refuse(errorResponse(500, 'internal'))
expect(await within(sheet).findByRole('alert')).toHaveTextContent(
"Couldn't mark your updates as seen. internal message",
)
expect(screen.getByTestId('unread-dot')).toBeInTheDocument()
// The updates are still listed.
expect(within(sheet).getByRole('list', { name: 'Recent updates' })).toBeInTheDocument()
})
it('reports updates it cannot load, with Retry', async () => {
let fail = true
stubServer({ [`GET ${UPDATES_PATH}`]: () => (fail ? errorResponse(500, 'internal') : jsonResponse(200, UPDATES)) })
await renderHome()
const sheet = await openSheet()
expect(await within(sheet).findByRole('alert')).toHaveTextContent("Couldn't load your updates. internal message")
fail = false
fireEvent.click(within(sheet).getByRole('button', { name: 'Retry' }))
expect(await within(sheet).findByRole('list', { name: 'Recent updates' })).toBeInTheDocument()
})
it('reports an update that would open another site rather than listing it', async () => {
const fetchMock = stubServer({
[`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: [{ ...UPDATES.updates[0], url: '//evil.example/plan' }] }),
})
await renderHome()
const sheet = await openSheet()
expect(await within(sheet).findByRole('alert')).toHaveTextContent(
'update url "//evil.example/plan" is not a path on this app',
)
expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
expect(posts(fetchMock)).toEqual([])
})
})
-117
View File
@@ -1,117 +0,0 @@
import { CalendarDays, ChefHat, Compass, Repeat, ShoppingCart, type LucideIcon } from 'lucide-react'
import { useEffect, useId, useState } from 'react'
import { useNavigate } from 'react-router'
import { ago } from '../plan/PlanPage.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { ListRow } from '../ui/ListRow.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { useMarkUpdatesSeen, useUpdates, type Update, type UpdateType } from './api.ts'
import styles from './UpdatesSheet.module.css'
const ICONS: Record<UpdateType, LucideIcon> = {
plan_ready: CalendarDays,
list_ready: ShoppingCart,
day_of_reminder: ChefHat,
new_suggestions: Compass,
rotation_review: Repeat,
}
// UpdateRow is one update: its icon, title and body, and how long ago it
// came. A tap opens its url.
function UpdateRow({ update, now, onOpen }: { update: Update; now: Date; onOpen: (url: string) => void }) {
return (
<li>
<ListRow
leading={<Icon icon={ICONS[update.type]} className={styles.icon} />}
title={update.title}
meta={update.body === '' ? undefined : update.body}
trailing={
<time dateTime={update.created_at} className={styles.time}>
{ago(new Date(update.created_at), now)}
</time>
}
onClick={() => onOpen(update.url)}
/>
</li>
)
}
// Updates lists the Recent updates, newest first, and marks them seen
// through the newest once they are shown, so the bell's dot clears.
function Updates({ onOpen }: { onOpen: (url: string) => void }) {
const updates = useUpdates()
const markSeen = useMarkUpdatesSeen()
const { mutate } = markSeen
// fresh says this opening's read has come back; until then a list still
// held from an earlier opening is neither shown nor marked seen.
const fresh = updates.isSuccess && updates.isFetchedAfterMount
const newest = fresh ? updates.data[0]?.created_at : undefined
// The instant the relative times count from, fixed while the sheet is
// open.
const [now] = useState(() => new Date())
useEffect(() => {
if (newest !== undefined) mutate(newest)
}, [newest, mutate])
if (updates.isError) {
return (
<ErrorBanner onRetry={() => void updates.refetch()}>Couldn't load your updates. {updates.error.message}</ErrorBanner>
)
}
if (!fresh) {
return (
<div aria-busy="true" aria-label="Updates loading" className={styles.rows}>
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className={styles.skeletonRow} />
))}
</div>
)
}
if (updates.data.length === 0) {
return <p className={styles.empty}>No updates yet. Plans, Supply runs and new Side quests show up here.</p>
}
return (
<>
{markSeen.isError && newest !== undefined && (
<ErrorBanner onRetry={() => mutate(newest)}>
Couldn't mark your updates as seen. {markSeen.error.message}
</ErrorBanner>
)}
<ul aria-label="Recent updates" className={styles.rows}>
{updates.data.map((update, i) => (
<UpdateRow key={`${i} ${update.created_at}`} update={update} now={now} onOpen={onOpen} />
))}
</ul>
</>
)
}
export type UpdatesSheetProps = {
open: boolean
onClose: () => void
}
// UpdatesSheet is the Recent updates sheet the Home bell opens: the last
// ten notification events with how long ago each came, each opening the
// screen it is about. Opening it marks them seen.
export function UpdatesSheet({ open, onClose }: UpdatesSheetProps) {
const titleId = useId()
const navigate = useNavigate()
const openUpdate = (url: string) => {
onClose()
void navigate(url)
}
return (
<Sheet open={open} onClose={onClose} aria-labelledby={titleId}>
<div className={styles.sheet}>
<h2 id={titleId} className={styles.title}>
Recent updates
</h2>
<Updates onOpen={openUpdate} />
</div>
</Sheet>
)
}
-52
View File
@@ -1,52 +0,0 @@
import { describe, expect, it } from 'vitest'
import { HOME } from '../test/ApiProviders.tsx'
import { parseHome, parseUpdates } from './api.ts'
const origin = 'https://mealquest.example'
const where = 'GET /api/v1/updates'
// body is a GET /api/v1/updates body holding one update that opens url.
function body(url: string) {
return {
updates: [{ type: 'plan_ready', title: 'Your week is drafted', body: '', url, created_at: '2026-10-10T16:00:00.000Z' }],
}
}
describe('parseUpdates', () => {
it('keeps a same-origin path with its query and fragment, as parsed', () => {
expect(parseUpdates(where, body('/plan/shopping?week=2026-10-05#tuesday'), origin)[0]!.url).toBe(
'/plan/shopping?week=2026-10-05#tuesday',
)
expect(parseUpdates(where, body('/plan/../discover'), origin)[0]!.url).toBe('/discover')
})
// Each of these would open another site, or run script, once parsed: URL
// parsing strips tabs and newlines and reads a backslash as a slash.
it.each([
['a tab', '/\t/evil.example/x'],
['a newline', '/\n/evil.example/x'],
['a carriage return', '/\r/evil.example/x'],
['a backslash', '/\\evil.example/x'],
['dot segments', '/..//evil.example/x'],
['a protocol-relative URL', '//evil.example/x'],
['an absolute URL', 'https://evil.example/x'],
['javascript:', 'javascript:alert(1)'],
['a relative path', 'plan'],
])('refuses an update url with %s', (_, url) => {
expect(() => parseUpdates(where, body(url), origin)).toThrow(
`${where}: update url ${JSON.stringify(url)} is not a path on this app`,
)
})
})
describe('parseHome', () => {
it("keeps the household's zone, and refuses one that is not an IANA zone", () => {
expect(parseHome('GET /api/v1/home', HOME).timezone).toBe('America/New_York')
expect(() => parseHome('GET /api/v1/home', { ...HOME, timezone: 'Mars/Olympus' })).toThrow(
'GET /api/v1/home: timezone "Mars/Olympus" is not an IANA zone',
)
expect(() => parseHome('GET /api/v1/home', { ...HOME, timezone: undefined })).toThrow(
'GET /api/v1/home: timezone undefined is not an IANA zone',
)
})
})
-230
View File
@@ -1,230 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { oneOf, photoId } from '../dinners/api.ts'
import { untilLocalMidnight, weekLabel } from '../plan/week.ts'
import { appPath } from '../push/notification.ts'
export const HOME_PATH = '/api/v1/home'
export const HOME_KEY = ['home'] as const
export const UPDATES_PATH = '/api/v1/updates'
export const UPDATES_SEEN_PATH = '/api/v1/updates/seen'
export const UPDATES_KEY = ['updates'] as const
export const ORIGINS = ['regular', 'side_quest'] as const
export type Origin = (typeof ORIGINS)[number]
// Tonight is the dinner planned for today: photo_id is its stored photo's
// id, from photo, and minutes_total its minutes from prep to table, a
// positive whole number, or null when the recipe does not say.
export type Tonight = { id: string; name: string; photo_id: string | undefined; origin: Origin; minutes_total: number | null }
// ShoppingListStatus is the Supply run Home points at: next week's once it
// has one, otherwise this week's, ready when that week has a list.
// weekStart is the week's Monday, YYYY-MM-DD, and dates the week as the
// status row shows it, "Oct 5–11" (the API's label is the long "Week 41 ·
// Oct 5 – 11").
export type ShoppingListStatus = { weekStart: string; dates: string; ready: boolean }
// Home is GET /api/v1/home: dateLabel is today, household-local, "Friday,
// Oct 2"; timezone is the household's IANA zone, whose midnight starts the
// next day; tonight is null when today has no plan; sideQuestCount is how
// many Side quests wait in the deck; unread says the Recent updates hold
// something not yet seen.
export type Home = {
dateLabel: string
timezone: string
tonight: Tonight | null
sideQuestCount: number
shoppingList: ShoppingListStatus
unread: boolean
}
function parseTonight(where: string, body: unknown): Tonight | null {
if (body === null) return null
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
throw new Error(`${where}: tonight is not a dinner`)
}
if (!('minutes_total' in body)) throw new Error(`${where}: tonight has no minutes_total`)
const minutes = body.minutes_total
if (minutes !== null && (!Number.isInteger(minutes) || (minutes as number) <= 0)) {
throw new Error(`${where}: tonight's minutes_total ${String(minutes)} is not a positive whole number`)
}
return {
id: body.id,
name: body.name,
photo_id: photoId(where, body.photo),
origin: oneOf(where, 'origin', ORIGINS, body.origin),
minutes_total: minutes as number | null,
}
}
// parseShoppingList checks the shoppingList of a GET /api/v1/home body. The
// list, ready or not, must carry the API's label for its week, which must be
// the label weekStart has, since the status row shows dates from weekStart.
function parseShoppingList(where: string, list: unknown): ShoppingListStatus {
if (!isRecord(list) || typeof list.weekStart !== 'string' || typeof list.ready !== 'boolean') {
throw new Error(`${where}: shoppingList is not a week's Supply run status`)
}
const week = weekLabel(list.weekStart)
if (typeof list.label !== 'string' || list.label === '') {
throw new Error(`${where}: the ${list.ready ? 'ready' : 'unready'} shoppingList has no label`)
}
if (list.label !== `${week.week} · ${week.dates}`) {
throw new Error(`${where}: shoppingList label "${list.label}" is not the week of ${list.weekStart}`)
}
return { weekStart: list.weekStart, dates: week.dates.replace(' – ', '–'), ready: list.ready }
}
// parseHome checks a GET /api/v1/home body.
export function parseHome(where: string, body: unknown): Home {
if (!isRecord(body) || typeof body.dateLabel !== 'string') throw new Error(`${where}: response is not the home`)
if (typeof body.timezone !== 'string' || !isZone(body.timezone)) {
throw new Error(`${where}: timezone ${JSON.stringify(body.timezone)} is not an IANA zone`)
}
if (!Number.isInteger(body.sideQuestCount) || (body.sideQuestCount as number) < 0) {
throw new Error(`${where}: sideQuestCount is not a count`)
}
const updates = body.recentUpdates
if (!isRecord(updates) || typeof updates.unread !== 'boolean') throw new Error(`${where}: recentUpdates has no unread flag`)
return {
dateLabel: body.dateLabel,
timezone: body.timezone,
tonight: parseTonight(where, body.tonight),
sideQuestCount: body.sideQuestCount as number,
shoppingList: parseShoppingList(where, body.shoppingList),
unread: updates.unread,
}
}
// isZone says whether zone is an IANA zone this browser knows.
function isZone(zone: string): boolean {
try {
new Intl.DateTimeFormat('en-US', { timeZone: zone })
return true
} catch {
return false
}
}
// useHome is the Home screen's summary (GET /api/v1/home). Home is read
// again at the household's next local midnight, counted from each read, so
// a Home left open does not keep yesterday's date and dinner.
export function useHome() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
const home = useQuery({
queryKey: HOME_KEY,
queryFn: async () => parseHome(`GET ${HOME_PATH}`, await apiFetch<unknown>(HOME_PATH)),
})
const zone = home.data?.timezone
const readAt = home.dataUpdatedAt
useEffect(() => {
if (zone === undefined) return
const timer = setTimeout(
() => void queryClient.invalidateQueries({ queryKey: HOME_KEY }),
untilLocalMidnight(zone, new Date()),
)
return () => clearTimeout(timer)
}, [queryClient, zone, readAt])
return home
}
export const UPDATE_TYPES = ['plan_ready', 'list_ready', 'day_of_reminder', 'new_suggestions', 'rotation_review'] as const
export type UpdateType = (typeof UPDATE_TYPES)[number]
// Update is one of the Recent updates: a notification event, whatever
// channels it went to. url is the same-origin path it opens; created_at is
// when it was stored, the UTC RFC 3339 instant POST /api/v1/updates/seen
// takes back.
export type Update = { type: UpdateType; title: string; body: string; url: string; created_at: string }
// parseUpdate checks one update. Its url must stay on origin once parsed, as
// the server only ever stores a path on the app's own origin; url is that
// path as parsed.
function parseUpdate(where: string, body: unknown, origin: string): Update {
if (
!isRecord(body) ||
typeof body.title !== 'string' ||
typeof body.body !== 'string' ||
typeof body.url !== 'string' ||
typeof body.created_at !== 'string'
) {
throw new Error(`${where}: an update is not a notification event`)
}
const url = appPath(body.url, origin)
if (url === null) throw new Error(`${where}: update url ${JSON.stringify(body.url)} is not a path on this app`)
if (Number.isNaN(Date.parse(body.created_at))) {
throw new Error(`${where}: update created_at "${body.created_at}" is not a time`)
}
return {
type: oneOf(where, 'type', UPDATE_TYPES, body.type),
title: body.title,
body: body.body,
url,
created_at: body.created_at,
}
}
// parseUpdates checks a GET /api/v1/updates body, from the app at origin:
// the updates, newest first.
export function parseUpdates(where: string, body: unknown, origin: string): Update[] {
if (!isRecord(body) || !Array.isArray(body.updates)) throw new Error(`${where}: response is not the updates`)
return body.updates.map((update) => parseUpdate(where, update, origin))
}
// useUpdates is the Recent updates (GET /api/v1/updates): the last ten
// notification events, newest first. Each opening of the sheet reads it
// afresh (refetchOnMount 'always'), whatever the query client still holds;
// the sheet shows, and marks seen, only the list that opening read. Closing
// the sheet aborts a read still in flight, so a reopen sends its own rather
// than adopt one sent before it.
export function useUpdates() {
const { apiFetch } = useApi()
return useQuery({
queryKey: UPDATES_KEY,
queryFn: async ({ signal }) =>
parseUpdates(`GET ${UPDATES_PATH}`, await apiFetch<unknown>(UPDATES_PATH, { signal }), window.location.origin),
refetchOnMount: 'always',
})
}
const MARK_SEEN_KEY = ['updates', 'seen'] as const
// useMarkUpdatesSeen records that the household has seen its updates
// through the created_at of the newest one shown (POST
// /api/v1/updates/seen). The bell's unread dot clears at once, and a Home
// read in flight is stopped so it cannot bring it back; if the server
// refuses, the dot is put back as it was before the first of the marks in
// flight. Once the last mark in flight settles Home is read again, so the
// dot shows only for an event stored since.
export function useMarkUpdatesSeen() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
// alone says whether this is the only mark in flight; each counts itself
// until it has settled.
const alone = () => queryClient.isMutating({ mutationKey: MARK_SEEN_KEY }) === 1
return useMutation({
mutationKey: MARK_SEEN_KEY,
mutationFn: async (through: string) => {
await apiFetch<unknown>(UPDATES_SEEN_PATH, { method: 'POST', body: { through } })
},
onMutate: async () => {
// A mark sent while another is in flight would snapshot that one's
// optimistic answer, so only the first snapshots.
const unread = alone() ? queryClient.getQueryData<Home>(HOME_KEY)?.unread : undefined
await queryClient.cancelQueries({ queryKey: HOME_KEY })
queryClient.setQueryData<Home>(HOME_KEY, (home) => home && { ...home, unread: false })
return { unread }
},
onError: (_error, _through, context) => {
if (context?.unread === undefined || !alone()) return
const { unread } = context
queryClient.setQueryData<Home>(HOME_KEY, (home) => home && { ...home, unread })
},
onSettled: () => {
if (alone()) void queryClient.invalidateQueries({ queryKey: HOME_KEY })
},
})
}
-107
View File
@@ -1,107 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { photoId } from '../dinners/api.ts'
import { isNotificationOpened } from '../push/notification.ts'
export const ROTATION_REVIEWS_PATH = '/api/v1/rotation-reviews'
export const ROTATION_REVIEWS_KEY = ['rotation-reviews'] as const
const DINNERS_KEY = ['dinners'] as const
// rotationReviewPath is where the household answers "Keep it in rotation?"
// for a tried dinner.
export function rotationReviewPath(dinnerId: string): string {
return `/api/v1/dinners/${encodeURIComponent(dinnerId)}/rotation-review`
}
// Rotation check answers: keep makes the tried dinner a Regular, drop ("Not
// for us") archives it so it is never suggested again, and not_made ("We
// didn't make it") returns it to the Quest log. Keep is the only way a tried
// dinner becomes a Regular; nothing decides one automatically.
export const DECISIONS = ['keep', 'drop', 'not_made'] as const
export type Decision = (typeof DECISIONS)[number]
// RotationReview is a pending Rotation check: the dinner tried, photo_id its
// stored photo's id, and the household-local plan date, YYYY-MM-DD, it was
// planned on.
export type RotationReview = { dinner: { id: string; name: string; photo_id: string | undefined }; plan_date: string }
function parseReview(where: string, body: unknown): RotationReview {
if (!isRecord(body) || typeof body.plan_date !== 'string') throw new Error(`${where}: a review is not a Rotation check`)
const dinner = body.dinner
if (!isRecord(dinner) || typeof dinner.id !== 'string' || typeof dinner.name !== 'string') {
throw new Error(`${where}: a Rotation check has no dinner`)
}
return { dinner: { id: dinner.id, name: dinner.name, photo_id: photoId(where, dinner.photo) }, plan_date: body.plan_date }
}
// parseRotationReviews checks a GET /api/v1/rotation-reviews body: the
// pending Rotation checks, oldest plan date first.
export function parseRotationReviews(where: string, body: unknown): RotationReview[] {
if (!isRecord(body) || !Array.isArray(body.reviews)) throw new Error(`${where}: response is not the Rotation checks`)
return body.reviews.map((review) => parseReview(where, review))
}
// useRotationReviews is the pending Rotation checks (GET
// /api/v1/rotation-reviews), oldest plan date first. Each mount of Home
// reads them afresh, and so does a notification tap that focuses Home
// already open (the service worker's NotificationOpened), so Home opened
// from a rotation_review notification shows the check it is about.
export function useRotationReviews() {
const { apiFetch } = useApi()
const query = useQuery({
queryKey: ROTATION_REVIEWS_KEY,
queryFn: async () =>
parseRotationReviews(`GET ${ROTATION_REVIEWS_PATH}`, await apiFetch<unknown>(ROTATION_REVIEWS_PATH)),
refetchOnMount: 'always',
})
const { refetch } = query
useEffect(() => {
// Without a service worker there are no notifications to tap.
if (!('serviceWorker' in navigator)) return
const container = navigator.serviceWorker
const onMessage = (event: MessageEvent<unknown>) => {
if (isNotificationOpened(event.data)) void refetch()
}
container.addEventListener('message', onMessage)
return () => container.removeEventListener('message', onMessage)
}, [refetch])
return query
}
// useDecideRotationReview answers "Keep it in rotation?" for a pending
// Rotation check (POST /api/v1/dinners/{id}/rotation-review). The answer
// must be that dinner, decided that way.
//
// Home leaves the check out while its answer is pending (the mutation's
// variables say which), so the next one shows at once; a read in flight is
// stopped so it cannot bring the check back meanwhile. Once saved, the check
// leaves the cached list; once failed, it is shown again as the list has it.
// Either way the Rotation checks and the dinners lists (the Regulars and the
// Quest log the answer moved it to) are read again.
export function useDecideRotationReview() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation<void, Error, { review: RotationReview; decision: Decision }>({
mutationFn: async ({ review, decision }) => {
const path = rotationReviewPath(review.dinner.id)
const body = await apiFetch<unknown>(path, { method: 'POST', body: { decision } })
if (!isRecord(body) || !isRecord(body.dinner) || body.dinner.id !== review.dinner.id || body.decision !== decision) {
throw new Error(`POST ${path}: response is not the Rotation check decided ${decision}`)
}
},
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ROTATION_REVIEWS_KEY })
},
onSuccess: (_data, { review }) => {
queryClient.setQueryData<RotationReview[]>(ROTATION_REVIEWS_KEY, (reviews) =>
reviews?.filter((r) => r.dinner.id !== review.dinner.id),
)
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ROTATION_REVIEWS_KEY })
void queryClient.invalidateQueries({ queryKey: DINNERS_KEY })
},
})
}
-226
View File
@@ -1,226 +0,0 @@
/* Cook from what I have, handoff mock 1i. */
.page {
box-sizing: border-box;
max-width: 560px;
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 6px;
}
.header {
display: flex;
align-items: center;
gap: 10px;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
line-height: 1.1;
}
/* The ingredient chip field: chips, then the trailing input, wrapping. */
.field {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 4px;
padding: 12px;
border-radius: 26px;
background: var(--color-surface);
cursor: text;
}
.field:focus-within {
box-shadow: inset 0 0 0 2px var(--color-accent-300);
}
.chips {
display: contents;
margin: 0;
padding: 0;
list-style: none;
}
.chip {
background: var(--color-bg);
color: var(--color-text);
font-size: 13px;
}
.entry {
flex: 1 1 120px;
min-width: 120px;
}
.input {
width: 100%;
height: 44px;
padding: 0 8px;
border: 0;
background: transparent;
font-size: 15px;
}
.input:focus {
outline: none;
}
.input::placeholder {
color: var(--color-neutral-600);
}
.notice {
margin: 0 4px;
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
color: var(--color-accent-700);
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
}
/* Any time | Quick only shares the row with Find ideas, as in the mock. */
.when {
flex: none;
}
.when > button {
padding: 0 12px;
font-size: 13px;
white-space: nowrap;
}
.find {
padding: 0 18px;
white-space: nowrap;
}
.results {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 8px;
}
.count {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px;
border-radius: 24px;
}
.cardHead {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
}
.name {
margin: 0;
font-family: var(--font-heading);
font-size: 17px;
font-weight: 400;
line-height: 1.2;
}
.time {
flex: none;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.summary {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-accent-2-800);
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.action {
min-height: 44px;
padding: 0 16px;
font-size: 14px;
white-space: nowrap;
}
/* An action done: what it did, in place of its button. */
.done {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 44px;
padding: 0 4px;
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
color: var(--color-accent-2-800);
}
.failure {
margin: 0;
font-family: var(--font-body);
font-size: 13px;
color: var(--color-accent-700);
}
.noPlan {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 10px;
font-family: var(--font-body);
font-size: 13px;
color: var(--color-text);
}
/* "Plan this week": a 44 px tall grape link to the Draft board. */
.planLink {
display: inline-flex;
align-items: center;
min-height: 44px;
font-weight: 800;
color: var(--color-accent-700);
text-decoration: underline;
}
.planLink:focus-visible {
outline: 3px solid var(--color-accent);
outline-offset: 2px;
}
.skeletonCard {
height: 128px;
border-radius: 24px;
}
-563
View File
@@ -1,563 +0,0 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { dinnerBody } from '../test/dinners.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { CookFromPantryPage } from './CookFromPantryPage.tsx'
import styles from './CookFromPantryPage.module.css'
const FIND = 'POST /api/v1/ideas/from-ingredients'
const INGREDIENTS = ['Chicken thighs', 'Rice', 'Spinach', 'Coconut milk', 'Garlic']
// idea is a grounded idea as the job's result carries it.
function idea(name: string, summary: string, minutes: number | null = 30) {
return {
name,
uses: INGREDIENTS,
missing: ['1 lime'],
summary,
method: 'stovetop',
minutes_total: minutes,
difficulty: 'easy',
quick: minutes !== null && minutes <= 30,
recipe_id: `r-${name}`,
photo_url: '',
source_url: 'https://recipes.example/x',
site_name: 'Recipes',
}
}
// The ideas of mock 1i.
const CURRY = idea('Coconut chicken curry', 'Uses all 5 · missing: 1 lime')
// BOWLS's source is withheld, as the server sends one whose link could
// carry a secret: the card shows all the same.
const BOWLS = { ...idea('Garlic spinach rice bowls', 'Uses 4 of 5 · nothing missing', 25), source_url: null, site_name: null }
const ONE_PAN = idea('One-pan chicken & rice', 'Uses 3 of 5 · missing: onion')
type JobBody = { status: string; result: unknown; error: string | null }
// running is the ideas_from_ingredients job's state at stage with ideas.
function state(status: string, stage: string, ideas: unknown[], quickOnly = false, error: string | null = null): JobBody {
return { status, result: { stage, ingredients: INGREDIENTS, quick_only: quickOnly, ideas, dropped: 0 }, error }
}
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
// tonightPlan is the week's plan as cook-tonight answers it, the idea's
// dinner on Thursday.
function tonightPlan() {
return {
id: 'plan-1',
week_start: '2026-10-05',
status: 'draft',
ai_note: null,
calendar_synced_at: null,
days: DAYS.map((date, i) => ({
date,
dinner: { id: `d${i}`, name: i === 3 ? CURRY.name : `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop', origin: 'regular' },
is_new: i === 3,
start_by: null,
})),
updated_at: '2026-10-08T15:00:00Z',
}
}
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
// stubServer answers "METHOD path" requests from responders, with /me; any
// other request fails the test. jobs holds each job's state, which every
// poll of it answers, so a test moves a job on by changing it.
function stubServer(responders: Record<string, Responder>) {
const jobs: Record<string, JobBody> = {}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
if (key === 'GET /api/v1/me') return Promise.resolve(jsonResponse(200, ME))
const job = /^GET \/api\/v1\/jobs\/([^/]+)$/.exec(key)
if (job !== null && !Object.hasOwn(responders, key)) {
const id = job[1]!
const body = jobs[id]
if (body === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(jsonResponse(200, { id, kind: 'ideas_from_ingredients', ...body }))
}
const respond = Object.hasOwn(responders, key) ? responders[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init))
})
vi.stubGlobal('fetch', fetchMock)
return { fetchMock, jobs }
}
// calls are the requests sent as "METHOD path", in order.
function calls(fetchMock: ReturnType<typeof stubServer>['fetchMock'], key: string) {
return fetchMock.mock.calls.filter(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}` === key)
}
function bodyOf(call: Parameters<typeof fetch>) {
return JSON.parse(String(call[1]?.body)) as unknown
}
async function renderPage() {
const api = createApiClient(fakeLocation('/cook-from-pantry'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<MemoryRouter initialEntries={['/cook-from-pantry']}>
<Routes>
<Route path="/cook-from-pantry" element={<CookFromPantryPage />} />
<Route path="/plan" element={<p>Plan screen</p>} />
<Route path="/" element={<p>Home screen</p>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
function input() {
return screen.getByRole('textbox', { name: 'Add ingredient' })
}
function chips() {
return within(screen.getByRole('list', { name: 'Ingredients on hand' }))
.queryAllByRole('listitem')
.map((item) => item.textContent)
}
function findButton() {
return screen.getByRole('button', { name: 'Find ideas' })
}
// enter types each ingredient and presses Enter.
function enter(...ingredients: string[]) {
for (const ingredient of ingredients) {
fireEvent.change(input(), { target: { value: ingredient } })
fireEvent.keyDown(input(), { key: 'Enter' })
}
}
function card(name: string) {
return screen.getByRole('article', { name })
}
const SLOW = { timeout: 3000 }
// searched lists the mock's ingredients, presses Find ideas and waits for
// the job's first poll.
async function searched(fetchMock: ReturnType<typeof stubServer>['fetchMock'], jobId = 'j1') {
enter(...INGREDIENTS)
fireEvent.click(findButton())
await waitFor(() => expect(calls(fetchMock, `GET /api/v1/jobs/${jobId}`).length).toBeGreaterThan(0))
}
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('CookFromPantryPage', () => {
it('renders mock 1i: back button, title, chip field, filter and a disabled Find ideas', async () => {
stubServer({})
await renderPage()
expect(screen.getByRole('heading', { level: 1, name: 'Cook from what I have' })).toBeInTheDocument()
expect(input()).toHaveAttribute('placeholder', 'Add ingredient…')
expect(input().closest(`.${styles.field}`)).not.toBeNull()
const filter = screen.getByRole('radiogroup', { name: 'Which dinners' })
expect(within(filter).getByRole('radio', { name: 'Any time' })).toBeChecked()
expect(within(filter).getByRole('radio', { name: 'Quick only' })).not.toBeChecked()
expect(findButton()).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Back to Home' }))
expect(screen.getByText('Home screen')).toBeInTheDocument()
})
describe('chip entry', () => {
it('adds an ingredient on Enter or a comma, and Find ideas needs one', async () => {
stubServer({})
await renderPage()
enter('Chicken thighs')
expect(chips()).toEqual(['Chicken thighs'])
expect(input()).toHaveValue('')
expect(findButton()).toBeEnabled()
fireEvent.change(input(), { target: { value: 'Rice,' } })
expect(chips()).toEqual(['Chicken thighs', 'Rice'])
expect(input()).toHaveValue('')
// Pasted text is split at its commas; what follows the last is still
// being typed.
fireEvent.change(input(), { target: { value: ' Spinach , coconut milk, Gar' } })
expect(chips()).toEqual(['Chicken thighs', 'Rice', 'Spinach', 'coconut milk'])
expect(input()).toHaveValue(' Gar')
// Enter on nothing adds nothing.
fireEvent.change(input(), { target: { value: ' ' } })
fireEvent.keyDown(input(), { key: 'Enter' })
expect(chips()).toHaveLength(4)
})
it('enables Find ideas for text typed and not yet added, and searches it', async () => {
const { fetchMock } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
await renderPage()
fireEvent.change(input(), { target: { value: ' ' } })
expect(findButton()).toBeDisabled()
fireEvent.change(input(), { target: { value: 'Chicken thighs' } })
expect(chips()).toEqual([])
expect(findButton()).toBeEnabled()
fireEvent.click(findButton())
await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(1))
expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: ['Chicken thighs'], quick_only: false })
expect(chips()).toEqual(['Chicken thighs'])
})
it('removes a chip with its cross, or the last one with Backspace in the empty input', async () => {
stubServer({})
await renderPage()
enter('Chicken thighs', 'Rice', 'Spinach')
fireEvent.click(screen.getByRole('button', { name: 'Remove Rice' }))
expect(chips()).toEqual(['Chicken thighs', 'Spinach'])
expect(input()).toHaveFocus()
fireEvent.keyDown(input(), { key: 'Backspace' })
expect(chips()).toEqual(['Chicken thighs'])
fireEvent.click(screen.getByRole('button', { name: 'Remove Chicken thighs' }))
expect(chips()).toEqual([])
expect(findButton()).toBeDisabled()
})
it('says why it leaves out a repeated, overlong or 21st ingredient', async () => {
stubServer({})
await renderPage()
enter('Rice', ' rice ')
expect(chips()).toEqual(['Rice'])
expect(screen.getByRole('status')).toHaveTextContent('rice is listed already.')
enter('x'.repeat(81))
expect(chips()).toEqual(['Rice'])
expect(screen.getByRole('status')).toHaveTextContent('An ingredient can be at most 80 characters.')
enter(...Array.from({ length: 19 }, (_, i) => `Item ${i}`))
expect(chips()).toHaveLength(20)
expect(screen.queryByRole('status')).toBeNull()
enter('One too many')
expect(chips()).toHaveLength(20)
expect(screen.getByRole('status')).toHaveTextContent('List at most 20 ingredients.')
})
})
it('fills the results progressively as the server grounds ideas, then enables the actions', async () => {
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
jobs.j1 = state('running', 'suggesting', [])
await renderPage()
enter(...INGREDIENTS)
fireEvent.change(input(), { target: { value: 'lime' } })
fireEvent.click(findButton())
// What is still typed is listed before the search.
expect(chips()).toEqual([...INGREDIENTS, 'lime'])
await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(1))
expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: [...INGREDIENTS, 'lime'], quick_only: false })
// Suggesting: five skeleton cards, no idea yet.
expect(await screen.findByText('Thinking up ideas…')).toBeInTheDocument()
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(5)
expect(screen.queryAllByRole('article')).toHaveLength(0)
// Grounding: the idea grounded so far, and skeletons for the rest; its
// actions wait for the job to end.
jobs.j1 = state('running', 'grounding', [CURRY])
await screen.findByRole('article', { name: CURRY.name }, SLOW)
expect(screen.getByText('Finding recipes for them…')).toBeInTheDocument()
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(4)
expect(within(card(CURRY.name)).getByRole('button', { name: 'Cook tonight' })).toBeDisabled()
expect(within(card(CURRY.name)).getByRole('button', { name: 'Add to Quest log' })).toBeDisabled()
jobs.j1 = state('running', 'grounding', [CURRY, BOWLS])
await screen.findByRole('article', { name: BOWLS.name }, SLOW)
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3)
jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS, ONE_PAN])
await screen.findByText('3 ideas', {}, SLOW)
expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0)
expect(screen.getAllByRole('article').map((a) => a.getAttribute('aria-label'))).toEqual([CURRY.name, BOWLS.name, ONE_PAN.name])
const curry = within(card(CURRY.name))
// The server's summary line, as it is.
expect(curry.getByText('Uses all 5 · missing: 1 lime')).toHaveClass(styles.summary!)
expect(card(CURRY.name).querySelector(`.${styles.time}`)).toHaveTextContent('30 min')
expect(card(BOWLS.name).querySelector(`.${styles.time}`)).toHaveTextContent('25 min')
expect(curry.getByRole('button', { name: 'Cook tonight' })).toBeEnabled()
expect(curry.getByRole('button', { name: 'Add to Quest log' })).toBeEnabled()
})
it('leaves the time off an idea whose recipe does not say it', async () => {
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
jobs.j1 = state('succeeded', 'done', [idea('Fried rice', 'Uses 2 of 5 · nothing missing', null)])
await renderPage()
await searched(fetchMock)
const fried = await screen.findByRole('article', { name: 'Fried rice' })
expect(within(fried).queryByText(/min/)).toBeNull()
})
describe('Any time | Quick only', () => {
it('sends quick_only, and asks again when changed after a search', async () => {
let n = 0
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) })
jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS], true)
jobs.j2 = state('succeeded', 'done', [ONE_PAN], false)
await renderPage()
// Before a search it only sets what Find ideas asks for.
fireEvent.click(screen.getByRole('radio', { name: 'Quick only' }))
expect(calls(fetchMock, FIND)).toHaveLength(0)
await searched(fetchMock, 'j1')
expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: INGREDIENTS, quick_only: true })
await screen.findByText('2 ideas')
fireEvent.click(screen.getByRole('radio', { name: 'Any time' }))
await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(2))
expect(bodyOf(calls(fetchMock, FIND)[1]!)).toEqual({ ingredients: INGREDIENTS, quick_only: false })
await screen.findByText('1 idea')
expect(screen.getAllByRole('article').map((a) => a.getAttribute('aria-label'))).toEqual([ONE_PAN.name])
})
it('refuses a result that is not for the filter asked for', async () => {
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
jobs.j1 = state('succeeded', 'done', [CURRY], true)
await renderPage()
await searched(fetchMock)
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't read the ideas.")
expect(screen.queryAllByRole('article')).toHaveLength(0)
})
})
describe('Add to Quest log', () => {
it('adds the idea and says so on its card', async () => {
const added = Promise.withResolvers<Response>()
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/1/quest-log': () => added.promise,
})
jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS])
await renderPage()
await searched(fetchMock)
await screen.findByText('2 ideas')
const bowls = within(card(BOWLS.name))
fireEvent.click(bowls.getByRole('button', { name: 'Add to Quest log' }))
expect(await bowls.findByRole('button', { name: 'Adding…' })).toBeDisabled()
added.resolve(jsonResponse(201, dinnerBody('dn-1', BOWLS.name, { status: 'quest_log', origin: 'ingredients' })))
expect(await bowls.findByRole('status')).toHaveTextContent('In the Quest log')
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/1/quest-log')).toHaveLength(1)
// The other card is untouched.
expect(within(card(CURRY.name)).getByRole('button', { name: 'Add to Quest log' })).toBeEnabled()
})
it('says an idea that came back as an existing Regular is already a Regular, not in the Quest log', async () => {
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/0/quest-log': () =>
jsonResponse(200, dinnerBody('dn-reg', 'Coconut Chicken Curry', { status: 'regular' })),
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
expect(await curry.findByRole('status')).toHaveTextContent('Already a Regular')
expect(curry.queryByText('In the Quest log')).toBeNull()
})
it('fails on a dinner whose status is neither quest_log nor regular', async () => {
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/0/quest-log': () => jsonResponse(200, dinnerBody('dn-1', CURRY.name, { status: 'suggested' })),
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
expect(await curry.findByRole('alert')).toHaveTextContent(
'POST /api/v1/ideas/j1/items/0/quest-log: status "suggested" is not one of quest_log, regular',
)
expect(curry.queryByRole('status')).toBeNull()
})
it('shows its failure on its card and lets it be pressed again', async () => {
let fail = true
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/0/quest-log': () =>
fail ? errorResponse(500, 'internal') : jsonResponse(200, dinnerBody('dn-1', CURRY.name, { status: 'quest_log' })),
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
expect(await curry.findByRole('alert')).toHaveTextContent("Couldn't add it to the Quest log. internal message")
fail = false
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
expect(await curry.findByRole('status')).toHaveTextContent('In the Quest log')
expect(curry.queryByRole('alert')).toBeNull()
})
})
describe('Cook tonight', () => {
it("puts the idea on tonight's plan", async () => {
const planned = Promise.withResolvers<Response>()
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/0/cook-tonight': () => planned.promise,
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' }))
expect(await curry.findByRole('button', { name: 'Planning…' })).toBeDisabled()
planned.resolve(jsonResponse(200, { dinner: dinnerBody('dn-1', CURRY.name, { status: 'quest_log' }), plan: tonightPlan() }))
expect(await curry.findByRole('status')).toHaveTextContent("On tonight's plan")
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/cook-tonight')).toHaveLength(1)
})
it('offers Plan this week, not an error, when the week has no plan', async () => {
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/0/cook-tonight': () => errorResponse(409, 'no_plan'),
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' }))
const link = await curry.findByRole('link', { name: 'Plan this week' })
expect(curry.getByRole('status')).toHaveTextContent('This week has no plan yet')
expect(screen.queryByRole('alert')).toBeNull()
fireEvent.click(link)
expect(screen.getByText('Plan screen')).toBeInTheDocument()
})
it('shows any other failure on its card', async () => {
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/0/cook-tonight': () => errorResponse(409, 'dinner_already_planned'),
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' }))
expect(await curry.findByRole('alert')).toHaveTextContent("Couldn't put it on tonight's plan. dinner_already_planned message")
expect(curry.queryByRole('link', { name: 'Plan this week' })).toBeNull()
expect(curry.getByRole('button', { name: 'Cook tonight' })).toBeEnabled()
})
})
describe('errors', () => {
it('shows the ErrorBanner when the search cannot start, and Retry asks again', async () => {
let n = 0
const { fetchMock, jobs } = stubServer({
[FIND]: () => (++n === 1 ? errorResponse(400, 'invalid_request') : jsonResponse(202, { jobId: 'j1' })),
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
enter(...INGREDIENTS)
fireEvent.click(findButton())
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't start looking for ideas. invalid_request message")
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('article', { name: CURRY.name })
expect(bodyOf(calls(fetchMock, FIND)[1]!)).toEqual({ ingredients: INGREDIENTS, quick_only: false })
})
it('shows a failed job with no cards, and Retry searches again', async () => {
let n = 0
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) })
jobs.j1 = state('failed', 'grounding', [], false, 'ai_failed')
jobs.j2 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't find ideas. The AI assistant couldn't come up with ideas.")
expect(screen.queryAllByRole('article')).toHaveLength(0)
expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('article', { name: CURRY.name })
expect(calls(fetchMock, FIND)).toHaveLength(2)
})
it.each([
['search_not_configured', 'Recipe search is not set up.'],
['search_failed', "The recipe search couldn't be reached."],
])('explains %s', async (code, message) => {
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
jobs.j1 = state('failed', 'grounding', [], false, code)
await renderPage()
await searched(fetchMock)
expect(await screen.findByRole('alert')).toHaveTextContent(message)
})
it('shows no ideas found with the ErrorBanner and no cards, and Retry searches again', async () => {
let n = 0
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) })
jobs.j1 = state('failed', 'grounding', [], false, 'no_ideas_found')
jobs.j2 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't find ideas. No ideas came back with a recipe to match.")
expect(screen.queryAllByRole('article')).toHaveLength(0)
expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0)
expect(screen.queryByRole('heading', { name: 'No ideas found' })).toBeNull()
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('article', { name: CURRY.name })
expect(calls(fetchMock, FIND)).toHaveLength(2)
expect(screen.queryByRole('alert')).toBeNull()
})
it('shows an unknown job with the ErrorBanner, and Retry searches again', async () => {
let n = 0
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: `j${++n}` }),
'GET /api/v1/jobs/j1': () => errorResponse(404, 'not_found'),
})
jobs.j2 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't check on the ideas. not_found message")
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('article', { name: CURRY.name })
expect(calls(fetchMock, FIND)).toHaveLength(2)
expect(screen.queryByRole('alert')).toBeNull()
})
})
})
-321
View File
@@ -1,321 +0,0 @@
import { Check, ChevronLeft } from 'lucide-react'
import { useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { Link, useNavigate } from 'react-router'
import { ApiError, useJob } from '../api/index.ts'
import { duration } from '../plan/PlanPage.tsx'
import { Button } from '../ui/Button.tsx'
import { Card } from '../ui/Card.tsx'
import { Chip } from '../ui/Chip.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { TextInput } from '../ui/TextInput.tsx'
import {
ADDED_LABEL,
FROM_INGREDIENTS_JOB_KIND,
IDEAS_KEPT,
ideasJobFailure,
ideasJobLost,
INGREDIENT_LIMIT,
MAX_INGREDIENTS,
parseIdeasResult,
useAddIdeaToQuestLog,
useCookIdeaTonight,
useFindIdeas,
type Idea,
type IdeaRef,
type IdeaStage,
type Search,
} from './api.ts'
import styles from './CookFromPantryPage.module.css'
type When = 'any' | 'quick'
const WHEN = [
{ value: 'any', label: 'Any time' },
{ value: 'quick', label: 'Quick only' },
] as const satisfies readonly { value: When; label: string }[]
// findingLines says what a running search is doing, by its stage.
const findingLines: Record<Exclude<IdeaStage, 'done'>, string> = {
suggesting: 'Thinking up ideas…',
grounding: 'Finding recipes for them…',
}
// fold is an ingredient as the chips compare it, so "Rice" repeats " rice".
function fold(ingredient: string): string {
return ingredient.trim().replace(/\s+/g, ' ').toLowerCase()
}
// IdeaCard is one idea (mock 1i): its name and time, the server's line on
// what it uses and what is missing, and its two actions, each showing its
// own progress and failure; an idea added says whether its dinner is in
// the Quest log or is already a Regular. ref is the idea in its job, null
// while the job runs: the server takes an idea only once the job has
// succeeded, as the ideas may still be reordered until then.
function IdeaCard({ idea, ideaRef }: { idea: Idea; ideaRef: IdeaRef | null }) {
const questLog = useAddIdeaToQuestLog()
const tonight = useCookIdeaTonight()
const noPlan = tonight.error instanceof ApiError && tonight.error.code === 'no_plan'
return (
<Card tone="muted" className={styles.card} role="article" aria-label={idea.name}>
<div className={styles.cardHead}>
<h2 className={styles.name}>{idea.name}</h2>
{idea.minutes_total !== null && <span className={styles.time}>{duration(idea.minutes_total)}</span>}
</div>
<p className={styles.summary}>{idea.summary}</p>
<div className={styles.actions}>
{tonight.isSuccess ? (
<span className={styles.done} role="status">
<Icon icon={Check} /> On tonight's plan
</span>
) : (
<Button
className={styles.action}
disabled={ideaRef === null || tonight.isPending}
onClick={() => tonight.mutate(ideaRef!)}
>
{tonight.isPending ? 'Planning…' : 'Cook tonight'}
</Button>
)}
{questLog.isSuccess ? (
<span className={styles.done} role="status">
<Icon icon={Check} /> {ADDED_LABEL[questLog.data.status]}
</span>
) : (
<Button
variant="outline"
className={styles.action}
disabled={ideaRef === null || questLog.isPending}
onClick={() => questLog.mutate(ideaRef!)}
>
{questLog.isPending ? 'Adding…' : 'Add to Quest log'}
</Button>
)}
</div>
{noPlan ? (
<div className={styles.noPlan} role="status">
<span>This week has no plan yet, so there's no tonight to put it on.</span>
<Link to="/plan" className={styles.planLink}>
Plan this week
</Link>
</div>
) : (
tonight.isError && (
<p className={styles.failure} role="alert">
Couldn't put it on tonight's plan. {tonight.error.message}
</p>
)
)}
{questLog.isError && (
<p className={styles.failure} role="alert">
Couldn't add it to the Quest log. {questLog.error.message}
</p>
)}
</Card>
)
}
// Skeletons stand in for the count ideas still to come.
function Skeletons({ count }: { count: number }) {
return Array.from({ length: count }, (_, i) => <Skeleton key={i} data-testid="skeleton-card" className={styles.skeletonCard} />)
}
// CookFromPantryPage is mock 1i, Cook from what I have: the ingredients on
// hand as chips (Enter or a comma adds one), Any time | Quick only, and
// Find ideas, which lists what is still typed, so it is enabled by a chip
// or by text typed and not yet added, and starts an ideas_from_ingredients
// job and shows its ideas as the server grounds them, skeleton cards
// holding the places of those still to come. Changing Any time | Quick
// only after a search asks again: the server, not the screen, says what is
// quick.
export function CookFromPantryPage() {
const navigate = useNavigate()
const inputRef = useRef<HTMLInputElement>(null)
const [ingredients, setIngredients] = useState<string[]>([])
const [draft, setDraft] = useState('')
const [notice, setNotice] = useState<string | null>(null)
const [when, setWhen] = useState<When>('any')
// The search last asked for, which Retry asks again, and its job.
const [asked, setAsked] = useState<Search | null>(null)
const [jobId, setJobId] = useState<string | null>(null)
const findIdeas = useFindIdeas()
const job = useJob<unknown>(FROM_INGREDIENTS_JOB_KIND, jobId)
// add lists entries after the chips there are, saying why it leaves out
// any it cannot list: the server would refuse a blank, overlong or
// repeated ingredient, or one past the most it takes.
function add(list: string[], entries: string[]): string[] {
const next = [...list]
for (const entry of entries.map((e) => e.trim().replace(/\s+/g, ' '))) {
if (entry === '') continue
if (next.some((ingredient) => fold(ingredient) === fold(entry))) {
setNotice(`${entry} is listed already.`)
} else if (entry.length > INGREDIENT_LIMIT) {
setNotice(`An ingredient can be at most ${INGREDIENT_LIMIT} characters.`)
} else if (next.length === MAX_INGREDIENTS) {
setNotice(`List at most ${MAX_INGREDIENTS} ingredients.`)
} else {
next.push(entry)
}
}
return next
}
function onDraftChange(value: string) {
setNotice(null)
// A comma ends an ingredient, also within pasted text.
const parts = value.split(',')
const rest = parts.pop()!
if (parts.length > 0) setIngredients(add(ingredients, parts))
setDraft(rest)
}
function onDraftKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
event.preventDefault()
setNotice(null)
setIngredients(add(ingredients, [draft]))
setDraft('')
} else if (event.key === 'Backspace' && draft === '' && ingredients.length > 0) {
setNotice(null)
setIngredients(ingredients.slice(0, -1))
}
}
// search asks for ideas. Only the latest search's job is followed: mutate
// calls back for the latest call only.
function search(next: Search) {
setAsked(next)
setJobId(null)
findIdeas.mutate(next, { onSuccess: setJobId })
}
// find lists what is still typed, then searches the chips.
function find() {
setNotice(null)
const list = add(ingredients, [draft])
setIngredients(list)
setDraft('')
if (list.length > 0) search({ ingredients: list, quickOnly: when === 'quick' })
}
function onWhenChange(value: When) {
setWhen(value)
if (asked !== null && ingredients.length > 0) search({ ingredients, quickOnly: value === 'quick' })
}
const retry = () => search(asked!)
// The job's result as far as it has gone, or why it cannot be read.
const found = useMemo(() => {
if (job.result === null || asked === null) return { result: null, error: null }
try {
return { result: parseIdeasResult(`GET /api/v1/jobs/${jobId}`, asked.quickOnly, job.result), error: null }
} catch (error) {
return { result: null, error: error as Error }
}
}, [job.result, jobId, asked])
let results = null
if (findIdeas.isPending) {
results = (
<section aria-busy="true" aria-label="Ideas loading" className={styles.results}>
<p className={styles.count}>{findingLines.suggesting}</p>
<Skeletons count={IDEAS_KEPT} />
</section>
)
} else if (findIdeas.isError) {
results = <ErrorBanner onRetry={retry}>Couldn't start looking for ideas. {findIdeas.error.message}</ErrorBanner>
} else if (jobId !== null && job.status === 'failed') {
results = <ErrorBanner onRetry={retry}>Couldn't find ideas. {ideasJobFailure(job.error!.message)}</ErrorBanner>
} else if (found.error !== null) {
results = <ErrorBanner onRetry={retry}>Couldn't read the ideas. {found.error.message}</ErrorBanner>
} else if (jobId !== null) {
const ideas = found.result?.ideas ?? []
const done = job.status === 'succeeded'
// A job not found will not be found by asking again: Retry searches
// again. Any other failed poll is asked again.
const lost = ideasJobLost(job.error)
results = (
<>
{job.error !== null && (
<ErrorBanner onRetry={lost ? retry : job.poll}>Couldn't check on the ideas. {job.error.message}</ErrorBanner>
)}
<section aria-busy={!done} aria-label="Ideas" className={styles.results}>
<p className={styles.count}>
{done
? `${ideas.length} ${ideas.length === 1 ? 'idea' : 'ideas'}`
: findingLines[found.result?.stage === 'grounding' ? 'grounding' : 'suggesting']}
</p>
{ideas.map((idea, index) => (
<IdeaCard key={`${jobId}:${index}`} idea={idea} ideaRef={done ? { jobId, index } : null} />
))}
{!done && <Skeletons count={Math.max(IDEAS_KEPT - ideas.length, 0)} />}
</section>
</>
)
}
return (
<div className={styles.page}>
<header className={styles.header}>
<IconButton label="Back to Home" onClick={() => void navigate('/')}>
<Icon icon={ChevronLeft} size="nav" />
</IconButton>
<h1 className={styles.title}>Cook from what I have</h1>
</header>
{/* A tap anywhere on the field types in it. */}
<div className={styles.field} onClick={() => inputRef.current?.focus()}>
<ul className={styles.chips} aria-label="Ingredients on hand">
{ingredients.map((ingredient, i) => (
<li key={ingredient}>
<Chip
label={ingredient}
className={styles.chip}
onRemove={() => {
setNotice(null)
setIngredients(ingredients.filter((_, j) => j !== i))
inputRef.current?.focus()
}}
/>
</li>
))}
</ul>
<div className={styles.entry}>
<TextInput
ref={inputRef}
label="Add ingredient"
labelHidden
placeholder="Add ingredient…"
enterKeyHint="enter"
autoComplete="off"
className={styles.input}
value={draft}
onChange={(event) => onDraftChange(event.target.value)}
onKeyDown={onDraftKeyDown}
/>
</div>
</div>
{notice !== null && (
<p className={styles.notice} role="status">
{notice}
</p>
)}
<div className={styles.controls}>
<Segmented label="Which dinners" options={WHEN} value={when} onValueChange={onWhenChange} className={styles.when} />
<Button
variant="dark"
className={styles.find}
disabled={(ingredients.length < 1 && draft.trim() === '') || findIdeas.isPending}
onClick={find}
>
Find ideas
</Button>
</div>
{results}
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More