Add the Quick dinners screen (1j) (Feature 18)
CI / ci (push) Successful in 6m31s
CI / ci (pull_request) Successful in 5m46s

/quick now renders mock 1j: a grape zap header, Crock pot | 30 min or
less tabs with a helper line each, and rows of grounded quick_ideas
(64 px washed photo, Bagel name, "25 min · Medium" or "Crock pot · 6 hr
· Easy", the time left off when the recipe has none) with a teal "+"
that adds the idea to the Quest log and becomes a check.

Each tab is its own POST /api/v1/ideas/quick search, polled with useJob
and filled as the server grounds ideas. A tab's pages, the job finding
its next page and the ideas added are kept in the query cache for the
session, so switching tabs or leaving the screen keeps them. "Show me
more" asks for the same kind excluding every name shown; no_ideas_found
on a later page ends the tab honestly. Failures use the ErrorBanner and
Retry, as Cook from what I have does.

The App bootstrap tests that used /quick as a placeholder route now use
/settings/notifications.

Maestro task db934d36-f554-41ef-9061-5f100a0b6452.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 07:53:57 -04:00
co-authored by Claude Opus 5.5
parent 2203c0c26f
commit 60b90323a5
6 changed files with 999 additions and 20 deletions
+19 -18
View File
@@ -35,6 +35,7 @@ const SCREENS = {
'/plan/shopping': true,
'/plan/shopping/archive': true,
'/discover': true,
'/quick': true,
'/cook-from-pantry': true,
'/settings': true,
'/settings/ai': true,
@@ -206,19 +207,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 +227,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,7 +300,7 @@ describe('App offline bootstrap', () => {
})
it.each([
['/quick', 'Quick dinners'],
['/settings/notifications', 'Notifications'],
['/dinner/01J9ZK', 'Dinner'],
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
await visitOnline()
@@ -323,7 +324,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 +332,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 +341,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 +358,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 +371,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 +392,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 () => {
+2
View File
@@ -5,6 +5,7 @@ import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
import { AuthBootstrap } from './AuthBootstrap.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 +47,7 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/plan/shopping': <SupplyRunPage />,
'/plan/shopping/archive': <SupplyArchivePage />,
'/discover': <DiscoverPage />,
'/quick': <QuickDinnersPage />,
'/cook-from-pantry': <CookFromPantryPage />,
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
+166
View File
@@ -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;
}
+426
View File
@@ -0,0 +1,426 @@
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('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.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()
})
})
})
+250
View File
@@ -0,0 +1,250 @@
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 {
ideasJobFailure,
ideasJobLost,
MAX_EXCLUDE,
parseQuickResult,
QUICK_IDEAS_KEPT,
QUICK_JOB_KIND,
quickTabKey,
shownNames,
useAddIdeaToQuestLog,
useFindQuickIdeas,
useQuickTab,
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, a check once it is there.
// ideaRef is null while its job runs: the server takes an idea only once
// the job has succeeded.
function QuickRow({ idea, ideaRef, added, onAdded }: { idea: QuickIdea; ideaRef: IdeaRef | null; added: boolean; onAdded: () => void }) {
const questLog = useAddIdeaToQuestLog()
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 ? (
<span className={styles.added} role="status">
<Icon icon={Check} size="nav" label="In the Quest log" />
</span>
) : (
<IconButton
label={`Add ${idea.name} to the Quest log`}
tone="teal"
className={styles.add}
disabled={ideaRef === null || questLog.isPending}
onClick={() => questLog.mutate(ideaRef!, { onSuccess: onAdded })}
>
<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 find = useFindQuickIdeas(kind)
const { mutate } = find
// 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.
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 && !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 = (key: string) => update((t) => ({ ...t, added: [...t.added, key] }))
// 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 (find.isError) {
next = (
<li>
<ErrorBanner onRetry={ask}>Couldn't start looking for Quick dinners. {find.error.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 && !find.isError && 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.includes(key)} onAdded={() => markAdded(key)} />
))}
{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>
)
}
+136 -2
View File
@@ -1,7 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
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 { parseDinner } from '../dinners/api.ts'
import { DIFFICULTIES, METHODS, parseDinner, photoId, type Difficulty, type Method } from '../dinners/api.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
import { parsePlan, planKey } from '../plan/api.ts'
@@ -141,6 +142,139 @@ export function useFindIdeas() {
})
}
// 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, and the ideas added to the Quest log, as
// "jobId:index".
export type QuickTab = { pages: QuickPage[]; jobId: string | null; ended: boolean; added: string[] }
const NEW_TAB: QuickTab = { pages: [], jobId: null, ended: false, 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, 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> => {
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
},
})
}
// IdeaRef is idea index of the succeeded ideas job jobId.
export type IdeaRef = { jobId: string; index: number }