Optimistic Discover decisions and deck refill #108

3 changed files with 105 additions and 14 deletions
Showing only changes of commit 6fd607f125 - Show all commits
+72
View File
@@ -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], {
+8 -3
View File
@@ -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
View File
@@ -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) =>