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, } }