Apply Discover decisions optimistically and refill the deck
CI / ci (push) Successful in 2m54s
CI / ci (pull_request) Successful in 2m38s

Try it and Pass are applied when the card's exit finishes: the Side
quest leaves the deck and, on Try it, its dinner joins the Quest log
count before the server answers. A refused decision puts the card back
on top of the deck, reverts the count and shows the ErrorBanner with
Retry. A saved decision reads the deck again once fewer than 3 cards are
left, and follows the replenish job it started, showing skeleton cards
rather than "All caught up" while that job runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 02:31:55 -04:00
co-authored by Claude Opus 5.5
parent 9088c313cc
commit 74611da62b
3 changed files with 273 additions and 29 deletions
+192 -8
View File
@@ -41,16 +41,16 @@ const ORZO = suggestionBody('s3', 'Lemon garlic shrimp orzo', { difficulty: 'har
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.
// server.deck and the Quest log the current server.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 server = { deck, questLog, 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}` })) }),
jsonResponse(200, { dinners: Array.from({ length: server.questLog }, (_, i) => ({ id: `q${i}`, name: `Quest ${i}` })) }),
...responders,
}
server.fetchMock = vi.fn<typeof fetch>((input, init) => {
@@ -70,6 +70,11 @@ function sent(fetchMock: Mock<typeof fetch>) {
.map(([input, init]) => `${init?.method} ${String(input)}`)
}
// deckReads counts the GET /api/v1/suggestions/deck requests.
function deckReads(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === DECK).length
}
// jobPolls counts the GET /api/v1/jobs/j1 requests.
function jobPolls(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === '/api/v1/jobs/j1').length
@@ -505,6 +510,179 @@ describe('DiscoverPage', () => {
expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument()
})
describe('optimistic decisions', () => {
const PAELLA = suggestionBody('s4', 'Sheet pan paella')
const SOUP = suggestionBody('s5', 'Tortellini soup')
const accepted = (quest: typeof BOWLS, replenish: string | null = null) =>
jsonResponse(200, { suggestion: { ...quest, status: 'accepted' }, replenish_job_id: replenish })
const declined = (quest: typeof BOWLS) => jsonResponse(200, { suggestion: { ...quest, status: 'declined' }, replenish_job_id: null })
// topCard is the deck's top card, the one read out.
const topCard = () => screen.getAllByRole('article')[0]
it('Try it takes the card off the deck and bumps the Quest log count once the exit finishes, before the answer', async () => {
const accept = deferred()
const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { 'POST /api/v1/suggestions/s1/accept': () => accept.promise })
const animations = stubAnimate({ autoFinish: false })
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
await screen.findByText('Quest log · 6')
// Nothing is applied on the press: the card is still leaving.
fireEvent.click(screen.getByRole('button', { name: 'Try it' }))
expect(screen.getByRole('article', { name: 'Korean beef bowls' })).toBeInTheDocument()
expect(screen.getByText('Quest log · 6')).toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual([])
// Once it is gone the decision is applied, and sent.
await finishRunning(animations)
await finishRunning(animations)
await screen.findByText('Quest log · 7')
expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')
expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])
expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled()
server.deck = [TIKKA, ORZO, PAELLA]
server.questLog = 7
accept.resolve(accepted(BOWLS))
await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled())
expect(screen.getByText('Quest log · 7')).toBeInTheDocument()
expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')
// Three Side quests are left: the deck is not read again.
expect(deckReads(server.fetchMock)).toBe(1)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('Pass takes the card off the deck before the answer, leaving the Quest log count', async () => {
const decline = deferred()
const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { 'POST /api/v1/suggestions/s1/decline': () => decline.promise })
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
await screen.findByText('Quest log · 6')
fireEvent.click(screen.getByRole('button', { name: 'Pass' }))
await waitFor(() => expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala'))
expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument()
expect(screen.getByText('Quest log · 6')).toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline'])
server.deck = [TIKKA, ORZO, PAELLA]
decline.resolve(declined(BOWLS))
await waitFor(() => expect(screen.getByRole('button', { name: 'Pass' })).toBeEnabled())
expect(screen.getByText('Quest log · 6')).toBeInTheDocument()
expect(deckReads(server.fetchMock)).toBe(1)
})
it('puts a refused Try it back on top of the deck, reverts the Quest log count and offers Retry', async () => {
const accept = deferred()
let refuse = true
const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], {
'POST /api/v1/suggestions/s1/accept': () => (refuse ? accept.promise : accepted(BOWLS)),
})
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
await screen.findByText('Quest log · 6')
fireEvent.click(screen.getByRole('button', { name: 'Try it' }))
await screen.findByText('Quest log · 7')
expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')
accept.resolve(errorResponse(500, 'internal'))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't save that choice.")
expect(topCard()).toHaveAccessibleName('Korean beef bowls')
expect(screen.getAllByRole('article', { hidden: true }).map((card) => card.querySelector('h2')!.textContent)).toEqual([
'Korean beef bowls',
'Crock pot chicken tikka masala',
'Lemon garlic shrimp orzo',
])
expect(screen.getByText('Quest log · 6')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()
// Retry sends the same decision again, applied again at once.
refuse = false
server.deck = [TIKKA, ORZO, PAELLA]
server.questLog = 7
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s1/accept'])
expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')
expect(screen.getByText('Quest log · 7')).toBeInTheDocument()
})
it('reads the deck again once a saved decision leaves fewer than 3 cards', async () => {
const server = stubServer([BOWLS, TIKKA, ORZO], {
'POST /api/v1/suggestions/s1/decline': () => {
server.deck = [TIKKA, ORZO, PAELLA, SOUP]
return declined(BOWLS)
},
})
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
expect(deckReads(server.fetchMock)).toBe(1)
fireEvent.click(screen.getByRole('button', { name: 'Pass' }))
await waitFor(() => expect(deckReads(server.fetchMock)).toBe(2))
await waitFor(() =>
expect(screen.getAllByRole('article', { hidden: true }).map((card) => card.querySelector('h2')!.textContent)).toEqual([
'Crock pot chicken tikka masala',
'Lemon garlic shrimp orzo',
'Sheet pan paella',
]),
)
})
it('shows skeleton cards, not "All caught up", while the replenish job a decision started runs', async () => {
const polls = [
{ 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: ['s2'], dropped: 0 }, error: null },
]
const server = stubServer([BOWLS], {
'POST /api/v1/suggestions/s1/accept': () => {
server.deck = []
return accepted(BOWLS, 'j1')
},
'GET /api/v1/jobs/j1': () => {
const job = polls.shift()!
if (job.status === 'succeeded') server.deck = [TIKKA]
return jsonResponse(200, job)
},
})
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
fireEvent.click(screen.getByRole('button', { name: 'Try it' }))
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: 'Crock pot chicken tikka masala' }, { timeout: 3000 })
expect(screen.queryByTestId('skeleton-card')).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'All caught up' })).not.toBeInTheDocument()
// The decision's replenish is followed; no batch is asked for.
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])
})
it('shows skeleton cards, not "All caught up", while the decision that emptied the deck is unanswered', async () => {
const accept = deferred()
const server = stubServer([BOWLS], { 'POST /api/v1/suggestions/s1/accept': () => accept.promise })
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
fireEvent.click(screen.getByRole('button', { name: 'Try it' }))
await waitFor(() => expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3))
expect(screen.queryByRole('heading', { name: 'All caught up' })).not.toBeInTheDocument()
expect(screen.queryByText('Finding new side quests…')).not.toBeInTheDocument()
// Saved without a replenish, the deck is caught up.
server.deck = []
accept.resolve(accepted(BOWLS))
await screen.findByRole('heading', { name: 'All caught up' })
expect(screen.queryByTestId('skeleton-card')).not.toBeInTheDocument()
})
})
describe('motion', () => {
const accepted = (quest: typeof BOWLS) => jsonResponse(200, { suggestion: { ...quest, status: 'accepted' }, replenish_job_id: null })
const declined = (quest: typeof BOWLS) => jsonResponse(200, { suggestion: { ...quest, status: 'declined' }, replenish_job_id: null })
@@ -990,14 +1168,16 @@ describe('DiscoverPage', () => {
'POST /api/v1/suggestions/s1/accept': () => (++calls === 1 ? errorResponse(500, 'internal') : retry.promise),
})
await renderPage()
const card = await screen.findByRole('article', { name: 'Korean beef bowls' })
await screen.findByRole('article', { name: 'Korean beef bowls' })
fireEvent.click(screen.getByRole('button', { name: 'Try it' }))
const alert = await screen.findByRole('alert')
// The refused Side quest is back on top of the deck.
const card = screen.getByRole('article', { name: 'Korean beef bowls' })
press(card)
drag(card, 60, 0)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled())
expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled()
drag(card, -160, 0)
expect(card.style.transform).toBe('translate(60px,0px) rotate(3.3333333333333335deg)')
const started = vi.mocked(Element.prototype.animate).mock.calls.length
@@ -1005,7 +1185,11 @@ describe('DiscoverPage', () => {
expect(card.style.transform).toBe('')
expect(within(card).getByText('Try it!').style.opacity).toBe('')
expect(vi.mocked(Element.prototype.animate).mock.calls.length).toBe(started)
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s1/accept'])
await waitFor(() =>
expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s1/accept']),
)
// The retried Side quest leaves the deck while the Retry is out.
await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' })
retry.resolve(accepted(BOWLS))
await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled())
})
+25 -14
View File
@@ -88,7 +88,8 @@ function QuestLogPill({ dinners, ref }: { dinners: { id: string; name: string }[
// DiscoverPage is mock 1a: the deck of Side quests to Try it or Pass, one
// at a time from the top card. A decision from the buttons, the ← / → keys
// or a swipe of the top card throws it out (motion.ts) and sends the
// decision once it is gone; Retry sends a refused one again. Input is
// decision once it is gone, taking the card off the deck at once (see
// useDecide); Retry sends a refused one again. Input is
// locked from the press until the decision settles: the lock is a ref, set
// before anything starts, so a second press before the next render does
// nothing, and busy mirrors it to disable the buttons.
@@ -98,22 +99,27 @@ export function DiscoverPage() {
const questLog = useQuestLog()
const decision = useDecide()
const startBatch = useStartBatch()
const job = useJob<unknown>(BATCH_JOB_KIND, startBatch.data?.job_id ?? null)
// The job of the batch last known to be finding Side quests: the one Get
// more now started or joined, or the replenish a decision started.
const [batchJobId, setBatchJobId] = useState<string | null>(null)
const job = useJob<unknown>(BATCH_JOB_KIND, batchJobId)
const top = deck.data?.[0]
const [busy, setBusy] = useState(false)
const jobRunning = startBatch.isSuccess && job.status !== 'succeeded' && job.status !== 'failed'
const jobRunning = batchJobId !== null && job.status !== 'succeeded' && job.status !== 'failed'
const finding = startBatch.isPending || jobRunning
const { mutate: mutateStartBatch } = startBatch
const getMore = useCallback(() => mutateStartBatch(undefined, { onSuccess: (b) => setBatchJobId(b.job_id) }), [mutateStartBatch])
// 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 }
return { stored: parseBatchResult(`GET /api/v1/jobs/${batchJobId}`, job.result).suggestion_ids.length, error: null }
} catch (error) {
return { stored: 0, error: error as Error }
}
}, [job.result, startBatch.data?.job_id])
}, [job.result, batchJobId])
// The job's failure, the latest poll's failure, an answer that is not the
// batch's job, or an unreadable result.
const batchError = job.error ?? batch.error
@@ -122,8 +128,8 @@ export function DiscoverPage() {
const passRef = useRef<HTMLButtonElement>(null)
const tryRef = useRef<HTMLButtonElement>(null)
const questLogRef = useRef<HTMLSpanElement>(null)
// The top card's exit while it runs or its decision is unsettled, and the
// Side quest the last decision sent was for, until the deck moves past it.
// The top card's exit until its decision settles, and the Side quest the
// last decision sent was for, until the deck moves past it.
const exit = useRef<Exit | null>(null)
const leaving = useRef<string | null>(null)
const drag = useRef<Drag | null>(null)
@@ -143,17 +149,21 @@ export function DiscoverPage() {
setBusy(true)
return true
}, [])
// send records a decision. A refused one puts the card back.
// send records a decision. A refused one puts the card back; a saved one
// that started a replenish batch follows its job.
const send = useCallback(
(variables: { id: string; choice: Choice }) => {
leaving.current = variables.id
mutateDecision(variables, {
onSuccess: (replenishJobId) => {
if (replenishJobId !== null) setBatchJobId(replenishJobId)
},
onError: () => {
exit.current?.cancel()
exit.current = null
leaving.current = null
},
onSettled: () => {
exit.current = null
deciding.current = false
setBusy(false)
},
@@ -278,7 +288,6 @@ export function DiscoverPage() {
useLayoutEffect(() => {
if (leaving.current === null || leaving.current === topId) return
leaving.current = null
exit.current = null
if (topId === undefined || prefersReducedMotion()) return
promote([...deckRef.current!.querySelectorAll<HTMLElement>(':scope > article')])
}, [topId])
@@ -307,7 +316,9 @@ export function DiscoverPage() {
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)) {
} else if (deck.isPending || (deck.data.length === 0 && (finding || decision.isPending))) {
// An empty deck is not caught up while a batch finds more Side quests,
// or while the last decision may yet start one or come back refused.
body = (
<section aria-busy="true" aria-label="Side quests loading" className={styles.loading}>
<div className={styles.deck}>
@@ -325,12 +336,12 @@ export function DiscoverPage() {
body = (
<>
{startBatch.isError && (
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't ask for more side quests. {startBatch.error.message}</ErrorBanner>
<ErrorBanner onRetry={getMore}>Couldn't ask for more side quests. {startBatch.error.message}</ErrorBanner>
)}
{batchError !== null && (
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't find new side quests. {batchError.message}</ErrorBanner>
<ErrorBanner onRetry={getMore}>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: getMore }}>
New side quests arrive Sunday.
</EmptyState>
</>
+56 -7
View File
@@ -12,10 +12,12 @@ export const QUEST_LOG_PATH = `${DINNERS_PATH}?status=quest_log`
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?".
// SideQuest is a pending suggestion as the deck shows it. dinner_id is the
// dinner Try it moves to the Quest log; photo_id is the stored photo's id,
// from photo_url; reason is the "Why this?".
export type SideQuest = {
id: string
dinner_id: string
name: string
photo_id: string | undefined
difficulty: Difficulty | null
@@ -38,6 +40,10 @@ export type BatchResult = { batch_id: string; stage: string; suggestion_ids: str
// QuestLogDinner is a dinner in the Quest log, as the Quest log pill lists it.
export type QuestLogDinner = { id: string; name: string }
// REFILL_BELOW is the deck size, the depths the deck shows, under which a
// saved decision reads the deck again.
export const REFILL_BELOW = 3
// DECISION_STATUS is the status a suggestion has once each choice is saved.
const DECISION_STATUS: Record<Choice, string> = { try: 'accepted', pass: 'declined' }
@@ -61,6 +67,7 @@ function parseSideQuest(where: string, body: unknown): SideQuest {
if (
!isRecord(body) ||
typeof body.id !== 'string' ||
typeof body.dinner_id !== 'string' ||
typeof body.name !== 'string' ||
typeof body.reason !== 'string'
) {
@@ -68,6 +75,7 @@ function parseSideQuest(where: string, body: unknown): SideQuest {
}
return {
id: body.id,
dinner_id: body.dinner_id,
name: body.name,
photo_id: photoId(where, body.photo_url),
difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty),
@@ -126,14 +134,25 @@ export function useStartBatch() {
})
}
// Decided is the context a decision's optimistic update leaves for its
// rollback: the Side quest taken off the deck.
type Decided = { quest: SideQuest }
// 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. The answer must be that suggestion, decided that way.
// (POST /api/v1/suggestions/{id}/decline). The answer must be that
// suggestion, decided that way; the mutation's data is the id of the
// replenish job the decision started, or null.
//
// The decision is applied at once: the Side quest leaves the deck and, on
// Try it, its dinner joins the Quest log. A refused decision puts the Side
// quest back on top of the deck and takes its dinner out of the Quest log
// again. A saved one reads the Quest log again, and the deck too once fewer
// than REFILL_BELOW Side quests are left in it.
export function useDecide() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, choice }: { id: string; choice: Choice }) => {
mutationFn: async ({ id, choice }: { id: string; choice: Choice }): Promise<string | null> => {
const action = choice === 'try' ? 'accept' : 'decline'
const path = `${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}`
const body = await apiFetch<unknown>(path, { method: 'POST' })
@@ -145,10 +164,40 @@ export function useDecide() {
) {
throw new Error(`POST ${path}: response is not the ${DECISION_STATUS[choice]} suggestion`)
}
if (body.replenish_job_id !== null && typeof body.replenish_job_id !== 'string') {
throw new Error(`POST ${path}: response has no replenish_job_id`)
}
return body.replenish_job_id
},
onSuccess: async () => {
onMutate: async ({ id, choice }): Promise<Decided> => {
// A read already under way would answer with the Side quest still
// pending, undoing the update.
await Promise.all([queryClient.cancelQueries({ queryKey: DECK_KEY }), queryClient.cancelQueries({ queryKey: QUEST_LOG_KEY })])
const quest = queryClient.getQueryData<SideQuest[]>(DECK_KEY)?.find((q) => q.id === id)
if (quest === undefined) throw new Error(`suggestion ${id} is not in the deck`)
queryClient.setQueryData<SideQuest[]>(DECK_KEY, (deck) => deck?.filter((q) => q.id !== id))
if (choice === 'try') {
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) =>
dinners?.concat({ id: quest.dinner_id, name: quest.name }),
)
}
return { quest }
},
onError: (_error, { choice }, context) => {
// onMutate refusing leaves nothing to undo.
if (context === undefined) return
const { quest } = context
queryClient.setQueryData<SideQuest[]>(DECK_KEY, (deck) => deck && [quest, ...deck.filter((q) => q.id !== quest.id)])
if (choice === 'try') {
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) => dinners?.filter((d) => d.id !== quest.dinner_id))
}
},
onSuccess: async (_replenishJobId, { id }) => {
// A read that landed while the decision was in flight may have put
// the Side quest back.
const deck = queryClient.setQueryData<SideQuest[]>(DECK_KEY, (current) => current?.filter((q) => q.id !== id))
await Promise.all([
queryClient.invalidateQueries({ queryKey: DECK_KEY }),
deck !== undefined && deck.length < REFILL_BELOW ? queryClient.invalidateQueries({ queryKey: DECK_KEY }) : undefined,
queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
])
},