Keep a Side quest off the shown deck while its decision is pending
The deck shown is now the deck's newest read less the Side quests whose decisions are in flight, taken from the pending decide mutations, instead of removing the card from the deck cache in onMutate. A read of the deck landing before the decision is answered can no longer bring the exited card back; settlement still reconciles from the server read as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -653,6 +653,78 @@ describe('DiscoverPage', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the card off the deck while its decision is unanswered when a read of the deck lands with it still pending, then reads the deck again once it is saved', async () => {
|
||||
const decline = deferred()
|
||||
const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { 'POST /api/v1/suggestions/s1/decline': () => decline.promise })
|
||||
const queryClient = createQueryClient()
|
||||
await renderPage(queryClient)
|
||||
await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pass' }))
|
||||
await waitFor(() => expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline']))
|
||||
expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')
|
||||
|
||||
// A read of the deck lands before the decision is saved, with the Side
|
||||
// quest still pending: the card stays off the deck.
|
||||
await act(async () => await queryClient.refetchQueries({ queryKey: DECK_KEY }))
|
||||
await act(async () => await new Promise((resolve) => setTimeout(resolve, 0)))
|
||||
expect(deckReads(server.fetchMock)).toBe(2)
|
||||
expect(queryClient.getQueryData<{ id: string }[]>(DECK_KEY)?.map((q) => q.id)).toEqual(['s1', 's2', 's3', 's4'])
|
||||
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',
|
||||
])
|
||||
|
||||
// Once it is saved, the deck read after it is the deck shown.
|
||||
server.deck = [TIKKA, ORZO, PAELLA]
|
||||
decline.resolve(declined(BOWLS))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Pass' })).toBeEnabled())
|
||||
expect(deckReads(server.fetchMock)).toBe(3)
|
||||
expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument()
|
||||
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',
|
||||
])
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the card off the deck while its decision is unanswered when a read of the deck lands with it still pending, then puts it back on top with a Retry once it is refused', async () => {
|
||||
const accept = deferred()
|
||||
const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { 'POST /api/v1/suggestions/s1/accept': () => accept.promise })
|
||||
const queryClient = createQueryClient()
|
||||
await renderPage(queryClient)
|
||||
await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try it' }))
|
||||
await waitFor(() => expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']))
|
||||
expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')
|
||||
|
||||
// A read of the deck lands mid-decision, in the server's order.
|
||||
server.deck = [TIKKA, BOWLS, ORZO, PAELLA]
|
||||
await act(async () => await queryClient.refetchQueries({ queryKey: DECK_KEY }))
|
||||
await act(async () => await new Promise((resolve) => setTimeout(resolve, 0)))
|
||||
expect(deckReads(server.fetchMock)).toBe(2)
|
||||
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',
|
||||
])
|
||||
|
||||
// The server refuses it; the deck read after it still has it pending.
|
||||
accept.resolve(errorResponse(400, 'invalid_request'))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't save that choice.")
|
||||
expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(deckReads(server.fetchMock)).toBe(3)
|
||||
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',
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves the Quest log count alone for a dinner already in it, on Try it and on its refusal', async () => {
|
||||
const accept = deferred()
|
||||
stubServer([BOWLS, TIKKA, ORZO, PAELLA], {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DECK_KEY,
|
||||
parseBatchResult,
|
||||
stillPending,
|
||||
useDeciding,
|
||||
useDeck,
|
||||
useDecide,
|
||||
useQuestLog,
|
||||
@@ -100,6 +101,10 @@ function QuestLogPill({ dinners, ref }: { dinners: { id: string; name: string }[
|
||||
export function DiscoverPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const deck = useDeck()
|
||||
// The deck shown: the deck's newest read, less the Side quests being
|
||||
// decided, which a read landing mid-decision may still have pending.
|
||||
const inFlight = useDeciding()
|
||||
const cards = deck.data?.filter((quest) => !inFlight.has(quest.id))
|
||||
const questLog = useQuestLog()
|
||||
const decision = useDecide()
|
||||
const startBatch = useStartBatch()
|
||||
@@ -108,7 +113,7 @@ export function DiscoverPage() {
|
||||
const [batchJobId, setBatchJobId] = useState<string | null>(null)
|
||||
const job = useJob<unknown>(BATCH_JOB_KIND, batchJobId)
|
||||
|
||||
const top = deck.data?.[0]
|
||||
const top = cards?.[0]
|
||||
const [busy, setBusy] = useState(false)
|
||||
const jobRunning = batchJobId !== null && job.status !== 'succeeded' && job.status !== 'failed'
|
||||
const finding = startBatch.isPending || jobRunning
|
||||
@@ -331,7 +336,7 @@ 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 || decision.isPending))) {
|
||||
} else if (deck.isPending || (cards!.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 = (
|
||||
@@ -365,7 +370,7 @@ export function DiscoverPage() {
|
||||
body = (
|
||||
<>
|
||||
<div ref={deckRef} className={styles.deck}>
|
||||
{deck.data.slice(0, DECK_DEPTH.length).map((quest, depth) => (
|
||||
{cards!.slice(0, DECK_DEPTH.length).map((quest, depth) => (
|
||||
<DeckCard key={quest.id} quest={quest} depth={depth} swipe={depth === 0 ? swipe : undefined} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
+25
-11
@@ -1,4 +1,4 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useMutationState, 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'
|
||||
@@ -8,9 +8,11 @@ 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.
|
||||
// DECK_KEY is the deck's query; QUEST_LOG_KEY the Quest log's;
|
||||
// DECIDE_KEY the decisions'.
|
||||
export const DECK_KEY = ['suggestions', 'deck'] as const
|
||||
export const QUEST_LOG_KEY = ['dinners', 'quest_log'] as const
|
||||
const DECIDE_KEY = ['suggestions', 'decide'] as const
|
||||
|
||||
// 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,
|
||||
@@ -139,20 +141,32 @@ export function stillPending(deck: { status: string; data: SideQuest[] | undefin
|
||||
return deck.data!.find((q) => q.id === id) ?? null
|
||||
}
|
||||
|
||||
// useDeciding is the ids of the Side quests whose decisions are pending:
|
||||
// sent and not yet settled. The deck shown leaves them out, whatever a read
|
||||
// of the deck has said meanwhile.
|
||||
export function useDeciding(): Set<string> {
|
||||
const ids = useMutationState<string>({
|
||||
filters: { mutationKey: DECIDE_KEY, status: 'pending' },
|
||||
select: (mutation) => (mutation.state.variables as { quest: SideQuest }).quest.id,
|
||||
})
|
||||
return new Set(ids)
|
||||
}
|
||||
|
||||
// useDecide records Try it (POST /api/v1/suggestions/{id}/accept) or Pass
|
||||
// (POST /api/v1/suggestions/{id}/decline) on the Side quest the household
|
||||
// decided on, whether or not a read of the deck has since taken it away.
|
||||
// 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 unless it is there already.
|
||||
// Nothing else is worked out here: the server is the record of both. However
|
||||
// the decision ends, saved or failed, the deck and the Quest log are read
|
||||
// again, and the decision is pending until those reads land, so the next one
|
||||
// (which DiscoverPage locks out until then) cannot start under them. A read
|
||||
// under way when the decision ends is replaced by the read made after it,
|
||||
// and DiscoverPage's batch reads wait for that one rather than replace it.
|
||||
// The decision is applied at once: the Side quest leaves the deck shown
|
||||
// while it is pending (see useDeciding) and, on Try it, its dinner joins the
|
||||
// Quest log unless it is there already. Nothing else is worked out here:
|
||||
// the server is the record of both. However the decision ends, saved or
|
||||
// failed, the deck and the Quest log are read again, and the decision is
|
||||
// pending until those reads land, so the next one (which DiscoverPage locks
|
||||
// out until then) cannot start under them. A read under way when the
|
||||
// decision ends is replaced by the read made after it, and DiscoverPage's
|
||||
// batch reads wait for that one rather than replace it.
|
||||
// A failed decision waits for the deck read it makes itself. Whether its
|
||||
// Side quest is still pending is then the deck query's to say, from its
|
||||
// newest successful read: once the reads have landed, a Side quest the deck
|
||||
@@ -181,6 +195,7 @@ export function useDecide() {
|
||||
}
|
||||
|
||||
return useMutation<string | null, Error, { quest: SideQuest; choice: Choice }>({
|
||||
mutationKey: DECIDE_KEY,
|
||||
mutationFn: async ({ quest, choice }) => {
|
||||
try {
|
||||
return await send(quest.id, choice)
|
||||
@@ -197,7 +212,6 @@ export function useDecide() {
|
||||
}
|
||||
},
|
||||
onMutate: ({ quest, choice }) => {
|
||||
queryClient.setQueryData<SideQuest[]>(DECK_KEY, (deck) => deck?.filter((q) => q.id !== quest.id))
|
||||
if (choice !== 'try') return
|
||||
// A stale deck can offer a Side quest whose dinner the Quest log has.
|
||||
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) =>
|
||||
|
||||
Reference in New Issue
Block a user