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 () => {
|
it('leaves the Quest log count alone for a dinner already in it, on Try it and on its refusal', async () => {
|
||||||
const accept = deferred()
|
const accept = deferred()
|
||||||
stubServer([BOWLS, TIKKA, ORZO, PAELLA], {
|
stubServer([BOWLS, TIKKA, ORZO, PAELLA], {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
DECK_KEY,
|
DECK_KEY,
|
||||||
parseBatchResult,
|
parseBatchResult,
|
||||||
stillPending,
|
stillPending,
|
||||||
|
useDeciding,
|
||||||
useDeck,
|
useDeck,
|
||||||
useDecide,
|
useDecide,
|
||||||
useQuestLog,
|
useQuestLog,
|
||||||
@@ -100,6 +101,10 @@ function QuestLogPill({ dinners, ref }: { dinners: { id: string; name: string }[
|
|||||||
export function DiscoverPage() {
|
export function DiscoverPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const deck = useDeck()
|
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 questLog = useQuestLog()
|
||||||
const decision = useDecide()
|
const decision = useDecide()
|
||||||
const startBatch = useStartBatch()
|
const startBatch = useStartBatch()
|
||||||
@@ -108,7 +113,7 @@ export function DiscoverPage() {
|
|||||||
const [batchJobId, setBatchJobId] = useState<string | null>(null)
|
const [batchJobId, setBatchJobId] = useState<string | null>(null)
|
||||||
const job = useJob<unknown>(BATCH_JOB_KIND, batchJobId)
|
const job = useJob<unknown>(BATCH_JOB_KIND, batchJobId)
|
||||||
|
|
||||||
const top = deck.data?.[0]
|
const top = cards?.[0]
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const jobRunning = batchJobId !== null && job.status !== 'succeeded' && job.status !== 'failed'
|
const jobRunning = batchJobId !== null && job.status !== 'succeeded' && job.status !== 'failed'
|
||||||
const finding = startBatch.isPending || jobRunning
|
const finding = startBatch.isPending || jobRunning
|
||||||
@@ -331,7 +336,7 @@ export function DiscoverPage() {
|
|||||||
let body
|
let body
|
||||||
if (deck.isError) {
|
if (deck.isError) {
|
||||||
body = <ErrorBanner onRetry={() => void deck.refetch()}>Couldn't load your side quests. {deck.error.message}</ErrorBanner>
|
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,
|
// 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.
|
// or while the last decision may yet start one or come back refused.
|
||||||
body = (
|
body = (
|
||||||
@@ -365,7 +370,7 @@ export function DiscoverPage() {
|
|||||||
body = (
|
body = (
|
||||||
<>
|
<>
|
||||||
<div ref={deckRef} className={styles.deck}>
|
<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} />
|
<DeckCard key={quest.id} quest={quest} depth={depth} swipe={depth === 0 ? swipe : undefined} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 { useApi } from '../api/index.ts'
|
||||||
import { isRecord } from '../api/errors.ts'
|
import { isRecord } from '../api/errors.ts'
|
||||||
import { DIFFICULTIES, DINNERS_PATH, oneOf, photoId, type Difficulty } from '../dinners/api.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 BATCHES_PATH = `${SUGGESTIONS_PATH}/batches`
|
||||||
export const QUEST_LOG_PATH = `${DINNERS_PATH}?status=quest_log`
|
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 DECK_KEY = ['suggestions', 'deck'] as const
|
||||||
export const QUEST_LOG_KEY = ['dinners', 'quest_log'] 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
|
// 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,
|
// 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
|
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
|
// useDecide records Try it (POST /api/v1/suggestions/{id}/accept) or Pass
|
||||||
// (POST /api/v1/suggestions/{id}/decline) on the Side quest the household
|
// (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.
|
// 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
|
// 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.
|
// 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
|
// The decision is applied at once: the Side quest leaves the deck shown
|
||||||
// Try it, its dinner joins the Quest log unless it is there already.
|
// while it is pending (see useDeciding) and, on Try it, its dinner joins the
|
||||||
// Nothing else is worked out here: the server is the record of both. However
|
// Quest log unless it is there already. Nothing else is worked out here:
|
||||||
// the decision ends, saved or failed, the deck and the Quest log are read
|
// the server is the record of both. However the decision ends, saved or
|
||||||
// again, and the decision is pending until those reads land, so the next one
|
// failed, the deck and the Quest log are read again, and the decision is
|
||||||
// (which DiscoverPage locks out until then) cannot start under them. A read
|
// pending until those reads land, so the next one (which DiscoverPage locks
|
||||||
// under way when the decision ends is replaced by the read made after it,
|
// out until then) cannot start under them. A read under way when the
|
||||||
// and DiscoverPage's batch reads wait for that one rather than replace it.
|
// 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
|
// 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
|
// 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
|
// 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 }>({
|
return useMutation<string | null, Error, { quest: SideQuest; choice: Choice }>({
|
||||||
|
mutationKey: DECIDE_KEY,
|
||||||
mutationFn: async ({ quest, choice }) => {
|
mutationFn: async ({ quest, choice }) => {
|
||||||
try {
|
try {
|
||||||
return await send(quest.id, choice)
|
return await send(quest.id, choice)
|
||||||
@@ -197,7 +212,6 @@ export function useDecide() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMutate: ({ quest, choice }) => {
|
onMutate: ({ quest, choice }) => {
|
||||||
queryClient.setQueryData<SideQuest[]>(DECK_KEY, (deck) => deck?.filter((q) => q.id !== quest.id))
|
|
||||||
if (choice !== 'try') return
|
if (choice !== 'try') return
|
||||||
// A stale deck can offer a Side quest whose dinner the Quest log has.
|
// A stale deck can offer a Side quest whose dinner the Quest log has.
|
||||||
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) =>
|
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user