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)) + }, + }) +}