Optimistic Discover decisions and deck refill #108
@@ -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