From c2a63192a6b21c650695c4e74a25589ea4bbc2e7 Mon Sep 17 00:00:00 2001
From: dev_agent
Date: Sun, 11 Oct 2026 09:35:55 -0400
Subject: [PATCH] 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
---
internal/server/shopping.go | 7 +-
internal/server/shopping_test.go | 75 +++++++++++++++++++--
internal/shopping/archive.go | 10 ++-
internal/shopping/archive_test.go | 5 +-
web/src/shopping/SupplyArchivePage.test.tsx | 59 +++++++++++-----
web/src/shopping/SupplyArchivePage.tsx | 30 ++++++---
web/src/shopping/SupplyRunPage.tsx | 3 +
web/src/shopping/api.ts | 9 ++-
8 files changed, 159 insertions(+), 39 deletions(-)
diff --git a/internal/server/shopping.go b/internal/server/shopping.go
index 27cfd9e..9d2bdb8 100644
--- a/internal/server/shopping.go
+++ b/internal/server/shopping.go
@@ -222,8 +222,9 @@ type archivePeriod struct {
}
// archivePost is one week's Supply run in the Supply archive. label is
-// the week label, which stands in for title while it is null, and
-// onHandCount counts the items marked "have it".
+// the week label, which stands in for title while it is null,
+// onHandCount counts the items marked "have it", and frozen is false
+// while the list can still be changed from the Supply run.
type archivePost struct {
WeekStart string `json:"weekStart"`
Label string `json:"label"`
@@ -233,6 +234,7 @@ type archivePost struct {
OnHandCount int `json:"onHandCount"`
Dinners []string `json:"dinners"`
Current bool `json:"current"`
+ Frozen bool `json:"frozen"`
}
// getSupplyArchive answers GET /shopping/archive?by=week|month|year with
@@ -262,6 +264,7 @@ func getSupplyArchive(logger *slog.Logger, svc *shopping.Service) http.HandlerFu
OnHandCount: post.OnHand,
Dinners: post.Dinners,
Current: post.Current,
+ Frozen: post.Frozen,
}
}
out.Periods[i] = archivePeriod{Key: p.Key, Heading: p.Heading, Posts: posts}
diff --git a/internal/server/shopping_test.go b/internal/server/shopping_test.go
index 739d44a..ff8ec76 100644
--- a/internal/server/shopping_test.go
+++ b/internal/server/shopping_test.go
@@ -720,6 +720,7 @@ type archiveJSON struct {
OnHandCount int `json:"onHandCount"`
Dinners []string `json:"dinners"`
Current bool `json:"current"`
+ Frozen bool `json:"frozen"`
} `json:"posts"`
} `json:"periods"`
}
@@ -798,7 +799,7 @@ func TestSupplyArchive(t *testing.T) {
if k := keys(period); !reflect.DeepEqual(k, []string{"heading", "key", "posts"}) {
t.Errorf("period fields = %v", k)
}
- wantPost := []string{"current", "dinnerCount", "dinners", "itemCount", "label", "onHandCount", "title", "weekStart"}
+ wantPost := []string{"current", "dinnerCount", "dinners", "frozen", "itemCount", "label", "onHandCount", "title", "weekStart"}
if k := keys(period["posts"].([]any)[0].(map[string]any)); !reflect.DeepEqual(k, wantPost) {
t.Errorf("post fields = %v, want %v", k, wantPost)
}
@@ -815,13 +816,13 @@ func TestSupplyArchive(t *testing.T) {
if post.Label != label || post.Title != nil {
t.Errorf("post %s: label %q title %v, want %q and null", post.WeekStart, post.Label, post.Title, label)
}
- posts = append(posts, fmt.Sprintf("%s %d dinners %d items %d on hand %v current=%t",
- post.WeekStart, post.DinnerCount, post.ItemCount, post.OnHandCount, post.Dinners, post.Current))
+ posts = append(posts, fmt.Sprintf("%s %d dinners %d items %d on hand %v current=%t frozen=%t",
+ post.WeekStart, post.DinnerCount, post.ItemCount, post.OnHandCount, post.Dinners, post.Current, post.Frozen))
}
}
want := []string{
- thisWeek.String() + " 1 dinners 2 items 0 on hand [Lemon Chicken] current=true",
- lastWeek.String() + " 1 dinners 3 items 1 on hand [Beef Stew] current=false",
+ thisWeek.String() + " 1 dinners 2 items 0 on hand [Lemon Chicken] current=true frozen=false",
+ lastWeek.String() + " 1 dinners 3 items 1 on hand [Beef Stew] current=false frozen=true",
}
if got.By != "week" || !reflect.DeepEqual(posts, want) {
t.Errorf("archive by %s =\n %s\nwant\n %s", got.By, strings.Join(posts, "\n "), strings.Join(want, "\n "))
@@ -852,6 +853,70 @@ func TestSupplyArchive(t *testing.T) {
}
}
+// TestSupplyArchiveUnfrozenPastWeek: a past week's Supply run that
+// week_rollover has not frozen is in the Supply archive as not frozen for
+// as long as the Supply run route can still change it, and as frozen once
+// it cannot.
+func TestSupplyArchiveUnfrozenPastWeek(t *testing.T) {
+ t.Parallel()
+ e := newPlansRouter(t)
+ if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM plan_days; DELETE FROM week_plans`); err != nil {
+ t.Fatal(err)
+ }
+ zone, err := household.LoadZone("America/New_York") // the env's household zone
+ if err != nil {
+ t.Fatal(err)
+ }
+ thisWeek := household.WeekStart(household.Today(time.Now(), zone))
+ y, m, d := thisWeek.Date()
+ lastWeek := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
+ last := e.weekSupplyRun(t, lastWeek, "Beef Stew", shopIng{"stewing beef", "2", "lb"}, shopIng{"carrots", "4", ""})
+
+ // post is last week's post in the Supply archive.
+ post := func() (frozen bool) {
+ t.Helper()
+ var got archiveJSON
+ if status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got); status != http.StatusOK {
+ t.Fatalf("GET archive = %d %v", status, raw)
+ }
+ for _, p := range got.Periods {
+ for _, post := range p.Posts {
+ if post.WeekStart == lastWeek.String() {
+ if post.Current {
+ t.Errorf("last week's post is current")
+ }
+ return post.Frozen
+ }
+ }
+ }
+ t.Fatalf("archive %+v has no post for %s", got, lastWeek)
+ return false
+ }
+ // change marks carrots and reports whether the Supply run route took it.
+ change := func(needed bool) bool {
+ t.Helper()
+ status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+last.itemID(t, "carrots"), fmt.Sprintf(`{"needed":%t}`, needed), nil)
+ switch {
+ case status == http.StatusOK:
+ return true
+ case status == http.StatusConflict && errorCode(body) == "archived":
+ return false
+ }
+ t.Fatalf("PATCH = %d %v, want 200 or 409 archived", status, body)
+ return false
+ }
+
+ if frozen, changed := post(), change(false); frozen || !changed {
+ t.Errorf("unfrozen past week: post frozen=%t, PATCH changed=%t; want a post not frozen for a list that changes", frozen, changed)
+ }
+ if err := e.shop.Freeze(t.Context(), lastWeek); err != nil {
+ t.Fatal(err)
+ }
+ if frozen, changed := post(), change(true); !frozen || changed {
+ t.Errorf("frozen past week: post frozen=%t, PATCH changed=%t; want a frozen post for a list that cannot change", frozen, changed)
+ }
+}
+
// TestSupplyRunSendChat posts the week's Supply run to Google Chat once,
// records sent_to_chat_at, answers a second send with the status as it
// is, and posts again only with resend.
diff --git a/internal/shopping/archive.go b/internal/shopping/archive.go
index a24d2dc..55ec094 100644
--- a/internal/shopping/archive.go
+++ b/internal/shopping/archive.go
@@ -152,6 +152,11 @@ type Post struct {
Items, OnHand int
// Current is true for the household's current week.
Current bool
+ // Frozen is true once the list is frozen into the Supply archive and
+ // can no longer change. A list not yet frozen — the current week's, or
+ // a past week's that week_rollover has not frozen — can still be
+ // changed from the Supply run.
+ Frozen bool
}
// Archive returns the Supply archive: the Supply run of every week up to
@@ -208,7 +213,7 @@ func (s *Service) Archive(ctx context.Context, by string) (out []Period, err err
if err != nil {
return nil, err
}
- p, err := postOf(monday, l, monday == thisWeek)
+ p, err := postOf(monday, l, monday == thisWeek, frozen(row))
if err != nil {
return nil, err
}
@@ -223,7 +228,7 @@ func (s *Service) Archive(ctx context.Context, by string) (out []Period, err err
// postOf is l, the Supply run of the week starting monday, as a post in
// the Supply archive.
-func postOf(monday store.LocalDate, l List, current bool) (Post, error) {
+func postOf(monday store.LocalDate, l List, current, isFrozen bool) (Post, error) {
label, err := household.WeekLabel(monday)
if err != nil {
return Post{}, fmt.Errorf("shopping: Supply run %s: %w", l.ID, err)
@@ -241,5 +246,6 @@ func postOf(monday store.LocalDate, l List, current bool) (Post, error) {
Items: c.Total,
OnHand: c.Total - c.Needed,
Current: current,
+ Frozen: isFrozen,
}, nil
}
diff --git a/internal/shopping/archive_test.go b/internal/shopping/archive_test.go
index 00fff92..977bab4 100644
--- a/internal/shopping/archive_test.go
+++ b/internal/shopping/archive_test.go
@@ -381,6 +381,9 @@ func postLine(p shopping.Post) string {
if p.Current {
cur = " | current"
}
+ if !p.Frozen {
+ cur += " | open"
+ }
return p.WeekStart + " " + p.Label + " | " + title + " | " + strings.Join(p.Dinners, ", ") + " | " +
strconv.Itoa(p.Items) + " items, " + strconv.Itoa(p.OnHand) + " on hand" + cur
}
@@ -427,7 +430,7 @@ func TestArchiveGroups(t *testing.T) {
}
posts := map[string]string{
- "jan": "2027-01-04 Week 1 · Jan 4 – 10 | - | Pad Thai, Beef Chili | 5 items, 1 on hand | current",
+ "jan": "2027-01-04 Week 1 · Jan 4 – 10 | - | Pad Thai, Beef Chili | 5 items, 1 on hand | current | open",
"dec": "2026-12-28 Week 53 · Dec 28 – Jan 3 | Chili, Noodles & Co | Beef Chili, Pad Thai | 5 items, 2 on hand",
"d07": "2026-12-07 Week 50 · Dec 7 – 13 | - | Pad Thai | 2 items, 0 on hand",
"nov": "2026-11-30 Week 49 · Nov 30 – Dec 6 | - | Pot Roast | 1 items, 1 on hand",
diff --git a/web/src/shopping/SupplyArchivePage.test.tsx b/web/src/shopping/SupplyArchivePage.test.tsx
index 0ab4000..ebff490 100644
--- a/web/src/shopping/SupplyArchivePage.test.tsx
+++ b/web/src/shopping/SupplyArchivePage.test.tsx
@@ -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
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) {
}
// 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 =
Supply run screen
) {
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') {
} />
- Supply run screen
} />
+
@@ -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', )
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', )
+
+ 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 () => {
diff --git a/web/src/shopping/SupplyArchivePage.tsx b/web/src/shopping/SupplyArchivePage.tsx
index 206328c..d3e4a80 100644
--- a/web/src/shopping/SupplyArchivePage.tsx
+++ b/web/src/shopping/SupplyArchivePage.tsx
@@ -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 (
-
+
{post.label}
- {post.current && ' · Current'}
+ {post.current ? ' · Current' : !post.frozen && ' · Still open'}
{post.title ?? post.label}
@@ -145,7 +156,7 @@ function SupplyArchive({ panel }: { panel: boolean }) {
{!panel && (
- void navigate('/plan/shopping')}>
+ void navigate(SUPPLY_RUN)}>
)}
@@ -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.
diff --git a/web/src/shopping/SupplyRunPage.tsx b/web/src/shopping/SupplyRunPage.tsx
index 7c889b8..a43545c 100644
--- a/web/src/shopping/SupplyRunPage.tsx
+++ b/web/src/shopping/SupplyRunPage.tsx
@@ -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'
diff --git a/web/src/shopping/api.ts b/web/src/shopping/api.ts
index a103fa3..1149c71 100644
--- a/web/src/shopping/api.ts
+++ b/web/src/shopping/api.ts
@@ -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,
}
}