Mark unfrozen Supply archive posts and open them in the Supply run
The Supply archive presented every post as a frozen, read-only list, even a past week's Supply run that week_rollover had not frozen and that the Supply run route could still change. Each archive post now carries frozen; a post not yet frozen is marked (Current, or Still open for a past week) and links to the Supply run, where it can still change. Only frozen posts open read-only in the archive. Maestro task 29b629aa-1ad9-48a3-be75-0380b645d4fe Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { mockViewport } from '../test/matchMedia.ts'
|
||||
import type { ArchivePost } from './api.ts'
|
||||
import { PAGE, SupplyArchivePage } from './SupplyArchivePage.tsx'
|
||||
import styles from './SupplyArchivePage.module.css'
|
||||
import { SupplyRunPage } from './SupplyRunPage.tsx'
|
||||
|
||||
const ARCHIVE = (by: string) => `/api/v1/shopping/archive?by=${by}`
|
||||
|
||||
@@ -24,13 +25,14 @@ function post(weekStart: string, label: string, overrides: Partial<ArchivePost>
|
||||
onHandCount: 4,
|
||||
dinners: ['Beef tacos', 'Tikka masala', 'Pizza', 'Pot roast', 'Orzo', 'Chili', 'Salmon'],
|
||||
current: false,
|
||||
frozen: true,
|
||||
...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_41 = post('2026-10-05', 'Week 41 · Oct 5 – 11', { title: 'Tacos, tikka & a salmon finale', current: true, frozen: false })
|
||||
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')
|
||||
@@ -83,8 +85,9 @@ function stubServer(responders: Record<string, Responder>) {
|
||||
}
|
||||
|
||||
// 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') {
|
||||
// missing PATCH is not for want of it) and renders the Supply archive at path,
|
||||
// with supplyRun at the Supply run's route.
|
||||
async function renderPage(path = '/plan/shopping/archive', supplyRun = <p>Supply run screen</p>) {
|
||||
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
@@ -93,7 +96,7 @@ async function renderPage(path = '/plan/shopping/archive') {
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="/plan/shopping/archive" element={<SupplyArchivePage />} />
|
||||
<Route path="/plan/shopping" element={<p>Supply run screen</p>} />
|
||||
<Route path="/plan/shopping" element={supplyRun} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
@@ -137,7 +140,7 @@ describe('SupplyArchivePage', () => {
|
||||
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?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',
|
||||
@@ -154,7 +157,7 @@ describe('SupplyArchivePage', () => {
|
||||
for (const period of BY.week) {
|
||||
const section = screen.getByRole('region', { name: period.heading })
|
||||
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
|
||||
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
|
||||
period.posts.map((p) => (p.frozen ? `/plan/shopping/archive?week=${p.weekStart}` : `/plan/shopping?week=${p.weekStart}`)),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -182,7 +185,7 @@ describe('SupplyArchivePage', () => {
|
||||
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}`),
|
||||
period.posts.map((p) => (p.frozen ? `/plan/shopping/archive?week=${p.weekStart}` : `/plan/shopping?week=${p.weekStart}`)),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -366,22 +369,44 @@ describe('SupplyArchivePage frozen post', () => {
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the current week’s post read-only too, tagged Archived, with no checkbox, nothing to send and no PATCH', async () => {
|
||||
const fetchMock = stubServer({
|
||||
it('opens the current week’s post, not yet frozen, in the Supply run, where it can still change', async () => {
|
||||
stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: archive('week'),
|
||||
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
|
||||
})
|
||||
await renderPage()
|
||||
await renderPage('/plan/shopping/archive', <SupplyRunPage />)
|
||||
|
||||
fireEvent.click(await screen.findByText('Tacos, tikka & a salmon finale'))
|
||||
|
||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
||||
expect(screen.getByText("This week's list as it stands, read-only in the Supply archive.")).toBeInTheDocument()
|
||||
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
fireEvent.click(screen.getByText('Corn tortillas'))
|
||||
fireEvent.click(screen.getByText('Limes'))
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
|
||||
expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeEnabled()
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks a past week’s post not yet frozen Still open and opens it in the Supply run, where it can still change', async () => {
|
||||
const open = { ...WEEK_40, frozen: false }
|
||||
stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'week', periods: [{ key: '2026-W40', heading: open.label, posts: [open] }] }),
|
||||
'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, { ...FROZEN, status: 'current' }),
|
||||
})
|
||||
await renderPage('/plan/shopping/archive', <SupplyRunPage />)
|
||||
|
||||
await screen.findByText('Pizza, pot roast & the great orzo trial')
|
||||
const card = postCard('Pizza, pot roast & the great orzo trial')
|
||||
expect(within(card).getByText('Week 40 · Sep 28 – Oct 4 · Still open')).toHaveClass(styles.kicker!)
|
||||
expect(card).not.toHaveClass(styles.current!)
|
||||
expect(card).toHaveAttribute('href', '/plan/shopping?week=2026-09-28')
|
||||
|
||||
fireEvent.click(card)
|
||||
|
||||
expect(await screen.findByRole('checkbox', { name: 'Pizza dough' })).toBeEnabled()
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks no frozen post Still open', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
|
||||
await renderPage()
|
||||
await screen.findByText('Chili season opens')
|
||||
expect(screen.queryByText(/Still open/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('on desktop opens the current week’s post read-only, and with no post open shows this week’s Supply run to change', async () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ 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'
|
||||
import { SUPPLY_ARCHIVE, SUPPLY_RUN, SupplyRunPage } from './SupplyRunPage.tsx'
|
||||
|
||||
// PAGE is how many posts the Supply archive shows at first, and how many
|
||||
// more each "Load older" shows.
|
||||
@@ -32,21 +32,32 @@ export function archivePost(weekStart: string): string {
|
||||
return `${SUPPLY_ARCHIVE}?${new URLSearchParams({ [WEEK_PARAM]: weekStart })}`
|
||||
}
|
||||
|
||||
// supplyRun is the Supply run screen on the week starting on Monday
|
||||
// weekStart, where a list not yet frozen can still be changed.
|
||||
function supplyRun(weekStart: string): string {
|
||||
return `${SUPPLY_RUN}?${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.
|
||||
// first dinners as chips. The current week's post is ringed in grape. A
|
||||
// frozen post opens the week's list here, read-only; a post not yet frozen —
|
||||
// this week's, or a past week's the week rollover has not frozen — is marked
|
||||
// Current or Still open and opens the Supply run, where it can still change.
|
||||
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(' ')}>
|
||||
<Link
|
||||
to={post.frozen ? archivePost(post.weekStart) : supplyRun(post.weekStart)}
|
||||
className={[styles.post, post.current && styles.current].filter(Boolean).join(' ')}
|
||||
>
|
||||
<span className={styles.kicker}>
|
||||
{post.label}
|
||||
{post.current && ' · Current'}
|
||||
{post.current ? ' · Current' : !post.frozen && ' · Still open'}
|
||||
</span>
|
||||
<span className={styles.postTitle}>{post.title ?? post.label}</span>
|
||||
<span className={styles.meta}>
|
||||
@@ -145,7 +156,7 @@ function SupplyArchive({ panel }: { panel: boolean }) {
|
||||
<div className={styles.archive}>
|
||||
<header className={styles.header}>
|
||||
{!panel && (
|
||||
<IconButton label="Back to the Supply run" onClick={() => void navigate('/plan/shopping')}>
|
||||
<IconButton label="Back to the Supply run" onClick={() => void navigate(SUPPLY_RUN)}>
|
||||
<Icon icon={ChevronLeft} size="nav" />
|
||||
</IconButton>
|
||||
)}
|
||||
@@ -157,10 +168,9 @@ function SupplyArchive({ panel }: { panel: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
// SupplyArchivePage is /plan/shopping/archive. A post opens it on its week
|
||||
// (?week=), whose list the Supply run screen shows read-only and tagged
|
||||
// Archived, whatever its status: even the current week's post is only
|
||||
// viewed here, never changed. On desktop (mock 2f) the archive is a panel at
|
||||
// SupplyArchivePage is /plan/shopping/archive. A frozen post opens it on its
|
||||
// week (?week=), whose list the Supply run screen shows read-only and tagged
|
||||
// Archived; a post not yet frozen opens the Supply run instead. On desktop (mock 2f) the archive is a panel at
|
||||
// the right of that list, this week's editable Supply run when no post is
|
||||
// open; on mobile and tablet it is the archive or, with a post open, the
|
||||
// post's list.
|
||||
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
import { keep, savedSchedule, useSavedProblem } from './offline.ts'
|
||||
import styles from './SupplyRunPage.module.css'
|
||||
|
||||
// SUPPLY_RUN is the Supply run screen.
|
||||
export const SUPPLY_RUN = '/plan/shopping'
|
||||
|
||||
// SUPPLY_ARCHIVE is the Supply archive screen.
|
||||
export const SUPPLY_ARCHIVE = '/plan/shopping/archive'
|
||||
|
||||
|
||||
@@ -537,7 +537,9 @@ 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.
|
||||
// dinners in day order. current is true for the household's current week,
|
||||
// and frozen once the list is frozen into the Supply archive: until then it
|
||||
// can still be changed from the Supply run.
|
||||
export type ArchivePost = {
|
||||
weekStart: string
|
||||
label: string
|
||||
@@ -547,6 +549,7 @@ export type ArchivePost = {
|
||||
onHandCount: number
|
||||
dinners: string[]
|
||||
current: boolean
|
||||
frozen: boolean
|
||||
}
|
||||
|
||||
// ArchiveGroup is one period of the Supply archive — a week, a month
|
||||
@@ -561,7 +564,8 @@ function parsePost(where: string, body: unknown): ArchivePost {
|
||||
(body.title !== null && typeof body.title !== 'string') ||
|
||||
!Array.isArray(body.dinners) ||
|
||||
!body.dinners.every((name) => typeof name === 'string') ||
|
||||
typeof body.current !== 'boolean'
|
||||
typeof body.current !== 'boolean' ||
|
||||
typeof body.frozen !== 'boolean'
|
||||
) {
|
||||
throw new Error(`${where}: response has a post that is not a Supply archive post`)
|
||||
}
|
||||
@@ -574,6 +578,7 @@ function parsePost(where: string, body: unknown): ArchivePost {
|
||||
onHandCount: count(where, 'onHandCount', body.onHandCount),
|
||||
dinners: body.dinners as string[],
|
||||
current: body.current,
|
||||
frozen: body.frozen,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user