From 8068d06ff06e528afc2d101283e34272e3bf8a95 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Thu, 8 Oct 2026 20:36:47 -0400 Subject: [PATCH] Mobile recipe detail (1k) (Feature 15) (#145) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- web/src/App.test.tsx | 3 +- web/src/App.tsx | 2 + web/src/dinners/DinnerDetailPage.module.css | 240 +++++++++++ web/src/dinners/DinnerDetailPage.test.tsx | 443 ++++++++++++++++++++ web/src/dinners/DinnerDetailPage.tsx | 225 ++++++++++ web/src/dinners/api.ts | 2 +- web/src/dinners/detail.ts | 178 ++++++++ web/src/ui/IconButton.module.css | 10 + web/src/ui/IconButton.test.tsx | 7 + web/src/ui/IconButton.tsx | 5 +- 10 files changed, 1111 insertions(+), 4 deletions(-) create mode 100644 web/src/dinners/DinnerDetailPage.module.css create mode 100644 web/src/dinners/DinnerDetailPage.test.tsx create mode 100644 web/src/dinners/DinnerDetailPage.tsx create mode 100644 web/src/dinners/detail.ts diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 35fcc2c..516cc1d 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -35,6 +35,7 @@ const SCREENS = { '/plan/shopping': true, '/plan/shopping/archive': true, '/discover': true, + '/dinner/:id': true, '/settings': true, '/settings/ai': true, '/settings/household': true, @@ -299,7 +300,7 @@ describe('App offline bootstrap', () => { it.each([ ['/quick', 'Quick dinners'], - ['/dinner/01J9ZK', 'Dinner'], + ['/cook-from-pantry', 'Cook from what I have'], ])('an offline reload of %s after an online visit opens the saved page', async (path, title) => { await visitOnline() goOffline() diff --git a/web/src/App.tsx b/web/src/App.tsx index 59c2b85..7d16d2f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,6 +3,7 @@ import { QueryClientProvider } from '@tanstack/react-query' import { Route, Routes } from 'react-router' import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts' import { AuthBootstrap } from './AuthBootstrap.tsx' +import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx' import { DiscoverPage } from './discover/DiscoverPage.tsx' import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx' import { OnboardingPage } from './onboarding/OnboardingPage.tsx' @@ -45,6 +46,7 @@ const screens: Partial> = { '/plan/shopping': , '/plan/shopping/archive': , '/discover': , + '/dinner/:id': , '/settings': , '/settings/ai': , '/settings/household': , diff --git a/web/src/dinners/DinnerDetailPage.module.css b/web/src/dinners/DinnerDetailPage.module.css new file mode 100644 index 0000000..dd65df2 --- /dev/null +++ b/web/src/dinners/DinnerDetailPage.module.css @@ -0,0 +1,240 @@ +/* Mock 1k. On mobile the photo and the sheet run full-bleed past the + shell's 20 px side padding; from tablet up they sit in one column. The + page is at least as tall as the shell's scroll area (a stretched flex + item, so its height resolves the percentage) and the sheet grows into + any room left, so the action bar rests at the bottom of a short recipe. */ +.page { + display: flex; + flex-direction: column; + min-height: 100%; + font-family: var(--font-body); + color: var(--color-text); +} + +.tablet, +.desktop { + max-width: 720px; + margin: 0 auto; +} + +.hero { + position: relative; + height: 250px; +} + +.mobile .hero, +.mobile .skeletonHero { + margin: 0 -20px; +} + +.tablet .hero, +.desktop .hero { + border-radius: var(--radius-card) var(--radius-card) 0 0; + overflow: hidden; +} + +/* Without a dinner to show, the back button sits alone above the message. */ +.backRow { + padding-top: calc(16px + env(safe-area-inset-top)); +} + +.backRow .back { + position: static; +} + +.photo { + width: 100%; + height: 100%; +} + +.back { + position: absolute; + top: calc(16px + env(safe-area-inset-top)); + left: 20px; +} + +/* The sheet overlaps the photo by 28 px. */ +.sheet { + position: relative; + flex-grow: 1; + margin-top: -28px; + display: flex; + flex-direction: column; + gap: 10px; + padding: 22px 20px 0; + border-radius: 32px 32px 0 0; + background: var(--color-bg); +} + +.mobile .sheet { + margin-left: -20px; + margin-right: -20px; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 28px; + font-weight: 400; + line-height: 1.05; +} + +.why { + margin: 0; + padding: 12px 14px; + border-radius: 18px; + background: var(--color-accent-2-100); + color: var(--color-accent-2-900); + font-size: 13px; + line-height: 1.45; +} + +.section { + display: flex; + flex-direction: column; + margin-top: 6px; +} + +.sectionTitle { + margin: 0 0 4px; + font-family: var(--font-heading); + font-size: 18px; + font-weight: 400; + line-height: 1.15; +} + +.ingredients, +.steps { + margin: 0; + padding: 0; + list-style: none; + font-size: 14px; + line-height: 1.45; +} + +.ingredient { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 7px 0; + border-bottom: 1px solid var(--color-divider); +} + +.ingredient:last-child { + border-bottom: 0; +} + +.note, +.qty { + color: var(--color-neutral-700); +} + +.qty { + flex: none; + text-align: right; +} + +.step { + display: flex; + gap: 12px; + padding: 8px 0; +} + +.number { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: var(--radius-pill); + background: var(--color-accent-100); + color: var(--color-accent-800); + font-size: 12px; + font-weight: 700; +} + +.source { + margin: 4px 0 0; + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.source a { + color: inherit; +} + +.missing { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + margin: 6px 0 0; + padding: 12px 14px; + border-radius: 18px; + background: var(--color-neutral-100); + color: var(--color-neutral-800); + font-size: 13px; + line-height: 1.45; +} + +.missing p { + margin: 0; +} + +/* A 44 px tall tap target for the link. */ +.fix { + display: inline-flex; + align-items: center; + min-height: 44px; + color: var(--color-accent-700); + font-weight: 700; +} + +/* At the bottom of the page, which fills the scroll area, and held at the + bottom of the screen over a long recipe. Sticky offsets count from inside + the scroll area's padding, which on mobile already keeps clear of the nav + bar. */ +.bar { + position: sticky; + bottom: 0; + z-index: 1; + display: flex; + align-items: center; + gap: 10px; + margin-top: 24px; + padding: 10px 0; + background: var(--color-bg); +} + +.bar > :first-child { + flex: 1; +} + +.add { + width: 100%; + min-height: 52px; + font-size: 16px; +} + +.filled { + color: var(--color-accent); + fill: currentColor; +} + +.skeletonHero { + height: 250px; + border-radius: 0; +} + +.skeletonTitle { + height: 32px; + width: 70%; +} diff --git a/web/src/dinners/DinnerDetailPage.test.tsx b/web/src/dinners/DinnerDetailPage.test.tsx new file mode 100644 index 0000000..5e93d46 --- /dev/null +++ b/web/src/dinners/DinnerDetailPage.test.tsx @@ -0,0 +1,443 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AppShell } from '../shell/AppShell.tsx' +import { cacheFallbackHeader } from '../sw/routes.ts' +import { ApiProviders } from '../test/ApiProviders.tsx' +import { declaredStyle } from '../test/cssRules.ts' +import { errorResponse, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts' +import { mockViewport } from '../test/matchMedia.ts' +import { GROUNDING_POLL_MS } from './api.ts' +import { DinnerDetailPage, REGULAR_DINNERS } from './DinnerDetailPage.tsx' + +const DINNER = '/api/v1/dinners/d1' + +// detail is a dinner as GET /api/v1/dinners/{id} sends it: mock 1k's +// Korean beef bowls, a Side quest grounded in a page that named its site. +function detail(overrides: Record = {}) { + return { + id: 'd1', + name: 'Korean beef bowls', + status: 'quest_log', + origin: 'suggested', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + cuisine: 'korean', + is_favorite: false, + photo_urls: { '400': '/media/photos/p1-400.jpg', '1200': '/media/photos/p1-1200.jpg' }, + ingredients: [ + { name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }, + { name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' }, + { name: 'Soy sauce', qty: '¼', unit: 'cup', note: null, raw_text: '¼ cup soy sauce' }, + { name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' }, + ], + steps: ['Cook the rice.', 'Brown the beef with the sauce.', 'Serve over rice with the onions.'], + source: { site_name: 'Serious Eats' }, + grounding_status: 'grounded', + grounding_error: null, + why_picked: 'close cousin of your Beef tacos and Chicken stir-fry.', + ...overrides, + } +} + +// A recipe the household typed in: only raw_text, no source. +const MANUAL = { + status: 'regular', + origin: 'regular', + photo_urls: null, + ingredients: [ + { name: null, qty: null, unit: null, note: null, raw_text: '2 lb chuck roast' }, + { name: null, qty: null, unit: null, note: null, raw_text: 'A splash of red wine' }, + ], + steps: ['Sear the roast.', 'Slow cook 8 hours.'], + source: null, + grounding_status: 'manual', + why_picked: null, +} + +// A Regular whose recipe could not be found: no recipe at all. +const FAILED = { + status: 'regular', + origin: 'regular', + photo_urls: null, + minutes_total: null, + difficulty: null, + ingredients: [], + steps: [], + source: null, + grounding_status: 'failed', + grounding_error: 'no_recipe_found', + why_picked: null, +} + +// renderAt renders the detail screen at the last of entries, inside the +// app shell's scroll area as App routes it, with probes for where its links +// and back button go. +function renderAt(entries: string[]) { + const router = createMemoryRouter( + [ + { element: , children: [{ path: '/dinner/:id', element: }] }, + { path: '/', element:

Home probe

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

Plan probe

}, + { path: REGULAR_DINNERS, element:

Regular dinners probe

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

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

    + ) +} + +// 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 ( +
    +

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

    + + Fix recipe + +
    + ) + } + if (dinner.grounding_status === 'pending') { + return

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

    + } + return ( + <> + {dinner.ingredients.length > 0 && ( +
    +

    Ingredients

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

    Steps

    +
      + {dinner.steps.map((step, i) => ( +
    1. + + {step} +
    2. + ))} +
    +
    + )} + {dinner.source !== null && dinner.source.siteName !== null && ( + + )} + + ) +} + +// ActionBar is the bar at the bottom of the screen, the page's last child: +// the page fills the scroll area so a short recipe still leaves it at the +// bottom, and it sticks there over a long one. It holds Add to this week +// and the favorite heart, showing whether the dinner is a favorite. Both +// rest unavailable until the next update wires them. +function ActionBar({ dinner }: { dinner: DinnerDetail }) { + return ( +
    + + Add to this week + + + + +
    + ) +} + +// Detail is a dinner as mock 1k shows it: the photo full-bleed under a sheet +// with its tags, title, why it was picked and its recipe, over the action bar. +function Detail({ dinner }: { dinner: DinnerDetail }) { + const tags = [ + dinner.difficulty !== null && difficultyLabels[dinner.difficulty], + dinner.minutes_total !== null && duration(dinner.minutes_total), + ].filter((tag) => tag !== false) + return ( + <> +
    + + +
    +
    +
    + {dinner.status === 'regular' ? 'Regular' : 'Side quest'} + {tags.map((tag) => ( + + {tag} + + ))} +
    +

    {dinner.name}

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

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

    + )} + +
    + + + ) +} + +// DinnerDetailPage is a dinner with its recipe (mock 1k): its photo, or the +// striped placeholder, under a sheet with Regular or Side quest, difficulty +// and time, why it was picked, the ingredients and the steps. A dinner whose +// recipe could not be found links to Settings → Regular dinners to fix it; +// an unknown or removed dinner says it is not found. +export function DinnerDetailPage() { + const { id = '' } = useParams() + const layout = useBreakpoint() + const dinner = useDinnerDetail(id) + + let body + if (dinner.isError) { + body = ( + <> +
    + +
    + {dinner.error instanceof ApiError && dinner.error.status === 404 ? ( + <> + This dinner isn't in our kitchen — it may have been removed. + + Go Home + + + ) : ( + void dinner.refetch()}>Couldn't load this dinner. {dinner.error.message} + )} + + ) + } else if (dinner.isPending) { + body = ( +
    + +
    + + + +
    +
    + ) + } else { + body = + } + return
    {body}
    +} diff --git a/web/src/dinners/api.ts b/web/src/dinners/api.ts index 06e040c..2a3b2b7 100644 --- a/web/src/dinners/api.ts +++ b/web/src/dinners/api.ts @@ -12,7 +12,7 @@ export const GROUNDING_POLL_MS = 3000 // Methods and Difficulties are the values the dinners API allows. export const METHODS = ['stovetop', 'oven', 'crockpot', 'grill', 'other'] as const export const DIFFICULTIES = ['easy', 'medium', 'hard'] as const -const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const +export const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const export type Method = (typeof METHODS)[number] export type Difficulty = (typeof DIFFICULTIES)[number] diff --git a/web/src/dinners/detail.ts b/web/src/dinners/detail.ts new file mode 100644 index 0000000..7e8ff2f --- /dev/null +++ b/web/src/dinners/detail.ts @@ -0,0 +1,178 @@ +import { useQuery } from '@tanstack/react-query' +import { useEffect, useSyncExternalStore } from 'react' +import { useApi } from '../api/index.ts' +import { isRecord } from '../api/errors.ts' +import { + DIFFICULTIES, + dinnerPath, + DINNERS_PATH, + GROUNDING_POLL_MS, + GROUNDING_STATUSES, + METHODS, + oneOf, + photoId, + type Difficulty, + type GroundingStatus, + type Method, +} from './api.ts' + +// The statuses a dinner with detail can have; an archived dinner is 404. +const STATUSES = ['suggested', 'quest_log', 'tried', 'regular'] as const + +export type DinnerStatus = (typeof STATUSES)[number] + +// Ingredient is one recipe line. raw_text is the line as the recipe gives +// it; name, qty, unit and note are the server's reading of it, each null +// when it has none — always name, qty and unit for a recipe typed in. +export type Ingredient = { + name: string | null + qty: string | null + unit: string | null + note: string | null + raw_text: string +} + +// RecipeSource is where a fetched recipe came from. siteName is the site, +// or null when there is none to name: a page that named no site, or a name +// the server withheld because it could have come from the link. url is the +// recipe's page, or null: today's API never sends the stored link, which can +// carry a token, so the credit is text until the server sends a url it has +// checked is free of secrets. +export type RecipeSource = { siteName: string | null; url: string | null } + +// DinnerDetail is a dinner with its recipe (GET /api/v1/dinners/{id}). +// source is null for a recipe typed in. cached says the service worker +// answered from its saved copy because the server could not be reached. +export type DinnerDetail = { + id: string + name: string + status: DinnerStatus + method: Method | null + minutes_total: number | null + difficulty: Difficulty | null + is_favorite: boolean + photo_id: string | undefined + ingredients: Ingredient[] + steps: string[] + source: RecipeSource | null + grounding_status: GroundingStatus + why_picked: string | null + cached: boolean +} + +function text(where: string, field: string, value: unknown): string { + if (typeof value !== 'string') throw new Error(`${where}: ${field} is not a string`) + return value +} + +function nullableText(where: string, field: string, value: unknown): string | null { + return value === null ? null : text(where, field, value) +} + +function parseIngredient(where: string, body: unknown): Ingredient { + if (!isRecord(body)) throw new Error(`${where}: ingredient is not an object`) + return { + name: nullableText(where, 'ingredient name', body.name), + qty: nullableText(where, 'ingredient qty', body.qty), + unit: nullableText(where, 'ingredient unit', body.unit), + note: nullableText(where, 'ingredient note', body.note), + raw_text: text(where, 'ingredient raw_text', body.raw_text), + } +} + +// detailPhotoId is the photo id of photo_urls, which the server sends as +// both cached sizes of one photo, or null. +function detailPhotoId(where: string, value: unknown): string | undefined { + if (value === null) return undefined + if (!isRecord(value)) throw new Error(`${where}: photo_urls is not an object`) + const id = photoId(where, value['400']) + if (id === undefined || value['1200'] !== `/media/photos/${encodeURIComponent(id)}-1200.jpg`) { + throw new Error(`${where}: photo_urls is not both sizes of one stored photo`) + } + return id +} + +// sourceUrl is source.url, which today's API does not send, or a link safe +// to open: https, with no user, password, query or fragment. +function sourceUrl(where: string, value: unknown): string | null { + if (value === undefined || value === null) return null + const href = text(where, 'source url', value) + let link: URL + try { + link = new URL(href) + } catch (error) { + throw new Error(`${where}: source url is not a URL`, { cause: error }) + } + if (link.protocol !== 'https:' || link.username !== '' || link.password !== '' || link.search !== '' || link.hash !== '') { + throw new Error(`${where}: source url is not an https link without credentials, query or fragment`) + } + return link.href +} + +function parseSource(where: string, value: unknown): RecipeSource | null { + if (value === null) return null + if (!isRecord(value)) throw new Error(`${where}: source is not an object`) + return { siteName: nullableText(where, 'source site_name', value.site_name), url: sourceUrl(where, value.url) } +} + +// parseDinnerDetail checks a dinner with its recipe as the API sends it; +// cached says whether the service worker answered from its saved copy. +export function parseDinnerDetail(where: string, body: unknown, cached: boolean): DinnerDetail { + if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') { + throw new Error(`${where}: response is not a dinner`) + } + if (typeof body.is_favorite !== 'boolean') throw new Error(`${where}: is_favorite is not a boolean`) + if (!Array.isArray(body.ingredients) || !Array.isArray(body.steps)) throw new Error(`${where}: response has no recipe lists`) + const minutes = body.minutes_total + if (minutes !== null && (typeof minutes !== 'number' || !Number.isInteger(minutes))) { + throw new Error(`${where}: minutes_total is not a whole number`) + } + return { + id: body.id, + name: body.name, + status: oneOf(where, 'status', STATUSES, body.status), + method: body.method === null ? null : oneOf(where, 'method', METHODS, body.method), + minutes_total: minutes, + difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty), + is_favorite: body.is_favorite, + photo_id: detailPhotoId(where, body.photo_urls), + ingredients: body.ingredients.map((ingredient) => parseIngredient(where, ingredient)), + steps: body.steps.map((step) => text(where, 'step', step)), + source: parseSource(where, body.source), + grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status), + why_picked: nullableText(where, 'why_picked', body.why_picked), + cached, + } +} + +// useDinnerDetail is dinner id with its recipe (GET /api/v1/dinners/{id}). +// While the dinner is still finding its recipe it is read again every +// GROUNDING_POLL_MS, but only while the server can be reached: polling pauses +// while the browser is offline or the last read was the service worker's +// saved copy, which would only answer the same again, and a saved pending +// copy is read once more when the connection comes back. Polling stops on +// unmount. An unknown or archived dinner fails with the API's 404. It runs +// even while the browser reports offline, so the copy the service worker +// saved is shown, or the read fails, instead of waiting for the network. +export function useDinnerDetail(id: string) { + const { apiRead, network } = useApi() + const offline = useSyncExternalStore(network.subscribe, network.offline) + const query = useQuery({ + queryKey: ['dinners', 'detail', id], + queryFn: async () => { + const read = await apiRead(dinnerPath(id)) + return parseDinnerDetail(`GET ${DINNERS_PATH}/{id}`, read.body, read.cached) + }, + networkMode: 'always', + refetchInterval: (query) => { + const dinner = query.state.data + return dinner?.grounding_status === 'pending' && !dinner.cached && !offline ? GROUNDING_POLL_MS : false + }, + }) + const { data, refetch } = query + const stalled = data?.grounding_status === 'pending' && data.cached + useEffect(() => { + if (stalled && !offline) void refetch() + }, [stalled, offline, refetch]) + return query +} diff --git a/web/src/ui/IconButton.module.css b/web/src/ui/IconButton.module.css index 2995110..910f3ab 100644 --- a/web/src/ui/IconButton.module.css +++ b/web/src/ui/IconButton.module.css @@ -24,6 +24,11 @@ margin: -2px; } +.size52 { + width: 52px; + height: 52px; +} + .size62 { width: 62px; height: 62px; @@ -56,6 +61,11 @@ height: 40px; } +.size52 .face { + width: 52px; + height: 52px; +} + .size62 .face { width: 62px; height: 62px; diff --git a/web/src/ui/IconButton.test.tsx b/web/src/ui/IconButton.test.tsx index e909cdd..a17f343 100644 --- a/web/src/ui/IconButton.test.tsx +++ b/web/src/ui/IconButton.test.tsx @@ -26,6 +26,13 @@ describe('IconButton', () => { expect(hitArea(face)).toEqual({ width: 40, height: 40 }) }) + it('is 52 px at the medium size', () => { + render() + const button = screen.getByRole('button', { name: 'Favorite' }) + expectMinHitArea(button) + expect(hitArea(button)).toEqual({ width: 52, height: 52 }) + }) + it('is 62 px at the large size', () => { render() const button = screen.getByRole('button', { name: 'Try it' }) diff --git a/web/src/ui/IconButton.tsx b/web/src/ui/IconButton.tsx index 6da1b22..a4132bf 100644 --- a/web/src/ui/IconButton.tsx +++ b/web/src/ui/IconButton.tsx @@ -1,7 +1,7 @@ import type { ComponentPropsWithRef } from 'react' import styles from './IconButton.module.css' -export type IconButtonSize = 40 | 62 +export type IconButtonSize = 40 | 52 | 62 export type IconButtonTone = 'surface' | 'neutral' | 'accent' | 'teal' export type IconButtonProps = Omit, 'aria-label'> & { @@ -15,7 +15,8 @@ export type IconButtonProps = Omit, 'aria-label' } // Round icon button, named by its label and showing it only with showLabel. -// The 40 px size keeps a 44 px hit box (see the CSS); the 62 px size is the +// The 40 px size keeps a 44 px hit box (see the CSS); the 52 px size sits +// beside a 52 px pill button, as the recipe's heart; the 62 px size is the // round action button with --shadow-md. export function IconButton({ label,