Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #105.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user