From 4b592fdbbb5628cd13c72d4c16076347d1536612 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Fri, 9 Oct 2026 14:01:14 -0400 Subject: [PATCH] Feature 15: Recipe Detail and Kitchen Reading Mode (#162) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- docs/ARCHITECTURE.md | 24 + internal/db/querier.go | 7 + internal/db/suggestions.sql.go | 35 + internal/dinners/detail.go | 283 ++++++++ internal/dinners/dinners.go | 7 +- internal/dinners/dinners_test.go | 101 +++ internal/dinners/export_test.go | 6 + internal/recipes/extract.go | 10 +- internal/server/dinnerdetail_test.go | 469 ++++++++++++++ internal/server/dinners.go | 18 + internal/server/dinners_test.go | 10 +- internal/server/server.go | 3 +- migrations/queries/suggestions.sql | 11 + web/src/App.test.tsx | 3 +- web/src/App.tsx | 2 + web/src/dinners/AddToWeekSheet.module.css | 7 + web/src/dinners/AddToWeekSheet.tsx | 173 +++++ web/src/dinners/DinnerDetailActions.test.tsx | 406 ++++++++++++ web/src/dinners/DinnerDetailOffline.test.tsx | 184 ++++++ web/src/dinners/DinnerDetailPage.module.css | 547 ++++++++++++++++ web/src/dinners/DinnerDetailPage.test.tsx | 473 ++++++++++++++ web/src/dinners/DinnerDetailPage.tsx | 582 +++++++++++++++++ web/src/dinners/KitchenReading.test.tsx | 604 ++++++++++++++++++ web/src/dinners/api.ts | 49 +- web/src/dinners/detail.ts | 178 ++++++ web/src/dinners/keepScreenOn.ts | 86 +++ .../dinners/testdata/source_url_cases.json | 52 ++ web/src/dinners/textSize.ts | 63 ++ web/src/plan/PlanPage.tsx | 29 +- web/src/plan/api.ts | 36 +- web/src/plan/week.test.ts | 7 +- web/src/plan/week.ts | 7 + web/src/sw/apiStrategy.test.ts | 67 +- web/src/test/serviceWorker.ts | 79 +++ web/src/ui/IconButton.module.css | 10 + web/src/ui/IconButton.test.tsx | 7 + web/src/ui/IconButton.tsx | 5 +- web/src/ui/Segmented.test.tsx | 10 + web/src/ui/Segmented.tsx | 4 + 39 files changed, 4549 insertions(+), 105 deletions(-) create mode 100644 internal/dinners/detail.go create mode 100644 internal/server/dinnerdetail_test.go create mode 100644 web/src/dinners/AddToWeekSheet.module.css create mode 100644 web/src/dinners/AddToWeekSheet.tsx create mode 100644 web/src/dinners/DinnerDetailActions.test.tsx create mode 100644 web/src/dinners/DinnerDetailOffline.test.tsx create mode 100644 web/src/dinners/DinnerDetailPage.module.css create mode 100644 web/src/dinners/DinnerDetailPage.test.tsx create mode 100644 web/src/dinners/DinnerDetailPage.tsx create mode 100644 web/src/dinners/KitchenReading.test.tsx create mode 100644 web/src/dinners/detail.ts create mode 100644 web/src/dinners/keepScreenOn.ts create mode 100644 web/src/dinners/testdata/source_url_cases.json create mode 100644 web/src/dinners/textSize.ts create mode 100644 web/src/test/serviceWorker.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 05db94d..a4fe3df 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -204,6 +204,30 @@ automatic** (D-021). sets status `archived` and keeps the row; `POST /api/v1/dinners/{id}/favorite` toggles `is_favorite`. Every write records the session's subject as `updated_by`; an unknown id is 404. + `GET /api/v1/dinners/{id}` returns the dinner with its recipe, read in + one transaction: `{id, name, status, origin, method, minutes_total, + difficulty, cuisine, is_favorite, photo_urls, ingredients, steps, + source, grounding_status, grounding_error, why_picked}`. `photo_urls` is + `{"400": url, "1200": url}` (`/media/photos/{photo_id}-{size}.jpg`) or + null with no photo; `ingredients` is `[{name, qty, unit, note, + raw_text}]` and `steps` the step texts, both in recipe position order and + empty (`[]`) with no recipe, as for a dinner whose grounding is + `pending` or `failed`; a recipe entered by hand has only `raw_text`. + Two items at one position leave recipe order unknown, so such a recipe + is 500 rather than shown out of order. `source` is `{site_name}` of the + page the recipe was fetched from, null for a manual recipe or none; it + has no `url`, as the page's link may carry a personal token. + `site_name` is the name the page gives its site (publisher or + `og:site_name`) and null otherwise: a page naming no site is stored + under its host, which may be the pasted link's — a token in a + subdomain — or a redirect's, so any stored name that could be a host + (the link's host, an IP address or a dotted host name, even a real + name like "Food.com") is withheld. The detail screen shows "Recipe + from {site}" only when `site_name` is not null. `why_picked` is the reason of the + suggestion that created the dinner — its pending or accepted one, as + declining a suggestion archives its dinner — null for one no suggestion + created; a dinner with more than one such suggestion is 500, as nothing + records which created it. An unknown or archived dinner is 404. - Dinner grounding. `POST /api/v1/dinners` also enqueues a `ground_dinner` job and answers with its `grounding_job_id` without waiting for it (`GET /api/v1/jobs/{id}` reports it); if the job cannot be enqueued it diff --git a/internal/db/querier.go b/internal/db/querier.go index 00e2e22..66b6610 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -103,6 +103,13 @@ type Querier interface { // Returns sql.ErrNoRows until the row exists. GetChatSettings(ctx context.Context) (ChatSetting, error) GetDinner(ctx context.Context, id string) (Dinner, error) + // The reason of each suggestion of dinner_id that can have created it, + // none for a dinner no suggestion created. A suggestion is only ever + // created with a new dinner, and declining one archives its dinner, so a + // live dinner's creating suggestion is pending (the dinner still + // suggested) or accepted; a declined one did not create it. More than one + // row means nothing records which did. + GetDinnerSuggestionReasons(ctx context.Context, dinnerID string) ([]string, error) // Returns sql.ErrNoRows until the row exists. GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error) // Returns sql.ErrNoRows until the row exists. diff --git a/internal/db/suggestions.sql.go b/internal/db/suggestions.sql.go index e7f12d7..879e606 100644 --- a/internal/db/suggestions.sql.go +++ b/internal/db/suggestions.sql.go @@ -187,6 +187,41 @@ func (q *Queries) GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch return i, err } +const getDinnerSuggestionReasons = `-- name: GetDinnerSuggestionReasons :many +SELECT reason FROM suggestions +WHERE dinner_id = ? AND status IN ('pending', 'accepted') +ORDER BY id +` + +// The reason of each suggestion of dinner_id that can have created it, +// none for a dinner no suggestion created. A suggestion is only ever +// created with a new dinner, and declining one archives its dinner, so a +// live dinner's creating suggestion is pending (the dinner still +// suggested) or accepted; a declined one did not create it. More than one +// row means nothing records which did. +func (q *Queries) GetDinnerSuggestionReasons(ctx context.Context, dinnerID string) ([]string, error) { + rows, err := q.db.QueryContext(ctx, getDinnerSuggestionReasons, dinnerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var reason string + if err := rows.Scan(&reason); err != nil { + return nil, err + } + items = append(items, reason) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getLatestSuggestionBatch = `-- name: GetLatestSuggestionBatch :one SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches ORDER BY id DESC diff --git a/internal/dinners/detail.go b/internal/dinners/detail.go new file mode 100644 index 0000000..dfaf779 --- /dev/null +++ b/internal/dinners/detail.go @@ -0,0 +1,283 @@ +package dinners + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net" + "net/url" + "strconv" + "strings" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" +) + +// Detail is a dinner with its recipe, as the recipe detail screen and +// Kitchen reading mode show it. +type Detail struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Origin string `json:"origin"` + Method *string `json:"method"` + MinutesTotal *int64 `json:"minutes_total"` + Difficulty *string `json:"difficulty"` + Cuisine *string `json:"cuisine"` + IsFavorite bool `json:"is_favorite"` + // PhotoURLs is the cached photo at each stored width, keyed by width + // ("400", "1200"); null when the dinner has no photo. + PhotoURLs map[string]string `json:"photo_urls"` + // Ingredients and Steps are the recipe's, in recipe order; empty when + // the dinner has no recipe. + Ingredients []Ingredient `json:"ingredients"` + Steps []string `json:"steps"` + // Source is the site of the recipe page the dinner is grounded in; + // null for a recipe entered by hand or a dinner with no recipe. + Source *Source `json:"source"` + GroundingStatus string `json:"grounding_status"` + // GroundingError is why grounding failed, one of the Reason codes; + // null unless GroundingStatus is failed. + GroundingError *string `json:"grounding_error"` + // WhyPicked is the reason of the suggestion that created the dinner + // (see GetDinnerSuggestionReasons); null for a dinner no suggestion + // created. + WhyPicked *string `json:"why_picked"` +} + +// Ingredient is one recipe line. RawText is the line as the recipe gives +// it; Name, Qty, Unit and Note are normalize_recipe's reading of it, each +// null when it has none, and Name, Qty and Unit always null for a recipe +// entered by hand (see IsManualRecipe). +type Ingredient struct { + Name *string `json:"name"` + Qty *string `json:"qty"` + Unit *string `json:"unit"` + Note *string `json:"note"` + RawText string `json:"raw_text"` +} + +// Source is the site a recipe was fetched from. SiteName is the name the +// page itself gives its site, and null when it gives none or the stored +// name could have come from a link (see pageSiteName). URL is the page's +// link only when it is verifiably free of secrets (see safeSourceURL), and +// null otherwise: a link pasted or offered as a candidate may carry a +// personal token, so a client links the credit only when URL is not null. +// Source is set for every fetched recipe, so a client always credits it: +// by SiteName, else by URL's host, else without naming the site. +type Source struct { + SiteName *string `json:"site_name"` + URL *string `json:"url"` +} + +// Get returns dinner id with its recipe and the reason it was suggested, +// read in one read transaction. An unknown or archived dinner is +// ErrNotFound: an archived dinner has no detail to show. +func (s *Service) Get(ctx context.Context, id string) (d Detail, err error) { + tx, err := s.st.Reader.BeginTx(ctx, nil) + if err != nil { + return Detail{}, fmt.Errorf("dinners: begin read: %w", err) + } + defer func() { + err = errors.Join(err, tx.Rollback()) + }() + q := db.New(tx) + row, err := q.GetDinner(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return Detail{}, ErrNotFound + } + if err != nil { + return Detail{}, fmt.Errorf("dinners: read %s: %w", id, err) + } + if row.Status == StatusArchived { + return Detail{}, ErrNotFound + } + d = Detail{ + ID: row.ID, + Name: row.Name, + Status: row.Status, + Origin: row.Origin, + Method: nullString(row.Method), + Difficulty: nullString(row.Difficulty), + Cuisine: nullString(row.Cuisine), + IsFavorite: row.IsFavorite == 1, + Ingredients: []Ingredient{}, + Steps: []string{}, + GroundingStatus: row.GroundingStatus, + GroundingError: nullString(row.GroundingError), + } + if row.MinutesTotal.Valid { + d.MinutesTotal = &row.MinutesTotal.Int64 + } + if row.PhotoID.Valid { + d.PhotoURLs = make(map[string]string, len(recipes.PhotoSizes)) + for _, size := range recipes.PhotoSizes { + d.PhotoURLs[strconv.Itoa(size)] = photoURL(row.PhotoID.String, size) + } + } + if (row.GroundingStatus == GroundingGrounded || row.GroundingStatus == GroundingManual) && !row.RecipeID.Valid { + return Detail{}, fmt.Errorf("dinners: %s is %s with no recipe", id, row.GroundingStatus) + } + if row.RecipeID.Valid { + if err := readRecipe(ctx, q, row.RecipeID.String, &d); err != nil { + return Detail{}, fmt.Errorf("dinners: read recipe of %s: %w", id, err) + } + } + reasons, err := q.GetDinnerSuggestionReasons(ctx, id) + if err != nil { + return Detail{}, fmt.Errorf("dinners: read suggestion reason of %s: %w", id, err) + } + switch len(reasons) { + case 0: + case 1: + d.WhyPicked = &reasons[0] + default: + // Nothing records which of them created the dinner, and any + // one picked would be a guess. + return Detail{}, fmt.Errorf("dinners: %s has %d suggestions that could have created it", id, len(reasons)) + } + return d, nil +} + +// readRecipe sets d's Source, Ingredients and Steps from recipe recipeID. +func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail) error { + rec, err := q.GetRecipe(ctx, recipeID) + if err != nil { + return fmt.Errorf("recipe %s: %w", recipeID, err) + } + if !IsManualRecipe(rec.SourceUrl) { + site, err := pageSiteName(rec.SiteName, rec.SourceUrl) + if err != nil { + return err + } + d.Source = &Source{SiteName: site, URL: safeSourceURL(rec.SourceUrl)} + } + ings, err := q.ListRecipeIngredients(ctx, recipeID) + if err != nil { + return fmt.Errorf("ingredients: %w", err) + } + for i, ing := range ings { + if i > 0 && ing.Position == ings[i-1].Position { + return duplicatePosition("ingredient", ing.Position) + } + d.Ingredients = append(d.Ingredients, Ingredient{ + Name: nullString(ing.Name), + Qty: nullString(ing.Qty), + Unit: nullString(ing.Unit), + Note: nullString(ing.Note), + RawText: ing.RawText, + }) + } + steps, err := q.ListRecipeSteps(ctx, recipeID) + if err != nil { + return fmt.Errorf("steps: %w", err) + } + for i, step := range steps { + if i > 0 && step.Position == steps[i-1].Position { + return duplicatePosition("step", step.Position) + } + d.Steps = append(d.Steps, step.Text) + } + return nil +} + +// pageSiteName is the stored site name of a recipe fetched from sourceURL +// when it is one the page gave, else nil. A page that names no site is +// stored under recipes.HostSiteName of the URL it was served from: the +// pasted link's host, which may hold a personal token (a private +// subdomain), or that of a page the link redirected to, which is not +// stored. Nothing records which a stored name is, so any name that could +// be a host — the link's own host, an IP address, or a dotted host name — +// is treated as one and withheld. A site that names itself as a domain, +// such as "Food.com", loses its name with them: a credit that does not +// name the site is better than one that leaks a link. +func pageSiteName(stored sql.NullString, sourceURL string) (*string, error) { + if !stored.Valid { + return nil, nil + } + link, err := url.Parse(sourceURL) + if err != nil { + // Not wrapped: url.Parse's error quotes the link. + return nil, errors.New("source url does not parse") + } + name := stored.String + if strings.EqualFold(name, recipes.HostSiteName(link)) || net.ParseIP(name) != nil { + return nil, nil + } + if u, err := url.Parse("https://" + name + "/"); err == nil && u.Host == name && strings.Contains(name, ".") { + return nil, nil + } + return &name, nil +} + +// safeSourceURL is sourceURL when it is verifiably free of secrets — an +// https link to a host, with no user or password, no query (not even a +// bare "?") and no fragment (not even a bare "#") — else nil. Those are +// the places a link carries a token; a link failing any check, or not +// parsing, is withheld rather than trimmed, since a trimmed link is not the +// page the recipe came from. The client (sourceUrl in +// web/src/dinners/detail.ts) parses the link with the browser's URL parser +// and refuses the whole recipe when it fails, so the host and port must +// also be ones it parses: see browserHost. +func safeSourceURL(sourceURL string) *string { + u, err := url.Parse(sourceURL) + if err != nil || u.Scheme != "https" || u.Opaque != "" || u.Host == "" || u.User != nil || + u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(sourceURL, "#") { + return nil + } + // url.Parse decodes a percent-encoded host; the link must spell its + // host out as it is checked. + authority, _, _ := strings.Cut(sourceURL[len(u.Scheme)+len("://"):], "/") + if authority != u.Host || !browserHost(u) { + return nil + } + if port := u.Port(); port != "" { + if n, err := strconv.ParseUint(port, 10, 16); err != nil || n == 0 { + return nil + } + } + return &sourceURL +} + +// browserHost says whether u's host is one the browser's URL parser (the +// WHATWG URL Standard) accepts as it stands, where url.Parse accepts more, +// such as "[bad]". The rule is narrower than the browser's: a bracketed +// host is an IPv6 address without a zone; any other is ASCII letters, +// digits and hyphens in dot-separated labels, none empty but a trailing +// one, none punycode ("xn--"), since the browser refuses invalid punycode +// and checking it is not worth a decoder; and a host whose last label is +// a number, which the browser reads as an IPv4 address, must be a dotted +// quad. A link to any other host is withheld. +func browserHost(u *url.URL) bool { + host := u.Hostname() + if strings.HasPrefix(u.Host, "[") { + ip := net.ParseIP(host) + return ip != nil && strings.Contains(host, ":") + } + labels := strings.Split(strings.TrimSuffix(host, "."), ".") + for _, label := range labels { + if label == "" || len(label) >= 4 && strings.EqualFold(label[:4], "xn--") { + return false + } + for _, c := range label { + if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || c == '-') { + return false + } + } + } + last := labels[len(labels)-1] + if strings.Trim(last, "0123456789") == "" || len(last) >= 2 && strings.EqualFold(last[:2], "0x") { + ip := net.ParseIP(host) + return ip != nil && ip.To4() != nil && !strings.Contains(host, ":") + } + return true +} + +// duplicatePosition is the error for two recipe items of kind at position. +// Position is the only record of recipe order, and the id that breaks the +// tie says nothing about it, so a recipe with a duplicate is refused rather +// than shown in an order nobody can trust. +func duplicatePosition(kind string, position int64) error { + return fmt.Errorf("two %ss at position %d: recipe order is unknown", kind, position) +} diff --git a/internal/dinners/dinners.go b/internal/dinners/dinners.go index b2eb82b..02d96bd 100644 --- a/internal/dinners/dinners.go +++ b/internal/dinners/dinners.go @@ -669,7 +669,12 @@ func findRepeat(ctx context.Context, q *db.Queries, self, name string, recipeURL // PhotoURL is where the API serves a list-sized copy of photo id. func PhotoURL(id string) string { - return fmt.Sprintf("/media/photos/%s-%d.jpg", id, photoListWidth) + return photoURL(id, photoListWidth) +} + +// photoURL is where the API serves photo id at stored width size. +func photoURL(id string, size int) string { + return fmt.Sprintf("/media/photos/%s-%d.jpg", id, size) } func fromRow(r db.Dinner) Dinner { diff --git a/internal/dinners/dinners_test.go b/internal/dinners/dinners_test.go index 764f6cb..dbd8bce 100644 --- a/internal/dinners/dinners_test.go +++ b/internal/dinners/dinners_test.go @@ -1,6 +1,7 @@ package dinners_test import ( + "database/sql" "encoding/json" "errors" "strings" @@ -74,3 +75,103 @@ func TestCreateRegularNeverNamesFromLink(t *testing.T) { } check("duplicate error", dup.Error()) } + +// TestPageSiteNameWithholdsHosts: a stored site name that could be a host — +// the link's own, in any case and without "www.", an IP address or a dotted +// host name — is withheld; a name the page gave, dotless or with spaces, is +// kept. +func TestPageSiteNameWithholdsHosts(t *testing.T) { + t.Parallel() + const link = "https://www.TokenHost/recipes/stew" + for _, tc := range []struct { + stored string + valid bool + want *string + }{ + {"", false, nil}, + {"tokenhost", true, nil}, + {"TOKENHOST", true, nil}, + {"private-token.example.com", true, nil}, + {"Food.com", true, nil}, + {"203.0.113.7", true, nil}, + {"2001:db8::1", true, nil}, + {"Delish", true, ptr("Delish")}, + {"Bon Appétit", true, ptr("Bon Appétit")}, + {"Mr. Food Test Kitchen", true, ptr("Mr. Food Test Kitchen")}, + } { + got, err := dinners.PageSiteName(sql.NullString{String: tc.stored, Valid: tc.valid}, link) + if err != nil { + t.Fatalf("PageSiteName(%q): %v", tc.stored, err) + } + if (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) { + t.Errorf("PageSiteName(%q) = %v, want %v", tc.stored, deref(got), deref(tc.want)) + } + } + if _, err := dinners.PageSiteName(sql.NullString{String: "Delish", Valid: true}, "https://secret-token\x7f.example/%zz"); err == nil || strings.Contains(err.Error(), "secret-token") { + t.Errorf("PageSiteName of an unparseable link: err = %v, want one not quoting the link", err) + } +} + +// TestSafeSourceURLOnlyWithoutSecrets: a recipe link is shown only when it +// is https to a host with no user, password, query or fragment, each of +// which can carry a token; any other link, or one that does not parse, is +// withheld whole. +func TestSafeSourceURLOnlyWithoutSecrets(t *testing.T) { + t.Parallel() + for _, link := range []string{ + "https://www.example.com/recipes/chicken-curry", + "https://example.com", + "https://example.com:8443/stew/", + "https://[2001:db8::1]/curry", + "https://1.2.3.4/curry", + "https://Kitchen-2.Example.com./curry", + } { + if got := dinners.SafeSourceURL(link); got == nil || *got != link { + t.Errorf("SafeSourceURL(%q) = %v, want the link", link, deref(got)) + } + } + for _, link := range []string{ + "https://example.com/curry?access_token=query-token", + "https://example.com/curry?", + "https://user:password@example.com/curry", + "https://token@example.com/curry", + "https://@example.com/curry", + "http://example.com/curry", + "https://example.com/curry#token=fragment-token", + "https://example.com/curry#", + "ftp://example.com/curry", + "https:example.com/curry", + "https:///curry", + "https://example.com:99999/curry", + "https://example.com:0/curry", + "//example.com/curry", + "https://secret-token\x7f.example/%zz", + "", + // Hosts url.Parse accepts but the browser's URL parser refuses + // (or reads as another host), which would fail the whole recipe + // screen. + "https://[bad]/curry", + "https://[fe80::1%25en0]/curry", + "https://[1.2.3.4]/curry", + "https://1.2.3.999/curry", + "https://1.2.3.4.5/curry", + "https://999/curry", + "https://example.0x/curry", + "https://xn--a/curry", + "https://ex%61mple.com/curry", + "https://ex%2fample.com/curry", + "https://a..b/curry", + "https://café.example/curry", + } { + if got := dinners.SafeSourceURL(link); got != nil { + t.Errorf("SafeSourceURL(%q) = %q, want nil", link, *got) + } + } +} + +func deref(s *string) any { + if s == nil { + return nil + } + return *s +} diff --git a/internal/dinners/export_test.go b/internal/dinners/export_test.go index b143035..1d62211 100644 --- a/internal/dinners/export_test.go +++ b/internal/dinners/export_test.go @@ -71,3 +71,9 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error) r, err := s.runGroundJob(ctx, p, nil) return r.GroundingStatus, err } + +// PageSiteName is pageSiteName. +var PageSiteName = pageSiteName + +// SafeSourceURL is safeSourceURL. +var SafeSourceURL = safeSourceURL diff --git a/internal/recipes/extract.go b/internal/recipes/extract.go index 2ce6069..9de3383 100644 --- a/internal/recipes/extract.go +++ b/internal/recipes/extract.go @@ -29,7 +29,7 @@ type Recipe struct { // Title is the recipe's name. Title string // SiteName is the publisher's name, else the page's og:site_name, else - // its host without a leading "www.". + // HostSiteName of the page's URL. SiteName string // ImageURL is the largest image, absolute; empty when the recipe has // none. @@ -232,7 +232,13 @@ func siteName(publisher any, g graph, ogSiteName string, base *url.URL) (string, if ogSiteName != "" { return ogSiteName, nil } - return strings.TrimPrefix(base.Hostname(), "www."), nil + return HostSiteName(base), nil +} + +// HostSiteName is the site name of a page that names none: its host +// without a leading "www.". +func HostSiteName(u *url.URL) string { + return strings.TrimPrefix(u.Hostname(), "www.") } // imageURL picks the largest of the recipe's images by width × height; diff --git a/internal/server/dinnerdetail_test.go b/internal/server/dinnerdetail_test.go new file mode 100644 index 0000000..1789e1c --- /dev/null +++ b/internal/server/dinnerdetail_test.go @@ -0,0 +1,469 @@ +package server + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "reflect" + "strings" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// getDetail answers GET /dinners/{id} for id, failing unless it is 200, and +// returns the body both raw (to tell null from absent) and decoded. +func getDetail(t *testing.T, h http.Handler, id string) (map[string]any, dinners.Detail) { + t.Helper() + rec, raw := callDinners(t, h, http.MethodGet, dinnersPath+"/"+id, "") + if rec.Code != http.StatusOK { + t.Fatalf("GET dinner = %d: %s", rec.Code, rec.Body) + } + var d dinners.Detail + dec := json.NewDecoder(rec.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&d); err != nil { + t.Fatalf("decode detail: %v", err) + } + return raw, d +} + +// wantNull fails unless each key is present in raw and null. +func wantNull(t *testing.T, raw map[string]any, keys ...string) { + t.Helper() + for _, k := range keys { + if v, ok := raw[k]; !ok || v != nil { + t.Errorf("%s = %v (present %v), want null", k, v, ok) + } + } +} + +// TestGetDinnerGroundedSideQuest: a Side quest grounded in a fetched page +// shows its photo at both stored sizes, its recipe's ingredients and steps +// in recipe order (not row order), its source site and the reason it was +// suggested. +func TestGetDinnerGroundedSideQuest(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t).withRecipes(t) + e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40)) + e.start(t) + rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "succeeded" { + t.Fatalf("grounding job = %v", job) + } + id := created["id"].(string) + + // Make it an accepted Side quest with the suggestion that created it, + // and reverse its recipe's positions so row order disagrees with + // recipe order. + var recipeID, photoID string + if err := e.st.Reader.QueryRow(`SELECT recipe_id, photo_id FROM dinners WHERE id = ?`, id).Scan(&recipeID, &photoID); err != nil { + t.Fatal(err) + } + for _, stmt := range []string{ + `UPDATE dinners SET status = 'quest_log', origin = 'side_quest', is_favorite = 1 WHERE id = ?`, + `UPDATE recipe_ingredients SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, + `UPDATE recipe_steps SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, + } { + if _, err := e.st.Writer.Exec(stmt, id); err != nil { + t.Fatal(err) + } + } + q := db.New(e.st.Writer) + now := store.Now().String() + batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now}) + if err != nil { + t.Fatal(err) + } + const reason = "Like your Tikka masala, but quicker." + if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{ + ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: "Chicken curry", + Reason: reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + + _, got := getDetail(t, e.h, id) + str := func(s string) *string { return &s } + minutes := int64(40) + want := dinners.Detail{ + ID: id, + Name: "Chicken curry", + Status: "quest_log", + Origin: "side_quest", + Method: str("stovetop"), + MinutesTotal: &minutes, + Difficulty: str("easy"), + IsFavorite: true, + PhotoURLs: map[string]string{ + "400": "/media/photos/" + photoID + "-400.jpg", + "1200": "/media/photos/" + photoID + "-1200.jpg", + }, + Ingredients: []dinners.Ingredient{ + {Name: str("garlic"), Qty: str("2"), Unit: str("cloves"), Note: str("minced"), RawText: "2 cloves garlic, minced"}, + {Name: str("onion"), Qty: str("1"), RawText: "1 onion"}, + }, + Steps: []string{"Fry with the garlic.", "Chop the onion."}, + Source: &dinners.Source{SiteName: str("Curry House"), URL: str(curryPage)}, + GroundingStatus: "grounded", + WhyPicked: str(reason), + } + if !reflect.DeepEqual(got, want) { + gj, _ := json.Marshal(got) + wj, _ := json.Marshal(want) + t.Errorf("detail =\n %s\nwant\n %s", gj, wj) + } + // Each photo URL is served. + for _, u := range got.PhotoURLs { + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, signedIn(http.MethodGet, u, nil)) + if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/jpeg" { + t.Errorf("GET %s = %d %s", u, rec.Code, rec.Header().Get("Content-Type")) + } + } +} + +// TestGetDinnerManualRegular: a Regular added by hand and given a recipe +// typed in has no photo, no source and no reason, and its ingredients are +// the typed lines with nothing normalized. +func TestGetDinnerManualRegular(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + id := createDinner(t, e.h, `{"name":"Family chili"}`) + if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", + `{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK { + t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body) + } + + raw, got := getDetail(t, e.h, id) + wantNull(t, raw, "photo_urls", "source", "why_picked", "difficulty", "grounding_error") + str := func(s string) *string { return &s } + minutes := int64(240) + want := dinners.Detail{ + ID: id, + Name: "Family chili", + Status: "regular", + Origin: "regular", + Method: str("crockpot"), + MinutesTotal: &minutes, + Ingredients: []dinners.Ingredient{{RawText: "1 lb beef"}, {RawText: "1 can beans"}}, + Steps: []string{"Brown.", "Simmer."}, + GroundingStatus: "manual", + } + if !reflect.DeepEqual(got, want) { + gj, _ := json.Marshal(got) + wj, _ := json.Marshal(want) + t.Errorf("detail =\n %s\nwant\n %s", gj, wj) + } +} + +// TestGetDinnerFailedGrounding: a dinner whose grounding failed shows its +// real state — failed with its reason, no photo, no source and empty +// ingredients and steps — never a pretend recipe. +func TestGetDinnerFailedGrounding(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t).withRecipes(t) + e.start(t) + rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Nothing matches this"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "failed" { + t.Fatalf("grounding job = %v, want failed", job) + } + + raw, got := getDetail(t, e.h, created["id"].(string)) + wantNull(t, raw, "photo_urls", "source", "why_picked", "method", "minutes_total", "difficulty") + if ings, ok := raw["ingredients"].([]any); !ok || len(ings) != 0 { + t.Errorf("ingredients = %v, want []", raw["ingredients"]) + } + if steps, ok := raw["steps"].([]any); !ok || len(steps) != 0 { + t.Errorf("steps = %v, want []", raw["steps"]) + } + if got.GroundingStatus != "failed" || got.GroundingError == nil || *got.GroundingError != dinners.ReasonNoRecipe || + got.Name != "Nothing matches this" || got.Status != "regular" || got.Origin != "regular" { + t.Errorf("detail = %+v, want the failed Regular with no_recipe_found", got) + } +} + +// TestGetDinnerNotFound: an unknown dinner and an archived one are both +// 404, and the detail needs a session like every /api/v1 route. +func TestGetDinnerNotFound(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + id := createDinner(t, e.h, `{"name":"Old favourite"}`) + if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK { + t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body) + } + for _, target := range []string{id, store.NewID()} { + rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+target, "") + if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" { + t.Errorf("GET %s = %d %v, want 404 not_found", target, rec.Code, body) + } + } + + live := createDinner(t, e.h, `{"name":"Taco night"}`) + req := httptest.NewRequest(http.MethodGet, dinnersPath+"/"+live, nil) + if rec, body := serve(t, e.h, req); rec.Code != http.StatusUnauthorized || errorCode(body) != "unauthenticated" { + t.Errorf("GET without a session = %d %v, want 401", rec.Code, body) + } +} + +// TestGetDinnerSourceURLOnlyWhenSafe: a recipe grounded in a clean https +// link shows it as source.url; one whose stored link carries a token in its +// query or userinfo, is http, or has a fragment shows its site name but +// source.url null and nothing of the link. +func TestGetDinnerSourceURLOnlyWhenSafe(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + const ( + clean = "https://kitchen.example/recipes/chicken-curry" + queryToken = "query-token-def456" + queryPage = "https://secret-query.example/curry?access_token=" + queryToken + ) + e.pages.Bodies[clean] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40) + e.pages.Bodies[queryPage] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25) + e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1) + e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2) + ground := func(dish, page string) (id string) { + t.Helper() + id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending + e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25)) + if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+page+`"}`); rec.Code != http.StatusOK { + t.Fatalf("POST ground %s = %d: %s", page, rec.Code, rec.Body) + } + var stored string + if err := e.st.Reader.QueryRow(`SELECT source_url FROM recipes WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id).Scan(&stored); err != nil { + t.Fatal(err) + } + if stored != page { + t.Fatalf("stored source_url = %q, want %q", stored, page) + } + return id + } + source := func(what, id string) map[string]any { + t.Helper() + raw, _ := getDetail(t, e.h, id) + src, ok := raw["source"].(map[string]any) + if !ok { + t.Fatalf("source of %s = %v, want an object", what, raw["source"]) + } + return src + } + + cleanID := ground("Chicken curry", clean) + if src := source("a clean link", cleanID); !reflect.DeepEqual(src, map[string]any{"site_name": "Curry House", "url": clean}) { + t.Errorf("source of a clean link = %v, want site_name Curry House and url %s", src, clean) + } + + queryID := ground("Weeknight curry", queryPage) + withheld := func(what, id, site string, secrets ...string) { + t.Helper() + rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "") + if rec.Code != http.StatusOK { + t.Fatalf("GET %s = %d: %s", what, rec.Code, rec.Body) + } + for _, secret := range secrets { + if strings.Contains(rec.Body.String(), secret) { + t.Errorf("detail of %s contains %q: %s", what, secret, rec.Body) + } + } + if src := source(what, id); !reflect.DeepEqual(src, map[string]any{"site_name": site, "url": nil}) { + t.Errorf("source of %s = %v, want site_name %q and url null", what, src, site) + } + } + withheld("a link with a query token", queryID, "B Kitchen", queryPage, "secret-query.example", queryToken, "access_token") + + // The other unsafe shapes, stored as a link grounded elsewhere would + // leave them. + for _, tc := range []struct{ what, link string }{ + {"a link with a userinfo token", "https://user-token-ghi789@kitchen.example/recipes/chicken-curry"}, + {"a link with a userinfo password", "https://cook:password-jkl012@kitchen.example/recipes/chicken-curry"}, + {"an http link", "http://kitchen.example/recipes/chicken-curry"}, + {"a link with a fragment", "https://kitchen.example/recipes/chicken-curry#token=fragment-mno345"}, + } { + if _, err := e.st.Writer.Exec(`UPDATE recipes SET source_url = ? WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, tc.link, cleanID); err != nil { + t.Fatal(err) + } + withheld(tc.what, cleanID, "Curry House", tc.link, "kitchen.example", "-token-", "password", "fragment") + } + + // web/src/dinners/testdata/source_url_cases.json pins source as this + // handler sends it for each stored link; the recipe screen's tests + // render each one, so a link the server sends and the client refuses + // fails one side or the other. + fixture, err := os.ReadFile("../../web/src/dinners/testdata/source_url_cases.json") + if err != nil { + t.Fatal(err) + } + var shared struct { + Cases []struct { + Stored string + Source map[string]any + } + } + if err := json.Unmarshal(fixture, &shared); err != nil { + t.Fatal(err) + } + if len(shared.Cases) == 0 { + t.Fatal("source_url_cases.json has no cases") + } + for _, c := range shared.Cases { + if _, err := e.st.Writer.Exec(`UPDATE recipes SET source_url = ? WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, c.Stored, cleanID); err != nil { + t.Fatal(err) + } + if src := source(c.Stored, cleanID); !reflect.DeepEqual(src, c.Source) { + got, _ := json.Marshal(src) + want, _ := json.Marshal(c.Source) + t.Errorf("source of %s = %s, want %s", c.Stored, got, want) + } + } +} + +// TestGetDinnerSiteNameNeverFromLink: a page that names no site is stored +// under its host, so a recipe grounded in a pasted link whose host carries a +// token has site_name null and nothing from the link in its detail; a page +// that names its site keeps that name, even on a token-bearing host; and a +// stored name shaped like another host, as a redirect leaves, is withheld. +// The links carry a query, so source.url is withheld too and nothing of the +// link may appear. +func TestGetDinnerSiteNameNeverFromLink(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + const ( + bareToken = "private-token-abc123" + namedToken = "named-token-def456" + bare = "https://" + bareToken + ".example.com/recipe?print=1" + named = "https://www." + namedToken + ".example.com/recipe?print=1" + ) + e.pages.Bodies[bare] = recipestest.RecipePage("Chicken Curry", "", "https://a.example/curry.jpg", 40) + e.pages.Bodies[named] = recipestest.RecipePage("Weeknight Curry", "Curry House", "https://b.example/curry.jpg", 25) + e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1) + e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2) + ground := func(dish, page string) (id, recipeID string) { + t.Helper() + id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending + e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25)) + if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+page+`"}`); rec.Code != http.StatusOK { + t.Fatalf("POST ground %s = %d: %s", page, rec.Code, rec.Body) + } + if err := e.st.Reader.QueryRow(`SELECT recipe_id FROM dinners WHERE id = ?`, id).Scan(&recipeID); err != nil { + t.Fatal(err) + } + return id, recipeID + } + storedSite := func(recipeID string) string { + t.Helper() + var site string + if err := e.st.Reader.QueryRow(`SELECT site_name FROM recipes WHERE id = ?`, recipeID).Scan(&site); err != nil { + t.Fatal(err) + } + return site + } + check := func(what, id string, leaks []string, want any) { + t.Helper() + rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "") + if rec.Code != http.StatusOK { + t.Fatalf("GET %s = %d: %s", what, rec.Code, rec.Body) + } + for _, leak := range leaks { + if strings.Contains(strings.ToLower(rec.Body.String()), leak) { + t.Errorf("detail of %s contains %q: %s", what, leak, rec.Body) + } + } + raw, _ := getDetail(t, e.h, id) + if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": want, "url": nil}) { + t.Errorf("source of %s = %v, want site_name %v and url null", what, raw["source"], want) + } + } + + bareID, bareRecipe := ground("Chicken curry", bare) + if got := storedSite(bareRecipe); got != bareToken+".example.com" { + t.Fatalf("stored site_name = %q, want the link's host", got) + } + check("a page naming no site", bareID, []string{bare, bareToken, "example.com"}, nil) + + namedID, namedRecipe := ground("Weeknight curry", named) + if got := storedSite(namedRecipe); got != "Curry House" { + t.Fatalf("stored site_name = %q, want the page's", got) + } + check("a page naming its site", namedID, []string{named, namedToken, "example.com"}, "Curry House") + + const redirectHost = "redirect-token-ghi789.example.net" + if _, err := e.st.Writer.Exec(`UPDATE recipes SET site_name = ? WHERE id = ?`, redirectHost, namedRecipe); err != nil { + t.Fatal(err) + } + check("a page named by a redirect's host", namedID, []string{redirectHost, "redirect-token", namedToken}, nil) +} + +// TestGetDinnerWhyPickedIsCreatingSuggestion: why_picked is the reason of +// the suggestion that created the dinner, never that of an earlier +// suggestion row for it that was declined. +func TestGetDinnerWhyPickedIsCreatingSuggestion(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + id := createDinner(t, e.h, `{"name":"Chicken curry"}`) + if _, err := e.st.Writer.Exec(`UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, id); err != nil { + t.Fatal(err) + } + q := db.New(e.st.Writer) + now := store.Now().String() + batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now}) + if err != nil { + t.Fatal(err) + } + const reason = "Like your Tikka masala, but quicker." + // The declined row is created first, so it is the earliest by both + // created_at and id. + for _, s := range []struct{ reason, status string }{ + {"Passed on last week.", "declined"}, + {reason, "accepted"}, + } { + sid := store.NewID() + if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{ + ID: sid, DinnerID: id, BatchID: batch.ID, Name: "Chicken curry", + Reason: s.reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + if n, err := q.DecideSuggestion(t.Context(), db.DecideSuggestionParams{ + Status: s.status, Now: sql.NullString{String: now, Valid: true}, ID: sid, + }); err != nil || n != 1 { + t.Fatalf("decide %s = %d, %v", s.status, n, err) + } + } + + if _, got := getDetail(t, e.h, id); got.WhyPicked == nil || *got.WhyPicked != reason { + t.Errorf("why_picked = %v, want %q", got.WhyPicked, reason) + } +} + +// TestGetDinnerDuplicatePositionFails: a recipe with two ingredients or two +// steps at one position has no order anyone can trust, so its detail is a +// 500 rather than the recipe in some order. +func TestGetDinnerDuplicatePositionFails(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + for _, table := range []string{"recipe_ingredients", "recipe_steps"} { + id := createDinner(t, e.h, `{"name":"Family chili `+table+`"}`) + if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", + `{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK { + t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body) + } + if _, err := e.st.Writer.Exec(`UPDATE `+table+` SET position = 0 WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id); err != nil { + t.Fatal(err) + } + rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "") + if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "Simmer.") || strings.Contains(rec.Body.String(), "1 lb beef") { + t.Errorf("GET with duplicate %s positions = %d: %s, want 500 and no recipe", table, rec.Code, rec.Body) + } + } +} diff --git a/internal/server/dinners.go b/internal/server/dinners.go index fab52ac..5f0d54e 100644 --- a/internal/server/dinners.go +++ b/internal/server/dinners.go @@ -70,6 +70,24 @@ func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc { } } +// getDinner answers GET /dinners/{id} with the dinner, its recipe and the +// reason it was suggested. An archived dinner is 404, like an unknown one, +// not a 200 with its status: it has no detail to show. source never has the +// recipe page's link, and source.site_name is null unless the page itself +// named its site — a name that could be a host, the link's or a redirect's, +// is withheld (dinners.Source) — so the detail screen shows "Recipe from +// {site}" only when site_name is not null. +func getDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + d, err := svc.Get(r.Context(), chi.URLParam(r, "id")) + if err != nil { + writeDinnerError(w, r, logger, "get dinner", err) + return + } + writeJSON(w, r, logger, http.StatusOK, d) + } +} + // postDinner adds a Regular, recording the session's actor as updated_by, // and answers 201 with it and its grounding_job_id without waiting for the // job. When the job cannot be enqueued it still answers 201, with the dinner diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go index 61faead..472bdb2 100644 --- a/internal/server/dinners_test.go +++ b/internal/server/dinners_test.go @@ -86,13 +86,19 @@ func newDinnersEnv(t *testing.T) *dinnersEnv { t.Fatal(err) } e.runner = runner - g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model) + // The router serves the photos the grounder caches. + dataDir := t.TempDir() + g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.model) + photos, err := recipes.NewPhotoCache(e.images, st, dataDir) + if err != nil { + t.Fatal(err) + } svc, err := dinners.NewService(st, g, runner, logger) if err != nil { t.Fatal(err) } e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/server.go b/internal/server/server.go index 5ec718c..3ee34f6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -40,7 +40,7 @@ type RouteMounter interface { // /media; settings serves /api/v1/settings/household; search serves // /api/v1/settings/recipe-search and its /test; ai serves // /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt; -// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE +// dinnerSvc serves GET and POST /api/v1/dinners, GET, PATCH and DELETE // /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET // /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground; // suggestSvc serves POST /api/v1/suggestions/batches, GET @@ -137,6 +137,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai)) apiV1.Get("/dinners", getDinners(logger, dinnerSvc)) apiV1.Post("/dinners", postDinner(logger, dinnerSvc)) + apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc)) apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc)) apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc)) apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc)) diff --git a/migrations/queries/suggestions.sql b/migrations/queries/suggestions.sql index 1068622..61aef9b 100644 --- a/migrations/queries/suggestions.sql +++ b/migrations/queries/suggestions.sql @@ -116,3 +116,14 @@ WHERE id = sqlc.arg(id) AND status = 'pending'; UPDATE dinners SET status = sqlc.arg(status), updated_by = sqlc.arg(updated_by), updated_at = sqlc.arg(updated_at) WHERE id = sqlc.arg(id) AND status = 'suggested'; + +-- name: GetDinnerSuggestionReasons :many +-- The reason of each suggestion of dinner_id that can have created it, +-- none for a dinner no suggestion created. A suggestion is only ever +-- created with a new dinner, and declining one archives its dinner, so a +-- live dinner's creating suggestion is pending (the dinner still +-- suggested) or accepted; a declined one did not create it. More than one +-- row means nothing records which did. +SELECT reason FROM suggestions +WHERE dinner_id = ? AND status IN ('pending', 'accepted') +ORDER BY id; diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index e9be839..4c29ca0 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -37,6 +37,7 @@ const SCREENS = { '/discover': true, '/quick': true, '/cook-from-pantry': true, + '/dinner/:id': true, '/settings': true, '/settings/ai': true, '/settings/household': true, @@ -300,8 +301,8 @@ describe('App offline bootstrap', () => { }) it.each([ + ['/', 'Home'], ['/settings/notifications', 'Notifications'], - ['/dinner/01J9ZK', 'Dinner'], ])('an offline reload of %s after an online visit opens the saved page', async (path, title) => { await visitOnline() goOffline() diff --git a/web/src/App.tsx b/web/src/App.tsx index 8fb3ea3..6f7c439 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,6 +3,7 @@ import { QueryClientProvider } from '@tanstack/react-query' import { Route, Routes } from 'react-router' import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts' import { AuthBootstrap } from './AuthBootstrap.tsx' +import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx' import { DiscoverPage } from './discover/DiscoverPage.tsx' import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx' import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx' @@ -49,6 +50,7 @@ const screens: Partial> = { '/discover': , '/quick': , '/cook-from-pantry': , + '/dinner/:id': , '/settings': , '/settings/ai': , '/settings/household': , diff --git a/web/src/dinners/AddToWeekSheet.module.css b/web/src/dinners/AddToWeekSheet.module.css new file mode 100644 index 0000000..80ffbfc --- /dev/null +++ b/web/src/dinners/AddToWeekSheet.module.css @@ -0,0 +1,7 @@ +/* A week with no plan: why there's no day, over the link to draft it. */ +.noPlan { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 14px; +} diff --git a/web/src/dinners/AddToWeekSheet.tsx b/web/src/dinners/AddToWeekSheet.tsx new file mode 100644 index 0000000..0998ae2 --- /dev/null +++ b/web/src/dinners/AddToWeekSheet.tsx @@ -0,0 +1,173 @@ +import { useId, useState } from 'react' +import { Link } from 'react-router' +import { usePlan, useWeekSwap, type PlanDinner, type Swap } from '../plan/api.ts' +import { cooking } from '../plan/PlanPage.tsx' +import swapStyles from '../plan/SwapSheet.module.css' +import { addDays, dayOfMonth, localNow, weekdayName, weekLabel, weekStart as weekOf } from '../plan/week.ts' +import { useHouseholdSettings } from '../settings/api.ts' +import buttonStyles from '../ui/Button.module.css' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { ListRow } from '../ui/ListRow.tsx' +import { Photo } from '../ui/Photo.tsx' +import { Segmented } from '../ui/Segmented.tsx' +import { Sheet } from '../ui/Sheet.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { Tag } from '../ui/Tag.tsx' +import type { DinnerDetail } from './detail.ts' +import styles from './AddToWeekSheet.module.css' + +// PLAN is the Draft board, where a week with no plan is drafted. +export const PLAN = '/plan' + +// Which is the week a dinner is added to: the household's current week or +// the one after. +export type Which = 'this' | 'next' + +export const weekNames: Record = { this: 'this week', next: 'next week' } + +// Added is a swap sent from the sheet: the week it went to and the swap. +export type Added = { week: string; which: Which; swap: Swap } + +// planDinner is the dinner as it is planned on a day. +function planDinner(dinner: DinnerDetail): PlanDinner { + return { id: dinner.id, name: dinner.name, photo_id: dinner.photo_id, minutes: dinner.minutes_total, method: dinner.method } +} + +type WeekDaysProps = { + dinner: DinnerDetail + week: string + which: Which + onAdd: (added: Added) => void +} + +// WeekDays is the seven days of the week starting on week, each with its +// dinner; choosing one swaps the dinner in as the Draft board swaps a day +// (useWeekSwap). A week with no plan offers to draft it on the Draft board: +// nothing here makes a plan up. A dinner already on the week can't be added +// again, nor can one while a swap of the week is still being saved or the +// week is being pushed to Calendar. +function WeekDays({ dinner, week, which, onAdd }: WeekDaysProps) { + const plan = usePlan(week) + const { swap, push, refused, swapIn } = useWeekSwap(week) + + if (plan.isError) { + return void plan.refetch()}>Couldn't load {weekNames[which]}'s plan. {plan.error.message} + } + if (plan.isPending) { + return ( +
+ {Array.from({ length: 7 }, (_, i) => ( + + ))} +
+ ) + } + if (plan.data === null) { + return ( +
+

There's no plan for {weekNames[which]} yet, so there's no day to add it to.

+ + Draft {weekNames[which]} first + +
+ ) + } + + const days = plan.data.days + const planned = days.find((day) => day.dinner.id === dinner.id) + const choosable = planned === undefined && !refused + function add(date: string) { + const chosen: Swap = { date, dinner: planDinner(dinner), fromQuestLog: dinner.status === 'quest_log' } + swapIn(chosen) + onAdd({ week, which, swap: chosen }) + } + return ( + <> + {planned !== undefined ? ( +

+ {dinner.name} is already on {weekdayName(planned.date)} {weekNames[which]}. +

+ ) : swap.pending ? ( +

A change to {weekNames[which]} is still being saved.

+ ) : ( + push.pending &&

The plan for {weekNames[which]} is being pushed to Calendar.

+ )} +
    + {days.map((day) => ( +
  • +
  • + ))} +
+ + ) +} + +export type AddToWeekSheetProps = { + dinner: DinnerDetail + onAdd: (added: Added) => void + onClose: () => void +} + +// AddToWeekSheet is the sheet Add to this week opens: the seven days of the +// household's current week, Monday to Sunday in its timezone, each with its +// dinner, and a toggle to next week. Choosing a day replaces its dinner. +export function AddToWeekSheet({ dinner, onAdd, onClose }: AddToWeekSheetProps) { + const titleId = useId() + const settings = useHouseholdSettings() + const [which, setWhich] = useState('this') + + let body + if (settings.isError) { + body = ( + void settings.refetch()}> + Couldn't load the household settings. {settings.error.message} + + ) + } else if (settings.isPending) { + body = + } else { + const thisWeek = weekOf(localNow(settings.data.timezone, new Date()).date) + const week = which === 'this' ? thisWeek : addDays(thisWeek, 7) + body = ( + <> +

+ {weekLabel(week).dates} · replaces the day's dinner +

+ + + + ) + } + + return ( + +
+

+ Add {dinner.name} +

+ {body} +
+
+ ) +} diff --git a/web/src/dinners/DinnerDetailActions.test.tsx b/web/src/dinners/DinnerDetailActions.test.tsx new file mode 100644 index 0000000..dc967a4 --- /dev/null +++ b/web/src/dinners/DinnerDetailActions.test.tsx @@ -0,0 +1,406 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { mockViewport } from '../test/matchMedia.ts' +import { PlanPage } from '../plan/PlanPage.tsx' +import { DinnerDetailPage } from './DinnerDetailPage.tsx' + +const DINNER = '/api/v1/dinners/d1' +const FAVORITE = `${DINNER}/favorite` +const THIS_WEEK = '/api/v1/plans/2026-10-05' +const NEXT_WEEK = '/api/v1/plans/2026-10-12' +const THURSDAY = `${THIS_WEEK}/days/2026-10-08` + +// detail is the Korean beef bowls, a Quest-log Side quest, as GET +// /api/v1/dinners/{id} sends it. +function detail(overrides: Record = {}) { + return { + id: 'd1', + name: 'Korean beef bowls', + status: 'quest_log', + origin: 'suggested', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + cuisine: 'korean', + is_favorite: false, + photo_urls: null, + ingredients: [{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }], + steps: ['Brown the beef with the sauce.'], + source: null, + grounding_status: 'grounded', + grounding_error: null, + why_picked: null, + ...overrides, + } +} + +// listed is the bowls as the dinners list, and so the favorite toggle, +// answers: no recipe. +function listed(is_favorite: boolean) { + return { + id: 'd1', + name: 'Korean beef bowls', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + is_favorite, + photo_url: null, + grounding_status: 'grounded', + } +} + +const THIS_NAMES = ['Beef tacos', 'Spaghetti bolognese', 'Lemon orzo', 'Chicken stir-fry', 'Pizza night', 'Fish tacos', 'Sheet-pan salmon'] +const NEXT_NAMES = ['Chili', 'Pad thai', 'Burgers', 'Ramen', 'Pizza night', 'Pot roast', 'Fried rice'] + +// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it, with +// dinner ids p0 to p6 unless ids says otherwise. +function planBody(weekStart: string, names: string[], ids = names.map((_, i) => `p${i}`)) { + const start = new Date(`${weekStart}T00:00:00Z`).getTime() + return { + id: `plan-${weekStart}`, + week_start: weekStart, + status: 'draft', + ai_note: null, + calendar_synced_at: null as string | null, + days: names.map((name, i) => ({ + date: new Date(start + i * 86_400_000).toISOString().slice(0, 10), + dinner: { id: ids[i], name, photo: null, minutes: 30, method: 'stovetop' }, + is_new: false, + start_by: null, + })), + } +} + +// THURSDAY_SWAPPED is this week's plan as the swap of Thursday answers it. +function thursdaySwapped() { + const ids = THIS_NAMES.map((_, i) => (i === 3 ? 'd1' : `p${i}`)) + const names = THIS_NAMES.map((name, i) => (i === 3 ? 'Korean beef bowls' : name)) + const plan = planBody('2026-10-05', names, ids) + plan.days[3]!.is_new = true + return plan +} + +type Responder = (init: RequestInit | undefined) => Response | Promise + +// stubServer answers "METHOD path" requests from responders, with /me, the +// household settings and the dinner; any other request fails the test. +function stubServer(responders: Record = {}) { + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + 'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), + [`GET ${DINNER}`]: () => jsonResponse(200, detail()), + ...responders, + } + const fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + return Promise.resolve(respond(init)) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// sent lists the requests sent with method, as "path body". +function sent(fetchMock: Mock, method: string) { + return fetchMock.mock.calls + .filter(([, init]) => init?.method === method) + .map(([input, init]) => `${String(input)} ${String(init?.body)}`) +} + +function reads(fetchMock: Mock, path: string) { + return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === path).length +} + +// deferred is a response the test sends when it chooses. +function deferred() { + let send!: (response: Response) => void + const response = new Promise((resolve) => { + send = resolve + }) + return { response, send } +} + +// renderPage loads /me (for the CSRF token every write needs) and renders +// the bowls' detail screen, with plan for the Plan: a probe unless told +// otherwise. It answers with the router. +async function renderPage(plan =

Plan probe

) { + const api = createApiClient(fakeLocation('/dinner/d1'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const router = createMemoryRouter( + [ + { path: '/dinner/:id', element: }, + { path: '/plan', element: plan }, + ], + { initialEntries: ['/dinner/d1'] }, + ) + render( + + + + + , + ) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) + return router +} + +// openSheet opens Add to this week and waits for the week's days. +async function openSheet(listName = 'Days of this week') { + fireEvent.click(screen.getByRole('button', { name: 'Add to this week' })) + const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' }) + const days = await within(sheet).findByRole('list', { name: listName }) + return { sheet, days } +} + +// dayRows is each day's row text, as "Monday 5 · Beef tacos · Stovetop · 30 min", +// with plain spaces. +function dayRows(days: HTMLElement) { + return within(days) + .getAllByRole('listitem') + .map((row) => [...row.querySelectorAll('span')].filter((span) => span.children.length === 0 && !span.closest('[aria-hidden="true"]')).map((span) => span.textContent).join(' · ').replace(/\s/g, ' ')) +} + +beforeEach(() => { + mockViewport(390) + // Wednesday Oct 7, noon in New York. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-10-07T16:00:00Z')) +}) + +afterEach(() => { + vi.useRealTimers() + Reflect.deleteProperty(window, 'matchMedia') + vi.unstubAllGlobals() +}) + +describe('Add to this week', () => { + it('lists the seven days of this week with their dinners, and next week by the toggle', async () => { + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)), + [`GET ${NEXT_WEEK}`]: () => jsonResponse(200, planBody('2026-10-12', NEXT_NAMES)), + }) + await renderPage() + // Nothing about the week is read until the sheet opens. + expect(reads(fetchMock, THIS_WEEK)).toBe(0) + + const { sheet, days } = await openSheet() + expect(within(sheet).getByText("Oct 5 – 11 · replaces the day's dinner")).toBeInTheDocument() + expect(dayRows(days)).toEqual([ + 'Monday 5 · Beef tacos · Stovetop · 30 min', + 'Tuesday 6 · Spaghetti bolognese · Stovetop · 30 min', + 'Wednesday 7 · Lemon orzo · Stovetop · 30 min', + 'Thursday 8 · Chicken stir-fry · Stovetop · 30 min', + 'Friday 9 · Pizza night · Stovetop · 30 min', + 'Saturday 10 · Fish tacos · Stovetop · 30 min', + 'Sunday 11 · Sheet-pan salmon · Stovetop · 30 min', + ]) + expect(within(days).getAllByRole('button')).toHaveLength(7) + + fireEvent.click(within(sheet).getByRole('radio', { name: 'Next week' })) + const next = await within(sheet).findByRole('list', { name: 'Days of next week' }) + expect(within(sheet).getByText("Oct 12 – 18 · replaces the day's dinner")).toBeInTheDocument() + expect(dayRows(next)[0]).toBe('Monday 12 · Chili · Stovetop · 30 min') + expect(dayRows(next)[6]).toBe('Sunday 18 · Fried rice · Stovetop · 30 min') + + fireEvent.click(within(sheet).getByRole('radio', { name: 'This week' })) + expect(await within(sheet).findByRole('list', { name: 'Days of this week' })).toBeInTheDocument() + }) + + it("takes this week in the household's timezone, not the device's UTC date", async () => { + // Sunday Oct 11, 10 pm in New York, already Monday Oct 12 in UTC. + vi.setSystemTime(new Date('2026-10-12T02:00:00Z')) + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)) }) + await renderPage() + + const { days } = await openSheet() + expect(dayRows(days)[6]).toBe('Sunday 11 · Sheet-pan salmon · Stovetop · 30 min') + expect(reads(fetchMock, THIS_WEEK)).toBe(1) + expect(reads(fetchMock, NEXT_WEEK)).toBe(0) + }) + + it('swaps the day chosen through the plan API, confirms it and reads the plan again', async () => { + const swap = deferred() + let plan = planBody('2026-10-05', THIS_NAMES) + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan), + [`PUT ${THURSDAY}`]: () => swap.response, + }) + await renderPage() + const { days } = await openSheet() + + fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await waitFor(() => expect(sent(fetchMock, 'PUT')).toHaveLength(1)) + expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`]) + expect(screen.getByRole('status')).toHaveTextContent('Adding to Thursday…') + + plan = thursdaySwapped() + await act(async () => swap.send(jsonResponse(200, plan))) + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.')) + // The plan query is invalidated: the week is read again from the server. + await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(2)) + + // The sheet opened again shows the plan as swapped. + const again = await openSheet() + expect(dayRows(again.days)[3]).toBe('Thursday 8 · Korean beef bowls · Stovetop · 30 min · This dinner') + expect(within(again.sheet).getByText('Korean beef bowls is already on Thursday this week.')).toBeInTheDocument() + expect(within(again.days).queryAllByRole('button')).toHaveLength(0) + }) + + it('offers to draft a week with no plan on the Plan, and swaps nothing', async () => { + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found') }) + await renderPage() + + fireEvent.click(screen.getByRole('button', { name: 'Add to this week' })) + const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' }) + const draft = await within(sheet).findByRole('link', { name: 'Draft this week first' }) + expect(draft).toHaveAttribute('href', '/plan') + expect(within(sheet).getByText("There's no plan for this week yet, so there's no day to add it to.")).toBeInTheDocument() + expect(within(sheet).queryByRole('list')).not.toBeInTheDocument() + + fireEvent.click(draft) + expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument() + expect(sent(fetchMock, 'PUT')).toEqual([]) + }) + + it('shows a refused swap with the error, puts the day back and retries it', async () => { + let refuse = true + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)), + [`PUT ${THURSDAY}`]: () => (refuse ? errorResponse(500, 'internal') : jsonResponse(200, thursdaySwapped())), + }) + await renderPage() + const { days } = await openSheet() + fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't add it to Thursday this week. internal message") + const reopened = await openSheet() + expect(dayRows(reopened.days)[3]).toBe('Thursday 8 · Chicken stir-fry · Stovetop · 30 min') + fireEvent.keyDown(reopened.sheet, { key: 'Escape' }) + + refuse = false + fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.')) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`, `${THURSDAY} {"dinner_id":"d1"}`]) + }) + + it("leaves a push's Supply run outcome behind on the Plan, as a swap on the Draft board does", async () => { + let plan = planBody('2026-10-05', THIS_NAMES) + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan), + [`POST ${THIS_WEEK}/push-calendar`]: () => { + plan = { ...plan, status: 'synced', calendar_synced_at: '2026-10-07T16:00:00Z' } + return jsonResponse(200, { ...plan, supply_run: { status: 'generated', error: null } }) + }, + [`PUT ${THURSDAY}`]: () => { + plan = { ...thursdaySwapped(), status: 'changed', calendar_synced_at: '2026-10-07T16:00:00Z' } + return jsonResponse(200, plan) + }, + 'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'), + }) + const router = await renderPage() + + // The week is pushed on the Plan, which says its Supply run was made. + await act(() => router.navigate('/plan')) + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") + // Coming back from the dinner with nothing swapped, it still says so. + await act(() => router.navigate('/dinner/d1')) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) + await act(() => router.navigate('/plan')) + expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") + + // Thursday is swapped from the dinner's Add to this week. + await act(() => router.navigate('/dinner/d1')) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) + const { days } = await openSheet() + fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ })) + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.')) + + // The Plan shows the swapped plan, not changed since its push, and no + // longer says the earlier plan's Supply run was made. + await act(() => router.navigate('/plan')) + expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument() + expect(screen.getByText('Korean beef bowls')).toBeInTheDocument() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(screen.queryByText("Made this week's Supply run from the plan.")).not.toBeInTheDocument() + expect(sent(fetchMock, 'POST')).toEqual([`${THIS_WEEK}/push-calendar undefined`]) + expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`]) + }) + + it('refuses a swap while the week is being pushed to Calendar', async () => { + const push = deferred() + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)), + [`POST ${THIS_WEEK}/push-calendar`]: () => push.response, + 'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'), + }) + const router = await renderPage() + await act(() => router.navigate('/plan')) + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1)) + + await act(() => router.navigate('/dinner/d1')) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) + const { sheet, days } = await openSheet() + expect(within(sheet).getByText('The plan for this week is being pushed to Calendar.')).toBeInTheDocument() + expect(within(days).queryAllByRole('button')).toHaveLength(0) + expect(sent(fetchMock, 'PUT')).toEqual([]) + }) +}) + +describe('the favorite heart', () => { + it('fills at once, keeps the recipe and takes the server answer', async () => { + const favorite = deferred() + const fetchMock = stubServer({ [`POST ${FAVORITE}`]: () => favorite.response }) + await renderPage() + const heart = screen.getByRole('button', { name: 'Favorite' }) + expect(heart).toHaveAttribute('aria-pressed', 'false') + + fireEvent.click(heart) + await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'true')) + expect(sent(fetchMock, 'POST')).toEqual([`${FAVORITE} undefined`]) + + await act(async () => favorite.send(jsonResponse(200, listed(true)))) + await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1)) + expect(heart).toHaveAttribute('aria-pressed', 'true') + // The list-shaped answer did not replace the detail: the recipe stays. + expect(screen.getByText('Ground beef')).toBeInTheDocument() + expect(screen.getByText('Brown the beef with the sauce.')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('empties again with the error when the server refuses, and retries', async () => { + const refused = deferred() + let refuse = true + const fetchMock = stubServer({ + [`GET ${DINNER}`]: () => jsonResponse(200, detail({ is_favorite: true })), + [`POST ${FAVORITE}`]: () => (refuse ? refused.response : jsonResponse(200, listed(false))), + }) + await renderPage() + const heart = screen.getByRole('button', { name: 'Favorite' }) + expect(heart).toHaveAttribute('aria-pressed', 'true') + + fireEvent.click(heart) + await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false')) + await act(async () => refused.send(errorResponse(500, 'internal'))) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't unfavorite Korean beef bowls. internal message") + expect(heart).toHaveAttribute('aria-pressed', 'true') + + refuse = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false')) + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + expect(sent(fetchMock, 'POST')).toHaveLength(2) + }) +}) diff --git a/web/src/dinners/DinnerDetailOffline.test.tsx b/web/src/dinners/DinnerDetailOffline.test.tsx new file mode 100644 index 0000000..386a1ec --- /dev/null +++ b/web/src/dinners/DinnerDetailOffline.test.tsx @@ -0,0 +1,184 @@ +// A dinner's detail offline once visited: the page reads through the service +// worker's real API strategy (src/test/serviceWorker.ts), so what it shows +// offline is what the worker saved while it was online. jsdom has no service +// worker, so the strategy runs directly against an in-memory cache. +import { render, screen, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { OfflinePill } from '../pwa/OfflinePill.tsx' +import { AppShell } from '../shell/AppShell.tsx' +import { cacheNames, routeFor } from '../sw/routes.ts' +import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' +import { errorResponse, jsonResponse } from '../test/fetchStub.ts' +import { mockViewport } from '../test/matchMedia.ts' +import { origin, stubFetchThroughWorker, stubWorkerCaches, type FakeCache } from '../test/serviceWorker.ts' +import { DinnerDetailPage } from './DinnerDetailPage.tsx' + +// grounded is a dinner as GET /api/v1/dinners/{id} sends it, its recipe found. +function grounded(id: string, name: string) { + return { + id, + name, + status: 'quest_log', + origin: 'suggested', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + cuisine: 'korean', + is_favorite: false, + photo_urls: { '400': `/media/photos/${id}-400.jpg`, '1200': `/media/photos/${id}-1200.jpg` }, + ingredients: [ + { name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }, + { name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' }, + ], + steps: ['Cook the rice.', 'Brown the beef with the sauce.'], + source: { site_name: 'Serious Eats' }, + grounding_status: 'grounded', + grounding_error: null, + why_picked: null, + } +} + +// server is what the server sends while it can be reached. Kitchen reading +// mode also reads the household settings, which the worker never saves, and +// the week's plans to find the dinner's day, of which there are none here. +const server: Record = { + '/api/v1/settings/household': HOUSEHOLD_SETTINGS, + '/api/v1/dinners/d1': grounded('d1', 'Korean beef bowls'), + '/api/v1/dinners/d2': grounded('d2', 'Chicken tikka masala'), + '/api/v1/dinners/d3': { + ...grounded('d3', 'Pork carnitas'), + photo_urls: null, + ingredients: [], + steps: [], + source: null, + grounding_status: 'pending', + }, +} + +let cache: FakeCache +let reachable: boolean +// networkReads is every path the network was asked for, reached or not. +let networkReads: string[] + +beforeEach(() => { + cache = stubWorkerCaches() + reachable = true + networkReads = [] + vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable) + stubFetchThroughWorker(async (request) => { + const path = new URL(request.url).pathname + networkReads.push(path) + if (!reachable) throw new TypeError('Failed to fetch') + if (path.startsWith('/api/v1/plans/')) return errorResponse(404, 'not_found') + if (!Object.hasOwn(server, path)) throw new Error(`unexpected network fetch: ${request.url}`) + return jsonResponse(200, server[path]) + }) +}) + +afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +// open loads /dinner/{id} as a fresh page load would: a new API client, +// network status and query cache, with the offline indicator App shows. +function open(id: string) { + const router = createMemoryRouter( + [{ element: , children: [{ path: '/dinner/:id', element: }] }], + { initialEntries: [`/dinner/${id}`] }, + ) + return render( + + + + , + ) +} + +// visit opens a dinner while online and leaves once its title shows. +async function visit(id: string, name: string) { + const page = open(id) + expect(await screen.findByRole('heading', { level: 1, name })).toBeInTheDocument() + expect(screen.queryByText('Offline — showing saved data')).not.toBeInTheDocument() + page.unmount() +} + +describe('dinner detail offline', () => { + it('saves a visited dinner in the API cache the worker reads offline', async () => { + mockViewport(390) + expect(routeFor({ method: 'GET', url: new URL('/api/v1/dinners/d1', origin), mode: 'cors' }, origin)).toEqual({ + strategy: 'network-first', + cacheName: cacheNames.api, + }) + await visit('d1', 'Korean beef bowls') + expect([...cache.entries.keys()]).toEqual([`${origin}/api/v1/dinners/d1`]) + }) + + it('shows a visited dinner offline from its saved copy: photo, ingredients and steps, with the offline indicator', async () => { + mockViewport(390) + await visit('d1', 'Korean beef bowls') + + reachable = false + open('d1') + expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data') + expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg') + const [ingredients, steps] = screen.getAllByRole('list') + expect(within(ingredients!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([ + 'Ground beef1 lb', + 'Jasmine rice2 cups', + ]) + expect(within(steps!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([ + '1Cook the rice.', + '2Brown the beef with the sauce.', + ]) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument() + }) + + it('shows a visited dinner offline in Kitchen reading mode on a tablet', async () => { + mockViewport(1180) + await visit('d1', 'Korean beef bowls') + + reachable = false + open('d1') + expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument() + expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg') + expect(within(screen.getByRole('region', { name: 'Ingredients' })).getAllByRole('checkbox')).toHaveLength(2) + expect(screen.getByRole('listitem', { current: 'step' })).toHaveTextContent('Cook the rice.') + }) + + it('shows an error, not a blank page or placeholder, for a dinner never visited', async () => { + mockViewport(390) + await visit('d1', 'Korean beef bowls') + + reachable = false + open('d2') + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.") + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument() + expect(screen.queryByText(/Korean beef bowls|Chicken tikka masala/)).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument() + expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument() + expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument() + }) + + it('keeps a saved copy still finding its recipe as that, not a finished recipe', async () => { + mockViewport(390) + await visit('d3', 'Pork carnitas') + + reachable = false + open('d3') + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument() + expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument() + // Read once online and tried once offline before the saved copy answered. + expect(networkReads.filter((path) => path.startsWith('/api/v1/dinners/'))).toEqual([ + '/api/v1/dinners/d3', + '/api/v1/dinners/d3', + ]) + }) +}) diff --git a/web/src/dinners/DinnerDetailPage.module.css b/web/src/dinners/DinnerDetailPage.module.css new file mode 100644 index 0000000..425564e --- /dev/null +++ b/web/src/dinners/DinnerDetailPage.module.css @@ -0,0 +1,547 @@ +/* Mock 1k on mobile: the photo and the sheet run full-bleed past the + shell's 20 px side padding. From tablet up the page is Kitchen reading + mode (mock 2a, below). The page is at least as tall as the shell's scroll + area (a stretched flex item, so its height resolves the percentage) and + the sheet, or the reading view, grows into any room left, so the action + bar rests at the bottom of a short recipe. */ +.page { + display: flex; + flex-direction: column; + min-height: 100%; + font-family: var(--font-body); + color: var(--color-text); +} + +/* Kitchen reading mode is no wider than the handoff's tablet frame. */ +.tablet, +.desktop { + width: 100%; + max-width: 1180px; + margin: 0 auto; +} + +.hero { + position: relative; + height: 250px; +} + +.mobile .hero, +.mobile .skeletonHero { + margin: 0 -20px; +} + +/* Without a dinner to show, the back button sits alone above the message. */ +.backRow { + padding-top: calc(16px + env(safe-area-inset-top)); +} + +.backRow .back { + position: static; +} + +.photo { + width: 100%; + height: 100%; +} + +.back { + position: absolute; + top: calc(16px + env(safe-area-inset-top)); + left: 20px; +} + +/* The sheet overlaps the photo by 28 px. */ +.sheet { + position: relative; + flex-grow: 1; + margin-top: -28px; + display: flex; + flex-direction: column; + gap: 10px; + padding: 22px 20px 0; + border-radius: 32px 32px 0 0; + background: var(--color-bg); +} + +.mobile .sheet { + margin-left: -20px; + margin-right: -20px; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 28px; + font-weight: 400; + line-height: 1.05; +} + +.why { + margin: 0; + padding: 12px 14px; + border-radius: 18px; + background: var(--color-accent-2-100); + color: var(--color-accent-2-900); + font-size: 13px; + line-height: 1.45; +} + +.section { + display: flex; + flex-direction: column; + margin-top: 6px; +} + +.sectionTitle { + margin: 0 0 4px; + font-family: var(--font-heading); + font-size: 18px; + font-weight: 400; + line-height: 1.15; +} + +.ingredients, +.steps { + margin: 0; + padding: 0; + list-style: none; + font-size: 14px; + line-height: 1.45; +} + +.ingredient { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 7px 0; + border-bottom: 1px solid var(--color-divider); +} + +.ingredient:last-child { + border-bottom: 0; +} + +.note, +.qty { + color: var(--color-neutral-700); +} + +.qty { + flex: none; + text-align: right; +} + +.step { + display: flex; + gap: 12px; + padding: 8px 0; +} + +.number { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: var(--radius-pill); + background: var(--color-accent-100); + color: var(--color-accent-800); + font-size: 12px; + font-weight: 700; +} + +.source { + margin: 4px 0 0; + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.source a { + color: inherit; +} + +.missing { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + margin: 6px 0 0; + padding: 12px 14px; + border-radius: 18px; + background: var(--color-neutral-100); + color: var(--color-neutral-800); + font-size: 13px; + line-height: 1.45; +} + +.missing p { + margin: 0; +} + +/* A 44 px tall tap target for the link. */ +.fix { + display: inline-flex; + align-items: center; + min-height: 44px; + color: var(--color-accent-700); + font-weight: 700; +} + +/* At the bottom of the page, which fills the scroll area, and held at the + bottom of the screen over a long recipe. Sticky offsets count from inside + the scroll area's padding, which on mobile already keeps clear of the nav + bar. */ +.bar { + position: sticky; + bottom: 0; + z-index: 1; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: 24px; + padding: 10px 0; + background: var(--color-bg); +} + +.add { + flex: 1; + min-height: 52px; + font-size: 16px; +} + +/* What became of an add or a favorite: a full row above the buttons. */ +.status { + order: -1; + flex-basis: 100%; +} + +.added { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 600; + color: var(--color-accent-2-800); +} + +.filled { + color: var(--color-accent); + fill: currentColor; +} + +.skeletonHero { + height: 250px; + border-radius: 0; +} + +.skeletonTitle { + height: 32px; + width: 70%; +} + +/* Mock 2a, Kitchen reading mode: the header over two columns, the + ingredients card (400 px) and the steps. Ingredient and step text scale + with the text size chosen: the handoff's sizes are medium. */ +.reading { + flex-grow: 1; + display: flex; + flex-direction: column; + gap: 22px; +} + +.small { + --reading-scale: 0.85; +} + +.medium { + --reading-scale: 1; +} + +.large { + --reading-scale: 1.25; +} + +.readingHeader { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 18px; +} + +.readingHeader .back { + position: static; + flex: none; +} + +.heading { + flex: 1 1 240px; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.kicker { + margin: 0; + font-size: 13px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.readingTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 36px; + font-weight: 400; + line-height: 1.05; +} + +.keepOn { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + font-weight: 700; +} + +.keepOnHint { + margin: 0; + font-size: 14px; + line-height: 1.4; + color: var(--color-neutral-700); +} + +/* The A / A / A control: an outlined pill, the size chosen in grape, each + A drawn at the size it stands for. */ +.readingHeader .textSize { + gap: 0; + padding: 0; + border: 1px solid var(--color-divider); + background: transparent; + overflow: hidden; +} + +.readingHeader .textSize > * { + padding: 0 14px; + border-radius: 0; + color: var(--color-text); +} + +.readingHeader .textSize > [aria-checked="true"] { + background: var(--color-accent); + color: var(--on-accent); + box-shadow: none; +} + +.readingHeader .textSize > :nth-child(1) { + font-size: 13px; +} + +.readingHeader .textSize > :nth-child(2) { + font-size: 17px; +} + +.readingHeader .textSize > :nth-child(3) { + font-size: 21px; +} + +.columns { + flex-grow: 1; + display: grid; + grid-template-columns: 400px minmax(0, 1fr); + gap: 28px; +} + +/* Below a 1024 px viewport the shell leaves too little beside a 400 px + card for the steps, so the columns share the width instead. */ +@media (max-width: 1023px) { + .columns { + grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); + } +} + +.oneColumn { + flex-grow: 1; + display: flex; + flex-direction: column; +} + +.columns .ingredientsCard { + display: flex; + flex-direction: column; + gap: 6px; + padding: 22px; + border-radius: 30px; +} + +.cardHead { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + margin-bottom: 6px; +} + +.readingSectionTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; + line-height: 1.15; +} + +.setOutCount { + flex: none; + white-space: nowrap; + font-size: 13px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.checklist { + margin: 0; + padding: 0; + list-style: none; +} + +.checkRow { + display: flex; + align-items: center; + gap: 14px; + padding: 4px 0; + border-bottom: 1px solid var(--color-divider); +} + +.checkRow .check { + flex: 1; + min-width: 0; + gap: 14px; +} + +.checkName { + font-size: calc(18px * var(--reading-scale)); + font-weight: 400; + line-height: 1.3; +} + +.setOut { + color: var(--color-neutral-600); + text-decoration: line-through; +} + +.checkQty { + flex: none; + text-align: right; + font-size: calc(15px * var(--reading-scale)); + color: var(--color-neutral-700); +} + +.method { + flex-grow: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 14px; +} + +.intro { + display: flex; + align-items: center; + gap: 16px; +} + +.introPhoto { + flex: 1; + min-width: 0; + height: 96px; +} + +.introWhy { + flex: 1.6; + padding: 14px 18px; + border-radius: 22px; + font-size: 14px; +} + +.readingSteps { + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 4px; +} + +.stepList { + display: flex; + flex-direction: column; + gap: 14px; + margin: 0; + padding: 0; + list-style: none; +} + +.readingStep { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 6px 18px; + border-radius: 26px; + font-size: calc(18px * var(--reading-scale)); + line-height: 1.45; +} + +.stepCircle { + flex: none; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: var(--radius-pill); + background: var(--color-surface); + font-family: var(--font-heading); + font-size: 18px; +} + +.stepText { + padding-top: 6px; +} + +.stepDone { + color: var(--color-neutral-600); +} + +.stepCurrent { + padding: 18px; + background: var(--color-accent-100); + box-shadow: inset 0 0 0 2px var(--color-accent-300); + font-size: calc(22px * var(--reading-scale)); +} + +.stepCurrent .stepCircle { + background: var(--color-accent); + color: var(--on-accent); +} + +/* The action bar rests at the bottom of the steps column, with Previous + and Next step at its right. */ +.reading .bar { + margin-top: auto; +} + +.reading .add { + flex: none; +} + +.stepButtons { + display: flex; + gap: 10px; + margin-left: auto; +} diff --git a/web/src/dinners/DinnerDetailPage.test.tsx b/web/src/dinners/DinnerDetailPage.test.tsx new file mode 100644 index 0000000..021f39f --- /dev/null +++ b/web/src/dinners/DinnerDetailPage.test.tsx @@ -0,0 +1,473 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AppShell } from '../shell/AppShell.tsx' +import { cacheFallbackHeader } from '../sw/routes.ts' +import { ApiProviders } from '../test/ApiProviders.tsx' +import { declaredStyle } from '../test/cssRules.ts' +import { errorResponse, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts' +import { mockViewport } from '../test/matchMedia.ts' +import { GROUNDING_POLL_MS } from './api.ts' +import { DinnerDetailPage, REGULAR_DINNERS } from './DinnerDetailPage.tsx' +import sourceCases from './testdata/source_url_cases.json' + +const DINNER = '/api/v1/dinners/d1' + +// detail is a dinner as GET /api/v1/dinners/{id} sends it: mock 1k's +// Korean beef bowls, a Side quest grounded in a page that named its site. +function detail(overrides: Record = {}) { + return { + id: 'd1', + name: 'Korean beef bowls', + status: 'quest_log', + origin: 'suggested', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + cuisine: 'korean', + is_favorite: false, + photo_urls: { '400': '/media/photos/p1-400.jpg', '1200': '/media/photos/p1-1200.jpg' }, + ingredients: [ + { name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }, + { name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' }, + { name: 'Soy sauce', qty: '¼', unit: 'cup', note: null, raw_text: '¼ cup soy sauce' }, + { name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' }, + ], + steps: ['Cook the rice.', 'Brown the beef with the sauce.', 'Serve over rice with the onions.'], + source: { site_name: 'Serious Eats' }, + grounding_status: 'grounded', + grounding_error: null, + why_picked: 'close cousin of your Beef tacos and Chicken stir-fry.', + ...overrides, + } +} + +// A recipe the household typed in: only raw_text, no source. +const MANUAL = { + status: 'regular', + origin: 'regular', + photo_urls: null, + ingredients: [ + { name: null, qty: null, unit: null, note: null, raw_text: '2 lb chuck roast' }, + { name: null, qty: null, unit: null, note: null, raw_text: 'A splash of red wine' }, + ], + steps: ['Sear the roast.', 'Slow cook 8 hours.'], + source: null, + grounding_status: 'manual', + why_picked: null, +} + +// A Regular whose recipe could not be found: no recipe at all. +const FAILED = { + status: 'regular', + origin: 'regular', + photo_urls: null, + minutes_total: null, + difficulty: null, + ingredients: [], + steps: [], + source: null, + grounding_status: 'failed', + grounding_error: 'no_recipe_found', + why_picked: null, +} + +// renderAt renders the detail screen at the last of entries, inside the +// app shell's scroll area as App routes it, with probes for where its links +// and back button go. +function renderAt(entries: string[]) { + const router = createMemoryRouter( + [ + { element: , children: [{ path: '/dinner/:id', element: }] }, + { path: '/', element:

Home probe

}, + { path: '/plan', element:

Plan probe

}, + { path: REGULAR_DINNERS, element:

Regular dinners probe

}, + ], + { initialEntries: entries, initialIndex: entries.length - 1 }, + ) + render( + + + , + ) + return router +} + +function serve(body: unknown) { + return stubFetchByPath({ [DINNER]: () => jsonResponse(200, body) }) +} + +beforeEach(() => { + mockViewport(390) +}) + +afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') + vi.unstubAllGlobals() +}) + +describe('DinnerDetailPage', () => { + describe('a grounded Side quest', () => { + it('shows its photo washed and full-bleed, tags, title and why it was picked', async () => { + serve(detail()) + renderAt(['/dinner/d1']) + + expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument() + const photo = screen.getByRole('img', { name: 'Korean beef bowls' }) + expect(photo).toHaveAttribute('src', '/media/photos/p1-400.jpg') + expect(photo).toHaveAttribute('srcset', '/media/photos/p1-400.jpg 400w, /media/photos/p1-1200.jpg 1200w') + expect(declaredStyle(photo.parentElement!).height).toBe('250px') + expect(declaredStyle(photo.parentElement!).margin).toBe('0px -20px') + + expect(screen.getByText('Side quest')).toBeInTheDocument() + expect(screen.getByText('Easy')).toBeInTheDocument() + expect(screen.getByText('30 min')).toBeInTheDocument() + expect(screen.queryByText('Regular')).not.toBeInTheDocument() + expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent( + 'Why it was picked: close cousin of your Beef tacos and Chicken stir-fry.', + ) + }) + + it('lists ingredients in recipe order with quantities on the right, then numbered steps', async () => { + serve(detail()) + renderAt(['/dinner/d1']) + + expect(await screen.findByRole('heading', { level: 2, name: 'Ingredients' })).toBeInTheDocument() + const rows = within(screen.getAllByRole('list')[0]!).getAllByRole('listitem') + expect(rows.map((row) => Array.from(row.children, (cell) => cell.textContent))).toEqual([ + ['Ground beef', '1 lb'], + ['Jasmine rice', '2 cups'], + ['Soy sauce', '¼ cup'], + ['Green onions, sliced', '1 bunch'], + ]) + const qty = rows[0]!.lastElementChild! + expect(declaredStyle(qty).textAlign).toBe('right') + expect(declaredStyle(rows[0]!).justifyContent).toBe('space-between') + expect(declaredStyle(rows[0]!).borderBottom).toBe('1px solid var(--color-divider)') + + expect(screen.getByRole('heading', { level: 2, name: 'Steps' })).toBeInTheDocument() + const steps = screen.getAllByRole('list')[1]! + expect(steps.tagName).toBe('OL') + expect(within(steps).getAllByRole('listitem').map((step) => step.textContent)).toEqual([ + '1Cook the rice.', + '2Brown the beef with the sauce.', + '3Serve over rice with the onions.', + ]) + }) + + it('credits the site the recipe came from, with no link to it', async () => { + serve(detail()) + renderAt(['/dinner/d1']) + + const credit = await screen.findByText('Recipe from Serious Eats') + expect(credit.closest('a')).toBeNull() + expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument() + }) + + it('links the credit to the recipe in a new tab once the server sends a safe url', async () => { + serve(detail({ source: { site_name: 'Serious Eats', url: 'https://www.seriouseats.com/korean-beef-bowls' } })) + renderAt(['/dinner/d1']) + + const credit = await screen.findByRole('link', { name: 'Recipe from Serious Eats' }) + expect(credit).toHaveAttribute('href', 'https://www.seriouseats.com/korean-beef-bowls') + expect(credit).toHaveAttribute('target', '_blank') + expect(credit).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it.each([ + ['http', 'http://www.seriouseats.com/korean-beef-bowls'], + ['a password', 'https://user:secret@www.seriouseats.com/korean-beef-bowls'], + ['a query', 'https://www.seriouseats.com/korean-beef-bowls?token=abc'], + ['a fragment', 'https://www.seriouseats.com/korean-beef-bowls#token=abc'], + ])('refuses a source url with %s', async (_, url) => { + serve(detail({ source: { site_name: 'Serious Eats', url } })) + renderAt(['/dinner/d1']) + + expect(await screen.findByRole('alert')).toHaveTextContent('source url is not an https link') + expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument() + }) + + // Each source exactly as the Go handler sends it for a stored link + // (internal/server's TestGetDinnerSourceURLOnlyWhenSafe checks the same + // file), so every url the server sends must parse here. + it.each(sourceCases.cases)('renders the source the server sends for $stored', async ({ source }) => { + serve(detail({ source })) + renderAt(['/dinner/d1']) + + const credit = await screen.findByText('Recipe from Curry House') + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + if (source.url === null) { + expect(credit.closest('a')).toBeNull() + expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument() + } else { + expect(screen.getByRole('link', { name: 'Recipe from Curry House' })).toHaveAttribute('href', new URL(source.url).href) + } + }) + + it("links the credit by the url's host when the server names no site", async () => { + serve(detail({ source: { site_name: null, url: 'https://Kitchen.Example:8443/korean-beef-bowls' } })) + renderAt(['/dinner/d1']) + + const credit = await screen.findByRole('link', { name: 'Recipe from kitchen.example:8443' }) + expect(credit).toHaveAttribute('href', 'https://kitchen.example:8443/korean-beef-bowls') + expect(credit).toHaveAttribute('target', '_blank') + expect(credit).toHaveAttribute('rel', 'noopener noreferrer') + }) + + // The server withholds both when the stored name could be the link's + // host and the link could carry a token: the credit names neither. + it('credits the web in plain text when the server sends neither site nor url', async () => { + serve(detail({ source: { site_name: null, url: null } })) + renderAt(['/dinner/d1']) + + const credit = await screen.findByText('Recipe from the web') + expect(credit.closest('a')).toBeNull() + expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument() + }) + + it('holds Add to this week and the heart at the bottom', async () => { + serve(detail({ is_favorite: true })) + renderAt(['/dinner/d1']) + + const add = await screen.findByRole('button', { name: 'Add to this week' }) + expect(add).toBeEnabled() + const heart = screen.getByRole('button', { name: 'Favorite' }) + expect(heart).toBeEnabled() + expect(heart).toHaveAttribute('aria-pressed', 'true') + const bar = heart.parentElement! + expect(bar).toContainElement(add) + expect(declaredStyle(bar).position).toBe('sticky') + expect(declaredStyle(bar).bottom).toBe('0px') + }) + }) + + // jsdom does no layout, so no test here can measure where the bar lands. + // This pins the CSS chain that puts it at the bottom of the screen on a + // short recipe: the shell's scroll area is a full-height flex item, the + // page fills it as a column, the sheet takes the room left and the bar is + // the page's last child. Drop any link and the bar sits right after the + // content again, and this fails. + it('fills the scroll area so the bar rests at the bottom of a short recipe', async () => { + serve(detail(FAILED)) + renderAt(['/dinner/d1']) + + const bar = (await screen.findByRole('button', { name: 'Favorite' })).parentElement! + const page = bar.parentElement! + const main = page.parentElement! + const shell = main.parentElement! + expect(main.tagName).toBe('MAIN') + expect(declaredStyle(shell).height).toBe('100dvh') + expect(declaredStyle(shell).display).toBe('flex') + expect(declaredStyle(shell).alignItems).toBe('') + expect(declaredStyle(main).overflowY).toBe('auto') + + expect(declaredStyle(page).minHeight).toBe('100%') + expect(declaredStyle(page).display).toBe('flex') + expect(declaredStyle(page).flexDirection).toBe('column') + expect(page.lastElementChild).toBe(bar) + const sheet = screen.getByRole('article') + expect(sheet.parentElement).toBe(page) + expect(sheet.nextElementSibling).toBe(bar) + expect(declaredStyle(sheet).flexGrow).toBe('1') + expect(declaredStyle(bar).position).toBe('sticky') + }) + + it('shows the striped placeholder when the dinner has no photo', async () => { + serve(detail({ photo_urls: null })) + renderAt(['/dinner/d1']) + + const placeholder = await screen.findByRole('img', { name: 'Korean beef bowls' }) + expect(placeholder.tagName).toBe('DIV') + expect(placeholder).toHaveTextContent('dish photo') + }) + + it('shows a manual Regular line by line, as typed, with no credit', async () => { + serve(detail(MANUAL)) + renderAt(['/dinner/d1']) + + expect(await screen.findByText('Regular')).toBeInTheDocument() + expect(screen.queryByText('Side quest')).not.toBeInTheDocument() + const [ingredients, steps] = screen.getAllByRole('list') + expect(within(ingredients!).getAllByRole('listitem').map((row) => row.firstElementChild!.textContent)).toEqual([ + '2 lb chuck roast', + 'A splash of red wine', + ]) + expect(within(steps!).getAllByRole('listitem')).toHaveLength(2) + expect(screen.queryByText(/Why it was picked/)).not.toBeInTheDocument() + expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveTextContent('dish photo') + }) + + it('links a dinner whose recipe was not found to Settings → Regular dinners, with no recipe', async () => { + serve(detail(FAILED)) + const router = renderAt(['/dinner/d1']) + + const fix = await screen.findByRole('link', { name: 'Fix recipe' }) + expect(screen.getByText(/couldn't find a recipe/)).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Steps' })).not.toBeInTheDocument() + expect(screen.queryByRole('list')).not.toBeInTheDocument() + + fireEvent.click(fix) + expect(await screen.findByRole('heading', { name: 'Regular dinners probe' })).toBeInTheDocument() + expect(router.state.location.pathname).toBe(REGULAR_DINNERS) + }) + + it('says it is still finding a recipe while grounding is pending', async () => { + serve(detail({ ...FAILED, grounding_status: 'pending', grounding_error: null })) + renderAt(['/dinner/d1']) + + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'Fix recipe' })).not.toBeInTheDocument() + }) + + describe('while the recipe is being found', () => { + const PENDING = detail({ ...FAILED, grounding_status: 'pending', grounding_error: null }) + const GROUNDED = detail({ grounding_status: 'grounded' }) + let onLine: boolean + let body: unknown + let fromCache: boolean + let fetchMock: Mock + + const reads = () => fetchMock.mock.calls.length + const tick = (intervals = 1) => act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * intervals)) + + function setOnline(value: boolean) { + onLine = value + act(() => { + window.dispatchEvent(new Event(value ? 'online' : 'offline')) + }) + } + + beforeEach(() => { + // Only intervals are faked: TanStack Query polls with setInterval, and + // Testing Library's waits keep their real timers. + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + onLine = true + vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine) + body = PENDING + fromCache = false + fetchMock = stubFetchByPath({ + [DINNER]: () => + new Response(JSON.stringify(body), { + headers: { 'Content-Type': 'application/json', ...(fromCache ? { [cacheFallbackHeader]: '1' } : {}) }, + }), + }) + }) + + // An offline event leaves TanStack Query's online manager, shared by + // every test, offline until an online event. + afterEach(() => { + window.dispatchEvent(new Event('online')) + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('reads the dinner again until the recipe lands, then stops', async () => { + renderAt(['/dinner/d1']) + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + expect(reads()).toBe(1) + + await tick() + await waitFor(() => expect(reads()).toBe(2)) + + body = GROUNDED + await tick() + expect(await screen.findByRole('heading', { level: 2, name: 'Ingredients' })).toBeInTheDocument() + expect(reads()).toBe(3) + + await tick(3) + expect(reads()).toBe(3) + }) + + it('pauses while the browser is offline and resumes when it is back', async () => { + renderAt(['/dinner/d1']) + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + expect(reads()).toBe(1) + + setOnline(false) + await tick(3) + expect(reads()).toBe(1) + + setOnline(true) + await tick() + await waitFor(() => expect(reads()).toBe(2)) + }) + + it('reads a saved pending copy offline once, then waits for the connection', async () => { + onLine = false + fromCache = true + renderAt(['/dinner/d1']) + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + expect(reads()).toBe(1) + + await tick(3) + expect(reads()).toBe(1) + + // The connection comes back: the saved copy is read again at once, + // and polling goes on once the server answers. + fromCache = false + setOnline(true) + await waitFor(() => expect(reads()).toBe(2)) + await tick() + await waitFor(() => expect(reads()).toBe(3)) + }) + + it('stops polling on a saved copy even while the browser says it is online', async () => { + fromCache = true + renderAt(['/dinner/d1']) + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + + await tick(3) + expect(reads()).toBe(1) + }) + + it('stops polling when the page is left', async () => { + const router = renderAt(['/dinner/d1']) + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + expect(reads()).toBe(1) + + await act(() => router.navigate('/')) + expect(screen.getByRole('heading', { name: 'Home probe' })).toBeInTheDocument() + await tick(3) + expect(reads()).toBe(1) + }) + }) + + it('says an unknown or removed dinner is not found', async () => { + stubFetchByPath({ [DINNER]: () => errorResponse(404, 'not_found') }) + renderAt(['/dinner/d1']) + + expect(await screen.findByRole('heading', { name: 'Dinner not found' })).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Go Home' })).toHaveAttribute('href', '/') + }) + + it('reports any other failure with a retry', async () => { + stubFetchByPath({ [DINNER]: () => errorResponse(500, 'internal') }) + renderAt(['/dinner/d1']) + + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.") + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + }) + + describe('the back button', () => { + it('goes back in the app', async () => { + serve(detail()) + const router = renderAt(['/plan', '/dinner/d1']) + + fireEvent.click(await screen.findByRole('button', { name: 'Back' })) + expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument() + expect(router.state.location.pathname).toBe('/plan') + }) + + it('goes Home when the dinner was opened from a link', async () => { + serve(detail()) + const router = renderAt(['/dinner/d1']) + + fireEvent.click(await screen.findByRole('button', { name: 'Back' })) + expect(await screen.findByRole('heading', { name: 'Home probe' })).toBeInTheDocument() + expect(router.state.location.pathname).toBe('/') + }) + }) +}) diff --git a/web/src/dinners/DinnerDetailPage.tsx b/web/src/dinners/DinnerDetailPage.tsx new file mode 100644 index 0000000..9874787 --- /dev/null +++ b/web/src/dinners/DinnerDetailPage.tsx @@ -0,0 +1,582 @@ +import { useQueryClient } from '@tanstack/react-query' +import { Check, ChevronLeft, ChevronRight, Heart } from 'lucide-react' +import { useCallback, useEffect, useId, useState, type ReactNode } from 'react' +import { Link, useLocation, useNavigate, useParams } from 'react-router' +import { ApiError } from '../api/errors.ts' +import { planKey, usePlan, useWeekSwap } from '../plan/api.ts' +import { duration } from '../plan/PlanPage.tsx' +import { addDays, dayAndDate, localNow, weekdayName, weekStart } from '../plan/week.ts' +import { useHouseholdSettings } from '../settings/api.ts' +import { useBreakpoint } from '../shell/useBreakpoint.ts' +import { Button } from '../ui/Button.tsx' +import buttonStyles from '../ui/Button.module.css' +import { Card } from '../ui/Card.tsx' +import { Checkbox } from '../ui/Checkbox.tsx' +import { EmptyState } from '../ui/EmptyState.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { IconButton } from '../ui/IconButton.tsx' +import { Photo } from '../ui/Photo.tsx' +import { Segmented, type SegmentedOption } from '../ui/Segmented.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { Tag } from '../ui/Tag.tsx' +import { Toggle } from '../ui/Toggle.tsx' +import { AddToWeekSheet, weekNames, type Added } from './AddToWeekSheet.tsx' +import { useToggleFavorite, type Difficulty } from './api.ts' +import { useDinnerDetail, type DinnerDetail, type Ingredient, type RecipeSource } from './detail.ts' +import styles from './DinnerDetailPage.module.css' +import { useKeepScreenOn } from './keepScreenOn.ts' +import { textSizeFailureText, useTextSize, type TextSize } from './textSize.ts' + +// REGULAR_DINNERS is Settings → Regular dinners, where a recipe is fixed. +export const REGULAR_DINNERS = '/settings/regulars' + +const difficultyLabels: Record = { easy: 'Easy', medium: 'Medium', hard: 'Hard' } + +// BackButton goes back in the app's history, or, opened from a link with +// no history in the app, to Home. +function BackButton() { + const navigate = useNavigate() + const location = useLocation() + // The first entry of a session has the key "default". + const back = () => (location.key === 'default' ? void navigate('/', { replace: true }) : void navigate(-1)) + return ( + + + + ) +} + +// quantity is an ingredient's amount as the right-hand column shows it, +// "1 lb", or empty when the recipe gives none. +function quantity(ingredient: Ingredient): string { + return [ingredient.qty, ingredient.unit].filter(Boolean).join(' ') +} + +// IngredientRow is one ingredient: its name with any note, or the line as +// the recipe gives it when there is no name, and its amount on the right. +function IngredientRow({ ingredient }: { ingredient: Ingredient }) { + return ( +
  • + + {ingredient.name ?? ingredient.raw_text} + {ingredient.name !== null && ingredient.note !== null && , {ingredient.note}} + + {quantity(ingredient)} +
  • + ) +} + +// SourceCredit is "Recipe from {site}" for a recipe fetched from the web: a +// link opening the recipe's page in a new tab when the server sends a url +// safe to open, else plain text. The site is the name the page gave, else +// the url's host — already in the link, so naming it shows nothing more — +// else, with neither to show, just "the web": a fetched recipe always +// credits where it came from, without a name the server withheld. +function SourceCredit({ siteName, url }: RecipeSource) { + const credit = `Recipe from ${siteName ?? (url === null ? 'the web' : new URL(url).host)}` + return ( +

    + {url === null ? ( + credit + ) : ( + + {credit} + + )} +

    + ) +} + +// hasRecipe says whether the dinner's recipe was found or typed in, rather +// than still being searched for or not found. +function hasRecipe(dinner: DinnerDetail): boolean { + return dinner.grounding_status !== 'pending' && dinner.grounding_status !== 'failed' +} + +// RecipeMissing is what stands in for a recipe still being searched for, or +// the link to fix one that could not be found. +function RecipeMissing({ dinner }: { dinner: DinnerDetail }) { + if (dinner.grounding_status === 'failed') { + return ( +
    +

    We couldn't find a recipe for this dinner, so there are no ingredients or steps yet.

    + + Fix recipe + +
    + ) + } + return

    Still finding the recipe — its ingredients and steps will show here.

    +} + +// Recipe is the dinner's ingredients and steps, or what stands in for them. +function Recipe({ dinner }: { dinner: DinnerDetail }) { + if (!hasRecipe(dinner)) return + return ( + <> + {dinner.ingredients.length > 0 && ( +
    +

    Ingredients

    +
      + {dinner.ingredients.map((ingredient, i) => ( + + ))} +
    +
    + )} + {dinner.steps.length > 0 && ( +
    +

    Steps

    +
      + {dinner.steps.map((step, i) => ( +
    1. + + {step} +
    2. + ))} +
    +
    + )} + {dinner.source !== null && } + + ) +} + +// AddedStatus says what became of a dinner added to a day: being saved, +// added, or why it couldn't be, with Retry, which sends the same swap again +// as the Draft board would (useWeekSwap), and is not offered while that is +// refused. The week's plan stays read while it shows, so a retry finds the +// day to swap; once added, the plan is read again from the server. +function AddedStatus({ added }: { added: Added }) { + const queryClient = useQueryClient() + usePlan(added.week) + const { swap, refused, swapIn } = useWeekSwap(added.week) + const { date } = added.swap + const done = swap.succeeded === added.swap + const failed = swap.failed?.swap === added.swap ? swap.failed.error : null + useEffect(() => { + if (done) void queryClient.invalidateQueries({ queryKey: planKey(added.week) }) + }, [done, queryClient, added.week]) + + if (failed !== null) { + return ( +
    + swapIn(added.swap)}> + Couldn't add it to {weekdayName(date)} {weekNames[added.which]}. {failed.message} + +
    + ) + } + return ( +

    + {done ? `Added to ${weekdayName(date)} ${weekNames[added.which]}.` : `Adding to ${weekdayName(date)}…`} +

    + ) +} + +// AddToWeek is Add to this week: it opens the sheet of the week's days, and +// says what became of the dinner added. +function AddToWeek({ dinner }: { dinner: DinnerDetail }) { + const [open, setOpen] = useState(false) + const [added, setAdded] = useState(null) + function add(chosen: Added) { + setOpen(false) + setAdded(chosen) + } + return ( + <> + {added !== null && } + + {open && setOpen(false)} />} + + ) +} + +// FavoriteHeart toggles whether the dinner is a favorite, at once, changing +// back with the error and Retry if the server refuses. One toggle is sent at +// a time. +function FavoriteHeart({ dinner }: { dinner: DinnerDetail }) { + const favorite = useToggleFavorite() + function toggle() { + if (!favorite.isPending) favorite.mutate({ id: dinner.id, is_favorite: dinner.is_favorite }) + } + return ( + <> + {favorite.isError && ( +
    + + Couldn't {dinner.is_favorite ? 'unfavorite' : 'favorite'} {dinner.name}. {favorite.error.message} + +
    + )} + + + + + ) +} + +// ActionBar is the bar at the bottom of the screen, the page's last child: +// the page fills the scroll area so a short recipe still leaves it at the +// bottom, and it sticks there over a long one. It holds Add to this week +// and the favorite heart, with what became of either above them, and in +// Kitchen reading mode the step buttons after them. +function ActionBar({ dinner, children }: { dinner: DinnerDetail; children?: ReactNode }) { + return ( +
    + + + {children} +
    + ) +} + +// Detail is a dinner as mock 1k shows it: the photo full-bleed under a sheet +// with its tags, title, why it was picked and its recipe, over the action bar. +function Detail({ dinner }: { dinner: DinnerDetail }) { + const tags = [ + dinner.difficulty !== null && difficultyLabels[dinner.difficulty], + dinner.minutes_total !== null && duration(dinner.minutes_total), + ].filter((tag) => tag !== false) + return ( + <> +
    + + +
    +
    +
    + {dinner.status === 'regular' ? 'Regular' : 'Side quest'} + {tags.map((tag) => ( + + {tag} + + ))} +
    +

    {dinner.name}

    + {dinner.why_picked !== null && ( +

    + Why it was picked: {dinner.why_picked} +

    + )} + +
    + + + ) +} + +// statusName is the dinner as the household calls it: a Regular, or a Side +// quest for any dinner not yet one. +function statusName(dinner: DinnerDetail): string { + return dinner.status === 'regular' ? 'Regular' : 'Side quest' +} + +// PlannedDay is the kicker of a dinner on a plan, "Saturday, Oct 10 · Side +// quest", whenever this week's or next week's plan has it. Of the days that +// do, it is the first from today on in the household's timezone; when every +// one is already past, it is the latest of them. There is none while the +// plans are read, nor when neither plan has the dinner. +function PlannedDay({ dinner, timezone }: { dinner: DinnerDetail; timezone: string }) { + const today = localNow(timezone, new Date()).date + const thisWeek = weekStart(today) + const plans = [usePlan(thisWeek), usePlan(addDays(thisWeek, 7))] + const failed = plans.find((plan) => plan.isError) + if (failed !== undefined) { + return ( + plans.forEach((plan) => plan.isError && void plan.refetch())}> + Couldn't check the plan for this dinner's day. {failed.error.message} + + ) + } + const planned = plans.flatMap((plan) => plan.data?.days ?? []).filter((day) => day.dinner.id === dinner.id) + const day = planned.find((day) => day.date >= today) ?? planned.at(-1) + if (day === undefined) return null + return ( +

    + {dayAndDate(day.date)} · {statusName(dinner)} +

    + ) +} + +// Kicker is PlannedDay, once the household's timezone is read. +function Kicker({ dinner }: { dinner: DinnerDetail }) { + const settings = useHouseholdSettings() + if (settings.isError) { + return ( + void settings.refetch()}> + Couldn't load the household settings to find this dinner's day. {settings.error.message} + + ) + } + if (settings.isPending) return null + return +} + +const textSizes: SegmentedOption[] = [ + { value: 'small', label: 'A', ariaLabel: 'Small text' }, + { value: 'medium', label: 'A', ariaLabel: 'Medium text' }, + { value: 'large', label: 'A', ariaLabel: 'Large text' }, +] + +// ReadingHeader is Kitchen reading mode's header: back, the day the dinner +// is planned for, its title, Keep screen on and the text size. +function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: ReturnType }) { + const keepOnId = useId() + const keepOnHintId = useId() + const keepScreenOn = useKeepScreenOn() + const { failure } = textSize + return ( + <> +
    + +
    + +

    {dinner.name}

    +
    +
    + Keep screen on + +
    + +
    + {!keepScreenOn.supported && ( +

    + This browser can't keep the screen on, so it may dim while you cook. +

    + )} + {keepScreenOn.failure !== null && ( + keepScreenOn.setOn(true)}> + Couldn't keep the screen on: {keepScreenOn.failure} + + )} + {failure !== null && ( + + {textSizeFailureText[failure.action]}: {failure.message} + + )} + + ) +} + +// IngredientsCard is the ingredients with a checkbox each, struck through +// once set out, and how many are. Which are set out is this visit's alone: +// it is never sent to the server and is not the Supply run's "have it". +function IngredientsCard({ ingredients }: { ingredients: Ingredient[] }) { + const titleId = useId() + const [setOut, setSetOut] = useState>(() => new Set()) + const count = ingredients.filter((_, i) => setOut.has(i)).length + function toggle(i: number) { + setSetOut((before) => { + const after = new Set(before) + if (!after.delete(i)) after.add(i) + return after + }) + } + return ( + +
    +

    + Ingredients +

    + + {count} of {ingredients.length} set out + +
    +
      + {ingredients.map((ingredient, i) => ( +
    • + toggle(i)} + label={ + + {ingredient.name ?? ingredient.raw_text} + {ingredient.name !== null && ingredient.note !== null && `, ${ingredient.note}`} + + } + /> + {quantity(ingredient)} +
    • + ))} +
    +
    + ) +} + +// Steps is the recipe's steps: those before current done, muted with a +// check; current highlighted in larger text; the rest still to come. +function Steps({ steps, current }: { steps: string[]; current: number }) { + const titleId = useId() + return ( +
    +

    + Steps +

    +
      + {steps.map((step, i) => ( +
    1. + + {step} +
    2. + ))} +
    +
    + ) +} + +// typing says whether target is a field taking text, where the arrow keys +// move the caret and are not the screen's to take. +function typing(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + return target.isContentEditable || target.matches('textarea, select, input:not([type="checkbox"], [type="radio"])') +} + +// useCurrentStep is the step being cooked, of count, from the first, with +// move, which goes back or on by a step and stops at either end. The ← and → +// keys move it too, unless a text field has focus, a sheet is open over the +// screen or the key was already taken (a segmented control's arrows). +function useCurrentStep(count: number): [number, (by: number) => void] { + const [step, setStep] = useState(0) + const last = count - 1 + const move = useCallback( + (by: number) => setStep((before) => Math.max(0, Math.min(Math.min(before, last) + by, last))), + [last], + ) + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + const by = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0 + if (by === 0 || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return + if (typing(event.target) || (event.target instanceof Element && event.target.closest('[role="dialog"]') !== null)) return + event.preventDefault() + move(by) + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [move]) + return [Math.max(0, Math.min(step, last)), move] +} + +// KitchenReading is a dinner as mock 2a shows it on a tablet: the header, +// then the ingredients to check off beside the photo, why it was picked and +// the steps, one at a time, over the action bar with Previous and Next step. +// What is checked and the step reached last only as long as this visit. +function KitchenReading({ dinner }: { dinner: DinnerDetail }) { + const textSize = useTextSize() + const [current, move] = useCurrentStep(dinner.steps.length) + const recipe = hasRecipe(dinner) + const last = dinner.steps.length - 1 + return ( +
    + +
    0 ? styles.columns : styles.oneColumn}> + {recipe && dinner.ingredients.length > 0 && } +
    +
    + + {dinner.why_picked !== null && ( +

    + Why it was picked: {dinner.why_picked} +

    + )} +
    + {!recipe && } + {recipe && dinner.steps.length > 0 && } + {recipe && dinner.source !== null && ( + + )} + + {recipe && dinner.steps.length > 0 && ( +
    + + +
    + )} +
    +
    +
    +
    + ) +} + +// DinnerDetailPage is a dinner with its recipe: on a phone (mock 1k) its +// photo, or the striped placeholder, under a sheet with Regular or Side +// quest, difficulty and time, why it was picked, the ingredients and the +// steps; from tablet up, Kitchen reading mode (mock 2a). A dinner whose +// recipe could not be found links to Settings → Regular dinners to fix it; +// an unknown or removed dinner says it is not found. +export function DinnerDetailPage() { + const { id = '' } = useParams() + const layout = useBreakpoint() + const dinner = useDinnerDetail(id) + + let body + if (dinner.isError) { + body = ( + <> +
    + +
    + {dinner.error instanceof ApiError && dinner.error.status === 404 ? ( + <> + This dinner isn't in our kitchen — it may have been removed. + + Go Home + + + ) : ( + void dinner.refetch()}>Couldn't load this dinner. {dinner.error.message} + )} + + ) + } else if (dinner.isPending) { + body = ( +
    + +
    + + + +
    +
    + ) + } else { + // Keyed by the dinner, so moving to another dinner starts its visit + // afresh: nothing checked, on the first step. + body = + layout === 'mobile' ? : + } + return
    {body}
    +} diff --git a/web/src/dinners/KitchenReading.test.tsx b/web/src/dinners/KitchenReading.test.tsx new file mode 100644 index 0000000..e393ae6 --- /dev/null +++ b/web/src/dinners/KitchenReading.test.tsx @@ -0,0 +1,604 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' +import { declaredStyle } from '../test/cssRules.ts' +import { errorResponse, jsonResponse } from '../test/fetchStub.ts' +import { mockViewport } from '../test/matchMedia.ts' +import { DinnerDetailPage } from './DinnerDetailPage.tsx' +import { TEXT_SIZE_KEY } from './textSize.ts' + +const DINNER = '/api/v1/dinners/d1' +const SETTINGS = '/api/v1/settings/household' +const THIS_WEEK = '/api/v1/plans/2026-10-05' +const NEXT_WEEK = '/api/v1/plans/2026-10-12' + +// detail is the Korean beef bowls, a Quest-log Side quest, as GET +// /api/v1/dinners/{id} sends it. +function detail(overrides: Record = {}) { + return { + id: 'd1', + name: 'Korean beef bowls', + status: 'quest_log', + origin: 'suggested', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + cuisine: 'korean', + is_favorite: false, + photo_urls: null, + ingredients: [ + { name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }, + { name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' }, + { name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' }, + ], + steps: [ + 'Rinse the rice and start it cooking.', + 'Brown the beef with the garlic and ginger.', + 'Stir in the sauce and simmer until glossy.', + ], + source: null, + grounding_status: 'grounded', + grounding_error: null, + why_picked: 'close cousin of your Beef tacos.', + ...overrides, + } +} + +// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it: p0 to +// p6, except the bowls (d1) on each day of bowlsOn, counting from Monday = 0. +function planBody(weekStart: string, ...bowlsOn: number[]) { + const start = new Date(`${weekStart}T00:00:00Z`).getTime() + return { + id: `plan-${weekStart}`, + week_start: weekStart, + status: 'draft', + ai_note: null, + calendar_synced_at: null, + days: Array.from({ length: 7 }, (_, i) => ({ + date: new Date(start + i * 86_400_000).toISOString().slice(0, 10), + dinner: + bowlsOn.includes(i) + ? { id: 'd1', name: 'Korean beef bowls', photo: null, minutes: 30, method: 'stovetop' } + : { id: `p${i}`, name: `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop' }, + is_new: bowlsOn.includes(i), + start_by: null, + })), + } +} + +// serve answers the screen's reads: the dinner, the household settings and +// both weeks' plans, unless responses says otherwise. +function serve(responses: Record Response> = {}): Mock { + const all: Record Response> = { + [DINNER]: () => jsonResponse(200, detail()), + [SETTINGS]: () => jsonResponse(200, HOUSEHOLD_SETTINGS), + [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 5)), + [NEXT_WEEK]: () => errorResponse(404, 'not_found'), + ...responses, + } + const fetchMock = vi.fn((input, init) => { + const path = String(input) + const respond = Object.hasOwn(all, path) ? all[path] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`) + return Promise.resolve(respond()) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +function reads(fetchMock: Mock, path: string) { + return fetchMock.mock.calls.filter(([input]) => String(input) === path).length +} + +// renderPage renders the bowls' detail screen and waits for its title. +async function renderPage() { + const router = createMemoryRouter([{ path: '/dinner/:id', element: }], { + initialEntries: ['/dinner/d1'], + }) + const view = render( + + + , + ) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) + return view +} + +function steps() { + return within(screen.getByRole('region', { name: 'Steps' })).getAllByRole('listitem') +} + +// currentStep is the text of the step marked current. +function currentStep() { + const current = steps().filter((step) => step.getAttribute('aria-current') === 'step') + expect(current).toHaveLength(1) + return current[0]!.textContent +} + +// FakeSentinel is a WakeLockSentinel as far as the screen uses one. +type FakeSentinel = EventTarget & { released: boolean; release: Mock<() => Promise> } + +// letGo releases a lock as the browser does, telling its listeners so. +function letGo(sentinel: FakeSentinel) { + if (sentinel.released) return + sentinel.released = true + sentinel.dispatchEvent(new Event('release')) +} + +// stubWakeLock gives jsdom, which has none, a Screen Wake Lock. Each request +// is granted at once, or, given hold, only when the test calls grant. +function stubWakeLock({ hold = false } = {}) { + const sentinels: FakeSentinel[] = [] + const pending: (() => void)[] = [] + const request = vi.fn((type: string) => { + expect(type).toBe('screen') + const sentinel: FakeSentinel = Object.assign(new EventTarget(), { + released: false, + release: vi.fn(() => { + letGo(sentinel) + return Promise.resolve() + }), + }) + sentinels.push(sentinel) + if (!hold) return Promise.resolve(sentinel) + return new Promise((resolve) => pending.push(() => resolve(sentinel))) + }) + Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request } }) + return { request, sentinels, grant: () => pending.shift()!() } +} + +// setVisibility shows or hides the page as the browser would, telling the +// page so. +function setVisibility(state: DocumentVisibilityState) { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state }) + document.dispatchEvent(new Event('visibilitychange')) +} + +beforeEach(() => { + mockViewport(1180) + // Wednesday Oct 7, noon in New York. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-10-07T16:00:00Z')) +}) + +afterEach(() => { + vi.useRealTimers() + Reflect.deleteProperty(window, 'matchMedia') + Reflect.deleteProperty(navigator, 'wakeLock') + Reflect.deleteProperty(document, 'visibilityState') + vi.unstubAllGlobals() +}) + +describe('Kitchen reading mode', () => { + it('is the tablet layout of the same route: header with back, title, Keep screen on and text size', async () => { + stubWakeLock() + serve() + await renderPage() + + const title = screen.getByRole('heading', { level: 1, name: 'Korean beef bowls' }) + expect(declaredStyle(title).fontSize).toBe('36px') + expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument() + // Not the phone's sheet: no photo hero tags. + expect(screen.queryByText('Easy')).not.toBeInTheDocument() + + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + expect(keepOn).toHaveAttribute('aria-checked', 'false') + fireEvent.click(keepOn) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + + const sizes = screen.getByRole('radiogroup', { name: 'Text size' }) + expect(within(sizes).getAllByRole('radio').map((radio) => radio.getAttribute('aria-label'))).toEqual([ + 'Small text', + 'Medium text', + 'Large text', + ]) + expect(within(sizes).getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true') + + expect(declaredStyle(screen.getByRole('region', { name: 'Ingredients' }).parentElement!).gridTemplateColumns).toBe( + '400px minmax(0, 1fr)', + ) + expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent('Why it was picked: close cousin') + }) + + describe('kicker', () => { + it('names the day the plan has the dinner on, and Side quest', async () => { + serve() + await renderPage() + expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument() + }) + + it("names a day of next week's plan, and Regular, for a Regular", async () => { + serve({ + [DINNER]: () => jsonResponse(200, detail({ status: 'regular', origin: 'regular' })), + [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05')), + [NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12', 0)), + }) + await renderPage() + expect(await screen.findByText('Monday, Oct 12 · Regular')).toBeInTheDocument() + }) + + it("names a day of this week's plan already past when that is the only day", async () => { + // Today is Wednesday; the bowls were on Monday, and are on no day of next week. + serve({ + [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0)), + [NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12')), + }) + await renderPage() + expect(await screen.findByText('Monday, Oct 5 · Side quest')).toBeInTheDocument() + }) + + it('names the first day from today on over a day already past', async () => { + serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1, 4, 6)) }) + await renderPage() + expect(await screen.findByText('Friday, Oct 9 · Side quest')).toBeInTheDocument() + expect(screen.queryByText(/Monday|Tuesday|Sunday/)).not.toBeInTheDocument() + }) + + it('names the latest day already past when every day is past', async () => { + serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1)) }) + await renderPage() + expect(await screen.findByText('Tuesday, Oct 6 · Side quest')).toBeInTheDocument() + expect(screen.queryByText(/Monday/)).not.toBeInTheDocument() + }) + + it('is absent when neither week has a plan', async () => { + const fetchMock = serve({ [THIS_WEEK]: () => errorResponse(404, 'not_found') }) + await renderPage() + await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(1)) + await waitFor(() => expect(reads(fetchMock, NEXT_WEEK)).toBe(1)) + await act(async () => {}) + expect(screen.queryByText(/· Side quest/)).not.toBeInTheDocument() + expect(screen.queryByText(/Oct/)).not.toBeInTheDocument() + }) + + it('says so when the plan cannot be read, with Retry', async () => { + let failing = true + serve({ + [THIS_WEEK]: () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, planBody('2026-10-05', 5))), + }) + await renderPage() + expect(await screen.findByText(/Couldn't check the plan for this dinner's day/)).toBeInTheDocument() + + failing = false + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument() + }) + }) + + describe('ingredients', () => { + it('checks ingredients off with 22 px checkboxes, struck through, counting those set out', async () => { + const fetchMock = serve() + await renderPage() + const card = screen.getByRole('region', { name: 'Ingredients' }) + expect(within(card).getByText('0 of 3 set out')).toBeInTheDocument() + + const beef = within(card).getByRole('checkbox', { name: 'Ground beef' }) + expect(declaredStyle(beef).width).toBe('22px') + expect(declaredStyle(beef).height).toBe('22px') + expect(within(card).getByRole('checkbox', { name: 'Green onions, sliced' })).not.toBeChecked() + + fireEvent.click(beef) + expect(beef).toBeChecked() + expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('line-through') + fireEvent.click(within(card).getByRole('checkbox', { name: 'Jasmine rice' })) + expect(within(card).getByText('2 of 3 set out')).toBeInTheDocument() + + fireEvent.click(beef) + expect(beef).not.toBeChecked() + expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('') + expect(within(card).getByText('1 of 3 set out')).toBeInTheDocument() + + // Local to the screen: nothing is sent. + expect(fetchMock.mock.calls.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')).toEqual([]) + }) + + it('starts afresh on the next visit', async () => { + serve() + const view = await renderPage() + fireEvent.click(screen.getByRole('checkbox', { name: 'Ground beef' })) + fireEvent.click(screen.getByRole('button', { name: 'Next step' })) + view.unmount() + + await renderPage() + expect(screen.getByText('0 of 3 set out')).toBeInTheDocument() + expect(currentStep()).toBe('1Rinse the rice and start it cooking.') + }) + }) + + describe('steps', () => { + it('highlights the current step in 22 px text, others 18 px, and moves by Previous and Next step', async () => { + serve() + await renderPage() + const previous = screen.getByRole('button', { name: 'Previous' }) + const next = screen.getByRole('button', { name: 'Next step' }) + + expect(currentStep()).toBe('1Rinse the rice and start it cooking.') + expect(previous).toBeDisabled() + expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(22px * var(--reading-scale))') + expect(declaredStyle(steps()[0]!).background).toBe('var(--color-accent-100)') + expect(declaredStyle(steps()[0]!).boxShadow).toBe('inset 0 0 0 2px var(--color-accent-300)') + expect(declaredStyle(steps()[1]!).fontSize).toBe('calc(18px * var(--reading-scale))') + + fireEvent.click(next) + expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.') + // The step done is muted, its number a check. + expect(steps()[0]!.textContent).toBe('Rinse the rice and start it cooking.') + expect(steps()[0]!.querySelector('svg')).not.toBeNull() + expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-600)') + expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(18px * var(--reading-scale))') + + fireEvent.click(next) + expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.') + expect(next).toBeDisabled() + fireEvent.click(next) + expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.') + + fireEvent.click(previous) + expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.') + }) + + it('moves by the ← and → keys, stopping at either end', async () => { + serve() + await renderPage() + + fireEvent.keyDown(document.body, { key: 'ArrowRight' }) + expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.') + fireEvent.keyDown(document.body, { key: 'ArrowRight' }) + fireEvent.keyDown(document.body, { key: 'ArrowRight' }) + expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.') + fireEvent.keyDown(document.body, { key: 'ArrowLeft' }) + expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.') + fireEvent.keyDown(document.body, { key: 'ArrowLeft' }) + fireEvent.keyDown(document.body, { key: 'ArrowLeft' }) + expect(currentStep()).toBe('1Rinse the rice and start it cooking.') + + // From a checked ingredient too. + const beef = screen.getByRole('checkbox', { name: 'Ground beef' }) + beef.focus() + fireEvent.keyDown(beef, { key: 'ArrowRight' }) + expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.') + }) + + it('leaves the keys alone in a text field, with a modifier, or in the text size control', async () => { + serve() + await renderPage() + const field = document.createElement('input') + document.body.append(field) + field.focus() + fireEvent.keyDown(field, { key: 'ArrowRight' }) + expect(currentStep()).toBe('1Rinse the rice and start it cooking.') + field.remove() + + fireEvent.keyDown(document.body, { key: 'ArrowRight', altKey: true }) + expect(currentStep()).toBe('1Rinse the rice and start it cooking.') + + const medium = screen.getByRole('radio', { name: 'Medium text' }) + fireEvent.keyDown(medium, { key: 'ArrowRight' }) + expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true') + expect(currentStep()).toBe('1Rinse the rice and start it cooking.') + }) + }) + + describe('text size', () => { + it('is remembered on this device', async () => { + serve() + const view = await renderPage() + fireEvent.click(screen.getByRole('radio', { name: 'Large text' })) + expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('large') + expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true') + view.unmount() + + await renderPage() + expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'false') + expect(declaredStyle(screen.getByRole('heading', { level: 1 }).closest('header')!.parentElement!).getPropertyValue('--reading-scale')).toBe( + '1.25', + ) + }) + + it('says so when the size saved is not one MealQuest saved, until one is chosen', async () => { + localStorage.setItem(TEXT_SIZE_KEY, 'huge') + serve() + await renderPage() + expect(screen.getByText(/Could not read the text size chosen on this device/)).toHaveTextContent('"huge"') + expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true') + + fireEvent.click(screen.getByRole('radio', { name: 'Small text' })) + expect(screen.queryByText(/Could not read the text size/)).not.toBeInTheDocument() + expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('small') + }) + }) + + describe('source credit', () => { + it.each([ + ['a site and a url', { site_name: 'Serious Eats', url: 'https://www.seriouseats.com/bowls' }, 'Recipe from Serious Eats'], + ['only a url', { site_name: null, url: 'https://www.seriouseats.com/bowls' }, 'Recipe from www.seriouseats.com'], + ])('links the recipe page in a new tab for %s', async (_, source, name) => { + serve({ [DINNER]: () => jsonResponse(200, detail({ source })) }) + await renderPage() + const credit = screen.getByRole('link', { name }) + expect(credit).toHaveAttribute('href', 'https://www.seriouseats.com/bowls') + expect(credit).toHaveAttribute('target', '_blank') + expect(credit).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it.each([ + ['only a site', { site_name: 'Serious Eats', url: null }, 'Recipe from Serious Eats'], + ['neither site nor url', { site_name: null, url: null }, 'Recipe from the web'], + ])('is plain text for %s', async (_, source, text) => { + serve({ [DINNER]: () => jsonResponse(200, detail({ source })) }) + await renderPage() + expect(screen.getByText(text).closest('a')).toBeNull() + expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument() + }) + + it('is absent for a recipe typed in', async () => { + serve({ [DINNER]: () => jsonResponse(200, detail({ source: null, grounding_status: 'manual' })) }) + await renderPage() + expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument() + }) + }) + + it('shows what stands in for a recipe not found, with no step buttons', async () => { + serve({ + [DINNER]: () => + jsonResponse(200, detail({ ingredients: [], steps: [], grounding_status: 'failed', grounding_error: 'no_recipe_found' })), + }) + await renderPage() + expect(screen.getByRole('link', { name: 'Fix recipe' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Ingredients' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Next step' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add to this week' })).toBeInTheDocument() + }) + + describe('Keep screen on', () => { + it('holds a screen wake lock while on and releases it when turned off', async () => { + const wakeLock = stubWakeLock() + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + expect(keepOn).toBeEnabled() + expect(wakeLock.request).not.toHaveBeenCalled() + + fireEvent.click(keepOn) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1)) + expect(wakeLock.sentinels[0]!.release).not.toHaveBeenCalled() + + fireEvent.click(keepOn) + expect(keepOn).toHaveAttribute('aria-checked', 'false') + expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1) + + // On again: a fresh lock. + fireEvent.click(keepOn) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2)) + expect(wakeLock.sentinels[1]!.released).toBe(false) + }) + + it('releases the lock when the screen is left', async () => { + const wakeLock = stubWakeLock() + serve() + const view = await renderPage() + fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' })) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1)) + + view.unmount() + expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1) + }) + + it('releases a lock granted only after the screen was left', async () => { + const wakeLock = stubWakeLock({ hold: true }) + serve() + const view = await renderPage() + fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' })) + expect(wakeLock.request).toHaveBeenCalledTimes(1) + + view.unmount() + await act(async () => wakeLock.grant()) + expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1) + }) + + it('asks for the lock again when the page is visible again while on, and not while off', async () => { + const wakeLock = stubWakeLock() + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + fireEvent.click(keepOn) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1)) + + // Hidden, the browser lets the lock go; nothing is asked while hidden. + act(() => setVisibility('hidden')) + act(() => letGo(wakeLock.sentinels[0]!)) + expect(wakeLock.request).toHaveBeenCalledTimes(1) + + act(() => setVisibility('visible')) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2)) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + + // Visible again with the lock still held: no second lock. + act(() => setVisibility('visible')) + expect(wakeLock.request).toHaveBeenCalledTimes(2) + + fireEvent.click(keepOn) + act(() => setVisibility('hidden')) + act(() => setVisibility('visible')) + expect(wakeLock.request).toHaveBeenCalledTimes(2) + }) + + it('asks for the lock again when the browser lets it go while the page is visible', async () => { + const wakeLock = stubWakeLock() + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + fireEvent.click(keepOn) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1)) + + act(() => letGo(wakeLock.sentinels[0]!)) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2)) + await act(async () => {}) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + + // The fresh lock is watched too. + act(() => letGo(wakeLock.sentinels[1]!)) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(3)) + expect(wakeLock.sentinels[2]!.released).toBe(false) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + }) + + it('asks for the lock again when the page was hidden and shown while a request was pending', async () => { + const wakeLock = stubWakeLock({ hold: true }) + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + fireEvent.click(keepOn) + expect(wakeLock.request).toHaveBeenCalledTimes(1) + + // Hidden and shown again before the first lock is granted, which the + // browser has let go by the time it is. + act(() => setVisibility('hidden')) + act(() => setVisibility('visible')) + expect(wakeLock.request).toHaveBeenCalledTimes(1) + letGo(wakeLock.sentinels[0]!) + await act(async () => wakeLock.grant()) + expect(wakeLock.request).toHaveBeenCalledTimes(2) + + await act(async () => wakeLock.grant()) + expect(wakeLock.sentinels[1]!.released).toBe(false) + expect(wakeLock.request).toHaveBeenCalledTimes(2) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + }) + + it('reports a lock the browser refuses and turns the switch back off', async () => { + const wakeLock = stubWakeLock() + wakeLock.request.mockRejectedValueOnce(new DOMException('Blocked by permissions policy', 'NotAllowedError')) + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + fireEvent.click(keepOn) + + expect(await screen.findByRole('alert')).toHaveTextContent( + "Couldn't keep the screen on: NotAllowedError: Blocked by permissions policy", + ) + expect(keepOn).toHaveAttribute('aria-checked', 'false') + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2)) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('is disabled, and says why, in a browser that cannot keep the screen on', async () => { + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + expect(keepOn).toBeDisabled() + expect(keepOn).toHaveAttribute('aria-checked', 'false') + expect(keepOn).toHaveAccessibleDescription( + "This browser can't keep the screen on, so it may dim while you cook.", + ) + fireEvent.click(keepOn) + expect(keepOn).toHaveAttribute('aria-checked', 'false') + }) + }) +}) diff --git a/web/src/dinners/api.ts b/web/src/dinners/api.ts index 06e040c..2c0c081 100644 --- a/web/src/dinners/api.ts +++ b/web/src/dinners/api.ts @@ -1,6 +1,7 @@ import { useIsMutating, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import { useApi } from '../api/index.ts' import { isRecord } from '../api/errors.ts' +import type { DinnerDetail } from './detail.ts' export const DINNERS_PATH = '/api/v1/dinners' export const REGULARS_PATH = `${DINNERS_PATH}?status=regular` @@ -12,7 +13,7 @@ export const GROUNDING_POLL_MS = 3000 // Methods and Difficulties are the values the dinners API allows. export const METHODS = ['stovetop', 'oven', 'crockpot', 'grill', 'other'] as const export const DIFFICULTIES = ['easy', 'medium', 'hard'] as const -const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const +export const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const export type Method = (typeof METHODS)[number] export type Difficulty = (typeof DIFFICULTIES)[number] @@ -146,6 +147,11 @@ export function dinnerPath(id: string): string { return `${DINNERS_PATH}/${encodeURIComponent(id)}` } +// dinnerDetailKey is the query of dinner id with its recipe (useDinnerDetail). +export function dinnerDetailKey(id: string) { + return ['dinners', 'detail', id] as const +} + // useRegulars is the household's Regulars, in the server's order. While any // of them is still finding its recipe it is read again every // GROUNDING_POLL_MS, except while any change is in flight (whose settling @@ -275,28 +281,43 @@ export function useEditRegular() { }) } -// useToggleFavorite toggles a Regular's favorite heart (POST -// /api/v1/dinners/{id}/favorite). The heart changes at once and changes -// back if the server refuses. +// Favorite is the dinner whose heart is toggled, as it was before the toggle. +export type Favorite = { id: string; is_favorite: boolean } + +// setFavorite shows dinner id's heart as is_favorite wherever it is cached: +// in the Regulars and in its detail. A cache not yet read is left unread. +function setFavorite(queryClient: QueryClient, id: string, is_favorite: boolean) { + queryClient.setQueryData(REGULARS_LIST_KEY, (list) => + list?.map((r) => (r.id === id ? { ...r, is_favorite } : r)), + ) + queryClient.setQueryData(dinnerDetailKey(id), (dinner) => dinner && { ...dinner, is_favorite }) +} + +// useToggleFavorite toggles a dinner's favorite heart (POST +// /api/v1/dinners/{id}/favorite), a Regular's or any dinner's on its detail +// screen. The heart changes at once and changes back if the server refuses. +// The server answers with the dinner as the dinners list sends it, which +// replaces the Regular; the detail keeps its recipe and takes only the heart. export function useToggleFavorite() { const { apiFetch } = useApi() const queryClient = useQueryClient() - const set = (id: string, is_favorite: boolean) => - setRegulars(queryClient, (list) => list.map((r) => (r.id === id ? { ...r, is_favorite } : r))) return useMutation({ mutationKey: REGULARS_MUTATION_KEY, - mutationFn: async (regular: Regular) => + mutationFn: async (dinner: Favorite) => parseDinner( `POST ${DINNERS_PATH}/{id}/favorite`, - await apiFetch(`${dinnerPath(regular.id)}/favorite`, { method: 'POST' }), + await apiFetch(`${dinnerPath(dinner.id)}/favorite`, { method: 'POST' }), ), - onMutate: async (regular) => { - await changeOptimistically(queryClient, (list) => - list.map((r) => (r.id === regular.id ? { ...r, is_favorite: !regular.is_favorite } : r)), - ) + onMutate: async (dinner) => { + await queryClient.cancelQueries({ queryKey: dinnerDetailKey(dinner.id) }) + await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY }) + setFavorite(queryClient, dinner.id, !dinner.is_favorite) }, - onSuccess: (dinner) => replace(queryClient, dinner), - onError: (_error, regular) => set(regular.id, regular.is_favorite), + onSuccess: (dinner) => { + queryClient.setQueryData(REGULARS_LIST_KEY, (list) => list?.map((r) => (r.id === dinner.id ? dinner : r))) + queryClient.setQueryData(dinnerDetailKey(dinner.id), (detail) => detail && { ...detail, is_favorite: dinner.is_favorite }) + }, + onError: (_error, dinner) => setFavorite(queryClient, dinner.id, dinner.is_favorite), onSettled: () => settle(queryClient), }) } diff --git a/web/src/dinners/detail.ts b/web/src/dinners/detail.ts new file mode 100644 index 0000000..e5386b2 --- /dev/null +++ b/web/src/dinners/detail.ts @@ -0,0 +1,178 @@ +import { useQuery } from '@tanstack/react-query' +import { useEffect, useSyncExternalStore } from 'react' +import { useApi } from '../api/index.ts' +import { isRecord } from '../api/errors.ts' +import { + DIFFICULTIES, + dinnerDetailKey, + dinnerPath, + DINNERS_PATH, + GROUNDING_POLL_MS, + GROUNDING_STATUSES, + METHODS, + oneOf, + photoId, + type Difficulty, + type GroundingStatus, + type Method, +} from './api.ts' + +// The statuses a dinner with detail can have; an archived dinner is 404. +const STATUSES = ['suggested', 'quest_log', 'tried', 'regular'] as const + +export type DinnerStatus = (typeof STATUSES)[number] + +// Ingredient is one recipe line. raw_text is the line as the recipe gives +// it; name, qty, unit and note are the server's reading of it, each null +// when it has none — always name, qty and unit for a recipe typed in. +export type Ingredient = { + name: string | null + qty: string | null + unit: string | null + note: string | null + raw_text: string +} + +// RecipeSource is where a fetched recipe came from. siteName is the site, +// or null when there is none to name: a page that named no site, or a name +// the server withheld because it could have come from the link. url is the +// recipe's page, or null when the server withheld the stored link because +// it could carry a token; the credit then never names its host. +export type RecipeSource = { siteName: string | null; url: string | null } + +// DinnerDetail is a dinner with its recipe (GET /api/v1/dinners/{id}). +// source is null for a recipe typed in. cached says the service worker +// answered from its saved copy because the server could not be reached. +export type DinnerDetail = { + id: string + name: string + status: DinnerStatus + method: Method | null + minutes_total: number | null + difficulty: Difficulty | null + is_favorite: boolean + photo_id: string | undefined + ingredients: Ingredient[] + steps: string[] + source: RecipeSource | null + grounding_status: GroundingStatus + why_picked: string | null + cached: boolean +} + +function text(where: string, field: string, value: unknown): string { + if (typeof value !== 'string') throw new Error(`${where}: ${field} is not a string`) + return value +} + +function nullableText(where: string, field: string, value: unknown): string | null { + return value === null ? null : text(where, field, value) +} + +function parseIngredient(where: string, body: unknown): Ingredient { + if (!isRecord(body)) throw new Error(`${where}: ingredient is not an object`) + return { + name: nullableText(where, 'ingredient name', body.name), + qty: nullableText(where, 'ingredient qty', body.qty), + unit: nullableText(where, 'ingredient unit', body.unit), + note: nullableText(where, 'ingredient note', body.note), + raw_text: text(where, 'ingredient raw_text', body.raw_text), + } +} + +// detailPhotoId is the photo id of photo_urls, which the server sends as +// both cached sizes of one photo, or null. +function detailPhotoId(where: string, value: unknown): string | undefined { + if (value === null) return undefined + if (!isRecord(value)) throw new Error(`${where}: photo_urls is not an object`) + const id = photoId(where, value['400']) + if (id === undefined || value['1200'] !== `/media/photos/${encodeURIComponent(id)}-1200.jpg`) { + throw new Error(`${where}: photo_urls is not both sizes of one stored photo`) + } + return id +} + +// sourceUrl is source.url, null when the server withholds it, or a link safe +// to open: https, with no user, password, query or fragment. +function sourceUrl(where: string, value: unknown): string | null { + if (value === undefined || value === null) return null + const href = text(where, 'source url', value) + let link: URL + try { + link = new URL(href) + } catch (error) { + throw new Error(`${where}: source url is not a URL`, { cause: error }) + } + if (link.protocol !== 'https:' || link.username !== '' || link.password !== '' || link.search !== '' || link.hash !== '') { + throw new Error(`${where}: source url is not an https link without credentials, query or fragment`) + } + return link.href +} + +function parseSource(where: string, value: unknown): RecipeSource | null { + if (value === null) return null + if (!isRecord(value)) throw new Error(`${where}: source is not an object`) + return { siteName: nullableText(where, 'source site_name', value.site_name), url: sourceUrl(where, value.url) } +} + +// parseDinnerDetail checks a dinner with its recipe as the API sends it; +// cached says whether the service worker answered from its saved copy. +export function parseDinnerDetail(where: string, body: unknown, cached: boolean): DinnerDetail { + if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') { + throw new Error(`${where}: response is not a dinner`) + } + if (typeof body.is_favorite !== 'boolean') throw new Error(`${where}: is_favorite is not a boolean`) + if (!Array.isArray(body.ingredients) || !Array.isArray(body.steps)) throw new Error(`${where}: response has no recipe lists`) + const minutes = body.minutes_total + if (minutes !== null && (typeof minutes !== 'number' || !Number.isInteger(minutes))) { + throw new Error(`${where}: minutes_total is not a whole number`) + } + return { + id: body.id, + name: body.name, + status: oneOf(where, 'status', STATUSES, body.status), + method: body.method === null ? null : oneOf(where, 'method', METHODS, body.method), + minutes_total: minutes, + difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty), + is_favorite: body.is_favorite, + photo_id: detailPhotoId(where, body.photo_urls), + ingredients: body.ingredients.map((ingredient) => parseIngredient(where, ingredient)), + steps: body.steps.map((step) => text(where, 'step', step)), + source: parseSource(where, body.source), + grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status), + why_picked: nullableText(where, 'why_picked', body.why_picked), + cached, + } +} + +// useDinnerDetail is dinner id with its recipe (GET /api/v1/dinners/{id}). +// While the dinner is still finding its recipe it is read again every +// GROUNDING_POLL_MS, but only while the server can be reached: polling pauses +// while the browser is offline or the last read was the service worker's +// saved copy, which would only answer the same again, and a saved pending +// copy is read once more when the connection comes back. Polling stops on +// unmount. An unknown or archived dinner fails with the API's 404. It runs +// even while the browser reports offline, so the copy the service worker +// saved is shown, or the read fails, instead of waiting for the network. +export function useDinnerDetail(id: string) { + const { apiRead, network } = useApi() + const offline = useSyncExternalStore(network.subscribe, network.offline) + const query = useQuery({ + queryKey: dinnerDetailKey(id), + queryFn: async () => { + const read = await apiRead(dinnerPath(id)) + return parseDinnerDetail(`GET ${DINNERS_PATH}/{id}`, read.body, read.cached) + }, + networkMode: 'always', + refetchInterval: (query) => { + const dinner = query.state.data + return dinner?.grounding_status === 'pending' && !dinner.cached && !offline ? GROUNDING_POLL_MS : false + }, + }) + const { data, refetch } = query + const stalled = data?.grounding_status === 'pending' && data.cached + useEffect(() => { + if (stalled && !offline) void refetch() + }, [stalled, offline, refetch]) + return query +} diff --git a/web/src/dinners/keepScreenOn.ts b/web/src/dinners/keepScreenOn.ts new file mode 100644 index 0000000..63ed9a3 --- /dev/null +++ b/web/src/dinners/keepScreenOn.ts @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react' + +// KeepScreenOn is Kitchen reading mode's Keep screen on switch: whether it is +// on, setOn, whether this browser can keep the screen on at all, and why the +// screen could not be kept on, if it could not. +export type KeepScreenOn = { + on: boolean + setOn: (on: boolean) => void + supported: boolean + failure: string | null +} + +// useKeepScreenOn holds a Screen Wake Lock while the switch is on, and only +// then. It starts off on each visit. The browser lets the lock go whenever +// the page is hidden, so it is asked for again each time the page is visible +// while the switch is on. Turning the switch off or leaving the screen +// releases it, including a lock still being granted as the screen is left. +// +// A browser without the Screen Wake Lock API (or a page not served over +// HTTPS) cannot keep the screen on: supported is false and the switch must +// not be offered as working. A request the browser refuses turns the switch +// back off and is reported as failure; turning it on again asks again. +export function useKeepScreenOn(): KeepScreenOn { + const [on, setOnState] = useState(false) + const [failure, setFailure] = useState(null) + const supported = 'wakeLock' in navigator + + useEffect(() => { + if (!on) return + const wakeLock = navigator.wakeLock + let sentinel: WakeLockSentinel | null = null + let requesting = false + let left = false + + // The browser may let a lock go while the switch is on: always when the + // page is hidden, and, the specification allows, while it is still + // visible. Either way the lock is asked for again at once if the page is + // visible, or when it is visible again if not, so the switch never claims + // a lock that is not held. + function onRelease() { + if (!left) acquire() + } + + function acquire() { + if (document.visibilityState !== 'visible' || requesting) return + if (sentinel !== null && !sentinel.released) return + requesting = true + wakeLock.request('screen').then( + (granted) => { + requesting = false + if (left) { + void granted.release() + return + } + sentinel = granted + granted.addEventListener('release', onRelease) + // While the request was pending the page may have been hidden and + // shown again (that visibilitychange was ignored) and the lock let + // go before it could be listened for, so check again now. + acquire() + }, + (err: unknown) => { + requesting = false + if (left) return + setFailure(String(err)) + setOnState(false) + }, + ) + } + + acquire() + document.addEventListener('visibilitychange', acquire) + return () => { + left = true + document.removeEventListener('visibilitychange', acquire) + if (sentinel !== null) void sentinel.release() + } + }, [on]) + + function setOn(next: boolean) { + if (next) setFailure(null) + setOnState(next) + } + + return { on, setOn, supported, failure } +} diff --git a/web/src/dinners/testdata/source_url_cases.json b/web/src/dinners/testdata/source_url_cases.json new file mode 100644 index 0000000..4d64542 --- /dev/null +++ b/web/src/dinners/testdata/source_url_cases.json @@ -0,0 +1,52 @@ +{ + "cases": [ + { + "stored": "https://kitchen.example/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": "https://kitchen.example/recipes/chicken-curry" } + }, + { + "stored": "https://Kitchen-2.Example.com.:8443/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": "https://Kitchen-2.Example.com.:8443/recipes/chicken-curry" } + }, + { + "stored": "https://[2001:db8::1]/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": "https://[2001:db8::1]/recipes/chicken-curry" } + }, + { + "stored": "https://192.0.2.7/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": "https://192.0.2.7/recipes/chicken-curry" } + }, + { + "stored": "https://[bad]/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "https://[fe80::1%25en0]/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "https://192.0.2.999/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "https://xn--a.example/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "https://kitchen.example/recipes/chicken-curry?access_token=query-token", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "https://user-token@kitchen.example/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "http://kitchen.example/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "https://kitchen.example/recipes/chicken-curry#token=fragment-token", + "source": { "site_name": "Curry House", "url": null } + } + ] +} diff --git a/web/src/dinners/textSize.ts b/web/src/dinners/textSize.ts new file mode 100644 index 0000000..0875785 --- /dev/null +++ b/web/src/dinners/textSize.ts @@ -0,0 +1,63 @@ +import { useState } from 'react' + +// TEXT_SIZE_KEY is the localStorage key remembering, on this device, the text +// size chosen in Kitchen reading mode. +export const TEXT_SIZE_KEY = 'mealquest.readingTextSize' + +export const TEXT_SIZES = ['small', 'medium', 'large'] as const + +export type TextSize = (typeof TEXT_SIZES)[number] + +// DEFAULT_TEXT_SIZE is the size of a device that has never chosen one: the +// handoff's 18 px ingredients and steps. +export const DEFAULT_TEXT_SIZE: TextSize = 'medium' + +// TextSizeState is the text size, and the storage failure, if any, that +// stopped us reading or saving it. +type TextSizeState = { + size: TextSize + failure: { action: 'read' | 'write'; message: string } | null +} + +function isTextSize(value: string): value is TextSize { + return (TEXT_SIZES as readonly string[]).includes(value) +} + +// Storage can be unavailable (blocked by the browser or privacy settings), in +// which case using it throws, and can hold a value MealQuest did not save. +// Either is reported, never treated as a remembered size. +function readTextSize(): TextSizeState { + try { + const saved = window.localStorage.getItem(TEXT_SIZE_KEY) + if (saved === null) return { size: DEFAULT_TEXT_SIZE, failure: null } + if (!isTextSize(saved)) throw new Error(`"${saved}" is not a text size MealQuest saved`) + return { size: saved, failure: null } + } catch (err) { + return { size: DEFAULT_TEXT_SIZE, failure: { action: 'read', message: String(err) } } + } +} + +function writeTextSize(size: TextSize): TextSizeState { + try { + window.localStorage.setItem(TEXT_SIZE_KEY, size) + return { size, failure: null } + } catch (err) { + return { size, failure: { action: 'write', message: String(err) } } + } +} + +export const textSizeFailureText = { + read: 'Could not read the text size chosen on this device', + write: 'Could not remember the text size on this device', +} + +// useTextSize is Kitchen reading mode's text size on this device: the size, +// the storage failure if any, choose, which saves a size, and retry, which +// reads or saves again whatever failed. A size that could not be saved is +// still used until the screen is left. +export function useTextSize() { + const [state, setState] = useState(readTextSize) + const choose = (size: TextSize) => setState(writeTextSize(size)) + const retry = () => setState(state.failure?.action === 'write' ? writeTextSize(state.size) : readTextSize()) + return { ...state, choose, retry } +} diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 87124ba..f6828b8 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -23,9 +23,8 @@ import { parseDraftResult, planKey, usePlan, - usePushToCalendar, useStartDraft, - useSwapDay, + useWeekSwap, type DraftResult, type Plan, type PlanDay, @@ -266,8 +265,7 @@ function WeekBoard({ const [jobId, setJobId] = useState(null) const job = useJob(DRAFT_JOB_KIND, jobId) const [confirming, setConfirming] = useState(false) - const swap = useSwapDay(weekStart) - const push = usePushToCalendar(weekStart) + const { swap, push, refused, swapIn: sendSwap } = useWeekSwap(weekStart) const run = useSupplyRun(weekStart) // Makes the week's Supply run again after a push could not make it. const generate = useGenerateSupplyRun(weekStart) @@ -431,25 +429,18 @@ function WeekBoard({ void run.refetch()}>Couldn't load this week's shopping list. {run.error.message} ) - // swapIn sends the swap chosen in the sheet and closes it. + // swapIn sends the swap chosen in the sheet, dropped on a day or retried + // (useWeekSwap) and closes the sheet. The Supply run made again on the + // Plan after a push is left behind with the push. function swapIn(chosen: Swap) { setSwappingDate(null) - push.reset() generate.reset() - swap.mutate(chosen) + sendSwap(chosen) } - // dropIn swaps in a dinner dragged onto a day. A drag is not picked up, - // nor dropped, while a swap of the week is pending. - function dropIn(chosen: Swap) { - swap.dismiss() - swapIn(chosen) - } - // openSwap opens the swap sheet on a day. One swap of the week is sent at - // a time, even across leaving the week and coming back: the plan the - // server answers it with must not overwrite another still pending. Nor is - // one sent while the week is being pushed to Calendar. + // openSwap opens the swap sheet on a day, unless a swap is refused now + // (useWeekSwap). A drag is not picked up, nor dropped, then either. function openSwap(date: string) { - if (swap.pending || pushing) return + if (refused) return swap.dismiss() setSwappingDate(date) } @@ -488,7 +479,7 @@ function WeekBoard({ body = layout !== 'mobile' ? ( <> - +
      {days.map((day) => ( void dismiss: () => void @@ -420,6 +422,7 @@ export function useSwapDay(weekStart: string): SwapDay { return { pending: swaps.some((swap) => swap.status === 'pending'), + succeeded: latest?.status === 'success' ? latest.swap : null, failed: latest?.status === 'error' ? { swap: latest.swap, error: latest.error! } : null, mutate, dismiss, @@ -531,6 +534,37 @@ export function usePushToCalendar(weekStart: string): PushToCalendar { } } +// WeekSwap is the one way a day of a week is swapped, wherever the swap is +// chosen: the week's swaps and pushes, whether a swap is refused now, and +// swapIn to send one. +export type WeekSwap = { + swap: SwapDay + push: PushToCalendar + refused: boolean + swapIn: (chosen: Swap) => void +} + +// useWeekSwap swaps a day of the week starting on weekStart, from the Draft +// board or a dinner's Add to this week. One swap of the week is sent at a +// time, even across leaving the week and coming back: the plan the server +// answers it with must not overwrite another still pending. Nor is one sent +// while the week is being pushed to Calendar: refused says so, and swapIn +// then throws. A swap leaves the week's settled swaps behind, and its +// pushes: what a push said of Calendar and the Supply run was said of the +// plan before the swap, not the one swapped. +export function useWeekSwap(weekStart: string): WeekSwap { + const swap = useSwapDay(weekStart) + const push = usePushToCalendar(weekStart) + const refused = swap.pending || push.pending + function swapIn(chosen: Swap) { + if (refused) throw new Error(`The week of ${weekStart} has a swap or push pending, so ${chosen.date} cannot be swapped.`) + swap.dismiss() + push.reset() + swap.mutate(chosen) + } + return { swap, push, refused, swapIn } +} + // settleUnmadePushes is called as the Supply run of the week starting on // weekStart starts being made, from the Plan or the Supply run screen // (useGenerateSupplyRun). The function it answers with is called once the diff --git a/web/src/plan/week.test.ts b/web/src/plan/week.test.ts index f56af4e..d3ad06d 100644 --- a/web/src/plan/week.test.ts +++ b/web/src/plan/week.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { addDays, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts' +import { addDays, dayAndDate, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts' // SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York. const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' } @@ -36,6 +36,11 @@ describe('week helpers', () => { expect(weekdayName('2026-10-11')).toBe('Sunday') }) + it('names the day of a date in full with its month and day', () => { + expect(dayAndDate('2026-10-10')).toBe('Saturday, Oct 10') + expect(dayAndDate('2026-11-01')).toBe('Sunday, Nov 1') + }) + it('reads the day of the month of a date', () => { expect(dayOfMonth('2026-10-05')).toBe(5) expect(dayOfMonth('2026-10-31')).toBe(31) diff --git a/web/src/plan/week.ts b/web/src/plan/week.ts index 41c2552..b2c82d0 100644 --- a/web/src/plan/week.ts +++ b/web/src/plan/week.ts @@ -52,6 +52,13 @@ export function weekdayName(date: string): string { return weekday.format(midnightUTC(date)) } +const longDay = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'short', day: 'numeric', timeZone: 'UTC' }) + +// dayAndDate is date's day in full with its month and day: "Saturday, Oct 10". +export function dayAndDate(date: string): string { + return longDay.format(midnightUTC(date)) +} + // monday reads start as midnight UTC, or throws when it is not a Monday. function monday(start: string): Date { const t = midnightUTC(start) diff --git a/web/src/sw/apiStrategy.test.ts b/web/src/sw/apiStrategy.test.ts index 46a7f5e..0aaab31 100644 --- a/web/src/sw/apiStrategy.test.ts +++ b/web/src/sw/apiStrategy.test.ts @@ -1,51 +1,16 @@ // Runs the real Workbox network-first strategy against an in-memory cache // storage: jsdom has none, and the worker itself needs service worker globals. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { RouteHandlerCallbackOptions } from 'workbox-core/types' import { createApiClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' import { fakeLocation, jsonResponse } from '../test/fetchStub.ts' -import { createApiStrategy } from './apiStrategy' -import { cacheFallbackHeader, cacheNames, routeFor } from './routes' - -const origin = 'https://mealquest.test' - -// FakeCache keys entries by URL, which is all the API cache needs. -class FakeCache { - readonly entries = new Map() - async match(request: RequestInfo | URL): Promise { - return this.entries.get(new Request(request).url)?.clone() - } - async put(request: RequestInfo | URL, response: Response): Promise { - this.entries.set(new Request(request).url, response) - } -} - -// FakeExtendableEvent stands in for the worker-only ExtendableEvent, -// collecting the promises the strategy waits on. -class FakeExtendableEvent extends Event { - readonly pending: Promise[] = [] - waitUntil(promise: Promise) { - this.pending.push(promise) - } -} +import { answer, origin, stubFetchThroughWorker, stubWorkerCaches, type FakeCache } from '../test/serviceWorker.ts' +import { cacheFallbackHeader } from './routes' let cache: FakeCache beforeEach(() => { - cache = new FakeCache() - // Workbox checks its arguments against these worker-only globals. - vi.stubGlobal('FetchEvent', class FetchEvent {}) - vi.stubGlobal('ExtendableEvent', FakeExtendableEvent) - const only = (name: string | undefined) => { - if (name !== cacheNames.api) throw new Error(`unexpected cache ${name}`) - return cache - } - vi.stubGlobal('caches', { - open: async (name: string) => only(name), - match: async (request: RequestInfo | URL, options?: MultiCacheQueryOptions) => - only(options?.cacheName).match(request), - }) + cache = stubWorkerCaches() }) afterEach(() => { @@ -58,17 +23,6 @@ async function handle(path: string): Promise { return answer(new Request(new URL(path, origin))) } -async function answer(request: Request): Promise { - expect(routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)).toEqual({ - strategy: 'network-first', - cacheName: cacheNames.api, - }) - const event = new FakeExtendableEvent('fetch') - const response = await createApiStrategy().handle({ request, event: event as unknown as RouteHandlerCallbackOptions['event'] }) - await Promise.all(event.pending) - return response -} - function recipe(): Response { return new Response(JSON.stringify({ id: '01J9ZR', name: 'Chicken tikka masala' }), { status: 200, @@ -107,22 +61,13 @@ describe('previously viewed plan and recipe reads', () => { const server: Record = { '/api/v1/plans/2026-W40': plan, '/api/v1/recipes/01J9ZR': recipe } let reachable: boolean - // The page's fetch goes through the worker's API strategy; the strategy's - // own fetch (always a Request) is the network. The API client fetches a - // path string, so the two are told apart by the argument's type. As in a - // browser, a worker that cannot answer is a network error to the page. beforeEach(() => { reachable = true vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable) - vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { - if (!(input instanceof Request)) { - return answer(new Request(new URL(String(input), origin), init)).catch((error: unknown) => { - throw new TypeError('Failed to fetch', { cause: error }) - }) - } + stubFetchThroughWorker(async (request) => { if (!reachable) throw new TypeError('Failed to fetch') - const body = server[new URL(input.url).pathname] - if (body === undefined) throw new Error(`unexpected network fetch: ${input.url}`) + const body = server[new URL(request.url).pathname] + if (body === undefined) throw new Error(`unexpected network fetch: ${request.url}`) return jsonResponse(200, body) }) }) diff --git a/web/src/test/serviceWorker.ts b/web/src/test/serviceWorker.ts new file mode 100644 index 0000000..606a12a --- /dev/null +++ b/web/src/test/serviceWorker.ts @@ -0,0 +1,79 @@ +// Runs requests through the service worker's real API strategy against an +// in-memory cache storage: jsdom has neither a service worker nor caches. +import { expect, vi } from 'vitest' +import type { RouteHandlerCallbackOptions } from 'workbox-core/types' +import { createApiStrategy } from '../sw/apiStrategy.ts' +import { cacheNames, routeFor } from '../sw/routes.ts' + +export const origin = 'https://mealquest.test' + +// FakeCache keys entries by URL, which is all the API cache needs. +export class FakeCache { + readonly entries = new Map() + async match(request: RequestInfo | URL): Promise { + return this.entries.get(new Request(request).url)?.clone() + } + async put(request: RequestInfo | URL, response: Response): Promise { + this.entries.set(new Request(request).url, response) + } +} + +// FakeExtendableEvent stands in for the worker-only ExtendableEvent, +// collecting the promises the strategy waits on. +class FakeExtendableEvent extends Event { + readonly pending: Promise[] = [] + waitUntil(promise: Promise) { + this.pending.push(promise) + } +} + +// stubWorkerCaches stubs the worker globals Workbox checks and a cache +// storage holding only the API cache, which it returns. Undo with +// vi.unstubAllGlobals. +export function stubWorkerCaches(): FakeCache { + const cache = new FakeCache() + vi.stubGlobal('FetchEvent', class FetchEvent {}) + vi.stubGlobal('ExtendableEvent', FakeExtendableEvent) + const only = (name: string | undefined) => { + if (name !== cacheNames.api) throw new Error(`unexpected cache ${name}`) + return cache + } + vi.stubGlobal('caches', { + open: async (name: string) => only(name), + match: async (request: RequestInfo | URL, options?: MultiCacheQueryOptions) => + only(options?.cacheName).match(request), + }) + return cache +} + +// answer sends a GET through the API strategy, as src/sw.ts does for a +// network-first route; the strategy's own fetch is the network. +export async function answer(request: Request): Promise { + expect(routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)).toEqual({ + strategy: 'network-first', + cacheName: cacheNames.api, + }) + const event = new FakeExtendableEvent('fetch') + const response = await createApiStrategy().handle({ request, event: event as unknown as RouteHandlerCallbackOptions['event'] }) + await Promise.all(event.pending) + return response +} + +// stubFetchThroughWorker replaces the global fetch with the page's view of +// it behind the worker, routed as src/sw.ts routes it: a network-first path +// from the API client goes through the API strategy, a network-only one +// straight to network; the strategy's own fetch, a Request, is network too. +// As in a browser, a worker that cannot answer is a network error to the +// page. Undo with vi.unstubAllGlobals. +export function stubFetchThroughWorker(network: (request: Request) => Promise) { + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { + if (input instanceof Request) return network(input) + const request = new Request(new URL(String(input), origin), init) + const route = routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin) + if (route.strategy === 'network-only') return network(request) + if (route.strategy !== 'network-first') throw new Error(`unexpected ${route.strategy} fetch: ${request.url}`) + return answer(request).catch((error: unknown) => { + throw new TypeError('Failed to fetch', { cause: error }) + }) + }) +} diff --git a/web/src/ui/IconButton.module.css b/web/src/ui/IconButton.module.css index 2995110..910f3ab 100644 --- a/web/src/ui/IconButton.module.css +++ b/web/src/ui/IconButton.module.css @@ -24,6 +24,11 @@ margin: -2px; } +.size52 { + width: 52px; + height: 52px; +} + .size62 { width: 62px; height: 62px; @@ -56,6 +61,11 @@ height: 40px; } +.size52 .face { + width: 52px; + height: 52px; +} + .size62 .face { width: 62px; height: 62px; diff --git a/web/src/ui/IconButton.test.tsx b/web/src/ui/IconButton.test.tsx index e909cdd..a17f343 100644 --- a/web/src/ui/IconButton.test.tsx +++ b/web/src/ui/IconButton.test.tsx @@ -26,6 +26,13 @@ describe('IconButton', () => { expect(hitArea(face)).toEqual({ width: 40, height: 40 }) }) + it('is 52 px at the medium size', () => { + render() + const button = screen.getByRole('button', { name: 'Favorite' }) + expectMinHitArea(button) + expect(hitArea(button)).toEqual({ width: 52, height: 52 }) + }) + it('is 62 px at the large size', () => { render() const button = screen.getByRole('button', { name: 'Try it' }) diff --git a/web/src/ui/IconButton.tsx b/web/src/ui/IconButton.tsx index 6da1b22..a4132bf 100644 --- a/web/src/ui/IconButton.tsx +++ b/web/src/ui/IconButton.tsx @@ -1,7 +1,7 @@ import type { ComponentPropsWithRef } from 'react' import styles from './IconButton.module.css' -export type IconButtonSize = 40 | 62 +export type IconButtonSize = 40 | 52 | 62 export type IconButtonTone = 'surface' | 'neutral' | 'accent' | 'teal' export type IconButtonProps = Omit, 'aria-label'> & { @@ -15,7 +15,8 @@ export type IconButtonProps = Omit, 'aria-label' } // Round icon button, named by its label and showing it only with showLabel. -// The 40 px size keeps a 44 px hit box (see the CSS); the 62 px size is the +// The 40 px size keeps a 44 px hit box (see the CSS); the 52 px size sits +// beside a 52 px pill button, as the recipe's heart; the 62 px size is the // round action button with --shadow-md. export function IconButton({ label, diff --git a/web/src/ui/Segmented.test.tsx b/web/src/ui/Segmented.test.tsx index c107a1e..99c04c8 100644 --- a/web/src/ui/Segmented.test.tsx +++ b/web/src/ui/Segmented.test.tsx @@ -83,6 +83,16 @@ describe('Segmented', () => { for (const option of screen.getAllByRole('radio')) expectMinHitArea(option) }) + it('names an option by its ariaLabel when it has one', () => { + const sizes = [ + { value: 'small', label: 'A', ariaLabel: 'Small text' }, + { value: 'large', label: 'A', ariaLabel: 'Large text' }, + ] as const + render( {}} />) + expect(radio('Small text')).toHaveTextContent('A') + expect(radio('Large text')).toHaveAttribute('aria-checked', 'true') + }) + it('fails fast when the value is not one of the options', () => { expect(() => render( {}} />), diff --git a/web/src/ui/Segmented.tsx b/web/src/ui/Segmented.tsx index bd53b27..6e943b2 100644 --- a/web/src/ui/Segmented.tsx +++ b/web/src/ui/Segmented.tsx @@ -4,6 +4,9 @@ import styles from './Segmented.module.css' export type SegmentedOption = { value: T label: string + // Accessible name, when the visible label alone does not say what the + // option is (the A / A / A of a text size). + ariaLabel?: string } export type SegmentedProps = { @@ -63,6 +66,7 @@ export function Segmented({ label, options, value, onValueChan type="button" role="radio" aria-checked={checked} + aria-label={option.ariaLabel} tabIndex={checked ? 0 : -1} className={checked ? `${styles.option} ${styles.selected}` : styles.option} onClick={() => select(index)}