Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #110.
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: [], active_batch: null, failed_batch: null }),
|
||||
'/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,14 +99,15 @@ 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`)
|
||||
return decodeURIComponent(match[1])
|
||||
}
|
||||
|
||||
function parseDinner(where: string, body: unknown): Regular {
|
||||
// parseDinner checks a dinner as the dinners API sends it.
|
||||
export function parseDinner(where: string, body: unknown): Regular {
|
||||
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
|
||||
throw new Error(`${where}: response is not a dinner`)
|
||||
}
|
||||
@@ -141,7 +142,7 @@ function parseCandidate(where: string, body: unknown): RecipeCandidate {
|
||||
}
|
||||
}
|
||||
|
||||
function dinnerPath(id: string): string {
|
||||
export function dinnerPath(id: string): string {
|
||||
return `${DINNERS_PATH}/${encodeURIComponent(id)}`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { DOMAttributes } from 'react'
|
||||
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
|
||||
|
||||
export const DIFFICULTY_LABEL: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
|
||||
|
||||
// SwipeHandlers are the pointer handlers that drag the top card.
|
||||
export type SwipeHandlers = Pick<
|
||||
DOMAttributes<HTMLElement>,
|
||||
'onPointerDown' | 'onPointerMove' | 'onPointerUp' | 'onPointerCancel' | 'onLostPointerCapture'
|
||||
>
|
||||
|
||||
export type DeckCardProps = {
|
||||
quest: SideQuest
|
||||
// 0 is the top card; 1 and 2 stack behind it.
|
||||
depth: number
|
||||
// swipe makes the card draggable; only the top card gets it.
|
||||
swipe?: SwipeHandlers
|
||||
}
|
||||
|
||||
// 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 or a drag shows them, and its shade until Pass folds it (see
|
||||
// motion.ts).
|
||||
export function DeckCard({ quest, depth, swipe }: DeckCardProps) {
|
||||
const top = depth === 0
|
||||
const titleId = `side-quest-${quest.id}`
|
||||
return (
|
||||
<article
|
||||
className={`${styles.card} ${styles[`depth${depth}`]}${swipe === undefined ? '' : ` ${styles.draggable}`}`}
|
||||
aria-labelledby={top ? titleId : undefined}
|
||||
aria-hidden={top ? undefined : true}
|
||||
inert={!top}
|
||||
{...swipe}
|
||||
>
|
||||
<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}`} data-part="try-stamp" aria-hidden="true">
|
||||
Try it!
|
||||
</span>
|
||||
<span className={`${styles.stamp} ${styles.passStamp}`} data-part="pass-stamp" aria-hidden="true">
|
||||
Pass
|
||||
</span>
|
||||
<span className={styles.shade} data-part="shade" />
|
||||
</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,249 @@
|
||||
/* 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 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);
|
||||
}
|
||||
|
||||
/* The top card follows a drag instead of scrolling the page. */
|
||||
.draggable {
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* The shade Pass darkens the card with as it folds. */
|
||||
.shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: var(--color-text);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* A blank card holds a depth a short deck has no card for, as the stack's
|
||||
edge, in that depth's place and colour. */
|
||||
.blankCard {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,434 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Heart, X } from 'lucide-react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type Ref } from 'react'
|
||||
import { useJob } from '../api/index.ts'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import {
|
||||
BATCH_JOB_KIND,
|
||||
DECK_KEY,
|
||||
parseBatchResult,
|
||||
stillPending,
|
||||
useDeciding,
|
||||
useDeck,
|
||||
useDecide,
|
||||
useQuestLog,
|
||||
useStartBatch,
|
||||
type Choice,
|
||||
type QuestLogDinner,
|
||||
type SideQuest,
|
||||
} from './api.ts'
|
||||
import { DeckCard, DECK_DEPTH, type SwipeHandlers } from './DeckCard.tsx'
|
||||
import {
|
||||
bump,
|
||||
fadeOut,
|
||||
follow,
|
||||
isAbort,
|
||||
pass,
|
||||
prefersReducedMotion,
|
||||
promote,
|
||||
snapBack,
|
||||
tryIt,
|
||||
unfollow,
|
||||
type Exit,
|
||||
} from './motion.ts'
|
||||
import { QuestLogSheet } from './QuestLogSheet.tsx'
|
||||
import styles from './DiscoverPage.module.css'
|
||||
|
||||
// SWIPE_THRESHOLD is how far, in px, the top card must be dragged left or
|
||||
// right for its release to Pass or Try it.
|
||||
const SWIPE_THRESHOLD = 110
|
||||
|
||||
// A Drag is the pointer dragging the top card: where it went down, and the
|
||||
// card's drag transform so far.
|
||||
type Drag = { pointerId: number; x: number; y: number; dx: number; transform: string | null }
|
||||
|
||||
// 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, which opens the Quest log
|
||||
// sheet. ref is the visible pill, which Try it bumps.
|
||||
function QuestLogPill({ dinners, ref }: { dinners: QuestLogDinner[]; ref: Ref<HTMLSpanElement> }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.questLogButton}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<span ref={ref} className={styles.questLog}>Quest log · {dinners.length}</span>
|
||||
</button>
|
||||
<QuestLogSheet open={open} dinners={dinners} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// DiscoverPage is mock 1a: the deck of Side quests to Try it or Pass, one
|
||||
// at a time from the top card. A decision from the buttons, the ← / → keys
|
||||
// or a swipe of the top card throws it out (motion.ts) and sends the
|
||||
// decision once it is gone, taking the card off the deck at once (see
|
||||
// useDecide); Retry sends a failed one again while the deck's newest read
|
||||
// still has its Side quest pending. Input is locked from the press
|
||||
// until the decision settles, which is once the deck and the Quest log have
|
||||
// been read again after it, so no decision starts under those reads. The lock is a
|
||||
// ref, set before anything starts, so a second press before the next render
|
||||
// does nothing, and busy mirrors it to disable the buttons.
|
||||
export function DiscoverPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const deck = useDeck()
|
||||
// The deck shown: the deck's newest read, less the Side quests being
|
||||
// decided, which a read landing mid-decision may still have pending.
|
||||
const inFlight = useDeciding()
|
||||
const cards = deck.data?.suggestions.filter((quest) => !inFlight.has(quest.id))
|
||||
const questLog = useQuestLog()
|
||||
const decision = useDecide()
|
||||
const startBatch = useStartBatch()
|
||||
// The job of the batch last known to be finding Side quests, or to have
|
||||
// failed to: the one Get more now started or joined, the replenish a
|
||||
// decision started or joined, or the deck's active or failed batch, which
|
||||
// may have been started elsewhere or before the page opened.
|
||||
const [batchJobId, setBatchJobId] = useState<string | null>(null)
|
||||
const job = useJob<unknown>(BATCH_JOB_KIND, batchJobId)
|
||||
// A read of the deck reporting a new active or failed batch follows its
|
||||
// job from that render on. One reporting neither leaves the last job
|
||||
// followed, so its end stays shown — unless that job is known to have
|
||||
// failed, by its poll or the deck's last read: the deck reports the newest
|
||||
// batch's failure, so a later batch has superseded it, and it goes.
|
||||
const failedJobId = deck.data?.failedBatch?.job_id ?? null
|
||||
const deckJobId = deck.data?.activeBatch?.job_id ?? failedJobId
|
||||
const [seenRead, setSeenRead] = useState({ at: 0, jobId: null as string | null, failedJobId: null as string | null })
|
||||
if (deck.dataUpdatedAt !== seenRead.at) {
|
||||
setSeenRead({ at: deck.dataUpdatedAt, jobId: deckJobId, failedJobId })
|
||||
if (deckJobId !== null && deckJobId !== seenRead.jobId) setBatchJobId(deckJobId)
|
||||
else if (deckJobId === null && batchJobId !== null && (job.status === 'failed' || batchJobId === seenRead.failedJobId)) setBatchJobId(null)
|
||||
}
|
||||
|
||||
const top = cards?.[0]
|
||||
const [busy, setBusy] = useState(false)
|
||||
// The deck's failed batch is not finding anything, even before its job's
|
||||
// first poll says why it failed.
|
||||
const jobRunning = batchJobId !== null && batchJobId !== failedJobId && job.status !== 'succeeded' && job.status !== 'failed'
|
||||
const finding = startBatch.isPending || jobRunning
|
||||
const { mutate: mutateStartBatch } = startBatch
|
||||
const getMore = useCallback(() => mutateStartBatch(undefined, { onSuccess: (b) => setBatchJobId(b.job_id) }), [mutateStartBatch])
|
||||
|
||||
// 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/${batchJobId}`, job.result).suggestion_ids.length, error: null }
|
||||
} catch (error) {
|
||||
return { stored: 0, error: error as Error }
|
||||
}
|
||||
}, [job.result, batchJobId])
|
||||
// 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 deckRef = useRef<HTMLDivElement>(null)
|
||||
const passRef = useRef<HTMLButtonElement>(null)
|
||||
const tryRef = useRef<HTMLButtonElement>(null)
|
||||
const questLogRef = useRef<HTMLSpanElement>(null)
|
||||
// The top card's exit until its decision settles, and the Side quest the
|
||||
// last decision sent was for, until the deck moves past it.
|
||||
const exit = useRef<Exit | null>(null)
|
||||
const leaving = useRef<string | null>(null)
|
||||
const drag = useRef<Drag | null>(null)
|
||||
// The top card's snap-back while it may still run; a new drag or a
|
||||
// decision stops it so it does not fight the card's new motion.
|
||||
const springing = useRef<Animation | null>(null)
|
||||
const stopSpring = useCallback(() => {
|
||||
springing.current?.cancel()
|
||||
springing.current = null
|
||||
}, [])
|
||||
|
||||
const { mutate: mutateDecision } = decision
|
||||
const deciding = useRef(false)
|
||||
const lock = useCallback(() => {
|
||||
if (deciding.current) return false
|
||||
deciding.current = true
|
||||
setBusy(true)
|
||||
return true
|
||||
}, [])
|
||||
// send records a decision. A failed one stops the card's exit, so a card
|
||||
// useDecide put back on the deck is at rest; a saved one that started or
|
||||
// joined a replenish batch follows its job.
|
||||
const send = useCallback(
|
||||
(variables: { quest: SideQuest; choice: Choice }) => {
|
||||
leaving.current = variables.quest.id
|
||||
mutateDecision(variables, {
|
||||
onSuccess: (replenishJobId) => {
|
||||
if (replenishJobId !== null) setBatchJobId(replenishJobId)
|
||||
},
|
||||
onError: () => {
|
||||
exit.current?.cancel()
|
||||
leaving.current = null
|
||||
},
|
||||
onSettled: () => {
|
||||
exit.current = null
|
||||
deciding.current = false
|
||||
setBusy(false)
|
||||
},
|
||||
})
|
||||
},
|
||||
[mutateDecision],
|
||||
)
|
||||
const submit = useCallback(
|
||||
(variables: { quest: SideQuest; choice: Choice }) => {
|
||||
if (lock()) send(variables)
|
||||
},
|
||||
[lock, send],
|
||||
)
|
||||
// decide bumps the button, throws the top card out — or, motion reduced,
|
||||
// fades it — then sends the decision; Try it bumps the Quest log pill too.
|
||||
// A swipe's decision comes from the card's drag transform: it is thrown
|
||||
// from there without a bump or wiggle. A button or key decision ends a
|
||||
// drag in progress, putting the card back first.
|
||||
const decide = useCallback(
|
||||
(choice: Choice, from?: string) => {
|
||||
if (top === undefined || !lock()) return
|
||||
stopSpring()
|
||||
const card = deckRef.current!.querySelector('article')!
|
||||
if (from === undefined && drag.current !== null) {
|
||||
drag.current = null
|
||||
unfollow(card)
|
||||
}
|
||||
const reduced = prefersReducedMotion()
|
||||
let run: Exit
|
||||
if (reduced) {
|
||||
run = fadeOut(card)
|
||||
} else if (from === undefined) {
|
||||
bump((choice === 'try' ? tryRef : passRef).current!)
|
||||
run = choice === 'try' ? tryIt(card) : pass(card)
|
||||
} else {
|
||||
run = choice === 'try' ? tryIt(card, from) : pass(card, from)
|
||||
}
|
||||
if (from !== undefined) {
|
||||
// Putting a swiped card back puts it back at rest.
|
||||
const thrown = run
|
||||
run = {
|
||||
finished: thrown.finished,
|
||||
cancel: () => {
|
||||
thrown.cancel()
|
||||
unfollow(card)
|
||||
},
|
||||
}
|
||||
}
|
||||
exit.current = run
|
||||
run.finished.then(
|
||||
() => {
|
||||
// The Quest log pill is missing while the Quest log fails to load.
|
||||
if (choice === 'try' && !reduced && questLogRef.current !== null) bump(questLogRef.current)
|
||||
send({ quest: top, choice })
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!isAbort(error)) throw error
|
||||
},
|
||||
)
|
||||
},
|
||||
[top, lock, send, stopSpring],
|
||||
)
|
||||
|
||||
// swipe drags the top card: it follows the pointer, captured from the
|
||||
// press, until the release decides past the threshold or springs it back
|
||||
// short of it. The release decides from where the pointer let go, which
|
||||
// may differ from its last move. A cancel, or losing the capture before
|
||||
// the release, springs it back from its last move. One pointer drags at
|
||||
// a time: a press while a drag is in progress is ignored, as is input
|
||||
// while busy, and a drag held into a busy spell (a Retry) is put back on
|
||||
// release.
|
||||
const swipe = useMemo<SwipeHandlers>(() => {
|
||||
function track(card: HTMLElement, current: Drag, event: PointerEvent<HTMLElement>) {
|
||||
current.dx = event.clientX - current.x
|
||||
current.transform = follow(card, current.dx, event.clientY - current.y)
|
||||
}
|
||||
function release(event: PointerEvent<HTMLElement>, decides: boolean) {
|
||||
const card = event.currentTarget
|
||||
const current = drag.current
|
||||
if (current === null || current.pointerId !== event.pointerId) return
|
||||
drag.current = null
|
||||
const moved = current.transform !== null || event.clientX !== current.x || event.clientY !== current.y
|
||||
if (decides && moved && !deciding.current) track(card, current, event)
|
||||
if (current.transform === null) return
|
||||
if (deciding.current) unfollow(card)
|
||||
else if (decides && current.dx > SWIPE_THRESHOLD) decide('try', current.transform)
|
||||
else if (decides && current.dx < -SWIPE_THRESHOLD) decide('pass', current.transform)
|
||||
else if (prefersReducedMotion()) unfollow(card)
|
||||
else springing.current = snapBack(card, current.transform)
|
||||
}
|
||||
return {
|
||||
onPointerDown: (event) => {
|
||||
if (drag.current !== null || deciding.current || !event.isPrimary || event.button !== 0) return
|
||||
stopSpring()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
drag.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, dx: 0, transform: null }
|
||||
},
|
||||
onPointerMove: (event) => {
|
||||
const current = drag.current
|
||||
if (current === null || current.pointerId !== event.pointerId || deciding.current) return
|
||||
track(event.currentTarget, current, event)
|
||||
},
|
||||
onPointerUp: (event) => release(event, true),
|
||||
onPointerCancel: (event) => release(event, false),
|
||||
// After a release the drag is already over, so this only ends a drag
|
||||
// whose capture was lost before it.
|
||||
onLostPointerCapture: (event) => release(event, false),
|
||||
}
|
||||
}, [decide, stopSpring])
|
||||
|
||||
// Leaving the page stops an exit before its decision is sent.
|
||||
useEffect(() => () => exit.current?.cancel(), [])
|
||||
|
||||
const topId = top?.id
|
||||
// A drag belongs to the card it started on, which a new top card ends.
|
||||
useLayoutEffect(() => {
|
||||
drag.current = null
|
||||
}, [topId])
|
||||
|
||||
// Once the deck moves past the card a decision was sent for, the cards
|
||||
// behind it move up a depth.
|
||||
useLayoutEffect(() => {
|
||||
if (leaving.current === null || leaving.current === topId) return
|
||||
leaving.current = null
|
||||
if (topId === undefined || prefersReducedMotion()) return
|
||||
promote([...deckRef.current!.querySelectorAll<HTMLElement>(':scope > article')])
|
||||
}, [topId])
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
if (isEditable(event.target)) return
|
||||
// A modal over the page (the Quest log sheet or its confirmation) makes
|
||||
// the deck inert: its keys are the modal's, not the deck's.
|
||||
if (deckRef.current === null || deckRef.current.closest('[inert]') !== null) 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. A read already out may be a
|
||||
// decision's, which must not be cut short: the batch's read waits for it,
|
||||
// then goes out after it, as it may predate what the batch reported.
|
||||
const { stored } = batch
|
||||
useEffect(() => {
|
||||
if (job.status === undefined) return
|
||||
const after = queryClient.isFetching({ queryKey: DECK_KEY }) > 0
|
||||
void queryClient
|
||||
.invalidateQueries({ queryKey: DECK_KEY }, { cancelRefetch: false })
|
||||
.then(() => (after ? queryClient.invalidateQueries({ queryKey: DECK_KEY }, { cancelRefetch: false }) : undefined))
|
||||
}, [queryClient, job.status, stored])
|
||||
|
||||
// A failed decision can be retried only while the deck's newest read
|
||||
// still has its Side quest pending: anything else decided it already.
|
||||
const pending = decision.isError ? stillPending(deck, decision.variables.quest.id) : undefined
|
||||
const retryable = pending !== undefined && pending !== null
|
||||
|
||||
// What went wrong asking for or finding more Side quests, shown whatever
|
||||
// the deck has: a replenish can fail while cards remain. While Get more now
|
||||
// is asking again, the last batch's failure is not the news.
|
||||
const batchFailure = startBatch.isPending ? null : startBatch.isError ? (
|
||||
<ErrorBanner onRetry={getMore}>Couldn't ask for more side quests. {startBatch.error.message}</ErrorBanner>
|
||||
) : batchError === null ? null : jobRunning ? (
|
||||
<ErrorBanner onRetry={job.poll}>Couldn't check on new side quests. {batchError.message}</ErrorBanner>
|
||||
) : (
|
||||
<ErrorBanner onRetry={getMore}>Couldn't find new side quests. {batchError.message}</ErrorBanner>
|
||||
)
|
||||
|
||||
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 || (cards!.length === 0 && (finding || decision.isPending))) {
|
||||
// An empty deck is not caught up while a batch finds more Side quests,
|
||||
// or while the last decision may yet start one or come back refused.
|
||||
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>}
|
||||
</section>
|
||||
)
|
||||
} else if (top === undefined) {
|
||||
body = (
|
||||
<EmptyState title="All caught up" action={{ label: 'Get more now', onClick: getMore }}>
|
||||
New side quests arrive Sunday.
|
||||
</EmptyState>
|
||||
)
|
||||
} else {
|
||||
const shown = cards!.slice(0, DECK_DEPTH.length)
|
||||
body = (
|
||||
<>
|
||||
{/* The deck keeps all three depths: a short deck fills those it has
|
||||
no card for with skeleton cards while a batch finds more Side
|
||||
quests, and with blank cards, the stack's edge, while none is
|
||||
coming. */}
|
||||
<div ref={deckRef} className={styles.deck}>
|
||||
{shown.map((quest, depth) => (
|
||||
<DeckCard key={quest.id} quest={quest} depth={depth} swipe={depth === 0 ? swipe : undefined} />
|
||||
))}
|
||||
{DECK_DEPTH.slice(shown.length).map((depth) =>
|
||||
finding ? (
|
||||
<Skeleton key={depth} data-testid="skeleton-card" className={`${styles.skeletonCard} ${styles[`depth${depth}`]}`} />
|
||||
) : (
|
||||
<div key={depth} data-testid="blank-card" aria-hidden="true" className={`${styles.blankCard} ${styles[`depth${depth}`]}`} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<IconButton ref={passRef} label="Pass" size={62} tone="neutral" showLabel disabled={busy} onClick={() => decide('pass')}>
|
||||
<Icon icon={X} size="nav" />
|
||||
</IconButton>
|
||||
<IconButton ref={tryRef} label="Try it" size={62} tone="teal" showLabel disabled={busy} onClick={() => decide('try')}>
|
||||
<Icon icon={Heart} size="nav" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{finding && <p className={styles.finding}>Finding new side quests…</p>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 ref={questLogRef} dinners={questLog.data} />}
|
||||
</header>
|
||||
{questLog.isError && (
|
||||
<ErrorBanner onRetry={() => void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message}</ErrorBanner>
|
||||
)}
|
||||
{body}
|
||||
{batchFailure}
|
||||
{/* A failed decision is shown whatever the deck has come to since. */}
|
||||
{decision.isError &&
|
||||
(retryable ? (
|
||||
<ErrorBanner onRetry={() => submit(decision.variables)}>Couldn't save that choice. {decision.error.message}</ErrorBanner>
|
||||
) : pending === undefined ? (
|
||||
<ErrorBanner>Couldn't save that choice. {decision.error.message}</ErrorBanner>
|
||||
) : (
|
||||
<ErrorBanner>That side quest has been decided already. {decision.error.message}</ErrorBanner>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* The Quest log sheet (mock 1a's "Quest log · N" pill opens it). */
|
||||
.sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.rows {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* The 48 px washed photo; Photo applies the washed filter. */
|
||||
.photo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { ListRow } from '../ui/ListRow.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import { Sheet } from '../ui/Sheet.tsx'
|
||||
import { useRemoveFromQuestLog, type QuestLogDinner } from './api.ts'
|
||||
import { DIFFICULTY_LABEL } from './DeckCard.tsx'
|
||||
import styles from './QuestLogSheet.module.css'
|
||||
|
||||
export type QuestLogSheetProps = {
|
||||
open: boolean
|
||||
// The Quest log, as useQuestLog reads it.
|
||||
dinners: QuestLogDinner[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// details is a Quest-log dinner's "difficulty · minutes" line, or null when
|
||||
// neither is known.
|
||||
function details(dinner: QuestLogDinner): string | null {
|
||||
const parts = [
|
||||
dinner.difficulty !== null && DIFFICULTY_LABEL[dinner.difficulty],
|
||||
dinner.minutes_total !== null && `${dinner.minutes_total} min`,
|
||||
].filter((part): part is string => typeof part === 'string')
|
||||
return parts.length === 0 ? null : parts.join(' · ')
|
||||
}
|
||||
|
||||
// QuestLogSheet is the Quest log as a bottom sheet: each dinner with its
|
||||
// washed photo, name, difficulty and minutes. A row opens the dinner; its
|
||||
// remove button, once confirmed, archives the dinner.
|
||||
export function QuestLogSheet({ open, dinners, onClose }: QuestLogSheetProps) {
|
||||
const titleId = useId()
|
||||
const navigate = useNavigate()
|
||||
const remove = useRemoveFromQuestLog()
|
||||
// The dinner whose removal is being confirmed.
|
||||
const [removing, setRemoving] = useState<QuestLogDinner | null>(null)
|
||||
// The dinner just removed and its place in the list, until focus has
|
||||
// moved on from it.
|
||||
const removed = useRef<{ id: string; index: number } | null>(null)
|
||||
const removeButtons = useRef(new Map<string, HTMLButtonElement>())
|
||||
const sheetRef = useRef<HTMLDivElement>(null)
|
||||
// Whether a removal has been sent and not yet settled. The ref is set as
|
||||
// Remove is activated, so a dismissal or a second Remove that arrives before
|
||||
// the next render is already refused; the state renders it.
|
||||
const sending = useRef(false)
|
||||
const [sendingShown, setSendingShown] = useState(false)
|
||||
|
||||
// Once a removal's confirmation has closed and the dinner has left the list,
|
||||
// focus goes to the remove button of the dinner now in its place, or of the
|
||||
// one before it when it was the last, or to the Quest log sheet itself when
|
||||
// none is left. Otherwise it would be left on the removed dinner's button,
|
||||
// which the confirmation hands it back to, and which is then gone.
|
||||
useEffect(() => {
|
||||
const done = removed.current
|
||||
if (removing !== null || done === null || dinners.some((d) => d.id === done.id)) return
|
||||
removed.current = null
|
||||
const next = dinners[Math.min(done.index, dinners.length - 1)]
|
||||
if (next) removeButtons.current.get(next.id)!.focus()
|
||||
else sheetRef.current!.closest<HTMLElement>('[role="dialog"]')!.focus()
|
||||
}, [removing, dinners])
|
||||
|
||||
function setSending(value: boolean) {
|
||||
sending.current = value
|
||||
setSendingShown(value)
|
||||
}
|
||||
|
||||
function cancelRemove() {
|
||||
if (sending.current) return
|
||||
remove.reset()
|
||||
setRemoving(null)
|
||||
}
|
||||
|
||||
function confirmRemove() {
|
||||
if (sending.current) return
|
||||
setSending(true)
|
||||
remove.mutate(removing!, {
|
||||
onSuccess: (_data, dinner) => {
|
||||
removed.current = { id: dinner.id, index: dinners.findIndex((d) => d.id === dinner.id) }
|
||||
setRemoving(null)
|
||||
},
|
||||
onSettled: () => setSending(false),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onClose={onClose} aria-labelledby={titleId}>
|
||||
<div ref={sheetRef} className={styles.sheet}>
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
Quest log
|
||||
</h2>
|
||||
{dinners.length === 0 ? (
|
||||
<p className={styles.empty}>Your Quest log is empty — swipe right on a side quest to add one.</p>
|
||||
) : (
|
||||
<ul aria-label="Quest log" className={styles.rows}>
|
||||
{dinners.map((dinner) => (
|
||||
<li key={dinner.id}>
|
||||
<ListRow
|
||||
leading={
|
||||
// The row's name already says what the photo shows.
|
||||
<span aria-hidden="true">
|
||||
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-md)" sizes="48px" className={styles.photo} />
|
||||
</span>
|
||||
}
|
||||
title={dinner.name}
|
||||
meta={details(dinner)}
|
||||
onClick={() => navigate(`/dinner/${encodeURIComponent(dinner.id)}`)}
|
||||
trailing={
|
||||
<IconButton
|
||||
ref={(button) => {
|
||||
if (button) removeButtons.current.set(dinner.id, button)
|
||||
else removeButtons.current.delete(dinner.id)
|
||||
}}
|
||||
label={`Remove ${dinner.name}`}
|
||||
tone="neutral"
|
||||
onClick={() => setRemoving(dinner)}
|
||||
>
|
||||
<Icon icon={Trash2} />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Sheet>
|
||||
<Confirm
|
||||
open={removing !== null}
|
||||
title={`Remove ${removing?.name ?? ''}?`}
|
||||
confirmLabel={sendingShown ? 'Removing…' : 'Remove'}
|
||||
cancelLabel="Keep it"
|
||||
busy={sendingShown}
|
||||
// Once the removal is sent, it stands: the confirmation stays until it settles.
|
||||
dismissible={!sendingShown}
|
||||
error={remove.isError ? `Couldn't remove it. ${remove.error.message}` : null}
|
||||
onCancel={cancelRemove}
|
||||
onConfirm={confirmRemove}
|
||||
>
|
||||
It leaves your Quest log for good.
|
||||
</Confirm>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { DIFFICULTIES, DINNERS_PATH, dinnerPath, oneOf, parseDinner, photoId, type Difficulty, type Regular } 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;
|
||||
// DECIDE_KEY the decisions'.
|
||||
export const DECK_KEY = ['suggestions', 'deck'] as const
|
||||
export const QUEST_LOG_KEY = ['dinners', 'quest_log'] as const
|
||||
const DECIDE_KEY = ['suggestions', 'decide'] as const
|
||||
|
||||
// SideQuest is a pending suggestion as the deck shows it. dinner_id is the
|
||||
// dinner Try it moves to the Quest log; photo_id is the stored photo's id,
|
||||
// from photo_url; reason is the "Why this?".
|
||||
export type SideQuest = {
|
||||
id: string
|
||||
dinner_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, or the deck's active
|
||||
// or failed batch: the batch and the job running it, which GET
|
||||
// /api/v1/jobs/{id} reports on.
|
||||
export type Batch = { batch_id: string; job_id: string }
|
||||
|
||||
// Deck is the deck as GET /api/v1/suggestions/deck reads it: the pending
|
||||
// Side quests, oldest first; the batch pending or running, if any; and the
|
||||
// newest batch when it failed, which a later batch supersedes. At most one
|
||||
// of the two batches is set.
|
||||
export type Deck = { suggestions: SideQuest[]; activeBatch: Batch | null; failedBatch: Batch | null }
|
||||
|
||||
function isBatch(body: unknown): body is Batch {
|
||||
return isRecord(body) && typeof body.batch_id === 'string' && typeof body.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 sheet lists
|
||||
// it: photo, name, difficulty and minutes.
|
||||
export type QuestLogDinner = Pick<Regular, 'id' | 'name' | 'photo_id' | 'difficulty' | 'minutes_total'>
|
||||
|
||||
// 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.dinner_id !== 'string' ||
|
||||
typeof body.name !== 'string' ||
|
||||
typeof body.reason !== 'string'
|
||||
) {
|
||||
throw new Error(`${where}: response is not a suggestion`)
|
||||
}
|
||||
return {
|
||||
id: body.id,
|
||||
dinner_id: body.dinner_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, the batch pending or
|
||||
// running and the newest batch when it failed (GET
|
||||
// /api/v1/suggestions/deck).
|
||||
export function useDeck() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: DECK_KEY,
|
||||
queryFn: async (): Promise<Deck> => {
|
||||
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`)
|
||||
if (body.active_batch !== null && !isBatch(body.active_batch)) throw new Error(`${where}: response has no active_batch`)
|
||||
if (body.failed_batch !== null && !isBatch(body.failed_batch)) throw new Error(`${where}: response has no failed_batch`)
|
||||
return {
|
||||
suggestions: body.suggestions.map((suggestion) => parseSideQuest(where, suggestion)),
|
||||
activeBatch: body.active_batch === null ? null : { batch_id: body.active_batch.batch_id, job_id: body.active_batch.job_id },
|
||||
failedBatch: body.failed_batch === null ? null : { batch_id: body.failed_batch.batch_id, job_id: body.failed_batch.job_id },
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// useQuestLog is the dinners in the Quest log, in the server's order (GET
|
||||
// /api/v1/dinners?status=quest_log). The server leaves out a dinner already
|
||||
// on the plan for the current week or a later one: it is scheduled.
|
||||
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) => {
|
||||
const { id, name, photo_id, difficulty, minutes_total } = parseDinner(where, dinner)
|
||||
return { id, name, photo_id, difficulty, minutes_total }
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// useRemoveFromQuestLog archives a Quest-log dinner (DELETE
|
||||
// /api/v1/dinners/{id}). Once the server has archived it, it leaves the
|
||||
// Quest log, and the Quest log is read again.
|
||||
export function useRemoveFromQuestLog() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (dinner: QuestLogDinner) => {
|
||||
await apiFetch<unknown>(dinnerPath(dinner.id), { method: 'DELETE' })
|
||||
},
|
||||
onSuccess: async (_data, dinner) => {
|
||||
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) => dinners?.filter((d) => d.id !== dinner.id))
|
||||
await queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 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 (!isBatch(body)) throw new Error(`POST ${BATCHES_PATH}: response is not a batch`)
|
||||
return { batch_id: body.batch_id, job_id: body.job_id }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// stillPending says whether the deck's newest read has the Side quest id
|
||||
// pending, after a failed decision on it: the Side quest when it does, null
|
||||
// when it does not, and undefined when that read failed, which says neither.
|
||||
// deck is the deck query's state, from the cache or useDeck.
|
||||
export function stillPending(deck: { status: string; data: Deck | undefined } | undefined, id: string): SideQuest | null | undefined {
|
||||
if (deck?.status !== 'success') return undefined
|
||||
return deck.data!.suggestions.find((q) => q.id === id) ?? null
|
||||
}
|
||||
|
||||
// useDeciding is the ids of the Side quests whose decisions are pending:
|
||||
// sent and not yet settled. The deck shown leaves them out, whatever a read
|
||||
// of the deck has said meanwhile.
|
||||
export function useDeciding(): Set<string> {
|
||||
const ids = useMutationState<string>({
|
||||
filters: { mutationKey: DECIDE_KEY, status: 'pending' },
|
||||
select: (mutation) => (mutation.state.variables as { quest: SideQuest }).quest.id,
|
||||
})
|
||||
return new Set(ids)
|
||||
}
|
||||
|
||||
// useDecide records Try it (POST /api/v1/suggestions/{id}/accept) or Pass
|
||||
// (POST /api/v1/suggestions/{id}/decline) on the Side quest the household
|
||||
// decided on, whether or not a read of the deck has since taken it away.
|
||||
// The answer must be that suggestion, decided that way; the mutation's data
|
||||
// is the id of the replenish job the decision started or joined, or null.
|
||||
//
|
||||
// The decision is applied at once: the Side quest leaves the deck shown
|
||||
// while it is pending (see useDeciding) and, on Try it, its dinner joins the
|
||||
// Quest log unless it is there already. Nothing else is worked out here:
|
||||
// the server is the record of both. However the decision ends, saved or
|
||||
// failed, the deck and the Quest log are read again, and the decision is
|
||||
// pending until those reads land, so the next one (which DiscoverPage locks
|
||||
// out until then) cannot start under them. A read under way when the
|
||||
// decision ends is replaced by the read made after it, and DiscoverPage's
|
||||
// batch reads wait for that one rather than replace it.
|
||||
// A failed decision waits for the deck read it makes itself. Whether its
|
||||
// Side quest is still pending is then the deck query's to say, from its
|
||||
// newest successful read: once the reads have landed, a Side quest the deck
|
||||
// still has goes back on top of it.
|
||||
export function useDecide() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// send POSTs the decision and checks the answer.
|
||||
async function send(id: string, choice: Choice): Promise<string | null> {
|
||||
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`)
|
||||
}
|
||||
if (body.replenish_job_id !== null && typeof body.replenish_job_id !== 'string') {
|
||||
throw new Error(`POST ${path}: response has no replenish_job_id`)
|
||||
}
|
||||
return body.replenish_job_id
|
||||
}
|
||||
|
||||
return useMutation<string | null, Error, { quest: SideQuest; choice: Choice }>({
|
||||
mutationKey: DECIDE_KEY,
|
||||
mutationFn: async ({ quest, choice }) => {
|
||||
try {
|
||||
return await send(quest.id, choice)
|
||||
} catch (error) {
|
||||
// The deck is read again as the decision's own fetch, replacing any
|
||||
// read already out. A failed read is the deck query's error, shown
|
||||
// with the deck; the decision's own error is the one thrown.
|
||||
await queryClient
|
||||
.getQueryCache()
|
||||
.build<Deck>(queryClient, { queryKey: DECK_KEY })
|
||||
.fetch(undefined, { cancelRefetch: true })
|
||||
.catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
onMutate: ({ quest, choice }) => {
|
||||
if (choice !== 'try') return
|
||||
// A stale deck can offer a Side quest whose dinner the Quest log has.
|
||||
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) =>
|
||||
dinners === undefined || dinners.some((d) => d.id === quest.dinner_id)
|
||||
? dinners
|
||||
: // The deck does not say the minutes; the Quest log read after the
|
||||
// decision does.
|
||||
dinners.concat({
|
||||
id: quest.dinner_id,
|
||||
name: quest.name,
|
||||
photo_id: quest.photo_id,
|
||||
difficulty: quest.difficulty,
|
||||
minutes_total: null,
|
||||
}),
|
||||
)
|
||||
},
|
||||
onSettled: async (_replenishJobId, error, { quest }) => {
|
||||
if (error === null) {
|
||||
await Promise.all([queryClient.invalidateQueries({ queryKey: DECK_KEY }), queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })])
|
||||
return
|
||||
}
|
||||
// A failed decision has read the deck already; any read since is newer.
|
||||
await queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
|
||||
const pending = stillPending(queryClient.getQueryState<Deck>(DECK_KEY), quest.id)
|
||||
if (pending === null || pending === undefined) return
|
||||
queryClient.setQueryData<Deck>(DECK_KEY, (deck) => ({
|
||||
...deck!,
|
||||
suggestions: [pending, ...deck!.suggestions.filter((q) => q.id !== pending.id)],
|
||||
}))
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { stubAnimate, type FakeAnimation } from '../test/animate.ts'
|
||||
import { bump, dragTransform, fadeOut, follow, isAbort, pass, promote, snapBack, tryIt, unfollow } from './motion.ts'
|
||||
|
||||
let animations: FakeAnimation[]
|
||||
|
||||
beforeEach(() => {
|
||||
animations = stubAnimate({ autoFinish: false })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(Element.prototype, 'animate')
|
||||
})
|
||||
|
||||
// card builds a deck card with the parts DeckCard marks.
|
||||
function card() {
|
||||
const article = document.createElement('article')
|
||||
article.innerHTML = '<span data-part="try-stamp"></span><span data-part="pass-stamp"></span><span data-part="shade"></span>'
|
||||
return {
|
||||
article,
|
||||
stamp: article.querySelector<HTMLElement>('[data-part="try-stamp"]')!,
|
||||
passStamp: article.querySelector<HTMLElement>('[data-part="pass-stamp"]')!,
|
||||
shade: article.querySelector('[data-part="shade"]')!,
|
||||
}
|
||||
}
|
||||
|
||||
// settled says whether a promise has settled after pending callbacks run.
|
||||
async function settled(promise: Promise<unknown>) {
|
||||
let done = false
|
||||
void promise.then(
|
||||
() => (done = true),
|
||||
() => (done = true),
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
return done
|
||||
}
|
||||
|
||||
const FLY_OFF = { transform: 'translate(150%,-60px) rotate(28deg)', opacity: 0 }
|
||||
|
||||
describe('motion', () => {
|
||||
it('bumps: scale 1 → .88 → 1.06 → 1, 300 ms ease-out', () => {
|
||||
const button = document.createElement('button')
|
||||
bump(button)
|
||||
expect(animations).toHaveLength(1)
|
||||
expect(animations[0]!.target).toBe(button)
|
||||
expect(animations[0]!.keyframes).toEqual([
|
||||
{ transform: 'scale(1)' },
|
||||
{ transform: 'scale(.88)' },
|
||||
{ transform: 'scale(1.06)' },
|
||||
{ transform: 'scale(1)' },
|
||||
])
|
||||
expect(animations[0]!.options).toEqual({ duration: 300, easing: 'ease-out' })
|
||||
})
|
||||
|
||||
it('Try it pops the stamp and wiggles, then throws the card right', async () => {
|
||||
const { article, stamp } = card()
|
||||
const exit = tryIt(article)
|
||||
|
||||
const [pop, wiggle] = animations
|
||||
expect(pop!.target).toBe(stamp)
|
||||
expect(pop!.keyframes).toEqual([
|
||||
{ opacity: 0, transform: 'rotate(-12deg) scale(1.5)' },
|
||||
{ opacity: 1, transform: 'rotate(-12deg) scale(1)' },
|
||||
])
|
||||
expect(pop!.options).toEqual({ duration: 220, easing: 'cubic-bezier(.34,1.4,.64,1)', fill: 'forwards' })
|
||||
expect(wiggle!.target).toBe(article)
|
||||
expect(wiggle!.keyframes).toEqual(['0deg', '-4deg', '4deg', '-3deg', '2deg', '0deg'].map((a) => ({ transform: `rotate(${a})` })))
|
||||
expect(wiggle!.options).toEqual({ duration: 420, easing: 'ease-in-out' })
|
||||
expect(animations).toHaveLength(2)
|
||||
|
||||
// The throw waits for the wiggle.
|
||||
pop!.finish()
|
||||
wiggle!.finish()
|
||||
await expect.poll(() => animations.length).toBe(3)
|
||||
const fly = animations[2]!
|
||||
expect(fly.target).toBe(article)
|
||||
expect(fly.keyframes).toEqual([
|
||||
{ transform: 'translate(0px,0px) rotate(0deg)' },
|
||||
{ transform: 'translate(-14px,4px) rotate(-3deg)', offset: 0.18 },
|
||||
FLY_OFF,
|
||||
])
|
||||
expect(fly.options).toEqual({ duration: 480, easing: 'cubic-bezier(.55,0,.8,.4)', fill: 'forwards' })
|
||||
|
||||
expect(await settled(exit.finished)).toBe(false)
|
||||
fly.finish()
|
||||
await expect(exit.finished).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('Try it from a dragged transform skips the wiggle and throws in 340 ms', async () => {
|
||||
const { article } = card()
|
||||
const exit = tryIt(article, 'translate(120px,9px) rotate(6.7deg)')
|
||||
await expect.poll(() => animations.length).toBe(2)
|
||||
const fly = animations[1]!
|
||||
expect(fly.target).toBe(article)
|
||||
expect(fly.keyframes).toEqual([{ transform: 'translate(120px,9px) rotate(6.7deg)' }, FLY_OFF])
|
||||
expect(fly.options).toEqual({ duration: 340, easing: 'cubic-bezier(.55,0,.8,.4)', fill: 'forwards' })
|
||||
fly.finish()
|
||||
await expect(exit.finished).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('Pass folds the card over its left edge under a darkening shade', async () => {
|
||||
const { article, shade } = card()
|
||||
const exit = pass(article)
|
||||
|
||||
const [darken, fold] = animations
|
||||
expect(animations).toHaveLength(2)
|
||||
expect(darken!.target).toBe(shade)
|
||||
expect(darken!.keyframes).toEqual([{ opacity: 0 }, { opacity: 0.4, offset: 0.45 }, { opacity: 0.55 }])
|
||||
expect(darken!.options).toEqual({ duration: 720, fill: 'forwards' })
|
||||
expect(fold!.target).toBe(article)
|
||||
expect(fold!.keyframes).toEqual([
|
||||
{ transformOrigin: '0% 50%', transform: 'translate(0px,0px) rotate(0deg) rotateY(0deg)', easing: 'cubic-bezier(.45,0,.85,.55)' },
|
||||
{
|
||||
transformOrigin: '0% 50%',
|
||||
transform: 'translate(0px,0px) rotate(0deg) rotateY(-80deg)',
|
||||
offset: 0.45,
|
||||
easing: 'cubic-bezier(.4,0,.9,.6)',
|
||||
},
|
||||
{ transformOrigin: '0% 50%', transform: 'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)', opacity: 0 },
|
||||
])
|
||||
expect(fold!.options).toEqual({ duration: 720, fill: 'forwards' })
|
||||
|
||||
darken!.finish()
|
||||
expect(await settled(exit.finished)).toBe(false)
|
||||
fold!.finish()
|
||||
await expect(exit.finished).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('Pass from a dragged transform folds from there', () => {
|
||||
const { article } = card()
|
||||
pass(article, 'translate(-130px,6px) rotate(-7deg)')
|
||||
const fold = animations[1]!
|
||||
expect(fold.keyframes.map((k) => k.transform)).toEqual([
|
||||
'translate(-130px,6px) rotate(-7deg) rotateY(0deg)',
|
||||
'translate(-130px,6px) rotate(-7deg) rotateY(-80deg)',
|
||||
'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)',
|
||||
])
|
||||
})
|
||||
|
||||
it('fades out in 200 ms with reduced motion', async () => {
|
||||
const { article } = card()
|
||||
const exit = fadeOut(article)
|
||||
expect(animations).toHaveLength(1)
|
||||
expect(animations[0]!.target).toBe(article)
|
||||
expect(animations[0]!.keyframes).toEqual([{ opacity: 1 }, { opacity: 0 }])
|
||||
expect(animations[0]!.options).toEqual({ duration: 200, fill: 'forwards' })
|
||||
animations[0]!.finish()
|
||||
await expect(exit.finished).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('promotes the top two cards from the depth behind, 300 ms', () => {
|
||||
const cards = [card().article, card().article, card().article]
|
||||
promote(cards)
|
||||
expect(animations.map((a) => a.target)).toEqual([cards[0], cards[1]])
|
||||
expect(animations.map((a) => a.keyframes)).toEqual([
|
||||
[{ transform: 'translateY(8px) scale(0.95)' }, { transform: 'translateY(0px) scale(1)' }],
|
||||
[{ transform: 'translateY(16px) scale(0.9)' }, { transform: 'translateY(8px) scale(0.95)' }],
|
||||
])
|
||||
for (const a of animations) expect(a.options).toEqual({ duration: 300, easing: 'cubic-bezier(.2,.8,.2,1)' })
|
||||
})
|
||||
|
||||
it('cancel stops an exit and never starts its next step', async () => {
|
||||
const { article } = card()
|
||||
const exit = tryIt(article)
|
||||
animations[1]!.finish()
|
||||
exit.cancel()
|
||||
const error: unknown = await exit.finished.catch((e: unknown) => e)
|
||||
expect(isAbort(error)).toBe(true)
|
||||
expect(animations).toHaveLength(2)
|
||||
expect(animations.map((a) => a.playState)).toEqual(['idle', 'idle'])
|
||||
})
|
||||
|
||||
it('drags: translate(dx, dy·0.3) rotate(dx/18 deg)', () => {
|
||||
expect(dragTransform(90, 20)).toBe('translate(90px,6px) rotate(5deg)')
|
||||
expect(dragTransform(-36, -10)).toBe('translate(-36px,-3px) rotate(-2deg)')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[50, '0.5', '0'],
|
||||
[-50, '0', '0.5'],
|
||||
[160, '1', '0'],
|
||||
[-160, '0', '1'],
|
||||
[0, '0', '0'],
|
||||
])('follows a drag of %i px with the stamps at Try it! %s, Pass %s', (dx, tryOpacity, passOpacity) => {
|
||||
const { article, stamp, passStamp } = card()
|
||||
expect(follow(article, dx, 0)).toBe(dragTransform(dx, 0))
|
||||
expect(article.style.transform).toBe(dragTransform(dx, 0))
|
||||
expect(stamp.style.opacity).toBe(tryOpacity)
|
||||
expect(passStamp.style.opacity).toBe(passOpacity)
|
||||
expect(animations).toEqual([])
|
||||
})
|
||||
|
||||
it('unfollows at once, back to the stylesheet', () => {
|
||||
const { article, stamp, passStamp } = card()
|
||||
follow(article, -80, 12)
|
||||
unfollow(article)
|
||||
expect(article.getAttribute('style')).toBe('')
|
||||
expect(stamp.getAttribute('style')).toBe('')
|
||||
expect(passStamp.getAttribute('style')).toBe('')
|
||||
expect(animations).toEqual([])
|
||||
})
|
||||
|
||||
it('snaps back from the drag transform to rest, 420 ms spring, and hides the stamps', () => {
|
||||
const { article, stamp, passStamp } = card()
|
||||
const from = follow(article, 70, 30)
|
||||
snapBack(article, from)
|
||||
expect(article.style.transform).toBe('')
|
||||
expect(stamp.style.opacity).toBe('')
|
||||
expect(passStamp.style.opacity).toBe('')
|
||||
expect(animations).toHaveLength(1)
|
||||
expect(animations[0]!.target).toBe(article)
|
||||
expect(animations[0]!.keyframes).toEqual([{ transform: 'translate(70px,9px) rotate(3.888888888888889deg)' }, { transform: 'translate(0px,0px) rotate(0deg)' }])
|
||||
expect(animations[0]!.options).toEqual({ duration: 420, easing: 'cubic-bezier(.34,1.4,.64,1)' })
|
||||
})
|
||||
|
||||
it('fails fast on a card without its parts', () => {
|
||||
expect(() => tryIt(document.createElement('article'))).toThrow('deck card has no try-stamp')
|
||||
expect(() => pass(document.createElement('article'))).toThrow('deck card has no shade')
|
||||
expect(() => follow(document.createElement('article'), 10, 0)).toThrow('deck card has no try-stamp')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
// The deck's motion, from the handoff's Motion §2 (Try it), §3 (Pass),
|
||||
// §4 (swipe), §5 (deck advance) and §6 (reduced motion), with the Web
|
||||
// Animations API. The exits take the card's starting transform so the swipe
|
||||
// gesture can throw a dragged card from where it was let go.
|
||||
|
||||
const SPRING = 'cubic-bezier(.34,1.4,.64,1)'
|
||||
const THROW = 'cubic-bezier(.55,0,.8,.4)'
|
||||
const SOFT = 'cubic-bezier(.2,.8,.2,1)'
|
||||
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
|
||||
|
||||
// REST is the top card's transform when nothing moves it, written as the
|
||||
// translate + rotate a drag gives it so the exits interpolate function by
|
||||
// function.
|
||||
export const REST = 'translate(0px,0px) rotate(0deg)'
|
||||
|
||||
// DEPTH_TRANSFORM is each deck depth's transform, as the stylesheet places it.
|
||||
export const DEPTH_TRANSFORM = ['translateY(0px) scale(1)', 'translateY(8px) scale(0.95)', 'translateY(16px) scale(0.9)'] as const
|
||||
|
||||
const FLY_OFF = 'translate(150%,-60px) rotate(28deg)'
|
||||
const PASS_ORIGIN = '0% 50%'
|
||||
|
||||
// An Exit is a card leaving the deck: finished resolves once it is off
|
||||
// screen and rejects with an AbortError if cancel stops it first. cancel
|
||||
// also undoes a finished exit, putting the card back.
|
||||
export type Exit = {
|
||||
finished: Promise<void>
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return window.matchMedia(REDUCED_MOTION_QUERY).matches
|
||||
}
|
||||
|
||||
// isAbort says whether an exit's finished rejected because it was canceled.
|
||||
export function isAbort(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === 'AbortError'
|
||||
}
|
||||
|
||||
// bump is the press bump of the Try it and Pass buttons and the Quest log pill.
|
||||
export function bump(element: Element): Animation {
|
||||
return element.animate(
|
||||
[{ transform: 'scale(1)' }, { transform: 'scale(.88)' }, { transform: 'scale(1.06)' }, { transform: 'scale(1)' }],
|
||||
{ duration: 300, easing: 'ease-out' },
|
||||
)
|
||||
}
|
||||
|
||||
// tryIt pops the "Try it!" stamp and throws the card off to the right. From
|
||||
// rest it wiggles first and winds up before the throw; from a dragged
|
||||
// transform it skips both and throws faster.
|
||||
export function tryIt(card: HTMLElement, from?: string): Exit {
|
||||
const exit = recorder()
|
||||
exit.run(part(card, 'try-stamp'), [
|
||||
{ opacity: 0, transform: 'rotate(-12deg) scale(1.5)' },
|
||||
{ opacity: 1, transform: 'rotate(-12deg) scale(1)' },
|
||||
], { duration: 220, easing: SPRING, fill: 'forwards' })
|
||||
const wiggled =
|
||||
from === undefined
|
||||
? exit.run(card, [
|
||||
{ transform: 'rotate(0deg)' },
|
||||
{ transform: 'rotate(-4deg)' },
|
||||
{ transform: 'rotate(4deg)' },
|
||||
{ transform: 'rotate(-3deg)' },
|
||||
{ transform: 'rotate(2deg)' },
|
||||
{ transform: 'rotate(0deg)' },
|
||||
], { duration: 420, easing: 'ease-in-out' }).finished
|
||||
: Promise.resolve()
|
||||
const finished = wiggled.then(() =>
|
||||
from === undefined
|
||||
? exit.run(card, [
|
||||
{ transform: REST },
|
||||
{ transform: 'translate(-14px,4px) rotate(-3deg)', offset: 0.18 },
|
||||
{ transform: FLY_OFF, opacity: 0 },
|
||||
], { duration: 480, easing: THROW, fill: 'forwards' }).finished
|
||||
: exit.run(card, [{ transform: from }, { transform: FLY_OFF, opacity: 0 }], { duration: 340, easing: THROW, fill: 'forwards' })
|
||||
.finished,
|
||||
)
|
||||
return exit.done(finished)
|
||||
}
|
||||
|
||||
// pass folds the card over its left edge like a page, darkening it, then
|
||||
// drops it off to the left. A dragged card folds from where it was let go.
|
||||
export function pass(card: HTMLElement, from: string = REST): Exit {
|
||||
const exit = recorder()
|
||||
exit.run(part(card, 'shade'), [{ opacity: 0 }, { opacity: 0.4, offset: 0.45 }, { opacity: 0.55 }], {
|
||||
duration: 720,
|
||||
fill: 'forwards',
|
||||
})
|
||||
const finished = exit.run(card, [
|
||||
{ transformOrigin: PASS_ORIGIN, transform: `${from} rotateY(0deg)`, easing: 'cubic-bezier(.45,0,.85,.55)' },
|
||||
{ transformOrigin: PASS_ORIGIN, transform: `${from} rotateY(-80deg)`, offset: 0.45, easing: 'cubic-bezier(.4,0,.9,.6)' },
|
||||
{ transformOrigin: PASS_ORIGIN, transform: 'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)', opacity: 0 },
|
||||
], { duration: 720, fill: 'forwards' }).finished
|
||||
return exit.done(finished)
|
||||
}
|
||||
|
||||
// dragTransform is the top card's transform dragged dx, dy from where the
|
||||
// pointer went down.
|
||||
export function dragTransform(dx: number, dy: number): string {
|
||||
return `translate(${dx}px,${dy * 0.3}px) rotate(${dx / 18}deg)`
|
||||
}
|
||||
|
||||
// follow moves a dragged card to its drag transform and shows each stamp as
|
||||
// far as the drag has gone toward its side, fully at 100 px. It returns the
|
||||
// transform, for the exit or snap-back the release starts.
|
||||
export function follow(card: HTMLElement, dx: number, dy: number): string {
|
||||
const transform = dragTransform(dx, dy)
|
||||
card.style.transform = transform
|
||||
part(card, 'try-stamp').style.opacity = String(clamp(dx / 100))
|
||||
part(card, 'pass-stamp').style.opacity = String(clamp(-dx / 100))
|
||||
return transform
|
||||
}
|
||||
|
||||
// unfollow puts a dragged card and its stamps back where the stylesheet
|
||||
// places them, at once.
|
||||
export function unfollow(card: HTMLElement) {
|
||||
card.style.removeProperty('transform')
|
||||
part(card, 'try-stamp').style.removeProperty('opacity')
|
||||
part(card, 'pass-stamp').style.removeProperty('opacity')
|
||||
}
|
||||
|
||||
// snapBack springs a card let go short of a decision back to rest from its
|
||||
// drag transform, and hides its stamps.
|
||||
export function snapBack(card: HTMLElement, from: string): Animation {
|
||||
unfollow(card)
|
||||
return card.animate([{ transform: from }, { transform: REST }], { duration: 420, easing: SPRING })
|
||||
}
|
||||
|
||||
// fadeOut is both exits under prefers-reduced-motion: reduce.
|
||||
export function fadeOut(card: HTMLElement): Exit {
|
||||
const exit = recorder()
|
||||
return exit.done(exit.run(card, [{ opacity: 1 }, { opacity: 0 }], { duration: 200, fill: 'forwards' }).finished)
|
||||
}
|
||||
|
||||
// promote moves the cards left after an exit, top first, from the depth
|
||||
// behind to their own.
|
||||
export function promote(cards: HTMLElement[]): Animation[] {
|
||||
return cards.slice(0, DEPTH_TRANSFORM.length - 1).map((card, depth) =>
|
||||
card.animate([{ transform: DEPTH_TRANSFORM[depth + 1] }, { transform: DEPTH_TRANSFORM[depth] }], {
|
||||
duration: 300,
|
||||
easing: SOFT,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function clamp(value: number): number {
|
||||
return Math.min(1, Math.max(0, value))
|
||||
}
|
||||
|
||||
// part is the card's element marked data-part="name", which DeckCard renders.
|
||||
function part(card: HTMLElement, name: string): HTMLElement {
|
||||
const element = card.querySelector<HTMLElement>(`[data-part="${name}"]`)
|
||||
if (element === null) throw new Error(`deck card has no ${name}`)
|
||||
return element
|
||||
}
|
||||
|
||||
// recorder keeps every animation an exit starts, so cancel can stop them all.
|
||||
// A step that would start after cancel aborts instead.
|
||||
function recorder() {
|
||||
const animations: Animation[] = []
|
||||
let canceled = false
|
||||
return {
|
||||
run(element: Element, keyframes: Keyframe[], options: KeyframeAnimationOptions): Animation {
|
||||
if (canceled) throw new DOMException('The exit was canceled.', 'AbortError')
|
||||
const animation = element.animate(keyframes, options)
|
||||
animations.push(animation)
|
||||
return animation
|
||||
},
|
||||
done(finished: Promise<unknown>): Exit {
|
||||
return {
|
||||
finished: finished.then(() => undefined),
|
||||
cancel: () => {
|
||||
canceled = true
|
||||
animations.forEach((animation) => animation.cancel())
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, type ReactNode } from 'react'
|
||||
import { useEffect, useId, useLayoutEffect, useRef, type ReactNode } from 'react'
|
||||
import { useBlocker } from 'react-router'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Sheet } from '../ui/Sheet.tsx'
|
||||
@@ -36,6 +36,9 @@ type ConfirmProps = {
|
||||
confirmLabel: string
|
||||
cancelLabel?: string
|
||||
busy?: boolean
|
||||
// When false, the cancel button, Escape and the scrim do nothing, so the
|
||||
// sheet stays until the action settles.
|
||||
dismissible?: boolean
|
||||
error?: string | null
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
@@ -49,14 +52,28 @@ export function Confirm({
|
||||
confirmLabel,
|
||||
cancelLabel = 'Cancel',
|
||||
busy = false,
|
||||
dismissible = true,
|
||||
error = null,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: ConfirmProps) {
|
||||
const titleId = useId()
|
||||
const sheetRef = useRef<HTMLDivElement>(null)
|
||||
// A focused button that becomes disabled either keeps focus while disabled or
|
||||
// loses it to the page, depending on the browser; the dialog takes it instead.
|
||||
useLayoutEffect(() => {
|
||||
const dialog = sheetRef.current?.closest<HTMLElement>('[role="dialog"]')
|
||||
if (!dialog) return
|
||||
const active = document.activeElement
|
||||
const lost = active === null || active === document.body || (dialog.contains(active) && active.matches(':disabled'))
|
||||
if (lost) dialog.focus()
|
||||
}, [busy, dismissible])
|
||||
function dismiss() {
|
||||
if (dismissible) onCancel()
|
||||
}
|
||||
return (
|
||||
<Sheet open={open} onClose={onCancel} aria-labelledby={titleId}>
|
||||
<div className={styles.sheet}>
|
||||
<Sheet open={open} onClose={dismiss} aria-labelledby={titleId}>
|
||||
<div ref={sheetRef} className={styles.sheet}>
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
{title}
|
||||
</h2>
|
||||
@@ -67,7 +84,7 @@ export function Confirm({
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.actions}>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
<Button variant="outline" disabled={!dismissible} onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={onConfirm}>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// A FakeAnimation is one Element.animate call: its target and arguments, and
|
||||
// a finished promise the test settles with finish (or cancel, which rejects
|
||||
// it with an AbortError while it runs).
|
||||
export type FakeAnimation = {
|
||||
target: Element
|
||||
keyframes: Keyframe[]
|
||||
options: KeyframeAnimationOptions
|
||||
finished: Promise<FakeAnimation>
|
||||
finish: () => void
|
||||
cancel: () => void
|
||||
playState: 'running' | 'finished' | 'idle'
|
||||
}
|
||||
|
||||
// stubAnimate replaces Element.prototype.animate (jsdom has no Web Animations
|
||||
// API) and returns every animation it starts, in order. With autoFinish each
|
||||
// animation finishes as it starts; otherwise the test finishes it. Remove the
|
||||
// stub with Reflect.deleteProperty(Element.prototype, 'animate').
|
||||
export function stubAnimate({ autoFinish }: { autoFinish: boolean }): FakeAnimation[] {
|
||||
const animations: FakeAnimation[] = []
|
||||
Element.prototype.animate = vi.fn(function (this: Element, keyframes, options) {
|
||||
let resolve!: (animation: FakeAnimation) => void
|
||||
let reject!: (error: DOMException) => void
|
||||
const animation: FakeAnimation = {
|
||||
target: this,
|
||||
keyframes: keyframes as Keyframe[],
|
||||
options: options as KeyframeAnimationOptions,
|
||||
finished: new Promise((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
}),
|
||||
playState: 'running',
|
||||
finish() {
|
||||
if (animation.playState !== 'running') throw new Error('finish on an animation that is not running')
|
||||
animation.playState = 'finished'
|
||||
resolve(animation)
|
||||
},
|
||||
cancel() {
|
||||
if (animation.playState === 'running') reject(new DOMException('The animation was canceled.', 'AbortError'))
|
||||
animation.playState = 'idle'
|
||||
},
|
||||
}
|
||||
// Browsers mark finished as handled, so a canceled animation nobody waits
|
||||
// on is not an unhandled rejection.
|
||||
animation.finished.catch(() => {})
|
||||
animations.push(animation)
|
||||
if (autoFinish) animation.finish()
|
||||
return animation as unknown as Animation
|
||||
}) as Element['animate']
|
||||
return animations
|
||||
}
|
||||
@@ -24,6 +24,12 @@ describe('ErrorBanner', () => {
|
||||
expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has no button without onRetry', () => {
|
||||
render(<ErrorBanner>That was done already.</ErrorBanner>)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('That was done already.')
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is a neutral-100 card with grape-700 text', () => {
|
||||
render(<ErrorBanner onRetry={() => {}}>Offline</ErrorBanner>)
|
||||
expect(declaredStyle(screen.getByRole('alert')).background).toBe('var(--color-neutral-100)')
|
||||
|
||||
@@ -6,16 +6,18 @@ import styles from './ErrorBanner.module.css'
|
||||
export type ErrorBannerProps = {
|
||||
// What went wrong, in the caller's words.
|
||||
children: ReactNode
|
||||
onRetry: () => void
|
||||
// What Retry does; without it there is nothing to retry and no button.
|
||||
onRetry?: () => void
|
||||
retryLabel?: string
|
||||
}
|
||||
|
||||
// Muted neutral-100 card with grape-700 text and a Retry button.
|
||||
// Muted neutral-100 card with grape-700 text and, given onRetry, a Retry
|
||||
// button.
|
||||
export function ErrorBanner({ children, onRetry, retryLabel = 'Retry' }: ErrorBannerProps) {
|
||||
return (
|
||||
<Card tone="muted" role="alert" className={styles.banner}>
|
||||
<p className={styles.message}>{children}</p>
|
||||
<Button onClick={onRetry}>{retryLabel}</Button>
|
||||
{onRetry !== undefined && <Button onClick={onRetry}>{retryLabel}</Button>}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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