From 00f9e304ad0bd94558b6d77ec651aad2565498c0 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 11:54:25 -0400 Subject: [PATCH 1/3] Build the Supply archive screen at /plan/shopping/archive (Feature 17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mock 1m, and the 2f right panel on desktop: a Week | Month | Year segmented control drives GET /api/v1/shopping/archive?by=, whose periods are rendered as the server groups them in the household's zone, under Bagel 18 px headings for month and year. Each post card shows the week label kicker (capitalised in CSS), the AI title or the week label while it is untitled, the counts and its first dinners as chips; the API's current post is ringed grape-100 + grape-300 with "· Current". "Load older" shows eight more posts at a time. A post opens the archive on its week (?week=), shown by the Supply run screen's own rendering: read-only and tagged Archived once frozen, with no checkbox and no PATCH. On desktop the archive is a panel beside that list; on mobile and tablet it is the archive or the open post's list. Maestro task 3cf3a22a-ae31-4f5f-989d-bf09824583d1. Co-Authored-By: Claude Opus 5.5 --- web/src/App.test.tsx | 13 + web/src/App.tsx | 2 + web/src/shopping/SupplyArchivePage.module.css | 144 ++++++++ web/src/shopping/SupplyArchivePage.test.tsx | 340 ++++++++++++++++++ web/src/shopping/SupplyArchivePage.tsx | 184 ++++++++++ web/src/shopping/api.ts | 74 ++++ 6 files changed, 757 insertions(+) create mode 100644 web/src/shopping/SupplyArchivePage.module.css create mode 100644 web/src/shopping/SupplyArchivePage.test.tsx create mode 100644 web/src/shopping/SupplyArchivePage.tsx diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index ff06886..35fcc2c 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -33,6 +33,7 @@ const SCREENS = { '/onboarding': true, '/plan': true, '/plan/shopping': true, + '/plan/shopping/archive': true, '/discover': true, '/settings': true, '/settings/ai': true, @@ -130,6 +131,18 @@ describe('App routes', () => { expect(await screen.findByRole('checkbox', { name: 'Ground beef' })).toBeChecked() }) + it('/plan/shopping/archive renders the Supply archive inside the shell', async () => { + mockViewport(390) + stubFetchByPath({ + ...SIGNED_IN, + '/api/v1/shopping/archive?by=week': () => jsonResponse(200, { by: 'week', periods: [] }), + }) + renderAt('/plan/shopping/archive') + const heading = await screen.findByRole('heading', { level: 1, name: 'Supply archive' }) + expect(screen.getByRole('main')).toContainElement(heading) + expect(await screen.findByText('Nothing archived yet')).toBeInTheDocument() + }) + 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 0472284..59c2b85 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -17,6 +17,7 @@ import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx' import { SettingsPage } from './settings/SettingsPage.tsx' import { AppShell } from './shell/AppShell.tsx' import { SupplyRunSync } from './shopping/api.ts' +import { SupplyArchivePage } from './shopping/SupplyArchivePage.tsx' import { SupplyRunPage } from './shopping/SupplyRunPage.tsx' // Every route the app serves, with the heading its placeholder page shows. @@ -42,6 +43,7 @@ export const routes = [ const screens: Partial> = { '/plan': , '/plan/shopping': , + '/plan/shopping/archive': , '/discover': , '/settings': , '/settings/ai': , diff --git a/web/src/shopping/SupplyArchivePage.module.css b/web/src/shopping/SupplyArchivePage.module.css new file mode 100644 index 0000000..8476795 --- /dev/null +++ b/web/src/shopping/SupplyArchivePage.module.css @@ -0,0 +1,144 @@ +/* Supply archive · weekly posts (mock 1m, and the right panel of 2f). */ +.archive { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 14px; + max-width: 560px; + padding-top: 8px; +} + +.header { + display: flex; + align-items: center; + gap: 10px; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 24px; + font-weight: 400; + line-height: 1.1; +} + +.periods { + align-self: flex-start; +} + +.period { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 6px; +} + +.heading { + margin: 0; + font-family: var(--font-heading); + font-size: 18px; + font-weight: 400; + line-height: 1.2; +} + +.posts { + display: flex; + flex-direction: column; + gap: 12px; + margin: 0; + padding: 0; + list-style: none; +} + +.post { + display: flex; + flex-direction: column; + gap: 6px; + padding: 16px; + border-radius: 24px; + background: var(--color-surface); + color: var(--color-text); + text-decoration: none; +} + +.post:focus-visible { + outline: 3px solid var(--color-accent); + outline-offset: 2px; +} + +/* The current week's post: grape-100 with a grape-300 ring. */ +.current { + background: var(--color-accent-100); + box-shadow: inset 0 0 0 2px var(--color-accent-300); +} + +/* The week label as the API writes it, capitalised here. */ +.kicker { + font-family: var(--font-body); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-neutral-700); +} + +.current .kicker { + color: var(--color-accent-700); +} + +.postTitle { + font-family: var(--font-heading); + font-size: 18px; + line-height: 1.15; +} + +.meta { + font-family: var(--font-body); + font-size: 13px; + color: var(--color-neutral-800); +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.chip { + padding: 3px 10px; + border-radius: var(--radius-pill); + background: var(--color-bg); + font-family: var(--font-body); + font-size: 11px; + font-weight: 700; +} + +.older { + align-self: center; +} + +.skeletonPost { + height: 112px; + border-radius: 24px; +} + +/* Desktop (mock 2f): the week's list at the left, the archive in a 340 px + panel at the right. */ +.split { + display: flex; + gap: 24px; + align-items: flex-start; +} + +.list { + flex: 1; + min-width: 0; +} + +.panel { + flex: none; + box-sizing: border-box; + width: 340px; + padding-left: 24px; + border-left: 1px solid var(--color-divider); +} diff --git a/web/src/shopping/SupplyArchivePage.test.tsx b/web/src/shopping/SupplyArchivePage.test.tsx new file mode 100644 index 0000000..1bb1c39 --- /dev/null +++ b/web/src/shopping/SupplyArchivePage.test.tsx @@ -0,0 +1,340 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi } 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 { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { mockViewport } from '../test/matchMedia.ts' +import type { ArchivePost } from './api.ts' +import { PAGE, SupplyArchivePage } from './SupplyArchivePage.tsx' +import styles from './SupplyArchivePage.module.css' + +const ARCHIVE = (by: string) => `/api/v1/shopping/archive?by=${by}` + +// post is one Supply archive post as GET /api/v1/shopping/archive sends it. +function post(weekStart: string, label: string, overrides: Partial = {}): ArchivePost { + return { + weekStart, + label, + title: null, + dinnerCount: 7, + itemCount: 23, + onHandCount: 4, + dinners: ['Beef tacos', 'Tikka masala', 'Pizza', 'Pot roast', 'Orzo', 'Chili', 'Salmon'], + current: false, + ...overrides, + } +} + +// The posts of mock 1m: this week's titled post, then the weeks across the +// September–October boundary and the 2025–2026 one, the last untitled. +const WEEK_41 = post('2026-10-05', 'Week 41 · Oct 5 – 11', { title: 'Tacos, tikka & a salmon finale', current: true }) +const WEEK_40 = post('2026-09-28', 'Week 40 · Sep 28 – Oct 4', { title: 'Pizza, pot roast & the great orzo trial', itemCount: 26 }) +const WEEK_39 = post('2026-09-21', 'Week 39 · Sep 21 – 27', { title: 'Chili season opens', dinnerCount: 1, itemCount: 1, onHandCount: 0, dinners: ['Chili'] }) +const WEEK_1 = post('2025-12-29', 'Week 1 · Dec 29 – Jan 4') + +// The archive as the server groups it: by month and year a week belongs to +// the period of its Monday, so week 40 is September's and week 1 is 2025's. +const BY = { + week: [WEEK_41, WEEK_40, WEEK_39, WEEK_1].map((p) => ({ key: p.weekStart, heading: p.label, posts: [p] })), + month: [ + { key: '2026-10', heading: 'October 2026', posts: [WEEK_41] }, + { key: '2026-09', heading: 'September 2026', posts: [WEEK_40, WEEK_39] }, + { key: '2025-12', heading: 'December 2025', posts: [WEEK_1] }, + ], + year: [ + { key: '2026', heading: '2026', posts: [WEEK_41, WEEK_40, WEEK_39] }, + { key: '2025', heading: '2025', posts: [WEEK_1] }, + ], +} + +function archive(by: keyof typeof BY) { + return () => jsonResponse(200, { by, periods: BY[by] }) +} + +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 +} + +// renderPage loads /me (for the CSRF token any write would need, so a +// missing PATCH is not for want of it) and renders the Supply archive at path. +async function renderPage(path = '/plan/shopping/archive') { + const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + return render( + + + + + } /> + Supply run screen

} /> +
+
+
+
, + ) +} + +// postCard is the post titled title, a link. +function postCard(title: string) { + return screen.getByText(title).closest('a')! +} + +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') +}) + +describe('SupplyArchivePage', () => { + it('renders mock 1m by week: one post per week with its kicker, title, counts and dinner chips, under no heading', async () => { + stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') }) + await renderPage() + + expect(screen.getByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument() + expect(screen.getByRole('radio', { name: 'Week' })).toBeChecked() + const card = await screen.findByText('Pizza, pot roast & the great orzo trial') + const post40 = card.closest('a')! + expect(within(post40).getByText('Week 40 · Sep 28 – Oct 4')).toHaveClass(styles.kicker!) + expect(post40).toHaveTextContent('7 dinners · 26 items · 4 already on hand') + expect(within(post40).getByText('Beef tacos')).toHaveClass(styles.chip!) + expect(within(post40).getByText('Tikka masala')).toHaveClass(styles.chip!) + expect(within(post40).getByText('+5')).toHaveClass(styles.chip!) + expect(within(post40).queryByText('Pizza')).not.toBeInTheDocument() + expect(postCard('Chili season opens')).toHaveTextContent('1 dinner · 1 item · 0 already on hand') + expect(within(postCard('Chili season opens')).queryByText(/^\+/)).not.toBeInTheDocument() + + expect(screen.getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([ + '/plan/shopping/archive?week=2026-10-05', + '/plan/shopping/archive?week=2026-09-28', + '/plan/shopping/archive?week=2026-09-21', + '/plan/shopping/archive?week=2025-12-29', + ]) + expect(screen.queryAllByRole('heading', { level: 2 })).toEqual([]) + }) + + it('capitalises the week label kicker in CSS from the label as the API writes it', async () => { + stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') }) + await renderPage() + expect(await screen.findByText('Week 39 · Sep 21 – 27')).toHaveClass(styles.kicker!) + }) + + it.each([ + ['Month', 'month', ['October 2026', 'September 2026', 'December 2025']], + ['Year', 'year', ['2026', '2025']], + ] as const)('groups by %s under the headings the server sends, across the month and year boundaries', async (label, by, headings) => { + const fetchMock = stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week'), [`GET ${ARCHIVE(by)}`]: archive(by) }) + await renderPage() + await screen.findByText('Chili season opens') + + fireEvent.click(screen.getByRole('radio', { name: label })) + + await waitFor(() => expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(headings)) + expect(screen.getByRole('heading', { level: 2, name: headings[0] })).toHaveClass(styles.heading!) + expect(fetchMock.mock.calls.map(([input]) => String(input))).toContain(ARCHIVE(by)) + for (const period of BY[by]) { + const section = screen.getByRole('region', { name: period.heading }) + expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual( + period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`), + ) + } + }) + + it('highlights the current week’s post, by the API’s current, and no other', async () => { + stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') }) + await renderPage() + + const current = (await screen.findByText('Tacos, tikka & a salmon finale')).closest('a')! + expect(current).toHaveClass(styles.current!) + expect(within(current).getByText('Week 41 · Oct 5 – 11 · Current')).toBeInTheDocument() + for (const title of ['Pizza, pot roast & the great orzo trial', 'Chili season opens']) { + expect(postCard(title)).not.toHaveClass(styles.current!) + expect(postCard(title)).not.toHaveTextContent('Current') + } + }) + + it('follows current from the API, not today’s date', async () => { + const past = { ...WEEK_40, current: true } + stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'week', periods: [{ key: 'w', heading: past.label, posts: [{ ...WEEK_41, current: false }, past] }] }) }) + await renderPage() + + expect(await screen.findByText('Week 40 · Sep 28 – Oct 4 · Current')).toBeInTheDocument() + expect(postCard('Tacos, tikka & a salmon finale')).not.toHaveClass(styles.current!) + }) + + it('titles an untitled post with its week label', async () => { + stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') }) + await renderPage() + + await screen.findByText('Chili season opens') + const untitled = screen.getByRole('link', { name: /Week 1 · Dec 29 – Jan 4/ }) + const label = within(untitled).getAllByText('Week 1 · Dec 29 – Jan 4') + expect(label.map((el) => el.className)).toEqual([styles.kicker, styles.postTitle]) + }) + + it(`shows ${PAGE} posts and "Load older" shows the next ${PAGE}, none twice and none skipped`, async () => { + const weeks = Array.from({ length: 2 * PAGE + 3 }, (_, i) => { + const monday = new Date(Date.UTC(2026, 9, 5 - 7 * i)).toISOString().slice(0, 10) + return post(monday, `Week of ${monday}`, { title: `Post ${i}` }) + }) + // By month, so the cut falls inside a period and its heading is shown once. + const periods = [ + { key: 'a', heading: 'Newer', posts: weeks.slice(0, PAGE + 2) }, + { key: 'b', heading: 'Older', posts: weeks.slice(PAGE + 2) }, + ] + stubServer({ + [`GET ${ARCHIVE('week')}`]: archive('week'), + [`GET ${ARCHIVE('month')}`]: () => jsonResponse(200, { by: 'month', periods }), + }) + await renderPage() + await screen.findByText('Chili season opens') + fireEvent.click(screen.getByRole('radio', { name: 'Month' })) + + const titles = () => screen.getAllByRole('link').map((link) => within(link).getByText(/^Post /).textContent) + await screen.findByText('Post 0') + expect(titles()).toEqual(weeks.slice(0, PAGE).map((p) => p.title)) + expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(['Newer']) + + fireEvent.click(screen.getByRole('button', { name: 'Load older' })) + expect(titles()).toEqual(weeks.slice(0, 2 * PAGE).map((p) => p.title)) + expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(['Newer', 'Older']) + + fireEvent.click(screen.getByRole('button', { name: 'Load older' })) + expect(titles()).toEqual(weeks.map((p) => p.title)) + expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument() + }) + + it('offers no "Load older" when every post is shown', async () => { + stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') }) + await renderPage() + await screen.findByText('Chili season opens') + expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument() + }) + + it('shows skeleton posts while the archive loads', async () => { + stubServer({ [`GET ${ARCHIVE('week')}`]: () => new Promise(() => {}) }) + await renderPage() + expect(screen.getByLabelText('Supply archive loading')).toHaveAttribute('aria-busy', 'true') + expect(screen.getAllByTestId('skeleton-post')).toHaveLength(3) + }) + + it('says so when nothing is archived yet', async () => { + stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'week', periods: [] }) }) + await renderPage() + expect(await screen.findByText('Nothing archived yet')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument() + }) + + it('reports a failed load and retries it', async () => { + let reads = 0 + stubServer({ + [`GET ${ARCHIVE('week')}`]: () => (++reads === 1 ? errorResponse(500, 'internal') : archive('week')()), + }) + await renderPage() + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't load the Supply archive.") + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Chili season opens')).toBeInTheDocument() + expect(reads).toBe(2) + }) + + it('reports a response that is not the archive by the period asked for', async () => { + stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'month', periods: [] }) }) + await renderPage() + expect(await screen.findByRole('alert')).toHaveTextContent(`GET ${ARCHIVE('week')}: response is not the Supply archive by week`) + }) + + it('goes back to the Supply run', async () => { + stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') }) + await renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Back to the Supply run' })) + expect(await screen.findByText('Supply run screen')).toBeInTheDocument() + }) +}) + +// The frozen Supply run of week 40, as GET /api/v1/shopping/{weekStart} +// sends it. +const FROZEN = { + weekStart: '2026-09-28', + label: 'Week 40 · Sep 28 – Oct 4', + title: 'Pizza, pot roast & the great orzo trial', + status: 'archived', + sent_to_chat_at: null, + needed_count: 1, + total_count: 2, + groups: [ + { + dinner: { id: 'd1', name: 'Friday pizza' }, + date: '2026-10-02', + items: [ + { id: 'i1', name: 'Pizza dough', qty: '2 balls', needed: true }, + { id: 'i2', name: 'Mozzarella', qty: '1 lb', needed: false }, + ], + }, + ], +} + +describe('SupplyArchivePage frozen post', () => { + it('opens a tapped post’s frozen list read-only, tagged Archived, sending no PATCH', async () => { + const fetchMock = stubServer({ + [`GET ${ARCHIVE('week')}`]: archive('week'), + 'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, FROZEN), + }) + await renderPage() + + fireEvent.click(await screen.findByText('Pizza, pot roast & the great orzo trial')) + + expect(await screen.findByText('Archived')).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: /Shopping list/ })).toBeInTheDocument() + expect(screen.getByText('Pizza dough')).toBeInTheDocument() + expect(screen.queryAllByRole('checkbox')).toEqual([]) + fireEvent.click(screen.getByText('Pizza dough')) + fireEvent.click(screen.getByText('Mozzarella')) + expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([]) + + // The list links back to the archive. + fireEvent.click(screen.getByRole('link', { name: 'Supply archive' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument() + }) + + it('on desktop (mock 2f) shows the archive as a panel beside the open post’s list', async () => { + mockViewport(1440) + stubServer({ + [`GET ${ARCHIVE('week')}`]: archive('week'), + 'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, FROZEN), + }) + await renderPage('/plan/shopping/archive?week=2026-09-28') + + const panel = screen.getByRole('complementary', { name: 'Supply archive' }) + expect(await within(panel).findByText('Chili season opens')).toBeInTheDocument() + expect(within(panel).queryByRole('button', { name: 'Back to the Supply run' })).not.toBeInTheDocument() + expect(await screen.findByText('Archived')).toBeInTheDocument() + expect(panel).not.toContainElement(screen.getByText('Pizza dough')) + expect(screen.queryAllByRole('checkbox')).toEqual([]) + }) +}) diff --git a/web/src/shopping/SupplyArchivePage.tsx b/web/src/shopping/SupplyArchivePage.tsx new file mode 100644 index 0000000..a036004 --- /dev/null +++ b/web/src/shopping/SupplyArchivePage.tsx @@ -0,0 +1,184 @@ +import { ChevronLeft } from 'lucide-react' +import { useState } from 'react' +import { Link, useNavigate, useSearchParams } from 'react-router' +import { useBreakpoint } from '../shell/useBreakpoint.ts' +import { Button } from '../ui/Button.tsx' +import { EmptyState } from '../ui/EmptyState.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { IconButton } from '../ui/IconButton.tsx' +import { Segmented } from '../ui/Segmented.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { useSupplyArchive, WEEK_PARAM, type ArchiveGroup, type ArchivePeriod, type ArchivePost } from './api.ts' +import styles from './SupplyArchivePage.module.css' +import { SUPPLY_ARCHIVE, SupplyRunPage } from './SupplyRunPage.tsx' + +// PAGE is how many posts the Supply archive shows at first, and how many +// more each "Load older" shows. +export const PAGE = 8 + +// CHIPS is how many of a post's dinners it names; a "+N" chip counts the rest. +const CHIPS = 2 + +const PERIODS = [ + { value: 'week', label: 'Week' }, + { value: 'month', label: 'Month' }, + { value: 'year', label: 'Year' }, +] as const satisfies readonly { value: ArchivePeriod; label: string }[] + +// archivePost is the Supply archive screen opened on the post of the week +// starting on Monday weekStart. +export function archivePost(weekStart: string): string { + return `${SUPPLY_ARCHIVE}?${new URLSearchParams({ [WEEK_PARAM]: weekStart })}` +} + +function plural(n: number, one: string, many: string): string { + return `${n} ${n === 1 ? one : many}` +} + +// PostCard is one week's Supply run (mock 1m): the week label as its kicker, +// the AI's title — the week label while it has none — the counts and its +// first dinners as chips. The current week's post is ringed in grape. It +// opens the week's list, read-only once frozen into the Supply archive. +function PostCard({ post }: { post: ArchivePost }) { + const rest = post.dinners.length - CHIPS + return ( + + + {post.label} + {post.current && ' · Current'} + + {post.title ?? post.label} + + {plural(post.dinnerCount, 'dinner', 'dinners')} · {plural(post.itemCount, 'item', 'items')} · {post.onHandCount} already on + hand + + {post.dinners.length > 0 && ( + + {post.dinners.slice(0, CHIPS).map((name, i) => ( + + {name} + + ))} + {rest > 0 && +{rest}} + + )} + + ) +} + +function Posts({ posts }: { posts: ArchivePost[] }) { + return ( +
    + {posts.map((post) => ( +
  • + +
  • + ))} +
+ ) +} + +// shownGroups is groups cut to their first shown posts, newest first, without +// the periods left with none. +function shownGroups(groups: ArchiveGroup[], shown: number): ArchiveGroup[] { + const out: ArchiveGroup[] = [] + let left = shown + for (const group of groups) { + if (left === 0) break + const posts = group.posts.slice(0, left) + out.push({ ...group, posts }) + left -= posts.length + } + return out +} + +// ArchivePosts is the Supply archive as the server groups it by by: one post +// per week, under Bagel headings of their month or year. It shows PAGE posts +// and "Load older" shows PAGE more. +function ArchivePosts({ by }: { by: ArchivePeriod }) { + const archive = useSupplyArchive(by) + const [shown, setShown] = useState(PAGE) + + if (archive.isError) { + return void archive.refetch()}>Couldn't load the Supply archive. {archive.error.message} + } + if (archive.isPending) { + return ( +
+ {Array.from({ length: 3 }, (_, i) => ( + + ))} +
+ ) + } + const total = archive.data.reduce((n, group) => n + group.posts.length, 0) + if (total === 0) { + return Each week's Supply run becomes a post here once it's made. + } + const groups = shownGroups(archive.data, shown) + return ( + <> + {by === 'week' ? ( + group.posts)} /> + ) : ( + groups.map((group) => ( +
+

{group.heading}

+ +
+ )) + )} + {shown < total && ( + + )} + + ) +} + +// SupplyArchive is the Supply archive (mock 1m, and the right panel of 2f): +// the Week | Month | Year control over the posts. On mobile and tablet a +// back button returns to the Supply run. +function SupplyArchive({ panel }: { panel: boolean }) { + const navigate = useNavigate() + const [by, setBy] = useState('week') + return ( +
+
+ {!panel && ( + void navigate('/plan/shopping')}> + + + )} +

Supply archive

+
+ + +
+ ) +} + +// SupplyArchivePage is /plan/shopping/archive. A post opens it on its week +// (?week=), whose list the Supply run screen shows — read-only once frozen +// into the Supply archive. On desktop (mock 2f) the archive is a panel at the +// right of that list, this week's when no post is open; on mobile and tablet +// it is the archive or, with a post open, the post's list. +export function SupplyArchivePage() { + const layout = useBreakpoint() + const [params] = useSearchParams() + if (layout === 'desktop') { + return ( +
+
+ +
+ +
+ ) + } + return params.has(WEEK_PARAM) ? : +} diff --git a/web/src/shopping/api.ts b/web/src/shopping/api.ts index 8ea8afc..6119f03 100644 --- a/web/src/shopping/api.ts +++ b/web/src/shopping/api.ts @@ -497,3 +497,77 @@ function shown(run: SupplyRun, pending: Toggle[]): SupplyRun { })) return { ...run, needed_count: needed, groups } } + +export const ARCHIVE_PERIODS = ['week', 'month', 'year'] as const + +// ArchivePeriod is what the Supply archive is grouped by. +export type ArchivePeriod = (typeof ARCHIVE_PERIODS)[number] + +// ArchivePost is one week's Supply run in the Supply archive: label is the +// week label, "Week 41 · Oct 5 – 11", which stands in for title while it is +// null; onHandCount counts the items marked "have it", and dinners names the +// dinners in day order. current is true for the household's current week. +export type ArchivePost = { + weekStart: string + label: string + title: string | null + dinnerCount: number + itemCount: number + onHandCount: number + dinners: string[] + current: boolean +} + +// ArchiveGroup is one period of the Supply archive — a week, a month +// ("October 2026") or a year ("2026") — and its posts, newest first. +export type ArchiveGroup = { key: string; heading: string; posts: ArchivePost[] } + +function parsePost(where: string, body: unknown): ArchivePost { + if ( + !isRecord(body) || + typeof body.weekStart !== 'string' || + typeof body.label !== 'string' || + (body.title !== null && typeof body.title !== 'string') || + !Array.isArray(body.dinners) || + !body.dinners.every((name) => typeof name === 'string') || + typeof body.current !== 'boolean' + ) { + throw new Error(`${where}: response has a post that is not a Supply archive post`) + } + return { + weekStart: body.weekStart, + label: body.label, + title: body.title, + dinnerCount: count(where, 'dinnerCount', body.dinnerCount), + itemCount: count(where, 'itemCount', body.itemCount), + onHandCount: count(where, 'onHandCount', body.onHandCount), + dinners: body.dinners as string[], + current: body.current, + } +} + +function parseArchiveGroup(where: string, body: unknown): ArchiveGroup { + if (!isRecord(body) || typeof body.key !== 'string' || typeof body.heading !== 'string' || !Array.isArray(body.posts)) { + throw new Error(`${where}: response has a period that is not a Supply archive period`) + } + return { key: body.key, heading: body.heading, posts: body.posts.map((post) => parsePost(where, post)) } +} + +// useSupplyArchive is the Supply archive grouped by by, newest first (GET +// /api/v1/shopping/archive?by=week|month|year): the server groups it in the +// household's zone, so it is shown as answered, never grouped again here. +export function useSupplyArchive(by: ArchivePeriod) { + const { apiRead } = useApi() + return useQuery({ + queryKey: ['supply archive', by] as const, + queryFn: async (): Promise => { + const path = `${SHOPPING_PATH}/archive?${new URLSearchParams({ by })}` + const where = `GET ${path}` + const { body } = await apiRead(path) + if (!isRecord(body) || body.by !== by || !Array.isArray(body.periods)) { + throw new Error(`${where}: response is not the Supply archive by ${by}`) + } + return body.periods.map((period) => parseArchiveGroup(where, period)) + }, + }) +} -- 2.55.0 From a7abc28c2903710f3a4a22a477945b80d08f78b5 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 12:15:31 -0400 Subject: [PATCH 2/3] Open every Supply archive post read-only and render Week headings (Feature 17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A post opened from the Supply archive is now read-only by the screen it is opened from, not by its list's status: SupplyRunPage takes an explicit readOnly mode, which the archive sets whenever a post is open. The current week's post — which the archive API includes — shows the Archived tag, no checkbox, no Send to Google Chat and no offer to make a list, so no PATCH can be sent from the archive. On desktop with no post open, the list beside the archive panel stays this week's editable Supply run. The Week view renders the periods the server sends with their headings, as Month and Year do, instead of flattening them. Maestro task 3cf3a22a-ae31-4f5f-989d-bf09824583d1. Co-Authored-By: Claude Opus 5.5 --- web/src/shopping/SupplyArchivePage.test.tsx | 94 +++++++++++++++++++-- web/src/shopping/SupplyArchivePage.tsx | 37 ++++---- web/src/shopping/SupplyRunPage.tsx | 60 +++++++------ 3 files changed, 142 insertions(+), 49 deletions(-) diff --git a/web/src/shopping/SupplyArchivePage.test.tsx b/web/src/shopping/SupplyArchivePage.test.tsx index 1bb1c39..34fffc5 100644 --- a/web/src/shopping/SupplyArchivePage.test.tsx +++ b/web/src/shopping/SupplyArchivePage.test.tsx @@ -35,10 +35,18 @@ const WEEK_40 = post('2026-09-28', 'Week 40 · Sep 28 – Oct 4', { title: 'Pizz const WEEK_39 = post('2026-09-21', 'Week 39 · Sep 21 – 27', { title: 'Chili season opens', dinnerCount: 1, itemCount: 1, onHandCount: 0, dinners: ['Chili'] }) const WEEK_1 = post('2025-12-29', 'Week 1 · Dec 29 – Jan 4') -// The archive as the server groups it: by month and year a week belongs to -// the period of its Monday, so week 40 is September's and week 1 is 2025's. +// The archive as the server groups it: by week each period is one ISO week +// headed by its label; by month and year a week belongs to the period of its +// Monday, so week 40 is September's and week 1 is 2025's. const BY = { - week: [WEEK_41, WEEK_40, WEEK_39, WEEK_1].map((p) => ({ key: p.weekStart, heading: p.label, posts: [p] })), + week: ( + [ + ['2026-W41', WEEK_41], + ['2026-W40', WEEK_40], + ['2026-W39', WEEK_39], + ['2026-W01', WEEK_1], + ] as const + ).map(([key, p]) => ({ key, heading: p.label, posts: [p] })), month: [ { key: '2026-10', heading: 'October 2026', posts: [WEEK_41] }, { key: '2026-09', heading: 'September 2026', posts: [WEEK_40, WEEK_39] }, @@ -111,7 +119,7 @@ afterEach(() => { }) describe('SupplyArchivePage', () => { - it('renders mock 1m by week: one post per week with its kicker, title, counts and dinner chips, under no heading', async () => { + it('renders mock 1m by week: one post per week with its kicker, title, counts and dinner chips', async () => { stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') }) await renderPage() @@ -134,13 +142,28 @@ describe('SupplyArchivePage', () => { '/plan/shopping/archive?week=2026-09-21', '/plan/shopping/archive?week=2025-12-29', ]) - expect(screen.queryAllByRole('heading', { level: 2 })).toEqual([]) + }) + + it('renders by week the periods and headings the server sends, as sent', async () => { + stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') }) + await renderPage() + await screen.findByText('Chili season opens') + + expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(BY.week.map((period) => period.heading)) + expect(screen.getByRole('heading', { level: 2, name: WEEK_40.label })).toHaveClass(styles.heading!) + for (const period of BY.week) { + const section = screen.getByRole('region', { name: period.heading }) + expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual( + period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`), + ) + } }) it('capitalises the week label kicker in CSS from the label as the API writes it', async () => { stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') }) await renderPage() - expect(await screen.findByText('Week 39 · Sep 21 – 27')).toHaveClass(styles.kicker!) + await screen.findByText('Chili season opens') + expect(within(postCard('Chili season opens')).getByText('Week 39 · Sep 21 – 27')).toHaveClass(styles.kicker!) }) it.each([ @@ -299,6 +322,26 @@ const FROZEN = { ], } +// The current week's Supply run, week 41, still current: the API's archive +// includes it, highlighted. +const CURRENT = { + ...FROZEN, + weekStart: '2026-10-05', + label: 'Week 41 · Oct 5 – 11', + title: 'Tacos, tikka & a salmon finale', + status: 'current', + groups: [ + { + dinner: { id: 'd2', name: 'Beef tacos' }, + date: '2026-10-05', + items: [ + { id: 'i3', name: 'Corn tortillas', qty: '12', needed: true }, + { id: 'i4', name: 'Limes', qty: '3', needed: false }, + ], + }, + ], +} + describe('SupplyArchivePage frozen post', () => { it('opens a tapped post’s frozen list read-only, tagged Archived, sending no PATCH', async () => { const fetchMock = stubServer({ @@ -322,6 +365,45 @@ describe('SupplyArchivePage frozen post', () => { expect(await screen.findByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument() }) + it('opens the current week’s post read-only too, tagged Archived, with no checkbox, nothing to send and no PATCH', async () => { + const fetchMock = stubServer({ + [`GET ${ARCHIVE('week')}`]: archive('week'), + 'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT), + }) + await renderPage() + + fireEvent.click(await screen.findByText('Tacos, tikka & a salmon finale')) + + expect(await screen.findByText('Archived')).toBeInTheDocument() + expect(screen.getByText("This week's list as it stands, read-only in the Supply archive.")).toBeInTheDocument() + expect(screen.getByText('Corn tortillas')).toBeInTheDocument() + expect(screen.queryAllByRole('checkbox')).toEqual([]) + fireEvent.click(screen.getByText('Corn tortillas')) + fireEvent.click(screen.getByText('Limes')) + expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([]) + }) + + it('on desktop opens the current week’s post read-only, and with no post open shows this week’s Supply run to change', async () => { + mockViewport(1440) + const fetchMock = stubServer({ + [`GET ${ARCHIVE('week')}`]: archive('week'), + 'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT), + }) + const view = await renderPage('/plan/shopping/archive?week=2026-10-05') + + expect(await screen.findByText('Archived')).toBeInTheDocument() + expect(screen.getByText('Corn tortillas')).toBeInTheDocument() + expect(screen.queryAllByRole('checkbox')).toEqual([]) + expect(screen.queryByText('Send to Google Chat')).not.toBeInTheDocument() + fireEvent.click(screen.getByText('Corn tortillas')) + expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([]) + view.unmount() + + await renderPage() + expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeInTheDocument() + expect(screen.queryByText('Archived')).not.toBeInTheDocument() + }) + it('on desktop (mock 2f) shows the archive as a panel beside the open post’s list', async () => { mockViewport(1440) stubServer({ diff --git a/web/src/shopping/SupplyArchivePage.tsx b/web/src/shopping/SupplyArchivePage.tsx index a036004..206328c 100644 --- a/web/src/shopping/SupplyArchivePage.tsx +++ b/web/src/shopping/SupplyArchivePage.tsx @@ -39,7 +39,7 @@ function plural(n: number, one: string, many: string): string { // PostCard is one week's Supply run (mock 1m): the week label as its kicker, // the AI's title — the week label while it has none — the counts and its // first dinners as chips. The current week's post is ringed in grape. It -// opens the week's list, read-only once frozen into the Supply archive. +// opens the week's list, read-only. function PostCard({ post }: { post: ArchivePost }) { const rest = post.dinners.length - CHIPS return ( @@ -94,8 +94,9 @@ function shownGroups(groups: ArchiveGroup[], shown: number): ArchiveGroup[] { } // ArchivePosts is the Supply archive as the server groups it by by: one post -// per week, under Bagel headings of their month or year. It shows PAGE posts -// and "Load older" shows PAGE more. +// per week, under the Bagel heading of each period the server sends — by +// week, the week's label. It shows PAGE posts and "Load older" shows PAGE +// more. function ArchivePosts({ by }: { by: ArchivePeriod }) { const archive = useSupplyArchive(by) const [shown, setShown] = useState(PAGE) @@ -119,16 +120,12 @@ function ArchivePosts({ by }: { by: ArchivePeriod }) { const groups = shownGroups(archive.data, shown) return ( <> - {by === 'week' ? ( - group.posts)} /> - ) : ( - groups.map((group) => ( -
-

{group.heading}

- -
- )) - )} + {groups.map((group) => ( +
+

{group.heading}

+ +
+ ))} {shown < total && (