diff --git a/web/src/discover/motion.test.ts b/web/src/discover/motion.test.ts
index 34baaf9..2352f08 100644
--- a/web/src/discover/motion.test.ts
+++ b/web/src/discover/motion.test.ts
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { stubAnimate, type FakeAnimation } from '../test/animate.ts'
-import { bump, fadeOut, isAbort, pass, promote, tryIt } from './motion.ts'
+import { bump, dragTransform, fadeOut, follow, isAbort, pass, promote, snapBack, tryIt, unfollow } from './motion.ts'
let animations: FakeAnimation[]
@@ -15,10 +15,11 @@ afterEach(() => {
// card builds a deck card with the parts DeckCard marks.
function card() {
const article = document.createElement('article')
- article.innerHTML = ''
+ article.innerHTML = ''
return {
article,
- stamp: article.querySelector('[data-part="try-stamp"]')!,
+ stamp: article.querySelector('[data-part="try-stamp"]')!,
+ passStamp: article.querySelector('[data-part="pass-stamp"]')!,
shade: article.querySelector('[data-part="shade"]')!,
}
}
@@ -169,8 +170,52 @@ describe('motion', () => {
expect(animations.map((a) => a.playState)).toEqual(['idle', 'idle'])
})
+ it('drags: translate(dx, dy·0.3) rotate(dx/18 deg)', () => {
+ expect(dragTransform(90, 20)).toBe('translate(90px,6px) rotate(5deg)')
+ expect(dragTransform(-36, -10)).toBe('translate(-36px,-3px) rotate(-2deg)')
+ })
+
+ it.each([
+ [50, '0.5', '0'],
+ [-50, '0', '0.5'],
+ [160, '1', '0'],
+ [-160, '0', '1'],
+ [0, '0', '0'],
+ ])('follows a drag of %i px with the stamps at Try it! %s, Pass %s', (dx, tryOpacity, passOpacity) => {
+ const { article, stamp, passStamp } = card()
+ expect(follow(article, dx, 0)).toBe(dragTransform(dx, 0))
+ expect(article.style.transform).toBe(dragTransform(dx, 0))
+ expect(stamp.style.opacity).toBe(tryOpacity)
+ expect(passStamp.style.opacity).toBe(passOpacity)
+ expect(animations).toEqual([])
+ })
+
+ it('unfollows at once, back to the stylesheet', () => {
+ const { article, stamp, passStamp } = card()
+ follow(article, -80, 12)
+ unfollow(article)
+ expect(article.getAttribute('style')).toBe('')
+ expect(stamp.getAttribute('style')).toBe('')
+ expect(passStamp.getAttribute('style')).toBe('')
+ expect(animations).toEqual([])
+ })
+
+ it('snaps back from the drag transform to rest, 420 ms spring, and hides the stamps', () => {
+ const { article, stamp, passStamp } = card()
+ const from = follow(article, 70, 30)
+ snapBack(article, from)
+ expect(article.style.transform).toBe('')
+ expect(stamp.style.opacity).toBe('')
+ expect(passStamp.style.opacity).toBe('')
+ expect(animations).toHaveLength(1)
+ expect(animations[0]!.target).toBe(article)
+ expect(animations[0]!.keyframes).toEqual([{ transform: 'translate(70px,9px) rotate(3.888888888888889deg)' }, { transform: 'translate(0px,0px) rotate(0deg)' }])
+ expect(animations[0]!.options).toEqual({ duration: 420, easing: 'cubic-bezier(.34,1.4,.64,1)' })
+ })
+
it('fails fast on a card without its parts', () => {
expect(() => tryIt(document.createElement('article'))).toThrow('deck card has no try-stamp')
expect(() => pass(document.createElement('article'))).toThrow('deck card has no shade')
+ expect(() => follow(document.createElement('article'), 10, 0)).toThrow('deck card has no try-stamp')
})
})
diff --git a/web/src/discover/motion.ts b/web/src/discover/motion.ts
index a144ae0..b620edb 100644
--- a/web/src/discover/motion.ts
+++ b/web/src/discover/motion.ts
@@ -1,7 +1,7 @@
// The deck's motion, from the handoff's Motion §2 (Try it), §3 (Pass),
-// §5 (deck advance) and §6 (reduced motion), with the Web Animations API.
-// The exits take the card's starting transform so the swipe gesture can
-// throw a dragged card from where it was let go.
+// §4 (swipe), §5 (deck advance) and §6 (reduced motion), with the Web
+// Animations API. The exits take the card's starting transform so the swipe
+// gesture can throw a dragged card from where it was let go.
const SPRING = 'cubic-bezier(.34,1.4,.64,1)'
const THROW = 'cubic-bezier(.55,0,.8,.4)'
@@ -93,6 +93,38 @@ export function pass(card: HTMLElement, from: string = REST): Exit {
return exit.done(finished)
}
+// dragTransform is the top card's transform dragged dx, dy from where the
+// pointer went down.
+export function dragTransform(dx: number, dy: number): string {
+ return `translate(${dx}px,${dy * 0.3}px) rotate(${dx / 18}deg)`
+}
+
+// follow moves a dragged card to its drag transform and shows each stamp as
+// far as the drag has gone toward its side, fully at 100 px. It returns the
+// transform, for the exit or snap-back the release starts.
+export function follow(card: HTMLElement, dx: number, dy: number): string {
+ const transform = dragTransform(dx, dy)
+ card.style.transform = transform
+ part(card, 'try-stamp').style.opacity = String(clamp(dx / 100))
+ part(card, 'pass-stamp').style.opacity = String(clamp(-dx / 100))
+ return transform
+}
+
+// unfollow puts a dragged card and its stamps back where the stylesheet
+// places them, at once.
+export function unfollow(card: HTMLElement) {
+ card.style.removeProperty('transform')
+ part(card, 'try-stamp').style.removeProperty('opacity')
+ part(card, 'pass-stamp').style.removeProperty('opacity')
+}
+
+// snapBack springs a card let go short of a decision back to rest from its
+// drag transform, and hides its stamps.
+export function snapBack(card: HTMLElement, from: string): Animation {
+ unfollow(card)
+ return card.animate([{ transform: from }, { transform: REST }], { duration: 420, easing: SPRING })
+}
+
// fadeOut is both exits under prefers-reduced-motion: reduce.
export function fadeOut(card: HTMLElement): Exit {
const exit = recorder()
@@ -110,6 +142,10 @@ export function promote(cards: HTMLElement[]): Animation[] {
)
}
+function clamp(value: number): number {
+ return Math.min(1, Math.max(0, value))
+}
+
// part is the card's element marked data-part="name", which DeckCard renders.
function part(card: HTMLElement, name: string): HTMLElement {
const element = card.querySelector(`[data-part="${name}"]`)
--
2.55.0
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 6/9] 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 = (
<>
@@ -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}
- {retryLabel}
+ {onRetry !== undefined && {retryLabel}}
)
}
--
2.55.0
From 721a79bce9d8d17fcff04e6f5777a8b382a0f010 Mon Sep 17 00:00:00 2001
From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
Date: Wed, 7 Oct 2026 04:45:56 -0400
Subject: [PATCH 7/9] Quest log sheet on Discover (#109)
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
---
docs/ARCHITECTURE.md | 4 +-
internal/db/dinners.sql.go | 57 ++++
internal/db/querier.go | 3 +
internal/dinners/dinners.go | 37 ++-
internal/server/dinners_test.go | 61 +++++
migrations/queries/dinners.sql | 13 +
web/src/dinners/api.ts | 5 +-
web/src/discover/DeckCard.tsx | 2 +-
web/src/discover/DiscoverPage.module.css | 20 --
web/src/discover/DiscoverPage.test.tsx | 312 +++++++++++++++++++---
web/src/discover/DiscoverPage.tsx | 31 +--
web/src/discover/QuestLogSheet.module.css | 38 +++
web/src/discover/QuestLogSheet.tsx | 147 ++++++++++
web/src/discover/api.ts | 45 +++-
web/src/settings/LeaveGuard.tsx | 25 +-
15 files changed, 703 insertions(+), 97 deletions(-)
create mode 100644 web/src/discover/QuestLogSheet.module.css
create mode 100644 web/src/discover/QuestLogSheet.tsx
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 3227230..d50ac13 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -194,7 +194,9 @@ automatic** (D-021).
`GET /api/v1/dinners?status=regular|quest_log` returns
`{dinners: [...]}` ordered by normalized name, each with `photo_url`
(`/media/photos/{photo_id}-400.jpg`, or null) and `grounding_status`;
- any other status is 400. `PATCH /api/v1/dinners/{id}` edits `name`
+ any other status is 400. The Quest log (`quest_log`) is the dinners
+ accepted and not yet scheduled: one on the plan for the household's
+ current week (by its timezone) or a later week is left out. `PATCH /api/v1/dinners/{id}` edits `name`
(deduplicated against the other unarchived dinners), `method`
(`stovetop` / `oven` / `crockpot` / `grill` / `other`), `minutes_total`
(1–1440) and `difficulty` (`easy` / `medium` / `hard`); invalid values
diff --git a/internal/db/dinners.sql.go b/internal/db/dinners.sql.go
index 74c929d..e8bb637 100644
--- a/internal/db/dinners.sql.go
+++ b/internal/db/dinners.sql.go
@@ -501,6 +501,63 @@ func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDi
return items, nil
}
+const listUnplannedQuestLogDinners = `-- name: ListUnplannedQuestLogDinners :many
+SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
+WHERE status = 'quest_log'
+ AND NOT EXISTS (
+ SELECT 1 FROM plan_days
+ JOIN week_plans ON week_plans.id = plan_days.plan_id
+ WHERE plan_days.dinner_id = dinners.id
+ AND week_plans.week_start >= ?1
+ )
+ORDER BY normalized_name, id
+`
+
+// The Quest log: dinners with status quest_log that no plan for the week
+// starting from_week_start or later has on any day.
+func (q *Queries) ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStart string) ([]Dinner, error) {
+ rows, err := q.db.QueryContext(ctx, listUnplannedQuestLogDinners, fromWeekStart)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []Dinner
+ for rows.Next() {
+ var i Dinner
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.NormalizedName,
+ &i.Status,
+ &i.Origin,
+ &i.Method,
+ &i.MinutesTotal,
+ &i.Difficulty,
+ &i.Cuisine,
+ &i.IsFavorite,
+ &i.RecipeID,
+ &i.PhotoID,
+ &i.GroundingStatus,
+ &i.UpdatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.RecipeUrl,
+ &i.GroundingError,
+ &i.GroundingRevision,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const setDinnerFavorite = `-- name: SetDinnerFavorite :one
UPDATE dinners
SET is_favorite = ?,
diff --git a/internal/db/querier.go b/internal/db/querier.go
index b6094eb..4765334 100644
--- a/internal/db/querier.go
+++ b/internal/db/querier.go
@@ -124,6 +124,9 @@ type Querier interface {
// Every dinner a new or renamed dinner must not repeat, with the
// source_url of the recipe it is grounded in (NULL while it has none).
ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error)
+ // The Quest log: dinners with status quest_log that no plan for the week
+ // starting from_week_start or later has on any day.
+ ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStart string) ([]Dinner, error)
// Moves a dinner still suggested to status (quest_log or archived),
// recording updated_by; a dinner no longer suggested is left as it is
// (0 rows).
diff --git a/internal/dinners/dinners.go b/internal/dinners/dinners.go
index ae2a20b..f68c771 100644
--- a/internal/dinners/dinners.go
+++ b/internal/dinners/dinners.go
@@ -16,10 +16,12 @@ import (
"slices"
"strings"
"sync"
+ "time"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
+ "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -273,12 +275,20 @@ func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy str
}
// List returns the dinners with status, which must be regular or
-// quest_log, ordered by normalized name.
+// quest_log, ordered by normalized name. The Quest log is the dinners
+// accepted and not yet scheduled, so a quest_log dinner on the plan for the
+// household's current week or a later one is left out of it.
func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
if !slices.Contains(listable, status) {
return nil, invalid("status", "must be one of "+strings.Join(listable, ", "))
}
- rows, err := db.New(s.st.Reader).ListDinnersByStatus(ctx, status)
+ var rows []db.Dinner
+ var err error
+ if status == StatusQuestLog {
+ rows, err = s.listQuestLog(ctx)
+ } else {
+ rows, err = db.New(s.st.Reader).ListDinnersByStatus(ctx, status)
+ }
if err != nil {
return nil, fmt.Errorf("dinners: list %s: %w", status, err)
}
@@ -289,6 +299,29 @@ func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
return out, nil
}
+// listQuestLog reads the Quest log in one read transaction, so the
+// household's current week and the plans come from the same snapshot.
+func (s *Service) listQuestLog(ctx context.Context) (rows []db.Dinner, err error) {
+ tx, err := s.st.Reader.BeginTx(ctx, nil)
+ if err != nil {
+ return nil, fmt.Errorf("begin read: %w", err)
+ }
+ defer func() {
+ err = errors.Join(err, tx.Rollback())
+ }()
+ q := db.New(tx)
+ settings, err := q.GetHouseholdSettings(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("read household settings: %w", err)
+ }
+ loc, err := household.LoadZone(settings.Timezone)
+ if err != nil {
+ return nil, fmt.Errorf("household settings: %w", err)
+ }
+ thisWeek := household.WeekStart(household.Today(time.Now(), loc))
+ return q.ListUnplannedQuestLogDinners(ctx, thisWeek.String())
+}
+
// Update applies p to dinner id, recording updatedBy. An invalid patch is a
// *ValidationError and a new name repeating another unarchived dinner a
// *DuplicateError; either changes nothing. Renaming a dinner still pending
diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go
index a8c312f..2d7861b 100644
--- a/internal/server/dinners_test.go
+++ b/internal/server/dinners_test.go
@@ -11,9 +11,11 @@ import (
"sync"
"testing"
"testing/fstest"
+ "time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
+ "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
@@ -66,6 +68,10 @@ func (b *logBuffer) String() string {
func newDinnersEnv(t *testing.T) *dinnersEnv {
t.Helper()
st := newTestStore(t)
+ // The Quest log is read against the household's current week.
+ if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
+ t.Fatal(err)
+ }
e := &dinnersEnv{
st: st,
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
@@ -414,6 +420,61 @@ func TestListDinnersFiltersByStatus(t *testing.T) {
}
}
+// The Quest log leaves out a dinner on the plan for the household's current
+// week or a later one: it is scheduled already. A dinner on an earlier
+// week's plan only, or on none, is listed.
+func TestQuestLogLeavesOutPlannedDinners(t *testing.T) {
+ t.Parallel()
+ router, st := newDinnersRouter(t)
+ ctx := context.Background()
+ names := []string{"Bibimbap", "Gumbo", "Pho", "Ramen", "Tagine"}
+ ids := map[string]string{}
+ for _, name := range names {
+ id := createDinner(t, router, `{"name":"`+name+`"}`)
+ if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, id); err != nil {
+ t.Fatal(err)
+ }
+ ids[name] = id
+ }
+
+ loc, err := time.LoadLocation("America/New_York")
+ if err != nil {
+ t.Fatal(err)
+ }
+ thisWeek := household.WeekStart(household.Today(time.Now(), loc))
+ // day is the date days after the Monday weeks after this week's.
+ day := func(weeks, days int) string {
+ y, m, d := thisWeek.Date()
+ return time.Date(y, m, d+7*weeks+days, 0, 0, 0, 0, time.UTC).Format(time.DateOnly)
+ }
+ // Gumbo is on last week's plan only; Pho on this week's; Ramen on the
+ // week after next; Tagine on last week's and this week's.
+ plan := func(weeks int, dinners ...string) {
+ t.Helper()
+ weekStart := day(weeks, 0)
+ planID := "plan-" + weekStart
+ if _, err := st.Writer.ExecContext(ctx,
+ `INSERT INTO week_plans (id, week_start, status, created_at, updated_at) VALUES (?, ?, 'draft', 't', 't')`,
+ planID, weekStart); err != nil {
+ t.Fatal(err)
+ }
+ for i, name := range dinners {
+ if _, err := st.Writer.ExecContext(ctx,
+ `INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at) VALUES (?, ?, ?, ?, 1, 't', 't')`,
+ planID+"-"+name, planID, day(weeks, i), ids[name]); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ plan(-1, "Gumbo", "Tagine")
+ plan(0, "Pho", "Tagine")
+ plan(2, "Ramen")
+
+ if got := strings.Join(listNames(t, router, "quest_log"), ","); got != "Bibimbap,Gumbo" {
+ t.Errorf("quest_log = %s, want Bibimbap,Gumbo", got)
+ }
+}
+
func TestPatchDinner(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
diff --git a/migrations/queries/dinners.sql b/migrations/queries/dinners.sql
index 43e96ae..ecd95bd 100644
--- a/migrations/queries/dinners.sql
+++ b/migrations/queries/dinners.sql
@@ -19,6 +19,19 @@ SELECT * FROM dinners
WHERE status = ?
ORDER BY normalized_name, id;
+-- name: ListUnplannedQuestLogDinners :many
+-- The Quest log: dinners with status quest_log that no plan for the week
+-- starting from_week_start or later has on any day.
+SELECT * FROM dinners
+WHERE status = 'quest_log'
+ AND NOT EXISTS (
+ SELECT 1 FROM plan_days
+ JOIN week_plans ON week_plans.id = plan_days.plan_id
+ WHERE plan_days.dinner_id = dinners.id
+ AND week_plans.week_start >= sqlc.arg(from_week_start)
+ )
+ORDER BY normalized_name, id;
+
-- name: CountRegularDinners :one
-- How many Regulars the household has; archived dinners are not counted.
SELECT COUNT(*) FROM dinners
diff --git a/web/src/dinners/api.ts b/web/src/dinners/api.ts
index dc5a556..06e040c 100644
--- a/web/src/dinners/api.ts
+++ b/web/src/dinners/api.ts
@@ -106,7 +106,8 @@ export function photoId(where: string, value: unknown): string | undefined {
return decodeURIComponent(match[1])
}
-function parseDinner(where: string, body: unknown): Regular {
+// parseDinner checks a dinner as the dinners API sends it.
+export function parseDinner(where: string, body: unknown): Regular {
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
throw new Error(`${where}: response is not a dinner`)
}
@@ -141,7 +142,7 @@ function parseCandidate(where: string, body: unknown): RecipeCandidate {
}
}
-function dinnerPath(id: string): string {
+export function dinnerPath(id: string): string {
return `${DINNERS_PATH}/${encodeURIComponent(id)}`
}
diff --git a/web/src/discover/DeckCard.tsx b/web/src/discover/DeckCard.tsx
index 9c4f119..26758be 100644
--- a/web/src/discover/DeckCard.tsx
+++ b/web/src/discover/DeckCard.tsx
@@ -7,7 +7,7 @@ import styles from './DiscoverPage.module.css'
// DECK_DEPTH is the depths the deck shows, top card first.
export const DECK_DEPTH = [0, 1, 2] as const
-const DIFFICULTY_LABEL: Record = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
+export const DIFFICULTY_LABEL: Record = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
// SwipeHandlers are the pointer handlers that drag the top card.
export type SwipeHandlers = Pick<
diff --git a/web/src/discover/DiscoverPage.module.css b/web/src/discover/DiscoverPage.module.css
index a8a98d4..ae35eb2 100644
--- a/web/src/discover/DiscoverPage.module.css
+++ b/web/src/discover/DiscoverPage.module.css
@@ -59,26 +59,6 @@
font-weight: 700;
}
-/* The Quest log's dinner names, under the header while the pill is open. */
-.questLogList {
- flex: 1 0 100%;
- font-family: var(--font-body);
- font-size: 14px;
-}
-
-.questLogNames {
- margin: 0;
- padding-left: 20px;
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.questLogEmpty {
- margin: 0;
- color: var(--color-neutral-700);
-}
-
/* The deck: three cards stacked in the same box, the top one in front. */
.deck {
position: relative;
diff --git a/web/src/discover/DiscoverPage.test.tsx b/web/src/discover/DiscoverPage.test.tsx
index 66d941c..f1f73f0 100644
--- a/web/src/discover/DiscoverPage.test.tsx
+++ b/web/src/discover/DiscoverPage.test.tsx
@@ -1,10 +1,12 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
-import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
+import { createMemoryRouter, RouterProvider, useParams } from 'react-router'
+import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { stubAnimate, type FakeAnimation } from '../test/animate.ts'
import { declaredStyle } from '../test/cssRules.ts'
+import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts'
@@ -14,6 +16,12 @@ import { DiscoverPage } from './DiscoverPage.tsx'
const DECK = '/api/v1/suggestions/deck'
const QUEST_LOG = '/api/v1/dinners?status=quest_log'
+// questLogBody is a Quest-log dinner as GET /api/v1/dinners?status=quest_log
+// sends it.
+function questLogBody(id: string, name: string, overrides: Partial = {}) {
+ return dinnerBody(id, name, { status: 'quest_log', origin: 'side_quest', ...overrides })
+}
+
// SuggestionBody is a suggestion as GET /api/v1/suggestions/deck sends it.
function suggestionBody(id: string, name: string, overrides: Record = {}) {
return {
@@ -51,7 +59,7 @@ function stubServer(deck: unknown[], responders: Record = {},
'GET /api/v1/me': () => jsonResponse(200, ME),
[`GET ${DECK}`]: () => jsonResponse(200, { suggestions: server.deck }),
[`GET ${QUEST_LOG}`]: () =>
- jsonResponse(200, { dinners: Array.from({ length: server.questLog }, (_, i) => ({ id: `q${i}`, name: `Quest ${i}` })) }),
+ jsonResponse(200, { dinners: Array.from({ length: server.questLog }, (_, i) => questLogBody(`q${i}`, `Quest ${i}`)) }),
...responders,
}
server.fetchMock = vi.fn((input, init) => {
@@ -102,15 +110,27 @@ async function finishRunning(animations: FakeAnimation[]) {
})
}
+// DinnerProbe stands in for the dinner screen, showing the id it opened.
+function DinnerProbe() {
+ return
Dinner screen for {useParams().id}
+}
+
// renderPage loads /me (for the CSRF token every write needs) and renders
-// the Discover page with queryClient.
+// the Discover page with queryClient at /discover, with /dinner/:id beside it.
async function renderPage(queryClient = createQueryClient()) {
const api = createApiClient(fakeLocation('/discover'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
+ const router = createMemoryRouter(
+ [
+ { path: '/discover', element: },
+ { path: '/dinner/:id', element: },
+ ],
+ { initialEntries: ['/discover'] },
+ )
return render(
-
+ ,
)
@@ -359,34 +379,6 @@ describe('DiscoverPage', () => {
await screen.findByRole('article', { name: 'Korean beef bowls' })
})
- it('opens and closes the Quest log from its pill, a keyboard-reachable button', async () => {
- stubServer([BOWLS], {}, 2)
- await renderPage()
- const pill = await screen.findByRole('button', { name: 'Quest log · 2' })
- expect(pill).toHaveAttribute('aria-expanded', 'false')
- expect(screen.queryByRole('list', { name: 'Quest log' })).not.toBeInTheDocument()
- expectMinHitArea(pill)
- pill.focus()
- expect(pill).toHaveFocus()
-
- fireEvent.click(pill)
- expect(pill).toHaveAttribute('aria-expanded', 'true')
- const list = screen.getByRole('list', { name: 'Quest log' })
- expect(pill).toHaveAttribute('aria-controls', list.parentElement!.id)
- expect(within(list).getAllByRole('listitem').map((item) => item.textContent)).toEqual(['Quest 0', 'Quest 1'])
-
- fireEvent.click(pill)
- expect(pill).toHaveAttribute('aria-expanded', 'false')
- expect(screen.queryByRole('list', { name: 'Quest log' })).not.toBeInTheDocument()
- })
-
- it('says so when the Quest log it opens is empty', async () => {
- stubServer([BOWLS], {}, 0)
- await renderPage()
- fireEvent.click(await screen.findByRole('button', { name: 'Quest log · 0' }))
- expect(screen.getByText('No dinners in the Quest log yet. Try it on a Side quest to add one.')).toBeInTheDocument()
- })
-
it('sends one decision for presses made before the page re-renders', async () => {
const accept = deferred()
const server = stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/accept': () => accept.promise })
@@ -511,6 +503,254 @@ describe('DiscoverPage', () => {
expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument()
})
+ describe('Quest log sheet', () => {
+ const PAD_THAI = questLogBody('q1', 'Pad thai', { photo_url: '/media/photos/p9-400.jpg', difficulty: 'medium', minutes_total: 35 })
+ const GUMBO = questLogBody('q2', 'Shrimp gumbo', { difficulty: 'hard', minutes_total: null })
+ const RISOTTO = questLogBody('q3', 'Mushroom risotto')
+
+ // stubQuestLog serves the deck and server.dinners as the Quest log.
+ function stubQuestLog(dinners: unknown[], responders: Record = {}) {
+ const questLog = { dinners }
+ const server = stubServer([BOWLS], { [`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: questLog.dinners }), ...responders })
+ return Object.assign(server, { questLogDinners: questLog })
+ }
+
+ async function openSheet(count: number) {
+ fireEvent.click(await screen.findByRole('button', { name: `Quest log · ${count}` }))
+ return screen.getByRole('dialog', { name: 'Quest log' })
+ }
+
+ function rows(sheet: HTMLElement) {
+ return within(within(sheet).getByRole('list', { name: 'Quest log' })).getAllByRole('listitem')
+ }
+
+ // names is the dinners the sheet lists, read off their remove buttons.
+ function names(sheet: HTMLElement) {
+ return rows(sheet).map((row) => within(row).getByRole('button', { name: /^Remove / }).getAttribute('aria-label')!.slice('Remove '.length))
+ }
+
+ it('opens from the "Quest log · N" pill and lists each dinner: 48 px washed photo, name, difficulty and minutes', async () => {
+ stubQuestLog([PAD_THAI, GUMBO, RISOTTO])
+ await renderPage()
+ const pill = await screen.findByRole('button', { name: 'Quest log · 3' })
+ expect(pill).toHaveAttribute('aria-haspopup', 'dialog')
+ expect(pill).toHaveAttribute('aria-expanded', 'false')
+ expectMinHitArea(pill)
+ pill.focus()
+
+ fireEvent.click(pill)
+ const sheet = screen.getByRole('dialog', { name: 'Quest log' })
+ expect(pill).toHaveAttribute('aria-expanded', 'true')
+ const [padThai, gumbo, risotto] = rows(sheet)
+ expect(names(sheet)).toEqual(['Pad thai', 'Shrimp gumbo', 'Mushroom risotto'])
+ expect(within(padThai!).getByText('Medium · 35 min')).toBeInTheDocument()
+ expect(within(gumbo!).getByText('Hard')).toBeInTheDocument()
+ // Nothing known, no meta line.
+ expect(within(risotto!).getByRole('button', { name: /^Mushroom risotto/ })).toHaveTextContent(/^dish photoMushroom risotto$/)
+
+ // The photo is the stored one, through the washed treatment, at 48 px.
+ const photo = padThai!.querySelector('img')!
+ expect(photo).toHaveAttribute('src', '/media/photos/p9-400.jpg')
+ expect(declaredStyle(photo).filter).toBe('saturate(0.6) contrast(0.85) brightness(1.1) opacity(0.94)')
+ expect(declaredStyle(photo).width).toBe('48px')
+ expect(declaredStyle(photo).height).toBe('48px')
+ // Without a photo, the striped placeholder stands in.
+ expect(gumbo!.querySelector('img')).toBeNull()
+ expect(within(gumbo!).getByText('dish photo')).toBeInTheDocument()
+
+ for (const row of [padThai!, gumbo!, risotto!]) {
+ for (const button of within(row).getAllByRole('button')) expectMinHitArea(button)
+ }
+
+ fireEvent.keyDown(sheet, { key: 'Escape' })
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ expect(pill).toHaveAttribute('aria-expanded', 'false')
+ expect(pill).toHaveFocus()
+ })
+
+ it('lists only the dinners the Quest log read returns, so ones already on the current or a future plan stay out', async () => {
+ // The server leaves out dinners on the current or a later week's plan;
+ // the sheet adds none and works out nothing of its own.
+ const server = stubQuestLog([RISOTTO])
+ await renderPage()
+ const sheet = await openSheet(1)
+ expect(names(sheet)).toEqual(['Mushroom risotto'])
+ expect(server.fetchMock.mock.calls.map(([input]) => String(input)).filter((path) => path.startsWith('/api/v1/plans'))).toEqual([])
+ })
+
+ it('opens /dinner/:id from a row', async () => {
+ stubQuestLog([PAD_THAI, GUMBO])
+ await renderPage()
+ const sheet = await openSheet(2)
+ fireEvent.click(within(sheet).getByRole('button', { name: /^Shrimp gumbo/ }))
+ expect(await screen.findByText('Dinner screen for q2')).toBeInTheDocument()
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('removes a dinner only once confirmed, archiving it and taking one off the count', async () => {
+ const server = stubQuestLog([PAD_THAI, GUMBO], {
+ 'DELETE /api/v1/dinners/q1': () => {
+ server.questLogDinners.dinners = [GUMBO]
+ return jsonResponse(200, { ...PAD_THAI, status: 'archived' })
+ },
+ })
+ await renderPage()
+ const sheet = await openSheet(2)
+
+ fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' }))
+ const confirm = screen.getByRole('dialog', { name: 'Remove Pad thai?' })
+ expect(confirm).toHaveTextContent('It leaves your Quest log for good.')
+ fireEvent.click(within(confirm).getByRole('button', { name: 'Keep it' }))
+ expect(screen.queryByRole('dialog', { name: 'Remove Pad thai?' })).not.toBeInTheDocument()
+ expect(rows(sheet)).toHaveLength(2)
+ expect(sent(server.fetchMock)).toEqual([])
+
+ fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' }))
+ fireEvent.click(within(screen.getByRole('dialog', { name: 'Remove Pad thai?' })).getByRole('button', { name: 'Remove' }))
+ await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Remove Pad thai?' })).not.toBeInTheDocument())
+ expect(sent(server.fetchMock)).toEqual(['DELETE /api/v1/dinners/q1'])
+ expect(names(sheet)).toEqual(['Shrimp gumbo'])
+ expect(screen.getByRole('button', { name: 'Quest log · 1' })).toBeInTheDocument()
+ // The Quest log is read again after the removal, and agrees.
+ await waitFor(() =>
+ expect(server.fetchMock.mock.calls.filter(([input]) => String(input) === QUEST_LOG)).toHaveLength(2),
+ )
+ expect(screen.getByRole('button', { name: 'Quest log · 1' })).toBeInTheDocument()
+ })
+
+ it('keeps focus in the open Quest log after a removal: on the next row, the one before the last, then the sheet', async () => {
+ const archive = (id: string) => () => {
+ server.questLogDinners.dinners = server.questLogDinners.dinners.filter((dinner) => dinner !== byId[id])
+ return jsonResponse(200, { ...byId[id]!, status: 'archived' })
+ }
+ const byId: Record = { q1: PAD_THAI, q2: GUMBO, q3: RISOTTO }
+ const server = stubQuestLog([PAD_THAI, GUMBO, RISOTTO], {
+ 'DELETE /api/v1/dinners/q1': archive('q1'),
+ 'DELETE /api/v1/dinners/q2': archive('q2'),
+ 'DELETE /api/v1/dinners/q3': archive('q3'),
+ })
+ await renderPage()
+ const sheet = await openSheet(3)
+
+ async function removeAndExpectFocus(name: string, expected: () => HTMLElement) {
+ within(sheet).getByRole('button', { name: `Remove ${name}` }).focus()
+ fireEvent.click(within(sheet).getByRole('button', { name: `Remove ${name}` }))
+ fireEvent.click(within(screen.getByRole('dialog', { name: `Remove ${name}?` })).getByRole('button', { name: 'Remove' }))
+ await waitFor(() => expect(screen.queryByRole('dialog', { name: `Remove ${name}?` })).not.toBeInTheDocument())
+ expect(expected()).toHaveFocus()
+ expect(sheet).toContainElement(document.activeElement as HTMLElement)
+ }
+
+ // A middle row: focus goes to the row now in its place.
+ await removeAndExpectFocus('Shrimp gumbo', () => within(sheet).getByRole('button', { name: 'Remove Mushroom risotto' }))
+ // The last row: focus goes to the row before it.
+ await removeAndExpectFocus('Mushroom risotto', () => within(sheet).getByRole('button', { name: 'Remove Pad thai' }))
+ // The only row: focus goes to the sheet itself.
+ await removeAndExpectFocus('Pad thai', () => sheet)
+ expect(within(sheet).getByText('Your Quest log is empty — swipe right on a side quest to add one.')).toBeInTheDocument()
+
+ // From there Escape still reaches the Quest log, and closes it.
+ fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it.each([
+ ['Keep it', (confirm: HTMLElement) => fireEvent.click(within(confirm).getByRole('button', { name: 'Keep it' }))],
+ ['Escape', (confirm: HTMLElement) => fireEvent.keyDown(confirm, { key: 'Escape' })],
+ ['the scrim', (confirm: HTMLElement) => fireEvent.click(confirm.previousElementSibling!)],
+ ])('keeps the confirmation and sends one removal once Remove is activated: %s and a second Remove at once do nothing', async (_, dismiss) => {
+ const archive = deferred()
+ const server = stubQuestLog([PAD_THAI, GUMBO], { 'DELETE /api/v1/dinners/q1': () => archive.promise })
+ await renderPage()
+ const sheet = await openSheet(2)
+
+ fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' }))
+ const confirm = screen.getByRole('dialog', { name: 'Remove Pad thai?' })
+ const removeButton = within(confirm).getByRole('button', { name: 'Remove' })
+ // Straight after Remove, without waiting for the pending request to render.
+ fireEvent.click(removeButton)
+ fireEvent.click(removeButton)
+ dismiss(confirm)
+ expect(screen.getByRole('dialog', { name: 'Remove Pad thai?' })).toBe(confirm)
+
+ expect(await within(confirm).findByRole('button', { name: 'Removing…' })).toBeDisabled()
+ expect(within(confirm).getByRole('button', { name: 'Keep it' })).toBeDisabled()
+ dismiss(confirm)
+ expect(screen.getByRole('dialog', { name: 'Remove Pad thai?' })).toBe(confirm)
+
+ server.questLogDinners.dinners = [GUMBO]
+ await act(async () => archive.resolve(jsonResponse(200, { ...PAD_THAI, status: 'archived' })))
+ await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Remove Pad thai?' })).not.toBeInTheDocument())
+ expect(names(sheet)).toEqual(['Shrimp gumbo'])
+ expect(sent(server.fetchMock)).toEqual(['DELETE /api/v1/dinners/q1'])
+ })
+
+ it.each([
+ ['keeps a disabled button focused', false],
+ ['moves focus off a disabled button to the page', true],
+ ])('keeps focus in the confirmation, off its disabled buttons, while a removal is under way when the browser %s', async (_, fixup) => {
+ // Some browsers move focus from a control to the page as it is disabled.
+ const setAttribute = Element.prototype.setAttribute
+ if (fixup) {
+ vi.spyOn(Element.prototype, 'setAttribute').mockImplementation(function (this: Element, name, value) {
+ // jsdom ignores blur() on a disabled control, so focus leaves just before.
+ if (name === 'disabled' && this === document.activeElement && this instanceof HTMLElement) this.blur()
+ setAttribute.call(this, name, value)
+ })
+ }
+ onTestFinished(() => {
+ vi.restoreAllMocks()
+ })
+ const archive = deferred()
+ const server = stubQuestLog([PAD_THAI, GUMBO], { 'DELETE /api/v1/dinners/q1': () => archive.promise })
+ await renderPage()
+ const sheet = await openSheet(2)
+
+ fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' }))
+ const confirm = screen.getByRole('dialog', { name: 'Remove Pad thai?' })
+ const removeButton = within(confirm).getByRole('button', { name: 'Remove' })
+ removeButton.focus()
+ fireEvent.click(removeButton)
+ expect(removeButton).toBeDisabled()
+ expect(confirm).toHaveFocus()
+
+ expect(await within(confirm).findByRole('button', { name: 'Removing…' })).toBeDisabled()
+ expect(confirm).toHaveFocus()
+
+ server.questLogDinners.dinners = [GUMBO]
+ await act(async () => archive.resolve(jsonResponse(200, { ...PAD_THAI, status: 'archived' })))
+ await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Remove Pad thai?' })).not.toBeInTheDocument())
+ expect(within(sheet).getByRole('button', { name: 'Remove Shrimp gumbo' })).toHaveFocus()
+ })
+
+ it('keeps a dinner whose removal the server refuses, saying why', async () => {
+ const server = stubQuestLog([PAD_THAI, GUMBO], { 'DELETE /api/v1/dinners/q1': () => errorResponse(500, 'internal') })
+ await renderPage()
+ const sheet = await openSheet(2)
+
+ fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' }))
+ const confirm = screen.getByRole('dialog', { name: 'Remove Pad thai?' })
+ fireEvent.click(within(confirm).getByRole('button', { name: 'Remove' }))
+ expect(await within(confirm).findByRole('alert')).toHaveTextContent("Couldn't remove it. internal message")
+ expect(sent(server.fetchMock)).toEqual(['DELETE /api/v1/dinners/q1'])
+ expect(rows(sheet)).toHaveLength(2)
+ expect(screen.getByRole('button', { name: 'Quest log · 2' })).toBeInTheDocument()
+
+ // Keep it closes the confirmation and clears the error.
+ fireEvent.click(within(confirm).getByRole('button', { name: 'Keep it' }))
+ fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' }))
+ expect(within(screen.getByRole('dialog', { name: 'Remove Pad thai?' })).queryByRole('alert')).not.toBeInTheDocument()
+ })
+
+ it('says how to fill an empty Quest log', async () => {
+ stubQuestLog([])
+ await renderPage()
+ const sheet = await openSheet(0)
+ expect(within(sheet).getByText('Your Quest log is empty — swipe right on a side quest to add one.')).toBeInTheDocument()
+ expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
+ })
+ })
+
describe('optimistic decisions', () => {
const PAELLA = suggestionBody('s4', 'Sheet pan paella')
const SOUP = suggestionBody('s5', 'Tortellini soup')
@@ -733,8 +973,8 @@ describe('DiscoverPage', () => {
[`GET ${QUEST_LOG}`]: () =>
jsonResponse(200, {
dinners: [
- { id: 'q0', name: 'Quest 0' },
- { id: 'dinner-s1', name: 'Korean beef bowls' },
+ questLogBody('q0', 'Quest 0'),
+ questLogBody('dinner-s1', 'Korean beef bowls'),
],
}),
})
@@ -754,7 +994,7 @@ describe('DiscoverPage', () => {
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 questLog = [questLogBody('q0', 'Quest 0')]
const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], {
'POST /api/v1/suggestions/s1/accept': () => accept.promise,
[`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: questLog }),
@@ -770,7 +1010,7 @@ describe('DiscoverPage', () => {
// 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' })
+ questLog.push(questLogBody('dinner-s1', 'Korean beef bowls'))
await act(async () => await queryClient.invalidateQueries({ queryKey: ['dinners', 'quest_log'] }))
accept.resolve(errorResponse(500, 'internal'))
await screen.findByRole('alert')
diff --git a/web/src/discover/DiscoverPage.tsx b/web/src/discover/DiscoverPage.tsx
index 078286b..5e9e010 100644
--- a/web/src/discover/DiscoverPage.tsx
+++ b/web/src/discover/DiscoverPage.tsx
@@ -1,8 +1,7 @@
import { useQueryClient } from '@tanstack/react-query'
import { Heart, X } from 'lucide-react'
-import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type Ref } from 'react'
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type Ref } from 'react'
import { useJob } from '../api/index.ts'
-import { Card } from '../ui/Card.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
@@ -19,6 +18,7 @@ import {
useQuestLog,
useStartBatch,
type Choice,
+ type QuestLogDinner,
type SideQuest,
} from './api.ts'
import { DeckCard, DECK_DEPTH, type SwipeHandlers } from './DeckCard.tsx'
@@ -35,6 +35,7 @@ import {
unfollow,
type Exit,
} from './motion.ts'
+import { QuestLogSheet } from './QuestLogSheet.tsx'
import styles from './DiscoverPage.module.css'
// SWIPE_THRESHOLD is how far, in px, the top card must be dragged left or
@@ -54,36 +55,22 @@ function isEditable(target: EventTarget | null): boolean {
)
}
-// QuestLogPill is the "Quest log · N" pill. It shows and hides the names
-// of the dinners in the Quest log; the Quest log sheet replaces this list.
-// ref is the visible pill, which Try it bumps.
-function QuestLogPill({ dinners, ref }: { dinners: { id: string; name: string }[]; ref: Ref }) {
+// QuestLogPill is the "Quest log · N" pill, which opens the Quest log
+// sheet. ref is the visible pill, which Try it bumps.
+function QuestLogPill({ dinners, ref }: { dinners: QuestLogDinner[]; ref: Ref }) {
const [open, setOpen] = useState(false)
- const listId = useId()
return (
<>
setOpen(!open)}
+ onClick={() => setOpen(true)}
>
Quest log · {dinners.length}
- {open && (
-
- {dinners.length === 0 ? (
-
No dinners in the Quest log yet. Try it on a Side quest to add one.
- ) : (
-
- {dinners.map((dinner) => (
-
{dinner.name}
- ))}
-
- )}
-
- )}
+ setOpen(false)} />
>
)
}
diff --git a/web/src/discover/QuestLogSheet.module.css b/web/src/discover/QuestLogSheet.module.css
new file mode 100644
index 0000000..75fce9c
--- /dev/null
+++ b/web/src/discover/QuestLogSheet.module.css
@@ -0,0 +1,38 @@
+/* The Quest log sheet (mock 1a's "Quest log · N" pill opens it). */
+.sheet {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ max-width: 560px;
+ margin: 0 auto;
+}
+
+.title {
+ margin: 0;
+ font-family: var(--font-heading);
+ font-size: 22px;
+ font-weight: 400;
+}
+
+.rows {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+}
+
+/* The 48 px washed photo; Photo applies the washed filter. */
+.photo {
+ width: 48px;
+ height: 48px;
+}
+
+.empty {
+ margin: 0;
+ font-family: var(--font-body);
+ font-size: 14px;
+ line-height: 1.45;
+ color: var(--color-neutral-700);
+}
diff --git a/web/src/discover/QuestLogSheet.tsx b/web/src/discover/QuestLogSheet.tsx
new file mode 100644
index 0000000..cafdfa7
--- /dev/null
+++ b/web/src/discover/QuestLogSheet.tsx
@@ -0,0 +1,147 @@
+import { Trash2 } from 'lucide-react'
+import { useEffect, useId, useRef, useState } from 'react'
+import { useNavigate } from 'react-router'
+import { Confirm } from '../settings/LeaveGuard.tsx'
+import { Icon } from '../ui/Icon.tsx'
+import { IconButton } from '../ui/IconButton.tsx'
+import { ListRow } from '../ui/ListRow.tsx'
+import { Photo } from '../ui/Photo.tsx'
+import { Sheet } from '../ui/Sheet.tsx'
+import { useRemoveFromQuestLog, type QuestLogDinner } from './api.ts'
+import { DIFFICULTY_LABEL } from './DeckCard.tsx'
+import styles from './QuestLogSheet.module.css'
+
+export type QuestLogSheetProps = {
+ open: boolean
+ // The Quest log, as useQuestLog reads it.
+ dinners: QuestLogDinner[]
+ onClose: () => void
+}
+
+// details is a Quest-log dinner's "difficulty · minutes" line, or null when
+// neither is known.
+function details(dinner: QuestLogDinner): string | null {
+ const parts = [
+ dinner.difficulty !== null && DIFFICULTY_LABEL[dinner.difficulty],
+ dinner.minutes_total !== null && `${dinner.minutes_total} min`,
+ ].filter((part): part is string => typeof part === 'string')
+ return parts.length === 0 ? null : parts.join(' · ')
+}
+
+// QuestLogSheet is the Quest log as a bottom sheet: each dinner with its
+// washed photo, name, difficulty and minutes. A row opens the dinner; its
+// remove button, once confirmed, archives the dinner.
+export function QuestLogSheet({ open, dinners, onClose }: QuestLogSheetProps) {
+ const titleId = useId()
+ const navigate = useNavigate()
+ const remove = useRemoveFromQuestLog()
+ // The dinner whose removal is being confirmed.
+ const [removing, setRemoving] = useState(null)
+ // The dinner just removed and its place in the list, until focus has
+ // moved on from it.
+ const removed = useRef<{ id: string; index: number } | null>(null)
+ const removeButtons = useRef(new Map())
+ const sheetRef = useRef(null)
+ // Whether a removal has been sent and not yet settled. The ref is set as
+ // Remove is activated, so a dismissal or a second Remove that arrives before
+ // the next render is already refused; the state renders it.
+ const sending = useRef(false)
+ const [sendingShown, setSendingShown] = useState(false)
+
+ // Once a removal's confirmation has closed and the dinner has left the list,
+ // focus goes to the remove button of the dinner now in its place, or of the
+ // one before it when it was the last, or to the Quest log sheet itself when
+ // none is left. Otherwise it would be left on the removed dinner's button,
+ // which the confirmation hands it back to, and which is then gone.
+ useEffect(() => {
+ const done = removed.current
+ if (removing !== null || done === null || dinners.some((d) => d.id === done.id)) return
+ removed.current = null
+ const next = dinners[Math.min(done.index, dinners.length - 1)]
+ if (next) removeButtons.current.get(next.id)!.focus()
+ else sheetRef.current!.closest('[role="dialog"]')!.focus()
+ }, [removing, dinners])
+
+ function setSending(value: boolean) {
+ sending.current = value
+ setSendingShown(value)
+ }
+
+ function cancelRemove() {
+ if (sending.current) return
+ remove.reset()
+ setRemoving(null)
+ }
+
+ function confirmRemove() {
+ if (sending.current) return
+ setSending(true)
+ remove.mutate(removing!, {
+ onSuccess: (_data, dinner) => {
+ removed.current = { id: dinner.id, index: dinners.findIndex((d) => d.id === dinner.id) }
+ setRemoving(null)
+ },
+ onSettled: () => setSending(false),
+ })
+ }
+
+ return (
+ <>
+
+
+
+ Quest log
+
+ {dinners.length === 0 ? (
+
Your Quest log is empty — swipe right on a side quest to add one.
+
+
+ It leaves your Quest log for good.
+
+ >
+ )
+}
diff --git a/web/src/discover/api.ts b/web/src/discover/api.ts
index c233de3..84a1917 100644
--- a/web/src/discover/api.ts
+++ b/web/src/discover/api.ts
@@ -1,7 +1,7 @@
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'
+import { DIFFICULTIES, DINNERS_PATH, dinnerPath, oneOf, parseDinner, photoId, type Difficulty, type Regular } from '../dinners/api.ts'
export const SUGGESTIONS_PATH = '/api/v1/suggestions'
export const DECK_PATH = `${SUGGESTIONS_PATH}/deck`
@@ -39,8 +39,9 @@ export const BATCH_JOB_KIND = 'suggestion_batch'
// BatchResult is the suggestion batch job's result, published as it goes.
export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number }
-// QuestLogDinner is a dinner in the Quest log, as the Quest log pill lists it.
-export type QuestLogDinner = { id: string; name: string }
+// QuestLogDinner is a dinner in the Quest log, as the Quest log sheet lists
+// it: photo, name, difficulty and minutes.
+export type QuestLogDinner = Pick
// DECISION_STATUS is the status a suggestion has once each choice is saved.
const DECISION_STATUS: Record = { try: 'accepted', pass: 'declined' }
@@ -97,7 +98,8 @@ export function useDeck() {
}
// useQuestLog is the dinners in the Quest log, in the server's order (GET
-// /api/v1/dinners?status=quest_log).
+// /api/v1/dinners?status=quest_log). The server leaves out a dinner already
+// on the plan for the current week or a later one: it is scheduled.
export function useQuestLog() {
const { apiFetch } = useApi()
return useQuery({
@@ -107,15 +109,30 @@ export function useQuestLog() {
const body = await apiFetch(QUEST_LOG_PATH)
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
return body.dinners.map((dinner) => {
- if (!isRecord(dinner) || typeof dinner.id !== 'string' || typeof dinner.name !== 'string') {
- throw new Error(`${where}: response is not a dinner`)
- }
- return { id: dinner.id, name: dinner.name }
+ const { id, name, photo_id, difficulty, minutes_total } = parseDinner(where, dinner)
+ return { id, name, photo_id, difficulty, minutes_total }
})
},
})
}
+// useRemoveFromQuestLog archives a Quest-log dinner (DELETE
+// /api/v1/dinners/{id}). Once the server has archived it, it leaves the
+// Quest log, and the Quest log is read again.
+export function useRemoveFromQuestLog() {
+ const { apiFetch } = useApi()
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async (dinner: QuestLogDinner) => {
+ await apiFetch(dinnerPath(dinner.id), { method: 'DELETE' })
+ },
+ onSuccess: async (_data, dinner) => {
+ queryClient.setQueryData(QUEST_LOG_KEY, (dinners) => dinners?.filter((d) => d.id !== dinner.id))
+ await queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
+ },
+ })
+}
+
// useStartBatch asks for more Side quests now (POST
// /api/v1/suggestions/batches). The server answers with the batch already
// running when there is one.
@@ -215,7 +232,17 @@ export function useDecide() {
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 }),
+ dinners === undefined || dinners.some((d) => d.id === quest.dinner_id)
+ ? dinners
+ : // The deck does not say the minutes; the Quest log read after the
+ // decision does.
+ dinners.concat({
+ id: quest.dinner_id,
+ name: quest.name,
+ photo_id: quest.photo_id,
+ difficulty: quest.difficulty,
+ minutes_total: null,
+ }),
)
},
onSettled: async (_replenishJobId, error, { quest }) => {
diff --git a/web/src/settings/LeaveGuard.tsx b/web/src/settings/LeaveGuard.tsx
index 35ae711..26ded91 100644
--- a/web/src/settings/LeaveGuard.tsx
+++ b/web/src/settings/LeaveGuard.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useId, type ReactNode } from 'react'
+import { useEffect, useId, useLayoutEffect, useRef, type ReactNode } from 'react'
import { useBlocker } from 'react-router'
import { Button } from '../ui/Button.tsx'
import { Sheet } from '../ui/Sheet.tsx'
@@ -36,6 +36,9 @@ type ConfirmProps = {
confirmLabel: string
cancelLabel?: string
busy?: boolean
+ // When false, the cancel button, Escape and the scrim do nothing, so the
+ // sheet stays until the action settles.
+ dismissible?: boolean
error?: string | null
onCancel: () => void
onConfirm: () => void
@@ -49,14 +52,28 @@ export function Confirm({
confirmLabel,
cancelLabel = 'Cancel',
busy = false,
+ dismissible = true,
error = null,
onCancel,
onConfirm,
}: ConfirmProps) {
const titleId = useId()
+ const sheetRef = useRef(null)
+ // A focused button that becomes disabled either keeps focus while disabled or
+ // loses it to the page, depending on the browser; the dialog takes it instead.
+ useLayoutEffect(() => {
+ const dialog = sheetRef.current?.closest('[role="dialog"]')
+ if (!dialog) return
+ const active = document.activeElement
+ const lost = active === null || active === document.body || (dialog.contains(active) && active.matches(':disabled'))
+ if (lost) dialog.focus()
+ }, [busy, dismissible])
+ function dismiss() {
+ if (dismissible) onCancel()
+ }
return (
-
-
+
+
{title}
@@ -67,7 +84,7 @@ export function Confirm({
)}
-
+
{cancelLabel}
--
2.55.0
From a05d57cb2ed0cc674b0447b9b2fe7889d26e15c4 Mon Sep 17 00:00:00 2001
From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
Date: Wed, 7 Oct 2026 05:20:10 -0400
Subject: [PATCH 8/9] Feature 13 integration fix: deck error surfacing,
active-batch tracking, modal key scope, three-card deck (#111)
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
---
docs/ARCHITECTURE.md | 14 +-
internal/server/suggestions.go | 10 +-
internal/server/suggestions_test.go | 40 +++-
internal/suggest/suggest.go | 101 ++++++----
internal/suggest/suggest_test.go | 80 +++++++-
web/src/App.test.tsx | 2 +-
web/src/discover/DiscoverPage.module.css | 8 +
web/src/discover/DiscoverPage.test.tsx | 228 +++++++++++++++++++++--
web/src/discover/DiscoverPage.tsx | 66 +++++--
web/src/discover/api.ts | 44 +++--
10 files changed, 484 insertions(+), 109 deletions(-)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 740d7f9..007ae36 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -310,9 +310,11 @@ automatic** (D-021).
block the next.
`GET /api/v1/suggestions/deck` returns `{suggestions: [{id, dinner_id,
name, photo_url, difficulty, method, minutes_total, cuisine, reason,
- cited_regular_ids, status, dinner_status, decided_at, created_at}]}`:
- the `pending` suggestions whose dinner is still `suggested`, oldest
- first. `POST /api/v1/suggestions/{id}/accept` (Try it) sets the
+ cited_regular_ids, status, dinner_status, decided_at, created_at}],
+ active_batch}`: the `pending` suggestions whose dinner is still
+ `suggested`, oldest first, and the batch `pending` or `running`
+ (`{batch_id, job_id, trigger, status}`, or null), read in one snapshot,
+ so a client that did not start the batch can follow its job. `POST /api/v1/suggestions/{id}/accept` (Try it) sets the
suggestion `accepted` and moves its dinner to `quest_log`; `POST
/api/v1/suggestions/{id}/decline` (Pass) sets it `declined` and archives
the dinner; every later batch, and the household context's passed list,
@@ -323,9 +325,9 @@ automatic** (D-021).
and answers 200; the opposite decision is 409 `suggestion_decided`, a
suggestion whose dinner left the deck some other way 409
`dinner_not_suggested`, and an unknown id 404. A decision that leaves
- fewer than 5 in the deck starts a `replenish` batch, whose job id it
- returns, unless a batch is already pending or running
- (`replenish_job_id` is then null). The batch and its job are written in
+ fewer than 5 in the deck starts a `replenish` batch, or joins the batch
+ already pending or running, and returns that batch's job id either way
+ (`replenish_job_id` is null when the deck keeps 5 or more). The batch and its job are written in
the decision's own transaction, so a decision is never recorded without
the replenish it calls for; when they cannot be written, neither is the
decision, and a retry decides again.
diff --git a/internal/server/suggestions.go b/internal/server/suggestions.go
index dbe1b84..f6e7711 100644
--- a/internal/server/suggestions.go
+++ b/internal/server/suggestions.go
@@ -10,10 +10,6 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
)
-type deck struct {
- Suggestions []suggest.Suggestion `json:"suggestions"`
-}
-
// postSuggestionBatch starts a manual suggestion batch and answers 202 with
// its batch_id and the job_id that GET /jobs/{id} reports on, without
// waiting for the job. When a batch is already pending or running it
@@ -30,15 +26,15 @@ func postSuggestionBatch(logger *slog.Logger, svc *suggest.Service) http.Handler
}
// getSuggestionDeck answers GET /suggestions/deck with the pending
-// suggestions, oldest first.
+// suggestions, oldest first, and the batch pending or running, or null.
func getSuggestionDeck(logger *slog.Logger, svc *suggest.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- list, err := svc.Deck(r.Context())
+ d, err := svc.Deck(r.Context())
if err != nil {
writeSuggestionError(w, r, logger, "list suggestion deck", err)
return
}
- writeJSON(w, r, logger, http.StatusOK, deck{Suggestions: list})
+ writeJSON(w, r, logger, http.StatusOK, d)
}
}
diff --git a/internal/server/suggestions_test.go b/internal/server/suggestions_test.go
index 7f33f3f..f81dbe2 100644
--- a/internal/server/suggestions_test.go
+++ b/internal/server/suggestions_test.go
@@ -148,8 +148,44 @@ func TestSuggestionDecisions(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("decline = %d: %s", rec.Code, rec.Body)
}
- if id, _ := body["replenish_job_id"].(string); id == "" {
- t.Errorf("decline leaving 4 = %v, want a replenish_job_id", body)
+ replenish, _ := body["replenish_job_id"].(string)
+ if replenish == "" {
+ t.Fatalf("decline leaving 4 = %v, want a replenish_job_id", body)
+ }
+
+ // A fourth decision joins the batch already pending and reports its job.
+ rec, body = callDinners(t, h, http.MethodPost, suggestionsPath+"/"+ids[3]+"/accept", "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("accept = %d: %s", rec.Code, rec.Body)
+ }
+ if body["replenish_job_id"] != replenish {
+ t.Errorf("accept leaving 3 = %v, want replenish_job_id %s", body, replenish)
+ }
+}
+
+func TestSuggestionDeckActiveBatch(t *testing.T) {
+ t.Parallel()
+ h, _, _ := newSuggestionsRouter(t, "Pho")
+
+ rec, body := callDinners(t, h, http.MethodGet, suggestionsPath+"/deck", "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET deck = %d: %s", rec.Code, rec.Body)
+ }
+ if active, ok := body["active_batch"]; !ok || active != nil {
+ t.Fatalf("deck = %v, want active_batch null", body)
+ }
+
+ rec, started := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "")
+ if rec.Code != http.StatusAccepted {
+ t.Fatalf("POST batches = %d: %s", rec.Code, rec.Body)
+ }
+ rec, body = callDinners(t, h, http.MethodGet, suggestionsPath+"/deck", "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET deck = %d: %s", rec.Code, rec.Body)
+ }
+ active, _ := body["active_batch"].(map[string]any)
+ if active == nil || active["batch_id"] != started["batch_id"] || active["job_id"] != started["job_id"] || active["status"] != "pending" {
+ t.Errorf("deck active_batch = %v, want the started batch %v", body["active_batch"], started)
}
}
diff --git a/internal/suggest/suggest.go b/internal/suggest/suggest.go
index efed277..33d5491 100644
--- a/internal/suggest/suggest.go
+++ b/internal/suggest/suggest.go
@@ -171,34 +171,47 @@ func (s *Service) StartBatch(ctx context.Context, trigger string) (Batch, error)
return b, nil
}
-// startBatch does StartBatch's work in tx: the batch and the job that runs
-// it are committed together, so a batch is never seen without its job. A
-// batch still pending or running whose job is no longer queued or running
-// is failed first, so a batch whose job ended without recording its end
-// (the process stopped, or the write failed) never blocks a later one. The
-// caller wakes the job runner once tx has committed and Created is true.
-func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) {
- q := db.New(tx)
+// activeBatch returns the batch pending or running in q's transaction, or
+// nil when there is none. A batch still pending or running whose job is no
+// longer queued or running is failed first, so a batch whose job ended
+// without recording its end (the process stopped, or the write failed) is
+// never reported as active and never blocks a later one.
+func (s *Service) activeBatch(ctx context.Context, q *db.Queries) (*Batch, error) {
n, err := q.FailOrphanedSuggestionBatches(ctx, db.FailOrphanedSuggestionBatchesParams{
UpdatedAt: store.Now().String(),
Kind: KindSuggestionBatch,
})
if err != nil {
- return Batch{}, fmt.Errorf("suggest: fail orphaned batches: %w", err)
+ return nil, fmt.Errorf("suggest: fail orphaned batches: %w", err)
}
if n > 0 {
s.logger.WarnContext(ctx, "failed suggestion batches whose job ended", "count", n)
}
cur, err := q.GetActiveSuggestionBatch(ctx)
- if err == nil {
- jobID, err := q.GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: cur.ID})
- if err != nil {
- return Batch{}, fmt.Errorf("suggest: read job of batch %s: %w", cur.ID, err)
- }
- return Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
}
- if !errors.Is(err, sql.ErrNoRows) {
- return Batch{}, fmt.Errorf("suggest: read active batch: %w", err)
+ if err != nil {
+ return nil, fmt.Errorf("suggest: read active batch: %w", err)
+ }
+ jobID, err := q.GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: cur.ID})
+ if err != nil {
+ return nil, fmt.Errorf("suggest: read job of batch %s: %w", cur.ID, err)
+ }
+ return &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
+}
+
+// startBatch does StartBatch's work in tx: the batch and the job that runs
+// it are committed together, so a batch is never seen without its job. The
+// caller wakes the job runner once tx has committed and Created is true.
+func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) {
+ q := db.New(tx)
+ cur, err := s.activeBatch(ctx, q)
+ if err != nil {
+ return Batch{}, err
+ }
+ if cur != nil {
+ return *cur, nil
}
now := store.Now().String()
row, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{
@@ -261,25 +274,44 @@ type Suggestion struct {
CreatedAt string `json:"created_at"`
}
-// Deck returns the pending suggestions whose dinner is still suggested,
-// oldest first.
-func (s *Service) Deck(ctx context.Context) ([]Suggestion, error) {
- rows, err := db.New(s.st.Reader).ListDeck(ctx)
- if err != nil {
- return nil, fmt.Errorf("suggest: list deck: %w", err)
- }
- out := make([]Suggestion, len(rows))
- for i, row := range rows {
- out[i], err = fromRow(row.Suggestion, row.Dinner)
+// Deck is the household's deck: the pending suggestions whose dinner is
+// still suggested, oldest first, and the batch pending or running, if any,
+// so a client can follow the job producing more.
+type Deck struct {
+ Suggestions []Suggestion `json:"suggestions"`
+ ActiveBatch *Batch `json:"active_batch"`
+}
+
+// Deck reads the deck and its active batch in one transaction, so both come
+// from the same snapshot. It is a write transaction because reading the
+// active batch first fails any batch whose job has ended (see activeBatch).
+func (s *Service) Deck(ctx context.Context) (Deck, error) {
+ var deck Deck
+ err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
+ q := db.New(tx)
+ rows, err := q.ListDeck(ctx)
if err != nil {
- return nil, err
+ return fmt.Errorf("suggest: list deck: %w", err)
}
+ deck.Suggestions = make([]Suggestion, len(rows))
+ for i, row := range rows {
+ deck.Suggestions[i], err = fromRow(row.Suggestion, row.Dinner)
+ if err != nil {
+ return err
+ }
+ }
+ deck.ActiveBatch, err = s.activeBatch(ctx, q)
+ return err
+ })
+ if err != nil {
+ return Deck{}, err
}
- return out, nil
+ return deck, nil
}
// Decision is a suggestion once decided, with the id of the replenish
-// batch's job when the decision started one.
+// batch's job when the decision called for one: the batch it started, or
+// the one already pending or running that it joined.
type Decision struct {
Suggestion Suggestion `json:"suggestion"`
ReplenishJobID *string `json:"replenish_job_id"`
@@ -303,8 +335,8 @@ func (s *Service) Decline(ctx context.Context, id, updatedBy string) (Decision,
// dinnerStatus. Repeating a decision changes nothing and returns the
// suggestion as it is; the opposite decision is a *DecidedError. A
// decision that leaves the deck under ReplenishBelow starts a replenish
-// batch unless one is already pending or running, in the decision's own
-// transaction: the decision is never recorded without the replenish it
+// batch, or joins the one already pending or running, in the decision's own
+// transaction, and returns its job id either way: the decision is never recorded without the replenish it
// calls for, and a failure to start the batch records neither.
func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, updatedBy string) (Decision, error) {
if updatedBy == "" {
@@ -369,8 +401,7 @@ func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, update
if err != nil {
return Decision{}, err
}
- created := replenish != nil && replenish.Created
- if created {
+ if replenish != nil && replenish.Created {
s.batchJob.Wake()
}
sug, err := fromRow(row.Suggestion, row.Dinner)
@@ -378,7 +409,7 @@ func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, update
return Decision{}, err
}
d := Decision{Suggestion: sug}
- if created {
+ if replenish != nil {
d.ReplenishJobID = &replenish.JobID
}
return d, nil
diff --git a/internal/suggest/suggest_test.go b/internal/suggest/suggest_test.go
index 157f1a5..683f046 100644
--- a/internal/suggest/suggest_test.go
+++ b/internal/suggest/suggest_test.go
@@ -2,6 +2,7 @@ package suggest_test
import (
"context"
+ "database/sql"
"encoding/json"
"errors"
"fmt"
@@ -329,7 +330,7 @@ func (e *env) deckNames(t *testing.T) []string {
t.Fatal(err)
}
names := []string{}
- for _, s := range deck {
+ for _, s := range deck.Suggestions {
names = append(names, s.Name)
}
return names
@@ -362,15 +363,15 @@ func TestBatchStoresGroundedSideQuests(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if len(deck) != 2 {
- t.Fatalf("deck has %d suggestions, want 2", len(deck))
+ if len(deck.Suggestions) != 2 {
+ t.Fatalf("deck has %d suggestions, want 2", len(deck.Suggestions))
}
want := []struct{ name, reason, regular string }{
{"Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"},
{"Chili Verde", "Your Beef Chili gone green.", "Beef Chili"},
}
for i, w := range want {
- s := deck[i]
+ s := deck.Suggestions[i]
if s.ID != res.SuggestionIDs[i] || s.Name != w.name || s.Reason != w.reason || s.Status != suggest.StatusPending {
t.Errorf("deck[%d] = %+v, want %s pending with reason %q", i, s, w.name, w.reason)
}
@@ -452,6 +453,69 @@ func TestDeckIsOldestFirst(t *testing.T) {
}
}
+func TestDeckReportsTheActiveBatch(t *testing.T) {
+ e := newEnv(t) // runner not started: the batch stays pending
+ e.seedDeck(t, "Pho")
+ deck, err := e.svc.Deck(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if deck.ActiveBatch != nil {
+ t.Fatalf("active batch = %+v with none pending or running, want nil", deck.ActiveBatch)
+ }
+ b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
+ if err != nil {
+ t.Fatal(err)
+ }
+ deck, err = e.svc.Deck(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(deck.Suggestions) != 1 {
+ t.Errorf("deck has %d suggestions, want 1", len(deck.Suggestions))
+ }
+ want := suggest.Batch{ID: b.ID, JobID: b.JobID, Trigger: suggest.TriggerManual, Status: suggest.BatchPending}
+ if deck.ActiveBatch == nil || *deck.ActiveBatch != want {
+ t.Errorf("active batch = %+v, want %+v", deck.ActiveBatch, want)
+ }
+}
+
+func TestDeckFailsBatchInterruptedByRestart(t *testing.T) {
+ e := newEnv(t)
+ e.seedDeck(t, "Pho")
+ b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // A previous process claimed the job and started the batch, then
+ // stopped before recording its end.
+ now := store.Now().String()
+ if _, err := db.New(e.st.Writer).ClaimNextJob(t.Context(), sql.NullString{String: now, Valid: true}); err != nil {
+ t.Fatal(err)
+ }
+ if n, err := db.New(e.st.Writer).StartSuggestionBatch(t.Context(), db.StartSuggestionBatchParams{UpdatedAt: now, ID: b.ID}); err != nil || n != 1 {
+ t.Fatalf("start batch: %d rows, %v", n, err)
+ }
+ e.start(t) // fails the running job as interrupted
+ if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusFailed {
+ t.Fatalf("job status = %s, want failed", job.Status)
+ }
+
+ deck, err := e.svc.Deck(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if deck.ActiveBatch != nil {
+ t.Errorf("active batch = %+v after its job failed, want nil", deck.ActiveBatch)
+ }
+ if len(deck.Suggestions) != 1 {
+ t.Errorf("deck has %d suggestions, want 1", len(deck.Suggestions))
+ }
+ if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed {
+ t.Errorf("interrupted batch status = %s, want failed", got)
+ }
+}
+
func TestDeckLeavesOutDinnersNoLongerSuggested(t *testing.T) {
e := newEnv(t)
ids := e.seedDeck(t, "Pho", "Ramen")
@@ -459,7 +523,7 @@ func TestDeckLeavesOutDinnersNoLongerSuggested(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if _, err := db.New(e.st.Writer).ArchiveDinner(t.Context(), db.ArchiveDinnerParams{ID: sug[0].DinnerID, UpdatedAt: store.Now().String()}); err != nil {
+ if _, err := db.New(e.st.Writer).ArchiveDinner(t.Context(), db.ArchiveDinnerParams{ID: sug.Suggestions[0].DinnerID, UpdatedAt: store.Now().String()}); err != nil {
t.Fatal(err)
}
if got := e.deckNames(t); !slices.Equal(got, []string{"Ramen"}) {
@@ -608,8 +672,10 @@ func TestReplenishIsSingleFlight(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if second.ReplenishJobID != nil {
- t.Errorf("second decision enqueued replenish job %s while one is pending", *second.ReplenishJobID)
+ // The second decision joins the pending batch and reports its job, so a
+ // client that did not start it can follow it.
+ if second.ReplenishJobID == nil || *second.ReplenishJobID != *first.ReplenishJobID {
+ t.Errorf("second decision replenish job = %v, want the pending batch's job %s", second.ReplenishJobID, *first.ReplenishJobID)
}
manual, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
if err != nil {
diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
index fb9df89..60a5e3a 100644
--- a/web/src/App.test.tsx
+++ b/web/src/App.test.tsx
@@ -107,7 +107,7 @@ describe('App routes', () => {
mockViewport(390)
stubFetchByPath({
...SIGNED_IN,
- '/api/v1/suggestions/deck': () => jsonResponse(200, { suggestions: [] }),
+ '/api/v1/suggestions/deck': () => jsonResponse(200, { suggestions: [], active_batch: null }),
'/api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }),
})
renderAt('/discover')
diff --git a/web/src/discover/DiscoverPage.module.css b/web/src/discover/DiscoverPage.module.css
index ae35eb2..d3d53ef 100644
--- a/web/src/discover/DiscoverPage.module.css
+++ b/web/src/discover/DiscoverPage.module.css
@@ -224,6 +224,14 @@
border-radius: var(--radius-card);
}
+/* A blank card holds a depth a short deck has no card for, as the stack's
+ edge, in that depth's place and colour. */
+.blankCard {
+ position: absolute;
+ inset: 0;
+ border-radius: var(--radius-card);
+}
+
.skeletonCard.depth0,
.skeletonCard.depth1,
.skeletonCard.depth2 {
diff --git a/web/src/discover/DiscoverPage.test.tsx b/web/src/discover/DiscoverPage.test.tsx
index f1f73f0..68b0b99 100644
--- a/web/src/discover/DiscoverPage.test.tsx
+++ b/web/src/discover/DiscoverPage.test.tsx
@@ -50,14 +50,15 @@ 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 the current server.questLog dinners; every
+// server.deck and server.activeBatch, 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, questLog, fetchMock: undefined as unknown as Mock }
+ const server = { deck, questLog, activeBatch: null as { batch_id: string; job_id: string } | null, fetchMock: undefined as unknown as Mock }
const all: Record = {
'GET /api/v1/me': () => jsonResponse(200, ME),
- [`GET ${DECK}`]: () => jsonResponse(200, { suggestions: server.deck }),
+ [`GET ${DECK}`]: () => jsonResponse(200, { suggestions: server.deck, active_batch: server.activeBatch }),
[`GET ${QUEST_LOG}`]: () =>
jsonResponse(200, { dinners: Array.from({ length: server.questLog }, (_, i) => questLogBody(`q${i}`, `Quest ${i}`)) }),
...responders,
@@ -89,6 +90,13 @@ function jobPolls(fetchMock: Mock) {
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === '/api/v1/jobs/j1').length
}
+// batchJob is the suggestion batch job j as GET /api/v1/jobs/{id} answers
+// it, having stored ids.
+function batchJob(j: string, status: string, ids: string[] = [], error: string | null = null) {
+ const result = status === 'failed' ? null : { batch_id: `b-${j}`, stage: status === 'succeeded' ? 'done' : 'grounding', suggestion_ids: ids, dropped: 0 }
+ return { id: j, kind: 'suggestion_batch', status, result, error }
+}
+
// Every animation finishes as it starts unless a test stubs animate again.
beforeEach(() => {
mockViewport(390)
@@ -370,7 +378,7 @@ describe('DiscoverPage', () => {
it('shows a deck that fails to load with a retry', async () => {
let fail = true
- stubServer([BOWLS], { [`GET ${DECK}`]: () => (fail ? errorResponse(500, 'internal') : jsonResponse(200, { suggestions: [BOWLS] })) })
+ stubServer([BOWLS], { [`GET ${DECK}`]: () => (fail ? errorResponse(500, 'internal') : jsonResponse(200, { suggestions: [BOWLS], active_batch: null })) })
await renderPage()
const alert = await screen.findByRole('alert', {}, { timeout: 5000 })
expect(alert).toHaveTextContent("Couldn't load your side quests.")
@@ -503,6 +511,160 @@ describe('DiscoverPage', () => {
expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument()
})
+ describe('batch and deck depth', () => {
+ const PAELLA = suggestionBody('s4', 'Sheet pan paella')
+ const SOUP = suggestionBody('s5', 'Tortellini soup')
+ const CURRY = suggestionBody('s6', 'Green curry')
+ const declined = (quest: typeof BOWLS, replenish: string | null) =>
+ jsonResponse(200, { suggestion: { ...quest, status: 'declined' }, replenish_job_id: replenish })
+ // deckLayers is the deck's three depths, top first: its cards, then the
+ // blank or skeleton cards standing in for those it has none for.
+ const deckLayers = (container: HTMLElement) => [...container.querySelector('article')!.parentElement!.children]
+
+ it('shows a replenish batch that fails while cards remain, beside the deck, with a retry that asks again', async () => {
+ const server = stubServer([BOWLS, TIKKA], {
+ 'POST /api/v1/suggestions/s1/decline': () => {
+ server.deck = [TIKKA]
+ return declined(BOWLS, 'j1')
+ },
+ 'GET /api/v1/jobs/j1': () => jsonResponse(200, batchJob('j1', 'failed', [], 'model unavailable')),
+ 'POST /api/v1/suggestions/batches': () => jsonResponse(202, { batch_id: 'b-j2', job_id: 'j2', trigger: 'manual', status: 'pending' }),
+ 'GET /api/v1/jobs/j2': () => {
+ server.deck = [TIKKA, ORZO]
+ return jsonResponse(200, batchJob('j2', 'succeeded', ['s3']))
+ },
+ })
+ await renderPage()
+ await screen.findByRole('article', { name: 'Korean beef bowls' })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Pass' }))
+ const alert = await screen.findByRole('alert')
+ expect(alert).toHaveTextContent("Couldn't find new side quests. model unavailable")
+ // The deck stays in play beside it.
+ expect(screen.getByRole('article', { name: 'Crock pot chicken tikka masala' })).toBeInTheDocument()
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled())
+
+ fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
+ await waitFor(() => expect(screen.getAllByRole('article', { hidden: true })).toHaveLength(2))
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline', 'POST /api/v1/suggestions/batches'])
+ })
+
+ it('shows a replenish batch whose job cannot be polled while cards remain, with a retry that polls it again', async () => {
+ let failPoll = true
+ const server = stubServer([BOWLS, TIKKA], {
+ 'POST /api/v1/suggestions/s1/decline': () => {
+ server.deck = [TIKKA]
+ return declined(BOWLS, 'j1')
+ },
+ 'GET /api/v1/jobs/j1': () => {
+ if (failPoll) return errorResponse(500, 'internal')
+ server.deck = [TIKKA, ORZO]
+ return jsonResponse(200, batchJob('j1', 'succeeded', ['s3']))
+ },
+ })
+ await renderPage()
+ await screen.findByRole('article', { name: 'Korean beef bowls' })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Pass' }))
+ const alert = await screen.findByRole('alert')
+ expect(alert).toHaveTextContent("Couldn't check on new side quests.")
+ expect(screen.getByRole('article', { name: 'Crock pot chicken tikka masala' })).toBeInTheDocument()
+
+ failPoll = false
+ const polls = jobPolls(server.fetchMock)
+ fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
+ await waitFor(() => expect(jobPolls(server.fetchMock)).toBe(polls + 1), { timeout: 200 })
+ await waitFor(() => expect(screen.getAllByRole('article', { hidden: true })).toHaveLength(2))
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline'])
+ })
+
+ it('follows the batch the deck reports running when the page opens, with skeleton cards rather than "All caught up"', async () => {
+ const jobs = [batchJob('j1', 'running'), batchJob('j1', 'succeeded', ['s1'])]
+ const server = stubServer([], {
+ 'GET /api/v1/jobs/j1': () => {
+ const job = jobs.length > 1 ? jobs.shift()! : jobs[0]!
+ if (job.status === 'succeeded') {
+ server.deck = [BOWLS]
+ server.activeBatch = null
+ }
+ return jsonResponse(200, job)
+ },
+ })
+ server.activeBatch = { batch_id: 'b-j1', job_id: 'j1' }
+ await renderPage()
+
+ expect(await screen.findByText('Finding new side quests…')).toBeInTheDocument()
+ expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3)
+ expect(screen.queryByRole('heading', { name: 'All caught up' })).not.toBeInTheDocument()
+
+ await screen.findByRole('article', { name: 'Korean beef bowls' }, { timeout: 3000 })
+ expect(screen.queryByText('Finding new side quests…')).not.toBeInTheDocument()
+ expect(sent(server.fetchMock)).toEqual([])
+ })
+
+ it('follows a batch another session started once a read of the deck reports it', async () => {
+ let done = false
+ const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA, SOUP, CURRY], {
+ // Five are left: this decision calls for no batch of its own.
+ 'POST /api/v1/suggestions/s1/decline': () => {
+ server.deck = [TIKKA, ORZO, PAELLA, SOUP, CURRY]
+ return declined(BOWLS, null)
+ },
+ 'GET /api/v1/jobs/j1': () => jsonResponse(200, batchJob('j1', done ? 'succeeded' : 'running')),
+ })
+ await renderPage()
+ await screen.findByRole('article', { name: 'Korean beef bowls' })
+ expect(jobPolls(server.fetchMock)).toBe(0)
+
+ // Another session asks for more meanwhile.
+ server.activeBatch = { batch_id: 'b-j1', job_id: 'j1' }
+ fireEvent.click(screen.getByRole('button', { name: 'Pass' }))
+ expect(await screen.findByText('Finding new side quests…')).toBeInTheDocument()
+ await waitFor(() => expect(jobPolls(server.fetchMock)).toBeGreaterThan(0))
+
+ server.activeBatch = null
+ done = true
+ await waitFor(() => expect(screen.queryByText('Finding new side quests…')).not.toBeInTheDocument(), { timeout: 3000 })
+ expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline'])
+ })
+
+ it.each([
+ ['one card', [BOWLS], ['translateY(8px) scale(0.95)', 'translateY(16px) scale(0.9)']],
+ ['two cards', [BOWLS, TIKKA], ['translateY(16px) scale(0.9)']],
+ ])('keeps three deck depths with %s, filling the rest with blank cards while no batch runs', async (_, deck, blankTransforms) => {
+ stubServer(deck)
+ const { container } = await renderPage()
+ await screen.findByRole('article', { name: 'Korean beef bowls' })
+
+ const layers = deckLayers(container)
+ expect(layers).toHaveLength(3)
+ expect(layers.filter((layer) => layer.tagName === 'ARTICLE')).toHaveLength(deck.length)
+ const blanks = screen.getAllByTestId('blank-card')
+ expect(blanks).toEqual(layers.slice(deck.length))
+ expect(blanks.map((blank) => declaredStyle(blank).transform)).toEqual(blankTransforms)
+ for (const blank of blanks) expect(blank).toHaveAttribute('aria-hidden', 'true')
+ // Nothing is coming: no skeleton cards and no "Finding" line.
+ expect(screen.queryByTestId('skeleton-card')).not.toBeInTheDocument()
+ expect(screen.queryByText('Finding new side quests…')).not.toBeInTheDocument()
+ })
+
+ it('fills a short deck with skeleton cards while a batch finds more Side quests', async () => {
+ const server = stubServer([BOWLS], { 'GET /api/v1/jobs/j1': () => jsonResponse(200, batchJob('j1', 'running')) })
+ server.activeBatch = { batch_id: 'b-j1', job_id: 'j1' }
+ const { container } = await renderPage()
+ await screen.findByRole('article', { name: 'Korean beef bowls' })
+
+ expect(await screen.findByText('Finding new side quests…')).toBeInTheDocument()
+ const layers = deckLayers(container)
+ expect(layers).toHaveLength(3)
+ expect(screen.getAllByTestId('skeleton-card')).toEqual(layers.slice(1))
+ expect(screen.queryByTestId('blank-card')).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()
+ })
+ })
+
describe('Quest log sheet', () => {
const PAD_THAI = questLogBody('q1', 'Pad thai', { photo_url: '/media/photos/p9-400.jpg', difficulty: 'medium', minutes_total: 35 })
const GUMBO = questLogBody('q2', 'Shrimp gumbo', { difficulty: 'hard', minutes_total: null })
@@ -749,6 +911,38 @@ describe('DiscoverPage', () => {
expect(within(sheet).getByText('Your Quest log is empty — swipe right on a side quest to add one.')).toBeInTheDocument()
expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
})
+
+ it('leaves ← / → to the Quest log sheet and its confirmation while they are open', async () => {
+ const server = stubQuestLog([PAD_THAI, GUMBO], {
+ 'POST /api/v1/suggestions/s1/accept': () => {
+ server.deck = []
+ return jsonResponse(200, { suggestion: { ...BOWLS, status: 'accepted' }, replenish_job_id: null })
+ },
+ })
+ const { container } = await renderPage()
+ await screen.findByRole('article', { name: 'Korean beef bowls' })
+ const pressArrows = (target: Element | Window) => {
+ for (const key of ['ArrowRight', 'ArrowLeft']) fireEvent.keyDown(target, { key })
+ }
+
+ const sheet = await openSheet(2)
+ pressArrows(sheet)
+ pressArrows(window)
+ fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' }))
+ const confirm = screen.getByRole('dialog', { name: 'Remove Pad thai?' })
+ pressArrows(confirm)
+ pressArrows(window)
+ await act(async () => await new Promise((resolve) => setTimeout(resolve, 0)))
+ expect(sent(server.fetchMock)).toEqual([])
+ expect(container.querySelector('article')).toHaveAccessibleName('Korean beef bowls')
+
+ // Once both are closed, the keys are the deck's again.
+ fireEvent.click(within(confirm).getByRole('button', { name: 'Keep it' }))
+ fireEvent.keyDown(sheet, { key: 'Escape' })
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ fireEvent.keyDown(window, { key: 'ArrowRight' })
+ await waitFor(() => expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']))
+ })
})
describe('optimistic decisions', () => {
@@ -860,7 +1054,7 @@ describe('DiscoverPage', () => {
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 }),
+ [`GET ${DECK}`]: () => late?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null }),
})
const queryClient = createQueryClient()
await renderPage(queryClient)
@@ -880,7 +1074,7 @@ describe('DiscoverPage', () => {
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] })))
+ await act(async () => stale.resolve(jsonResponse(200, { suggestions: [BOWLS, TIKKA, ORZO, PAELLA], active_batch: null })))
// The late answer is dropped, and the deck is read again after the save.
await act(async () => await new Promise((resolve) => setTimeout(resolve, 0)))
@@ -909,7 +1103,7 @@ describe('DiscoverPage', () => {
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(queryClient.getQueryData<{ suggestions: { id: string }[] }>(DECK_KEY)?.suggestions.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',
@@ -1054,7 +1248,7 @@ describe('DiscoverPage', () => {
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 }),
+ [`GET ${DECK}`]: () => late?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null }),
})
const queryClient = createQueryClient()
await renderPage(queryClient)
@@ -1071,7 +1265,7 @@ describe('DiscoverPage', () => {
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 })))
+ await act(async () => delayed.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null })))
const reads = deckReads(server.fetchMock)
await act(async () => lose(new TypeError('Failed to fetch')))
@@ -1088,7 +1282,7 @@ describe('DiscoverPage', () => {
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 }),
+ [`GET ${DECK}`]: () => read?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null }),
})
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
@@ -1108,7 +1302,7 @@ describe('DiscoverPage', () => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled()
- await act(async () => pending.resolve(jsonResponse(200, { suggestions: server.deck })))
+ await act(async () => pending.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null })))
const alert = await screen.findByRole('alert')
expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument()
expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')
@@ -1145,7 +1339,7 @@ describe('DiscoverPage', () => {
read = deferred()
return errorResponse(500, 'internal')
},
- [`GET ${DECK}`]: () => read?.promise ?? jsonResponse(200, { suggestions: server.deck }),
+ [`GET ${DECK}`]: () => read?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null }),
})
await renderPage()
await screen.findByRole('article', { name: 'Korean beef bowls' })
@@ -1165,7 +1359,7 @@ describe('DiscoverPage', () => {
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 })))
+ await act(async () => slow.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null })))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't save that choice.")
expect(topCard()).toHaveAccessibleName('Korean beef bowls')
@@ -1199,7 +1393,7 @@ describe('DiscoverPage', () => {
return errorResponse(500, 'internal')
},
'GET /api/v1/jobs/j1': () => poll?.promise ?? job([]),
- [`GET ${DECK}`]: () => read?.promise ?? jsonResponse(200, { suggestions: server.deck }),
+ [`GET ${DECK}`]: () => read?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null }),
})
const queryClient = createQueryClient()
await renderPage(queryClient)
@@ -1234,7 +1428,7 @@ describe('DiscoverPage', () => {
// 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 act(async () => reconciling.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null })))
await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled())
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent("Couldn't save that choice.")
@@ -1244,8 +1438,8 @@ describe('DiscoverPage', () => {
// 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']))
+ await act(async () => after.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null })))
+ await waitFor(() => expect(queryClient.getQueryData<{ suggestions: { id: string }[] }>(DECK_KEY)?.suggestions.map((q) => q.id)).toEqual(['s2', 's3', 's4', 's5']))
expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')
// Retry sends it again.
diff --git a/web/src/discover/DiscoverPage.tsx b/web/src/discover/DiscoverPage.tsx
index 5e9e010..db17367 100644
--- a/web/src/discover/DiscoverPage.tsx
+++ b/web/src/discover/DiscoverPage.tsx
@@ -91,13 +91,24 @@ export function DiscoverPage() {
// 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 cards = deck.data?.suggestions.filter((quest) => !inFlight.has(quest.id))
const questLog = useQuestLog()
const decision = useDecide()
const startBatch = useStartBatch()
// 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.
+ // more now started or joined, the replenish a decision started or joined,
+ // or the deck's active batch, which may have been started elsewhere or
+ // before the page opened.
const [batchJobId, setBatchJobId] = useState(null)
+ // A read of the deck reporting a new active batch follows its job from
+ // that render on; one reporting none leaves the last job followed, so its
+ // end and any failure stay shown.
+ const activeJobId = deck.data?.activeBatch?.job_id ?? null
+ const [seenActiveJobId, setSeenActiveJobId] = useState(null)
+ if (activeJobId !== seenActiveJobId) {
+ setSeenActiveJobId(activeJobId)
+ if (activeJobId !== null) setBatchJobId(activeJobId)
+ }
const job = useJob(BATCH_JOB_KIND, batchJobId)
const top = cards?.[0]
@@ -146,8 +157,8 @@ export function DiscoverPage() {
return true
}, [])
// 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.
+ // useDecide put back on the deck is at rest; a saved one that started or
+ // joined a replenish batch follows its job.
const send = useCallback(
(variables: { quest: SideQuest; choice: Choice }) => {
leaving.current = variables.quest.id
@@ -293,6 +304,9 @@ export function DiscoverPage() {
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
if (isEditable(event.target)) return
+ // A modal over the page (the Quest log sheet or its confirmation) makes
+ // the deck inert: its keys are the modal's, not the deck's.
+ if (deckRef.current === null || deckRef.current.closest('[inert]') !== null) return
if (event.key === 'ArrowLeft') decide('pass')
else if (event.key === 'ArrowRight') decide('try')
else return
@@ -320,6 +334,17 @@ export function DiscoverPage() {
const pending = decision.isError ? stillPending(deck, decision.variables.quest.id) : undefined
const retryable = pending !== undefined && pending !== null
+ // What went wrong asking for or finding more Side quests, shown whatever
+ // the deck has: a replenish can fail while cards remain. While Get more now
+ // is asking again, the last batch's failure is not the news.
+ const batchFailure = startBatch.isPending ? null : startBatch.isError ? (
+ Couldn't ask for more side quests. {startBatch.error.message}
+ ) : batchError === null ? null : jobRunning ? (
+ Couldn't check on new side quests. {batchError.message}
+ ) : (
+ Couldn't find new side quests. {batchError.message}
+ )
+
let body
if (deck.isError) {
body = void deck.refetch()}>Couldn't load your side quests. {deck.error.message}
@@ -334,32 +359,33 @@ export function DiscoverPage() {
))}
{finding &&
Finding new side quests…
}
- {jobRunning && batchError !== null && (
- Couldn't check on new side quests. {batchError.message}
- )}
)
} else if (top === undefined) {
body = (
- <>
- {startBatch.isError && (
- Couldn't ask for more side quests. {startBatch.error.message}
- )}
- {batchError !== null && (
- Couldn't find new side quests. {batchError.message}
- )}
-
- New side quests arrive Sunday.
-
- >
+
+ New side quests arrive Sunday.
+
)
} else {
+ const shown = cards!.slice(0, DECK_DEPTH.length)
body = (
<>
+ {/* The deck keeps all three depths: a short deck fills those it has
+ no card for with skeleton cards while a batch finds more Side
+ quests, and with blank cards, the stack's edge, while none is
+ coming. */}