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
`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
week label stands in for good. A `title_supply_run` job still in flight
with the same scope shares that run (`harness.Run`); whichever stores the
title first wins, and a job storing after the freeze stores nothing. The
week label stands in for good. Titling and freezing are ordered by a
per-service `RWMutex`: a `title_supply_run` job holds it shared across
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.
## 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 { InDataRouter } from './test/dataRouter.tsx'
import { dinnerBody } from './test/dinners.ts'
import { defaultWeek } from './plan/week.ts'
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
@@ -31,6 +32,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
const SCREENS = {
'/onboarding': true,
'/plan': true,
'/plan/shopping': true,
'/discover': true,
'/settings': true,
'/settings/ai': true,
@@ -105,6 +107,29 @@ describe('App routes', () => {
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 () => {
mockViewport(390)
stubFetchByPath({
+2
View File
@@ -16,6 +16,7 @@ import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
import { SettingsPage } from './settings/SettingsPage.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.
export const routes = [
@@ -39,6 +40,7 @@ export const routes = [
// The screens built so far; every other route shows its placeholder page.
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/plan': <PlanPage />,
'/plan/shopping': <SupplyRunPage />,
'/discover': <DiscoverPage />,
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
+17 -2
View File
@@ -258,8 +258,8 @@
color: var(--color-neutral-700);
}
/* An action with nothing behind it yet (the shopping list until Feature
17): it keeps focus, so hovering, tapping or focusing it shows the
/* An action with nothing behind it yet (Send to Google Chat until Feature
19): it keeps focus, so hovering, tapping or focusing it shows the
tooltip. */
.unavailable {
position: relative;
@@ -414,6 +414,21 @@
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. */
.settingsLink {
color: inherit;
+158 -10
View File
@@ -1,6 +1,6 @@
import { QueryClientProvider } from '@tanstack/react-query'
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 { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.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)] }),
}
// 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
// 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>) {
const all: Record<string, Responder> = {
'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 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}`)
return Promise.resolve(respond(init))
})
@@ -139,13 +143,37 @@ async function renderPage() {
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<MemoryRouter initialEntries={['/plan']}>
<PlanPage />
<Routes>
<Route path="/plan" element={<PlanPage />} />
<Route path="/plan/shopping" element={<Opened />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
</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
// the page opens on this week.
beforeEach(() => {
@@ -729,6 +757,85 @@ describe('PlanPage', () => {
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 () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
@@ -1026,11 +1133,11 @@ describe.each([
expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop')
// Actions: the shopping list unavailable, saying why; Rebuild week
// outlined and Push to Calendar primary, both working.
const shopping = screen.getByRole('button', { name: 'Shopping list' })
expect(shopping).toHaveAttribute('aria-disabled', 'true')
expect(shopping).toHaveAccessibleDescription('The shopping list arrives once the Supply run is built.')
// Actions: the shopping list, with no count while the week has no
// Supply run, and Rebuild week outlined; Push to Calendar primary.
const shopping = await screen.findByRole('link', { name: 'Shopping list' })
expect(shopping).not.toHaveAttribute('aria-disabled')
expect(shopping).not.toHaveAccessibleDescription()
const push = screen.getByRole('button', { name: 'Push to Calendar' })
expect(push).toBeEnabled()
expect(push).not.toHaveAttribute('aria-disabled')
@@ -1038,7 +1145,6 @@ describe.each([
const rebuild = screen.getByRole('button', { name: 'Rebuild week' })
expect(rebuild).toBeEnabled()
expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)')
fireEvent.click(shopping)
expect(posts(fetchMock)).toEqual([])
// Mobile's own pieces are not shown.
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')
})
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 () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), ...lists })
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 { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { supplyRunScreen, useSupplyRun } from '../shopping/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button, type ButtonVariant } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.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.
const syncStatuses: Record<PlanStatus, string> = {
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
// drafts it, as two columns of tiles on mobile and seven columns wider.
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,
// 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
// Calendar and the unavailable Shopping list are actions in the header,
// over the plan's sync status and the 7-column grid.
// From tablet up it is the plan manager (mocks 2c, 2d): Shopping list,
// Rebuild week and Push to Calendar are actions in the header, over the
// 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
// 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 swap = useSwapDay(weekStart)
const push = usePushToCalendar(weekStart)
const run = useSupplyRun(weekStart)
const pushing = push.pending
// The date of the day being swapped, while the swap sheet is open.
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>
)
// 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.
function swapIn(chosen: Swap) {
@@ -462,6 +488,8 @@ function WeekBoard({
{pushing ? 'Pushing to Calendar…' : 'Looks good — push to Calendar'}
</Button>
<SyncStatus plan={plan.data} className={styles.mobileStatus} />
{shoppingList}
{supplyRunFailure}
{sheet}
</>
)
@@ -493,10 +521,7 @@ function WeekBoard({
<div className={styles.actionBar}>
{plan.data ? <SyncStatus plan={plan.data} /> : <span className={styles.status} />}
<div className={styles.actions}>
<Unavailable hint={SHOPPING_HINT} variant="outline" below className={styles.action}>
<Icon icon={ShoppingCart} size="inline" />
Shopping list
</Unavailable>
{shoppingList}
<Button
variant="outline"
className={styles.action}
@@ -537,6 +562,7 @@ function WeekBoard({
{header}
{body}
{failure}
{layout !== 'mobile' && supplyRunFailure}
<Confirm
open={confirming}
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):
// it stays focusable, so a tap or focus shows the tooltip saying why, above
// it or, at the top of the page, below.
function Unavailable({
// Unavailable is an action with nothing behind it yet (Send to Google
// Chat): it stays focusable, so a tap or focus shows the tooltip saying
// why, above it or, at the top of the page, below.
export function Unavailable({
hint,
variant = 'primary',
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 }
}