From a866dfad0692b756d44d9e4590a0d1df0ca96f4f Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Wed, 7 Oct 2026 04:03:48 -0400 Subject: [PATCH] Optimistic Discover decisions and deck refill (#108) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- web/src/discover/DiscoverPage.test.tsx | 698 ++++++++++++++++++++++++- web/src/discover/DiscoverPage.tsx | 95 ++-- web/src/discover/api.ts | 123 ++++- web/src/ui/ErrorBanner.test.tsx | 6 + web/src/ui/ErrorBanner.tsx | 8 +- 5 files changed, 862 insertions(+), 68 deletions(-) diff --git a/web/src/discover/DiscoverPage.test.tsx b/web/src/discover/DiscoverPage.test.tsx index a98ecb0..66d941c 100644 --- a/web/src/discover/DiscoverPage.test.tsx +++ b/web/src/discover/DiscoverPage.test.tsx @@ -8,6 +8,7 @@ import { declaredStyle } from '../test/cssRules.ts' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' import { expectMinHitArea } from '../test/hitArea.ts' import { mockViewport } from '../test/matchMedia.ts' +import { DECK_KEY } from './api.ts' import { DiscoverPage } from './DiscoverPage.tsx' const DECK = '/api/v1/suggestions/deck' @@ -41,16 +42,16 @@ const ORZO = suggestionBody('s3', 'Lemon garlic shrimp orzo', { difficulty: 'har type Responder = () => Response | Promise // stubServer stands in for the API: GET of the deck answers the current -// server.deck and the Quest log has questLog dinners; every other request -// is answered by responders, keyed "METHOD path". Any other request fails -// the test. +// server.deck and the Quest log the current server.questLog dinners; every +// other request is answered by responders, keyed "METHOD path". Any other +// request fails the test. function stubServer(deck: unknown[], responders: Record = {}, questLog = 6) { - const server = { deck, fetchMock: undefined as unknown as Mock } + const server = { deck, questLog, fetchMock: undefined as unknown as Mock } const all: Record = { 'GET /api/v1/me': () => jsonResponse(200, ME), [`GET ${DECK}`]: () => jsonResponse(200, { suggestions: server.deck }), [`GET ${QUEST_LOG}`]: () => - jsonResponse(200, { dinners: Array.from({ length: questLog }, (_, i) => ({ id: `q${i}`, name: `Quest ${i}` })) }), + jsonResponse(200, { dinners: Array.from({ length: server.questLog }, (_, i) => ({ id: `q${i}`, name: `Quest ${i}` })) }), ...responders, } server.fetchMock = vi.fn((input, init) => { @@ -70,6 +71,11 @@ function sent(fetchMock: Mock) { .map(([input, init]) => `${init?.method} ${String(input)}`) } +// deckReads counts the GET /api/v1/suggestions/deck requests. +function deckReads(fetchMock: Mock) { + return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === DECK).length +} + // jobPolls counts the GET /api/v1/jobs/j1 requests. function jobPolls(fetchMock: Mock) { return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === '/api/v1/jobs/j1').length @@ -97,13 +103,13 @@ async function finishRunning(animations: FakeAnimation[]) { } // renderPage loads /me (for the CSRF token every write needs) and renders -// the Discover page. -async function renderPage() { +// the Discover page with queryClient. +async function renderPage(queryClient = createQueryClient()) { const api = createApiClient(fakeLocation('/discover'), createNetworkStatus(window), createVerifiedSession(localStorage)) await api.loadMe() return render( - + , @@ -505,6 +511,663 @@ describe('DiscoverPage', () => { expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument() }) + describe('optimistic decisions', () => { + const PAELLA = suggestionBody('s4', 'Sheet pan paella') + const SOUP = suggestionBody('s5', 'Tortellini soup') + const accepted = (quest: typeof BOWLS, replenish: string | null = null) => + jsonResponse(200, { suggestion: { ...quest, status: 'accepted' }, replenish_job_id: replenish }) + const declined = (quest: typeof BOWLS) => jsonResponse(200, { suggestion: { ...quest, status: 'declined' }, replenish_job_id: null }) + // topCard is the deck's top card, the one read out. + const topCard = () => screen.getAllByRole('article')[0] + + it('Try it takes the card off the deck and bumps the Quest log count once the exit finishes, before the answer', async () => { + const accept = deferred() + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { 'POST /api/v1/suggestions/s1/accept': () => accept.promise }) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + await screen.findByText('Quest log · 6') + + // Nothing is applied on the press: the card is still leaving. + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + expect(screen.getByRole('article', { name: 'Korean beef bowls' })).toBeInTheDocument() + expect(screen.getByText('Quest log · 6')).toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual([]) + + // Once it is gone the decision is applied, and sent. + await finishRunning(animations) + await finishRunning(animations) + await screen.findByText('Quest log · 7') + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']) + expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled() + + server.deck = [TIKKA, ORZO, PAELLA] + server.questLog = 7 + accept.resolve(accepted(BOWLS)) + await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()) + expect(screen.getByText('Quest log · 7')).toBeInTheDocument() + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + // The deck is read again once the decision is saved. + expect(deckReads(server.fetchMock)).toBe(2) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('Pass takes the card off the deck before the answer, leaving the Quest log count', async () => { + const decline = deferred() + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { 'POST /api/v1/suggestions/s1/decline': () => decline.promise }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + await screen.findByText('Quest log · 6') + + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + await waitFor(() => expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')) + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + expect(screen.getByText('Quest log · 6')).toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline']) + + server.deck = [TIKKA, ORZO, PAELLA] + decline.resolve(declined(BOWLS)) + await waitFor(() => expect(screen.getByRole('button', { name: 'Pass' })).toBeEnabled()) + expect(screen.getByText('Quest log · 6')).toBeInTheDocument() + expect(deckReads(server.fetchMock)).toBe(2) + }) + + it('puts a refused Try it back on top of the deck, reverts the Quest log count and offers Retry', async () => { + const accept = deferred() + let refuse = true + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/accept': () => (refuse ? accept.promise : accepted(BOWLS)), + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + await screen.findByText('Quest log · 6') + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await screen.findByText('Quest log · 7') + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + + // The server refuses the request itself: the deck read again after it + // still has the Side quest pending, in the server's order. + server.deck = [TIKKA, BOWLS, ORZO, PAELLA] + accept.resolve(errorResponse(400, 'invalid_request')) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't save that choice.") + expect(topCard()).toHaveAccessibleName('Korean beef bowls') + expect(deckReads(server.fetchMock)).toBe(2) + 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', + ]) + expect(screen.getByText('Quest log · 6')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled() + + // Retry sends the same decision again, applied again at once. + refuse = false + server.deck = [TIKKA, ORZO, PAELLA] + server.questLog = 7 + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s1/accept']) + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + expect(screen.getByText('Quest log · 7')).toBeInTheDocument() + }) + + it('keeps a saved decision off the deck when a read started during it answers late', async () => { + const decline = deferred() + let late: ReturnType | null = null + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/decline': () => decline.promise, + [`GET ${DECK}`]: () => late?.promise ?? jsonResponse(200, { suggestions: server.deck }), + }) + 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 starts before the decision is saved, and answers + // only after: with the Side quest still pending. + late = deferred() + const stale = late + await act(async () => void queryClient.invalidateQueries({ queryKey: DECK_KEY })) + expect(deckReads(server.fetchMock)).toBe(2) + late = null + server.deck = [TIKKA, ORZO, PAELLA] + decline.resolve(declined(BOWLS)) + await waitFor(() => expect(screen.getByRole('button', { name: 'Pass' })).toBeEnabled()) + await act(async () => stale.resolve(jsonResponse(200, { suggestions: [BOWLS, TIKKA, ORZO, PAELLA] }))) + + // The late answer is dropped, and the deck is read again after the save. + await act(async () => await new Promise((resolve) => setTimeout(resolve, 0))) + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(3)) + 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', + ]) + }) + + 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], { + 'POST /api/v1/suggestions/s1/accept': () => accept.promise, + // A read of the Quest log already has the dinner the stale deck offers. + [`GET ${QUEST_LOG}`]: () => + jsonResponse(200, { + dinners: [ + { id: 'q0', name: 'Quest 0' }, + { id: 'dinner-s1', name: 'Korean beef bowls' }, + ], + }), + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + await screen.findByText('Quest log · 2') + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await waitFor(() => expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')) + expect(screen.getByText('Quest log · 2')).toBeInTheDocument() + + accept.resolve(errorResponse(500, 'internal')) + await screen.findByRole('alert') + expect(topCard()).toHaveAccessibleName('Korean beef bowls') + expect(screen.getByText('Quest log · 2')).toBeInTheDocument() + }) + + it('takes out of the Quest log only the entry a refused Try it added, after a read of the Quest log lands during it', async () => { + const accept = deferred() + const questLog = [{ id: 'q0', name: 'Quest 0' }] + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/accept': () => accept.promise, + [`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: questLog }), + }) + const queryClient = createQueryClient() + await renderPage(queryClient) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + await screen.findByText('Quest log · 1') + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await waitFor(() => expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])) + expect(screen.getByText('Quest log · 2')).toBeInTheDocument() + + // The decision is saved, and a read of the Quest log lands with its + // dinner in it; then the answer to the decision is lost. + questLog.push({ id: 'dinner-s1', name: 'Korean beef bowls' }) + await act(async () => await queryClient.invalidateQueries({ queryKey: ['dinners', 'quest_log'] })) + accept.resolve(errorResponse(500, 'internal')) + await screen.findByRole('alert') + expect(screen.getByText('Quest log · 2')).toBeInTheDocument() + // The answer was lost, so the Quest log is read again. + const questLogReads = () => server.fetchMock.mock.calls.filter(([input]) => String(input) === QUEST_LOG).length + await waitFor(() => expect(questLogReads()).toBe(3)) + expect(screen.getByText('Quest log · 2')).toBeInTheDocument() + }) + + it('sends a decision for the card thrown out even when a read of the deck took it away during the exit, offering no Retry for it', async () => { + const server = stubServer([BOWLS], { + 'POST /api/v1/suggestions/s1/accept': () => errorResponse(500, 'internal'), + }) + const queryClient = createQueryClient() + const animations = stubAnimate({ autoFinish: false }) + await renderPage(queryClient) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + // While the card leaves, a read of the deck takes the last card away. + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + server.deck = [] + await act(async () => await queryClient.invalidateQueries({ queryKey: DECK_KEY })) + await screen.findByRole('heading', { name: 'All caught up' }) + + await finishRunning(animations) + await finishRunning(animations) + const alert = await screen.findByRole('alert') + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']) + // The deck read after it confirms the Side quest is decided: the card + // is not put back, and there is nothing to retry. + expect(alert).toHaveTextContent('That side quest has been decided already.') + expect(within(alert).queryByRole('button')).not.toBeInTheDocument() + expect(screen.queryByRole('article')).not.toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument() + }) + + it('keeps a card off the deck when a read during its decision left it out and the answer is lost, and reads the deck again', async () => { + let lose!: (error: Error) => void + const accept = new Promise((_, reject) => (lose = reject)) + let late: ReturnType | null = null + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/accept': () => accept, + [`GET ${DECK}`]: () => late?.promise ?? jsonResponse(200, { suggestions: server.deck }), + }) + 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'])) + + // The decision is saved, and a read of the deck started during it + // answers without the Side quest; then the answer to the decision is + // lost on the way back. + late = deferred() + const delayed = late + await act(async () => void queryClient.invalidateQueries({ queryKey: DECK_KEY })) + late = null + server.deck = [TIKKA, ORZO, PAELLA] + await act(async () => delayed.resolve(jsonResponse(200, { suggestions: server.deck }))) + const reads = deckReads(server.fetchMock) + await act(async () => lose(new TypeError('Failed to fetch'))) + + await screen.findByRole('alert') + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + // The decision may have been saved: the deck is read again. + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(reads + 1)) + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + }) + + it('keeps a card off the deck when another session already decided it (409), and reads the deck again', async () => { + const accept = deferred() + let read: ReturnType | null = null + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/accept': () => accept.promise, + [`GET ${DECK}`]: () => read?.promise ?? jsonResponse(200, { suggestions: server.deck }), + }) + await renderPage() + 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'])) + + // Another session passed on it first; no read of the deck lands + // during the decision. + read = deferred() + const pending = read + server.deck = [TIKKA, ORZO, PAELLA] + await act(async () => accept.resolve(errorResponse(409, 'suggestion_decided'))) + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(2)) + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + // The decision is not over until that read lands. + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled() + + await act(async () => pending.resolve(jsonResponse(200, { suggestions: server.deck }))) + const alert = await screen.findByRole('alert') + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + // It was decided: there is no decision to retry. + expect(alert).toHaveTextContent('That side quest has been decided already.') + expect(within(alert).queryByRole('button')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled() + }) + + it('reads the Quest log again after a Pass another session beat with Try it (409), counting its dinner', async () => { + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/decline': () => { + // Another session tried it first: its dinner is in the Quest log. + server.deck = [TIKKA, ORZO, PAELLA] + server.questLog = 7 + return errorResponse(409, 'suggestion_decided') + }, + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + await screen.findByText('Quest log · 6') + + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('That side quest has been decided already.') + expect(screen.getByText('Quest log · 7')).toBeInTheDocument() + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + }) + + it('locks the next decision until the reads after a failed one land, then puts the still-pending card back on top with a Retry', async () => { + let read: ReturnType | null = null + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/accept': () => { + read = deferred() + return errorResponse(500, 'internal') + }, + [`GET ${DECK}`]: () => read?.promise ?? jsonResponse(200, { suggestions: server.deck }), + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + // The decision failed and was not saved; the deck read after it is slow. + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(2)) + const slow = read! + read = null + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + + // Deciding the next card does nothing while that read is out. + expect(screen.getByRole('button', { name: 'Pass' })).toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + fireEvent.keyDown(window, { key: 'ArrowLeft' }) + await act(async () => await new Promise((resolve) => setTimeout(resolve, 0))) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + await act(async () => slow.resolve(jsonResponse(200, { suggestions: server.deck }))) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't save that choice.") + expect(topCard()).toHaveAccessibleName('Korean beef bowls') + expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled() + + // Retry sends it again. + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s1/accept'])) + }) + + it('settles a failed decision on its own read of the deck when a batch poll reads the deck again during it', async () => { + const job = (suggestionIds: string[]) => + jsonResponse(200, { + id: 'j1', + kind: 'suggestion_batch', + status: 'running', + result: { batch_id: 'b1', stage: 'suggesting', suggestion_ids: suggestionIds, dropped: 0 }, + error: null, + }) + let poll: ReturnType | null = null + let read: ReturnType | null = null + let refuse = true + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/decline': () => { + server.deck = [TIKKA, ORZO, PAELLA] + return jsonResponse(200, { suggestion: { ...BOWLS, status: 'declined' }, replenish_job_id: 'j1' }) + }, + 'POST /api/v1/suggestions/s2/accept': () => { + if (!refuse) return accepted(TIKKA) + read = deferred() + return errorResponse(500, 'internal') + }, + 'GET /api/v1/jobs/j1': () => poll?.promise ?? job([]), + [`GET ${DECK}`]: () => read?.promise ?? jsonResponse(200, { suggestions: server.deck }), + }) + const queryClient = createQueryClient() + await renderPage(queryClient) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + // A Pass starts a replenish batch, whose job is polled. + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + await waitFor(() => expect(jobPolls(server.fetchMock)).toBe(1)) + await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()) + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + poll = deferred() + const polled = poll + await waitFor(() => expect(jobPolls(server.fetchMock)).toBe(2), { timeout: 2000 }) + + // Try it fails without being saved; the deck read it makes is slow. + const reads = deckReads(server.fetchMock) + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(reads + 1)) + const reconciling = read! + read = deferred() + const after = read + expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled() + + // The batch stores a Side quest while that read is out: its read of + // the deck waits for the decision's rather than replacing it. + const SOUP = suggestionBody('s5', 'Tortellini soup') + server.deck = [TIKKA, ORZO, PAELLA, SOUP] + await act(async () => polled.resolve(job(['s5']))) + await act(async () => await new Promise((resolve) => setTimeout(resolve, 0))) + expect(deckReads(server.fetchMock)).toBe(reads + 1) + expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled() + + // The decision's read has the Side quest still pending: the card goes + // back on top with a Retry, and the decision settles on that read. + await act(async () => reconciling.resolve(jsonResponse(200, { suggestions: server.deck }))) + await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()) + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent("Couldn't save that choice.") + expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + + // The batch's read goes out after it, and brings its Side quest. + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(reads + 2)) + read = null + await act(async () => after.resolve(jsonResponse(200, { suggestions: server.deck }))) + await waitFor(() => expect(queryClient.getQueryData<{ id: string }[]>(DECK_KEY)?.map((q) => q.id)).toEqual(['s2', 's3', 's4', 's5'])) + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + + // Retry sends it again. + refuse = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => + expect(sent(server.fetchMock)).toEqual([ + 'POST /api/v1/suggestions/s1/decline', + 'POST /api/v1/suggestions/s2/accept', + 'POST /api/v1/suggestions/s2/accept', + ]), + ) + }) + + it('does not put a failed decision back on the deck when a later read, landing before it settles, has it decided', async () => { + let log: ReturnType | null = null + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/accept': () => { + log = deferred() + return errorResponse(500, 'internal') + }, + [`GET ${QUEST_LOG}`]: () => log?.promise ?? jsonResponse(200, { dinners: [] }), + }) + const queryClient = createQueryClient() + await renderPage(queryClient) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + // The decision fails; the deck read after it still has the Side quest + // pending, and the Quest log read after it is slow. + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await waitFor(() => expect(log).not.toBeNull()) + const slow = log! + log = null + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(2)) + + // Another session decides it, and a read of the deck lands meanwhile. + server.deck = [TIKKA, ORZO, PAELLA] + await act(async () => await queryClient.refetchQueries({ queryKey: DECK_KEY })) + await act(async () => slow.resolve(jsonResponse(200, { dinners: [] }))) + + const alert = await screen.findByRole('alert') + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + expect(alert).toHaveTextContent('That side quest has been decided already.') + expect(within(alert).queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument() + }) + + it('withdraws the Retry for a failed decision once a later read of the deck has it decided', async () => { + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/accept': () => errorResponse(500, 'internal'), + }) + const queryClient = createQueryClient() + await renderPage(queryClient) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + const alert = await screen.findByRole('alert') + expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(topCard()).toHaveAccessibleName('Korean beef bowls') + + // Another session decides it, and the next read of the deck says so. + server.deck = [TIKKA, ORZO, PAELLA] + await act(async () => await queryClient.refetchQueries({ queryKey: DECK_KEY })) + await waitFor(() => expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')) + expect(screen.getByRole('alert')).toHaveTextContent('That side quest has been decided already.') + expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument() + }) + + it('reads the deck again after a 500 to a decision, rather than putting the card back', async () => { + const decline = deferred() + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/decline': () => decline.promise, + }) + await renderPage() + 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'])) + + // The decision was saved, and the answer to it is a 500 all the same. + server.deck = [TIKKA, ORZO, PAELLA] + await act(async () => decline.resolve(errorResponse(500, 'internal'))) + await screen.findByRole('alert') + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(2)) + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + }) + + it('reads the deck again once a saved decision leaves fewer than 3 cards', async () => { + const server = stubServer([BOWLS, TIKKA, ORZO], { + 'POST /api/v1/suggestions/s1/decline': () => { + server.deck = [TIKKA, ORZO, PAELLA, SOUP] + return declined(BOWLS) + }, + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + expect(deckReads(server.fetchMock)).toBe(1) + + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(2)) + await waitFor(() => + 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', + ]), + ) + }) + + it('shows skeleton cards, not "All caught up", while the replenish job a decision started runs', async () => { + const polls = [ + { id: 'j1', kind: 'suggestion_batch', status: 'running', result: { batch_id: 'b1', stage: 'suggesting', suggestion_ids: [], dropped: 0 }, error: null }, + { id: 'j1', kind: 'suggestion_batch', status: 'succeeded', result: { batch_id: 'b1', stage: 'done', suggestion_ids: ['s2'], dropped: 0 }, error: null }, + ] + const server = stubServer([BOWLS], { + 'POST /api/v1/suggestions/s1/accept': () => { + server.deck = [] + return accepted(BOWLS, 'j1') + }, + 'GET /api/v1/jobs/j1': () => { + const job = polls.shift()! + if (job.status === 'succeeded') server.deck = [TIKKA] + return jsonResponse(200, job) + }, + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + expect(await screen.findByText('Finding new side quests…')).toBeInTheDocument() + expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3) + expect(screen.getByRole('region', { name: 'Side quests loading' })).toHaveAttribute('aria-busy', 'true') + expect(screen.queryByRole('heading', { name: 'All caught up' })).not.toBeInTheDocument() + + await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' }, { timeout: 3000 }) + expect(screen.queryByTestId('skeleton-card')).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'All caught up' })).not.toBeInTheDocument() + // The decision's replenish is followed; no batch is asked for. + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']) + }) + + it('shows skeleton cards, not "All caught up", while the decision that emptied the deck is unanswered', async () => { + const accept = deferred() + const server = stubServer([BOWLS], { 'POST /api/v1/suggestions/s1/accept': () => accept.promise }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await waitFor(() => expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3)) + expect(screen.queryByRole('heading', { name: 'All caught up' })).not.toBeInTheDocument() + expect(screen.queryByText('Finding new side quests…')).not.toBeInTheDocument() + + // Saved without a replenish, the deck is caught up. + server.deck = [] + accept.resolve(accepted(BOWLS)) + await screen.findByRole('heading', { name: 'All caught up' }) + expect(screen.queryByTestId('skeleton-card')).not.toBeInTheDocument() + }) + }) + describe('motion', () => { const accepted = (quest: typeof BOWLS) => jsonResponse(200, { suggestion: { ...quest, status: 'accepted' }, replenish_job_id: null }) const declined = (quest: typeof BOWLS) => jsonResponse(200, { suggestion: { ...quest, status: 'declined' }, replenish_job_id: null }) @@ -989,15 +1652,21 @@ describe('DiscoverPage', () => { const server = stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/accept': () => (++calls === 1 ? errorResponse(500, 'internal') : retry.promise), }) - await renderPage() - const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + const queryClient = createQueryClient() + await renderPage(queryClient) + await screen.findByRole('article', { name: 'Korean beef bowls' }) fireEvent.click(screen.getByRole('button', { name: 'Try it' })) const alert = await screen.findByRole('alert') + // A read of the deck puts the refused Side quest back in the server's + // order, still pending, behind the card dragged during its Retry. + server.deck = [TIKKA, BOWLS] + await act(async () => await queryClient.invalidateQueries({ queryKey: DECK_KEY })) + const card = await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' }) press(card) drag(card, 60, 0) fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) - await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled()) + expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled() drag(card, -160, 0) expect(card.style.transform).toBe('translate(60px,0px) rotate(3.3333333333333335deg)') const started = vi.mocked(Element.prototype.animate).mock.calls.length @@ -1005,7 +1674,12 @@ describe('DiscoverPage', () => { expect(card.style.transform).toBe('') expect(within(card).getByText('Try it!').style.opacity).toBe('') expect(vi.mocked(Element.prototype.animate).mock.calls.length).toBe(started) - expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s1/accept']) + await waitFor(() => + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s1/accept']), + ) + // The retried Side quest leaves the deck while the Retry is out. + await waitFor(() => expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument()) + server.deck = [TIKKA] retry.resolve(accepted(BOWLS)) await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()) }) diff --git a/web/src/discover/DiscoverPage.tsx b/web/src/discover/DiscoverPage.tsx index 12535ec..078286b 100644 --- a/web/src/discover/DiscoverPage.tsx +++ b/web/src/discover/DiscoverPage.tsx @@ -12,11 +12,14 @@ import { BATCH_JOB_KIND, DECK_KEY, parseBatchResult, + stillPending, + useDeciding, useDeck, useDecide, useQuestLog, useStartBatch, type Choice, + type SideQuest, } from './api.ts' import { DeckCard, DECK_DEPTH, type SwipeHandlers } from './DeckCard.tsx' import { @@ -88,32 +91,44 @@ function QuestLogPill({ dinners, ref }: { dinners: { id: string; name: string }[ // DiscoverPage is mock 1a: the deck of Side quests to Try it or Pass, one // at a time from the top card. A decision from the buttons, the ← / → keys // or a swipe of the top card throws it out (motion.ts) and sends the -// decision once it is gone; Retry sends a refused one again. Input is -// locked from the press until the decision settles: the lock is a ref, set -// before anything starts, so a second press before the next render does -// nothing, and busy mirrors it to disable the buttons. +// decision once it is gone, taking the card off the deck at once (see +// useDecide); Retry sends a failed one again while the deck's newest read +// still has its Side quest pending. Input is locked from the press +// until the decision settles, which is once the deck and the Quest log have +// been read again after it, so no decision starts under those reads. The lock is a +// ref, set before anything starts, so a second press before the next render +// does nothing, and busy mirrors it to disable the buttons. 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() - const job = useJob(BATCH_JOB_KIND, startBatch.data?.job_id ?? null) + // The job of the batch last known to be finding Side quests: the one Get + // more now started or joined, or the replenish a decision started. + 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 = startBatch.isSuccess && job.status !== 'succeeded' && job.status !== 'failed' + const jobRunning = batchJobId !== null && job.status !== 'succeeded' && job.status !== 'failed' const finding = startBatch.isPending || jobRunning + const { mutate: mutateStartBatch } = startBatch + const getMore = useCallback(() => mutateStartBatch(undefined, { onSuccess: (b) => setBatchJobId(b.job_id) }), [mutateStartBatch]) // The batch's result as far as it has gone, or why it cannot be read. const batch = useMemo(() => { if (job.result === null) return { stored: 0, error: null } try { - return { stored: parseBatchResult(`GET /api/v1/jobs/${startBatch.data?.job_id}`, job.result).suggestion_ids.length, error: null } + return { stored: parseBatchResult(`GET /api/v1/jobs/${batchJobId}`, job.result).suggestion_ids.length, error: null } } catch (error) { return { stored: 0, error: error as Error } } - }, [job.result, startBatch.data?.job_id]) + }, [job.result, batchJobId]) // The job's failure, the latest poll's failure, an answer that is not the // batch's job, or an unreadable result. const batchError = job.error ?? batch.error @@ -122,8 +137,8 @@ export function DiscoverPage() { const passRef = useRef(null) const tryRef = useRef(null) const questLogRef = useRef(null) - // The top card's exit while it runs or its decision is unsettled, and the - // Side quest the last decision sent was for, until the deck moves past it. + // The top card's exit until its decision settles, and the Side quest the + // last decision sent was for, until the deck moves past it. const exit = useRef(null) const leaving = useRef(null) const drag = useRef(null) @@ -143,17 +158,22 @@ export function DiscoverPage() { setBusy(true) return true }, []) - // send records a decision. A refused one puts the card back. + // send records a decision. A failed one stops the card's exit, so a card + // useDecide put back on the deck is at rest; a saved one that started a + // replenish batch follows its job. const send = useCallback( - (variables: { id: string; choice: Choice }) => { - leaving.current = variables.id + (variables: { quest: SideQuest; choice: Choice }) => { + leaving.current = variables.quest.id mutateDecision(variables, { + onSuccess: (replenishJobId) => { + if (replenishJobId !== null) setBatchJobId(replenishJobId) + }, onError: () => { exit.current?.cancel() - exit.current = null leaving.current = null }, onSettled: () => { + exit.current = null deciding.current = false setBusy(false) }, @@ -162,7 +182,7 @@ export function DiscoverPage() { [mutateDecision], ) const submit = useCallback( - (variables: { id: string; choice: Choice }) => { + (variables: { quest: SideQuest; choice: Choice }) => { if (lock()) send(variables) }, [lock, send], @@ -207,7 +227,7 @@ export function DiscoverPage() { () => { // The Quest log pill is missing while the Quest log fails to load. if (choice === 'try' && !reduced && questLogRef.current !== null) bump(questLogRef.current) - send({ id: top.id, choice }) + send({ quest: top, choice }) }, (error: unknown) => { if (!isAbort(error)) throw error @@ -278,7 +298,6 @@ export function DiscoverPage() { useLayoutEffect(() => { if (leaving.current === null || leaving.current === topId) return leaving.current = null - exit.current = null if (topId === undefined || prefersReducedMotion()) return promote([...deckRef.current!.querySelectorAll(':scope > article')]) }, [topId]) @@ -297,17 +316,29 @@ export function DiscoverPage() { }, [decide]) // The batch stores its Side quests as it goes, so the deck is read again - // whenever it reports more, and once it ends. + // whenever it reports more, and once it ends. A read already out may be a + // decision's, which must not be cut short: the batch's read waits for it, + // then goes out after it, as it may predate what the batch reported. const { stored } = batch useEffect(() => { if (job.status === undefined) return - void queryClient.invalidateQueries({ queryKey: DECK_KEY }) + const after = queryClient.isFetching({ queryKey: DECK_KEY }) > 0 + void queryClient + .invalidateQueries({ queryKey: DECK_KEY }, { cancelRefetch: false }) + .then(() => (after ? queryClient.invalidateQueries({ queryKey: DECK_KEY }, { cancelRefetch: false }) : undefined)) }, [queryClient, job.status, stored]) + // A failed decision can be retried only while the deck's newest read + // still has its Side quest pending: anything else decided it already. + const pending = decision.isError ? stillPending(deck, decision.variables.quest.id) : undefined + const retryable = pending !== undefined && pending !== null + 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)) { + } 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 = (
@@ -325,12 +356,12 @@ export function DiscoverPage() { body = ( <> {startBatch.isError && ( - startBatch.mutate()}>Couldn't ask for more side quests. {startBatch.error.message} + Couldn't ask for more side quests. {startBatch.error.message} )} {batchError !== null && ( - startBatch.mutate()}>Couldn't find new side quests. {batchError.message} + Couldn't find new side quests. {batchError.message} )} - startBatch.mutate() }}> + New side quests arrive Sunday. @@ -339,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) => ( ))}
@@ -351,11 +382,6 @@ export function DiscoverPage() {
- {decision.isError && ( - submit(decision.variables!)}> - Couldn't save that choice. {decision.error.message} - - )} ) } @@ -373,6 +399,15 @@ export function DiscoverPage() { void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message} )} {body} + {/* A failed decision is shown whatever the deck has come to since. */} + {decision.isError && + (retryable ? ( + submit(decision.variables)}>Couldn't save that choice. {decision.error.message} + ) : pending === undefined ? ( + Couldn't save that choice. {decision.error.message} + ) : ( + That side quest has been decided already. {decision.error.message} + ))} ) } diff --git a/web/src/discover/api.ts b/web/src/discover/api.ts index 9767eb7..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,14 +8,18 @@ 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. photo_id is the -// stored photo's id, from photo_url; reason is the "Why this?". +// 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, +// from photo_url; reason is the "Why this?". export type SideQuest = { id: string + dinner_id: string name: string photo_id: string | undefined difficulty: Difficulty | null @@ -61,6 +65,7 @@ function parseSideQuest(where: string, body: unknown): SideQuest { if ( !isRecord(body) || typeof body.id !== 'string' || + typeof body.dinner_id !== 'string' || typeof body.name !== 'string' || typeof body.reason !== 'string' ) { @@ -68,6 +73,7 @@ function parseSideQuest(where: string, body: unknown): SideQuest { } return { id: body.id, + dinner_id: body.dinner_id, name: body.name, photo_id: photoId(where, body.photo_url), difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty), @@ -126,31 +132,102 @@ export function useStartBatch() { }) } +// stillPending says whether the deck's newest read has the Side quest id +// pending, after a failed decision on it: the Side quest when it does, null +// when it does not, and undefined when that read failed, which says neither. +// deck is the deck query's state, from the cache or useDeck. +export function stillPending(deck: { status: string; data: SideQuest[] | undefined } | undefined, id: string): SideQuest | null | undefined { + if (deck?.status !== 'success') return undefined + 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), then reads the deck and the -// Quest log again. The answer must be that suggestion, decided that way. +// (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 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 +// still has goes back on top of it. export function useDecide() { const { apiFetch } = useApi() const queryClient = useQueryClient() - return useMutation({ - mutationFn: async ({ id, choice }: { id: string; choice: Choice }) => { - const action = choice === 'try' ? 'accept' : 'decline' - const path = `${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}` - const body = await apiFetch(path, { method: 'POST' }) - if ( - !isRecord(body) || - !isRecord(body.suggestion) || - body.suggestion.id !== id || - body.suggestion.status !== DECISION_STATUS[choice] - ) { - throw new Error(`POST ${path}: response is not the ${DECISION_STATUS[choice]} suggestion`) + + // send POSTs the decision and checks the answer. + async function send(id: string, choice: Choice): Promise { + const action = choice === 'try' ? 'accept' : 'decline' + const path = `${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}` + const body = await apiFetch(path, { method: 'POST' }) + if ( + !isRecord(body) || + !isRecord(body.suggestion) || + body.suggestion.id !== id || + body.suggestion.status !== DECISION_STATUS[choice] + ) { + throw new Error(`POST ${path}: response is not the ${DECISION_STATUS[choice]} suggestion`) + } + if (body.replenish_job_id !== null && typeof body.replenish_job_id !== 'string') { + throw new Error(`POST ${path}: response has no replenish_job_id`) + } + return body.replenish_job_id + } + + return useMutation({ + mutationKey: DECIDE_KEY, + mutationFn: async ({ quest, choice }) => { + try { + return await send(quest.id, choice) + } catch (error) { + // The deck is read again as the decision's own fetch, replacing any + // read already out. A failed read is the deck query's error, shown + // with the deck; the decision's own error is the one thrown. + await queryClient + .getQueryCache() + .build(queryClient, { queryKey: DECK_KEY }) + .fetch(undefined, { cancelRefetch: true }) + .catch(() => undefined) + throw error } }, - onSuccess: async () => { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: DECK_KEY }), - queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }), - ]) + onMutate: ({ quest, choice }) => { + if (choice !== 'try') return + // A stale deck can offer a Side quest whose dinner the Quest log has. + queryClient.setQueryData(QUEST_LOG_KEY, (dinners) => + dinners === undefined || dinners.some((d) => d.id === quest.dinner_id) ? dinners : dinners.concat({ id: quest.dinner_id, name: quest.name }), + ) + }, + onSettled: async (_replenishJobId, error, { quest }) => { + if (error === null) { + await Promise.all([queryClient.invalidateQueries({ queryKey: DECK_KEY }), queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })]) + return + } + // A failed decision has read the deck already; any read since is newer. + await queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }) + const pending = stillPending(queryClient.getQueryState(DECK_KEY), quest.id) + if (pending === null || pending === undefined) return + queryClient.setQueryData(DECK_KEY, (deck) => [pending, ...deck!.filter((q) => q.id !== pending.id)]) }, }) } diff --git a/web/src/ui/ErrorBanner.test.tsx b/web/src/ui/ErrorBanner.test.tsx index 811b5e5..71a91f0 100644 --- a/web/src/ui/ErrorBanner.test.tsx +++ b/web/src/ui/ErrorBanner.test.tsx @@ -24,6 +24,12 @@ describe('ErrorBanner', () => { expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeInTheDocument() }) + it('has no button without onRetry', () => { + render(That was done already.) + expect(screen.getByRole('alert')).toHaveTextContent('That was done already.') + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + it('is a neutral-100 card with grape-700 text', () => { render( {}}>Offline) expect(declaredStyle(screen.getByRole('alert')).background).toBe('var(--color-neutral-100)') diff --git a/web/src/ui/ErrorBanner.tsx b/web/src/ui/ErrorBanner.tsx index 1274bf9..5537f43 100644 --- a/web/src/ui/ErrorBanner.tsx +++ b/web/src/ui/ErrorBanner.tsx @@ -6,16 +6,18 @@ import styles from './ErrorBanner.module.css' export type ErrorBannerProps = { // What went wrong, in the caller's words. children: ReactNode - onRetry: () => void + // What Retry does; without it there is nothing to retry and no button. + onRetry?: () => void retryLabel?: string } -// Muted neutral-100 card with grape-700 text and a Retry button. +// Muted neutral-100 card with grape-700 text and, given onRetry, a Retry +// button. export function ErrorBanner({ children, onRetry, retryLabel = 'Retry' }: ErrorBannerProps) { return (

{children}

- + {onRetry !== undefined && }
) }