Report the latest failed suggestion batch on the deck
CI / ci (push) Successful in 3m3s
CI / ci (pull_request) Successful in 2m45s

The deck reported only a pending or running batch, so a Discover page
opened after a batch failed (or was interrupted by a restart) had no job
to follow and showed "All caught up" or the remaining cards with no
error. GET /api/v1/suggestions/deck now also returns failed_batch: the
newest batch when it failed, read in the same snapshot after orphaned
batches are failed, so at most one of active_batch and failed_batch is
set and a later batch supersedes the failure. The page follows the
failed batch's job like the active one, so its failure shows with the
existing Retry, which asks for a new batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 05:29:10 -04:00
co-authored by Claude Opus 5.5
parent 6d36a7a10c
commit b611d1bbce
11 changed files with 213 additions and 44 deletions
+1 -1
View File
@@ -107,7 +107,7 @@ describe('App routes', () => {
mockViewport(390)
stubFetchByPath({
...SIGNED_IN,
'/api/v1/suggestions/deck': () => jsonResponse(200, { suggestions: [], active_batch: null }),
'/api/v1/suggestions/deck': () => jsonResponse(200, { suggestions: [], active_batch: null, failed_batch: null }),
'/api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }),
})
renderAt('/discover')
+63 -15
View File
@@ -50,15 +50,21 @@ const ORZO = suggestionBody('s3', 'Lemon garlic shrimp orzo', { difficulty: 'har
type Responder = () => Response | Promise<Response>
// stubServer stands in for the API: GET of the deck answers the current
// server.deck and server.activeBatch, and the Quest log the current
// server.deck, server.activeBatch and server.failedBatch, and the Quest log the current
// server.questLog dinners; every
// other request is answered by responders, keyed "METHOD path". Any other
// request fails the test.
function stubServer(deck: unknown[], responders: Record<string, Responder> = {}, questLog = 6) {
const server = { deck, questLog, activeBatch: null as { batch_id: string; job_id: string } | null, fetchMock: undefined as unknown as Mock<typeof fetch> }
const server = {
deck,
questLog,
activeBatch: null as { batch_id: string; job_id: string } | null,
failedBatch: null as { batch_id: string; job_id: string } | null,
fetchMock: undefined as unknown as Mock<typeof fetch>,
}
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
[`GET ${DECK}`]: () => jsonResponse(200, { suggestions: server.deck, active_batch: server.activeBatch }),
[`GET ${DECK}`]: () => jsonResponse(200, { suggestions: server.deck, active_batch: server.activeBatch, failed_batch: server.failedBatch }),
[`GET ${QUEST_LOG}`]: () =>
jsonResponse(200, { dinners: Array.from({ length: server.questLog }, (_, i) => questLogBody(`q${i}`, `Quest ${i}`)) }),
...responders,
@@ -378,7 +384,7 @@ describe('DiscoverPage', () => {
it('shows a deck that fails to load with a retry', async () => {
let fail = true
stubServer([BOWLS], { [`GET ${DECK}`]: () => (fail ? errorResponse(500, 'internal') : jsonResponse(200, { suggestions: [BOWLS], active_batch: null })) })
stubServer([BOWLS], { [`GET ${DECK}`]: () => (fail ? errorResponse(500, 'internal') : jsonResponse(200, { suggestions: [BOWLS], active_batch: null, failed_batch: null })) })
await renderPage()
const alert = await screen.findByRole('alert', {}, { timeout: 5000 })
expect(alert).toHaveTextContent("Couldn't load your side quests.")
@@ -580,6 +586,48 @@ describe('DiscoverPage', () => {
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline'])
})
it.each([
['the AI failed', 'an empty deck', 'ai_failed', []],
['the AI failed', 'cards left', 'ai_failed', [BOWLS, TIKKA]],
['a restart interrupted it', 'an empty deck', 'interrupted by restart', []],
['a restart interrupted it', 'cards left', 'interrupted by restart', [BOWLS, TIKKA]],
])('shows the failed batch the deck reports when the page opens after %s, with %s, and a retry that asks again', async (_, __, reason, deck) => {
const server = stubServer(deck, {
'GET /api/v1/jobs/j1': () => jsonResponse(200, batchJob('j1', 'failed', [], reason)),
'POST /api/v1/suggestions/batches': () => {
server.failedBatch = null
server.activeBatch = { batch_id: 'b-j2', job_id: 'j2' }
return jsonResponse(202, { batch_id: 'b-j2', job_id: 'j2', trigger: 'manual', status: 'pending' })
},
'GET /api/v1/jobs/j2': () => {
server.activeBatch = null
server.deck = [...deck, ORZO]
return jsonResponse(200, batchJob('j2', 'succeeded', ['s3']))
},
})
// The batch failed while the page was closed.
server.failedBatch = { batch_id: 'b-j1', job_id: 'j1' }
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent(`Couldn't find new side quests. ${reason}`)
if (deck.length === 0) {
expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument()
} else {
expect(screen.getByRole('article', { name: 'Korean beef bowls' })).toBeInTheDocument()
expect(screen.getAllByTestId('blank-card')).toHaveLength(1)
}
// A failed batch is not finding anything.
expect(screen.queryByText('Finding new side quests…')).not.toBeInTheDocument()
expect(screen.queryByTestId('skeleton-card')).not.toBeInTheDocument()
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
// The new batch's Side quest joins the deck.
await waitFor(() => expect(screen.getAllByRole('article', { hidden: true })).toHaveLength(deck.length + 1), { timeout: 3000 })
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/batches'])
})
it('follows the batch the deck reports running when the page opens, with skeleton cards rather than "All caught up"', async () => {
const jobs = [batchJob('j1', 'running'), batchJob('j1', 'succeeded', ['s1'])]
const server = stubServer([], {
@@ -1054,7 +1102,7 @@ describe('DiscoverPage', () => {
let late: ReturnType<typeof deferred> | null = null
const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], {
'POST /api/v1/suggestions/s1/decline': () => decline.promise,
[`GET ${DECK}`]: () => late?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null }),
[`GET ${DECK}`]: () => late?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null }),
})
const queryClient = createQueryClient()
await renderPage(queryClient)
@@ -1074,7 +1122,7 @@ describe('DiscoverPage', () => {
server.deck = [TIKKA, ORZO, PAELLA]
decline.resolve(declined(BOWLS))
await waitFor(() => expect(screen.getByRole('button', { name: 'Pass' })).toBeEnabled())
await act(async () => stale.resolve(jsonResponse(200, { suggestions: [BOWLS, TIKKA, ORZO, PAELLA], active_batch: null })))
await act(async () => stale.resolve(jsonResponse(200, { suggestions: [BOWLS, TIKKA, ORZO, PAELLA], active_batch: null, failed_batch: null })))
// The late answer is dropped, and the deck is read again after the save.
await act(async () => await new Promise((resolve) => setTimeout(resolve, 0)))
@@ -1248,7 +1296,7 @@ describe('DiscoverPage', () => {
let late: ReturnType<typeof deferred> | null = null
const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], {
'POST /api/v1/suggestions/s1/accept': () => accept,
[`GET ${DECK}`]: () => late?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null }),
[`GET ${DECK}`]: () => late?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null }),
})
const queryClient = createQueryClient()
await renderPage(queryClient)
@@ -1265,7 +1313,7 @@ describe('DiscoverPage', () => {
await act(async () => void queryClient.invalidateQueries({ queryKey: DECK_KEY }))
late = null
server.deck = [TIKKA, ORZO, PAELLA]
await act(async () => delayed.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null })))
await act(async () => delayed.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null })))
const reads = deckReads(server.fetchMock)
await act(async () => lose(new TypeError('Failed to fetch')))
@@ -1282,7 +1330,7 @@ describe('DiscoverPage', () => {
let read: ReturnType<typeof deferred> | null = null
const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], {
'POST /api/v1/suggestions/s1/accept': () => accept.promise,
[`GET ${DECK}`]: () => read?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null }),
[`GET ${DECK}`]: () => read?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null }),
})
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
@@ -1302,7 +1350,7 @@ describe('DiscoverPage', () => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled()
await act(async () => pending.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null })))
await act(async () => pending.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null })))
const alert = await screen.findByRole('alert')
expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument()
expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')
@@ -1339,7 +1387,7 @@ describe('DiscoverPage', () => {
read = deferred()
return errorResponse(500, 'internal')
},
[`GET ${DECK}`]: () => read?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null }),
[`GET ${DECK}`]: () => read?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null }),
})
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
@@ -1359,7 +1407,7 @@ describe('DiscoverPage', () => {
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
await act(async () => slow.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null })))
await act(async () => slow.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null })))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't save that choice.")
expect(topCard()).toHaveAccessibleName('Korean beef bowls')
@@ -1393,7 +1441,7 @@ describe('DiscoverPage', () => {
return errorResponse(500, 'internal')
},
'GET /api/v1/jobs/j1': () => poll?.promise ?? job([]),
[`GET ${DECK}`]: () => read?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null }),
[`GET ${DECK}`]: () => read?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null }),
})
const queryClient = createQueryClient()
await renderPage(queryClient)
@@ -1428,7 +1476,7 @@ describe('DiscoverPage', () => {
// The decision's read has the Side quest still pending: the card goes
// back on top with a Retry, and the decision settles on that read.
await act(async () => reconciling.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null })))
await act(async () => reconciling.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null })))
await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled())
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent("Couldn't save that choice.")
@@ -1438,7 +1486,7 @@ describe('DiscoverPage', () => {
// The batch's read goes out after it, and brings its Side quest.
await waitFor(() => expect(deckReads(server.fetchMock)).toBe(reads + 2))
read = null
await act(async () => after.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null })))
await act(async () => after.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null })))
await waitFor(() => expect(queryClient.getQueryData<{ suggestions: { id: string }[] }>(DECK_KEY)?.suggestions.map((q) => q.id)).toEqual(['s2', 's3', 's4', 's5']))
expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')
+16 -13
View File
@@ -95,25 +95,28 @@ export function DiscoverPage() {
const questLog = useQuestLog()
const decision = useDecide()
const startBatch = useStartBatch()
// The job of the batch last known to be finding Side quests: the one Get
// more now started or joined, the replenish a decision started or joined,
// or the deck's active batch, which may have been started elsewhere or
// before the page opened.
// The job of the batch last known to be finding Side quests, or to have
// failed to: the one Get more now started or joined, the replenish a
// decision started or joined, or the deck's active or failed batch, which
// may have been started elsewhere or before the page opened.
const [batchJobId, setBatchJobId] = useState<string | null>(null)
// A read of the deck reporting a new active batch follows its job from
// that render on; one reporting none leaves the last job followed, so its
// end and any failure stay shown.
const activeJobId = deck.data?.activeBatch?.job_id ?? null
const [seenActiveJobId, setSeenActiveJobId] = useState<string | null>(null)
if (activeJobId !== seenActiveJobId) {
setSeenActiveJobId(activeJobId)
if (activeJobId !== null) setBatchJobId(activeJobId)
// A read of the deck reporting a new active or failed batch follows its
// job from that render on; one reporting neither leaves the last job
// followed, so its end and any failure stay shown.
const failedJobId = deck.data?.failedBatch?.job_id ?? null
const deckJobId = deck.data?.activeBatch?.job_id ?? failedJobId
const [seenDeckJobId, setSeenDeckJobId] = useState<string | null>(null)
if (deckJobId !== seenDeckJobId) {
setSeenDeckJobId(deckJobId)
if (deckJobId !== null) setBatchJobId(deckJobId)
}
const job = useJob<unknown>(BATCH_JOB_KIND, batchJobId)
const top = cards?.[0]
const [busy, setBusy] = useState(false)
const jobRunning = batchJobId !== null && job.status !== 'succeeded' && job.status !== 'failed'
// The deck's failed batch is not finding anything, even before its job's
// first poll says why it failed.
const jobRunning = batchJobId !== null && batchJobId !== failedJobId && job.status !== 'succeeded' && job.status !== 'failed'
const finding = startBatch.isPending || jobRunning
const { mutate: mutateStartBatch } = startBatch
const getMore = useCallback(() => mutateStartBatch(undefined, { onSuccess: (b) => setBatchJobId(b.job_id) }), [mutateStartBatch])
+11 -6
View File
@@ -30,13 +30,15 @@ export type SideQuest = {
export type Choice = 'try' | 'pass'
// Batch is a POST /api/v1/suggestions/batches answer, or the deck's active
// batch: the batch and the job running it, which GET /api/v1/jobs/{id}
// reports on.
// or failed batch: the batch and the job running it, which GET
// /api/v1/jobs/{id} reports on.
export type Batch = { batch_id: string; job_id: string }
// Deck is the deck as GET /api/v1/suggestions/deck reads it: the pending
// Side quests, oldest first, and the batch pending or running, if any.
export type Deck = { suggestions: SideQuest[]; activeBatch: Batch | null }
// Side quests, oldest first; the batch pending or running, if any; and the
// newest batch when it failed, which a later batch supersedes. At most one
// of the two batches is set.
export type Deck = { suggestions: SideQuest[]; activeBatch: Batch | null; failedBatch: Batch | null }
function isBatch(body: unknown): body is Batch {
return isRecord(body) && typeof body.batch_id === 'string' && typeof body.job_id === 'string'
@@ -91,8 +93,9 @@ function parseSideQuest(where: string, body: unknown): SideQuest {
}
}
// useDeck is the pending Side quests, oldest first, and the batch pending
// or running (GET /api/v1/suggestions/deck).
// useDeck is the pending Side quests, oldest first, the batch pending or
// running and the newest batch when it failed (GET
// /api/v1/suggestions/deck).
export function useDeck() {
const { apiFetch } = useApi()
return useQuery({
@@ -102,9 +105,11 @@ export function useDeck() {
const body = await apiFetch<unknown>(DECK_PATH)
if (!isRecord(body) || !Array.isArray(body.suggestions)) throw new Error(`${where}: response has no suggestions list`)
if (body.active_batch !== null && !isBatch(body.active_batch)) throw new Error(`${where}: response has no active_batch`)
if (body.failed_batch !== null && !isBatch(body.failed_batch)) throw new Error(`${where}: response has no failed_batch`)
return {
suggestions: body.suggestions.map((suggestion) => parseSideQuest(where, suggestion)),
activeBatch: body.active_batch === null ? null : { batch_id: body.active_batch.batch_id, job_id: body.active_batch.job_id },
failedBatch: body.failed_batch === null ? null : { batch_id: body.failed_batch.batch_id, job_id: body.failed_batch.job_id },
}
},
})