Merge remote-tracking branch 'origin/dev' into HEAD
# Conflicts: # cmd/server/main.go # internal/server/aisettings_test.go # internal/server/dinners_test.go # internal/server/google_test.go # internal/server/login_test.go # internal/server/media_test.go # internal/server/origin_test.go # internal/server/plans_test.go # internal/server/recipesearch_test.go # internal/server/server.go # internal/server/server_test.go # internal/server/settings_test.go # internal/server/suggestions_test.go
This commit is contained in:
+22
-19
@@ -35,6 +35,9 @@ const SCREENS = {
|
||||
'/plan/shopping': true,
|
||||
'/plan/shopping/archive': true,
|
||||
'/discover': true,
|
||||
'/quick': true,
|
||||
'/cook-from-pantry': true,
|
||||
'/dinner/:id': true,
|
||||
'/settings': true,
|
||||
'/settings/ai': true,
|
||||
'/settings/household': true,
|
||||
@@ -206,19 +209,19 @@ describe('App first-run redirect', () => {
|
||||
it('opens the routes once onboarding is finished', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
renderAt('/quick')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
renderAt('/settings/notifications')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a household settings failure with a retry instead of rendering the routes', async () => {
|
||||
mockViewport(390)
|
||||
stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
renderAt('/quick')
|
||||
renderAt('/settings/notifications')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
screen.getByRole('button', { name: 'Retry' }).click()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -226,12 +229,12 @@ describe('App bootstrap', () => {
|
||||
it('shows the skeleton until /me loads, then the routes', async () => {
|
||||
mockViewport(390)
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
renderAt('/quick')
|
||||
renderAt('/settings/notifications')
|
||||
|
||||
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
||||
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
|
||||
})
|
||||
@@ -299,8 +302,8 @@ describe('App offline bootstrap', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/quick', 'Quick dinners'],
|
||||
['/dinner/01J9ZK', 'Dinner'],
|
||||
['/', 'Home'],
|
||||
['/settings/notifications', 'Notifications'],
|
||||
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
||||
await visitOnline()
|
||||
goOffline()
|
||||
@@ -323,7 +326,7 @@ describe('App offline bootstrap', () => {
|
||||
() => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))),
|
||||
).mockRejectedValue(new TypeError('Failed to fetch')),
|
||||
)
|
||||
renderAt('/quick')
|
||||
renderAt('/settings/notifications')
|
||||
await vi.waitFor(() => expect(failMe).toBeDefined())
|
||||
onLine = false
|
||||
act(() => {
|
||||
@@ -331,7 +334,7 @@ describe('App offline bootstrap', () => {
|
||||
})
|
||||
act(() => failMe())
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
@@ -340,15 +343,15 @@ describe('App offline bootstrap', () => {
|
||||
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
|
||||
await visitOnline()
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
renderAt('/quick')
|
||||
renderAt('/settings/notifications')
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
||||
})
|
||||
|
||||
it('reports the failure offline when this device never verified a session', async () => {
|
||||
goOffline()
|
||||
renderAt('/quick')
|
||||
renderAt('/settings/notifications')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
@@ -357,12 +360,12 @@ describe('App offline bootstrap', () => {
|
||||
it('reports the failure offline after the session was refused', async () => {
|
||||
await visitOnline()
|
||||
stubFetch(errorResponse(401, 'unauthenticated'))
|
||||
const { location, unmount } = renderAt('/quick')
|
||||
const { location, unmount } = renderAt('/settings/notifications')
|
||||
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
|
||||
unmount()
|
||||
|
||||
goOffline()
|
||||
renderAt('/quick')
|
||||
renderAt('/settings/notifications')
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -370,13 +373,13 @@ describe('App offline bootstrap', () => {
|
||||
it('loads /me again on reconnect and refuses writes until it has', async () => {
|
||||
await visitOnline()
|
||||
goOffline()
|
||||
const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const api = createApiClient(fakeLocation('/settings/notifications'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
render(
|
||||
<InDataRouter path="/quick">
|
||||
<InDataRouter path="/settings/notifications">
|
||||
<App api={api} serviceWorker={undefined} />
|
||||
</InDataRouter>,
|
||||
)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
|
||||
|
||||
const fetchMock = stubFetchByPath(SIGNED_IN)
|
||||
@@ -391,7 +394,7 @@ describe('App offline bootstrap', () => {
|
||||
]),
|
||||
)
|
||||
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/settings/calendar renders the Google Calendar screen inside the shell', async () => {
|
||||
|
||||
@@ -3,7 +3,10 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Route, Routes } from 'react-router'
|
||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx'
|
||||
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
||||
import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx'
|
||||
import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx'
|
||||
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
||||
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
|
||||
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
||||
@@ -46,6 +49,9 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/plan/shopping': <SupplyRunPage />,
|
||||
'/plan/shopping/archive': <SupplyArchivePage />,
|
||||
'/discover': <DiscoverPage />,
|
||||
'/quick': <QuickDinnersPage />,
|
||||
'/cook-from-pantry': <CookFromPantryPage />,
|
||||
'/dinner/:id': <DinnerDetailPage />,
|
||||
'/settings': <SettingsPage />,
|
||||
'/settings/notifications': <NotificationsPage />,
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/* A week with no plan: why there's no day, over the link to draft it. */
|
||||
.noPlan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { usePlan, useWeekSwap, type PlanDinner, type Swap } from '../plan/api.ts'
|
||||
import { cooking } from '../plan/PlanPage.tsx'
|
||||
import swapStyles from '../plan/SwapSheet.module.css'
|
||||
import { addDays, dayOfMonth, localNow, weekdayName, weekLabel, weekStart as weekOf } from '../plan/week.ts'
|
||||
import { useHouseholdSettings } from '../settings/api.ts'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { ListRow } from '../ui/ListRow.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import { Segmented } from '../ui/Segmented.tsx'
|
||||
import { Sheet } from '../ui/Sheet.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import type { DinnerDetail } from './detail.ts'
|
||||
import styles from './AddToWeekSheet.module.css'
|
||||
|
||||
// PLAN is the Draft board, where a week with no plan is drafted.
|
||||
export const PLAN = '/plan'
|
||||
|
||||
// Which is the week a dinner is added to: the household's current week or
|
||||
// the one after.
|
||||
export type Which = 'this' | 'next'
|
||||
|
||||
export const weekNames: Record<Which, string> = { this: 'this week', next: 'next week' }
|
||||
|
||||
// Added is a swap sent from the sheet: the week it went to and the swap.
|
||||
export type Added = { week: string; which: Which; swap: Swap }
|
||||
|
||||
// planDinner is the dinner as it is planned on a day.
|
||||
function planDinner(dinner: DinnerDetail): PlanDinner {
|
||||
return { id: dinner.id, name: dinner.name, photo_id: dinner.photo_id, minutes: dinner.minutes_total, method: dinner.method }
|
||||
}
|
||||
|
||||
type WeekDaysProps = {
|
||||
dinner: DinnerDetail
|
||||
week: string
|
||||
which: Which
|
||||
onAdd: (added: Added) => void
|
||||
}
|
||||
|
||||
// WeekDays is the seven days of the week starting on week, each with its
|
||||
// dinner; choosing one swaps the dinner in as the Draft board swaps a day
|
||||
// (useWeekSwap). A week with no plan offers to draft it on the Draft board:
|
||||
// nothing here makes a plan up. A dinner already on the week can't be added
|
||||
// again, nor can one while a swap of the week is still being saved or the
|
||||
// week is being pushed to Calendar.
|
||||
function WeekDays({ dinner, week, which, onAdd }: WeekDaysProps) {
|
||||
const plan = usePlan(week)
|
||||
const { swap, push, refused, swapIn } = useWeekSwap(week)
|
||||
|
||||
if (plan.isError) {
|
||||
return <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load {weekNames[which]}'s plan. {plan.error.message}</ErrorBanner>
|
||||
}
|
||||
if (plan.isPending) {
|
||||
return (
|
||||
<div aria-busy="true" aria-label="Plan loading" className={swapStyles.rows}>
|
||||
{Array.from({ length: 7 }, (_, i) => (
|
||||
<Skeleton key={i} className={swapStyles.skeletonRow} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (plan.data === null) {
|
||||
return (
|
||||
<div className={styles.noPlan}>
|
||||
<p className={swapStyles.empty}>There's no plan for {weekNames[which]} yet, so there's no day to add it to.</p>
|
||||
<Link to={PLAN} className={[buttonStyles.button, buttonStyles.primary].join(' ')}>
|
||||
Draft {weekNames[which]} first
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const days = plan.data.days
|
||||
const planned = days.find((day) => day.dinner.id === dinner.id)
|
||||
const choosable = planned === undefined && !refused
|
||||
function add(date: string) {
|
||||
const chosen: Swap = { date, dinner: planDinner(dinner), fromQuestLog: dinner.status === 'quest_log' }
|
||||
swapIn(chosen)
|
||||
onAdd({ week, which, swap: chosen })
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{planned !== undefined ? (
|
||||
<p className={swapStyles.empty}>
|
||||
{dinner.name} is already on {weekdayName(planned.date)} {weekNames[which]}.
|
||||
</p>
|
||||
) : swap.pending ? (
|
||||
<p className={swapStyles.empty}>A change to {weekNames[which]} is still being saved.</p>
|
||||
) : (
|
||||
push.pending && <p className={swapStyles.empty}>The plan for {weekNames[which]} is being pushed to Calendar.</p>
|
||||
)}
|
||||
<ul aria-label={`Days of ${weekNames[which]}`} className={swapStyles.rows}>
|
||||
{days.map((day) => (
|
||||
<li key={day.date}>
|
||||
<ListRow
|
||||
className={choosable ? undefined : swapStyles.already}
|
||||
leading={
|
||||
// The row's dinner names what the photo shows.
|
||||
<span aria-hidden="true">
|
||||
<Photo id={day.dinner.photo_id} name={day.dinner.name} radius="var(--radius-md)" sizes="48px" className={swapStyles.photo} />
|
||||
</span>
|
||||
}
|
||||
title={`${weekdayName(day.date)} ${dayOfMonth(day.date)}`}
|
||||
meta={[day.dinner.name, cooking(day.dinner)].filter(Boolean).join(' · ')}
|
||||
onClick={choosable ? () => add(day.date) : undefined}
|
||||
trailing={day === planned && <Tag>This dinner</Tag>}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export type AddToWeekSheetProps = {
|
||||
dinner: DinnerDetail
|
||||
onAdd: (added: Added) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// AddToWeekSheet is the sheet Add to this week opens: the seven days of the
|
||||
// household's current week, Monday to Sunday in its timezone, each with its
|
||||
// dinner, and a toggle to next week. Choosing a day replaces its dinner.
|
||||
export function AddToWeekSheet({ dinner, onAdd, onClose }: AddToWeekSheetProps) {
|
||||
const titleId = useId()
|
||||
const settings = useHouseholdSettings()
|
||||
const [which, setWhich] = useState<Which>('this')
|
||||
|
||||
let body
|
||||
if (settings.isError) {
|
||||
body = (
|
||||
<ErrorBanner onRetry={() => void settings.refetch()}>
|
||||
Couldn't load the household settings. {settings.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
} else if (settings.isPending) {
|
||||
body = <Skeleton className={swapStyles.skeletonRow} />
|
||||
} else {
|
||||
const thisWeek = weekOf(localNow(settings.data.timezone, new Date()).date)
|
||||
const week = which === 'this' ? thisWeek : addDays(thisWeek, 7)
|
||||
body = (
|
||||
<>
|
||||
<p className={swapStyles.replaces}>
|
||||
{weekLabel(week).dates} · replaces the day's dinner
|
||||
</p>
|
||||
<Segmented
|
||||
label="Week"
|
||||
options={[
|
||||
{ value: 'this', label: 'This week' },
|
||||
{ value: 'next', label: 'Next week' },
|
||||
]}
|
||||
value={which}
|
||||
onValueChange={setWhich}
|
||||
/>
|
||||
<WeekDays key={week} dinner={dinner} week={week} which={which} onAdd={onAdd} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open onClose={onClose} aria-labelledby={titleId}>
|
||||
<div className={swapStyles.sheet}>
|
||||
<h2 id={titleId} className={swapStyles.title}>
|
||||
Add {dinner.name}
|
||||
</h2>
|
||||
{body}
|
||||
</div>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { PlanPage } from '../plan/PlanPage.tsx'
|
||||
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
|
||||
|
||||
const DINNER = '/api/v1/dinners/d1'
|
||||
const FAVORITE = `${DINNER}/favorite`
|
||||
const THIS_WEEK = '/api/v1/plans/2026-10-05'
|
||||
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
|
||||
const THURSDAY = `${THIS_WEEK}/days/2026-10-08`
|
||||
|
||||
// detail is the Korean beef bowls, a Quest-log Side quest, as GET
|
||||
// /api/v1/dinners/{id} sends it.
|
||||
function detail(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'd1',
|
||||
name: 'Korean beef bowls',
|
||||
status: 'quest_log',
|
||||
origin: 'suggested',
|
||||
method: 'stovetop',
|
||||
minutes_total: 30,
|
||||
difficulty: 'easy',
|
||||
cuisine: 'korean',
|
||||
is_favorite: false,
|
||||
photo_urls: null,
|
||||
ingredients: [{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }],
|
||||
steps: ['Brown the beef with the sauce.'],
|
||||
source: null,
|
||||
grounding_status: 'grounded',
|
||||
grounding_error: null,
|
||||
why_picked: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// listed is the bowls as the dinners list, and so the favorite toggle,
|
||||
// answers: no recipe.
|
||||
function listed(is_favorite: boolean) {
|
||||
return {
|
||||
id: 'd1',
|
||||
name: 'Korean beef bowls',
|
||||
method: 'stovetop',
|
||||
minutes_total: 30,
|
||||
difficulty: 'easy',
|
||||
is_favorite,
|
||||
photo_url: null,
|
||||
grounding_status: 'grounded',
|
||||
}
|
||||
}
|
||||
|
||||
const THIS_NAMES = ['Beef tacos', 'Spaghetti bolognese', 'Lemon orzo', 'Chicken stir-fry', 'Pizza night', 'Fish tacos', 'Sheet-pan salmon']
|
||||
const NEXT_NAMES = ['Chili', 'Pad thai', 'Burgers', 'Ramen', 'Pizza night', 'Pot roast', 'Fried rice']
|
||||
|
||||
// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it, with
|
||||
// dinner ids p0 to p6 unless ids says otherwise.
|
||||
function planBody(weekStart: string, names: string[], ids = names.map((_, i) => `p${i}`)) {
|
||||
const start = new Date(`${weekStart}T00:00:00Z`).getTime()
|
||||
return {
|
||||
id: `plan-${weekStart}`,
|
||||
week_start: weekStart,
|
||||
status: 'draft',
|
||||
ai_note: null,
|
||||
calendar_synced_at: null as string | null,
|
||||
days: names.map((name, i) => ({
|
||||
date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
|
||||
dinner: { id: ids[i], name, photo: null, minutes: 30, method: 'stovetop' },
|
||||
is_new: false,
|
||||
start_by: null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// THURSDAY_SWAPPED is this week's plan as the swap of Thursday answers it.
|
||||
function thursdaySwapped() {
|
||||
const ids = THIS_NAMES.map((_, i) => (i === 3 ? 'd1' : `p${i}`))
|
||||
const names = THIS_NAMES.map((name, i) => (i === 3 ? 'Korean beef bowls' : name))
|
||||
const plan = planBody('2026-10-05', names, ids)
|
||||
plan.days[3]!.is_new = true
|
||||
return plan
|
||||
}
|
||||
|
||||
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
|
||||
|
||||
// stubServer answers "METHOD path" requests from responders, with /me, the
|
||||
// household settings and the dinner; any other request fails the test.
|
||||
function stubServer(responders: Record<string, Responder> = {}) {
|
||||
const all: Record<string, Responder> = {
|
||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||
[`GET ${DINNER}`]: () => jsonResponse(200, detail()),
|
||||
...responders,
|
||||
}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(respond(init))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
// sent lists the requests sent with method, as "path body".
|
||||
function sent(fetchMock: Mock<typeof fetch>, method: string) {
|
||||
return fetchMock.mock.calls
|
||||
.filter(([, init]) => init?.method === method)
|
||||
.map(([input, init]) => `${String(input)} ${String(init?.body)}`)
|
||||
}
|
||||
|
||||
function reads(fetchMock: Mock<typeof fetch>, path: string) {
|
||||
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === path).length
|
||||
}
|
||||
|
||||
// deferred is a response the test sends when it chooses.
|
||||
function deferred() {
|
||||
let send!: (response: Response) => void
|
||||
const response = new Promise<Response>((resolve) => {
|
||||
send = resolve
|
||||
})
|
||||
return { response, send }
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||
// the bowls' detail screen, with plan for the Plan: a probe unless told
|
||||
// otherwise. It answers with the router.
|
||||
async function renderPage(plan = <h1>Plan probe</h1>) {
|
||||
const api = createApiClient(fakeLocation('/dinner/d1'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{ path: '/dinner/:id', element: <DinnerDetailPage /> },
|
||||
{ path: '/plan', element: plan },
|
||||
],
|
||||
{ initialEntries: ['/dinner/d1'] },
|
||||
)
|
||||
render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||
return router
|
||||
}
|
||||
|
||||
// openSheet opens Add to this week and waits for the week's days.
|
||||
async function openSheet(listName = 'Days of this week') {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add to this week' }))
|
||||
const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' })
|
||||
const days = await within(sheet).findByRole('list', { name: listName })
|
||||
return { sheet, days }
|
||||
}
|
||||
|
||||
// dayRows is each day's row text, as "Monday 5 · Beef tacos · Stovetop · 30 min",
|
||||
// with plain spaces.
|
||||
function dayRows(days: HTMLElement) {
|
||||
return within(days)
|
||||
.getAllByRole('listitem')
|
||||
.map((row) => [...row.querySelectorAll('span')].filter((span) => span.children.length === 0 && !span.closest('[aria-hidden="true"]')).map((span) => span.textContent).join(' · ').replace(/\s/g, ' '))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockViewport(390)
|
||||
// Wednesday Oct 7, noon in New York.
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Add to this week', () => {
|
||||
it('lists the seven days of this week with their dinners, and next week by the toggle', async () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
|
||||
[`GET ${NEXT_WEEK}`]: () => jsonResponse(200, planBody('2026-10-12', NEXT_NAMES)),
|
||||
})
|
||||
await renderPage()
|
||||
// Nothing about the week is read until the sheet opens.
|
||||
expect(reads(fetchMock, THIS_WEEK)).toBe(0)
|
||||
|
||||
const { sheet, days } = await openSheet()
|
||||
expect(within(sheet).getByText("Oct 5 – 11 · replaces the day's dinner")).toBeInTheDocument()
|
||||
expect(dayRows(days)).toEqual([
|
||||
'Monday 5 · Beef tacos · Stovetop · 30 min',
|
||||
'Tuesday 6 · Spaghetti bolognese · Stovetop · 30 min',
|
||||
'Wednesday 7 · Lemon orzo · Stovetop · 30 min',
|
||||
'Thursday 8 · Chicken stir-fry · Stovetop · 30 min',
|
||||
'Friday 9 · Pizza night · Stovetop · 30 min',
|
||||
'Saturday 10 · Fish tacos · Stovetop · 30 min',
|
||||
'Sunday 11 · Sheet-pan salmon · Stovetop · 30 min',
|
||||
])
|
||||
expect(within(days).getAllByRole('button')).toHaveLength(7)
|
||||
|
||||
fireEvent.click(within(sheet).getByRole('radio', { name: 'Next week' }))
|
||||
const next = await within(sheet).findByRole('list', { name: 'Days of next week' })
|
||||
expect(within(sheet).getByText("Oct 12 – 18 · replaces the day's dinner")).toBeInTheDocument()
|
||||
expect(dayRows(next)[0]).toBe('Monday 12 · Chili · Stovetop · 30 min')
|
||||
expect(dayRows(next)[6]).toBe('Sunday 18 · Fried rice · Stovetop · 30 min')
|
||||
|
||||
fireEvent.click(within(sheet).getByRole('radio', { name: 'This week' }))
|
||||
expect(await within(sheet).findByRole('list', { name: 'Days of this week' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("takes this week in the household's timezone, not the device's UTC date", async () => {
|
||||
// Sunday Oct 11, 10 pm in New York, already Monday Oct 12 in UTC.
|
||||
vi.setSystemTime(new Date('2026-10-12T02:00:00Z'))
|
||||
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)) })
|
||||
await renderPage()
|
||||
|
||||
const { days } = await openSheet()
|
||||
expect(dayRows(days)[6]).toBe('Sunday 11 · Sheet-pan salmon · Stovetop · 30 min')
|
||||
expect(reads(fetchMock, THIS_WEEK)).toBe(1)
|
||||
expect(reads(fetchMock, NEXT_WEEK)).toBe(0)
|
||||
})
|
||||
|
||||
it('swaps the day chosen through the plan API, confirms it and reads the plan again', async () => {
|
||||
const swap = deferred()
|
||||
let plan = planBody('2026-10-05', THIS_NAMES)
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
|
||||
[`PUT ${THURSDAY}`]: () => swap.response,
|
||||
})
|
||||
await renderPage()
|
||||
const { days } = await openSheet()
|
||||
|
||||
fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(sent(fetchMock, 'PUT')).toHaveLength(1))
|
||||
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`])
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Adding to Thursday…')
|
||||
|
||||
plan = thursdaySwapped()
|
||||
await act(async () => swap.send(jsonResponse(200, plan)))
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
|
||||
// The plan query is invalidated: the week is read again from the server.
|
||||
await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(2))
|
||||
|
||||
// The sheet opened again shows the plan as swapped.
|
||||
const again = await openSheet()
|
||||
expect(dayRows(again.days)[3]).toBe('Thursday 8 · Korean beef bowls · Stovetop · 30 min · This dinner')
|
||||
expect(within(again.sheet).getByText('Korean beef bowls is already on Thursday this week.')).toBeInTheDocument()
|
||||
expect(within(again.days).queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('offers to draft a week with no plan on the Plan, and swaps nothing', async () => {
|
||||
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found') })
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add to this week' }))
|
||||
const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' })
|
||||
const draft = await within(sheet).findByRole('link', { name: 'Draft this week first' })
|
||||
expect(draft).toHaveAttribute('href', '/plan')
|
||||
expect(within(sheet).getByText("There's no plan for this week yet, so there's no day to add it to.")).toBeInTheDocument()
|
||||
expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(draft)
|
||||
expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument()
|
||||
expect(sent(fetchMock, 'PUT')).toEqual([])
|
||||
})
|
||||
|
||||
it('shows a refused swap with the error, puts the day back and retries it', async () => {
|
||||
let refuse = true
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
|
||||
[`PUT ${THURSDAY}`]: () => (refuse ? errorResponse(500, 'internal') : jsonResponse(200, thursdaySwapped())),
|
||||
})
|
||||
await renderPage()
|
||||
const { days } = await openSheet()
|
||||
fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't add it to Thursday this week. internal message")
|
||||
const reopened = await openSheet()
|
||||
expect(dayRows(reopened.days)[3]).toBe('Thursday 8 · Chicken stir-fry · Stovetop · 30 min')
|
||||
fireEvent.keyDown(reopened.sheet, { key: 'Escape' })
|
||||
|
||||
refuse = false
|
||||
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`, `${THURSDAY} {"dinner_id":"d1"}`])
|
||||
})
|
||||
|
||||
it("leaves a push's Supply run outcome behind on the Plan, as a swap on the Draft board does", async () => {
|
||||
let plan = planBody('2026-10-05', THIS_NAMES)
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
|
||||
[`POST ${THIS_WEEK}/push-calendar`]: () => {
|
||||
plan = { ...plan, status: 'synced', calendar_synced_at: '2026-10-07T16:00:00Z' }
|
||||
return jsonResponse(200, { ...plan, supply_run: { status: 'generated', error: null } })
|
||||
},
|
||||
[`PUT ${THURSDAY}`]: () => {
|
||||
plan = { ...thursdaySwapped(), status: 'changed', calendar_synced_at: '2026-10-07T16:00:00Z' }
|
||||
return jsonResponse(200, plan)
|
||||
},
|
||||
'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'),
|
||||
})
|
||||
const router = await renderPage(<PlanPage />)
|
||||
|
||||
// The week is pushed on the Plan, which says its Supply run was made.
|
||||
await act(() => router.navigate('/plan'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
|
||||
// Coming back from the dinner with nothing swapped, it still says so.
|
||||
await act(() => router.navigate('/dinner/d1'))
|
||||
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||
await act(() => router.navigate('/plan'))
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
|
||||
|
||||
// Thursday is swapped from the dinner's Add to this week.
|
||||
await act(() => router.navigate('/dinner/d1'))
|
||||
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||
const { days } = await openSheet()
|
||||
fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
|
||||
|
||||
// The Plan shows the swapped plan, not changed since its push, and no
|
||||
// longer says the earlier plan's Supply run was made.
|
||||
await act(() => router.navigate('/plan'))
|
||||
expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
|
||||
expect(screen.getByText('Korean beef bowls')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText("Made this week's Supply run from the plan.")).not.toBeInTheDocument()
|
||||
expect(sent(fetchMock, 'POST')).toEqual([`${THIS_WEEK}/push-calendar undefined`])
|
||||
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`])
|
||||
})
|
||||
|
||||
it('refuses a swap while the week is being pushed to Calendar', async () => {
|
||||
const push = deferred()
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
|
||||
[`POST ${THIS_WEEK}/push-calendar`]: () => push.response,
|
||||
'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'),
|
||||
})
|
||||
const router = await renderPage(<PlanPage />)
|
||||
await act(() => router.navigate('/plan'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1))
|
||||
|
||||
await act(() => router.navigate('/dinner/d1'))
|
||||
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||
const { sheet, days } = await openSheet()
|
||||
expect(within(sheet).getByText('The plan for this week is being pushed to Calendar.')).toBeInTheDocument()
|
||||
expect(within(days).queryAllByRole('button')).toHaveLength(0)
|
||||
expect(sent(fetchMock, 'PUT')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the favorite heart', () => {
|
||||
it('fills at once, keeps the recipe and takes the server answer', async () => {
|
||||
const favorite = deferred()
|
||||
const fetchMock = stubServer({ [`POST ${FAVORITE}`]: () => favorite.response })
|
||||
await renderPage()
|
||||
const heart = screen.getByRole('button', { name: 'Favorite' })
|
||||
expect(heart).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
fireEvent.click(heart)
|
||||
await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'true'))
|
||||
expect(sent(fetchMock, 'POST')).toEqual([`${FAVORITE} undefined`])
|
||||
|
||||
await act(async () => favorite.send(jsonResponse(200, listed(true))))
|
||||
await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1))
|
||||
expect(heart).toHaveAttribute('aria-pressed', 'true')
|
||||
// The list-shaped answer did not replace the detail: the recipe stays.
|
||||
expect(screen.getByText('Ground beef')).toBeInTheDocument()
|
||||
expect(screen.getByText('Brown the beef with the sauce.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('empties again with the error when the server refuses, and retries', async () => {
|
||||
const refused = deferred()
|
||||
let refuse = true
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${DINNER}`]: () => jsonResponse(200, detail({ is_favorite: true })),
|
||||
[`POST ${FAVORITE}`]: () => (refuse ? refused.response : jsonResponse(200, listed(false))),
|
||||
})
|
||||
await renderPage()
|
||||
const heart = screen.getByRole('button', { name: 'Favorite' })
|
||||
expect(heart).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
fireEvent.click(heart)
|
||||
await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false'))
|
||||
await act(async () => refused.send(errorResponse(500, 'internal')))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't unfavorite Korean beef bowls. internal message")
|
||||
expect(heart).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
refuse = false
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false'))
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
|
||||
expect(sent(fetchMock, 'POST')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
// A dinner's detail offline once visited: the page reads through the service
|
||||
// worker's real API strategy (src/test/serviceWorker.ts), so what it shows
|
||||
// offline is what the worker saved while it was online. jsdom has no service
|
||||
// worker, so the strategy runs directly against an in-memory cache.
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OfflinePill } from '../pwa/OfflinePill.tsx'
|
||||
import { AppShell } from '../shell/AppShell.tsx'
|
||||
import { cacheNames, routeFor } from '../sw/routes.ts'
|
||||
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { errorResponse, jsonResponse } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { origin, stubFetchThroughWorker, stubWorkerCaches, type FakeCache } from '../test/serviceWorker.ts'
|
||||
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
|
||||
|
||||
// grounded is a dinner as GET /api/v1/dinners/{id} sends it, its recipe found.
|
||||
function grounded(id: string, name: string) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
status: 'quest_log',
|
||||
origin: 'suggested',
|
||||
method: 'stovetop',
|
||||
minutes_total: 30,
|
||||
difficulty: 'easy',
|
||||
cuisine: 'korean',
|
||||
is_favorite: false,
|
||||
photo_urls: { '400': `/media/photos/${id}-400.jpg`, '1200': `/media/photos/${id}-1200.jpg` },
|
||||
ingredients: [
|
||||
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
|
||||
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
|
||||
],
|
||||
steps: ['Cook the rice.', 'Brown the beef with the sauce.'],
|
||||
source: { site_name: 'Serious Eats' },
|
||||
grounding_status: 'grounded',
|
||||
grounding_error: null,
|
||||
why_picked: null,
|
||||
}
|
||||
}
|
||||
|
||||
// server is what the server sends while it can be reached. Kitchen reading
|
||||
// mode also reads the household settings, which the worker never saves, and
|
||||
// the week's plans to find the dinner's day, of which there are none here.
|
||||
const server: Record<string, unknown> = {
|
||||
'/api/v1/settings/household': HOUSEHOLD_SETTINGS,
|
||||
'/api/v1/dinners/d1': grounded('d1', 'Korean beef bowls'),
|
||||
'/api/v1/dinners/d2': grounded('d2', 'Chicken tikka masala'),
|
||||
'/api/v1/dinners/d3': {
|
||||
...grounded('d3', 'Pork carnitas'),
|
||||
photo_urls: null,
|
||||
ingredients: [],
|
||||
steps: [],
|
||||
source: null,
|
||||
grounding_status: 'pending',
|
||||
},
|
||||
}
|
||||
|
||||
let cache: FakeCache
|
||||
let reachable: boolean
|
||||
// networkReads is every path the network was asked for, reached or not.
|
||||
let networkReads: string[]
|
||||
|
||||
beforeEach(() => {
|
||||
cache = stubWorkerCaches()
|
||||
reachable = true
|
||||
networkReads = []
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable)
|
||||
stubFetchThroughWorker(async (request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
networkReads.push(path)
|
||||
if (!reachable) throw new TypeError('Failed to fetch')
|
||||
if (path.startsWith('/api/v1/plans/')) return errorResponse(404, 'not_found')
|
||||
if (!Object.hasOwn(server, path)) throw new Error(`unexpected network fetch: ${request.url}`)
|
||||
return jsonResponse(200, server[path])
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// open loads /dinner/{id} as a fresh page load would: a new API client,
|
||||
// network status and query cache, with the offline indicator App shows.
|
||||
function open(id: string) {
|
||||
const router = createMemoryRouter(
|
||||
[{ element: <AppShell />, children: [{ path: '/dinner/:id', element: <DinnerDetailPage /> }] }],
|
||||
{ initialEntries: [`/dinner/${id}`] },
|
||||
)
|
||||
return render(
|
||||
<ApiProviders>
|
||||
<RouterProvider router={router} />
|
||||
<OfflinePill />
|
||||
</ApiProviders>,
|
||||
)
|
||||
}
|
||||
|
||||
// visit opens a dinner while online and leaves once its title shows.
|
||||
async function visit(id: string, name: string) {
|
||||
const page = open(id)
|
||||
expect(await screen.findByRole('heading', { level: 1, name })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Offline — showing saved data')).not.toBeInTheDocument()
|
||||
page.unmount()
|
||||
}
|
||||
|
||||
describe('dinner detail offline', () => {
|
||||
it('saves a visited dinner in the API cache the worker reads offline', async () => {
|
||||
mockViewport(390)
|
||||
expect(routeFor({ method: 'GET', url: new URL('/api/v1/dinners/d1', origin), mode: 'cors' }, origin)).toEqual({
|
||||
strategy: 'network-first',
|
||||
cacheName: cacheNames.api,
|
||||
})
|
||||
await visit('d1', 'Korean beef bowls')
|
||||
expect([...cache.entries.keys()]).toEqual([`${origin}/api/v1/dinners/d1`])
|
||||
})
|
||||
|
||||
it('shows a visited dinner offline from its saved copy: photo, ingredients and steps, with the offline indicator', async () => {
|
||||
mockViewport(390)
|
||||
await visit('d1', 'Korean beef bowls')
|
||||
|
||||
reachable = false
|
||||
open('d1')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
||||
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg')
|
||||
const [ingredients, steps] = screen.getAllByRole('list')
|
||||
expect(within(ingredients!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([
|
||||
'Ground beef1 lb',
|
||||
'Jasmine rice2 cups',
|
||||
])
|
||||
expect(within(steps!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([
|
||||
'1Cook the rice.',
|
||||
'2Brown the beef with the sauce.',
|
||||
])
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a visited dinner offline in Kitchen reading mode on a tablet', async () => {
|
||||
mockViewport(1180)
|
||||
await visit('d1', 'Korean beef bowls')
|
||||
|
||||
reachable = false
|
||||
open('d1')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg')
|
||||
expect(within(screen.getByRole('region', { name: 'Ingredients' })).getAllByRole('checkbox')).toHaveLength(2)
|
||||
expect(screen.getByRole('listitem', { current: 'step' })).toHaveTextContent('Cook the rice.')
|
||||
})
|
||||
|
||||
it('shows an error, not a blank page or placeholder, for a dinner never visited', async () => {
|
||||
mockViewport(390)
|
||||
await visit('d1', 'Korean beef bowls')
|
||||
|
||||
reachable = false
|
||||
open('d2')
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.")
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Korean beef bowls|Chicken tikka masala/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps a saved copy still finding its recipe as that, not a finished recipe', async () => {
|
||||
mockViewport(390)
|
||||
await visit('d3', 'Pork carnitas')
|
||||
|
||||
reachable = false
|
||||
open('d3')
|
||||
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
|
||||
// Read once online and tried once offline before the saved copy answered.
|
||||
expect(networkReads.filter((path) => path.startsWith('/api/v1/dinners/'))).toEqual([
|
||||
'/api/v1/dinners/d3',
|
||||
'/api/v1/dinners/d3',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,547 @@
|
||||
/* Mock 1k on mobile: the photo and the sheet run full-bleed past the
|
||||
shell's 20 px side padding. From tablet up the page is Kitchen reading
|
||||
mode (mock 2a, below). The page is at least as tall as the shell's scroll
|
||||
area (a stretched flex item, so its height resolves the percentage) and
|
||||
the sheet, or the reading view, grows into any room left, so the action
|
||||
bar rests at the bottom of a short recipe. */
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Kitchen reading mode is no wider than the handoff's tablet frame. */
|
||||
.tablet,
|
||||
.desktop {
|
||||
width: 100%;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.mobile .hero,
|
||||
.mobile .skeletonHero {
|
||||
margin: 0 -20px;
|
||||
}
|
||||
|
||||
/* Without a dinner to show, the back button sits alone above the message. */
|
||||
.backRow {
|
||||
padding-top: calc(16px + env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.backRow .back {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.photo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.back {
|
||||
position: absolute;
|
||||
top: calc(16px + env(safe-area-inset-top));
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
/* The sheet overlaps the photo by 28 px. */
|
||||
.sheet {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
margin-top: -28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 22px 20px 0;
|
||||
border-radius: 32px 32px 0 0;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.mobile .sheet {
|
||||
margin-left: -20px;
|
||||
margin-right: -20px;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.why {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
background: var(--color-accent-2-100);
|
||||
color: var(--color-accent-2-900);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0 0 4px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.ingredients,
|
||||
.steps {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.ingredient {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
.ingredient:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.note,
|
||||
.qty {
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.qty {
|
||||
flex: none;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.number {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent-100);
|
||||
color: var(--color-accent-800);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.source {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.source a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.missing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 6px 0 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
background: var(--color-neutral-100);
|
||||
color: var(--color-neutral-800);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.missing p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* A 44 px tall tap target for the link. */
|
||||
.fix {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
color: var(--color-accent-700);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* At the bottom of the page, which fills the scroll area, and held at the
|
||||
bottom of the screen over a long recipe. Sticky offsets count from inside
|
||||
the scroll area's padding, which on mobile already keeps clear of the nav
|
||||
bar. */
|
||||
.bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
padding: 10px 0;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.add {
|
||||
flex: 1;
|
||||
min-height: 52px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* What became of an add or a favorite: a full row above the buttons. */
|
||||
.status {
|
||||
order: -1;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.added {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.filled {
|
||||
color: var(--color-accent);
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.skeletonHero {
|
||||
height: 250px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.skeletonTitle {
|
||||
height: 32px;
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
/* Mock 2a, Kitchen reading mode: the header over two columns, the
|
||||
ingredients card (400 px) and the steps. Ingredient and step text scale
|
||||
with the text size chosen: the handoff's sizes are medium. */
|
||||
.reading {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.small {
|
||||
--reading-scale: 0.85;
|
||||
}
|
||||
|
||||
.medium {
|
||||
--reading-scale: 1;
|
||||
}
|
||||
|
||||
.large {
|
||||
--reading-scale: 1.25;
|
||||
}
|
||||
|
||||
.readingHeader {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.readingHeader .back {
|
||||
position: static;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.heading {
|
||||
flex: 1 1 240px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.readingTitle {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.keepOn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.keepOnHint {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* The A / A / A control: an outlined pill, the size chosen in grape, each
|
||||
A drawn at the size it stands for. */
|
||||
.readingHeader .textSize {
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-divider);
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.readingHeader .textSize > * {
|
||||
padding: 0 14px;
|
||||
border-radius: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.readingHeader .textSize > [aria-checked="true"] {
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.readingHeader .textSize > :nth-child(1) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.readingHeader .textSize > :nth-child(2) {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.readingHeader .textSize > :nth-child(3) {
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.columns {
|
||||
flex-grow: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 400px minmax(0, 1fr);
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
/* Below a 1024 px viewport the shell leaves too little beside a 400 px
|
||||
card for the steps, so the columns share the width instead. */
|
||||
@media (max-width: 1023px) {
|
||||
.columns {
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
|
||||
}
|
||||
}
|
||||
|
||||
.oneColumn {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.columns .ingredientsCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 22px;
|
||||
border-radius: 30px;
|
||||
}
|
||||
|
||||
.cardHead {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.readingSectionTitle {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.setOutCount {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.checklist {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.checkRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
.checkRow .check {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.checkName {
|
||||
font-size: calc(18px * var(--reading-scale));
|
||||
font-weight: 400;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.setOut {
|
||||
color: var(--color-neutral-600);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.checkQty {
|
||||
flex: none;
|
||||
text-align: right;
|
||||
font-size: calc(15px * var(--reading-scale));
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.method {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.introPhoto {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 96px;
|
||||
}
|
||||
|
||||
.introWhy {
|
||||
flex: 1.6;
|
||||
padding: 14px 18px;
|
||||
border-radius: 22px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.readingSteps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stepList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.readingStep {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 6px 18px;
|
||||
border-radius: 26px;
|
||||
font-size: calc(18px * var(--reading-scale));
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.stepCircle {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-surface);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.stepText {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.stepDone {
|
||||
color: var(--color-neutral-600);
|
||||
}
|
||||
|
||||
.stepCurrent {
|
||||
padding: 18px;
|
||||
background: var(--color-accent-100);
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent-300);
|
||||
font-size: calc(22px * var(--reading-scale));
|
||||
}
|
||||
|
||||
.stepCurrent .stepCircle {
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
/* The action bar rests at the bottom of the steps column, with Previous
|
||||
and Next step at its right. */
|
||||
.reading .bar {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.reading .add {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.stepButtons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { AppShell } from '../shell/AppShell.tsx'
|
||||
import { cacheFallbackHeader } from '../sw/routes.ts'
|
||||
import { ApiProviders } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { errorResponse, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { GROUNDING_POLL_MS } from './api.ts'
|
||||
import { DinnerDetailPage, REGULAR_DINNERS } from './DinnerDetailPage.tsx'
|
||||
import sourceCases from './testdata/source_url_cases.json'
|
||||
|
||||
const DINNER = '/api/v1/dinners/d1'
|
||||
|
||||
// detail is a dinner as GET /api/v1/dinners/{id} sends it: mock 1k's
|
||||
// Korean beef bowls, a Side quest grounded in a page that named its site.
|
||||
function detail(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'd1',
|
||||
name: 'Korean beef bowls',
|
||||
status: 'quest_log',
|
||||
origin: 'suggested',
|
||||
method: 'stovetop',
|
||||
minutes_total: 30,
|
||||
difficulty: 'easy',
|
||||
cuisine: 'korean',
|
||||
is_favorite: false,
|
||||
photo_urls: { '400': '/media/photos/p1-400.jpg', '1200': '/media/photos/p1-1200.jpg' },
|
||||
ingredients: [
|
||||
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
|
||||
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
|
||||
{ name: 'Soy sauce', qty: '¼', unit: 'cup', note: null, raw_text: '¼ cup soy sauce' },
|
||||
{ name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' },
|
||||
],
|
||||
steps: ['Cook the rice.', 'Brown the beef with the sauce.', 'Serve over rice with the onions.'],
|
||||
source: { site_name: 'Serious Eats' },
|
||||
grounding_status: 'grounded',
|
||||
grounding_error: null,
|
||||
why_picked: 'close cousin of your Beef tacos and Chicken stir-fry.',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// A recipe the household typed in: only raw_text, no source.
|
||||
const MANUAL = {
|
||||
status: 'regular',
|
||||
origin: 'regular',
|
||||
photo_urls: null,
|
||||
ingredients: [
|
||||
{ name: null, qty: null, unit: null, note: null, raw_text: '2 lb chuck roast' },
|
||||
{ name: null, qty: null, unit: null, note: null, raw_text: 'A splash of red wine' },
|
||||
],
|
||||
steps: ['Sear the roast.', 'Slow cook 8 hours.'],
|
||||
source: null,
|
||||
grounding_status: 'manual',
|
||||
why_picked: null,
|
||||
}
|
||||
|
||||
// A Regular whose recipe could not be found: no recipe at all.
|
||||
const FAILED = {
|
||||
status: 'regular',
|
||||
origin: 'regular',
|
||||
photo_urls: null,
|
||||
minutes_total: null,
|
||||
difficulty: null,
|
||||
ingredients: [],
|
||||
steps: [],
|
||||
source: null,
|
||||
grounding_status: 'failed',
|
||||
grounding_error: 'no_recipe_found',
|
||||
why_picked: null,
|
||||
}
|
||||
|
||||
// renderAt renders the detail screen at the last of entries, inside the
|
||||
// app shell's scroll area as App routes it, with probes for where its links
|
||||
// and back button go.
|
||||
function renderAt(entries: string[]) {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{ element: <AppShell />, children: [{ path: '/dinner/:id', element: <DinnerDetailPage /> }] },
|
||||
{ path: '/', element: <h1>Home probe</h1> },
|
||||
{ path: '/plan', element: <h1>Plan probe</h1> },
|
||||
{ path: REGULAR_DINNERS, element: <h1>Regular dinners probe</h1> },
|
||||
],
|
||||
{ initialEntries: entries, initialIndex: entries.length - 1 },
|
||||
)
|
||||
render(
|
||||
<ApiProviders>
|
||||
<RouterProvider router={router} />
|
||||
</ApiProviders>,
|
||||
)
|
||||
return router
|
||||
}
|
||||
|
||||
function serve(body: unknown) {
|
||||
return stubFetchByPath({ [DINNER]: () => jsonResponse(200, body) })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockViewport(390)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('DinnerDetailPage', () => {
|
||||
describe('a grounded Side quest', () => {
|
||||
it('shows its photo washed and full-bleed, tags, title and why it was picked', async () => {
|
||||
serve(detail())
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
|
||||
const photo = screen.getByRole('img', { name: 'Korean beef bowls' })
|
||||
expect(photo).toHaveAttribute('src', '/media/photos/p1-400.jpg')
|
||||
expect(photo).toHaveAttribute('srcset', '/media/photos/p1-400.jpg 400w, /media/photos/p1-1200.jpg 1200w')
|
||||
expect(declaredStyle(photo.parentElement!).height).toBe('250px')
|
||||
expect(declaredStyle(photo.parentElement!).margin).toBe('0px -20px')
|
||||
|
||||
expect(screen.getByText('Side quest')).toBeInTheDocument()
|
||||
expect(screen.getByText('Easy')).toBeInTheDocument()
|
||||
expect(screen.getByText('30 min')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Regular')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent(
|
||||
'Why it was picked: close cousin of your Beef tacos and Chicken stir-fry.',
|
||||
)
|
||||
})
|
||||
|
||||
it('lists ingredients in recipe order with quantities on the right, then numbered steps', async () => {
|
||||
serve(detail())
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 2, name: 'Ingredients' })).toBeInTheDocument()
|
||||
const rows = within(screen.getAllByRole('list')[0]!).getAllByRole('listitem')
|
||||
expect(rows.map((row) => Array.from(row.children, (cell) => cell.textContent))).toEqual([
|
||||
['Ground beef', '1 lb'],
|
||||
['Jasmine rice', '2 cups'],
|
||||
['Soy sauce', '¼ cup'],
|
||||
['Green onions, sliced', '1 bunch'],
|
||||
])
|
||||
const qty = rows[0]!.lastElementChild!
|
||||
expect(declaredStyle(qty).textAlign).toBe('right')
|
||||
expect(declaredStyle(rows[0]!).justifyContent).toBe('space-between')
|
||||
expect(declaredStyle(rows[0]!).borderBottom).toBe('1px solid var(--color-divider)')
|
||||
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Steps' })).toBeInTheDocument()
|
||||
const steps = screen.getAllByRole('list')[1]!
|
||||
expect(steps.tagName).toBe('OL')
|
||||
expect(within(steps).getAllByRole('listitem').map((step) => step.textContent)).toEqual([
|
||||
'1Cook the rice.',
|
||||
'2Brown the beef with the sauce.',
|
||||
'3Serve over rice with the onions.',
|
||||
])
|
||||
})
|
||||
|
||||
it('credits the site the recipe came from, with no link to it', async () => {
|
||||
serve(detail())
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const credit = await screen.findByText('Recipe from Serious Eats')
|
||||
expect(credit.closest('a')).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links the credit to the recipe in a new tab once the server sends a safe url', async () => {
|
||||
serve(detail({ source: { site_name: 'Serious Eats', url: 'https://www.seriouseats.com/korean-beef-bowls' } }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const credit = await screen.findByRole('link', { name: 'Recipe from Serious Eats' })
|
||||
expect(credit).toHaveAttribute('href', 'https://www.seriouseats.com/korean-beef-bowls')
|
||||
expect(credit).toHaveAttribute('target', '_blank')
|
||||
expect(credit).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['http', 'http://www.seriouseats.com/korean-beef-bowls'],
|
||||
['a password', 'https://user:secret@www.seriouseats.com/korean-beef-bowls'],
|
||||
['a query', 'https://www.seriouseats.com/korean-beef-bowls?token=abc'],
|
||||
['a fragment', 'https://www.seriouseats.com/korean-beef-bowls#token=abc'],
|
||||
])('refuses a source url with %s', async (_, url) => {
|
||||
serve(detail({ source: { site_name: 'Serious Eats', url } }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('source url is not an https link')
|
||||
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Each source exactly as the Go handler sends it for a stored link
|
||||
// (internal/server's TestGetDinnerSourceURLOnlyWhenSafe checks the same
|
||||
// file), so every url the server sends must parse here.
|
||||
it.each(sourceCases.cases)('renders the source the server sends for $stored', async ({ source }) => {
|
||||
serve(detail({ source }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const credit = await screen.findByText('Recipe from Curry House')
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
if (source.url === null) {
|
||||
expect(credit.closest('a')).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
|
||||
} else {
|
||||
expect(screen.getByRole('link', { name: 'Recipe from Curry House' })).toHaveAttribute('href', new URL(source.url).href)
|
||||
}
|
||||
})
|
||||
|
||||
it("links the credit by the url's host when the server names no site", async () => {
|
||||
serve(detail({ source: { site_name: null, url: 'https://Kitchen.Example:8443/korean-beef-bowls' } }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const credit = await screen.findByRole('link', { name: 'Recipe from kitchen.example:8443' })
|
||||
expect(credit).toHaveAttribute('href', 'https://kitchen.example:8443/korean-beef-bowls')
|
||||
expect(credit).toHaveAttribute('target', '_blank')
|
||||
expect(credit).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
// The server withholds both when the stored name could be the link's
|
||||
// host and the link could carry a token: the credit names neither.
|
||||
it('credits the web in plain text when the server sends neither site nor url', async () => {
|
||||
serve(detail({ source: { site_name: null, url: null } }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const credit = await screen.findByText('Recipe from the web')
|
||||
expect(credit.closest('a')).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('holds Add to this week and the heart at the bottom', async () => {
|
||||
serve(detail({ is_favorite: true }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const add = await screen.findByRole('button', { name: 'Add to this week' })
|
||||
expect(add).toBeEnabled()
|
||||
const heart = screen.getByRole('button', { name: 'Favorite' })
|
||||
expect(heart).toBeEnabled()
|
||||
expect(heart).toHaveAttribute('aria-pressed', 'true')
|
||||
const bar = heart.parentElement!
|
||||
expect(bar).toContainElement(add)
|
||||
expect(declaredStyle(bar).position).toBe('sticky')
|
||||
expect(declaredStyle(bar).bottom).toBe('0px')
|
||||
})
|
||||
})
|
||||
|
||||
// jsdom does no layout, so no test here can measure where the bar lands.
|
||||
// This pins the CSS chain that puts it at the bottom of the screen on a
|
||||
// short recipe: the shell's scroll area is a full-height flex item, the
|
||||
// page fills it as a column, the sheet takes the room left and the bar is
|
||||
// the page's last child. Drop any link and the bar sits right after the
|
||||
// content again, and this fails.
|
||||
it('fills the scroll area so the bar rests at the bottom of a short recipe', async () => {
|
||||
serve(detail(FAILED))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const bar = (await screen.findByRole('button', { name: 'Favorite' })).parentElement!
|
||||
const page = bar.parentElement!
|
||||
const main = page.parentElement!
|
||||
const shell = main.parentElement!
|
||||
expect(main.tagName).toBe('MAIN')
|
||||
expect(declaredStyle(shell).height).toBe('100dvh')
|
||||
expect(declaredStyle(shell).display).toBe('flex')
|
||||
expect(declaredStyle(shell).alignItems).toBe('')
|
||||
expect(declaredStyle(main).overflowY).toBe('auto')
|
||||
|
||||
expect(declaredStyle(page).minHeight).toBe('100%')
|
||||
expect(declaredStyle(page).display).toBe('flex')
|
||||
expect(declaredStyle(page).flexDirection).toBe('column')
|
||||
expect(page.lastElementChild).toBe(bar)
|
||||
const sheet = screen.getByRole('article')
|
||||
expect(sheet.parentElement).toBe(page)
|
||||
expect(sheet.nextElementSibling).toBe(bar)
|
||||
expect(declaredStyle(sheet).flexGrow).toBe('1')
|
||||
expect(declaredStyle(bar).position).toBe('sticky')
|
||||
})
|
||||
|
||||
it('shows the striped placeholder when the dinner has no photo', async () => {
|
||||
serve(detail({ photo_urls: null }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const placeholder = await screen.findByRole('img', { name: 'Korean beef bowls' })
|
||||
expect(placeholder.tagName).toBe('DIV')
|
||||
expect(placeholder).toHaveTextContent('dish photo')
|
||||
})
|
||||
|
||||
it('shows a manual Regular line by line, as typed, with no credit', async () => {
|
||||
serve(detail(MANUAL))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByText('Regular')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Side quest')).not.toBeInTheDocument()
|
||||
const [ingredients, steps] = screen.getAllByRole('list')
|
||||
expect(within(ingredients!).getAllByRole('listitem').map((row) => row.firstElementChild!.textContent)).toEqual([
|
||||
'2 lb chuck roast',
|
||||
'A splash of red wine',
|
||||
])
|
||||
expect(within(steps!).getAllByRole('listitem')).toHaveLength(2)
|
||||
expect(screen.queryByText(/Why it was picked/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveTextContent('dish photo')
|
||||
})
|
||||
|
||||
it('links a dinner whose recipe was not found to Settings → Regular dinners, with no recipe', async () => {
|
||||
serve(detail(FAILED))
|
||||
const router = renderAt(['/dinner/d1'])
|
||||
|
||||
const fix = await screen.findByRole('link', { name: 'Fix recipe' })
|
||||
expect(screen.getByText(/couldn't find a recipe/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'Steps' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(fix)
|
||||
expect(await screen.findByRole('heading', { name: 'Regular dinners probe' })).toBeInTheDocument()
|
||||
expect(router.state.location.pathname).toBe(REGULAR_DINNERS)
|
||||
})
|
||||
|
||||
it('says it is still finding a recipe while grounding is pending', async () => {
|
||||
serve(detail({ ...FAILED, grounding_status: 'pending', grounding_error: null }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'Fix recipe' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('while the recipe is being found', () => {
|
||||
const PENDING = detail({ ...FAILED, grounding_status: 'pending', grounding_error: null })
|
||||
const GROUNDED = detail({ grounding_status: 'grounded' })
|
||||
let onLine: boolean
|
||||
let body: unknown
|
||||
let fromCache: boolean
|
||||
let fetchMock: Mock<typeof fetch>
|
||||
|
||||
const reads = () => fetchMock.mock.calls.length
|
||||
const tick = (intervals = 1) => act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * intervals))
|
||||
|
||||
function setOnline(value: boolean) {
|
||||
onLine = value
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event(value ? 'online' : 'offline'))
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Only intervals are faked: TanStack Query polls with setInterval, and
|
||||
// Testing Library's waits keep their real timers.
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
||||
onLine = true
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine)
|
||||
body = PENDING
|
||||
fromCache = false
|
||||
fetchMock = stubFetchByPath({
|
||||
[DINNER]: () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json', ...(fromCache ? { [cacheFallbackHeader]: '1' } : {}) },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
// An offline event leaves TanStack Query's online manager, shared by
|
||||
// every test, offline until an online event.
|
||||
afterEach(() => {
|
||||
window.dispatchEvent(new Event('online'))
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reads the dinner again until the recipe lands, then stops', async () => {
|
||||
renderAt(['/dinner/d1'])
|
||||
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
|
||||
expect(reads()).toBe(1)
|
||||
|
||||
await tick()
|
||||
await waitFor(() => expect(reads()).toBe(2))
|
||||
|
||||
body = GROUNDED
|
||||
await tick()
|
||||
expect(await screen.findByRole('heading', { level: 2, name: 'Ingredients' })).toBeInTheDocument()
|
||||
expect(reads()).toBe(3)
|
||||
|
||||
await tick(3)
|
||||
expect(reads()).toBe(3)
|
||||
})
|
||||
|
||||
it('pauses while the browser is offline and resumes when it is back', async () => {
|
||||
renderAt(['/dinner/d1'])
|
||||
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
|
||||
expect(reads()).toBe(1)
|
||||
|
||||
setOnline(false)
|
||||
await tick(3)
|
||||
expect(reads()).toBe(1)
|
||||
|
||||
setOnline(true)
|
||||
await tick()
|
||||
await waitFor(() => expect(reads()).toBe(2))
|
||||
})
|
||||
|
||||
it('reads a saved pending copy offline once, then waits for the connection', async () => {
|
||||
onLine = false
|
||||
fromCache = true
|
||||
renderAt(['/dinner/d1'])
|
||||
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
|
||||
expect(reads()).toBe(1)
|
||||
|
||||
await tick(3)
|
||||
expect(reads()).toBe(1)
|
||||
|
||||
// The connection comes back: the saved copy is read again at once,
|
||||
// and polling goes on once the server answers.
|
||||
fromCache = false
|
||||
setOnline(true)
|
||||
await waitFor(() => expect(reads()).toBe(2))
|
||||
await tick()
|
||||
await waitFor(() => expect(reads()).toBe(3))
|
||||
})
|
||||
|
||||
it('stops polling on a saved copy even while the browser says it is online', async () => {
|
||||
fromCache = true
|
||||
renderAt(['/dinner/d1'])
|
||||
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
|
||||
|
||||
await tick(3)
|
||||
expect(reads()).toBe(1)
|
||||
})
|
||||
|
||||
it('stops polling when the page is left', async () => {
|
||||
const router = renderAt(['/dinner/d1'])
|
||||
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
|
||||
expect(reads()).toBe(1)
|
||||
|
||||
await act(() => router.navigate('/'))
|
||||
expect(screen.getByRole('heading', { name: 'Home probe' })).toBeInTheDocument()
|
||||
await tick(3)
|
||||
expect(reads()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('says an unknown or removed dinner is not found', async () => {
|
||||
stubFetchByPath({ [DINNER]: () => errorResponse(404, 'not_found') })
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Dinner not found' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Go Home' })).toHaveAttribute('href', '/')
|
||||
})
|
||||
|
||||
it('reports any other failure with a retry', async () => {
|
||||
stubFetchByPath({ [DINNER]: () => errorResponse(500, 'internal') })
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.")
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('the back button', () => {
|
||||
it('goes back in the app', async () => {
|
||||
serve(detail())
|
||||
const router = renderAt(['/plan', '/dinner/d1'])
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Back' }))
|
||||
expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument()
|
||||
expect(router.state.location.pathname).toBe('/plan')
|
||||
})
|
||||
|
||||
it('goes Home when the dinner was opened from a link', async () => {
|
||||
serve(detail())
|
||||
const router = renderAt(['/dinner/d1'])
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Back' }))
|
||||
expect(await screen.findByRole('heading', { name: 'Home probe' })).toBeInTheDocument()
|
||||
expect(router.state.location.pathname).toBe('/')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,582 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Check, ChevronLeft, ChevronRight, Heart } from 'lucide-react'
|
||||
import { useCallback, useEffect, useId, useState, type ReactNode } from 'react'
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router'
|
||||
import { ApiError } from '../api/errors.ts'
|
||||
import { planKey, usePlan, useWeekSwap } from '../plan/api.ts'
|
||||
import { duration } from '../plan/PlanPage.tsx'
|
||||
import { addDays, dayAndDate, localNow, weekdayName, weekStart } from '../plan/week.ts'
|
||||
import { useHouseholdSettings } from '../settings/api.ts'
|
||||
import { useBreakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { Card } from '../ui/Card.tsx'
|
||||
import { Checkbox } from '../ui/Checkbox.tsx'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import { Segmented, type SegmentedOption } from '../ui/Segmented.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import { Toggle } from '../ui/Toggle.tsx'
|
||||
import { AddToWeekSheet, weekNames, type Added } from './AddToWeekSheet.tsx'
|
||||
import { useToggleFavorite, type Difficulty } from './api.ts'
|
||||
import { useDinnerDetail, type DinnerDetail, type Ingredient, type RecipeSource } from './detail.ts'
|
||||
import styles from './DinnerDetailPage.module.css'
|
||||
import { useKeepScreenOn } from './keepScreenOn.ts'
|
||||
import { textSizeFailureText, useTextSize, type TextSize } from './textSize.ts'
|
||||
|
||||
// REGULAR_DINNERS is Settings → Regular dinners, where a recipe is fixed.
|
||||
export const REGULAR_DINNERS = '/settings/regulars'
|
||||
|
||||
const difficultyLabels: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
|
||||
|
||||
// BackButton goes back in the app's history, or, opened from a link with
|
||||
// no history in the app, to Home.
|
||||
function BackButton() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
// The first entry of a session has the key "default".
|
||||
const back = () => (location.key === 'default' ? void navigate('/', { replace: true }) : void navigate(-1))
|
||||
return (
|
||||
<IconButton label="Back" tone="neutral" className={styles.back} onClick={back}>
|
||||
<Icon icon={ChevronLeft} size="inline" />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
// quantity is an ingredient's amount as the right-hand column shows it,
|
||||
// "1 lb", or empty when the recipe gives none.
|
||||
function quantity(ingredient: Ingredient): string {
|
||||
return [ingredient.qty, ingredient.unit].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
// IngredientRow is one ingredient: its name with any note, or the line as
|
||||
// the recipe gives it when there is no name, and its amount on the right.
|
||||
function IngredientRow({ ingredient }: { ingredient: Ingredient }) {
|
||||
return (
|
||||
<li className={styles.ingredient}>
|
||||
<span>
|
||||
{ingredient.name ?? ingredient.raw_text}
|
||||
{ingredient.name !== null && ingredient.note !== null && <span className={styles.note}>, {ingredient.note}</span>}
|
||||
</span>
|
||||
<span className={styles.qty}>{quantity(ingredient)}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// SourceCredit is "Recipe from {site}" for a recipe fetched from the web: a
|
||||
// link opening the recipe's page in a new tab when the server sends a url
|
||||
// safe to open, else plain text. The site is the name the page gave, else
|
||||
// the url's host — already in the link, so naming it shows nothing more —
|
||||
// else, with neither to show, just "the web": a fetched recipe always
|
||||
// credits where it came from, without a name the server withheld.
|
||||
function SourceCredit({ siteName, url }: RecipeSource) {
|
||||
const credit = `Recipe from ${siteName ?? (url === null ? 'the web' : new URL(url).host)}`
|
||||
return (
|
||||
<p className={styles.source}>
|
||||
{url === null ? (
|
||||
credit
|
||||
) : (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
{credit}
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// hasRecipe says whether the dinner's recipe was found or typed in, rather
|
||||
// than still being searched for or not found.
|
||||
function hasRecipe(dinner: DinnerDetail): boolean {
|
||||
return dinner.grounding_status !== 'pending' && dinner.grounding_status !== 'failed'
|
||||
}
|
||||
|
||||
// RecipeMissing is what stands in for a recipe still being searched for, or
|
||||
// the link to fix one that could not be found.
|
||||
function RecipeMissing({ dinner }: { dinner: DinnerDetail }) {
|
||||
if (dinner.grounding_status === 'failed') {
|
||||
return (
|
||||
<section className={styles.missing} aria-label="No recipe">
|
||||
<p>We couldn't find a recipe for this dinner, so there are no ingredients or steps yet.</p>
|
||||
<Link to={REGULAR_DINNERS} className={styles.fix}>
|
||||
Fix recipe
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
return <p className={styles.missing}>Still finding the recipe — its ingredients and steps will show here.</p>
|
||||
}
|
||||
|
||||
// Recipe is the dinner's ingredients and steps, or what stands in for them.
|
||||
function Recipe({ dinner }: { dinner: DinnerDetail }) {
|
||||
if (!hasRecipe(dinner)) return <RecipeMissing dinner={dinner} />
|
||||
return (
|
||||
<>
|
||||
{dinner.ingredients.length > 0 && (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Ingredients</h2>
|
||||
<ul className={styles.ingredients}>
|
||||
{dinner.ingredients.map((ingredient, i) => (
|
||||
<IngredientRow key={i} ingredient={ingredient} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{dinner.steps.length > 0 && (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Steps</h2>
|
||||
<ol className={styles.steps}>
|
||||
{dinner.steps.map((step, i) => (
|
||||
<li key={i} className={styles.step}>
|
||||
<span className={styles.number} aria-hidden="true">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span>{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
{dinner.source !== null && <SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// AddedStatus says what became of a dinner added to a day: being saved,
|
||||
// added, or why it couldn't be, with Retry, which sends the same swap again
|
||||
// as the Draft board would (useWeekSwap), and is not offered while that is
|
||||
// refused. The week's plan stays read while it shows, so a retry finds the
|
||||
// day to swap; once added, the plan is read again from the server.
|
||||
function AddedStatus({ added }: { added: Added }) {
|
||||
const queryClient = useQueryClient()
|
||||
usePlan(added.week)
|
||||
const { swap, refused, swapIn } = useWeekSwap(added.week)
|
||||
const { date } = added.swap
|
||||
const done = swap.succeeded === added.swap
|
||||
const failed = swap.failed?.swap === added.swap ? swap.failed.error : null
|
||||
useEffect(() => {
|
||||
if (done) void queryClient.invalidateQueries({ queryKey: planKey(added.week) })
|
||||
}, [done, queryClient, added.week])
|
||||
|
||||
if (failed !== null) {
|
||||
return (
|
||||
<div className={styles.status}>
|
||||
<ErrorBanner onRetry={refused ? undefined : () => swapIn(added.swap)}>
|
||||
Couldn't add it to {weekdayName(date)} {weekNames[added.which]}. {failed.message}
|
||||
</ErrorBanner>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<p role="status" className={[styles.status, styles.added].join(' ')}>
|
||||
{done ? `Added to ${weekdayName(date)} ${weekNames[added.which]}.` : `Adding to ${weekdayName(date)}…`}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// AddToWeek is Add to this week: it opens the sheet of the week's days, and
|
||||
// says what became of the dinner added.
|
||||
function AddToWeek({ dinner }: { dinner: DinnerDetail }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [added, setAdded] = useState<Added | null>(null)
|
||||
function add(chosen: Added) {
|
||||
setOpen(false)
|
||||
setAdded(chosen)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{added !== null && <AddedStatus key={`${added.week} ${added.swap.date}`} added={added} />}
|
||||
<Button className={styles.add} onClick={() => setOpen(true)}>
|
||||
Add to this week
|
||||
</Button>
|
||||
{open && <AddToWeekSheet dinner={dinner} onAdd={add} onClose={() => setOpen(false)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// FavoriteHeart toggles whether the dinner is a favorite, at once, changing
|
||||
// back with the error and Retry if the server refuses. One toggle is sent at
|
||||
// a time.
|
||||
function FavoriteHeart({ dinner }: { dinner: DinnerDetail }) {
|
||||
const favorite = useToggleFavorite()
|
||||
function toggle() {
|
||||
if (!favorite.isPending) favorite.mutate({ id: dinner.id, is_favorite: dinner.is_favorite })
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{favorite.isError && (
|
||||
<div className={styles.status}>
|
||||
<ErrorBanner onRetry={toggle}>
|
||||
Couldn't {dinner.is_favorite ? 'unfavorite' : 'favorite'} {dinner.name}. {favorite.error.message}
|
||||
</ErrorBanner>
|
||||
</div>
|
||||
)}
|
||||
<IconButton label="Favorite" size={52} aria-pressed={dinner.is_favorite} onClick={toggle}>
|
||||
<Icon icon={Heart} size="nav" className={dinner.is_favorite ? styles.filled : undefined} />
|
||||
</IconButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ActionBar is the bar at the bottom of the screen, the page's last child:
|
||||
// the page fills the scroll area so a short recipe still leaves it at the
|
||||
// bottom, and it sticks there over a long one. It holds Add to this week
|
||||
// and the favorite heart, with what became of either above them, and in
|
||||
// Kitchen reading mode the step buttons after them.
|
||||
function ActionBar({ dinner, children }: { dinner: DinnerDetail; children?: ReactNode }) {
|
||||
return (
|
||||
<div className={styles.bar}>
|
||||
<AddToWeek dinner={dinner} />
|
||||
<FavoriteHeart dinner={dinner} />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Detail is a dinner as mock 1k shows it: the photo full-bleed under a sheet
|
||||
// with its tags, title, why it was picked and its recipe, over the action bar.
|
||||
function Detail({ dinner }: { dinner: DinnerDetail }) {
|
||||
const tags = [
|
||||
dinner.difficulty !== null && difficultyLabels[dinner.difficulty],
|
||||
dinner.minutes_total !== null && duration(dinner.minutes_total),
|
||||
].filter((tag) => tag !== false)
|
||||
return (
|
||||
<>
|
||||
<div className={styles.hero}>
|
||||
<Photo id={dinner.photo_id} name={dinner.name} radius="0" sizes="100vw" className={styles.photo} />
|
||||
<BackButton />
|
||||
</div>
|
||||
<article className={styles.sheet}>
|
||||
<div className={styles.tags}>
|
||||
<Tag tone="grape">{dinner.status === 'regular' ? 'Regular' : 'Side quest'}</Tag>
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} tone="neutral">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<h1 className={styles.title}>{dinner.name}</h1>
|
||||
{dinner.why_picked !== null && (
|
||||
<p className={styles.why}>
|
||||
<b>Why it was picked:</b> {dinner.why_picked}
|
||||
</p>
|
||||
)}
|
||||
<Recipe dinner={dinner} />
|
||||
</article>
|
||||
<ActionBar dinner={dinner} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// statusName is the dinner as the household calls it: a Regular, or a Side
|
||||
// quest for any dinner not yet one.
|
||||
function statusName(dinner: DinnerDetail): string {
|
||||
return dinner.status === 'regular' ? 'Regular' : 'Side quest'
|
||||
}
|
||||
|
||||
// PlannedDay is the kicker of a dinner on a plan, "Saturday, Oct 10 · Side
|
||||
// quest", whenever this week's or next week's plan has it. Of the days that
|
||||
// do, it is the first from today on in the household's timezone; when every
|
||||
// one is already past, it is the latest of them. There is none while the
|
||||
// plans are read, nor when neither plan has the dinner.
|
||||
function PlannedDay({ dinner, timezone }: { dinner: DinnerDetail; timezone: string }) {
|
||||
const today = localNow(timezone, new Date()).date
|
||||
const thisWeek = weekStart(today)
|
||||
const plans = [usePlan(thisWeek), usePlan(addDays(thisWeek, 7))]
|
||||
const failed = plans.find((plan) => plan.isError)
|
||||
if (failed !== undefined) {
|
||||
return (
|
||||
<ErrorBanner onRetry={() => plans.forEach((plan) => plan.isError && void plan.refetch())}>
|
||||
Couldn't check the plan for this dinner's day. {failed.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
}
|
||||
const planned = plans.flatMap((plan) => plan.data?.days ?? []).filter((day) => day.dinner.id === dinner.id)
|
||||
const day = planned.find((day) => day.date >= today) ?? planned.at(-1)
|
||||
if (day === undefined) return null
|
||||
return (
|
||||
<p className={styles.kicker}>
|
||||
{dayAndDate(day.date)} · {statusName(dinner)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// Kicker is PlannedDay, once the household's timezone is read.
|
||||
function Kicker({ dinner }: { dinner: DinnerDetail }) {
|
||||
const settings = useHouseholdSettings()
|
||||
if (settings.isError) {
|
||||
return (
|
||||
<ErrorBanner onRetry={() => void settings.refetch()}>
|
||||
Couldn't load the household settings to find this dinner's day. {settings.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
}
|
||||
if (settings.isPending) return null
|
||||
return <PlannedDay dinner={dinner} timezone={settings.data.timezone} />
|
||||
}
|
||||
|
||||
const textSizes: SegmentedOption<TextSize>[] = [
|
||||
{ value: 'small', label: 'A', ariaLabel: 'Small text' },
|
||||
{ value: 'medium', label: 'A', ariaLabel: 'Medium text' },
|
||||
{ value: 'large', label: 'A', ariaLabel: 'Large text' },
|
||||
]
|
||||
|
||||
// ReadingHeader is Kitchen reading mode's header: back, the day the dinner
|
||||
// is planned for, its title, Keep screen on and the text size.
|
||||
function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: ReturnType<typeof useTextSize> }) {
|
||||
const keepOnId = useId()
|
||||
const keepOnHintId = useId()
|
||||
const keepScreenOn = useKeepScreenOn()
|
||||
const { failure } = textSize
|
||||
return (
|
||||
<>
|
||||
<header className={styles.readingHeader}>
|
||||
<BackButton />
|
||||
<div className={styles.heading}>
|
||||
<Kicker dinner={dinner} />
|
||||
<h1 className={styles.readingTitle}>{dinner.name}</h1>
|
||||
</div>
|
||||
<div className={styles.keepOn}>
|
||||
<span id={keepOnId}>Keep screen on</span>
|
||||
<Toggle
|
||||
checked={keepScreenOn.on}
|
||||
onCheckedChange={keepScreenOn.setOn}
|
||||
aria-labelledby={keepOnId}
|
||||
disabled={!keepScreenOn.supported}
|
||||
aria-describedby={keepScreenOn.supported ? undefined : keepOnHintId}
|
||||
/>
|
||||
</div>
|
||||
<Segmented
|
||||
label="Text size"
|
||||
options={textSizes}
|
||||
value={textSize.size}
|
||||
onValueChange={textSize.choose}
|
||||
className={styles.textSize}
|
||||
/>
|
||||
</header>
|
||||
{!keepScreenOn.supported && (
|
||||
<p id={keepOnHintId} className={styles.keepOnHint}>
|
||||
This browser can't keep the screen on, so it may dim while you cook.
|
||||
</p>
|
||||
)}
|
||||
{keepScreenOn.failure !== null && (
|
||||
<ErrorBanner onRetry={() => keepScreenOn.setOn(true)}>
|
||||
Couldn't keep the screen on: {keepScreenOn.failure}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
{failure !== null && (
|
||||
<ErrorBanner onRetry={textSize.retry}>
|
||||
{textSizeFailureText[failure.action]}: {failure.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// IngredientsCard is the ingredients with a checkbox each, struck through
|
||||
// once set out, and how many are. Which are set out is this visit's alone:
|
||||
// it is never sent to the server and is not the Supply run's "have it".
|
||||
function IngredientsCard({ ingredients }: { ingredients: Ingredient[] }) {
|
||||
const titleId = useId()
|
||||
const [setOut, setSetOut] = useState<ReadonlySet<number>>(() => new Set())
|
||||
const count = ingredients.filter((_, i) => setOut.has(i)).length
|
||||
function toggle(i: number) {
|
||||
setSetOut((before) => {
|
||||
const after = new Set(before)
|
||||
if (!after.delete(i)) after.add(i)
|
||||
return after
|
||||
})
|
||||
}
|
||||
return (
|
||||
<Card role="region" aria-labelledby={titleId} className={styles.ingredientsCard}>
|
||||
<div className={styles.cardHead}>
|
||||
<h2 id={titleId} className={styles.readingSectionTitle}>
|
||||
Ingredients
|
||||
</h2>
|
||||
<span className={styles.setOutCount}>
|
||||
{count} of {ingredients.length} set out
|
||||
</span>
|
||||
</div>
|
||||
<ul className={styles.checklist}>
|
||||
{ingredients.map((ingredient, i) => (
|
||||
<li key={i} className={styles.checkRow}>
|
||||
<Checkbox
|
||||
className={styles.check}
|
||||
checked={setOut.has(i)}
|
||||
onChange={() => toggle(i)}
|
||||
label={
|
||||
<span className={[styles.checkName, setOut.has(i) && styles.setOut].filter(Boolean).join(' ')}>
|
||||
{ingredient.name ?? ingredient.raw_text}
|
||||
{ingredient.name !== null && ingredient.note !== null && `, ${ingredient.note}`}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<span className={styles.checkQty}>{quantity(ingredient)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Steps is the recipe's steps: those before current done, muted with a
|
||||
// check; current highlighted in larger text; the rest still to come.
|
||||
function Steps({ steps, current }: { steps: string[]; current: number }) {
|
||||
const titleId = useId()
|
||||
return (
|
||||
<section aria-labelledby={titleId} className={styles.readingSteps}>
|
||||
<h2 id={titleId} className={styles.readingSectionTitle}>
|
||||
Steps
|
||||
</h2>
|
||||
<ol className={styles.stepList}>
|
||||
{steps.map((step, i) => (
|
||||
<li
|
||||
key={i}
|
||||
aria-current={i === current ? 'step' : undefined}
|
||||
className={[styles.readingStep, i < current ? styles.stepDone : i === current ? styles.stepCurrent : undefined]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<span className={styles.stepCircle} aria-hidden="true">
|
||||
{i < current ? <Icon icon={Check} /> : i + 1}
|
||||
</span>
|
||||
<span className={styles.stepText}>{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// typing says whether target is a field taking text, where the arrow keys
|
||||
// move the caret and are not the screen's to take.
|
||||
function typing(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
return target.isContentEditable || target.matches('textarea, select, input:not([type="checkbox"], [type="radio"])')
|
||||
}
|
||||
|
||||
// useCurrentStep is the step being cooked, of count, from the first, with
|
||||
// move, which goes back or on by a step and stops at either end. The ← and →
|
||||
// keys move it too, unless a text field has focus, a sheet is open over the
|
||||
// screen or the key was already taken (a segmented control's arrows).
|
||||
function useCurrentStep(count: number): [number, (by: number) => void] {
|
||||
const [step, setStep] = useState(0)
|
||||
const last = count - 1
|
||||
const move = useCallback(
|
||||
(by: number) => setStep((before) => Math.max(0, Math.min(Math.min(before, last) + by, last))),
|
||||
[last],
|
||||
)
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
const by = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
|
||||
if (by === 0 || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
if (typing(event.target) || (event.target instanceof Element && event.target.closest('[role="dialog"]') !== null)) return
|
||||
event.preventDefault()
|
||||
move(by)
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [move])
|
||||
return [Math.max(0, Math.min(step, last)), move]
|
||||
}
|
||||
|
||||
// KitchenReading is a dinner as mock 2a shows it on a tablet: the header,
|
||||
// then the ingredients to check off beside the photo, why it was picked and
|
||||
// the steps, one at a time, over the action bar with Previous and Next step.
|
||||
// What is checked and the step reached last only as long as this visit.
|
||||
function KitchenReading({ dinner }: { dinner: DinnerDetail }) {
|
||||
const textSize = useTextSize()
|
||||
const [current, move] = useCurrentStep(dinner.steps.length)
|
||||
const recipe = hasRecipe(dinner)
|
||||
const last = dinner.steps.length - 1
|
||||
return (
|
||||
<div className={[styles.reading, styles[textSize.size]].join(' ')}>
|
||||
<ReadingHeader dinner={dinner} textSize={textSize} />
|
||||
<div className={recipe && dinner.ingredients.length > 0 ? styles.columns : styles.oneColumn}>
|
||||
{recipe && dinner.ingredients.length > 0 && <IngredientsCard ingredients={dinner.ingredients} />}
|
||||
<div className={styles.method}>
|
||||
<div className={styles.intro}>
|
||||
<Photo id={dinner.photo_id} name={dinner.name} radius="24px" sizes="400px" className={styles.introPhoto} />
|
||||
{dinner.why_picked !== null && (
|
||||
<p className={[styles.why, styles.introWhy].join(' ')}>
|
||||
<b>Why it was picked:</b> {dinner.why_picked}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!recipe && <RecipeMissing dinner={dinner} />}
|
||||
{recipe && dinner.steps.length > 0 && <Steps steps={dinner.steps} current={current} />}
|
||||
{recipe && dinner.source !== null && (
|
||||
<SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />
|
||||
)}
|
||||
<ActionBar dinner={dinner}>
|
||||
{recipe && dinner.steps.length > 0 && (
|
||||
<div className={styles.stepButtons}>
|
||||
<Button variant="outline" disabled={current === 0} onClick={() => move(-1)}>
|
||||
<Icon icon={ChevronLeft} />
|
||||
Previous
|
||||
</Button>
|
||||
<Button disabled={current === last} onClick={() => move(1)}>
|
||||
<Icon icon={ChevronRight} />
|
||||
Next step
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ActionBar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// DinnerDetailPage is a dinner with its recipe: on a phone (mock 1k) its
|
||||
// photo, or the striped placeholder, under a sheet with Regular or Side
|
||||
// quest, difficulty and time, why it was picked, the ingredients and the
|
||||
// steps; from tablet up, Kitchen reading mode (mock 2a). A dinner whose
|
||||
// recipe could not be found links to Settings → Regular dinners to fix it;
|
||||
// an unknown or removed dinner says it is not found.
|
||||
export function DinnerDetailPage() {
|
||||
const { id = '' } = useParams()
|
||||
const layout = useBreakpoint()
|
||||
const dinner = useDinnerDetail(id)
|
||||
|
||||
let body
|
||||
if (dinner.isError) {
|
||||
body = (
|
||||
<>
|
||||
<div className={styles.backRow}>
|
||||
<BackButton />
|
||||
</div>
|
||||
{dinner.error instanceof ApiError && dinner.error.status === 404 ? (
|
||||
<>
|
||||
<EmptyState title="Dinner not found">This dinner isn't in our kitchen — it may have been removed.</EmptyState>
|
||||
<Link to="/" className={[buttonStyles.button, buttonStyles.outline].join(' ')}>
|
||||
Go Home
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<ErrorBanner onRetry={() => void dinner.refetch()}>Couldn't load this dinner. {dinner.error.message}</ErrorBanner>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
} else if (dinner.isPending) {
|
||||
body = (
|
||||
<section aria-busy="true" aria-label="Dinner loading">
|
||||
<Skeleton className={styles.skeletonHero} />
|
||||
<div className={styles.sheet}>
|
||||
<Skeleton className={styles.skeletonTitle} />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
} else {
|
||||
// Keyed by the dinner, so moving to another dinner starts its visit
|
||||
// afresh: nothing checked, on the first step.
|
||||
body =
|
||||
layout === 'mobile' ? <Detail dinner={dinner.data} /> : <KitchenReading key={dinner.data.id} dinner={dinner.data} />
|
||||
}
|
||||
return <div className={`${styles.page} ${styles[layout]}`}>{body}</div>
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { errorResponse, jsonResponse } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
|
||||
import { TEXT_SIZE_KEY } from './textSize.ts'
|
||||
|
||||
const DINNER = '/api/v1/dinners/d1'
|
||||
const SETTINGS = '/api/v1/settings/household'
|
||||
const THIS_WEEK = '/api/v1/plans/2026-10-05'
|
||||
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
|
||||
|
||||
// detail is the Korean beef bowls, a Quest-log Side quest, as GET
|
||||
// /api/v1/dinners/{id} sends it.
|
||||
function detail(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'd1',
|
||||
name: 'Korean beef bowls',
|
||||
status: 'quest_log',
|
||||
origin: 'suggested',
|
||||
method: 'stovetop',
|
||||
minutes_total: 30,
|
||||
difficulty: 'easy',
|
||||
cuisine: 'korean',
|
||||
is_favorite: false,
|
||||
photo_urls: null,
|
||||
ingredients: [
|
||||
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
|
||||
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
|
||||
{ name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' },
|
||||
],
|
||||
steps: [
|
||||
'Rinse the rice and start it cooking.',
|
||||
'Brown the beef with the garlic and ginger.',
|
||||
'Stir in the sauce and simmer until glossy.',
|
||||
],
|
||||
source: null,
|
||||
grounding_status: 'grounded',
|
||||
grounding_error: null,
|
||||
why_picked: 'close cousin of your Beef tacos.',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it: p0 to
|
||||
// p6, except the bowls (d1) on each day of bowlsOn, counting from Monday = 0.
|
||||
function planBody(weekStart: string, ...bowlsOn: number[]) {
|
||||
const start = new Date(`${weekStart}T00:00:00Z`).getTime()
|
||||
return {
|
||||
id: `plan-${weekStart}`,
|
||||
week_start: weekStart,
|
||||
status: 'draft',
|
||||
ai_note: null,
|
||||
calendar_synced_at: null,
|
||||
days: Array.from({ length: 7 }, (_, i) => ({
|
||||
date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
|
||||
dinner:
|
||||
bowlsOn.includes(i)
|
||||
? { id: 'd1', name: 'Korean beef bowls', photo: null, minutes: 30, method: 'stovetop' }
|
||||
: { id: `p${i}`, name: `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop' },
|
||||
is_new: bowlsOn.includes(i),
|
||||
start_by: null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// serve answers the screen's reads: the dinner, the household settings and
|
||||
// both weeks' plans, unless responses says otherwise.
|
||||
function serve(responses: Record<string, () => Response> = {}): Mock<typeof fetch> {
|
||||
const all: Record<string, () => Response> = {
|
||||
[DINNER]: () => jsonResponse(200, detail()),
|
||||
[SETTINGS]: () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 5)),
|
||||
[NEXT_WEEK]: () => errorResponse(404, 'not_found'),
|
||||
...responses,
|
||||
}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const path = String(input)
|
||||
const respond = Object.hasOwn(all, path) ? all[path] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`)
|
||||
return Promise.resolve(respond())
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
function reads(fetchMock: Mock<typeof fetch>, path: string) {
|
||||
return fetchMock.mock.calls.filter(([input]) => String(input) === path).length
|
||||
}
|
||||
|
||||
// renderPage renders the bowls' detail screen and waits for its title.
|
||||
async function renderPage() {
|
||||
const router = createMemoryRouter([{ path: '/dinner/:id', element: <DinnerDetailPage /> }], {
|
||||
initialEntries: ['/dinner/d1'],
|
||||
})
|
||||
const view = render(
|
||||
<ApiProviders>
|
||||
<RouterProvider router={router} />
|
||||
</ApiProviders>,
|
||||
)
|
||||
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||
return view
|
||||
}
|
||||
|
||||
function steps() {
|
||||
return within(screen.getByRole('region', { name: 'Steps' })).getAllByRole('listitem')
|
||||
}
|
||||
|
||||
// currentStep is the text of the step marked current.
|
||||
function currentStep() {
|
||||
const current = steps().filter((step) => step.getAttribute('aria-current') === 'step')
|
||||
expect(current).toHaveLength(1)
|
||||
return current[0]!.textContent
|
||||
}
|
||||
|
||||
// FakeSentinel is a WakeLockSentinel as far as the screen uses one.
|
||||
type FakeSentinel = EventTarget & { released: boolean; release: Mock<() => Promise<void>> }
|
||||
|
||||
// letGo releases a lock as the browser does, telling its listeners so.
|
||||
function letGo(sentinel: FakeSentinel) {
|
||||
if (sentinel.released) return
|
||||
sentinel.released = true
|
||||
sentinel.dispatchEvent(new Event('release'))
|
||||
}
|
||||
|
||||
// stubWakeLock gives jsdom, which has none, a Screen Wake Lock. Each request
|
||||
// is granted at once, or, given hold, only when the test calls grant.
|
||||
function stubWakeLock({ hold = false } = {}) {
|
||||
const sentinels: FakeSentinel[] = []
|
||||
const pending: (() => void)[] = []
|
||||
const request = vi.fn((type: string) => {
|
||||
expect(type).toBe('screen')
|
||||
const sentinel: FakeSentinel = Object.assign(new EventTarget(), {
|
||||
released: false,
|
||||
release: vi.fn(() => {
|
||||
letGo(sentinel)
|
||||
return Promise.resolve()
|
||||
}),
|
||||
})
|
||||
sentinels.push(sentinel)
|
||||
if (!hold) return Promise.resolve(sentinel)
|
||||
return new Promise<FakeSentinel>((resolve) => pending.push(() => resolve(sentinel)))
|
||||
})
|
||||
Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request } })
|
||||
return { request, sentinels, grant: () => pending.shift()!() }
|
||||
}
|
||||
|
||||
// setVisibility shows or hides the page as the browser would, telling the
|
||||
// page so.
|
||||
function setVisibility(state: DocumentVisibilityState) {
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockViewport(1180)
|
||||
// Wednesday Oct 7, noon in New York.
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
Reflect.deleteProperty(navigator, 'wakeLock')
|
||||
Reflect.deleteProperty(document, 'visibilityState')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Kitchen reading mode', () => {
|
||||
it('is the tablet layout of the same route: header with back, title, Keep screen on and text size', async () => {
|
||||
stubWakeLock()
|
||||
serve()
|
||||
await renderPage()
|
||||
|
||||
const title = screen.getByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||
expect(declaredStyle(title).fontSize).toBe('36px')
|
||||
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
|
||||
// Not the phone's sheet: no photo hero tags.
|
||||
expect(screen.queryByText('Easy')).not.toBeInTheDocument()
|
||||
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
||||
fireEvent.click(keepOn)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
const sizes = screen.getByRole('radiogroup', { name: 'Text size' })
|
||||
expect(within(sizes).getAllByRole('radio').map((radio) => radio.getAttribute('aria-label'))).toEqual([
|
||||
'Small text',
|
||||
'Medium text',
|
||||
'Large text',
|
||||
])
|
||||
expect(within(sizes).getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
expect(declaredStyle(screen.getByRole('region', { name: 'Ingredients' }).parentElement!).gridTemplateColumns).toBe(
|
||||
'400px minmax(0, 1fr)',
|
||||
)
|
||||
expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent('Why it was picked: close cousin')
|
||||
})
|
||||
|
||||
describe('kicker', () => {
|
||||
it('names the day the plan has the dinner on, and Side quest', async () => {
|
||||
serve()
|
||||
await renderPage()
|
||||
expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("names a day of next week's plan, and Regular, for a Regular", async () => {
|
||||
serve({
|
||||
[DINNER]: () => jsonResponse(200, detail({ status: 'regular', origin: 'regular' })),
|
||||
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05')),
|
||||
[NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12', 0)),
|
||||
})
|
||||
await renderPage()
|
||||
expect(await screen.findByText('Monday, Oct 12 · Regular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("names a day of this week's plan already past when that is the only day", async () => {
|
||||
// Today is Wednesday; the bowls were on Monday, and are on no day of next week.
|
||||
serve({
|
||||
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0)),
|
||||
[NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12')),
|
||||
})
|
||||
await renderPage()
|
||||
expect(await screen.findByText('Monday, Oct 5 · Side quest')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('names the first day from today on over a day already past', async () => {
|
||||
serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1, 4, 6)) })
|
||||
await renderPage()
|
||||
expect(await screen.findByText('Friday, Oct 9 · Side quest')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Monday|Tuesday|Sunday/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('names the latest day already past when every day is past', async () => {
|
||||
serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1)) })
|
||||
await renderPage()
|
||||
expect(await screen.findByText('Tuesday, Oct 6 · Side quest')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Monday/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is absent when neither week has a plan', async () => {
|
||||
const fetchMock = serve({ [THIS_WEEK]: () => errorResponse(404, 'not_found') })
|
||||
await renderPage()
|
||||
await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(1))
|
||||
await waitFor(() => expect(reads(fetchMock, NEXT_WEEK)).toBe(1))
|
||||
await act(async () => {})
|
||||
expect(screen.queryByText(/· Side quest/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Oct/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says so when the plan cannot be read, with Retry', async () => {
|
||||
let failing = true
|
||||
serve({
|
||||
[THIS_WEEK]: () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, planBody('2026-10-05', 5))),
|
||||
})
|
||||
await renderPage()
|
||||
expect(await screen.findByText(/Couldn't check the plan for this dinner's day/)).toBeInTheDocument()
|
||||
|
||||
failing = false
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ingredients', () => {
|
||||
it('checks ingredients off with 22 px checkboxes, struck through, counting those set out', async () => {
|
||||
const fetchMock = serve()
|
||||
await renderPage()
|
||||
const card = screen.getByRole('region', { name: 'Ingredients' })
|
||||
expect(within(card).getByText('0 of 3 set out')).toBeInTheDocument()
|
||||
|
||||
const beef = within(card).getByRole('checkbox', { name: 'Ground beef' })
|
||||
expect(declaredStyle(beef).width).toBe('22px')
|
||||
expect(declaredStyle(beef).height).toBe('22px')
|
||||
expect(within(card).getByRole('checkbox', { name: 'Green onions, sliced' })).not.toBeChecked()
|
||||
|
||||
fireEvent.click(beef)
|
||||
expect(beef).toBeChecked()
|
||||
expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('line-through')
|
||||
fireEvent.click(within(card).getByRole('checkbox', { name: 'Jasmine rice' }))
|
||||
expect(within(card).getByText('2 of 3 set out')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(beef)
|
||||
expect(beef).not.toBeChecked()
|
||||
expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('')
|
||||
expect(within(card).getByText('1 of 3 set out')).toBeInTheDocument()
|
||||
|
||||
// Local to the screen: nothing is sent.
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')).toEqual([])
|
||||
})
|
||||
|
||||
it('starts afresh on the next visit', async () => {
|
||||
serve()
|
||||
const view = await renderPage()
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Ground beef' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next step' }))
|
||||
view.unmount()
|
||||
|
||||
await renderPage()
|
||||
expect(screen.getByText('0 of 3 set out')).toBeInTheDocument()
|
||||
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('steps', () => {
|
||||
it('highlights the current step in 22 px text, others 18 px, and moves by Previous and Next step', async () => {
|
||||
serve()
|
||||
await renderPage()
|
||||
const previous = screen.getByRole('button', { name: 'Previous' })
|
||||
const next = screen.getByRole('button', { name: 'Next step' })
|
||||
|
||||
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
||||
expect(previous).toBeDisabled()
|
||||
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(22px * var(--reading-scale))')
|
||||
expect(declaredStyle(steps()[0]!).background).toBe('var(--color-accent-100)')
|
||||
expect(declaredStyle(steps()[0]!).boxShadow).toBe('inset 0 0 0 2px var(--color-accent-300)')
|
||||
expect(declaredStyle(steps()[1]!).fontSize).toBe('calc(18px * var(--reading-scale))')
|
||||
|
||||
fireEvent.click(next)
|
||||
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
||||
// The step done is muted, its number a check.
|
||||
expect(steps()[0]!.textContent).toBe('Rinse the rice and start it cooking.')
|
||||
expect(steps()[0]!.querySelector('svg')).not.toBeNull()
|
||||
expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-600)')
|
||||
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(18px * var(--reading-scale))')
|
||||
|
||||
fireEvent.click(next)
|
||||
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
|
||||
expect(next).toBeDisabled()
|
||||
fireEvent.click(next)
|
||||
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
|
||||
|
||||
fireEvent.click(previous)
|
||||
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
||||
})
|
||||
|
||||
it('moves by the ← and → keys, stopping at either end', async () => {
|
||||
serve()
|
||||
await renderPage()
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
|
||||
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
|
||||
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
|
||||
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
|
||||
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
||||
|
||||
// From a checked ingredient too.
|
||||
const beef = screen.getByRole('checkbox', { name: 'Ground beef' })
|
||||
beef.focus()
|
||||
fireEvent.keyDown(beef, { key: 'ArrowRight' })
|
||||
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
||||
})
|
||||
|
||||
it('leaves the keys alone in a text field, with a modifier, or in the text size control', async () => {
|
||||
serve()
|
||||
await renderPage()
|
||||
const field = document.createElement('input')
|
||||
document.body.append(field)
|
||||
field.focus()
|
||||
fireEvent.keyDown(field, { key: 'ArrowRight' })
|
||||
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
||||
field.remove()
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowRight', altKey: true })
|
||||
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
||||
|
||||
const medium = screen.getByRole('radio', { name: 'Medium text' })
|
||||
fireEvent.keyDown(medium, { key: 'ArrowRight' })
|
||||
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('text size', () => {
|
||||
it('is remembered on this device', async () => {
|
||||
serve()
|
||||
const view = await renderPage()
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Large text' }))
|
||||
expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('large')
|
||||
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
|
||||
view.unmount()
|
||||
|
||||
await renderPage()
|
||||
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'false')
|
||||
expect(declaredStyle(screen.getByRole('heading', { level: 1 }).closest('header')!.parentElement!).getPropertyValue('--reading-scale')).toBe(
|
||||
'1.25',
|
||||
)
|
||||
})
|
||||
|
||||
it('says so when the size saved is not one MealQuest saved, until one is chosen', async () => {
|
||||
localStorage.setItem(TEXT_SIZE_KEY, 'huge')
|
||||
serve()
|
||||
await renderPage()
|
||||
expect(screen.getByText(/Could not read the text size chosen on this device/)).toHaveTextContent('"huge"')
|
||||
expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Small text' }))
|
||||
expect(screen.queryByText(/Could not read the text size/)).not.toBeInTheDocument()
|
||||
expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('small')
|
||||
})
|
||||
})
|
||||
|
||||
describe('source credit', () => {
|
||||
it.each([
|
||||
['a site and a url', { site_name: 'Serious Eats', url: 'https://www.seriouseats.com/bowls' }, 'Recipe from Serious Eats'],
|
||||
['only a url', { site_name: null, url: 'https://www.seriouseats.com/bowls' }, 'Recipe from www.seriouseats.com'],
|
||||
])('links the recipe page in a new tab for %s', async (_, source, name) => {
|
||||
serve({ [DINNER]: () => jsonResponse(200, detail({ source })) })
|
||||
await renderPage()
|
||||
const credit = screen.getByRole('link', { name })
|
||||
expect(credit).toHaveAttribute('href', 'https://www.seriouseats.com/bowls')
|
||||
expect(credit).toHaveAttribute('target', '_blank')
|
||||
expect(credit).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['only a site', { site_name: 'Serious Eats', url: null }, 'Recipe from Serious Eats'],
|
||||
['neither site nor url', { site_name: null, url: null }, 'Recipe from the web'],
|
||||
])('is plain text for %s', async (_, source, text) => {
|
||||
serve({ [DINNER]: () => jsonResponse(200, detail({ source })) })
|
||||
await renderPage()
|
||||
expect(screen.getByText(text).closest('a')).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is absent for a recipe typed in', async () => {
|
||||
serve({ [DINNER]: () => jsonResponse(200, detail({ source: null, grounding_status: 'manual' })) })
|
||||
await renderPage()
|
||||
expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows what stands in for a recipe not found, with no step buttons', async () => {
|
||||
serve({
|
||||
[DINNER]: () =>
|
||||
jsonResponse(200, detail({ ingredients: [], steps: [], grounding_status: 'failed', grounding_error: 'no_recipe_found' })),
|
||||
})
|
||||
await renderPage()
|
||||
expect(screen.getByRole('link', { name: 'Fix recipe' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: 'Ingredients' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Next step' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add to this week' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Keep screen on', () => {
|
||||
it('holds a screen wake lock while on and releases it when turned off', async () => {
|
||||
const wakeLock = stubWakeLock()
|
||||
serve()
|
||||
await renderPage()
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
expect(keepOn).toBeEnabled()
|
||||
expect(wakeLock.request).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(keepOn)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
||||
expect(wakeLock.sentinels[0]!.release).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(keepOn)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
||||
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
|
||||
|
||||
// On again: a fresh lock.
|
||||
fireEvent.click(keepOn)
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
||||
expect(wakeLock.sentinels[1]!.released).toBe(false)
|
||||
})
|
||||
|
||||
it('releases the lock when the screen is left', async () => {
|
||||
const wakeLock = stubWakeLock()
|
||||
serve()
|
||||
const view = await renderPage()
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' }))
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
||||
|
||||
view.unmount()
|
||||
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('releases a lock granted only after the screen was left', async () => {
|
||||
const wakeLock = stubWakeLock({ hold: true })
|
||||
serve()
|
||||
const view = await renderPage()
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' }))
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.unmount()
|
||||
await act(async () => wakeLock.grant())
|
||||
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('asks for the lock again when the page is visible again while on, and not while off', async () => {
|
||||
const wakeLock = stubWakeLock()
|
||||
serve()
|
||||
await renderPage()
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
fireEvent.click(keepOn)
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
||||
|
||||
// Hidden, the browser lets the lock go; nothing is asked while hidden.
|
||||
act(() => setVisibility('hidden'))
|
||||
act(() => letGo(wakeLock.sentinels[0]!))
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => setVisibility('visible'))
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
// Visible again with the lock still held: no second lock.
|
||||
act(() => setVisibility('visible'))
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
||||
|
||||
fireEvent.click(keepOn)
|
||||
act(() => setVisibility('hidden'))
|
||||
act(() => setVisibility('visible'))
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('asks for the lock again when the browser lets it go while the page is visible', async () => {
|
||||
const wakeLock = stubWakeLock()
|
||||
serve()
|
||||
await renderPage()
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
fireEvent.click(keepOn)
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
||||
|
||||
act(() => letGo(wakeLock.sentinels[0]!))
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
||||
await act(async () => {})
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
// The fresh lock is watched too.
|
||||
act(() => letGo(wakeLock.sentinels[1]!))
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(3))
|
||||
expect(wakeLock.sentinels[2]!.released).toBe(false)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('asks for the lock again when the page was hidden and shown while a request was pending', async () => {
|
||||
const wakeLock = stubWakeLock({ hold: true })
|
||||
serve()
|
||||
await renderPage()
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
fireEvent.click(keepOn)
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Hidden and shown again before the first lock is granted, which the
|
||||
// browser has let go by the time it is.
|
||||
act(() => setVisibility('hidden'))
|
||||
act(() => setVisibility('visible'))
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
||||
letGo(wakeLock.sentinels[0]!)
|
||||
await act(async () => wakeLock.grant())
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
||||
|
||||
await act(async () => wakeLock.grant())
|
||||
expect(wakeLock.sentinels[1]!.released).toBe(false)
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('reports a lock the browser refuses and turns the switch back off', async () => {
|
||||
const wakeLock = stubWakeLock()
|
||||
wakeLock.request.mockRejectedValueOnce(new DOMException('Blocked by permissions policy', 'NotAllowedError'))
|
||||
serve()
|
||||
await renderPage()
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
fireEvent.click(keepOn)
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
"Couldn't keep the screen on: NotAllowedError: Blocked by permissions policy",
|
||||
)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is disabled, and says why, in a browser that cannot keep the screen on', async () => {
|
||||
serve()
|
||||
await renderPage()
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
expect(keepOn).toBeDisabled()
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
||||
expect(keepOn).toHaveAccessibleDescription(
|
||||
"This browser can't keep the screen on, so it may dim while you cook.",
|
||||
)
|
||||
fireEvent.click(keepOn)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
})
|
||||
})
|
||||
+35
-14
@@ -1,6 +1,7 @@
|
||||
import { useIsMutating, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import type { DinnerDetail } from './detail.ts'
|
||||
|
||||
export const DINNERS_PATH = '/api/v1/dinners'
|
||||
export const REGULARS_PATH = `${DINNERS_PATH}?status=regular`
|
||||
@@ -12,7 +13,7 @@ export const GROUNDING_POLL_MS = 3000
|
||||
// Methods and Difficulties are the values the dinners API allows.
|
||||
export const METHODS = ['stovetop', 'oven', 'crockpot', 'grill', 'other'] as const
|
||||
export const DIFFICULTIES = ['easy', 'medium', 'hard'] as const
|
||||
const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const
|
||||
export const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const
|
||||
|
||||
export type Method = (typeof METHODS)[number]
|
||||
export type Difficulty = (typeof DIFFICULTIES)[number]
|
||||
@@ -146,6 +147,11 @@ export function dinnerPath(id: string): string {
|
||||
return `${DINNERS_PATH}/${encodeURIComponent(id)}`
|
||||
}
|
||||
|
||||
// dinnerDetailKey is the query of dinner id with its recipe (useDinnerDetail).
|
||||
export function dinnerDetailKey(id: string) {
|
||||
return ['dinners', 'detail', id] as const
|
||||
}
|
||||
|
||||
// useRegulars is the household's Regulars, in the server's order. While any
|
||||
// of them is still finding its recipe it is read again every
|
||||
// GROUNDING_POLL_MS, except while any change is in flight (whose settling
|
||||
@@ -275,28 +281,43 @@ export function useEditRegular() {
|
||||
})
|
||||
}
|
||||
|
||||
// useToggleFavorite toggles a Regular's favorite heart (POST
|
||||
// /api/v1/dinners/{id}/favorite). The heart changes at once and changes
|
||||
// back if the server refuses.
|
||||
// Favorite is the dinner whose heart is toggled, as it was before the toggle.
|
||||
export type Favorite = { id: string; is_favorite: boolean }
|
||||
|
||||
// setFavorite shows dinner id's heart as is_favorite wherever it is cached:
|
||||
// in the Regulars and in its detail. A cache not yet read is left unread.
|
||||
function setFavorite(queryClient: QueryClient, id: string, is_favorite: boolean) {
|
||||
queryClient.setQueryData<CachedRegular[]>(REGULARS_LIST_KEY, (list) =>
|
||||
list?.map((r) => (r.id === id ? { ...r, is_favorite } : r)),
|
||||
)
|
||||
queryClient.setQueryData<DinnerDetail>(dinnerDetailKey(id), (dinner) => dinner && { ...dinner, is_favorite })
|
||||
}
|
||||
|
||||
// useToggleFavorite toggles a dinner's favorite heart (POST
|
||||
// /api/v1/dinners/{id}/favorite), a Regular's or any dinner's on its detail
|
||||
// screen. The heart changes at once and changes back if the server refuses.
|
||||
// The server answers with the dinner as the dinners list sends it, which
|
||||
// replaces the Regular; the detail keeps its recipe and takes only the heart.
|
||||
export function useToggleFavorite() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
const set = (id: string, is_favorite: boolean) =>
|
||||
setRegulars(queryClient, (list) => list.map((r) => (r.id === id ? { ...r, is_favorite } : r)))
|
||||
return useMutation({
|
||||
mutationKey: REGULARS_MUTATION_KEY,
|
||||
mutationFn: async (regular: Regular) =>
|
||||
mutationFn: async (dinner: Favorite) =>
|
||||
parseDinner(
|
||||
`POST ${DINNERS_PATH}/{id}/favorite`,
|
||||
await apiFetch<unknown>(`${dinnerPath(regular.id)}/favorite`, { method: 'POST' }),
|
||||
await apiFetch<unknown>(`${dinnerPath(dinner.id)}/favorite`, { method: 'POST' }),
|
||||
),
|
||||
onMutate: async (regular) => {
|
||||
await changeOptimistically(queryClient, (list) =>
|
||||
list.map((r) => (r.id === regular.id ? { ...r, is_favorite: !regular.is_favorite } : r)),
|
||||
)
|
||||
onMutate: async (dinner) => {
|
||||
await queryClient.cancelQueries({ queryKey: dinnerDetailKey(dinner.id) })
|
||||
await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY })
|
||||
setFavorite(queryClient, dinner.id, !dinner.is_favorite)
|
||||
},
|
||||
onSuccess: (dinner) => replace(queryClient, dinner),
|
||||
onError: (_error, regular) => set(regular.id, regular.is_favorite),
|
||||
onSuccess: (dinner) => {
|
||||
queryClient.setQueryData<CachedRegular[]>(REGULARS_LIST_KEY, (list) => list?.map((r) => (r.id === dinner.id ? dinner : r)))
|
||||
queryClient.setQueryData<DinnerDetail>(dinnerDetailKey(dinner.id), (detail) => detail && { ...detail, is_favorite: dinner.is_favorite })
|
||||
},
|
||||
onError: (_error, dinner) => setFavorite(queryClient, dinner.id, dinner.is_favorite),
|
||||
onSettled: () => settle(queryClient),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import {
|
||||
DIFFICULTIES,
|
||||
dinnerDetailKey,
|
||||
dinnerPath,
|
||||
DINNERS_PATH,
|
||||
GROUNDING_POLL_MS,
|
||||
GROUNDING_STATUSES,
|
||||
METHODS,
|
||||
oneOf,
|
||||
photoId,
|
||||
type Difficulty,
|
||||
type GroundingStatus,
|
||||
type Method,
|
||||
} from './api.ts'
|
||||
|
||||
// The statuses a dinner with detail can have; an archived dinner is 404.
|
||||
const STATUSES = ['suggested', 'quest_log', 'tried', 'regular'] as const
|
||||
|
||||
export type DinnerStatus = (typeof STATUSES)[number]
|
||||
|
||||
// Ingredient is one recipe line. raw_text is the line as the recipe gives
|
||||
// it; name, qty, unit and note are the server's reading of it, each null
|
||||
// when it has none — always name, qty and unit for a recipe typed in.
|
||||
export type Ingredient = {
|
||||
name: string | null
|
||||
qty: string | null
|
||||
unit: string | null
|
||||
note: string | null
|
||||
raw_text: string
|
||||
}
|
||||
|
||||
// RecipeSource is where a fetched recipe came from. siteName is the site,
|
||||
// or null when there is none to name: a page that named no site, or a name
|
||||
// the server withheld because it could have come from the link. url is the
|
||||
// recipe's page, or null when the server withheld the stored link because
|
||||
// it could carry a token; the credit then never names its host.
|
||||
export type RecipeSource = { siteName: string | null; url: string | null }
|
||||
|
||||
// DinnerDetail is a dinner with its recipe (GET /api/v1/dinners/{id}).
|
||||
// source is null for a recipe typed in. cached says the service worker
|
||||
// answered from its saved copy because the server could not be reached.
|
||||
export type DinnerDetail = {
|
||||
id: string
|
||||
name: string
|
||||
status: DinnerStatus
|
||||
method: Method | null
|
||||
minutes_total: number | null
|
||||
difficulty: Difficulty | null
|
||||
is_favorite: boolean
|
||||
photo_id: string | undefined
|
||||
ingredients: Ingredient[]
|
||||
steps: string[]
|
||||
source: RecipeSource | null
|
||||
grounding_status: GroundingStatus
|
||||
why_picked: string | null
|
||||
cached: boolean
|
||||
}
|
||||
|
||||
function text(where: string, field: string, value: unknown): string {
|
||||
if (typeof value !== 'string') throw new Error(`${where}: ${field} is not a string`)
|
||||
return value
|
||||
}
|
||||
|
||||
function nullableText(where: string, field: string, value: unknown): string | null {
|
||||
return value === null ? null : text(where, field, value)
|
||||
}
|
||||
|
||||
function parseIngredient(where: string, body: unknown): Ingredient {
|
||||
if (!isRecord(body)) throw new Error(`${where}: ingredient is not an object`)
|
||||
return {
|
||||
name: nullableText(where, 'ingredient name', body.name),
|
||||
qty: nullableText(where, 'ingredient qty', body.qty),
|
||||
unit: nullableText(where, 'ingredient unit', body.unit),
|
||||
note: nullableText(where, 'ingredient note', body.note),
|
||||
raw_text: text(where, 'ingredient raw_text', body.raw_text),
|
||||
}
|
||||
}
|
||||
|
||||
// detailPhotoId is the photo id of photo_urls, which the server sends as
|
||||
// both cached sizes of one photo, or null.
|
||||
function detailPhotoId(where: string, value: unknown): string | undefined {
|
||||
if (value === null) return undefined
|
||||
if (!isRecord(value)) throw new Error(`${where}: photo_urls is not an object`)
|
||||
const id = photoId(where, value['400'])
|
||||
if (id === undefined || value['1200'] !== `/media/photos/${encodeURIComponent(id)}-1200.jpg`) {
|
||||
throw new Error(`${where}: photo_urls is not both sizes of one stored photo`)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// sourceUrl is source.url, null when the server withholds it, or a link safe
|
||||
// to open: https, with no user, password, query or fragment.
|
||||
function sourceUrl(where: string, value: unknown): string | null {
|
||||
if (value === undefined || value === null) return null
|
||||
const href = text(where, 'source url', value)
|
||||
let link: URL
|
||||
try {
|
||||
link = new URL(href)
|
||||
} catch (error) {
|
||||
throw new Error(`${where}: source url is not a URL`, { cause: error })
|
||||
}
|
||||
if (link.protocol !== 'https:' || link.username !== '' || link.password !== '' || link.search !== '' || link.hash !== '') {
|
||||
throw new Error(`${where}: source url is not an https link without credentials, query or fragment`)
|
||||
}
|
||||
return link.href
|
||||
}
|
||||
|
||||
function parseSource(where: string, value: unknown): RecipeSource | null {
|
||||
if (value === null) return null
|
||||
if (!isRecord(value)) throw new Error(`${where}: source is not an object`)
|
||||
return { siteName: nullableText(where, 'source site_name', value.site_name), url: sourceUrl(where, value.url) }
|
||||
}
|
||||
|
||||
// parseDinnerDetail checks a dinner with its recipe as the API sends it;
|
||||
// cached says whether the service worker answered from its saved copy.
|
||||
export function parseDinnerDetail(where: string, body: unknown, cached: boolean): DinnerDetail {
|
||||
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
|
||||
throw new Error(`${where}: response is not a dinner`)
|
||||
}
|
||||
if (typeof body.is_favorite !== 'boolean') throw new Error(`${where}: is_favorite is not a boolean`)
|
||||
if (!Array.isArray(body.ingredients) || !Array.isArray(body.steps)) throw new Error(`${where}: response has no recipe lists`)
|
||||
const minutes = body.minutes_total
|
||||
if (minutes !== null && (typeof minutes !== 'number' || !Number.isInteger(minutes))) {
|
||||
throw new Error(`${where}: minutes_total is not a whole number`)
|
||||
}
|
||||
return {
|
||||
id: body.id,
|
||||
name: body.name,
|
||||
status: oneOf(where, 'status', STATUSES, body.status),
|
||||
method: body.method === null ? null : oneOf(where, 'method', METHODS, body.method),
|
||||
minutes_total: minutes,
|
||||
difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty),
|
||||
is_favorite: body.is_favorite,
|
||||
photo_id: detailPhotoId(where, body.photo_urls),
|
||||
ingredients: body.ingredients.map((ingredient) => parseIngredient(where, ingredient)),
|
||||
steps: body.steps.map((step) => text(where, 'step', step)),
|
||||
source: parseSource(where, body.source),
|
||||
grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status),
|
||||
why_picked: nullableText(where, 'why_picked', body.why_picked),
|
||||
cached,
|
||||
}
|
||||
}
|
||||
|
||||
// useDinnerDetail is dinner id with its recipe (GET /api/v1/dinners/{id}).
|
||||
// While the dinner is still finding its recipe it is read again every
|
||||
// GROUNDING_POLL_MS, but only while the server can be reached: polling pauses
|
||||
// while the browser is offline or the last read was the service worker's
|
||||
// saved copy, which would only answer the same again, and a saved pending
|
||||
// copy is read once more when the connection comes back. Polling stops on
|
||||
// unmount. An unknown or archived dinner fails with the API's 404. It runs
|
||||
// even while the browser reports offline, so the copy the service worker
|
||||
// saved is shown, or the read fails, instead of waiting for the network.
|
||||
export function useDinnerDetail(id: string) {
|
||||
const { apiRead, network } = useApi()
|
||||
const offline = useSyncExternalStore(network.subscribe, network.offline)
|
||||
const query = useQuery({
|
||||
queryKey: dinnerDetailKey(id),
|
||||
queryFn: async () => {
|
||||
const read = await apiRead<unknown>(dinnerPath(id))
|
||||
return parseDinnerDetail(`GET ${DINNERS_PATH}/{id}`, read.body, read.cached)
|
||||
},
|
||||
networkMode: 'always',
|
||||
refetchInterval: (query) => {
|
||||
const dinner = query.state.data
|
||||
return dinner?.grounding_status === 'pending' && !dinner.cached && !offline ? GROUNDING_POLL_MS : false
|
||||
},
|
||||
})
|
||||
const { data, refetch } = query
|
||||
const stalled = data?.grounding_status === 'pending' && data.cached
|
||||
useEffect(() => {
|
||||
if (stalled && !offline) void refetch()
|
||||
}, [stalled, offline, refetch])
|
||||
return query
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// KeepScreenOn is Kitchen reading mode's Keep screen on switch: whether it is
|
||||
// on, setOn, whether this browser can keep the screen on at all, and why the
|
||||
// screen could not be kept on, if it could not.
|
||||
export type KeepScreenOn = {
|
||||
on: boolean
|
||||
setOn: (on: boolean) => void
|
||||
supported: boolean
|
||||
failure: string | null
|
||||
}
|
||||
|
||||
// useKeepScreenOn holds a Screen Wake Lock while the switch is on, and only
|
||||
// then. It starts off on each visit. The browser lets the lock go whenever
|
||||
// the page is hidden, so it is asked for again each time the page is visible
|
||||
// while the switch is on. Turning the switch off or leaving the screen
|
||||
// releases it, including a lock still being granted as the screen is left.
|
||||
//
|
||||
// A browser without the Screen Wake Lock API (or a page not served over
|
||||
// HTTPS) cannot keep the screen on: supported is false and the switch must
|
||||
// not be offered as working. A request the browser refuses turns the switch
|
||||
// back off and is reported as failure; turning it on again asks again.
|
||||
export function useKeepScreenOn(): KeepScreenOn {
|
||||
const [on, setOnState] = useState(false)
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
const supported = 'wakeLock' in navigator
|
||||
|
||||
useEffect(() => {
|
||||
if (!on) return
|
||||
const wakeLock = navigator.wakeLock
|
||||
let sentinel: WakeLockSentinel | null = null
|
||||
let requesting = false
|
||||
let left = false
|
||||
|
||||
// The browser may let a lock go while the switch is on: always when the
|
||||
// page is hidden, and, the specification allows, while it is still
|
||||
// visible. Either way the lock is asked for again at once if the page is
|
||||
// visible, or when it is visible again if not, so the switch never claims
|
||||
// a lock that is not held.
|
||||
function onRelease() {
|
||||
if (!left) acquire()
|
||||
}
|
||||
|
||||
function acquire() {
|
||||
if (document.visibilityState !== 'visible' || requesting) return
|
||||
if (sentinel !== null && !sentinel.released) return
|
||||
requesting = true
|
||||
wakeLock.request('screen').then(
|
||||
(granted) => {
|
||||
requesting = false
|
||||
if (left) {
|
||||
void granted.release()
|
||||
return
|
||||
}
|
||||
sentinel = granted
|
||||
granted.addEventListener('release', onRelease)
|
||||
// While the request was pending the page may have been hidden and
|
||||
// shown again (that visibilitychange was ignored) and the lock let
|
||||
// go before it could be listened for, so check again now.
|
||||
acquire()
|
||||
},
|
||||
(err: unknown) => {
|
||||
requesting = false
|
||||
if (left) return
|
||||
setFailure(String(err))
|
||||
setOnState(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
acquire()
|
||||
document.addEventListener('visibilitychange', acquire)
|
||||
return () => {
|
||||
left = true
|
||||
document.removeEventListener('visibilitychange', acquire)
|
||||
if (sentinel !== null) void sentinel.release()
|
||||
}
|
||||
}, [on])
|
||||
|
||||
function setOn(next: boolean) {
|
||||
if (next) setFailure(null)
|
||||
setOnState(next)
|
||||
}
|
||||
|
||||
return { on, setOn, supported, failure }
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"stored": "https://kitchen.example/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": "https://kitchen.example/recipes/chicken-curry" }
|
||||
},
|
||||
{
|
||||
"stored": "https://Kitchen-2.Example.com.:8443/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": "https://Kitchen-2.Example.com.:8443/recipes/chicken-curry" }
|
||||
},
|
||||
{
|
||||
"stored": "https://[2001:db8::1]/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": "https://[2001:db8::1]/recipes/chicken-curry" }
|
||||
},
|
||||
{
|
||||
"stored": "https://192.0.2.7/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": "https://192.0.2.7/recipes/chicken-curry" }
|
||||
},
|
||||
{
|
||||
"stored": "https://[bad]/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "https://[fe80::1%25en0]/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "https://192.0.2.999/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "https://xn--a.example/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "https://kitchen.example/recipes/chicken-curry?access_token=query-token",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "https://user-token@kitchen.example/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "http://kitchen.example/recipes/chicken-curry",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
},
|
||||
{
|
||||
"stored": "https://kitchen.example/recipes/chicken-curry#token=fragment-token",
|
||||
"source": { "site_name": "Curry House", "url": null }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
// TEXT_SIZE_KEY is the localStorage key remembering, on this device, the text
|
||||
// size chosen in Kitchen reading mode.
|
||||
export const TEXT_SIZE_KEY = 'mealquest.readingTextSize'
|
||||
|
||||
export const TEXT_SIZES = ['small', 'medium', 'large'] as const
|
||||
|
||||
export type TextSize = (typeof TEXT_SIZES)[number]
|
||||
|
||||
// DEFAULT_TEXT_SIZE is the size of a device that has never chosen one: the
|
||||
// handoff's 18 px ingredients and steps.
|
||||
export const DEFAULT_TEXT_SIZE: TextSize = 'medium'
|
||||
|
||||
// TextSizeState is the text size, and the storage failure, if any, that
|
||||
// stopped us reading or saving it.
|
||||
type TextSizeState = {
|
||||
size: TextSize
|
||||
failure: { action: 'read' | 'write'; message: string } | null
|
||||
}
|
||||
|
||||
function isTextSize(value: string): value is TextSize {
|
||||
return (TEXT_SIZES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
// Storage can be unavailable (blocked by the browser or privacy settings), in
|
||||
// which case using it throws, and can hold a value MealQuest did not save.
|
||||
// Either is reported, never treated as a remembered size.
|
||||
function readTextSize(): TextSizeState {
|
||||
try {
|
||||
const saved = window.localStorage.getItem(TEXT_SIZE_KEY)
|
||||
if (saved === null) return { size: DEFAULT_TEXT_SIZE, failure: null }
|
||||
if (!isTextSize(saved)) throw new Error(`"${saved}" is not a text size MealQuest saved`)
|
||||
return { size: saved, failure: null }
|
||||
} catch (err) {
|
||||
return { size: DEFAULT_TEXT_SIZE, failure: { action: 'read', message: String(err) } }
|
||||
}
|
||||
}
|
||||
|
||||
function writeTextSize(size: TextSize): TextSizeState {
|
||||
try {
|
||||
window.localStorage.setItem(TEXT_SIZE_KEY, size)
|
||||
return { size, failure: null }
|
||||
} catch (err) {
|
||||
return { size, failure: { action: 'write', message: String(err) } }
|
||||
}
|
||||
}
|
||||
|
||||
export const textSizeFailureText = {
|
||||
read: 'Could not read the text size chosen on this device',
|
||||
write: 'Could not remember the text size on this device',
|
||||
}
|
||||
|
||||
// useTextSize is Kitchen reading mode's text size on this device: the size,
|
||||
// the storage failure if any, choose, which saves a size, and retry, which
|
||||
// reads or saves again whatever failed. A size that could not be saved is
|
||||
// still used until the screen is left.
|
||||
export function useTextSize() {
|
||||
const [state, setState] = useState<TextSizeState>(readTextSize)
|
||||
const choose = (size: TextSize) => setState(writeTextSize(size))
|
||||
const retry = () => setState(state.failure?.action === 'write' ? writeTextSize(state.size) : readTextSize())
|
||||
return { ...state, choose, retry }
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/* Cook from what I have, handoff mock 1i. */
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* The ingredient chip field: chips, then the trailing input, wrapping. */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 12px;
|
||||
border-radius: 26px;
|
||||
background: var(--color-surface);
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.field:focus-within {
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent-300);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: contents;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
flex: 1 1 120px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-neutral-600);
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0 4px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Any time | Quick only shares the row with Find ideas, as in the mock. */
|
||||
.when {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.when > button {
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.find {
|
||||
padding: 0 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.count {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.cardHead {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.time {
|
||||
flex: none;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.action {
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* An action done: what it did, in place of its button. */
|
||||
.done {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 44px;
|
||||
padding: 0 4px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.failure {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.noPlan {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 10px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* "Plan this week": a 44 px tall grape link to the Draft board. */
|
||||
.planLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
font-weight: 800;
|
||||
color: var(--color-accent-700);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.planLink:focus-visible {
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.skeletonCard {
|
||||
height: 128px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { dinnerBody } from '../test/dinners.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { CookFromPantryPage } from './CookFromPantryPage.tsx'
|
||||
import styles from './CookFromPantryPage.module.css'
|
||||
|
||||
const FIND = 'POST /api/v1/ideas/from-ingredients'
|
||||
|
||||
const INGREDIENTS = ['Chicken thighs', 'Rice', 'Spinach', 'Coconut milk', 'Garlic']
|
||||
|
||||
// idea is a grounded idea as the job's result carries it.
|
||||
function idea(name: string, summary: string, minutes: number | null = 30) {
|
||||
return {
|
||||
name,
|
||||
uses: INGREDIENTS,
|
||||
missing: ['1 lime'],
|
||||
summary,
|
||||
method: 'stovetop',
|
||||
minutes_total: minutes,
|
||||
difficulty: 'easy',
|
||||
quick: minutes !== null && minutes <= 30,
|
||||
recipe_id: `r-${name}`,
|
||||
photo_url: '',
|
||||
source_url: 'https://recipes.example/x',
|
||||
site_name: 'Recipes',
|
||||
}
|
||||
}
|
||||
|
||||
// The ideas of mock 1i.
|
||||
const CURRY = idea('Coconut chicken curry', 'Uses all 5 · missing: 1 lime')
|
||||
const BOWLS = idea('Garlic spinach rice bowls', 'Uses 4 of 5 · nothing missing', 25)
|
||||
const ONE_PAN = idea('One-pan chicken & rice', 'Uses 3 of 5 · missing: onion')
|
||||
|
||||
type JobBody = { status: string; result: unknown; error: string | null }
|
||||
|
||||
// running is the ideas_from_ingredients job's state at stage with ideas.
|
||||
function state(status: string, stage: string, ideas: unknown[], quickOnly = false, error: string | null = null): JobBody {
|
||||
return { status, result: { stage, ingredients: INGREDIENTS, quick_only: quickOnly, ideas, dropped: 0 }, error }
|
||||
}
|
||||
|
||||
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
|
||||
|
||||
// tonightPlan is the week's plan as cook-tonight answers it, the idea's
|
||||
// dinner on Thursday.
|
||||
function tonightPlan() {
|
||||
return {
|
||||
id: 'plan-1',
|
||||
week_start: '2026-10-05',
|
||||
status: 'draft',
|
||||
ai_note: null,
|
||||
calendar_synced_at: null,
|
||||
days: DAYS.map((date, i) => ({
|
||||
date,
|
||||
dinner: { id: `d${i}`, name: i === 3 ? CURRY.name : `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop', origin: 'regular' },
|
||||
is_new: i === 3,
|
||||
start_by: null,
|
||||
})),
|
||||
updated_at: '2026-10-08T15:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
|
||||
|
||||
// stubServer answers "METHOD path" requests from responders, with /me; any
|
||||
// other request fails the test. jobs holds each job's state, which every
|
||||
// poll of it answers, so a test moves a job on by changing it.
|
||||
function stubServer(responders: Record<string, Responder>) {
|
||||
const jobs: Record<string, JobBody> = {}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
if (key === 'GET /api/v1/me') return Promise.resolve(jsonResponse(200, ME))
|
||||
const job = /^GET \/api\/v1\/jobs\/([^/]+)$/.exec(key)
|
||||
if (job !== null && !Object.hasOwn(responders, key)) {
|
||||
const id = job[1]!
|
||||
const body = jobs[id]
|
||||
if (body === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(jsonResponse(200, { id, kind: 'ideas_from_ingredients', ...body }))
|
||||
}
|
||||
const respond = Object.hasOwn(responders, key) ? responders[key] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(respond(init))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return { fetchMock, jobs }
|
||||
}
|
||||
|
||||
// calls are the requests sent as "METHOD path", in order.
|
||||
function calls(fetchMock: ReturnType<typeof stubServer>['fetchMock'], key: string) {
|
||||
return fetchMock.mock.calls.filter(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}` === key)
|
||||
}
|
||||
|
||||
function bodyOf(call: Parameters<typeof fetch>) {
|
||||
return JSON.parse(String(call[1]?.body)) as unknown
|
||||
}
|
||||
|
||||
async function renderPage() {
|
||||
const api = createApiClient(fakeLocation('/cook-from-pantry'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<MemoryRouter initialEntries={['/cook-from-pantry']}>
|
||||
<Routes>
|
||||
<Route path="/cook-from-pantry" element={<CookFromPantryPage />} />
|
||||
<Route path="/plan" element={<p>Plan screen</p>} />
|
||||
<Route path="/" element={<p>Home screen</p>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function input() {
|
||||
return screen.getByRole('textbox', { name: 'Add ingredient' })
|
||||
}
|
||||
|
||||
function chips() {
|
||||
return within(screen.getByRole('list', { name: 'Ingredients on hand' }))
|
||||
.queryAllByRole('listitem')
|
||||
.map((item) => item.textContent)
|
||||
}
|
||||
|
||||
function findButton() {
|
||||
return screen.getByRole('button', { name: 'Find ideas' })
|
||||
}
|
||||
|
||||
// enter types each ingredient and presses Enter.
|
||||
function enter(...ingredients: string[]) {
|
||||
for (const ingredient of ingredients) {
|
||||
fireEvent.change(input(), { target: { value: ingredient } })
|
||||
fireEvent.keyDown(input(), { key: 'Enter' })
|
||||
}
|
||||
}
|
||||
|
||||
function card(name: string) {
|
||||
return screen.getByRole('article', { name })
|
||||
}
|
||||
|
||||
const SLOW = { timeout: 3000 }
|
||||
|
||||
// searched lists the mock's ingredients, presses Find ideas and waits for
|
||||
// the job's first poll.
|
||||
async function searched(fetchMock: ReturnType<typeof stubServer>['fetchMock'], jobId = 'j1') {
|
||||
enter(...INGREDIENTS)
|
||||
fireEvent.click(findButton())
|
||||
await waitFor(() => expect(calls(fetchMock, `GET /api/v1/jobs/${jobId}`).length).toBeGreaterThan(0))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('CookFromPantryPage', () => {
|
||||
it('renders mock 1i: back button, title, chip field, filter and a disabled Find ideas', async () => {
|
||||
stubServer({})
|
||||
await renderPage()
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Cook from what I have' })).toBeInTheDocument()
|
||||
expect(input()).toHaveAttribute('placeholder', 'Add ingredient…')
|
||||
expect(input().closest(`.${styles.field}`)).not.toBeNull()
|
||||
const filter = screen.getByRole('radiogroup', { name: 'Which dinners' })
|
||||
expect(within(filter).getByRole('radio', { name: 'Any time' })).toBeChecked()
|
||||
expect(within(filter).getByRole('radio', { name: 'Quick only' })).not.toBeChecked()
|
||||
expect(findButton()).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to Home' }))
|
||||
expect(screen.getByText('Home screen')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('chip entry', () => {
|
||||
it('adds an ingredient on Enter or a comma, and Find ideas needs one', async () => {
|
||||
stubServer({})
|
||||
await renderPage()
|
||||
|
||||
enter('Chicken thighs')
|
||||
expect(chips()).toEqual(['Chicken thighs'])
|
||||
expect(input()).toHaveValue('')
|
||||
expect(findButton()).toBeEnabled()
|
||||
|
||||
fireEvent.change(input(), { target: { value: 'Rice,' } })
|
||||
expect(chips()).toEqual(['Chicken thighs', 'Rice'])
|
||||
expect(input()).toHaveValue('')
|
||||
|
||||
// Pasted text is split at its commas; what follows the last is still
|
||||
// being typed.
|
||||
fireEvent.change(input(), { target: { value: ' Spinach , coconut milk, Gar' } })
|
||||
expect(chips()).toEqual(['Chicken thighs', 'Rice', 'Spinach', 'coconut milk'])
|
||||
expect(input()).toHaveValue(' Gar')
|
||||
|
||||
// Enter on nothing adds nothing.
|
||||
fireEvent.change(input(), { target: { value: ' ' } })
|
||||
fireEvent.keyDown(input(), { key: 'Enter' })
|
||||
expect(chips()).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('enables Find ideas for text typed and not yet added, and searches it', async () => {
|
||||
const { fetchMock } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
|
||||
await renderPage()
|
||||
|
||||
fireEvent.change(input(), { target: { value: ' ' } })
|
||||
expect(findButton()).toBeDisabled()
|
||||
fireEvent.change(input(), { target: { value: 'Chicken thighs' } })
|
||||
expect(chips()).toEqual([])
|
||||
expect(findButton()).toBeEnabled()
|
||||
|
||||
fireEvent.click(findButton())
|
||||
await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(1))
|
||||
expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: ['Chicken thighs'], quick_only: false })
|
||||
expect(chips()).toEqual(['Chicken thighs'])
|
||||
})
|
||||
|
||||
it('removes a chip with its cross, or the last one with Backspace in the empty input', async () => {
|
||||
stubServer({})
|
||||
await renderPage()
|
||||
enter('Chicken thighs', 'Rice', 'Spinach')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Rice' }))
|
||||
expect(chips()).toEqual(['Chicken thighs', 'Spinach'])
|
||||
expect(input()).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(input(), { key: 'Backspace' })
|
||||
expect(chips()).toEqual(['Chicken thighs'])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Chicken thighs' }))
|
||||
expect(chips()).toEqual([])
|
||||
expect(findButton()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('says why it leaves out a repeated, overlong or 21st ingredient', async () => {
|
||||
stubServer({})
|
||||
await renderPage()
|
||||
enter('Rice', ' rice ')
|
||||
expect(chips()).toEqual(['Rice'])
|
||||
expect(screen.getByRole('status')).toHaveTextContent('rice is listed already.')
|
||||
|
||||
enter('x'.repeat(81))
|
||||
expect(chips()).toEqual(['Rice'])
|
||||
expect(screen.getByRole('status')).toHaveTextContent('An ingredient can be at most 80 characters.')
|
||||
|
||||
enter(...Array.from({ length: 19 }, (_, i) => `Item ${i}`))
|
||||
expect(chips()).toHaveLength(20)
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
enter('One too many')
|
||||
expect(chips()).toHaveLength(20)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('List at most 20 ingredients.')
|
||||
})
|
||||
})
|
||||
|
||||
it('fills the results progressively as the server grounds ideas, then enables the actions', async () => {
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
|
||||
jobs.j1 = state('running', 'suggesting', [])
|
||||
await renderPage()
|
||||
|
||||
enter(...INGREDIENTS)
|
||||
fireEvent.change(input(), { target: { value: 'lime' } })
|
||||
fireEvent.click(findButton())
|
||||
// What is still typed is listed before the search.
|
||||
expect(chips()).toEqual([...INGREDIENTS, 'lime'])
|
||||
await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(1))
|
||||
expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: [...INGREDIENTS, 'lime'], quick_only: false })
|
||||
|
||||
// Suggesting: five skeleton cards, no idea yet.
|
||||
expect(await screen.findByText('Thinking up ideas…')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(5)
|
||||
expect(screen.queryAllByRole('article')).toHaveLength(0)
|
||||
|
||||
// Grounding: the idea grounded so far, and skeletons for the rest; its
|
||||
// actions wait for the job to end.
|
||||
jobs.j1 = state('running', 'grounding', [CURRY])
|
||||
await screen.findByRole('article', { name: CURRY.name }, SLOW)
|
||||
expect(screen.getByText('Finding recipes for them…')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(4)
|
||||
expect(within(card(CURRY.name)).getByRole('button', { name: 'Cook tonight' })).toBeDisabled()
|
||||
expect(within(card(CURRY.name)).getByRole('button', { name: 'Add to Quest log' })).toBeDisabled()
|
||||
|
||||
jobs.j1 = state('running', 'grounding', [CURRY, BOWLS])
|
||||
await screen.findByRole('article', { name: BOWLS.name }, SLOW)
|
||||
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3)
|
||||
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS, ONE_PAN])
|
||||
await screen.findByText('3 ideas', {}, SLOW)
|
||||
expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0)
|
||||
expect(screen.getAllByRole('article').map((a) => a.getAttribute('aria-label'))).toEqual([CURRY.name, BOWLS.name, ONE_PAN.name])
|
||||
const curry = within(card(CURRY.name))
|
||||
// The server's summary line, as it is.
|
||||
expect(curry.getByText('Uses all 5 · missing: 1 lime')).toHaveClass(styles.summary!)
|
||||
expect(card(CURRY.name).querySelector(`.${styles.time}`)).toHaveTextContent('30 min')
|
||||
expect(card(BOWLS.name).querySelector(`.${styles.time}`)).toHaveTextContent('25 min')
|
||||
expect(curry.getByRole('button', { name: 'Cook tonight' })).toBeEnabled()
|
||||
expect(curry.getByRole('button', { name: 'Add to Quest log' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('leaves the time off an idea whose recipe does not say it', async () => {
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
|
||||
jobs.j1 = state('succeeded', 'done', [idea('Fried rice', 'Uses 2 of 5 · nothing missing', null)])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
const fried = await screen.findByRole('article', { name: 'Fried rice' })
|
||||
expect(within(fried).queryByText(/min/)).toBeNull()
|
||||
})
|
||||
|
||||
describe('Any time | Quick only', () => {
|
||||
it('sends quick_only, and asks again when changed after a search', async () => {
|
||||
let n = 0
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) })
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS], true)
|
||||
jobs.j2 = state('succeeded', 'done', [ONE_PAN], false)
|
||||
await renderPage()
|
||||
|
||||
// Before a search it only sets what Find ideas asks for.
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Quick only' }))
|
||||
expect(calls(fetchMock, FIND)).toHaveLength(0)
|
||||
|
||||
await searched(fetchMock, 'j1')
|
||||
expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: INGREDIENTS, quick_only: true })
|
||||
await screen.findByText('2 ideas')
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Any time' }))
|
||||
await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(2))
|
||||
expect(bodyOf(calls(fetchMock, FIND)[1]!)).toEqual({ ingredients: INGREDIENTS, quick_only: false })
|
||||
await screen.findByText('1 idea')
|
||||
expect(screen.getAllByRole('article').map((a) => a.getAttribute('aria-label'))).toEqual([ONE_PAN.name])
|
||||
})
|
||||
|
||||
it('refuses a result that is not for the filter asked for', async () => {
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY], true)
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't read the ideas.")
|
||||
expect(screen.queryAllByRole('article')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add to Quest log', () => {
|
||||
it('adds the idea and says so on its card', async () => {
|
||||
const added = Promise.withResolvers<Response>()
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||
'POST /api/v1/ideas/j1/items/1/quest-log': () => added.promise,
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
await screen.findByText('2 ideas')
|
||||
|
||||
const bowls = within(card(BOWLS.name))
|
||||
fireEvent.click(bowls.getByRole('button', { name: 'Add to Quest log' }))
|
||||
expect(await bowls.findByRole('button', { name: 'Adding…' })).toBeDisabled()
|
||||
added.resolve(jsonResponse(201, dinnerBody('dn-1', BOWLS.name, { status: 'quest_log', origin: 'ingredients' })))
|
||||
expect(await bowls.findByRole('status')).toHaveTextContent('In the Quest log')
|
||||
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/1/quest-log')).toHaveLength(1)
|
||||
// The other card is untouched.
|
||||
expect(within(card(CURRY.name)).getByRole('button', { name: 'Add to Quest log' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('says an idea that came back as an existing Regular is already a Regular, not in the Quest log', async () => {
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||
'POST /api/v1/ideas/j1/items/0/quest-log': () =>
|
||||
jsonResponse(200, dinnerBody('dn-reg', 'Coconut Chicken Curry', { status: 'regular' })),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
|
||||
|
||||
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
|
||||
expect(await curry.findByRole('status')).toHaveTextContent('Already a Regular')
|
||||
expect(curry.queryByText('In the Quest log')).toBeNull()
|
||||
})
|
||||
|
||||
it('fails on a dinner whose status is neither quest_log nor regular', async () => {
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||
'POST /api/v1/ideas/j1/items/0/quest-log': () => jsonResponse(200, dinnerBody('dn-1', CURRY.name, { status: 'suggested' })),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
|
||||
|
||||
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
|
||||
expect(await curry.findByRole('alert')).toHaveTextContent(
|
||||
'POST /api/v1/ideas/j1/items/0/quest-log: status "suggested" is not one of quest_log, regular',
|
||||
)
|
||||
expect(curry.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows its failure on its card and lets it be pressed again', async () => {
|
||||
let fail = true
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||
'POST /api/v1/ideas/j1/items/0/quest-log': () =>
|
||||
fail ? errorResponse(500, 'internal') : jsonResponse(200, dinnerBody('dn-1', CURRY.name, { status: 'quest_log' })),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
|
||||
|
||||
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
|
||||
expect(await curry.findByRole('alert')).toHaveTextContent("Couldn't add it to the Quest log. internal message")
|
||||
|
||||
fail = false
|
||||
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
|
||||
expect(await curry.findByRole('status')).toHaveTextContent('In the Quest log')
|
||||
expect(curry.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cook tonight', () => {
|
||||
it("puts the idea on tonight's plan", async () => {
|
||||
const planned = Promise.withResolvers<Response>()
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||
'POST /api/v1/ideas/j1/items/0/cook-tonight': () => planned.promise,
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
|
||||
|
||||
fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' }))
|
||||
expect(await curry.findByRole('button', { name: 'Planning…' })).toBeDisabled()
|
||||
planned.resolve(jsonResponse(200, { dinner: dinnerBody('dn-1', CURRY.name, { status: 'quest_log' }), plan: tonightPlan() }))
|
||||
expect(await curry.findByRole('status')).toHaveTextContent("On tonight's plan")
|
||||
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/cook-tonight')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('offers Plan this week, not an error, when the week has no plan', async () => {
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||
'POST /api/v1/ideas/j1/items/0/cook-tonight': () => errorResponse(409, 'no_plan'),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
|
||||
|
||||
fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' }))
|
||||
const link = await curry.findByRole('link', { name: 'Plan this week' })
|
||||
expect(curry.getByRole('status')).toHaveTextContent('This week has no plan yet')
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
fireEvent.click(link)
|
||||
expect(screen.getByText('Plan screen')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows any other failure on its card', async () => {
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||
'POST /api/v1/ideas/j1/items/0/cook-tonight': () => errorResponse(409, 'dinner_already_planned'),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
|
||||
|
||||
fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' }))
|
||||
expect(await curry.findByRole('alert')).toHaveTextContent("Couldn't put it on tonight's plan. dinner_already_planned message")
|
||||
expect(curry.queryByRole('link', { name: 'Plan this week' })).toBeNull()
|
||||
expect(curry.getByRole('button', { name: 'Cook tonight' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('errors', () => {
|
||||
it('shows the ErrorBanner when the search cannot start, and Retry asks again', async () => {
|
||||
let n = 0
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => (++n === 1 ? errorResponse(400, 'invalid_request') : jsonResponse(202, { jobId: 'j1' })),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
enter(...INGREDIENTS)
|
||||
fireEvent.click(findButton())
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't start looking for ideas. invalid_request message")
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('article', { name: CURRY.name })
|
||||
expect(bodyOf(calls(fetchMock, FIND)[1]!)).toEqual({ ingredients: INGREDIENTS, quick_only: false })
|
||||
})
|
||||
|
||||
it('shows a failed job with no cards, and Retry searches again', async () => {
|
||||
let n = 0
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) })
|
||||
jobs.j1 = state('failed', 'grounding', [], false, 'ai_failed')
|
||||
jobs.j2 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't find ideas. The AI assistant couldn't come up with ideas.")
|
||||
expect(screen.queryAllByRole('article')).toHaveLength(0)
|
||||
expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0)
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('article', { name: CURRY.name })
|
||||
expect(calls(fetchMock, FIND)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['search_not_configured', 'Recipe search is not set up.'],
|
||||
['search_failed', "The recipe search couldn't be reached."],
|
||||
])('explains %s', async (code, message) => {
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
|
||||
jobs.j1 = state('failed', 'grounding', [], false, code)
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(message)
|
||||
})
|
||||
|
||||
it('shows no ideas found with the ErrorBanner and no cards, and Retry searches again', async () => {
|
||||
let n = 0
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) })
|
||||
jobs.j1 = state('failed', 'grounding', [], false, 'no_ideas_found')
|
||||
jobs.j2 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't find ideas. No ideas came back with a recipe to match.")
|
||||
expect(screen.queryAllByRole('article')).toHaveLength(0)
|
||||
expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0)
|
||||
expect(screen.queryByRole('heading', { name: 'No ideas found' })).toBeNull()
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('article', { name: CURRY.name })
|
||||
expect(calls(fetchMock, FIND)).toHaveLength(2)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows an unknown job with the ErrorBanner, and Retry searches again', async () => {
|
||||
let n = 0
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: `j${++n}` }),
|
||||
'GET /api/v1/jobs/j1': () => errorResponse(404, 'not_found'),
|
||||
})
|
||||
jobs.j2 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't check on the ideas. not_found message")
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('article', { name: CURRY.name })
|
||||
expect(calls(fetchMock, FIND)).toHaveLength(2)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,321 @@
|
||||
import { Check, ChevronLeft } from 'lucide-react'
|
||||
import { useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { ApiError, useJob } from '../api/index.ts'
|
||||
import { duration } from '../plan/PlanPage.tsx'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Card } from '../ui/Card.tsx'
|
||||
import { Chip } from '../ui/Chip.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Segmented } from '../ui/Segmented.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { TextInput } from '../ui/TextInput.tsx'
|
||||
import {
|
||||
ADDED_LABEL,
|
||||
FROM_INGREDIENTS_JOB_KIND,
|
||||
IDEAS_KEPT,
|
||||
ideasJobFailure,
|
||||
ideasJobLost,
|
||||
INGREDIENT_LIMIT,
|
||||
MAX_INGREDIENTS,
|
||||
parseIdeasResult,
|
||||
useAddIdeaToQuestLog,
|
||||
useCookIdeaTonight,
|
||||
useFindIdeas,
|
||||
type Idea,
|
||||
type IdeaRef,
|
||||
type IdeaStage,
|
||||
type Search,
|
||||
} from './api.ts'
|
||||
import styles from './CookFromPantryPage.module.css'
|
||||
|
||||
type When = 'any' | 'quick'
|
||||
|
||||
const WHEN = [
|
||||
{ value: 'any', label: 'Any time' },
|
||||
{ value: 'quick', label: 'Quick only' },
|
||||
] as const satisfies readonly { value: When; label: string }[]
|
||||
|
||||
// findingLines says what a running search is doing, by its stage.
|
||||
const findingLines: Record<Exclude<IdeaStage, 'done'>, string> = {
|
||||
suggesting: 'Thinking up ideas…',
|
||||
grounding: 'Finding recipes for them…',
|
||||
}
|
||||
|
||||
// fold is an ingredient as the chips compare it, so "Rice" repeats " rice".
|
||||
function fold(ingredient: string): string {
|
||||
return ingredient.trim().replace(/\s+/g, ' ').toLowerCase()
|
||||
}
|
||||
|
||||
// IdeaCard is one idea (mock 1i): its name and time, the server's line on
|
||||
// what it uses and what is missing, and its two actions, each showing its
|
||||
// own progress and failure; an idea added says whether its dinner is in
|
||||
// the Quest log or is already a Regular. ref is the idea in its job, null
|
||||
// while the job runs: the server takes an idea only once the job has
|
||||
// succeeded, as the ideas may still be reordered until then.
|
||||
function IdeaCard({ idea, ideaRef }: { idea: Idea; ideaRef: IdeaRef | null }) {
|
||||
const questLog = useAddIdeaToQuestLog()
|
||||
const tonight = useCookIdeaTonight()
|
||||
const noPlan = tonight.error instanceof ApiError && tonight.error.code === 'no_plan'
|
||||
return (
|
||||
<Card tone="muted" className={styles.card} role="article" aria-label={idea.name}>
|
||||
<div className={styles.cardHead}>
|
||||
<h2 className={styles.name}>{idea.name}</h2>
|
||||
{idea.minutes_total !== null && <span className={styles.time}>{duration(idea.minutes_total)}</span>}
|
||||
</div>
|
||||
<p className={styles.summary}>{idea.summary}</p>
|
||||
<div className={styles.actions}>
|
||||
{tonight.isSuccess ? (
|
||||
<span className={styles.done} role="status">
|
||||
<Icon icon={Check} /> On tonight's plan
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
className={styles.action}
|
||||
disabled={ideaRef === null || tonight.isPending}
|
||||
onClick={() => tonight.mutate(ideaRef!)}
|
||||
>
|
||||
{tonight.isPending ? 'Planning…' : 'Cook tonight'}
|
||||
</Button>
|
||||
)}
|
||||
{questLog.isSuccess ? (
|
||||
<span className={styles.done} role="status">
|
||||
<Icon icon={Check} /> {ADDED_LABEL[questLog.data.status]}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={styles.action}
|
||||
disabled={ideaRef === null || questLog.isPending}
|
||||
onClick={() => questLog.mutate(ideaRef!)}
|
||||
>
|
||||
{questLog.isPending ? 'Adding…' : 'Add to Quest log'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{noPlan ? (
|
||||
<div className={styles.noPlan} role="status">
|
||||
<span>This week has no plan yet, so there's no tonight to put it on.</span>
|
||||
<Link to="/plan" className={styles.planLink}>
|
||||
Plan this week
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
tonight.isError && (
|
||||
<p className={styles.failure} role="alert">
|
||||
Couldn't put it on tonight's plan. {tonight.error.message}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{questLog.isError && (
|
||||
<p className={styles.failure} role="alert">
|
||||
Couldn't add it to the Quest log. {questLog.error.message}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeletons stand in for the count ideas still to come.
|
||||
function Skeletons({ count }: { count: number }) {
|
||||
return Array.from({ length: count }, (_, i) => <Skeleton key={i} data-testid="skeleton-card" className={styles.skeletonCard} />)
|
||||
}
|
||||
|
||||
// CookFromPantryPage is mock 1i, Cook from what I have: the ingredients on
|
||||
// hand as chips (Enter or a comma adds one), Any time | Quick only, and
|
||||
// Find ideas, which lists what is still typed, so it is enabled by a chip
|
||||
// or by text typed and not yet added, and starts an ideas_from_ingredients
|
||||
// job and shows its ideas as the server grounds them, skeleton cards
|
||||
// holding the places of those still to come. Changing Any time | Quick
|
||||
// only after a search asks again: the server, not the screen, says what is
|
||||
// quick.
|
||||
export function CookFromPantryPage() {
|
||||
const navigate = useNavigate()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [ingredients, setIngredients] = useState<string[]>([])
|
||||
const [draft, setDraft] = useState('')
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const [when, setWhen] = useState<When>('any')
|
||||
// The search last asked for, which Retry asks again, and its job.
|
||||
const [asked, setAsked] = useState<Search | null>(null)
|
||||
const [jobId, setJobId] = useState<string | null>(null)
|
||||
const findIdeas = useFindIdeas()
|
||||
const job = useJob<unknown>(FROM_INGREDIENTS_JOB_KIND, jobId)
|
||||
|
||||
// add lists entries after the chips there are, saying why it leaves out
|
||||
// any it cannot list: the server would refuse a blank, overlong or
|
||||
// repeated ingredient, or one past the most it takes.
|
||||
function add(list: string[], entries: string[]): string[] {
|
||||
const next = [...list]
|
||||
for (const entry of entries.map((e) => e.trim().replace(/\s+/g, ' '))) {
|
||||
if (entry === '') continue
|
||||
if (next.some((ingredient) => fold(ingredient) === fold(entry))) {
|
||||
setNotice(`${entry} is listed already.`)
|
||||
} else if (entry.length > INGREDIENT_LIMIT) {
|
||||
setNotice(`An ingredient can be at most ${INGREDIENT_LIMIT} characters.`)
|
||||
} else if (next.length === MAX_INGREDIENTS) {
|
||||
setNotice(`List at most ${MAX_INGREDIENTS} ingredients.`)
|
||||
} else {
|
||||
next.push(entry)
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function onDraftChange(value: string) {
|
||||
setNotice(null)
|
||||
// A comma ends an ingredient, also within pasted text.
|
||||
const parts = value.split(',')
|
||||
const rest = parts.pop()!
|
||||
if (parts.length > 0) setIngredients(add(ingredients, parts))
|
||||
setDraft(rest)
|
||||
}
|
||||
|
||||
function onDraftKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
setNotice(null)
|
||||
setIngredients(add(ingredients, [draft]))
|
||||
setDraft('')
|
||||
} else if (event.key === 'Backspace' && draft === '' && ingredients.length > 0) {
|
||||
setNotice(null)
|
||||
setIngredients(ingredients.slice(0, -1))
|
||||
}
|
||||
}
|
||||
|
||||
// search asks for ideas. Only the latest search's job is followed: mutate
|
||||
// calls back for the latest call only.
|
||||
function search(next: Search) {
|
||||
setAsked(next)
|
||||
setJobId(null)
|
||||
findIdeas.mutate(next, { onSuccess: setJobId })
|
||||
}
|
||||
|
||||
// find lists what is still typed, then searches the chips.
|
||||
function find() {
|
||||
setNotice(null)
|
||||
const list = add(ingredients, [draft])
|
||||
setIngredients(list)
|
||||
setDraft('')
|
||||
if (list.length > 0) search({ ingredients: list, quickOnly: when === 'quick' })
|
||||
}
|
||||
|
||||
function onWhenChange(value: When) {
|
||||
setWhen(value)
|
||||
if (asked !== null && ingredients.length > 0) search({ ingredients, quickOnly: value === 'quick' })
|
||||
}
|
||||
|
||||
const retry = () => search(asked!)
|
||||
|
||||
// The job's result as far as it has gone, or why it cannot be read.
|
||||
const found = useMemo(() => {
|
||||
if (job.result === null || asked === null) return { result: null, error: null }
|
||||
try {
|
||||
return { result: parseIdeasResult(`GET /api/v1/jobs/${jobId}`, asked.quickOnly, job.result), error: null }
|
||||
} catch (error) {
|
||||
return { result: null, error: error as Error }
|
||||
}
|
||||
}, [job.result, jobId, asked])
|
||||
|
||||
let results = null
|
||||
if (findIdeas.isPending) {
|
||||
results = (
|
||||
<section aria-busy="true" aria-label="Ideas loading" className={styles.results}>
|
||||
<p className={styles.count}>{findingLines.suggesting}</p>
|
||||
<Skeletons count={IDEAS_KEPT} />
|
||||
</section>
|
||||
)
|
||||
} else if (findIdeas.isError) {
|
||||
results = <ErrorBanner onRetry={retry}>Couldn't start looking for ideas. {findIdeas.error.message}</ErrorBanner>
|
||||
} else if (jobId !== null && job.status === 'failed') {
|
||||
results = <ErrorBanner onRetry={retry}>Couldn't find ideas. {ideasJobFailure(job.error!.message)}</ErrorBanner>
|
||||
} else if (found.error !== null) {
|
||||
results = <ErrorBanner onRetry={retry}>Couldn't read the ideas. {found.error.message}</ErrorBanner>
|
||||
} else if (jobId !== null) {
|
||||
const ideas = found.result?.ideas ?? []
|
||||
const done = job.status === 'succeeded'
|
||||
// A job not found will not be found by asking again: Retry searches
|
||||
// again. Any other failed poll is asked again.
|
||||
const lost = ideasJobLost(job.error)
|
||||
results = (
|
||||
<>
|
||||
{job.error !== null && (
|
||||
<ErrorBanner onRetry={lost ? retry : job.poll}>Couldn't check on the ideas. {job.error.message}</ErrorBanner>
|
||||
)}
|
||||
<section aria-busy={!done} aria-label="Ideas" className={styles.results}>
|
||||
<p className={styles.count}>
|
||||
{done
|
||||
? `${ideas.length} ${ideas.length === 1 ? 'idea' : 'ideas'}`
|
||||
: findingLines[found.result?.stage === 'grounding' ? 'grounding' : 'suggesting']}
|
||||
</p>
|
||||
{ideas.map((idea, index) => (
|
||||
<IdeaCard key={`${jobId}:${index}`} idea={idea} ideaRef={done ? { jobId, index } : null} />
|
||||
))}
|
||||
{!done && <Skeletons count={Math.max(IDEAS_KEPT - ideas.length, 0)} />}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<IconButton label="Back to Home" onClick={() => void navigate('/')}>
|
||||
<Icon icon={ChevronLeft} size="nav" />
|
||||
</IconButton>
|
||||
<h1 className={styles.title}>Cook from what I have</h1>
|
||||
</header>
|
||||
{/* A tap anywhere on the field types in it. */}
|
||||
<div className={styles.field} onClick={() => inputRef.current?.focus()}>
|
||||
<ul className={styles.chips} aria-label="Ingredients on hand">
|
||||
{ingredients.map((ingredient, i) => (
|
||||
<li key={ingredient}>
|
||||
<Chip
|
||||
label={ingredient}
|
||||
className={styles.chip}
|
||||
onRemove={() => {
|
||||
setNotice(null)
|
||||
setIngredients(ingredients.filter((_, j) => j !== i))
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.entry}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
label="Add ingredient"
|
||||
labelHidden
|
||||
placeholder="Add ingredient…"
|
||||
enterKeyHint="enter"
|
||||
autoComplete="off"
|
||||
className={styles.input}
|
||||
value={draft}
|
||||
onChange={(event) => onDraftChange(event.target.value)}
|
||||
onKeyDown={onDraftKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{notice !== null && (
|
||||
<p className={styles.notice} role="status">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.controls}>
|
||||
<Segmented label="Which dinners" options={WHEN} value={when} onValueChange={onWhenChange} className={styles.when} />
|
||||
<Button
|
||||
variant="dark"
|
||||
className={styles.find}
|
||||
disabled={(ingredients.length < 1 && draft.trim() === '') || findIdeas.isPending}
|
||||
onClick={find}
|
||||
>
|
||||
Find ideas
|
||||
</Button>
|
||||
</div>
|
||||
{results}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/* Quick dinners, handoff mock 1j. */
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* The 36 px grape circle holding the zap. */
|
||||
.zap {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.zapIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.helper {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
border-radius: 24px;
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.photo {
|
||||
flex: none;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.failure {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
/* The teal "+": a 36 px circle in the IconButton's 44 px hit box (scoped
|
||||
to the rows to outrank the IconButton's 40 px face). */
|
||||
.rows .add > span {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
/* An idea added: a check in the "+"'s place, of the same size. */
|
||||
.added {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 4px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent-2-100);
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
/* "↻ Show me more": a grape-700 text button, 44 px tall. */
|
||||
.more {
|
||||
align-self: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--color-accent-700);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.more:focus-visible {
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.end {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.skeletonRow {
|
||||
height: 84px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { dinnerBody } from '../test/dinners.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { QuickDinnersPage } from './QuickDinnersPage.tsx'
|
||||
import styles from './QuickDinnersPage.module.css'
|
||||
|
||||
const QUICK = 'POST /api/v1/ideas/quick'
|
||||
|
||||
// idea is a grounded Quick dinner idea as the job's result carries it.
|
||||
function idea(name: string, method: string, minutes: number | null, difficulty: string) {
|
||||
return {
|
||||
name,
|
||||
method,
|
||||
minutes_total: minutes,
|
||||
difficulty,
|
||||
quick: true,
|
||||
recipe_id: `r-${name}`,
|
||||
photo_url: `/media/photos/p-${name.length}-400.jpg`,
|
||||
source_url: 'https://recipes.example/x',
|
||||
site_name: 'Recipes',
|
||||
}
|
||||
}
|
||||
|
||||
// The ideas of mock 1j, and more for later pages and the Crock pot tab.
|
||||
const ORZO = idea('Lemon garlic shrimp orzo', 'stovetop', 25, 'medium')
|
||||
const QUESADILLAS = idea('Chicken quesadillas', 'stovetop', 20, 'easy')
|
||||
const BROCCOLI = idea('Beef & broccoli', 'stovetop', 30, 'easy')
|
||||
const GNOCCHI = idea('Pesto gnocchi & peas', 'stovetop', 15, 'easy')
|
||||
const CHILI = idea('Crock pot chili', 'crockpot', 360, 'easy')
|
||||
const CARNITAS = idea('Pork carnitas', 'crockpot', null, 'medium')
|
||||
|
||||
type JobBody = { status: string; result: unknown; error: string | null }
|
||||
|
||||
// state is a quick_ideas job's state at stage with ideas.
|
||||
function state(status: string, stage: string, kind: string, ideas: unknown[], error: string | null = null): JobBody {
|
||||
return { status, result: { stage, kind, exclude: [], ideas, dropped: 0 }, error }
|
||||
}
|
||||
|
||||
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
|
||||
|
||||
// stubServer answers "METHOD path" requests from responders, with /me; any
|
||||
// other request fails the test. jobs holds each job's state, which every
|
||||
// poll of it answers, so a test moves a job on by changing it.
|
||||
function stubServer(responders: Record<string, Responder>) {
|
||||
const jobs: Record<string, JobBody> = {}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
if (key === 'GET /api/v1/me') return Promise.resolve(jsonResponse(200, ME))
|
||||
const job = /^GET \/api\/v1\/jobs\/([^/]+)$/.exec(key)
|
||||
if (job !== null && !Object.hasOwn(responders, key)) {
|
||||
const id = job[1]!
|
||||
const body = jobs[id]
|
||||
if (body === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(jsonResponse(200, { id, kind: 'quick_ideas', ...body }))
|
||||
}
|
||||
const respond = Object.hasOwn(responders, key) ? responders[key] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(respond(init))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return { fetchMock, jobs }
|
||||
}
|
||||
|
||||
// quickServer answers each POST /ideas/quick with the next of jobIds.
|
||||
function quickServer(jobIds: string[], responders: Record<string, Responder> = {}) {
|
||||
let n = 0
|
||||
return stubServer({
|
||||
[QUICK]: () => {
|
||||
const jobId = jobIds[n++]
|
||||
if (jobId === undefined) throw new Error('unexpected quick search')
|
||||
return jsonResponse(202, { jobId })
|
||||
},
|
||||
...responders,
|
||||
})
|
||||
}
|
||||
|
||||
// calls are the requests sent as "METHOD path", in order.
|
||||
function calls(fetchMock: ReturnType<typeof stubServer>['fetchMock'], key: string) {
|
||||
return fetchMock.mock.calls.filter(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}` === key)
|
||||
}
|
||||
|
||||
// searches are the POST /ideas/quick bodies sent, in order.
|
||||
function searches(fetchMock: ReturnType<typeof stubServer>['fetchMock']) {
|
||||
return calls(fetchMock, QUICK).map((call) => JSON.parse(String(call[1]?.body)) as unknown)
|
||||
}
|
||||
|
||||
async function renderPage(queryClient: QueryClient = createQueryClient()) {
|
||||
const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/quick']}>
|
||||
<Routes>
|
||||
<Route path="/quick" element={<QuickDinnersPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function tab(name: string) {
|
||||
return within(screen.getByRole('radiogroup', { name: 'Kind of Quick dinner' })).getByRole('radio', { name })
|
||||
}
|
||||
|
||||
function row(name: string) {
|
||||
return screen.getByRole('listitem', { name })
|
||||
}
|
||||
|
||||
function rowNames() {
|
||||
return within(screen.getByRole('list', { name: 'Quick dinners' }))
|
||||
.queryAllByRole('heading', { level: 2 })
|
||||
.map((heading) => heading.textContent)
|
||||
}
|
||||
|
||||
function addButton(name: string) {
|
||||
return within(row(name)).getByRole('button', { name: `Add ${name} to the Quest log` })
|
||||
}
|
||||
|
||||
// showMore waits for Show me more, shown once a page is kept.
|
||||
function showMore() {
|
||||
return screen.findByRole('button', { name: 'Show me more' })
|
||||
}
|
||||
|
||||
const SLOW = { timeout: 3000 }
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('QuickDinnersPage', () => {
|
||||
it('renders mock 1j: zap header, tabs with 30 min or less chosen, its helper, and its first page', async () => {
|
||||
const { fetchMock, jobs } = quickServer(['j1'])
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS, BROCCOLI, GNOCCHI])
|
||||
const { container } = await renderPage()
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
const zap = container.querySelector(`.${styles.zap}`)!
|
||||
expect(declaredStyle(zap).width).toBe('36px')
|
||||
expect(declaredStyle(zap).background).toBe('var(--color-accent)')
|
||||
expect(zap.querySelector('svg')).toHaveAttribute('stroke-width', '2.75')
|
||||
expect(tab('30 min or less')).toBeChecked()
|
||||
expect(tab('Crock pot')).not.toBeChecked()
|
||||
expect(screen.getByText('Prep to table in 30 minutes or less, picked from your tastes.')).toBeInTheDocument()
|
||||
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name, GNOCCHI.name])
|
||||
expect(searches(fetchMock)).toEqual([{ kind: 'under30', exclude: [] }])
|
||||
|
||||
const orzo = within(row(ORZO.name))
|
||||
expect(orzo.getByText('25 min · Medium')).toHaveClass(styles.meta!)
|
||||
expect(orzo.getByRole('img', { name: ORZO.name })).toHaveAttribute('src', '/media/photos/p-24-400.jpg')
|
||||
expect(declaredStyle(row(ORZO.name)).background).toBe('var(--color-neutral-100)')
|
||||
expect(declaredStyle(row(ORZO.name)).borderRadius).toBe('24px')
|
||||
expectMinHitArea(addButton(ORZO.name))
|
||||
expect(addButton(ORZO.name)).toBeEnabled()
|
||||
expectMinHitArea(await showMore())
|
||||
expect(declaredStyle(await showMore()).color).toBe('var(--color-accent-700)')
|
||||
})
|
||||
|
||||
it('fills the rows progressively as the server grounds ideas, adding only once the page is found', async () => {
|
||||
const { jobs } = quickServer(['j1'])
|
||||
jobs.j1 = state('running', 'suggesting', 'under30', [])
|
||||
await renderPage()
|
||||
|
||||
await waitFor(() => expect(screen.getAllByTestId('skeleton-row')).toHaveLength(6))
|
||||
expect(screen.getByRole('list', { name: 'Quick dinners' })).toHaveAttribute('aria-busy', 'true')
|
||||
|
||||
jobs.j1 = state('running', 'grounding', 'under30', [ORZO])
|
||||
await screen.findByRole('listitem', { name: ORZO.name }, SLOW)
|
||||
expect(screen.getAllByTestId('skeleton-row')).toHaveLength(5)
|
||||
expect(addButton(ORZO.name)).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
|
||||
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
|
||||
await screen.findByRole('listitem', { name: QUESADILLAS.name }, SLOW)
|
||||
expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0)
|
||||
expect(addButton(ORZO.name)).toBeEnabled()
|
||||
expect(await showMore()).toBeInTheDocument()
|
||||
expect(screen.getByRole('list', { name: 'Quick dinners' })).toHaveAttribute('aria-busy', 'false')
|
||||
})
|
||||
|
||||
describe('tabs', () => {
|
||||
it('searches each tab when first shown, keeps what each found, and says what each finds', async () => {
|
||||
const { fetchMock, jobs } = quickServer(['j1', 'j2'])
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI, CARNITAS])
|
||||
await renderPage()
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
|
||||
fireEvent.click(tab('Crock pot'))
|
||||
expect(tab('Crock pot')).toBeChecked()
|
||||
expect(screen.getByText(/in the crock pot.*picked from your tastes\./)).toBeInTheDocument()
|
||||
await screen.findByRole('listitem', { name: CHILI.name })
|
||||
expect(rowNames()).toEqual([CHILI.name, CARNITAS.name])
|
||||
expect(searches(fetchMock)).toEqual([
|
||||
{ kind: 'under30', exclude: [] },
|
||||
{ kind: 'crockpot', exclude: [] },
|
||||
])
|
||||
|
||||
// Each tab's ideas are kept: going back asks for nothing.
|
||||
fireEvent.click(tab('30 min or less'))
|
||||
expect(rowNames()).toEqual([ORZO.name])
|
||||
fireEvent.click(tab('Crock pot'))
|
||||
expect(rowNames()).toEqual([CHILI.name, CARNITAS.name])
|
||||
expect(calls(fetchMock, QUICK)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows a crock-pot idea as "Crock pot · 6 hr · Easy", without a time when the recipe has none', async () => {
|
||||
const { jobs } = quickServer(['j1', 'j2'])
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI, CARNITAS])
|
||||
await renderPage()
|
||||
fireEvent.click(tab('Crock pot'))
|
||||
await screen.findByRole('listitem', { name: CHILI.name })
|
||||
|
||||
expect(within(row(CHILI.name)).getByText('Crock pot · 6 hr · Easy')).toBeInTheDocument()
|
||||
expect(within(row(CARNITAS.name)).getByText('Crock pot · Medium')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps a tab for the session, also when the screen is left and opened again', async () => {
|
||||
const { fetchMock, jobs } = quickServer(['j1'])
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
const queryClient = createQueryClient()
|
||||
const { unmount } = await renderPage(queryClient)
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
unmount()
|
||||
|
||||
await renderPage(queryClient)
|
||||
expect(rowNames()).toEqual([ORZO.name])
|
||||
expect(calls(fetchMock, QUICK)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('refuses a result that is not of the tab asked for', async () => {
|
||||
const { jobs } = quickServer(['j1'])
|
||||
jobs.j1 = state('succeeded', 'done', 'crockpot', [CHILI])
|
||||
await renderPage()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
"Couldn't read the Quick dinners. GET /api/v1/jobs/j1: result is not Quick dinner ideas of kind under30",
|
||||
)
|
||||
expect(screen.queryByRole('listitem', { name: CHILI.name })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add to Quest log', () => {
|
||||
it('adds the idea with its "+", which becomes a check, kept across tabs', async () => {
|
||||
const added = Promise.withResolvers<Response>()
|
||||
const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'POST /api/v1/ideas/j1/items/1/quest-log': () => added.promise })
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
|
||||
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
|
||||
await renderPage()
|
||||
await screen.findByRole('listitem', { name: QUESADILLAS.name })
|
||||
|
||||
fireEvent.click(addButton(QUESADILLAS.name))
|
||||
await waitFor(() => expect(addButton(QUESADILLAS.name)).toBeDisabled())
|
||||
added.resolve(jsonResponse(201, dinnerBody('dn-1', QUESADILLAS.name, { status: 'quest_log', origin: 'quick' })))
|
||||
const quesadillas = within(row(QUESADILLAS.name))
|
||||
expect(await quesadillas.findByRole('status')).toContainElement(quesadillas.getByRole('img', { name: 'In the Quest log' }))
|
||||
expect(quesadillas.queryByRole('button')).toBeNull()
|
||||
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/1/quest-log')).toHaveLength(1)
|
||||
// The other row is untouched.
|
||||
expect(addButton(ORZO.name)).toBeEnabled()
|
||||
|
||||
fireEvent.click(tab('Crock pot'))
|
||||
await screen.findByRole('listitem', { name: CHILI.name })
|
||||
fireEvent.click(tab('30 min or less'))
|
||||
expect(within(row(QUESADILLAS.name)).getByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('labels the check Already a Regular when the idea came back as an existing Regular', async () => {
|
||||
const { fetchMock, jobs } = quickServer(['j1', 'j2'], {
|
||||
'POST /api/v1/ideas/j1/items/0/quest-log': () => jsonResponse(200, dinnerBody('dn-reg', ORZO.name, { status: 'regular' })),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
|
||||
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
|
||||
await renderPage()
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
|
||||
fireEvent.click(addButton(ORZO.name))
|
||||
const orzo = within(row(ORZO.name))
|
||||
expect(await orzo.findByRole('status')).toContainElement(orzo.getByRole('img', { name: 'Already a Regular' }))
|
||||
expect(orzo.queryByRole('img', { name: 'In the Quest log' })).toBeNull()
|
||||
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/quest-log')).toHaveLength(1)
|
||||
|
||||
// Kept across tabs as what it is.
|
||||
fireEvent.click(tab('Crock pot'))
|
||||
await screen.findByRole('listitem', { name: CHILI.name })
|
||||
fireEvent.click(tab('30 min or less'))
|
||||
expect(within(row(ORZO.name)).getByRole('img', { name: 'Already a Regular' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks an add that settles while another tab is shown', async () => {
|
||||
const added = Promise.withResolvers<Response>()
|
||||
const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'POST /api/v1/ideas/j1/items/0/quest-log': () => added.promise })
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
|
||||
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
|
||||
const queryClient = createQueryClient()
|
||||
await renderPage(queryClient)
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
|
||||
fireEvent.click(addButton(ORZO.name))
|
||||
await waitFor(() => expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/quest-log')).toHaveLength(1))
|
||||
fireEvent.click(tab('Crock pot'))
|
||||
await screen.findByRole('listitem', { name: CHILI.name })
|
||||
added.resolve(jsonResponse(201, dinnerBody('dn-1', ORZO.name, { status: 'quest_log', origin: 'quick' })))
|
||||
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
|
||||
|
||||
fireEvent.click(tab('30 min or less'))
|
||||
expect(within(row(ORZO.name)).getByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
|
||||
expect(addButton(QUESADILLAS.name)).toBeEnabled()
|
||||
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/quest-log')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows its failure in its row and lets it be pressed again', async () => {
|
||||
let fail = true
|
||||
const { jobs } = quickServer(['j1'], {
|
||||
'POST /api/v1/ideas/j1/items/0/quest-log': () =>
|
||||
fail ? errorResponse(500, 'internal') : jsonResponse(200, dinnerBody('dn-1', ORZO.name, { status: 'quest_log' })),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
await renderPage()
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
const orzo = within(row(ORZO.name))
|
||||
|
||||
fireEvent.click(addButton(ORZO.name))
|
||||
expect(await orzo.findByRole('alert')).toHaveTextContent("Couldn't add it to the Quest log. internal message")
|
||||
|
||||
fail = false
|
||||
fireEvent.click(addButton(ORZO.name))
|
||||
expect(await orzo.findByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
|
||||
expect(orzo.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Show me more', () => {
|
||||
it('asks for the same kind excluding every name shown, and adds from the page that found it', async () => {
|
||||
const { fetchMock, jobs } = quickServer(['j1', 'j2', 'j3'], {
|
||||
'POST /api/v1/ideas/j2/items/0/quest-log': () => jsonResponse(201, dinnerBody('dn-1', BROCCOLI.name, { status: 'quest_log' })),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
|
||||
jobs.j2 = state('running', 'grounding', 'under30', [BROCCOLI])
|
||||
jobs.j3 = state('succeeded', 'done', 'under30', [GNOCCHI])
|
||||
await renderPage()
|
||||
await screen.findByRole('listitem', { name: QUESADILLAS.name })
|
||||
|
||||
fireEvent.click(await showMore())
|
||||
await screen.findByRole('listitem', { name: BROCCOLI.name }, SLOW)
|
||||
// The first page stays while the next fills.
|
||||
expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name])
|
||||
expect(screen.getAllByTestId('skeleton-row')).toHaveLength(5)
|
||||
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
|
||||
|
||||
jobs.j2 = state('succeeded', 'done', 'under30', [BROCCOLI])
|
||||
await screen.findByRole('button', { name: 'Show me more' }, SLOW)
|
||||
fireEvent.click(addButton(BROCCOLI.name))
|
||||
expect(await within(row(BROCCOLI.name)).findByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(await showMore())
|
||||
await screen.findByRole('listitem', { name: GNOCCHI.name })
|
||||
expect(searches(fetchMock)).toEqual([
|
||||
{ kind: 'under30', exclude: [] },
|
||||
{ kind: 'under30', exclude: [ORZO.name, QUESADILLAS.name] },
|
||||
{ kind: 'under30', exclude: [ORZO.name, QUESADILLAS.name, BROCCOLI.name] },
|
||||
])
|
||||
expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name, GNOCCHI.name])
|
||||
})
|
||||
|
||||
it('says every idea has been shown when a later page finds none', async () => {
|
||||
const { fetchMock, jobs } = quickServer(['j1', 'j2'])
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
jobs.j2 = state('failed', 'grounding', 'under30', [], 'no_ideas_found')
|
||||
await renderPage()
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
|
||||
fireEvent.click(await showMore())
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("That's every Quick dinner for now.")
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
|
||||
expect(rowNames()).toEqual([ORZO.name])
|
||||
expect(calls(fetchMock, QUICK)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows a later page that fails with the ErrorBanner, keeping the rows, and Retry asks again', async () => {
|
||||
const { fetchMock, jobs } = quickServer(['j1', 'j2', 'j3'])
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
jobs.j2 = state('failed', 'grounding', 'under30', [], 'search_failed')
|
||||
jobs.j3 = state('succeeded', 'done', 'under30', [QUESADILLAS])
|
||||
await renderPage()
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
|
||||
fireEvent.click(await showMore())
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't find Quick dinners. The recipe search couldn't be reached.")
|
||||
expect(rowNames()).toEqual([ORZO.name])
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('listitem', { name: QUESADILLAS.name })
|
||||
expect(searches(fetchMock)[2]).toEqual({ kind: 'under30', exclude: [ORZO.name] })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('errors', () => {
|
||||
it('shows the ErrorBanner when the search cannot start, and Retry asks again', async () => {
|
||||
let n = 0
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[QUICK]: () => (++n === 1 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: 'j1' })),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
await renderPage()
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message")
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
expect(searches(fetchMock)).toEqual([
|
||||
{ kind: 'under30', exclude: [] },
|
||||
{ kind: 'under30', exclude: [] },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a first page that cannot start across a tab switch, asking again only on Retry', async () => {
|
||||
let under30 = 0
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[QUICK]: (init) => {
|
||||
const { kind } = JSON.parse(String(init?.body)) as { kind: string }
|
||||
if (kind === 'crockpot') return jsonResponse(202, { jobId: 'j2' })
|
||||
return ++under30 === 1 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: 'j1' })
|
||||
},
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
|
||||
await renderPage()
|
||||
await screen.findByRole('alert')
|
||||
|
||||
fireEvent.click(tab('Crock pot'))
|
||||
await screen.findByRole('listitem', { name: CHILI.name })
|
||||
fireEvent.click(tab('30 min or less'))
|
||||
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message")
|
||||
expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0)
|
||||
expect(searches(fetchMock)).toEqual([
|
||||
{ kind: 'under30', exclude: [] },
|
||||
{ kind: 'crockpot', exclude: [] },
|
||||
])
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
expect(calls(fetchMock, QUICK)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('keeps a later page that cannot start across a tab switch, asking again only on Retry', async () => {
|
||||
let under30 = 0
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[QUICK]: (init) => {
|
||||
const { kind } = JSON.parse(String(init?.body)) as { kind: string }
|
||||
if (kind === 'crockpot') return jsonResponse(202, { jobId: 'j2' })
|
||||
under30++
|
||||
return under30 === 2 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: under30 === 1 ? 'j1' : 'j3' })
|
||||
},
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
|
||||
jobs.j3 = state('succeeded', 'done', 'under30', [QUESADILLAS])
|
||||
await renderPage()
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
fireEvent.click(await showMore())
|
||||
await screen.findByRole('alert')
|
||||
|
||||
fireEvent.click(tab('Crock pot'))
|
||||
await screen.findByRole('listitem', { name: CHILI.name })
|
||||
fireEvent.click(tab('30 min or less'))
|
||||
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message")
|
||||
expect(rowNames()).toEqual([ORZO.name])
|
||||
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
|
||||
expect(calls(fetchMock, QUICK)).toHaveLength(3)
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('listitem', { name: QUESADILLAS.name })
|
||||
expect(searches(fetchMock)[3]).toEqual({ kind: 'under30', exclude: [ORZO.name] })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['ai_failed', "The AI assistant couldn't come up with ideas."],
|
||||
['no_ideas_found', 'No ideas came back with a recipe to match.'],
|
||||
['search_not_configured', 'Recipe search is not set up.'],
|
||||
['internal_error', 'Something went wrong on the server.'],
|
||||
])('shows a first page failed with %s with the ErrorBanner and no rows, and Retry searches again', async (code, message) => {
|
||||
const { fetchMock, jobs } = quickServer(['j1', 'j2'])
|
||||
jobs.j1 = state('failed', 'grounding', 'under30', [], code)
|
||||
jobs.j2 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
await renderPage()
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent(`Couldn't find Quick dinners. ${message}`)
|
||||
expect(screen.queryAllByRole('listitem', { name: /./ })).toHaveLength(0)
|
||||
expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0)
|
||||
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
expect(calls(fetchMock, QUICK)).toHaveLength(2)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows an unknown job with the ErrorBanner, and Retry searches again', async () => {
|
||||
const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'GET /api/v1/jobs/j1': () => errorResponse(404, 'not_found') })
|
||||
jobs.j2 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
await renderPage()
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't check on the Quick dinners. not_found message")
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
expect(calls(fetchMock, QUICK)).toHaveLength(2)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,266 @@
|
||||
import { Check, Plus, RefreshCw, Zap } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useIsMutating } from '@tanstack/react-query'
|
||||
import { useJob } from '../api/index.ts'
|
||||
import { DIFFICULTY_LABEL } from '../discover/DeckCard.tsx'
|
||||
import { duration } from '../plan/PlanPage.tsx'
|
||||
import { methodLabels } from '../settings/RegularsSettingsPage.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 } from '../ui/Segmented.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import {
|
||||
ADDED_LABEL,
|
||||
ideasJobFailure,
|
||||
ideasJobLost,
|
||||
MAX_EXCLUDE,
|
||||
parseQuickResult,
|
||||
QUICK_IDEAS_KEPT,
|
||||
QUICK_JOB_KIND,
|
||||
quickTabKey,
|
||||
shownNames,
|
||||
useAddIdeaToQuestLog,
|
||||
useFindQuickIdeas,
|
||||
useQuickTab,
|
||||
type AddedDinner,
|
||||
type AddedStatus,
|
||||
type IdeaRef,
|
||||
type QuickIdea,
|
||||
type QuickKind,
|
||||
} from './api.ts'
|
||||
import styles from './QuickDinnersPage.module.css'
|
||||
|
||||
const TABS = [
|
||||
{ value: 'crockpot', label: 'Crock pot' },
|
||||
{ value: 'under30', label: '30 min or less' },
|
||||
] as const satisfies readonly { value: QuickKind; label: string }[]
|
||||
|
||||
// helpers says what each tab finds.
|
||||
const helpers: Record<QuickKind, string> = {
|
||||
crockpot: 'Set it going in the crock pot and come back to dinner, picked from your tastes.',
|
||||
under30: 'Prep to table in 30 minutes or less, picked from your tastes.',
|
||||
}
|
||||
|
||||
// The server's code for a search that found nothing: on a later page, it
|
||||
// means the tab has shown every idea there is.
|
||||
const NO_IDEAS_FOUND = 'no_ideas_found'
|
||||
|
||||
// meta is a row's "25 min · Medium" line: "Crock pot" first for a crock-pot
|
||||
// idea, and the time only when the recipe says it.
|
||||
function meta(idea: QuickIdea): string {
|
||||
return [
|
||||
idea.method === 'crockpot' && methodLabels.crockpot,
|
||||
idea.minutes_total !== null && duration(idea.minutes_total),
|
||||
DIFFICULTY_LABEL[idea.difficulty],
|
||||
]
|
||||
.filter((part): part is string => typeof part === 'string')
|
||||
.join(' · ')
|
||||
}
|
||||
|
||||
// QuickRow is one idea (mock 1j): its washed photo, name and meta line, and
|
||||
// the teal "+" that adds it to the Quest log, then a check labelled with
|
||||
// where it went: added is the status of the dinner it came back as.
|
||||
// ideaRef is null while its job runs: the server takes an idea only once
|
||||
// the job has succeeded. onAdded marks the idea added, also if the row has
|
||||
// unmounted before the add settles.
|
||||
function QuickRow({
|
||||
idea,
|
||||
ideaRef,
|
||||
added,
|
||||
onAdded,
|
||||
}: {
|
||||
idea: QuickIdea
|
||||
ideaRef: IdeaRef | null
|
||||
added: AddedStatus | undefined
|
||||
onAdded: (idea: IdeaRef, dinner: AddedDinner) => void
|
||||
}) {
|
||||
const questLog = useAddIdeaToQuestLog(onAdded)
|
||||
return (
|
||||
<li className={styles.row} aria-label={idea.name}>
|
||||
<Photo id={idea.photo_id} name={idea.name} radius="50%" sizes="64px" className={styles.photo} />
|
||||
<div className={styles.text}>
|
||||
<h2 className={styles.name}>{idea.name}</h2>
|
||||
<span className={styles.meta}>{meta(idea)}</span>
|
||||
{questLog.isError && (
|
||||
<p className={styles.failure} role="alert">
|
||||
Couldn't add it to the Quest log. {questLog.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{added !== undefined ? (
|
||||
<span className={styles.added} role="status">
|
||||
<Icon icon={Check} size="nav" label={ADDED_LABEL[added]} />
|
||||
</span>
|
||||
) : (
|
||||
<IconButton
|
||||
label={`Add ${idea.name} to the Quest log`}
|
||||
tone="teal"
|
||||
className={styles.add}
|
||||
disabled={ideaRef === null || questLog.isPending}
|
||||
onClick={() => questLog.mutate(ideaRef!)}
|
||||
>
|
||||
<Icon icon={Plus} size="nav" />
|
||||
</IconButton>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeletons stand in for the count ideas still to come.
|
||||
function Skeletons({ count }: { count: number }) {
|
||||
return Array.from({ length: count }, (_, i) => (
|
||||
<li key={i} aria-hidden="true">
|
||||
<Skeleton data-testid="skeleton-row" className={styles.skeletonRow} />
|
||||
</li>
|
||||
))
|
||||
}
|
||||
|
||||
// QuickTabPanel is one tab's ideas: its pages so far, the page being
|
||||
// found, filling as the server grounds it, and Show me more, which asks for
|
||||
// a page excluding every name shown. The tab's first page is asked for when
|
||||
// the tab is first shown; what it finds is kept for the session.
|
||||
function QuickTabPanel({ kind }: { kind: QuickKind }) {
|
||||
const [tab, update] = useQuickTab(kind)
|
||||
const { mutate } = useFindQuickIdeas(kind)
|
||||
// A page being asked for, also by this tab before it was last shown.
|
||||
const starting = useIsMutating({ mutationKey: quickTabKey(kind) }) > 0
|
||||
const job = useJob<unknown>(QUICK_JOB_KIND, tab.jobId)
|
||||
const names = shownNames(tab)
|
||||
|
||||
// ask asks for the next page, excluding every name shown; Retry asks
|
||||
// again for the page that failed. A page that could not start stays the
|
||||
// tab's startError until Retry: nothing asks again by itself.
|
||||
const ask = () => mutate(names)
|
||||
|
||||
// The first page, asked for once.
|
||||
const asked = useRef(false)
|
||||
const fresh = tab.pages.length === 0 && tab.jobId === null && !tab.ended && tab.startError === null && !starting
|
||||
useEffect(() => {
|
||||
if (!fresh || asked.current) return
|
||||
asked.current = true
|
||||
mutate([])
|
||||
}, [fresh, mutate])
|
||||
|
||||
// The job's result as far as it has gone, or why it cannot be read.
|
||||
const found = useMemo(() => {
|
||||
if (job.result === null || tab.jobId === null) return { result: null, error: null }
|
||||
try {
|
||||
return { result: parseQuickResult(`GET /api/v1/jobs/${tab.jobId}`, kind, job.result), error: null }
|
||||
} catch (error) {
|
||||
return { result: null, error: error as Error }
|
||||
}
|
||||
}, [job.result, tab.jobId, kind])
|
||||
|
||||
const failure = job.status === 'failed' ? job.error!.message : null
|
||||
// A later page that finds nothing means there is nothing more to show.
|
||||
const exhausted = failure === NO_IDEAS_FOUND && tab.pages.length > 0
|
||||
|
||||
// A page found is kept with the tab's pages; a page that ends the tab
|
||||
// ends it.
|
||||
const jobId = tab.jobId
|
||||
const ideas = job.status === 'succeeded' ? (found.result?.ideas ?? null) : null
|
||||
useEffect(() => {
|
||||
if (jobId === null) return
|
||||
if (ideas !== null) update((t) => (t.jobId === jobId ? { ...t, pages: [...t.pages, { jobId, ideas }], jobId: null } : t))
|
||||
else if (exhausted) update((t) => (t.jobId === jobId ? { ...t, jobId: null, ended: true } : t))
|
||||
}, [jobId, ideas, exhausted, update])
|
||||
|
||||
const markAdded = ({ jobId, index }: IdeaRef, { status }: AddedDinner) =>
|
||||
update((t) => ({ ...t, added: { ...t.added, [`${jobId}:${index}`]: status } }))
|
||||
|
||||
// The rows of every page found, then of the page being found, in one
|
||||
// keyed list: a page kept with the tab's pages keeps its rows, and with
|
||||
// them any add still under way.
|
||||
const rows: { idea: QuickIdea; ref: IdeaRef | null; key: string }[] = tab.pages.flatMap((page) =>
|
||||
page.ideas.map((idea, index) => ({ idea, ref: { jobId: page.jobId, index }, key: `${page.jobId}:${index}` })),
|
||||
)
|
||||
|
||||
let next = null
|
||||
if (starting) {
|
||||
next = <Skeletons count={QUICK_IDEAS_KEPT} />
|
||||
} else if (tab.startError !== null) {
|
||||
next = (
|
||||
<li>
|
||||
<ErrorBanner onRetry={ask}>Couldn't start looking for Quick dinners. {tab.startError.message}</ErrorBanner>
|
||||
</li>
|
||||
)
|
||||
} else if (jobId !== null && failure !== null && !exhausted) {
|
||||
next = (
|
||||
<li>
|
||||
<ErrorBanner onRetry={ask}>Couldn't find Quick dinners. {ideasJobFailure(failure)}</ErrorBanner>
|
||||
</li>
|
||||
)
|
||||
} else if (found.error !== null) {
|
||||
next = (
|
||||
<li>
|
||||
<ErrorBanner onRetry={ask}>Couldn't read the Quick dinners. {found.error.message}</ErrorBanner>
|
||||
</li>
|
||||
)
|
||||
} else if (jobId !== null && !exhausted) {
|
||||
const shown = found.result?.ideas ?? []
|
||||
const done = job.status === 'succeeded'
|
||||
shown.forEach((idea, index) => rows.push({ idea, ref: done ? { jobId, index } : null, key: `${jobId}:${index}` }))
|
||||
// A job not found will not be found by asking again: Retry asks for the
|
||||
// page again. Any other failed poll is asked again.
|
||||
const lost = ideasJobLost(job.error)
|
||||
next = (
|
||||
<>
|
||||
{!done && <Skeletons count={Math.max(QUICK_IDEAS_KEPT - shown.length, 0)} />}
|
||||
{job.error !== null && (
|
||||
<li>
|
||||
<ErrorBanner onRetry={lost ? ask : job.poll}>Couldn't check on the Quick dinners. {job.error.message}</ErrorBanner>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Show me more once the tab is between pages; the server takes at most
|
||||
// MAX_EXCLUDE names to leave out, so past that there is no more to ask.
|
||||
const between = !starting && tab.startError === null && jobId === null && tab.pages.length > 0
|
||||
const allShown = tab.ended || names.length > MAX_EXCLUDE
|
||||
const busy = starting || (jobId !== null && failure === null && found.error === null && job.status !== 'succeeded')
|
||||
|
||||
return (
|
||||
<section aria-label={TABS.find((t) => t.value === kind)!.label} className={styles.panel}>
|
||||
<p className={styles.helper}>{helpers[kind]}</p>
|
||||
<ul aria-busy={busy} aria-label="Quick dinners" className={styles.rows}>
|
||||
{rows.map(({ idea, ref, key }) => (
|
||||
<QuickRow key={key} idea={idea} ideaRef={ref} added={tab.added[key]} onAdded={markAdded} />
|
||||
))}
|
||||
{next}
|
||||
</ul>
|
||||
{between &&
|
||||
(allShown ? (
|
||||
<p className={styles.end} role="status">
|
||||
That's every Quick dinner for now.
|
||||
</p>
|
||||
) : (
|
||||
<button type="button" className={styles.more} onClick={ask}>
|
||||
<Icon icon={RefreshCw} /> Show me more
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// QuickDinnersPage is mock 1j, Quick dinners: a Crock pot | 30 min or less
|
||||
// tab for each kind of Quick dinner, each a quick_ideas search of its own
|
||||
// whose ideas fill as the server grounds them, kept for the session.
|
||||
export function QuickDinnersPage() {
|
||||
const [kind, setKind] = useState<QuickKind>('under30')
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<span className={styles.zap}>
|
||||
<Icon icon={Zap} className={styles.zapIcon} />
|
||||
</span>
|
||||
<h1 className={styles.title}>Quick dinners</h1>
|
||||
</header>
|
||||
<Segmented label="Kind of Quick dinner" options={TABS} value={kind} onValueChange={setKind} className={styles.tabs} />
|
||||
<QuickTabPanel key={kind} kind={kind} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiError, useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { DIFFICULTIES, METHODS, parseDinner, photoId, type Difficulty, type Method, type Regular } from '../dinners/api.ts'
|
||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
import { parsePlan, planKey } from '../plan/api.ts'
|
||||
|
||||
export const IDEAS_PATH = '/api/v1/ideas'
|
||||
export const FROM_INGREDIENTS_PATH = `${IDEAS_PATH}/from-ingredients`
|
||||
|
||||
// FROM_INGREDIENTS_JOB_KIND is the kind of the job finding ideas from the
|
||||
// ingredients on hand.
|
||||
export const FROM_INGREDIENTS_JOB_KIND = 'ideas_from_ingredients'
|
||||
|
||||
// IDEAS_KEPT is the most ideas a job keeps (skills.IdeasKept), so the most
|
||||
// cards a search fills.
|
||||
export const IDEAS_KEPT = 5
|
||||
|
||||
// MAX_INGREDIENTS is the most ingredients the household may list, and
|
||||
// INGREDIENT_LIMIT the most characters one may have
|
||||
// (skills.MaxIngredients, skills.IngredientLimit).
|
||||
export const MAX_INGREDIENTS = 20
|
||||
export const INGREDIENT_LIMIT = 80
|
||||
|
||||
// IDEA_STAGES are a job's stages: asking the AI for ideas, grounding them
|
||||
// in recipes, and done.
|
||||
export const IDEA_STAGES = ['suggesting', 'grounding', 'done'] as const
|
||||
export type IdeaStage = (typeof IDEA_STAGES)[number]
|
||||
|
||||
// Idea is a grounded idea as a result card shows it. summary is the
|
||||
// server's "Uses all 5 · missing: 1 lime" line, shown as it is;
|
||||
// minutes_total is the grounded recipe's, null when it does not say.
|
||||
export type Idea = {
|
||||
name: string
|
||||
uses: string[]
|
||||
missing: string[]
|
||||
summary: string
|
||||
minutes_total: number | null
|
||||
quick: boolean
|
||||
}
|
||||
|
||||
// IdeasResult is an ideas_from_ingredients job's result, published as it
|
||||
// goes: the ideas grounded so far, in the order the server shows them.
|
||||
export type IdeasResult = {
|
||||
stage: IdeaStage
|
||||
ingredients: string[]
|
||||
quick_only: boolean
|
||||
ideas: Idea[]
|
||||
dropped: number
|
||||
}
|
||||
|
||||
// IDEAS_JOB_FAILURES says why an ideas job failed, by its failure code.
|
||||
// no_ideas_found is no fault to fix, but the search still came back empty.
|
||||
const IDEAS_JOB_FAILURES: Record<string, string> = {
|
||||
no_ideas_found: 'No ideas came back with a recipe to match.',
|
||||
ai_failed: "The AI assistant couldn't come up with ideas.",
|
||||
search_not_configured: 'Recipe search is not set up. An admin can set it up in Settings.',
|
||||
search_failed: "The recipe search couldn't be reached.",
|
||||
internal_error: 'Something went wrong on the server.',
|
||||
}
|
||||
|
||||
// ideasJobFailure says why an ideas job failed; an unknown code is shown
|
||||
// as it is.
|
||||
export function ideasJobFailure(code: string): string {
|
||||
return IDEAS_JOB_FAILURES[code] ?? code
|
||||
}
|
||||
|
||||
// ideasJobLost says a poll of an ideas job failed because the server no
|
||||
// longer has the job (404), so asking for it again will not find it: the
|
||||
// search must be started again.
|
||||
export function ideasJobLost(error: Error | null): boolean {
|
||||
return error instanceof ApiError && error.status === 404
|
||||
}
|
||||
|
||||
// Search is what Find ideas asks for.
|
||||
export type Search = { ingredients: string[]; quickOnly: boolean }
|
||||
|
||||
function isStrings(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((v) => typeof v === 'string')
|
||||
}
|
||||
|
||||
function parseIdea(where: string, body: unknown): Idea {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.name !== 'string' ||
|
||||
!isStrings(body.uses) ||
|
||||
!isStrings(body.missing) ||
|
||||
typeof body.summary !== 'string' ||
|
||||
typeof body.quick !== 'boolean' ||
|
||||
(body.minutes_total !== null && !Number.isInteger(body.minutes_total))
|
||||
) {
|
||||
throw new Error(`${where}: result has an idea that is not a grounded idea`)
|
||||
}
|
||||
return {
|
||||
name: body.name,
|
||||
uses: body.uses,
|
||||
missing: body.missing,
|
||||
summary: body.summary,
|
||||
minutes_total: body.minutes_total as number | null,
|
||||
quick: body.quick,
|
||||
}
|
||||
}
|
||||
|
||||
// parseIdeasResult checks an ideas_from_ingredients job's result, which
|
||||
// useJob passes on unread, answers the search asked for.
|
||||
export function parseIdeasResult(where: string, quickOnly: boolean, body: unknown): IdeasResult {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.stage !== 'string' ||
|
||||
!(IDEA_STAGES as readonly string[]).includes(body.stage) ||
|
||||
!isStrings(body.ingredients) ||
|
||||
body.quick_only !== quickOnly ||
|
||||
!Array.isArray(body.ideas) ||
|
||||
typeof body.dropped !== 'number'
|
||||
) {
|
||||
throw new Error(`${where}: result is not ideas from ingredients`)
|
||||
}
|
||||
return {
|
||||
stage: body.stage as IdeaStage,
|
||||
ingredients: body.ingredients,
|
||||
quick_only: quickOnly,
|
||||
ideas: body.ideas.map((idea) => parseIdea(where, idea)),
|
||||
dropped: body.dropped,
|
||||
}
|
||||
}
|
||||
|
||||
// useFindIdeas starts finding ideas from the ingredients on hand (POST
|
||||
// /api/v1/ideas/from-ingredients) and resolves with the id of the job,
|
||||
// which GET /api/v1/jobs/{id} reports on.
|
||||
export function useFindIdeas() {
|
||||
const { apiFetch } = useApi()
|
||||
return useMutation({
|
||||
mutationFn: async ({ ingredients, quickOnly }: Search): Promise<string> => {
|
||||
const body = await apiFetch<unknown>(FROM_INGREDIENTS_PATH, {
|
||||
method: 'POST',
|
||||
body: { ingredients, quick_only: quickOnly },
|
||||
})
|
||||
if (!isRecord(body) || typeof body.jobId !== 'string') throw new Error(`POST ${FROM_INGREDIENTS_PATH}: response has no jobId`)
|
||||
return body.jobId
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// QUICK_PATH starts finding a page of Quick dinner ideas, and
|
||||
// QUICK_JOB_KIND is the kind of the job that finds it.
|
||||
export const QUICK_PATH = `${IDEAS_PATH}/quick`
|
||||
export const QUICK_JOB_KIND = 'quick_ideas'
|
||||
|
||||
// QUICK_KINDS are the kinds of Quick dinner, the tabs of Quick dinners in
|
||||
// order (skills.QuickKinds): cooked in a crock pot, or 30 minutes or less
|
||||
// from prep to table.
|
||||
export const QUICK_KINDS = ['crockpot', 'under30'] as const
|
||||
export type QuickKind = (typeof QUICK_KINDS)[number]
|
||||
|
||||
// QUICK_IDEAS_KEPT is the most ideas one page of Quick dinners shows
|
||||
// (skills.QuickIdeasKept), and MAX_EXCLUDE the most names a request may
|
||||
// exclude (skills.MaxExclude).
|
||||
export const QUICK_IDEAS_KEPT = 6
|
||||
export const MAX_EXCLUDE = 60
|
||||
|
||||
// QuickIdea is a grounded Quick dinner idea as a row shows it. method,
|
||||
// minutes_total and difficulty are the grounded recipe's; minutes_total is
|
||||
// null when the recipe does not say, which only a crock-pot idea may be.
|
||||
// photo_id is the stored photo's, from photo_url.
|
||||
export type QuickIdea = {
|
||||
name: string
|
||||
method: Method
|
||||
minutes_total: number | null
|
||||
difficulty: Difficulty
|
||||
photo_id: string | undefined
|
||||
}
|
||||
|
||||
// QuickResult is a quick_ideas job's result, published as it goes: the
|
||||
// ideas grounded so far, in the order the server shows them.
|
||||
export type QuickResult = {
|
||||
stage: IdeaStage
|
||||
kind: QuickKind
|
||||
ideas: QuickIdea[]
|
||||
}
|
||||
|
||||
function parseQuickIdea(where: string, body: unknown): QuickIdea {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.name !== 'string' ||
|
||||
!(METHODS as readonly unknown[]).includes(body.method) ||
|
||||
(body.minutes_total !== null && !Number.isInteger(body.minutes_total)) ||
|
||||
!(DIFFICULTIES as readonly unknown[]).includes(body.difficulty)
|
||||
) {
|
||||
throw new Error(`${where}: result has an idea that is not a grounded Quick dinner`)
|
||||
}
|
||||
return {
|
||||
name: body.name,
|
||||
method: body.method as Method,
|
||||
minutes_total: body.minutes_total as number | null,
|
||||
difficulty: body.difficulty as Difficulty,
|
||||
photo_id: photoId(where, body.photo_url),
|
||||
}
|
||||
}
|
||||
|
||||
// parseQuickResult checks a quick_ideas job's result, which useJob passes
|
||||
// on unread, is a page of the kind asked for.
|
||||
export function parseQuickResult(where: string, kind: QuickKind, body: unknown): QuickResult {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.stage !== 'string' ||
|
||||
!(IDEA_STAGES as readonly string[]).includes(body.stage) ||
|
||||
body.kind !== kind ||
|
||||
!isStrings(body.exclude) ||
|
||||
!Array.isArray(body.ideas) ||
|
||||
typeof body.dropped !== 'number'
|
||||
) {
|
||||
throw new Error(`${where}: result is not Quick dinner ideas of kind ${kind}`)
|
||||
}
|
||||
return { stage: body.stage as IdeaStage, kind, ideas: body.ideas.map((idea) => parseQuickIdea(where, idea)) }
|
||||
}
|
||||
|
||||
// QuickPage is a page of Quick dinner ideas its job found.
|
||||
export type QuickPage = { jobId: string; ideas: QuickIdea[] }
|
||||
|
||||
// QuickTab is what one tab of Quick dinners has found this session: the
|
||||
// pages found, the job finding the next one, whether the server has said
|
||||
// there are no more, why the last page asked for could not start, kept
|
||||
// until Retry asks again, and the status of the dinner each idea added
|
||||
// came back as, by "jobId:index".
|
||||
export type QuickTab = {
|
||||
pages: QuickPage[]
|
||||
jobId: string | null
|
||||
ended: boolean
|
||||
startError: Error | null
|
||||
added: Record<string, AddedStatus>
|
||||
}
|
||||
|
||||
const NEW_TAB: QuickTab = { pages: [], jobId: null, ended: false, startError: null, added: {} }
|
||||
|
||||
export function quickTabKey(kind: QuickKind) {
|
||||
return ['ideas', 'quick', kind] as const
|
||||
}
|
||||
|
||||
// shownNames are the names of every idea a tab shows, which its next page
|
||||
// excludes so that nothing repeats.
|
||||
export function shownNames(tab: QuickTab): string[] {
|
||||
return tab.pages.flatMap((page) => page.ideas.map((idea) => idea.name))
|
||||
}
|
||||
|
||||
// useQuickTab is a tab's QuickTab, held in the query cache for the session
|
||||
// (never stale, never collected) so that leaving the tab or the screen
|
||||
// keeps it, and update, which changes it. Nothing is fetched: a refetch
|
||||
// reads back what the tab holds.
|
||||
export function useQuickTab(kind: QuickKind) {
|
||||
const queryClient = useQueryClient()
|
||||
const { data } = useQuery({
|
||||
queryKey: quickTabKey(kind),
|
||||
queryFn: () => queryClient.getQueryData<QuickTab>(quickTabKey(kind)) ?? NEW_TAB,
|
||||
initialData: NEW_TAB,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
})
|
||||
const update = useCallback(
|
||||
(change: (tab: QuickTab) => QuickTab) => queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => change(tab ?? NEW_TAB)),
|
||||
[queryClient, kind],
|
||||
)
|
||||
return [data, update] as const
|
||||
}
|
||||
|
||||
// useFindQuickIdeas starts finding a page of Quick dinner ideas of kind,
|
||||
// none named in exclude (POST /api/v1/ideas/quick), and makes its job the
|
||||
// tab's job, or its failure the tab's startError, also if the screen has
|
||||
// been left meanwhile. Its mutationKey is the tab's, so a tab shown again
|
||||
// knows a page is still being asked for.
|
||||
export function useFindQuickIdeas(kind: QuickKind) {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationKey: quickTabKey(kind),
|
||||
mutationFn: async (exclude: string[]): Promise<string> => {
|
||||
queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), startError: null }))
|
||||
const body = await apiFetch<unknown>(QUICK_PATH, { method: 'POST', body: { kind, exclude } })
|
||||
if (!isRecord(body) || typeof body.jobId !== 'string') throw new Error(`POST ${QUICK_PATH}: response has no jobId`)
|
||||
const jobId = body.jobId
|
||||
queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), jobId }))
|
||||
return jobId
|
||||
},
|
||||
onError: (startError: Error) => queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), startError })),
|
||||
})
|
||||
}
|
||||
|
||||
// IdeaRef is idea index of the succeeded ideas job jobId.
|
||||
export type IdeaRef = { jobId: string; index: number }
|
||||
|
||||
function ideaPath({ jobId, index }: IdeaRef, action: 'quest-log' | 'cook-tonight'): string {
|
||||
return `${IDEAS_PATH}/${encodeURIComponent(jobId)}/items/${index}/${action}`
|
||||
}
|
||||
|
||||
// ADDED_STATUSES are the statuses a dinner an idea is added as may have:
|
||||
// quest_log for a dinner made or already in the Quest log, regular for a
|
||||
// Regular the idea repeats, which the server answers as it is.
|
||||
export const ADDED_STATUSES = ['quest_log', 'regular'] as const
|
||||
export type AddedStatus = (typeof ADDED_STATUSES)[number]
|
||||
|
||||
// ADDED_LABEL says where an idea added went, by its dinner's status.
|
||||
export const ADDED_LABEL: Record<AddedStatus, string> = {
|
||||
quest_log: 'In the Quest log',
|
||||
regular: 'Already a Regular',
|
||||
}
|
||||
|
||||
// AddedDinner is the dinner an idea was added as, with its status.
|
||||
export type AddedDinner = Regular & { status: AddedStatus }
|
||||
|
||||
function parseAddedDinner(where: string, body: unknown): AddedDinner {
|
||||
const dinner = parseDinner(where, body)
|
||||
const status = isRecord(body) ? body.status : undefined
|
||||
if (!(ADDED_STATUSES as readonly unknown[]).includes(status)) {
|
||||
throw new Error(`${where}: status ${JSON.stringify(status)} is not one of ${ADDED_STATUSES.join(', ')}`)
|
||||
}
|
||||
return { ...dinner, status: status as AddedStatus }
|
||||
}
|
||||
|
||||
// useAddIdeaToQuestLog adds an idea to the Quest log (POST
|
||||
// /api/v1/ideas/{jobId}/items/{i}/quest-log) and resolves with the dinner
|
||||
// it came back as: a dinner the idea repeats is answered as it is, so a
|
||||
// Regular stays a Regular, and its status says which. Asking again answers
|
||||
// the same dinner. The Quest log is read again after it, and onAdded told
|
||||
// with the dinner, also if the caller has unmounted meanwhile.
|
||||
export function useAddIdeaToQuestLog(onAdded?: (idea: IdeaRef, dinner: AddedDinner) => void) {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (idea: IdeaRef): Promise<AddedDinner> => {
|
||||
const path = ideaPath(idea, 'quest-log')
|
||||
return parseAddedDinner(`POST ${path}`, await apiFetch<unknown>(path, { method: 'POST' }))
|
||||
},
|
||||
onSuccess: (dinner, idea) => {
|
||||
onAdded?.(idea, dinner)
|
||||
return queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// useCookIdeaTonight puts an idea on today's plan (POST
|
||||
// /api/v1/ideas/{jobId}/items/{i}/cook-tonight) and resolves with the
|
||||
// dinner's name. The plan it answers with is cached as its week's plan,
|
||||
// and the Quest log is read again. With no plan for the current week the
|
||||
// server answers 409 no_plan, the mutation's ApiError, and changes nothing.
|
||||
export function useCookIdeaTonight() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (idea: IdeaRef): Promise<string> => {
|
||||
const path = ideaPath(idea, 'cook-tonight')
|
||||
const where = `POST ${path}`
|
||||
const body = await apiFetch<unknown>(path, { method: 'POST' })
|
||||
if (!isRecord(body) || !isRecord(body.plan) || typeof body.plan.week_start !== 'string') {
|
||||
throw new Error(`${where}: response has no plan`)
|
||||
}
|
||||
const dinner = parseDinner(where, body.dinner)
|
||||
const plan = parsePlan(where, body.plan.week_start, body.plan, false)
|
||||
queryClient.setQueryData(planKey(plan.week_start), plan)
|
||||
return dinner.name
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
|
||||
})
|
||||
}
|
||||
+10
-19
@@ -23,9 +23,8 @@ import {
|
||||
parseDraftResult,
|
||||
planKey,
|
||||
usePlan,
|
||||
usePushToCalendar,
|
||||
useStartDraft,
|
||||
useSwapDay,
|
||||
useWeekSwap,
|
||||
type DraftResult,
|
||||
type Plan,
|
||||
type PlanDay,
|
||||
@@ -266,8 +265,7 @@ function WeekBoard({
|
||||
const [jobId, setJobId] = useState<string | null>(null)
|
||||
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const swap = useSwapDay(weekStart)
|
||||
const push = usePushToCalendar(weekStart)
|
||||
const { swap, push, refused, swapIn: sendSwap } = useWeekSwap(weekStart)
|
||||
const run = useSupplyRun(weekStart)
|
||||
// Makes the week's Supply run again after a push could not make it.
|
||||
const generate = useGenerateSupplyRun(weekStart)
|
||||
@@ -431,25 +429,18 @@ function WeekBoard({
|
||||
<ErrorBanner onRetry={() => void run.refetch()}>Couldn't load this week's shopping list. {run.error.message}</ErrorBanner>
|
||||
)
|
||||
|
||||
// swapIn sends the swap chosen in the sheet and closes it.
|
||||
// swapIn sends the swap chosen in the sheet, dropped on a day or retried
|
||||
// (useWeekSwap) and closes the sheet. The Supply run made again on the
|
||||
// Plan after a push is left behind with the push.
|
||||
function swapIn(chosen: Swap) {
|
||||
setSwappingDate(null)
|
||||
push.reset()
|
||||
generate.reset()
|
||||
swap.mutate(chosen)
|
||||
sendSwap(chosen)
|
||||
}
|
||||
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
|
||||
// nor dropped, while a swap of the week is pending.
|
||||
function dropIn(chosen: Swap) {
|
||||
swap.dismiss()
|
||||
swapIn(chosen)
|
||||
}
|
||||
// openSwap opens the swap sheet on a day. One swap of the week is sent at
|
||||
// a time, even across leaving the week and coming back: the plan the
|
||||
// server answers it with must not overwrite another still pending. Nor is
|
||||
// one sent while the week is being pushed to Calendar.
|
||||
// openSwap opens the swap sheet on a day, unless a swap is refused now
|
||||
// (useWeekSwap). A drag is not picked up, nor dropped, then either.
|
||||
function openSwap(date: string) {
|
||||
if (swap.pending || pushing) return
|
||||
if (refused) return
|
||||
swap.dismiss()
|
||||
setSwappingDate(date)
|
||||
}
|
||||
@@ -488,7 +479,7 @@ function WeekBoard({
|
||||
body =
|
||||
layout !== 'mobile' ? (
|
||||
<>
|
||||
<DropBoard days={days} pending={swap.pending || pushing} drop={dropIn} />
|
||||
<DropBoard days={days} pending={refused} drop={swapIn} />
|
||||
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
|
||||
{days.map((day) => (
|
||||
<DayColumn
|
||||
|
||||
+38
-2
@@ -93,7 +93,9 @@ function syncedAt(where: string, value: unknown): Date | null {
|
||||
return at
|
||||
}
|
||||
|
||||
function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
|
||||
// parsePlan checks a body the plans API sends is the plan of the week
|
||||
// starting on weekStart; cached says the service worker answered it.
|
||||
export function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.id !== 'string' ||
|
||||
@@ -329,9 +331,11 @@ function swapKey(weekStart: string) {
|
||||
}
|
||||
|
||||
// SwapDay is the swaps of one week, as the mutation cache holds them:
|
||||
// whether one is pending, and the failure of the latest, if it failed.
|
||||
// whether one is pending, the latest if it succeeded, and the failure of the
|
||||
// latest, if it failed.
|
||||
export type SwapDay = {
|
||||
pending: boolean
|
||||
succeeded: Swap | null
|
||||
failed: { swap: Swap; error: Error } | null
|
||||
mutate: (chosen: Swap) => void
|
||||
dismiss: () => void
|
||||
@@ -418,6 +422,7 @@ export function useSwapDay(weekStart: string): SwapDay {
|
||||
|
||||
return {
|
||||
pending: swaps.some((swap) => swap.status === 'pending'),
|
||||
succeeded: latest?.status === 'success' ? latest.swap : null,
|
||||
failed: latest?.status === 'error' ? { swap: latest.swap, error: latest.error! } : null,
|
||||
mutate,
|
||||
dismiss,
|
||||
@@ -529,6 +534,37 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
|
||||
}
|
||||
}
|
||||
|
||||
// WeekSwap is the one way a day of a week is swapped, wherever the swap is
|
||||
// chosen: the week's swaps and pushes, whether a swap is refused now, and
|
||||
// swapIn to send one.
|
||||
export type WeekSwap = {
|
||||
swap: SwapDay
|
||||
push: PushToCalendar
|
||||
refused: boolean
|
||||
swapIn: (chosen: Swap) => void
|
||||
}
|
||||
|
||||
// useWeekSwap swaps a day of the week starting on weekStart, from the Draft
|
||||
// board or a dinner's Add to this week. One swap of the week is sent at a
|
||||
// time, even across leaving the week and coming back: the plan the server
|
||||
// answers it with must not overwrite another still pending. Nor is one sent
|
||||
// while the week is being pushed to Calendar: refused says so, and swapIn
|
||||
// then throws. A swap leaves the week's settled swaps behind, and its
|
||||
// pushes: what a push said of Calendar and the Supply run was said of the
|
||||
// plan before the swap, not the one swapped.
|
||||
export function useWeekSwap(weekStart: string): WeekSwap {
|
||||
const swap = useSwapDay(weekStart)
|
||||
const push = usePushToCalendar(weekStart)
|
||||
const refused = swap.pending || push.pending
|
||||
function swapIn(chosen: Swap) {
|
||||
if (refused) throw new Error(`The week of ${weekStart} has a swap or push pending, so ${chosen.date} cannot be swapped.`)
|
||||
swap.dismiss()
|
||||
push.reset()
|
||||
swap.mutate(chosen)
|
||||
}
|
||||
return { swap, push, refused, swapIn }
|
||||
}
|
||||
|
||||
// settleUnmadePushes is called as the Supply run of the week starting on
|
||||
// weekStart starts being made, from the Plan or the Supply run screen
|
||||
// (useGenerateSupplyRun). The function it answers with is called once the
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addDays, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
|
||||
import { addDays, dayAndDate, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
|
||||
|
||||
// SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York.
|
||||
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
|
||||
@@ -36,6 +36,11 @@ describe('week helpers', () => {
|
||||
expect(weekdayName('2026-10-11')).toBe('Sunday')
|
||||
})
|
||||
|
||||
it('names the day of a date in full with its month and day', () => {
|
||||
expect(dayAndDate('2026-10-10')).toBe('Saturday, Oct 10')
|
||||
expect(dayAndDate('2026-11-01')).toBe('Sunday, Nov 1')
|
||||
})
|
||||
|
||||
it('reads the day of the month of a date', () => {
|
||||
expect(dayOfMonth('2026-10-05')).toBe(5)
|
||||
expect(dayOfMonth('2026-10-31')).toBe(31)
|
||||
|
||||
@@ -52,6 +52,13 @@ export function weekdayName(date: string): string {
|
||||
return weekday.format(midnightUTC(date))
|
||||
}
|
||||
|
||||
const longDay = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'short', day: 'numeric', timeZone: 'UTC' })
|
||||
|
||||
// dayAndDate is date's day in full with its month and day: "Saturday, Oct 10".
|
||||
export function dayAndDate(date: string): string {
|
||||
return longDay.format(midnightUTC(date))
|
||||
}
|
||||
|
||||
// monday reads start as midnight UTC, or throws when it is not a Monday.
|
||||
function monday(start: string): Date {
|
||||
const t = midnightUTC(start)
|
||||
|
||||
@@ -1,51 +1,16 @@
|
||||
// Runs the real Workbox network-first strategy against an in-memory cache
|
||||
// storage: jsdom has none, and the worker itself needs service worker globals.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RouteHandlerCallbackOptions } from 'workbox-core/types'
|
||||
import { createApiClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { fakeLocation, jsonResponse } from '../test/fetchStub.ts'
|
||||
import { createApiStrategy } from './apiStrategy'
|
||||
import { cacheFallbackHeader, cacheNames, routeFor } from './routes'
|
||||
|
||||
const origin = 'https://mealquest.test'
|
||||
|
||||
// FakeCache keys entries by URL, which is all the API cache needs.
|
||||
class FakeCache {
|
||||
readonly entries = new Map<string, Response>()
|
||||
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||
return this.entries.get(new Request(request).url)?.clone()
|
||||
}
|
||||
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
||||
this.entries.set(new Request(request).url, response)
|
||||
}
|
||||
}
|
||||
|
||||
// FakeExtendableEvent stands in for the worker-only ExtendableEvent,
|
||||
// collecting the promises the strategy waits on.
|
||||
class FakeExtendableEvent extends Event {
|
||||
readonly pending: Promise<unknown>[] = []
|
||||
waitUntil(promise: Promise<unknown>) {
|
||||
this.pending.push(promise)
|
||||
}
|
||||
}
|
||||
import { answer, origin, stubFetchThroughWorker, stubWorkerCaches, type FakeCache } from '../test/serviceWorker.ts'
|
||||
import { cacheFallbackHeader } from './routes'
|
||||
|
||||
let cache: FakeCache
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new FakeCache()
|
||||
// Workbox checks its arguments against these worker-only globals.
|
||||
vi.stubGlobal('FetchEvent', class FetchEvent {})
|
||||
vi.stubGlobal('ExtendableEvent', FakeExtendableEvent)
|
||||
const only = (name: string | undefined) => {
|
||||
if (name !== cacheNames.api) throw new Error(`unexpected cache ${name}`)
|
||||
return cache
|
||||
}
|
||||
vi.stubGlobal('caches', {
|
||||
open: async (name: string) => only(name),
|
||||
match: async (request: RequestInfo | URL, options?: MultiCacheQueryOptions) =>
|
||||
only(options?.cacheName).match(request),
|
||||
})
|
||||
cache = stubWorkerCaches()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -58,17 +23,6 @@ async function handle(path: string): Promise<Response> {
|
||||
return answer(new Request(new URL(path, origin)))
|
||||
}
|
||||
|
||||
async function answer(request: Request): Promise<Response> {
|
||||
expect(routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)).toEqual({
|
||||
strategy: 'network-first',
|
||||
cacheName: cacheNames.api,
|
||||
})
|
||||
const event = new FakeExtendableEvent('fetch')
|
||||
const response = await createApiStrategy().handle({ request, event: event as unknown as RouteHandlerCallbackOptions['event'] })
|
||||
await Promise.all(event.pending)
|
||||
return response
|
||||
}
|
||||
|
||||
function recipe(): Response {
|
||||
return new Response(JSON.stringify({ id: '01J9ZR', name: 'Chicken tikka masala' }), {
|
||||
status: 200,
|
||||
@@ -107,22 +61,13 @@ describe('previously viewed plan and recipe reads', () => {
|
||||
const server: Record<string, unknown> = { '/api/v1/plans/2026-W40': plan, '/api/v1/recipes/01J9ZR': recipe }
|
||||
let reachable: boolean
|
||||
|
||||
// The page's fetch goes through the worker's API strategy; the strategy's
|
||||
// own fetch (always a Request) is the network. The API client fetches a
|
||||
// path string, so the two are told apart by the argument's type. As in a
|
||||
// browser, a worker that cannot answer is a network error to the page.
|
||||
beforeEach(() => {
|
||||
reachable = true
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable)
|
||||
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (!(input instanceof Request)) {
|
||||
return answer(new Request(new URL(String(input), origin), init)).catch((error: unknown) => {
|
||||
throw new TypeError('Failed to fetch', { cause: error })
|
||||
})
|
||||
}
|
||||
stubFetchThroughWorker(async (request) => {
|
||||
if (!reachable) throw new TypeError('Failed to fetch')
|
||||
const body = server[new URL(input.url).pathname]
|
||||
if (body === undefined) throw new Error(`unexpected network fetch: ${input.url}`)
|
||||
const body = server[new URL(request.url).pathname]
|
||||
if (body === undefined) throw new Error(`unexpected network fetch: ${request.url}`)
|
||||
return jsonResponse(200, body)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Runs requests through the service worker's real API strategy against an
|
||||
// in-memory cache storage: jsdom has neither a service worker nor caches.
|
||||
import { expect, vi } from 'vitest'
|
||||
import type { RouteHandlerCallbackOptions } from 'workbox-core/types'
|
||||
import { createApiStrategy } from '../sw/apiStrategy.ts'
|
||||
import { cacheNames, routeFor } from '../sw/routes.ts'
|
||||
|
||||
export const origin = 'https://mealquest.test'
|
||||
|
||||
// FakeCache keys entries by URL, which is all the API cache needs.
|
||||
export class FakeCache {
|
||||
readonly entries = new Map<string, Response>()
|
||||
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||
return this.entries.get(new Request(request).url)?.clone()
|
||||
}
|
||||
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
||||
this.entries.set(new Request(request).url, response)
|
||||
}
|
||||
}
|
||||
|
||||
// FakeExtendableEvent stands in for the worker-only ExtendableEvent,
|
||||
// collecting the promises the strategy waits on.
|
||||
class FakeExtendableEvent extends Event {
|
||||
readonly pending: Promise<unknown>[] = []
|
||||
waitUntil(promise: Promise<unknown>) {
|
||||
this.pending.push(promise)
|
||||
}
|
||||
}
|
||||
|
||||
// stubWorkerCaches stubs the worker globals Workbox checks and a cache
|
||||
// storage holding only the API cache, which it returns. Undo with
|
||||
// vi.unstubAllGlobals.
|
||||
export function stubWorkerCaches(): FakeCache {
|
||||
const cache = new FakeCache()
|
||||
vi.stubGlobal('FetchEvent', class FetchEvent {})
|
||||
vi.stubGlobal('ExtendableEvent', FakeExtendableEvent)
|
||||
const only = (name: string | undefined) => {
|
||||
if (name !== cacheNames.api) throw new Error(`unexpected cache ${name}`)
|
||||
return cache
|
||||
}
|
||||
vi.stubGlobal('caches', {
|
||||
open: async (name: string) => only(name),
|
||||
match: async (request: RequestInfo | URL, options?: MultiCacheQueryOptions) =>
|
||||
only(options?.cacheName).match(request),
|
||||
})
|
||||
return cache
|
||||
}
|
||||
|
||||
// answer sends a GET through the API strategy, as src/sw.ts does for a
|
||||
// network-first route; the strategy's own fetch is the network.
|
||||
export async function answer(request: Request): Promise<Response> {
|
||||
expect(routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)).toEqual({
|
||||
strategy: 'network-first',
|
||||
cacheName: cacheNames.api,
|
||||
})
|
||||
const event = new FakeExtendableEvent('fetch')
|
||||
const response = await createApiStrategy().handle({ request, event: event as unknown as RouteHandlerCallbackOptions['event'] })
|
||||
await Promise.all(event.pending)
|
||||
return response
|
||||
}
|
||||
|
||||
// stubFetchThroughWorker replaces the global fetch with the page's view of
|
||||
// it behind the worker, routed as src/sw.ts routes it: a network-first path
|
||||
// from the API client goes through the API strategy, a network-only one
|
||||
// straight to network; the strategy's own fetch, a Request, is network too.
|
||||
// As in a browser, a worker that cannot answer is a network error to the
|
||||
// page. Undo with vi.unstubAllGlobals.
|
||||
export function stubFetchThroughWorker(network: (request: Request) => Promise<Response>) {
|
||||
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (input instanceof Request) return network(input)
|
||||
const request = new Request(new URL(String(input), origin), init)
|
||||
const route = routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)
|
||||
if (route.strategy === 'network-only') return network(request)
|
||||
if (route.strategy !== 'network-first') throw new Error(`unexpected ${route.strategy} fetch: ${request.url}`)
|
||||
return answer(request).catch((error: unknown) => {
|
||||
throw new TypeError('Failed to fetch', { cause: error })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -24,6 +24,11 @@
|
||||
margin: -2px;
|
||||
}
|
||||
|
||||
.size52 {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.size62 {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
@@ -56,6 +61,11 @@
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.size52 .face {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.size62 .face {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
|
||||
@@ -26,6 +26,13 @@ describe('IconButton', () => {
|
||||
expect(hitArea(face)).toEqual({ width: 40, height: 40 })
|
||||
})
|
||||
|
||||
it('is 52 px at the medium size', () => {
|
||||
render(<IconButton label="Favorite" size={52} />)
|
||||
const button = screen.getByRole('button', { name: 'Favorite' })
|
||||
expectMinHitArea(button)
|
||||
expect(hitArea(button)).toEqual({ width: 52, height: 52 })
|
||||
})
|
||||
|
||||
it('is 62 px at the large size', () => {
|
||||
render(<IconButton label="Try it" size={62} tone="teal" />)
|
||||
const button = screen.getByRole('button', { name: 'Try it' })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import styles from './IconButton.module.css'
|
||||
|
||||
export type IconButtonSize = 40 | 62
|
||||
export type IconButtonSize = 40 | 52 | 62
|
||||
export type IconButtonTone = 'surface' | 'neutral' | 'accent' | 'teal'
|
||||
|
||||
export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'> & {
|
||||
@@ -15,7 +15,8 @@ export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'
|
||||
}
|
||||
|
||||
// Round icon button, named by its label and showing it only with showLabel.
|
||||
// The 40 px size keeps a 44 px hit box (see the CSS); the 62 px size is the
|
||||
// The 40 px size keeps a 44 px hit box (see the CSS); the 52 px size sits
|
||||
// beside a 52 px pill button, as the recipe's heart; the 62 px size is the
|
||||
// round action button with --shadow-md.
|
||||
export function IconButton({
|
||||
label,
|
||||
|
||||
@@ -83,6 +83,16 @@ describe('Segmented', () => {
|
||||
for (const option of screen.getAllByRole('radio')) expectMinHitArea(option)
|
||||
})
|
||||
|
||||
it('names an option by its ariaLabel when it has one', () => {
|
||||
const sizes = [
|
||||
{ value: 'small', label: 'A', ariaLabel: 'Small text' },
|
||||
{ value: 'large', label: 'A', ariaLabel: 'Large text' },
|
||||
] as const
|
||||
render(<Segmented label="Text size" options={sizes} value="large" onValueChange={() => {}} />)
|
||||
expect(radio('Small text')).toHaveTextContent('A')
|
||||
expect(radio('Large text')).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('fails fast when the value is not one of the options', () => {
|
||||
expect(() =>
|
||||
render(<Segmented label="Archive period" options={options} value={'decade' as Period} onValueChange={() => {}} />),
|
||||
|
||||
@@ -4,6 +4,9 @@ import styles from './Segmented.module.css'
|
||||
export type SegmentedOption<T extends string> = {
|
||||
value: T
|
||||
label: string
|
||||
// Accessible name, when the visible label alone does not say what the
|
||||
// option is (the A / A / A of a text size).
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export type SegmentedProps<T extends string> = {
|
||||
@@ -63,6 +66,7 @@ export function Segmented<T extends string>({ label, options, value, onValueChan
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={checked}
|
||||
aria-label={option.ariaLabel}
|
||||
tabIndex={checked ? 0 : -1}
|
||||
className={checked ? `${styles.option} ${styles.selected}` : styles.option}
|
||||
onClick={() => select(index)}
|
||||
|
||||
Reference in New Issue
Block a user