Build the Supply archive screen at /plan/shopping/archive (Feature 17) #139
@@ -33,6 +33,7 @@ const SCREENS = {
|
|||||||
'/onboarding': true,
|
'/onboarding': true,
|
||||||
'/plan': true,
|
'/plan': true,
|
||||||
'/plan/shopping': true,
|
'/plan/shopping': true,
|
||||||
|
'/plan/shopping/archive': true,
|
||||||
'/discover': true,
|
'/discover': true,
|
||||||
'/settings': true,
|
'/settings': true,
|
||||||
'/settings/ai': true,
|
'/settings/ai': true,
|
||||||
@@ -130,6 +131,18 @@ describe('App routes', () => {
|
|||||||
expect(await screen.findByRole('checkbox', { name: 'Ground beef' })).toBeChecked()
|
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 () => {
|
it('/discover renders the Side quests deck inside the shell', async () => {
|
||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
stubFetchByPath({
|
stubFetchByPath({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
|
|||||||
import { SettingsPage } from './settings/SettingsPage.tsx'
|
import { SettingsPage } from './settings/SettingsPage.tsx'
|
||||||
import { AppShell } from './shell/AppShell.tsx'
|
import { AppShell } from './shell/AppShell.tsx'
|
||||||
import { SupplyRunSync } from './shopping/api.ts'
|
import { SupplyRunSync } from './shopping/api.ts'
|
||||||
|
import { SupplyArchivePage } from './shopping/SupplyArchivePage.tsx'
|
||||||
import { SupplyRunPage } from './shopping/SupplyRunPage.tsx'
|
import { SupplyRunPage } from './shopping/SupplyRunPage.tsx'
|
||||||
|
|
||||||
// Every route the app serves, with the heading its placeholder page shows.
|
// 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>> = {
|
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||||
'/plan': <PlanPage />,
|
'/plan': <PlanPage />,
|
||||||
'/plan/shopping': <SupplyRunPage />,
|
'/plan/shopping': <SupplyRunPage />,
|
||||||
|
'/plan/shopping/archive': <SupplyArchivePage />,
|
||||||
'/discover': <DiscoverPage />,
|
'/discover': <DiscoverPage />,
|
||||||
'/settings': <SettingsPage />,
|
'/settings': <SettingsPage />,
|
||||||
'/settings/ai': <AISettingsPage />,
|
'/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);
|
||||||
|
}
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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} />
|
||||||
|
}
|
||||||
@@ -497,3 +497,77 @@ function shown(run: SupplyRun, pending: Toggle[]): SupplyRun {
|
|||||||
}))
|
}))
|
||||||
return { ...run, needed_count: needed, groups }
|
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))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user