Compare commits

...
Author SHA1 Message Date
orchestrator-agent e1b6cb77ed Feature 15 F15-01: always credit a grounded recipe's source (#163)
CI / ci (pull_request) Successful in 6m26s
CI / ci (push) Successful in 5m49s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 13:32:31 -04:00
dev_agent 4662a24aaf Merge remote-tracking branch 'origin/dev' into HEAD
CI / ci (push) Successful in 6m1s
CI / ci (pull_request) Successful in 6m14s
# Conflicts:
#	internal/server/dinners_test.go
#	web/src/App.test.tsx
#	web/src/App.tsx
2026-10-09 12:30:45 -04:00
orchestrator-agent b40af908a9 Recipe detail works offline once visited (Feature 15) (#159)
CI / ci (push) Successful in 5m48s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 12:24:14 -04:00
orchestrator-agent c6d287860b Source attribution links when a URL is verifiably safe (Feature 15) (#154)
CI / ci (push) Successful in 4m59s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 11:00:15 -04:00
orchestrator-agent 1028817193 Keep screen on via Wake Lock (Feature 15) (#151)
CI / ci (push) Successful in 5m27s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 10:52:41 -04:00
dev_agent 930e55ab43 Merge remote-tracking branch 'origin/dev' into HEAD
CI / ci (push) Successful in 5m4s
2026-10-09 10:11:06 -04:00
orchestrator-agent 28e9c24fc7 Tablet kitchen reading mode (2a) (Feature 15) (#149)
CI / ci (push) Successful in 5m19s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 07:42:48 -04:00
orchestrator-agent c454c76816 Add to this week and favorite (Feature 15) (#147)
CI / ci (push) Successful in 5m14s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 21:31:21 -04:00
orchestrator-agent 8068d06ff0 Mobile recipe detail (1k) (Feature 15) (#145)
CI / ci (push) Successful in 5m12s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 20:36:47 -04:00
orchestrator-agent d8ce8d8072 Dinner detail API (Feature 15) (#143)
CI / ci (push) Successful in 5m1s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 19:46:31 -04:00
39 changed files with 4549 additions and 105 deletions
+24
View File
@@ -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
+7
View File
@@ -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.
+35
View File
@@ -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
+283
View File
@@ -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)
}
+6 -1
View File
@@ -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 {
+101
View File
@@ -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
}
+6
View File
@@ -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
+8 -2
View File
@@ -29,7 +29,7 @@ type Recipe struct {
// Title is the recipe's name.
Title string
// SiteName is the publisher's name, else the page's og:site_name, else
// 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;
+469
View File
@@ -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)
}
}
}
+18
View File
@@ -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
+8 -2
View File
@@ -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)
}
+2 -1
View File
@@ -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))
+11
View File
@@ -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;
+2 -1
View File
@@ -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()
+2
View File
@@ -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<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/discover': <DiscoverPage />,
'/quick': <QuickDinnersPage />,
'/cook-from-pantry': <CookFromPantryPage />,
'/dinner/:id': <DinnerDetailPage />,
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
'/settings/household': <HouseholdSettingsPage />,
@@ -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;
}
+173
View File
@@ -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<Which, string> = { this: 'this week', next: 'next week' }
// Added is a swap sent from the sheet: the week it went to and the swap.
export type Added = { week: string; which: Which; swap: Swap }
// planDinner is the dinner as it is planned on a day.
function planDinner(dinner: DinnerDetail): PlanDinner {
return { id: dinner.id, name: dinner.name, photo_id: dinner.photo_id, minutes: dinner.minutes_total, method: dinner.method }
}
type WeekDaysProps = {
dinner: DinnerDetail
week: string
which: Which
onAdd: (added: Added) => void
}
// WeekDays is the seven days of the week starting on week, each with its
// dinner; choosing one swaps the dinner in as the Draft board swaps a day
// (useWeekSwap). A week with no plan offers to draft it on the Draft board:
// nothing here makes a plan up. A dinner already on the week can't be added
// again, nor can one while a swap of the week is still being saved or the
// week is being pushed to Calendar.
function WeekDays({ dinner, week, which, onAdd }: WeekDaysProps) {
const plan = usePlan(week)
const { swap, push, refused, swapIn } = useWeekSwap(week)
if (plan.isError) {
return <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load {weekNames[which]}'s plan. {plan.error.message}</ErrorBanner>
}
if (plan.isPending) {
return (
<div aria-busy="true" aria-label="Plan loading" className={swapStyles.rows}>
{Array.from({ length: 7 }, (_, i) => (
<Skeleton key={i} className={swapStyles.skeletonRow} />
))}
</div>
)
}
if (plan.data === null) {
return (
<div className={styles.noPlan}>
<p className={swapStyles.empty}>There's no plan for {weekNames[which]} yet, so there's no day to add it to.</p>
<Link to={PLAN} className={[buttonStyles.button, buttonStyles.primary].join(' ')}>
Draft {weekNames[which]} first
</Link>
</div>
)
}
const days = plan.data.days
const planned = days.find((day) => day.dinner.id === dinner.id)
const choosable = planned === undefined && !refused
function add(date: string) {
const chosen: Swap = { date, dinner: planDinner(dinner), fromQuestLog: dinner.status === 'quest_log' }
swapIn(chosen)
onAdd({ week, which, swap: chosen })
}
return (
<>
{planned !== undefined ? (
<p className={swapStyles.empty}>
{dinner.name} is already on {weekdayName(planned.date)} {weekNames[which]}.
</p>
) : swap.pending ? (
<p className={swapStyles.empty}>A change to {weekNames[which]} is still being saved.</p>
) : (
push.pending && <p className={swapStyles.empty}>The plan for {weekNames[which]} is being pushed to Calendar.</p>
)}
<ul aria-label={`Days of ${weekNames[which]}`} className={swapStyles.rows}>
{days.map((day) => (
<li key={day.date}>
<ListRow
className={choosable ? undefined : swapStyles.already}
leading={
// The row's dinner names what the photo shows.
<span aria-hidden="true">
<Photo id={day.dinner.photo_id} name={day.dinner.name} radius="var(--radius-md)" sizes="48px" className={swapStyles.photo} />
</span>
}
title={`${weekdayName(day.date)} ${dayOfMonth(day.date)}`}
meta={[day.dinner.name, cooking(day.dinner)].filter(Boolean).join(' · ')}
onClick={choosable ? () => add(day.date) : undefined}
trailing={day === planned && <Tag>This dinner</Tag>}
/>
</li>
))}
</ul>
</>
)
}
export type AddToWeekSheetProps = {
dinner: DinnerDetail
onAdd: (added: Added) => void
onClose: () => void
}
// AddToWeekSheet is the sheet Add to this week opens: the seven days of the
// household's current week, Monday to Sunday in its timezone, each with its
// dinner, and a toggle to next week. Choosing a day replaces its dinner.
export function AddToWeekSheet({ dinner, onAdd, onClose }: AddToWeekSheetProps) {
const titleId = useId()
const settings = useHouseholdSettings()
const [which, setWhich] = useState<Which>('this')
let body
if (settings.isError) {
body = (
<ErrorBanner onRetry={() => void settings.refetch()}>
Couldn't load the household settings. {settings.error.message}
</ErrorBanner>
)
} else if (settings.isPending) {
body = <Skeleton className={swapStyles.skeletonRow} />
} else {
const thisWeek = weekOf(localNow(settings.data.timezone, new Date()).date)
const week = which === 'this' ? thisWeek : addDays(thisWeek, 7)
body = (
<>
<p className={swapStyles.replaces}>
{weekLabel(week).dates} · replaces the day's dinner
</p>
<Segmented
label="Week"
options={[
{ value: 'this', label: 'This week' },
{ value: 'next', label: 'Next week' },
]}
value={which}
onValueChange={setWhich}
/>
<WeekDays key={week} dinner={dinner} week={week} which={which} onAdd={onAdd} />
</>
)
}
return (
<Sheet open onClose={onClose} aria-labelledby={titleId}>
<div className={swapStyles.sheet}>
<h2 id={titleId} className={swapStyles.title}>
Add {dinner.name}
</h2>
{body}
</div>
</Sheet>
)
}
@@ -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<string, unknown> = {}) {
return {
id: 'd1',
name: 'Korean beef bowls',
status: 'quest_log',
origin: 'suggested',
method: 'stovetop',
minutes_total: 30,
difficulty: 'easy',
cuisine: 'korean',
is_favorite: false,
photo_urls: null,
ingredients: [{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }],
steps: ['Brown the beef with the sauce.'],
source: null,
grounding_status: 'grounded',
grounding_error: null,
why_picked: null,
...overrides,
}
}
// listed is the bowls as the dinners list, and so the favorite toggle,
// answers: no recipe.
function listed(is_favorite: boolean) {
return {
id: 'd1',
name: 'Korean beef bowls',
method: 'stovetop',
minutes_total: 30,
difficulty: 'easy',
is_favorite,
photo_url: null,
grounding_status: 'grounded',
}
}
const THIS_NAMES = ['Beef tacos', 'Spaghetti bolognese', 'Lemon orzo', 'Chicken stir-fry', 'Pizza night', 'Fish tacos', 'Sheet-pan salmon']
const NEXT_NAMES = ['Chili', 'Pad thai', 'Burgers', 'Ramen', 'Pizza night', 'Pot roast', 'Fried rice']
// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it, with
// dinner ids p0 to p6 unless ids says otherwise.
function planBody(weekStart: string, names: string[], ids = names.map((_, i) => `p${i}`)) {
const start = new Date(`${weekStart}T00:00:00Z`).getTime()
return {
id: `plan-${weekStart}`,
week_start: weekStart,
status: 'draft',
ai_note: null,
calendar_synced_at: null as string | null,
days: names.map((name, i) => ({
date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
dinner: { id: ids[i], name, photo: null, minutes: 30, method: 'stovetop' },
is_new: false,
start_by: null,
})),
}
}
// THURSDAY_SWAPPED is this week's plan as the swap of Thursday answers it.
function thursdaySwapped() {
const ids = THIS_NAMES.map((_, i) => (i === 3 ? 'd1' : `p${i}`))
const names = THIS_NAMES.map((name, i) => (i === 3 ? 'Korean beef bowls' : name))
const plan = planBody('2026-10-05', names, ids)
plan.days[3]!.is_new = true
return plan
}
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
// stubServer answers "METHOD path" requests from responders, with /me, the
// household settings and the dinner; any other request fails the test.
function stubServer(responders: Record<string, Responder> = {}) {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
[`GET ${DINNER}`]: () => jsonResponse(200, detail()),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// sent lists the requests sent with method, as "path body".
function sent(fetchMock: Mock<typeof fetch>, method: string) {
return fetchMock.mock.calls
.filter(([, init]) => init?.method === method)
.map(([input, init]) => `${String(input)} ${String(init?.body)}`)
}
function reads(fetchMock: Mock<typeof fetch>, path: string) {
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === path).length
}
// deferred is a response the test sends when it chooses.
function deferred() {
let send!: (response: Response) => void
const response = new Promise<Response>((resolve) => {
send = resolve
})
return { response, send }
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the bowls' detail screen, with plan for the Plan: a probe unless told
// otherwise. It answers with the router.
async function renderPage(plan = <h1>Plan probe</h1>) {
const api = createApiClient(fakeLocation('/dinner/d1'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const router = createMemoryRouter(
[
{ path: '/dinner/:id', element: <DinnerDetailPage /> },
{ path: '/plan', element: plan },
],
{ initialEntries: ['/dinner/d1'] },
)
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<RouterProvider router={router} />
</QueryClientProvider>
</ApiProvider>,
)
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
return router
}
// openSheet opens Add to this week and waits for the week's days.
async function openSheet(listName = 'Days of this week') {
fireEvent.click(screen.getByRole('button', { name: 'Add to this week' }))
const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' })
const days = await within(sheet).findByRole('list', { name: listName })
return { sheet, days }
}
// dayRows is each day's row text, as "Monday 5 · Beef tacos · Stovetop · 30 min",
// with plain spaces.
function dayRows(days: HTMLElement) {
return within(days)
.getAllByRole('listitem')
.map((row) => [...row.querySelectorAll('span')].filter((span) => span.children.length === 0 && !span.closest('[aria-hidden="true"]')).map((span) => span.textContent).join(' · ').replace(/\s/g, ' '))
}
beforeEach(() => {
mockViewport(390)
// Wednesday Oct 7, noon in New York.
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
Reflect.deleteProperty(window, 'matchMedia')
vi.unstubAllGlobals()
})
describe('Add to this week', () => {
it('lists the seven days of this week with their dinners, and next week by the toggle', async () => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
[`GET ${NEXT_WEEK}`]: () => jsonResponse(200, planBody('2026-10-12', NEXT_NAMES)),
})
await renderPage()
// Nothing about the week is read until the sheet opens.
expect(reads(fetchMock, THIS_WEEK)).toBe(0)
const { sheet, days } = await openSheet()
expect(within(sheet).getByText("Oct 5 – 11 · replaces the day's dinner")).toBeInTheDocument()
expect(dayRows(days)).toEqual([
'Monday 5 · Beef tacos · Stovetop · 30 min',
'Tuesday 6 · Spaghetti bolognese · Stovetop · 30 min',
'Wednesday 7 · Lemon orzo · Stovetop · 30 min',
'Thursday 8 · Chicken stir-fry · Stovetop · 30 min',
'Friday 9 · Pizza night · Stovetop · 30 min',
'Saturday 10 · Fish tacos · Stovetop · 30 min',
'Sunday 11 · Sheet-pan salmon · Stovetop · 30 min',
])
expect(within(days).getAllByRole('button')).toHaveLength(7)
fireEvent.click(within(sheet).getByRole('radio', { name: 'Next week' }))
const next = await within(sheet).findByRole('list', { name: 'Days of next week' })
expect(within(sheet).getByText("Oct 12 – 18 · replaces the day's dinner")).toBeInTheDocument()
expect(dayRows(next)[0]).toBe('Monday 12 · Chili · Stovetop · 30 min')
expect(dayRows(next)[6]).toBe('Sunday 18 · Fried rice · Stovetop · 30 min')
fireEvent.click(within(sheet).getByRole('radio', { name: 'This week' }))
expect(await within(sheet).findByRole('list', { name: 'Days of this week' })).toBeInTheDocument()
})
it("takes this week in the household's timezone, not the device's UTC date", async () => {
// Sunday Oct 11, 10 pm in New York, already Monday Oct 12 in UTC.
vi.setSystemTime(new Date('2026-10-12T02:00:00Z'))
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)) })
await renderPage()
const { days } = await openSheet()
expect(dayRows(days)[6]).toBe('Sunday 11 · Sheet-pan salmon · Stovetop · 30 min')
expect(reads(fetchMock, THIS_WEEK)).toBe(1)
expect(reads(fetchMock, NEXT_WEEK)).toBe(0)
})
it('swaps the day chosen through the plan API, confirms it and reads the plan again', async () => {
const swap = deferred()
let plan = planBody('2026-10-05', THIS_NAMES)
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
[`PUT ${THURSDAY}`]: () => swap.response,
})
await renderPage()
const { days } = await openSheet()
fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await waitFor(() => expect(sent(fetchMock, 'PUT')).toHaveLength(1))
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`])
expect(screen.getByRole('status')).toHaveTextContent('Adding to Thursday…')
plan = thursdaySwapped()
await act(async () => swap.send(jsonResponse(200, plan)))
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
// The plan query is invalidated: the week is read again from the server.
await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(2))
// The sheet opened again shows the plan as swapped.
const again = await openSheet()
expect(dayRows(again.days)[3]).toBe('Thursday 8 · Korean beef bowls · Stovetop · 30 min · This dinner')
expect(within(again.sheet).getByText('Korean beef bowls is already on Thursday this week.')).toBeInTheDocument()
expect(within(again.days).queryAllByRole('button')).toHaveLength(0)
})
it('offers to draft a week with no plan on the Plan, and swaps nothing', async () => {
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found') })
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Add to this week' }))
const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' })
const draft = await within(sheet).findByRole('link', { name: 'Draft this week first' })
expect(draft).toHaveAttribute('href', '/plan')
expect(within(sheet).getByText("There's no plan for this week yet, so there's no day to add it to.")).toBeInTheDocument()
expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
fireEvent.click(draft)
expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument()
expect(sent(fetchMock, 'PUT')).toEqual([])
})
it('shows a refused swap with the error, puts the day back and retries it', async () => {
let refuse = true
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
[`PUT ${THURSDAY}`]: () => (refuse ? errorResponse(500, 'internal') : jsonResponse(200, thursdaySwapped())),
})
await renderPage()
const { days } = await openSheet()
fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't add it to Thursday this week. internal message")
const reopened = await openSheet()
expect(dayRows(reopened.days)[3]).toBe('Thursday 8 · Chicken stir-fry · Stovetop · 30 min')
fireEvent.keyDown(reopened.sheet, { key: 'Escape' })
refuse = false
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`, `${THURSDAY} {"dinner_id":"d1"}`])
})
it("leaves a push's Supply run outcome behind on the Plan, as a swap on the Draft board does", async () => {
let plan = planBody('2026-10-05', THIS_NAMES)
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
[`POST ${THIS_WEEK}/push-calendar`]: () => {
plan = { ...plan, status: 'synced', calendar_synced_at: '2026-10-07T16:00:00Z' }
return jsonResponse(200, { ...plan, supply_run: { status: 'generated', error: null } })
},
[`PUT ${THURSDAY}`]: () => {
plan = { ...thursdaySwapped(), status: 'changed', calendar_synced_at: '2026-10-07T16:00:00Z' }
return jsonResponse(200, plan)
},
'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'),
})
const router = await renderPage(<PlanPage />)
// The week is pushed on the Plan, which says its Supply run was made.
await act(() => router.navigate('/plan'))
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
// Coming back from the dinner with nothing swapped, it still says so.
await act(() => router.navigate('/dinner/d1'))
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
await act(() => router.navigate('/plan'))
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
// Thursday is swapped from the dinner's Add to this week.
await act(() => router.navigate('/dinner/d1'))
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
const { days } = await openSheet()
fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
// The Plan shows the swapped plan, not changed since its push, and no
// longer says the earlier plan's Supply run was made.
await act(() => router.navigate('/plan'))
expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
expect(screen.getByText('Korean beef bowls')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.queryByText("Made this week's Supply run from the plan.")).not.toBeInTheDocument()
expect(sent(fetchMock, 'POST')).toEqual([`${THIS_WEEK}/push-calendar undefined`])
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`])
})
it('refuses a swap while the week is being pushed to Calendar', async () => {
const push = deferred()
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
[`POST ${THIS_WEEK}/push-calendar`]: () => push.response,
'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'),
})
const router = await renderPage(<PlanPage />)
await act(() => router.navigate('/plan'))
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1))
await act(() => router.navigate('/dinner/d1'))
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
const { sheet, days } = await openSheet()
expect(within(sheet).getByText('The plan for this week is being pushed to Calendar.')).toBeInTheDocument()
expect(within(days).queryAllByRole('button')).toHaveLength(0)
expect(sent(fetchMock, 'PUT')).toEqual([])
})
})
describe('the favorite heart', () => {
it('fills at once, keeps the recipe and takes the server answer', async () => {
const favorite = deferred()
const fetchMock = stubServer({ [`POST ${FAVORITE}`]: () => favorite.response })
await renderPage()
const heart = screen.getByRole('button', { name: 'Favorite' })
expect(heart).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(heart)
await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'true'))
expect(sent(fetchMock, 'POST')).toEqual([`${FAVORITE} undefined`])
await act(async () => favorite.send(jsonResponse(200, listed(true))))
await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1))
expect(heart).toHaveAttribute('aria-pressed', 'true')
// The list-shaped answer did not replace the detail: the recipe stays.
expect(screen.getByText('Ground beef')).toBeInTheDocument()
expect(screen.getByText('Brown the beef with the sauce.')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('empties again with the error when the server refuses, and retries', async () => {
const refused = deferred()
let refuse = true
const fetchMock = stubServer({
[`GET ${DINNER}`]: () => jsonResponse(200, detail({ is_favorite: true })),
[`POST ${FAVORITE}`]: () => (refuse ? refused.response : jsonResponse(200, listed(false))),
})
await renderPage()
const heart = screen.getByRole('button', { name: 'Favorite' })
expect(heart).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(heart)
await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false'))
await act(async () => refused.send(errorResponse(500, 'internal')))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't unfavorite Korean beef bowls. internal message")
expect(heart).toHaveAttribute('aria-pressed', 'true')
refuse = false
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false'))
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
expect(sent(fetchMock, 'POST')).toHaveLength(2)
})
})
@@ -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<string, unknown> = {
'/api/v1/settings/household': HOUSEHOLD_SETTINGS,
'/api/v1/dinners/d1': grounded('d1', 'Korean beef bowls'),
'/api/v1/dinners/d2': grounded('d2', 'Chicken tikka masala'),
'/api/v1/dinners/d3': {
...grounded('d3', 'Pork carnitas'),
photo_urls: null,
ingredients: [],
steps: [],
source: null,
grounding_status: 'pending',
},
}
let cache: FakeCache
let reachable: boolean
// networkReads is every path the network was asked for, reached or not.
let networkReads: string[]
beforeEach(() => {
cache = stubWorkerCaches()
reachable = true
networkReads = []
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable)
stubFetchThroughWorker(async (request) => {
const path = new URL(request.url).pathname
networkReads.push(path)
if (!reachable) throw new TypeError('Failed to fetch')
if (path.startsWith('/api/v1/plans/')) return errorResponse(404, 'not_found')
if (!Object.hasOwn(server, path)) throw new Error(`unexpected network fetch: ${request.url}`)
return jsonResponse(200, server[path])
})
})
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
// open loads /dinner/{id} as a fresh page load would: a new API client,
// network status and query cache, with the offline indicator App shows.
function open(id: string) {
const router = createMemoryRouter(
[{ element: <AppShell />, children: [{ path: '/dinner/:id', element: <DinnerDetailPage /> }] }],
{ initialEntries: [`/dinner/${id}`] },
)
return render(
<ApiProviders>
<RouterProvider router={router} />
<OfflinePill />
</ApiProviders>,
)
}
// visit opens a dinner while online and leaves once its title shows.
async function visit(id: string, name: string) {
const page = open(id)
expect(await screen.findByRole('heading', { level: 1, name })).toBeInTheDocument()
expect(screen.queryByText('Offline — showing saved data')).not.toBeInTheDocument()
page.unmount()
}
describe('dinner detail offline', () => {
it('saves a visited dinner in the API cache the worker reads offline', async () => {
mockViewport(390)
expect(routeFor({ method: 'GET', url: new URL('/api/v1/dinners/d1', origin), mode: 'cors' }, origin)).toEqual({
strategy: 'network-first',
cacheName: cacheNames.api,
})
await visit('d1', 'Korean beef bowls')
expect([...cache.entries.keys()]).toEqual([`${origin}/api/v1/dinners/d1`])
})
it('shows a visited dinner offline from its saved copy: photo, ingredients and steps, with the offline indicator', async () => {
mockViewport(390)
await visit('d1', 'Korean beef bowls')
reachable = false
open('d1')
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg')
const [ingredients, steps] = screen.getAllByRole('list')
expect(within(ingredients!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([
'Ground beef1 lb',
'Jasmine rice2 cups',
])
expect(within(steps!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([
'1Cook the rice.',
'2Brown the beef with the sauce.',
])
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument()
})
it('shows a visited dinner offline in Kitchen reading mode on a tablet', async () => {
mockViewport(1180)
await visit('d1', 'Korean beef bowls')
reachable = false
open('d1')
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg')
expect(within(screen.getByRole('region', { name: 'Ingredients' })).getAllByRole('checkbox')).toHaveLength(2)
expect(screen.getByRole('listitem', { current: 'step' })).toHaveTextContent('Cook the rice.')
})
it('shows an error, not a blank page or placeholder, for a dinner never visited', async () => {
mockViewport(390)
await visit('d1', 'Korean beef bowls')
reachable = false
open('d2')
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.")
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument()
expect(screen.queryByText(/Korean beef bowls|Chicken tikka masala/)).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument()
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
})
it('keeps a saved copy still finding its recipe as that, not a finished recipe', async () => {
mockViewport(390)
await visit('d3', 'Pork carnitas')
reachable = false
open('d3')
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
// Read once online and tried once offline before the saved copy answered.
expect(networkReads.filter((path) => path.startsWith('/api/v1/dinners/'))).toEqual([
'/api/v1/dinners/d3',
'/api/v1/dinners/d3',
])
})
})
+547
View File
@@ -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;
}
+473
View File
@@ -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<string, unknown> = {}) {
return {
id: 'd1',
name: 'Korean beef bowls',
status: 'quest_log',
origin: 'suggested',
method: 'stovetop',
minutes_total: 30,
difficulty: 'easy',
cuisine: 'korean',
is_favorite: false,
photo_urls: { '400': '/media/photos/p1-400.jpg', '1200': '/media/photos/p1-1200.jpg' },
ingredients: [
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
{ name: 'Soy sauce', qty: '¼', unit: 'cup', note: null, raw_text: '¼ cup soy sauce' },
{ name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' },
],
steps: ['Cook the rice.', 'Brown the beef with the sauce.', 'Serve over rice with the onions.'],
source: { site_name: 'Serious Eats' },
grounding_status: 'grounded',
grounding_error: null,
why_picked: 'close cousin of your Beef tacos and Chicken stir-fry.',
...overrides,
}
}
// A recipe the household typed in: only raw_text, no source.
const MANUAL = {
status: 'regular',
origin: 'regular',
photo_urls: null,
ingredients: [
{ name: null, qty: null, unit: null, note: null, raw_text: '2 lb chuck roast' },
{ name: null, qty: null, unit: null, note: null, raw_text: 'A splash of red wine' },
],
steps: ['Sear the roast.', 'Slow cook 8 hours.'],
source: null,
grounding_status: 'manual',
why_picked: null,
}
// A Regular whose recipe could not be found: no recipe at all.
const FAILED = {
status: 'regular',
origin: 'regular',
photo_urls: null,
minutes_total: null,
difficulty: null,
ingredients: [],
steps: [],
source: null,
grounding_status: 'failed',
grounding_error: 'no_recipe_found',
why_picked: null,
}
// renderAt renders the detail screen at the last of entries, inside the
// app shell's scroll area as App routes it, with probes for where its links
// and back button go.
function renderAt(entries: string[]) {
const router = createMemoryRouter(
[
{ element: <AppShell />, children: [{ path: '/dinner/:id', element: <DinnerDetailPage /> }] },
{ path: '/', element: <h1>Home probe</h1> },
{ path: '/plan', element: <h1>Plan probe</h1> },
{ path: REGULAR_DINNERS, element: <h1>Regular dinners probe</h1> },
],
{ initialEntries: entries, initialIndex: entries.length - 1 },
)
render(
<ApiProviders>
<RouterProvider router={router} />
</ApiProviders>,
)
return router
}
function serve(body: unknown) {
return stubFetchByPath({ [DINNER]: () => jsonResponse(200, body) })
}
beforeEach(() => {
mockViewport(390)
})
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
vi.unstubAllGlobals()
})
describe('DinnerDetailPage', () => {
describe('a grounded Side quest', () => {
it('shows its photo washed and full-bleed, tags, title and why it was picked', async () => {
serve(detail())
renderAt(['/dinner/d1'])
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
const photo = screen.getByRole('img', { name: 'Korean beef bowls' })
expect(photo).toHaveAttribute('src', '/media/photos/p1-400.jpg')
expect(photo).toHaveAttribute('srcset', '/media/photos/p1-400.jpg 400w, /media/photos/p1-1200.jpg 1200w')
expect(declaredStyle(photo.parentElement!).height).toBe('250px')
expect(declaredStyle(photo.parentElement!).margin).toBe('0px -20px')
expect(screen.getByText('Side quest')).toBeInTheDocument()
expect(screen.getByText('Easy')).toBeInTheDocument()
expect(screen.getByText('30 min')).toBeInTheDocument()
expect(screen.queryByText('Regular')).not.toBeInTheDocument()
expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent(
'Why it was picked: close cousin of your Beef tacos and Chicken stir-fry.',
)
})
it('lists ingredients in recipe order with quantities on the right, then numbered steps', async () => {
serve(detail())
renderAt(['/dinner/d1'])
expect(await screen.findByRole('heading', { level: 2, name: 'Ingredients' })).toBeInTheDocument()
const rows = within(screen.getAllByRole('list')[0]!).getAllByRole('listitem')
expect(rows.map((row) => Array.from(row.children, (cell) => cell.textContent))).toEqual([
['Ground beef', '1 lb'],
['Jasmine rice', '2 cups'],
['Soy sauce', '¼ cup'],
['Green onions, sliced', '1 bunch'],
])
const qty = rows[0]!.lastElementChild!
expect(declaredStyle(qty).textAlign).toBe('right')
expect(declaredStyle(rows[0]!).justifyContent).toBe('space-between')
expect(declaredStyle(rows[0]!).borderBottom).toBe('1px solid var(--color-divider)')
expect(screen.getByRole('heading', { level: 2, name: 'Steps' })).toBeInTheDocument()
const steps = screen.getAllByRole('list')[1]!
expect(steps.tagName).toBe('OL')
expect(within(steps).getAllByRole('listitem').map((step) => step.textContent)).toEqual([
'1Cook the rice.',
'2Brown the beef with the sauce.',
'3Serve over rice with the onions.',
])
})
it('credits the site the recipe came from, with no link to it', async () => {
serve(detail())
renderAt(['/dinner/d1'])
const credit = await screen.findByText('Recipe from Serious Eats')
expect(credit.closest('a')).toBeNull()
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
})
it('links the credit to the recipe in a new tab once the server sends a safe url', async () => {
serve(detail({ source: { site_name: 'Serious Eats', url: 'https://www.seriouseats.com/korean-beef-bowls' } }))
renderAt(['/dinner/d1'])
const credit = await screen.findByRole('link', { name: 'Recipe from Serious Eats' })
expect(credit).toHaveAttribute('href', 'https://www.seriouseats.com/korean-beef-bowls')
expect(credit).toHaveAttribute('target', '_blank')
expect(credit).toHaveAttribute('rel', 'noopener noreferrer')
})
it.each([
['http', 'http://www.seriouseats.com/korean-beef-bowls'],
['a password', 'https://user:secret@www.seriouseats.com/korean-beef-bowls'],
['a query', 'https://www.seriouseats.com/korean-beef-bowls?token=abc'],
['a fragment', 'https://www.seriouseats.com/korean-beef-bowls#token=abc'],
])('refuses a source url with %s', async (_, url) => {
serve(detail({ source: { site_name: 'Serious Eats', url } }))
renderAt(['/dinner/d1'])
expect(await screen.findByRole('alert')).toHaveTextContent('source url is not an https link')
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
})
// Each source exactly as the Go handler sends it for a stored link
// (internal/server's TestGetDinnerSourceURLOnlyWhenSafe checks the same
// file), so every url the server sends must parse here.
it.each(sourceCases.cases)('renders the source the server sends for $stored', async ({ source }) => {
serve(detail({ source }))
renderAt(['/dinner/d1'])
const credit = await screen.findByText('Recipe from Curry House')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
if (source.url === null) {
expect(credit.closest('a')).toBeNull()
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
} else {
expect(screen.getByRole('link', { name: 'Recipe from Curry House' })).toHaveAttribute('href', new URL(source.url).href)
}
})
it("links the credit by the url's host when the server names no site", async () => {
serve(detail({ source: { site_name: null, url: 'https://Kitchen.Example:8443/korean-beef-bowls' } }))
renderAt(['/dinner/d1'])
const credit = await screen.findByRole('link', { name: 'Recipe from kitchen.example:8443' })
expect(credit).toHaveAttribute('href', 'https://kitchen.example:8443/korean-beef-bowls')
expect(credit).toHaveAttribute('target', '_blank')
expect(credit).toHaveAttribute('rel', 'noopener noreferrer')
})
// The server withholds both when the stored name could be the link's
// host and the link could carry a token: the credit names neither.
it('credits the web in plain text when the server sends neither site nor url', async () => {
serve(detail({ source: { site_name: null, url: null } }))
renderAt(['/dinner/d1'])
const credit = await screen.findByText('Recipe from the web')
expect(credit.closest('a')).toBeNull()
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
})
it('holds Add to this week and the heart at the bottom', async () => {
serve(detail({ is_favorite: true }))
renderAt(['/dinner/d1'])
const add = await screen.findByRole('button', { name: 'Add to this week' })
expect(add).toBeEnabled()
const heart = screen.getByRole('button', { name: 'Favorite' })
expect(heart).toBeEnabled()
expect(heart).toHaveAttribute('aria-pressed', 'true')
const bar = heart.parentElement!
expect(bar).toContainElement(add)
expect(declaredStyle(bar).position).toBe('sticky')
expect(declaredStyle(bar).bottom).toBe('0px')
})
})
// jsdom does no layout, so no test here can measure where the bar lands.
// This pins the CSS chain that puts it at the bottom of the screen on a
// short recipe: the shell's scroll area is a full-height flex item, the
// page fills it as a column, the sheet takes the room left and the bar is
// the page's last child. Drop any link and the bar sits right after the
// content again, and this fails.
it('fills the scroll area so the bar rests at the bottom of a short recipe', async () => {
serve(detail(FAILED))
renderAt(['/dinner/d1'])
const bar = (await screen.findByRole('button', { name: 'Favorite' })).parentElement!
const page = bar.parentElement!
const main = page.parentElement!
const shell = main.parentElement!
expect(main.tagName).toBe('MAIN')
expect(declaredStyle(shell).height).toBe('100dvh')
expect(declaredStyle(shell).display).toBe('flex')
expect(declaredStyle(shell).alignItems).toBe('')
expect(declaredStyle(main).overflowY).toBe('auto')
expect(declaredStyle(page).minHeight).toBe('100%')
expect(declaredStyle(page).display).toBe('flex')
expect(declaredStyle(page).flexDirection).toBe('column')
expect(page.lastElementChild).toBe(bar)
const sheet = screen.getByRole('article')
expect(sheet.parentElement).toBe(page)
expect(sheet.nextElementSibling).toBe(bar)
expect(declaredStyle(sheet).flexGrow).toBe('1')
expect(declaredStyle(bar).position).toBe('sticky')
})
it('shows the striped placeholder when the dinner has no photo', async () => {
serve(detail({ photo_urls: null }))
renderAt(['/dinner/d1'])
const placeholder = await screen.findByRole('img', { name: 'Korean beef bowls' })
expect(placeholder.tagName).toBe('DIV')
expect(placeholder).toHaveTextContent('dish photo')
})
it('shows a manual Regular line by line, as typed, with no credit', async () => {
serve(detail(MANUAL))
renderAt(['/dinner/d1'])
expect(await screen.findByText('Regular')).toBeInTheDocument()
expect(screen.queryByText('Side quest')).not.toBeInTheDocument()
const [ingredients, steps] = screen.getAllByRole('list')
expect(within(ingredients!).getAllByRole('listitem').map((row) => row.firstElementChild!.textContent)).toEqual([
'2 lb chuck roast',
'A splash of red wine',
])
expect(within(steps!).getAllByRole('listitem')).toHaveLength(2)
expect(screen.queryByText(/Why it was picked/)).not.toBeInTheDocument()
expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveTextContent('dish photo')
})
it('links a dinner whose recipe was not found to Settings → Regular dinners, with no recipe', async () => {
serve(detail(FAILED))
const router = renderAt(['/dinner/d1'])
const fix = await screen.findByRole('link', { name: 'Fix recipe' })
expect(screen.getByText(/couldn't find a recipe/)).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Steps' })).not.toBeInTheDocument()
expect(screen.queryByRole('list')).not.toBeInTheDocument()
fireEvent.click(fix)
expect(await screen.findByRole('heading', { name: 'Regular dinners probe' })).toBeInTheDocument()
expect(router.state.location.pathname).toBe(REGULAR_DINNERS)
})
it('says it is still finding a recipe while grounding is pending', async () => {
serve(detail({ ...FAILED, grounding_status: 'pending', grounding_error: null }))
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Fix recipe' })).not.toBeInTheDocument()
})
describe('while the recipe is being found', () => {
const PENDING = detail({ ...FAILED, grounding_status: 'pending', grounding_error: null })
const GROUNDED = detail({ grounding_status: 'grounded' })
let onLine: boolean
let body: unknown
let fromCache: boolean
let fetchMock: Mock<typeof fetch>
const reads = () => fetchMock.mock.calls.length
const tick = (intervals = 1) => act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * intervals))
function setOnline(value: boolean) {
onLine = value
act(() => {
window.dispatchEvent(new Event(value ? 'online' : 'offline'))
})
}
beforeEach(() => {
// Only intervals are faked: TanStack Query polls with setInterval, and
// Testing Library's waits keep their real timers.
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
onLine = true
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine)
body = PENDING
fromCache = false
fetchMock = stubFetchByPath({
[DINNER]: () =>
new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json', ...(fromCache ? { [cacheFallbackHeader]: '1' } : {}) },
}),
})
})
// An offline event leaves TanStack Query's online manager, shared by
// every test, offline until an online event.
afterEach(() => {
window.dispatchEvent(new Event('online'))
vi.useRealTimers()
vi.restoreAllMocks()
})
it('reads the dinner again until the recipe lands, then stops', async () => {
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(reads()).toBe(1)
await tick()
await waitFor(() => expect(reads()).toBe(2))
body = GROUNDED
await tick()
expect(await screen.findByRole('heading', { level: 2, name: 'Ingredients' })).toBeInTheDocument()
expect(reads()).toBe(3)
await tick(3)
expect(reads()).toBe(3)
})
it('pauses while the browser is offline and resumes when it is back', async () => {
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(reads()).toBe(1)
setOnline(false)
await tick(3)
expect(reads()).toBe(1)
setOnline(true)
await tick()
await waitFor(() => expect(reads()).toBe(2))
})
it('reads a saved pending copy offline once, then waits for the connection', async () => {
onLine = false
fromCache = true
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(reads()).toBe(1)
await tick(3)
expect(reads()).toBe(1)
// The connection comes back: the saved copy is read again at once,
// and polling goes on once the server answers.
fromCache = false
setOnline(true)
await waitFor(() => expect(reads()).toBe(2))
await tick()
await waitFor(() => expect(reads()).toBe(3))
})
it('stops polling on a saved copy even while the browser says it is online', async () => {
fromCache = true
renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
await tick(3)
expect(reads()).toBe(1)
})
it('stops polling when the page is left', async () => {
const router = renderAt(['/dinner/d1'])
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(reads()).toBe(1)
await act(() => router.navigate('/'))
expect(screen.getByRole('heading', { name: 'Home probe' })).toBeInTheDocument()
await tick(3)
expect(reads()).toBe(1)
})
})
it('says an unknown or removed dinner is not found', async () => {
stubFetchByPath({ [DINNER]: () => errorResponse(404, 'not_found') })
renderAt(['/dinner/d1'])
expect(await screen.findByRole('heading', { name: 'Dinner not found' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Go Home' })).toHaveAttribute('href', '/')
})
it('reports any other failure with a retry', async () => {
stubFetchByPath({ [DINNER]: () => errorResponse(500, 'internal') })
renderAt(['/dinner/d1'])
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.")
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
})
describe('the back button', () => {
it('goes back in the app', async () => {
serve(detail())
const router = renderAt(['/plan', '/dinner/d1'])
fireEvent.click(await screen.findByRole('button', { name: 'Back' }))
expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/plan')
})
it('goes Home when the dinner was opened from a link', async () => {
serve(detail())
const router = renderAt(['/dinner/d1'])
fireEvent.click(await screen.findByRole('button', { name: 'Back' }))
expect(await screen.findByRole('heading', { name: 'Home probe' })).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/')
})
})
})
+582
View File
@@ -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<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
// BackButton goes back in the app's history, or, opened from a link with
// no history in the app, to Home.
function BackButton() {
const navigate = useNavigate()
const location = useLocation()
// The first entry of a session has the key "default".
const back = () => (location.key === 'default' ? void navigate('/', { replace: true }) : void navigate(-1))
return (
<IconButton label="Back" tone="neutral" className={styles.back} onClick={back}>
<Icon icon={ChevronLeft} size="inline" />
</IconButton>
)
}
// quantity is an ingredient's amount as the right-hand column shows it,
// "1 lb", or empty when the recipe gives none.
function quantity(ingredient: Ingredient): string {
return [ingredient.qty, ingredient.unit].filter(Boolean).join(' ')
}
// IngredientRow is one ingredient: its name with any note, or the line as
// the recipe gives it when there is no name, and its amount on the right.
function IngredientRow({ ingredient }: { ingredient: Ingredient }) {
return (
<li className={styles.ingredient}>
<span>
{ingredient.name ?? ingredient.raw_text}
{ingredient.name !== null && ingredient.note !== null && <span className={styles.note}>, {ingredient.note}</span>}
</span>
<span className={styles.qty}>{quantity(ingredient)}</span>
</li>
)
}
// SourceCredit is "Recipe from {site}" for a recipe fetched from the web: a
// link opening the recipe's page in a new tab when the server sends a url
// safe to open, else plain text. The site is the name the page gave, else
// the url's host — already in the link, so naming it shows nothing more —
// else, with neither to show, just "the web": a fetched recipe always
// credits where it came from, without a name the server withheld.
function SourceCredit({ siteName, url }: RecipeSource) {
const credit = `Recipe from ${siteName ?? (url === null ? 'the web' : new URL(url).host)}`
return (
<p className={styles.source}>
{url === null ? (
credit
) : (
<a href={url} target="_blank" rel="noopener noreferrer">
{credit}
</a>
)}
</p>
)
}
// hasRecipe says whether the dinner's recipe was found or typed in, rather
// than still being searched for or not found.
function hasRecipe(dinner: DinnerDetail): boolean {
return dinner.grounding_status !== 'pending' && dinner.grounding_status !== 'failed'
}
// RecipeMissing is what stands in for a recipe still being searched for, or
// the link to fix one that could not be found.
function RecipeMissing({ dinner }: { dinner: DinnerDetail }) {
if (dinner.grounding_status === 'failed') {
return (
<section className={styles.missing} aria-label="No recipe">
<p>We couldn't find a recipe for this dinner, so there are no ingredients or steps yet.</p>
<Link to={REGULAR_DINNERS} className={styles.fix}>
Fix recipe
</Link>
</section>
)
}
return <p className={styles.missing}>Still finding the recipe — its ingredients and steps will show here.</p>
}
// Recipe is the dinner's ingredients and steps, or what stands in for them.
function Recipe({ dinner }: { dinner: DinnerDetail }) {
if (!hasRecipe(dinner)) return <RecipeMissing dinner={dinner} />
return (
<>
{dinner.ingredients.length > 0 && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Ingredients</h2>
<ul className={styles.ingredients}>
{dinner.ingredients.map((ingredient, i) => (
<IngredientRow key={i} ingredient={ingredient} />
))}
</ul>
</section>
)}
{dinner.steps.length > 0 && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Steps</h2>
<ol className={styles.steps}>
{dinner.steps.map((step, i) => (
<li key={i} className={styles.step}>
<span className={styles.number} aria-hidden="true">
{i + 1}
</span>
<span>{step}</span>
</li>
))}
</ol>
</section>
)}
{dinner.source !== null && <SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />}
</>
)
}
// AddedStatus says what became of a dinner added to a day: being saved,
// added, or why it couldn't be, with Retry, which sends the same swap again
// as the Draft board would (useWeekSwap), and is not offered while that is
// refused. The week's plan stays read while it shows, so a retry finds the
// day to swap; once added, the plan is read again from the server.
function AddedStatus({ added }: { added: Added }) {
const queryClient = useQueryClient()
usePlan(added.week)
const { swap, refused, swapIn } = useWeekSwap(added.week)
const { date } = added.swap
const done = swap.succeeded === added.swap
const failed = swap.failed?.swap === added.swap ? swap.failed.error : null
useEffect(() => {
if (done) void queryClient.invalidateQueries({ queryKey: planKey(added.week) })
}, [done, queryClient, added.week])
if (failed !== null) {
return (
<div className={styles.status}>
<ErrorBanner onRetry={refused ? undefined : () => swapIn(added.swap)}>
Couldn't add it to {weekdayName(date)} {weekNames[added.which]}. {failed.message}
</ErrorBanner>
</div>
)
}
return (
<p role="status" className={[styles.status, styles.added].join(' ')}>
{done ? `Added to ${weekdayName(date)} ${weekNames[added.which]}.` : `Adding to ${weekdayName(date)}…`}
</p>
)
}
// AddToWeek is Add to this week: it opens the sheet of the week's days, and
// says what became of the dinner added.
function AddToWeek({ dinner }: { dinner: DinnerDetail }) {
const [open, setOpen] = useState(false)
const [added, setAdded] = useState<Added | null>(null)
function add(chosen: Added) {
setOpen(false)
setAdded(chosen)
}
return (
<>
{added !== null && <AddedStatus key={`${added.week} ${added.swap.date}`} added={added} />}
<Button className={styles.add} onClick={() => setOpen(true)}>
Add to this week
</Button>
{open && <AddToWeekSheet dinner={dinner} onAdd={add} onClose={() => setOpen(false)} />}
</>
)
}
// FavoriteHeart toggles whether the dinner is a favorite, at once, changing
// back with the error and Retry if the server refuses. One toggle is sent at
// a time.
function FavoriteHeart({ dinner }: { dinner: DinnerDetail }) {
const favorite = useToggleFavorite()
function toggle() {
if (!favorite.isPending) favorite.mutate({ id: dinner.id, is_favorite: dinner.is_favorite })
}
return (
<>
{favorite.isError && (
<div className={styles.status}>
<ErrorBanner onRetry={toggle}>
Couldn't {dinner.is_favorite ? 'unfavorite' : 'favorite'} {dinner.name}. {favorite.error.message}
</ErrorBanner>
</div>
)}
<IconButton label="Favorite" size={52} aria-pressed={dinner.is_favorite} onClick={toggle}>
<Icon icon={Heart} size="nav" className={dinner.is_favorite ? styles.filled : undefined} />
</IconButton>
</>
)
}
// ActionBar is the bar at the bottom of the screen, the page's last child:
// the page fills the scroll area so a short recipe still leaves it at the
// bottom, and it sticks there over a long one. It holds Add to this week
// and the favorite heart, with what became of either above them, and in
// Kitchen reading mode the step buttons after them.
function ActionBar({ dinner, children }: { dinner: DinnerDetail; children?: ReactNode }) {
return (
<div className={styles.bar}>
<AddToWeek dinner={dinner} />
<FavoriteHeart dinner={dinner} />
{children}
</div>
)
}
// Detail is a dinner as mock 1k shows it: the photo full-bleed under a sheet
// with its tags, title, why it was picked and its recipe, over the action bar.
function Detail({ dinner }: { dinner: DinnerDetail }) {
const tags = [
dinner.difficulty !== null && difficultyLabels[dinner.difficulty],
dinner.minutes_total !== null && duration(dinner.minutes_total),
].filter((tag) => tag !== false)
return (
<>
<div className={styles.hero}>
<Photo id={dinner.photo_id} name={dinner.name} radius="0" sizes="100vw" className={styles.photo} />
<BackButton />
</div>
<article className={styles.sheet}>
<div className={styles.tags}>
<Tag tone="grape">{dinner.status === 'regular' ? 'Regular' : 'Side quest'}</Tag>
{tags.map((tag) => (
<Tag key={tag} tone="neutral">
{tag}
</Tag>
))}
</div>
<h1 className={styles.title}>{dinner.name}</h1>
{dinner.why_picked !== null && (
<p className={styles.why}>
<b>Why it was picked:</b> {dinner.why_picked}
</p>
)}
<Recipe dinner={dinner} />
</article>
<ActionBar dinner={dinner} />
</>
)
}
// statusName is the dinner as the household calls it: a Regular, or a Side
// quest for any dinner not yet one.
function statusName(dinner: DinnerDetail): string {
return dinner.status === 'regular' ? 'Regular' : 'Side quest'
}
// PlannedDay is the kicker of a dinner on a plan, "Saturday, Oct 10 · Side
// quest", whenever this week's or next week's plan has it. Of the days that
// do, it is the first from today on in the household's timezone; when every
// one is already past, it is the latest of them. There is none while the
// plans are read, nor when neither plan has the dinner.
function PlannedDay({ dinner, timezone }: { dinner: DinnerDetail; timezone: string }) {
const today = localNow(timezone, new Date()).date
const thisWeek = weekStart(today)
const plans = [usePlan(thisWeek), usePlan(addDays(thisWeek, 7))]
const failed = plans.find((plan) => plan.isError)
if (failed !== undefined) {
return (
<ErrorBanner onRetry={() => plans.forEach((plan) => plan.isError && void plan.refetch())}>
Couldn't check the plan for this dinner's day. {failed.error.message}
</ErrorBanner>
)
}
const planned = plans.flatMap((plan) => plan.data?.days ?? []).filter((day) => day.dinner.id === dinner.id)
const day = planned.find((day) => day.date >= today) ?? planned.at(-1)
if (day === undefined) return null
return (
<p className={styles.kicker}>
{dayAndDate(day.date)} · {statusName(dinner)}
</p>
)
}
// Kicker is PlannedDay, once the household's timezone is read.
function Kicker({ dinner }: { dinner: DinnerDetail }) {
const settings = useHouseholdSettings()
if (settings.isError) {
return (
<ErrorBanner onRetry={() => void settings.refetch()}>
Couldn't load the household settings to find this dinner's day. {settings.error.message}
</ErrorBanner>
)
}
if (settings.isPending) return null
return <PlannedDay dinner={dinner} timezone={settings.data.timezone} />
}
const textSizes: SegmentedOption<TextSize>[] = [
{ value: 'small', label: 'A', ariaLabel: 'Small text' },
{ value: 'medium', label: 'A', ariaLabel: 'Medium text' },
{ value: 'large', label: 'A', ariaLabel: 'Large text' },
]
// ReadingHeader is Kitchen reading mode's header: back, the day the dinner
// is planned for, its title, Keep screen on and the text size.
function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: ReturnType<typeof useTextSize> }) {
const keepOnId = useId()
const keepOnHintId = useId()
const keepScreenOn = useKeepScreenOn()
const { failure } = textSize
return (
<>
<header className={styles.readingHeader}>
<BackButton />
<div className={styles.heading}>
<Kicker dinner={dinner} />
<h1 className={styles.readingTitle}>{dinner.name}</h1>
</div>
<div className={styles.keepOn}>
<span id={keepOnId}>Keep screen on</span>
<Toggle
checked={keepScreenOn.on}
onCheckedChange={keepScreenOn.setOn}
aria-labelledby={keepOnId}
disabled={!keepScreenOn.supported}
aria-describedby={keepScreenOn.supported ? undefined : keepOnHintId}
/>
</div>
<Segmented
label="Text size"
options={textSizes}
value={textSize.size}
onValueChange={textSize.choose}
className={styles.textSize}
/>
</header>
{!keepScreenOn.supported && (
<p id={keepOnHintId} className={styles.keepOnHint}>
This browser can't keep the screen on, so it may dim while you cook.
</p>
)}
{keepScreenOn.failure !== null && (
<ErrorBanner onRetry={() => keepScreenOn.setOn(true)}>
Couldn't keep the screen on: {keepScreenOn.failure}
</ErrorBanner>
)}
{failure !== null && (
<ErrorBanner onRetry={textSize.retry}>
{textSizeFailureText[failure.action]}: {failure.message}
</ErrorBanner>
)}
</>
)
}
// IngredientsCard is the ingredients with a checkbox each, struck through
// once set out, and how many are. Which are set out is this visit's alone:
// it is never sent to the server and is not the Supply run's "have it".
function IngredientsCard({ ingredients }: { ingredients: Ingredient[] }) {
const titleId = useId()
const [setOut, setSetOut] = useState<ReadonlySet<number>>(() => new Set())
const count = ingredients.filter((_, i) => setOut.has(i)).length
function toggle(i: number) {
setSetOut((before) => {
const after = new Set(before)
if (!after.delete(i)) after.add(i)
return after
})
}
return (
<Card role="region" aria-labelledby={titleId} className={styles.ingredientsCard}>
<div className={styles.cardHead}>
<h2 id={titleId} className={styles.readingSectionTitle}>
Ingredients
</h2>
<span className={styles.setOutCount}>
{count} of {ingredients.length} set out
</span>
</div>
<ul className={styles.checklist}>
{ingredients.map((ingredient, i) => (
<li key={i} className={styles.checkRow}>
<Checkbox
className={styles.check}
checked={setOut.has(i)}
onChange={() => toggle(i)}
label={
<span className={[styles.checkName, setOut.has(i) && styles.setOut].filter(Boolean).join(' ')}>
{ingredient.name ?? ingredient.raw_text}
{ingredient.name !== null && ingredient.note !== null && `, ${ingredient.note}`}
</span>
}
/>
<span className={styles.checkQty}>{quantity(ingredient)}</span>
</li>
))}
</ul>
</Card>
)
}
// Steps is the recipe's steps: those before current done, muted with a
// check; current highlighted in larger text; the rest still to come.
function Steps({ steps, current }: { steps: string[]; current: number }) {
const titleId = useId()
return (
<section aria-labelledby={titleId} className={styles.readingSteps}>
<h2 id={titleId} className={styles.readingSectionTitle}>
Steps
</h2>
<ol className={styles.stepList}>
{steps.map((step, i) => (
<li
key={i}
aria-current={i === current ? 'step' : undefined}
className={[styles.readingStep, i < current ? styles.stepDone : i === current ? styles.stepCurrent : undefined]
.filter(Boolean)
.join(' ')}
>
<span className={styles.stepCircle} aria-hidden="true">
{i < current ? <Icon icon={Check} /> : i + 1}
</span>
<span className={styles.stepText}>{step}</span>
</li>
))}
</ol>
</section>
)
}
// typing says whether target is a field taking text, where the arrow keys
// move the caret and are not the screen's to take.
function typing(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
return target.isContentEditable || target.matches('textarea, select, input:not([type="checkbox"], [type="radio"])')
}
// useCurrentStep is the step being cooked, of count, from the first, with
// move, which goes back or on by a step and stops at either end. The ← and →
// keys move it too, unless a text field has focus, a sheet is open over the
// screen or the key was already taken (a segmented control's arrows).
function useCurrentStep(count: number): [number, (by: number) => void] {
const [step, setStep] = useState(0)
const last = count - 1
const move = useCallback(
(by: number) => setStep((before) => Math.max(0, Math.min(Math.min(before, last) + by, last))),
[last],
)
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
const by = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
if (by === 0 || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
if (typing(event.target) || (event.target instanceof Element && event.target.closest('[role="dialog"]') !== null)) return
event.preventDefault()
move(by)
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [move])
return [Math.max(0, Math.min(step, last)), move]
}
// KitchenReading is a dinner as mock 2a shows it on a tablet: the header,
// then the ingredients to check off beside the photo, why it was picked and
// the steps, one at a time, over the action bar with Previous and Next step.
// What is checked and the step reached last only as long as this visit.
function KitchenReading({ dinner }: { dinner: DinnerDetail }) {
const textSize = useTextSize()
const [current, move] = useCurrentStep(dinner.steps.length)
const recipe = hasRecipe(dinner)
const last = dinner.steps.length - 1
return (
<div className={[styles.reading, styles[textSize.size]].join(' ')}>
<ReadingHeader dinner={dinner} textSize={textSize} />
<div className={recipe && dinner.ingredients.length > 0 ? styles.columns : styles.oneColumn}>
{recipe && dinner.ingredients.length > 0 && <IngredientsCard ingredients={dinner.ingredients} />}
<div className={styles.method}>
<div className={styles.intro}>
<Photo id={dinner.photo_id} name={dinner.name} radius="24px" sizes="400px" className={styles.introPhoto} />
{dinner.why_picked !== null && (
<p className={[styles.why, styles.introWhy].join(' ')}>
<b>Why it was picked:</b> {dinner.why_picked}
</p>
)}
</div>
{!recipe && <RecipeMissing dinner={dinner} />}
{recipe && dinner.steps.length > 0 && <Steps steps={dinner.steps} current={current} />}
{recipe && dinner.source !== null && (
<SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />
)}
<ActionBar dinner={dinner}>
{recipe && dinner.steps.length > 0 && (
<div className={styles.stepButtons}>
<Button variant="outline" disabled={current === 0} onClick={() => move(-1)}>
<Icon icon={ChevronLeft} />
Previous
</Button>
<Button disabled={current === last} onClick={() => move(1)}>
<Icon icon={ChevronRight} />
Next step
</Button>
</div>
)}
</ActionBar>
</div>
</div>
</div>
)
}
// DinnerDetailPage is a dinner with its recipe: on a phone (mock 1k) its
// photo, or the striped placeholder, under a sheet with Regular or Side
// quest, difficulty and time, why it was picked, the ingredients and the
// steps; from tablet up, Kitchen reading mode (mock 2a). A dinner whose
// recipe could not be found links to Settings → Regular dinners to fix it;
// an unknown or removed dinner says it is not found.
export function DinnerDetailPage() {
const { id = '' } = useParams()
const layout = useBreakpoint()
const dinner = useDinnerDetail(id)
let body
if (dinner.isError) {
body = (
<>
<div className={styles.backRow}>
<BackButton />
</div>
{dinner.error instanceof ApiError && dinner.error.status === 404 ? (
<>
<EmptyState title="Dinner not found">This dinner isn't in our kitchen — it may have been removed.</EmptyState>
<Link to="/" className={[buttonStyles.button, buttonStyles.outline].join(' ')}>
Go Home
</Link>
</>
) : (
<ErrorBanner onRetry={() => void dinner.refetch()}>Couldn't load this dinner. {dinner.error.message}</ErrorBanner>
)}
</>
)
} else if (dinner.isPending) {
body = (
<section aria-busy="true" aria-label="Dinner loading">
<Skeleton className={styles.skeletonHero} />
<div className={styles.sheet}>
<Skeleton className={styles.skeletonTitle} />
<Skeleton />
<Skeleton />
</div>
</section>
)
} else {
// Keyed by the dinner, so moving to another dinner starts its visit
// afresh: nothing checked, on the first step.
body =
layout === 'mobile' ? <Detail dinner={dinner.data} /> : <KitchenReading key={dinner.data.id} dinner={dinner.data} />
}
return <div className={`${styles.page} ${styles[layout]}`}>{body}</div>
}
+604
View File
@@ -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<string, unknown> = {}) {
return {
id: 'd1',
name: 'Korean beef bowls',
status: 'quest_log',
origin: 'suggested',
method: 'stovetop',
minutes_total: 30,
difficulty: 'easy',
cuisine: 'korean',
is_favorite: false,
photo_urls: null,
ingredients: [
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
{ name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' },
],
steps: [
'Rinse the rice and start it cooking.',
'Brown the beef with the garlic and ginger.',
'Stir in the sauce and simmer until glossy.',
],
source: null,
grounding_status: 'grounded',
grounding_error: null,
why_picked: 'close cousin of your Beef tacos.',
...overrides,
}
}
// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it: p0 to
// p6, except the bowls (d1) on each day of bowlsOn, counting from Monday = 0.
function planBody(weekStart: string, ...bowlsOn: number[]) {
const start = new Date(`${weekStart}T00:00:00Z`).getTime()
return {
id: `plan-${weekStart}`,
week_start: weekStart,
status: 'draft',
ai_note: null,
calendar_synced_at: null,
days: Array.from({ length: 7 }, (_, i) => ({
date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
dinner:
bowlsOn.includes(i)
? { id: 'd1', name: 'Korean beef bowls', photo: null, minutes: 30, method: 'stovetop' }
: { id: `p${i}`, name: `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop' },
is_new: bowlsOn.includes(i),
start_by: null,
})),
}
}
// serve answers the screen's reads: the dinner, the household settings and
// both weeks' plans, unless responses says otherwise.
function serve(responses: Record<string, () => Response> = {}): Mock<typeof fetch> {
const all: Record<string, () => Response> = {
[DINNER]: () => jsonResponse(200, detail()),
[SETTINGS]: () => jsonResponse(200, HOUSEHOLD_SETTINGS),
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 5)),
[NEXT_WEEK]: () => errorResponse(404, 'not_found'),
...responses,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const path = String(input)
const respond = Object.hasOwn(all, path) ? all[path] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`)
return Promise.resolve(respond())
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
function reads(fetchMock: Mock<typeof fetch>, path: string) {
return fetchMock.mock.calls.filter(([input]) => String(input) === path).length
}
// renderPage renders the bowls' detail screen and waits for its title.
async function renderPage() {
const router = createMemoryRouter([{ path: '/dinner/:id', element: <DinnerDetailPage /> }], {
initialEntries: ['/dinner/d1'],
})
const view = render(
<ApiProviders>
<RouterProvider router={router} />
</ApiProviders>,
)
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
return view
}
function steps() {
return within(screen.getByRole('region', { name: 'Steps' })).getAllByRole('listitem')
}
// currentStep is the text of the step marked current.
function currentStep() {
const current = steps().filter((step) => step.getAttribute('aria-current') === 'step')
expect(current).toHaveLength(1)
return current[0]!.textContent
}
// FakeSentinel is a WakeLockSentinel as far as the screen uses one.
type FakeSentinel = EventTarget & { released: boolean; release: Mock<() => Promise<void>> }
// letGo releases a lock as the browser does, telling its listeners so.
function letGo(sentinel: FakeSentinel) {
if (sentinel.released) return
sentinel.released = true
sentinel.dispatchEvent(new Event('release'))
}
// stubWakeLock gives jsdom, which has none, a Screen Wake Lock. Each request
// is granted at once, or, given hold, only when the test calls grant.
function stubWakeLock({ hold = false } = {}) {
const sentinels: FakeSentinel[] = []
const pending: (() => void)[] = []
const request = vi.fn((type: string) => {
expect(type).toBe('screen')
const sentinel: FakeSentinel = Object.assign(new EventTarget(), {
released: false,
release: vi.fn(() => {
letGo(sentinel)
return Promise.resolve()
}),
})
sentinels.push(sentinel)
if (!hold) return Promise.resolve(sentinel)
return new Promise<FakeSentinel>((resolve) => pending.push(() => resolve(sentinel)))
})
Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request } })
return { request, sentinels, grant: () => pending.shift()!() }
}
// setVisibility shows or hides the page as the browser would, telling the
// page so.
function setVisibility(state: DocumentVisibilityState) {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
document.dispatchEvent(new Event('visibilitychange'))
}
beforeEach(() => {
mockViewport(1180)
// Wednesday Oct 7, noon in New York.
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
Reflect.deleteProperty(window, 'matchMedia')
Reflect.deleteProperty(navigator, 'wakeLock')
Reflect.deleteProperty(document, 'visibilityState')
vi.unstubAllGlobals()
})
describe('Kitchen reading mode', () => {
it('is the tablet layout of the same route: header with back, title, Keep screen on and text size', async () => {
stubWakeLock()
serve()
await renderPage()
const title = screen.getByRole('heading', { level: 1, name: 'Korean beef bowls' })
expect(declaredStyle(title).fontSize).toBe('36px')
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
// Not the phone's sheet: no photo hero tags.
expect(screen.queryByText('Easy')).not.toBeInTheDocument()
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
expect(keepOn).toHaveAttribute('aria-checked', 'false')
fireEvent.click(keepOn)
expect(keepOn).toHaveAttribute('aria-checked', 'true')
const sizes = screen.getByRole('radiogroup', { name: 'Text size' })
expect(within(sizes).getAllByRole('radio').map((radio) => radio.getAttribute('aria-label'))).toEqual([
'Small text',
'Medium text',
'Large text',
])
expect(within(sizes).getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true')
expect(declaredStyle(screen.getByRole('region', { name: 'Ingredients' }).parentElement!).gridTemplateColumns).toBe(
'400px minmax(0, 1fr)',
)
expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent('Why it was picked: close cousin')
})
describe('kicker', () => {
it('names the day the plan has the dinner on, and Side quest', async () => {
serve()
await renderPage()
expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument()
})
it("names a day of next week's plan, and Regular, for a Regular", async () => {
serve({
[DINNER]: () => jsonResponse(200, detail({ status: 'regular', origin: 'regular' })),
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05')),
[NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12', 0)),
})
await renderPage()
expect(await screen.findByText('Monday, Oct 12 · Regular')).toBeInTheDocument()
})
it("names a day of this week's plan already past when that is the only day", async () => {
// Today is Wednesday; the bowls were on Monday, and are on no day of next week.
serve({
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0)),
[NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12')),
})
await renderPage()
expect(await screen.findByText('Monday, Oct 5 · Side quest')).toBeInTheDocument()
})
it('names the first day from today on over a day already past', async () => {
serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1, 4, 6)) })
await renderPage()
expect(await screen.findByText('Friday, Oct 9 · Side quest')).toBeInTheDocument()
expect(screen.queryByText(/Monday|Tuesday|Sunday/)).not.toBeInTheDocument()
})
it('names the latest day already past when every day is past', async () => {
serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1)) })
await renderPage()
expect(await screen.findByText('Tuesday, Oct 6 · Side quest')).toBeInTheDocument()
expect(screen.queryByText(/Monday/)).not.toBeInTheDocument()
})
it('is absent when neither week has a plan', async () => {
const fetchMock = serve({ [THIS_WEEK]: () => errorResponse(404, 'not_found') })
await renderPage()
await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(1))
await waitFor(() => expect(reads(fetchMock, NEXT_WEEK)).toBe(1))
await act(async () => {})
expect(screen.queryByText(/· Side quest/)).not.toBeInTheDocument()
expect(screen.queryByText(/Oct/)).not.toBeInTheDocument()
})
it('says so when the plan cannot be read, with Retry', async () => {
let failing = true
serve({
[THIS_WEEK]: () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, planBody('2026-10-05', 5))),
})
await renderPage()
expect(await screen.findByText(/Couldn't check the plan for this dinner's day/)).toBeInTheDocument()
failing = false
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument()
})
})
describe('ingredients', () => {
it('checks ingredients off with 22 px checkboxes, struck through, counting those set out', async () => {
const fetchMock = serve()
await renderPage()
const card = screen.getByRole('region', { name: 'Ingredients' })
expect(within(card).getByText('0 of 3 set out')).toBeInTheDocument()
const beef = within(card).getByRole('checkbox', { name: 'Ground beef' })
expect(declaredStyle(beef).width).toBe('22px')
expect(declaredStyle(beef).height).toBe('22px')
expect(within(card).getByRole('checkbox', { name: 'Green onions, sliced' })).not.toBeChecked()
fireEvent.click(beef)
expect(beef).toBeChecked()
expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('line-through')
fireEvent.click(within(card).getByRole('checkbox', { name: 'Jasmine rice' }))
expect(within(card).getByText('2 of 3 set out')).toBeInTheDocument()
fireEvent.click(beef)
expect(beef).not.toBeChecked()
expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('')
expect(within(card).getByText('1 of 3 set out')).toBeInTheDocument()
// Local to the screen: nothing is sent.
expect(fetchMock.mock.calls.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')).toEqual([])
})
it('starts afresh on the next visit', async () => {
serve()
const view = await renderPage()
fireEvent.click(screen.getByRole('checkbox', { name: 'Ground beef' }))
fireEvent.click(screen.getByRole('button', { name: 'Next step' }))
view.unmount()
await renderPage()
expect(screen.getByText('0 of 3 set out')).toBeInTheDocument()
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
})
})
describe('steps', () => {
it('highlights the current step in 22 px text, others 18 px, and moves by Previous and Next step', async () => {
serve()
await renderPage()
const previous = screen.getByRole('button', { name: 'Previous' })
const next = screen.getByRole('button', { name: 'Next step' })
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
expect(previous).toBeDisabled()
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(22px * var(--reading-scale))')
expect(declaredStyle(steps()[0]!).background).toBe('var(--color-accent-100)')
expect(declaredStyle(steps()[0]!).boxShadow).toBe('inset 0 0 0 2px var(--color-accent-300)')
expect(declaredStyle(steps()[1]!).fontSize).toBe('calc(18px * var(--reading-scale))')
fireEvent.click(next)
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
// The step done is muted, its number a check.
expect(steps()[0]!.textContent).toBe('Rinse the rice and start it cooking.')
expect(steps()[0]!.querySelector('svg')).not.toBeNull()
expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-600)')
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(18px * var(--reading-scale))')
fireEvent.click(next)
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
expect(next).toBeDisabled()
fireEvent.click(next)
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
fireEvent.click(previous)
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
})
it('moves by the ← and → keys, stopping at either end', async () => {
serve()
await renderPage()
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
// From a checked ingredient too.
const beef = screen.getByRole('checkbox', { name: 'Ground beef' })
beef.focus()
fireEvent.keyDown(beef, { key: 'ArrowRight' })
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
})
it('leaves the keys alone in a text field, with a modifier, or in the text size control', async () => {
serve()
await renderPage()
const field = document.createElement('input')
document.body.append(field)
field.focus()
fireEvent.keyDown(field, { key: 'ArrowRight' })
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
field.remove()
fireEvent.keyDown(document.body, { key: 'ArrowRight', altKey: true })
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
const medium = screen.getByRole('radio', { name: 'Medium text' })
fireEvent.keyDown(medium, { key: 'ArrowRight' })
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
})
})
describe('text size', () => {
it('is remembered on this device', async () => {
serve()
const view = await renderPage()
fireEvent.click(screen.getByRole('radio', { name: 'Large text' }))
expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('large')
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
view.unmount()
await renderPage()
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'false')
expect(declaredStyle(screen.getByRole('heading', { level: 1 }).closest('header')!.parentElement!).getPropertyValue('--reading-scale')).toBe(
'1.25',
)
})
it('says so when the size saved is not one MealQuest saved, until one is chosen', async () => {
localStorage.setItem(TEXT_SIZE_KEY, 'huge')
serve()
await renderPage()
expect(screen.getByText(/Could not read the text size chosen on this device/)).toHaveTextContent('"huge"')
expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true')
fireEvent.click(screen.getByRole('radio', { name: 'Small text' }))
expect(screen.queryByText(/Could not read the text size/)).not.toBeInTheDocument()
expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('small')
})
})
describe('source credit', () => {
it.each([
['a site and a url', { site_name: 'Serious Eats', url: 'https://www.seriouseats.com/bowls' }, 'Recipe from Serious Eats'],
['only a url', { site_name: null, url: 'https://www.seriouseats.com/bowls' }, 'Recipe from www.seriouseats.com'],
])('links the recipe page in a new tab for %s', async (_, source, name) => {
serve({ [DINNER]: () => jsonResponse(200, detail({ source })) })
await renderPage()
const credit = screen.getByRole('link', { name })
expect(credit).toHaveAttribute('href', 'https://www.seriouseats.com/bowls')
expect(credit).toHaveAttribute('target', '_blank')
expect(credit).toHaveAttribute('rel', 'noopener noreferrer')
})
it.each([
['only a site', { site_name: 'Serious Eats', url: null }, 'Recipe from Serious Eats'],
['neither site nor url', { site_name: null, url: null }, 'Recipe from the web'],
])('is plain text for %s', async (_, source, text) => {
serve({ [DINNER]: () => jsonResponse(200, detail({ source })) })
await renderPage()
expect(screen.getByText(text).closest('a')).toBeNull()
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
})
it('is absent for a recipe typed in', async () => {
serve({ [DINNER]: () => jsonResponse(200, detail({ source: null, grounding_status: 'manual' })) })
await renderPage()
expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument()
})
})
it('shows what stands in for a recipe not found, with no step buttons', async () => {
serve({
[DINNER]: () =>
jsonResponse(200, detail({ ingredients: [], steps: [], grounding_status: 'failed', grounding_error: 'no_recipe_found' })),
})
await renderPage()
expect(screen.getByRole('link', { name: 'Fix recipe' })).toBeInTheDocument()
expect(screen.queryByRole('region', { name: 'Ingredients' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Next step' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add to this week' })).toBeInTheDocument()
})
describe('Keep screen on', () => {
it('holds a screen wake lock while on and releases it when turned off', async () => {
const wakeLock = stubWakeLock()
serve()
await renderPage()
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
expect(keepOn).toBeEnabled()
expect(wakeLock.request).not.toHaveBeenCalled()
fireEvent.click(keepOn)
expect(keepOn).toHaveAttribute('aria-checked', 'true')
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
expect(wakeLock.sentinels[0]!.release).not.toHaveBeenCalled()
fireEvent.click(keepOn)
expect(keepOn).toHaveAttribute('aria-checked', 'false')
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
// On again: a fresh lock.
fireEvent.click(keepOn)
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
expect(wakeLock.sentinels[1]!.released).toBe(false)
})
it('releases the lock when the screen is left', async () => {
const wakeLock = stubWakeLock()
serve()
const view = await renderPage()
fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' }))
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
view.unmount()
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
})
it('releases a lock granted only after the screen was left', async () => {
const wakeLock = stubWakeLock({ hold: true })
serve()
const view = await renderPage()
fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' }))
expect(wakeLock.request).toHaveBeenCalledTimes(1)
view.unmount()
await act(async () => wakeLock.grant())
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
})
it('asks for the lock again when the page is visible again while on, and not while off', async () => {
const wakeLock = stubWakeLock()
serve()
await renderPage()
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
fireEvent.click(keepOn)
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
// Hidden, the browser lets the lock go; nothing is asked while hidden.
act(() => setVisibility('hidden'))
act(() => letGo(wakeLock.sentinels[0]!))
expect(wakeLock.request).toHaveBeenCalledTimes(1)
act(() => setVisibility('visible'))
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
expect(keepOn).toHaveAttribute('aria-checked', 'true')
// Visible again with the lock still held: no second lock.
act(() => setVisibility('visible'))
expect(wakeLock.request).toHaveBeenCalledTimes(2)
fireEvent.click(keepOn)
act(() => setVisibility('hidden'))
act(() => setVisibility('visible'))
expect(wakeLock.request).toHaveBeenCalledTimes(2)
})
it('asks for the lock again when the browser lets it go while the page is visible', async () => {
const wakeLock = stubWakeLock()
serve()
await renderPage()
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
fireEvent.click(keepOn)
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
act(() => letGo(wakeLock.sentinels[0]!))
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
await act(async () => {})
expect(keepOn).toHaveAttribute('aria-checked', 'true')
// The fresh lock is watched too.
act(() => letGo(wakeLock.sentinels[1]!))
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(3))
expect(wakeLock.sentinels[2]!.released).toBe(false)
expect(keepOn).toHaveAttribute('aria-checked', 'true')
})
it('asks for the lock again when the page was hidden and shown while a request was pending', async () => {
const wakeLock = stubWakeLock({ hold: true })
serve()
await renderPage()
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
fireEvent.click(keepOn)
expect(wakeLock.request).toHaveBeenCalledTimes(1)
// Hidden and shown again before the first lock is granted, which the
// browser has let go by the time it is.
act(() => setVisibility('hidden'))
act(() => setVisibility('visible'))
expect(wakeLock.request).toHaveBeenCalledTimes(1)
letGo(wakeLock.sentinels[0]!)
await act(async () => wakeLock.grant())
expect(wakeLock.request).toHaveBeenCalledTimes(2)
await act(async () => wakeLock.grant())
expect(wakeLock.sentinels[1]!.released).toBe(false)
expect(wakeLock.request).toHaveBeenCalledTimes(2)
expect(keepOn).toHaveAttribute('aria-checked', 'true')
})
it('reports a lock the browser refuses and turns the switch back off', async () => {
const wakeLock = stubWakeLock()
wakeLock.request.mockRejectedValueOnce(new DOMException('Blocked by permissions policy', 'NotAllowedError'))
serve()
await renderPage()
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
fireEvent.click(keepOn)
expect(await screen.findByRole('alert')).toHaveTextContent(
"Couldn't keep the screen on: NotAllowedError: Blocked by permissions policy",
)
expect(keepOn).toHaveAttribute('aria-checked', 'false')
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(keepOn).toHaveAttribute('aria-checked', 'true')
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('is disabled, and says why, in a browser that cannot keep the screen on', async () => {
serve()
await renderPage()
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
expect(keepOn).toBeDisabled()
expect(keepOn).toHaveAttribute('aria-checked', 'false')
expect(keepOn).toHaveAccessibleDescription(
"This browser can't keep the screen on, so it may dim while you cook.",
)
fireEvent.click(keepOn)
expect(keepOn).toHaveAttribute('aria-checked', 'false')
})
})
})
+35 -14
View File
@@ -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<CachedRegular[]>(REGULARS_LIST_KEY, (list) =>
list?.map((r) => (r.id === id ? { ...r, is_favorite } : r)),
)
queryClient.setQueryData<DinnerDetail>(dinnerDetailKey(id), (dinner) => dinner && { ...dinner, is_favorite })
}
// useToggleFavorite toggles a dinner's favorite heart (POST
// /api/v1/dinners/{id}/favorite), a Regular's or any dinner's on its detail
// screen. The heart changes at once and changes back if the server refuses.
// The server answers with the dinner as the dinners list sends it, which
// replaces the Regular; the detail keeps its recipe and takes only the heart.
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<unknown>(`${dinnerPath(regular.id)}/favorite`, { method: 'POST' }),
await apiFetch<unknown>(`${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<CachedRegular[]>(REGULARS_LIST_KEY, (list) => list?.map((r) => (r.id === dinner.id ? dinner : r)))
queryClient.setQueryData<DinnerDetail>(dinnerDetailKey(dinner.id), (detail) => detail && { ...detail, is_favorite: dinner.is_favorite })
},
onError: (_error, dinner) => setFavorite(queryClient, dinner.id, dinner.is_favorite),
onSettled: () => settle(queryClient),
})
}
+178
View File
@@ -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<unknown>(dinnerPath(id))
return parseDinnerDetail(`GET ${DINNERS_PATH}/{id}`, read.body, read.cached)
},
networkMode: 'always',
refetchInterval: (query) => {
const dinner = query.state.data
return dinner?.grounding_status === 'pending' && !dinner.cached && !offline ? GROUNDING_POLL_MS : false
},
})
const { data, refetch } = query
const stalled = data?.grounding_status === 'pending' && data.cached
useEffect(() => {
if (stalled && !offline) void refetch()
}, [stalled, offline, refetch])
return query
}
+86
View File
@@ -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<string | null>(null)
const supported = 'wakeLock' in navigator
useEffect(() => {
if (!on) return
const wakeLock = navigator.wakeLock
let sentinel: WakeLockSentinel | null = null
let requesting = false
let left = false
// The browser may let a lock go while the switch is on: always when the
// page is hidden, and, the specification allows, while it is still
// visible. Either way the lock is asked for again at once if the page is
// visible, or when it is visible again if not, so the switch never claims
// a lock that is not held.
function onRelease() {
if (!left) acquire()
}
function acquire() {
if (document.visibilityState !== 'visible' || requesting) return
if (sentinel !== null && !sentinel.released) return
requesting = true
wakeLock.request('screen').then(
(granted) => {
requesting = false
if (left) {
void granted.release()
return
}
sentinel = granted
granted.addEventListener('release', onRelease)
// While the request was pending the page may have been hidden and
// shown again (that visibilitychange was ignored) and the lock let
// go before it could be listened for, so check again now.
acquire()
},
(err: unknown) => {
requesting = false
if (left) return
setFailure(String(err))
setOnState(false)
},
)
}
acquire()
document.addEventListener('visibilitychange', acquire)
return () => {
left = true
document.removeEventListener('visibilitychange', acquire)
if (sentinel !== null) void sentinel.release()
}
}, [on])
function setOn(next: boolean) {
if (next) setFailure(null)
setOnState(next)
}
return { on, setOn, supported, failure }
}
+52
View File
@@ -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 }
}
]
}
+63
View File
@@ -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<TextSizeState>(readTextSize)
const choose = (size: TextSize) => setState(writeTextSize(size))
const retry = () => setState(state.failure?.action === 'write' ? writeTextSize(state.size) : readTextSize())
return { ...state, choose, retry }
}
+10 -19
View File
@@ -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<string | null>(null)
const job = useJob<unknown>(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({
<ErrorBanner onRetry={() => void run.refetch()}>Couldn't load this week's shopping list. {run.error.message}</ErrorBanner>
)
// 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' ? (
<>
<DropBoard days={days} pending={swap.pending || pushing} drop={dropIn} />
<DropBoard days={days} pending={refused} drop={swapIn} />
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
{days.map((day) => (
<DayColumn
+35 -1
View File
@@ -331,9 +331,11 @@ function swapKey(weekStart: string) {
}
// SwapDay is the swaps of one week, as the mutation cache holds them:
// whether one is pending, and the failure of the latest, if it failed.
// whether one is pending, the latest if it succeeded, and the failure of the
// latest, if it failed.
export type SwapDay = {
pending: boolean
succeeded: Swap | null
failed: { swap: Swap; error: Error } | null
mutate: (chosen: Swap) => 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
+6 -1
View File
@@ -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)
+7
View File
@@ -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)
+6 -61
View File
@@ -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<string, Response>()
async match(request: RequestInfo | URL): Promise<Response | undefined> {
return this.entries.get(new Request(request).url)?.clone()
}
async put(request: RequestInfo | URL, response: Response): Promise<void> {
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<unknown>[] = []
waitUntil(promise: Promise<unknown>) {
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<Response> {
return answer(new Request(new URL(path, origin)))
}
async function answer(request: Request): Promise<Response> {
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<string, unknown> = { '/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)
})
})
+79
View File
@@ -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<string, Response>()
async match(request: RequestInfo | URL): Promise<Response | undefined> {
return this.entries.get(new Request(request).url)?.clone()
}
async put(request: RequestInfo | URL, response: Response): Promise<void> {
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<unknown>[] = []
waitUntil(promise: Promise<unknown>) {
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<Response> {
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<Response>) {
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 })
})
})
}
+10
View File
@@ -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;
+7
View File
@@ -26,6 +26,13 @@ describe('IconButton', () => {
expect(hitArea(face)).toEqual({ width: 40, height: 40 })
})
it('is 52 px at the medium size', () => {
render(<IconButton label="Favorite" size={52} />)
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(<IconButton label="Try it" size={62} tone="teal" />)
const button = screen.getByRole('button', { name: 'Try it' })
+3 -2
View File
@@ -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<ComponentPropsWithRef<'button'>, 'aria-label'> & {
@@ -15,7 +15,8 @@ export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, '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,
+10
View File
@@ -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(<Segmented label="Text size" options={sizes} value="large" onValueChange={() => {}} />)
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(<Segmented label="Archive period" options={options} value={'decade' as Period} onValueChange={() => {}} />),
+4
View File
@@ -4,6 +4,9 @@ import styles from './Segmented.module.css'
export type SegmentedOption<T extends string> = {
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<T extends string> = {
@@ -63,6 +66,7 @@ export function Segmented<T extends string>({ 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)}