Cook from what I have screen (1i) (Feature 18) (#150)
CI / ci (push) Successful in 6m19s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #150.
This commit is contained in:
2026-10-09 07:42:48 -04:00
committed by approver_agent
parent d6f732c119
commit 2203c0c26f
7 changed files with 1246 additions and 1 deletions
+1
View File
@@ -35,6 +35,7 @@ const SCREENS = {
'/plan/shopping': true,
'/plan/shopping/archive': true,
'/discover': true,
'/cook-from-pantry': true,
'/settings': true,
'/settings/ai': true,
'/settings/household': true,
+2
View File
@@ -4,6 +4,7 @@ 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 { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
@@ -45,6 +46,7 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/plan/shopping': <SupplyRunPage />,
'/plan/shopping/archive': <SupplyArchivePage />,
'/discover': <DiscoverPage />,
'/cook-from-pantry': <CookFromPantryPage />,
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
'/settings/household': <HouseholdSettingsPage />,
+226
View File
@@ -0,0 +1,226 @@
/* Cook from what I have, handoff mock 1i. */
.page {
box-sizing: border-box;
max-width: 560px;
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 6px;
}
.header {
display: flex;
align-items: center;
gap: 10px;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
line-height: 1.1;
}
/* The ingredient chip field: chips, then the trailing input, wrapping. */
.field {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 4px;
padding: 12px;
border-radius: 26px;
background: var(--color-surface);
cursor: text;
}
.field:focus-within {
box-shadow: inset 0 0 0 2px var(--color-accent-300);
}
.chips {
display: contents;
margin: 0;
padding: 0;
list-style: none;
}
.chip {
background: var(--color-bg);
color: var(--color-text);
font-size: 13px;
}
.entry {
flex: 1 1 120px;
min-width: 120px;
}
.input {
width: 100%;
height: 44px;
padding: 0 8px;
border: 0;
background: transparent;
font-size: 15px;
}
.input:focus {
outline: none;
}
.input::placeholder {
color: var(--color-neutral-600);
}
.notice {
margin: 0 4px;
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
color: var(--color-accent-700);
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
}
/* Any time | Quick only shares the row with Find ideas, as in the mock. */
.when {
flex: none;
}
.when > button {
padding: 0 12px;
font-size: 13px;
white-space: nowrap;
}
.find {
padding: 0 18px;
white-space: nowrap;
}
.results {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 8px;
}
.count {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px;
border-radius: 24px;
}
.cardHead {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
}
.name {
margin: 0;
font-family: var(--font-heading);
font-size: 17px;
font-weight: 400;
line-height: 1.2;
}
.time {
flex: none;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.summary {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-accent-2-800);
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.action {
min-height: 44px;
padding: 0 16px;
font-size: 14px;
white-space: nowrap;
}
/* An action done: what it did, in place of its button. */
.done {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 44px;
padding: 0 4px;
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
color: var(--color-accent-2-800);
}
.failure {
margin: 0;
font-family: var(--font-body);
font-size: 13px;
color: var(--color-accent-700);
}
.noPlan {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 10px;
font-family: var(--font-body);
font-size: 13px;
color: var(--color-text);
}
/* "Plan this week": a 44 px tall grape link to the Draft board. */
.planLink {
display: inline-flex;
align-items: center;
min-height: 44px;
font-weight: 800;
color: var(--color-accent-700);
text-decoration: underline;
}
.planLink:focus-visible {
outline: 3px solid var(--color-accent);
outline-offset: 2px;
}
.skeletonCard {
height: 128px;
border-radius: 24px;
}
+512
View File
@@ -0,0 +1,512 @@
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('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('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()
})
})
})
+312
View File
@@ -0,0 +1,312 @@
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 {
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. 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} /> In the Quest log
</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 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 || findIdeas.isPending} onClick={find}>
Find ideas
</Button>
</div>
{results}
</div>
)
}
+190
View File
@@ -0,0 +1,190 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ApiError, useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { parseDinner } from '../dinners/api.ts'
import { 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
},
})
}
// 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}`
}
// useAddIdeaToQuestLog adds an idea to the Quest log (POST
// /api/v1/ideas/{jobId}/items/{i}/quest-log) and resolves with the
// dinner's name. Asking again answers the same dinner. The Quest log is
// read again after it.
export function useAddIdeaToQuestLog() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (idea: IdeaRef): Promise<string> => {
const path = ideaPath(idea, 'quest-log')
return parseDinner(`POST ${path}`, await apiFetch<unknown>(path, { method: 'POST' })).name
},
onSuccess: () => 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 }),
})
}
+3 -1
View File
@@ -93,7 +93,9 @@ function syncedAt(where: string, value: unknown): Date | null {
return at
}
function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
// parsePlan checks a body the plans API sends is the plan of the week
// starting on weekStart; cached says the service worker answered it.
export function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
if (
!isRecord(body) ||
typeof body.id !== 'string' ||