From 74611da62bb7d181e6eaa9e11edd77135afa6039 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 02:31:55 -0400 Subject: [PATCH] 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 --- web/src/discover/DiscoverPage.test.tsx | 200 ++++++++++++++++++++++++- web/src/discover/DiscoverPage.tsx | 39 +++-- web/src/discover/api.ts | 63 +++++++- 3 files changed, 273 insertions(+), 29 deletions(-) diff --git a/web/src/discover/DiscoverPage.test.tsx b/web/src/discover/DiscoverPage.test.tsx index a98ecb0..3ee3d1b 100644 --- a/web/src/discover/DiscoverPage.test.tsx +++ b/web/src/discover/DiscoverPage.test.tsx @@ -41,16 +41,16 @@ const ORZO = suggestionBody('s3', 'Lemon garlic shrimp orzo', { difficulty: 'har type Responder = () => Response | Promise // 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 = {}, questLog = 6) { - const server = { deck, fetchMock: undefined as unknown as Mock } + const server = { deck, questLog, fetchMock: undefined as unknown as Mock } const all: Record = { '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((input, init) => { @@ -70,6 +70,11 @@ function sent(fetchMock: Mock) { .map(([input, init]) => `${init?.method} ${String(input)}`) } +// deckReads counts the GET /api/v1/suggestions/deck requests. +function deckReads(fetchMock: Mock) { + 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) { 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()) }) diff --git a/web/src/discover/DiscoverPage.tsx b/web/src/discover/DiscoverPage.tsx index 12535ec..2fadb80 100644 --- a/web/src/discover/DiscoverPage.tsx +++ b/web/src/discover/DiscoverPage.tsx @@ -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(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(null) + const job = useJob(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(null) const tryRef = useRef(null) const questLogRef = useRef(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(null) const leaving = useRef(null) const drag = useRef(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(':scope > article')]) }, [topId]) @@ -307,7 +316,9 @@ export function DiscoverPage() { let body if (deck.isError) { body = void deck.refetch()}>Couldn't load your side quests. {deck.error.message} - } 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 = (
@@ -325,12 +336,12 @@ export function DiscoverPage() { body = ( <> {startBatch.isError && ( - startBatch.mutate()}>Couldn't ask for more side quests. {startBatch.error.message} + Couldn't ask for more side quests. {startBatch.error.message} )} {batchError !== null && ( - startBatch.mutate()}>Couldn't find new side quests. {batchError.message} + Couldn't find new side quests. {batchError.message} )} - startBatch.mutate() }}> + New side quests arrive Sunday. diff --git a/web/src/discover/api.ts b/web/src/discover/api.ts index 9767eb7..ede8008 100644 --- a/web/src/discover/api.ts +++ b/web/src/discover/api.ts @@ -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 = { 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 => { const action = choice === 'try' ? 'accept' : 'decline' const path = `${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}` const body = await apiFetch(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 => { + // 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(DECK_KEY)?.find((q) => q.id === id) + if (quest === undefined) throw new Error(`suggestion ${id} is not in the deck`) + queryClient.setQueryData(DECK_KEY, (deck) => deck?.filter((q) => q.id !== id)) + if (choice === 'try') { + queryClient.setQueryData(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(DECK_KEY, (deck) => deck && [quest, ...deck.filter((q) => q.id !== quest.id)]) + if (choice === 'try') { + queryClient.setQueryData(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(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 }), ]) },