Add the Discover deck screen (mock 1a)
CI / ci (push) Successful in 2m53s
CI / ci (pull_request) Successful in 2m37s

/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:
2026-10-07 01:03:48 -04:00
co-authored by Claude Opus 5.5
parent 4c34467f8b
commit 38fec723f6
8 changed files with 942 additions and 2 deletions
+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 }),
])
},
})
}