From 1b464aecd2d9fc322dc5e95d8153a2bc8e3210c9 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Fri, 9 Oct 2026 08:58:41 -0400 Subject: [PATCH] Quick dinners screen (1j) (Feature 18) (#152) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- web/src/App.test.tsx | 37 +- web/src/App.tsx | 2 + web/src/ideas/QuickDinnersPage.module.css | 166 +++++++ web/src/ideas/QuickDinnersPage.test.tsx | 514 ++++++++++++++++++++++ web/src/ideas/QuickDinnersPage.tsx | 261 +++++++++++ web/src/ideas/api.ts | 152 ++++++- 6 files changed, 1109 insertions(+), 23 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..891e13e --- /dev/null +++ b/web/src/ideas/QuickDinnersPage.test.tsx @@ -0,0 +1,514 @@ +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('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'], { + 'POST /api/v1/ideas/j1/items/0/quest-log': () => + fail ? errorResponse(500, 'internal') : jsonResponse(200, dinnerBody('dn-1', ORZO.name, { status: 'quest_log' })), + }) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO]) + await renderPage() + await screen.findByRole('listitem', { name: ORZO.name }) + const orzo = within(row(ORZO.name)) + + fireEvent.click(addButton(ORZO.name)) + expect(await orzo.findByRole('alert')).toHaveTextContent("Couldn't add it to the Quest log. internal message") + + fail = false + fireEvent.click(addButton(ORZO.name)) + expect(await orzo.findByRole('img', { name: 'In the Quest log' })).toBeInTheDocument() + expect(orzo.queryByRole('alert')).toBeNull() + }) + }) + + describe('Show me more', () => { + it('asks for the same kind excluding every name shown, and adds from the page that found it', async () => { + const { fetchMock, jobs } = quickServer(['j1', 'j2', 'j3'], { + 'POST /api/v1/ideas/j2/items/0/quest-log': () => jsonResponse(201, dinnerBody('dn-1', BROCCOLI.name, { status: 'quest_log' })), + }) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS]) + jobs.j2 = state('running', 'grounding', 'under30', [BROCCOLI]) + jobs.j3 = state('succeeded', 'done', 'under30', [GNOCCHI]) + await renderPage() + await screen.findByRole('listitem', { name: QUESADILLAS.name }) + + fireEvent.click(await showMore()) + await screen.findByRole('listitem', { name: BROCCOLI.name }, SLOW) + // The first page stays while the next fills. + expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name]) + expect(screen.getAllByTestId('skeleton-row')).toHaveLength(5) + expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull() + + jobs.j2 = state('succeeded', 'done', 'under30', [BROCCOLI]) + await screen.findByRole('button', { name: 'Show me more' }, SLOW) + fireEvent.click(addButton(BROCCOLI.name)) + expect(await within(row(BROCCOLI.name)).findByRole('img', { name: 'In the Quest log' })).toBeInTheDocument() + + fireEvent.click(await showMore()) + await screen.findByRole('listitem', { name: GNOCCHI.name }) + expect(searches(fetchMock)).toEqual([ + { kind: 'under30', exclude: [] }, + { kind: 'under30', exclude: [ORZO.name, QUESADILLAS.name] }, + { kind: 'under30', exclude: [ORZO.name, QUESADILLAS.name, BROCCOLI.name] }, + ]) + expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name, GNOCCHI.name]) + }) + + it('says every idea has been shown when a later page finds none', async () => { + const { fetchMock, jobs } = quickServer(['j1', 'j2']) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO]) + jobs.j2 = state('failed', 'grounding', 'under30', [], 'no_ideas_found') + await renderPage() + await screen.findByRole('listitem', { name: ORZO.name }) + + fireEvent.click(await showMore()) + expect(await screen.findByRole('status')).toHaveTextContent("That's every Quick dinner for now.") + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull() + expect(rowNames()).toEqual([ORZO.name]) + expect(calls(fetchMock, QUICK)).toHaveLength(2) + }) + + it('shows a later page that fails with the ErrorBanner, keeping the rows, and Retry asks again', async () => { + const { fetchMock, jobs } = quickServer(['j1', 'j2', 'j3']) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO]) + jobs.j2 = state('failed', 'grounding', 'under30', [], 'search_failed') + jobs.j3 = state('succeeded', 'done', 'under30', [QUESADILLAS]) + await renderPage() + await screen.findByRole('listitem', { name: ORZO.name }) + + fireEvent.click(await showMore()) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't find Quick dinners. The recipe search couldn't be reached.") + expect(rowNames()).toEqual([ORZO.name]) + + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('listitem', { name: QUESADILLAS.name }) + expect(searches(fetchMock)[2]).toEqual({ kind: 'under30', exclude: [ORZO.name] }) + expect(screen.queryByRole('alert')).toBeNull() + }) + }) + + describe('errors', () => { + it('shows the ErrorBanner when the search cannot start, and Retry asks again', async () => { + let n = 0 + const { fetchMock, jobs } = stubServer({ + [QUICK]: () => (++n === 1 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: 'j1' })), + }) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO]) + await renderPage() + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message") + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('listitem', { name: ORZO.name }) + expect(searches(fetchMock)).toEqual([ + { kind: 'under30', exclude: [] }, + { kind: 'under30', exclude: [] }, + ]) + }) + + it('keeps a first page that cannot start across a tab switch, asking again only on Retry', async () => { + let under30 = 0 + const { fetchMock, jobs } = stubServer({ + [QUICK]: (init) => { + const { kind } = JSON.parse(String(init?.body)) as { kind: string } + if (kind === 'crockpot') return jsonResponse(202, { jobId: 'j2' }) + return ++under30 === 1 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: 'j1' }) + }, + }) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO]) + jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI]) + await renderPage() + await screen.findByRole('alert') + + fireEvent.click(tab('Crock pot')) + await screen.findByRole('listitem', { name: CHILI.name }) + fireEvent.click(tab('30 min or less')) + + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message") + expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0) + expect(searches(fetchMock)).toEqual([ + { kind: 'under30', exclude: [] }, + { kind: 'crockpot', exclude: [] }, + ]) + + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('listitem', { name: ORZO.name }) + expect(screen.queryByRole('alert')).toBeNull() + expect(calls(fetchMock, QUICK)).toHaveLength(3) + }) + + it('keeps a later page that cannot start across a tab switch, asking again only on Retry', async () => { + let under30 = 0 + const { fetchMock, jobs } = stubServer({ + [QUICK]: (init) => { + const { kind } = JSON.parse(String(init?.body)) as { kind: string } + if (kind === 'crockpot') return jsonResponse(202, { jobId: 'j2' }) + under30++ + return under30 === 2 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: under30 === 1 ? 'j1' : 'j3' }) + }, + }) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO]) + jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI]) + jobs.j3 = state('succeeded', 'done', 'under30', [QUESADILLAS]) + await renderPage() + await screen.findByRole('listitem', { name: ORZO.name }) + fireEvent.click(await showMore()) + await screen.findByRole('alert') + + fireEvent.click(tab('Crock pot')) + await screen.findByRole('listitem', { name: CHILI.name }) + fireEvent.click(tab('30 min or less')) + + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message") + expect(rowNames()).toEqual([ORZO.name]) + expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull() + expect(calls(fetchMock, QUICK)).toHaveLength(3) + + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('listitem', { name: QUESADILLAS.name }) + expect(searches(fetchMock)[3]).toEqual({ kind: 'under30', exclude: [ORZO.name] }) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it.each([ + ['ai_failed', "The AI assistant couldn't come up with ideas."], + ['no_ideas_found', 'No ideas came back with a recipe to match.'], + ['search_not_configured', 'Recipe search is not set up.'], + ['internal_error', 'Something went wrong on the server.'], + ])('shows a first page failed with %s with the ErrorBanner and no rows, and Retry searches again', async (code, message) => { + const { fetchMock, jobs } = quickServer(['j1', 'j2']) + jobs.j1 = state('failed', 'grounding', 'under30', [], code) + jobs.j2 = state('succeeded', 'done', 'under30', [ORZO]) + await renderPage() + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent(`Couldn't find Quick dinners. ${message}`) + expect(screen.queryAllByRole('listitem', { name: /./ })).toHaveLength(0) + expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0) + expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull() + + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('listitem', { name: ORZO.name }) + expect(calls(fetchMock, QUICK)).toHaveLength(2) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('shows an unknown job with the ErrorBanner, and Retry searches again', async () => { + const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'GET /api/v1/jobs/j1': () => errorResponse(404, 'not_found') }) + jobs.j2 = state('succeeded', 'done', 'under30', [ORZO]) + await renderPage() + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't check on the Quick dinners. not_found message") + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('listitem', { name: ORZO.name }) + expect(calls(fetchMock, QUICK)).toHaveLength(2) + expect(screen.queryByRole('alert')).toBeNull() + }) + }) +}) diff --git a/web/src/ideas/QuickDinnersPage.tsx b/web/src/ideas/QuickDinnersPage.tsx new file mode 100644 index 0000000..5fe4c96 --- /dev/null +++ b/web/src/ideas/QuickDinnersPage.tsx @@ -0,0 +1,261 @@ +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. 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 ( +
  • + +
    +

    {idea.name}

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

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

    + )} +
    + {added ? ( + + + + ) : ( + questLog.mutate(ideaRef!)} + > + + + )} +
  • + ) +} + +// 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 { 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. A page that could not start stays the + // tab's startError until Retry: nothing asks again by itself. + const ask = () => mutate(names) + + // The first page, asked for once. + const asked = useRef(false) + const fresh = tab.pages.length === 0 && tab.jobId === null && !tab.ended && tab.startError === null && !starting + useEffect(() => { + if (!fresh || asked.current) return + asked.current = true + mutate([]) + }, [fresh, mutate]) + + // The job's result as far as it has gone, or why it cannot be read. + const found = useMemo(() => { + if (job.result === null || tab.jobId === null) return { result: null, error: null } + try { + return { result: parseQuickResult(`GET /api/v1/jobs/${tab.jobId}`, kind, job.result), error: null } + } catch (error) { + return { result: null, error: error as Error } + } + }, [job.result, tab.jobId, kind]) + + const failure = job.status === 'failed' ? job.error!.message : null + // A later page that finds nothing means there is nothing more to show. + const exhausted = failure === NO_IDEAS_FOUND && tab.pages.length > 0 + + // A page found is kept with the tab's pages; a page that ends the tab + // ends it. + const jobId = tab.jobId + const ideas = job.status === 'succeeded' ? (found.result?.ideas ?? null) : null + useEffect(() => { + if (jobId === null) return + if (ideas !== null) update((t) => (t.jobId === jobId ? { ...t, pages: [...t.pages, { jobId, ideas }], jobId: null } : t)) + else if (exhausted) update((t) => (t.jobId === jobId ? { ...t, jobId: null, ended: true } : t)) + }, [jobId, ideas, exhausted, update]) + + const markAdded = ({ jobId, index }: IdeaRef) => 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 + // 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 (tab.startError !== null) { + next = ( +
  • + Couldn't start looking for Quick dinners. {tab.startError.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 && tab.startError === null && jobId === null && tab.pages.length > 0 + const allShown = tab.ended || names.length > MAX_EXCLUDE + const busy = starting || (jobId !== null && failure === null && found.error === null && job.status !== 'succeeded') + + return ( +
    t.value === kind)!.label} className={styles.panel}> +

    {helpers[kind]}

    +
      + {rows.map(({ idea, ref, 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..0963f3d 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,143 @@ 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, 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; startError: Error | null; added: string[] } + +const NEW_TAB: QuickTab = { pages: [], jobId: null, ended: false, startError: null, added: [] } + +export function quickTabKey(kind: QuickKind) { + return ['ideas', 'quick', kind] as const +} + +// shownNames are the names of every idea a tab shows, which its next page +// excludes so that nothing repeats. +export function shownNames(tab: QuickTab): string[] { + return tab.pages.flatMap((page) => page.ideas.map((idea) => idea.name)) +} + +// useQuickTab is a tab's QuickTab, held in the query cache for the session +// (never stale, never collected) so that leaving the tab or the screen +// keeps it, and update, which changes it. Nothing is fetched: a refetch +// reads back what the tab holds. +export function useQuickTab(kind: QuickKind) { + const queryClient = useQueryClient() + const { data } = useQuery({ + queryKey: quickTabKey(kind), + queryFn: () => queryClient.getQueryData(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, 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 })), + }) +} + // IdeaRef is idea index of the succeeded ideas job jobId. export type IdeaRef = { jobId: string; index: number } @@ -151,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({ @@ -160,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 }) + }, }) }