Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 e93c9c63f6 Add idea actions: add to Quest log and cook tonight (Feature 18)
CI / ci (push) Successful in 5m42s
CI / ci (pull_request) Successful in 5m44s
POST /api/v1/ideas/{jobId}/items/{i}/quest-log adds idea i of a
succeeded ideas job to the Quest log as a dinner grounded in the idea's
own recipe, with origin ingredients or quick, or answers the dinner it
repeats as it is (the Regular dedupe rule), so repeating the call is
idempotent. POST .../cook-tonight does the same, then puts the dinner on
today (household timezone) of this week's plan through plan.Service.Swap;
with no plan for the week it answers 409 no_plan and changes nothing.

Maestro task 54dcd487-1597-4a91-8493-97f3a7635e2d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:58:20 -04:00
orchestrator-agent 659d082178 quick_ideas skill and job (Feature 18) (#146)
CI / ci (push) Successful in 6m22s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 20:48:04 -04:00
orchestrator-agent 3c58e0c7f7 ideas_from_ingredients skill and job (Feature 18) (#144)
CI / ci (push) Successful in 6m19s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 20:04:20 -04:00
11 changed files with 20 additions and 2472 deletions
+18 -20
View File
@@ -35,8 +35,6 @@ const SCREENS = {
'/plan/shopping': true,
'/plan/shopping/archive': true,
'/discover': true,
'/quick': true,
'/cook-from-pantry': true,
'/settings': true,
'/settings/ai': true,
'/settings/household': true,
@@ -207,19 +205,19 @@ describe('App first-run redirect', () => {
it('opens the routes once onboarding is finished', async () => {
mockViewport(390)
stubFetchByPath(SIGNED_IN)
renderAt('/settings/notifications')
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
renderAt('/quick')
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).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('/settings/notifications')
renderAt('/quick')
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: 'Notifications' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
})
})
@@ -227,12 +225,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('/settings/notifications')
renderAt('/quick')
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: 'Notifications' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
})
@@ -300,7 +298,7 @@ describe('App offline bootstrap', () => {
})
it.each([
['/settings/notifications', 'Notifications'],
['/quick', 'Quick dinners'],
['/dinner/01J9ZK', 'Dinner'],
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
await visitOnline()
@@ -324,7 +322,7 @@ describe('App offline bootstrap', () => {
() => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))),
).mockRejectedValue(new TypeError('Failed to fetch')),
)
renderAt('/settings/notifications')
renderAt('/quick')
await vi.waitFor(() => expect(failMe).toBeDefined())
onLine = false
act(() => {
@@ -332,7 +330,7 @@ describe('App offline bootstrap', () => {
})
act(() => failMe())
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).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()
@@ -341,15 +339,15 @@ describe('App offline bootstrap', () => {
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
await visitOnline()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
renderAt('/settings/notifications')
renderAt('/quick')
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).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('/settings/notifications')
renderAt('/quick')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
@@ -358,12 +356,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('/settings/notifications')
const { location, unmount } = renderAt('/quick')
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
unmount()
goOffline()
renderAt('/settings/notifications')
renderAt('/quick')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
})
@@ -371,13 +369,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('/settings/notifications'), createNetworkStatus(window), createVerifiedSession(localStorage))
const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<InDataRouter path="/settings/notifications">
<InDataRouter path="/quick">
<App api={api} serviceWorker={undefined} />
</InDataRouter>,
)
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
const fetchMock = stubFetchByPath(SIGNED_IN)
@@ -392,7 +390,7 @@ describe('App offline bootstrap', () => {
]),
)
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
expect(screen.getByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
})
it('/settings/calendar renders the Google Calendar screen inside the shell', async () => {
-4
View File
@@ -4,8 +4,6 @@ import { Route, Routes } from 'react-router'
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'
@@ -47,8 +45,6 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/plan/shopping': <SupplyRunPage />,
'/plan/shopping/archive': <SupplyArchivePage />,
'/discover': <DiscoverPage />,
'/quick': <QuickDinnersPage />,
'/cook-from-pantry': <CookFromPantryPage />,
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
'/settings/household': <HouseholdSettingsPage />,
-226
View File
@@ -1,226 +0,0 @@
/* Cook from what I have, handoff mock 1i. */
.page {
box-sizing: border-box;
max-width: 560px;
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 6px;
}
.header {
display: flex;
align-items: center;
gap: 10px;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
line-height: 1.1;
}
/* The ingredient chip field: chips, then the trailing input, wrapping. */
.field {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 4px;
padding: 12px;
border-radius: 26px;
background: var(--color-surface);
cursor: text;
}
.field:focus-within {
box-shadow: inset 0 0 0 2px var(--color-accent-300);
}
.chips {
display: contents;
margin: 0;
padding: 0;
list-style: none;
}
.chip {
background: var(--color-bg);
color: var(--color-text);
font-size: 13px;
}
.entry {
flex: 1 1 120px;
min-width: 120px;
}
.input {
width: 100%;
height: 44px;
padding: 0 8px;
border: 0;
background: transparent;
font-size: 15px;
}
.input:focus {
outline: none;
}
.input::placeholder {
color: var(--color-neutral-600);
}
.notice {
margin: 0 4px;
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
color: var(--color-accent-700);
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
}
/* Any time | Quick only shares the row with Find ideas, as in the mock. */
.when {
flex: none;
}
.when > button {
padding: 0 12px;
font-size: 13px;
white-space: nowrap;
}
.find {
padding: 0 18px;
white-space: nowrap;
}
.results {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 8px;
}
.count {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px;
border-radius: 24px;
}
.cardHead {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
}
.name {
margin: 0;
font-family: var(--font-heading);
font-size: 17px;
font-weight: 400;
line-height: 1.2;
}
.time {
flex: none;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.summary {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-accent-2-800);
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.action {
min-height: 44px;
padding: 0 16px;
font-size: 14px;
white-space: nowrap;
}
/* An action done: what it did, in place of its button. */
.done {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 44px;
padding: 0 4px;
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
color: var(--color-accent-2-800);
}
.failure {
margin: 0;
font-family: var(--font-body);
font-size: 13px;
color: var(--color-accent-700);
}
.noPlan {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 10px;
font-family: var(--font-body);
font-size: 13px;
color: var(--color-text);
}
/* "Plan this week": a 44 px tall grape link to the Draft board. */
.planLink {
display: inline-flex;
align-items: center;
min-height: 44px;
font-weight: 800;
color: var(--color-accent-700);
text-decoration: underline;
}
.planLink:focus-visible {
outline: 3px solid var(--color-accent);
outline-offset: 2px;
}
.skeletonCard {
height: 128px;
border-radius: 24px;
}
-561
View File
@@ -1,561 +0,0 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { dinnerBody } from '../test/dinners.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { CookFromPantryPage } from './CookFromPantryPage.tsx'
import styles from './CookFromPantryPage.module.css'
const FIND = 'POST /api/v1/ideas/from-ingredients'
const INGREDIENTS = ['Chicken thighs', 'Rice', 'Spinach', 'Coconut milk', 'Garlic']
// idea is a grounded idea as the job's result carries it.
function idea(name: string, summary: string, minutes: number | null = 30) {
return {
name,
uses: INGREDIENTS,
missing: ['1 lime'],
summary,
method: 'stovetop',
minutes_total: minutes,
difficulty: 'easy',
quick: minutes !== null && minutes <= 30,
recipe_id: `r-${name}`,
photo_url: '',
source_url: 'https://recipes.example/x',
site_name: 'Recipes',
}
}
// The ideas of mock 1i.
const CURRY = idea('Coconut chicken curry', 'Uses all 5 · missing: 1 lime')
const BOWLS = idea('Garlic spinach rice bowls', 'Uses 4 of 5 · nothing missing', 25)
const ONE_PAN = idea('One-pan chicken & rice', 'Uses 3 of 5 · missing: onion')
type JobBody = { status: string; result: unknown; error: string | null }
// running is the ideas_from_ingredients job's state at stage with ideas.
function state(status: string, stage: string, ideas: unknown[], quickOnly = false, error: string | null = null): JobBody {
return { status, result: { stage, ingredients: INGREDIENTS, quick_only: quickOnly, ideas, dropped: 0 }, error }
}
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
// tonightPlan is the week's plan as cook-tonight answers it, the idea's
// dinner on Thursday.
function tonightPlan() {
return {
id: 'plan-1',
week_start: '2026-10-05',
status: 'draft',
ai_note: null,
calendar_synced_at: null,
days: DAYS.map((date, i) => ({
date,
dinner: { id: `d${i}`, name: i === 3 ? CURRY.name : `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop', origin: 'regular' },
is_new: i === 3,
start_by: null,
})),
updated_at: '2026-10-08T15:00:00Z',
}
}
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
// stubServer answers "METHOD path" requests from responders, with /me; any
// other request fails the test. jobs holds each job's state, which every
// poll of it answers, so a test moves a job on by changing it.
function stubServer(responders: Record<string, Responder>) {
const jobs: Record<string, JobBody> = {}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
if (key === 'GET /api/v1/me') return Promise.resolve(jsonResponse(200, ME))
const job = /^GET \/api\/v1\/jobs\/([^/]+)$/.exec(key)
if (job !== null && !Object.hasOwn(responders, key)) {
const id = job[1]!
const body = jobs[id]
if (body === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(jsonResponse(200, { id, kind: 'ideas_from_ingredients', ...body }))
}
const respond = Object.hasOwn(responders, key) ? responders[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init))
})
vi.stubGlobal('fetch', fetchMock)
return { fetchMock, jobs }
}
// calls are the requests sent as "METHOD path", in order.
function calls(fetchMock: ReturnType<typeof stubServer>['fetchMock'], key: string) {
return fetchMock.mock.calls.filter(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}` === key)
}
function bodyOf(call: Parameters<typeof fetch>) {
return JSON.parse(String(call[1]?.body)) as unknown
}
async function renderPage() {
const api = createApiClient(fakeLocation('/cook-from-pantry'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<MemoryRouter initialEntries={['/cook-from-pantry']}>
<Routes>
<Route path="/cook-from-pantry" element={<CookFromPantryPage />} />
<Route path="/plan" element={<p>Plan screen</p>} />
<Route path="/" element={<p>Home screen</p>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
function input() {
return screen.getByRole('textbox', { name: 'Add ingredient' })
}
function chips() {
return within(screen.getByRole('list', { name: 'Ingredients on hand' }))
.queryAllByRole('listitem')
.map((item) => item.textContent)
}
function findButton() {
return screen.getByRole('button', { name: 'Find ideas' })
}
// enter types each ingredient and presses Enter.
function enter(...ingredients: string[]) {
for (const ingredient of ingredients) {
fireEvent.change(input(), { target: { value: ingredient } })
fireEvent.keyDown(input(), { key: 'Enter' })
}
}
function card(name: string) {
return screen.getByRole('article', { name })
}
const SLOW = { timeout: 3000 }
// searched lists the mock's ingredients, presses Find ideas and waits for
// the job's first poll.
async function searched(fetchMock: ReturnType<typeof stubServer>['fetchMock'], jobId = 'j1') {
enter(...INGREDIENTS)
fireEvent.click(findButton())
await waitFor(() => expect(calls(fetchMock, `GET /api/v1/jobs/${jobId}`).length).toBeGreaterThan(0))
}
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('CookFromPantryPage', () => {
it('renders mock 1i: back button, title, chip field, filter and a disabled Find ideas', async () => {
stubServer({})
await renderPage()
expect(screen.getByRole('heading', { level: 1, name: 'Cook from what I have' })).toBeInTheDocument()
expect(input()).toHaveAttribute('placeholder', 'Add ingredient…')
expect(input().closest(`.${styles.field}`)).not.toBeNull()
const filter = screen.getByRole('radiogroup', { name: 'Which dinners' })
expect(within(filter).getByRole('radio', { name: 'Any time' })).toBeChecked()
expect(within(filter).getByRole('radio', { name: 'Quick only' })).not.toBeChecked()
expect(findButton()).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Back to Home' }))
expect(screen.getByText('Home screen')).toBeInTheDocument()
})
describe('chip entry', () => {
it('adds an ingredient on Enter or a comma, and Find ideas needs one', async () => {
stubServer({})
await renderPage()
enter('Chicken thighs')
expect(chips()).toEqual(['Chicken thighs'])
expect(input()).toHaveValue('')
expect(findButton()).toBeEnabled()
fireEvent.change(input(), { target: { value: 'Rice,' } })
expect(chips()).toEqual(['Chicken thighs', 'Rice'])
expect(input()).toHaveValue('')
// Pasted text is split at its commas; what follows the last is still
// being typed.
fireEvent.change(input(), { target: { value: ' Spinach , coconut milk, Gar' } })
expect(chips()).toEqual(['Chicken thighs', 'Rice', 'Spinach', 'coconut milk'])
expect(input()).toHaveValue(' Gar')
// Enter on nothing adds nothing.
fireEvent.change(input(), { target: { value: ' ' } })
fireEvent.keyDown(input(), { key: 'Enter' })
expect(chips()).toHaveLength(4)
})
it('enables Find ideas for text typed and not yet added, and searches it', async () => {
const { fetchMock } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
await renderPage()
fireEvent.change(input(), { target: { value: ' ' } })
expect(findButton()).toBeDisabled()
fireEvent.change(input(), { target: { value: 'Chicken thighs' } })
expect(chips()).toEqual([])
expect(findButton()).toBeEnabled()
fireEvent.click(findButton())
await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(1))
expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: ['Chicken thighs'], quick_only: false })
expect(chips()).toEqual(['Chicken thighs'])
})
it('removes a chip with its cross, or the last one with Backspace in the empty input', async () => {
stubServer({})
await renderPage()
enter('Chicken thighs', 'Rice', 'Spinach')
fireEvent.click(screen.getByRole('button', { name: 'Remove Rice' }))
expect(chips()).toEqual(['Chicken thighs', 'Spinach'])
expect(input()).toHaveFocus()
fireEvent.keyDown(input(), { key: 'Backspace' })
expect(chips()).toEqual(['Chicken thighs'])
fireEvent.click(screen.getByRole('button', { name: 'Remove Chicken thighs' }))
expect(chips()).toEqual([])
expect(findButton()).toBeDisabled()
})
it('says why it leaves out a repeated, overlong or 21st ingredient', async () => {
stubServer({})
await renderPage()
enter('Rice', ' rice ')
expect(chips()).toEqual(['Rice'])
expect(screen.getByRole('status')).toHaveTextContent('rice is listed already.')
enter('x'.repeat(81))
expect(chips()).toEqual(['Rice'])
expect(screen.getByRole('status')).toHaveTextContent('An ingredient can be at most 80 characters.')
enter(...Array.from({ length: 19 }, (_, i) => `Item ${i}`))
expect(chips()).toHaveLength(20)
expect(screen.queryByRole('status')).toBeNull()
enter('One too many')
expect(chips()).toHaveLength(20)
expect(screen.getByRole('status')).toHaveTextContent('List at most 20 ingredients.')
})
})
it('fills the results progressively as the server grounds ideas, then enables the actions', async () => {
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
jobs.j1 = state('running', 'suggesting', [])
await renderPage()
enter(...INGREDIENTS)
fireEvent.change(input(), { target: { value: 'lime' } })
fireEvent.click(findButton())
// What is still typed is listed before the search.
expect(chips()).toEqual([...INGREDIENTS, 'lime'])
await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(1))
expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: [...INGREDIENTS, 'lime'], quick_only: false })
// Suggesting: five skeleton cards, no idea yet.
expect(await screen.findByText('Thinking up ideas…')).toBeInTheDocument()
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(5)
expect(screen.queryAllByRole('article')).toHaveLength(0)
// Grounding: the idea grounded so far, and skeletons for the rest; its
// actions wait for the job to end.
jobs.j1 = state('running', 'grounding', [CURRY])
await screen.findByRole('article', { name: CURRY.name }, SLOW)
expect(screen.getByText('Finding recipes for them…')).toBeInTheDocument()
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(4)
expect(within(card(CURRY.name)).getByRole('button', { name: 'Cook tonight' })).toBeDisabled()
expect(within(card(CURRY.name)).getByRole('button', { name: 'Add to Quest log' })).toBeDisabled()
jobs.j1 = state('running', 'grounding', [CURRY, BOWLS])
await screen.findByRole('article', { name: BOWLS.name }, SLOW)
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3)
jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS, ONE_PAN])
await screen.findByText('3 ideas', {}, SLOW)
expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0)
expect(screen.getAllByRole('article').map((a) => a.getAttribute('aria-label'))).toEqual([CURRY.name, BOWLS.name, ONE_PAN.name])
const curry = within(card(CURRY.name))
// The server's summary line, as it is.
expect(curry.getByText('Uses all 5 · missing: 1 lime')).toHaveClass(styles.summary!)
expect(card(CURRY.name).querySelector(`.${styles.time}`)).toHaveTextContent('30 min')
expect(card(BOWLS.name).querySelector(`.${styles.time}`)).toHaveTextContent('25 min')
expect(curry.getByRole('button', { name: 'Cook tonight' })).toBeEnabled()
expect(curry.getByRole('button', { name: 'Add to Quest log' })).toBeEnabled()
})
it('leaves the time off an idea whose recipe does not say it', async () => {
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
jobs.j1 = state('succeeded', 'done', [idea('Fried rice', 'Uses 2 of 5 · nothing missing', null)])
await renderPage()
await searched(fetchMock)
const fried = await screen.findByRole('article', { name: 'Fried rice' })
expect(within(fried).queryByText(/min/)).toBeNull()
})
describe('Any time | Quick only', () => {
it('sends quick_only, and asks again when changed after a search', async () => {
let n = 0
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) })
jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS], true)
jobs.j2 = state('succeeded', 'done', [ONE_PAN], false)
await renderPage()
// Before a search it only sets what Find ideas asks for.
fireEvent.click(screen.getByRole('radio', { name: 'Quick only' }))
expect(calls(fetchMock, FIND)).toHaveLength(0)
await searched(fetchMock, 'j1')
expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: INGREDIENTS, quick_only: true })
await screen.findByText('2 ideas')
fireEvent.click(screen.getByRole('radio', { name: 'Any time' }))
await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(2))
expect(bodyOf(calls(fetchMock, FIND)[1]!)).toEqual({ ingredients: INGREDIENTS, quick_only: false })
await screen.findByText('1 idea')
expect(screen.getAllByRole('article').map((a) => a.getAttribute('aria-label'))).toEqual([ONE_PAN.name])
})
it('refuses a result that is not for the filter asked for', async () => {
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
jobs.j1 = state('succeeded', 'done', [CURRY], true)
await renderPage()
await searched(fetchMock)
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't read the ideas.")
expect(screen.queryAllByRole('article')).toHaveLength(0)
})
})
describe('Add to Quest log', () => {
it('adds the idea and says so on its card', async () => {
const added = Promise.withResolvers<Response>()
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/1/quest-log': () => added.promise,
})
jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS])
await renderPage()
await searched(fetchMock)
await screen.findByText('2 ideas')
const bowls = within(card(BOWLS.name))
fireEvent.click(bowls.getByRole('button', { name: 'Add to Quest log' }))
expect(await bowls.findByRole('button', { name: 'Adding…' })).toBeDisabled()
added.resolve(jsonResponse(201, dinnerBody('dn-1', BOWLS.name, { status: 'quest_log', origin: 'ingredients' })))
expect(await bowls.findByRole('status')).toHaveTextContent('In the Quest log')
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/1/quest-log')).toHaveLength(1)
// The other card is untouched.
expect(within(card(CURRY.name)).getByRole('button', { name: 'Add to Quest log' })).toBeEnabled()
})
it('says an idea that came back as an existing Regular is already a Regular, not in the Quest log', async () => {
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/0/quest-log': () =>
jsonResponse(200, dinnerBody('dn-reg', 'Coconut Chicken Curry', { status: 'regular' })),
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
expect(await curry.findByRole('status')).toHaveTextContent('Already a Regular')
expect(curry.queryByText('In the Quest log')).toBeNull()
})
it('fails on a dinner whose status is neither quest_log nor regular', async () => {
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/0/quest-log': () => jsonResponse(200, dinnerBody('dn-1', CURRY.name, { status: 'suggested' })),
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
expect(await curry.findByRole('alert')).toHaveTextContent(
'POST /api/v1/ideas/j1/items/0/quest-log: status "suggested" is not one of quest_log, regular',
)
expect(curry.queryByRole('status')).toBeNull()
})
it('shows its failure on its card and lets it be pressed again', async () => {
let fail = true
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/0/quest-log': () =>
fail ? errorResponse(500, 'internal') : jsonResponse(200, dinnerBody('dn-1', CURRY.name, { status: 'quest_log' })),
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
expect(await curry.findByRole('alert')).toHaveTextContent("Couldn't add it to the Quest log. internal message")
fail = false
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
expect(await curry.findByRole('status')).toHaveTextContent('In the Quest log')
expect(curry.queryByRole('alert')).toBeNull()
})
})
describe('Cook tonight', () => {
it("puts the idea on tonight's plan", async () => {
const planned = Promise.withResolvers<Response>()
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/0/cook-tonight': () => planned.promise,
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' }))
expect(await curry.findByRole('button', { name: 'Planning…' })).toBeDisabled()
planned.resolve(jsonResponse(200, { dinner: dinnerBody('dn-1', CURRY.name, { status: 'quest_log' }), plan: tonightPlan() }))
expect(await curry.findByRole('status')).toHaveTextContent("On tonight's plan")
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/cook-tonight')).toHaveLength(1)
})
it('offers Plan this week, not an error, when the week has no plan', async () => {
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/0/cook-tonight': () => errorResponse(409, 'no_plan'),
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' }))
const link = await curry.findByRole('link', { name: 'Plan this week' })
expect(curry.getByRole('status')).toHaveTextContent('This week has no plan yet')
expect(screen.queryByRole('alert')).toBeNull()
fireEvent.click(link)
expect(screen.getByText('Plan screen')).toBeInTheDocument()
})
it('shows any other failure on its card', async () => {
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/0/cook-tonight': () => errorResponse(409, 'dinner_already_planned'),
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' }))
expect(await curry.findByRole('alert')).toHaveTextContent("Couldn't put it on tonight's plan. dinner_already_planned message")
expect(curry.queryByRole('link', { name: 'Plan this week' })).toBeNull()
expect(curry.getByRole('button', { name: 'Cook tonight' })).toBeEnabled()
})
})
describe('errors', () => {
it('shows the ErrorBanner when the search cannot start, and Retry asks again', async () => {
let n = 0
const { fetchMock, jobs } = stubServer({
[FIND]: () => (++n === 1 ? errorResponse(400, 'invalid_request') : jsonResponse(202, { jobId: 'j1' })),
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
enter(...INGREDIENTS)
fireEvent.click(findButton())
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't start looking for ideas. invalid_request message")
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('article', { name: CURRY.name })
expect(bodyOf(calls(fetchMock, FIND)[1]!)).toEqual({ ingredients: INGREDIENTS, quick_only: false })
})
it('shows a failed job with no cards, and Retry searches again', async () => {
let n = 0
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) })
jobs.j1 = state('failed', 'grounding', [], false, 'ai_failed')
jobs.j2 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't find ideas. The AI assistant couldn't come up with ideas.")
expect(screen.queryAllByRole('article')).toHaveLength(0)
expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('article', { name: CURRY.name })
expect(calls(fetchMock, FIND)).toHaveLength(2)
})
it.each([
['search_not_configured', 'Recipe search is not set up.'],
['search_failed', "The recipe search couldn't be reached."],
])('explains %s', async (code, message) => {
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
jobs.j1 = state('failed', 'grounding', [], false, code)
await renderPage()
await searched(fetchMock)
expect(await screen.findByRole('alert')).toHaveTextContent(message)
})
it('shows no ideas found with the ErrorBanner and no cards, and Retry searches again', async () => {
let n = 0
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) })
jobs.j1 = state('failed', 'grounding', [], false, 'no_ideas_found')
jobs.j2 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't find ideas. No ideas came back with a recipe to match.")
expect(screen.queryAllByRole('article')).toHaveLength(0)
expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0)
expect(screen.queryByRole('heading', { name: 'No ideas found' })).toBeNull()
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('article', { name: CURRY.name })
expect(calls(fetchMock, FIND)).toHaveLength(2)
expect(screen.queryByRole('alert')).toBeNull()
})
it('shows an unknown job with the ErrorBanner, and Retry searches again', async () => {
let n = 0
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: `j${++n}` }),
'GET /api/v1/jobs/j1': () => errorResponse(404, 'not_found'),
})
jobs.j2 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't check on the ideas. not_found message")
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('article', { name: CURRY.name })
expect(calls(fetchMock, FIND)).toHaveLength(2)
expect(screen.queryByRole('alert')).toBeNull()
})
})
})
-321
View File
@@ -1,321 +0,0 @@
import { Check, ChevronLeft } from 'lucide-react'
import { useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { Link, useNavigate } from 'react-router'
import { ApiError, useJob } from '../api/index.ts'
import { duration } from '../plan/PlanPage.tsx'
import { Button } from '../ui/Button.tsx'
import { Card } from '../ui/Card.tsx'
import { Chip } from '../ui/Chip.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { TextInput } from '../ui/TextInput.tsx'
import {
ADDED_LABEL,
FROM_INGREDIENTS_JOB_KIND,
IDEAS_KEPT,
ideasJobFailure,
ideasJobLost,
INGREDIENT_LIMIT,
MAX_INGREDIENTS,
parseIdeasResult,
useAddIdeaToQuestLog,
useCookIdeaTonight,
useFindIdeas,
type Idea,
type IdeaRef,
type IdeaStage,
type Search,
} from './api.ts'
import styles from './CookFromPantryPage.module.css'
type When = 'any' | 'quick'
const WHEN = [
{ value: 'any', label: 'Any time' },
{ value: 'quick', label: 'Quick only' },
] as const satisfies readonly { value: When; label: string }[]
// findingLines says what a running search is doing, by its stage.
const findingLines: Record<Exclude<IdeaStage, 'done'>, string> = {
suggesting: 'Thinking up ideas…',
grounding: 'Finding recipes for them…',
}
// fold is an ingredient as the chips compare it, so "Rice" repeats " rice".
function fold(ingredient: string): string {
return ingredient.trim().replace(/\s+/g, ' ').toLowerCase()
}
// IdeaCard is one idea (mock 1i): its name and time, the server's line on
// what it uses and what is missing, and its two actions, each showing its
// own progress and failure; an idea added says whether its dinner is in
// the Quest log or is already a Regular. ref is the idea in its job, null
// while the job runs: the server takes an idea only once the job has
// succeeded, as the ideas may still be reordered until then.
function IdeaCard({ idea, ideaRef }: { idea: Idea; ideaRef: IdeaRef | null }) {
const questLog = useAddIdeaToQuestLog()
const tonight = useCookIdeaTonight()
const noPlan = tonight.error instanceof ApiError && tonight.error.code === 'no_plan'
return (
<Card tone="muted" className={styles.card} role="article" aria-label={idea.name}>
<div className={styles.cardHead}>
<h2 className={styles.name}>{idea.name}</h2>
{idea.minutes_total !== null && <span className={styles.time}>{duration(idea.minutes_total)}</span>}
</div>
<p className={styles.summary}>{idea.summary}</p>
<div className={styles.actions}>
{tonight.isSuccess ? (
<span className={styles.done} role="status">
<Icon icon={Check} /> On tonight's plan
</span>
) : (
<Button
className={styles.action}
disabled={ideaRef === null || tonight.isPending}
onClick={() => tonight.mutate(ideaRef!)}
>
{tonight.isPending ? 'Planning…' : 'Cook tonight'}
</Button>
)}
{questLog.isSuccess ? (
<span className={styles.done} role="status">
<Icon icon={Check} /> {ADDED_LABEL[questLog.data.status]}
</span>
) : (
<Button
variant="outline"
className={styles.action}
disabled={ideaRef === null || questLog.isPending}
onClick={() => questLog.mutate(ideaRef!)}
>
{questLog.isPending ? 'Adding…' : 'Add to Quest log'}
</Button>
)}
</div>
{noPlan ? (
<div className={styles.noPlan} role="status">
<span>This week has no plan yet, so there's no tonight to put it on.</span>
<Link to="/plan" className={styles.planLink}>
Plan this week
</Link>
</div>
) : (
tonight.isError && (
<p className={styles.failure} role="alert">
Couldn't put it on tonight's plan. {tonight.error.message}
</p>
)
)}
{questLog.isError && (
<p className={styles.failure} role="alert">
Couldn't add it to the Quest log. {questLog.error.message}
</p>
)}
</Card>
)
}
// Skeletons stand in for the count ideas still to come.
function Skeletons({ count }: { count: number }) {
return Array.from({ length: count }, (_, i) => <Skeleton key={i} data-testid="skeleton-card" className={styles.skeletonCard} />)
}
// CookFromPantryPage is mock 1i, Cook from what I have: the ingredients on
// hand as chips (Enter or a comma adds one), Any time | Quick only, and
// Find ideas, which lists what is still typed, so it is enabled by a chip
// or by text typed and not yet added, and starts an ideas_from_ingredients
// job and shows its ideas as the server grounds them, skeleton cards
// holding the places of those still to come. Changing Any time | Quick
// only after a search asks again: the server, not the screen, says what is
// quick.
export function CookFromPantryPage() {
const navigate = useNavigate()
const inputRef = useRef<HTMLInputElement>(null)
const [ingredients, setIngredients] = useState<string[]>([])
const [draft, setDraft] = useState('')
const [notice, setNotice] = useState<string | null>(null)
const [when, setWhen] = useState<When>('any')
// The search last asked for, which Retry asks again, and its job.
const [asked, setAsked] = useState<Search | null>(null)
const [jobId, setJobId] = useState<string | null>(null)
const findIdeas = useFindIdeas()
const job = useJob<unknown>(FROM_INGREDIENTS_JOB_KIND, jobId)
// add lists entries after the chips there are, saying why it leaves out
// any it cannot list: the server would refuse a blank, overlong or
// repeated ingredient, or one past the most it takes.
function add(list: string[], entries: string[]): string[] {
const next = [...list]
for (const entry of entries.map((e) => e.trim().replace(/\s+/g, ' '))) {
if (entry === '') continue
if (next.some((ingredient) => fold(ingredient) === fold(entry))) {
setNotice(`${entry} is listed already.`)
} else if (entry.length > INGREDIENT_LIMIT) {
setNotice(`An ingredient can be at most ${INGREDIENT_LIMIT} characters.`)
} else if (next.length === MAX_INGREDIENTS) {
setNotice(`List at most ${MAX_INGREDIENTS} ingredients.`)
} else {
next.push(entry)
}
}
return next
}
function onDraftChange(value: string) {
setNotice(null)
// A comma ends an ingredient, also within pasted text.
const parts = value.split(',')
const rest = parts.pop()!
if (parts.length > 0) setIngredients(add(ingredients, parts))
setDraft(rest)
}
function onDraftKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
event.preventDefault()
setNotice(null)
setIngredients(add(ingredients, [draft]))
setDraft('')
} else if (event.key === 'Backspace' && draft === '' && ingredients.length > 0) {
setNotice(null)
setIngredients(ingredients.slice(0, -1))
}
}
// search asks for ideas. Only the latest search's job is followed: mutate
// calls back for the latest call only.
function search(next: Search) {
setAsked(next)
setJobId(null)
findIdeas.mutate(next, { onSuccess: setJobId })
}
// find lists what is still typed, then searches the chips.
function find() {
setNotice(null)
const list = add(ingredients, [draft])
setIngredients(list)
setDraft('')
if (list.length > 0) search({ ingredients: list, quickOnly: when === 'quick' })
}
function onWhenChange(value: When) {
setWhen(value)
if (asked !== null && ingredients.length > 0) search({ ingredients, quickOnly: value === 'quick' })
}
const retry = () => search(asked!)
// The job's result as far as it has gone, or why it cannot be read.
const found = useMemo(() => {
if (job.result === null || asked === null) return { result: null, error: null }
try {
return { result: parseIdeasResult(`GET /api/v1/jobs/${jobId}`, asked.quickOnly, job.result), error: null }
} catch (error) {
return { result: null, error: error as Error }
}
}, [job.result, jobId, asked])
let results = null
if (findIdeas.isPending) {
results = (
<section aria-busy="true" aria-label="Ideas loading" className={styles.results}>
<p className={styles.count}>{findingLines.suggesting}</p>
<Skeletons count={IDEAS_KEPT} />
</section>
)
} else if (findIdeas.isError) {
results = <ErrorBanner onRetry={retry}>Couldn't start looking for ideas. {findIdeas.error.message}</ErrorBanner>
} else if (jobId !== null && job.status === 'failed') {
results = <ErrorBanner onRetry={retry}>Couldn't find ideas. {ideasJobFailure(job.error!.message)}</ErrorBanner>
} else if (found.error !== null) {
results = <ErrorBanner onRetry={retry}>Couldn't read the ideas. {found.error.message}</ErrorBanner>
} else if (jobId !== null) {
const ideas = found.result?.ideas ?? []
const done = job.status === 'succeeded'
// A job not found will not be found by asking again: Retry searches
// again. Any other failed poll is asked again.
const lost = ideasJobLost(job.error)
results = (
<>
{job.error !== null && (
<ErrorBanner onRetry={lost ? retry : job.poll}>Couldn't check on the ideas. {job.error.message}</ErrorBanner>
)}
<section aria-busy={!done} aria-label="Ideas" className={styles.results}>
<p className={styles.count}>
{done
? `${ideas.length} ${ideas.length === 1 ? 'idea' : 'ideas'}`
: findingLines[found.result?.stage === 'grounding' ? 'grounding' : 'suggesting']}
</p>
{ideas.map((idea, index) => (
<IdeaCard key={`${jobId}:${index}`} idea={idea} ideaRef={done ? { jobId, index } : null} />
))}
{!done && <Skeletons count={Math.max(IDEAS_KEPT - ideas.length, 0)} />}
</section>
</>
)
}
return (
<div className={styles.page}>
<header className={styles.header}>
<IconButton label="Back to Home" onClick={() => void navigate('/')}>
<Icon icon={ChevronLeft} size="nav" />
</IconButton>
<h1 className={styles.title}>Cook from what I have</h1>
</header>
{/* A tap anywhere on the field types in it. */}
<div className={styles.field} onClick={() => inputRef.current?.focus()}>
<ul className={styles.chips} aria-label="Ingredients on hand">
{ingredients.map((ingredient, i) => (
<li key={ingredient}>
<Chip
label={ingredient}
className={styles.chip}
onRemove={() => {
setNotice(null)
setIngredients(ingredients.filter((_, j) => j !== i))
inputRef.current?.focus()
}}
/>
</li>
))}
</ul>
<div className={styles.entry}>
<TextInput
ref={inputRef}
label="Add ingredient"
labelHidden
placeholder="Add ingredient…"
enterKeyHint="enter"
autoComplete="off"
className={styles.input}
value={draft}
onChange={(event) => onDraftChange(event.target.value)}
onKeyDown={onDraftKeyDown}
/>
</div>
</div>
{notice !== null && (
<p className={styles.notice} role="status">
{notice}
</p>
)}
<div className={styles.controls}>
<Segmented label="Which dinners" options={WHEN} value={when} onValueChange={onWhenChange} className={styles.when} />
<Button
variant="dark"
className={styles.find}
disabled={(ingredients.length < 1 && draft.trim() === '') || findIdeas.isPending}
onClick={find}
>
Find ideas
</Button>
</div>
{results}
</div>
)
}
-166
View File
@@ -1,166 +0,0 @@
/* 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;
}
-536
View File
@@ -1,536 +0,0 @@
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { declaredStyle } from '../test/cssRules.ts'
import { dinnerBody } from '../test/dinners.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { QuickDinnersPage } from './QuickDinnersPage.tsx'
import styles from './QuickDinnersPage.module.css'
const QUICK = 'POST /api/v1/ideas/quick'
// idea is a grounded Quick dinner idea as the job's result carries it.
function idea(name: string, method: string, minutes: number | null, difficulty: string) {
return {
name,
method,
minutes_total: minutes,
difficulty,
quick: true,
recipe_id: `r-${name}`,
photo_url: `/media/photos/p-${name.length}-400.jpg`,
source_url: 'https://recipes.example/x',
site_name: 'Recipes',
}
}
// The ideas of mock 1j, and more for later pages and the Crock pot tab.
const ORZO = idea('Lemon garlic shrimp orzo', 'stovetop', 25, 'medium')
const QUESADILLAS = idea('Chicken quesadillas', 'stovetop', 20, 'easy')
const BROCCOLI = idea('Beef & broccoli', 'stovetop', 30, 'easy')
const GNOCCHI = idea('Pesto gnocchi & peas', 'stovetop', 15, 'easy')
const CHILI = idea('Crock pot chili', 'crockpot', 360, 'easy')
const CARNITAS = idea('Pork carnitas', 'crockpot', null, 'medium')
type JobBody = { status: string; result: unknown; error: string | null }
// state is a quick_ideas job's state at stage with ideas.
function state(status: string, stage: string, kind: string, ideas: unknown[], error: string | null = null): JobBody {
return { status, result: { stage, kind, exclude: [], ideas, dropped: 0 }, error }
}
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
// stubServer answers "METHOD path" requests from responders, with /me; any
// other request fails the test. jobs holds each job's state, which every
// poll of it answers, so a test moves a job on by changing it.
function stubServer(responders: Record<string, Responder>) {
const jobs: Record<string, JobBody> = {}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
if (key === 'GET /api/v1/me') return Promise.resolve(jsonResponse(200, ME))
const job = /^GET \/api\/v1\/jobs\/([^/]+)$/.exec(key)
if (job !== null && !Object.hasOwn(responders, key)) {
const id = job[1]!
const body = jobs[id]
if (body === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(jsonResponse(200, { id, kind: 'quick_ideas', ...body }))
}
const respond = Object.hasOwn(responders, key) ? responders[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init))
})
vi.stubGlobal('fetch', fetchMock)
return { fetchMock, jobs }
}
// quickServer answers each POST /ideas/quick with the next of jobIds.
function quickServer(jobIds: string[], responders: Record<string, Responder> = {}) {
let n = 0
return stubServer({
[QUICK]: () => {
const jobId = jobIds[n++]
if (jobId === undefined) throw new Error('unexpected quick search')
return jsonResponse(202, { jobId })
},
...responders,
})
}
// calls are the requests sent as "METHOD path", in order.
function calls(fetchMock: ReturnType<typeof stubServer>['fetchMock'], key: string) {
return fetchMock.mock.calls.filter(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}` === key)
}
// searches are the POST /ideas/quick bodies sent, in order.
function searches(fetchMock: ReturnType<typeof stubServer>['fetchMock']) {
return calls(fetchMock, QUICK).map((call) => JSON.parse(String(call[1]?.body)) as unknown)
}
async function renderPage(queryClient: QueryClient = createQueryClient()) {
const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/quick']}>
<Routes>
<Route path="/quick" element={<QuickDinnersPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
function tab(name: string) {
return within(screen.getByRole('radiogroup', { name: 'Kind of Quick dinner' })).getByRole('radio', { name })
}
function row(name: string) {
return screen.getByRole('listitem', { name })
}
function rowNames() {
return within(screen.getByRole('list', { name: 'Quick dinners' }))
.queryAllByRole('heading', { level: 2 })
.map((heading) => heading.textContent)
}
function addButton(name: string) {
return within(row(name)).getByRole('button', { name: `Add ${name} to the Quest log` })
}
// showMore waits for Show me more, shown once a page is kept.
function showMore() {
return screen.findByRole('button', { name: 'Show me more' })
}
const SLOW = { timeout: 3000 }
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('QuickDinnersPage', () => {
it('renders mock 1j: zap header, tabs with 30 min or less chosen, its helper, and its first page', async () => {
const { fetchMock, jobs } = quickServer(['j1'])
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS, BROCCOLI, GNOCCHI])
const { container } = await renderPage()
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
const zap = container.querySelector(`.${styles.zap}`)!
expect(declaredStyle(zap).width).toBe('36px')
expect(declaredStyle(zap).background).toBe('var(--color-accent)')
expect(zap.querySelector('svg')).toHaveAttribute('stroke-width', '2.75')
expect(tab('30 min or less')).toBeChecked()
expect(tab('Crock pot')).not.toBeChecked()
expect(screen.getByText('Prep to table in 30 minutes or less, picked from your tastes.')).toBeInTheDocument()
await screen.findByRole('listitem', { name: ORZO.name })
expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name, GNOCCHI.name])
expect(searches(fetchMock)).toEqual([{ kind: 'under30', exclude: [] }])
const orzo = within(row(ORZO.name))
expect(orzo.getByText('25 min · Medium')).toHaveClass(styles.meta!)
expect(orzo.getByRole('img', { name: ORZO.name })).toHaveAttribute('src', '/media/photos/p-24-400.jpg')
expect(declaredStyle(row(ORZO.name)).background).toBe('var(--color-neutral-100)')
expect(declaredStyle(row(ORZO.name)).borderRadius).toBe('24px')
expectMinHitArea(addButton(ORZO.name))
expect(addButton(ORZO.name)).toBeEnabled()
expectMinHitArea(await showMore())
expect(declaredStyle(await showMore()).color).toBe('var(--color-accent-700)')
})
it('fills the rows progressively as the server grounds ideas, adding only once the page is found', async () => {
const { jobs } = quickServer(['j1'])
jobs.j1 = state('running', 'suggesting', 'under30', [])
await renderPage()
await waitFor(() => expect(screen.getAllByTestId('skeleton-row')).toHaveLength(6))
expect(screen.getByRole('list', { name: 'Quick dinners' })).toHaveAttribute('aria-busy', 'true')
jobs.j1 = state('running', 'grounding', 'under30', [ORZO])
await screen.findByRole('listitem', { name: ORZO.name }, SLOW)
expect(screen.getAllByTestId('skeleton-row')).toHaveLength(5)
expect(addButton(ORZO.name)).toBeDisabled()
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
await screen.findByRole('listitem', { name: QUESADILLAS.name }, SLOW)
expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0)
expect(addButton(ORZO.name)).toBeEnabled()
expect(await showMore()).toBeInTheDocument()
expect(screen.getByRole('list', { name: 'Quick dinners' })).toHaveAttribute('aria-busy', 'false')
})
describe('tabs', () => {
it('searches each tab when first shown, keeps what each found, and says what each finds', async () => {
const { fetchMock, jobs } = quickServer(['j1', 'j2'])
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI, CARNITAS])
await renderPage()
await screen.findByRole('listitem', { name: ORZO.name })
fireEvent.click(tab('Crock pot'))
expect(tab('Crock pot')).toBeChecked()
expect(screen.getByText(/in the crock pot.*picked from your tastes\./)).toBeInTheDocument()
await screen.findByRole('listitem', { name: CHILI.name })
expect(rowNames()).toEqual([CHILI.name, CARNITAS.name])
expect(searches(fetchMock)).toEqual([
{ kind: 'under30', exclude: [] },
{ kind: 'crockpot', exclude: [] },
])
// Each tab's ideas are kept: going back asks for nothing.
fireEvent.click(tab('30 min or less'))
expect(rowNames()).toEqual([ORZO.name])
fireEvent.click(tab('Crock pot'))
expect(rowNames()).toEqual([CHILI.name, CARNITAS.name])
expect(calls(fetchMock, QUICK)).toHaveLength(2)
})
it('shows a crock-pot idea as "Crock pot · 6 hr · Easy", without a time when the recipe has none', async () => {
const { jobs } = quickServer(['j1', 'j2'])
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI, CARNITAS])
await renderPage()
fireEvent.click(tab('Crock pot'))
await screen.findByRole('listitem', { name: CHILI.name })
expect(within(row(CHILI.name)).getByText('Crock pot · 6 hr · Easy')).toBeInTheDocument()
expect(within(row(CARNITAS.name)).getByText('Crock pot · Medium')).toBeInTheDocument()
})
it('keeps a tab for the session, also when the screen is left and opened again', async () => {
const { fetchMock, jobs } = quickServer(['j1'])
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
const queryClient = createQueryClient()
const { unmount } = await renderPage(queryClient)
await screen.findByRole('listitem', { name: ORZO.name })
unmount()
await renderPage(queryClient)
expect(rowNames()).toEqual([ORZO.name])
expect(calls(fetchMock, QUICK)).toHaveLength(1)
})
it('refuses a result that is not of the tab asked for', async () => {
const { jobs } = quickServer(['j1'])
jobs.j1 = state('succeeded', 'done', 'crockpot', [CHILI])
await renderPage()
expect(await screen.findByRole('alert')).toHaveTextContent(
"Couldn't read the Quick dinners. GET /api/v1/jobs/j1: result is not Quick dinner ideas of kind under30",
)
expect(screen.queryByRole('listitem', { name: CHILI.name })).toBeNull()
})
})
describe('Add to Quest log', () => {
it('adds the idea with its "+", which becomes a check, kept across tabs', async () => {
const added = Promise.withResolvers<Response>()
const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'POST /api/v1/ideas/j1/items/1/quest-log': () => added.promise })
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
await renderPage()
await screen.findByRole('listitem', { name: QUESADILLAS.name })
fireEvent.click(addButton(QUESADILLAS.name))
await waitFor(() => expect(addButton(QUESADILLAS.name)).toBeDisabled())
added.resolve(jsonResponse(201, dinnerBody('dn-1', QUESADILLAS.name, { status: 'quest_log', origin: 'quick' })))
const quesadillas = within(row(QUESADILLAS.name))
expect(await quesadillas.findByRole('status')).toContainElement(quesadillas.getByRole('img', { name: 'In the Quest log' }))
expect(quesadillas.queryByRole('button')).toBeNull()
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/1/quest-log')).toHaveLength(1)
// The other row is untouched.
expect(addButton(ORZO.name)).toBeEnabled()
fireEvent.click(tab('Crock pot'))
await screen.findByRole('listitem', { name: CHILI.name })
fireEvent.click(tab('30 min or less'))
expect(within(row(QUESADILLAS.name)).getByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
})
it('labels the check Already a Regular when the idea came back as an existing Regular', async () => {
const { fetchMock, jobs } = quickServer(['j1', 'j2'], {
'POST /api/v1/ideas/j1/items/0/quest-log': () => jsonResponse(200, dinnerBody('dn-reg', ORZO.name, { status: 'regular' })),
})
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
await renderPage()
await screen.findByRole('listitem', { name: ORZO.name })
fireEvent.click(addButton(ORZO.name))
const orzo = within(row(ORZO.name))
expect(await orzo.findByRole('status')).toContainElement(orzo.getByRole('img', { name: 'Already a Regular' }))
expect(orzo.queryByRole('img', { name: 'In the Quest log' })).toBeNull()
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/quest-log')).toHaveLength(1)
// Kept across tabs as what it is.
fireEvent.click(tab('Crock pot'))
await screen.findByRole('listitem', { name: CHILI.name })
fireEvent.click(tab('30 min or less'))
expect(within(row(ORZO.name)).getByRole('img', { name: 'Already a Regular' })).toBeInTheDocument()
})
it('marks an add that settles while another tab is shown', async () => {
const added = Promise.withResolvers<Response>()
const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'POST /api/v1/ideas/j1/items/0/quest-log': () => added.promise })
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
const queryClient = createQueryClient()
await renderPage(queryClient)
await screen.findByRole('listitem', { name: ORZO.name })
fireEvent.click(addButton(ORZO.name))
await waitFor(() => expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/quest-log')).toHaveLength(1))
fireEvent.click(tab('Crock pot'))
await screen.findByRole('listitem', { name: CHILI.name })
added.resolve(jsonResponse(201, dinnerBody('dn-1', ORZO.name, { status: 'quest_log', origin: 'quick' })))
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
fireEvent.click(tab('30 min or less'))
expect(within(row(ORZO.name)).getByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
expect(addButton(QUESADILLAS.name)).toBeEnabled()
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/quest-log')).toHaveLength(1)
})
it('shows its failure in its row and lets it be pressed again', async () => {
let fail = true
const { jobs } = quickServer(['j1'], {
'POST /api/v1/ideas/j1/items/0/quest-log': () =>
fail ? errorResponse(500, 'internal') : jsonResponse(200, dinnerBody('dn-1', ORZO.name, { status: 'quest_log' })),
})
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
await renderPage()
await screen.findByRole('listitem', { name: ORZO.name })
const orzo = within(row(ORZO.name))
fireEvent.click(addButton(ORZO.name))
expect(await orzo.findByRole('alert')).toHaveTextContent("Couldn't add it to the Quest log. internal message")
fail = false
fireEvent.click(addButton(ORZO.name))
expect(await orzo.findByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
expect(orzo.queryByRole('alert')).toBeNull()
})
})
describe('Show me more', () => {
it('asks for the same kind excluding every name shown, and adds from the page that found it', async () => {
const { fetchMock, jobs } = quickServer(['j1', 'j2', 'j3'], {
'POST /api/v1/ideas/j2/items/0/quest-log': () => jsonResponse(201, dinnerBody('dn-1', BROCCOLI.name, { status: 'quest_log' })),
})
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
jobs.j2 = state('running', 'grounding', 'under30', [BROCCOLI])
jobs.j3 = state('succeeded', 'done', 'under30', [GNOCCHI])
await renderPage()
await screen.findByRole('listitem', { name: QUESADILLAS.name })
fireEvent.click(await showMore())
await screen.findByRole('listitem', { name: BROCCOLI.name }, SLOW)
// The first page stays while the next fills.
expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name])
expect(screen.getAllByTestId('skeleton-row')).toHaveLength(5)
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
jobs.j2 = state('succeeded', 'done', 'under30', [BROCCOLI])
await screen.findByRole('button', { name: 'Show me more' }, SLOW)
fireEvent.click(addButton(BROCCOLI.name))
expect(await within(row(BROCCOLI.name)).findByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
fireEvent.click(await showMore())
await screen.findByRole('listitem', { name: GNOCCHI.name })
expect(searches(fetchMock)).toEqual([
{ kind: 'under30', exclude: [] },
{ kind: 'under30', exclude: [ORZO.name, QUESADILLAS.name] },
{ kind: 'under30', exclude: [ORZO.name, QUESADILLAS.name, BROCCOLI.name] },
])
expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name, GNOCCHI.name])
})
it('says every idea has been shown when a later page finds none', async () => {
const { fetchMock, jobs } = quickServer(['j1', 'j2'])
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
jobs.j2 = state('failed', 'grounding', 'under30', [], 'no_ideas_found')
await renderPage()
await screen.findByRole('listitem', { name: ORZO.name })
fireEvent.click(await showMore())
expect(await screen.findByRole('status')).toHaveTextContent("That's every Quick dinner for now.")
expect(screen.queryByRole('alert')).toBeNull()
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
expect(rowNames()).toEqual([ORZO.name])
expect(calls(fetchMock, QUICK)).toHaveLength(2)
})
it('shows a later page that fails with the ErrorBanner, keeping the rows, and Retry asks again', async () => {
const { fetchMock, jobs } = quickServer(['j1', 'j2', 'j3'])
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
jobs.j2 = state('failed', 'grounding', 'under30', [], 'search_failed')
jobs.j3 = state('succeeded', 'done', 'under30', [QUESADILLAS])
await renderPage()
await screen.findByRole('listitem', { name: ORZO.name })
fireEvent.click(await showMore())
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't find Quick dinners. The recipe search couldn't be reached.")
expect(rowNames()).toEqual([ORZO.name])
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('listitem', { name: QUESADILLAS.name })
expect(searches(fetchMock)[2]).toEqual({ kind: 'under30', exclude: [ORZO.name] })
expect(screen.queryByRole('alert')).toBeNull()
})
})
describe('errors', () => {
it('shows the ErrorBanner when the search cannot start, and Retry asks again', async () => {
let n = 0
const { fetchMock, jobs } = stubServer({
[QUICK]: () => (++n === 1 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: 'j1' })),
})
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message")
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('listitem', { name: ORZO.name })
expect(searches(fetchMock)).toEqual([
{ kind: 'under30', exclude: [] },
{ kind: 'under30', exclude: [] },
])
})
it('keeps a first page that cannot start across a tab switch, asking again only on Retry', async () => {
let under30 = 0
const { fetchMock, jobs } = stubServer({
[QUICK]: (init) => {
const { kind } = JSON.parse(String(init?.body)) as { kind: string }
if (kind === 'crockpot') return jsonResponse(202, { jobId: 'j2' })
return ++under30 === 1 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: 'j1' })
},
})
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
await renderPage()
await screen.findByRole('alert')
fireEvent.click(tab('Crock pot'))
await screen.findByRole('listitem', { name: CHILI.name })
fireEvent.click(tab('30 min or less'))
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message")
expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0)
expect(searches(fetchMock)).toEqual([
{ kind: 'under30', exclude: [] },
{ kind: 'crockpot', exclude: [] },
])
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('listitem', { name: ORZO.name })
expect(screen.queryByRole('alert')).toBeNull()
expect(calls(fetchMock, QUICK)).toHaveLength(3)
})
it('keeps a later page that cannot start across a tab switch, asking again only on Retry', async () => {
let under30 = 0
const { fetchMock, jobs } = stubServer({
[QUICK]: (init) => {
const { kind } = JSON.parse(String(init?.body)) as { kind: string }
if (kind === 'crockpot') return jsonResponse(202, { jobId: 'j2' })
under30++
return under30 === 2 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: under30 === 1 ? 'j1' : 'j3' })
},
})
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
jobs.j3 = state('succeeded', 'done', 'under30', [QUESADILLAS])
await renderPage()
await screen.findByRole('listitem', { name: ORZO.name })
fireEvent.click(await showMore())
await screen.findByRole('alert')
fireEvent.click(tab('Crock pot'))
await screen.findByRole('listitem', { name: CHILI.name })
fireEvent.click(tab('30 min or less'))
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message")
expect(rowNames()).toEqual([ORZO.name])
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
expect(calls(fetchMock, QUICK)).toHaveLength(3)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('listitem', { name: QUESADILLAS.name })
expect(searches(fetchMock)[3]).toEqual({ kind: 'under30', exclude: [ORZO.name] })
expect(screen.queryByRole('alert')).toBeNull()
})
it.each([
['ai_failed', "The AI assistant couldn't come up with ideas."],
['no_ideas_found', 'No ideas came back with a recipe to match.'],
['search_not_configured', 'Recipe search is not set up.'],
['internal_error', 'Something went wrong on the server.'],
])('shows a first page failed with %s with the ErrorBanner and no rows, and Retry searches again', async (code, message) => {
const { fetchMock, jobs } = quickServer(['j1', 'j2'])
jobs.j1 = state('failed', 'grounding', 'under30', [], code)
jobs.j2 = state('succeeded', 'done', 'under30', [ORZO])
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent(`Couldn't find Quick dinners. ${message}`)
expect(screen.queryAllByRole('listitem', { name: /./ })).toHaveLength(0)
expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0)
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('listitem', { name: ORZO.name })
expect(calls(fetchMock, QUICK)).toHaveLength(2)
expect(screen.queryByRole('alert')).toBeNull()
})
it('shows an unknown job with the ErrorBanner, and Retry searches again', async () => {
const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'GET /api/v1/jobs/j1': () => errorResponse(404, 'not_found') })
jobs.j2 = state('succeeded', 'done', 'under30', [ORZO])
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't check on the Quick dinners. not_found message")
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('listitem', { name: ORZO.name })
expect(calls(fetchMock, QUICK)).toHaveLength(2)
expect(screen.queryByRole('alert')).toBeNull()
})
})
})
-266
View File
@@ -1,266 +0,0 @@
import { Check, Plus, RefreshCw, Zap } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useIsMutating } from '@tanstack/react-query'
import { useJob } from '../api/index.ts'
import { DIFFICULTY_LABEL } from '../discover/DeckCard.tsx'
import { duration } from '../plan/PlanPage.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Photo } from '../ui/Photo.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import {
ADDED_LABEL,
ideasJobFailure,
ideasJobLost,
MAX_EXCLUDE,
parseQuickResult,
QUICK_IDEAS_KEPT,
QUICK_JOB_KIND,
quickTabKey,
shownNames,
useAddIdeaToQuestLog,
useFindQuickIdeas,
useQuickTab,
type AddedDinner,
type AddedStatus,
type IdeaRef,
type QuickIdea,
type QuickKind,
} from './api.ts'
import styles from './QuickDinnersPage.module.css'
const TABS = [
{ value: 'crockpot', label: 'Crock pot' },
{ value: 'under30', label: '30 min or less' },
] as const satisfies readonly { value: QuickKind; label: string }[]
// helpers says what each tab finds.
const helpers: Record<QuickKind, string> = {
crockpot: 'Set it going in the crock pot and come back to dinner, picked from your tastes.',
under30: 'Prep to table in 30 minutes or less, picked from your tastes.',
}
// The server's code for a search that found nothing: on a later page, it
// means the tab has shown every idea there is.
const NO_IDEAS_FOUND = 'no_ideas_found'
// meta is a row's "25 min · Medium" line: "Crock pot" first for a crock-pot
// idea, and the time only when the recipe says it.
function meta(idea: QuickIdea): string {
return [
idea.method === 'crockpot' && methodLabels.crockpot,
idea.minutes_total !== null && duration(idea.minutes_total),
DIFFICULTY_LABEL[idea.difficulty],
]
.filter((part): part is string => typeof part === 'string')
.join(' · ')
}
// QuickRow is one idea (mock 1j): its washed photo, name and meta line, and
// the teal "+" that adds it to the Quest log, then a check labelled with
// where it went: added is the status of the dinner it came back as.
// ideaRef is null while its job runs: the server takes an idea only once
// the job has succeeded. onAdded marks the idea added, also if the row has
// unmounted before the add settles.
function QuickRow({
idea,
ideaRef,
added,
onAdded,
}: {
idea: QuickIdea
ideaRef: IdeaRef | null
added: AddedStatus | undefined
onAdded: (idea: IdeaRef, dinner: AddedDinner) => void
}) {
const questLog = useAddIdeaToQuestLog(onAdded)
return (
<li className={styles.row} aria-label={idea.name}>
<Photo id={idea.photo_id} name={idea.name} radius="50%" sizes="64px" className={styles.photo} />
<div className={styles.text}>
<h2 className={styles.name}>{idea.name}</h2>
<span className={styles.meta}>{meta(idea)}</span>
{questLog.isError && (
<p className={styles.failure} role="alert">
Couldn't add it to the Quest log. {questLog.error.message}
</p>
)}
</div>
{added !== undefined ? (
<span className={styles.added} role="status">
<Icon icon={Check} size="nav" label={ADDED_LABEL[added]} />
</span>
) : (
<IconButton
label={`Add ${idea.name} to the Quest log`}
tone="teal"
className={styles.add}
disabled={ideaRef === null || questLog.isPending}
onClick={() => questLog.mutate(ideaRef!)}
>
<Icon icon={Plus} size="nav" />
</IconButton>
)}
</li>
)
}
// Skeletons stand in for the count ideas still to come.
function Skeletons({ count }: { count: number }) {
return Array.from({ length: count }, (_, i) => (
<li key={i} aria-hidden="true">
<Skeleton data-testid="skeleton-row" className={styles.skeletonRow} />
</li>
))
}
// QuickTabPanel is one tab's ideas: its pages so far, the page being
// found, filling as the server grounds it, and Show me more, which asks for
// a page excluding every name shown. The tab's first page is asked for when
// the tab is first shown; what it finds is kept for the session.
function QuickTabPanel({ kind }: { kind: QuickKind }) {
const [tab, update] = useQuickTab(kind)
const { mutate } = useFindQuickIdeas(kind)
// A page being asked for, also by this tab before it was last shown.
const starting = useIsMutating({ mutationKey: quickTabKey(kind) }) > 0
const job = useJob<unknown>(QUICK_JOB_KIND, tab.jobId)
const names = shownNames(tab)
// ask asks for the next page, excluding every name shown; Retry asks
// again for the page that failed. A page that could not start stays the
// tab's startError until Retry: nothing asks again by itself.
const ask = () => mutate(names)
// The first page, asked for once.
const asked = useRef(false)
const fresh = tab.pages.length === 0 && tab.jobId === null && !tab.ended && tab.startError === null && !starting
useEffect(() => {
if (!fresh || asked.current) return
asked.current = true
mutate([])
}, [fresh, mutate])
// The job's result as far as it has gone, or why it cannot be read.
const found = useMemo(() => {
if (job.result === null || tab.jobId === null) return { result: null, error: null }
try {
return { result: parseQuickResult(`GET /api/v1/jobs/${tab.jobId}`, kind, job.result), error: null }
} catch (error) {
return { result: null, error: error as Error }
}
}, [job.result, tab.jobId, kind])
const failure = job.status === 'failed' ? job.error!.message : null
// A later page that finds nothing means there is nothing more to show.
const exhausted = failure === NO_IDEAS_FOUND && tab.pages.length > 0
// A page found is kept with the tab's pages; a page that ends the tab
// ends it.
const jobId = tab.jobId
const ideas = job.status === 'succeeded' ? (found.result?.ideas ?? null) : null
useEffect(() => {
if (jobId === null) return
if (ideas !== null) update((t) => (t.jobId === jobId ? { ...t, pages: [...t.pages, { jobId, ideas }], jobId: null } : t))
else if (exhausted) update((t) => (t.jobId === jobId ? { ...t, jobId: null, ended: true } : t))
}, [jobId, ideas, exhausted, update])
const markAdded = ({ jobId, index }: IdeaRef, { status }: AddedDinner) =>
update((t) => ({ ...t, added: { ...t.added, [`${jobId}:${index}`]: status } }))
// The rows of every page found, then of the page being found, in one
// keyed list: a page kept with the tab's pages keeps its rows, and with
// them any add still under way.
const rows: { idea: QuickIdea; ref: IdeaRef | null; key: string }[] = tab.pages.flatMap((page) =>
page.ideas.map((idea, index) => ({ idea, ref: { jobId: page.jobId, index }, key: `${page.jobId}:${index}` })),
)
let next = null
if (starting) {
next = <Skeletons count={QUICK_IDEAS_KEPT} />
} else if (tab.startError !== null) {
next = (
<li>
<ErrorBanner onRetry={ask}>Couldn't start looking for Quick dinners. {tab.startError.message}</ErrorBanner>
</li>
)
} else if (jobId !== null && failure !== null && !exhausted) {
next = (
<li>
<ErrorBanner onRetry={ask}>Couldn't find Quick dinners. {ideasJobFailure(failure)}</ErrorBanner>
</li>
)
} else if (found.error !== null) {
next = (
<li>
<ErrorBanner onRetry={ask}>Couldn't read the Quick dinners. {found.error.message}</ErrorBanner>
</li>
)
} else if (jobId !== null && !exhausted) {
const shown = found.result?.ideas ?? []
const done = job.status === 'succeeded'
shown.forEach((idea, index) => rows.push({ idea, ref: done ? { jobId, index } : null, key: `${jobId}:${index}` }))
// A job not found will not be found by asking again: Retry asks for the
// page again. Any other failed poll is asked again.
const lost = ideasJobLost(job.error)
next = (
<>
{!done && <Skeletons count={Math.max(QUICK_IDEAS_KEPT - shown.length, 0)} />}
{job.error !== null && (
<li>
<ErrorBanner onRetry={lost ? ask : job.poll}>Couldn't check on the Quick dinners. {job.error.message}</ErrorBanner>
</li>
)}
</>
)
}
// Show me more once the tab is between pages; the server takes at most
// MAX_EXCLUDE names to leave out, so past that there is no more to ask.
const between = !starting && tab.startError === null && jobId === null && tab.pages.length > 0
const allShown = tab.ended || names.length > MAX_EXCLUDE
const busy = starting || (jobId !== null && failure === null && found.error === null && job.status !== 'succeeded')
return (
<section aria-label={TABS.find((t) => t.value === kind)!.label} className={styles.panel}>
<p className={styles.helper}>{helpers[kind]}</p>
<ul aria-busy={busy} aria-label="Quick dinners" className={styles.rows}>
{rows.map(({ idea, ref, key }) => (
<QuickRow key={key} idea={idea} ideaRef={ref} added={tab.added[key]} onAdded={markAdded} />
))}
{next}
</ul>
{between &&
(allShown ? (
<p className={styles.end} role="status">
That's every Quick dinner for now.
</p>
) : (
<button type="button" className={styles.more} onClick={ask}>
<Icon icon={RefreshCw} /> Show me more
</button>
))}
</section>
)
}
// QuickDinnersPage is mock 1j, Quick dinners: a Crock pot | 30 min or less
// tab for each kind of Quick dinner, each a quick_ideas search of its own
// whose ideas fill as the server grounds them, kept for the session.
export function QuickDinnersPage() {
const [kind, setKind] = useState<QuickKind>('under30')
return (
<div className={styles.page}>
<header className={styles.header}>
<span className={styles.zap}>
<Icon icon={Zap} className={styles.zapIcon} />
</span>
<h1 className={styles.title}>Quick dinners</h1>
</header>
<Segmented label="Kind of Quick dinner" options={TABS} value={kind} onValueChange={setKind} className={styles.tabs} />
<QuickTabPanel key={kind} kind={kind} />
</div>
)
}
-363
View File
@@ -1,363 +0,0 @@
import { useCallback } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ApiError, useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { DIFFICULTIES, METHODS, parseDinner, photoId, type Difficulty, type Method, type Regular } from '../dinners/api.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
import { parsePlan, planKey } from '../plan/api.ts'
export const IDEAS_PATH = '/api/v1/ideas'
export const FROM_INGREDIENTS_PATH = `${IDEAS_PATH}/from-ingredients`
// FROM_INGREDIENTS_JOB_KIND is the kind of the job finding ideas from the
// ingredients on hand.
export const FROM_INGREDIENTS_JOB_KIND = 'ideas_from_ingredients'
// IDEAS_KEPT is the most ideas a job keeps (skills.IdeasKept), so the most
// cards a search fills.
export const IDEAS_KEPT = 5
// MAX_INGREDIENTS is the most ingredients the household may list, and
// INGREDIENT_LIMIT the most characters one may have
// (skills.MaxIngredients, skills.IngredientLimit).
export const MAX_INGREDIENTS = 20
export const INGREDIENT_LIMIT = 80
// IDEA_STAGES are a job's stages: asking the AI for ideas, grounding them
// in recipes, and done.
export const IDEA_STAGES = ['suggesting', 'grounding', 'done'] as const
export type IdeaStage = (typeof IDEA_STAGES)[number]
// Idea is a grounded idea as a result card shows it. summary is the
// server's "Uses all 5 · missing: 1 lime" line, shown as it is;
// minutes_total is the grounded recipe's, null when it does not say.
export type Idea = {
name: string
uses: string[]
missing: string[]
summary: string
minutes_total: number | null
quick: boolean
}
// IdeasResult is an ideas_from_ingredients job's result, published as it
// goes: the ideas grounded so far, in the order the server shows them.
export type IdeasResult = {
stage: IdeaStage
ingredients: string[]
quick_only: boolean
ideas: Idea[]
dropped: number
}
// IDEAS_JOB_FAILURES says why an ideas job failed, by its failure code.
// no_ideas_found is no fault to fix, but the search still came back empty.
const IDEAS_JOB_FAILURES: Record<string, string> = {
no_ideas_found: 'No ideas came back with a recipe to match.',
ai_failed: "The AI assistant couldn't come up with ideas.",
search_not_configured: 'Recipe search is not set up. An admin can set it up in Settings.',
search_failed: "The recipe search couldn't be reached.",
internal_error: 'Something went wrong on the server.',
}
// ideasJobFailure says why an ideas job failed; an unknown code is shown
// as it is.
export function ideasJobFailure(code: string): string {
return IDEAS_JOB_FAILURES[code] ?? code
}
// ideasJobLost says a poll of an ideas job failed because the server no
// longer has the job (404), so asking for it again will not find it: the
// search must be started again.
export function ideasJobLost(error: Error | null): boolean {
return error instanceof ApiError && error.status === 404
}
// Search is what Find ideas asks for.
export type Search = { ingredients: string[]; quickOnly: boolean }
function isStrings(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === 'string')
}
function parseIdea(where: string, body: unknown): Idea {
if (
!isRecord(body) ||
typeof body.name !== 'string' ||
!isStrings(body.uses) ||
!isStrings(body.missing) ||
typeof body.summary !== 'string' ||
typeof body.quick !== 'boolean' ||
(body.minutes_total !== null && !Number.isInteger(body.minutes_total))
) {
throw new Error(`${where}: result has an idea that is not a grounded idea`)
}
return {
name: body.name,
uses: body.uses,
missing: body.missing,
summary: body.summary,
minutes_total: body.minutes_total as number | null,
quick: body.quick,
}
}
// parseIdeasResult checks an ideas_from_ingredients job's result, which
// useJob passes on unread, answers the search asked for.
export function parseIdeasResult(where: string, quickOnly: boolean, body: unknown): IdeasResult {
if (
!isRecord(body) ||
typeof body.stage !== 'string' ||
!(IDEA_STAGES as readonly string[]).includes(body.stage) ||
!isStrings(body.ingredients) ||
body.quick_only !== quickOnly ||
!Array.isArray(body.ideas) ||
typeof body.dropped !== 'number'
) {
throw new Error(`${where}: result is not ideas from ingredients`)
}
return {
stage: body.stage as IdeaStage,
ingredients: body.ingredients,
quick_only: quickOnly,
ideas: body.ideas.map((idea) => parseIdea(where, idea)),
dropped: body.dropped,
}
}
// useFindIdeas starts finding ideas from the ingredients on hand (POST
// /api/v1/ideas/from-ingredients) and resolves with the id of the job,
// which GET /api/v1/jobs/{id} reports on.
export function useFindIdeas() {
const { apiFetch } = useApi()
return useMutation({
mutationFn: async ({ ingredients, quickOnly }: Search): Promise<string> => {
const body = await apiFetch<unknown>(FROM_INGREDIENTS_PATH, {
method: 'POST',
body: { ingredients, quick_only: quickOnly },
})
if (!isRecord(body) || typeof body.jobId !== 'string') throw new Error(`POST ${FROM_INGREDIENTS_PATH}: response has no jobId`)
return body.jobId
},
})
}
// QUICK_PATH starts finding a page of Quick dinner ideas, and
// QUICK_JOB_KIND is the kind of the job that finds it.
export const QUICK_PATH = `${IDEAS_PATH}/quick`
export const QUICK_JOB_KIND = 'quick_ideas'
// QUICK_KINDS are the kinds of Quick dinner, the tabs of Quick dinners in
// order (skills.QuickKinds): cooked in a crock pot, or 30 minutes or less
// from prep to table.
export const QUICK_KINDS = ['crockpot', 'under30'] as const
export type QuickKind = (typeof QUICK_KINDS)[number]
// QUICK_IDEAS_KEPT is the most ideas one page of Quick dinners shows
// (skills.QuickIdeasKept), and MAX_EXCLUDE the most names a request may
// exclude (skills.MaxExclude).
export const QUICK_IDEAS_KEPT = 6
export const MAX_EXCLUDE = 60
// QuickIdea is a grounded Quick dinner idea as a row shows it. method,
// minutes_total and difficulty are the grounded recipe's; minutes_total is
// null when the recipe does not say, which only a crock-pot idea may be.
// photo_id is the stored photo's, from photo_url.
export type QuickIdea = {
name: string
method: Method
minutes_total: number | null
difficulty: Difficulty
photo_id: string | undefined
}
// QuickResult is a quick_ideas job's result, published as it goes: the
// ideas grounded so far, in the order the server shows them.
export type QuickResult = {
stage: IdeaStage
kind: QuickKind
ideas: QuickIdea[]
}
function parseQuickIdea(where: string, body: unknown): QuickIdea {
if (
!isRecord(body) ||
typeof body.name !== 'string' ||
!(METHODS as readonly unknown[]).includes(body.method) ||
(body.minutes_total !== null && !Number.isInteger(body.minutes_total)) ||
!(DIFFICULTIES as readonly unknown[]).includes(body.difficulty)
) {
throw new Error(`${where}: result has an idea that is not a grounded Quick dinner`)
}
return {
name: body.name,
method: body.method as Method,
minutes_total: body.minutes_total as number | null,
difficulty: body.difficulty as Difficulty,
photo_id: photoId(where, body.photo_url),
}
}
// parseQuickResult checks a quick_ideas job's result, which useJob passes
// on unread, is a page of the kind asked for.
export function parseQuickResult(where: string, kind: QuickKind, body: unknown): QuickResult {
if (
!isRecord(body) ||
typeof body.stage !== 'string' ||
!(IDEA_STAGES as readonly string[]).includes(body.stage) ||
body.kind !== kind ||
!isStrings(body.exclude) ||
!Array.isArray(body.ideas) ||
typeof body.dropped !== 'number'
) {
throw new Error(`${where}: result is not Quick dinner ideas of kind ${kind}`)
}
return { stage: body.stage as IdeaStage, kind, ideas: body.ideas.map((idea) => parseQuickIdea(where, idea)) }
}
// QuickPage is a page of Quick dinner ideas its job found.
export type QuickPage = { jobId: string; ideas: QuickIdea[] }
// QuickTab is what one tab of Quick dinners has found this session: the
// pages found, the job finding the next one, whether the server has said
// there are no more, why the last page asked for could not start, kept
// until Retry asks again, and the status of the dinner each idea added
// came back as, by "jobId:index".
export type QuickTab = {
pages: QuickPage[]
jobId: string | null
ended: boolean
startError: Error | null
added: Record<string, AddedStatus>
}
const NEW_TAB: QuickTab = { pages: [], jobId: null, ended: false, startError: null, added: {} }
export function quickTabKey(kind: QuickKind) {
return ['ideas', 'quick', kind] as const
}
// shownNames are the names of every idea a tab shows, which its next page
// excludes so that nothing repeats.
export function shownNames(tab: QuickTab): string[] {
return tab.pages.flatMap((page) => page.ideas.map((idea) => idea.name))
}
// useQuickTab is a tab's QuickTab, held in the query cache for the session
// (never stale, never collected) so that leaving the tab or the screen
// keeps it, and update, which changes it. Nothing is fetched: a refetch
// reads back what the tab holds.
export function useQuickTab(kind: QuickKind) {
const queryClient = useQueryClient()
const { data } = useQuery({
queryKey: quickTabKey(kind),
queryFn: () => queryClient.getQueryData<QuickTab>(quickTabKey(kind)) ?? NEW_TAB,
initialData: NEW_TAB,
staleTime: Infinity,
gcTime: Infinity,
})
const update = useCallback(
(change: (tab: QuickTab) => QuickTab) => queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => change(tab ?? NEW_TAB)),
[queryClient, kind],
)
return [data, update] as const
}
// useFindQuickIdeas starts finding a page of Quick dinner ideas of kind,
// none named in exclude (POST /api/v1/ideas/quick), and makes its job the
// tab's job, or its failure the tab's startError, also if the screen has
// been left meanwhile. Its mutationKey is the tab's, so a tab shown again
// knows a page is still being asked for.
export function useFindQuickIdeas(kind: QuickKind) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationKey: quickTabKey(kind),
mutationFn: async (exclude: string[]): Promise<string> => {
queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), startError: null }))
const body = await apiFetch<unknown>(QUICK_PATH, { method: 'POST', body: { kind, exclude } })
if (!isRecord(body) || typeof body.jobId !== 'string') throw new Error(`POST ${QUICK_PATH}: response has no jobId`)
const jobId = body.jobId
queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), jobId }))
return jobId
},
onError: (startError: Error) => queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), startError })),
})
}
// IdeaRef is idea index of the succeeded ideas job jobId.
export type IdeaRef = { jobId: string; index: number }
function ideaPath({ jobId, index }: IdeaRef, action: 'quest-log' | 'cook-tonight'): string {
return `${IDEAS_PATH}/${encodeURIComponent(jobId)}/items/${index}/${action}`
}
// ADDED_STATUSES are the statuses a dinner an idea is added as may have:
// quest_log for a dinner made or already in the Quest log, regular for a
// Regular the idea repeats, which the server answers as it is.
export const ADDED_STATUSES = ['quest_log', 'regular'] as const
export type AddedStatus = (typeof ADDED_STATUSES)[number]
// ADDED_LABEL says where an idea added went, by its dinner's status.
export const ADDED_LABEL: Record<AddedStatus, string> = {
quest_log: 'In the Quest log',
regular: 'Already a Regular',
}
// AddedDinner is the dinner an idea was added as, with its status.
export type AddedDinner = Regular & { status: AddedStatus }
function parseAddedDinner(where: string, body: unknown): AddedDinner {
const dinner = parseDinner(where, body)
const status = isRecord(body) ? body.status : undefined
if (!(ADDED_STATUSES as readonly unknown[]).includes(status)) {
throw new Error(`${where}: status ${JSON.stringify(status)} is not one of ${ADDED_STATUSES.join(', ')}`)
}
return { ...dinner, status: status as AddedStatus }
}
// useAddIdeaToQuestLog adds an idea to the Quest log (POST
// /api/v1/ideas/{jobId}/items/{i}/quest-log) and resolves with the dinner
// it came back as: a dinner the idea repeats is answered as it is, so a
// Regular stays a Regular, and its status says which. Asking again answers
// the same dinner. The Quest log is read again after it, and onAdded told
// with the dinner, also if the caller has unmounted meanwhile.
export function useAddIdeaToQuestLog(onAdded?: (idea: IdeaRef, dinner: AddedDinner) => void) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (idea: IdeaRef): Promise<AddedDinner> => {
const path = ideaPath(idea, 'quest-log')
return parseAddedDinner(`POST ${path}`, await apiFetch<unknown>(path, { method: 'POST' }))
},
onSuccess: (dinner, idea) => {
onAdded?.(idea, dinner)
return queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
},
})
}
// useCookIdeaTonight puts an idea on today's plan (POST
// /api/v1/ideas/{jobId}/items/{i}/cook-tonight) and resolves with the
// dinner's name. The plan it answers with is cached as its week's plan,
// and the Quest log is read again. With no plan for the current week the
// server answers 409 no_plan, the mutation's ApiError, and changes nothing.
export function useCookIdeaTonight() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (idea: IdeaRef): Promise<string> => {
const path = ideaPath(idea, 'cook-tonight')
const where = `POST ${path}`
const body = await apiFetch<unknown>(path, { method: 'POST' })
if (!isRecord(body) || !isRecord(body.plan) || typeof body.plan.week_start !== 'string') {
throw new Error(`${where}: response has no plan`)
}
const dinner = parseDinner(where, body.dinner)
const plan = parsePlan(where, body.plan.week_start, body.plan, false)
queryClient.setQueryData(planKey(plan.week_start), plan)
return dinner.name
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
})
}
+1 -3
View File
@@ -93,9 +93,7 @@ function syncedAt(where: string, value: unknown): Date | null {
return at
}
// parsePlan checks a body the plans API sends is the plan of the week
// starting on weekStart; cached says the service worker answered it.
export function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
if (
!isRecord(body) ||
typeof body.id !== 'string' ||
+1 -6
View File
@@ -201,15 +201,10 @@ async function openPage() {
}
// reload ends the page open and its timers, as a reload does: a toggle it
// was waiting to try again is never tried, by it, again. The clock reads on
// as it did: clearAllTimers also puts it back to the real time the fake
// timers were installed at, which would open the new page on whatever week
// the real date falls in.
// was waiting to try again is never tried, by it, again.
function reload(page: { unmount: () => void }) {
page.unmount()
const now = Date.now()
vi.clearAllTimers()
vi.setSystemTime(now)
}
async function advance(ms: number) {