8 changed files with 942 additions and 2 deletions
Showing only changes of commit 38fec723f6 - Show all commits
+14
View File
@@ -30,6 +30,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
// The routes that render a built screen rather than a placeholder page.
const SCREENS = {
'/onboarding': true,
'/discover': true,
'/settings': true,
'/settings/ai': true,
'/settings/household': true,
@@ -102,6 +103,19 @@ describe('App routes', () => {
expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument()
})
it('/discover renders the Side quests deck inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
...SIGNED_IN,
'/api/v1/suggestions/deck': () => jsonResponse(200, { suggestions: [] }),
'/api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }),
})
renderAt('/discover')
const heading = await screen.findByRole('heading', { level: 1, name: 'Side quests' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByText('Quest log · 0')).toBeInTheDocument()
})
it('/settings/household renders the household settings screen inside the shell', async () => {
mockViewport(390)
stubFetchByPath(SIGNED_IN)
+2
View File
@@ -3,6 +3,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
import { Route, Routes } from 'react-router'
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
import { AuthBootstrap } from './AuthBootstrap.tsx'
import { DiscoverPage } from './discover/DiscoverPage.tsx'
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
@@ -35,6 +36,7 @@ export const routes = [
// The screens built so far; every other route shows its placeholder page.
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/discover': <DiscoverPage />,
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
'/settings/household': <HouseholdSettingsPage />,
+2 -2
View File
@@ -79,7 +79,7 @@ export const REGULARS_KEY = ['dinners', 'regular'] as const
const REGULARS_LIST_KEY = [...REGULARS_KEY, 'list'] as const
const REGULARS_MUTATION_KEY = ['dinners', 'regular', 'change'] as const
function oneOf<T extends string>(where: string, field: string, values: readonly T[], value: unknown): T {
export function oneOf<T extends string>(where: string, field: string, values: readonly T[], value: unknown): T {
if (typeof value !== 'string' || !(values as readonly string[]).includes(value)) {
throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not one of ${values.join(', ')}`)
}
@@ -99,7 +99,7 @@ const PHOTO_URL = /^\/media\/photos\/([^/]+)-400\.jpg$/
// photoId reads the photo id out of a photo_url, which the server always
// sends as /media/photos/{id}-400.jpg or null.
function photoId(where: string, value: unknown): string | undefined {
export function photoId(where: string, value: unknown): string | undefined {
if (value === null) return undefined
const match = typeof value === 'string' ? PHOTO_URL.exec(value) : null
if (!match?.[1]) throw new Error(`${where}: photo_url ${JSON.stringify(value)} is not a stored photo`)
+63
View File
@@ -0,0 +1,63 @@
import { DIFFICULTIES, type Difficulty } from '../dinners/api.ts'
import { Photo } from '../ui/Photo.tsx'
import type { SideQuest } from './api.ts'
import styles from './DiscoverPage.module.css'
// DECK_DEPTH is the depths the deck shows, top card first.
export const DECK_DEPTH = [0, 1, 2] as const
const DIFFICULTY_LABEL: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
export type DeckCardProps = {
quest: SideQuest
// 0 is the top card; 1 and 2 stack behind it.
depth: number
}
// DeckCard is one Side quest in the deck. Only the top card is read out and
// reachable; the cards behind it are the stack's edge, kept rendered so they
// can be promoted. Its "Try it!" and "Pass" stamps stay hidden until a
// decision shows them.
export function DeckCard({ quest, depth }: DeckCardProps) {
const top = depth === 0
const titleId = `side-quest-${quest.id}`
return (
<article
className={`${styles.card} ${styles[`depth${depth}`]}`}
aria-labelledby={top ? titleId : undefined}
aria-hidden={top ? undefined : true}
inert={!top}
>
<Photo id={quest.photo_id} name={quest.name} radius="24px" inset="10px" sizes="400px" className={styles.photo} />
<div className={styles.content}>
<h2 id={titleId} className={styles.name}>
{quest.name}
</h2>
{quest.difficulty !== null && <DifficultyDots difficulty={quest.difficulty} />}
<p className={styles.why}>
<strong>Why this?</strong> {quest.reason}
</p>
</div>
<span className={`${styles.stamp} ${styles.tryStamp}`} aria-hidden="true">
Try it!
</span>
<span className={`${styles.stamp} ${styles.passStamp}`} aria-hidden="true">
Pass
</span>
</article>
)
}
function DifficultyDots({ difficulty }: { difficulty: Difficulty }) {
const level = DIFFICULTIES.indexOf(difficulty) + 1
return (
<p className={styles.difficulty}>
<span className={styles.dots} aria-hidden="true">
{DIFFICULTIES.map((d, i) => (
<span key={d} data-filled={i < level} className={i < level ? styles.dotFilled : styles.dot} />
))}
</span>
{DIFFICULTY_LABEL[difficulty]}
</p>
)
}
+264
View File
@@ -0,0 +1,264 @@
/* 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;
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. */
.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);
}
.depth1 {
z-index: 2;
transform: translateY(8px) scale(0.95);
background: var(--color-surface);
}
.depth2 {
z-index: 1;
transform: translateY(16px) scale(0.9);
background: var(--color-neutral-300);
}
.photo {
flex: none;
width: calc(100% - 20px);
height: 220px;
}
.content {
display: flex;
flex-direction: column;
gap: 10px;
padding: 4px 20px 0;
}
.name {
margin: 0;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 400;
line-height: 1.1;
}
.difficulty {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.dots {
display: flex;
gap: 3px;
}
.dot,
.dotFilled {
width: 8px;
height: 8px;
border-radius: var(--radius-pill);
background: var(--color-neutral-300);
}
.dotFilled {
background: var(--color-accent);
}
/* The "Why this?" box. */
.why {
margin: 0;
padding: 12px 14px;
border-radius: 18px;
background: var(--color-accent-2-100);
color: var(--color-accent-2-900);
font-family: var(--font-body);
font-size: 13px;
line-height: 1.45;
}
/* The decision stamps, hidden until a decision shows them. */
.stamp {
position: absolute;
top: 26px;
padding: 6px 14px;
border: 3px solid;
border-radius: 14px;
font-family: var(--font-heading);
font-size: 24px;
opacity: 0;
pointer-events: none;
}
.tryStamp {
left: 22px;
transform: rotate(-12deg);
border-color: var(--color-accent-2-700);
background: var(--color-accent-2-100);
color: var(--color-accent-2-800);
}
.passStamp {
right: 22px;
transform: rotate(12deg);
border-color: var(--color-neutral-800);
background: var(--color-neutral-100);
color: var(--color-neutral-900);
}
/* Pass and Try it: a 62 px circle over an 11 px label, one button each. */
.actions {
display: flex;
justify-content: center;
gap: 28px;
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. */
.loading {
display: flex;
flex-direction: column;
gap: 22px;
}
.deck > .skeletonCard {
position: absolute;
inset: 0;
height: auto;
border-radius: var(--radius-card);
}
.skeletonCard.depth0,
.skeletonCard.depth1,
.skeletonCard.depth2 {
background: var(--color-surface);
box-shadow: none;
}
.finding {
margin: 0;
text-align: center;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
@media (prefers-reduced-motion: reduce) {
.circle {
transition: none;
}
}
+331
View File
@@ -0,0 +1,331 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { declaredStyle } from '../test/cssRules.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { DiscoverPage } from './DiscoverPage.tsx'
const DECK = '/api/v1/suggestions/deck'
const QUEST_LOG = '/api/v1/dinners?status=quest_log'
// SuggestionBody is a suggestion as GET /api/v1/suggestions/deck sends it.
function suggestionBody(id: string, name: string, overrides: Record<string, unknown> = {}) {
return {
id,
dinner_id: `dinner-${id}`,
name,
photo_url: null,
difficulty: 'easy',
method: 'stovetop',
minutes_total: 25,
cuisine: 'Korean',
reason: 'You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.',
cited_regular_ids: ['r1', 'r2'],
status: 'pending',
dinner_status: 'suggested',
decided_at: null,
created_at: '2026-10-04T08:00:00.000Z',
...overrides,
}
}
const BOWLS = suggestionBody('s1', 'Korean beef bowls', { photo_url: '/media/photos/p1-400.jpg' })
const TIKKA = suggestionBody('s2', 'Crock pot chicken tikka masala', { difficulty: 'medium', reason: 'Butter chicken night is a favorite.' })
const ORZO = suggestionBody('s3', 'Lemon garlic shrimp orzo', { difficulty: 'hard' })
type Responder = () => Response | Promise<Response>
// stubServer stands in for the API: GET of the deck answers the current
// server.deck and the Quest log has questLog dinners; every other request
// is answered by responders, keyed "METHOD path". Any other request fails
// the test.
function stubServer(deck: unknown[], responders: Record<string, Responder> = {}, questLog = 6) {
const server = { deck, fetchMock: undefined as unknown as Mock<typeof fetch> }
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
[`GET ${DECK}`]: () => jsonResponse(200, { suggestions: server.deck }),
[`GET ${QUEST_LOG}`]: () =>
jsonResponse(200, { dinners: Array.from({ length: questLog }, (_, i) => ({ id: `q${i}`, name: `Quest ${i}` })) }),
...responders,
}
server.fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond())
})
vi.stubGlobal('fetch', server.fetchMock)
return server
}
// sent lists the requests other than GETs, as "METHOD path".
function sent(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
.map(([input, init]) => `${init?.method} ${String(input)}`)
}
afterEach(() => {
vi.unstubAllGlobals()
})
// renderPage loads /me (for the CSRF token every write needs) and renders
// the Discover page.
async function renderPage() {
const api = createApiClient(fakeLocation('/discover'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<DiscoverPage />
</QueryClientProvider>
</ApiProvider>,
)
}
function deferred() {
let resolve!: (response: Response) => void
const promise = new Promise<Response>((r) => (resolve = r))
return { promise, resolve }
}
describe('DiscoverPage', () => {
it('renders mock 1a from the deck: header, Quest log pill and three stacked cards', async () => {
stubServer([BOWLS, TIKKA, ORZO])
const { container } = await renderPage()
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
expect(screen.getByText('Discover')).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: 'Side quests' })).toBeInTheDocument()
const pill = await screen.findByText('Quest log · 6')
expect(declaredStyle(pill).background).toBe('var(--color-accent-2-200)')
expect(declaredStyle(pill).color).toBe('var(--color-accent-2-800)')
// The deck: 440 px, perspective 1000px, three cards stacked by depth.
const cards = container.querySelectorAll('article')
expect(cards).toHaveLength(3)
const deck = cards[0]!.parentElement!
expect(getComputedStyle(deck).height).toBe('440px')
expect(getComputedStyle(deck).perspective).toBe('1000px')
const [top, second, third] = [...cards].map((c) => declaredStyle(c))
expect(top!.transform).toBe('none')
expect(top!.background).toBe('var(--color-neutral-100)')
expect(top!.boxShadow).toBe('var(--shadow-lg)')
expect(second!.transform).toBe('translateY(8px) scale(0.95)')
expect(second!.background).toBe('var(--color-surface)')
expect(third!.transform).toBe('translateY(16px) scale(0.9)')
expect(third!.background).toBe('var(--color-neutral-300)')
for (const style of [top, second, third]) expect(style!.borderRadius).toBe('var(--radius-card)')
// Only the top card is read out; the ones behind it are the stack's edge.
expect(cards[1]).toHaveAttribute('aria-hidden', 'true')
expect(cards[2]).toHaveAttribute('aria-hidden', 'true')
expect(screen.queryByRole('article', { name: 'Crock pot chicken tikka masala' })).not.toBeInTheDocument()
// The top card: washed photo, Bagel name, difficulty dots, "Why this?".
const photo = within(card).getByRole('img', { name: 'Korean beef bowls' })
expect(photo).toHaveAttribute('src', '/media/photos/p1-400.jpg')
expect(getComputedStyle(photo).height).toBe('220px')
expect(photo.style.getPropertyValue('--photo-inset')).toBe('10px')
expect(photo.style.getPropertyValue('--photo-radius')).toBe('24px')
const name = within(card).getByRole('heading', { level: 2, name: 'Korean beef bowls' })
expect(getComputedStyle(name).fontSize).toBe('24px')
const difficulty = within(card).getByText('Easy')
const dots = difficulty.querySelectorAll('[data-filled]')
expect([...dots].map((dot) => dot.getAttribute('data-filled'))).toEqual(['true', 'false', 'false'])
expect(getComputedStyle(dots[0]!).width).toBe('8px')
const why = within(card).getByText('Why this?').parentElement!
expect(why).toHaveTextContent('Why this? You make Chicken stir-fry and Beef tacos most weeks.')
expect(declaredStyle(why).background).toBe('var(--color-accent-2-100)')
// The stamps are there, hidden until a decision shows them.
const tryStamp = within(card).getByText('Try it!')
const passStamp = within(card).getByText('Pass')
expect(getComputedStyle(tryStamp).opacity).toBe('0')
expect(getComputedStyle(tryStamp).transform).toBe('rotate(-12deg)')
expect(getComputedStyle(passStamp).opacity).toBe('0')
expect(getComputedStyle(passStamp).transform).toBe('rotate(12deg)')
// Pass and Try it: 62 px buttons with 11 px labels.
for (const label of ['Pass', 'Try it']) {
const button = screen.getByRole('button', { name: label })
expect(getComputedStyle(button).fontSize).toBe('11px')
expectMinHitArea(button)
expect(getComputedStyle(button.querySelector('span')!).width).toBe('62px')
}
})
it.each([
[TIKKA, 'Medium', 2],
[ORZO, 'Hard', 3],
] as const)('shows $1 with filled dots', async (quest, label, filled) => {
stubServer([quest])
await renderPage()
const card = await screen.findByRole('article', { name: quest.name })
expect(within(card).getByText(label).querySelectorAll('[data-filled="true"]')).toHaveLength(filled)
})
it('records Try it on → and Pass on ←, for the top card', async () => {
const server = stubServer([BOWLS, TIKKA, ORZO], {
'POST /api/v1/suggestions/s1/accept': () => {
server.deck = [TIKKA, ORZO]
return jsonResponse(200, { suggestion: { ...BOWLS, status: 'accepted' }, replenish_job_id: null })
},
'POST /api/v1/suggestions/s2/decline': () => {
server.deck = [ORZO]
return jsonResponse(200, { suggestion: { ...TIKKA, status: 'declined' }, replenish_job_id: null })
},
})
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
fireEvent.keyDown(window, { key: 'ArrowRight' })
await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])
fireEvent.keyDown(window, { key: 'ArrowLeft' })
await screen.findByRole('article', { name: 'Lemon garlic shrimp orzo' })
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s2/decline'])
})
it('records Pass and Try it from the buttons', async () => {
const server = stubServer([BOWLS, TIKKA], {
'POST /api/v1/suggestions/s1/decline': () => {
server.deck = [TIKKA]
return jsonResponse(200, { suggestion: { ...BOWLS, status: 'declined' }, replenish_job_id: null })
},
'POST /api/v1/suggestions/s2/accept': () => {
server.deck = []
return jsonResponse(200, { suggestion: { ...TIKKA, status: 'accepted' }, replenish_job_id: null })
},
})
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
fireEvent.click(screen.getByRole('button', { name: 'Pass' }))
await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
fireEvent.click(screen.getByRole('button', { name: 'Try it' }))
await screen.findByRole('heading', { name: 'All caught up' })
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline', 'POST /api/v1/suggestions/s2/accept'])
})
it('locks input while a decision is in flight', async () => {
const accept = deferred()
const server = stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/accept': () => accept.promise })
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
fireEvent.keyDown(window, { key: 'ArrowRight' })
await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled())
expect(screen.getByRole('button', { name: 'Pass' })).toBeDisabled()
fireEvent.keyDown(window, { key: 'ArrowLeft' })
fireEvent.keyDown(window, { key: 'ArrowRight' })
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])
server.deck = [TIKKA]
accept.resolve(jsonResponse(200, { suggestion: { ...BOWLS, status: 'accepted' }, replenish_job_id: null }))
await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()
})
it('leaves ← / → alone with a modifier or while typing in a field', async () => {
const server = stubServer([BOWLS])
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
fireEvent.keyDown(window, { key: 'ArrowRight', shiftKey: true })
fireEvent.keyDown(window, { key: 'ArrowLeft', metaKey: true })
const field = document.createElement('input')
document.body.append(field)
fireEvent.keyDown(field, { key: 'ArrowRight' })
field.remove()
expect(sent(server.fetchMock)).toEqual([])
})
it('shows a refused decision with a retry, keeping the card', async () => {
let refuse = true
const server = stubServer([BOWLS], {
'POST /api/v1/suggestions/s1/accept': () => {
if (refuse) return errorResponse(500, 'internal')
server.deck = []
return jsonResponse(200, { suggestion: { ...BOWLS, status: 'accepted' }, replenish_job_id: null })
},
})
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
fireEvent.click(screen.getByRole('button', { name: 'Try it' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't save that choice.")
expect(screen.getByRole('article', { name: 'Korean beef bowls' })).toBeInTheDocument()
refuse = false
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('heading', { name: 'All caught up' })
})
it('shows "All caught up" when the deck is empty', async () => {
stubServer([])
await renderPage()
expect(await screen.findByRole('heading', { level: 2, name: 'All caught up' })).toBeInTheDocument()
expect(screen.getByText('New side quests arrive Sunday.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Get more now' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Try it' })).not.toBeInTheDocument()
})
it('Get more now starts a batch and shows skeleton cards until its Side quests arrive', async () => {
const jobs = [
{ id: 'j1', kind: 'suggestion_batch', status: 'running', result: { batch_id: 'b1', stage: 'suggesting', suggestion_ids: [], dropped: 0 }, error: null },
{ id: 'j1', kind: 'suggestion_batch', status: 'succeeded', result: { batch_id: 'b1', stage: 'done', suggestion_ids: ['s1'], dropped: 0 }, error: null },
]
const server = stubServer([], {
'POST /api/v1/suggestions/batches': () =>
jsonResponse(202, { batch_id: 'b1', job_id: 'j1', trigger: 'manual', status: 'pending' }),
'GET /api/v1/jobs/j1': () => {
const job = jobs.shift()!
if (job.status === 'succeeded') server.deck = [BOWLS]
return jsonResponse(200, job)
},
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Get more now' }))
expect(await screen.findByText('Finding new side quests…')).toBeInTheDocument()
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3)
expect(screen.getByRole('region', { name: 'Side quests loading' })).toHaveAttribute('aria-busy', 'true')
expect(screen.queryByRole('heading', { name: 'All caught up' })).not.toBeInTheDocument()
await screen.findByRole('article', { name: 'Korean beef bowls' }, { timeout: 3000 })
expect(screen.queryByTestId('skeleton-card')).not.toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/batches'])
})
it('shows a failed batch with a retry over the empty state', async () => {
stubServer([], {
'POST /api/v1/suggestions/batches': () =>
jsonResponse(202, { batch_id: 'b1', job_id: 'j1', trigger: 'manual', status: 'pending' }),
'GET /api/v1/jobs/j1': () =>
jsonResponse(200, { id: 'j1', kind: 'suggestion_batch', status: 'failed', result: null, error: 'model unavailable' }),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Get more now' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't find new side quests. model unavailable")
expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument()
})
it('shows a deck that fails to load with a retry', async () => {
let fail = true
stubServer([BOWLS], { [`GET ${DECK}`]: () => (fail ? errorResponse(500, 'internal') : jsonResponse(200, { suggestions: [BOWLS] })) })
await renderPage()
const alert = await screen.findByRole('alert', {}, { timeout: 5000 })
expect(alert).toHaveTextContent("Couldn't load your side quests.")
fail = false
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('article', { name: 'Korean beef bowls' })
})
})
+150
View File
@@ -0,0 +1,150 @@
import { useQueryClient } from '@tanstack/react-query'
import { Heart, X } from 'lucide-react'
import { useCallback, useEffect } 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 { Skeleton } from '../ui/Skeleton.tsx'
import {
DECK_KEY,
useDeck,
useDecide,
useQuestLogCount,
useStartBatch,
type BatchResult,
type Choice,
} from './api.ts'
import { DeckCard, DECK_DEPTH } from './DeckCard.tsx'
import styles from './DiscoverPage.module.css'
// isEditable says whether a key press belongs to a field being typed in,
// which the deck's ← / → keys leave alone.
function isEditable(target: EventTarget | null): boolean {
return (
target instanceof HTMLElement &&
(target.isContentEditable || target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement)
)
}
// DiscoverPage is mock 1a: the deck of Side quests to Try it or Pass, one
// at a time from the top card. Every decision — the buttons, the ← / →
// keys — goes through decide, which does nothing while busy.
export function DiscoverPage() {
const queryClient = useQueryClient()
const deck = useDeck()
const questLog = useQuestLogCount()
const decision = useDecide()
const startBatch = useStartBatch()
const job = useJob<BatchResult>(startBatch.data?.job_id ?? null)
const top = deck.data?.[0]
const busy = decision.isPending
const jobRunning = startBatch.isSuccess && job.status !== 'succeeded' && job.status !== 'failed'
const finding = startBatch.isPending || jobRunning
const { mutate: mutateDecision } = decision
const decide = useCallback(
(choice: Choice) => {
if (busy || top === undefined) return
mutateDecision({ id: top.id, choice })
},
[busy, top, mutateDecision],
)
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
if (isEditable(event.target)) return
if (event.key === 'ArrowLeft') decide('pass')
else if (event.key === 'ArrowRight') decide('try')
else return
event.preventDefault()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [decide])
// The batch stores its Side quests as it goes, so the deck is read again
// whenever it reports more, and once it ends.
const stored = job.result?.suggestion_ids.length ?? 0
useEffect(() => {
if (job.status === undefined) return
void queryClient.invalidateQueries({ queryKey: DECK_KEY })
}, [queryClient, job.status, stored])
let body
if (deck.isError) {
body = <ErrorBanner onRetry={() => void deck.refetch()}>Couldn't load your side quests. {deck.error.message}</ErrorBanner>
} else if (deck.isPending || (deck.data.length === 0 && finding)) {
body = (
<section aria-busy="true" aria-label="Side quests loading" className={styles.loading}>
<div className={styles.deck}>
{DECK_DEPTH.map((depth) => (
<Skeleton key={depth} data-testid="skeleton-card" className={`${styles.skeletonCard} ${styles[`depth${depth}`]}`} />
))}
</div>
{finding && <p className={styles.finding}>Finding new side quests…</p>}
</section>
)
} else if (top === undefined) {
body = (
<>
{startBatch.isError && (
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't ask for more side quests. {startBatch.error.message}</ErrorBanner>
)}
{job.status === 'failed' && job.error !== null && (
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't find new side quests. {job.error.message}</ErrorBanner>
)}
<EmptyState title="All caught up" action={{ label: 'Get more now', onClick: () => startBatch.mutate() }}>
New side quests arrive Sunday.
</EmptyState>
</>
)
} else {
body = (
<>
<div className={styles.deck}>
{deck.data.slice(0, DECK_DEPTH.length).map((quest, depth) => (
<DeckCard key={quest.id} quest={quest} depth={depth} />
))}
</div>
<div className={styles.actions}>
<button type="button" className={styles.action} disabled={busy} onClick={() => decide('pass')}>
<span className={`${styles.circle} ${styles.pass}`} aria-hidden="true">
<Icon icon={X} size="nav" />
</span>
Pass
</button>
<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>
{decision.isError && (
<ErrorBanner onRetry={() => mutateDecision(decision.variables!)}>
Couldn't save that choice. {decision.error.message}
</ErrorBanner>
)}
</>
)
}
return (
<div className={styles.page}>
<header className={styles.header}>
<div>
<p className={styles.kicker}>Discover</p>
<h1 className={styles.title}>Side quests</h1>
</div>
{questLog.isSuccess && <span className={styles.questLog}>Quest log · {questLog.data}</span>}
</header>
{questLog.isError && (
<ErrorBanner onRetry={() => void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message}</ErrorBanner>
)}
{body}
</div>
)
}
+116
View File
@@ -0,0 +1,116 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { DIFFICULTIES, DINNERS_PATH, oneOf, photoId, type Difficulty } from '../dinners/api.ts'
export const SUGGESTIONS_PATH = '/api/v1/suggestions'
export const DECK_PATH = `${SUGGESTIONS_PATH}/deck`
export const BATCHES_PATH = `${SUGGESTIONS_PATH}/batches`
export const QUEST_LOG_PATH = `${DINNERS_PATH}?status=quest_log`
// DECK_KEY is the deck's query; QUEST_LOG_KEY the Quest log's.
export const DECK_KEY = ['suggestions', 'deck'] as const
export const QUEST_LOG_KEY = ['dinners', 'quest_log'] as const
// SideQuest is a pending suggestion as the deck shows it. photo_id is the
// stored photo's id, from photo_url; reason is the "Why this?".
export type SideQuest = {
id: string
name: string
photo_id: string | undefined
difficulty: Difficulty | null
reason: string
}
// Choice is the household's decision on a Side quest: Try it or Pass.
export type Choice = 'try' | 'pass'
// Batch is a POST /api/v1/suggestions/batches answer: the batch and the job
// running it, which GET /api/v1/jobs/{id} reports on.
export type Batch = { batch_id: string; job_id: string }
// 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 }
function parseSideQuest(where: string, body: unknown): SideQuest {
if (
!isRecord(body) ||
typeof body.id !== 'string' ||
typeof body.name !== 'string' ||
typeof body.reason !== 'string'
) {
throw new Error(`${where}: response is not a suggestion`)
}
return {
id: body.id,
name: body.name,
photo_id: photoId(where, body.photo_url),
difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty),
reason: body.reason,
}
}
// useDeck is the pending Side quests, oldest first (GET
// /api/v1/suggestions/deck).
export function useDeck() {
const { apiFetch } = useApi()
return useQuery({
queryKey: DECK_KEY,
queryFn: async () => {
const where = `GET ${DECK_PATH}`
const body = await apiFetch<unknown>(DECK_PATH)
if (!isRecord(body) || !Array.isArray(body.suggestions)) throw new Error(`${where}: response has no suggestions list`)
return body.suggestions.map((suggestion) => parseSideQuest(where, suggestion))
},
})
}
// useQuestLogCount is how many dinners are in the Quest log (GET
// /api/v1/dinners?status=quest_log).
export function useQuestLogCount() {
const { apiFetch } = useApi()
return useQuery({
queryKey: QUEST_LOG_KEY,
queryFn: async () => {
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`)
return body.dinners.length
},
})
}
// useStartBatch asks for more Side quests now (POST
// /api/v1/suggestions/batches). The server answers with the batch already
// running when there is one.
export function useStartBatch() {
const { apiFetch } = useApi()
return useMutation({
mutationFn: async (): Promise<Batch> => {
const body = await apiFetch<unknown>(BATCHES_PATH, { method: 'POST' })
if (!isRecord(body) || typeof body.batch_id !== 'string' || typeof body.job_id !== 'string') {
throw new Error(`POST ${BATCHES_PATH}: response is not a batch`)
}
return { batch_id: body.batch_id, job_id: body.job_id }
},
})
}
// useDecide records Try it (POST /api/v1/suggestions/{id}/accept) or Pass
// (POST /api/v1/suggestions/{id}/decline), then reads the deck and the
// Quest log again.
export function useDecide() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, choice }: { id: string; choice: Choice }) => {
const action = choice === 'try' ? 'accept' : 'decline'
await apiFetch<unknown>(`${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}`, { method: 'POST' })
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: DECK_KEY }),
queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
])
},
})
}