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/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/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..bcae6e1 --- /dev/null +++ b/web/src/discover/DiscoverPage.module.css @@ -0,0 +1,245 @@ +/* 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; + flex-wrap: wrap; + 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, 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; + 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 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; + 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: IconButtons with their 11 px labels shown. */ +.actions { + display: flex; + justify-content: center; + gap: 28px; + margin-top: 4px; +} + +/* 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); +} diff --git a/web/src/discover/DiscoverPage.test.tsx b/web/src/discover/DiscoverPage.test.tsx new file mode 100644 index 0000000..a147537 --- /dev/null +++ b/web/src/discover/DiscoverPage.test.tsx @@ -0,0 +1,488 @@ +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)}`) +} + +// 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() +}) + +// 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' }) + }) + + 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) + + // 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']) + }) + + 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 () => { + 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 new file mode 100644 index 0000000..77a4337 --- /dev/null +++ b/web/src/discover/DiscoverPage.tsx @@ -0,0 +1,210 @@ +import { useQueryClient } from '@tanstack/react-query' +import { Heart, X } from 'lucide-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 { + BATCH_JOB_KIND, + DECK_KEY, + parseBatchResult, + useDeck, + useDecide, + useQuestLog, + useStartBatch, + 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) + ) +} + +// 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, 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 = useQuestLog() + const decision = useDecide() + const startBatch = useStartBatch() + const job = useJob(BATCH_JOB_KIND, 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, an answer that is not the + // batch's job, 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 (top !== undefined) submit({ id: top.id, choice }) + }, + [top, submit], + ) + + 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 } = batch + 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…

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

Discover

+

Side quests

+
+ {questLog.isSuccess && } +
+ {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..9767eb7 --- /dev/null +++ b/web/src/discover/api.ts @@ -0,0 +1,156 @@ +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 } + +// 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 } + +// 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) || + 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)) + }, + }) +} + +// useQuestLog is the dinners in the Quest log, in the server's order (GET +// /api/v1/dinners?status=quest_log). +export function useQuestLog() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: QUEST_LOG_KEY, + 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(`${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 } + }) + }, + }) +} + +// 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. 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' + 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([ + queryClient.invalidateQueries({ queryKey: DECK_KEY }), + queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }), + ]) + }, + }) +} 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 ( - ) }