Supply archive: mark an unfrozen past run as still open (Maestro 29b629aa) (#200)
CI / ci (push) Successful in 8m36s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #200.
This commit is contained in:
2026-10-11 17:29:09 -04:00
committed by approver_agent
parent 01cd9a8741
commit cfc229049f
8 changed files with 186 additions and 63 deletions
+57 -26
View File
@@ -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,38 +369,66 @@ 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('on desktop opens the current week’s post read-only, and with no post open shows this week’s Supply run to change', async () => {
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('opens a saved link to a past week’s list not yet frozen in the editable Supply run, not read-only', async () => {
stubServer({ 'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, { ...FROZEN, status: 'current' }) })
await renderPage('/plan/shopping/archive?week=2026-09-28', <SupplyRunPage />)
expect(await screen.findByRole('checkbox', { name: 'Pizza dough' })).toBeEnabled()
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
expect(screen.getByText("Uncheck anything you already have — it won't be counted.")).toBeInTheDocument()
})
it('on desktop opens a saved link to the current week’s list, not yet frozen, in the editable Supply run, as with no post open', async () => {
mockViewport(1440)
const fetchMock = stubServer({
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')
const view = await renderPage('/plan/shopping/archive?week=2026-10-05', <SupplyRunPage />)
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([])
expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeEnabled()
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
expect(screen.queryByRole('complementary', { name: 'Supply archive' })).not.toBeInTheDocument()
view.unmount()
await renderPage()
+16 -11
View File
@@ -9,9 +9,9 @@ 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 { supplyRunScreen, 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.
@@ -38,15 +38,20 @@ 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.
// 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) : supplyRunScreen(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 +150,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 +162,10 @@ 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, as does a
// ?week= visit to a list not yet frozen. 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.
+19 -14
View File
@@ -1,6 +1,6 @@
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router'
import { Link, Navigate, useSearchParams } from 'react-router'
import { defaultWeek, isMonday, localNow, weekdayKicker, weekLabel } from '../plan/week.ts'
import { ApiError } from '../api/errors.ts'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
@@ -15,6 +15,7 @@ import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import {
useGenerateSupplyRun,
supplyRunScreen,
useSendToChat,
useSupplyRun,
useToggles,
@@ -26,6 +27,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'
@@ -143,10 +147,10 @@ function ArchiveLink({ layout }: { layout: Breakpoint }) {
// tablet up, where Archive and Send to Google Chat are actions in the
// header; a send that fails says why, and one whose outcome is not known
// offers no retry and leaves the status line saying so. On a current Supply run each item toggles at once (useToggles); a
// 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) {
// toggle refused puts the item back and says why. An archived one is
// read-only and tagged Archived, and a toggle it refused is not offered
// again.
function SupplyRunList({ weekStart, timezone, layout }: Omit<WeekProps, 'readOnly'>) {
const toggles = useToggles(weekStart)
const send = useSendToChat(weekStart)
const run = toggles.shown
@@ -157,7 +161,7 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
<UnconfirmedStatus at={run.chat_send_unconfirmed_at} timezone={timezone} />
)
const sendUnconfirmed = send.error instanceof ApiError && send.error.code === 'chat_unconfirmed'
const viewOnly = readOnly || run.status === 'archived'
const viewOnly = 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)
@@ -175,11 +179,9 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
</p>
</div>
<p className={styles.helper}>
{run.status === 'archived'
{viewOnly
? 'This list is in the Supply archive, as it was when the week ended.'
: viewOnly
? "This week's list as it stands, read-only in the Supply archive."
: "Uncheck anything you already have — it won't be counted."}
: "Uncheck anything you already have — it won't be counted."}
</p>
</div>
{wide && (
@@ -240,7 +242,8 @@ type WeekProps = { weekStart: string; timezone: string; layout: Breakpoint; read
// 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 —
// not offered read-only.
// not offered read-only. Read-only, a Supply run not yet frozen opens the
// editable Supply run screen instead, where it can still change.
function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
const run = useSupplyRun(weekStart)
const generate = useGenerateSupplyRun(weekStart)
@@ -267,14 +270,16 @@ function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
</div>
)
}
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} readOnly={readOnly} />
if (readOnly && run.data.status !== 'archived') return <Navigate to={supplyRunScreen(weekStart)} replace />
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} />
}
// 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=. Opened from the Supply archive it is readOnly: tagged Archived,
// with no checkbox and nothing to make or send, whatever the week. Offline,
// ?week=. Opened from the Supply archive it is readOnly: a frozen Supply run
// is tagged Archived, with no checkbox and nothing to make or send; one not
// yet frozen opens the editable Supply run screen instead. 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 }) {
+7 -2
View File
@@ -545,7 +545,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
@@ -555,6 +557,7 @@ export type ArchivePost = {
onHandCount: number
dinners: string[]
current: boolean
frozen: boolean
}
// ArchiveGroup is one period of the Supply archive — a week, a month
@@ -569,7 +572,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`)
}
@@ -582,6 +586,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,
}
}