Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 7e2bf897a2 Validate the job envelope and make Retry poll the batch's job
CI / ci (push) Successful in 2m54s
CI / ci (pull_request) Successful in 2m37s
useJob now takes the job kind and checks each GET /api/v1/jobs/{id}
answer: the requested id, the expected kind, a known status, a result
and a text or null error. An answer that is not the job is exposed as the
error and stops polling, so an unknown status no longer leaves the
Discover skeleton cards running with no error or Retry. useJob also
returns poll, which asks for the job at once and restarts the backoff;
the Discover page's "Couldn't check on new side quests" Retry uses it
instead of posting another batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 01:22:17 -04:00
dev_agentandClaude Opus 5.5 fe87968ac9 Fix the Discover deck review findings
CI / ci (pull_request) Successful in 2m39s
CI / ci (push) Successful in 3m10s
The Quest log pill is now a button that shows and hides the names of
the dinners in the Quest log, read from GET
/api/v1/dinners?status=quest_log; the designed Quest log sheet replaces
this list in its own task.

A batch whose job cannot be polled, or whose result cannot be read,
now shows an error with a Retry that asks for the batch again, instead
of leaving skeleton cards on screen with no way out.

Decisions go through one submit guarded by a ref set before the request
is sent, so presses before the next render cannot send a second,
conflicting decision. A decision counts as saved only when the answer
is that suggestion, accepted or declined as chosen; anything else shows
in the "Couldn't save that choice" banner.

Pass and Try it are IconButtons: IconButton gains a neutral tone and
showLabel, which shows the label as an 11 px caption under the circle.

Maestro task 1ecc121c-86eb-4784-a9fb-1314709b4a59.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 01:13:00 -04:00
dev_agentandClaude Opus 5.5 38fec723f6 Add the Discover deck screen (mock 1a)
CI / ci (push) Successful in 2m53s
CI / ci (pull_request) Successful in 2m37s
/discover now shows the Side quests deck: the "Discover" kicker and
"Side quests" title, the teal "Quest log · N" pill, and the three
stacked cards in a 440 px deck with perspective 1000px. The top card
shows the washed photo, the name, the difficulty dots and the
"Why this?" box, with hidden "Try it!" and "Pass" stamps. The 62 px
Pass and Try it buttons and the ←/→ keys all go through one decide
handler that ignores input while a decision is in flight.

An empty deck shows "All caught up" with "Get more now", which starts a
suggestion batch and shows skeleton cards while its job runs, reading
the deck again as the batch stores Side quests.

Maestro task 1ecc121c-86eb-4784-a9fb-1314709b4a59.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 01:03:48 -04:00
13 changed files with 1378 additions and 43 deletions
+14
View File
@@ -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)
+2
View File
@@ -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<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/discover': <DiscoverPage />,
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
'/settings/household': <HouseholdSettingsPage />,
+77 -8
View File
@@ -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 }) => <ApiProvider client={api}>{children}</ApiProvider>
return renderHook(({ id }) => useJob<Draft>(id), { wrapper, initialProps: { id: jobId } })
return renderHook(({ id }) => useJob<Draft>('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 })
})
})
+73 -28
View File
@@ -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<R> = {
@@ -18,28 +21,56 @@ export type JobState<R> = {
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<R> = Omit<JobState<R>, '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<R>(where: string, kind: string, jobId: string, body: unknown): Job<R> {
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<R>(jobId: string | null): JobState<R> {
// 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<R>(kind: string, jobId: string | null): JobState<R> {
const { apiFetch } = useApi()
const [state, setState] = useState<{ jobId: string | null; job: JobState<R> }>({ jobId, job: idle })
const [state, setState] = useState<{ jobId: string | null; job: JobData<R> }>({ 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<R>(jobId: string | null): JobState<R> {
let timer: ReturnType<typeof setTimeout> | 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<Job<R>>(`/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<unknown>(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<R>
try {
job = parseJob<R>(`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 }
}
+2 -2
View File
@@ -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<T extends string>(where: string, field: string, values: readonly T[], value: unknown): T {
export function oneOf<T extends string>(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`)
+63
View File
@@ -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<Difficulty, string> = { 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 (
<article
className={`${styles.card} ${styles[`depth${depth}`]}`}
aria-labelledby={top ? titleId : undefined}
aria-hidden={top ? undefined : true}
inert={!top}
>
<Photo id={quest.photo_id} name={quest.name} radius="24px" inset="10px" sizes="400px" className={styles.photo} />
<div className={styles.content}>
<h2 id={titleId} className={styles.name}>
{quest.name}
</h2>
{quest.difficulty !== null && <DifficultyDots difficulty={quest.difficulty} />}
<p className={styles.why}>
<strong>Why this?</strong> {quest.reason}
</p>
</div>
<span className={`${styles.stamp} ${styles.tryStamp}`} aria-hidden="true">
Try it!
</span>
<span className={`${styles.stamp} ${styles.passStamp}`} aria-hidden="true">
Pass
</span>
</article>
)
}
function DifficultyDots({ difficulty }: { difficulty: Difficulty }) {
const level = DIFFICULTIES.indexOf(difficulty) + 1
return (
<p className={styles.difficulty}>
<span className={styles.dots} aria-hidden="true">
{DIFFICULTIES.map((d, i) => (
<span key={d} data-filled={i < level} className={i < level ? styles.dotFilled : styles.dot} />
))}
</span>
{DIFFICULTY_LABEL[difficulty]}
</p>
)
}
+245
View File
@@ -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);
}
+488
View File
@@ -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<string, unknown> = {}) {
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<Response>
// 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<string, Responder> = {}, questLog = 6) {
const server = { deck, fetchMock: undefined as unknown as Mock<typeof fetch> }
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
[`GET ${DECK}`]: () => jsonResponse(200, { suggestions: server.deck }),
[`GET ${QUEST_LOG}`]: () =>
jsonResponse(200, { dinners: Array.from({ length: questLog }, (_, i) => ({ id: `q${i}`, name: `Quest ${i}` })) }),
...responders,
}
server.fetchMock = vi.fn<typeof fetch>((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<typeof fetch>) {
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<typeof fetch>) {
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(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<DiscoverPage />
</QueryClientProvider>
</ApiProvider>,
)
}
function deferred() {
let resolve!: (response: Response) => void
const promise = new Promise<Response>((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()
})
})
+210
View File
@@ -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 (
<>
<button
type="button"
className={styles.questLogButton}
aria-expanded={open}
aria-controls={open ? listId : undefined}
onClick={() => setOpen(!open)}
>
<span className={styles.questLog}>Quest log · {dinners.length}</span>
</button>
{open && (
<Card id={listId} tone="muted" className={styles.questLogList}>
{dinners.length === 0 ? (
<p className={styles.questLogEmpty}>No dinners in the Quest log yet. Try it on a Side quest to add one.</p>
) : (
<ul aria-label="Quest log" className={styles.questLogNames}>
{dinners.map((dinner) => (
<li key={dinner.id}>{dinner.name}</li>
))}
</ul>
)}
</Card>
)}
</>
)
}
// 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<unknown>(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 = <ErrorBanner onRetry={() => void deck.refetch()}>Couldn't load your side quests. {deck.error.message}</ErrorBanner>
} else if (deck.isPending || (deck.data.length === 0 && finding)) {
body = (
<section aria-busy="true" aria-label="Side quests loading" className={styles.loading}>
<div className={styles.deck}>
{DECK_DEPTH.map((depth) => (
<Skeleton key={depth} data-testid="skeleton-card" className={`${styles.skeletonCard} ${styles[`depth${depth}`]}`} />
))}
</div>
{finding && <p className={styles.finding}>Finding new side quests…</p>}
{jobRunning && batchError !== null && (
<ErrorBanner onRetry={job.poll}>Couldn't check on new side quests. {batchError.message}</ErrorBanner>
)}
</section>
)
} else if (top === undefined) {
body = (
<>
{startBatch.isError && (
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't ask for more side quests. {startBatch.error.message}</ErrorBanner>
)}
{batchError !== null && (
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't find new side quests. {batchError.message}</ErrorBanner>
)}
<EmptyState title="All caught up" action={{ label: 'Get more now', onClick: () => startBatch.mutate() }}>
New side quests arrive Sunday.
</EmptyState>
</>
)
} else {
body = (
<>
<div className={styles.deck}>
{deck.data.slice(0, DECK_DEPTH.length).map((quest, depth) => (
<DeckCard key={quest.id} quest={quest} depth={depth} />
))}
</div>
<div className={styles.actions}>
<IconButton label="Pass" size={62} tone="neutral" showLabel disabled={busy} onClick={() => decide('pass')}>
<Icon icon={X} size="nav" />
</IconButton>
<IconButton label="Try it" size={62} tone="teal" showLabel disabled={busy} onClick={() => decide('try')}>
<Icon icon={Heart} size="nav" />
</IconButton>
</div>
{decision.isError && (
<ErrorBanner onRetry={() => submit(decision.variables!)}>
Couldn't save that choice. {decision.error.message}
</ErrorBanner>
)}
</>
)
}
return (
<div className={styles.page}>
<header className={styles.header}>
<div>
<p className={styles.kicker}>Discover</p>
<h1 className={styles.title}>Side quests</h1>
</div>
{questLog.isSuccess && <QuestLogPill dinners={questLog.data} />}
</header>
{questLog.isError && (
<ErrorBanner onRetry={() => void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message}</ErrorBanner>
)}
{body}
</div>
)
}
+156
View File
@@ -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<Choice, string> = { 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<unknown>(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<QuestLogDinner[]> => {
const where = `GET ${QUEST_LOG_PATH}`
const body = await apiFetch<unknown>(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<Batch> => {
const body = await apiFetch<unknown>(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<unknown>(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 }),
])
},
})
}
+19
View File
@@ -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);
+18
View File
@@ -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(
<IconButton label="Pass" size={62} tone="neutral" showLabel>
<svg />
</IconButton>,
)
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)')
})
})
+11 -5
View File
@@ -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<ComponentPropsWithRef<'button'>, '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 (
<button type={type} aria-label={label} className={classes} {...rest}>
<button type={type} aria-label={showLabel ? undefined : label} className={classes} {...rest}>
<span className={[styles.face, styles[tone]].join(' ')} aria-hidden="true">
{children}
</span>
{showLabel && label}
</button>
)
}