diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 35fcc2c..1eeaa28 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -35,6 +35,7 @@ const SCREENS = { '/plan/shopping': true, '/plan/shopping/archive': true, '/discover': true, + '/cook-from-pantry': true, '/settings': true, '/settings/ai': true, '/settings/household': true, diff --git a/web/src/App.tsx b/web/src/App.tsx index 59c2b85..d8a00bd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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> = { '/plan/shopping': , '/plan/shopping/archive': , '/discover': , + '/cook-from-pantry': , '/settings': , '/settings/ai': , '/settings/household': , diff --git a/web/src/ideas/CookFromPantryPage.module.css b/web/src/ideas/CookFromPantryPage.module.css new file mode 100644 index 0000000..fb6465e --- /dev/null +++ b/web/src/ideas/CookFromPantryPage.module.css @@ -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; +} diff --git a/web/src/ideas/CookFromPantryPage.test.tsx b/web/src/ideas/CookFromPantryPage.test.tsx new file mode 100644 index 0000000..99e54f8 --- /dev/null +++ b/web/src/ideas/CookFromPantryPage.test.tsx @@ -0,0 +1,506 @@ +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 + +// stubServer answers "METHOD path" requests from responders, with /me; any +// other request fails the test. jobs holds each job's state, which every +// poll of it answers, so a test moves a job on by changing it. +function stubServer(responders: Record) { + const jobs: Record = {} + const fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + if (key === 'GET /api/v1/me') return Promise.resolve(jsonResponse(200, ME)) + const job = /^GET \/api\/v1\/jobs\/([^/]+)$/.exec(key) + if (job !== null && !Object.hasOwn(responders, key)) { + const id = job[1]! + const body = jobs[id] + if (body === undefined) throw new Error(`unexpected fetch: ${key}`) + return Promise.resolve(jsonResponse(200, { id, kind: '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['fetchMock'], key: string) { + return fetchMock.mock.calls.filter(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}` === key) +} + +function bodyOf(call: Parameters) { + 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( + + + + + } /> + Plan screen

} /> + Home screen

} /> +
+
+
+
, + ) +} + +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['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() + 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() + 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('says no ideas were found, with no cards', 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('failed', 'grounding', [], false, 'no_ideas_found') + await renderPage() + await searched(fetchMock) + + expect(await screen.findByRole('heading', { name: 'No ideas found' })).toBeInTheDocument() + expect(screen.queryAllByRole('article')).toHaveLength(0) + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(2)) + }) + + 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() + }) + }) +}) diff --git a/web/src/ideas/CookFromPantryPage.tsx b/web/src/ideas/CookFromPantryPage.tsx new file mode 100644 index 0000000..a4283d9 --- /dev/null +++ b/web/src/ideas/CookFromPantryPage.tsx @@ -0,0 +1,329 @@ +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 { EmptyState } from '../ui/EmptyState.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, + 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, string> = { + suggesting: 'Thinking up ideas…', + grounding: 'Finding recipes for them…', +} + +// searchFailures says why a search failed, by its failure code. Any other +// code is shown as it is; no_ideas_found is not a failure to fix, and has +// its own empty state. +const searchFailures: Record = { + 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.', +} + +// 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 ( + +
+

{idea.name}

+ {idea.minutes_total !== null && {duration(idea.minutes_total)}} +
+

{idea.summary}

+
+ {tonight.isSuccess ? ( + + On tonight's plan + + ) : ( + + )} + {questLog.isSuccess ? ( + + In the Quest log + + ) : ( + + )} +
+ {noPlan ? ( +
+ This week has no plan yet, so there's no tonight to put it on. + + Plan this week + +
+ ) : ( + tonight.isError && ( +

+ Couldn't put it on tonight's plan. {tonight.error.message} +

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

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

+ )} +
+ ) +} + +// Skeletons stand in for the count ideas still to come. +function Skeletons({ count }: { count: number }) { + return Array.from({ length: count }, (_, i) => ) +} + +// 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(null) + const [ingredients, setIngredients] = useState([]) + const [draft, setDraft] = useState('') + const [notice, setNotice] = useState(null) + const [when, setWhen] = useState('any') + // The search last asked for, which Retry asks again, and its job. + const [asked, setAsked] = useState(null) + const [jobId, setJobId] = useState(null) + const findIdeas = useFindIdeas() + const job = useJob(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) { + 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 = ( +
+

{findingLines.suggesting}

+ +
+ ) + } else if (findIdeas.isError) { + results = Couldn't start looking for ideas. {findIdeas.error.message} + } else if (jobId !== null && job.status === 'failed') { + const code = job.error!.message + results = + code === 'no_ideas_found' ? ( + + No recipe matched {asked!.quickOnly ? 'a quick dinner from ' : ''}those ingredients. Add a few more and try again. + + ) : ( + Couldn't find ideas. {searchFailures[code] ?? code} + ) + } else if (found.error !== null) { + results = Couldn't read the ideas. {found.error.message} + } 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 = job.error instanceof ApiError && job.error.status === 404 + results = ( + <> + {job.error !== null && ( + Couldn't check on the ideas. {job.error.message} + )} +
+

+ {done + ? `${ideas.length} ${ideas.length === 1 ? 'idea' : 'ideas'}` + : findingLines[found.result?.stage === 'grounding' ? 'grounding' : 'suggesting']} +

+ {ideas.map((idea, index) => ( + + ))} + {!done && } +
+ + ) + } + + return ( +
+
+ void navigate('/')}> + + +

Cook from what I have

+
+ {/* A tap anywhere on the field types in it. */} +
inputRef.current?.focus()}> +
    + {ingredients.map((ingredient, i) => ( +
  • + { + setNotice(null) + setIngredients(ingredients.filter((_, j) => j !== i)) + inputRef.current?.focus() + }} + /> +
  • + ))} +
+
+ onDraftChange(event.target.value)} + onKeyDown={onDraftKeyDown} + /> +
+
+ {notice !== null && ( +

+ {notice} +

+ )} +
+ + +
+ {results} +
+ ) +} diff --git a/web/src/ideas/api.ts b/web/src/ideas/api.ts new file mode 100644 index 0000000..8ff3edc --- /dev/null +++ b/web/src/ideas/api.ts @@ -0,0 +1,167 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { 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 +} + +// 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 => { + const body = await apiFetch(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 => { + const path = ideaPath(idea, 'quest-log') + return parseDinner(`POST ${path}`, await apiFetch(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 => { + const path = ideaPath(idea, 'cook-tonight') + const where = `POST ${path}` + const body = await apiFetch(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 }), + }) +} diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index e6094fc..cef2928 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -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' ||