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..34fffc5
--- /dev/null
+++ b/web/src/shopping/SupplyArchivePage.test.tsx
@@ -0,0 +1,422 @@
+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 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: (
+ [
+ ['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] },
+ { 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', 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',
+ ])
+ })
+
+ 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()
+ await screen.findByText('Chili season opens')
+ expect(within(postCard('Chili season opens')).getByText('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 },
+ ],
+ },
+ ],
+}
+
+// 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({
+ [`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('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({
+ [`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..206328c
--- /dev/null
+++ b/web/src/shopping/SupplyArchivePage.tsx
@@ -0,0 +1,183 @@
+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.
+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 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)
+
+ if (archive.isError) {
+ return void archive.refetch()}>Couldn't load the Supply archive. {archive.error.message}
+ }
+ if (archive.isPending) {
+ return (
+
+ )
+ }
+ 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 (
+ <>
+ {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 (
+
+ )
+}
+
+// SupplyArchivePage is /plan/shopping/archive. A post opens it on its week
+// (?week=), whose list the Supply run screen shows read-only and tagged
+// Archived, whatever its status: even the current week's post is only
+// viewed here, never changed. On desktop (mock 2f) the archive is a panel at
+// the right of that list, this week's editable Supply run 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/SupplyRunPage.tsx b/web/src/shopping/SupplyRunPage.tsx
index 30072ef..f9ae536 100644
--- a/web/src/shopping/SupplyRunPage.tsx
+++ b/web/src/shopping/SupplyRunPage.tsx
@@ -40,8 +40,8 @@ export function sentAt(at: Date, timezone: string): string {
// 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. An item toggled while the server cannot be reached
+// "have it" stands in for the quantity. A read-only Supply run shows the
+// same marks. An item toggled while the server cannot be reached
// says it will sync.
function ItemRow({ item, toggles }: { item: SupplyRunItem; toggles: Toggles | null }) {
const name = (
@@ -121,12 +121,13 @@ function ArchiveLink({ layout }: { layout: Breakpoint }) {
// one card per dinner, stacked on mobile and in three masonry columns from
// tablet up, where Archive and Send to Google Chat are actions in the
// header. On a current Supply run each item toggles at once (useToggles); a
-// toggle refused puts the item back and says why. An archived one is
-// read-only, and a toggle it refused is not offered again.
-function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; timezone: string; layout: Breakpoint }) {
+// toggle refused puts the item back and says why. An archived one, or any
+// opened from the Supply archive (readOnly), is read-only and tagged
+// Archived, and a toggle it refused is not offered again.
+function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
const toggles = useToggles(weekStart)
const run = toggles.shown
- const archived = run.status === 'archived'
+ const viewOnly = readOnly || 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)
@@ -138,21 +139,23 @@ function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; tim
Supply run · {weekLabel(weekStart).dates}
Shopping list
- {archived && Archived}
+ {viewOnly && Archived}
{run.needed_count} / {run.total_count}
- {archived
+ {run.status === '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."}
+ : viewOnly
+ ? "This week's list as it stands, read-only in the Supply archive."
+ : "Uncheck anything you already have — it won't be counted."}
{wide && (
- {!archived && (
+ {!viewOnly && (
Send to Google Chat
@@ -162,7 +165,7 @@ function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; tim
)}
{failed !== null && (
- toggles.toggle(failed.toggle)}>
+ toggles.toggle(failed.toggle)}>
Couldn't mark {failedItem ? failedItem.name : 'an item'} {failed.toggle.needed ? 'to buy' : '“have it”'}.{' '}
{failed.error.message}
@@ -173,7 +176,7 @@ function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; tim
) : (
{run.groups.map((group) => (
-
+
))}
)}
@@ -201,9 +204,12 @@ function SkeletonGroups({ layout }: { layout: Breakpoint }) {
)
}
+type WeekProps = { weekStart: string; timezone: string; layout: Breakpoint; readOnly: boolean }
+
// 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 }) {
+// when the week has none yet, the offer to make it from the week's plan —
+// not offered read-only.
+function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
const run = useSupplyRun(weekStart)
const generate = useGenerateSupplyRun(weekStart)
@@ -216,36 +222,42 @@ function WeekSupplyRun({ weekStart, timezone, layout }: { weekStart: string; tim
if (run.data === null) {
return (
- generate.mutate() }}>
- I'll gather the ingredients of this week's planned dinners into a Supply run.
-
+ {readOnly ? (
+ This week has no Supply run in the Supply archive.
+ ) : (
+ 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
+ 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. Offline, where the settings
-// cannot be read, it chooses the week by the schedule saved on this device.
-export function SupplyRunPage() {
+// ?week=. Opened from the Supply archive it is readOnly: tagged Archived,
+// with no checkbox and nothing to make or send, whatever the week. Offline,
+// where the settings cannot be read, it chooses the week by the schedule
+// saved on this device.
+export function SupplyRunPage({ readOnly = false }: { readOnly?: boolean }) {
const problem = useSavedProblem()
return (
<>
{problem !== null && (
Changes to the shopping list can't be kept on this device while offline. {problem.message}
)}
-
+
>
)
}
-function ChosenSupplyRun() {
+function ChosenSupplyRun({ readOnly }: { readOnly: boolean }) {
const layout = useBreakpoint()
const settings = useHouseholdSettings()
const [params] = useSearchParams()
@@ -264,7 +276,7 @@ function ChosenSupplyRun() {
}
if (!schedule) return
const week = chosen ?? defaultWeek(schedule, new Date())
- return
+ return
}
// isMonday says whether date is a real YYYY-MM-DD Monday.
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))
+ },
+ })
+}
diff --git a/web/src/shopping/offlineQueue.test.tsx b/web/src/shopping/offlineQueue.test.tsx
index 2f64ec5..1ab93fc 100644
--- a/web/src/shopping/offlineQueue.test.tsx
+++ b/web/src/shopping/offlineQueue.test.tsx
@@ -1,7 +1,7 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, 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 { afterEach, beforeEach, describe, expect, it, vi, type Mock, type MockInstance } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus, type NetworkWindow } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
@@ -211,6 +211,17 @@ async function advance(ms: number) {
await act(() => vi.advanceTimersByTimeAsync(ms))
}
+// The page's setTimeout, watched: retrySecondTry waits for a timer to be set.
+let timers: MockInstance
+
+// retrySecondTry jumps past the wait before a toggle's second try — RETRY_FIRST
+// doubled by its failed first try — once that wait's timer is set, so the jump
+// never lands before the timer it is meant to fire.
+async function retrySecondTry() {
+ await waitFor(() => expect(timers).toHaveBeenCalledWith(expect.any(Function), 2 * RETRY_FIRST))
+ await advance(2 * RETRY_FIRST)
+}
+
function checkbox(name: string) {
return screen.getByRole('checkbox', { name: new RegExp(`^${name}`) })
}
@@ -227,6 +238,7 @@ beforeEach(() => {
// a wait (advance).
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'], shouldAdvanceTime: true })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
+ timers = vi.spyOn(globalThis, 'setTimeout')
})
afterEach(() => {
@@ -459,7 +471,7 @@ describe('offline toggle queue, replay', () => {
expect(patches(server.fetchMock)).toHaveLength(1)
expect(queue()).toEqual(['i3 true', 'i1 false'])
- await advance(RETRY_FIRST)
+ await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
@@ -492,7 +504,7 @@ describe('offline toggle queue, replay', () => {
expect(patches(server.fetchMock)).toEqual([])
// The next try sends a fresh /me, which the server answers.
- await advance(RETRY_FIRST)
+ await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([]))
expect(mes(server.fetchMock)).toBe(3)
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`])
@@ -532,7 +544,7 @@ describe('offline toggle queue, replay', () => {
expect(queue()).toEqual(['i3 true', 'i1 false'])
// The next try is refused again and sends a fresh refresh, which answers.
- await advance(RETRY_FIRST)
+ await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
@@ -555,8 +567,10 @@ describe('offline toggle queue, replay', () => {
fireEvent.click(checkbox('Corn tortillas'))
expect(await within(row('Corn tortillas')).findByText('will sync')).toBeInTheDocument()
reachable = true
+ expect(patches(fetchMock)).toEqual([])
- await advance(RETRY_FIRST)
+ // Nothing says the server is back: only the wait's timer tries it again.
+ await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([]))
// The first try failed at GET /api/v1/me, before its PATCH.
expect(patches(fetchMock)).toEqual([`${ITEM('i2')} {"needed":false}`])