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 }), + ]) + }, + }) +}