Apply Discover decisions optimistically and refill the deck
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:
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 }),
|
||||
])
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user