Build the Supply run screen at /plan/shopping (Feature 17) (#136)
CI / ci (push) Successful in 5m1s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #136.
This commit is contained in:
2026-10-08 09:49:26 -04:00
committed by approver_agent
parent 428e87674e
commit 5d7906ae0d
10 changed files with 1545 additions and 30 deletions
+9 -3
View File
@@ -505,9 +505,15 @@ the Monday of the household's current week), so a week not yet ended is
frozen list does nothing. A list still untitled is titled first with frozen list does nothing. A list still untitled is titled first with
`archive_title` from the week's plan, as `Generate` titles it; a title `archive_title` from the week's plan, as `Generate` titles it; a title
that cannot be written is logged and the list is frozen untitled, when its that cannot be written is logged and the list is frozen untitled, when its
week label stands in for good. A `title_supply_run` job still in flight week label stands in for good. Titling and freezing are ordered by a
with the same scope shares that run (`harness.Run`); whichever stores the per-service `RWMutex`: a `title_supply_run` job holds it shared across
title first wins, and a job storing after the freeze stores nothing. The reading the list, the `archive_title` run and the title write, and `Freeze`
holds it exclusively from reading the list to freezing it. So a freeze
waits for a job in flight to store its title or fail for good; when the
job failed, the freeze finds the list still untitled and makes its own
`archive_title` attempt before freezing. A job that starts during a freeze
waits for it, then finds the list titled (that title stands) or frozen
untitled (the job fails `title_failed`), and stores nothing. The
`week_rollover` schedule is to call `Freeze` for last week. `week_rollover` schedule is to call `Freeze` for last week.
## Recipe grounding ## Recipe grounding
+25
View File
@@ -8,6 +8,7 @@ import App, { routes } from './App.tsx'
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx' import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
import { InDataRouter } from './test/dataRouter.tsx' import { InDataRouter } from './test/dataRouter.tsx'
import { dinnerBody } from './test/dinners.ts' import { dinnerBody } from './test/dinners.ts'
import { defaultWeek } from './plan/week.ts'
afterEach(() => { afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia') Reflect.deleteProperty(window, 'matchMedia')
@@ -31,6 +32,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
const SCREENS = { const SCREENS = {
'/onboarding': true, '/onboarding': true,
'/plan': true, '/plan': true,
'/plan/shopping': true,
'/discover': true, '/discover': true,
'/settings': true, '/settings': true,
'/settings/ai': true, '/settings/ai': true,
@@ -105,6 +107,29 @@ describe('App routes', () => {
expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument() expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument()
}) })
it('/plan/shopping renders the Supply run inside the shell', async () => {
mockViewport(390)
const week = defaultWeek(HOUSEHOLD_SETTINGS, new Date())
stubFetchByPath({
...SIGNED_IN,
[`/api/v1/shopping/${week}`]: () =>
jsonResponse(200, {
weekStart: week,
label: 'Week 41 · Oct 5 – 11',
title: null,
status: 'current',
sent_to_chat_at: null,
needed_count: 1,
total_count: 1,
groups: [{ dinner: { id: 'd1', name: 'Beef tacos' }, date: week, day: 'Monday', items: [{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true }] }],
}),
})
renderAt('/plan/shopping')
const heading = await screen.findByRole('heading', { level: 1, name: 'Shopping list' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByRole('checkbox', { name: 'Ground beef' })).toBeChecked()
})
it('/discover renders the Side quests deck inside the shell', async () => { it('/discover renders the Side quests deck inside the shell', async () => {
mockViewport(390) mockViewport(390)
stubFetchByPath({ stubFetchByPath({
+2
View File
@@ -16,6 +16,7 @@ import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx' import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
import { SettingsPage } from './settings/SettingsPage.tsx' import { SettingsPage } from './settings/SettingsPage.tsx'
import { AppShell } from './shell/AppShell.tsx' import { AppShell } from './shell/AppShell.tsx'
import { SupplyRunPage } from './shopping/SupplyRunPage.tsx'
// Every route the app serves, with the heading its placeholder page shows. // Every route the app serves, with the heading its placeholder page shows.
export const routes = [ export const routes = [
@@ -39,6 +40,7 @@ export const routes = [
// The screens built so far; every other route shows its placeholder page. // The screens built so far; every other route shows its placeholder page.
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = { const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/plan': <PlanPage />, '/plan': <PlanPage />,
'/plan/shopping': <SupplyRunPage />,
'/discover': <DiscoverPage />, '/discover': <DiscoverPage />,
'/settings': <SettingsPage />, '/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />, '/settings/ai': <AISettingsPage />,
+17 -2
View File
@@ -258,8 +258,8 @@
color: var(--color-neutral-700); color: var(--color-neutral-700);
} }
/* An action with nothing behind it yet (the shopping list until Feature /* An action with nothing behind it yet (Send to Google Chat until Feature
17): it keeps focus, so hovering, tapping or focusing it shows the 19): it keeps focus, so hovering, tapping or focusing it shows the
tooltip. */ tooltip. */
.unavailable { .unavailable {
position: relative; position: relative;
@@ -414,6 +414,21 @@
text-align: center; text-align: center;
} }
/* The way to the week's Supply run under the Draft board on mobile. */
.shoppingLink {
display: inline-flex;
align-self: center;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-accent-700);
text-decoration: none;
}
/* A push failure's way to the Google Calendar settings. */ /* A push failure's way to the Google Calendar settings. */
.settingsLink { .settingsLink {
color: inherit; color: inherit;
+158 -10
View File
@@ -1,6 +1,6 @@
import { QueryClientProvider } from '@tanstack/react-query' import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router' import { MemoryRouter, Route, Routes, useLocation } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts'
@@ -100,8 +100,12 @@ const lists = {
jsonResponse(200, { dinners: [dinner('d0', 'Beef tacos', 'easy', 30), dinner('r1', 'Fish tacos', 'medium', 35)] }), jsonResponse(200, { dinners: [dinner('d0', 'Beef tacos', 'easy', 30), dinner('r1', 'Fish tacos', 'medium', 35)] }),
} }
// SUPPLY_RUN is the Supply run of a week, as GET /api/v1/shopping/{weekStart}.
const SUPPLY_RUN = /^GET \/api\/v1\/shopping\/\d{4}-\d{2}-\d{2}$/
// stubServer answers "METHOD path" requests from responders, with /me and // stubServer answers "METHOD path" requests from responders, with /me and
// the household settings; any other request fails the test. // the household settings, and no Supply run (404) for a week given none;
// any other request fails the test.
function stubServer(responders: Record<string, Responder>) { function stubServer(responders: Record<string, Responder>) {
const all: Record<string, Responder> = { const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME), 'GET /api/v1/me': () => jsonResponse(200, ME),
@@ -110,7 +114,7 @@ function stubServer(responders: Record<string, Responder>) {
} }
const fetchMock = vi.fn<typeof fetch>((input, init) => { const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}` const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined const respond = Object.hasOwn(all, key) ? all[key] : SUPPLY_RUN.test(key) ? () => errorResponse(404, 'not_found') : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init)) return Promise.resolve(respond(init))
}) })
@@ -139,13 +143,37 @@ async function renderPage() {
<ApiProvider client={api}> <ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}> <QueryClientProvider client={createQueryClient()}>
<MemoryRouter initialEntries={['/plan']}> <MemoryRouter initialEntries={['/plan']}>
<PlanPage /> <Routes>
<Route path="/plan" element={<PlanPage />} />
<Route path="/plan/shopping" element={<Opened />} />
</Routes>
</MemoryRouter> </MemoryRouter>
</QueryClientProvider> </QueryClientProvider>
</ApiProvider>, </ApiProvider>,
) )
} }
// Opened stands in for the screen a link opened, saying where it is.
function Opened() {
const { pathname, search } = useLocation()
return <p>Opened {pathname + search}</p>
}
// supplyRun is the Supply run of the week of Oct 5 with needed of its 32
// items still to buy.
function supplyRun(needed: number) {
return {
weekStart: '2026-10-05',
label: 'Week 41 · Oct 5 – 11',
title: null,
status: 'current',
sent_to_chat_at: null,
needed_count: needed,
total_count: 32,
groups: [],
}
}
// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so // Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so
// the page opens on this week. // the page opens on this week.
beforeEach(() => { beforeEach(() => {
@@ -729,6 +757,85 @@ describe('PlanPage', () => {
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument() expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
}) })
it("links to the week's Supply run under the Draft board, saying how many items are still to buy", async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, supplyRun(19)),
})
await renderPage()
const shopping = await screen.findByRole('link', { name: 'Shopping list · 19' })
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
expect(declaredStyle(shopping).color).toBe('var(--color-accent-700)')
// It follows the push button and the sync status.
const push = screen.getByRole('button', { name: 'Looks good — push to Calendar' })
expect(push.compareDocumentPosition(shopping) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
fireEvent.click(shopping)
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
it("shows a failed read of the week's shopping list with Retry, the link still opening the Supply run", async () => {
let failing = true
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(19))),
})
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message")
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
// Retry reads it again.
failing = false
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
const shopping = await screen.findByRole('link', { name: 'Shopping list · 19' })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/shopping/2026-10-05')).toHaveLength(2)
fireEvent.click(shopping)
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
it("drops the count when reading the week's shopping list again fails, showing the failure with Retry", async () => {
let failing = false
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(19))),
})
await renderPage()
expect(await screen.findByRole('link', { name: 'Shopping list · 19' })).toBeInTheDocument()
// Coming back to the page once the Supply run is stale reads it again,
// and that read fails.
failing = true
vi.setSystemTime(new Date('2026-10-07T16:01:00Z'))
act(() => {
window.dispatchEvent(new Event('visibilitychange'))
})
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message")
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
expect(screen.queryByRole('link', { name: 'Shopping list · 19' })).not.toBeInTheDocument()
// Retry reads it again, and the count is back.
failing = false
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByRole('link', { name: 'Shopping list · 19' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('links to the Supply run with no count while the week has no shopping list', async () => {
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) })
await renderPage()
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
await waitFor(() => expect(fetchMock.mock.calls.map(([input]) => String(input))).toContain('/api/v1/shopping/2026-10-05'))
const shopping = screen.getByRole('link', { name: 'Shopping list' })
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
// No list is not a failure.
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
fireEvent.click(shopping)
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
it('moves between weeks with the previous and next week buttons', async () => { it('moves between weeks with the previous and next week buttons', async () => {
stubServer({ stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
@@ -1026,11 +1133,11 @@ describe.each([
expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop') expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop')
// Actions: the shopping list unavailable, saying why; Rebuild week // Actions: the shopping list, with no count while the week has no
// outlined and Push to Calendar primary, both working. // Supply run, and Rebuild week outlined; Push to Calendar primary.
const shopping = screen.getByRole('button', { name: 'Shopping list' }) const shopping = await screen.findByRole('link', { name: 'Shopping list' })
expect(shopping).toHaveAttribute('aria-disabled', 'true') expect(shopping).not.toHaveAttribute('aria-disabled')
expect(shopping).toHaveAccessibleDescription('The shopping list arrives once the Supply run is built.') expect(shopping).not.toHaveAccessibleDescription()
const push = screen.getByRole('button', { name: 'Push to Calendar' }) const push = screen.getByRole('button', { name: 'Push to Calendar' })
expect(push).toBeEnabled() expect(push).toBeEnabled()
expect(push).not.toHaveAttribute('aria-disabled') expect(push).not.toHaveAttribute('aria-disabled')
@@ -1038,7 +1145,6 @@ describe.each([
const rebuild = screen.getByRole('button', { name: 'Rebuild week' }) const rebuild = screen.getByRole('button', { name: 'Rebuild week' })
expect(rebuild).toBeEnabled() expect(rebuild).toBeEnabled()
expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)') expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)')
fireEvent.click(shopping)
expect(posts(fetchMock)).toEqual([]) expect(posts(fetchMock)).toEqual([])
// Mobile's own pieces are not shown. // Mobile's own pieces are not shown.
expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument()
@@ -1098,6 +1204,48 @@ describe.each([
expect(status).toHaveTextContent('Synced to Google Calendar 1 hr ago') expect(status).toHaveTextContent('Synced to Google Calendar 1 hr ago')
}) })
it("shows a failed read of the week's shopping list with Retry, the action still opening the Supply run", async () => {
let failing = true
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(28))),
...lists,
})
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message")
expect(screen.getByText('Spaghetti bolognese')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
failing = false
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
const shopping = await screen.findByRole('link', { name: 'Shopping list · 28' })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
fireEvent.click(shopping)
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
it("opens the week's Supply run from the Shopping list action, saying how many items are still to buy", async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, supplyRun(28)),
...lists,
})
await renderPage()
const shopping = await screen.findByRole('link', { name: 'Shopping list · 28' })
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
expect(declaredStyle(shopping).borderColor).toBe('var(--color-neutral-300)')
// Another week's action opens that week's, which has no list yet.
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-12')
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
fireEvent.click(await screen.findByRole('link', { name: 'Shopping list · 28' }))
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
it('says changes to a pushed plan are not on Google Calendar', async () => { it('says changes to a pushed plan are not on Google Calendar', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), ...lists }) stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), ...lists })
await renderPage() await renderPage()
+41 -15
View File
@@ -7,8 +7,10 @@ import { QUEST_LOG_KEY } from '../discover/api.ts'
import { Confirm } from '../settings/LeaveGuard.tsx' import { Confirm } from '../settings/LeaveGuard.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.tsx' import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts' import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { supplyRunScreen, useSupplyRun } from '../shopping/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts' import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button, type ButtonVariant } from '../ui/Button.tsx' import { Button, type ButtonVariant } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
import { EmptyState } from '../ui/EmptyState.tsx' import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx' import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx' import { Icon } from '../ui/Icon.tsx'
@@ -79,10 +81,6 @@ const settingsFailures: Record<string, { reason: string; fix: string }> = {
}, },
} }
// SHOPPING_HINT is why the shopping list is unavailable.
// TODO(Feature 17): open the Supply run, with its count, once it is built.
const SHOPPING_HINT = 'The shopping list arrives once the Supply run is built.'
// syncStatuses is the plan manager's line on whether the plan is on Calendar. // syncStatuses is the plan manager's line on whether the plan is on Calendar.
const syncStatuses: Record<PlanStatus, string> = { const syncStatuses: Record<PlanStatus, string> = {
draft: 'Not pushed to Google Calendar yet', draft: 'Not pushed to Google Calendar yet',
@@ -195,6 +193,22 @@ function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone
) )
} }
// ShoppingList opens the week's Supply run: an outline action in the plan
// manager's header, a grape link under the Draft board on mobile. Once the
// week has a Supply run it says how many items are still to buy, "Shopping
// list · 28"; until then, while that is read, or when reading it failed, it
// says no count, and the Supply run screen offers to make the list or shows
// the failure.
function ShoppingList({ weekStart, neededCount, layout }: { weekStart: string; neededCount: number | undefined; layout: Breakpoint }) {
const className = layout === 'mobile' ? styles.shoppingLink : [buttonStyles.button, buttonStyles.outline, styles.action].join(' ')
return (
<Link to={supplyRunScreen(weekStart)} className={className}>
<Icon icon={ShoppingCart} size="inline" />
{neededCount !== undefined ? `Shopping list · ${neededCount}` : 'Shopping list'}
</Link>
)
}
// SkeletonTiles stands in for the seven days while the plan loads or the AI // SkeletonTiles stands in for the seven days while the plan loads or the AI
// drafts it, as two columns of tiles on mobile and seven columns wider. // drafts it, as two columns of tiles on mobile and seven columns wider.
function SkeletonTiles({ label, layout, children }: { label: string; layout: Breakpoint; children?: string }) { function SkeletonTiles({ label, layout, children }: { label: string; layout: Breakpoint; children?: string }) {
@@ -217,9 +231,10 @@ function SkeletonTiles({ label, layout, children }: { label: string; layout: Bre
// the plan is read again. Rebuilding a plan pushed to Calendar asks first, // the plan is read again. Rebuilding a plan pushed to Calendar asks first,
// and sends confirm. A failed draft is shown with Retry; no plan is made up. // and sends confirm. A failed draft is shown with Retry; no plan is made up.
// //
// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week, Push to // From tablet up it is the plan manager (mocks 2c, 2d): Shopping list,
// Calendar and the unavailable Shopping list are actions in the header, // Rebuild week and Push to Calendar are actions in the header, over the
// over the plan's sync status and the 7-column grid. // plan's sync status and the 7-column grid. On mobile the Shopping list
// link sits under the push button.
// //
// Push to Calendar, on either layout, sends the plan shown and shows it as // Push to Calendar, on either layout, sends the plan shown and shows it as
// synced once the server answers; a failure says why, linking to the Google // synced once the server answers; a failure says why, linking to the Google
@@ -243,6 +258,7 @@ function WeekBoard({
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const swap = useSwapDay(weekStart) const swap = useSwapDay(weekStart)
const push = usePushToCalendar(weekStart) const push = usePushToCalendar(weekStart)
const run = useSupplyRun(weekStart)
const pushing = push.pending const pushing = push.pending
// The date of the day being swapped, while the swap sheet is open. // The date of the day being swapped, while the swap sheet is open.
const [swappingDate, setSwappingDate] = useState<string | null>(null) const [swappingDate, setSwappingDate] = useState<string | null>(null)
@@ -360,6 +376,16 @@ function WeekBoard({
) : ( ) : (
<ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner> <ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner>
) )
// A failed read of the week's Supply run is shown, with Retry, after the
// Draft board's Shopping list link or under the plan manager's board; the
// Shopping list action still opens the Supply run. The count is that of a
// successful read only: a failed refetch keeps the earlier Supply run in
// run.data, and its count is then no longer current.
const neededCount = run.isSuccess ? run.data?.needed_count : undefined
const shoppingList = <ShoppingList weekStart={weekStart} neededCount={neededCount} layout={layout} />
const supplyRunFailure = run.isError && (
<ErrorBanner onRetry={() => void run.refetch()}>Couldn't load this week's shopping list. {run.error.message}</ErrorBanner>
)
// swapIn sends the swap chosen in the sheet and closes it. // swapIn sends the swap chosen in the sheet and closes it.
function swapIn(chosen: Swap) { function swapIn(chosen: Swap) {
@@ -462,6 +488,8 @@ function WeekBoard({
{pushing ? 'Pushing to Calendar…' : 'Looks good — push to Calendar'} {pushing ? 'Pushing to Calendar…' : 'Looks good — push to Calendar'}
</Button> </Button>
<SyncStatus plan={plan.data} className={styles.mobileStatus} /> <SyncStatus plan={plan.data} className={styles.mobileStatus} />
{shoppingList}
{supplyRunFailure}
{sheet} {sheet}
</> </>
) )
@@ -493,10 +521,7 @@ function WeekBoard({
<div className={styles.actionBar}> <div className={styles.actionBar}>
{plan.data ? <SyncStatus plan={plan.data} /> : <span className={styles.status} />} {plan.data ? <SyncStatus plan={plan.data} /> : <span className={styles.status} />}
<div className={styles.actions}> <div className={styles.actions}>
<Unavailable hint={SHOPPING_HINT} variant="outline" below className={styles.action}> {shoppingList}
<Icon icon={ShoppingCart} size="inline" />
Shopping list
</Unavailable>
<Button <Button
variant="outline" variant="outline"
className={styles.action} className={styles.action}
@@ -537,6 +562,7 @@ function WeekBoard({
{header} {header}
{body} {body}
{failure} {failure}
{layout !== 'mobile' && supplyRunFailure}
<Confirm <Confirm
open={confirming} open={confirming}
title="Rebuild this week?" title="Rebuild this week?"
@@ -600,10 +626,10 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
) )
} }
// Unavailable is an action with nothing behind it yet (the shopping list): // Unavailable is an action with nothing behind it yet (Send to Google
// it stays focusable, so a tap or focus shows the tooltip saying why, above // Chat): it stays focusable, so a tap or focus shows the tooltip saying
// it or, at the top of the page, below. // why, above it or, at the top of the page, below.
function Unavailable({ export function Unavailable({
hint, hint,
variant = 'primary', variant = 'primary',
below = false, below = false,
+259
View File
@@ -0,0 +1,259 @@
/* Supply run · shopping list (mocks 1l, 2b, 2f). */
.page {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 18px;
}
.mobile {
max-width: 440px;
padding-top: 8px;
}
.header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.heading {
flex: 1;
min-width: 0;
}
.kicker {
margin: 0;
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-accent-700);
}
.titleRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 30px;
font-weight: 400;
line-height: 1.1;
}
/* Still to buy / all items: at the right of the title on mobile, beside it
from tablet up, where the actions take the right. */
.count {
margin: 0 0 0 auto;
font-family: var(--font-body);
font-size: 16px;
font-weight: 700;
color: var(--color-neutral-700);
white-space: nowrap;
}
.tablet .count,
.desktop .count {
margin-left: 0;
}
.helper {
margin: 6px 0 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-text);
}
.actions {
flex: none;
display: flex;
gap: 12px;
}
.action {
min-height: 44px;
text-decoration: none;
}
/* One card per dinner: stacked on mobile, three masonry columns from
tablet up. */
.groups {
display: flex;
flex-direction: column;
gap: 12px;
}
.tablet .groups,
.desktop .groups {
display: block;
column-count: 3;
column-gap: 16px;
}
.group {
box-sizing: border-box;
padding: 16px 18px 10px;
border-radius: 24px;
background: var(--color-surface);
color: var(--color-text);
break-inside: avoid;
}
.tablet .group,
.desktop .group {
margin-bottom: 16px;
}
.groupHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.dinner {
margin: 0;
font-family: var(--font-heading);
font-size: 16px;
font-weight: 400;
line-height: 1.2;
}
.day {
flex: none;
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--color-neutral-700);
}
.items {
margin: 4px 0 0;
padding: 0;
list-style: none;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
/* The checkbox's label fills the row, so the whole row up to the quantity
is the hit area. */
.check {
flex: 1;
min-width: 0;
}
.readOnly {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: center;
gap: var(--space-3);
min-height: 44px;
}
/* An archived item's mark: the checkbox's look, not interactive. */
.box {
box-sizing: border-box;
display: grid;
place-content: center;
flex: none;
width: 22px;
height: 22px;
border: 2px solid var(--color-neutral-400);
border-radius: var(--radius-sm);
}
.item:not(.have) .box {
border-color: var(--color-accent);
background: var(--color-accent);
color: var(--on-accent);
}
.mark {
width: 14px;
height: 14px;
}
.name {
font-family: var(--font-body);
font-size: 15px;
font-weight: 500;
color: var(--color-text);
}
.have .name {
text-decoration: line-through;
color: var(--color-neutral-700);
}
.qty {
flex: none;
font-family: var(--font-body);
font-size: 13px;
color: var(--color-neutral-700);
text-align: right;
}
.have .qty {
font-weight: 700;
}
.sent {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-accent-2-800);
}
.footer {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 14px;
}
.archiveLink {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 44px;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-accent-700);
text-decoration: none;
}
.skeletonTitle {
width: 220px;
height: 34px;
}
.skeletonGroup {
height: 160px;
border-radius: 24px;
}
.tablet .skeletonGroup,
.desktop .skeletonGroup {
margin-bottom: 16px;
break-inside: avoid;
}
+491
View File
@@ -0,0 +1,491 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import buttonStyles from '../ui/Button.module.css'
import { sentAt, SupplyRunPage } from './SupplyRunPage.tsx'
const THIS_WEEK = '/api/v1/shopping/2026-10-05'
const GENERATE = `${THIS_WEEK}/generate`
const ITEM = (id: string) => `/api/v1/shopping/items/${id}`
type Item = { id: string; name: string; qty: string | null; needed: boolean }
// The week of Oct 5's Supply run, as mock 1l starts it: Monday's tacos and
// Wednesday's tikka masala, each with one item the household has.
const GROUPS: { dinner: { id: string; name: string }; date: string; day: string; items: Item[] }[] = [
{
dinner: { id: 'd0', name: 'Beef tacos' },
date: '2026-10-05',
day: 'Monday',
items: [
{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true },
{ id: 'i2', name: 'Corn tortillas', qty: '12', needed: true },
{ id: 'i3', name: 'Taco seasoning', qty: '1 packet', needed: false },
],
},
{
dinner: { id: 'd2', name: 'Crock pot tikka masala' },
date: '2026-10-07',
day: 'Wednesday',
items: [
{ id: 'i4', name: 'Chicken thighs', qty: '2 lb', needed: true },
{ id: 'i5', name: 'Tikka masala paste', qty: '1 jar', needed: true },
{ id: 'i6', name: 'Basmati rice', qty: null, needed: false },
],
},
]
// supplyRun is the week's Supply run as GET /api/v1/shopping/{weekStart}
// sends it, counted from its items.
function supplyRun(overrides: Record<string, unknown> = {}, weekStart = '2026-10-05', label = 'Week 41 · Oct 5 – 11') {
const items = GROUPS.flatMap((group) => group.items)
return {
weekStart,
label,
title: null,
status: 'current',
sent_to_chat_at: null,
needed_count: items.filter((item) => item.needed).length,
total_count: items.length,
groups: GROUPS,
...overrides,
}
}
// deferred is a response the test sends when it chooses.
function deferred() {
let send!: (response: Response) => void
const response = new Promise<Response>((resolve) => {
send = resolve
})
return { response, send }
}
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
// stubServer answers "METHOD path" requests from responders, with /me and
// the household settings; any other request fails the test.
function stubServer(responders: Record<string, Responder>) {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// writes lists the PATCHes and POSTs sent, as "METHOD path body".
function writes(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => init?.method === 'PATCH' || init?.method === 'POST')
.map(([input, init]) => `${init!.method} ${String(input)} ${String(init?.body)}`)
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the Supply run at path.
async function renderPage(path = '/plan/shopping') {
const api = createApiClient(fakeLocation('/plan/shopping'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<MemoryRouter initialEntries={[path]}>
<SupplyRunPage />
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so
// the page opens on this week.
beforeEach(() => {
mockViewport(390)
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
})
function checkbox(name: string) {
return screen.getByRole('checkbox', { name })
}
function row(name: string) {
return screen.getByText(name).closest('li')!
}
describe('SupplyRunPage', () => {
it('renders mock 1l: kicker, title, the count to buy of all items and the helper', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
await renderPage()
const title = await screen.findByRole('heading', { level: 1, name: 'Shopping list' })
expect(declaredStyle(title).fontFamily).toBe('var(--font-heading)')
// The kicker is the week's dates, set in capitals by the stylesheet.
const kicker = screen.getByText('Supply run · Oct 5 – 11')
expect(declaredStyle(kicker).textTransform).toBe('uppercase')
expect(declaredStyle(kicker).color).toBe('var(--color-accent-700)')
expect(screen.getByText('4 / 6')).toBeInTheDocument()
expect(screen.getByText("Uncheck anything you already have — it won't be counted.")).toBeInTheDocument()
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
})
it('shows one surface card per dinner with its Bagel name and day', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
await renderPage()
const tacos = await screen.findByRole('region', { name: 'Beef tacos' })
expect(screen.getAllByRole('region')).toHaveLength(2)
expect(declaredStyle(tacos).borderRadius).toBe('24px')
expect(declaredStyle(tacos).background).toBe('var(--color-surface)')
const name = within(tacos).getByRole('heading', { level: 2, name: 'Beef tacos' })
expect(declaredStyle(name).fontFamily).toBe('var(--font-heading)')
expect(declaredStyle(name).fontSize).toBe('16px')
expect(declaredStyle(within(tacos).getByText('MON')).fontSize).toBe('11px')
expect(within(screen.getByRole('region', { name: 'Crock pot tikka masala' })).getByText('WED')).toBeInTheDocument()
})
it('checks the items to buy; one the household has is unchecked, struck through and "have it"', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
await renderPage()
expect(await screen.findByRole('checkbox', { name: 'Ground beef' })).toBeChecked()
expect(within(row('Ground beef')).getByText('1 lb')).toBeInTheDocument()
expect(declaredStyle(screen.getByText('Ground beef')).textDecoration).toBe('')
expect(checkbox('Taco seasoning')).not.toBeChecked()
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
expect(within(row('Taco seasoning')).queryByText('1 packet')).not.toBeInTheDocument()
expect(declaredStyle(screen.getByText('Taco seasoning')).textDecoration).toBe('line-through')
expect(screen.getAllByText('have it')).toHaveLength(2)
})
it('toggles an item at once, then shows the counts the server answers with', async () => {
const answer = deferred()
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`PATCH ${ITEM('i3')}`]: () => answer.response,
})
await renderPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
// At once, before the server answers: checked, no longer "have it", and counted.
expect(checkbox('Taco seasoning')).toBeChecked()
expect(within(row('Taco seasoning')).getByText('1 packet')).toBeInTheDocument()
expect(screen.getByText('5 / 6')).toBeInTheDocument()
await waitFor(() => expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`]))
const call = fetchMock.mock.calls.find(([, init]) => init?.method === 'PATCH')!
expect(new Headers(call[1]?.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
// The server's counts are the ones shown.
answer.send(jsonResponse(200, { id: 'i3', needed: true, needed_count: 5, total_count: 7 }))
expect(await screen.findByText('5 / 7')).toBeInTheDocument()
expect(checkbox('Taco seasoning')).toBeChecked()
})
it('marks an item "have it" by unchecking it', async () => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`PATCH ${ITEM('i1')}`]: () => jsonResponse(200, { id: 'i1', needed: false, needed_count: 3, total_count: 6 }),
})
await renderPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Ground beef' }))
expect(checkbox('Ground beef')).not.toBeChecked()
expect(within(row('Ground beef')).getByText('have it')).toBeInTheDocument()
expect(screen.getByText('3 / 6')).toBeInTheDocument()
await waitFor(() => expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i1')} {"needed":false}`]))
})
it('puts a refused toggle back, item and count, and says why with Retry', async () => {
const answer = deferred()
let patches = 0
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`PATCH ${ITEM('i3')}`]: () => {
patches++
return patches === 1 ? answer.response : jsonResponse(200, { id: 'i3', needed: true, needed_count: 5, total_count: 6 })
},
})
await renderPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
expect(checkbox('Taco seasoning')).toBeChecked()
expect(screen.getByText('5 / 6')).toBeInTheDocument()
answer.send(errorResponse(500, 'internal'))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('Couldn\'t mark Taco seasoning to buy. internal message')
expect(checkbox('Taco seasoning')).not.toBeChecked()
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
expect(screen.getByText('4 / 6')).toBeInTheDocument()
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(checkbox('Taco seasoning')).toBeChecked()
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
expect(screen.getByText('5 / 6')).toBeInTheDocument()
expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`, `PATCH ${ITEM('i3')} {"needed":true}`])
})
it('sends toggles one at a time and keeps the count true when one between them fails', async () => {
const first = deferred()
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`PATCH ${ITEM('i3')}`]: () => first.response,
[`PATCH ${ITEM('i6')}`]: () => jsonResponse(200, { id: 'i6', needed: true, needed_count: 5, total_count: 6 }),
})
await renderPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
fireEvent.click(checkbox('Basmati rice'))
expect(screen.getByText('6 / 6')).toBeInTheDocument()
// The second waits for the first.
await waitFor(() => expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`]))
first.send(errorResponse(500, 'internal'))
await waitFor(() => expect(writes(fetchMock)).toHaveLength(2))
await waitFor(() => expect(screen.getByText('5 / 6')).toBeInTheDocument())
expect(checkbox('Taco seasoning')).not.toBeChecked()
expect(checkbox('Basmati rice')).toBeChecked()
})
it('keeps a refused toggle shown when a later toggle of another item succeeds, until it is retried', async () => {
let patches = 0
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`PATCH ${ITEM('i3')}`]: () => {
patches++
return patches === 1 ? errorResponse(500, 'internal') : jsonResponse(200, { id: 'i3', needed: true, needed_count: 6, total_count: 6 })
},
[`PATCH ${ITEM('i6')}`]: () => jsonResponse(200, { id: 'i6', needed: true, needed_count: 5, total_count: 6 }),
})
await renderPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't mark Taco seasoning to buy. internal message")
expect(checkbox('Taco seasoning')).not.toBeChecked()
fireEvent.click(checkbox('Basmati rice'))
await waitFor(() => expect(screen.getByText('5 / 6')).toBeInTheDocument())
expect(checkbox('Basmati rice')).toBeChecked()
// The refusal is still shown: nothing has superseded it.
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. internal message")
expect(checkbox('Taco seasoning')).not.toBeChecked()
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(screen.getByText('6 / 6')).toBeInTheDocument())
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(checkbox('Taco seasoning')).toBeChecked()
})
it('reads the list again when a toggle is refused because the week was archived, and shows it read-only', async () => {
let reads = 0
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => {
reads++
return jsonResponse(200, supplyRun(reads === 1 ? {} : { status: 'archived' }))
},
[`PATCH ${ITEM('i3')}`]: () => errorResponse(409, 'archived'),
})
await renderPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(reads).toBe(2)
expect(screen.queryAllByRole('checkbox')).toEqual([])
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
expect(screen.getByText('4 / 6')).toBeInTheDocument()
expect(screen.getByText('This list is in the Supply archive, as it was when the week ended.')).toBeInTheDocument()
// It says why, with nothing to retry.
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. archived message")
expect(within(alert).queryByRole('button')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Taco seasoning'))
expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`])
})
it('shows the status line once the list was sent to Google Chat', async () => {
// Saturday Oct 10, 8:00 am in New York.
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })) })
await renderPage()
const status = await screen.findByText('Sent to Google Chat · Sat 8:00 am')
expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)')
})
it('has no status line until the list is sent', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
await renderPage()
await screen.findByText('4 / 6')
expect(screen.queryByText(/Sent to Google Chat/)).not.toBeInTheDocument()
})
it('stacks the cards on mobile, with a link to the Supply archive and no header actions', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
await renderPage()
await screen.findByText('4 / 6')
const groups = screen.getByTestId('supply-run-groups')
expect(declaredStyle(groups).flexDirection).toBe('column')
expect(declaredStyle(groups).columnCount).toBe('')
expect(screen.getByRole('link', { name: 'Supply archive' })).toHaveAttribute('href', '/plan/shopping/archive')
expect(screen.queryByRole('button', { name: 'Send to Google Chat' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Archive' })).not.toBeInTheDocument()
})
it.each([
['tablet (mock 2b)', 1180],
['desktop (mock 2f)', 1440],
])('lays the cards out in 3 masonry columns on %s, with Archive and Send to Google Chat', async (_layout, width) => {
mockViewport(width)
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })) })
await renderPage()
await screen.findByText('4 / 6')
const groups = screen.getByTestId('supply-run-groups')
expect(declaredStyle(groups).columnCount).toBe('3')
expect(declaredStyle(screen.getByRole('region', { name: 'Beef tacos' })).breakInside).toBe('avoid')
const archive = screen.getByRole('link', { name: 'Archive' })
expect(archive).toHaveAttribute('href', '/plan/shopping/archive')
expect(archive).toHaveClass(buttonStyles.outline!)
// Send to Google Chat waits for Feature 19: primary, unavailable, and
// its tooltip says so. Pressing it sends nothing.
const send = screen.getByRole('button', { name: 'Send to Google Chat' })
expect(send).toHaveClass(buttonStyles.primary!)
expect(send).toHaveAttribute('aria-disabled', 'true')
expect(send).toHaveAccessibleDescription('Sending the shopping list to Google Chat arrives in a later update.')
fireEvent.click(send)
expect(writes(fetchMock)).toEqual([])
expect(screen.getByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Supply archive' })).not.toBeInTheDocument()
})
it('renders an archived list read-only, tagged Archived', async () => {
const fetchMock = stubServer({
'GET /api/v1/shopping/2026-09-28': () =>
jsonResponse(200, supplyRun({ status: 'archived' }, '2026-09-28', 'Week 40 · Sep 28 – Oct 4')),
})
await renderPage('/plan/shopping?week=2026-09-28')
expect(await screen.findByText('Supply run · Sep 28 – Oct 4')).toBeInTheDocument()
expect(screen.getByText('Archived')).toBeInTheDocument()
expect(screen.getByText('This list is in the Supply archive, as it was when the week ended.')).toBeInTheDocument()
expect(screen.queryAllByRole('checkbox')).toEqual([])
// The marks still show what was bought and what was on hand.
expect(within(row('Ground beef')).getByText('1 lb')).toBeInTheDocument()
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
expect(declaredStyle(screen.getByText('Taco seasoning')).textDecoration).toBe('line-through')
expect(screen.getByText('4 / 6')).toBeInTheDocument()
fireEvent.click(screen.getByText('Taco seasoning'))
expect(screen.getByText('4 / 6')).toBeInTheDocument()
expect(writes(fetchMock)).toEqual([])
})
it('offers no Send to Google Chat for an archived list on tablet', async () => {
mockViewport(1180)
stubServer({
'GET /api/v1/shopping/2026-09-28': () =>
jsonResponse(200, supplyRun({ status: 'archived' }, '2026-09-28', 'Week 40 · Sep 28 – Oct 4')),
})
await renderPage('/plan/shopping?week=2026-09-28')
await screen.findByText('Archived')
expect(screen.queryAllByRole('checkbox')).toEqual([])
expect(screen.queryByRole('button', { name: 'Send to Google Chat' })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Archive' })).toBeInTheDocument()
})
it('refuses a week that is not a Monday', async () => {
const fetchMock = stubServer({})
await renderPage('/plan/shopping?week=2026-09-29')
expect(await screen.findByRole('alert')).toHaveTextContent('“2026-09-29” is not the Monday of a week, as YYYY-MM-DD.')
expect(fetchMock.mock.calls.filter(([input]) => String(input).startsWith('/api/v1/shopping'))).toEqual([])
})
it('shows skeleton cards while the list loads', async () => {
const answer = deferred()
stubServer({ [`GET ${THIS_WEEK}`]: () => answer.response })
await renderPage()
expect(await screen.findByRole('region', { name: 'Shopping list loading' })).toHaveAttribute('aria-busy', 'true')
await waitFor(() => expect(screen.getAllByTestId('skeleton-group')).toHaveLength(3))
answer.send(jsonResponse(200, supplyRun()))
expect(await screen.findByText('4 / 6')).toBeInTheDocument()
expect(screen.queryByTestId('skeleton-group')).not.toBeInTheDocument()
})
it('says when the list cannot load, and Retry reads it again', async () => {
let reads = 0
stubServer({
[`GET ${THIS_WEEK}`]: () => {
reads++
return reads === 1 ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun())
},
})
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't load the shopping list. internal message")
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('4 / 6')).toBeInTheDocument()
})
it('offers to make the list when the week has none, and shows the list made', async () => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${GENERATE}`]: () => jsonResponse(200, { ...supplyRun(), skipped: [] }),
})
await renderPage()
expect(await screen.findByRole('heading', { name: 'No shopping list yet' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByText('4 / 6')).toBeInTheDocument()
expect(writes(fetchMock)).toEqual([`POST ${GENERATE} undefined`])
})
it('says why the list could not be made', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${GENERATE}`]: () => errorResponse(404, 'not_found'),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't make the shopping list. not_found message")
expect(screen.getByRole('heading', { name: 'No shopping list yet' })).toBeInTheDocument()
})
})
describe('sentAt', () => {
it('says the day and time in the household zone', () => {
expect(sentAt(new Date('2026-10-10T12:00:00Z'), 'America/New_York')).toBe('Sat 8:00 am')
expect(sentAt(new Date('2026-10-11T03:30:00Z'), 'America/New_York')).toBe('Sat 11:30 pm')
})
})
+244
View File
@@ -0,0 +1,244 @@
import { Archive, Check, MessageSquare } from 'lucide-react'
import { Link, useSearchParams } from 'react-router'
import { Unavailable } from '../plan/PlanPage.tsx'
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import buttonStyles from '../ui/Button.module.css'
import { Checkbox } from '../ui/Checkbox.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import {
useGenerateSupplyRun,
useSupplyRun,
useToggles,
WEEK_PARAM,
type SupplyRunGroup,
type SupplyRunItem,
type Toggles,
} from './api.ts'
import styles from './SupplyRunPage.module.css'
// SUPPLY_ARCHIVE is the Supply archive screen.
export const SUPPLY_ARCHIVE = '/plan/shopping/archive'
// CHAT_HINT is why Send to Google Chat is unavailable.
// TODO(Feature 19): send the Supply run to Google Chat once it is built.
const CHAT_HINT = 'Sending the shopping list to Google Chat arrives in a later update.'
// sentAt is when the Supply run was sent to Google Chat as its status line
// says it, in the household's zone: "Sat 8:00 am".
export function sentAt(at: Date, timezone: string): string {
const day = new Intl.DateTimeFormat('en-US', { weekday: 'short', timeZone: timezone }).format(at)
return `${day} ${formatClockTime(localNow(timezone, at).time)}`
}
// ItemRow is one ingredient: on a current Supply run a checkbox, checked
// (grape) while it is to buy; unchecked, the name is struck through and
// "have it" stands in for the quantity. An archived Supply run shows the
// same marks, read-only.
function ItemRow({ item, toggles }: { item: SupplyRunItem; toggles: Toggles | null }) {
const name = <span className={styles.name}>{item.name}</span>
return (
<li className={[styles.item, !item.needed && styles.have].filter(Boolean).join(' ')}>
{toggles === null ? (
<span className={styles.readOnly}>
<span className={styles.box} aria-hidden="true">
{item.needed && <Icon icon={Check} size="inline" className={styles.mark} />}
</span>
{name}
</span>
) : (
<Checkbox
className={styles.check}
label={name}
checked={item.needed}
onChange={() => toggles.toggle({ id: item.id, needed: !item.needed })}
/>
)}
<span className={styles.qty}>{item.needed ? item.qty : 'have it'}</span>
</li>
)
}
// GroupCard is one dinner's items: its name in Bagel over its day.
function GroupCard({ group, toggles }: { group: SupplyRunGroup; toggles: Toggles | null }) {
return (
<section className={styles.group} aria-label={group.dinner.name}>
<header className={styles.groupHeader}>
<h2 className={styles.dinner}>{group.dinner.name}</h2>
<span className={styles.day}>{weekdayKicker(group.date)}</span>
</header>
<ul className={styles.items}>
{group.items.map((item) => (
<ItemRow key={item.id} item={item} toggles={toggles} />
))}
</ul>
</section>
)
}
// SentStatus is the status line once the Supply run was sent to Google Chat.
function SentStatus({ at, timezone }: { at: Date; timezone: string }) {
return (
<p className={styles.sent}>
<Icon icon={MessageSquare} size="inline" />
Sent to Google Chat · {sentAt(at, timezone)}
</p>
)
}
// ArchiveLink opens the Supply archive: an outline action from tablet up, a
// grape link on mobile.
function ArchiveLink({ layout }: { layout: Breakpoint }) {
const className = layout === 'mobile' ? styles.archiveLink : [buttonStyles.button, buttonStyles.outline, styles.action].join(' ')
return (
<Link to={SUPPLY_ARCHIVE} className={className}>
<Icon icon={Archive} size="inline" />
{layout === 'mobile' ? 'Supply archive' : 'Archive'}
</Link>
)
}
// SupplyRunList is a week's Supply run (mocks 1l, 2b, 2f): the kicker with
// the week's dates, the title with the count still to buy of all items, and
// one card per dinner, stacked on mobile and in three masonry columns from
// tablet up, where Archive and Send to Google Chat are actions in the
// header. On a current Supply run each item toggles at once (useToggles); a
// toggle refused puts the item back and says why. An archived one is
// read-only, and a toggle it refused is not offered again.
function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; timezone: string; layout: Breakpoint }) {
const toggles = useToggles(weekStart)
const run = toggles.shown
const archived = run.status === 'archived'
const wide = layout !== 'mobile'
const failed = toggles.failed
const failedItem = failed && run.groups.flatMap((group) => group.items).find((item) => item.id === failed.toggle.id)
return (
<div className={`${styles.page} ${styles[layout]}`}>
<header className={styles.header}>
<div className={styles.heading}>
<p className={styles.kicker}>Supply run · {weekLabel(weekStart).dates}</p>
<div className={styles.titleRow}>
<h1 className={styles.title}>Shopping list</h1>
{archived && <Tag tone="grape">Archived</Tag>}
<p className={styles.count}>
{run.needed_count} / {run.total_count}
</p>
</div>
<p className={styles.helper}>
{archived
? 'This list is in the Supply archive, as it was when the week ended.'
: "Uncheck anything you already have — it won't be counted."}
</p>
</div>
{wide && (
<div className={styles.actions}>
<ArchiveLink layout={layout} />
{!archived && (
<Unavailable hint={CHAT_HINT} below className={styles.action}>
<Icon icon={MessageSquare} size="inline" />
Send to Google Chat
</Unavailable>
)}
</div>
)}
</header>
{failed !== null && (
<ErrorBanner onRetry={archived ? undefined : () => toggles.toggle(failed.toggle)}>
Couldn't mark {failedItem ? failedItem.name : 'an item'} {failed.toggle.needed ? 'to buy' : '“have it”'}.{' '}
{failed.error.message}
</ErrorBanner>
)}
{wide && run.sent_to_chat_at !== null && <SentStatus at={run.sent_to_chat_at} timezone={timezone} />}
{run.groups.length === 0 ? (
<EmptyState title="Nothing to buy">None of this week's dinners has a recipe with ingredients yet.</EmptyState>
) : (
<div className={styles.groups} data-testid="supply-run-groups">
{run.groups.map((group) => (
<GroupCard key={`${group.date} ${group.dinner.id}`} group={group} toggles={archived ? null : toggles} />
))}
</div>
)}
{!wide && (
<footer className={styles.footer}>
{run.sent_to_chat_at !== null && <SentStatus at={run.sent_to_chat_at} timezone={timezone} />}
<ArchiveLink layout={layout} />
</footer>
)}
</div>
)
}
// SkeletonGroups stands in for the dinner cards while the Supply run loads.
function SkeletonGroups({ layout }: { layout: Breakpoint }) {
return (
<section aria-busy="true" aria-label="Shopping list loading" className={`${styles.page} ${styles[layout]}`}>
<Skeleton className={styles.skeletonTitle} />
<div className={styles.groups}>
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} data-testid="skeleton-group" className={styles.skeletonGroup} />
))}
</div>
</section>
)
}
// WeekSupplyRun is the Supply run of the week starting on weekStart, or,
// when the week has none yet, the offer to make it from the week's plan.
function WeekSupplyRun({ weekStart, timezone, layout }: { weekStart: string; timezone: string; layout: Breakpoint }) {
const run = useSupplyRun(weekStart)
const generate = useGenerateSupplyRun(weekStart)
if (run.isError) {
return <ErrorBanner onRetry={() => void run.refetch()}>Couldn't load the shopping list. {run.error.message}</ErrorBanner>
}
if (run.isPending || generate.isPending) return <SkeletonGroups layout={layout} />
if (run.data === null) {
return (
<div className={`${styles.page} ${styles[layout]}`}>
<EmptyState title="No shopping list yet" action={{ label: 'Make the shopping list', onClick: () => generate.mutate() }}>
I'll gather the ingredients of this week's planned dinners into a Supply run.
</EmptyState>
{generate.isError && (
<ErrorBanner onRetry={() => generate.mutate()}>Couldn't make the shopping list. {generate.error.message}</ErrorBanner>
)}
</div>
)
}
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} />
}
// SupplyRunPage is the Supply run (mock 1l on mobile, 2b on tablet, 2f on
// desktop) of the week the Plan opens on — next week once this week's
// plan_draft time has come, otherwise this week — or of the week chosen by
// ?week=, such as a week in the Supply archive.
export function SupplyRunPage() {
const layout = useBreakpoint()
const settings = useHouseholdSettings()
const [params] = useSearchParams()
const chosen = params.get(WEEK_PARAM)
if (chosen !== null && !isMonday(chosen)) {
return <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner>
}
if (settings.isError) {
return <ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
}
if (!settings.data) return <SkeletonGroups layout={layout} />
const week = chosen ?? defaultWeek(settings.data, new Date())
return <WeekSupplyRun key={week} weekStart={week} timezone={settings.data.timezone} layout={layout} />
}
// isMonday says whether date is a real YYYY-MM-DD Monday.
function isMonday(date: string): boolean {
try {
return weekOf(date) === date
} catch {
return false
}
}
+299
View File
@@ -0,0 +1,299 @@
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'
import { useReducer } from 'react'
import { ApiError, useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { oneOf } from '../dinners/api.ts'
export const SHOPPING_PATH = '/api/v1/shopping'
// WEEK_PARAM names the search parameter choosing the week the Supply run
// screen shows, its Monday as YYYY-MM-DD: /plan/shopping?week=2026-09-28.
export const WEEK_PARAM = 'week'
// supplyRunScreen is the Supply run screen on the week starting on Monday
// weekStart.
export function supplyRunScreen(weekStart: string): string {
return `/plan/shopping?${new URLSearchParams({ [WEEK_PARAM]: weekStart })}`
}
// supplyRunPath is the Supply run of the week starting on Monday weekStart.
export function supplyRunPath(weekStart: string): string {
return `${SHOPPING_PATH}/${encodeURIComponent(weekStart)}`
}
// supplyRunKey is the query of the Supply run of the week starting on
// weekStart.
export function supplyRunKey(weekStart: string) {
return ['shopping', weekStart] as const
}
export const SUPPLY_RUN_STATUSES = ['current', 'archived'] as const
// SupplyRunStatus is current until the week ends and the Supply run is
// frozen into the Supply archive: archived, after which nothing on it
// changes.
export type SupplyRunStatus = (typeof SUPPLY_RUN_STATUSES)[number]
// SupplyRunItem is one ingredient: qty is null when the recipe gives none,
// and needed is false once it is marked "have it".
export type SupplyRunItem = { id: string; name: string; qty: string | null; needed: boolean }
// SupplyRunGroup is the items of one plan day's dinner: date is the plan
// day, household-local YYYY-MM-DD.
export type SupplyRunGroup = { dinner: { id: string; name: string }; date: string; items: SupplyRunItem[] }
// Counts is how many of a Supply run's items are still to buy (needed) of
// all of them (total).
export type Counts = { needed_count: number; total_count: number }
// SupplyRun is GET /api/v1/shopping/{weekStart}: label is the week label,
// "Week 41 · Oct 5 – 11", title the AI's title once written, and
// sent_to_chat_at when it was sent to Google Chat, if it was.
export type SupplyRun = Counts & {
weekStart: string
label: string
title: string | null
status: SupplyRunStatus
sent_to_chat_at: Date | null
groups: SupplyRunGroup[]
}
function count(where: string, field: string, value: unknown): number {
if (!Number.isInteger(value) || (value as number) < 0) throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not a count`)
return value as number
}
function parseCounts(where: string, body: Record<string, unknown>): Counts {
const counts = { needed_count: count(where, 'needed_count', body.needed_count), total_count: count(where, 'total_count', body.total_count) }
if (counts.needed_count > counts.total_count) throw new Error(`${where}: needed_count is more than total_count`)
return counts
}
function parseItem(where: string, body: unknown): SupplyRunItem {
if (
!isRecord(body) ||
typeof body.id !== 'string' ||
typeof body.name !== 'string' ||
(body.qty !== null && typeof body.qty !== 'string') ||
typeof body.needed !== 'boolean'
) {
throw new Error(`${where}: response has an item that is not a Supply run item`)
}
return { id: body.id, name: body.name, qty: body.qty, needed: body.needed }
}
function parseGroup(where: string, body: unknown): SupplyRunGroup {
if (
!isRecord(body) ||
!isRecord(body.dinner) ||
typeof body.dinner.id !== 'string' ||
typeof body.dinner.name !== 'string' ||
typeof body.date !== 'string' ||
!Array.isArray(body.items)
) {
throw new Error(`${where}: response has a group that is not a dinner's items`)
}
return {
dinner: { id: body.dinner.id, name: body.dinner.name },
date: body.date,
items: body.items.map((item) => parseItem(where, item)),
}
}
// sentAt reads sent_to_chat_at, an RFC 3339 instant or null.
function sentAt(where: string, value: unknown): Date | null {
if (value === null) return null
const at = typeof value === 'string' ? new Date(value) : null
if (at === null || Number.isNaN(at.getTime())) {
throw new Error(`${where}: sent_to_chat_at ${JSON.stringify(value)} is not an instant`)
}
return at
}
export function parseSupplyRun(where: string, weekStart: string, body: unknown): SupplyRun {
if (
!isRecord(body) ||
body.weekStart !== weekStart ||
typeof body.label !== 'string' ||
(body.title !== null && typeof body.title !== 'string') ||
!Array.isArray(body.groups)
) {
throw new Error(`${where}: response is not the Supply run of the week of ${weekStart}`)
}
return {
weekStart,
label: body.label,
title: body.title,
status: oneOf(where, 'status', SUPPLY_RUN_STATUSES, body.status),
sent_to_chat_at: sentAt(where, body.sent_to_chat_at),
...parseCounts(where, body),
groups: body.groups.map((group) => parseGroup(where, group)),
}
}
// useSupplyRun is the Supply run of the week starting on Monday weekStart
// (GET /api/v1/shopping/{weekStart}), or null when the week has none (404).
export function useSupplyRun(weekStart: string) {
const { apiRead } = useApi()
return useQuery({
queryKey: supplyRunKey(weekStart),
queryFn: async (): Promise<SupplyRun | null> => {
const path = supplyRunPath(weekStart)
let body: unknown
try {
body = (await apiRead<unknown>(path)).body
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null
throw error
}
return parseSupplyRun(`GET ${path}`, weekStart, body)
},
})
}
// useGenerateSupplyRun makes the week's Supply run from its plan (POST
// /api/v1/shopping/{weekStart}/generate); the Supply run it answers with is
// the one shown.
export function useGenerateSupplyRun(weekStart: string) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (): Promise<SupplyRun> => {
const path = `${supplyRunPath(weekStart)}/generate`
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
},
onSuccess: (run) => queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run),
})
}
// Toggle is one item marked to buy (needed) or "have it" (not needed).
export type Toggle = { id: string; needed: boolean }
// toggleKey keys the toggles of the week starting on weekStart.
function toggleKey(weekStart: string) {
return ['shopping', weekStart, 'toggle'] as const
}
// Toggles is the toggles of one week's Supply run: shown, the Supply run
// with those still pending laid over it, the latest failed toggle no later
// toggle of its item has superseded, and toggle to make another.
export type Toggles = {
shown: SupplyRun
failed: { toggle: Toggle; error: Error } | null
toggle: (toggle: Toggle) => void
}
// useToggles marks items of the week's Supply run, already read, to buy or
// "have it" (PATCH /api/v1/shopping/items/{id}). The Supply run cached is
// the server's: a toggle's answer writes the item and the counts it answers
// with there. Until then the toggle is pending and is laid over the Supply
// run shown, so the item and the needed count change at once; a refused
// toggle just stops being pending, which puts both back.
//
// The Supply run and the toggles are read straight from the query and
// mutation caches as each render begins, never from the caches' batched
// notices, which can arrive apart: a toggle is in the mutation cache as
// pending as soon as it is made, and its answer is in the query cache
// before it stops being pending. Making a toggle renders again at once, so
// the checkbox clicked never shows its old state; useSupplyRun and
// useMutationState render again on every later change.
//
// The week's toggles are sent one at a time, in the order made (one
// mutation scope), so the counts each is answered with include every toggle
// before it and none after. Each is sent once, and not held back while the
// device is offline (networkMode always): an item never shows a state for
// longer than it takes the server to refuse it.
//
// A failed toggle stays failed, and shown, until a later toggle of its item
// supersedes it, such as its Retry: a toggle of another item succeeding
// does not hide it. So that a failure is never collected unseen, toggles are
// kept (gcTime Infinity), and a settled toggle removes the earlier toggles
// of its item it supersedes. A toggle refused because the week was frozen
// into the Supply archive (409 archived) reads the Supply run again, which
// then renders read-only.
export function useToggles(weekStart: string): Toggles {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
const key = supplyRunKey(weekStart)
const mutationKey = toggleKey(weekStart)
useSupplyRun(weekStart)
useMutationState({ filters: { mutationKey } })
const [, renderAgain] = useReducer((n: number) => n + 1, 0)
const run = queryClient.getQueryData<SupplyRun | null>(key)
if (!run) throw new Error(`The week of ${weekStart} has no Supply run read to toggle items of.`)
const toggles = queryClient
.getMutationCache()
.findAll({ mutationKey })
.map((mutation) => ({ status: mutation.state.status, toggle: mutation.state.variables as Toggle, error: mutation.state.error }))
const failed = toggles
.filter((t, i) => t.status === 'error' && !toggles.slice(i + 1).some((later) => later.toggle.id === t.toggle.id))
.at(-1)
const { mutate } = useMutation({
mutationKey,
scope: { id: mutationKey.join(' ') },
networkMode: 'always',
retry: false,
gcTime: Infinity,
mutationFn: async ({ id, needed }: Toggle): Promise<Counts> => {
const path = `${SHOPPING_PATH}/items/${encodeURIComponent(id)}`
const where = `PATCH ${path}`
const body = await apiFetch<unknown>(path, { method: 'PATCH', body: { needed } })
if (!isRecord(body) || body.id !== id || body.needed !== needed) throw new Error(`${where}: response is not item ${id} as toggled`)
return parseCounts(where, body)
},
// A read of the Supply run sent before the toggle must not land after
// its answer, over it.
onMutate: () => queryClient.cancelQueries({ queryKey: key }),
onSuccess: (counts, { id, needed }) => {
queryClient.setQueryData<SupplyRun | null>(
key,
(run) =>
run && {
...run,
...counts,
groups: run.groups.map((group) => ({
...group,
items: group.items.map((item) => (item.id === id ? { ...item, needed } : item)),
})),
},
)
},
onError: (error) => {
if (error instanceof ApiError && error.code === 'archived') void queryClient.invalidateQueries({ queryKey: key })
},
// This toggle is still pending as it settles, and those after it wait
// for it: the settled toggles of its item are the earlier ones.
onSettled: (_counts, _error, { id }) => {
const cache = queryClient.getMutationCache()
for (const earlier of cache.findAll({ mutationKey, predicate: (m) => m.state.status !== 'pending' })) {
if ((earlier.state.variables as Toggle).id === id) cache.remove(earlier)
}
},
})
return {
shown: shown(run, toggles.filter((t) => t.status === 'pending').map((t) => t.toggle)),
failed: failed === undefined ? null : { toggle: failed.toggle, error: failed.error! },
toggle: (toggle) => {
mutate(toggle)
renderAgain()
},
}
}
// shown is run as the household sees it with the pending toggles laid over
// it: each item as its latest pending toggle marks it, and the needed count
// moved to match.
function shown(run: SupplyRun, pending: Toggle[]): SupplyRun {
const marked = new Map(pending.map((toggle) => [toggle.id, toggle.needed]))
let needed = run.needed_count
const groups = run.groups.map((group) => ({
...group,
items: group.items.map((item) => {
const mark = marked.get(item.id)
if (mark === undefined || mark === item.needed) return item
needed += mark ? 1 : -1
return { ...item, needed: mark }
}),
}))
return { ...run, needed_count: needed, groups }
}