Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1b6cb77ed | ||
|
|
4662a24aaf | ||
|
|
b40af908a9 | ||
|
|
c6d287860b | ||
|
|
1028817193 | ||
|
|
930e55ab43 | ||
|
|
28e9c24fc7 | ||
|
|
c454c76816 | ||
|
|
8068d06ff0 | ||
|
|
d8ce8d8072 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
package dinners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
)
|
||||
|
||||
// Detail is a dinner with its recipe, as the recipe detail screen and
|
||||
// Kitchen reading mode show it.
|
||||
type Detail struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Origin string `json:"origin"`
|
||||
Method *string `json:"method"`
|
||||
MinutesTotal *int64 `json:"minutes_total"`
|
||||
Difficulty *string `json:"difficulty"`
|
||||
Cuisine *string `json:"cuisine"`
|
||||
IsFavorite bool `json:"is_favorite"`
|
||||
// PhotoURLs is the cached photo at each stored width, keyed by width
|
||||
// ("400", "1200"); null when the dinner has no photo.
|
||||
PhotoURLs map[string]string `json:"photo_urls"`
|
||||
// Ingredients and Steps are the recipe's, in recipe order; empty when
|
||||
// the dinner has no recipe.
|
||||
Ingredients []Ingredient `json:"ingredients"`
|
||||
Steps []string `json:"steps"`
|
||||
// Source is the site of the recipe page the dinner is grounded in;
|
||||
// null for a recipe entered by hand or a dinner with no recipe.
|
||||
Source *Source `json:"source"`
|
||||
GroundingStatus string `json:"grounding_status"`
|
||||
// GroundingError is why grounding failed, one of the Reason codes;
|
||||
// null unless GroundingStatus is failed.
|
||||
GroundingError *string `json:"grounding_error"`
|
||||
// WhyPicked is the reason of the suggestion that created the dinner
|
||||
// (see GetDinnerSuggestionReasons); null for a dinner no suggestion
|
||||
// created.
|
||||
WhyPicked *string `json:"why_picked"`
|
||||
}
|
||||
|
||||
// Ingredient is one recipe line. RawText is the line as the recipe gives
|
||||
// it; Name, Qty, Unit and Note are normalize_recipe's reading of it, each
|
||||
// null when it has none, and Name, Qty and Unit always null for a recipe
|
||||
// entered by hand (see IsManualRecipe).
|
||||
type Ingredient struct {
|
||||
Name *string `json:"name"`
|
||||
Qty *string `json:"qty"`
|
||||
Unit *string `json:"unit"`
|
||||
Note *string `json:"note"`
|
||||
RawText string `json:"raw_text"`
|
||||
}
|
||||
|
||||
// Source is the site a recipe was fetched from. SiteName is the name the
|
||||
// page itself gives its site, and null when it gives none or the stored
|
||||
// name could have come from a link (see pageSiteName). URL is the page's
|
||||
// link only when it is verifiably free of secrets (see safeSourceURL), and
|
||||
// null otherwise: a link pasted or offered as a candidate may carry a
|
||||
// personal token, so a client links the credit only when URL is not null.
|
||||
// Source is set for every fetched recipe, so a client always credits it:
|
||||
// by SiteName, else by URL's host, else without naming the site.
|
||||
type Source struct {
|
||||
SiteName *string `json:"site_name"`
|
||||
URL *string `json:"url"`
|
||||
}
|
||||
|
||||
// Get returns dinner id with its recipe and the reason it was suggested,
|
||||
// read in one read transaction. An unknown or archived dinner is
|
||||
// ErrNotFound: an archived dinner has no detail to show.
|
||||
func (s *Service) Get(ctx context.Context, id string) (d Detail, err error) {
|
||||
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Detail{}, fmt.Errorf("dinners: begin read: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, tx.Rollback())
|
||||
}()
|
||||
q := db.New(tx)
|
||||
row, err := q.GetDinner(ctx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Detail{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Detail{}, fmt.Errorf("dinners: read %s: %w", id, err)
|
||||
}
|
||||
if row.Status == StatusArchived {
|
||||
return Detail{}, ErrNotFound
|
||||
}
|
||||
d = Detail{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Status: row.Status,
|
||||
Origin: row.Origin,
|
||||
Method: nullString(row.Method),
|
||||
Difficulty: nullString(row.Difficulty),
|
||||
Cuisine: nullString(row.Cuisine),
|
||||
IsFavorite: row.IsFavorite == 1,
|
||||
Ingredients: []Ingredient{},
|
||||
Steps: []string{},
|
||||
GroundingStatus: row.GroundingStatus,
|
||||
GroundingError: nullString(row.GroundingError),
|
||||
}
|
||||
if row.MinutesTotal.Valid {
|
||||
d.MinutesTotal = &row.MinutesTotal.Int64
|
||||
}
|
||||
if row.PhotoID.Valid {
|
||||
d.PhotoURLs = make(map[string]string, len(recipes.PhotoSizes))
|
||||
for _, size := range recipes.PhotoSizes {
|
||||
d.PhotoURLs[strconv.Itoa(size)] = photoURL(row.PhotoID.String, size)
|
||||
}
|
||||
}
|
||||
if (row.GroundingStatus == GroundingGrounded || row.GroundingStatus == GroundingManual) && !row.RecipeID.Valid {
|
||||
return Detail{}, fmt.Errorf("dinners: %s is %s with no recipe", id, row.GroundingStatus)
|
||||
}
|
||||
if row.RecipeID.Valid {
|
||||
if err := readRecipe(ctx, q, row.RecipeID.String, &d); err != nil {
|
||||
return Detail{}, fmt.Errorf("dinners: read recipe of %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
reasons, err := q.GetDinnerSuggestionReasons(ctx, id)
|
||||
if err != nil {
|
||||
return Detail{}, fmt.Errorf("dinners: read suggestion reason of %s: %w", id, err)
|
||||
}
|
||||
switch len(reasons) {
|
||||
case 0:
|
||||
case 1:
|
||||
d.WhyPicked = &reasons[0]
|
||||
default:
|
||||
// Nothing records which of them created the dinner, and any
|
||||
// one picked would be a guess.
|
||||
return Detail{}, fmt.Errorf("dinners: %s has %d suggestions that could have created it", id, len(reasons))
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// readRecipe sets d's Source, Ingredients and Steps from recipe recipeID.
|
||||
func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail) error {
|
||||
rec, err := q.GetRecipe(ctx, recipeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("recipe %s: %w", recipeID, err)
|
||||
}
|
||||
if !IsManualRecipe(rec.SourceUrl) {
|
||||
site, err := pageSiteName(rec.SiteName, rec.SourceUrl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.Source = &Source{SiteName: site, URL: safeSourceURL(rec.SourceUrl)}
|
||||
}
|
||||
ings, err := q.ListRecipeIngredients(ctx, recipeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ingredients: %w", err)
|
||||
}
|
||||
for i, ing := range ings {
|
||||
if i > 0 && ing.Position == ings[i-1].Position {
|
||||
return duplicatePosition("ingredient", ing.Position)
|
||||
}
|
||||
d.Ingredients = append(d.Ingredients, Ingredient{
|
||||
Name: nullString(ing.Name),
|
||||
Qty: nullString(ing.Qty),
|
||||
Unit: nullString(ing.Unit),
|
||||
Note: nullString(ing.Note),
|
||||
RawText: ing.RawText,
|
||||
})
|
||||
}
|
||||
steps, err := q.ListRecipeSteps(ctx, recipeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("steps: %w", err)
|
||||
}
|
||||
for i, step := range steps {
|
||||
if i > 0 && step.Position == steps[i-1].Position {
|
||||
return duplicatePosition("step", step.Position)
|
||||
}
|
||||
d.Steps = append(d.Steps, step.Text)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pageSiteName is the stored site name of a recipe fetched from sourceURL
|
||||
// when it is one the page gave, else nil. A page that names no site is
|
||||
// stored under recipes.HostSiteName of the URL it was served from: the
|
||||
// pasted link's host, which may hold a personal token (a private
|
||||
// subdomain), or that of a page the link redirected to, which is not
|
||||
// stored. Nothing records which a stored name is, so any name that could
|
||||
// be a host — the link's own host, an IP address, or a dotted host name —
|
||||
// is treated as one and withheld. A site that names itself as a domain,
|
||||
// such as "Food.com", loses its name with them: a credit that does not
|
||||
// name the site is better than one that leaks a link.
|
||||
func pageSiteName(stored sql.NullString, sourceURL string) (*string, error) {
|
||||
if !stored.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
link, err := url.Parse(sourceURL)
|
||||
if err != nil {
|
||||
// Not wrapped: url.Parse's error quotes the link.
|
||||
return nil, errors.New("source url does not parse")
|
||||
}
|
||||
name := stored.String
|
||||
if strings.EqualFold(name, recipes.HostSiteName(link)) || net.ParseIP(name) != nil {
|
||||
return nil, nil
|
||||
}
|
||||
if u, err := url.Parse("https://" + name + "/"); err == nil && u.Host == name && strings.Contains(name, ".") {
|
||||
return nil, nil
|
||||
}
|
||||
return &name, nil
|
||||
}
|
||||
|
||||
// safeSourceURL is sourceURL when it is verifiably free of secrets — an
|
||||
// https link to a host, with no user or password, no query (not even a
|
||||
// bare "?") and no fragment (not even a bare "#") — else nil. Those are
|
||||
// the places a link carries a token; a link failing any check, or not
|
||||
// parsing, is withheld rather than trimmed, since a trimmed link is not the
|
||||
// page the recipe came from. The client (sourceUrl in
|
||||
// web/src/dinners/detail.ts) parses the link with the browser's URL parser
|
||||
// and refuses the whole recipe when it fails, so the host and port must
|
||||
// also be ones it parses: see browserHost.
|
||||
func safeSourceURL(sourceURL string) *string {
|
||||
u, err := url.Parse(sourceURL)
|
||||
if err != nil || u.Scheme != "https" || u.Opaque != "" || u.Host == "" || u.User != nil ||
|
||||
u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(sourceURL, "#") {
|
||||
return nil
|
||||
}
|
||||
// url.Parse decodes a percent-encoded host; the link must spell its
|
||||
// host out as it is checked.
|
||||
authority, _, _ := strings.Cut(sourceURL[len(u.Scheme)+len("://"):], "/")
|
||||
if authority != u.Host || !browserHost(u) {
|
||||
return nil
|
||||
}
|
||||
if port := u.Port(); port != "" {
|
||||
if n, err := strconv.ParseUint(port, 10, 16); err != nil || n == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return &sourceURL
|
||||
}
|
||||
|
||||
// browserHost says whether u's host is one the browser's URL parser (the
|
||||
// WHATWG URL Standard) accepts as it stands, where url.Parse accepts more,
|
||||
// such as "[bad]". The rule is narrower than the browser's: a bracketed
|
||||
// host is an IPv6 address without a zone; any other is ASCII letters,
|
||||
// digits and hyphens in dot-separated labels, none empty but a trailing
|
||||
// one, none punycode ("xn--"), since the browser refuses invalid punycode
|
||||
// and checking it is not worth a decoder; and a host whose last label is
|
||||
// a number, which the browser reads as an IPv4 address, must be a dotted
|
||||
// quad. A link to any other host is withheld.
|
||||
func browserHost(u *url.URL) bool {
|
||||
host := u.Hostname()
|
||||
if strings.HasPrefix(u.Host, "[") {
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && strings.Contains(host, ":")
|
||||
}
|
||||
labels := strings.Split(strings.TrimSuffix(host, "."), ".")
|
||||
for _, label := range labels {
|
||||
if label == "" || len(label) >= 4 && strings.EqualFold(label[:4], "xn--") {
|
||||
return false
|
||||
}
|
||||
for _, c := range label {
|
||||
if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || c == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
last := labels[len(labels)-1]
|
||||
if strings.Trim(last, "0123456789") == "" || len(last) >= 2 && strings.EqualFold(last[:2], "0x") {
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.To4() != nil && !strings.Contains(host, ":")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// duplicatePosition is the error for two recipe items of kind at position.
|
||||
// Position is the only record of recipe order, and the id that breaks the
|
||||
// tie says nothing about it, so a recipe with a duplicate is refused rather
|
||||
// than shown in an order nobody can trust.
|
||||
func duplicatePosition(kind string, position int64) error {
|
||||
return fmt.Errorf("two %ss at position %d: recipe order is unknown", kind, position)
|
||||
}
|
||||
@@ -669,7 +669,12 @@ func findRepeat(ctx context.Context, q *db.Queries, self, name string, recipeURL
|
||||
|
||||
// PhotoURL is where the API serves a list-sized copy of photo id.
|
||||
func PhotoURL(id string) string {
|
||||
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, photoListWidth)
|
||||
return photoURL(id, photoListWidth)
|
||||
}
|
||||
|
||||
// photoURL is where the API serves photo id at stored width size.
|
||||
func photoURL(id string, size int) string {
|
||||
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, size)
|
||||
}
|
||||
|
||||
func fromRow(r db.Dinner) Dinner {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dinners_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
@@ -74,3 +75,103 @@ func TestCreateRegularNeverNamesFromLink(t *testing.T) {
|
||||
}
|
||||
check("duplicate error", dup.Error())
|
||||
}
|
||||
|
||||
// TestPageSiteNameWithholdsHosts: a stored site name that could be a host —
|
||||
// the link's own, in any case and without "www.", an IP address or a dotted
|
||||
// host name — is withheld; a name the page gave, dotless or with spaces, is
|
||||
// kept.
|
||||
func TestPageSiteNameWithholdsHosts(t *testing.T) {
|
||||
t.Parallel()
|
||||
const link = "https://www.TokenHost/recipes/stew"
|
||||
for _, tc := range []struct {
|
||||
stored string
|
||||
valid bool
|
||||
want *string
|
||||
}{
|
||||
{"", false, nil},
|
||||
{"tokenhost", true, nil},
|
||||
{"TOKENHOST", true, nil},
|
||||
{"private-token.example.com", true, nil},
|
||||
{"Food.com", true, nil},
|
||||
{"203.0.113.7", true, nil},
|
||||
{"2001:db8::1", true, nil},
|
||||
{"Delish", true, ptr("Delish")},
|
||||
{"Bon Appétit", true, ptr("Bon Appétit")},
|
||||
{"Mr. Food Test Kitchen", true, ptr("Mr. Food Test Kitchen")},
|
||||
} {
|
||||
got, err := dinners.PageSiteName(sql.NullString{String: tc.stored, Valid: tc.valid}, link)
|
||||
if err != nil {
|
||||
t.Fatalf("PageSiteName(%q): %v", tc.stored, err)
|
||||
}
|
||||
if (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) {
|
||||
t.Errorf("PageSiteName(%q) = %v, want %v", tc.stored, deref(got), deref(tc.want))
|
||||
}
|
||||
}
|
||||
if _, err := dinners.PageSiteName(sql.NullString{String: "Delish", Valid: true}, "https://secret-token\x7f.example/%zz"); err == nil || strings.Contains(err.Error(), "secret-token") {
|
||||
t.Errorf("PageSiteName of an unparseable link: err = %v, want one not quoting the link", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeSourceURLOnlyWithoutSecrets: a recipe link is shown only when it
|
||||
// is https to a host with no user, password, query or fragment, each of
|
||||
// which can carry a token; any other link, or one that does not parse, is
|
||||
// withheld whole.
|
||||
func TestSafeSourceURLOnlyWithoutSecrets(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, link := range []string{
|
||||
"https://www.example.com/recipes/chicken-curry",
|
||||
"https://example.com",
|
||||
"https://example.com:8443/stew/",
|
||||
"https://[2001:db8::1]/curry",
|
||||
"https://1.2.3.4/curry",
|
||||
"https://Kitchen-2.Example.com./curry",
|
||||
} {
|
||||
if got := dinners.SafeSourceURL(link); got == nil || *got != link {
|
||||
t.Errorf("SafeSourceURL(%q) = %v, want the link", link, deref(got))
|
||||
}
|
||||
}
|
||||
for _, link := range []string{
|
||||
"https://example.com/curry?access_token=query-token",
|
||||
"https://example.com/curry?",
|
||||
"https://user:password@example.com/curry",
|
||||
"https://token@example.com/curry",
|
||||
"https://@example.com/curry",
|
||||
"http://example.com/curry",
|
||||
"https://example.com/curry#token=fragment-token",
|
||||
"https://example.com/curry#",
|
||||
"ftp://example.com/curry",
|
||||
"https:example.com/curry",
|
||||
"https:///curry",
|
||||
"https://example.com:99999/curry",
|
||||
"https://example.com:0/curry",
|
||||
"//example.com/curry",
|
||||
"https://secret-token\x7f.example/%zz",
|
||||
"",
|
||||
// Hosts url.Parse accepts but the browser's URL parser refuses
|
||||
// (or reads as another host), which would fail the whole recipe
|
||||
// screen.
|
||||
"https://[bad]/curry",
|
||||
"https://[fe80::1%25en0]/curry",
|
||||
"https://[1.2.3.4]/curry",
|
||||
"https://1.2.3.999/curry",
|
||||
"https://1.2.3.4.5/curry",
|
||||
"https://999/curry",
|
||||
"https://example.0x/curry",
|
||||
"https://xn--a/curry",
|
||||
"https://ex%61mple.com/curry",
|
||||
"https://ex%2fample.com/curry",
|
||||
"https://a..b/curry",
|
||||
"https://café.example/curry",
|
||||
} {
|
||||
if got := dinners.SafeSourceURL(link); got != nil {
|
||||
t.Errorf("SafeSourceURL(%q) = %q, want nil", link, *got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deref(s *string) any {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
@@ -71,3 +71,9 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error)
|
||||
r, err := s.runGroundJob(ctx, p, nil)
|
||||
return r.GroundingStatus, err
|
||||
}
|
||||
|
||||
// PageSiteName is pageSiteName.
|
||||
var PageSiteName = pageSiteName
|
||||
|
||||
// SafeSourceURL is safeSourceURL.
|
||||
var SafeSourceURL = safeSourceURL
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// getDetail answers GET /dinners/{id} for id, failing unless it is 200, and
|
||||
// returns the body both raw (to tell null from absent) and decoded.
|
||||
func getDetail(t *testing.T, h http.Handler, id string) (map[string]any, dinners.Detail) {
|
||||
t.Helper()
|
||||
rec, raw := callDinners(t, h, http.MethodGet, dinnersPath+"/"+id, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET dinner = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
var d dinners.Detail
|
||||
dec := json.NewDecoder(rec.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&d); err != nil {
|
||||
t.Fatalf("decode detail: %v", err)
|
||||
}
|
||||
return raw, d
|
||||
}
|
||||
|
||||
// wantNull fails unless each key is present in raw and null.
|
||||
func wantNull(t *testing.T, raw map[string]any, keys ...string) {
|
||||
t.Helper()
|
||||
for _, k := range keys {
|
||||
if v, ok := raw[k]; !ok || v != nil {
|
||||
t.Errorf("%s = %v (present %v), want null", k, v, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDinnerGroundedSideQuest: a Side quest grounded in a fetched page
|
||||
// shows its photo at both stored sizes, its recipe's ingredients and steps
|
||||
// in recipe order (not row order), its source site and the reason it was
|
||||
// suggested.
|
||||
func TestGetDinnerGroundedSideQuest(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
e.start(t)
|
||||
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "succeeded" {
|
||||
t.Fatalf("grounding job = %v", job)
|
||||
}
|
||||
id := created["id"].(string)
|
||||
|
||||
// Make it an accepted Side quest with the suggestion that created it,
|
||||
// and reverse its recipe's positions so row order disagrees with
|
||||
// recipe order.
|
||||
var recipeID, photoID string
|
||||
if err := e.st.Reader.QueryRow(`SELECT recipe_id, photo_id FROM dinners WHERE id = ?`, id).Scan(&recipeID, &photoID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
`UPDATE dinners SET status = 'quest_log', origin = 'side_quest', is_favorite = 1 WHERE id = ?`,
|
||||
`UPDATE recipe_ingredients SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
|
||||
`UPDATE recipe_steps SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
|
||||
} {
|
||||
if _, err := e.st.Writer.Exec(stmt, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const reason = "Like your Tikka masala, but quicker."
|
||||
if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{
|
||||
ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: "Chicken curry",
|
||||
Reason: reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, got := getDetail(t, e.h, id)
|
||||
str := func(s string) *string { return &s }
|
||||
minutes := int64(40)
|
||||
want := dinners.Detail{
|
||||
ID: id,
|
||||
Name: "Chicken curry",
|
||||
Status: "quest_log",
|
||||
Origin: "side_quest",
|
||||
Method: str("stovetop"),
|
||||
MinutesTotal: &minutes,
|
||||
Difficulty: str("easy"),
|
||||
IsFavorite: true,
|
||||
PhotoURLs: map[string]string{
|
||||
"400": "/media/photos/" + photoID + "-400.jpg",
|
||||
"1200": "/media/photos/" + photoID + "-1200.jpg",
|
||||
},
|
||||
Ingredients: []dinners.Ingredient{
|
||||
{Name: str("garlic"), Qty: str("2"), Unit: str("cloves"), Note: str("minced"), RawText: "2 cloves garlic, minced"},
|
||||
{Name: str("onion"), Qty: str("1"), RawText: "1 onion"},
|
||||
},
|
||||
Steps: []string{"Fry with the garlic.", "Chop the onion."},
|
||||
Source: &dinners.Source{SiteName: str("Curry House"), URL: str(curryPage)},
|
||||
GroundingStatus: "grounded",
|
||||
WhyPicked: str(reason),
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
gj, _ := json.Marshal(got)
|
||||
wj, _ := json.Marshal(want)
|
||||
t.Errorf("detail =\n %s\nwant\n %s", gj, wj)
|
||||
}
|
||||
// Each photo URL is served.
|
||||
for _, u := range got.PhotoURLs {
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, signedIn(http.MethodGet, u, nil))
|
||||
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/jpeg" {
|
||||
t.Errorf("GET %s = %d %s", u, rec.Code, rec.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDinnerManualRegular: a Regular added by hand and given a recipe
|
||||
// typed in has no photo, no source and no reason, and its ingredients are
|
||||
// the typed lines with nothing normalized.
|
||||
func TestGetDinnerManualRegular(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
id := createDinner(t, e.h, `{"name":"Family chili"}`)
|
||||
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
|
||||
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
raw, got := getDetail(t, e.h, id)
|
||||
wantNull(t, raw, "photo_urls", "source", "why_picked", "difficulty", "grounding_error")
|
||||
str := func(s string) *string { return &s }
|
||||
minutes := int64(240)
|
||||
want := dinners.Detail{
|
||||
ID: id,
|
||||
Name: "Family chili",
|
||||
Status: "regular",
|
||||
Origin: "regular",
|
||||
Method: str("crockpot"),
|
||||
MinutesTotal: &minutes,
|
||||
Ingredients: []dinners.Ingredient{{RawText: "1 lb beef"}, {RawText: "1 can beans"}},
|
||||
Steps: []string{"Brown.", "Simmer."},
|
||||
GroundingStatus: "manual",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
gj, _ := json.Marshal(got)
|
||||
wj, _ := json.Marshal(want)
|
||||
t.Errorf("detail =\n %s\nwant\n %s", gj, wj)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDinnerFailedGrounding: a dinner whose grounding failed shows its
|
||||
// real state — failed with its reason, no photo, no source and empty
|
||||
// ingredients and steps — never a pretend recipe.
|
||||
func TestGetDinnerFailedGrounding(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
e.start(t)
|
||||
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Nothing matches this"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "failed" {
|
||||
t.Fatalf("grounding job = %v, want failed", job)
|
||||
}
|
||||
|
||||
raw, got := getDetail(t, e.h, created["id"].(string))
|
||||
wantNull(t, raw, "photo_urls", "source", "why_picked", "method", "minutes_total", "difficulty")
|
||||
if ings, ok := raw["ingredients"].([]any); !ok || len(ings) != 0 {
|
||||
t.Errorf("ingredients = %v, want []", raw["ingredients"])
|
||||
}
|
||||
if steps, ok := raw["steps"].([]any); !ok || len(steps) != 0 {
|
||||
t.Errorf("steps = %v, want []", raw["steps"])
|
||||
}
|
||||
if got.GroundingStatus != "failed" || got.GroundingError == nil || *got.GroundingError != dinners.ReasonNoRecipe ||
|
||||
got.Name != "Nothing matches this" || got.Status != "regular" || got.Origin != "regular" {
|
||||
t.Errorf("detail = %+v, want the failed Regular with no_recipe_found", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDinnerNotFound: an unknown dinner and an archived one are both
|
||||
// 404, and the detail needs a session like every /api/v1 route.
|
||||
func TestGetDinnerNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
id := createDinner(t, e.h, `{"name":"Old favourite"}`)
|
||||
if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
for _, target := range []string{id, store.NewID()} {
|
||||
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+target, "")
|
||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
||||
t.Errorf("GET %s = %d %v, want 404 not_found", target, rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
live := createDinner(t, e.h, `{"name":"Taco night"}`)
|
||||
req := httptest.NewRequest(http.MethodGet, dinnersPath+"/"+live, nil)
|
||||
if rec, body := serve(t, e.h, req); rec.Code != http.StatusUnauthorized || errorCode(body) != "unauthenticated" {
|
||||
t.Errorf("GET without a session = %d %v, want 401", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDinnerSourceURLOnlyWhenSafe: a recipe grounded in a clean https
|
||||
// link shows it as source.url; one whose stored link carries a token in its
|
||||
// query or userinfo, is http, or has a fragment shows its site name but
|
||||
// source.url null and nothing of the link.
|
||||
func TestGetDinnerSourceURLOnlyWhenSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
const (
|
||||
clean = "https://kitchen.example/recipes/chicken-curry"
|
||||
queryToken = "query-token-def456"
|
||||
queryPage = "https://secret-query.example/curry?access_token=" + queryToken
|
||||
)
|
||||
e.pages.Bodies[clean] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
|
||||
e.pages.Bodies[queryPage] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25)
|
||||
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
|
||||
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
|
||||
ground := func(dish, page string) (id string) {
|
||||
t.Helper()
|
||||
id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+page+`"}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST ground %s = %d: %s", page, rec.Code, rec.Body)
|
||||
}
|
||||
var stored string
|
||||
if err := e.st.Reader.QueryRow(`SELECT source_url FROM recipes WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored != page {
|
||||
t.Fatalf("stored source_url = %q, want %q", stored, page)
|
||||
}
|
||||
return id
|
||||
}
|
||||
source := func(what, id string) map[string]any {
|
||||
t.Helper()
|
||||
raw, _ := getDetail(t, e.h, id)
|
||||
src, ok := raw["source"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("source of %s = %v, want an object", what, raw["source"])
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
cleanID := ground("Chicken curry", clean)
|
||||
if src := source("a clean link", cleanID); !reflect.DeepEqual(src, map[string]any{"site_name": "Curry House", "url": clean}) {
|
||||
t.Errorf("source of a clean link = %v, want site_name Curry House and url %s", src, clean)
|
||||
}
|
||||
|
||||
queryID := ground("Weeknight curry", queryPage)
|
||||
withheld := func(what, id, site string, secrets ...string) {
|
||||
t.Helper()
|
||||
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d: %s", what, rec.Code, rec.Body)
|
||||
}
|
||||
for _, secret := range secrets {
|
||||
if strings.Contains(rec.Body.String(), secret) {
|
||||
t.Errorf("detail of %s contains %q: %s", what, secret, rec.Body)
|
||||
}
|
||||
}
|
||||
if src := source(what, id); !reflect.DeepEqual(src, map[string]any{"site_name": site, "url": nil}) {
|
||||
t.Errorf("source of %s = %v, want site_name %q and url null", what, src, site)
|
||||
}
|
||||
}
|
||||
withheld("a link with a query token", queryID, "B Kitchen", queryPage, "secret-query.example", queryToken, "access_token")
|
||||
|
||||
// The other unsafe shapes, stored as a link grounded elsewhere would
|
||||
// leave them.
|
||||
for _, tc := range []struct{ what, link string }{
|
||||
{"a link with a userinfo token", "https://user-token-ghi789@kitchen.example/recipes/chicken-curry"},
|
||||
{"a link with a userinfo password", "https://cook:password-jkl012@kitchen.example/recipes/chicken-curry"},
|
||||
{"an http link", "http://kitchen.example/recipes/chicken-curry"},
|
||||
{"a link with a fragment", "https://kitchen.example/recipes/chicken-curry#token=fragment-mno345"},
|
||||
} {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE recipes SET source_url = ? WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, tc.link, cleanID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withheld(tc.what, cleanID, "Curry House", tc.link, "kitchen.example", "-token-", "password", "fragment")
|
||||
}
|
||||
|
||||
// web/src/dinners/testdata/source_url_cases.json pins source as this
|
||||
// handler sends it for each stored link; the recipe screen's tests
|
||||
// render each one, so a link the server sends and the client refuses
|
||||
// fails one side or the other.
|
||||
fixture, err := os.ReadFile("../../web/src/dinners/testdata/source_url_cases.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var shared struct {
|
||||
Cases []struct {
|
||||
Stored string
|
||||
Source map[string]any
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(fixture, &shared); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(shared.Cases) == 0 {
|
||||
t.Fatal("source_url_cases.json has no cases")
|
||||
}
|
||||
for _, c := range shared.Cases {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE recipes SET source_url = ? WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, c.Stored, cleanID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if src := source(c.Stored, cleanID); !reflect.DeepEqual(src, c.Source) {
|
||||
got, _ := json.Marshal(src)
|
||||
want, _ := json.Marshal(c.Source)
|
||||
t.Errorf("source of %s = %s, want %s", c.Stored, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDinnerSiteNameNeverFromLink: a page that names no site is stored
|
||||
// under its host, so a recipe grounded in a pasted link whose host carries a
|
||||
// token has site_name null and nothing from the link in its detail; a page
|
||||
// that names its site keeps that name, even on a token-bearing host; and a
|
||||
// stored name shaped like another host, as a redirect leaves, is withheld.
|
||||
// The links carry a query, so source.url is withheld too and nothing of the
|
||||
// link may appear.
|
||||
func TestGetDinnerSiteNameNeverFromLink(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
const (
|
||||
bareToken = "private-token-abc123"
|
||||
namedToken = "named-token-def456"
|
||||
bare = "https://" + bareToken + ".example.com/recipe?print=1"
|
||||
named = "https://www." + namedToken + ".example.com/recipe?print=1"
|
||||
)
|
||||
e.pages.Bodies[bare] = recipestest.RecipePage("Chicken Curry", "", "https://a.example/curry.jpg", 40)
|
||||
e.pages.Bodies[named] = recipestest.RecipePage("Weeknight Curry", "Curry House", "https://b.example/curry.jpg", 25)
|
||||
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
|
||||
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
|
||||
ground := func(dish, page string) (id, recipeID string) {
|
||||
t.Helper()
|
||||
id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+page+`"}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST ground %s = %d: %s", page, rec.Code, rec.Body)
|
||||
}
|
||||
if err := e.st.Reader.QueryRow(`SELECT recipe_id FROM dinners WHERE id = ?`, id).Scan(&recipeID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id, recipeID
|
||||
}
|
||||
storedSite := func(recipeID string) string {
|
||||
t.Helper()
|
||||
var site string
|
||||
if err := e.st.Reader.QueryRow(`SELECT site_name FROM recipes WHERE id = ?`, recipeID).Scan(&site); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return site
|
||||
}
|
||||
check := func(what, id string, leaks []string, want any) {
|
||||
t.Helper()
|
||||
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d: %s", what, rec.Code, rec.Body)
|
||||
}
|
||||
for _, leak := range leaks {
|
||||
if strings.Contains(strings.ToLower(rec.Body.String()), leak) {
|
||||
t.Errorf("detail of %s contains %q: %s", what, leak, rec.Body)
|
||||
}
|
||||
}
|
||||
raw, _ := getDetail(t, e.h, id)
|
||||
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": want, "url": nil}) {
|
||||
t.Errorf("source of %s = %v, want site_name %v and url null", what, raw["source"], want)
|
||||
}
|
||||
}
|
||||
|
||||
bareID, bareRecipe := ground("Chicken curry", bare)
|
||||
if got := storedSite(bareRecipe); got != bareToken+".example.com" {
|
||||
t.Fatalf("stored site_name = %q, want the link's host", got)
|
||||
}
|
||||
check("a page naming no site", bareID, []string{bare, bareToken, "example.com"}, nil)
|
||||
|
||||
namedID, namedRecipe := ground("Weeknight curry", named)
|
||||
if got := storedSite(namedRecipe); got != "Curry House" {
|
||||
t.Fatalf("stored site_name = %q, want the page's", got)
|
||||
}
|
||||
check("a page naming its site", namedID, []string{named, namedToken, "example.com"}, "Curry House")
|
||||
|
||||
const redirectHost = "redirect-token-ghi789.example.net"
|
||||
if _, err := e.st.Writer.Exec(`UPDATE recipes SET site_name = ? WHERE id = ?`, redirectHost, namedRecipe); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check("a page named by a redirect's host", namedID, []string{redirectHost, "redirect-token", namedToken}, nil)
|
||||
}
|
||||
|
||||
// TestGetDinnerWhyPickedIsCreatingSuggestion: why_picked is the reason of
|
||||
// the suggestion that created the dinner, never that of an earlier
|
||||
// suggestion row for it that was declined.
|
||||
func TestGetDinnerWhyPickedIsCreatingSuggestion(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
|
||||
if _, err := e.st.Writer.Exec(`UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const reason = "Like your Tikka masala, but quicker."
|
||||
// The declined row is created first, so it is the earliest by both
|
||||
// created_at and id.
|
||||
for _, s := range []struct{ reason, status string }{
|
||||
{"Passed on last week.", "declined"},
|
||||
{reason, "accepted"},
|
||||
} {
|
||||
sid := store.NewID()
|
||||
if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{
|
||||
ID: sid, DinnerID: id, BatchID: batch.ID, Name: "Chicken curry",
|
||||
Reason: s.reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, err := q.DecideSuggestion(t.Context(), db.DecideSuggestionParams{
|
||||
Status: s.status, Now: sql.NullString{String: now, Valid: true}, ID: sid,
|
||||
}); err != nil || n != 1 {
|
||||
t.Fatalf("decide %s = %d, %v", s.status, n, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, got := getDetail(t, e.h, id); got.WhyPicked == nil || *got.WhyPicked != reason {
|
||||
t.Errorf("why_picked = %v, want %q", got.WhyPicked, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDinnerDuplicatePositionFails: a recipe with two ingredients or two
|
||||
// steps at one position has no order anyone can trust, so its detail is a
|
||||
// 500 rather than the recipe in some order.
|
||||
func TestGetDinnerDuplicatePositionFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
for _, table := range []string{"recipe_ingredients", "recipe_steps"} {
|
||||
id := createDinner(t, e.h, `{"name":"Family chili `+table+`"}`)
|
||||
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
|
||||
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if _, err := e.st.Writer.Exec(`UPDATE `+table+` SET position = 0 WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
|
||||
if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "Simmer.") || strings.Contains(rec.Body.String(), "1 lb beef") {
|
||||
t.Errorf("GET with duplicate %s positions = %d: %s, want 500 and no recipe", table, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -86,13 +86,19 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.runner = runner
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model)
|
||||
// The router serves the photos the grounder caches.
|
||||
dataDir := t.TempDir()
|
||||
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.model)
|
||||
photos, err := recipes.NewPhotoCache(e.images, st, dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := dinners.NewService(st, g, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ type RouteMounter interface {
|
||||
// /media; settings serves /api/v1/settings/household; search serves
|
||||
// /api/v1/settings/recipe-search and its /test; ai serves
|
||||
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
|
||||
// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE
|
||||
// dinnerSvc serves GET and POST /api/v1/dinners, GET, PATCH and DELETE
|
||||
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
|
||||
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
|
||||
// suggestSvc serves POST /api/v1/suggestions/batches, GET
|
||||
@@ -137,6 +137,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
|
||||
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
|
||||
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
|
||||
apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc))
|
||||
apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc))
|
||||
apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc))
|
||||
apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -37,6 +37,7 @@ const SCREENS = {
|
||||
'/discover': true,
|
||||
'/quick': true,
|
||||
'/cook-from-pantry': true,
|
||||
'/dinner/:id': true,
|
||||
'/settings': true,
|
||||
'/settings/ai': true,
|
||||
'/settings/household': true,
|
||||
@@ -300,8 +301,8 @@ describe('App offline bootstrap', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/', 'Home'],
|
||||
['/settings/notifications', 'Notifications'],
|
||||
['/dinner/01J9ZK', 'Dinner'],
|
||||
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
||||
await visitOnline()
|
||||
goOffline()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Route, Routes } from 'react-router'
|
||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx'
|
||||
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
||||
import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx'
|
||||
import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx'
|
||||
@@ -49,6 +50,7 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/discover': <DiscoverPage />,
|
||||
'/quick': <QuickDinnersPage />,
|
||||
'/cook-from-pantry': <CookFromPantryPage />,
|
||||
'/dinner/:id': <DinnerDetailPage />,
|
||||
'/settings': <SettingsPage />,
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
'/settings/household': <HouseholdSettingsPage />,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/* A week with no plan: why there's no day, over the link to draft it. */
|
||||
.noPlan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { usePlan, useWeekSwap, type PlanDinner, type Swap } from '../plan/api.ts'
|
||||
import { cooking } from '../plan/PlanPage.tsx'
|
||||
import swapStyles from '../plan/SwapSheet.module.css'
|
||||
import { addDays, dayOfMonth, localNow, weekdayName, weekLabel, weekStart as weekOf } from '../plan/week.ts'
|
||||
import { useHouseholdSettings } from '../settings/api.ts'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { ListRow } from '../ui/ListRow.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import { Segmented } from '../ui/Segmented.tsx'
|
||||
import { Sheet } from '../ui/Sheet.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import type { DinnerDetail } from './detail.ts'
|
||||
import styles from './AddToWeekSheet.module.css'
|
||||
|
||||
// PLAN is the Draft board, where a week with no plan is drafted.
|
||||
export const PLAN = '/plan'
|
||||
|
||||
// Which is the week a dinner is added to: the household's current week or
|
||||
// the one after.
|
||||
export type Which = 'this' | 'next'
|
||||
|
||||
export const weekNames: Record<Which, string> = { this: 'this week', next: 'next week' }
|
||||
|
||||
// Added is a swap sent from the sheet: the week it went to and the swap.
|
||||
export type Added = { week: string; which: Which; swap: Swap }
|
||||
|
||||
// planDinner is the dinner as it is planned on a day.
|
||||
function planDinner(dinner: DinnerDetail): PlanDinner {
|
||||
return { id: dinner.id, name: dinner.name, photo_id: dinner.photo_id, minutes: dinner.minutes_total, method: dinner.method }
|
||||
}
|
||||
|
||||
type WeekDaysProps = {
|
||||
dinner: DinnerDetail
|
||||
week: string
|
||||
which: Which
|
||||
onAdd: (added: Added) => void
|
||||
}
|
||||
|
||||
// WeekDays is the seven days of the week starting on week, each with its
|
||||
// dinner; choosing one swaps the dinner in as the Draft board swaps a day
|
||||
// (useWeekSwap). A week with no plan offers to draft it on the Draft board:
|
||||
// nothing here makes a plan up. A dinner already on the week can't be added
|
||||
// again, nor can one while a swap of the week is still being saved or the
|
||||
// week is being pushed to Calendar.
|
||||
function WeekDays({ dinner, week, which, onAdd }: WeekDaysProps) {
|
||||
const plan = usePlan(week)
|
||||
const { swap, push, refused, swapIn } = useWeekSwap(week)
|
||||
|
||||
if (plan.isError) {
|
||||
return <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load {weekNames[which]}'s plan. {plan.error.message}</ErrorBanner>
|
||||
}
|
||||
if (plan.isPending) {
|
||||
return (
|
||||
<div aria-busy="true" aria-label="Plan loading" className={swapStyles.rows}>
|
||||
{Array.from({ length: 7 }, (_, i) => (
|
||||
<Skeleton key={i} className={swapStyles.skeletonRow} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (plan.data === null) {
|
||||
return (
|
||||
<div className={styles.noPlan}>
|
||||
<p className={swapStyles.empty}>There's no plan for {weekNames[which]} yet, so there's no day to add it to.</p>
|
||||
<Link to={PLAN} className={[buttonStyles.button, buttonStyles.primary].join(' ')}>
|
||||
Draft {weekNames[which]} first
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const days = plan.data.days
|
||||
const planned = days.find((day) => day.dinner.id === dinner.id)
|
||||
const choosable = planned === undefined && !refused
|
||||
function add(date: string) {
|
||||
const chosen: Swap = { date, dinner: planDinner(dinner), fromQuestLog: dinner.status === 'quest_log' }
|
||||
swapIn(chosen)
|
||||
onAdd({ week, which, swap: chosen })
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{planned !== undefined ? (
|
||||
<p className={swapStyles.empty}>
|
||||
{dinner.name} is already on {weekdayName(planned.date)} {weekNames[which]}.
|
||||
</p>
|
||||
) : swap.pending ? (
|
||||
<p className={swapStyles.empty}>A change to {weekNames[which]} is still being saved.</p>
|
||||
) : (
|
||||
push.pending && <p className={swapStyles.empty}>The plan for {weekNames[which]} is being pushed to Calendar.</p>
|
||||
)}
|
||||
<ul aria-label={`Days of ${weekNames[which]}`} className={swapStyles.rows}>
|
||||
{days.map((day) => (
|
||||
<li key={day.date}>
|
||||
<ListRow
|
||||
className={choosable ? undefined : swapStyles.already}
|
||||
leading={
|
||||
// The row's dinner names what the photo shows.
|
||||
<span aria-hidden="true">
|
||||
<Photo id={day.dinner.photo_id} name={day.dinner.name} radius="var(--radius-md)" sizes="48px" className={swapStyles.photo} />
|
||||
</span>
|
||||
}
|
||||
title={`${weekdayName(day.date)} ${dayOfMonth(day.date)}`}
|
||||
meta={[day.dinner.name, cooking(day.dinner)].filter(Boolean).join(' · ')}
|
||||
onClick={choosable ? () => add(day.date) : undefined}
|
||||
trailing={day === planned && <Tag>This dinner</Tag>}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export type AddToWeekSheetProps = {
|
||||
dinner: DinnerDetail
|
||||
onAdd: (added: Added) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// AddToWeekSheet is the sheet Add to this week opens: the seven days of the
|
||||
// household's current week, Monday to Sunday in its timezone, each with its
|
||||
// dinner, and a toggle to next week. Choosing a day replaces its dinner.
|
||||
export function AddToWeekSheet({ dinner, onAdd, onClose }: AddToWeekSheetProps) {
|
||||
const titleId = useId()
|
||||
const settings = useHouseholdSettings()
|
||||
const [which, setWhich] = useState<Which>('this')
|
||||
|
||||
let body
|
||||
if (settings.isError) {
|
||||
body = (
|
||||
<ErrorBanner onRetry={() => void settings.refetch()}>
|
||||
Couldn't load the household settings. {settings.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
} else if (settings.isPending) {
|
||||
body = <Skeleton className={swapStyles.skeletonRow} />
|
||||
} else {
|
||||
const thisWeek = weekOf(localNow(settings.data.timezone, new Date()).date)
|
||||
const week = which === 'this' ? thisWeek : addDays(thisWeek, 7)
|
||||
body = (
|
||||
<>
|
||||
<p className={swapStyles.replaces}>
|
||||
{weekLabel(week).dates} · replaces the day's dinner
|
||||
</p>
|
||||
<Segmented
|
||||
label="Week"
|
||||
options={[
|
||||
{ value: 'this', label: 'This week' },
|
||||
{ value: 'next', label: 'Next week' },
|
||||
]}
|
||||
value={which}
|
||||
onValueChange={setWhich}
|
||||
/>
|
||||
<WeekDays key={week} dinner={dinner} week={week} which={which} onAdd={onAdd} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open onClose={onClose} aria-labelledby={titleId}>
|
||||
<div className={swapStyles.sheet}>
|
||||
<h2 id={titleId} className={swapStyles.title}>
|
||||
Add {dinner.name}
|
||||
</h2>
|
||||
{body}
|
||||
</div>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { PlanPage } from '../plan/PlanPage.tsx'
|
||||
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
|
||||
|
||||
const DINNER = '/api/v1/dinners/d1'
|
||||
const FAVORITE = `${DINNER}/favorite`
|
||||
const THIS_WEEK = '/api/v1/plans/2026-10-05'
|
||||
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
|
||||
const THURSDAY = `${THIS_WEEK}/days/2026-10-08`
|
||||
|
||||
// detail is the Korean beef bowls, a Quest-log Side quest, as GET
|
||||
// /api/v1/dinners/{id} sends it.
|
||||
function detail(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'd1',
|
||||
name: 'Korean beef bowls',
|
||||
status: 'quest_log',
|
||||
origin: 'suggested',
|
||||
method: 'stovetop',
|
||||
minutes_total: 30,
|
||||
difficulty: 'easy',
|
||||
cuisine: 'korean',
|
||||
is_favorite: false,
|
||||
photo_urls: null,
|
||||
ingredients: [{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }],
|
||||
steps: ['Brown the beef with the sauce.'],
|
||||
source: null,
|
||||
grounding_status: 'grounded',
|
||||
grounding_error: null,
|
||||
why_picked: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// listed is the bowls as the dinners list, and so the favorite toggle,
|
||||
// answers: no recipe.
|
||||
function listed(is_favorite: boolean) {
|
||||
return {
|
||||
id: 'd1',
|
||||
name: 'Korean beef bowls',
|
||||
method: 'stovetop',
|
||||
minutes_total: 30,
|
||||
difficulty: 'easy',
|
||||
is_favorite,
|
||||
photo_url: null,
|
||||
grounding_status: 'grounded',
|
||||
}
|
||||
}
|
||||
|
||||
const THIS_NAMES = ['Beef tacos', 'Spaghetti bolognese', 'Lemon orzo', 'Chicken stir-fry', 'Pizza night', 'Fish tacos', 'Sheet-pan salmon']
|
||||
const NEXT_NAMES = ['Chili', 'Pad thai', 'Burgers', 'Ramen', 'Pizza night', 'Pot roast', 'Fried rice']
|
||||
|
||||
// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it, with
|
||||
// dinner ids p0 to p6 unless ids says otherwise.
|
||||
function planBody(weekStart: string, names: string[], ids = names.map((_, i) => `p${i}`)) {
|
||||
const start = new Date(`${weekStart}T00:00:00Z`).getTime()
|
||||
return {
|
||||
id: `plan-${weekStart}`,
|
||||
week_start: weekStart,
|
||||
status: 'draft',
|
||||
ai_note: null,
|
||||
calendar_synced_at: null as string | null,
|
||||
days: names.map((name, i) => ({
|
||||
date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
|
||||
dinner: { id: ids[i], name, photo: null, minutes: 30, method: 'stovetop' },
|
||||
is_new: false,
|
||||
start_by: null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// THURSDAY_SWAPPED is this week's plan as the swap of Thursday answers it.
|
||||
function thursdaySwapped() {
|
||||
const ids = THIS_NAMES.map((_, i) => (i === 3 ? 'd1' : `p${i}`))
|
||||
const names = THIS_NAMES.map((name, i) => (i === 3 ? 'Korean beef bowls' : name))
|
||||
const plan = planBody('2026-10-05', names, ids)
|
||||
plan.days[3]!.is_new = true
|
||||
return plan
|
||||
}
|
||||
|
||||
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
|
||||
|
||||
// stubServer answers "METHOD path" requests from responders, with /me, the
|
||||
// household settings and the dinner; any other request fails the test.
|
||||
function stubServer(responders: Record<string, Responder> = {}) {
|
||||
const all: Record<string, Responder> = {
|
||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||
[`GET ${DINNER}`]: () => jsonResponse(200, detail()),
|
||||
...responders,
|
||||
}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(respond(init))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
// sent lists the requests sent with method, as "path body".
|
||||
function sent(fetchMock: Mock<typeof fetch>, method: string) {
|
||||
return fetchMock.mock.calls
|
||||
.filter(([, init]) => init?.method === method)
|
||||
.map(([input, init]) => `${String(input)} ${String(init?.body)}`)
|
||||
}
|
||||
|
||||
function reads(fetchMock: Mock<typeof fetch>, path: string) {
|
||||
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === path).length
|
||||
}
|
||||
|
||||
// deferred is a response the test sends when it chooses.
|
||||
function deferred() {
|
||||
let send!: (response: Response) => void
|
||||
const response = new Promise<Response>((resolve) => {
|
||||
send = resolve
|
||||
})
|
||||
return { response, send }
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||
// the bowls' detail screen, with plan for the Plan: a probe unless told
|
||||
// otherwise. It answers with the router.
|
||||
async function renderPage(plan = <h1>Plan probe</h1>) {
|
||||
const api = createApiClient(fakeLocation('/dinner/d1'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{ path: '/dinner/:id', element: <DinnerDetailPage /> },
|
||||
{ path: '/plan', element: plan },
|
||||
],
|
||||
{ initialEntries: ['/dinner/d1'] },
|
||||
)
|
||||
render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||
return router
|
||||
}
|
||||
|
||||
// openSheet opens Add to this week and waits for the week's days.
|
||||
async function openSheet(listName = 'Days of this week') {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add to this week' }))
|
||||
const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' })
|
||||
const days = await within(sheet).findByRole('list', { name: listName })
|
||||
return { sheet, days }
|
||||
}
|
||||
|
||||
// dayRows is each day's row text, as "Monday 5 · Beef tacos · Stovetop · 30 min",
|
||||
// with plain spaces.
|
||||
function dayRows(days: HTMLElement) {
|
||||
return within(days)
|
||||
.getAllByRole('listitem')
|
||||
.map((row) => [...row.querySelectorAll('span')].filter((span) => span.children.length === 0 && !span.closest('[aria-hidden="true"]')).map((span) => span.textContent).join(' · ').replace(/\s/g, ' '))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockViewport(390)
|
||||
// Wednesday Oct 7, noon in New York.
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Add to this week', () => {
|
||||
it('lists the seven days of this week with their dinners, and next week by the toggle', async () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
|
||||
[`GET ${NEXT_WEEK}`]: () => jsonResponse(200, planBody('2026-10-12', NEXT_NAMES)),
|
||||
})
|
||||
await renderPage()
|
||||
// Nothing about the week is read until the sheet opens.
|
||||
expect(reads(fetchMock, THIS_WEEK)).toBe(0)
|
||||
|
||||
const { sheet, days } = await openSheet()
|
||||
expect(within(sheet).getByText("Oct 5 – 11 · replaces the day's dinner")).toBeInTheDocument()
|
||||
expect(dayRows(days)).toEqual([
|
||||
'Monday 5 · Beef tacos · Stovetop · 30 min',
|
||||
'Tuesday 6 · Spaghetti bolognese · Stovetop · 30 min',
|
||||
'Wednesday 7 · Lemon orzo · Stovetop · 30 min',
|
||||
'Thursday 8 · Chicken stir-fry · Stovetop · 30 min',
|
||||
'Friday 9 · Pizza night · Stovetop · 30 min',
|
||||
'Saturday 10 · Fish tacos · Stovetop · 30 min',
|
||||
'Sunday 11 · Sheet-pan salmon · Stovetop · 30 min',
|
||||
])
|
||||
expect(within(days).getAllByRole('button')).toHaveLength(7)
|
||||
|
||||
fireEvent.click(within(sheet).getByRole('radio', { name: 'Next week' }))
|
||||
const next = await within(sheet).findByRole('list', { name: 'Days of next week' })
|
||||
expect(within(sheet).getByText("Oct 12 – 18 · replaces the day's dinner")).toBeInTheDocument()
|
||||
expect(dayRows(next)[0]).toBe('Monday 12 · Chili · Stovetop · 30 min')
|
||||
expect(dayRows(next)[6]).toBe('Sunday 18 · Fried rice · Stovetop · 30 min')
|
||||
|
||||
fireEvent.click(within(sheet).getByRole('radio', { name: 'This week' }))
|
||||
expect(await within(sheet).findByRole('list', { name: 'Days of this week' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("takes this week in the household's timezone, not the device's UTC date", async () => {
|
||||
// Sunday Oct 11, 10 pm in New York, already Monday Oct 12 in UTC.
|
||||
vi.setSystemTime(new Date('2026-10-12T02:00:00Z'))
|
||||
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)) })
|
||||
await renderPage()
|
||||
|
||||
const { days } = await openSheet()
|
||||
expect(dayRows(days)[6]).toBe('Sunday 11 · Sheet-pan salmon · Stovetop · 30 min')
|
||||
expect(reads(fetchMock, THIS_WEEK)).toBe(1)
|
||||
expect(reads(fetchMock, NEXT_WEEK)).toBe(0)
|
||||
})
|
||||
|
||||
it('swaps the day chosen through the plan API, confirms it and reads the plan again', async () => {
|
||||
const swap = deferred()
|
||||
let plan = planBody('2026-10-05', THIS_NAMES)
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
|
||||
[`PUT ${THURSDAY}`]: () => swap.response,
|
||||
})
|
||||
await renderPage()
|
||||
const { days } = await openSheet()
|
||||
|
||||
fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(sent(fetchMock, 'PUT')).toHaveLength(1))
|
||||
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`])
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Adding to Thursday…')
|
||||
|
||||
plan = thursdaySwapped()
|
||||
await act(async () => swap.send(jsonResponse(200, plan)))
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
|
||||
// The plan query is invalidated: the week is read again from the server.
|
||||
await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(2))
|
||||
|
||||
// The sheet opened again shows the plan as swapped.
|
||||
const again = await openSheet()
|
||||
expect(dayRows(again.days)[3]).toBe('Thursday 8 · Korean beef bowls · Stovetop · 30 min · This dinner')
|
||||
expect(within(again.sheet).getByText('Korean beef bowls is already on Thursday this week.')).toBeInTheDocument()
|
||||
expect(within(again.days).queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('offers to draft a week with no plan on the Plan, and swaps nothing', async () => {
|
||||
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found') })
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add to this week' }))
|
||||
const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' })
|
||||
const draft = await within(sheet).findByRole('link', { name: 'Draft this week first' })
|
||||
expect(draft).toHaveAttribute('href', '/plan')
|
||||
expect(within(sheet).getByText("There's no plan for this week yet, so there's no day to add it to.")).toBeInTheDocument()
|
||||
expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(draft)
|
||||
expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument()
|
||||
expect(sent(fetchMock, 'PUT')).toEqual([])
|
||||
})
|
||||
|
||||
it('shows a refused swap with the error, puts the day back and retries it', async () => {
|
||||
let refuse = true
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
|
||||
[`PUT ${THURSDAY}`]: () => (refuse ? errorResponse(500, 'internal') : jsonResponse(200, thursdaySwapped())),
|
||||
})
|
||||
await renderPage()
|
||||
const { days } = await openSheet()
|
||||
fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't add it to Thursday this week. internal message")
|
||||
const reopened = await openSheet()
|
||||
expect(dayRows(reopened.days)[3]).toBe('Thursday 8 · Chicken stir-fry · Stovetop · 30 min')
|
||||
fireEvent.keyDown(reopened.sheet, { key: 'Escape' })
|
||||
|
||||
refuse = false
|
||||
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`, `${THURSDAY} {"dinner_id":"d1"}`])
|
||||
})
|
||||
|
||||
it("leaves a push's Supply run outcome behind on the Plan, as a swap on the Draft board does", async () => {
|
||||
let plan = planBody('2026-10-05', THIS_NAMES)
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
|
||||
[`POST ${THIS_WEEK}/push-calendar`]: () => {
|
||||
plan = { ...plan, status: 'synced', calendar_synced_at: '2026-10-07T16:00:00Z' }
|
||||
return jsonResponse(200, { ...plan, supply_run: { status: 'generated', error: null } })
|
||||
},
|
||||
[`PUT ${THURSDAY}`]: () => {
|
||||
plan = { ...thursdaySwapped(), status: 'changed', calendar_synced_at: '2026-10-07T16:00:00Z' }
|
||||
return jsonResponse(200, plan)
|
||||
},
|
||||
'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'),
|
||||
})
|
||||
const router = await renderPage(<PlanPage />)
|
||||
|
||||
// The week is pushed on the Plan, which says its Supply run was made.
|
||||
await act(() => router.navigate('/plan'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
|
||||
// Coming back from the dinner with nothing swapped, it still says so.
|
||||
await act(() => router.navigate('/dinner/d1'))
|
||||
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||
await act(() => router.navigate('/plan'))
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
|
||||
|
||||
// Thursday is swapped from the dinner's Add to this week.
|
||||
await act(() => router.navigate('/dinner/d1'))
|
||||
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||
const { days } = await openSheet()
|
||||
fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
|
||||
|
||||
// The Plan shows the swapped plan, not changed since its push, and no
|
||||
// longer says the earlier plan's Supply run was made.
|
||||
await act(() => router.navigate('/plan'))
|
||||
expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
|
||||
expect(screen.getByText('Korean beef bowls')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText("Made this week's Supply run from the plan.")).not.toBeInTheDocument()
|
||||
expect(sent(fetchMock, 'POST')).toEqual([`${THIS_WEEK}/push-calendar undefined`])
|
||||
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`])
|
||||
})
|
||||
|
||||
it('refuses a swap while the week is being pushed to Calendar', async () => {
|
||||
const push = deferred()
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
|
||||
[`POST ${THIS_WEEK}/push-calendar`]: () => push.response,
|
||||
'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'),
|
||||
})
|
||||
const router = await renderPage(<PlanPage />)
|
||||
await act(() => router.navigate('/plan'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1))
|
||||
|
||||
await act(() => router.navigate('/dinner/d1'))
|
||||
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||
const { sheet, days } = await openSheet()
|
||||
expect(within(sheet).getByText('The plan for this week is being pushed to Calendar.')).toBeInTheDocument()
|
||||
expect(within(days).queryAllByRole('button')).toHaveLength(0)
|
||||
expect(sent(fetchMock, 'PUT')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the favorite heart', () => {
|
||||
it('fills at once, keeps the recipe and takes the server answer', async () => {
|
||||
const favorite = deferred()
|
||||
const fetchMock = stubServer({ [`POST ${FAVORITE}`]: () => favorite.response })
|
||||
await renderPage()
|
||||
const heart = screen.getByRole('button', { name: 'Favorite' })
|
||||
expect(heart).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
fireEvent.click(heart)
|
||||
await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'true'))
|
||||
expect(sent(fetchMock, 'POST')).toEqual([`${FAVORITE} undefined`])
|
||||
|
||||
await act(async () => favorite.send(jsonResponse(200, listed(true))))
|
||||
await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1))
|
||||
expect(heart).toHaveAttribute('aria-pressed', 'true')
|
||||
// The list-shaped answer did not replace the detail: the recipe stays.
|
||||
expect(screen.getByText('Ground beef')).toBeInTheDocument()
|
||||
expect(screen.getByText('Brown the beef with the sauce.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('empties again with the error when the server refuses, and retries', async () => {
|
||||
const refused = deferred()
|
||||
let refuse = true
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${DINNER}`]: () => jsonResponse(200, detail({ is_favorite: true })),
|
||||
[`POST ${FAVORITE}`]: () => (refuse ? refused.response : jsonResponse(200, listed(false))),
|
||||
})
|
||||
await renderPage()
|
||||
const heart = screen.getByRole('button', { name: 'Favorite' })
|
||||
expect(heart).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
fireEvent.click(heart)
|
||||
await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false'))
|
||||
await act(async () => refused.send(errorResponse(500, 'internal')))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't unfavorite Korean beef bowls. internal message")
|
||||
expect(heart).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
refuse = false
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false'))
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
|
||||
expect(sent(fetchMock, 'POST')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
// A dinner's detail offline once visited: the page reads through the service
|
||||
// worker's real API strategy (src/test/serviceWorker.ts), so what it shows
|
||||
// offline is what the worker saved while it was online. jsdom has no service
|
||||
// worker, so the strategy runs directly against an in-memory cache.
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OfflinePill } from '../pwa/OfflinePill.tsx'
|
||||
import { AppShell } from '../shell/AppShell.tsx'
|
||||
import { cacheNames, routeFor } from '../sw/routes.ts'
|
||||
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { errorResponse, jsonResponse } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { origin, stubFetchThroughWorker, stubWorkerCaches, type FakeCache } from '../test/serviceWorker.ts'
|
||||
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
|
||||
|
||||
// grounded is a dinner as GET /api/v1/dinners/{id} sends it, its recipe found.
|
||||
function grounded(id: string, name: string) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
status: 'quest_log',
|
||||
origin: 'suggested',
|
||||
method: 'stovetop',
|
||||
minutes_total: 30,
|
||||
difficulty: 'easy',
|
||||
cuisine: 'korean',
|
||||
is_favorite: false,
|
||||
photo_urls: { '400': `/media/photos/${id}-400.jpg`, '1200': `/media/photos/${id}-1200.jpg` },
|
||||
ingredients: [
|
||||
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
|
||||
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
|
||||
],
|
||||
steps: ['Cook the rice.', 'Brown the beef with the sauce.'],
|
||||
source: { site_name: 'Serious Eats' },
|
||||
grounding_status: 'grounded',
|
||||
grounding_error: null,
|
||||
why_picked: null,
|
||||
}
|
||||
}
|
||||
|
||||
// server is what the server sends while it can be reached. Kitchen reading
|
||||
// mode also reads the household settings, which the worker never saves, and
|
||||
// the week's plans to find the dinner's day, of which there are none here.
|
||||
const server: Record<string, unknown> = {
|
||||
'/api/v1/settings/household': HOUSEHOLD_SETTINGS,
|
||||
'/api/v1/dinners/d1': grounded('d1', 'Korean beef bowls'),
|
||||
'/api/v1/dinners/d2': grounded('d2', 'Chicken tikka masala'),
|
||||
'/api/v1/dinners/d3': {
|
||||
...grounded('d3', 'Pork carnitas'),
|
||||
photo_urls: null,
|
||||
ingredients: [],
|
||||
steps: [],
|
||||
source: null,
|
||||
grounding_status: 'pending',
|
||||
},
|
||||
}
|
||||
|
||||
let cache: FakeCache
|
||||
let reachable: boolean
|
||||
// networkReads is every path the network was asked for, reached or not.
|
||||
let networkReads: string[]
|
||||
|
||||
beforeEach(() => {
|
||||
cache = stubWorkerCaches()
|
||||
reachable = true
|
||||
networkReads = []
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable)
|
||||
stubFetchThroughWorker(async (request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
networkReads.push(path)
|
||||
if (!reachable) throw new TypeError('Failed to fetch')
|
||||
if (path.startsWith('/api/v1/plans/')) return errorResponse(404, 'not_found')
|
||||
if (!Object.hasOwn(server, path)) throw new Error(`unexpected network fetch: ${request.url}`)
|
||||
return jsonResponse(200, server[path])
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// open loads /dinner/{id} as a fresh page load would: a new API client,
|
||||
// network status and query cache, with the offline indicator App shows.
|
||||
function open(id: string) {
|
||||
const router = createMemoryRouter(
|
||||
[{ element: <AppShell />, children: [{ path: '/dinner/:id', element: <DinnerDetailPage /> }] }],
|
||||
{ initialEntries: [`/dinner/${id}`] },
|
||||
)
|
||||
return render(
|
||||
<ApiProviders>
|
||||
<RouterProvider router={router} />
|
||||
<OfflinePill />
|
||||
</ApiProviders>,
|
||||
)
|
||||
}
|
||||
|
||||
// visit opens a dinner while online and leaves once its title shows.
|
||||
async function visit(id: string, name: string) {
|
||||
const page = open(id)
|
||||
expect(await screen.findByRole('heading', { level: 1, name })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Offline — showing saved data')).not.toBeInTheDocument()
|
||||
page.unmount()
|
||||
}
|
||||
|
||||
describe('dinner detail offline', () => {
|
||||
it('saves a visited dinner in the API cache the worker reads offline', async () => {
|
||||
mockViewport(390)
|
||||
expect(routeFor({ method: 'GET', url: new URL('/api/v1/dinners/d1', origin), mode: 'cors' }, origin)).toEqual({
|
||||
strategy: 'network-first',
|
||||
cacheName: cacheNames.api,
|
||||
})
|
||||
await visit('d1', 'Korean beef bowls')
|
||||
expect([...cache.entries.keys()]).toEqual([`${origin}/api/v1/dinners/d1`])
|
||||
})
|
||||
|
||||
it('shows a visited dinner offline from its saved copy: photo, ingredients and steps, with the offline indicator', async () => {
|
||||
mockViewport(390)
|
||||
await visit('d1', 'Korean beef bowls')
|
||||
|
||||
reachable = false
|
||||
open('d1')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
||||
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg')
|
||||
const [ingredients, steps] = screen.getAllByRole('list')
|
||||
expect(within(ingredients!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([
|
||||
'Ground beef1 lb',
|
||||
'Jasmine rice2 cups',
|
||||
])
|
||||
expect(within(steps!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([
|
||||
'1Cook the rice.',
|
||||
'2Brown the beef with the sauce.',
|
||||
])
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a visited dinner offline in Kitchen reading mode on a tablet', async () => {
|
||||
mockViewport(1180)
|
||||
await visit('d1', 'Korean beef bowls')
|
||||
|
||||
reachable = false
|
||||
open('d1')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg')
|
||||
expect(within(screen.getByRole('region', { name: 'Ingredients' })).getAllByRole('checkbox')).toHaveLength(2)
|
||||
expect(screen.getByRole('listitem', { current: 'step' })).toHaveTextContent('Cook the rice.')
|
||||
})
|
||||
|
||||
it('shows an error, not a blank page or placeholder, for a dinner never visited', async () => {
|
||||
mockViewport(390)
|
||||
await visit('d1', 'Korean beef bowls')
|
||||
|
||||
reachable = false
|
||||
open('d2')
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.")
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Korean beef bowls|Chicken tikka masala/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps a saved copy still finding its recipe as that, not a finished recipe', async () => {
|
||||
mockViewport(390)
|
||||
await visit('d3', 'Pork carnitas')
|
||||
|
||||
reachable = false
|
||||
open('d3')
|
||||
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
|
||||
// Read once online and tried once offline before the saved copy answered.
|
||||
expect(networkReads.filter((path) => path.startsWith('/api/v1/dinners/'))).toEqual([
|
||||
'/api/v1/dinners/d3',
|
||||
'/api/v1/dinners/d3',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,547 @@
|
||||
/* Mock 1k on mobile: the photo and the sheet run full-bleed past the
|
||||
shell's 20 px side padding. From tablet up the page is Kitchen reading
|
||||
mode (mock 2a, below). The page is at least as tall as the shell's scroll
|
||||
area (a stretched flex item, so its height resolves the percentage) and
|
||||
the sheet, or the reading view, grows into any room left, so the action
|
||||
bar rests at the bottom of a short recipe. */
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Kitchen reading mode is no wider than the handoff's tablet frame. */
|
||||
.tablet,
|
||||
.desktop {
|
||||
width: 100%;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.mobile .hero,
|
||||
.mobile .skeletonHero {
|
||||
margin: 0 -20px;
|
||||
}
|
||||
|
||||
/* Without a dinner to show, the back button sits alone above the message. */
|
||||
.backRow {
|
||||
padding-top: calc(16px + env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.backRow .back {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.photo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.back {
|
||||
position: absolute;
|
||||
top: calc(16px + env(safe-area-inset-top));
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
/* The sheet overlaps the photo by 28 px. */
|
||||
.sheet {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
margin-top: -28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 22px 20px 0;
|
||||
border-radius: 32px 32px 0 0;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.mobile .sheet {
|
||||
margin-left: -20px;
|
||||
margin-right: -20px;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.why {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
background: var(--color-accent-2-100);
|
||||
color: var(--color-accent-2-900);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0 0 4px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.ingredients,
|
||||
.steps {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.ingredient {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
.ingredient:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.note,
|
||||
.qty {
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.qty {
|
||||
flex: none;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.number {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent-100);
|
||||
color: var(--color-accent-800);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.source {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.source a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.missing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 6px 0 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
background: var(--color-neutral-100);
|
||||
color: var(--color-neutral-800);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.missing p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* A 44 px tall tap target for the link. */
|
||||
.fix {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
color: var(--color-accent-700);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* At the bottom of the page, which fills the scroll area, and held at the
|
||||
bottom of the screen over a long recipe. Sticky offsets count from inside
|
||||
the scroll area's padding, which on mobile already keeps clear of the nav
|
||||
bar. */
|
||||
.bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
padding: 10px 0;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.add {
|
||||
flex: 1;
|
||||
min-height: 52px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* What became of an add or a favorite: a full row above the buttons. */
|
||||
.status {
|
||||
order: -1;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.added {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.filled {
|
||||
color: var(--color-accent);
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.skeletonHero {
|
||||
height: 250px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.skeletonTitle {
|
||||
height: 32px;
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
/* Mock 2a, Kitchen reading mode: the header over two columns, the
|
||||
ingredients card (400 px) and the steps. Ingredient and step text scale
|
||||
with the text size chosen: the handoff's sizes are medium. */
|
||||
.reading {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.small {
|
||||
--reading-scale: 0.85;
|
||||
}
|
||||
|
||||
.medium {
|
||||
--reading-scale: 1;
|
||||
}
|
||||
|
||||
.large {
|
||||
--reading-scale: 1.25;
|
||||
}
|
||||
|
||||
.readingHeader {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.readingHeader .back {
|
||||
position: static;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.heading {
|
||||
flex: 1 1 240px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.readingTitle {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.keepOn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.keepOnHint {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* The A / A / A control: an outlined pill, the size chosen in grape, each
|
||||
A drawn at the size it stands for. */
|
||||
.readingHeader .textSize {
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-divider);
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.readingHeader .textSize > * {
|
||||
padding: 0 14px;
|
||||
border-radius: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.readingHeader .textSize > [aria-checked="true"] {
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.readingHeader .textSize > :nth-child(1) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.readingHeader .textSize > :nth-child(2) {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.readingHeader .textSize > :nth-child(3) {
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.columns {
|
||||
flex-grow: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 400px minmax(0, 1fr);
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
/* Below a 1024 px viewport the shell leaves too little beside a 400 px
|
||||
card for the steps, so the columns share the width instead. */
|
||||
@media (max-width: 1023px) {
|
||||
.columns {
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
|
||||
}
|
||||
}
|
||||
|
||||
.oneColumn {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.columns .ingredientsCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 22px;
|
||||
border-radius: 30px;
|
||||
}
|
||||
|
||||
.cardHead {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.readingSectionTitle {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.setOutCount {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.checklist {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.checkRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
.checkRow .check {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.checkName {
|
||||
font-size: calc(18px * var(--reading-scale));
|
||||
font-weight: 400;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.setOut {
|
||||
color: var(--color-neutral-600);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.checkQty {
|
||||
flex: none;
|
||||
text-align: right;
|
||||
font-size: calc(15px * var(--reading-scale));
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.method {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.introPhoto {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 96px;
|
||||
}
|
||||
|
||||
.introWhy {
|
||||
flex: 1.6;
|
||||
padding: 14px 18px;
|
||||
border-radius: 22px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.readingSteps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stepList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.readingStep {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 6px 18px;
|
||||
border-radius: 26px;
|
||||
font-size: calc(18px * var(--reading-scale));
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.stepCircle {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-surface);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.stepText {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.stepDone {
|
||||
color: var(--color-neutral-600);
|
||||
}
|
||||
|
||||
.stepCurrent {
|
||||
padding: 18px;
|
||||
background: var(--color-accent-100);
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent-300);
|
||||
font-size: calc(22px * var(--reading-scale));
|
||||
}
|
||||
|
||||
.stepCurrent .stepCircle {
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
/* The action bar rests at the bottom of the steps column, with Previous
|
||||
and Next step at its right. */
|
||||
.reading .bar {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.reading .add {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.stepButtons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { AppShell } from '../shell/AppShell.tsx'
|
||||
import { cacheFallbackHeader } from '../sw/routes.ts'
|
||||
import { ApiProviders } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { errorResponse, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { GROUNDING_POLL_MS } from './api.ts'
|
||||
import { DinnerDetailPage, REGULAR_DINNERS } from './DinnerDetailPage.tsx'
|
||||
import sourceCases from './testdata/source_url_cases.json'
|
||||
|
||||
const DINNER = '/api/v1/dinners/d1'
|
||||
|
||||
// detail is a dinner as GET /api/v1/dinners/{id} sends it: mock 1k's
|
||||
// Korean beef bowls, a Side quest grounded in a page that named its site.
|
||||
function detail(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'd1',
|
||||
name: 'Korean beef bowls',
|
||||
status: 'quest_log',
|
||||
origin: 'suggested',
|
||||
method: 'stovetop',
|
||||
minutes_total: 30,
|
||||
difficulty: 'easy',
|
||||
cuisine: 'korean',
|
||||
is_favorite: false,
|
||||
photo_urls: { '400': '/media/photos/p1-400.jpg', '1200': '/media/photos/p1-1200.jpg' },
|
||||
ingredients: [
|
||||
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
|
||||
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
|
||||
{ name: 'Soy sauce', qty: '¼', unit: 'cup', note: null, raw_text: '¼ cup soy sauce' },
|
||||
{ name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' },
|
||||
],
|
||||
steps: ['Cook the rice.', 'Brown the beef with the sauce.', 'Serve over rice with the onions.'],
|
||||
source: { site_name: 'Serious Eats' },
|
||||
grounding_status: 'grounded',
|
||||
grounding_error: null,
|
||||
why_picked: 'close cousin of your Beef tacos and Chicken stir-fry.',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// A recipe the household typed in: only raw_text, no source.
|
||||
const MANUAL = {
|
||||
status: 'regular',
|
||||
origin: 'regular',
|
||||
photo_urls: null,
|
||||
ingredients: [
|
||||
{ name: null, qty: null, unit: null, note: null, raw_text: '2 lb chuck roast' },
|
||||
{ name: null, qty: null, unit: null, note: null, raw_text: 'A splash of red wine' },
|
||||
],
|
||||
steps: ['Sear the roast.', 'Slow cook 8 hours.'],
|
||||
source: null,
|
||||
grounding_status: 'manual',
|
||||
why_picked: null,
|
||||
}
|
||||
|
||||
// A Regular whose recipe could not be found: no recipe at all.
|
||||
const FAILED = {
|
||||
status: 'regular',
|
||||
origin: 'regular',
|
||||
photo_urls: null,
|
||||
minutes_total: null,
|
||||
difficulty: null,
|
||||
ingredients: [],
|
||||
steps: [],
|
||||
source: null,
|
||||
grounding_status: 'failed',
|
||||
grounding_error: 'no_recipe_found',
|
||||
why_picked: null,
|
||||
}
|
||||
|
||||
// renderAt renders the detail screen at the last of entries, inside the
|
||||
// app shell's scroll area as App routes it, with probes for where its links
|
||||
// and back button go.
|
||||
function renderAt(entries: string[]) {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{ element: <AppShell />, children: [{ path: '/dinner/:id', element: <DinnerDetailPage /> }] },
|
||||
{ path: '/', element: <h1>Home probe</h1> },
|
||||
{ path: '/plan', element: <h1>Plan probe</h1> },
|
||||
{ path: REGULAR_DINNERS, element: <h1>Regular dinners probe</h1> },
|
||||
],
|
||||
{ initialEntries: entries, initialIndex: entries.length - 1 },
|
||||
)
|
||||
render(
|
||||
<ApiProviders>
|
||||
<RouterProvider router={router} />
|
||||
</ApiProviders>,
|
||||
)
|
||||
return router
|
||||
}
|
||||
|
||||
function serve(body: unknown) {
|
||||
return stubFetchByPath({ [DINNER]: () => jsonResponse(200, body) })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockViewport(390)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('DinnerDetailPage', () => {
|
||||
describe('a grounded Side quest', () => {
|
||||
it('shows its photo washed and full-bleed, tags, title and why it was picked', async () => {
|
||||
serve(detail())
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
|
||||
const photo = screen.getByRole('img', { name: 'Korean beef bowls' })
|
||||
expect(photo).toHaveAttribute('src', '/media/photos/p1-400.jpg')
|
||||
expect(photo).toHaveAttribute('srcset', '/media/photos/p1-400.jpg 400w, /media/photos/p1-1200.jpg 1200w')
|
||||
expect(declaredStyle(photo.parentElement!).height).toBe('250px')
|
||||
expect(declaredStyle(photo.parentElement!).margin).toBe('0px -20px')
|
||||
|
||||
expect(screen.getByText('Side quest')).toBeInTheDocument()
|
||||
expect(screen.getByText('Easy')).toBeInTheDocument()
|
||||
expect(screen.getByText('30 min')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Regular')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent(
|
||||
'Why it was picked: close cousin of your Beef tacos and Chicken stir-fry.',
|
||||
)
|
||||
})
|
||||
|
||||
it('lists ingredients in recipe order with quantities on the right, then numbered steps', async () => {
|
||||
serve(detail())
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 2, name: 'Ingredients' })).toBeInTheDocument()
|
||||
const rows = within(screen.getAllByRole('list')[0]!).getAllByRole('listitem')
|
||||
expect(rows.map((row) => Array.from(row.children, (cell) => cell.textContent))).toEqual([
|
||||
['Ground beef', '1 lb'],
|
||||
['Jasmine rice', '2 cups'],
|
||||
['Soy sauce', '¼ cup'],
|
||||
['Green onions, sliced', '1 bunch'],
|
||||
])
|
||||
const qty = rows[0]!.lastElementChild!
|
||||
expect(declaredStyle(qty).textAlign).toBe('right')
|
||||
expect(declaredStyle(rows[0]!).justifyContent).toBe('space-between')
|
||||
expect(declaredStyle(rows[0]!).borderBottom).toBe('1px solid var(--color-divider)')
|
||||
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Steps' })).toBeInTheDocument()
|
||||
const steps = screen.getAllByRole('list')[1]!
|
||||
expect(steps.tagName).toBe('OL')
|
||||
expect(within(steps).getAllByRole('listitem').map((step) => step.textContent)).toEqual([
|
||||
'1Cook the rice.',
|
||||
'2Brown the beef with the sauce.',
|
||||
'3Serve over rice with the onions.',
|
||||
])
|
||||
})
|
||||
|
||||
it('credits the site the recipe came from, with no link to it', async () => {
|
||||
serve(detail())
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const credit = await screen.findByText('Recipe from Serious Eats')
|
||||
expect(credit.closest('a')).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links the credit to the recipe in a new tab once the server sends a safe url', async () => {
|
||||
serve(detail({ source: { site_name: 'Serious Eats', url: 'https://www.seriouseats.com/korean-beef-bowls' } }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const credit = await screen.findByRole('link', { name: 'Recipe from Serious Eats' })
|
||||
expect(credit).toHaveAttribute('href', 'https://www.seriouseats.com/korean-beef-bowls')
|
||||
expect(credit).toHaveAttribute('target', '_blank')
|
||||
expect(credit).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['http', 'http://www.seriouseats.com/korean-beef-bowls'],
|
||||
['a password', 'https://user:secret@www.seriouseats.com/korean-beef-bowls'],
|
||||
['a query', 'https://www.seriouseats.com/korean-beef-bowls?token=abc'],
|
||||
['a fragment', 'https://www.seriouseats.com/korean-beef-bowls#token=abc'],
|
||||
])('refuses a source url with %s', async (_, url) => {
|
||||
serve(detail({ source: { site_name: 'Serious Eats', url } }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('source url is not an https link')
|
||||
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Each source exactly as the Go handler sends it for a stored link
|
||||
// (internal/server's TestGetDinnerSourceURLOnlyWhenSafe checks the same
|
||||
// file), so every url the server sends must parse here.
|
||||
it.each(sourceCases.cases)('renders the source the server sends for $stored', async ({ source }) => {
|
||||
serve(detail({ source }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const credit = await screen.findByText('Recipe from Curry House')
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
if (source.url === null) {
|
||||
expect(credit.closest('a')).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
|
||||
} else {
|
||||
expect(screen.getByRole('link', { name: 'Recipe from Curry House' })).toHaveAttribute('href', new URL(source.url).href)
|
||||
}
|
||||
})
|
||||
|
||||
it("links the credit by the url's host when the server names no site", async () => {
|
||||
serve(detail({ source: { site_name: null, url: 'https://Kitchen.Example:8443/korean-beef-bowls' } }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const credit = await screen.findByRole('link', { name: 'Recipe from kitchen.example:8443' })
|
||||
expect(credit).toHaveAttribute('href', 'https://kitchen.example:8443/korean-beef-bowls')
|
||||
expect(credit).toHaveAttribute('target', '_blank')
|
||||
expect(credit).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
// The server withholds both when the stored name could be the link's
|
||||
// host and the link could carry a token: the credit names neither.
|
||||
it('credits the web in plain text when the server sends neither site nor url', async () => {
|
||||
serve(detail({ source: { site_name: null, url: null } }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const credit = await screen.findByText('Recipe from the web')
|
||||
expect(credit.closest('a')).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('holds Add to this week and the heart at the bottom', async () => {
|
||||
serve(detail({ is_favorite: true }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const add = await screen.findByRole('button', { name: 'Add to this week' })
|
||||
expect(add).toBeEnabled()
|
||||
const heart = screen.getByRole('button', { name: 'Favorite' })
|
||||
expect(heart).toBeEnabled()
|
||||
expect(heart).toHaveAttribute('aria-pressed', 'true')
|
||||
const bar = heart.parentElement!
|
||||
expect(bar).toContainElement(add)
|
||||
expect(declaredStyle(bar).position).toBe('sticky')
|
||||
expect(declaredStyle(bar).bottom).toBe('0px')
|
||||
})
|
||||
})
|
||||
|
||||
// jsdom does no layout, so no test here can measure where the bar lands.
|
||||
// This pins the CSS chain that puts it at the bottom of the screen on a
|
||||
// short recipe: the shell's scroll area is a full-height flex item, the
|
||||
// page fills it as a column, the sheet takes the room left and the bar is
|
||||
// the page's last child. Drop any link and the bar sits right after the
|
||||
// content again, and this fails.
|
||||
it('fills the scroll area so the bar rests at the bottom of a short recipe', async () => {
|
||||
serve(detail(FAILED))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const bar = (await screen.findByRole('button', { name: 'Favorite' })).parentElement!
|
||||
const page = bar.parentElement!
|
||||
const main = page.parentElement!
|
||||
const shell = main.parentElement!
|
||||
expect(main.tagName).toBe('MAIN')
|
||||
expect(declaredStyle(shell).height).toBe('100dvh')
|
||||
expect(declaredStyle(shell).display).toBe('flex')
|
||||
expect(declaredStyle(shell).alignItems).toBe('')
|
||||
expect(declaredStyle(main).overflowY).toBe('auto')
|
||||
|
||||
expect(declaredStyle(page).minHeight).toBe('100%')
|
||||
expect(declaredStyle(page).display).toBe('flex')
|
||||
expect(declaredStyle(page).flexDirection).toBe('column')
|
||||
expect(page.lastElementChild).toBe(bar)
|
||||
const sheet = screen.getByRole('article')
|
||||
expect(sheet.parentElement).toBe(page)
|
||||
expect(sheet.nextElementSibling).toBe(bar)
|
||||
expect(declaredStyle(sheet).flexGrow).toBe('1')
|
||||
expect(declaredStyle(bar).position).toBe('sticky')
|
||||
})
|
||||
|
||||
it('shows the striped placeholder when the dinner has no photo', async () => {
|
||||
serve(detail({ photo_urls: null }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const placeholder = await screen.findByRole('img', { name: 'Korean beef bowls' })
|
||||
expect(placeholder.tagName).toBe('DIV')
|
||||
expect(placeholder).toHaveTextContent('dish photo')
|
||||
})
|
||||
|
||||
it('shows a manual Regular line by line, as typed, with no credit', async () => {
|
||||
serve(detail(MANUAL))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByText('Regular')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Side quest')).not.toBeInTheDocument()
|
||||
const [ingredients, steps] = screen.getAllByRole('list')
|
||||
expect(within(ingredients!).getAllByRole('listitem').map((row) => row.firstElementChild!.textContent)).toEqual([
|
||||
'2 lb chuck roast',
|
||||
'A splash of red wine',
|
||||
])
|
||||
expect(within(steps!).getAllByRole('listitem')).toHaveLength(2)
|
||||
expect(screen.queryByText(/Why it was picked/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveTextContent('dish photo')
|
||||
})
|
||||
|
||||
it('links a dinner whose recipe was not found to Settings → Regular dinners, with no recipe', async () => {
|
||||
serve(detail(FAILED))
|
||||
const router = renderAt(['/dinner/d1'])
|
||||
|
||||
const fix = await screen.findByRole('link', { name: 'Fix recipe' })
|
||||
expect(screen.getByText(/couldn't find a recipe/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'Steps' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(fix)
|
||||
expect(await screen.findByRole('heading', { name: 'Regular dinners probe' })).toBeInTheDocument()
|
||||
expect(router.state.location.pathname).toBe(REGULAR_DINNERS)
|
||||
})
|
||||
|
||||
it('says it is still finding a recipe while grounding is pending', async () => {
|
||||
serve(detail({ ...FAILED, grounding_status: 'pending', grounding_error: null }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'Fix recipe' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('while the recipe is being found', () => {
|
||||
const PENDING = detail({ ...FAILED, grounding_status: 'pending', grounding_error: null })
|
||||
const GROUNDED = detail({ grounding_status: 'grounded' })
|
||||
let onLine: boolean
|
||||
let body: unknown
|
||||
let fromCache: boolean
|
||||
let fetchMock: Mock<typeof fetch>
|
||||
|
||||
const reads = () => fetchMock.mock.calls.length
|
||||
const tick = (intervals = 1) => act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * intervals))
|
||||
|
||||
function setOnline(value: boolean) {
|
||||
onLine = value
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event(value ? 'online' : 'offline'))
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Only intervals are faked: TanStack Query polls with setInterval, and
|
||||
// Testing Library's waits keep their real timers.
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
||||
onLine = true
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine)
|
||||
body = PENDING
|
||||
fromCache = false
|
||||
fetchMock = stubFetchByPath({
|
||||
[DINNER]: () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json', ...(fromCache ? { [cacheFallbackHeader]: '1' } : {}) },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
// An offline event leaves TanStack Query's online manager, shared by
|
||||
// every test, offline until an online event.
|
||||
afterEach(() => {
|
||||
window.dispatchEvent(new Event('online'))
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reads the dinner again until the recipe lands, then stops', async () => {
|
||||
renderAt(['/dinner/d1'])
|
||||
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
|
||||
expect(reads()).toBe(1)
|
||||
|
||||
await tick()
|
||||
await waitFor(() => expect(reads()).toBe(2))
|
||||
|
||||
body = GROUNDED
|
||||
await tick()
|
||||
expect(await screen.findByRole('heading', { level: 2, name: 'Ingredients' })).toBeInTheDocument()
|
||||
expect(reads()).toBe(3)
|
||||
|
||||
await tick(3)
|
||||
expect(reads()).toBe(3)
|
||||
})
|
||||
|
||||
it('pauses while the browser is offline and resumes when it is back', async () => {
|
||||
renderAt(['/dinner/d1'])
|
||||
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
|
||||
expect(reads()).toBe(1)
|
||||
|
||||
setOnline(false)
|
||||
await tick(3)
|
||||
expect(reads()).toBe(1)
|
||||
|
||||
setOnline(true)
|
||||
await tick()
|
||||
await waitFor(() => expect(reads()).toBe(2))
|
||||
})
|
||||
|
||||
it('reads a saved pending copy offline once, then waits for the connection', async () => {
|
||||
onLine = false
|
||||
fromCache = true
|
||||
renderAt(['/dinner/d1'])
|
||||
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
|
||||
expect(reads()).toBe(1)
|
||||
|
||||
await tick(3)
|
||||
expect(reads()).toBe(1)
|
||||
|
||||
// The connection comes back: the saved copy is read again at once,
|
||||
// and polling goes on once the server answers.
|
||||
fromCache = false
|
||||
setOnline(true)
|
||||
await waitFor(() => expect(reads()).toBe(2))
|
||||
await tick()
|
||||
await waitFor(() => expect(reads()).toBe(3))
|
||||
})
|
||||
|
||||
it('stops polling on a saved copy even while the browser says it is online', async () => {
|
||||
fromCache = true
|
||||
renderAt(['/dinner/d1'])
|
||||
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
|
||||
|
||||
await tick(3)
|
||||
expect(reads()).toBe(1)
|
||||
})
|
||||
|
||||
it('stops polling when the page is left', async () => {
|
||||
const router = renderAt(['/dinner/d1'])
|
||||
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
|
||||
expect(reads()).toBe(1)
|
||||
|
||||
await act(() => router.navigate('/'))
|
||||
expect(screen.getByRole('heading', { name: 'Home probe' })).toBeInTheDocument()
|
||||
await tick(3)
|
||||
expect(reads()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('says an unknown or removed dinner is not found', async () => {
|
||||
stubFetchByPath({ [DINNER]: () => errorResponse(404, 'not_found') })
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Dinner not found' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Go Home' })).toHaveAttribute('href', '/')
|
||||
})
|
||||
|
||||
it('reports any other failure with a retry', async () => {
|
||||
stubFetchByPath({ [DINNER]: () => errorResponse(500, 'internal') })
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.")
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('the back button', () => {
|
||||
it('goes back in the app', async () => {
|
||||
serve(detail())
|
||||
const router = renderAt(['/plan', '/dinner/d1'])
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Back' }))
|
||||
expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument()
|
||||
expect(router.state.location.pathname).toBe('/plan')
|
||||
})
|
||||
|
||||
it('goes Home when the dinner was opened from a link', async () => {
|
||||
serve(detail())
|
||||
const router = renderAt(['/dinner/d1'])
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Back' }))
|
||||
expect(await screen.findByRole('heading', { name: 'Home probe' })).toBeInTheDocument()
|
||||
expect(router.state.location.pathname).toBe('/')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,582 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Check, ChevronLeft, ChevronRight, Heart } from 'lucide-react'
|
||||
import { useCallback, useEffect, useId, useState, type ReactNode } from 'react'
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router'
|
||||
import { ApiError } from '../api/errors.ts'
|
||||
import { planKey, usePlan, useWeekSwap } from '../plan/api.ts'
|
||||
import { duration } from '../plan/PlanPage.tsx'
|
||||
import { addDays, dayAndDate, localNow, weekdayName, weekStart } from '../plan/week.ts'
|
||||
import { useHouseholdSettings } from '../settings/api.ts'
|
||||
import { useBreakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { Card } from '../ui/Card.tsx'
|
||||
import { Checkbox } from '../ui/Checkbox.tsx'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import { Segmented, type SegmentedOption } from '../ui/Segmented.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import { Toggle } from '../ui/Toggle.tsx'
|
||||
import { AddToWeekSheet, weekNames, type Added } from './AddToWeekSheet.tsx'
|
||||
import { useToggleFavorite, type Difficulty } from './api.ts'
|
||||
import { useDinnerDetail, type DinnerDetail, type Ingredient, type RecipeSource } from './detail.ts'
|
||||
import styles from './DinnerDetailPage.module.css'
|
||||
import { useKeepScreenOn } from './keepScreenOn.ts'
|
||||
import { textSizeFailureText, useTextSize, type TextSize } from './textSize.ts'
|
||||
|
||||
// REGULAR_DINNERS is Settings → Regular dinners, where a recipe is fixed.
|
||||
export const REGULAR_DINNERS = '/settings/regulars'
|
||||
|
||||
const difficultyLabels: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
|
||||
|
||||
// BackButton goes back in the app's history, or, opened from a link with
|
||||
// no history in the app, to Home.
|
||||
function BackButton() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
// The first entry of a session has the key "default".
|
||||
const back = () => (location.key === 'default' ? void navigate('/', { replace: true }) : void navigate(-1))
|
||||
return (
|
||||
<IconButton label="Back" tone="neutral" className={styles.back} onClick={back}>
|
||||
<Icon icon={ChevronLeft} size="inline" />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
// quantity is an ingredient's amount as the right-hand column shows it,
|
||||
// "1 lb", or empty when the recipe gives none.
|
||||
function quantity(ingredient: Ingredient): string {
|
||||
return [ingredient.qty, ingredient.unit].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
// IngredientRow is one ingredient: its name with any note, or the line as
|
||||
// the recipe gives it when there is no name, and its amount on the right.
|
||||
function IngredientRow({ ingredient }: { ingredient: Ingredient }) {
|
||||
return (
|
||||
<li className={styles.ingredient}>
|
||||
<span>
|
||||
{ingredient.name ?? ingredient.raw_text}
|
||||
{ingredient.name !== null && ingredient.note !== null && <span className={styles.note}>, {ingredient.note}</span>}
|
||||
</span>
|
||||
<span className={styles.qty}>{quantity(ingredient)}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// SourceCredit is "Recipe from {site}" for a recipe fetched from the web: a
|
||||
// link opening the recipe's page in a new tab when the server sends a url
|
||||
// safe to open, else plain text. The site is the name the page gave, else
|
||||
// the url's host — already in the link, so naming it shows nothing more —
|
||||
// else, with neither to show, just "the web": a fetched recipe always
|
||||
// credits where it came from, without a name the server withheld.
|
||||
function SourceCredit({ siteName, url }: RecipeSource) {
|
||||
const credit = `Recipe from ${siteName ?? (url === null ? 'the web' : new URL(url).host)}`
|
||||
return (
|
||||
<p className={styles.source}>
|
||||
{url === null ? (
|
||||
credit
|
||||
) : (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
{credit}
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// hasRecipe says whether the dinner's recipe was found or typed in, rather
|
||||
// than still being searched for or not found.
|
||||
function hasRecipe(dinner: DinnerDetail): boolean {
|
||||
return dinner.grounding_status !== 'pending' && dinner.grounding_status !== 'failed'
|
||||
}
|
||||
|
||||
// RecipeMissing is what stands in for a recipe still being searched for, or
|
||||
// the link to fix one that could not be found.
|
||||
function RecipeMissing({ dinner }: { dinner: DinnerDetail }) {
|
||||
if (dinner.grounding_status === 'failed') {
|
||||
return (
|
||||
<section className={styles.missing} aria-label="No recipe">
|
||||
<p>We couldn't find a recipe for this dinner, so there are no ingredients or steps yet.</p>
|
||||
<Link to={REGULAR_DINNERS} className={styles.fix}>
|
||||
Fix recipe
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
return <p className={styles.missing}>Still finding the recipe — its ingredients and steps will show here.</p>
|
||||
}
|
||||
|
||||
// Recipe is the dinner's ingredients and steps, or what stands in for them.
|
||||
function Recipe({ dinner }: { dinner: DinnerDetail }) {
|
||||
if (!hasRecipe(dinner)) return <RecipeMissing dinner={dinner} />
|
||||
return (
|
||||
<>
|
||||
{dinner.ingredients.length > 0 && (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Ingredients</h2>
|
||||
<ul className={styles.ingredients}>
|
||||
{dinner.ingredients.map((ingredient, i) => (
|
||||
<IngredientRow key={i} ingredient={ingredient} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{dinner.steps.length > 0 && (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Steps</h2>
|
||||
<ol className={styles.steps}>
|
||||
{dinner.steps.map((step, i) => (
|
||||
<li key={i} className={styles.step}>
|
||||
<span className={styles.number} aria-hidden="true">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span>{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
{dinner.source !== null && <SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// AddedStatus says what became of a dinner added to a day: being saved,
|
||||
// added, or why it couldn't be, with Retry, which sends the same swap again
|
||||
// as the Draft board would (useWeekSwap), and is not offered while that is
|
||||
// refused. The week's plan stays read while it shows, so a retry finds the
|
||||
// day to swap; once added, the plan is read again from the server.
|
||||
function AddedStatus({ added }: { added: Added }) {
|
||||
const queryClient = useQueryClient()
|
||||
usePlan(added.week)
|
||||
const { swap, refused, swapIn } = useWeekSwap(added.week)
|
||||
const { date } = added.swap
|
||||
const done = swap.succeeded === added.swap
|
||||
const failed = swap.failed?.swap === added.swap ? swap.failed.error : null
|
||||
useEffect(() => {
|
||||
if (done) void queryClient.invalidateQueries({ queryKey: planKey(added.week) })
|
||||
}, [done, queryClient, added.week])
|
||||
|
||||
if (failed !== null) {
|
||||
return (
|
||||
<div className={styles.status}>
|
||||
<ErrorBanner onRetry={refused ? undefined : () => swapIn(added.swap)}>
|
||||
Couldn't add it to {weekdayName(date)} {weekNames[added.which]}. {failed.message}
|
||||
</ErrorBanner>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<p role="status" className={[styles.status, styles.added].join(' ')}>
|
||||
{done ? `Added to ${weekdayName(date)} ${weekNames[added.which]}.` : `Adding to ${weekdayName(date)}…`}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// AddToWeek is Add to this week: it opens the sheet of the week's days, and
|
||||
// says what became of the dinner added.
|
||||
function AddToWeek({ dinner }: { dinner: DinnerDetail }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [added, setAdded] = useState<Added | null>(null)
|
||||
function add(chosen: Added) {
|
||||
setOpen(false)
|
||||
setAdded(chosen)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{added !== null && <AddedStatus key={`${added.week} ${added.swap.date}`} added={added} />}
|
||||
<Button className={styles.add} onClick={() => setOpen(true)}>
|
||||
Add to this week
|
||||
</Button>
|
||||
{open && <AddToWeekSheet dinner={dinner} onAdd={add} onClose={() => setOpen(false)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// FavoriteHeart toggles whether the dinner is a favorite, at once, changing
|
||||
// back with the error and Retry if the server refuses. One toggle is sent at
|
||||
// a time.
|
||||
function FavoriteHeart({ dinner }: { dinner: DinnerDetail }) {
|
||||
const favorite = useToggleFavorite()
|
||||
function toggle() {
|
||||
if (!favorite.isPending) favorite.mutate({ id: dinner.id, is_favorite: dinner.is_favorite })
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{favorite.isError && (
|
||||
<div className={styles.status}>
|
||||
<ErrorBanner onRetry={toggle}>
|
||||
Couldn't {dinner.is_favorite ? 'unfavorite' : 'favorite'} {dinner.name}. {favorite.error.message}
|
||||
</ErrorBanner>
|
||||
</div>
|
||||
)}
|
||||
<IconButton label="Favorite" size={52} aria-pressed={dinner.is_favorite} onClick={toggle}>
|
||||
<Icon icon={Heart} size="nav" className={dinner.is_favorite ? styles.filled : undefined} />
|
||||
</IconButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ActionBar is the bar at the bottom of the screen, the page's last child:
|
||||
// the page fills the scroll area so a short recipe still leaves it at the
|
||||
// bottom, and it sticks there over a long one. It holds Add to this week
|
||||
// and the favorite heart, with what became of either above them, and in
|
||||
// Kitchen reading mode the step buttons after them.
|
||||
function ActionBar({ dinner, children }: { dinner: DinnerDetail; children?: ReactNode }) {
|
||||
return (
|
||||
<div className={styles.bar}>
|
||||
<AddToWeek dinner={dinner} />
|
||||
<FavoriteHeart dinner={dinner} />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Detail is a dinner as mock 1k shows it: the photo full-bleed under a sheet
|
||||
// with its tags, title, why it was picked and its recipe, over the action bar.
|
||||
function Detail({ dinner }: { dinner: DinnerDetail }) {
|
||||
const tags = [
|
||||
dinner.difficulty !== null && difficultyLabels[dinner.difficulty],
|
||||
dinner.minutes_total !== null && duration(dinner.minutes_total),
|
||||
].filter((tag) => tag !== false)
|
||||
return (
|
||||
<>
|
||||
<div className={styles.hero}>
|
||||
<Photo id={dinner.photo_id} name={dinner.name} radius="0" sizes="100vw" className={styles.photo} />
|
||||
<BackButton />
|
||||
</div>
|
||||
<article className={styles.sheet}>
|
||||
<div className={styles.tags}>
|
||||
<Tag tone="grape">{dinner.status === 'regular' ? 'Regular' : 'Side quest'}</Tag>
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} tone="neutral">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<h1 className={styles.title}>{dinner.name}</h1>
|
||||
{dinner.why_picked !== null && (
|
||||
<p className={styles.why}>
|
||||
<b>Why it was picked:</b> {dinner.why_picked}
|
||||
</p>
|
||||
)}
|
||||
<Recipe dinner={dinner} />
|
||||
</article>
|
||||
<ActionBar dinner={dinner} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// statusName is the dinner as the household calls it: a Regular, or a Side
|
||||
// quest for any dinner not yet one.
|
||||
function statusName(dinner: DinnerDetail): string {
|
||||
return dinner.status === 'regular' ? 'Regular' : 'Side quest'
|
||||
}
|
||||
|
||||
// PlannedDay is the kicker of a dinner on a plan, "Saturday, Oct 10 · Side
|
||||
// quest", whenever this week's or next week's plan has it. Of the days that
|
||||
// do, it is the first from today on in the household's timezone; when every
|
||||
// one is already past, it is the latest of them. There is none while the
|
||||
// plans are read, nor when neither plan has the dinner.
|
||||
function PlannedDay({ dinner, timezone }: { dinner: DinnerDetail; timezone: string }) {
|
||||
const today = localNow(timezone, new Date()).date
|
||||
const thisWeek = weekStart(today)
|
||||
const plans = [usePlan(thisWeek), usePlan(addDays(thisWeek, 7))]
|
||||
const failed = plans.find((plan) => plan.isError)
|
||||
if (failed !== undefined) {
|
||||
return (
|
||||
<ErrorBanner onRetry={() => plans.forEach((plan) => plan.isError && void plan.refetch())}>
|
||||
Couldn't check the plan for this dinner's day. {failed.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
}
|
||||
const planned = plans.flatMap((plan) => plan.data?.days ?? []).filter((day) => day.dinner.id === dinner.id)
|
||||
const day = planned.find((day) => day.date >= today) ?? planned.at(-1)
|
||||
if (day === undefined) return null
|
||||
return (
|
||||
<p className={styles.kicker}>
|
||||
{dayAndDate(day.date)} · {statusName(dinner)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// Kicker is PlannedDay, once the household's timezone is read.
|
||||
function Kicker({ dinner }: { dinner: DinnerDetail }) {
|
||||
const settings = useHouseholdSettings()
|
||||
if (settings.isError) {
|
||||
return (
|
||||
<ErrorBanner onRetry={() => void settings.refetch()}>
|
||||
Couldn't load the household settings to find this dinner's day. {settings.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
}
|
||||
if (settings.isPending) return null
|
||||
return <PlannedDay dinner={dinner} timezone={settings.data.timezone} />
|
||||
}
|
||||
|
||||
const textSizes: SegmentedOption<TextSize>[] = [
|
||||
{ value: 'small', label: 'A', ariaLabel: 'Small text' },
|
||||
{ value: 'medium', label: 'A', ariaLabel: 'Medium text' },
|
||||
{ value: 'large', label: 'A', ariaLabel: 'Large text' },
|
||||
]
|
||||
|
||||
// ReadingHeader is Kitchen reading mode's header: back, the day the dinner
|
||||
// is planned for, its title, Keep screen on and the text size.
|
||||
function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: ReturnType<typeof useTextSize> }) {
|
||||
const keepOnId = useId()
|
||||
const keepOnHintId = useId()
|
||||
const keepScreenOn = useKeepScreenOn()
|
||||
const { failure } = textSize
|
||||
return (
|
||||
<>
|
||||
<header className={styles.readingHeader}>
|
||||
<BackButton />
|
||||
<div className={styles.heading}>
|
||||
<Kicker dinner={dinner} />
|
||||
<h1 className={styles.readingTitle}>{dinner.name}</h1>
|
||||
</div>
|
||||
<div className={styles.keepOn}>
|
||||
<span id={keepOnId}>Keep screen on</span>
|
||||
<Toggle
|
||||
checked={keepScreenOn.on}
|
||||
onCheckedChange={keepScreenOn.setOn}
|
||||
aria-labelledby={keepOnId}
|
||||
disabled={!keepScreenOn.supported}
|
||||
aria-describedby={keepScreenOn.supported ? undefined : keepOnHintId}
|
||||
/>
|
||||
</div>
|
||||
<Segmented
|
||||
label="Text size"
|
||||
options={textSizes}
|
||||
value={textSize.size}
|
||||
onValueChange={textSize.choose}
|
||||
className={styles.textSize}
|
||||
/>
|
||||
</header>
|
||||
{!keepScreenOn.supported && (
|
||||
<p id={keepOnHintId} className={styles.keepOnHint}>
|
||||
This browser can't keep the screen on, so it may dim while you cook.
|
||||
</p>
|
||||
)}
|
||||
{keepScreenOn.failure !== null && (
|
||||
<ErrorBanner onRetry={() => keepScreenOn.setOn(true)}>
|
||||
Couldn't keep the screen on: {keepScreenOn.failure}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
{failure !== null && (
|
||||
<ErrorBanner onRetry={textSize.retry}>
|
||||
{textSizeFailureText[failure.action]}: {failure.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// IngredientsCard is the ingredients with a checkbox each, struck through
|
||||
// once set out, and how many are. Which are set out is this visit's alone:
|
||||
// it is never sent to the server and is not the Supply run's "have it".
|
||||
function IngredientsCard({ ingredients }: { ingredients: Ingredient[] }) {
|
||||
const titleId = useId()
|
||||
const [setOut, setSetOut] = useState<ReadonlySet<number>>(() => new Set())
|
||||
const count = ingredients.filter((_, i) => setOut.has(i)).length
|
||||
function toggle(i: number) {
|
||||
setSetOut((before) => {
|
||||
const after = new Set(before)
|
||||
if (!after.delete(i)) after.add(i)
|
||||
return after
|
||||
})
|
||||
}
|
||||
return (
|
||||
<Card role="region" aria-labelledby={titleId} className={styles.ingredientsCard}>
|
||||
<div className={styles.cardHead}>
|
||||
<h2 id={titleId} className={styles.readingSectionTitle}>
|
||||
Ingredients
|
||||
</h2>
|
||||
<span className={styles.setOutCount}>
|
||||
{count} of {ingredients.length} set out
|
||||
</span>
|
||||
</div>
|
||||
<ul className={styles.checklist}>
|
||||
{ingredients.map((ingredient, i) => (
|
||||
<li key={i} className={styles.checkRow}>
|
||||
<Checkbox
|
||||
className={styles.check}
|
||||
checked={setOut.has(i)}
|
||||
onChange={() => toggle(i)}
|
||||
label={
|
||||
<span className={[styles.checkName, setOut.has(i) && styles.setOut].filter(Boolean).join(' ')}>
|
||||
{ingredient.name ?? ingredient.raw_text}
|
||||
{ingredient.name !== null && ingredient.note !== null && `, ${ingredient.note}`}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<span className={styles.checkQty}>{quantity(ingredient)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Steps is the recipe's steps: those before current done, muted with a
|
||||
// check; current highlighted in larger text; the rest still to come.
|
||||
function Steps({ steps, current }: { steps: string[]; current: number }) {
|
||||
const titleId = useId()
|
||||
return (
|
||||
<section aria-labelledby={titleId} className={styles.readingSteps}>
|
||||
<h2 id={titleId} className={styles.readingSectionTitle}>
|
||||
Steps
|
||||
</h2>
|
||||
<ol className={styles.stepList}>
|
||||
{steps.map((step, i) => (
|
||||
<li
|
||||
key={i}
|
||||
aria-current={i === current ? 'step' : undefined}
|
||||
className={[styles.readingStep, i < current ? styles.stepDone : i === current ? styles.stepCurrent : undefined]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<span className={styles.stepCircle} aria-hidden="true">
|
||||
{i < current ? <Icon icon={Check} /> : i + 1}
|
||||
</span>
|
||||
<span className={styles.stepText}>{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// typing says whether target is a field taking text, where the arrow keys
|
||||
// move the caret and are not the screen's to take.
|
||||
function typing(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
return target.isContentEditable || target.matches('textarea, select, input:not([type="checkbox"], [type="radio"])')
|
||||
}
|
||||
|
||||
// useCurrentStep is the step being cooked, of count, from the first, with
|
||||
// move, which goes back or on by a step and stops at either end. The ← and →
|
||||
// keys move it too, unless a text field has focus, a sheet is open over the
|
||||
// screen or the key was already taken (a segmented control's arrows).
|
||||
function useCurrentStep(count: number): [number, (by: number) => void] {
|
||||
const [step, setStep] = useState(0)
|
||||
const last = count - 1
|
||||
const move = useCallback(
|
||||
(by: number) => setStep((before) => Math.max(0, Math.min(Math.min(before, last) + by, last))),
|
||||
[last],
|
||||
)
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
const by = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
|
||||
if (by === 0 || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
if (typing(event.target) || (event.target instanceof Element && event.target.closest('[role="dialog"]') !== null)) return
|
||||
event.preventDefault()
|
||||
move(by)
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [move])
|
||||
return [Math.max(0, Math.min(step, last)), move]
|
||||
}
|
||||
|
||||
// KitchenReading is a dinner as mock 2a shows it on a tablet: the header,
|
||||
// then the ingredients to check off beside the photo, why it was picked and
|
||||
// the steps, one at a time, over the action bar with Previous and Next step.
|
||||
// What is checked and the step reached last only as long as this visit.
|
||||
function KitchenReading({ dinner }: { dinner: DinnerDetail }) {
|
||||
const textSize = useTextSize()
|
||||
const [current, move] = useCurrentStep(dinner.steps.length)
|
||||
const recipe = hasRecipe(dinner)
|
||||
const last = dinner.steps.length - 1
|
||||
return (
|
||||
<div className={[styles.reading, styles[textSize.size]].join(' ')}>
|
||||
<ReadingHeader dinner={dinner} textSize={textSize} />
|
||||
<div className={recipe && dinner.ingredients.length > 0 ? styles.columns : styles.oneColumn}>
|
||||
{recipe && dinner.ingredients.length > 0 && <IngredientsCard ingredients={dinner.ingredients} />}
|
||||
<div className={styles.method}>
|
||||
<div className={styles.intro}>
|
||||
<Photo id={dinner.photo_id} name={dinner.name} radius="24px" sizes="400px" className={styles.introPhoto} />
|
||||
{dinner.why_picked !== null && (
|
||||
<p className={[styles.why, styles.introWhy].join(' ')}>
|
||||
<b>Why it was picked:</b> {dinner.why_picked}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!recipe && <RecipeMissing dinner={dinner} />}
|
||||
{recipe && dinner.steps.length > 0 && <Steps steps={dinner.steps} current={current} />}
|
||||
{recipe && dinner.source !== null && (
|
||||
<SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />
|
||||
)}
|
||||
<ActionBar dinner={dinner}>
|
||||
{recipe && dinner.steps.length > 0 && (
|
||||
<div className={styles.stepButtons}>
|
||||
<Button variant="outline" disabled={current === 0} onClick={() => move(-1)}>
|
||||
<Icon icon={ChevronLeft} />
|
||||
Previous
|
||||
</Button>
|
||||
<Button disabled={current === last} onClick={() => move(1)}>
|
||||
<Icon icon={ChevronRight} />
|
||||
Next step
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ActionBar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// DinnerDetailPage is a dinner with its recipe: on a phone (mock 1k) its
|
||||
// photo, or the striped placeholder, under a sheet with Regular or Side
|
||||
// quest, difficulty and time, why it was picked, the ingredients and the
|
||||
// steps; from tablet up, Kitchen reading mode (mock 2a). A dinner whose
|
||||
// recipe could not be found links to Settings → Regular dinners to fix it;
|
||||
// an unknown or removed dinner says it is not found.
|
||||
export function DinnerDetailPage() {
|
||||
const { id = '' } = useParams()
|
||||
const layout = useBreakpoint()
|
||||
const dinner = useDinnerDetail(id)
|
||||
|
||||
let body
|
||||
if (dinner.isError) {
|
||||
body = (
|
||||
<>
|
||||
<div className={styles.backRow}>
|
||||
<BackButton />
|
||||
</div>
|
||||
{dinner.error instanceof ApiError && dinner.error.status === 404 ? (
|
||||
<>
|
||||
<EmptyState title="Dinner not found">This dinner isn't in our kitchen — it may have been removed.</EmptyState>
|
||||
<Link to="/" className={[buttonStyles.button, buttonStyles.outline].join(' ')}>
|
||||
Go Home
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<ErrorBanner onRetry={() => void dinner.refetch()}>Couldn't load this dinner. {dinner.error.message}</ErrorBanner>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
} else if (dinner.isPending) {
|
||||
body = (
|
||||
<section aria-busy="true" aria-label="Dinner loading">
|
||||
<Skeleton className={styles.skeletonHero} />
|
||||
<div className={styles.sheet}>
|
||||
<Skeleton className={styles.skeletonTitle} />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
} else {
|
||||
// Keyed by the dinner, so moving to another dinner starts its visit
|
||||
// afresh: nothing checked, on the first step.
|
||||
body =
|
||||
layout === 'mobile' ? <Detail dinner={dinner.data} /> : <KitchenReading key={dinner.data.id} dinner={dinner.data} />
|
||||
}
|
||||
return <div className={`${styles.page} ${styles[layout]}`}>{body}</div>
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { errorResponse, jsonResponse } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
|
||||
import { TEXT_SIZE_KEY } from './textSize.ts'
|
||||
|
||||
const DINNER = '/api/v1/dinners/d1'
|
||||
const SETTINGS = '/api/v1/settings/household'
|
||||
const THIS_WEEK = '/api/v1/plans/2026-10-05'
|
||||
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
|
||||
|
||||
// detail is the Korean beef bowls, a Quest-log Side quest, as GET
|
||||
// /api/v1/dinners/{id} sends it.
|
||||
function detail(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'd1',
|
||||
name: 'Korean beef bowls',
|
||||
status: 'quest_log',
|
||||
origin: 'suggested',
|
||||
method: 'stovetop',
|
||||
minutes_total: 30,
|
||||
difficulty: 'easy',
|
||||
cuisine: 'korean',
|
||||
is_favorite: false,
|
||||
photo_urls: null,
|
||||
ingredients: [
|
||||
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
|
||||
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
|
||||
{ name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' },
|
||||
],
|
||||
steps: [
|
||||
'Rinse the rice and start it cooking.',
|
||||
'Brown the beef with the garlic and ginger.',
|
||||
'Stir in the sauce and simmer until glossy.',
|
||||
],
|
||||
source: null,
|
||||
grounding_status: 'grounded',
|
||||
grounding_error: null,
|
||||
why_picked: 'close cousin of your Beef tacos.',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it: p0 to
|
||||
// p6, except the bowls (d1) on each day of bowlsOn, counting from Monday = 0.
|
||||
function planBody(weekStart: string, ...bowlsOn: number[]) {
|
||||
const start = new Date(`${weekStart}T00:00:00Z`).getTime()
|
||||
return {
|
||||
id: `plan-${weekStart}`,
|
||||
week_start: weekStart,
|
||||
status: 'draft',
|
||||
ai_note: null,
|
||||
calendar_synced_at: null,
|
||||
days: Array.from({ length: 7 }, (_, i) => ({
|
||||
date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
|
||||
dinner:
|
||||
bowlsOn.includes(i)
|
||||
? { id: 'd1', name: 'Korean beef bowls', photo: null, minutes: 30, method: 'stovetop' }
|
||||
: { id: `p${i}`, name: `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop' },
|
||||
is_new: bowlsOn.includes(i),
|
||||
start_by: null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// serve answers the screen's reads: the dinner, the household settings and
|
||||
// both weeks' plans, unless responses says otherwise.
|
||||
function serve(responses: Record<string, () => Response> = {}): Mock<typeof fetch> {
|
||||
const all: Record<string, () => Response> = {
|
||||
[DINNER]: () => jsonResponse(200, detail()),
|
||||
[SETTINGS]: () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 5)),
|
||||
[NEXT_WEEK]: () => errorResponse(404, 'not_found'),
|
||||
...responses,
|
||||
}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const path = String(input)
|
||||
const respond = Object.hasOwn(all, path) ? all[path] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`)
|
||||
return Promise.resolve(respond())
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
function reads(fetchMock: Mock<typeof fetch>, path: string) {
|
||||
return fetchMock.mock.calls.filter(([input]) => String(input) === path).length
|
||||
}
|
||||
|
||||
// renderPage renders the bowls' detail screen and waits for its title.
|
||||
async function renderPage() {
|
||||
const router = createMemoryRouter([{ path: '/dinner/:id', element: <DinnerDetailPage /> }], {
|
||||
initialEntries: ['/dinner/d1'],
|
||||
})
|
||||
const view = render(
|
||||
<ApiProviders>
|
||||
<RouterProvider router={router} />
|
||||
</ApiProviders>,
|
||||
)
|
||||
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||
return view
|
||||
}
|
||||
|
||||
function steps() {
|
||||
return within(screen.getByRole('region', { name: 'Steps' })).getAllByRole('listitem')
|
||||
}
|
||||
|
||||
// currentStep is the text of the step marked current.
|
||||
function currentStep() {
|
||||
const current = steps().filter((step) => step.getAttribute('aria-current') === 'step')
|
||||
expect(current).toHaveLength(1)
|
||||
return current[0]!.textContent
|
||||
}
|
||||
|
||||
// FakeSentinel is a WakeLockSentinel as far as the screen uses one.
|
||||
type FakeSentinel = EventTarget & { released: boolean; release: Mock<() => Promise<void>> }
|
||||
|
||||
// letGo releases a lock as the browser does, telling its listeners so.
|
||||
function letGo(sentinel: FakeSentinel) {
|
||||
if (sentinel.released) return
|
||||
sentinel.released = true
|
||||
sentinel.dispatchEvent(new Event('release'))
|
||||
}
|
||||
|
||||
// stubWakeLock gives jsdom, which has none, a Screen Wake Lock. Each request
|
||||
// is granted at once, or, given hold, only when the test calls grant.
|
||||
function stubWakeLock({ hold = false } = {}) {
|
||||
const sentinels: FakeSentinel[] = []
|
||||
const pending: (() => void)[] = []
|
||||
const request = vi.fn((type: string) => {
|
||||
expect(type).toBe('screen')
|
||||
const sentinel: FakeSentinel = Object.assign(new EventTarget(), {
|
||||
released: false,
|
||||
release: vi.fn(() => {
|
||||
letGo(sentinel)
|
||||
return Promise.resolve()
|
||||
}),
|
||||
})
|
||||
sentinels.push(sentinel)
|
||||
if (!hold) return Promise.resolve(sentinel)
|
||||
return new Promise<FakeSentinel>((resolve) => pending.push(() => resolve(sentinel)))
|
||||
})
|
||||
Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request } })
|
||||
return { request, sentinels, grant: () => pending.shift()!() }
|
||||
}
|
||||
|
||||
// setVisibility shows or hides the page as the browser would, telling the
|
||||
// page so.
|
||||
function setVisibility(state: DocumentVisibilityState) {
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockViewport(1180)
|
||||
// Wednesday Oct 7, noon in New York.
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
Reflect.deleteProperty(navigator, 'wakeLock')
|
||||
Reflect.deleteProperty(document, 'visibilityState')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Kitchen reading mode', () => {
|
||||
it('is the tablet layout of the same route: header with back, title, Keep screen on and text size', async () => {
|
||||
stubWakeLock()
|
||||
serve()
|
||||
await renderPage()
|
||||
|
||||
const title = screen.getByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||
expect(declaredStyle(title).fontSize).toBe('36px')
|
||||
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
|
||||
// Not the phone's sheet: no photo hero tags.
|
||||
expect(screen.queryByText('Easy')).not.toBeInTheDocument()
|
||||
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
||||
fireEvent.click(keepOn)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
const sizes = screen.getByRole('radiogroup', { name: 'Text size' })
|
||||
expect(within(sizes).getAllByRole('radio').map((radio) => radio.getAttribute('aria-label'))).toEqual([
|
||||
'Small text',
|
||||
'Medium text',
|
||||
'Large text',
|
||||
])
|
||||
expect(within(sizes).getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
expect(declaredStyle(screen.getByRole('region', { name: 'Ingredients' }).parentElement!).gridTemplateColumns).toBe(
|
||||
'400px minmax(0, 1fr)',
|
||||
)
|
||||
expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent('Why it was picked: close cousin')
|
||||
})
|
||||
|
||||
describe('kicker', () => {
|
||||
it('names the day the plan has the dinner on, and Side quest', async () => {
|
||||
serve()
|
||||
await renderPage()
|
||||
expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("names a day of next week's plan, and Regular, for a Regular", async () => {
|
||||
serve({
|
||||
[DINNER]: () => jsonResponse(200, detail({ status: 'regular', origin: 'regular' })),
|
||||
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05')),
|
||||
[NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12', 0)),
|
||||
})
|
||||
await renderPage()
|
||||
expect(await screen.findByText('Monday, Oct 12 · Regular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("names a day of this week's plan already past when that is the only day", async () => {
|
||||
// Today is Wednesday; the bowls were on Monday, and are on no day of next week.
|
||||
serve({
|
||||
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0)),
|
||||
[NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12')),
|
||||
})
|
||||
await renderPage()
|
||||
expect(await screen.findByText('Monday, Oct 5 · Side quest')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('names the first day from today on over a day already past', async () => {
|
||||
serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1, 4, 6)) })
|
||||
await renderPage()
|
||||
expect(await screen.findByText('Friday, Oct 9 · Side quest')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Monday|Tuesday|Sunday/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('names the latest day already past when every day is past', async () => {
|
||||
serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1)) })
|
||||
await renderPage()
|
||||
expect(await screen.findByText('Tuesday, Oct 6 · Side quest')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Monday/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is absent when neither week has a plan', async () => {
|
||||
const fetchMock = serve({ [THIS_WEEK]: () => errorResponse(404, 'not_found') })
|
||||
await renderPage()
|
||||
await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(1))
|
||||
await waitFor(() => expect(reads(fetchMock, NEXT_WEEK)).toBe(1))
|
||||
await act(async () => {})
|
||||
expect(screen.queryByText(/· Side quest/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Oct/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says so when the plan cannot be read, with Retry', async () => {
|
||||
let failing = true
|
||||
serve({
|
||||
[THIS_WEEK]: () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, planBody('2026-10-05', 5))),
|
||||
})
|
||||
await renderPage()
|
||||
expect(await screen.findByText(/Couldn't check the plan for this dinner's day/)).toBeInTheDocument()
|
||||
|
||||
failing = false
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ingredients', () => {
|
||||
it('checks ingredients off with 22 px checkboxes, struck through, counting those set out', async () => {
|
||||
const fetchMock = serve()
|
||||
await renderPage()
|
||||
const card = screen.getByRole('region', { name: 'Ingredients' })
|
||||
expect(within(card).getByText('0 of 3 set out')).toBeInTheDocument()
|
||||
|
||||
const beef = within(card).getByRole('checkbox', { name: 'Ground beef' })
|
||||
expect(declaredStyle(beef).width).toBe('22px')
|
||||
expect(declaredStyle(beef).height).toBe('22px')
|
||||
expect(within(card).getByRole('checkbox', { name: 'Green onions, sliced' })).not.toBeChecked()
|
||||
|
||||
fireEvent.click(beef)
|
||||
expect(beef).toBeChecked()
|
||||
expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('line-through')
|
||||
fireEvent.click(within(card).getByRole('checkbox', { name: 'Jasmine rice' }))
|
||||
expect(within(card).getByText('2 of 3 set out')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(beef)
|
||||
expect(beef).not.toBeChecked()
|
||||
expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('')
|
||||
expect(within(card).getByText('1 of 3 set out')).toBeInTheDocument()
|
||||
|
||||
// Local to the screen: nothing is sent.
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')).toEqual([])
|
||||
})
|
||||
|
||||
it('starts afresh on the next visit', async () => {
|
||||
serve()
|
||||
const view = await renderPage()
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Ground beef' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next step' }))
|
||||
view.unmount()
|
||||
|
||||
await renderPage()
|
||||
expect(screen.getByText('0 of 3 set out')).toBeInTheDocument()
|
||||
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('steps', () => {
|
||||
it('highlights the current step in 22 px text, others 18 px, and moves by Previous and Next step', async () => {
|
||||
serve()
|
||||
await renderPage()
|
||||
const previous = screen.getByRole('button', { name: 'Previous' })
|
||||
const next = screen.getByRole('button', { name: 'Next step' })
|
||||
|
||||
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
||||
expect(previous).toBeDisabled()
|
||||
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(22px * var(--reading-scale))')
|
||||
expect(declaredStyle(steps()[0]!).background).toBe('var(--color-accent-100)')
|
||||
expect(declaredStyle(steps()[0]!).boxShadow).toBe('inset 0 0 0 2px var(--color-accent-300)')
|
||||
expect(declaredStyle(steps()[1]!).fontSize).toBe('calc(18px * var(--reading-scale))')
|
||||
|
||||
fireEvent.click(next)
|
||||
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
||||
// The step done is muted, its number a check.
|
||||
expect(steps()[0]!.textContent).toBe('Rinse the rice and start it cooking.')
|
||||
expect(steps()[0]!.querySelector('svg')).not.toBeNull()
|
||||
expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-600)')
|
||||
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(18px * var(--reading-scale))')
|
||||
|
||||
fireEvent.click(next)
|
||||
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
|
||||
expect(next).toBeDisabled()
|
||||
fireEvent.click(next)
|
||||
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
|
||||
|
||||
fireEvent.click(previous)
|
||||
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
||||
})
|
||||
|
||||
it('moves by the ← and → keys, stopping at either end', async () => {
|
||||
serve()
|
||||
await renderPage()
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
|
||||
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
|
||||
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
|
||||
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
|
||||
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
||||
|
||||
// From a checked ingredient too.
|
||||
const beef = screen.getByRole('checkbox', { name: 'Ground beef' })
|
||||
beef.focus()
|
||||
fireEvent.keyDown(beef, { key: 'ArrowRight' })
|
||||
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
||||
})
|
||||
|
||||
it('leaves the keys alone in a text field, with a modifier, or in the text size control', async () => {
|
||||
serve()
|
||||
await renderPage()
|
||||
const field = document.createElement('input')
|
||||
document.body.append(field)
|
||||
field.focus()
|
||||
fireEvent.keyDown(field, { key: 'ArrowRight' })
|
||||
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
||||
field.remove()
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowRight', altKey: true })
|
||||
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
||||
|
||||
const medium = screen.getByRole('radio', { name: 'Medium text' })
|
||||
fireEvent.keyDown(medium, { key: 'ArrowRight' })
|
||||
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('text size', () => {
|
||||
it('is remembered on this device', async () => {
|
||||
serve()
|
||||
const view = await renderPage()
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Large text' }))
|
||||
expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('large')
|
||||
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
|
||||
view.unmount()
|
||||
|
||||
await renderPage()
|
||||
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'false')
|
||||
expect(declaredStyle(screen.getByRole('heading', { level: 1 }).closest('header')!.parentElement!).getPropertyValue('--reading-scale')).toBe(
|
||||
'1.25',
|
||||
)
|
||||
})
|
||||
|
||||
it('says so when the size saved is not one MealQuest saved, until one is chosen', async () => {
|
||||
localStorage.setItem(TEXT_SIZE_KEY, 'huge')
|
||||
serve()
|
||||
await renderPage()
|
||||
expect(screen.getByText(/Could not read the text size chosen on this device/)).toHaveTextContent('"huge"')
|
||||
expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Small text' }))
|
||||
expect(screen.queryByText(/Could not read the text size/)).not.toBeInTheDocument()
|
||||
expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('small')
|
||||
})
|
||||
})
|
||||
|
||||
describe('source credit', () => {
|
||||
it.each([
|
||||
['a site and a url', { site_name: 'Serious Eats', url: 'https://www.seriouseats.com/bowls' }, 'Recipe from Serious Eats'],
|
||||
['only a url', { site_name: null, url: 'https://www.seriouseats.com/bowls' }, 'Recipe from www.seriouseats.com'],
|
||||
])('links the recipe page in a new tab for %s', async (_, source, name) => {
|
||||
serve({ [DINNER]: () => jsonResponse(200, detail({ source })) })
|
||||
await renderPage()
|
||||
const credit = screen.getByRole('link', { name })
|
||||
expect(credit).toHaveAttribute('href', 'https://www.seriouseats.com/bowls')
|
||||
expect(credit).toHaveAttribute('target', '_blank')
|
||||
expect(credit).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['only a site', { site_name: 'Serious Eats', url: null }, 'Recipe from Serious Eats'],
|
||||
['neither site nor url', { site_name: null, url: null }, 'Recipe from the web'],
|
||||
])('is plain text for %s', async (_, source, text) => {
|
||||
serve({ [DINNER]: () => jsonResponse(200, detail({ source })) })
|
||||
await renderPage()
|
||||
expect(screen.getByText(text).closest('a')).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is absent for a recipe typed in', async () => {
|
||||
serve({ [DINNER]: () => jsonResponse(200, detail({ source: null, grounding_status: 'manual' })) })
|
||||
await renderPage()
|
||||
expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows what stands in for a recipe not found, with no step buttons', async () => {
|
||||
serve({
|
||||
[DINNER]: () =>
|
||||
jsonResponse(200, detail({ ingredients: [], steps: [], grounding_status: 'failed', grounding_error: 'no_recipe_found' })),
|
||||
})
|
||||
await renderPage()
|
||||
expect(screen.getByRole('link', { name: 'Fix recipe' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: 'Ingredients' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Next step' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add to this week' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Keep screen on', () => {
|
||||
it('holds a screen wake lock while on and releases it when turned off', async () => {
|
||||
const wakeLock = stubWakeLock()
|
||||
serve()
|
||||
await renderPage()
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
expect(keepOn).toBeEnabled()
|
||||
expect(wakeLock.request).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(keepOn)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
||||
expect(wakeLock.sentinels[0]!.release).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(keepOn)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
||||
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
|
||||
|
||||
// On again: a fresh lock.
|
||||
fireEvent.click(keepOn)
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
||||
expect(wakeLock.sentinels[1]!.released).toBe(false)
|
||||
})
|
||||
|
||||
it('releases the lock when the screen is left', async () => {
|
||||
const wakeLock = stubWakeLock()
|
||||
serve()
|
||||
const view = await renderPage()
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' }))
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
||||
|
||||
view.unmount()
|
||||
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('releases a lock granted only after the screen was left', async () => {
|
||||
const wakeLock = stubWakeLock({ hold: true })
|
||||
serve()
|
||||
const view = await renderPage()
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' }))
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.unmount()
|
||||
await act(async () => wakeLock.grant())
|
||||
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('asks for the lock again when the page is visible again while on, and not while off', async () => {
|
||||
const wakeLock = stubWakeLock()
|
||||
serve()
|
||||
await renderPage()
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
fireEvent.click(keepOn)
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
||||
|
||||
// Hidden, the browser lets the lock go; nothing is asked while hidden.
|
||||
act(() => setVisibility('hidden'))
|
||||
act(() => letGo(wakeLock.sentinels[0]!))
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => setVisibility('visible'))
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
// Visible again with the lock still held: no second lock.
|
||||
act(() => setVisibility('visible'))
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
||||
|
||||
fireEvent.click(keepOn)
|
||||
act(() => setVisibility('hidden'))
|
||||
act(() => setVisibility('visible'))
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('asks for the lock again when the browser lets it go while the page is visible', async () => {
|
||||
const wakeLock = stubWakeLock()
|
||||
serve()
|
||||
await renderPage()
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
fireEvent.click(keepOn)
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
||||
|
||||
act(() => letGo(wakeLock.sentinels[0]!))
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
||||
await act(async () => {})
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
// The fresh lock is watched too.
|
||||
act(() => letGo(wakeLock.sentinels[1]!))
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(3))
|
||||
expect(wakeLock.sentinels[2]!.released).toBe(false)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('asks for the lock again when the page was hidden and shown while a request was pending', async () => {
|
||||
const wakeLock = stubWakeLock({ hold: true })
|
||||
serve()
|
||||
await renderPage()
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
fireEvent.click(keepOn)
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Hidden and shown again before the first lock is granted, which the
|
||||
// browser has let go by the time it is.
|
||||
act(() => setVisibility('hidden'))
|
||||
act(() => setVisibility('visible'))
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
||||
letGo(wakeLock.sentinels[0]!)
|
||||
await act(async () => wakeLock.grant())
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
||||
|
||||
await act(async () => wakeLock.grant())
|
||||
expect(wakeLock.sentinels[1]!.released).toBe(false)
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('reports a lock the browser refuses and turns the switch back off', async () => {
|
||||
const wakeLock = stubWakeLock()
|
||||
wakeLock.request.mockRejectedValueOnce(new DOMException('Blocked by permissions policy', 'NotAllowedError'))
|
||||
serve()
|
||||
await renderPage()
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
fireEvent.click(keepOn)
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
"Couldn't keep the screen on: NotAllowedError: Blocked by permissions policy",
|
||||
)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is disabled, and says why, in a browser that cannot keep the screen on', async () => {
|
||||
serve()
|
||||
await renderPage()
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
expect(keepOn).toBeDisabled()
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
||||
expect(keepOn).toHaveAccessibleDescription(
|
||||
"This browser can't keep the screen on, so it may dim while you cook.",
|
||||
)
|
||||
fireEvent.click(keepOn)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
})
|
||||
})
|
||||
+35
-14
@@ -1,6 +1,7 @@
|
||||
import { useIsMutating, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import type { DinnerDetail } from './detail.ts'
|
||||
|
||||
export const DINNERS_PATH = '/api/v1/dinners'
|
||||
export const REGULARS_PATH = `${DINNERS_PATH}?status=regular`
|
||||
@@ -12,7 +13,7 @@ export const GROUNDING_POLL_MS = 3000
|
||||
// Methods and Difficulties are the values the dinners API allows.
|
||||
export const METHODS = ['stovetop', 'oven', 'crockpot', 'grill', 'other'] as const
|
||||
export const DIFFICULTIES = ['easy', 'medium', 'hard'] as const
|
||||
const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const
|
||||
export const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const
|
||||
|
||||
export type Method = (typeof METHODS)[number]
|
||||
export type Difficulty = (typeof DIFFICULTIES)[number]
|
||||
@@ -146,6 +147,11 @@ export function dinnerPath(id: string): string {
|
||||
return `${DINNERS_PATH}/${encodeURIComponent(id)}`
|
||||
}
|
||||
|
||||
// dinnerDetailKey is the query of dinner id with its recipe (useDinnerDetail).
|
||||
export function dinnerDetailKey(id: string) {
|
||||
return ['dinners', 'detail', id] as const
|
||||
}
|
||||
|
||||
// useRegulars is the household's Regulars, in the server's order. While any
|
||||
// of them is still finding its recipe it is read again every
|
||||
// GROUNDING_POLL_MS, except while any change is in flight (whose settling
|
||||
@@ -275,28 +281,43 @@ export function useEditRegular() {
|
||||
})
|
||||
}
|
||||
|
||||
// useToggleFavorite toggles a Regular's favorite heart (POST
|
||||
// /api/v1/dinners/{id}/favorite). The heart changes at once and changes
|
||||
// back if the server refuses.
|
||||
// Favorite is the dinner whose heart is toggled, as it was before the toggle.
|
||||
export type Favorite = { id: string; is_favorite: boolean }
|
||||
|
||||
// setFavorite shows dinner id's heart as is_favorite wherever it is cached:
|
||||
// in the Regulars and in its detail. A cache not yet read is left unread.
|
||||
function setFavorite(queryClient: QueryClient, id: string, is_favorite: boolean) {
|
||||
queryClient.setQueryData<CachedRegular[]>(REGULARS_LIST_KEY, (list) =>
|
||||
list?.map((r) => (r.id === id ? { ...r, is_favorite } : r)),
|
||||
)
|
||||
queryClient.setQueryData<DinnerDetail>(dinnerDetailKey(id), (dinner) => dinner && { ...dinner, is_favorite })
|
||||
}
|
||||
|
||||
// useToggleFavorite toggles a dinner's favorite heart (POST
|
||||
// /api/v1/dinners/{id}/favorite), a Regular's or any dinner's on its detail
|
||||
// screen. The heart changes at once and changes back if the server refuses.
|
||||
// The server answers with the dinner as the dinners list sends it, which
|
||||
// replaces the Regular; the detail keeps its recipe and takes only the heart.
|
||||
export function useToggleFavorite() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
const set = (id: string, is_favorite: boolean) =>
|
||||
setRegulars(queryClient, (list) => list.map((r) => (r.id === id ? { ...r, is_favorite } : r)))
|
||||
return useMutation({
|
||||
mutationKey: REGULARS_MUTATION_KEY,
|
||||
mutationFn: async (regular: Regular) =>
|
||||
mutationFn: async (dinner: Favorite) =>
|
||||
parseDinner(
|
||||
`POST ${DINNERS_PATH}/{id}/favorite`,
|
||||
await apiFetch<unknown>(`${dinnerPath(regular.id)}/favorite`, { method: 'POST' }),
|
||||
await apiFetch<unknown>(`${dinnerPath(dinner.id)}/favorite`, { method: 'POST' }),
|
||||
),
|
||||
onMutate: async (regular) => {
|
||||
await changeOptimistically(queryClient, (list) =>
|
||||
list.map((r) => (r.id === regular.id ? { ...r, is_favorite: !regular.is_favorite } : r)),
|
||||
)
|
||||
onMutate: async (dinner) => {
|
||||
await queryClient.cancelQueries({ queryKey: dinnerDetailKey(dinner.id) })
|
||||
await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY })
|
||||
setFavorite(queryClient, dinner.id, !dinner.is_favorite)
|
||||
},
|
||||
onSuccess: (dinner) => replace(queryClient, dinner),
|
||||
onError: (_error, regular) => set(regular.id, regular.is_favorite),
|
||||
onSuccess: (dinner) => {
|
||||
queryClient.setQueryData<CachedRegular[]>(REGULARS_LIST_KEY, (list) => list?.map((r) => (r.id === dinner.id ? dinner : r)))
|
||||
queryClient.setQueryData<DinnerDetail>(dinnerDetailKey(dinner.id), (detail) => detail && { ...detail, is_favorite: dinner.is_favorite })
|
||||
},
|
||||
onError: (_error, dinner) => setFavorite(queryClient, dinner.id, dinner.is_favorite),
|
||||
onSettled: () => settle(queryClient),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import {
|
||||
DIFFICULTIES,
|
||||
dinnerDetailKey,
|
||||
dinnerPath,
|
||||
DINNERS_PATH,
|
||||
GROUNDING_POLL_MS,
|
||||
GROUNDING_STATUSES,
|
||||
METHODS,
|
||||
oneOf,
|
||||
photoId,
|
||||
type Difficulty,
|
||||
type GroundingStatus,
|
||||
type Method,
|
||||
} from './api.ts'
|
||||
|
||||
// The statuses a dinner with detail can have; an archived dinner is 404.
|
||||
const STATUSES = ['suggested', 'quest_log', 'tried', 'regular'] as const
|
||||
|
||||
export type DinnerStatus = (typeof STATUSES)[number]
|
||||
|
||||
// Ingredient is one recipe line. raw_text is the line as the recipe gives
|
||||
// it; name, qty, unit and note are the server's reading of it, each null
|
||||
// when it has none — always name, qty and unit for a recipe typed in.
|
||||
export type Ingredient = {
|
||||
name: string | null
|
||||
qty: string | null
|
||||
unit: string | null
|
||||
note: string | null
|
||||
raw_text: string
|
||||
}
|
||||
|
||||
// RecipeSource is where a fetched recipe came from. siteName is the site,
|
||||
// or null when there is none to name: a page that named no site, or a name
|
||||
// the server withheld because it could have come from the link. url is the
|
||||
// recipe's page, or null when the server withheld the stored link because
|
||||
// it could carry a token; the credit then never names its host.
|
||||
export type RecipeSource = { siteName: string | null; url: string | null }
|
||||
|
||||
// DinnerDetail is a dinner with its recipe (GET /api/v1/dinners/{id}).
|
||||
// source is null for a recipe typed in. cached says the service worker
|
||||
// answered from its saved copy because the server could not be reached.
|
||||
export type DinnerDetail = {
|
||||
id: string
|
||||
name: string
|
||||
status: DinnerStatus
|
||||
method: Method | null
|
||||
minutes_total: number | null
|
||||
difficulty: Difficulty | null
|
||||
is_favorite: boolean
|
||||
photo_id: string | undefined
|
||||
ingredients: Ingredient[]
|
||||
steps: string[]
|
||||
source: RecipeSource | null
|
||||
grounding_status: GroundingStatus
|
||||
why_picked: string | null
|
||||
cached: boolean
|
||||
}
|
||||
|
||||
function text(where: string, field: string, value: unknown): string {
|
||||
if (typeof value !== 'string') throw new Error(`${where}: ${field} is not a string`)
|
||||
return value
|
||||
}
|
||||
|
||||
function nullableText(where: string, field: string, value: unknown): string | null {
|
||||
return value === null ? null : text(where, field, value)
|
||||
}
|
||||
|
||||
function parseIngredient(where: string, body: unknown): Ingredient {
|
||||
if (!isRecord(body)) throw new Error(`${where}: ingredient is not an object`)
|
||||
return {
|
||||
name: nullableText(where, 'ingredient name', body.name),
|
||||
qty: nullableText(where, 'ingredient qty', body.qty),
|
||||
unit: nullableText(where, 'ingredient unit', body.unit),
|
||||
note: nullableText(where, 'ingredient note', body.note),
|
||||
raw_text: text(where, 'ingredient raw_text', body.raw_text),
|
||||
}
|
||||
}
|
||||
|
||||
// detailPhotoId is the photo id of photo_urls, which the server sends as
|
||||
// both cached sizes of one photo, or null.
|
||||
function detailPhotoId(where: string, value: unknown): string | undefined {
|
||||
if (value === null) return undefined
|
||||
if (!isRecord(value)) throw new Error(`${where}: photo_urls is not an object`)
|
||||
const id = photoId(where, value['400'])
|
||||
if (id === undefined || value['1200'] !== `/media/photos/${encodeURIComponent(id)}-1200.jpg`) {
|
||||
throw new Error(`${where}: photo_urls is not both sizes of one stored photo`)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// sourceUrl is source.url, null when the server withholds it, or a link safe
|
||||
// to open: https, with no user, password, query or fragment.
|
||||
function sourceUrl(where: string, value: unknown): string | null {
|
||||
if (value === undefined || value === null) return null
|
||||
const href = text(where, 'source url', value)
|
||||
let link: URL
|
||||
try {
|
||||
link = new URL(href)
|
||||
} catch (error) {
|
||||
throw new Error(`${where}: source url is not a URL`, { cause: error })
|
||||
}
|
||||
if (link.protocol !== 'https:' || link.username !== '' || link.password !== '' || link.search !== '' || link.hash !== '') {
|
||||
throw new Error(`${where}: source url is not an https link without credentials, query or fragment`)
|
||||
}
|
||||
return link.href
|
||||
}
|
||||
|
||||
function parseSource(where: string, value: unknown): RecipeSource | null {
|
||||
if (value === null) return null
|
||||
if (!isRecord(value)) throw new Error(`${where}: source is not an object`)
|
||||
return { siteName: nullableText(where, 'source site_name', value.site_name), url: sourceUrl(where, value.url) }
|
||||
}
|
||||
|
||||
// parseDinnerDetail checks a dinner with its recipe as the API sends it;
|
||||
// cached says whether the service worker answered from its saved copy.
|
||||
export function parseDinnerDetail(where: string, body: unknown, cached: boolean): DinnerDetail {
|
||||
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
|
||||
throw new Error(`${where}: response is not a dinner`)
|
||||
}
|
||||
if (typeof body.is_favorite !== 'boolean') throw new Error(`${where}: is_favorite is not a boolean`)
|
||||
if (!Array.isArray(body.ingredients) || !Array.isArray(body.steps)) throw new Error(`${where}: response has no recipe lists`)
|
||||
const minutes = body.minutes_total
|
||||
if (minutes !== null && (typeof minutes !== 'number' || !Number.isInteger(minutes))) {
|
||||
throw new Error(`${where}: minutes_total is not a whole number`)
|
||||
}
|
||||
return {
|
||||
id: body.id,
|
||||
name: body.name,
|
||||
status: oneOf(where, 'status', STATUSES, body.status),
|
||||
method: body.method === null ? null : oneOf(where, 'method', METHODS, body.method),
|
||||
minutes_total: minutes,
|
||||
difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty),
|
||||
is_favorite: body.is_favorite,
|
||||
photo_id: detailPhotoId(where, body.photo_urls),
|
||||
ingredients: body.ingredients.map((ingredient) => parseIngredient(where, ingredient)),
|
||||
steps: body.steps.map((step) => text(where, 'step', step)),
|
||||
source: parseSource(where, body.source),
|
||||
grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status),
|
||||
why_picked: nullableText(where, 'why_picked', body.why_picked),
|
||||
cached,
|
||||
}
|
||||
}
|
||||
|
||||
// useDinnerDetail is dinner id with its recipe (GET /api/v1/dinners/{id}).
|
||||
// While the dinner is still finding its recipe it is read again every
|
||||
// GROUNDING_POLL_MS, but only while the server can be reached: polling pauses
|
||||
// while the browser is offline or the last read was the service worker's
|
||||
// saved copy, which would only answer the same again, and a saved pending
|
||||
// copy is read once more when the connection comes back. Polling stops on
|
||||
// unmount. An unknown or archived dinner fails with the API's 404. It runs
|
||||
// even while the browser reports offline, so the copy the service worker
|
||||
// saved is shown, or the read fails, instead of waiting for the network.
|
||||
export function useDinnerDetail(id: string) {
|
||||
const { apiRead, network } = useApi()
|
||||
const offline = useSyncExternalStore(network.subscribe, network.offline)
|
||||
const query = useQuery({
|
||||
queryKey: dinnerDetailKey(id),
|
||||
queryFn: async () => {
|
||||
const read = await apiRead<unknown>(dinnerPath(id))
|
||||
return parseDinnerDetail(`GET ${DINNERS_PATH}/{id}`, read.body, read.cached)
|
||||
},
|
||||
networkMode: 'always',
|
||||
refetchInterval: (query) => {
|
||||
const dinner = query.state.data
|
||||
return dinner?.grounding_status === 'pending' && !dinner.cached && !offline ? GROUNDING_POLL_MS : false
|
||||
},
|
||||
})
|
||||
const { data, refetch } = query
|
||||
const stalled = data?.grounding_status === 'pending' && data.cached
|
||||
useEffect(() => {
|
||||
if (stalled && !offline) void refetch()
|
||||
}, [stalled, offline, refetch])
|
||||
return query
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// KeepScreenOn is Kitchen reading mode's Keep screen on switch: whether it is
|
||||
// on, setOn, whether this browser can keep the screen on at all, and why the
|
||||
// screen could not be kept on, if it could not.
|
||||
export type KeepScreenOn = {
|
||||
on: boolean
|
||||
setOn: (on: boolean) => void
|
||||
supported: boolean
|
||||
failure: string | null
|
||||
}
|
||||
|
||||
// useKeepScreenOn holds a Screen Wake Lock while the switch is on, and only
|
||||
// then. It starts off on each visit. The browser lets the lock go whenever
|
||||
// the page is hidden, so it is asked for again each time the page is visible
|
||||
// while the switch is on. Turning the switch off or leaving the screen
|
||||
// releases it, including a lock still being granted as the screen is left.
|
||||
//
|
||||
// A browser without the Screen Wake Lock API (or a page not served over
|
||||
// HTTPS) cannot keep the screen on: supported is false and the switch must
|
||||
// not be offered as working. A request the browser refuses turns the switch
|
||||
// back off and is reported as failure; turning it on again asks again.
|
||||
export function useKeepScreenOn(): KeepScreenOn {
|
||||
const [on, setOnState] = useState(false)
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
const supported = 'wakeLock' in navigator
|
||||
|
||||
useEffect(() => {
|
||||
if (!on) return
|
||||
const wakeLock = navigator.wakeLock
|
||||
let sentinel: WakeLockSentinel | null = null
|
||||
let requesting = false
|
||||
let left = false
|
||||
|
||||
// The browser may let a lock go while the switch is on: always when the
|
||||
// page is hidden, and, the specification allows, while it is still
|
||||
// visible. Either way the lock is asked for again at once if the page is
|
||||
// visible, or when it is visible again if not, so the switch never claims
|
||||
// a lock that is not held.
|
||||
function onRelease() {
|
||||
if (!left) acquire()
|
||||
}
|
||||
|
||||
function acquire() {
|
||||
if (document.visibilityState !== 'visible' || requesting) return
|
||||
if (sentinel !== null && !sentinel.released) return
|
||||
requesting = true
|
||||
wakeLock.request('screen').then(
|
||||
(granted) => {
|
||||
requesting = false
|
||||
if (left) {
|
||||
void granted.release()
|
||||
return
|
||||
}
|
||||
sentinel = granted
|
||||
granted.addEventListener('release', onRelease)
|
||||
// While the request was pending the page may have been hidden and
|
||||
// shown again (that visibilitychange was ignored) and the lock let
|
||||
// go before it could be listened for, so check again now.
|
||||
acquire()
|
||||
},
|
||||
(err: unknown) => {
|
||||
requesting = false
|
||||
if (left) return
|
||||
setFailure(String(err))
|
||||
setOnState(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
acquire()
|
||||
document.addEventListener('visibilitychange', acquire)
|
||||
return () => {
|
||||
left = true
|
||||
document.removeEventListener('visibilitychange', acquire)
|
||||
if (sentinel !== null) void sentinel.release()
|
||||
}
|
||||
}, [on])
|
||||
|
||||
function setOn(next: boolean) {
|
||||
if (next) setFailure(null)
|
||||
setOnState(next)
|
||||
}
|
||||
|
||||
return { on, setOn, supported, failure }
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"stored": "https://kitchen.example/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": "https://kitchen.example/recipes/chicken-curry" }
|
||||
},
|
||||
{
|
||||
"stored": "https://Kitchen-2.Example.com.:8443/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": "https://Kitchen-2.Example.com.:8443/recipes/chicken-curry" }
|
||||
},
|
||||
{
|
||||
"stored": "https://[2001:db8::1]/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": "https://[2001:db8::1]/recipes/chicken-curry" }
|
||||
},
|
||||
{
|
||||
"stored": "https://192.0.2.7/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": "https://192.0.2.7/recipes/chicken-curry" }
|
||||
},
|
||||
{
|
||||
"stored": "https://[bad]/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "https://[fe80::1%25en0]/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "https://192.0.2.999/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "https://xn--a.example/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "https://kitchen.example/recipes/chicken-curry?access_token=query-token",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "https://user-token@kitchen.example/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "http://kitchen.example/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "https://kitchen.example/recipes/chicken-curry#token=fragment-token",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
// TEXT_SIZE_KEY is the localStorage key remembering, on this device, the text
|
||||
// size chosen in Kitchen reading mode.
|
||||
export const TEXT_SIZE_KEY = 'mealquest.readingTextSize'
|
||||
|
||||
export const TEXT_SIZES = ['small', 'medium', 'large'] as const
|
||||
|
||||
export type TextSize = (typeof TEXT_SIZES)[number]
|
||||
|
||||
// DEFAULT_TEXT_SIZE is the size of a device that has never chosen one: the
|
||||
// handoff's 18 px ingredients and steps.
|
||||
export const DEFAULT_TEXT_SIZE: TextSize = 'medium'
|
||||
|
||||
// TextSizeState is the text size, and the storage failure, if any, that
|
||||
// stopped us reading or saving it.
|
||||
type TextSizeState = {
|
||||
size: TextSize
|
||||
failure: { action: 'read' | 'write'; message: string } | null
|
||||
}
|
||||
|
||||
function isTextSize(value: string): value is TextSize {
|
||||
return (TEXT_SIZES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
// Storage can be unavailable (blocked by the browser or privacy settings), in
|
||||
// which case using it throws, and can hold a value MealQuest did not save.
|
||||
// Either is reported, never treated as a remembered size.
|
||||
function readTextSize(): TextSizeState {
|
||||
try {
|
||||
const saved = window.localStorage.getItem(TEXT_SIZE_KEY)
|
||||
if (saved === null) return { size: DEFAULT_TEXT_SIZE, failure: null }
|
||||
if (!isTextSize(saved)) throw new Error(`"${saved}" is not a text size MealQuest saved`)
|
||||
return { size: saved, failure: null }
|
||||
} catch (err) {
|
||||
return { size: DEFAULT_TEXT_SIZE, failure: { action: 'read', message: String(err) } }
|
||||
}
|
||||
}
|
||||
|
||||
function writeTextSize(size: TextSize): TextSizeState {
|
||||
try {
|
||||
window.localStorage.setItem(TEXT_SIZE_KEY, size)
|
||||
return { size, failure: null }
|
||||
} catch (err) {
|
||||
return { size, failure: { action: 'write', message: String(err) } }
|
||||
}
|
||||
}
|
||||
|
||||
export const textSizeFailureText = {
|
||||
read: 'Could not read the text size chosen on this device',
|
||||
write: 'Could not remember the text size on this device',
|
||||
}
|
||||
|
||||
// useTextSize is Kitchen reading mode's text size on this device: the size,
|
||||
// the storage failure if any, choose, which saves a size, and retry, which
|
||||
// reads or saves again whatever failed. A size that could not be saved is
|
||||
// still used until the screen is left.
|
||||
export function useTextSize() {
|
||||
const [state, setState] = useState<TextSizeState>(readTextSize)
|
||||
const choose = (size: TextSize) => setState(writeTextSize(size))
|
||||
const retry = () => setState(state.failure?.action === 'write' ? writeTextSize(state.size) : readTextSize())
|
||||
return { ...state, choose, retry }
|
||||
}
|
||||
+10
-19
@@ -23,9 +23,8 @@ import {
|
||||
parseDraftResult,
|
||||
planKey,
|
||||
usePlan,
|
||||
usePushToCalendar,
|
||||
useStartDraft,
|
||||
useSwapDay,
|
||||
useWeekSwap,
|
||||
type DraftResult,
|
||||
type Plan,
|
||||
type PlanDay,
|
||||
@@ -266,8 +265,7 @@ function WeekBoard({
|
||||
const [jobId, setJobId] = useState<string | null>(null)
|
||||
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const swap = useSwapDay(weekStart)
|
||||
const push = usePushToCalendar(weekStart)
|
||||
const { swap, push, refused, swapIn: sendSwap } = useWeekSwap(weekStart)
|
||||
const run = useSupplyRun(weekStart)
|
||||
// Makes the week's Supply run again after a push could not make it.
|
||||
const generate = useGenerateSupplyRun(weekStart)
|
||||
@@ -431,25 +429,18 @@ function WeekBoard({
|
||||
<ErrorBanner onRetry={() => void run.refetch()}>Couldn't load this week's shopping list. {run.error.message}</ErrorBanner>
|
||||
)
|
||||
|
||||
// swapIn sends the swap chosen in the sheet and closes it.
|
||||
// swapIn sends the swap chosen in the sheet, dropped on a day or retried
|
||||
// (useWeekSwap) and closes the sheet. The Supply run made again on the
|
||||
// Plan after a push is left behind with the push.
|
||||
function swapIn(chosen: Swap) {
|
||||
setSwappingDate(null)
|
||||
push.reset()
|
||||
generate.reset()
|
||||
swap.mutate(chosen)
|
||||
sendSwap(chosen)
|
||||
}
|
||||
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
|
||||
// nor dropped, while a swap of the week is pending.
|
||||
function dropIn(chosen: Swap) {
|
||||
swap.dismiss()
|
||||
swapIn(chosen)
|
||||
}
|
||||
// openSwap opens the swap sheet on a day. One swap of the week is sent at
|
||||
// a time, even across leaving the week and coming back: the plan the
|
||||
// server answers it with must not overwrite another still pending. Nor is
|
||||
// one sent while the week is being pushed to Calendar.
|
||||
// openSwap opens the swap sheet on a day, unless a swap is refused now
|
||||
// (useWeekSwap). A drag is not picked up, nor dropped, then either.
|
||||
function openSwap(date: string) {
|
||||
if (swap.pending || pushing) return
|
||||
if (refused) return
|
||||
swap.dismiss()
|
||||
setSwappingDate(date)
|
||||
}
|
||||
@@ -488,7 +479,7 @@ function WeekBoard({
|
||||
body =
|
||||
layout !== 'mobile' ? (
|
||||
<>
|
||||
<DropBoard days={days} pending={swap.pending || pushing} drop={dropIn} />
|
||||
<DropBoard days={days} pending={refused} drop={swapIn} />
|
||||
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
|
||||
{days.map((day) => (
|
||||
<DayColumn
|
||||
|
||||
+35
-1
@@ -331,9 +331,11 @@ function swapKey(weekStart: string) {
|
||||
}
|
||||
|
||||
// SwapDay is the swaps of one week, as the mutation cache holds them:
|
||||
// whether one is pending, and the failure of the latest, if it failed.
|
||||
// whether one is pending, the latest if it succeeded, and the failure of the
|
||||
// latest, if it failed.
|
||||
export type SwapDay = {
|
||||
pending: boolean
|
||||
succeeded: Swap | null
|
||||
failed: { swap: Swap; error: Error } | null
|
||||
mutate: (chosen: Swap) => void
|
||||
dismiss: () => void
|
||||
@@ -420,6 +422,7 @@ export function useSwapDay(weekStart: string): SwapDay {
|
||||
|
||||
return {
|
||||
pending: swaps.some((swap) => swap.status === 'pending'),
|
||||
succeeded: latest?.status === 'success' ? latest.swap : null,
|
||||
failed: latest?.status === 'error' ? { swap: latest.swap, error: latest.error! } : null,
|
||||
mutate,
|
||||
dismiss,
|
||||
@@ -531,6 +534,37 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
|
||||
}
|
||||
}
|
||||
|
||||
// WeekSwap is the one way a day of a week is swapped, wherever the swap is
|
||||
// chosen: the week's swaps and pushes, whether a swap is refused now, and
|
||||
// swapIn to send one.
|
||||
export type WeekSwap = {
|
||||
swap: SwapDay
|
||||
push: PushToCalendar
|
||||
refused: boolean
|
||||
swapIn: (chosen: Swap) => void
|
||||
}
|
||||
|
||||
// useWeekSwap swaps a day of the week starting on weekStart, from the Draft
|
||||
// board or a dinner's Add to this week. One swap of the week is sent at a
|
||||
// time, even across leaving the week and coming back: the plan the server
|
||||
// answers it with must not overwrite another still pending. Nor is one sent
|
||||
// while the week is being pushed to Calendar: refused says so, and swapIn
|
||||
// then throws. A swap leaves the week's settled swaps behind, and its
|
||||
// pushes: what a push said of Calendar and the Supply run was said of the
|
||||
// plan before the swap, not the one swapped.
|
||||
export function useWeekSwap(weekStart: string): WeekSwap {
|
||||
const swap = useSwapDay(weekStart)
|
||||
const push = usePushToCalendar(weekStart)
|
||||
const refused = swap.pending || push.pending
|
||||
function swapIn(chosen: Swap) {
|
||||
if (refused) throw new Error(`The week of ${weekStart} has a swap or push pending, so ${chosen.date} cannot be swapped.`)
|
||||
swap.dismiss()
|
||||
push.reset()
|
||||
swap.mutate(chosen)
|
||||
}
|
||||
return { swap, push, refused, swapIn }
|
||||
}
|
||||
|
||||
// settleUnmadePushes is called as the Supply run of the week starting on
|
||||
// weekStart starts being made, from the Plan or the Supply run screen
|
||||
// (useGenerateSupplyRun). The function it answers with is called once the
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addDays, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
|
||||
import { addDays, dayAndDate, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
|
||||
|
||||
// SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York.
|
||||
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
|
||||
@@ -36,6 +36,11 @@ describe('week helpers', () => {
|
||||
expect(weekdayName('2026-10-11')).toBe('Sunday')
|
||||
})
|
||||
|
||||
it('names the day of a date in full with its month and day', () => {
|
||||
expect(dayAndDate('2026-10-10')).toBe('Saturday, Oct 10')
|
||||
expect(dayAndDate('2026-11-01')).toBe('Sunday, Nov 1')
|
||||
})
|
||||
|
||||
it('reads the day of the month of a date', () => {
|
||||
expect(dayOfMonth('2026-10-05')).toBe(5)
|
||||
expect(dayOfMonth('2026-10-31')).toBe(31)
|
||||
|
||||
@@ -52,6 +52,13 @@ export function weekdayName(date: string): string {
|
||||
return weekday.format(midnightUTC(date))
|
||||
}
|
||||
|
||||
const longDay = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'short', day: 'numeric', timeZone: 'UTC' })
|
||||
|
||||
// dayAndDate is date's day in full with its month and day: "Saturday, Oct 10".
|
||||
export function dayAndDate(date: string): string {
|
||||
return longDay.format(midnightUTC(date))
|
||||
}
|
||||
|
||||
// monday reads start as midnight UTC, or throws when it is not a Monday.
|
||||
function monday(start: string): Date {
|
||||
const t = midnightUTC(start)
|
||||
|
||||
@@ -1,51 +1,16 @@
|
||||
// Runs the real Workbox network-first strategy against an in-memory cache
|
||||
// storage: jsdom has none, and the worker itself needs service worker globals.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RouteHandlerCallbackOptions } from 'workbox-core/types'
|
||||
import { createApiClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { fakeLocation, jsonResponse } from '../test/fetchStub.ts'
|
||||
import { createApiStrategy } from './apiStrategy'
|
||||
import { cacheFallbackHeader, cacheNames, routeFor } from './routes'
|
||||
|
||||
const origin = 'https://mealquest.test'
|
||||
|
||||
// FakeCache keys entries by URL, which is all the API cache needs.
|
||||
class FakeCache {
|
||||
readonly entries = new Map<string, Response>()
|
||||
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||
return this.entries.get(new Request(request).url)?.clone()
|
||||
}
|
||||
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
||||
this.entries.set(new Request(request).url, response)
|
||||
}
|
||||
}
|
||||
|
||||
// FakeExtendableEvent stands in for the worker-only ExtendableEvent,
|
||||
// collecting the promises the strategy waits on.
|
||||
class FakeExtendableEvent extends Event {
|
||||
readonly pending: Promise<unknown>[] = []
|
||||
waitUntil(promise: Promise<unknown>) {
|
||||
this.pending.push(promise)
|
||||
}
|
||||
}
|
||||
import { answer, origin, stubFetchThroughWorker, stubWorkerCaches, type FakeCache } from '../test/serviceWorker.ts'
|
||||
import { cacheFallbackHeader } from './routes'
|
||||
|
||||
let cache: FakeCache
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new FakeCache()
|
||||
// Workbox checks its arguments against these worker-only globals.
|
||||
vi.stubGlobal('FetchEvent', class FetchEvent {})
|
||||
vi.stubGlobal('ExtendableEvent', FakeExtendableEvent)
|
||||
const only = (name: string | undefined) => {
|
||||
if (name !== cacheNames.api) throw new Error(`unexpected cache ${name}`)
|
||||
return cache
|
||||
}
|
||||
vi.stubGlobal('caches', {
|
||||
open: async (name: string) => only(name),
|
||||
match: async (request: RequestInfo | URL, options?: MultiCacheQueryOptions) =>
|
||||
only(options?.cacheName).match(request),
|
||||
})
|
||||
cache = stubWorkerCaches()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -58,17 +23,6 @@ async function handle(path: string): Promise<Response> {
|
||||
return answer(new Request(new URL(path, origin)))
|
||||
}
|
||||
|
||||
async function answer(request: Request): Promise<Response> {
|
||||
expect(routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)).toEqual({
|
||||
strategy: 'network-first',
|
||||
cacheName: cacheNames.api,
|
||||
})
|
||||
const event = new FakeExtendableEvent('fetch')
|
||||
const response = await createApiStrategy().handle({ request, event: event as unknown as RouteHandlerCallbackOptions['event'] })
|
||||
await Promise.all(event.pending)
|
||||
return response
|
||||
}
|
||||
|
||||
function recipe(): Response {
|
||||
return new Response(JSON.stringify({ id: '01J9ZR', name: 'Chicken tikka masala' }), {
|
||||
status: 200,
|
||||
@@ -107,22 +61,13 @@ describe('previously viewed plan and recipe reads', () => {
|
||||
const server: Record<string, unknown> = { '/api/v1/plans/2026-W40': plan, '/api/v1/recipes/01J9ZR': recipe }
|
||||
let reachable: boolean
|
||||
|
||||
// The page's fetch goes through the worker's API strategy; the strategy's
|
||||
// own fetch (always a Request) is the network. The API client fetches a
|
||||
// path string, so the two are told apart by the argument's type. As in a
|
||||
// browser, a worker that cannot answer is a network error to the page.
|
||||
beforeEach(() => {
|
||||
reachable = true
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable)
|
||||
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (!(input instanceof Request)) {
|
||||
return answer(new Request(new URL(String(input), origin), init)).catch((error: unknown) => {
|
||||
throw new TypeError('Failed to fetch', { cause: error })
|
||||
})
|
||||
}
|
||||
stubFetchThroughWorker(async (request) => {
|
||||
if (!reachable) throw new TypeError('Failed to fetch')
|
||||
const body = server[new URL(input.url).pathname]
|
||||
if (body === undefined) throw new Error(`unexpected network fetch: ${input.url}`)
|
||||
const body = server[new URL(request.url).pathname]
|
||||
if (body === undefined) throw new Error(`unexpected network fetch: ${request.url}`)
|
||||
return jsonResponse(200, body)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Runs requests through the service worker's real API strategy against an
|
||||
// in-memory cache storage: jsdom has neither a service worker nor caches.
|
||||
import { expect, vi } from 'vitest'
|
||||
import type { RouteHandlerCallbackOptions } from 'workbox-core/types'
|
||||
import { createApiStrategy } from '../sw/apiStrategy.ts'
|
||||
import { cacheNames, routeFor } from '../sw/routes.ts'
|
||||
|
||||
export const origin = 'https://mealquest.test'
|
||||
|
||||
// FakeCache keys entries by URL, which is all the API cache needs.
|
||||
export class FakeCache {
|
||||
readonly entries = new Map<string, Response>()
|
||||
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||
return this.entries.get(new Request(request).url)?.clone()
|
||||
}
|
||||
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
||||
this.entries.set(new Request(request).url, response)
|
||||
}
|
||||
}
|
||||
|
||||
// FakeExtendableEvent stands in for the worker-only ExtendableEvent,
|
||||
// collecting the promises the strategy waits on.
|
||||
class FakeExtendableEvent extends Event {
|
||||
readonly pending: Promise<unknown>[] = []
|
||||
waitUntil(promise: Promise<unknown>) {
|
||||
this.pending.push(promise)
|
||||
}
|
||||
}
|
||||
|
||||
// stubWorkerCaches stubs the worker globals Workbox checks and a cache
|
||||
// storage holding only the API cache, which it returns. Undo with
|
||||
// vi.unstubAllGlobals.
|
||||
export function stubWorkerCaches(): FakeCache {
|
||||
const cache = new FakeCache()
|
||||
vi.stubGlobal('FetchEvent', class FetchEvent {})
|
||||
vi.stubGlobal('ExtendableEvent', FakeExtendableEvent)
|
||||
const only = (name: string | undefined) => {
|
||||
if (name !== cacheNames.api) throw new Error(`unexpected cache ${name}`)
|
||||
return cache
|
||||
}
|
||||
vi.stubGlobal('caches', {
|
||||
open: async (name: string) => only(name),
|
||||
match: async (request: RequestInfo | URL, options?: MultiCacheQueryOptions) =>
|
||||
only(options?.cacheName).match(request),
|
||||
})
|
||||
return cache
|
||||
}
|
||||
|
||||
// answer sends a GET through the API strategy, as src/sw.ts does for a
|
||||
// network-first route; the strategy's own fetch is the network.
|
||||
export async function answer(request: Request): Promise<Response> {
|
||||
expect(routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)).toEqual({
|
||||
strategy: 'network-first',
|
||||
cacheName: cacheNames.api,
|
||||
})
|
||||
const event = new FakeExtendableEvent('fetch')
|
||||
const response = await createApiStrategy().handle({ request, event: event as unknown as RouteHandlerCallbackOptions['event'] })
|
||||
await Promise.all(event.pending)
|
||||
return response
|
||||
}
|
||||
|
||||
// stubFetchThroughWorker replaces the global fetch with the page's view of
|
||||
// it behind the worker, routed as src/sw.ts routes it: a network-first path
|
||||
// from the API client goes through the API strategy, a network-only one
|
||||
// straight to network; the strategy's own fetch, a Request, is network too.
|
||||
// As in a browser, a worker that cannot answer is a network error to the
|
||||
// page. Undo with vi.unstubAllGlobals.
|
||||
export function stubFetchThroughWorker(network: (request: Request) => Promise<Response>) {
|
||||
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (input instanceof Request) return network(input)
|
||||
const request = new Request(new URL(String(input), origin), init)
|
||||
const route = routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)
|
||||
if (route.strategy === 'network-only') return network(request)
|
||||
if (route.strategy !== 'network-first') throw new Error(`unexpected ${route.strategy} fetch: ${request.url}`)
|
||||
return answer(request).catch((error: unknown) => {
|
||||
throw new TypeError('Failed to fetch', { cause: error })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -83,6 +83,16 @@ describe('Segmented', () => {
|
||||
for (const option of screen.getAllByRole('radio')) expectMinHitArea(option)
|
||||
})
|
||||
|
||||
it('names an option by its ariaLabel when it has one', () => {
|
||||
const sizes = [
|
||||
{ value: 'small', label: 'A', ariaLabel: 'Small text' },
|
||||
{ value: 'large', label: 'A', ariaLabel: 'Large text' },
|
||||
] as const
|
||||
render(<Segmented label="Text size" options={sizes} value="large" onValueChange={() => {}} />)
|
||||
expect(radio('Small text')).toHaveTextContent('A')
|
||||
expect(radio('Large text')).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('fails fast when the value is not one of the options', () => {
|
||||
expect(() =>
|
||||
render(<Segmented label="Archive period" options={options} value={'decade' as Period} onValueChange={() => {}} />),
|
||||
|
||||
@@ -4,6 +4,9 @@ import styles from './Segmented.module.css'
|
||||
export type SegmentedOption<T extends string> = {
|
||||
value: T
|
||||
label: string
|
||||
// Accessible name, when the visible label alone does not say what the
|
||||
// option is (the A / A / A of a text size).
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export type SegmentedProps<T extends string> = {
|
||||
@@ -63,6 +66,7 @@ export function Segmented<T extends string>({ label, options, value, onValueChan
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={checked}
|
||||
aria-label={option.ariaLabel}
|
||||
tabIndex={checked ? 0 : -1}
|
||||
className={checked ? `${styles.option} ${styles.selected}` : styles.option}
|
||||
onClick={() => select(index)}
|
||||
|
||||
Reference in New Issue
Block a user