Compare commits

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:53:52 -04:00
18 changed files with 83 additions and 2131 deletions
@@ -1,7 +0,0 @@
/* A week with no plan: why there's no day, over the link to draft it. */
.noPlan {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 14px;
}
-173
View File
@@ -1,173 +0,0 @@
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>
)
}
@@ -1,406 +0,0 @@
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)
})
})
+17 -324
View File
@@ -1,9 +1,8 @@
/* 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. */
/* Mock 1k. On mobile the photo and the sheet run full-bleed past the
shell's 20 px side padding; from tablet up they sit in one column. The
page is at least as tall as the shell's scroll area (a stretched flex
item, so its height resolves the percentage) and the sheet grows into
any room left, so the action bar rests at the bottom of a short recipe. */
.page {
display: flex;
flex-direction: column;
@@ -12,11 +11,9 @@
color: var(--color-text);
}
/* Kitchen reading mode is no wider than the handoff's tablet frame. */
.tablet,
.desktop {
width: 100%;
max-width: 1180px;
max-width: 720px;
margin: 0 auto;
}
@@ -30,6 +27,12 @@
margin: 0 -20px;
}
.tablet .hero,
.desktop .hero {
border-radius: var(--radius-card) var(--radius-card) 0 0;
overflow: hidden;
}
/* Without a dinner to show, the back button sits alone above the message. */
.backRow {
padding-top: calc(16px + env(safe-area-inset-top));
@@ -204,7 +207,6 @@
bottom: 0;
z-index: 1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 24px;
@@ -212,26 +214,16 @@
background: var(--color-bg);
}
.add {
.bar > :first-child {
flex: 1;
}
.add {
width: 100%;
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;
@@ -246,302 +238,3 @@
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;
}
+4 -3
View File
@@ -194,14 +194,15 @@ describe('DinnerDetailPage', () => {
expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument()
})
it('holds Add to this week and the heart at the bottom', async () => {
it('holds Add to this week and the heart at the bottom, at rest until the next update', async () => {
serve(detail({ is_favorite: true }))
renderAt(['/dinner/d1'])
const add = await screen.findByRole('button', { name: 'Add to this week' })
expect(add).toBeEnabled()
expect(add).toHaveAttribute('aria-disabled', 'true')
expect(add).toHaveAccessibleDescription('Adding to this week and the favorite heart arrive in the next update.')
const heart = screen.getByRole('button', { name: 'Favorite' })
expect(heart).toBeEnabled()
expect(heart).toBeDisabled()
expect(heart).toHaveAttribute('aria-pressed', 'true')
const bar = heart.parentElement!
expect(bar).toContainElement(add)
+26 -381
View File
@@ -1,36 +1,27 @@
import { useQueryClient } from '@tanstack/react-query'
import { Check, ChevronLeft, ChevronRight, Heart } from 'lucide-react'
import { useCallback, useEffect, useId, useState, type ReactNode } from 'react'
import { ChevronLeft, Heart } from 'lucide-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 { duration, Unavailable } from '../plan/PlanPage.tsx'
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 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'
// ADD_HINT is why Add to this week and the heart are unavailable.
// TODO(07423ba7): the add-to-week sheet and the favorite heart.
const ADD_HINT = 'Adding to this week and the favorite heart arrive in the next update.'
const difficultyLabels: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
// BackButton goes back in the app's history, or, opened from a link with
@@ -84,15 +75,9 @@ function SourceCredit({ siteName, url }: RecipeSource & { siteName: string }) {
)
}
// 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 }) {
// Recipe is the dinner's ingredients and steps, or what stands in for them:
// still searching, or the link to fix a recipe that could not be found.
function Recipe({ dinner }: { dinner: DinnerDetail }) {
if (dinner.grounding_status === 'failed') {
return (
<section className={styles.missing} aria-label="No recipe">
@@ -103,12 +88,9 @@ function RecipeMissing({ dinner }: { dinner: DinnerDetail }) {
</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} />
if (dinner.grounding_status === 'pending') {
return <p className={styles.missing}>Still finding the recipe — its ingredients and steps will show here.</p>
}
return (
<>
{dinner.ingredients.length > 0 && (
@@ -143,93 +125,20 @@ function Recipe({ dinner }: { dinner: DinnerDetail }) {
)
}
// 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 }) {
// and the favorite heart, showing whether the dinner is a favorite. Both
// rest unavailable until the next update wires them.
function ActionBar({ dinner }: { dinner: DinnerDetail }) {
return (
<div className={styles.bar}>
<AddToWeek dinner={dinner} />
<FavoriteHeart dinner={dinner} />
{children}
<Unavailable hint={ADD_HINT} className={styles.add}>
Add to this week
</Unavailable>
<IconButton label="Favorite" size={52} aria-pressed={dinner.is_favorite} disabled>
<Icon icon={Heart} size="nav" className={dinner.is_favorite ? styles.filled : undefined} />
</IconButton>
</div>
)
}
@@ -269,270 +178,9 @@ function Detail({ dinner }: { dinner: DinnerDetail }) {
)
}
// 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 && dinner.source.siteName !== 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
// DinnerDetailPage is a dinner with its recipe (mock 1k): its photo, or the
// striped placeholder, under a sheet with Regular or Side quest, difficulty
// and time, why it was picked, the ingredients and the steps. A dinner whose
// recipe could not be found links to Settings → Regular dinners to fix it;
// an unknown or removed dinner says it is not found.
export function DinnerDetailPage() {
@@ -571,10 +219,7 @@ export function DinnerDetailPage() {
</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} />
body = <Detail dinner={dinner.data} />
}
return <div className={`${styles.page} ${styles[layout]}`}>{body}</div>
}
-574
View File
@@ -1,574 +0,0 @@
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')
})
})
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')
})
})
})
+13 -34
View File
@@ -1,7 +1,6 @@
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`
@@ -147,11 +146,6 @@ 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
@@ -281,43 +275,28 @@ export function useEditRegular() {
})
}
// 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.
// 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.
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 (dinner: Favorite) =>
mutationFn: async (regular: Regular) =>
parseDinner(
`POST ${DINNERS_PATH}/{id}/favorite`,
await apiFetch<unknown>(`${dinnerPath(dinner.id)}/favorite`, { method: 'POST' }),
await apiFetch<unknown>(`${dinnerPath(regular.id)}/favorite`, { method: 'POST' }),
),
onMutate: async (dinner) => {
await queryClient.cancelQueries({ queryKey: dinnerDetailKey(dinner.id) })
await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY })
setFavorite(queryClient, dinner.id, !dinner.is_favorite)
onMutate: async (regular) => {
await changeOptimistically(queryClient, (list) =>
list.map((r) => (r.id === regular.id ? { ...r, is_favorite: !regular.is_favorite } : r)),
)
},
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),
onSuccess: (dinner) => replace(queryClient, dinner),
onError: (_error, regular) => set(regular.id, regular.is_favorite),
onSettled: () => settle(queryClient),
})
}
+1 -2
View File
@@ -4,7 +4,6 @@ import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import {
DIFFICULTIES,
dinnerDetailKey,
dinnerPath,
DINNERS_PATH,
GROUNDING_POLL_MS,
@@ -159,7 +158,7 @@ export function useDinnerDetail(id: string) {
const { apiRead, network } = useApi()
const offline = useSyncExternalStore(network.subscribe, network.offline)
const query = useQuery({
queryKey: dinnerDetailKey(id),
queryKey: ['dinners', 'detail', id],
queryFn: async () => {
const read = await apiRead<unknown>(dinnerPath(id))
return parseDinnerDetail(`GET ${DINNERS_PATH}/{id}`, read.body, read.cached)
-86
View File
@@ -1,86 +0,0 @@
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 }
}
-63
View File
@@ -1,63 +0,0 @@
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 }
}
+19 -10
View File
@@ -23,8 +23,9 @@ import {
parseDraftResult,
planKey,
usePlan,
usePushToCalendar,
useStartDraft,
useWeekSwap,
useSwapDay,
type DraftResult,
type Plan,
type PlanDay,
@@ -265,7 +266,8 @@ function WeekBoard({
const [jobId, setJobId] = useState<string | null>(null)
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
const [confirming, setConfirming] = useState(false)
const { swap, push, refused, swapIn: sendSwap } = useWeekSwap(weekStart)
const swap = useSwapDay(weekStart)
const push = usePushToCalendar(weekStart)
const run = useSupplyRun(weekStart)
// Makes the week's Supply run again after a push could not make it.
const generate = useGenerateSupplyRun(weekStart)
@@ -429,18 +431,25 @@ 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, 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.
// swapIn sends the swap chosen in the sheet and closes it.
function swapIn(chosen: Swap) {
setSwappingDate(null)
push.reset()
generate.reset()
sendSwap(chosen)
swap.mutate(chosen)
}
// 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.
// 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.
function openSwap(date: string) {
if (refused) return
if (swap.pending || pushing) return
swap.dismiss()
setSwappingDate(date)
}
@@ -479,7 +488,7 @@ function WeekBoard({
body =
layout !== 'mobile' ? (
<>
<DropBoard days={days} pending={refused} drop={swapIn} />
<DropBoard days={days} pending={swap.pending || pushing} drop={dropIn} />
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
{days.map((day) => (
<DayColumn
+1 -35
View File
@@ -329,11 +329,9 @@ function swapKey(weekStart: string) {
}
// SwapDay is the swaps of one week, as the mutation cache holds them:
// whether one is pending, the latest if it succeeded, and the failure of the
// latest, if it failed.
// whether one is pending, 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,7 +418,6 @@ 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,
@@ -532,37 +529,6 @@ 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 -6
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { addDays, dayAndDate, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
import { addDays, 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,11 +36,6 @@ describe('week helpers', () => {
expect(weekdayName('2026-10-11')).toBe('Sunday')
})
it('names the day of a date in full with its month and day', () => {
expect(dayAndDate('2026-10-10')).toBe('Saturday, Oct 10')
expect(dayAndDate('2026-11-01')).toBe('Sunday, Nov 1')
})
it('reads the day of the month of a date', () => {
expect(dayOfMonth('2026-10-05')).toBe(5)
expect(dayOfMonth('2026-10-31')).toBe(31)
-7
View File
@@ -52,13 +52,6 @@ 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 -6
View File
@@ -201,15 +201,10 @@ async function openPage() {
}
// reload ends the page open and its timers, as a reload does: a toggle it
// was waiting to try again is never tried, by it, again. The clock reads on
// as it did: clearAllTimers also puts it back to the real time the fake
// timers were installed at, which would open the new page on whatever week
// the real date falls in.
// was waiting to try again is never tried, by it, again.
function reload(page: { unmount: () => void }) {
page.unmount()
const now = Date.now()
vi.clearAllTimers()
vi.setSystemTime(now)
}
async function advance(ms: number) {
-10
View File
@@ -83,16 +83,6 @@ describe('Segmented', () => {
for (const option of screen.getAllByRole('radio')) expectMinHitArea(option)
})
it('names an option by its ariaLabel when it has one', () => {
const sizes = [
{ value: 'small', label: 'A', ariaLabel: 'Small text' },
{ value: 'large', label: 'A', ariaLabel: 'Large text' },
] as const
render(<Segmented label="Text size" options={sizes} value="large" onValueChange={() => {}} />)
expect(radio('Small text')).toHaveTextContent('A')
expect(radio('Large text')).toHaveAttribute('aria-checked', 'true')
})
it('fails fast when the value is not one of the options', () => {
expect(() =>
render(<Segmented label="Archive period" options={options} value={'decade' as Period} onValueChange={() => {}} />),
-4
View File
@@ -4,9 +4,6 @@ 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> = {
@@ -66,7 +63,6 @@ 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)}