Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 57fa849ab4 Keep the recipe action bar at the bottom and pause polling offline
CI / ci (push) Successful in 5m38s
CI / ci (pull_request) Successful in 5m4s
The detail page now fills the shell's scroll area (min-height: 100% of
a stretched flex item) and the sheet grows into the room left, so on a
short or failed recipe Add to this week and the heart rest at the
bottom of the screen; over a long recipe the bar still sticks there.

useDinnerDetail reads through apiRead and keeps whether the answer was
the service worker's saved copy. A pending dinner is polled only while
the network status says the server can be reached and the last answer
was not a saved copy; a saved pending copy is read again once the
connection comes back. TanStack Query clears the interval on unmount.

The credit stays plain text. source.url is parsed when present (https,
no userinfo, query or fragment, else the read fails) and makes "Recipe
from {site}" a new-tab link; today's API never sends it.

Maestro task 70a903ef-107c-444e-b3b0-fb3d37e8fb7d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:10:26 -04:00
dev_agentandClaude Opus 5.5 6d7c57eced Build the mobile recipe detail screen (mock 1k)
CI / ci (push) Successful in 5m9s
CI / ci (pull_request) Successful in 5m4s
Replace the /dinner/:id placeholder with DinnerDetailPage, reading
GET /api/v1/dinners/{id} through useDinnerDetail: a 250 px full-bleed
washed photo (striped placeholder without one) with a round back button
that goes Home from a deep link; a sheet overlapping by 28 px with the
Regular or Side quest, difficulty and time tags, the 28 px Bagel title,
the teal "Why it was picked" box, ingredients in recipe order with
quantities right-aligned (raw_text when a line has no name), numbered
steps and "Recipe from {site}" when the server names a site. The API
sends no source link, so the credit is text, not a link.

A dinner whose recipe was not found links to Settings -> Regular
dinners instead of showing a recipe; a pending one says it is still
searching and is read again until it lands. A 404 is a "Dinner not
found" state; other failures retry. The sticky bar renders "Add to this
week" and the heart (showing is_favorite) unavailable until task
07423ba7 wires them. IconButton gains a 52 px size for the heart.

Maestro task 70a903ef-107c-444e-b3b0-fb3d37e8fb7d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:53:52 -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
orchestrator-agent cb451baed4 Feature 17: Shopping List and Supply Archive (#140)
CI / ci (push) Successful in 5m45s
CI / ci (pull_request) Successful in 5m23s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 15:16:51 -04:00
29 changed files with 2428 additions and 39 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
+218
View File
@@ -0,0 +1,218 @@
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. It has no url: the page's
// link, pasted or offered as a candidate, may carry a personal token, and
// no stored link can be shown to be free of one, so like a dinner's
// recipe_url and a candidate's page it never leaves the server. 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), so a
// client shows "Recipe from {site}" only when SiteName is not null.
type Source struct {
SiteName *string `json:"site_name"`
}
// 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}
}
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 attribution with them: a recipe with no
// attribution 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
}
// 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
@@ -572,7 +572,12 @@ func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeUR
// 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 {
+44
View File
@@ -1,6 +1,7 @@
package dinners_test
import (
"database/sql"
"encoding/json"
"errors"
"strings"
@@ -74,3 +75,46 @@ 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)
}
}
func deref(s *string) any {
if s == nil {
return nil
}
return *s
}
+3
View File
@@ -71,3 +71,6 @@ 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
+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;
+404
View File
@@ -0,0 +1,404 @@
package server
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"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")},
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)
}
}
// TestGetDinnerSourceHidesTokenURL: a recipe grounded in a pasted link that
// carries a token in its path or query shows its site name, but never the
// link or any part of it.
func TestGetDinnerSourceHidesTokenURL(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
const (
pathToken = "path-token-abc123"
queryToken = "query-token-def456"
pathPage = "https://secret-path.example/u/" + pathToken + "/chicken-curry"
queryPage = "https://secret-query.example/curry?access_token=" + queryToken
)
e.pages.Bodies[pathPage] = 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)
for _, tc := range []struct{ dish, page, site string }{
{"Chicken curry", pathPage, "Curry House"},
{"Weeknight curry", queryPage, "B Kitchen"},
} {
id := createDinner(t, e.h, `{"name":"`+tc.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":"`+tc.page+`"}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground %s = %d: %s", tc.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 != tc.page {
t.Fatalf("stored source_url = %q, want %q", stored, tc.page)
}
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET dinner = %d: %s", rec.Code, rec.Body)
}
for _, secret := range []string{tc.page, "secret-path.example", "secret-query.example", pathToken, queryToken, "access_token", `"url"`} {
if strings.Contains(rec.Body.String(), secret) {
t.Errorf("detail of %s contains %q: %s", tc.dish, secret, rec.Body)
}
}
raw, _ := getDetail(t, e.h, id)
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": tc.site}) {
t.Errorf("source of %s = %v, want only site_name %q", tc.dish, raw["source"], tc.site)
}
}
}
// 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.
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"
named = "https://www." + namedToken + ".example.com/recipe"
)
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}) {
t.Errorf("source of %s = %v, want only site_name %v", 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", `"url"`}, 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", `"url"`}, "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), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+2 -1
View File
@@ -39,7 +39,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
@@ -130,6 +130,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
@@ -35,6 +35,7 @@ const SCREENS = {
'/plan/shopping': true,
'/plan/shopping/archive': true,
'/discover': true,
'/dinner/:id': true,
'/settings': true,
'/settings/ai': true,
'/settings/household': true,
@@ -299,7 +300,7 @@ describe('App offline bootstrap', () => {
it.each([
['/quick', 'Quick dinners'],
['/dinner/01J9ZK', 'Dinner'],
['/cook-from-pantry', 'Cook from what I have'],
])('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 { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
@@ -45,6 +46,7 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/plan/shopping': <SupplyRunPage />,
'/plan/shopping/archive': <SupplyArchivePage />,
'/discover': <DiscoverPage />,
'/dinner/:id': <DinnerDetailPage />,
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
'/settings/household': <HouseholdSettingsPage />,
+240
View File
@@ -0,0 +1,240 @@
/* Mock 1k. On mobile the photo and the sheet run full-bleed past the
shell's 20 px side padding; from tablet up they sit in one column. 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 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);
}
.tablet,
.desktop {
max-width: 720px;
margin: 0 auto;
}
.hero {
position: relative;
height: 250px;
}
.mobile .hero,
.mobile .skeletonHero {
margin: 0 -20px;
}
.tablet .hero,
.desktop .hero {
border-radius: var(--radius-card) var(--radius-card) 0 0;
overflow: hidden;
}
/* 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;
align-items: center;
gap: 10px;
margin-top: 24px;
padding: 10px 0;
background: var(--color-bg);
}
.bar > :first-child {
flex: 1;
}
.add {
width: 100%;
min-height: 52px;
font-size: 16px;
}
.filled {
color: var(--color-accent);
fill: currentColor;
}
.skeletonHero {
height: 250px;
border-radius: 0;
}
.skeletonTitle {
height: 32px;
width: 70%;
}
+443
View File
@@ -0,0 +1,443 @@
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'
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()
})
it('leaves the credit out when the server names no site', async () => {
serve(detail({ source: { site_name: null } }))
renderAt(['/dinner/d1'])
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument()
})
it('holds Add to this week and the heart at the bottom, at rest until the next update', async () => {
serve(detail({ is_favorite: true }))
renderAt(['/dinner/d1'])
const add = await screen.findByRole('button', { name: 'Add to this week' })
expect(add).toHaveAttribute('aria-disabled', 'true')
expect(add).toHaveAccessibleDescription('Adding to this week and the favorite heart arrive in the next update.')
const heart = screen.getByRole('button', { name: 'Favorite' })
expect(heart).toBeDisabled()
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('/')
})
})
})
+225
View File
@@ -0,0 +1,225 @@
import { ChevronLeft, Heart } from 'lucide-react'
import { Link, useLocation, useNavigate, useParams } from 'react-router'
import { ApiError } from '../api/errors.ts'
import { duration, Unavailable } from '../plan/PlanPage.tsx'
import { useBreakpoint } from '../shell/useBreakpoint.ts'
import buttonStyles from '../ui/Button.module.css'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Photo } from '../ui/Photo.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import type { Difficulty } from './api.ts'
import { useDinnerDetail, type DinnerDetail, type Ingredient, type RecipeSource } from './detail.ts'
import styles from './DinnerDetailPage.module.css'
// REGULAR_DINNERS is Settings → Regular dinners, where a recipe is fixed.
export const REGULAR_DINNERS = '/settings/regulars'
// ADD_HINT is why Add to this week and the heart are unavailable.
// TODO(07423ba7): the add-to-week sheet and the favorite heart.
const ADD_HINT = 'Adding to this week and the favorite heart arrive in the next update.'
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}": a link opening the recipe's page in
// a new tab when the server sends a url safe to open, else plain text.
function SourceCredit({ siteName, url }: RecipeSource & { siteName: string }) {
const credit = `Recipe from ${siteName}`
return (
<p className={styles.source}>
{url === null ? (
credit
) : (
<a href={url} target="_blank" rel="noopener noreferrer">
{credit}
</a>
)}
</p>
)
}
// Recipe is the dinner's ingredients and steps, or what stands in for them:
// still searching, or the link to fix a recipe that could not be found.
function Recipe({ 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>
)
}
if (dinner.grounding_status === 'pending') {
return <p className={styles.missing}>Still finding the recipe — its ingredients and steps will show here.</p>
}
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 && dinner.source.siteName !== null && (
<SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />
)}
</>
)
}
// 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, showing whether the dinner is a favorite. Both
// rest unavailable until the next update wires them.
function ActionBar({ dinner }: { dinner: DinnerDetail }) {
return (
<div className={styles.bar}>
<Unavailable hint={ADD_HINT} className={styles.add}>
Add to this week
</Unavailable>
<IconButton label="Favorite" size={52} aria-pressed={dinner.is_favorite} disabled>
<Icon icon={Heart} size="nav" className={dinner.is_favorite ? styles.filled : undefined} />
</IconButton>
</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} />
</>
)
}
// DinnerDetailPage is a dinner with its recipe (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. 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 {
body = <Detail dinner={dinner.data} />
}
return <div className={`${styles.page} ${styles[layout]}`}>{body}</div>
}
+1 -1
View File
@@ -12,7 +12,7 @@ export const GROUNDING_POLL_MS = 3000
// Methods and Difficulties are the values the dinners API allows.
export const METHODS = ['stovetop', 'oven', 'crockpot', 'grill', 'other'] as const
export const DIFFICULTIES = ['easy', 'medium', 'hard'] as const
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]
+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,
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: today's API never sends the stored link, which can
// carry a token, so the credit is text until the server sends a url it has
// checked is free of secrets.
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, which today's API does not send, 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: ['dinners', 'detail', 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
}
+10
View File
@@ -414,6 +414,16 @@
text-align: center;
}
/* What became of the week's Supply run after a push. */
.pushed {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
text-align: center;
color: var(--color-neutral-700);
}
/* The way to the week's Supply run under the Draft board on mobile. */
.shoppingLink {
display: inline-flex;
+288 -11
View File
@@ -1,9 +1,10 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter, Route, Routes, useLocation } from 'react-router'
import { Link, MemoryRouter, Route, Routes, useLocation } 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 { SupplyRunPage } from '../shopping/SupplyRunPage.tsx'
import { cacheFallbackHeader } from '../sw/routes.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
@@ -52,9 +53,33 @@ function planBody(status: string, names = NAMES) {
}
// syncedNow is the plan of the week of Oct 5 as a push answers it: synced
// at the tests' noon.
function syncedNow() {
return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z' }
// at the tests' noon, with what became of the week's Supply run, made
// unless told otherwise.
function syncedNow(supplyRun: { status: string; error: { code: string; message: string; retryable: boolean } | null } = GENERATED) {
return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z', supply_run: supplyRun }
}
// The Supply run outcomes a push answers with, as the server sends them.
const GENERATED = { status: 'generated', error: null }
const ARCHIVED = {
status: 'archived',
error: { code: 'archived', message: 'the Supply run is in the Supply archive', retryable: false },
}
const PLAN_CHANGED = {
status: 'failed',
error: {
code: 'plan_changed',
message: 'the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again',
retryable: true,
},
}
const SUPPLY_RUN_FAILED = {
status: 'failed',
error: {
code: 'supply_run_failed',
message: 'the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run',
retryable: true,
},
}
// deferred is a response the test sends when it chooses.
@@ -135,17 +160,17 @@ function planReads(fetchMock: Mock<typeof fetch>) {
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the Plan page.
async function renderPage() {
// the Plan page, over queryClient, with shopping as the Supply run screen.
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />) {
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/plan']}>
<Routes>
<Route path="/plan" element={<PlanPage />} />
<Route path="/plan/shopping" element={<Opened />} />
<Route path="/plan/shopping" element={shopping} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
@@ -153,10 +178,32 @@ async function renderPage() {
)
}
// Opened stands in for the screen a link opened, saying where it is.
// Opened stands in for the screen a link opened, saying where it is, with
// a link back to the Plan.
function Opened() {
const { pathname, search } = useLocation()
return <p>Opened {pathname + search}</p>
return (
<>
<p>Opened {pathname + search}</p>
<Link to="/plan">Back to the Plan</Link>
</>
)
}
// pushesKept counts the pushes of the week of Oct 5 the mutation cache of
// queryClient keeps.
function pushesKept(queryClient: QueryClient) {
return queryClient.getMutationCache().findAll({ mutationKey: ['plans', '2026-10-05', 'push'] }).length
}
// SupplyRunScreen is the Supply run screen, with a link back to the Plan.
function SupplyRunScreen() {
return (
<>
<SupplyRunPage />
<Link to="/plan">Back to the Plan</Link>
</>
)
}
// supplyRun is the Supply run of the week of Oct 5 with needed of its 32
@@ -420,6 +467,236 @@ describe('PlanPage', () => {
expect(planReads(fetchMock)).toBe(2)
})
it("reads the week's Supply run again once a push has made it, and says it was made", async () => {
let run = () => errorResponse(404, 'not_found')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => {
run = () => jsonResponse(200, supplyRun(28))
return jsonResponse(200, syncedNow(GENERATED))
},
'GET /api/v1/shopping/2026-10-05': () => run(),
})
await renderPage()
expect(await screen.findByRole('link', { name: 'Shopping list' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
// The list made is read at once, not after the cached 404 goes stale.
expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/shopping/2026-10-05')).toHaveLength(2)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('says a Supply run in the Supply archive was left as it was', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(ARCHIVED)),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent("This week's Supply run is in the Supply archive, so it was left as it was.")
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('says no Supply run was made when the plan changed after it was pushed, reads the plan again, and pushes again on Retry', async () => {
let plan = planBody('draft')
let push = () => {
plan = planBody('changed', ['Lasagna', ...NAMES.slice(1)])
return jsonResponse(200, syncedNow(PLAN_CHANGED))
}
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
[`POST ${PUSH}`]: () => push(),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent(
'Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push it again.',
)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
// The plan edited meanwhile is the one shown, not the one pushed.
expect(await screen.findByText('Lasagna')).toBeInTheDocument()
expect(screen.getByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
expect(planReads(fetchMock)).toBe(2)
push = () => jsonResponse(200, syncedNow(GENERATED))
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`])
})
it('says the Supply run could not be made after the push, and makes the shopping list again', async () => {
let generate = () => errorResponse(500, 'internal')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => generate(),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
// Calendar has the plan, but the push is not reported a quiet success.
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
expect(screen.queryByRole('status')).not.toBeInTheDocument()
fireEvent.click(within(alert).getByRole('button', { name: 'Make the shopping list' }))
expect(
await screen.findByText("Pushed to Calendar, but this week's Supply run couldn't be made. Couldn't make the shopping list. internal message"),
).toBeInTheDocument()
generate = () => jsonResponse(200, { ...supplyRun(28), skipped: [] })
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
expect(posts(fetchMock)).toEqual([
`${PUSH} undefined`,
'/api/v1/shopping/2026-10-05/generate undefined',
'/api/v1/shopping/2026-10-05/generate undefined',
])
})
it('no longer says the Supply run could not be made once the shopping list is made, after leaving the Plan and coming back', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
// A Supply run never made is still said after leaving the Plan and
// coming back.
fireEvent.click(screen.getByRole('link', { name: 'Shopping list' }))
fireEvent.click(await screen.findByRole('link', { name: 'Back to the Plan' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
// Once made, it is not said again to be unmade.
fireEvent.click(screen.getByRole('link', { name: 'Shopping list · 28' }))
fireEvent.click(await screen.findByRole('link', { name: 'Back to the Plan' }))
expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()
})
it('still says the Supply run could not be made when making the shopping list fails after leaving the week', async () => {
const made = deferred()
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => made.response,
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
fireEvent.click(within(await screen.findByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Making the shopping list…')
// The week is left while the shopping list is being made, which fails.
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
await act(async () => made.send(errorResponse(500, 'internal')))
// Coming back, the Supply run is still said to be unmade, and can be
// made again.
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
expect(within(alert).getByRole('button', { name: 'Make the shopping list' })).toBeEnabled()
})
it('no longer says the Supply run could not be made once the shopping list is made after leaving the week', async () => {
const made = deferred()
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => made.response,
})
const queryClient = createQueryClient()
await renderPage(queryClient)
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
fireEvent.click(within(await screen.findByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Making the shopping list…')
// The push is kept while the week is left, until the list is made: it
// is then settled, not merely swept with the week.
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
expect(pushesKept(queryClient)).toBe(1)
await act(async () => made.send(jsonResponse(200, { ...supplyRun(28), skipped: [] })))
expect(pushesKept(queryClient)).toBe(0)
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()
})
it('no longer says the Supply run could not be made once the shopping list is made on the Supply run screen', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
})
const queryClient = createQueryClient()
await renderPage(queryClient, <SupplyRunScreen />)
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
// The list is made on the Supply run screen, not from the Plan's alert.
fireEvent.click(screen.getByRole('link', { name: 'Shopping list' }))
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
await waitFor(() => expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument())
expect(pushesKept(queryClient)).toBe(0)
fireEvent.click(screen.getByRole('link', { name: 'Back to the Plan' }))
expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()
})
it('still says the Supply run could not be made when making it on the Supply run screen fails', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
'POST /api/v1/shopping/2026-10-05/generate': () => errorResponse(500, 'internal'),
})
const queryClient = createQueryClient()
await renderPage(queryClient, <SupplyRunScreen />)
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
fireEvent.click(screen.getByRole('link', { name: 'Shopping list' }))
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't make the shopping list.")
expect(pushesKept(queryClient)).toBe(1)
fireEvent.click(screen.getByRole('link', { name: 'Back to the Plan' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
expect(within(alert).getByRole('button', { name: 'Make the shopping list' })).toBeEnabled()
})
it('pushes nothing again until the changed plan is read and shown', async () => {
let read: () => Response | Promise<Response> = () => jsonResponse(200, planBody('draft'))
let push = () => errorResponse(409, 'plan_changed')
+49 -3
View File
@@ -7,7 +7,7 @@ import { QUEST_LOG_KEY } from '../discover/api.ts'
import { Confirm } from '../settings/LeaveGuard.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { supplyRunScreen, useSupplyRun } from '../shopping/api.ts'
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button, type ButtonVariant } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
@@ -31,6 +31,7 @@ import {
type PlanDay,
type PlanDinner,
type PlanStatus,
type PushedSupplyRun,
type Swap,
} from './api.ts'
import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx'
@@ -88,6 +89,13 @@ const syncStatuses: Record<PlanStatus, string> = {
changed: 'Changes not pushed to Google Calendar',
}
// supplyRunOutcomes says what became of the week's Supply run after a push
// that made it or left it as it was.
const supplyRunOutcomes: Record<Exclude<PushedSupplyRun['status'], 'failed'>, string> = {
generated: "Made this week's Supply run from the plan.",
archived: "This week's Supply run is in the Supply archive, so it was left as it was.",
}
// duration is minutes as the plan manager's cards say it: "45 min", "6 hr",
// "1 hr 30 min", with no-break spaces so a narrow card wraps the line
// between the method and the duration, never inside the duration.
@@ -238,7 +246,9 @@ function SkeletonTiles({ label, layout, children }: { label: string; layout: Bre
//
// Push to Calendar, on either layout, sends the plan shown and shows it as
// synced once the server answers; a failure says why, linking to the Google
// Calendar settings when only they can fix it.
// Calendar settings when only they can fix it. What became of the week's
// Supply run is said too: made, left as it was in the Supply archive, or
// not made, with the way to make it.
function WeekBoard({
weekStart,
timezone,
@@ -259,6 +269,8 @@ function WeekBoard({
const swap = useSwapDay(weekStart)
const push = usePushToCalendar(weekStart)
const run = useSupplyRun(weekStart)
// Makes the week's Supply run again after a push could not make it.
const generate = useGenerateSupplyRun(weekStart)
const pushing = push.pending
// The date of the day being swapped, while the swap sheet is open.
const [swappingDate, setSwappingDate] = useState<string | null>(null)
@@ -328,6 +340,7 @@ function WeekBoard({
if (!canPush) return
swap.dismiss()
push.reset()
generate.reset()
push.mutate()
}
@@ -339,6 +352,7 @@ function WeekBoard({
setJobId(null)
swap.dismiss()
push.reset()
generate.reset()
mutate(
{ weekStart, confirm },
{
@@ -357,8 +371,33 @@ function WeekBoard({
else start(false)
}
const retryPush = cached ? () => void plan.refetch() : pushToCalendar
// makeShoppingList makes the week's Supply run again after a push could
// not make it. Once made, here or on the Supply run screen, the push is
// settled (useGenerateSupplyRun). A failure is the mutation's state, which
// generate.error reports.
function makeShoppingList() {
generate.mutate()
}
// A push's Supply run made again on the Plan stands for the push's, which
// is settled.
const supplyRun: PushedSupplyRun | null = generate.isSuccess ? { status: 'generated', error: null } : push.supplyRun
const failure = push.error !== null ? (
<PushFailure error={push.error} cached={cached} onRetry={cached ? () => void plan.refetch() : pushToCalendar} />
<PushFailure error={push.error} cached={cached} onRetry={retryPush} />
) : supplyRun?.status === 'failed' ? (
supplyRun.error.code === 'plan_changed' ? (
<ErrorBanner onRetry={retryPush}>
Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push
it again.
</ErrorBanner>
) : (
<ErrorBanner onRetry={generate.isPending ? undefined : makeShoppingList} retryLabel="Make the shopping list">
Pushed to Calendar, but this week's Supply run couldn't be made.
{generate.isPending
? ' Making the shopping list…'
: generate.isError && ` Couldn't make the shopping list. ${generate.error.message}`}
</ErrorBanner>
)
) : swap.failed !== null ? (
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
@@ -376,6 +415,11 @@ function WeekBoard({
) : (
<ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner>
)
const pushed = supplyRun !== null && supplyRun.status !== 'failed' && (
<p role="status" className={styles.pushed}>
{supplyRunOutcomes[supplyRun.status]}
</p>
)
// A failed read of the week's Supply run is shown, with Retry, after the
// Draft board's Shopping list link or under the plan manager's board; the
// Shopping list action still opens the Supply run. The count is that of a
@@ -391,6 +435,7 @@ function WeekBoard({
function swapIn(chosen: Swap) {
setSwappingDate(null)
push.reset()
generate.reset()
swap.mutate(chosen)
}
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
@@ -562,6 +607,7 @@ function WeekBoard({
{header}
{body}
{failure}
{pushed}
{layout !== 'mobile' && supplyRunFailure}
<Confirm
open={confirming}
+54 -2
View File
@@ -10,6 +10,21 @@ import { planKey, usePlan, usePushToCalendar, useSwapDay, type Plan, type PushTo
const WEEK = '2026-10-05'
const PLAN = `/api/v1/plans/${WEEK}`
// GENERATED is a push answer's Supply run when the push made it.
const GENERATED = { supply_run: { status: 'generated', error: null } }
// SUPPLY_RUN_FAILED is a push answer's Supply run when it could not be made.
const SUPPLY_RUN_FAILED = {
supply_run: {
status: 'failed',
error: {
code: 'supply_run_failed',
message: 'the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run',
retryable: true,
},
},
}
const MONDAY = `${PLAN}/days/2026-10-05`
const FRIDAY = `${PLAN}/days/2026-10-09`
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
@@ -318,7 +333,7 @@ describe('week mutations', () => {
// The push succeeds while the third week is shown: it is left behind,
// and the week's pending swap is not.
await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT })))
await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT, ...GENERATED })))
await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual([]))
expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success'])
@@ -338,6 +353,43 @@ describe('week mutations', () => {
expect(weekMutations(queryClient, LATER)).toEqual([])
})
it('keeps a push whose Supply run was not made, settled in a week no longer shown, to be shown on coming back', async () => {
const nextPush = deferred()
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
[`GET /api/v1/plans/${NEXT}`]: () => jsonResponse(200, { ...planBody(), week_start: NEXT }),
[`POST /api/v1/plans/${NEXT}/push-calendar`]: () => nextPush.reply,
})
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const queryClient = createQueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ApiProvider>
)
const hook = renderHook(({ week }) => ({ plan: usePlan(week), push: usePushToCalendar(week) }), {
wrapper,
initialProps: { week: NEXT },
})
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
// The push is pushed to Calendar while another week is shown, but its
// Supply run is not made: it stays, though it succeeded.
act(() => hook.result.current.push.mutate())
hook.rerender({ week: WEEK })
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
const synced = { ...planBody(), week_start: NEXT, status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' }
await act(async () => nextPush.send(jsonResponse(200, { ...synced, ...SUPPLY_RUN_FAILED })))
await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual(['push success']))
// Coming back mounts the week's board again, as the Plan's does.
hook.unmount()
const back = renderHook(() => usePushToCalendar(NEXT), { wrapper })
expect(back.result.current.error).toBeNull()
expect(back.result.current.supplyRun).toEqual(SUPPLY_RUN_FAILED.supply_run)
})
it('leaves behind what settles once Plan is left for another route, and keeps what is pending', async () => {
const friday = deferred()
const monday = deferred()
@@ -394,7 +446,7 @@ describe('week mutations', () => {
// The push succeeds: the plan as synced is recorded, and the push is
// left behind.
const synced = { ...planBody(), status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' }
await act(async () => push.send(jsonResponse(200, synced)))
await act(async () => push.send(jsonResponse(200, { ...synced, ...GENERATED })))
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual([]))
expect(queryClient.getQueryData<Plan>(planKey(WEEK))).toMatchObject({ status: 'synced', calendar_synced_at: new Date('2026-10-08T06:00:00Z') })
})
+120 -11
View File
@@ -5,6 +5,7 @@ import { isRecord } from '../api/errors.ts'
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
import { noteGoogleFailure } from '../settings/calendarApi.ts'
import { supplyRunKey } from '../shopping/api.ts'
export const PLANS_PATH = '/api/v1/plans'
@@ -114,6 +115,56 @@ function parsePlan(where: string, weekStart: string, body: unknown, cached: bool
}
}
// SupplyRunError is the API error a Push to Calendar's Supply run carries
// when it was not generated.
export type SupplyRunError<C extends string> = { code: C; message: string; retryable: boolean }
// PushedSupplyRun says whether a Push to Calendar made the week's Supply
// run: generated when it did; archived when the week's Supply run is in the
// Supply archive and was left as it was; or failed when it was not made.
// A failed one's error is plan_changed when the plan was edited after it
// was pushed, and pushing again makes it, or supply_run_failed when it
// could not be made, and making the shopping list again does.
export type PushedSupplyRun =
| { status: 'generated'; error: null }
| { status: 'archived'; error: SupplyRunError<'archived'> }
| { status: 'failed'; error: SupplyRunError<'plan_changed' | 'supply_run_failed'> }
// PushedPlan is the POST /api/v1/plans/{weekStart}/push-calendar answer:
// the plan as synced, and what became of the week's Supply run.
export type PushedPlan = { plan: Plan; supply_run: PushedSupplyRun }
const PUSHED_SUPPLY_RUN_STATUSES = ['generated', 'archived', 'failed'] as const
const FAILED_SUPPLY_RUN_CODES = ['plan_changed', 'supply_run_failed'] as const
function parseSupplyRunError<C extends string>(where: string, codes: readonly C[], body: unknown): SupplyRunError<C> {
if (!isRecord(body) || typeof body.message !== 'string' || typeof body.retryable !== 'boolean') {
throw new Error(`${where}: supply_run has an error that is not an API error`)
}
return { code: oneOf(where, 'supply_run.error.code', codes, body.code), message: body.message, retryable: body.retryable }
}
function parsePushedSupplyRun(where: string, body: unknown): PushedSupplyRun {
if (!isRecord(body)) throw new Error(`${where}: response has no supply_run`)
const status = oneOf(where, 'supply_run.status', PUSHED_SUPPLY_RUN_STATUSES, body.status)
switch (status) {
case 'generated':
if (body.error !== null) throw new Error(`${where}: a generated supply_run has an error`)
return { status, error: null }
case 'archived':
return { status, error: parseSupplyRunError(where, ['archived'] as const, body.error) }
case 'failed':
return { status, error: parseSupplyRunError(where, FAILED_SUPPLY_RUN_CODES, body.error) }
}
}
function parsePushedPlan(where: string, weekStart: string, body: unknown): PushedPlan {
return {
plan: parsePlan(where, weekStart, body, false),
supply_run: parsePushedSupplyRun(where, isRecord(body) ? body.supply_run : undefined),
}
}
// parseDraftResult checks a draft_week job's result, which useJob passes on
// unread, is the draft of the week starting on weekStart.
export function parseDraftResult(where: string, weekStart: string, body: unknown): DraftResult {
@@ -178,6 +229,19 @@ function sameDay(a: PlanDay, b: PlanDay): boolean {
)
}
// failed says whether a swap or push failed: a push whose Supply run was
// not made failed too, though Calendar has the plan.
function failed(mutation: Mutation): boolean {
return mutation.state.status === 'error' || unmade(mutation)
}
// unmade says whether a push succeeded but its Supply run was not made: the
// week is on Calendar with no shopping list until one is made.
function unmade(mutation: Mutation): boolean {
const data = mutation.state.data as PushedPlan | undefined
return mutation.state.status === 'success' && weekMutation(mutation)?.kind === 'push' && data?.supply_run.status === 'failed'
}
// weekMutation is the kind and week of a swap or push of a week the mutation
// cache keeps for it, or null for any other mutation.
function weekMutation(mutation: Mutation): { kind: 'swap' | 'push'; week: string } | null {
@@ -203,9 +267,13 @@ const weeksShown = new WeakMap<QueryClient, WeeksShown>()
// earlier pending one must see, stay across leaving its week and coming
// back. When one settles, the same is done, except that a week's latest,
// when it failed, stays until a week is next shown, so that coming straight
// back to it shows the failure. The cache holds at most the weeks shown's,
// those of weeks left pending, and one failure of each week whose last
// pending one settled since a week was last shown.
// back to it shows the failure. A week's latest push whose Supply run was
// not made stays whatever is shown: the week has no shopping list until one
// is made, which settles the push, or a push sent since replaces it, and
// making it may still be under way when the week is left. The cache holds
// at most the weeks shown's, those of weeks left pending, one push of each
// week whose Supply run was not made, and one failure of each week whose
// last pending one settled since a week was last shown.
function keepWeekMutations(queryClient: QueryClient): WeeksShown {
const kept = weeksShown.get(queryClient)
if (kept !== undefined) return kept
@@ -225,7 +293,7 @@ function keepWeekMutations(queryClient: QueryClient): WeeksShown {
if (week.some((mutation) => mutation.state.status === 'pending')) continue
const latest = week.at(-1)
for (const mutation of week) {
if (settled && mutation === latest && mutation.state.status === 'error') continue
if (mutation === latest && (unmade(mutation) || (settled && failed(mutation)))) continue
mutations.remove(mutation)
}
}
@@ -363,10 +431,12 @@ function pushKey(weekStart: string) {
}
// PushToCalendar is the push of one week, as the mutation cache holds it:
// whether it is pending, and why the latest failed, if it failed.
// whether it is pending, why the latest failed, if it failed, and what
// became of the week's Supply run, if it succeeded.
export type PushToCalendar = {
pending: boolean
error: Error | null
supplyRun: PushedSupplyRun | null
mutate: () => void
reset: () => void
}
@@ -380,10 +450,19 @@ export type PushToCalendar = {
// the one shown. One Google refused the grant for reads the Google Calendar
// connection again, which is now reconnect required.
//
// Once pushed, the server makes the week's Supply run from the plan pushed
// and answers with its outcome, which supplyRun reports: a failed one is
// never a quiet success. The week's Supply run is read again, whatever the
// outcome, so the list shown is the server's. A Supply run not made because
// the plan was edited after it was pushed reads the plan again too, as a
// push refused for that does.
//
// The week's pushes live in the mutation cache, not in the component, as its
// swaps do, so leaving the week and coming back keeps one pending. reset
// leaves settled pushes behind; it is refused while one is pending. Settled
// pushes of a week no longer shown are left behind too
// leaves settled pushes behind; it is refused while one is pending. A push
// whose Supply run was not made is left behind once the list is made
// (settleUnmadePushes). Settled pushes of a week no longer shown are left
// behind too, but for the latest when its Supply run was not made
// (keepWeekMutations).
export function usePushToCalendar(weekStart: string): PushToCalendar {
const { apiFetch } = useApi()
@@ -395,12 +474,17 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
// The week's pushes, in the order sent.
const pushes = useMutationState({
filters: { mutationKey },
select: (mutation) => ({ status: mutation.state.status, error: mutation.state.error }),
select: (mutation) => ({
mutationId: mutation.mutationId,
status: mutation.state.status,
error: mutation.state.error,
data: mutation.state.data as PushedPlan | undefined,
}),
})
const latest = pushes.at(-1)
function mutate() {
const mutation = mutations.build<Plan, Error, void, unknown>(queryClient, {
const mutation = mutations.build<PushedPlan, Error, void, unknown>(queryClient, {
mutationKey,
// Kept until reset or settled in a week not shown (keepWeekMutations):
// a push pending in a week left must still be found when it is shown
@@ -408,9 +492,15 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
gcTime: Infinity,
mutationFn: async () => {
const path = `${planPath(weekStart)}/push-calendar`
return parsePlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }), false)
return parsePushedPlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
},
onSuccess: async ({ plan, supply_run }) => {
queryClient.setQueryData<Plan>(key, plan)
void queryClient.invalidateQueries({ queryKey: supplyRunKey(weekStart) })
if (supply_run.error?.code === 'plan_changed') {
await queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' })
}
},
onSuccess: (plan) => queryClient.setQueryData<Plan>(key, plan),
onError: async (error) => {
noteGoogleFailure(queryClient, error)
if (error instanceof ApiError && error.code === 'plan_changed') {
@@ -433,7 +523,26 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
return {
pending: pushes.some((push) => push.status === 'pending'),
error: latest?.status === 'error' ? latest.error! : null,
supplyRun: latest?.status === 'success' ? latest.data!.supply_run : null,
mutate,
reset,
}
}
// 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
// list is made: it leaves behind the week's pushes sent before whose Supply
// run was not made, so the Plan does not say again that it is unmade, even
// if the Plan was left meanwhile. A push sent since is kept, and a list not
// made leaves them as they are.
export function settleUnmadePushes(queryClient: QueryClient, weekStart: string): () => void {
const mutations = queryClient.getMutationCache()
const mutationKey = pushKey(weekStart)
const sent = new Set(mutations.findAll({ mutationKey }))
return () => {
for (const mutation of mutations.findAll({ mutationKey })) {
if (sent.has(mutation) && unmade(mutation)) mutations.remove(mutation)
}
}
}
+8 -2
View File
@@ -12,6 +12,7 @@ import { ApiError, useApi, type ApiClient } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { meQueryKey } from '../AuthBootstrap.tsx'
import { oneOf } from '../dinners/api.ts'
import { settleUnmadePushes } from '../plan/api.ts'
import type { NetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS_KEY, type HouseholdSettings } from '../settings/api.ts'
import {
@@ -187,16 +188,21 @@ export function useSupplyRun(weekStart: string) {
// useGenerateSupplyRun makes the week's Supply run from its plan (POST
// /api/v1/shopping/{weekStart}/generate); the Supply run it answers with is
// the one shown.
// the one shown. Once made, from whichever screen, a push of the week whose
// Supply run was not made is settled (settleUnmadePushes).
export function useGenerateSupplyRun(weekStart: string) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
onMutate: () => settleUnmadePushes(queryClient, weekStart),
mutationFn: async (): Promise<SupplyRun> => {
const path = `${supplyRunPath(weekStart)}/generate`
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
},
onSuccess: (run) => queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run),
onSuccess: (run, _, settle) => {
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
settle()
},
})
}
+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,