diff --git a/web/src/discover/DiscoverPage.test.tsx b/web/src/discover/DiscoverPage.test.tsx index 5ff2518..66d941c 100644 --- a/web/src/discover/DiscoverPage.test.tsx +++ b/web/src/discover/DiscoverPage.test.tsx @@ -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], { diff --git a/web/src/discover/DiscoverPage.tsx b/web/src/discover/DiscoverPage.tsx index b3e3a0f..078286b 100644 --- a/web/src/discover/DiscoverPage.tsx +++ b/web/src/discover/DiscoverPage.tsx @@ -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(null) const job = useJob(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 = void deck.refetch()}>Couldn't load your side quests. {deck.error.message} - } 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 = ( <>
- {deck.data.slice(0, DECK_DEPTH.length).map((quest, depth) => ( + {cards!.slice(0, DECK_DEPTH.length).map((quest, depth) => ( ))}
diff --git a/web/src/discover/api.ts b/web/src/discover/api.ts index 1251e5a..c233de3 100644 --- a/web/src/discover/api.ts +++ b/web/src/discover/api.ts @@ -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 { + const ids = useMutationState({ + 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({ + 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(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(QUEST_LOG_KEY, (dinners) =>