Add the Discover deck screen (mock 1a)
/discover now shows the Side quests deck: the "Discover" kicker and "Side quests" title, the teal "Quest log · N" pill, and the three stacked cards in a 440 px deck with perspective 1000px. The top card shows the washed photo, the name, the difficulty dots and the "Why this?" box, with hidden "Try it!" and "Pass" stamps. The 62 px Pass and Try it buttons and the ←/→ keys all go through one decide handler that ignores input while a decision is in flight. An empty deck shows "All caught up" with "Get more now", which starts a suggestion batch and shows skeleton cards while its job runs, reading the deck again as the batch stores Side quests. Maestro task 1ecc121c-86eb-4784-a9fb-1314709b4a59. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
|
||||
// The routes that render a built screen rather than a placeholder page.
|
||||
const SCREENS = {
|
||||
'/onboarding': true,
|
||||
'/discover': true,
|
||||
'/settings': true,
|
||||
'/settings/ai': true,
|
||||
'/settings/household': true,
|
||||
@@ -102,6 +103,19 @@ describe('App routes', () => {
|
||||
expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/discover renders the Side quests deck inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
...SIGNED_IN,
|
||||
'/api/v1/suggestions/deck': () => jsonResponse(200, { suggestions: [] }),
|
||||
'/api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }),
|
||||
})
|
||||
renderAt('/discover')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Side quests' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByText('Quest log · 0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/settings/household renders the household settings screen inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Route, Routes } from 'react-router'
|
||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
||||
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
||||
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
|
||||
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
||||
@@ -35,6 +36,7 @@ export const routes = [
|
||||
|
||||
// The screens built so far; every other route shows its placeholder page.
|
||||
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/discover': <DiscoverPage />,
|
||||
'/settings': <SettingsPage />,
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
'/settings/household': <HouseholdSettingsPage />,
|
||||
|
||||
@@ -79,7 +79,7 @@ export const REGULARS_KEY = ['dinners', 'regular'] as const
|
||||
const REGULARS_LIST_KEY = [...REGULARS_KEY, 'list'] as const
|
||||
const REGULARS_MUTATION_KEY = ['dinners', 'regular', 'change'] as const
|
||||
|
||||
function oneOf<T extends string>(where: string, field: string, values: readonly T[], value: unknown): T {
|
||||
export function oneOf<T extends string>(where: string, field: string, values: readonly T[], value: unknown): T {
|
||||
if (typeof value !== 'string' || !(values as readonly string[]).includes(value)) {
|
||||
throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not one of ${values.join(', ')}`)
|
||||
}
|
||||
@@ -99,7 +99,7 @@ const PHOTO_URL = /^\/media\/photos\/([^/]+)-400\.jpg$/
|
||||
|
||||
// photoId reads the photo id out of a photo_url, which the server always
|
||||
// sends as /media/photos/{id}-400.jpg or null.
|
||||
function photoId(where: string, value: unknown): string | undefined {
|
||||
export function photoId(where: string, value: unknown): string | undefined {
|
||||
if (value === null) return undefined
|
||||
const match = typeof value === 'string' ? PHOTO_URL.exec(value) : null
|
||||
if (!match?.[1]) throw new Error(`${where}: photo_url ${JSON.stringify(value)} is not a stored photo`)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { DIFFICULTIES, type Difficulty } from '../dinners/api.ts'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import type { SideQuest } from './api.ts'
|
||||
import styles from './DiscoverPage.module.css'
|
||||
|
||||
// DECK_DEPTH is the depths the deck shows, top card first.
|
||||
export const DECK_DEPTH = [0, 1, 2] as const
|
||||
|
||||
const DIFFICULTY_LABEL: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
|
||||
|
||||
export type DeckCardProps = {
|
||||
quest: SideQuest
|
||||
// 0 is the top card; 1 and 2 stack behind it.
|
||||
depth: number
|
||||
}
|
||||
|
||||
// DeckCard is one Side quest in the deck. Only the top card is read out and
|
||||
// reachable; the cards behind it are the stack's edge, kept rendered so they
|
||||
// can be promoted. Its "Try it!" and "Pass" stamps stay hidden until a
|
||||
// decision shows them.
|
||||
export function DeckCard({ quest, depth }: DeckCardProps) {
|
||||
const top = depth === 0
|
||||
const titleId = `side-quest-${quest.id}`
|
||||
return (
|
||||
<article
|
||||
className={`${styles.card} ${styles[`depth${depth}`]}`}
|
||||
aria-labelledby={top ? titleId : undefined}
|
||||
aria-hidden={top ? undefined : true}
|
||||
inert={!top}
|
||||
>
|
||||
<Photo id={quest.photo_id} name={quest.name} radius="24px" inset="10px" sizes="400px" className={styles.photo} />
|
||||
<div className={styles.content}>
|
||||
<h2 id={titleId} className={styles.name}>
|
||||
{quest.name}
|
||||
</h2>
|
||||
{quest.difficulty !== null && <DifficultyDots difficulty={quest.difficulty} />}
|
||||
<p className={styles.why}>
|
||||
<strong>Why this?</strong> {quest.reason}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`${styles.stamp} ${styles.tryStamp}`} aria-hidden="true">
|
||||
Try it!
|
||||
</span>
|
||||
<span className={`${styles.stamp} ${styles.passStamp}`} aria-hidden="true">
|
||||
Pass
|
||||
</span>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function DifficultyDots({ difficulty }: { difficulty: Difficulty }) {
|
||||
const level = DIFFICULTIES.indexOf(difficulty) + 1
|
||||
return (
|
||||
<p className={styles.difficulty}>
|
||||
<span className={styles.dots} aria-hidden="true">
|
||||
{DIFFICULTIES.map((d, i) => (
|
||||
<span key={d} data-filled={i < level} className={i < level ? styles.dotFilled : styles.dot} />
|
||||
))}
|
||||
</span>
|
||||
{DIFFICULTY_LABEL[difficulty]}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user