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_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.
// The archive as the server groups it: by week each period is one ISO week
// 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 = {
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: [
{ key: '2026-10', heading: 'October 2026', posts: [WEEK_41] },
{ key: '2026-09', heading: 'September 2026', posts: [WEEK_40, WEEK_39] },
@@ -111,7 +119,7 @@ afterEach(() => {
})
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') })
await renderPage()
@@ -134,13 +142,28 @@ describe('SupplyArchivePage', () => {
'/plan/shopping/archive?week=2026-09-21',
'/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 () => {
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
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([
@@ -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', () => {
it('opens a tapped post’s frozen list read-only, tagged Archived, sending no PATCH', async () => {
const fetchMock = stubServer({
@@ -322,6 +365,45 @@ 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({
[`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 () => {
mockViewport(1440)
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,
// 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.
// opens the week's list, read-only.
function PostCard({ post }: { post: ArchivePost }) {
const rest = post.dinners.length - CHIPS
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
// per week, under Bagel headings of their month or year. It shows PAGE posts
// and "Load older" shows PAGE more.
// per week, under the Bagel heading of each period the server sends — by
// week, the week's label. It shows PAGE posts and "Load older" shows PAGE
// more.
function ArchivePosts({ by }: { by: ArchivePeriod }) {
const archive = useSupplyArchive(by)
const [shown, setShown] = useState(PAGE)
@@ -119,16 +120,12 @@ function ArchivePosts({ by }: { by: ArchivePeriod }) {
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>
))
)}
{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
@@ -161,10 +158,12 @@ 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 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.
// (?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
// 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() {
const layout = useBreakpoint()
const [params] = useSearchParams()
@@ -172,7 +171,7 @@ export function SupplyArchivePage() {
return (
<div className={styles.split}>
<div className={styles.list}>
<SupplyRunPage />
<SupplyRunPage readOnly={params.has(WEEK_PARAM)} />
</div>
<aside className={styles.panel} aria-label="Supply archive">
<SupplyArchive panel />
@@ -180,5 +179,5 @@ export function SupplyArchivePage() {
</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
// (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
// same marks, read-only. An item toggled while the server cannot be reached
// "have it" stands in for the quantity. A read-only Supply run shows the
// same marks. An item toggled while the server cannot be reached
// says it will sync.
function ItemRow({ item, toggles }: { item: SupplyRunItem; toggles: Toggles | null }) {
const name = (
@@ -121,12 +121,13 @@ function ArchiveLink({ layout }: { layout: Breakpoint }) {
// 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
// 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
// read-only, and a toggle it refused is not offered again.
function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; timezone: string; layout: Breakpoint }) {
// toggle refused puts the item back and says why. An archived one, or any
// opened from the Supply archive (readOnly), is read-only and tagged
// Archived, and a toggle it refused is not offered again.
function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
const toggles = useToggles(weekStart)
const run = toggles.shown
const archived = run.status === 'archived'
const viewOnly = readOnly || run.status === 'archived'
const wide = layout !== 'mobile'
const failed = toggles.failed
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>
<div className={styles.titleRow}>
<h1 className={styles.title}>Shopping list</h1>
{archived && <Tag tone="grape">Archived</Tag>}
{viewOnly && <Tag tone="grape">Archived</Tag>}
<p className={styles.count}>
{run.needed_count} / {run.total_count}
</p>
</div>
<p className={styles.helper}>
{archived
{run.status === 'archived'
? '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>
</div>
{wide && (
<div className={styles.actions}>
<ArchiveLink layout={layout} />
{!archived && (
{!viewOnly && (
<Unavailable hint={CHAT_HINT} below className={styles.action}>
<Icon icon={MessageSquare} size="inline" />
Send to Google Chat
@@ -162,7 +165,7 @@ function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; tim
)}
</header>
{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”'}.{' '}
{failed.error.message}
</ErrorBanner>
@@ -173,7 +176,7 @@ function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; tim
) : (
<div className={styles.groups} data-testid="supply-run-groups">
{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>
)}
@@ -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,
// 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 }) {
// when the week has none yet, the offer to make it from the week's plan —
// not offered read-only.
function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
const run = useSupplyRun(weekStart)
const generate = useGenerateSupplyRun(weekStart)
@@ -216,36 +222,42 @@ function WeekSupplyRun({ weekStart, timezone, layout }: { weekStart: string; tim
if (run.data === null) {
return (
<div className={`${styles.page} ${styles[layout]}`}>
<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>
{readOnly ? (
<EmptyState title="No shopping list">This week has no Supply run in the Supply archive.</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 && (
<ErrorBanner onRetry={() => generate.mutate()}>Couldn't make the shopping list. {generate.error.message}</ErrorBanner>
)}
</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
// 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
// ?week=, such as a week in the Supply archive. Offline, where the settings
// cannot be read, it chooses the week by the schedule saved on this device.
export function SupplyRunPage() {
// ?week=. Opened from the Supply archive it is readOnly: tagged Archived,
// with no checkbox and nothing to make or send, whatever the week. Offline,
// 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()
return (
<>
{problem !== null && (
<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 settings = useHouseholdSettings()
const [params] = useSearchParams()
@@ -264,7 +276,7 @@ function ChosenSupplyRun() {
}
if (!schedule) return <SkeletonGroups layout={layout} />
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.