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..4998f6e --- /dev/null +++ b/web/src/dinners/DinnerDetailPage.module.css @@ -0,0 +1,229 @@ +/* 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. */ +.page { + display: flex; + flex-direction: column; + 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; + 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); +} + +.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; +} + +/* Held at the bottom of the screen. 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..1be3fad --- /dev/null +++ b/web/src/dinners/DinnerDetailPage.test.tsx @@ -0,0 +1,271 @@ +import { fireEvent, render, screen, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +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 { 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, with probes +// for where its links and back button go. +function renderAt(entries: string[]) { + const router = createMemoryRouter( + [ + { 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('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') + }) + }) + + 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() + }) + + 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..86c3219 --- /dev/null +++ b/web/src/dinners/DinnerDetailPage.tsx @@ -0,0 +1,204 @@ +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 } 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)} +
  • + ) +} + +// 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.siteName !== null &&

    Recipe from {dinner.siteName}

    } + + ) +} + +// ActionBar is the bar held at the bottom of the screen: 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..c6f4d85 --- /dev/null +++ b/web/src/dinners/detail.ts @@ -0,0 +1,135 @@ +import { useQuery } from '@tanstack/react-query' +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 +} + +// DinnerDetail is a dinner with its recipe (GET /api/v1/dinners/{id}). +// siteName is the site the recipe was fetched from, or null when there is +// none to name: a recipe typed in, a page that named no site, or a name the +// server withheld because it could have come from the link. The recipe's +// link itself never reaches the client. +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[] + siteName: string | null + grounding_status: GroundingStatus + why_picked: string | null +} + +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 +} + +function siteName(where: string, value: unknown): string | null { + if (value === null) return null + if (!isRecord(value)) throw new Error(`${where}: source is not an object`) + return nullableText(where, 'source site_name', value.site_name) +} + +// parseDinnerDetail checks a dinner with its recipe as the API sends it. +export function parseDinnerDetail(where: string, body: unknown): 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)), + siteName: siteName(where, body.source), + grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status), + why_picked: nullableText(where, 'why_picked', body.why_picked), + } +} + +// 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. 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 { apiFetch } = useApi() + return useQuery({ + queryKey: ['dinners', 'detail', id], + queryFn: async () => parseDinnerDetail(`GET ${DINNERS_PATH}/{id}`, await apiFetch(dinnerPath(id))), + networkMode: 'always', + refetchInterval: (query) => (query.state.data?.grounding_status === 'pending' ? GROUNDING_POLL_MS : false), + }) +} 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,