From 5d7906ae0de4a8c4627b2dfe9c27252076daae1b Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Thu, 8 Oct 2026 09:49:26 -0400 Subject: [PATCH] Build the Supply run screen at /plan/shopping (Feature 17) (#136) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- docs/ARCHITECTURE.md | 12 +- web/src/App.test.tsx | 25 ++ web/src/App.tsx | 2 + web/src/plan/PlanPage.module.css | 19 +- web/src/plan/PlanPage.test.tsx | 168 +++++++- web/src/plan/PlanPage.tsx | 56 ++- web/src/shopping/SupplyRunPage.module.css | 259 ++++++++++++ web/src/shopping/SupplyRunPage.test.tsx | 491 ++++++++++++++++++++++ web/src/shopping/SupplyRunPage.tsx | 244 +++++++++++ web/src/shopping/api.ts | 299 +++++++++++++ 10 files changed, 1545 insertions(+), 30 deletions(-) create mode 100644 web/src/shopping/SupplyRunPage.module.css create mode 100644 web/src/shopping/SupplyRunPage.test.tsx create mode 100644 web/src/shopping/SupplyRunPage.tsx create mode 100644 web/src/shopping/api.ts 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.module.css b/web/src/plan/PlanPage.module.css index 49acb3b..9cac0e3 100644 --- a/web/src/plan/PlanPage.module.css +++ b/web/src/plan/PlanPage.module.css @@ -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; diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index f3d73bf..6f4ec8c 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -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) { const all: Record = { 'GET /api/v1/me': () => jsonResponse(200, ME), @@ -110,7 +114,7 @@ function stubServer(responders: Record) { } const fetchMock = vi.fn((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() { - + + } /> + } /> + , ) } +// Opened stands in for the screen a link opened, saying where it is. +function Opened() { + const { pathname, search } = useLocation() + return

Opened {pathname + search}

+} + +// 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() diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 4cf8667..8f97f99 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -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 = { }, } -// 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 = { 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 ( + + + {neededCount !== undefined ? `Shopping list · ${neededCount}` : 'Shopping list'} + + ) +} + // 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(null) @@ -360,6 +376,16 @@ function WeekBoard({ ) : ( Couldn't check on the draft. {job.error.message} ) + // 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 = + const supplyRunFailure = run.isError && ( + void run.refetch()}>Couldn't load this week's shopping list. {run.error.message} + ) // 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'} + {shoppingList} + {supplyRunFailure} {sheet} ) @@ -493,10 +521,7 @@ function WeekBoard({
{plan.data ? : }
- - - Shopping list - + {shoppingList}