From 38fec723f6c1920b9748c217cb55c82946bcd497 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 01:03:48 -0400 Subject: [PATCH 1/3] Add the Discover deck screen (mock 1a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /discover now shows the Side quests deck: the "Discover" kicker and "Side quests" title, the teal "Quest log · N" pill, and the three stacked cards in a 440 px deck with perspective 1000px. The top card shows the washed photo, the name, the difficulty dots and the "Why this?" box, with hidden "Try it!" and "Pass" stamps. The 62 px Pass and Try it buttons and the ←/→ keys all go through one decide handler that ignores input while a decision is in flight. An empty deck shows "All caught up" with "Get more now", which starts a suggestion batch and shows skeleton cards while its job runs, reading the deck again as the batch stores Side quests. Maestro task 1ecc121c-86eb-4784-a9fb-1314709b4a59. Co-Authored-By: Claude Opus 5.5 --- web/src/App.test.tsx | 14 + web/src/App.tsx | 2 + web/src/dinners/api.ts | 4 +- web/src/discover/DeckCard.tsx | 63 +++++ web/src/discover/DiscoverPage.module.css | 264 ++++++++++++++++++ web/src/discover/DiscoverPage.test.tsx | 331 +++++++++++++++++++++++ web/src/discover/DiscoverPage.tsx | 150 ++++++++++ web/src/discover/api.ts | 116 ++++++++ 8 files changed, 942 insertions(+), 2 deletions(-) create mode 100644 web/src/discover/DeckCard.tsx create mode 100644 web/src/discover/DiscoverPage.module.css create mode 100644 web/src/discover/DiscoverPage.test.tsx create mode 100644 web/src/discover/DiscoverPage.tsx create mode 100644 web/src/discover/api.ts diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index e06992c..ba314af 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -30,6 +30,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) { // The routes that render a built screen rather than a placeholder page. const SCREENS = { '/onboarding': true, + '/discover': true, '/settings': true, '/settings/ai': true, '/settings/household': true, @@ -102,6 +103,19 @@ describe('App routes', () => { expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument() }) + it('/discover renders the Side quests deck inside the shell', async () => { + mockViewport(390) + stubFetchByPath({ + ...SIGNED_IN, + '/api/v1/suggestions/deck': () => jsonResponse(200, { suggestions: [] }), + '/api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }), + }) + renderAt('/discover') + const heading = await screen.findByRole('heading', { level: 1, name: 'Side quests' }) + expect(screen.getByRole('main')).toContainElement(heading) + expect(await screen.findByText('Quest log · 0')).toBeInTheDocument() + }) + it('/settings/household renders the household settings screen inside the shell', async () => { mockViewport(390) stubFetchByPath(SIGNED_IN) diff --git a/web/src/App.tsx b/web/src/App.tsx index 1becaca..4a2d297 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,6 +3,7 @@ import { QueryClientProvider } from '@tanstack/react-query' 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 { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx' import { OnboardingPage } from './onboarding/OnboardingPage.tsx' import { PlaceholderPage } from './pages/PlaceholderPage.tsx' @@ -35,6 +36,7 @@ export const routes = [ // The screens built so far; every other route shows its placeholder page. const screens: Partial> = { + '/discover': , '/settings': , '/settings/ai': , '/settings/household': , diff --git a/web/src/dinners/api.ts b/web/src/dinners/api.ts index 657d1de..dc5a556 100644 --- a/web/src/dinners/api.ts +++ b/web/src/dinners/api.ts @@ -79,7 +79,7 @@ export const REGULARS_KEY = ['dinners', 'regular'] as const const REGULARS_LIST_KEY = [...REGULARS_KEY, 'list'] as const const REGULARS_MUTATION_KEY = ['dinners', 'regular', 'change'] as const -function oneOf(where: string, field: string, values: readonly T[], value: unknown): T { +export function oneOf(where: string, field: string, values: readonly T[], value: unknown): T { if (typeof value !== 'string' || !(values as readonly string[]).includes(value)) { throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not one of ${values.join(', ')}`) } @@ -99,7 +99,7 @@ const PHOTO_URL = /^\/media\/photos\/([^/]+)-400\.jpg$/ // photoId reads the photo id out of a photo_url, which the server always // sends as /media/photos/{id}-400.jpg or null. -function photoId(where: string, value: unknown): string | undefined { +export function photoId(where: string, value: unknown): string | undefined { if (value === null) return undefined const match = typeof value === 'string' ? PHOTO_URL.exec(value) : null if (!match?.[1]) throw new Error(`${where}: photo_url ${JSON.stringify(value)} is not a stored photo`) diff --git a/web/src/discover/DeckCard.tsx b/web/src/discover/DeckCard.tsx new file mode 100644 index 0000000..7d3df64 --- /dev/null +++ b/web/src/discover/DeckCard.tsx @@ -0,0 +1,63 @@ +import { DIFFICULTIES, type Difficulty } from '../dinners/api.ts' +import { Photo } from '../ui/Photo.tsx' +import type { SideQuest } from './api.ts' +import styles from './DiscoverPage.module.css' + +// DECK_DEPTH is the depths the deck shows, top card first. +export const DECK_DEPTH = [0, 1, 2] as const + +const DIFFICULTY_LABEL: Record = { easy: 'Easy', medium: 'Medium', hard: 'Hard' } + +export type DeckCardProps = { + quest: SideQuest + // 0 is the top card; 1 and 2 stack behind it. + depth: number +} + +// DeckCard is one Side quest in the deck. Only the top card is read out and +// reachable; the cards behind it are the stack's edge, kept rendered so they +// can be promoted. Its "Try it!" and "Pass" stamps stay hidden until a +// decision shows them. +export function DeckCard({ quest, depth }: DeckCardProps) { + const top = depth === 0 + const titleId = `side-quest-${quest.id}` + return ( +
+ +
+

+ {quest.name} +

+ {quest.difficulty !== null && } +

+ Why this? {quest.reason} +

+
+ + +
+ ) +} + +function DifficultyDots({ difficulty }: { difficulty: Difficulty }) { + const level = DIFFICULTIES.indexOf(difficulty) + 1 + return ( +

+

+ ) +} diff --git a/web/src/discover/DiscoverPage.module.css b/web/src/discover/DiscoverPage.module.css new file mode 100644 index 0000000..cd72438 --- /dev/null +++ b/web/src/discover/DiscoverPage.module.css @@ -0,0 +1,264 @@ +/* Discover · Side quests (mock 1a). */ +.page { + box-sizing: border-box; + max-width: 440px; + display: flex; + flex-direction: column; + gap: 18px; + padding-top: 8px; +} + +.header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; +} + +.kicker { + margin: 0; + font-family: var(--font-body); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-accent-700); +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 30px; + font-weight: 400; + line-height: 1.1; +} + +/* The Quest log count pill. */ +.questLog { + flex: none; + padding: 7px 12px; + border-radius: var(--radius-pill); + background: var(--color-accent-2-200); + color: var(--color-accent-2-800); + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; +} + +/* The deck: three cards stacked in the same box, the top one in front. */ +.deck { + position: relative; + height: 440px; + perspective: 1000px; +} + +.card { + position: absolute; + inset: 0; + box-sizing: border-box; + display: flex; + flex-direction: column; + border-radius: var(--radius-card); + overflow: hidden; + color: var(--color-text); +} + +.depth0 { + z-index: 3; + transform: none; + background: var(--color-neutral-100); + box-shadow: var(--shadow-lg); +} + +.depth1 { + z-index: 2; + transform: translateY(8px) scale(0.95); + background: var(--color-surface); +} + +.depth2 { + z-index: 1; + transform: translateY(16px) scale(0.9); + background: var(--color-neutral-300); +} + +.photo { + flex: none; + width: calc(100% - 20px); + height: 220px; +} + +.content { + display: flex; + flex-direction: column; + gap: 10px; + padding: 4px 20px 0; +} + +.name { + margin: 0; + font-family: var(--font-heading); + font-size: 24px; + font-weight: 400; + line-height: 1.1; +} + +.difficulty { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.dots { + display: flex; + gap: 3px; +} + +.dot, +.dotFilled { + width: 8px; + height: 8px; + border-radius: var(--radius-pill); + background: var(--color-neutral-300); +} + +.dotFilled { + background: var(--color-accent); +} + +/* The "Why this?" box. */ +.why { + margin: 0; + padding: 12px 14px; + border-radius: 18px; + background: var(--color-accent-2-100); + color: var(--color-accent-2-900); + font-family: var(--font-body); + font-size: 13px; + line-height: 1.45; +} + +/* The decision stamps, hidden until a decision shows them. */ +.stamp { + position: absolute; + top: 26px; + padding: 6px 14px; + border: 3px solid; + border-radius: 14px; + font-family: var(--font-heading); + font-size: 24px; + opacity: 0; + pointer-events: none; +} + +.tryStamp { + left: 22px; + transform: rotate(-12deg); + border-color: var(--color-accent-2-700); + background: var(--color-accent-2-100); + color: var(--color-accent-2-800); +} + +.passStamp { + right: 22px; + transform: rotate(12deg); + border-color: var(--color-neutral-800); + background: var(--color-neutral-100); + color: var(--color-neutral-900); +} + +/* Pass and Try it: a 62 px circle over an 11 px label, one button each. */ +.actions { + display: flex; + justify-content: center; + gap: 28px; + margin-top: 4px; +} + +.action { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-width: 62px; + min-height: 62px; + padding: 0; + border: 0; + background: none; + color: var(--color-neutral-700); + font-family: var(--font-body); + font-size: 11px; + font-weight: 700; + cursor: pointer; +} + +.action:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.circle { + display: flex; + align-items: center; + justify-content: center; + width: 62px; + height: 62px; + border-radius: var(--radius-pill); + box-shadow: var(--shadow-md); + transition: transform 100ms var(--ease-out-soft); +} + +.action:active:not(:disabled) .circle { + transform: scale(0.97); +} + +.pass { + background: var(--color-neutral-100); + color: var(--color-text); +} + +.tryIt { + background: var(--color-accent-2-600); + color: var(--on-accent); +} + +/* Skeleton cards while the deck loads or a batch finds Side quests. */ +.loading { + display: flex; + flex-direction: column; + gap: 22px; +} + +.deck > .skeletonCard { + position: absolute; + inset: 0; + height: auto; + border-radius: var(--radius-card); +} + +.skeletonCard.depth0, +.skeletonCard.depth1, +.skeletonCard.depth2 { + background: var(--color-surface); + box-shadow: none; +} + +.finding { + margin: 0; + text-align: center; + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + color: var(--color-neutral-700); +} + +@media (prefers-reduced-motion: reduce) { + .circle { + transition: none; + } +} diff --git a/web/src/discover/DiscoverPage.test.tsx b/web/src/discover/DiscoverPage.test.tsx new file mode 100644 index 0000000..202fb9e --- /dev/null +++ b/web/src/discover/DiscoverPage.test.tsx @@ -0,0 +1,331 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { declaredStyle } from '../test/cssRules.ts' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { DiscoverPage } from './DiscoverPage.tsx' + +const DECK = '/api/v1/suggestions/deck' +const QUEST_LOG = '/api/v1/dinners?status=quest_log' + +// SuggestionBody is a suggestion as GET /api/v1/suggestions/deck sends it. +function suggestionBody(id: string, name: string, overrides: Record = {}) { + return { + id, + dinner_id: `dinner-${id}`, + name, + photo_url: null, + difficulty: 'easy', + method: 'stovetop', + minutes_total: 25, + cuisine: 'Korean', + reason: 'You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.', + cited_regular_ids: ['r1', 'r2'], + status: 'pending', + dinner_status: 'suggested', + decided_at: null, + created_at: '2026-10-04T08:00:00.000Z', + ...overrides, + } +} + +const BOWLS = suggestionBody('s1', 'Korean beef bowls', { photo_url: '/media/photos/p1-400.jpg' }) +const TIKKA = suggestionBody('s2', 'Crock pot chicken tikka masala', { difficulty: 'medium', reason: 'Butter chicken night is a favorite.' }) +const ORZO = suggestionBody('s3', 'Lemon garlic shrimp orzo', { difficulty: 'hard' }) + +type Responder = () => Response | Promise + +// stubServer stands in for the API: GET of the deck answers the current +// server.deck and the Quest log has questLog dinners; every other request +// is answered by responders, keyed "METHOD path". Any other request fails +// the test. +function stubServer(deck: unknown[], responders: Record = {}, questLog = 6) { + const server = { deck, fetchMock: undefined as unknown as Mock } + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + [`GET ${DECK}`]: () => jsonResponse(200, { suggestions: server.deck }), + [`GET ${QUEST_LOG}`]: () => + jsonResponse(200, { dinners: Array.from({ length: questLog }, (_, i) => ({ id: `q${i}`, name: `Quest ${i}` })) }), + ...responders, + } + server.fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + return Promise.resolve(respond()) + }) + vi.stubGlobal('fetch', server.fetchMock) + return server +} + +// sent lists the requests other than GETs, as "METHOD path". +function sent(fetchMock: Mock) { + return fetchMock.mock.calls + .filter(([, init]) => (init?.method ?? 'GET') !== 'GET') + .map(([input, init]) => `${init?.method} ${String(input)}`) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +// renderPage loads /me (for the CSRF token every write needs) and renders +// the Discover page. +async function renderPage() { + const api = createApiClient(fakeLocation('/discover'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + return render( + + + + + , + ) +} + +function deferred() { + let resolve!: (response: Response) => void + const promise = new Promise((r) => (resolve = r)) + return { promise, resolve } +} + +describe('DiscoverPage', () => { + it('renders mock 1a from the deck: header, Quest log pill and three stacked cards', async () => { + stubServer([BOWLS, TIKKA, ORZO]) + const { container } = await renderPage() + + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + expect(screen.getByText('Discover')).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: 'Side quests' })).toBeInTheDocument() + const pill = await screen.findByText('Quest log · 6') + expect(declaredStyle(pill).background).toBe('var(--color-accent-2-200)') + expect(declaredStyle(pill).color).toBe('var(--color-accent-2-800)') + + // The deck: 440 px, perspective 1000px, three cards stacked by depth. + const cards = container.querySelectorAll('article') + expect(cards).toHaveLength(3) + const deck = cards[0]!.parentElement! + expect(getComputedStyle(deck).height).toBe('440px') + expect(getComputedStyle(deck).perspective).toBe('1000px') + const [top, second, third] = [...cards].map((c) => declaredStyle(c)) + expect(top!.transform).toBe('none') + expect(top!.background).toBe('var(--color-neutral-100)') + expect(top!.boxShadow).toBe('var(--shadow-lg)') + expect(second!.transform).toBe('translateY(8px) scale(0.95)') + expect(second!.background).toBe('var(--color-surface)') + expect(third!.transform).toBe('translateY(16px) scale(0.9)') + expect(third!.background).toBe('var(--color-neutral-300)') + for (const style of [top, second, third]) expect(style!.borderRadius).toBe('var(--radius-card)') + + // Only the top card is read out; the ones behind it are the stack's edge. + expect(cards[1]).toHaveAttribute('aria-hidden', 'true') + expect(cards[2]).toHaveAttribute('aria-hidden', 'true') + expect(screen.queryByRole('article', { name: 'Crock pot chicken tikka masala' })).not.toBeInTheDocument() + + // The top card: washed photo, Bagel name, difficulty dots, "Why this?". + const photo = within(card).getByRole('img', { name: 'Korean beef bowls' }) + expect(photo).toHaveAttribute('src', '/media/photos/p1-400.jpg') + expect(getComputedStyle(photo).height).toBe('220px') + expect(photo.style.getPropertyValue('--photo-inset')).toBe('10px') + expect(photo.style.getPropertyValue('--photo-radius')).toBe('24px') + const name = within(card).getByRole('heading', { level: 2, name: 'Korean beef bowls' }) + expect(getComputedStyle(name).fontSize).toBe('24px') + const difficulty = within(card).getByText('Easy') + const dots = difficulty.querySelectorAll('[data-filled]') + expect([...dots].map((dot) => dot.getAttribute('data-filled'))).toEqual(['true', 'false', 'false']) + expect(getComputedStyle(dots[0]!).width).toBe('8px') + const why = within(card).getByText('Why this?').parentElement! + expect(why).toHaveTextContent('Why this? You make Chicken stir-fry and Beef tacos most weeks.') + expect(declaredStyle(why).background).toBe('var(--color-accent-2-100)') + + // The stamps are there, hidden until a decision shows them. + const tryStamp = within(card).getByText('Try it!') + const passStamp = within(card).getByText('Pass') + expect(getComputedStyle(tryStamp).opacity).toBe('0') + expect(getComputedStyle(tryStamp).transform).toBe('rotate(-12deg)') + expect(getComputedStyle(passStamp).opacity).toBe('0') + expect(getComputedStyle(passStamp).transform).toBe('rotate(12deg)') + + // Pass and Try it: 62 px buttons with 11 px labels. + for (const label of ['Pass', 'Try it']) { + const button = screen.getByRole('button', { name: label }) + expect(getComputedStyle(button).fontSize).toBe('11px') + expectMinHitArea(button) + expect(getComputedStyle(button.querySelector('span')!).width).toBe('62px') + } + }) + + it.each([ + [TIKKA, 'Medium', 2], + [ORZO, 'Hard', 3], + ] as const)('shows $1 with filled dots', async (quest, label, filled) => { + stubServer([quest]) + await renderPage() + const card = await screen.findByRole('article', { name: quest.name }) + expect(within(card).getByText(label).querySelectorAll('[data-filled="true"]')).toHaveLength(filled) + }) + + it('records Try it on → and Pass on ←, for the top card', async () => { + const server = stubServer([BOWLS, TIKKA, ORZO], { + 'POST /api/v1/suggestions/s1/accept': () => { + server.deck = [TIKKA, ORZO] + return jsonResponse(200, { suggestion: { ...BOWLS, status: 'accepted' }, replenish_job_id: null }) + }, + 'POST /api/v1/suggestions/s2/decline': () => { + server.deck = [ORZO] + return jsonResponse(200, { suggestion: { ...TIKKA, status: 'declined' }, replenish_job_id: null }) + }, + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.keyDown(window, { key: 'ArrowRight' }) + await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' }) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']) + + fireEvent.keyDown(window, { key: 'ArrowLeft' }) + await screen.findByRole('article', { name: 'Lemon garlic shrimp orzo' }) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s2/decline']) + }) + + it('records Pass and Try it from the buttons', async () => { + const server = stubServer([BOWLS, TIKKA], { + 'POST /api/v1/suggestions/s1/decline': () => { + server.deck = [TIKKA] + return jsonResponse(200, { suggestion: { ...BOWLS, status: 'declined' }, replenish_job_id: null }) + }, + 'POST /api/v1/suggestions/s2/accept': () => { + server.deck = [] + return jsonResponse(200, { suggestion: { ...TIKKA, status: 'accepted' }, replenish_job_id: null }) + }, + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' }) + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await screen.findByRole('heading', { name: 'All caught up' }) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline', 'POST /api/v1/suggestions/s2/accept']) + }) + + it('locks input while a decision is in flight', async () => { + const accept = deferred() + const server = stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/accept': () => accept.promise }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.keyDown(window, { key: 'ArrowRight' }) + await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled()) + expect(screen.getByRole('button', { name: 'Pass' })).toBeDisabled() + fireEvent.keyDown(window, { key: 'ArrowLeft' }) + fireEvent.keyDown(window, { key: 'ArrowRight' }) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']) + + server.deck = [TIKKA] + accept.resolve(jsonResponse(200, { suggestion: { ...BOWLS, status: 'accepted' }, replenish_job_id: null })) + await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' }) + expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled() + }) + + it('leaves ← / → alone with a modifier or while typing in a field', async () => { + const server = stubServer([BOWLS]) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.keyDown(window, { key: 'ArrowRight', shiftKey: true }) + fireEvent.keyDown(window, { key: 'ArrowLeft', metaKey: true }) + const field = document.createElement('input') + document.body.append(field) + fireEvent.keyDown(field, { key: 'ArrowRight' }) + field.remove() + expect(sent(server.fetchMock)).toEqual([]) + }) + + it('shows a refused decision with a retry, keeping the card', async () => { + let refuse = true + const server = stubServer([BOWLS], { + 'POST /api/v1/suggestions/s1/accept': () => { + if (refuse) return errorResponse(500, 'internal') + server.deck = [] + return jsonResponse(200, { suggestion: { ...BOWLS, status: 'accepted' }, replenish_job_id: null }) + }, + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't save that choice.") + expect(screen.getByRole('article', { name: 'Korean beef bowls' })).toBeInTheDocument() + + refuse = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('heading', { name: 'All caught up' }) + }) + + it('shows "All caught up" when the deck is empty', async () => { + stubServer([]) + await renderPage() + expect(await screen.findByRole('heading', { level: 2, name: 'All caught up' })).toBeInTheDocument() + expect(screen.getByText('New side quests arrive Sunday.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Get more now' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Try it' })).not.toBeInTheDocument() + }) + + it('Get more now starts a batch and shows skeleton cards until its Side quests arrive', async () => { + const jobs = [ + { id: 'j1', kind: 'suggestion_batch', status: 'running', result: { batch_id: 'b1', stage: 'suggesting', suggestion_ids: [], dropped: 0 }, error: null }, + { id: 'j1', kind: 'suggestion_batch', status: 'succeeded', result: { batch_id: 'b1', stage: 'done', suggestion_ids: ['s1'], dropped: 0 }, error: null }, + ] + const server = stubServer([], { + 'POST /api/v1/suggestions/batches': () => + jsonResponse(202, { batch_id: 'b1', job_id: 'j1', trigger: 'manual', status: 'pending' }), + 'GET /api/v1/jobs/j1': () => { + const job = jobs.shift()! + if (job.status === 'succeeded') server.deck = [BOWLS] + return jsonResponse(200, job) + }, + }) + await renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Get more now' })) + + expect(await screen.findByText('Finding new side quests…')).toBeInTheDocument() + expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3) + expect(screen.getByRole('region', { name: 'Side quests loading' })).toHaveAttribute('aria-busy', 'true') + expect(screen.queryByRole('heading', { name: 'All caught up' })).not.toBeInTheDocument() + + await screen.findByRole('article', { name: 'Korean beef bowls' }, { timeout: 3000 }) + expect(screen.queryByTestId('skeleton-card')).not.toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/batches']) + }) + + it('shows a failed batch with a retry over the empty state', async () => { + stubServer([], { + 'POST /api/v1/suggestions/batches': () => + jsonResponse(202, { batch_id: 'b1', job_id: 'j1', trigger: 'manual', status: 'pending' }), + 'GET /api/v1/jobs/j1': () => + jsonResponse(200, { id: 'j1', kind: 'suggestion_batch', status: 'failed', result: null, error: 'model unavailable' }), + }) + await renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Get more now' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't find new side quests. model unavailable") + expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument() + }) + + 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] })) }) + await renderPage() + const alert = await screen.findByRole('alert', {}, { timeout: 5000 }) + expect(alert).toHaveTextContent("Couldn't load your side quests.") + fail = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + }) +}) diff --git a/web/src/discover/DiscoverPage.tsx b/web/src/discover/DiscoverPage.tsx new file mode 100644 index 0000000..6ec9e04 --- /dev/null +++ b/web/src/discover/DiscoverPage.tsx @@ -0,0 +1,150 @@ +import { useQueryClient } from '@tanstack/react-query' +import { Heart, X } from 'lucide-react' +import { useCallback, useEffect } from 'react' +import { useJob } from '../api/index.ts' +import { EmptyState } from '../ui/EmptyState.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { + DECK_KEY, + useDeck, + useDecide, + useQuestLogCount, + useStartBatch, + type BatchResult, + type Choice, +} from './api.ts' +import { DeckCard, DECK_DEPTH } from './DeckCard.tsx' +import styles from './DiscoverPage.module.css' + +// isEditable says whether a key press belongs to a field being typed in, +// which the deck's ← / → keys leave alone. +function isEditable(target: EventTarget | null): boolean { + return ( + target instanceof HTMLElement && + (target.isContentEditable || target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) + ) +} + +// DiscoverPage is mock 1a: the deck of Side quests to Try it or Pass, one +// at a time from the top card. Every decision — the buttons, the ← / → +// keys — goes through decide, which does nothing while busy. +export function DiscoverPage() { + const queryClient = useQueryClient() + const deck = useDeck() + const questLog = useQuestLogCount() + const decision = useDecide() + const startBatch = useStartBatch() + const job = useJob(startBatch.data?.job_id ?? null) + + const top = deck.data?.[0] + const busy = decision.isPending + const jobRunning = startBatch.isSuccess && job.status !== 'succeeded' && job.status !== 'failed' + const finding = startBatch.isPending || jobRunning + + const { mutate: mutateDecision } = decision + const decide = useCallback( + (choice: Choice) => { + if (busy || top === undefined) return + mutateDecision({ id: top.id, choice }) + }, + [busy, top, mutateDecision], + ) + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return + if (isEditable(event.target)) return + if (event.key === 'ArrowLeft') decide('pass') + else if (event.key === 'ArrowRight') decide('try') + else return + event.preventDefault() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [decide]) + + // The batch stores its Side quests as it goes, so the deck is read again + // whenever it reports more, and once it ends. + const stored = job.result?.suggestion_ids.length ?? 0 + useEffect(() => { + if (job.status === undefined) return + void queryClient.invalidateQueries({ queryKey: DECK_KEY }) + }, [queryClient, job.status, stored]) + + let body + if (deck.isError) { + body = void deck.refetch()}>Couldn't load your side quests. {deck.error.message} + } else if (deck.isPending || (deck.data.length === 0 && finding)) { + body = ( +
+
+ {DECK_DEPTH.map((depth) => ( + + ))} +
+ {finding &&

Finding new side quests…

} +
+ ) + } else if (top === undefined) { + body = ( + <> + {startBatch.isError && ( + startBatch.mutate()}>Couldn't ask for more side quests. {startBatch.error.message} + )} + {job.status === 'failed' && job.error !== null && ( + startBatch.mutate()}>Couldn't find new side quests. {job.error.message} + )} + startBatch.mutate() }}> + New side quests arrive Sunday. + + + ) + } else { + body = ( + <> +
+ {deck.data.slice(0, DECK_DEPTH.length).map((quest, depth) => ( + + ))} +
+
+ + +
+ {decision.isError && ( + mutateDecision(decision.variables!)}> + Couldn't save that choice. {decision.error.message} + + )} + + ) + } + + return ( +
+
+
+

Discover

+

Side quests

+
+ {questLog.isSuccess && Quest log · {questLog.data}} +
+ {questLog.isError && ( + void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message} + )} + {body} +
+ ) +} diff --git a/web/src/discover/api.ts b/web/src/discover/api.ts new file mode 100644 index 0000000..0773611 --- /dev/null +++ b/web/src/discover/api.ts @@ -0,0 +1,116 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useApi } from '../api/index.ts' +import { isRecord } from '../api/errors.ts' +import { DIFFICULTIES, DINNERS_PATH, oneOf, photoId, type Difficulty } from '../dinners/api.ts' + +export const SUGGESTIONS_PATH = '/api/v1/suggestions' +export const DECK_PATH = `${SUGGESTIONS_PATH}/deck` +export const BATCHES_PATH = `${SUGGESTIONS_PATH}/batches` +export const QUEST_LOG_PATH = `${DINNERS_PATH}?status=quest_log` + +// DECK_KEY is the deck's query; QUEST_LOG_KEY the Quest log's. +export const DECK_KEY = ['suggestions', 'deck'] as const +export const QUEST_LOG_KEY = ['dinners', 'quest_log'] as const + +// SideQuest is a pending suggestion as the deck shows it. photo_id is the +// stored photo's id, from photo_url; reason is the "Why this?". +export type SideQuest = { + id: string + name: string + photo_id: string | undefined + difficulty: Difficulty | null + reason: string +} + +// Choice is the household's decision on a Side quest: Try it or Pass. +export type Choice = 'try' | 'pass' + +// Batch is a POST /api/v1/suggestions/batches answer: the batch and the job +// running it, which GET /api/v1/jobs/{id} reports on. +export type Batch = { batch_id: string; job_id: string } + +// BatchResult is the suggestion batch job's result, published as it goes. +export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number } + +function parseSideQuest(where: string, body: unknown): SideQuest { + if ( + !isRecord(body) || + typeof body.id !== 'string' || + typeof body.name !== 'string' || + typeof body.reason !== 'string' + ) { + throw new Error(`${where}: response is not a suggestion`) + } + return { + id: body.id, + name: body.name, + photo_id: photoId(where, body.photo_url), + difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty), + reason: body.reason, + } +} + +// useDeck is the pending Side quests, oldest first (GET +// /api/v1/suggestions/deck). +export function useDeck() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: DECK_KEY, + queryFn: async () => { + const where = `GET ${DECK_PATH}` + const body = await apiFetch(DECK_PATH) + if (!isRecord(body) || !Array.isArray(body.suggestions)) throw new Error(`${where}: response has no suggestions list`) + return body.suggestions.map((suggestion) => parseSideQuest(where, suggestion)) + }, + }) +} + +// useQuestLogCount is how many dinners are in the Quest log (GET +// /api/v1/dinners?status=quest_log). +export function useQuestLogCount() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: QUEST_LOG_KEY, + queryFn: async () => { + const body = await apiFetch(QUEST_LOG_PATH) + if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`GET ${QUEST_LOG_PATH}: response has no dinners list`) + return body.dinners.length + }, + }) +} + +// useStartBatch asks for more Side quests now (POST +// /api/v1/suggestions/batches). The server answers with the batch already +// running when there is one. +export function useStartBatch() { + const { apiFetch } = useApi() + return useMutation({ + mutationFn: async (): Promise => { + const body = await apiFetch(BATCHES_PATH, { method: 'POST' }) + if (!isRecord(body) || typeof body.batch_id !== 'string' || typeof body.job_id !== 'string') { + throw new Error(`POST ${BATCHES_PATH}: response is not a batch`) + } + return { batch_id: body.batch_id, job_id: body.job_id } + }, + }) +} + +// useDecide records Try it (POST /api/v1/suggestions/{id}/accept) or Pass +// (POST /api/v1/suggestions/{id}/decline), then reads the deck and the +// Quest log again. +export function useDecide() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ id, choice }: { id: string; choice: Choice }) => { + const action = choice === 'try' ? 'accept' : 'decline' + await apiFetch(`${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}`, { method: 'POST' }) + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: DECK_KEY }), + queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }), + ]) + }, + }) +} -- 2.55.0 From fe87968ac9c89541cbfddbe8b91fa9fc2f42a80a Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 01:13:00 -0400 Subject: [PATCH 2/3] Fix the Discover deck review findings The Quest log pill is now a button that shows and hides the names of the dinners in the Quest log, read from GET /api/v1/dinners?status=quest_log; the designed Quest log sheet replaces this list in its own task. A batch whose job cannot be polled, or whose result cannot be read, now shows an error with a Retry that asks for the batch again, instead of leaving skeleton cards on screen with no way out. Decisions go through one submit guarded by a ref set before the request is sent, so presses before the next render cannot send a second, conflicting decision. A decision counts as saved only when the answer is that suggestion, accepted or declined as chosen; anything else shows in the "Couldn't save that choice" banner. Pass and Try it are IconButtons: IconButton gains a neutral tone and showLabel, which shows the label as an 11 px caption under the circle. Maestro task 1ecc121c-86eb-4784-a9fb-1314709b4a59. Co-Authored-By: Claude Opus 5.5 --- web/src/discover/DiscoverPage.module.css | 91 ++++++++---------- web/src/discover/DiscoverPage.test.tsx | 113 +++++++++++++++++++++++ web/src/discover/DiscoverPage.tsx | 110 ++++++++++++++++------ web/src/discover/api.ts | 51 ++++++++-- web/src/ui/IconButton.module.css | 19 ++++ web/src/ui/IconButton.test.tsx | 18 ++++ web/src/ui/IconButton.tsx | 16 +++- 7 files changed, 325 insertions(+), 93 deletions(-) diff --git a/web/src/discover/DiscoverPage.module.css b/web/src/discover/DiscoverPage.module.css index cd72438..bcae6e1 100644 --- a/web/src/discover/DiscoverPage.module.css +++ b/web/src/discover/DiscoverPage.module.css @@ -10,6 +10,7 @@ .header { display: flex; + flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 16px; @@ -33,7 +34,20 @@ line-height: 1.1; } -/* The Quest log count pill. */ +/* The Quest log count pill, in a 44 px tall hit box. */ +.questLogButton { + flex: none; + display: inline-flex; + align-items: center; + min-width: 44px; + min-height: 44px; + padding: 0; + border: 0; + border-radius: var(--radius-pill); + background: transparent; + cursor: pointer; +} + .questLog { flex: none; padding: 7px 12px; @@ -45,6 +59,26 @@ font-weight: 700; } +/* The Quest log's dinner names, under the header while the pill is open. */ +.questLogList { + flex: 1 0 100%; + font-family: var(--font-body); + font-size: 14px; +} + +.questLogNames { + margin: 0; + padding-left: 20px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.questLogEmpty { + margin: 0; + color: var(--color-neutral-700); +} + /* The deck: three cards stacked in the same box, the top one in front. */ .deck { position: relative; @@ -172,7 +206,7 @@ color: var(--color-neutral-900); } -/* Pass and Try it: a 62 px circle over an 11 px label, one button each. */ +/* Pass and Try it: IconButtons with their 11 px labels shown. */ .actions { display: flex; justify-content: center; @@ -180,53 +214,6 @@ margin-top: 4px; } -.action { - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; - min-width: 62px; - min-height: 62px; - padding: 0; - border: 0; - background: none; - color: var(--color-neutral-700); - font-family: var(--font-body); - font-size: 11px; - font-weight: 700; - cursor: pointer; -} - -.action:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.circle { - display: flex; - align-items: center; - justify-content: center; - width: 62px; - height: 62px; - border-radius: var(--radius-pill); - box-shadow: var(--shadow-md); - transition: transform 100ms var(--ease-out-soft); -} - -.action:active:not(:disabled) .circle { - transform: scale(0.97); -} - -.pass { - background: var(--color-neutral-100); - color: var(--color-text); -} - -.tryIt { - background: var(--color-accent-2-600); - color: var(--on-accent); -} - /* Skeleton cards while the deck loads or a batch finds Side quests. */ .loading { display: flex; @@ -256,9 +243,3 @@ font-weight: 700; color: var(--color-neutral-700); } - -@media (prefers-reduced-motion: reduce) { - .circle { - transition: none; - } -} diff --git a/web/src/discover/DiscoverPage.test.tsx b/web/src/discover/DiscoverPage.test.tsx index 202fb9e..3589aa1 100644 --- a/web/src/discover/DiscoverPage.test.tsx +++ b/web/src/discover/DiscoverPage.test.tsx @@ -328,4 +328,117 @@ describe('DiscoverPage', () => { fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) await screen.findByRole('article', { name: 'Korean beef bowls' }) }) + + it('opens and closes the Quest log from its pill, a keyboard-reachable button', async () => { + stubServer([BOWLS], {}, 2) + await renderPage() + const pill = await screen.findByRole('button', { name: 'Quest log · 2' }) + expect(pill).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('list', { name: 'Quest log' })).not.toBeInTheDocument() + expectMinHitArea(pill) + pill.focus() + expect(pill).toHaveFocus() + + fireEvent.click(pill) + expect(pill).toHaveAttribute('aria-expanded', 'true') + const list = screen.getByRole('list', { name: 'Quest log' }) + expect(pill).toHaveAttribute('aria-controls', list.parentElement!.id) + expect(within(list).getAllByRole('listitem').map((item) => item.textContent)).toEqual(['Quest 0', 'Quest 1']) + + fireEvent.click(pill) + expect(pill).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('list', { name: 'Quest log' })).not.toBeInTheDocument() + }) + + it('says so when the Quest log it opens is empty', async () => { + stubServer([BOWLS], {}, 0) + await renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Quest log · 0' })) + expect(screen.getByText('No dinners in the Quest log yet. Try it on a Side quest to add one.')).toBeInTheDocument() + }) + + it('sends one decision for presses made before the page re-renders', async () => { + const accept = deferred() + const server = stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/accept': () => accept.promise }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.keyDown(window, { key: 'ArrowRight' }) + fireEvent.keyDown(window, { key: 'ArrowLeft' }) + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + await waitFor(() => expect(sent(server.fetchMock)).toContain('POST /api/v1/suggestions/s1/accept')) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']) + expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled() + + server.deck = [TIKKA] + accept.resolve(jsonResponse(200, { suggestion: { ...BOWLS, status: 'accepted' }, replenish_job_id: null })) + await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' }) + fireEvent.keyDown(window, { key: 'ArrowLeft' }) + await waitFor(() => + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s2/decline']), + ) + }) + + it.each([ + ['not a decision', {}], + ['another suggestion', { suggestion: { ...TIKKA, status: 'accepted' }, replenish_job_id: null }], + ['not accepted', { suggestion: { ...BOWLS, status: 'pending' }, replenish_job_id: null }], + ])('shows a decision answered with %s as unsaved, keeping the card', async (_, answer) => { + stubServer([BOWLS], { 'POST /api/v1/suggestions/s1/accept': () => jsonResponse(200, answer) }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't save that choice. POST /api/v1/suggestions/s1/accept: response is not the accepted suggestion") + expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(screen.getByRole('article', { name: 'Korean beef bowls' })).toBeInTheDocument() + }) + + it('shows a batch whose job cannot be polled with a retry, keeping the skeleton cards', async () => { + let failPoll = true + const server = stubServer([], { + 'POST /api/v1/suggestions/batches': () => + jsonResponse(202, { batch_id: 'b1', job_id: 'j1', trigger: 'manual', status: 'pending' }), + 'GET /api/v1/jobs/j1': () => { + if (failPoll) return errorResponse(500, 'internal') + server.deck = [BOWLS] + return jsonResponse(200, { + id: 'j1', + kind: 'suggestion_batch', + status: 'succeeded', + result: { batch_id: 'b1', stage: 'done', suggestion_ids: ['s1'], dropped: 0 }, + error: null, + }) + }, + }) + await renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Get more now' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't check on new side quests.") + expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3) + + failPoll = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/batches', 'POST /api/v1/suggestions/batches']) + }) + + it('shows a batch whose job result is unreadable with a retry', async () => { + stubServer([], { + 'POST /api/v1/suggestions/batches': () => + jsonResponse(202, { batch_id: 'b1', job_id: 'j1', trigger: 'manual', status: 'pending' }), + 'GET /api/v1/jobs/j1': () => + jsonResponse(200, { id: 'j1', kind: 'suggestion_batch', status: 'succeeded', result: { batch_id: 'b1' }, error: null }), + }) + await renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Get more now' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't find new side quests. GET /api/v1/jobs/j1: result is not a suggestion batch") + expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument() + }) }) diff --git a/web/src/discover/DiscoverPage.tsx b/web/src/discover/DiscoverPage.tsx index 6ec9e04..45f8a4e 100644 --- a/web/src/discover/DiscoverPage.tsx +++ b/web/src/discover/DiscoverPage.tsx @@ -1,18 +1,20 @@ import { useQueryClient } from '@tanstack/react-query' import { Heart, X } from 'lucide-react' -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { useJob } from '../api/index.ts' +import { Card } from '../ui/Card.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 { Skeleton } from '../ui/Skeleton.tsx' import { DECK_KEY, + parseBatchResult, useDeck, useDecide, - useQuestLogCount, + useQuestLog, useStartBatch, - type BatchResult, type Choice, } from './api.ts' import { DeckCard, DECK_DEPTH } from './DeckCard.tsx' @@ -27,29 +29,88 @@ function isEditable(target: EventTarget | null): boolean { ) } +// QuestLogPill is the "Quest log · N" pill. It shows and hides the names +// of the dinners in the Quest log; the Quest log sheet replaces this list. +function QuestLogPill({ dinners }: { dinners: { id: string; name: string }[] }) { + const [open, setOpen] = useState(false) + const listId = useId() + return ( + <> + + {open && ( + + {dinners.length === 0 ? ( +

No dinners in the Quest log yet. Try it on a Side quest to add one.

+ ) : ( +
    + {dinners.map((dinner) => ( +
  • {dinner.name}
  • + ))} +
+ )} +
+ )} + + ) +} + // DiscoverPage is mock 1a: the deck of Side quests to Try it or Pass, one // at a time from the top card. Every decision — the buttons, the ← / → -// keys — goes through decide, which does nothing while busy. +// keys, Retry — goes through submit, which does nothing while one is in +// flight. The lock is a ref, set before the decision is sent, so a second +// press before the next render cannot send another. export function DiscoverPage() { const queryClient = useQueryClient() const deck = useDeck() - const questLog = useQuestLogCount() + const questLog = useQuestLog() const decision = useDecide() const startBatch = useStartBatch() - const job = useJob(startBatch.data?.job_id ?? null) + const job = useJob(startBatch.data?.job_id ?? null) const top = deck.data?.[0] const busy = decision.isPending const jobRunning = startBatch.isSuccess && job.status !== 'succeeded' && job.status !== 'failed' const finding = startBatch.isPending || jobRunning + // The batch's result as far as it has gone, or why it cannot be read. + const batch = useMemo(() => { + if (job.result === null) return { stored: 0, error: null } + try { + return { stored: parseBatchResult(`GET /api/v1/jobs/${startBatch.data?.job_id}`, job.result).suggestion_ids.length, error: null } + } catch (error) { + return { stored: 0, error: error as Error } + } + }, [job.result, startBatch.data?.job_id]) + // The job's failure, the latest poll's failure, or an unreadable result. + const batchError = job.error ?? batch.error + const { mutate: mutateDecision } = decision + const deciding = useRef(false) + const submit = useCallback( + (variables: { id: string; choice: Choice }) => { + if (deciding.current) return + deciding.current = true + mutateDecision(variables, { + onSettled: () => { + deciding.current = false + }, + }) + }, + [mutateDecision], + ) const decide = useCallback( (choice: Choice) => { - if (busy || top === undefined) return - mutateDecision({ id: top.id, choice }) + if (top !== undefined) submit({ id: top.id, choice }) }, - [busy, top, mutateDecision], + [top, submit], ) useEffect(() => { @@ -67,7 +128,7 @@ export function DiscoverPage() { // The batch stores its Side quests as it goes, so the deck is read again // whenever it reports more, and once it ends. - const stored = job.result?.suggestion_ids.length ?? 0 + const { stored } = batch useEffect(() => { if (job.status === undefined) return void queryClient.invalidateQueries({ queryKey: DECK_KEY }) @@ -85,6 +146,9 @@ export function DiscoverPage() { ))} {finding &&

Finding new side quests…

} + {jobRunning && batchError !== null && ( + startBatch.mutate()}>Couldn't check on new side quests. {batchError.message} + )} ) } else if (top === undefined) { @@ -93,8 +157,8 @@ export function DiscoverPage() { {startBatch.isError && ( startBatch.mutate()}>Couldn't ask for more side quests. {startBatch.error.message} )} - {job.status === 'failed' && job.error !== null && ( - startBatch.mutate()}>Couldn't find new side quests. {job.error.message} + {batchError !== null && ( + startBatch.mutate()}>Couldn't find new side quests. {batchError.message} )} startBatch.mutate() }}> New side quests arrive Sunday. @@ -110,21 +174,15 @@ export function DiscoverPage() { ))}
- - + decide('pass')}> + + + decide('try')}> + +
{decision.isError && ( - mutateDecision(decision.variables!)}> + submit(decision.variables!)}> Couldn't save that choice. {decision.error.message} )} @@ -139,7 +197,7 @@ export function DiscoverPage() {

Discover

Side quests

- {questLog.isSuccess && Quest log · {questLog.data}} + {questLog.isSuccess && } {questLog.isError && ( void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message} diff --git a/web/src/discover/api.ts b/web/src/discover/api.ts index 0773611..b0a0a77 100644 --- a/web/src/discover/api.ts +++ b/web/src/discover/api.ts @@ -32,6 +32,28 @@ export type Batch = { batch_id: string; job_id: string } // BatchResult is the suggestion batch job's result, published as it goes. export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number } +// QuestLogDinner is a dinner in the Quest log, as the Quest log pill lists it. +export type QuestLogDinner = { id: string; name: string } + +// DECISION_STATUS is the status a suggestion has once each choice is saved. +const DECISION_STATUS: Record = { try: 'accepted', pass: 'declined' } + +// parseBatchResult checks a suggestion batch job's result, which useJob +// passes on unread. +export function parseBatchResult(where: string, body: unknown): BatchResult { + if ( + !isRecord(body) || + typeof body.batch_id !== 'string' || + typeof body.stage !== 'string' || + !Array.isArray(body.suggestion_ids) || + !body.suggestion_ids.every((id) => typeof id === 'string') || + typeof body.dropped !== 'number' + ) { + throw new Error(`${where}: result is not a suggestion batch`) + } + return { batch_id: body.batch_id, stage: body.stage, suggestion_ids: body.suggestion_ids as string[], dropped: body.dropped } +} + function parseSideQuest(where: string, body: unknown): SideQuest { if ( !isRecord(body) || @@ -65,16 +87,22 @@ export function useDeck() { }) } -// useQuestLogCount is how many dinners are in the Quest log (GET +// useQuestLog is the dinners in the Quest log, in the server's order (GET // /api/v1/dinners?status=quest_log). -export function useQuestLogCount() { +export function useQuestLog() { const { apiFetch } = useApi() return useQuery({ queryKey: QUEST_LOG_KEY, - queryFn: async () => { + queryFn: async (): Promise => { + const where = `GET ${QUEST_LOG_PATH}` const body = await apiFetch(QUEST_LOG_PATH) - if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`GET ${QUEST_LOG_PATH}: response has no dinners list`) - return body.dinners.length + if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`) + return body.dinners.map((dinner) => { + if (!isRecord(dinner) || typeof dinner.id !== 'string' || typeof dinner.name !== 'string') { + throw new Error(`${where}: response is not a dinner`) + } + return { id: dinner.id, name: dinner.name } + }) }, }) } @@ -97,14 +125,23 @@ export function useStartBatch() { // useDecide records Try it (POST /api/v1/suggestions/{id}/accept) or Pass // (POST /api/v1/suggestions/{id}/decline), then reads the deck and the -// Quest log again. +// Quest log again. The answer must be that suggestion, decided that way. export function useDecide() { const { apiFetch } = useApi() const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ id, choice }: { id: string; choice: Choice }) => { const action = choice === 'try' ? 'accept' : 'decline' - await apiFetch(`${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}`, { method: 'POST' }) + const path = `${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}` + const body = await apiFetch(path, { method: 'POST' }) + if ( + !isRecord(body) || + !isRecord(body.suggestion) || + body.suggestion.id !== id || + body.suggestion.status !== DECISION_STATUS[choice] + ) { + throw new Error(`POST ${path}: response is not the ${DECISION_STATUS[choice]} suggestion`) + } }, onSuccess: async () => { await Promise.all([ diff --git a/web/src/ui/IconButton.module.css b/web/src/ui/IconButton.module.css index 7dd0c66..2995110 100644 --- a/web/src/ui/IconButton.module.css +++ b/web/src/ui/IconButton.module.css @@ -29,6 +29,20 @@ height: 62px; } +/* A shown label sits 6 px under the circle, and the button grows to hold it. */ +.labelled { + flex-direction: column; + gap: 6px; + width: auto; + min-width: 62px; + height: auto; + min-height: 62px; + color: var(--color-neutral-700); + font-family: var(--font-body); + font-size: 11px; + font-weight: 700; +} + .face { display: inline-flex; align-items: center; @@ -57,6 +71,11 @@ color: var(--color-text); } +.neutral { + background: var(--color-neutral-100); + color: var(--color-text); +} + .accent { background: var(--color-accent); color: var(--on-accent); diff --git a/web/src/ui/IconButton.test.tsx b/web/src/ui/IconButton.test.tsx index 0341c1a..e909cdd 100644 --- a/web/src/ui/IconButton.test.tsx +++ b/web/src/ui/IconButton.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' +import { declaredStyle } from '../test/cssRules.ts' import { hitArea, expectMinHitArea } from '../test/hitArea.ts' import { IconButton } from './IconButton.tsx' @@ -31,4 +32,21 @@ describe('IconButton', () => { expectMinHitArea(button) expect(hitArea(button)).toEqual({ width: 62, height: 62 }) }) + + it('shows its label as an 11 px caption under the circle, which names it', () => { + render( + + + , + ) + const button = screen.getByRole('button', { name: 'Pass' }) + expect(button).not.toHaveAttribute('aria-label') + expect(button).toHaveTextContent('Pass') + expect(getComputedStyle(button).fontSize).toBe('11px') + expect(getComputedStyle(button).flexDirection).toBe('column') + expectMinHitArea(button) + const face = button.firstElementChild! + expect(hitArea(face)).toEqual({ width: 62, height: 62 }) + expect(declaredStyle(face).background).toBe('var(--color-neutral-100)') + }) }) diff --git a/web/src/ui/IconButton.tsx b/web/src/ui/IconButton.tsx index 1a5ab73..6da1b22 100644 --- a/web/src/ui/IconButton.tsx +++ b/web/src/ui/IconButton.tsx @@ -2,32 +2,38 @@ import type { ComponentPropsWithRef } from 'react' import styles from './IconButton.module.css' export type IconButtonSize = 40 | 62 -export type IconButtonTone = 'surface' | 'accent' | 'teal' +export type IconButtonTone = 'surface' | 'neutral' | 'accent' | 'teal' export type IconButtonProps = Omit, 'aria-label'> & { // Accessible name; an icon alone never names a button. label: string size?: IconButtonSize tone?: IconButtonTone + // Shows the label as an 11 px caption under the circle, which then names + // the button. + showLabel?: boolean } -// Round icon-only button. The 40 px size keeps a 44 px hit box (see the CSS); -// the 62 px size is the round action button with --shadow-md. +// Round icon button, named by its label and showing it only with showLabel. +// The 40 px size keeps a 44 px hit box (see the CSS); the 62 px size is the +// round action button with --shadow-md. export function IconButton({ label, size = 40, tone = 'surface', + showLabel = false, type = 'button', className, children, ...rest }: IconButtonProps) { - const classes = [styles.button, styles[`size${size}`], className].filter(Boolean).join(' ') + const classes = [styles.button, styles[`size${size}`], showLabel && styles.labelled, className].filter(Boolean).join(' ') return ( - ) } -- 2.55.0 From 7e2bf897a27ff9b15ffa7dcb0ff4a05a347b7064 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 01:22:17 -0400 Subject: [PATCH 3/3] Validate the job envelope and make Retry poll the batch's job useJob now takes the job kind and checks each GET /api/v1/jobs/{id} answer: the requested id, the expected kind, a known status, a result and a text or null error. An answer that is not the job is exposed as the error and stops polling, so an unknown status no longer leaves the Discover skeleton cards running with no error or Retry. useJob also returns poll, which asks for the job at once and restarts the backoff; the Discover page's "Couldn't check on new side quests" Retry uses it instead of posting another batch. Co-Authored-By: Claude Opus 5.5 --- web/src/api/useJob.test.tsx | 85 +++++++++++++++++++-- web/src/api/useJob.ts | 101 ++++++++++++++++++------- web/src/discover/DiscoverPage.test.tsx | 46 ++++++++++- web/src/discover/DiscoverPage.tsx | 8 +- web/src/discover/api.ts | 3 + 5 files changed, 203 insertions(+), 40 deletions(-) diff --git a/web/src/api/useJob.test.tsx b/web/src/api/useJob.test.tsx index 21d1359..6c114f8 100644 --- a/web/src/api/useJob.test.tsx +++ b/web/src/api/useJob.test.tsx @@ -14,7 +14,7 @@ function job(status: string, result: Draft | null, error: string | null = null) function renderJob(jobId: string | null) { const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) const wrapper = ({ children }: { children: ReactNode }) => {children} - return renderHook(({ id }) => useJob(id), { wrapper, initialProps: { id: jobId } }) + return renderHook(({ id }) => useJob('plan.draft', id), { wrapper, initialProps: { id: jobId } }) } // advance runs the timers due in ms and lets the polls they start answer. @@ -42,7 +42,7 @@ describe('useJob', () => { job('succeeded', { dinners: ['Tacos', 'Chili', 'Soup'] }), ) const { result } = renderJob('j1') - expect(result.current).toEqual({ status: undefined, result: null, error: null }) + expect(result.current).toMatchObject({ status: undefined, result: null, error: null }) await advance(0) expect(result.current.status).toBe('queued') @@ -51,7 +51,7 @@ describe('useJob', () => { await advance(499) expect(fetchMock).toHaveBeenCalledTimes(1) await advance(1) - expect(result.current).toEqual({ status: 'running', result: { dinners: ['Tacos'] }, error: null }) + expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] }, error: null }) await advance(999) expect(fetchMock).toHaveBeenCalledTimes(2) @@ -62,7 +62,7 @@ describe('useJob', () => { expect(fetchMock).toHaveBeenCalledTimes(4) await advance(2000) expect(fetchMock).toHaveBeenCalledTimes(5) - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili', 'Soup'] }, error: null, @@ -106,10 +106,76 @@ describe('useJob', () => { expect(fetchMock).toHaveBeenCalledTimes(2) await advance(1) expect(fetchMock).toHaveBeenCalledTimes(3) - expect(result.current).toEqual({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null }) + expect(result.current).toMatchObject({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null }) expect(vi.getTimerCount()).toBe(0) }) + it.each([ + ['an unknown status', { id: 'j1', kind: 'plan.draft', status: 'stuck', result: null, error: null }], + ['another job', { id: 'j2', kind: 'plan.draft', status: 'running', result: null, error: null }], + ['another kind', { id: 'j1', kind: 'suggestion_batch', status: 'running', result: null, error: null }], + ['no result', { id: 'j1', kind: 'plan.draft', status: 'running', error: null }], + ['an error that is not text', { id: 'j1', kind: 'plan.draft', status: 'failed', result: null, error: 500 }], + ['not a job', ['j1']], + ])('exposes an answer with %s as the error and stops, keeping the last status', async (_, body) => { + const fetchMock = stubFetch(job('running', { dinners: ['Tacos'] }), jsonResponse(200, body)) + const { result } = renderJob('j1') + + await advance(0) + await advance(500) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] } }) + expect(result.current.error?.message).toBe('GET /api/v1/jobs/j1: response is not the plan.draft job j1') + expect(vi.getTimerCount()).toBe(0) + await advance(10_000) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('polls again at once on poll after stopping on an answer that is not the job, then keeps polling', async () => { + const fetchMock = stubFetch( + jsonResponse(200, { id: 'j1', kind: 'plan.draft', status: 'stuck', result: null, error: null }), + job('running', { dinners: ['Tacos'] }), + job('succeeded', { dinners: ['Tacos', 'Chili'] }), + ) + const { result } = renderJob('j1') + await advance(0) + expect(result.current.error).not.toBeNull() + expect(vi.getTimerCount()).toBe(0) + + act(() => result.current.poll()) + await advance(0) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] }, error: null }) + await advance(500) + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(result.current).toMatchObject({ status: 'succeeded', error: null }) + }) + + it('polls at once on poll during the backoff after a failed poll, starting the backoff again', async () => { + const fetchMock = stubFetch( + job('running', null), + errorResponse(503, 'unavailable', true), + errorResponse(503, 'unavailable', true), + job('running', { dinners: ['Tacos'] }), + job('succeeded', { dinners: ['Tacos'] }), + ) + const { result } = renderJob('j1') + await advance(0) + await advance(500) + await advance(1000) + expect(fetchMock).toHaveBeenCalledTimes(3) + + // The next poll was due 2 s later; poll asks now. + act(() => result.current.poll()) + await advance(0) + expect(fetchMock).toHaveBeenCalledTimes(4) + expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] }, error: null }) + expect(vi.getTimerCount()).toBe(1) + await advance(500) + expect(fetchMock).toHaveBeenCalledTimes(5) + expect(result.current.status).toBe('succeeded') + }) + it('stops polling on unmount after a failed poll', async () => { const fetchMock = stubFetch(errorResponse(503, 'unavailable', true)) const { result, unmount } = renderJob('j1') @@ -125,12 +191,15 @@ describe('useJob', () => { }) it('stops polling the old job when the job id changes', async () => { - const fetchMock = stubFetch(job('running', null), job('running', null)) + const fetchMock = stubFetch( + job('running', null), + jsonResponse(200, { id: 'j2', kind: 'plan.draft', status: 'running', result: null, error: null }), + ) const { result, rerender } = renderJob('j1') await advance(0) rerender({ id: 'j2' }) - expect(result.current).toEqual({ status: undefined, result: null, error: null }) + expect(result.current).toMatchObject({ status: undefined, result: null, error: null }) await advance(0) expect(request(fetchMock, 1).path).toBe('/api/v1/jobs/j2') expect(vi.getTimerCount()).toBe(1) @@ -165,6 +234,6 @@ describe('useJob', () => { const { result } = renderJob(null) await advance(5000) expect(fetchMock).not.toHaveBeenCalled() - expect(result.current).toEqual({ status: undefined, result: null, error: null }) + expect(result.current).toMatchObject({ status: undefined, result: null, error: null }) }) }) diff --git a/web/src/api/useJob.ts b/web/src/api/useJob.ts index 2a26c2c..b918742 100644 --- a/web/src/api/useJob.ts +++ b/web/src/api/useJob.ts @@ -1,8 +1,11 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useApi } from './ApiContext.tsx' +import { isRecord } from './errors.ts' export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed' +const STATUSES: readonly string[] = ['queued', 'running', 'succeeded', 'failed'] satisfies JobStatus[] + // Job is the GET /api/v1/jobs/{id} response. result may be set while the job // is running (a partial result); error is set only when it failed. export type Job = { @@ -18,28 +21,56 @@ export type JobState = { status: JobStatus | undefined // The latest result, partial while the job runs. result: R | null - // The job's failure, or the latest polling request's own failure (cleared - // by the next poll that answers). + // The job's failure, the latest polling request's own failure (cleared + // by the next poll that answers), or an answer that is not this job. error: Error | null + // poll asks for the job now, starting the backoff again — also after + // polling stopped on an answer that is not this job. + poll: () => void } // Polling starts at FIRST_DELAY_MS and doubles up to MAX_DELAY_MS. export const FIRST_DELAY_MS = 500 export const MAX_DELAY_MS = 2000 +type JobData = Omit, 'poll'> + const idle = { status: undefined, result: null, error: null } +// parseJob checks a GET /api/v1/jobs/{id} answer is the kind of job asked +// for, with a known status. result is passed on unread for the caller to +// check. +function parseJob(where: string, kind: string, jobId: string, body: unknown): Job { + if ( + !isRecord(body) || + body.id !== jobId || + body.kind !== kind || + typeof body.status !== 'string' || + !STATUSES.includes(body.status) || + !('result' in body) || + (body.error !== null && typeof body.error !== 'string') + ) { + throw new Error(`${where}: response is not the ${kind} job ${jobId}`) + } + return { id: jobId, kind, status: body.status as JobStatus, result: body.result as R | null, error: body.error as string | null } +} + function isTerminal(status: JobStatus): boolean { return status === 'succeeded' || status === 'failed' } -// useJob polls GET /api/v1/jobs/{jobId} until the job succeeds or fails, -// backing off from 500 ms to 2 s. A failed poll is exposed as the error and -// polling continues. Polling stops only at a terminal status, when jobId -// changes and on unmount. A null jobId polls nothing. -export function useJob(jobId: string | null): JobState { +// useJob polls GET /api/v1/jobs/{jobId}, a job of the given kind, until it +// succeeds or fails, backing off from 500 ms to 2 s. A failed poll is +// exposed as the error and polling continues. An answer that is not this +// job is exposed as the error and stops polling until poll is called. +// Polling also stops at a terminal status, when jobId changes and on +// unmount. A null jobId polls nothing. +export function useJob(kind: string, jobId: string | null): JobState { const { apiFetch } = useApi() - const [state, setState] = useState<{ jobId: string | null; job: JobState }>({ jobId, job: idle }) + const [state, setState] = useState<{ jobId: string | null; job: JobData }>({ jobId, job: idle }) + // Each poll call bumps round, which restarts the polling effect. + const [round, setRound] = useState(0) + const poll = useCallback(() => setRound((r) => r + 1), []) useEffect(() => { if (jobId === null) return @@ -47,37 +78,51 @@ export function useJob(jobId: string | null): JobState { let timer: ReturnType | undefined let delay = FIRST_DELAY_MS - function next() { - timer = setTimeout(poll, delay) - delay = Math.min(delay * 2, MAX_DELAY_MS) - } - - async function poll() { + async function fetchJob() { + const path = `/api/v1/jobs/${encodeURIComponent(jobId!)}` + let body: unknown try { - const job = await apiFetch>(`/api/v1/jobs/${encodeURIComponent(jobId!)}`) - if (!active) return - const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null - setState({ jobId, job: { status: job.status, result: job.result, error } }) - if (isTerminal(job.status)) return + body = await apiFetch(path) } catch (error) { if (!active) return // A failed poll is not the job's end: keep the last known status and // result, expose the request's error, and poll again. - setState((prev) => ({ - jobId, - job: { ...(prev.jobId === jobId ? prev.job : idle), error: error instanceof Error ? error : new Error(String(error)) }, - })) + fail(error instanceof Error ? error : new Error(String(error))) + next() + return } - next() + if (!active) return + let job: Job + try { + job = parseJob(`GET ${path}`, kind, jobId!, body) + } catch (error) { + // An answer that is not this job will not become one by asking + // again: expose it and stop. + fail(error as Error) + return + } + const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null + setState({ jobId, job: { status: job.status, result: job.result, error } }) + if (!isTerminal(job.status)) next() } - void poll() + // fail keeps the last known status and result, with error. + function fail(error: Error) { + setState((prev) => ({ jobId, job: { ...(prev.jobId === jobId ? prev.job : idle), error } })) + } + + function next() { + timer = setTimeout(fetchJob, delay) + delay = Math.min(delay * 2, MAX_DELAY_MS) + } + + void fetchJob() return () => { active = false clearTimeout(timer) } - }, [apiFetch, jobId]) + }, [apiFetch, kind, jobId, round]) // State left from a previous jobId is not this job's. - return state.jobId === jobId ? state.job : idle + return { ...(state.jobId === jobId ? state.job : idle), poll } } diff --git a/web/src/discover/DiscoverPage.test.tsx b/web/src/discover/DiscoverPage.test.tsx index 3589aa1..a147537 100644 --- a/web/src/discover/DiscoverPage.test.tsx +++ b/web/src/discover/DiscoverPage.test.tsx @@ -68,6 +68,11 @@ function sent(fetchMock: Mock) { .map(([input, init]) => `${init?.method} ${String(input)}`) } +// jobPolls counts the GET /api/v1/jobs/j1 requests. +function jobPolls(fetchMock: Mock) { + return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === '/api/v1/jobs/j1').length +} + afterEach(() => { vi.unstubAllGlobals() }) @@ -419,11 +424,50 @@ describe('DiscoverPage', () => { expect(alert).toHaveTextContent("Couldn't check on new side quests.") expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3) + // Retry polls the job at once, well before the next poll is due; it + // does not ask for another batch. failPoll = false + const polls = jobPolls(server.fetchMock) fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(jobPolls(server.fetchMock)).toBe(polls + 1), { timeout: 200 }) await screen.findByRole('article', { name: 'Korean beef bowls' }) expect(screen.queryByRole('alert')).not.toBeInTheDocument() - expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/batches', 'POST /api/v1/suggestions/batches']) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/batches']) + }) + + it('shows a batch whose job answers with an unknown status as an error, and Retry polls the job again', async () => { + let status = 'stuck' + const server = stubServer([], { + 'POST /api/v1/suggestions/batches': () => + jsonResponse(202, { batch_id: 'b1', job_id: 'j1', trigger: 'manual', status: 'pending' }), + 'GET /api/v1/jobs/j1': () => { + if (status === 'succeeded') server.deck = [BOWLS] + return jsonResponse(200, { + id: 'j1', + kind: 'suggestion_batch', + status, + result: { batch_id: 'b1', stage: 'done', suggestion_ids: ['s1'], dropped: 0 }, + error: null, + }) + }, + }) + await renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Get more now' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent( + "Couldn't check on new side quests. GET /api/v1/jobs/j1: response is not the suggestion_batch job j1", + ) + // Polling has stopped: nothing more is asked until Retry. + await new Promise((resolve) => setTimeout(resolve, 700)) + expect(jobPolls(server.fetchMock)).toBe(1) + + status = 'succeeded' + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + expect(jobPolls(server.fetchMock)).toBe(2) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/batches']) }) it('shows a batch whose job result is unreadable with a retry', async () => { diff --git a/web/src/discover/DiscoverPage.tsx b/web/src/discover/DiscoverPage.tsx index 45f8a4e..77a4337 100644 --- a/web/src/discover/DiscoverPage.tsx +++ b/web/src/discover/DiscoverPage.tsx @@ -9,6 +9,7 @@ import { Icon } from '../ui/Icon.tsx' import { IconButton } from '../ui/IconButton.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { + BATCH_JOB_KIND, DECK_KEY, parseBatchResult, useDeck, @@ -73,7 +74,7 @@ export function DiscoverPage() { const questLog = useQuestLog() const decision = useDecide() const startBatch = useStartBatch() - const job = useJob(startBatch.data?.job_id ?? null) + const job = useJob(BATCH_JOB_KIND, startBatch.data?.job_id ?? null) const top = deck.data?.[0] const busy = decision.isPending @@ -89,7 +90,8 @@ export function DiscoverPage() { return { stored: 0, error: error as Error } } }, [job.result, startBatch.data?.job_id]) - // The job's failure, the latest poll's failure, or an unreadable result. + // The job's failure, the latest poll's failure, an answer that is not the + // batch's job, or an unreadable result. const batchError = job.error ?? batch.error const { mutate: mutateDecision } = decision @@ -147,7 +149,7 @@ export function DiscoverPage() { {finding &&

Finding new side quests…

} {jobRunning && batchError !== null && ( - startBatch.mutate()}>Couldn't check on new side quests. {batchError.message} + Couldn't check on new side quests. {batchError.message} )} ) diff --git a/web/src/discover/api.ts b/web/src/discover/api.ts index b0a0a77..9767eb7 100644 --- a/web/src/discover/api.ts +++ b/web/src/discover/api.ts @@ -29,6 +29,9 @@ export type Choice = 'try' | 'pass' // running it, which GET /api/v1/jobs/{id} reports on. export type Batch = { batch_id: string; job_id: string } +// BATCH_JOB_KIND is the kind of the job running a suggestion batch. +export const BATCH_JOB_KIND = 'suggestion_batch' + // BatchResult is the suggestion batch job's result, published as it goes. export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number } -- 2.55.0