Build the Supply archive screen at /plan/shopping/archive (Feature 17)
CI / ci (push) Failing after 4m15s
CI / ci (pull_request) Failing after 4m12s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 11:54:25 -04:00
co-authored by Claude Opus 5.5
parent de75b1a883
commit 00f9e304ad
6 changed files with 757 additions and 0 deletions
+13
View File
@@ -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({
+2
View File
@@ -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<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/plan': <PlanPage />,
'/plan/shopping': <SupplyRunPage />,
'/plan/shopping/archive': <SupplyArchivePage />,
'/discover': <DiscoverPage />,
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
@@ -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);
}
+340
View File
@@ -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> = {}): 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<Response>
// stubServer answers "METHOD path" requests from responders, with /me and
// the household settings; any other request fails the test.
function stubServer(responders: Record<string, Responder>) {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// 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(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/plan/shopping/archive" element={<SupplyArchivePage />} />
<Route path="/plan/shopping" element={<p>Supply run screen</p>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
// 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<Response>(() => {}) })
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([])
})
})
+184
View File
@@ -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 (
<Link to={archivePost(post.weekStart)} className={[styles.post, post.current && styles.current].filter(Boolean).join(' ')}>
<span className={styles.kicker}>
{post.label}
{post.current && ' · Current'}
</span>
<span className={styles.postTitle}>{post.title ?? post.label}</span>
<span className={styles.meta}>
{plural(post.dinnerCount, 'dinner', 'dinners')} · {plural(post.itemCount, 'item', 'items')} · {post.onHandCount} already on
hand
</span>
{post.dinners.length > 0 && (
<span className={styles.chips}>
{post.dinners.slice(0, CHIPS).map((name, i) => (
<span key={i} className={styles.chip}>
{name}
</span>
))}
{rest > 0 && <span className={styles.chip}>+{rest}</span>}
</span>
)}
</Link>
)
}
function Posts({ posts }: { posts: ArchivePost[] }) {
return (
<ul className={styles.posts}>
{posts.map((post) => (
<li key={post.weekStart}>
<PostCard post={post} />
</li>
))}
</ul>
)
}
// 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 <ErrorBanner onRetry={() => void archive.refetch()}>Couldn't load the Supply archive. {archive.error.message}</ErrorBanner>
}
if (archive.isPending) {
return (
<div aria-busy="true" aria-label="Supply archive loading" className={styles.posts}>
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} data-testid="skeleton-post" className={styles.skeletonPost} />
))}
</div>
)
}
const total = archive.data.reduce((n, group) => n + group.posts.length, 0)
if (total === 0) {
return <EmptyState title="Nothing archived yet">Each week's Supply run becomes a post here once it's made.</EmptyState>
}
const groups = shownGroups(archive.data, shown)
return (
<>
{by === 'week' ? (
<Posts posts={groups.flatMap((group) => group.posts)} />
) : (
groups.map((group) => (
<section key={group.key} aria-label={group.heading} className={styles.period}>
<h2 className={styles.heading}>{group.heading}</h2>
<Posts posts={group.posts} />
</section>
))
)}
{shown < total && (
<Button variant="outline" className={styles.older} onClick={() => setShown(shown + PAGE)}>
Load older
</Button>
)}
</>
)
}
// 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<ArchivePeriod>('week')
return (
<div className={styles.archive}>
<header className={styles.header}>
{!panel && (
<IconButton label="Back to the Supply run" onClick={() => void navigate('/plan/shopping')}>
<Icon icon={ChevronLeft} size="nav" />
</IconButton>
)}
<h1 className={styles.title}>Supply archive</h1>
</header>
<Segmented label="Group the Supply archive by" options={PERIODS} value={by} onValueChange={setBy} className={styles.periods} />
<ArchivePosts key={by} by={by} />
</div>
)
}
// 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 (
<div className={styles.split}>
<div className={styles.list}>
<SupplyRunPage />
</div>
<aside className={styles.panel} aria-label="Supply archive">
<SupplyArchive panel />
</aside>
</div>
)
}
return params.has(WEEK_PARAM) ? <SupplyRunPage /> : <SupplyArchive panel={false} />
}
+74
View File
@@ -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<ArchiveGroup[]> => {
const path = `${SHOPPING_PATH}/archive?${new URLSearchParams({ by })}`
const where = `GET ${path}`
const { body } = await apiRead<unknown>(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))
},
})
}