From 60b90323a58ecc7743e5bfe7ee6524386b1cdbd6 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Fri, 9 Oct 2026 07:53:57 -0400 Subject: [PATCH 1/2] Add the Quick dinners screen (1j) (Feature 18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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 --- web/src/App.test.tsx | 37 +- web/src/App.tsx | 2 + web/src/ideas/QuickDinnersPage.module.css | 166 +++++++++ web/src/ideas/QuickDinnersPage.test.tsx | 426 ++++++++++++++++++++++ web/src/ideas/QuickDinnersPage.tsx | 250 +++++++++++++ web/src/ideas/api.ts | 138 ++++++- 6 files changed, 999 insertions(+), 20 deletions(-) create mode 100644 web/src/ideas/QuickDinnersPage.module.css create mode 100644 web/src/ideas/QuickDinnersPage.test.tsx create mode 100644 web/src/ideas/QuickDinnersPage.tsx diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 1eeaa28..e9be839 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -35,6 +35,7 @@ const SCREENS = { '/plan/shopping': true, '/plan/shopping/archive': true, '/discover': true, + '/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().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( - + , ) - 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 () => { diff --git a/web/src/App.tsx b/web/src/App.tsx index d8a00bd..8fb3ea3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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> = { '/plan/shopping': , '/plan/shopping/archive': , '/discover': , + '/quick': , '/cook-from-pantry': , '/settings': , '/settings/ai': , diff --git a/web/src/ideas/QuickDinnersPage.module.css b/web/src/ideas/QuickDinnersPage.module.css new file mode 100644 index 0000000..071709f --- /dev/null +++ b/web/src/ideas/QuickDinnersPage.module.css @@ -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; +} diff --git a/web/src/ideas/QuickDinnersPage.test.tsx b/web/src/ideas/QuickDinnersPage.test.tsx new file mode 100644 index 0000000..e020f80 --- /dev/null +++ b/web/src/ideas/QuickDinnersPage.test.tsx @@ -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 + +// 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) { + const jobs: Record = {} + const fetchMock = vi.fn((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 = {}) { + 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['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['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( + + + + + } /> + + + + , + ) +} + +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() + 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() + }) + }) +}) diff --git a/web/src/ideas/QuickDinnersPage.tsx b/web/src/ideas/QuickDinnersPage.tsx new file mode 100644 index 0000000..b13f305 --- /dev/null +++ b/web/src/ideas/QuickDinnersPage.tsx @@ -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 = { + 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 ( +
  • + +
    +

    {idea.name}

    + {meta(idea)} + {questLog.isError && ( +

    + Couldn't add it to the Quest log. {questLog.error.message} +

    + )} +
    + {added ? ( + + + + ) : ( + questLog.mutate(ideaRef!, { onSuccess: onAdded })} + > + + + )} +
  • + ) +} + +// Skeletons stand in for the count ideas still to come. +function Skeletons({ count }: { count: number }) { + return Array.from({ length: count }, (_, i) => ( + + )) +} + +// 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(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 = + } else if (find.isError) { + next = ( +
  • + Couldn't start looking for Quick dinners. {find.error.message} +
  • + ) + } else if (jobId !== null && failure !== null && !exhausted) { + next = ( +
  • + Couldn't find Quick dinners. {ideasJobFailure(failure)} +
  • + ) + } else if (found.error !== null) { + next = ( +
  • + Couldn't read the Quick dinners. {found.error.message} +
  • + ) + } 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 && } + {job.error !== null && ( +
  • + Couldn't check on the Quick dinners. {job.error.message} +
  • + )} + + ) + } + + // 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 ( +
    t.value === kind)!.label} className={styles.panel}> +

    {helpers[kind]}

    +
      + {rows.map(({ idea, ref, key }) => ( + markAdded(key)} /> + ))} + {next} +
    + {between && + (allShown ? ( +

    + That's every Quick dinner for now. +

    + ) : ( + + ))} +
    + ) +} + +// 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('under30') + return ( +
    +
    + + + +

    Quick dinners

    +
    + + +
    + ) +} diff --git a/web/src/ideas/api.ts b/web/src/ideas/api.ts index 44f34d6..d0f14ed 100644 --- a/web/src/ideas/api.ts +++ b/web/src/ideas/api.ts @@ -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(quickTabKey(kind)) ?? NEW_TAB, + initialData: NEW_TAB, + staleTime: Infinity, + gcTime: Infinity, + }) + const update = useCallback( + (change: (tab: QuickTab) => QuickTab) => queryClient.setQueryData(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 => { + const body = await apiFetch(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(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 } -- 2.55.0 From c28d55681cad681c40747193a2af1d3cba9da75e Mon Sep 17 00:00:00 2001 From: dev_agent Date: Fri, 9 Oct 2026 08:09:08 -0400 Subject: [PATCH 2/2] Keep Quick dinners start failures and adds across tab switches A search that could not start is now the tab's startError in the per-tab cache, set by the mutation's own onError, so it survives the panel unmounting. The first page is no longer asked for again by itself, and the ErrorBanner with Retry stays until Retry clears it. An add to the Quest log now marks the row added from the mutation's own onSuccess, which runs even when the row has unmounted, so the check is there when the tab is shown again. Review findings F1 and F2 on PR #152; task db934d36-f554-41ef-9061-5f100a0b6452. Co-Authored-By: Claude Opus 5.5 --- web/src/ideas/QuickDinnersPage.test.tsx | 88 +++++++++++++++++++++++++ web/src/ideas/QuickDinnersPage.tsx | 37 +++++++---- web/src/ideas/api.ts | 24 ++++--- 3 files changed, 128 insertions(+), 21 deletions(-) diff --git a/web/src/ideas/QuickDinnersPage.test.tsx b/web/src/ideas/QuickDinnersPage.test.tsx index e020f80..891e13e 100644 --- a/web/src/ideas/QuickDinnersPage.test.tsx +++ b/web/src/ideas/QuickDinnersPage.test.tsx @@ -279,6 +279,28 @@ describe('QuickDinnersPage', () => { expect(within(row(QUESADILLAS.name)).getByRole('img', { name: 'In the Quest log' })).toBeInTheDocument() }) + it('marks an add that settles while another tab is shown', async () => { + const added = Promise.withResolvers() + 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'], { @@ -387,6 +409,72 @@ describe('QuickDinnersPage', () => { ]) }) + 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.'], diff --git a/web/src/ideas/QuickDinnersPage.tsx b/web/src/ideas/QuickDinnersPage.tsx index b13f305..5fe4c96 100644 --- a/web/src/ideas/QuickDinnersPage.tsx +++ b/web/src/ideas/QuickDinnersPage.tsx @@ -59,9 +59,20 @@ function meta(idea: QuickIdea): string { // 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() +// 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: boolean + onAdded: (idea: IdeaRef) => void +}) { + const questLog = useAddIdeaToQuestLog(onAdded) return (
  • @@ -84,7 +95,7 @@ function QuickRow({ idea, ideaRef, added, onAdded }: { idea: QuickIdea; ideaRef: tone="teal" className={styles.add} disabled={ideaRef === null || questLog.isPending} - onClick={() => questLog.mutate(ideaRef!, { onSuccess: onAdded })} + onClick={() => questLog.mutate(ideaRef!)} > @@ -108,20 +119,20 @@ function Skeletons({ count }: { count: number }) { // 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 + 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(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. + // 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 && !starting + const fresh = tab.pages.length === 0 && tab.jobId === null && !tab.ended && tab.startError === null && !starting useEffect(() => { if (!fresh || asked.current) return asked.current = true @@ -152,7 +163,7 @@ function QuickTabPanel({ kind }: { kind: QuickKind }) { 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] })) + const markAdded = ({ jobId, index }: IdeaRef) => update((t) => ({ ...t, added: [...t.added, `${jobId}:${index}`] })) // 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 @@ -164,10 +175,10 @@ function QuickTabPanel({ kind }: { kind: QuickKind }) { let next = null if (starting) { next = - } else if (find.isError) { + } else if (tab.startError !== null) { next = (
  • - Couldn't start looking for Quick dinners. {find.error.message} + Couldn't start looking for Quick dinners. {tab.startError.message}
  • ) } else if (jobId !== null && failure !== null && !exhausted) { @@ -203,7 +214,7 @@ function QuickTabPanel({ kind }: { kind: QuickKind }) { // 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 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') @@ -212,7 +223,7 @@ function QuickTabPanel({ kind }: { kind: QuickKind }) {

    {helpers[kind]}

      {rows.map(({ idea, ref, key }) => ( - markAdded(key)} /> + ))} {next}
    diff --git a/web/src/ideas/api.ts b/web/src/ideas/api.ts index d0f14ed..0963f3d 100644 --- a/web/src/ideas/api.ts +++ b/web/src/ideas/api.ts @@ -220,11 +220,12 @@ 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 +// there are no more, why the last page asked for could not start, kept +// until Retry asks again, and the ideas added to the Quest log, as // "jobId:index". -export type QuickTab = { pages: QuickPage[]; jobId: string | null; ended: boolean; added: string[] } +export type QuickTab = { pages: QuickPage[]; jobId: string | null; ended: boolean; startError: Error | null; added: string[] } -const NEW_TAB: QuickTab = { pages: [], jobId: null, ended: false, added: [] } +const NEW_TAB: QuickTab = { pages: [], jobId: null, ended: false, startError: null, added: [] } export function quickTabKey(kind: QuickKind) { return ['ideas', 'quick', kind] as const @@ -258,20 +259,23 @@ export function useQuickTab(kind: QuickKind) { // 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. +// 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 => { + queryClient.setQueryData(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), startError: null })) const body = await apiFetch(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(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), jobId })) return jobId }, + onError: (startError: Error) => queryClient.setQueryData(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), startError })), }) } @@ -285,8 +289,9 @@ function ideaPath({ jobId, index }: IdeaRef, action: 'quest-log' | 'cook-tonight // useAddIdeaToQuestLog adds an idea to the Quest log (POST // /api/v1/ideas/{jobId}/items/{i}/quest-log) and resolves with the // dinner's name. Asking again answers the same dinner. The Quest log is -// read again after it. -export function useAddIdeaToQuestLog() { +// read again after it, and onAdded told, also if the caller has unmounted +// meanwhile. +export function useAddIdeaToQuestLog(onAdded?: (idea: IdeaRef) => void) { const { apiFetch } = useApi() const queryClient = useQueryClient() return useMutation({ @@ -294,7 +299,10 @@ export function useAddIdeaToQuestLog() { const path = ideaPath(idea, 'quest-log') return parseDinner(`POST ${path}`, await apiFetch(path, { method: 'POST' })).name }, - onSuccess: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }), + onSuccess: (_name, idea) => { + onAdded?.(idea) + return queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }) + }, }) } -- 2.55.0