Fix the Discover deck review findings
CI / ci (push) Successful in 3m10s
CI / ci (pull_request) Successful in 2m39s

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:
2026-10-07 01:13:00 -04:00
co-authored by Claude Opus 5.5
parent 38fec723f6
commit fe87968ac9
7 changed files with 325 additions and 93 deletions
+44 -7
View File
@@ -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.
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 {
if (
!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).
export function useQuestLogCount() {
export function useQuestLog() {
const { apiFetch } = useApi()
return useQuery({
queryKey: QUEST_LOG_KEY,
queryFn: async () => {
queryFn: async (): Promise<QuestLogDinner[]> => {
const where = `GET ${QUEST_LOG_PATH}`
const body = await apiFetch<unknown>(QUEST_LOG_PATH)
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`GET ${QUEST_LOG_PATH}: response has no dinners list`)
return body.dinners.length
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
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
// (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() {
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' })
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 () => {
await Promise.all([