Open every Supply archive post read-only and render Week headings (Feature 17)
CI / ci (push) Failing after 4m9s
CI / ci (pull_request) Successful in 5m0s

A post opened from the Supply archive is now read-only by the screen it is
opened from, not by its list's status: SupplyRunPage takes an explicit
readOnly mode, which the archive sets whenever a post is open. The current
week's post — which the archive API includes — shows the Archived tag, no
checkbox, no Send to Google Chat and no offer to make a list, so no PATCH
can be sent from the archive. On desktop with no post open, the list
beside the archive panel stays this week's editable Supply run.

The Week view renders the periods the server sends with their headings,
as Month and Year do, instead of flattening them.

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 12:15:31 -04:00
co-authored by Claude Opus 5.5
parent 00f9e304ad
commit a7abc28c29
3 changed files with 142 additions and 49 deletions
+88 -6
View File
@@ -35,10 +35,18 @@ const WEEK_40 = post('2026-09-28', 'Week 40 · Sep 28 – Oct 4', { title: 'Pizz
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_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') 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 archive as the server groups it: by week each period is one ISO week
// the period of its Monday, so week 40 is September's and week 1 is 2025's. // headed by its label; by month and year a week belongs to the period of its
// Monday, so week 40 is September's and week 1 is 2025's.
const BY = { const BY = {
week: [WEEK_41, WEEK_40, WEEK_39, WEEK_1].map((p) => ({ key: p.weekStart, heading: p.label, posts: [p] })), week: (
[
['2026-W41', WEEK_41],
['2026-W40', WEEK_40],
['2026-W39', WEEK_39],
['2026-W01', WEEK_1],
] as const
).map(([key, p]) => ({ key, heading: p.label, posts: [p] })),
month: [ month: [
{ key: '2026-10', heading: 'October 2026', posts: [WEEK_41] }, { key: '2026-10', heading: 'October 2026', posts: [WEEK_41] },
{ key: '2026-09', heading: 'September 2026', posts: [WEEK_40, WEEK_39] }, { key: '2026-09', heading: 'September 2026', posts: [WEEK_40, WEEK_39] },
@@ -111,7 +119,7 @@ afterEach(() => {
}) })
describe('SupplyArchivePage', () => { describe('SupplyArchivePage', () => {
it('renders mock 1m by week: one post per week with its kicker, title, counts and dinner chips, under no heading', async () => { it('renders mock 1m by week: one post per week with its kicker, title, counts and dinner chips', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') }) stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage() await renderPage()
@@ -134,13 +142,28 @@ describe('SupplyArchivePage', () => {
'/plan/shopping/archive?week=2026-09-21', '/plan/shopping/archive?week=2026-09-21',
'/plan/shopping/archive?week=2025-12-29', '/plan/shopping/archive?week=2025-12-29',
]) ])
expect(screen.queryAllByRole('heading', { level: 2 })).toEqual([]) })
it('renders by week the periods and headings the server sends, as sent', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage()
await screen.findByText('Chili season opens')
expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(BY.week.map((period) => period.heading))
expect(screen.getByRole('heading', { level: 2, name: WEEK_40.label })).toHaveClass(styles.heading!)
for (const period of BY.week) {
const section = screen.getByRole('region', { name: period.heading })
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
)
}
}) })
it('capitalises the week label kicker in CSS from the label as the API writes it', async () => { it('capitalises the week label kicker in CSS from the label as the API writes it', async () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') }) stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
await renderPage() await renderPage()
expect(await screen.findByText('Week 39 · Sep 21 – 27')).toHaveClass(styles.kicker!) await screen.findByText('Chili season opens')
expect(within(postCard('Chili season opens')).getByText('Week 39 · Sep 21 – 27')).toHaveClass(styles.kicker!)
}) })
it.each([ it.each([
@@ -299,6 +322,26 @@ const FROZEN = {
], ],
} }
// The current week's Supply run, week 41, still current: the API's archive
// includes it, highlighted.
const CURRENT = {
...FROZEN,
weekStart: '2026-10-05',
label: 'Week 41 · Oct 5 – 11',
title: 'Tacos, tikka & a salmon finale',
status: 'current',
groups: [
{
dinner: { id: 'd2', name: 'Beef tacos' },
date: '2026-10-05',
items: [
{ id: 'i3', name: 'Corn tortillas', qty: '12', needed: true },
{ id: 'i4', name: 'Limes', qty: '3', needed: false },
],
},
],
}
describe('SupplyArchivePage frozen post', () => { describe('SupplyArchivePage frozen post', () => {
it('opens a tapped post’s frozen list read-only, tagged Archived, sending no PATCH', async () => { it('opens a tapped post’s frozen list read-only, tagged Archived, sending no PATCH', async () => {
const fetchMock = stubServer({ const fetchMock = stubServer({
@@ -322,6 +365,45 @@ describe('SupplyArchivePage frozen post', () => {
expect(await screen.findByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument() expect(await screen.findByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument()
}) })
it('opens the current week’s post read-only too, tagged Archived, with no checkbox, nothing to send and no PATCH', async () => {
const fetchMock = stubServer({
[`GET ${ARCHIVE('week')}`]: archive('week'),
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
})
await renderPage()
fireEvent.click(await screen.findByText('Tacos, tikka & a salmon finale'))
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(screen.getByText("This week's list as it stands, read-only in the Supply archive.")).toBeInTheDocument()
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
expect(screen.queryAllByRole('checkbox')).toEqual([])
fireEvent.click(screen.getByText('Corn tortillas'))
fireEvent.click(screen.getByText('Limes'))
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
})
it('on desktop opens the current week’s post read-only, and with no post open shows this week’s Supply run to change', async () => {
mockViewport(1440)
const fetchMock = stubServer({
[`GET ${ARCHIVE('week')}`]: archive('week'),
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
})
const view = await renderPage('/plan/shopping/archive?week=2026-10-05')
expect(await screen.findByText('Archived')).toBeInTheDocument()
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
expect(screen.queryAllByRole('checkbox')).toEqual([])
expect(screen.queryByText('Send to Google Chat')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Corn tortillas'))
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
view.unmount()
await renderPage()
expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeInTheDocument()
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
})
it('on desktop (mock 2f) shows the archive as a panel beside the open post’s list', async () => { it('on desktop (mock 2f) shows the archive as a panel beside the open post’s list', async () => {
mockViewport(1440) mockViewport(1440)
stubServer({ stubServer({
+18 -19
View File
@@ -39,7 +39,7 @@ function plural(n: number, one: string, many: string): string {
// PostCard is one week's Supply run (mock 1m): the week label as its kicker, // 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 // 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 // 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. // opens the week's list, read-only.
function PostCard({ post }: { post: ArchivePost }) { function PostCard({ post }: { post: ArchivePost }) {
const rest = post.dinners.length - CHIPS const rest = post.dinners.length - CHIPS
return ( return (
@@ -94,8 +94,9 @@ function shownGroups(groups: ArchiveGroup[], shown: number): ArchiveGroup[] {
} }
// ArchivePosts is the Supply archive as the server groups it by by: one post // 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 // per week, under the Bagel heading of each period the server sends — by
// and "Load older" shows PAGE more. // week, the week's label. It shows PAGE posts and "Load older" shows PAGE
// more.
function ArchivePosts({ by }: { by: ArchivePeriod }) { function ArchivePosts({ by }: { by: ArchivePeriod }) {
const archive = useSupplyArchive(by) const archive = useSupplyArchive(by)
const [shown, setShown] = useState(PAGE) const [shown, setShown] = useState(PAGE)
@@ -119,16 +120,12 @@ function ArchivePosts({ by }: { by: ArchivePeriod }) {
const groups = shownGroups(archive.data, shown) const groups = shownGroups(archive.data, shown)
return ( return (
<> <>
{by === 'week' ? ( {groups.map((group) => (
<Posts posts={groups.flatMap((group) => group.posts)} /> <section key={group.key} aria-label={group.heading} className={styles.period}>
) : ( <h2 className={styles.heading}>{group.heading}</h2>
groups.map((group) => ( <Posts posts={group.posts} />
<section key={group.key} aria-label={group.heading} className={styles.period}> </section>
<h2 className={styles.heading}>{group.heading}</h2> ))}
<Posts posts={group.posts} />
</section>
))
)}
{shown < total && ( {shown < total && (
<Button variant="outline" className={styles.older} onClick={() => setShown(shown + PAGE)}> <Button variant="outline" className={styles.older} onClick={() => setShown(shown + PAGE)}>
Load older Load older
@@ -161,10 +158,12 @@ function SupplyArchive({ panel }: { panel: boolean }) {
} }
// SupplyArchivePage is /plan/shopping/archive. A post opens it on its week // SupplyArchivePage is /plan/shopping/archive. A post opens it on its week
// (?week=), whose list the Supply run screen shows — read-only once frozen // (?week=), whose list the Supply run screen shows read-only and tagged
// into the Supply archive. On desktop (mock 2f) the archive is a panel at the // Archived, whatever its status: even the current week's post is only
// right of that list, this week's when no post is open; on mobile and tablet // viewed here, never changed. On desktop (mock 2f) the archive is a panel at
// it is the archive or, with a post open, the post's list. // the right of that list, this week's editable Supply run when no post is
// open; on mobile and tablet it is the archive or, with a post open, the
// post's list.
export function SupplyArchivePage() { export function SupplyArchivePage() {
const layout = useBreakpoint() const layout = useBreakpoint()
const [params] = useSearchParams() const [params] = useSearchParams()
@@ -172,7 +171,7 @@ export function SupplyArchivePage() {
return ( return (
<div className={styles.split}> <div className={styles.split}>
<div className={styles.list}> <div className={styles.list}>
<SupplyRunPage /> <SupplyRunPage readOnly={params.has(WEEK_PARAM)} />
</div> </div>
<aside className={styles.panel} aria-label="Supply archive"> <aside className={styles.panel} aria-label="Supply archive">
<SupplyArchive panel /> <SupplyArchive panel />
@@ -180,5 +179,5 @@ export function SupplyArchivePage() {
</div> </div>
) )
} }
return params.has(WEEK_PARAM) ? <SupplyRunPage /> : <SupplyArchive panel={false} /> return params.has(WEEK_PARAM) ? <SupplyRunPage readOnly /> : <SupplyArchive panel={false} />
} }
+36 -24
View File
@@ -40,8 +40,8 @@ export function sentAt(at: Date, timezone: string): string {
// ItemRow is one ingredient: on a current Supply run a checkbox, checked // ItemRow is one ingredient: on a current Supply run a checkbox, checked
// (grape) while it is to buy; unchecked, the name is struck through and // (grape) while it is to buy; unchecked, the name is struck through and
// "have it" stands in for the quantity. An archived Supply run shows the // "have it" stands in for the quantity. A read-only Supply run shows the
// same marks, read-only. An item toggled while the server cannot be reached // same marks. An item toggled while the server cannot be reached
// says it will sync. // says it will sync.
function ItemRow({ item, toggles }: { item: SupplyRunItem; toggles: Toggles | null }) { function ItemRow({ item, toggles }: { item: SupplyRunItem; toggles: Toggles | null }) {
const name = ( const name = (
@@ -121,12 +121,13 @@ function ArchiveLink({ layout }: { layout: Breakpoint }) {
// one card per dinner, stacked on mobile and in three masonry columns from // one card per dinner, stacked on mobile and in three masonry columns from
// tablet up, where Archive and Send to Google Chat are actions in the // tablet up, where Archive and Send to Google Chat are actions in the
// header. On a current Supply run each item toggles at once (useToggles); a // header. On a current Supply run each item toggles at once (useToggles); a
// toggle refused puts the item back and says why. An archived one is // toggle refused puts the item back and says why. An archived one, or any
// read-only, and a toggle it refused is not offered again. // opened from the Supply archive (readOnly), is read-only and tagged
function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; timezone: string; layout: Breakpoint }) { // Archived, and a toggle it refused is not offered again.
function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
const toggles = useToggles(weekStart) const toggles = useToggles(weekStart)
const run = toggles.shown const run = toggles.shown
const archived = run.status === 'archived' const viewOnly = readOnly || run.status === 'archived'
const wide = layout !== 'mobile' const wide = layout !== 'mobile'
const failed = toggles.failed const failed = toggles.failed
const failedItem = failed && run.groups.flatMap((group) => group.items).find((item) => item.id === failed.toggle.id) const failedItem = failed && run.groups.flatMap((group) => group.items).find((item) => item.id === failed.toggle.id)
@@ -138,21 +139,23 @@ function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; tim
<p className={styles.kicker}>Supply run · {weekLabel(weekStart).dates}</p> <p className={styles.kicker}>Supply run · {weekLabel(weekStart).dates}</p>
<div className={styles.titleRow}> <div className={styles.titleRow}>
<h1 className={styles.title}>Shopping list</h1> <h1 className={styles.title}>Shopping list</h1>
{archived && <Tag tone="grape">Archived</Tag>} {viewOnly && <Tag tone="grape">Archived</Tag>}
<p className={styles.count}> <p className={styles.count}>
{run.needed_count} / {run.total_count} {run.needed_count} / {run.total_count}
</p> </p>
</div> </div>
<p className={styles.helper}> <p className={styles.helper}>
{archived {run.status === 'archived'
? 'This list is in the Supply archive, as it was when the week ended.' ? 'This list is in the Supply archive, as it was when the week ended.'
: "Uncheck anything you already have — it won't be counted."} : viewOnly
? "This week's list as it stands, read-only in the Supply archive."
: "Uncheck anything you already have — it won't be counted."}
</p> </p>
</div> </div>
{wide && ( {wide && (
<div className={styles.actions}> <div className={styles.actions}>
<ArchiveLink layout={layout} /> <ArchiveLink layout={layout} />
{!archived && ( {!viewOnly && (
<Unavailable hint={CHAT_HINT} below className={styles.action}> <Unavailable hint={CHAT_HINT} below className={styles.action}>
<Icon icon={MessageSquare} size="inline" /> <Icon icon={MessageSquare} size="inline" />
Send to Google Chat Send to Google Chat
@@ -162,7 +165,7 @@ function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; tim
)} )}
</header> </header>
{failed !== null && ( {failed !== null && (
<ErrorBanner onRetry={archived ? undefined : () => toggles.toggle(failed.toggle)}> <ErrorBanner onRetry={viewOnly ? undefined : () => toggles.toggle(failed.toggle)}>
Couldn't mark {failedItem ? failedItem.name : 'an item'} {failed.toggle.needed ? 'to buy' : '“have it”'}.{' '} Couldn't mark {failedItem ? failedItem.name : 'an item'} {failed.toggle.needed ? 'to buy' : '“have it”'}.{' '}
{failed.error.message} {failed.error.message}
</ErrorBanner> </ErrorBanner>
@@ -173,7 +176,7 @@ function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; tim
) : ( ) : (
<div className={styles.groups} data-testid="supply-run-groups"> <div className={styles.groups} data-testid="supply-run-groups">
{run.groups.map((group) => ( {run.groups.map((group) => (
<GroupCard key={`${group.date} ${group.dinner.id}`} group={group} toggles={archived ? null : toggles} /> <GroupCard key={`${group.date} ${group.dinner.id}`} group={group} toggles={viewOnly ? null : toggles} />
))} ))}
</div> </div>
)} )}
@@ -201,9 +204,12 @@ function SkeletonGroups({ layout }: { layout: Breakpoint }) {
) )
} }
type WeekProps = { weekStart: string; timezone: string; layout: Breakpoint; readOnly: boolean }
// WeekSupplyRun is the Supply run of the week starting on weekStart, or, // WeekSupplyRun is the Supply run of the week starting on weekStart, or,
// when the week has none yet, the offer to make it from the week's plan. // when the week has none yet, the offer to make it from the week's plan —
function WeekSupplyRun({ weekStart, timezone, layout }: { weekStart: string; timezone: string; layout: Breakpoint }) { // not offered read-only.
function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
const run = useSupplyRun(weekStart) const run = useSupplyRun(weekStart)
const generate = useGenerateSupplyRun(weekStart) const generate = useGenerateSupplyRun(weekStart)
@@ -216,36 +222,42 @@ function WeekSupplyRun({ weekStart, timezone, layout }: { weekStart: string; tim
if (run.data === null) { if (run.data === null) {
return ( return (
<div className={`${styles.page} ${styles[layout]}`}> <div className={`${styles.page} ${styles[layout]}`}>
<EmptyState title="No shopping list yet" action={{ label: 'Make the shopping list', onClick: () => generate.mutate() }}> {readOnly ? (
I'll gather the ingredients of this week's planned dinners into a Supply run. <EmptyState title="No shopping list">This week has no Supply run in the Supply archive.</EmptyState>
</EmptyState> ) : (
<EmptyState title="No shopping list yet" action={{ label: 'Make the shopping list', onClick: () => generate.mutate() }}>
I'll gather the ingredients of this week's planned dinners into a Supply run.
</EmptyState>
)}
{generate.isError && ( {generate.isError && (
<ErrorBanner onRetry={() => generate.mutate()}>Couldn't make the shopping list. {generate.error.message}</ErrorBanner> <ErrorBanner onRetry={() => generate.mutate()}>Couldn't make the shopping list. {generate.error.message}</ErrorBanner>
)} )}
</div> </div>
) )
} }
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} /> return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} readOnly={readOnly} />
} }
// SupplyRunPage is the Supply run (mock 1l on mobile, 2b on tablet, 2f on // SupplyRunPage is the Supply run (mock 1l on mobile, 2b on tablet, 2f on
// desktop) of the week the Plan opens on — next week once this week's // desktop) of the week the Plan opens on — next week once this week's
// plan_draft time has come, otherwise this week — or of the week chosen by // plan_draft time has come, otherwise this week — or of the week chosen by
// ?week=, such as a week in the Supply archive. Offline, where the settings // ?week=. Opened from the Supply archive it is readOnly: tagged Archived,
// cannot be read, it chooses the week by the schedule saved on this device. // with no checkbox and nothing to make or send, whatever the week. Offline,
export function SupplyRunPage() { // where the settings cannot be read, it chooses the week by the schedule
// saved on this device.
export function SupplyRunPage({ readOnly = false }: { readOnly?: boolean }) {
const problem = useSavedProblem() const problem = useSavedProblem()
return ( return (
<> <>
{problem !== null && ( {problem !== null && (
<ErrorBanner>Changes to the shopping list can't be kept on this device while offline. {problem.message}</ErrorBanner> <ErrorBanner>Changes to the shopping list can't be kept on this device while offline. {problem.message}</ErrorBanner>
)} )}
<ChosenSupplyRun /> <ChosenSupplyRun readOnly={readOnly} />
</> </>
) )
} }
function ChosenSupplyRun() { function ChosenSupplyRun({ readOnly }: { readOnly: boolean }) {
const layout = useBreakpoint() const layout = useBreakpoint()
const settings = useHouseholdSettings() const settings = useHouseholdSettings()
const [params] = useSearchParams() const [params] = useSearchParams()
@@ -264,7 +276,7 @@ function ChosenSupplyRun() {
} }
if (!schedule) return <SkeletonGroups layout={layout} /> if (!schedule) return <SkeletonGroups layout={layout} />
const week = chosen ?? defaultWeek(schedule, new Date()) const week = chosen ?? defaultWeek(schedule, new Date())
return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} /> return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} readOnly={readOnly} />
} }
// isMonday says whether date is a real YYYY-MM-DD Monday. // isMonday says whether date is a real YYYY-MM-DD Monday.