Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 6fd607f125 Keep a Side quest off the shown deck while its decision is pending
CI / ci (push) Successful in 2m55s
CI / ci (pull_request) Successful in 2m37s
The deck shown is now the deck's newest read less the Side quests whose
decisions are in flight, taken from the pending decide mutations, instead
of removing the card from the deck cache in onMutate. A read of the deck
landing before the decision is answered can no longer bring the exited
card back; settlement still reconciles from the server read as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 03:57:53 -04:00
dev_agentandClaude Opus 5.5 701021d327 Settle a failed Discover decision on the deck's newest read
CI / ci (push) Successful in 2m56s
CI / ci (pull_request) Successful in 2m38s
A failed decision still waits for the deck read it makes itself, but no
longer carries that read's answer on its error. Whether its Side quest is
still pending is now asked of the deck query's newest successful read
(stillPending), both when the decision settles and when the page offers
Retry. A read that lands later and has the Side quest decided keeps it off
the deck and withdraws the Retry; a deck whose latest read failed offers no
Retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 03:45:51 -04:00
dev_agentandClaude Opus 5.5 8b18399710 Settle a failed Discover decision on the deck read it makes itself
CI / ci (push) Successful in 2m58s
CI / ci (pull_request) Successful in 2m39s
A failed decision now reads the deck as its own fetch and waits for it,
and that read's answer travels on the DecisionError: still pending puts
the Side quest back on top with a Retry, decided shows the informational
banner, and a failed read shows the error without Retry. The page no
longer classifies from whatever the deck cache holds.

A batch poll's deck read no longer cancels a read already out (which may
be a decision's): it waits for it, then reads again, as that read may
predate what the batch reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 03:36:19 -04:00
dev_agentandClaude Opus 5.5 f26cfd5129 Reconcile every Discover decision from the server before the next
CI / ci (push) Successful in 2m54s
CI / ci (pull_request) Successful in 2m38s
Replace the hand-maintained optimistic bookkeeping in useDecide with one
rule: a decision takes its card off the deck (and, on Try it, adds its
dinner to the Quest log) when the exit finishes; however it settles, the
deck and the Quest log are read again and the decision stays pending
until those reads land, so the input lock keeps the next decision out.
No decision cancels a read.

A failed decision whose Side quest the reconciled deck still has goes
back on top with a Retry; otherwise the banner says it was decided
already, with no Retry. ErrorBanner's Retry is now optional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 03:21:54 -04:00
dev_agentandClaude Opus 5.5 3319434bd1 Reconcile the deck after a decision failure that may not leave the card pending
CI / ci (push) Successful in 2m55s
CI / ci (pull_request) Successful in 2m39s
Only an API error that refuses the request itself (a 4xx other than 404
or 409) puts a failed Side quest back on top of the deck. A 409 (decided
by another session or no longer suggested), a 404, a 5xx or a failure
without an answer now reads the deck again instead, keeping the card off
it until that read answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 03:10:39 -04:00
dev_agentandClaude Opus 5.5 1c59e5d9d7 Keep a decided Side quest off the deck after an uncertain decision failure
CI / ci (push) Successful in 2m57s
CI / ci (pull_request) Successful in 2m38s
A decision now follows the reads of the deck that land while it is in
flight. When it fails, the Side quest is put back on top of the deck only
if no such read has left it out, so a card the server already decided is
not offered again.

A failure that is not an API error answer, such as a lost response, may
have come after the server saved the decision, so the deck is read again
to reconcile it. An API error answer still puts the card back on top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 03:02:04 -04:00
dev_agentandClaude Opus 5.5 0ea1c218ea Roll back only the decision's own Quest log entry and send stale-card decisions
CI / ci (push) Failing after 1m55s
CI / ci (pull_request) Successful in 2m43s
A refused Try it now removes the Quest log entry it tagged when it added
it, so a read of the Quest log that landed during the decision is kept,
and reads the Quest log again since the decision may have been saved.

A decision is sent for the card thrown out even when a read of the deck
took it off during the exit, so Retry on that refusal posts the decision
again instead of failing before sending it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:53:09 -04:00
dev_agentandClaude Opus 5.5 6c105416a9 Keep decided Side quests off the deck and show refused decisions
CI / ci (push) Successful in 2m56s
CI / ci (pull_request) Successful in 2m38s
A saved decision now reads the deck again whenever a read started before
the save is still under way, so a late answer can no longer put the
decided Side quest back. Optimistic Try it adds its dinner to the Quest
log only when it is not there yet, and a refusal removes only that
addition. A refused decision's ErrorBanner with Retry now shows whatever
the deck has come to, including when a read emptied it during the exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:43:06 -04:00
dev_agentandClaude Opus 5.5 74611da62b Apply Discover decisions optimistically and refill the deck
CI / ci (push) Successful in 2m54s
CI / ci (pull_request) Successful in 2m38s
Try it and Pass are applied when the card's exit finishes: the Side
quest leaves the deck and, on Try it, its dinner joins the Quest log
count before the server answers. A refused decision puts the card back
on top of the deck, reverts the count and shows the ErrorBanner with
Retry. A saved decision reads the deck again once fewer than 3 cards are
left, and follows the replenish job it started, showing skeleton cards
rather than "All caught up" while that job runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:31:55 -04:00
5 changed files with 862 additions and 68 deletions
+686 -12
View File
@@ -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<Response>
// 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<string, Responder> = {}, questLog = 6) {
const server = { deck, fetchMock: undefined as unknown as Mock<typeof fetch> }
const server = { deck, questLog, fetchMock: undefined as unknown as Mock<typeof fetch> }
const all: Record<string, Responder> = {
'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<typeof fetch>((input, init) => {
@@ -70,6 +71,11 @@ function sent(fetchMock: Mock<typeof fetch>) {
.map(([input, init]) => `${init?.method} ${String(input)}`)
}
// deckReads counts the GET /api/v1/suggestions/deck requests.
function deckReads(fetchMock: Mock<typeof fetch>) {
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<typeof fetch>) {
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(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<QueryClientProvider client={queryClient}>
<DiscoverPage />
</QueryClientProvider>
</ApiProvider>,
@@ -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<typeof deferred> | 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<Response>((_, reject) => (lose = reject))
let late: ReturnType<typeof deferred> | 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<typeof deferred> | 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<typeof deferred> | 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<typeof deferred> | null = null
let read: ReturnType<typeof deferred> | 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<typeof deferred> | 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())
})
+65 -30
View File
@@ -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<unknown>(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<string | null>(null)
const job = useJob<unknown>(BATCH_JOB_KIND, batchJobId)
const top = deck.data?.[0]
const top = cards?.[0]
const [busy, setBusy] = useState(false)
const jobRunning = 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<HTMLButtonElement>(null)
const tryRef = useRef<HTMLButtonElement>(null)
const questLogRef = useRef<HTMLSpanElement>(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<Exit | null>(null)
const leaving = useRef<string | null>(null)
const drag = useRef<Drag | null>(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<HTMLElement>(':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 = <ErrorBanner onRetry={() => void deck.refetch()}>Couldn't load your side quests. {deck.error.message}</ErrorBanner>
} 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 = (
<section aria-busy="true" aria-label="Side quests loading" className={styles.loading}>
<div className={styles.deck}>
@@ -325,12 +356,12 @@ export function DiscoverPage() {
body = (
<>
{startBatch.isError && (
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't ask for more side quests. {startBatch.error.message}</ErrorBanner>
<ErrorBanner onRetry={getMore}>Couldn't ask for more side quests. {startBatch.error.message}</ErrorBanner>
)}
{batchError !== null && (
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't find new side quests. {batchError.message}</ErrorBanner>
<ErrorBanner onRetry={getMore}>Couldn't find new side quests. {batchError.message}</ErrorBanner>
)}
<EmptyState title="All caught up" action={{ label: 'Get more now', onClick: () => startBatch.mutate() }}>
<EmptyState title="All caught up" action={{ label: 'Get more now', onClick: getMore }}>
New side quests arrive Sunday.
</EmptyState>
</>
@@ -339,7 +370,7 @@ export function DiscoverPage() {
body = (
<>
<div ref={deckRef} className={styles.deck}>
{deck.data.slice(0, DECK_DEPTH.length).map((quest, depth) => (
{cards!.slice(0, DECK_DEPTH.length).map((quest, depth) => (
<DeckCard key={quest.id} quest={quest} depth={depth} swipe={depth === 0 ? swipe : undefined} />
))}
</div>
@@ -351,11 +382,6 @@ export function DiscoverPage() {
<Icon icon={Heart} size="nav" />
</IconButton>
</div>
{decision.isError && (
<ErrorBanner onRetry={() => submit(decision.variables!)}>
Couldn't save that choice. {decision.error.message}
</ErrorBanner>
)}
</>
)
}
@@ -373,6 +399,15 @@ export function DiscoverPage() {
<ErrorBanner onRetry={() => void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message}</ErrorBanner>
)}
{body}
{/* A failed decision is shown whatever the deck has come to since. */}
{decision.isError &&
(retryable ? (
<ErrorBanner onRetry={() => submit(decision.variables)}>Couldn't save that choice. {decision.error.message}</ErrorBanner>
) : pending === undefined ? (
<ErrorBanner>Couldn't save that choice. {decision.error.message}</ErrorBanner>
) : (
<ErrorBanner>That side quest has been decided already. {decision.error.message}</ErrorBanner>
))}
</div>
)
}
+100 -23
View File
@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { DIFFICULTIES, DINNERS_PATH, oneOf, photoId, type Difficulty } from '../dinners/api.ts'
@@ -8,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<string> {
const ids = useMutationState<string>({
filters: { mutationKey: DECIDE_KEY, status: 'pending' },
select: (mutation) => (mutation.state.variables as { quest: SideQuest }).quest.id,
})
return new Set(ids)
}
// useDecide records Try it (POST /api/v1/suggestions/{id}/accept) or Pass
// (POST /api/v1/suggestions/{id}/decline), 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<unknown>(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<string | null> {
const action = choice === 'try' ? 'accept' : 'decline'
const path = `${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}`
const body = await apiFetch<unknown>(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<string | null, Error, { quest: SideQuest; choice: Choice }>({
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<SideQuest[]>(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<QuestLogDinner[]>(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<SideQuest[]>(DECK_KEY), quest.id)
if (pending === null || pending === undefined) return
queryClient.setQueryData<SideQuest[]>(DECK_KEY, (deck) => [pending, ...deck!.filter((q) => q.id !== pending.id)])
},
})
}
+6
View File
@@ -24,6 +24,12 @@ describe('ErrorBanner', () => {
expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeInTheDocument()
})
it('has no button without onRetry', () => {
render(<ErrorBanner>That was done already.</ErrorBanner>)
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(<ErrorBanner onRetry={() => {}}>Offline</ErrorBanner>)
expect(declaredStyle(screen.getByRole('alert')).background).toBe('var(--color-neutral-100)')
+5 -3
View File
@@ -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 (
<Card tone="muted" role="alert" className={styles.banner}>
<p className={styles.message}>{children}</p>
<Button onClick={onRetry}>{retryLabel}</Button>
{onRetry !== undefined && <Button onClick={onRetry}>{retryLabel}</Button>}
</Card>
)
}