Compare commits

...
Author SHA1 Message Date
orchestrator-agent 1b464aecd2 Quick dinners screen (1j) (Feature 18) (#152)
CI / ci (push) Failing after 5m36s
CI / ci (pull_request) Failing after 4m52s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 08:58:41 -04:00
orchestrator-agent 2203c0c26f Cook from what I have screen (1i) (Feature 18) (#150)
CI / ci (push) Successful in 6m19s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 07:42:48 -04:00
orchestrator-agent d6f732c119 Idea actions: add to Quest log and cook tonight (Feature 18) (#148)
CI / ci (push) Successful in 5m54s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 21:21:15 -04:00
18 changed files with 3135 additions and 25 deletions
+28
View File
@@ -363,6 +363,34 @@ automatic** (D-021).
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}],
+100 -3
View File
@@ -288,6 +288,89 @@ 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
@@ -551,9 +634,23 @@ 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 fmt.Errorf("dinners: list for dedupe: %w", err)
return db.Dinner{}, false, fmt.Errorf("dinners: list for dedupe: %w", err)
}
for _, row := range rows {
if row.Dinner.ID == self {
@@ -564,10 +661,10 @@ func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeUR
link = row.RecipeSourceUrl
}
if harness.Similar(name, row.Dinner.Name) || (recipeURL.Valid && link == recipeURL) {
return &DuplicateError{Existing: fromRow(row.Dinner)}
return row.Dinner, true, nil
}
}
return nil
return db.Dinner{}, false, nil
}
// PhotoURL is where the API serves a list-sized copy of photo id.
+72 -1
View File
@@ -92,6 +92,7 @@ type QuickGenerator func(ctx context.Context, scope string, in skills.QuickIdeas
type Service struct {
st *store.Store
grounder *recipes.Grounder
runner *jobs.Runner
generate Generator
quick QuickGenerator
logger *slog.Logger
@@ -120,7 +121,7 @@ func NewService(st *store.Store, grounder *recipes.Grounder, generate Generator,
case logger == nil:
return nil, errors.New("ideas: nil logger")
}
s := &Service{st: st, grounder: grounder, generate: generate, quick: quick, logger: 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, withoutIdeas[Result]); err != nil {
return nil, err
@@ -589,3 +590,73 @@ func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) QuickIdea {
SiteName: gr.SiteName,
}
}
// 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
}
+34
View File
@@ -423,6 +423,40 @@ 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 {
+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": 5, // the constant (2), listable, CreateRegular's status and origin
"dinners/dinners.go": 6, // the constant (2), listable, CreateRegular's status and origin, AddToQuestLog reusing a Regular as it is
"plan/plan.go": 1, // plannable
"plan/rotation.go": 1, // decisionStatus[DecisionKeep]
"suggest/batch.go": 1, // reads which dinners are Regulars to cite
+416
View File
@@ -0,0 +1,416 @@
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/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), &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),
dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), ideasSvc, 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)
}
}
+129
View File
@@ -4,8 +4,13 @@ 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.
@@ -93,3 +98,127 @@ func postIdeasQuick(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc {
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")
}
+5 -1
View File
@@ -57,7 +57,9 @@ type RouteMounter interface {
// /api/v1/integrations/google and its /connect, /callback and /calendars,
// PUT /api/v1/integrations/google/calendar and DELETE
// /api/v1/integrations/google; ideasSvc serves POST
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick; runner serves
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with
// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with
// planSvc too, /cook-tonight; runner serves
// 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.
@@ -165,6 +167,8 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc))
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))
+20 -18
View File
@@ -35,6 +35,8 @@ const SCREENS = {
'/plan/shopping': true,
'/plan/shopping/archive': true,
'/discover': true,
'/quick': true,
'/cook-from-pantry': true,
'/settings': true,
'/settings/ai': true,
'/settings/household': true,
@@ -205,19 +207,19 @@ describe('App first-run redirect', () => {
it('opens the routes once onboarding is finished', async () => {
mockViewport(390)
stubFetchByPath(SIGNED_IN)
renderAt('/quick')
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
renderAt('/settings/notifications')
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).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('/quick')
renderAt('/settings/notifications')
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: 'Quick dinners' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
})
})
@@ -225,12 +227,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('/quick')
renderAt('/settings/notifications')
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: 'Quick dinners' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
})
@@ -298,7 +300,7 @@ describe('App offline bootstrap', () => {
})
it.each([
['/quick', 'Quick dinners'],
['/settings/notifications', 'Notifications'],
['/dinner/01J9ZK', 'Dinner'],
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
await visitOnline()
@@ -322,7 +324,7 @@ describe('App offline bootstrap', () => {
() => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))),
).mockRejectedValue(new TypeError('Failed to fetch')),
)
renderAt('/quick')
renderAt('/settings/notifications')
await vi.waitFor(() => expect(failMe).toBeDefined())
onLine = false
act(() => {
@@ -330,7 +332,7 @@ describe('App offline bootstrap', () => {
})
act(() => failMe())
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).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()
@@ -339,15 +341,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('/quick')
renderAt('/settings/notifications')
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).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('/quick')
renderAt('/settings/notifications')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
@@ -356,12 +358,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('/quick')
const { location, unmount } = renderAt('/settings/notifications')
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
unmount()
goOffline()
renderAt('/quick')
renderAt('/settings/notifications')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
})
@@ -369,13 +371,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('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage))
const api = createApiClient(fakeLocation('/settings/notifications'), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<InDataRouter path="/quick">
<InDataRouter path="/settings/notifications">
<App api={api} serviceWorker={undefined} />
</InDataRouter>,
)
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
const fetchMock = stubFetchByPath(SIGNED_IN)
@@ -390,7 +392,7 @@ describe('App offline bootstrap', () => {
]),
)
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
})
it('/settings/calendar renders the Google Calendar screen inside the shell', async () => {
+4
View File
@@ -4,6 +4,8 @@ import { Route, Routes } from 'react-router'
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
import { AuthBootstrap } from './AuthBootstrap.tsx'
import { DiscoverPage } from './discover/DiscoverPage.tsx'
import { 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'
@@ -45,6 +47,8 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/plan/shopping': <SupplyRunPage />,
'/plan/shopping/archive': <SupplyArchivePage />,
'/discover': <DiscoverPage />,
'/quick': <QuickDinnersPage />,
'/cook-from-pantry': <CookFromPantryPage />,
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
'/settings/household': <HouseholdSettingsPage />,
+226
View File
@@ -0,0 +1,226 @@
/* 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;
}
+512
View File
@@ -0,0 +1,512 @@
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')
const BOWLS = idea('Garlic spinach rice bowls', 'Uses 4 of 5 · nothing missing', 25)
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('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('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()
})
})
})
+312
View File
@@ -0,0 +1,312 @@
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 {
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. 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} /> In the Quest log
</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 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 || findIdeas.isPending} onClick={find}>
Find ideas
</Button>
</div>
{results}
</div>
)
}
+166
View File
@@ -0,0 +1,166 @@
/* Quick dinners, handoff mock 1j. */
.page {
box-sizing: border-box;
max-width: 560px;
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 6px;
}
.header {
display: flex;
align-items: center;
gap: 10px;
}
/* The 36 px grape circle holding the zap. */
.zap {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-pill);
background: var(--color-accent);
color: var(--on-accent);
}
.zapIcon {
width: 18px;
height: 18px;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 400;
line-height: 1.1;
}
.panel {
display: flex;
flex-direction: column;
gap: 14px;
}
.helper {
margin: 0;
font-family: var(--font-body);
font-size: 13px;
color: var(--color-neutral-800);
}
.rows {
display: flex;
flex-direction: column;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
border-radius: 24px;
background: var(--color-neutral-100);
}
.photo {
flex: none;
width: 64px;
height: 64px;
}
.text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.name {
margin: 0;
font-family: var(--font-heading);
font-size: 16px;
font-weight: 400;
line-height: 1.15;
}
.meta {
font-family: var(--font-body);
font-size: 12px;
color: var(--color-neutral-700);
}
.failure {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
color: var(--color-accent-700);
}
/* The teal "+": a 36 px circle in the IconButton's 44 px hit box (scoped
to the rows to outrank the IconButton's 40 px face). */
.rows .add > span {
width: 36px;
height: 36px;
}
/* An idea added: a check in the "+"'s place, of the same size. */
.added {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin: 4px;
border-radius: var(--radius-pill);
background: var(--color-accent-2-100);
color: var(--color-accent-2-800);
}
/* "↻ Show me more": a grape-700 text button, 44 px tall. */
.more {
align-self: center;
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 44px;
min-height: 44px;
padding: 0 12px;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-accent-700);
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.more:focus-visible {
outline: 3px solid var(--color-accent);
outline-offset: 2px;
}
.end {
margin: 0;
text-align: center;
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
color: var(--color-neutral-700);
}
.skeletonRow {
height: 84px;
border-radius: 24px;
}
+514
View File
@@ -0,0 +1,514 @@
import { QueryClientProvider, type QueryClient } 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 { declaredStyle } from '../test/cssRules.ts'
import { dinnerBody } from '../test/dinners.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { QuickDinnersPage } from './QuickDinnersPage.tsx'
import styles from './QuickDinnersPage.module.css'
const QUICK = 'POST /api/v1/ideas/quick'
// idea is a grounded Quick dinner idea as the job's result carries it.
function idea(name: string, method: string, minutes: number | null, difficulty: string) {
return {
name,
method,
minutes_total: minutes,
difficulty,
quick: true,
recipe_id: `r-${name}`,
photo_url: `/media/photos/p-${name.length}-400.jpg`,
source_url: 'https://recipes.example/x',
site_name: 'Recipes',
}
}
// The ideas of mock 1j, and more for later pages and the Crock pot tab.
const ORZO = idea('Lemon garlic shrimp orzo', 'stovetop', 25, 'medium')
const QUESADILLAS = idea('Chicken quesadillas', 'stovetop', 20, 'easy')
const BROCCOLI = idea('Beef & broccoli', 'stovetop', 30, 'easy')
const GNOCCHI = idea('Pesto gnocchi & peas', 'stovetop', 15, 'easy')
const CHILI = idea('Crock pot chili', 'crockpot', 360, 'easy')
const CARNITAS = idea('Pork carnitas', 'crockpot', null, 'medium')
type JobBody = { status: string; result: unknown; error: string | null }
// state is a quick_ideas job's state at stage with ideas.
function state(status: string, stage: string, kind: string, ideas: unknown[], error: string | null = null): JobBody {
return { status, result: { stage, kind, exclude: [], ideas, dropped: 0 }, error }
}
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: 'quick_ideas', ...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 }
}
// quickServer answers each POST /ideas/quick with the next of jobIds.
function quickServer(jobIds: string[], responders: Record<string, Responder> = {}) {
let n = 0
return stubServer({
[QUICK]: () => {
const jobId = jobIds[n++]
if (jobId === undefined) throw new Error('unexpected quick search')
return jsonResponse(202, { jobId })
},
...responders,
})
}
// 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)
}
// searches are the POST /ideas/quick bodies sent, in order.
function searches(fetchMock: ReturnType<typeof stubServer>['fetchMock']) {
return calls(fetchMock, QUICK).map((call) => JSON.parse(String(call[1]?.body)) as unknown)
}
async function renderPage(queryClient: QueryClient = createQueryClient()) {
const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/quick']}>
<Routes>
<Route path="/quick" element={<QuickDinnersPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
function tab(name: string) {
return within(screen.getByRole('radiogroup', { name: 'Kind of Quick dinner' })).getByRole('radio', { name })
}
function row(name: string) {
return screen.getByRole('listitem', { name })
}
function rowNames() {
return within(screen.getByRole('list', { name: 'Quick dinners' }))
.queryAllByRole('heading', { level: 2 })
.map((heading) => heading.textContent)
}
function addButton(name: string) {
return within(row(name)).getByRole('button', { name: `Add ${name} to the Quest log` })
}
// showMore waits for Show me more, shown once a page is kept.
function showMore() {
return screen.findByRole('button', { name: 'Show me more' })
}
const SLOW = { timeout: 3000 }
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('QuickDinnersPage', () => {
it('renders mock 1j: zap header, tabs with 30 min or less chosen, its helper, and its first page', async () => {
const { fetchMock, jobs } = quickServer(['j1'])
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS, BROCCOLI, GNOCCHI])
const { container } = await renderPage()
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
const zap = container.querySelector(`.${styles.zap}`)!
expect(declaredStyle(zap).width).toBe('36px')
expect(declaredStyle(zap).background).toBe('var(--color-accent)')
expect(zap.querySelector('svg')).toHaveAttribute('stroke-width', '2.75')
expect(tab('30 min or less')).toBeChecked()
expect(tab('Crock pot')).not.toBeChecked()
expect(screen.getByText('Prep to table in 30 minutes or less, picked from your tastes.')).toBeInTheDocument()
await screen.findByRole('listitem', { name: ORZO.name })
expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name, GNOCCHI.name])
expect(searches(fetchMock)).toEqual([{ kind: 'under30', exclude: [] }])
const orzo = within(row(ORZO.name))
expect(orzo.getByText('25 min · Medium')).toHaveClass(styles.meta!)
expect(orzo.getByRole('img', { name: ORZO.name })).toHaveAttribute('src', '/media/photos/p-24-400.jpg')
expect(declaredStyle(row(ORZO.name)).background).toBe('var(--color-neutral-100)')
expect(declaredStyle(row(ORZO.name)).borderRadius).toBe('24px')
expectMinHitArea(addButton(ORZO.name))
expect(addButton(ORZO.name)).toBeEnabled()
expectMinHitArea(await showMore())
expect(declaredStyle(await showMore()).color).toBe('var(--color-accent-700)')
})
it('fills the rows progressively as the server grounds ideas, adding only once the page is found', async () => {
const { jobs } = quickServer(['j1'])
jobs.j1 = state('running', 'suggesting', 'under30', [])
await renderPage()
await waitFor(() => expect(screen.getAllByTestId('skeleton-row')).toHaveLength(6))
expect(screen.getByRole('list', { name: 'Quick dinners' })).toHaveAttribute('aria-busy', 'true')
jobs.j1 = state('running', 'grounding', 'under30', [ORZO])
await screen.findByRole('listitem', { name: ORZO.name }, SLOW)
expect(screen.getAllByTestId('skeleton-row')).toHaveLength(5)
expect(addButton(ORZO.name)).toBeDisabled()
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
await screen.findByRole('listitem', { name: QUESADILLAS.name }, SLOW)
expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0)
expect(addButton(ORZO.name)).toBeEnabled()
expect(await showMore()).toBeInTheDocument()
expect(screen.getByRole('list', { name: 'Quick dinners' })).toHaveAttribute('aria-busy', 'false')
})
describe('tabs', () => {
it('searches each tab when first shown, keeps what each found, and says what each finds', async () => {
const { fetchMock, jobs } = quickServer(['j1', 'j2'])
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI, CARNITAS])
await renderPage()
await screen.findByRole('listitem', { name: ORZO.name })
fireEvent.click(tab('Crock pot'))
expect(tab('Crock pot')).toBeChecked()
expect(screen.getByText(/in the crock pot.*picked from your tastes\./)).toBeInTheDocument()
await screen.findByRole('listitem', { name: CHILI.name })
expect(rowNames()).toEqual([CHILI.name, CARNITAS.name])
expect(searches(fetchMock)).toEqual([
{ kind: 'under30', exclude: [] },
{ kind: 'crockpot', exclude: [] },
])
// Each tab's ideas are kept: going back asks for nothing.
fireEvent.click(tab('30 min or less'))
expect(rowNames()).toEqual([ORZO.name])
fireEvent.click(tab('Crock pot'))
expect(rowNames()).toEqual([CHILI.name, CARNITAS.name])
expect(calls(fetchMock, QUICK)).toHaveLength(2)
})
it('shows a crock-pot idea as "Crock pot · 6 hr · Easy", without a time when the recipe has none', async () => {
const { jobs } = quickServer(['j1', 'j2'])
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI, CARNITAS])
await renderPage()
fireEvent.click(tab('Crock pot'))
await screen.findByRole('listitem', { name: CHILI.name })
expect(within(row(CHILI.name)).getByText('Crock pot · 6 hr · Easy')).toBeInTheDocument()
expect(within(row(CARNITAS.name)).getByText('Crock pot · Medium')).toBeInTheDocument()
})
it('keeps a tab for the session, also when the screen is left and opened again', async () => {
const { fetchMock, jobs } = quickServer(['j1'])
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
const queryClient = createQueryClient()
const { unmount } = await renderPage(queryClient)
await screen.findByRole('listitem', { name: ORZO.name })
unmount()
await renderPage(queryClient)
expect(rowNames()).toEqual([ORZO.name])
expect(calls(fetchMock, QUICK)).toHaveLength(1)
})
it('refuses a result that is not of the tab asked for', async () => {
const { jobs } = quickServer(['j1'])
jobs.j1 = state('succeeded', 'done', 'crockpot', [CHILI])
await renderPage()
expect(await screen.findByRole('alert')).toHaveTextContent(
"Couldn't read the Quick dinners. GET /api/v1/jobs/j1: result is not Quick dinner ideas of kind under30",
)
expect(screen.queryByRole('listitem', { name: CHILI.name })).toBeNull()
})
})
describe('Add to Quest log', () => {
it('adds the idea with its "+", which becomes a check, kept across tabs', async () => {
const added = Promise.withResolvers<Response>()
const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'POST /api/v1/ideas/j1/items/1/quest-log': () => added.promise })
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
await renderPage()
await screen.findByRole('listitem', { name: QUESADILLAS.name })
fireEvent.click(addButton(QUESADILLAS.name))
await waitFor(() => expect(addButton(QUESADILLAS.name)).toBeDisabled())
added.resolve(jsonResponse(201, dinnerBody('dn-1', QUESADILLAS.name, { status: 'quest_log', origin: 'quick' })))
const quesadillas = within(row(QUESADILLAS.name))
expect(await quesadillas.findByRole('status')).toContainElement(quesadillas.getByRole('img', { name: 'In the Quest log' }))
expect(quesadillas.queryByRole('button')).toBeNull()
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/1/quest-log')).toHaveLength(1)
// The other row is untouched.
expect(addButton(ORZO.name)).toBeEnabled()
fireEvent.click(tab('Crock pot'))
await screen.findByRole('listitem', { name: CHILI.name })
fireEvent.click(tab('30 min or less'))
expect(within(row(QUESADILLAS.name)).getByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
})
it('marks an add that settles while another tab is shown', async () => {
const added = Promise.withResolvers<Response>()
const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'POST /api/v1/ideas/j1/items/0/quest-log': () => added.promise })
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
const queryClient = createQueryClient()
await renderPage(queryClient)
await screen.findByRole('listitem', { name: ORZO.name })
fireEvent.click(addButton(ORZO.name))
await waitFor(() => expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/quest-log')).toHaveLength(1))
fireEvent.click(tab('Crock pot'))
await screen.findByRole('listitem', { name: CHILI.name })
added.resolve(jsonResponse(201, dinnerBody('dn-1', ORZO.name, { status: 'quest_log', origin: 'quick' })))
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
fireEvent.click(tab('30 min or less'))
expect(within(row(ORZO.name)).getByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
expect(addButton(QUESADILLAS.name)).toBeEnabled()
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/quest-log')).toHaveLength(1)
})
it('shows its failure in its row and lets it be pressed again', async () => {
let fail = true
const { jobs } = quickServer(['j1'], {
'POST /api/v1/ideas/j1/items/0/quest-log': () =>
fail ? errorResponse(500, 'internal') : jsonResponse(200, dinnerBody('dn-1', ORZO.name, { status: 'quest_log' })),
})
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
await renderPage()
await screen.findByRole('listitem', { name: ORZO.name })
const orzo = within(row(ORZO.name))
fireEvent.click(addButton(ORZO.name))
expect(await orzo.findByRole('alert')).toHaveTextContent("Couldn't add it to the Quest log. internal message")
fail = false
fireEvent.click(addButton(ORZO.name))
expect(await orzo.findByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
expect(orzo.queryByRole('alert')).toBeNull()
})
})
describe('Show me more', () => {
it('asks for the same kind excluding every name shown, and adds from the page that found it', async () => {
const { fetchMock, jobs } = quickServer(['j1', 'j2', 'j3'], {
'POST /api/v1/ideas/j2/items/0/quest-log': () => jsonResponse(201, dinnerBody('dn-1', BROCCOLI.name, { status: 'quest_log' })),
})
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
jobs.j2 = state('running', 'grounding', 'under30', [BROCCOLI])
jobs.j3 = state('succeeded', 'done', 'under30', [GNOCCHI])
await renderPage()
await screen.findByRole('listitem', { name: QUESADILLAS.name })
fireEvent.click(await showMore())
await screen.findByRole('listitem', { name: BROCCOLI.name }, SLOW)
// The first page stays while the next fills.
expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name])
expect(screen.getAllByTestId('skeleton-row')).toHaveLength(5)
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
jobs.j2 = state('succeeded', 'done', 'under30', [BROCCOLI])
await screen.findByRole('button', { name: 'Show me more' }, SLOW)
fireEvent.click(addButton(BROCCOLI.name))
expect(await within(row(BROCCOLI.name)).findByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
fireEvent.click(await showMore())
await screen.findByRole('listitem', { name: GNOCCHI.name })
expect(searches(fetchMock)).toEqual([
{ kind: 'under30', exclude: [] },
{ kind: 'under30', exclude: [ORZO.name, QUESADILLAS.name] },
{ kind: 'under30', exclude: [ORZO.name, QUESADILLAS.name, BROCCOLI.name] },
])
expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name, GNOCCHI.name])
})
it('says every idea has been shown when a later page finds none', async () => {
const { fetchMock, jobs } = quickServer(['j1', 'j2'])
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
jobs.j2 = state('failed', 'grounding', 'under30', [], 'no_ideas_found')
await renderPage()
await screen.findByRole('listitem', { name: ORZO.name })
fireEvent.click(await showMore())
expect(await screen.findByRole('status')).toHaveTextContent("That's every Quick dinner for now.")
expect(screen.queryByRole('alert')).toBeNull()
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
expect(rowNames()).toEqual([ORZO.name])
expect(calls(fetchMock, QUICK)).toHaveLength(2)
})
it('shows a later page that fails with the ErrorBanner, keeping the rows, and Retry asks again', async () => {
const { fetchMock, jobs } = quickServer(['j1', 'j2', 'j3'])
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
jobs.j2 = state('failed', 'grounding', 'under30', [], 'search_failed')
jobs.j3 = state('succeeded', 'done', 'under30', [QUESADILLAS])
await renderPage()
await screen.findByRole('listitem', { name: ORZO.name })
fireEvent.click(await showMore())
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't find Quick dinners. The recipe search couldn't be reached.")
expect(rowNames()).toEqual([ORZO.name])
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('listitem', { name: QUESADILLAS.name })
expect(searches(fetchMock)[2]).toEqual({ kind: 'under30', exclude: [ORZO.name] })
expect(screen.queryByRole('alert')).toBeNull()
})
})
describe('errors', () => {
it('shows the ErrorBanner when the search cannot start, and Retry asks again', async () => {
let n = 0
const { fetchMock, jobs } = stubServer({
[QUICK]: () => (++n === 1 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: 'j1' })),
})
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message")
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('listitem', { name: ORZO.name })
expect(searches(fetchMock)).toEqual([
{ kind: 'under30', exclude: [] },
{ kind: 'under30', exclude: [] },
])
})
it('keeps a first page that cannot start across a tab switch, asking again only on Retry', async () => {
let under30 = 0
const { fetchMock, jobs } = stubServer({
[QUICK]: (init) => {
const { kind } = JSON.parse(String(init?.body)) as { kind: string }
if (kind === 'crockpot') return jsonResponse(202, { jobId: 'j2' })
return ++under30 === 1 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: 'j1' })
},
})
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
await renderPage()
await screen.findByRole('alert')
fireEvent.click(tab('Crock pot'))
await screen.findByRole('listitem', { name: CHILI.name })
fireEvent.click(tab('30 min or less'))
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message")
expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0)
expect(searches(fetchMock)).toEqual([
{ kind: 'under30', exclude: [] },
{ kind: 'crockpot', exclude: [] },
])
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('listitem', { name: ORZO.name })
expect(screen.queryByRole('alert')).toBeNull()
expect(calls(fetchMock, QUICK)).toHaveLength(3)
})
it('keeps a later page that cannot start across a tab switch, asking again only on Retry', async () => {
let under30 = 0
const { fetchMock, jobs } = stubServer({
[QUICK]: (init) => {
const { kind } = JSON.parse(String(init?.body)) as { kind: string }
if (kind === 'crockpot') return jsonResponse(202, { jobId: 'j2' })
under30++
return under30 === 2 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: under30 === 1 ? 'j1' : 'j3' })
},
})
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
jobs.j3 = state('succeeded', 'done', 'under30', [QUESADILLAS])
await renderPage()
await screen.findByRole('listitem', { name: ORZO.name })
fireEvent.click(await showMore())
await screen.findByRole('alert')
fireEvent.click(tab('Crock pot'))
await screen.findByRole('listitem', { name: CHILI.name })
fireEvent.click(tab('30 min or less'))
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message")
expect(rowNames()).toEqual([ORZO.name])
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
expect(calls(fetchMock, QUICK)).toHaveLength(3)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('listitem', { name: QUESADILLAS.name })
expect(searches(fetchMock)[3]).toEqual({ kind: 'under30', exclude: [ORZO.name] })
expect(screen.queryByRole('alert')).toBeNull()
})
it.each([
['ai_failed', "The AI assistant couldn't come up with ideas."],
['no_ideas_found', 'No ideas came back with a recipe to match.'],
['search_not_configured', 'Recipe search is not set up.'],
['internal_error', 'Something went wrong on the server.'],
])('shows a first page failed with %s with the ErrorBanner and no rows, and Retry searches again', async (code, message) => {
const { fetchMock, jobs } = quickServer(['j1', 'j2'])
jobs.j1 = state('failed', 'grounding', 'under30', [], code)
jobs.j2 = state('succeeded', 'done', 'under30', [ORZO])
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent(`Couldn't find Quick dinners. ${message}`)
expect(screen.queryAllByRole('listitem', { name: /./ })).toHaveLength(0)
expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0)
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('listitem', { name: ORZO.name })
expect(calls(fetchMock, QUICK)).toHaveLength(2)
expect(screen.queryByRole('alert')).toBeNull()
})
it('shows an unknown job with the ErrorBanner, and Retry searches again', async () => {
const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'GET /api/v1/jobs/j1': () => errorResponse(404, 'not_found') })
jobs.j2 = state('succeeded', 'done', 'under30', [ORZO])
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't check on the Quick dinners. not_found message")
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('listitem', { name: ORZO.name })
expect(calls(fetchMock, QUICK)).toHaveLength(2)
expect(screen.queryByRole('alert')).toBeNull()
})
})
})
+261
View File
@@ -0,0 +1,261 @@
import { Check, Plus, RefreshCw, Zap } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useIsMutating } from '@tanstack/react-query'
import { useJob } from '../api/index.ts'
import { DIFFICULTY_LABEL } from '../discover/DeckCard.tsx'
import { duration } from '../plan/PlanPage.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.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 } from '../ui/Segmented.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import {
ideasJobFailure,
ideasJobLost,
MAX_EXCLUDE,
parseQuickResult,
QUICK_IDEAS_KEPT,
QUICK_JOB_KIND,
quickTabKey,
shownNames,
useAddIdeaToQuestLog,
useFindQuickIdeas,
useQuickTab,
type IdeaRef,
type QuickIdea,
type QuickKind,
} from './api.ts'
import styles from './QuickDinnersPage.module.css'
const TABS = [
{ value: 'crockpot', label: 'Crock pot' },
{ value: 'under30', label: '30 min or less' },
] as const satisfies readonly { value: QuickKind; label: string }[]
// helpers says what each tab finds.
const helpers: Record<QuickKind, string> = {
crockpot: 'Set it going in the crock pot and come back to dinner, picked from your tastes.',
under30: 'Prep to table in 30 minutes or less, picked from your tastes.',
}
// The server's code for a search that found nothing: on a later page, it
// means the tab has shown every idea there is.
const NO_IDEAS_FOUND = 'no_ideas_found'
// meta is a row's "25 min · Medium" line: "Crock pot" first for a crock-pot
// idea, and the time only when the recipe says it.
function meta(idea: QuickIdea): string {
return [
idea.method === 'crockpot' && methodLabels.crockpot,
idea.minutes_total !== null && duration(idea.minutes_total),
DIFFICULTY_LABEL[idea.difficulty],
]
.filter((part): part is string => typeof part === 'string')
.join(' · ')
}
// QuickRow is one idea (mock 1j): its washed photo, name and meta line, and
// the teal "+" that adds it to the Quest log, a check once it is there.
// ideaRef is null while its job runs: the server takes an idea only once
// the job has succeeded. onAdded marks the idea added, also if the row has
// unmounted before the add settles.
function QuickRow({
idea,
ideaRef,
added,
onAdded,
}: {
idea: QuickIdea
ideaRef: IdeaRef | null
added: boolean
onAdded: (idea: IdeaRef) => void
}) {
const questLog = useAddIdeaToQuestLog(onAdded)
return (
<li className={styles.row} aria-label={idea.name}>
<Photo id={idea.photo_id} name={idea.name} radius="50%" sizes="64px" className={styles.photo} />
<div className={styles.text}>
<h2 className={styles.name}>{idea.name}</h2>
<span className={styles.meta}>{meta(idea)}</span>
{questLog.isError && (
<p className={styles.failure} role="alert">
Couldn't add it to the Quest log. {questLog.error.message}
</p>
)}
</div>
{added ? (
<span className={styles.added} role="status">
<Icon icon={Check} size="nav" label="In the Quest log" />
</span>
) : (
<IconButton
label={`Add ${idea.name} to the Quest log`}
tone="teal"
className={styles.add}
disabled={ideaRef === null || questLog.isPending}
onClick={() => questLog.mutate(ideaRef!)}
>
<Icon icon={Plus} size="nav" />
</IconButton>
)}
</li>
)
}
// Skeletons stand in for the count ideas still to come.
function Skeletons({ count }: { count: number }) {
return Array.from({ length: count }, (_, i) => (
<li key={i} aria-hidden="true">
<Skeleton data-testid="skeleton-row" className={styles.skeletonRow} />
</li>
))
}
// QuickTabPanel is one tab's ideas: its pages so far, the page being
// found, filling as the server grounds it, and Show me more, which asks for
// a page excluding every name shown. The tab's first page is asked for when
// the tab is first shown; what it finds is kept for the session.
function QuickTabPanel({ kind }: { kind: QuickKind }) {
const [tab, update] = useQuickTab(kind)
const { mutate } = useFindQuickIdeas(kind)
// A page being asked for, also by this tab before it was last shown.
const starting = useIsMutating({ mutationKey: quickTabKey(kind) }) > 0
const job = useJob<unknown>(QUICK_JOB_KIND, tab.jobId)
const names = shownNames(tab)
// ask asks for the next page, excluding every name shown; Retry asks
// again for the page that failed. A page that could not start stays the
// tab's startError until Retry: nothing asks again by itself.
const ask = () => mutate(names)
// The first page, asked for once.
const asked = useRef(false)
const fresh = tab.pages.length === 0 && tab.jobId === null && !tab.ended && tab.startError === null && !starting
useEffect(() => {
if (!fresh || asked.current) return
asked.current = true
mutate([])
}, [fresh, mutate])
// The job's result as far as it has gone, or why it cannot be read.
const found = useMemo(() => {
if (job.result === null || tab.jobId === null) return { result: null, error: null }
try {
return { result: parseQuickResult(`GET /api/v1/jobs/${tab.jobId}`, kind, job.result), error: null }
} catch (error) {
return { result: null, error: error as Error }
}
}, [job.result, tab.jobId, kind])
const failure = job.status === 'failed' ? job.error!.message : null
// A later page that finds nothing means there is nothing more to show.
const exhausted = failure === NO_IDEAS_FOUND && tab.pages.length > 0
// A page found is kept with the tab's pages; a page that ends the tab
// ends it.
const jobId = tab.jobId
const ideas = job.status === 'succeeded' ? (found.result?.ideas ?? null) : null
useEffect(() => {
if (jobId === null) return
if (ideas !== null) update((t) => (t.jobId === jobId ? { ...t, pages: [...t.pages, { jobId, ideas }], jobId: null } : t))
else if (exhausted) update((t) => (t.jobId === jobId ? { ...t, jobId: null, ended: true } : t))
}, [jobId, ideas, exhausted, update])
const markAdded = ({ jobId, index }: IdeaRef) => update((t) => ({ ...t, added: [...t.added, `${jobId}:${index}`] }))
// The rows of every page found, then of the page being found, in one
// keyed list: a page kept with the tab's pages keeps its rows, and with
// them any add still under way.
const rows: { idea: QuickIdea; ref: IdeaRef | null; key: string }[] = tab.pages.flatMap((page) =>
page.ideas.map((idea, index) => ({ idea, ref: { jobId: page.jobId, index }, key: `${page.jobId}:${index}` })),
)
let next = null
if (starting) {
next = <Skeletons count={QUICK_IDEAS_KEPT} />
} else if (tab.startError !== null) {
next = (
<li>
<ErrorBanner onRetry={ask}>Couldn't start looking for Quick dinners. {tab.startError.message}</ErrorBanner>
</li>
)
} else if (jobId !== null && failure !== null && !exhausted) {
next = (
<li>
<ErrorBanner onRetry={ask}>Couldn't find Quick dinners. {ideasJobFailure(failure)}</ErrorBanner>
</li>
)
} else if (found.error !== null) {
next = (
<li>
<ErrorBanner onRetry={ask}>Couldn't read the Quick dinners. {found.error.message}</ErrorBanner>
</li>
)
} else if (jobId !== null && !exhausted) {
const shown = found.result?.ideas ?? []
const done = job.status === 'succeeded'
shown.forEach((idea, index) => rows.push({ idea, ref: done ? { jobId, index } : null, key: `${jobId}:${index}` }))
// A job not found will not be found by asking again: Retry asks for the
// page again. Any other failed poll is asked again.
const lost = ideasJobLost(job.error)
next = (
<>
{!done && <Skeletons count={Math.max(QUICK_IDEAS_KEPT - shown.length, 0)} />}
{job.error !== null && (
<li>
<ErrorBanner onRetry={lost ? ask : job.poll}>Couldn't check on the Quick dinners. {job.error.message}</ErrorBanner>
</li>
)}
</>
)
}
// Show me more once the tab is between pages; the server takes at most
// MAX_EXCLUDE names to leave out, so past that there is no more to ask.
const between = !starting && tab.startError === null && jobId === null && tab.pages.length > 0
const allShown = tab.ended || names.length > MAX_EXCLUDE
const busy = starting || (jobId !== null && failure === null && found.error === null && job.status !== 'succeeded')
return (
<section aria-label={TABS.find((t) => t.value === kind)!.label} className={styles.panel}>
<p className={styles.helper}>{helpers[kind]}</p>
<ul aria-busy={busy} aria-label="Quick dinners" className={styles.rows}>
{rows.map(({ idea, ref, key }) => (
<QuickRow key={key} idea={idea} ideaRef={ref} added={tab.added.includes(key)} onAdded={markAdded} />
))}
{next}
</ul>
{between &&
(allShown ? (
<p className={styles.end} role="status">
That's every Quick dinner for now.
</p>
) : (
<button type="button" className={styles.more} onClick={ask}>
<Icon icon={RefreshCw} /> Show me more
</button>
))}
</section>
)
}
// QuickDinnersPage is mock 1j, Quick dinners: a Crock pot | 30 min or less
// tab for each kind of Quick dinner, each a quick_ideas search of its own
// whose ideas fill as the server grounds them, kept for the session.
export function QuickDinnersPage() {
const [kind, setKind] = useState<QuickKind>('under30')
return (
<div className={styles.page}>
<header className={styles.header}>
<span className={styles.zap}>
<Icon icon={Zap} className={styles.zapIcon} />
</span>
<h1 className={styles.title}>Quick dinners</h1>
</header>
<Segmented label="Kind of Quick dinner" options={TABS} value={kind} onValueChange={setKind} className={styles.tabs} />
<QuickTabPanel key={kind} kind={kind} />
</div>
)
}
+332
View File
@@ -0,0 +1,332 @@
import { useCallback } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ApiError, useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { DIFFICULTIES, METHODS, parseDinner, photoId, type Difficulty, type Method } from '../dinners/api.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
import { parsePlan, planKey } from '../plan/api.ts'
export const IDEAS_PATH = '/api/v1/ideas'
export const FROM_INGREDIENTS_PATH = `${IDEAS_PATH}/from-ingredients`
// FROM_INGREDIENTS_JOB_KIND is the kind of the job finding ideas from the
// ingredients on hand.
export const FROM_INGREDIENTS_JOB_KIND = 'ideas_from_ingredients'
// IDEAS_KEPT is the most ideas a job keeps (skills.IdeasKept), so the most
// cards a search fills.
export const IDEAS_KEPT = 5
// MAX_INGREDIENTS is the most ingredients the household may list, and
// INGREDIENT_LIMIT the most characters one may have
// (skills.MaxIngredients, skills.IngredientLimit).
export const MAX_INGREDIENTS = 20
export const INGREDIENT_LIMIT = 80
// IDEA_STAGES are a job's stages: asking the AI for ideas, grounding them
// in recipes, and done.
export const IDEA_STAGES = ['suggesting', 'grounding', 'done'] as const
export type IdeaStage = (typeof IDEA_STAGES)[number]
// Idea is a grounded idea as a result card shows it. summary is the
// server's "Uses all 5 · missing: 1 lime" line, shown as it is;
// minutes_total is the grounded recipe's, null when it does not say.
export type Idea = {
name: string
uses: string[]
missing: string[]
summary: string
minutes_total: number | null
quick: boolean
}
// IdeasResult is an ideas_from_ingredients job's result, published as it
// goes: the ideas grounded so far, in the order the server shows them.
export type IdeasResult = {
stage: IdeaStage
ingredients: string[]
quick_only: boolean
ideas: Idea[]
dropped: number
}
// IDEAS_JOB_FAILURES says why an ideas job failed, by its failure code.
// no_ideas_found is no fault to fix, but the search still came back empty.
const IDEAS_JOB_FAILURES: Record<string, string> = {
no_ideas_found: 'No ideas came back with a recipe to match.',
ai_failed: "The AI assistant couldn't come up with ideas.",
search_not_configured: 'Recipe search is not set up. An admin can set it up in Settings.',
search_failed: "The recipe search couldn't be reached.",
internal_error: 'Something went wrong on the server.',
}
// ideasJobFailure says why an ideas job failed; an unknown code is shown
// as it is.
export function ideasJobFailure(code: string): string {
return IDEAS_JOB_FAILURES[code] ?? code
}
// ideasJobLost says a poll of an ideas job failed because the server no
// longer has the job (404), so asking for it again will not find it: the
// search must be started again.
export function ideasJobLost(error: Error | null): boolean {
return error instanceof ApiError && error.status === 404
}
// Search is what Find ideas asks for.
export type Search = { ingredients: string[]; quickOnly: boolean }
function isStrings(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === 'string')
}
function parseIdea(where: string, body: unknown): Idea {
if (
!isRecord(body) ||
typeof body.name !== 'string' ||
!isStrings(body.uses) ||
!isStrings(body.missing) ||
typeof body.summary !== 'string' ||
typeof body.quick !== 'boolean' ||
(body.minutes_total !== null && !Number.isInteger(body.minutes_total))
) {
throw new Error(`${where}: result has an idea that is not a grounded idea`)
}
return {
name: body.name,
uses: body.uses,
missing: body.missing,
summary: body.summary,
minutes_total: body.minutes_total as number | null,
quick: body.quick,
}
}
// parseIdeasResult checks an ideas_from_ingredients job's result, which
// useJob passes on unread, answers the search asked for.
export function parseIdeasResult(where: string, quickOnly: boolean, body: unknown): IdeasResult {
if (
!isRecord(body) ||
typeof body.stage !== 'string' ||
!(IDEA_STAGES as readonly string[]).includes(body.stage) ||
!isStrings(body.ingredients) ||
body.quick_only !== quickOnly ||
!Array.isArray(body.ideas) ||
typeof body.dropped !== 'number'
) {
throw new Error(`${where}: result is not ideas from ingredients`)
}
return {
stage: body.stage as IdeaStage,
ingredients: body.ingredients,
quick_only: quickOnly,
ideas: body.ideas.map((idea) => parseIdea(where, idea)),
dropped: body.dropped,
}
}
// useFindIdeas starts finding ideas from the ingredients on hand (POST
// /api/v1/ideas/from-ingredients) and resolves with the id of the job,
// which GET /api/v1/jobs/{id} reports on.
export function useFindIdeas() {
const { apiFetch } = useApi()
return useMutation({
mutationFn: async ({ ingredients, quickOnly }: Search): Promise<string> => {
const body = await apiFetch<unknown>(FROM_INGREDIENTS_PATH, {
method: 'POST',
body: { ingredients, quick_only: quickOnly },
})
if (!isRecord(body) || typeof body.jobId !== 'string') throw new Error(`POST ${FROM_INGREDIENTS_PATH}: response has no jobId`)
return body.jobId
},
})
}
// QUICK_PATH starts finding a page of Quick dinner ideas, and
// QUICK_JOB_KIND is the kind of the job that finds it.
export const QUICK_PATH = `${IDEAS_PATH}/quick`
export const QUICK_JOB_KIND = 'quick_ideas'
// QUICK_KINDS are the kinds of Quick dinner, the tabs of Quick dinners in
// order (skills.QuickKinds): cooked in a crock pot, or 30 minutes or less
// from prep to table.
export const QUICK_KINDS = ['crockpot', 'under30'] as const
export type QuickKind = (typeof QUICK_KINDS)[number]
// QUICK_IDEAS_KEPT is the most ideas one page of Quick dinners shows
// (skills.QuickIdeasKept), and MAX_EXCLUDE the most names a request may
// exclude (skills.MaxExclude).
export const QUICK_IDEAS_KEPT = 6
export const MAX_EXCLUDE = 60
// QuickIdea is a grounded Quick dinner idea as a row shows it. method,
// minutes_total and difficulty are the grounded recipe's; minutes_total is
// null when the recipe does not say, which only a crock-pot idea may be.
// photo_id is the stored photo's, from photo_url.
export type QuickIdea = {
name: string
method: Method
minutes_total: number | null
difficulty: Difficulty
photo_id: string | undefined
}
// QuickResult is a quick_ideas job's result, published as it goes: the
// ideas grounded so far, in the order the server shows them.
export type QuickResult = {
stage: IdeaStage
kind: QuickKind
ideas: QuickIdea[]
}
function parseQuickIdea(where: string, body: unknown): QuickIdea {
if (
!isRecord(body) ||
typeof body.name !== 'string' ||
!(METHODS as readonly unknown[]).includes(body.method) ||
(body.minutes_total !== null && !Number.isInteger(body.minutes_total)) ||
!(DIFFICULTIES as readonly unknown[]).includes(body.difficulty)
) {
throw new Error(`${where}: result has an idea that is not a grounded Quick dinner`)
}
return {
name: body.name,
method: body.method as Method,
minutes_total: body.minutes_total as number | null,
difficulty: body.difficulty as Difficulty,
photo_id: photoId(where, body.photo_url),
}
}
// parseQuickResult checks a quick_ideas job's result, which useJob passes
// on unread, is a page of the kind asked for.
export function parseQuickResult(where: string, kind: QuickKind, body: unknown): QuickResult {
if (
!isRecord(body) ||
typeof body.stage !== 'string' ||
!(IDEA_STAGES as readonly string[]).includes(body.stage) ||
body.kind !== kind ||
!isStrings(body.exclude) ||
!Array.isArray(body.ideas) ||
typeof body.dropped !== 'number'
) {
throw new Error(`${where}: result is not Quick dinner ideas of kind ${kind}`)
}
return { stage: body.stage as IdeaStage, kind, ideas: body.ideas.map((idea) => parseQuickIdea(where, idea)) }
}
// QuickPage is a page of Quick dinner ideas its job found.
export type QuickPage = { jobId: string; ideas: QuickIdea[] }
// QuickTab is what one tab of Quick dinners has found this session: the
// pages found, the job finding the next one, whether the server has said
// there are no more, why the last page asked for could not start, kept
// until Retry asks again, and the ideas added to the Quest log, as
// "jobId:index".
export type QuickTab = { pages: QuickPage[]; jobId: string | null; ended: boolean; startError: Error | null; added: string[] }
const NEW_TAB: QuickTab = { pages: [], jobId: null, ended: false, startError: null, added: [] }
export function quickTabKey(kind: QuickKind) {
return ['ideas', 'quick', kind] as const
}
// shownNames are the names of every idea a tab shows, which its next page
// excludes so that nothing repeats.
export function shownNames(tab: QuickTab): string[] {
return tab.pages.flatMap((page) => page.ideas.map((idea) => idea.name))
}
// useQuickTab is a tab's QuickTab, held in the query cache for the session
// (never stale, never collected) so that leaving the tab or the screen
// keeps it, and update, which changes it. Nothing is fetched: a refetch
// reads back what the tab holds.
export function useQuickTab(kind: QuickKind) {
const queryClient = useQueryClient()
const { data } = useQuery({
queryKey: quickTabKey(kind),
queryFn: () => queryClient.getQueryData<QuickTab>(quickTabKey(kind)) ?? NEW_TAB,
initialData: NEW_TAB,
staleTime: Infinity,
gcTime: Infinity,
})
const update = useCallback(
(change: (tab: QuickTab) => QuickTab) => queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => change(tab ?? NEW_TAB)),
[queryClient, kind],
)
return [data, update] as const
}
// useFindQuickIdeas starts finding a page of Quick dinner ideas of kind,
// none named in exclude (POST /api/v1/ideas/quick), and makes its job the
// tab's job, or its failure the tab's startError, also if the screen has
// been left meanwhile. Its mutationKey is the tab's, so a tab shown again
// knows a page is still being asked for.
export function useFindQuickIdeas(kind: QuickKind) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationKey: quickTabKey(kind),
mutationFn: async (exclude: string[]): Promise<string> => {
queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), startError: null }))
const body = await apiFetch<unknown>(QUICK_PATH, { method: 'POST', body: { kind, exclude } })
if (!isRecord(body) || typeof body.jobId !== 'string') throw new Error(`POST ${QUICK_PATH}: response has no jobId`)
const jobId = body.jobId
queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), jobId }))
return jobId
},
onError: (startError: Error) => queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), startError })),
})
}
// IdeaRef is idea index of the succeeded ideas job jobId.
export type IdeaRef = { jobId: string; index: number }
function ideaPath({ jobId, index }: IdeaRef, action: 'quest-log' | 'cook-tonight'): string {
return `${IDEAS_PATH}/${encodeURIComponent(jobId)}/items/${index}/${action}`
}
// useAddIdeaToQuestLog adds an idea to the Quest log (POST
// /api/v1/ideas/{jobId}/items/{i}/quest-log) and resolves with the
// dinner's name. Asking again answers the same dinner. The Quest log is
// read again after it, and onAdded told, also if the caller has unmounted
// meanwhile.
export function useAddIdeaToQuestLog(onAdded?: (idea: IdeaRef) => void) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (idea: IdeaRef): Promise<string> => {
const path = ideaPath(idea, 'quest-log')
return parseDinner(`POST ${path}`, await apiFetch<unknown>(path, { method: 'POST' })).name
},
onSuccess: (_name, idea) => {
onAdded?.(idea)
return queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
},
})
}
// useCookIdeaTonight puts an idea on today's plan (POST
// /api/v1/ideas/{jobId}/items/{i}/cook-tonight) and resolves with the
// dinner's name. The plan it answers with is cached as its week's plan,
// and the Quest log is read again. With no plan for the current week the
// server answers 409 no_plan, the mutation's ApiError, and changes nothing.
export function useCookIdeaTonight() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (idea: IdeaRef): Promise<string> => {
const path = ideaPath(idea, 'cook-tonight')
const where = `POST ${path}`
const body = await apiFetch<unknown>(path, { method: 'POST' })
if (!isRecord(body) || !isRecord(body.plan) || typeof body.plan.week_start !== 'string') {
throw new Error(`${where}: response has no plan`)
}
const dinner = parseDinner(where, body.dinner)
const plan = parsePlan(where, body.plan.week_start, body.plan, false)
queryClient.setQueryData(planKey(plan.week_start), plan)
return dinner.name
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
})
}
+3 -1
View File
@@ -93,7 +93,9 @@ function syncedAt(where: string, value: unknown): Date | null {
return at
}
function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
// parsePlan checks a body the plans API sends is the plan of the week
// starting on weekStart; cached says the service worker answered it.
export function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
if (
!isRecord(body) ||
typeof body.id !== 'string' ||