From dc81c634a345873219932ae269d41ffdb99de38f Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 21:43:51 -0400 Subject: [PATCH] Show no_ideas_found as the ErrorBanner with Retry (Feature 18, mock 1i) A failed ideas job always shows the ErrorBanner with Retry and no cards, no_ideas_found included. The failure messages and the 404-means-search- again rule move to ideas/api.ts (ideasJobFailure, ideasJobLost) for the Quick dinners screen to share. Co-Authored-By: Claude Opus 5.5 --- web/src/ideas/CookFromPantryPage.test.tsx | 16 ++++++++++----- web/src/ideas/CookFromPantryPage.tsx | 25 ++++------------------- web/src/ideas/api.ts | 25 ++++++++++++++++++++++- 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/web/src/ideas/CookFromPantryPage.test.tsx b/web/src/ideas/CookFromPantryPage.test.tsx index 99e54f8..cb892e0 100644 --- a/web/src/ideas/CookFromPantryPage.test.tsx +++ b/web/src/ideas/CookFromPantryPage.test.tsx @@ -471,18 +471,24 @@ describe('CookFromPantryPage', () => { expect(await screen.findByRole('alert')).toHaveTextContent(message) }) - it('says no ideas were found, with no cards', async () => { + 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('failed', 'grounding', [], false, 'no_ideas_found') + jobs.j2 = state('succeeded', 'done', [CURRY]) await renderPage() await searched(fetchMock) - expect(await screen.findByRole('heading', { name: 'No ideas found' })).toBeInTheDocument() + 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) - fireEvent.click(screen.getByRole('button', { name: 'Retry' })) - await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(2)) + 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 () => { diff --git a/web/src/ideas/CookFromPantryPage.tsx b/web/src/ideas/CookFromPantryPage.tsx index a4283d9..e8d1dc1 100644 --- a/web/src/ideas/CookFromPantryPage.tsx +++ b/web/src/ideas/CookFromPantryPage.tsx @@ -6,7 +6,6 @@ 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' @@ -16,6 +15,8 @@ import { TextInput } from '../ui/TextInput.tsx' import { FROM_INGREDIENTS_JOB_KIND, IDEAS_KEPT, + ideasJobFailure, + ideasJobLost, INGREDIENT_LIMIT, MAX_INGREDIENTS, parseIdeasResult, @@ -42,16 +43,6 @@ const findingLines: Record, string> = { 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() @@ -235,15 +226,7 @@ export function CookFromPantryPage() { } 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} - ) + results = Couldn't find ideas. {ideasJobFailure(job.error!.message)} } else if (found.error !== null) { results = Couldn't read the ideas. {found.error.message} } else if (jobId !== null) { @@ -251,7 +234,7 @@ export function CookFromPantryPage() { 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 + const lost = ideasJobLost(job.error) results = ( <> {job.error !== null && ( diff --git a/web/src/ideas/api.ts b/web/src/ideas/api.ts index 8ff3edc..44f34d6 100644 --- a/web/src/ideas/api.ts +++ b/web/src/ideas/api.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import { useApi } from '../api/index.ts' +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' @@ -49,6 +49,29 @@ export type IdeasResult = { 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 = { + 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 }