diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3ebc9db..6522df2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index a3dde74..ff06886 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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({ diff --git a/web/src/App.tsx b/web/src/App.tsx index de51b4f..17c0a7c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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> = { '/plan': , + '/plan/shopping': , '/discover': , '/settings': , '/settings/ai': , diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 4cf8667..c5bf6ce 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -600,10 +600,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 (the shopping list, +// 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, diff --git a/web/src/shopping/SupplyRunPage.module.css b/web/src/shopping/SupplyRunPage.module.css new file mode 100644 index 0000000..94c0671 --- /dev/null +++ b/web/src/shopping/SupplyRunPage.module.css @@ -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; +} diff --git a/web/src/shopping/SupplyRunPage.test.tsx b/web/src/shopping/SupplyRunPage.test.tsx new file mode 100644 index 0000000..f149685 --- /dev/null +++ b/web/src/shopping/SupplyRunPage.test.tsx @@ -0,0 +1,434 @@ +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 = {}, 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((resolve) => { + send = resolve + }) + return { response, send } +} + +type Responder = (init: RequestInit | undefined) => Response | Promise + +// stubServer answers "METHOD path" requests from responders, with /me and +// the household settings; any other request fails the test. +function stubServer(responders: Record) { + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + 'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), + ...responders, + } + const fetchMock = vi.fn((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) { + 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( + + + + + + + , + ) +} + +// 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 API's week label, set in capitals by the stylesheet. + const kicker = screen.getByText('Supply run · Week 41 · 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('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 · Week 40 · 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') + }) +}) diff --git a/web/src/shopping/SupplyRunPage.tsx b/web/src/shopping/SupplyRunPage.tsx new file mode 100644 index 0000000..bc5542c --- /dev/null +++ b/web/src/shopping/SupplyRunPage.tsx @@ -0,0 +1,240 @@ +import { Archive, Check, MessageSquare } from 'lucide-react' +import { Link, useSearchParams } from 'react-router' +import { Unavailable } from '../plan/PlanPage.tsx' +import { defaultWeek, localNow, weekdayKicker, 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, 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' + +// WEEK_PARAM names the search parameter choosing the week shown, its Monday +// as YYYY-MM-DD: /plan/shopping?week=2026-09-28. +export const WEEK_PARAM = 'week' + +// 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 = {item.name} + return ( +
  • + {toggles === null ? ( + + + {name} + + ) : ( + toggles.toggle({ id: item.id, needed: !item.needed })} + /> + )} + {item.needed ? item.qty : 'have it'} +
  • + ) +} + +// GroupCard is one dinner's items: its name in Bagel over its day. +function GroupCard({ group, toggles }: { group: SupplyRunGroup; toggles: Toggles | null }) { + return ( +
    +
    +

    {group.dinner.name}

    + {weekdayKicker(group.date)} +
    +
      + {group.items.map((item) => ( + + ))} +
    +
    + ) +} + +// SentStatus is the status line once the Supply run was sent to Google Chat. +function SentStatus({ at, timezone }: { at: Date; timezone: string }) { + return ( +

    + + Sent to Google Chat · {sentAt(at, timezone)} +

    + ) +} + +// 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 ( + + + {layout === 'mobile' ? 'Supply archive' : 'Archive'} + + ) +} + +// SupplyRunList is a week's Supply run (mocks 1l, 2b, 2f): the kicker with +// the week label, 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. +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 ( +
    +
    +
    +

    Supply run · {run.label}

    +
    +

    Shopping list

    + {archived && Archived} +

    + {run.needed_count} / {run.total_count} +

    +
    +

    + {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."} +

    +
    + {wide && ( +
    + + {!archived && ( + + + Send to Google Chat + + )} +
    + )} +
    + {failed !== null && ( + toggles.toggle(failed.toggle)}> + Couldn't mark {failedItem ? failedItem.name : 'an item'} {failed.toggle.needed ? 'to buy' : '“have it”'}.{' '} + {failed.error.message} + + )} + {wide && run.sent_to_chat_at !== null && } + {run.groups.length === 0 ? ( + None of this week's dinners has a recipe with ingredients yet. + ) : ( +
    + {run.groups.map((group) => ( + + ))} +
    + )} + {!wide && ( +
    + {run.sent_to_chat_at !== null && } + +
    + )} +
    + ) +} + +// SkeletonGroups stands in for the dinner cards while the Supply run loads. +function SkeletonGroups({ layout }: { layout: Breakpoint }) { + return ( +
    + +
    + {Array.from({ length: 3 }, (_, i) => ( + + ))} +
    +
    + ) +} + +// 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 void run.refetch()}>Couldn't load the shopping list. {run.error.message} + } + if (run.isPending || generate.isPending) return + if (run.data === null) { + return ( +
    + generate.mutate() }}> + I'll gather the ingredients of this week's planned dinners into a Supply run. + + {generate.isError && ( + generate.mutate()}>Couldn't make the shopping list. {generate.error.message} + )} +
    + ) + } + return +} + +// 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 {`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`} + } + if (settings.isError) { + return void settings.refetch()}>Couldn't load the household settings. {settings.error.message} + } + if (!settings.data) return + const week = chosen ?? defaultWeek(settings.data, new Date()) + return +} + +// isMonday says whether date is a real YYYY-MM-DD Monday. +function isMonday(date: string): boolean { + try { + return weekOf(date) === date + } catch { + return false + } +} diff --git a/web/src/shopping/api.ts b/web/src/shopping/api.ts new file mode 100644 index 0000000..fab2326 --- /dev/null +++ b/web/src/shopping/api.ts @@ -0,0 +1,267 @@ +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' + +// 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): 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 => { + const path = supplyRunPath(weekStart) + let body: unknown + try { + body = (await apiRead(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 => { + const path = `${supplyRunPath(weekStart)}/generate` + return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST' })) + }, + onSuccess: (run) => queryClient.setQueryData(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 toggle if it failed, +// 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. +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(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 latest = toggles.at(-1) + const { mutate } = useMutation({ + mutationKey, + scope: { id: mutationKey.join(' ') }, + networkMode: 'always', + retry: false, + mutationFn: async ({ id, needed }: Toggle): Promise => { + const path = `${SHOPPING_PATH}/items/${encodeURIComponent(id)}` + const where = `PATCH ${path}` + const body = await apiFetch(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( + key, + (run) => + run && { + ...run, + ...counts, + groups: run.groups.map((group) => ({ + ...group, + items: group.items.map((item) => (item.id === id ? { ...item, needed } : item)), + })), + }, + ) + }, + }) + return { + shown: shown(run, toggles.filter((t) => t.status === 'pending').map((t) => t.toggle)), + failed: latest?.status === 'error' ? { toggle: latest.toggle, error: latest.error! } : null, + 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 } +}