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 () => { 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], {
+8 -3
View File
@@ -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
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 { 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) =>