Fix the Discover deck review findings
The Quest log pill is now a button that shows and hides the names of the dinners in the Quest log, read from GET /api/v1/dinners?status=quest_log; the designed Quest log sheet replaces this list in its own task. A batch whose job cannot be polled, or whose result cannot be read, now shows an error with a Retry that asks for the batch again, instead of leaving skeleton cards on screen with no way out. Decisions go through one submit guarded by a ref set before the request is sent, so presses before the next render cannot send a second, conflicting decision. A decision counts as saved only when the answer is that suggestion, accepted or declined as chosen; anything else shows in the "Couldn't save that choice" banner. Pass and Try it are IconButtons: IconButton gains a neutral tone and showLabel, which shows the label as an 11 px caption under the circle. Maestro task 1ecc121c-86eb-4784-a9fb-1314709b4a59. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -33,7 +34,20 @@
|
|||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The Quest log count pill. */
|
/* 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 {
|
.questLog {
|
||||||
flex: none;
|
flex: none;
|
||||||
padding: 7px 12px;
|
padding: 7px 12px;
|
||||||
@@ -45,6 +59,26 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The Quest log's dinner names, under the header while the pill is open. */
|
||||||
|
.questLogList {
|
||||||
|
flex: 1 0 100%;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questLogNames {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questLogEmpty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
/* The deck: three cards stacked in the same box, the top one in front. */
|
/* The deck: three cards stacked in the same box, the top one in front. */
|
||||||
.deck {
|
.deck {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -172,7 +206,7 @@
|
|||||||
color: var(--color-neutral-900);
|
color: var(--color-neutral-900);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pass and Try it: a 62 px circle over an 11 px label, one button each. */
|
/* Pass and Try it: IconButtons with their 11 px labels shown. */
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -180,53 +214,6 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 62px;
|
|
||||||
min-height: 62px;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: none;
|
|
||||||
color: var(--color-neutral-700);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.circle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 62px;
|
|
||||||
height: 62px;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
transition: transform 100ms var(--ease-out-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action:active:not(:disabled) .circle {
|
|
||||||
transform: scale(0.97);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pass {
|
|
||||||
background: var(--color-neutral-100);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tryIt {
|
|
||||||
background: var(--color-accent-2-600);
|
|
||||||
color: var(--on-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Skeleton cards while the deck loads or a batch finds Side quests. */
|
/* Skeleton cards while the deck loads or a batch finds Side quests. */
|
||||||
.loading {
|
.loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -256,9 +243,3 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-neutral-700);
|
color: var(--color-neutral-700);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.circle {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -328,4 +328,117 @@ describe('DiscoverPage', () => {
|
|||||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||||
await screen.findByRole('article', { name: 'Korean beef bowls' })
|
await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens and closes the Quest log from its pill, a keyboard-reachable button', async () => {
|
||||||
|
stubServer([BOWLS], {}, 2)
|
||||||
|
await renderPage()
|
||||||
|
const pill = await screen.findByRole('button', { name: 'Quest log · 2' })
|
||||||
|
expect(pill).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
expect(screen.queryByRole('list', { name: 'Quest log' })).not.toBeInTheDocument()
|
||||||
|
expectMinHitArea(pill)
|
||||||
|
pill.focus()
|
||||||
|
expect(pill).toHaveFocus()
|
||||||
|
|
||||||
|
fireEvent.click(pill)
|
||||||
|
expect(pill).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
const list = screen.getByRole('list', { name: 'Quest log' })
|
||||||
|
expect(pill).toHaveAttribute('aria-controls', list.parentElement!.id)
|
||||||
|
expect(within(list).getAllByRole('listitem').map((item) => item.textContent)).toEqual(['Quest 0', 'Quest 1'])
|
||||||
|
|
||||||
|
fireEvent.click(pill)
|
||||||
|
expect(pill).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
expect(screen.queryByRole('list', { name: 'Quest log' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says so when the Quest log it opens is empty', async () => {
|
||||||
|
stubServer([BOWLS], {}, 0)
|
||||||
|
await renderPage()
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Quest log · 0' }))
|
||||||
|
expect(screen.getByText('No dinners in the Quest log yet. Try it on a Side quest to add one.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends one decision for presses made before the page re-renders', async () => {
|
||||||
|
const accept = deferred()
|
||||||
|
const server = stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/accept': () => accept.promise })
|
||||||
|
await renderPage()
|
||||||
|
await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
|
|
||||||
|
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
||||||
|
fireEvent.keyDown(window, { key: 'ArrowLeft' })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Pass' }))
|
||||||
|
await waitFor(() => expect(sent(server.fetchMock)).toContain('POST /api/v1/suggestions/s1/accept'))
|
||||||
|
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])
|
||||||
|
expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled()
|
||||||
|
|
||||||
|
server.deck = [TIKKA]
|
||||||
|
accept.resolve(jsonResponse(200, { suggestion: { ...BOWLS, status: 'accepted' }, replenish_job_id: null }))
|
||||||
|
await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
|
||||||
|
fireEvent.keyDown(window, { key: 'ArrowLeft' })
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s2/decline']),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['not a decision', {}],
|
||||||
|
['another suggestion', { suggestion: { ...TIKKA, status: 'accepted' }, replenish_job_id: null }],
|
||||||
|
['not accepted', { suggestion: { ...BOWLS, status: 'pending' }, replenish_job_id: null }],
|
||||||
|
])('shows a decision answered with %s as unsaved, keeping the card', async (_, answer) => {
|
||||||
|
stubServer([BOWLS], { 'POST /api/v1/suggestions/s1/accept': () => jsonResponse(200, answer) })
|
||||||
|
await renderPage()
|
||||||
|
await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Try it' }))
|
||||||
|
const alert = await screen.findByRole('alert')
|
||||||
|
expect(alert).toHaveTextContent("Couldn't save that choice. POST /api/v1/suggestions/s1/accept: response is not the accepted suggestion")
|
||||||
|
expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('article', { name: 'Korean beef bowls' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a batch whose job cannot be polled with a retry, keeping the skeleton cards', async () => {
|
||||||
|
let failPoll = true
|
||||||
|
const server = stubServer([], {
|
||||||
|
'POST /api/v1/suggestions/batches': () =>
|
||||||
|
jsonResponse(202, { batch_id: 'b1', job_id: 'j1', trigger: 'manual', status: 'pending' }),
|
||||||
|
'GET /api/v1/jobs/j1': () => {
|
||||||
|
if (failPoll) return errorResponse(500, 'internal')
|
||||||
|
server.deck = [BOWLS]
|
||||||
|
return jsonResponse(200, {
|
||||||
|
id: 'j1',
|
||||||
|
kind: 'suggestion_batch',
|
||||||
|
status: 'succeeded',
|
||||||
|
result: { batch_id: 'b1', stage: 'done', suggestion_ids: ['s1'], dropped: 0 },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Get more now' }))
|
||||||
|
|
||||||
|
const alert = await screen.findByRole('alert')
|
||||||
|
expect(alert).toHaveTextContent("Couldn't check on new side quests.")
|
||||||
|
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3)
|
||||||
|
|
||||||
|
failPoll = false
|
||||||
|
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||||
|
await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
|
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/batches', 'POST /api/v1/suggestions/batches'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a batch whose job result is unreadable with a retry', async () => {
|
||||||
|
stubServer([], {
|
||||||
|
'POST /api/v1/suggestions/batches': () =>
|
||||||
|
jsonResponse(202, { batch_id: 'b1', job_id: 'j1', trigger: 'manual', status: 'pending' }),
|
||||||
|
'GET /api/v1/jobs/j1': () =>
|
||||||
|
jsonResponse(200, { id: 'j1', kind: 'suggestion_batch', status: 'succeeded', result: { batch_id: 'b1' }, error: null }),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Get more now' }))
|
||||||
|
|
||||||
|
const alert = await screen.findByRole('alert')
|
||||||
|
expect(alert).toHaveTextContent("Couldn't find new side quests. GET /api/v1/jobs/j1: result is not a suggestion batch")
|
||||||
|
expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { Heart, X } from 'lucide-react'
|
import { Heart, X } from 'lucide-react'
|
||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||||
import { useJob } from '../api/index.ts'
|
import { useJob } from '../api/index.ts'
|
||||||
|
import { Card } from '../ui/Card.tsx'
|
||||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||||
import { Icon } from '../ui/Icon.tsx'
|
import { Icon } from '../ui/Icon.tsx'
|
||||||
|
import { IconButton } from '../ui/IconButton.tsx'
|
||||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||||
import {
|
import {
|
||||||
DECK_KEY,
|
DECK_KEY,
|
||||||
|
parseBatchResult,
|
||||||
useDeck,
|
useDeck,
|
||||||
useDecide,
|
useDecide,
|
||||||
useQuestLogCount,
|
useQuestLog,
|
||||||
useStartBatch,
|
useStartBatch,
|
||||||
type BatchResult,
|
|
||||||
type Choice,
|
type Choice,
|
||||||
} from './api.ts'
|
} from './api.ts'
|
||||||
import { DeckCard, DECK_DEPTH } from './DeckCard.tsx'
|
import { DeckCard, DECK_DEPTH } from './DeckCard.tsx'
|
||||||
@@ -27,29 +29,88 @@ function isEditable(target: EventTarget | null): boolean {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuestLogPill is the "Quest log · N" pill. It shows and hides the names
|
||||||
|
// of the dinners in the Quest log; the Quest log sheet replaces this list.
|
||||||
|
function QuestLogPill({ dinners }: { dinners: { id: string; name: string }[] }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const listId = useId()
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.questLogButton}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={open ? listId : undefined}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
>
|
||||||
|
<span className={styles.questLog}>Quest log · {dinners.length}</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<Card id={listId} tone="muted" className={styles.questLogList}>
|
||||||
|
{dinners.length === 0 ? (
|
||||||
|
<p className={styles.questLogEmpty}>No dinners in the Quest log yet. Try it on a Side quest to add one.</p>
|
||||||
|
) : (
|
||||||
|
<ul aria-label="Quest log" className={styles.questLogNames}>
|
||||||
|
{dinners.map((dinner) => (
|
||||||
|
<li key={dinner.id}>{dinner.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// DiscoverPage is mock 1a: the deck of Side quests to Try it or Pass, one
|
// DiscoverPage is mock 1a: the deck of Side quests to Try it or Pass, one
|
||||||
// at a time from the top card. Every decision — the buttons, the ← / →
|
// at a time from the top card. Every decision — the buttons, the ← / →
|
||||||
// keys — goes through decide, which does nothing while busy.
|
// keys, Retry — goes through submit, which does nothing while one is in
|
||||||
|
// flight. The lock is a ref, set before the decision is sent, so a second
|
||||||
|
// press before the next render cannot send another.
|
||||||
export function DiscoverPage() {
|
export function DiscoverPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const deck = useDeck()
|
const deck = useDeck()
|
||||||
const questLog = useQuestLogCount()
|
const questLog = useQuestLog()
|
||||||
const decision = useDecide()
|
const decision = useDecide()
|
||||||
const startBatch = useStartBatch()
|
const startBatch = useStartBatch()
|
||||||
const job = useJob<BatchResult>(startBatch.data?.job_id ?? null)
|
const job = useJob<unknown>(startBatch.data?.job_id ?? null)
|
||||||
|
|
||||||
const top = deck.data?.[0]
|
const top = deck.data?.[0]
|
||||||
const busy = decision.isPending
|
const busy = decision.isPending
|
||||||
const jobRunning = startBatch.isSuccess && job.status !== 'succeeded' && job.status !== 'failed'
|
const jobRunning = startBatch.isSuccess && job.status !== 'succeeded' && job.status !== 'failed'
|
||||||
const finding = startBatch.isPending || jobRunning
|
const finding = startBatch.isPending || jobRunning
|
||||||
|
|
||||||
|
// The batch's result as far as it has gone, or why it cannot be read.
|
||||||
|
const batch = useMemo(() => {
|
||||||
|
if (job.result === null) return { stored: 0, error: null }
|
||||||
|
try {
|
||||||
|
return { stored: parseBatchResult(`GET /api/v1/jobs/${startBatch.data?.job_id}`, job.result).suggestion_ids.length, error: null }
|
||||||
|
} catch (error) {
|
||||||
|
return { stored: 0, error: error as Error }
|
||||||
|
}
|
||||||
|
}, [job.result, startBatch.data?.job_id])
|
||||||
|
// The job's failure, the latest poll's failure, or an unreadable result.
|
||||||
|
const batchError = job.error ?? batch.error
|
||||||
|
|
||||||
const { mutate: mutateDecision } = decision
|
const { mutate: mutateDecision } = decision
|
||||||
|
const deciding = useRef(false)
|
||||||
|
const submit = useCallback(
|
||||||
|
(variables: { id: string; choice: Choice }) => {
|
||||||
|
if (deciding.current) return
|
||||||
|
deciding.current = true
|
||||||
|
mutateDecision(variables, {
|
||||||
|
onSettled: () => {
|
||||||
|
deciding.current = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[mutateDecision],
|
||||||
|
)
|
||||||
const decide = useCallback(
|
const decide = useCallback(
|
||||||
(choice: Choice) => {
|
(choice: Choice) => {
|
||||||
if (busy || top === undefined) return
|
if (top !== undefined) submit({ id: top.id, choice })
|
||||||
mutateDecision({ id: top.id, choice })
|
|
||||||
},
|
},
|
||||||
[busy, top, mutateDecision],
|
[top, submit],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,7 +128,7 @@ export function DiscoverPage() {
|
|||||||
|
|
||||||
// The batch stores its Side quests as it goes, so the deck is read again
|
// The batch stores its Side quests as it goes, so the deck is read again
|
||||||
// whenever it reports more, and once it ends.
|
// whenever it reports more, and once it ends.
|
||||||
const stored = job.result?.suggestion_ids.length ?? 0
|
const { stored } = batch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (job.status === undefined) return
|
if (job.status === undefined) return
|
||||||
void queryClient.invalidateQueries({ queryKey: DECK_KEY })
|
void queryClient.invalidateQueries({ queryKey: DECK_KEY })
|
||||||
@@ -85,6 +146,9 @@ export function DiscoverPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{finding && <p className={styles.finding}>Finding new side quests…</p>}
|
{finding && <p className={styles.finding}>Finding new side quests…</p>}
|
||||||
|
{jobRunning && batchError !== null && (
|
||||||
|
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't check on new side quests. {batchError.message}</ErrorBanner>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
} else if (top === undefined) {
|
} else if (top === undefined) {
|
||||||
@@ -93,8 +157,8 @@ export function DiscoverPage() {
|
|||||||
{startBatch.isError && (
|
{startBatch.isError && (
|
||||||
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't ask for more side quests. {startBatch.error.message}</ErrorBanner>
|
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't ask for more side quests. {startBatch.error.message}</ErrorBanner>
|
||||||
)}
|
)}
|
||||||
{job.status === 'failed' && job.error !== null && (
|
{batchError !== null && (
|
||||||
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't find new side quests. {job.error.message}</ErrorBanner>
|
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't find new side quests. {batchError.message}</ErrorBanner>
|
||||||
)}
|
)}
|
||||||
<EmptyState title="All caught up" action={{ label: 'Get more now', onClick: () => startBatch.mutate() }}>
|
<EmptyState title="All caught up" action={{ label: 'Get more now', onClick: () => startBatch.mutate() }}>
|
||||||
New side quests arrive Sunday.
|
New side quests arrive Sunday.
|
||||||
@@ -110,21 +174,15 @@ export function DiscoverPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<button type="button" className={styles.action} disabled={busy} onClick={() => decide('pass')}>
|
<IconButton label="Pass" size={62} tone="neutral" showLabel disabled={busy} onClick={() => decide('pass')}>
|
||||||
<span className={`${styles.circle} ${styles.pass}`} aria-hidden="true">
|
<Icon icon={X} size="nav" />
|
||||||
<Icon icon={X} size="nav" />
|
</IconButton>
|
||||||
</span>
|
<IconButton label="Try it" size={62} tone="teal" showLabel disabled={busy} onClick={() => decide('try')}>
|
||||||
Pass
|
<Icon icon={Heart} size="nav" />
|
||||||
</button>
|
</IconButton>
|
||||||
<button type="button" className={styles.action} disabled={busy} onClick={() => decide('try')}>
|
|
||||||
<span className={`${styles.circle} ${styles.tryIt}`} aria-hidden="true">
|
|
||||||
<Icon icon={Heart} size="nav" />
|
|
||||||
</span>
|
|
||||||
Try it
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{decision.isError && (
|
{decision.isError && (
|
||||||
<ErrorBanner onRetry={() => mutateDecision(decision.variables!)}>
|
<ErrorBanner onRetry={() => submit(decision.variables!)}>
|
||||||
Couldn't save that choice. {decision.error.message}
|
Couldn't save that choice. {decision.error.message}
|
||||||
</ErrorBanner>
|
</ErrorBanner>
|
||||||
)}
|
)}
|
||||||
@@ -139,7 +197,7 @@ export function DiscoverPage() {
|
|||||||
<p className={styles.kicker}>Discover</p>
|
<p className={styles.kicker}>Discover</p>
|
||||||
<h1 className={styles.title}>Side quests</h1>
|
<h1 className={styles.title}>Side quests</h1>
|
||||||
</div>
|
</div>
|
||||||
{questLog.isSuccess && <span className={styles.questLog}>Quest log · {questLog.data}</span>}
|
{questLog.isSuccess && <QuestLogPill dinners={questLog.data} />}
|
||||||
</header>
|
</header>
|
||||||
{questLog.isError && (
|
{questLog.isError && (
|
||||||
<ErrorBanner onRetry={() => void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message}</ErrorBanner>
|
<ErrorBanner onRetry={() => void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message}</ErrorBanner>
|
||||||
|
|||||||
+44
-7
@@ -32,6 +32,28 @@ export type Batch = { batch_id: string; job_id: string }
|
|||||||
// BatchResult is the suggestion batch job's result, published as it goes.
|
// 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 }
|
export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number }
|
||||||
|
|
||||||
|
// QuestLogDinner is a dinner in the Quest log, as the Quest log pill lists it.
|
||||||
|
export type QuestLogDinner = { id: string; name: string }
|
||||||
|
|
||||||
|
// DECISION_STATUS is the status a suggestion has once each choice is saved.
|
||||||
|
const DECISION_STATUS: Record<Choice, string> = { try: 'accepted', pass: 'declined' }
|
||||||
|
|
||||||
|
// parseBatchResult checks a suggestion batch job's result, which useJob
|
||||||
|
// passes on unread.
|
||||||
|
export function parseBatchResult(where: string, body: unknown): BatchResult {
|
||||||
|
if (
|
||||||
|
!isRecord(body) ||
|
||||||
|
typeof body.batch_id !== 'string' ||
|
||||||
|
typeof body.stage !== 'string' ||
|
||||||
|
!Array.isArray(body.suggestion_ids) ||
|
||||||
|
!body.suggestion_ids.every((id) => typeof id === 'string') ||
|
||||||
|
typeof body.dropped !== 'number'
|
||||||
|
) {
|
||||||
|
throw new Error(`${where}: result is not a suggestion batch`)
|
||||||
|
}
|
||||||
|
return { batch_id: body.batch_id, stage: body.stage, suggestion_ids: body.suggestion_ids as string[], dropped: body.dropped }
|
||||||
|
}
|
||||||
|
|
||||||
function parseSideQuest(where: string, body: unknown): SideQuest {
|
function parseSideQuest(where: string, body: unknown): SideQuest {
|
||||||
if (
|
if (
|
||||||
!isRecord(body) ||
|
!isRecord(body) ||
|
||||||
@@ -65,16 +87,22 @@ export function useDeck() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// useQuestLogCount is how many dinners are in the Quest log (GET
|
// useQuestLog is the dinners in the Quest log, in the server's order (GET
|
||||||
// /api/v1/dinners?status=quest_log).
|
// /api/v1/dinners?status=quest_log).
|
||||||
export function useQuestLogCount() {
|
export function useQuestLog() {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: QUEST_LOG_KEY,
|
queryKey: QUEST_LOG_KEY,
|
||||||
queryFn: async () => {
|
queryFn: async (): Promise<QuestLogDinner[]> => {
|
||||||
|
const where = `GET ${QUEST_LOG_PATH}`
|
||||||
const body = await apiFetch<unknown>(QUEST_LOG_PATH)
|
const body = await apiFetch<unknown>(QUEST_LOG_PATH)
|
||||||
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`GET ${QUEST_LOG_PATH}: response has no dinners list`)
|
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
|
||||||
return body.dinners.length
|
return body.dinners.map((dinner) => {
|
||||||
|
if (!isRecord(dinner) || typeof dinner.id !== 'string' || typeof dinner.name !== 'string') {
|
||||||
|
throw new Error(`${where}: response is not a dinner`)
|
||||||
|
}
|
||||||
|
return { id: dinner.id, name: dinner.name }
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -97,14 +125,23 @@ export function useStartBatch() {
|
|||||||
|
|
||||||
// useDecide records Try it (POST /api/v1/suggestions/{id}/accept) or Pass
|
// useDecide records Try it (POST /api/v1/suggestions/{id}/accept) or Pass
|
||||||
// (POST /api/v1/suggestions/{id}/decline), then reads the deck and the
|
// (POST /api/v1/suggestions/{id}/decline), then reads the deck and the
|
||||||
// Quest log again.
|
// Quest log again. The answer must be that suggestion, decided that way.
|
||||||
export function useDecide() {
|
export function useDecide() {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ id, choice }: { id: string; choice: Choice }) => {
|
mutationFn: async ({ id, choice }: { id: string; choice: Choice }) => {
|
||||||
const action = choice === 'try' ? 'accept' : 'decline'
|
const action = choice === 'try' ? 'accept' : 'decline'
|
||||||
await apiFetch<unknown>(`${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}`, { method: 'POST' })
|
const path = `${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}`
|
||||||
|
const body = await apiFetch<unknown>(path, { method: 'POST' })
|
||||||
|
if (
|
||||||
|
!isRecord(body) ||
|
||||||
|
!isRecord(body.suggestion) ||
|
||||||
|
body.suggestion.id !== id ||
|
||||||
|
body.suggestion.status !== DECISION_STATUS[choice]
|
||||||
|
) {
|
||||||
|
throw new Error(`POST ${path}: response is not the ${DECISION_STATUS[choice]} suggestion`)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|||||||
@@ -29,6 +29,20 @@
|
|||||||
height: 62px;
|
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 {
|
.face {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -57,6 +71,11 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.neutral {
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
.accent {
|
.accent {
|
||||||
background: var(--color-accent);
|
background: var(--color-accent);
|
||||||
color: var(--on-accent);
|
color: var(--on-accent);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { declaredStyle } from '../test/cssRules.ts'
|
||||||
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
|
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
|
||||||
import { IconButton } from './IconButton.tsx'
|
import { IconButton } from './IconButton.tsx'
|
||||||
|
|
||||||
@@ -31,4 +32,21 @@ describe('IconButton', () => {
|
|||||||
expectMinHitArea(button)
|
expectMinHitArea(button)
|
||||||
expect(hitArea(button)).toEqual({ width: 62, height: 62 })
|
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'
|
import styles from './IconButton.module.css'
|
||||||
|
|
||||||
export type IconButtonSize = 40 | 62
|
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'> & {
|
export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'> & {
|
||||||
// Accessible name; an icon alone never names a button.
|
// Accessible name; an icon alone never names a button.
|
||||||
label: string
|
label: string
|
||||||
size?: IconButtonSize
|
size?: IconButtonSize
|
||||||
tone?: IconButtonTone
|
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);
|
// Round icon button, named by its label and showing it only with showLabel.
|
||||||
// the 62 px size is the round action button with --shadow-md.
|
// 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({
|
export function IconButton({
|
||||||
label,
|
label,
|
||||||
size = 40,
|
size = 40,
|
||||||
tone = 'surface',
|
tone = 'surface',
|
||||||
|
showLabel = false,
|
||||||
type = 'button',
|
type = 'button',
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
...rest
|
...rest
|
||||||
}: IconButtonProps) {
|
}: 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 (
|
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">
|
<span className={[styles.face, styles[tone]].join(' ')} aria-hidden="true">
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
|
{showLabel && label}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user