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 b8b5d5e..0002c43 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) + } +} + // TestSupplyArchiveKeepsFrozenNames freezes last week's Supply run, then // renames its dinner and this week's through the dinners API: the frozen // post and list keep the name the dinner had when frozen, while this diff --git a/internal/shopping/archive.go b/internal/shopping/archive.go index fe2895d..a69cbfb 100644 --- a/internal/shopping/archive.go +++ b/internal/shopping/archive.go @@ -166,6 +166,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 @@ -222,7 +227,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 } @@ -237,7 +242,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) @@ -255,5 +260,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 9ba0278..bb67b89 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..1eaad07 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,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', ) 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', ) + + 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', ) + + 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', ) - 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() diff --git a/web/src/shopping/SupplyArchivePage.tsx b/web/src/shopping/SupplyArchivePage.tsx index 206328c..e645c95 100644 --- a/web/src/shopping/SupplyArchivePage.tsx +++ b/web/src/shopping/SupplyArchivePage.tsx @@ -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 ( - + {post.label} - {post.current && ' · Current'} + {post.current ? ' · Current' : !post.frozen && ' · Still open'} {post.title ?? post.label} @@ -145,7 +150,7 @@ function SupplyArchive({ panel }: { panel: boolean }) {
{!panel && ( - void navigate('/plan/shopping')}> + void navigate(SUPPLY_RUN)}> )} @@ -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. diff --git a/web/src/shopping/SupplyRunPage.tsx b/web/src/shopping/SupplyRunPage.tsx index 7c889b8..44d4d0b 100644 --- a/web/src/shopping/SupplyRunPage.tsx +++ b/web/src/shopping/SupplyRunPage.tsx @@ -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) { const toggles = useToggles(weekStart) const send = useSendToChat(weekStart) const run = toggles.shown @@ -157,7 +161,7 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) { ) 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) {

- {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."}

{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) { ) } - return + if (readOnly && run.data.status !== 'archived') return + return } // 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 }) { diff --git a/web/src/shopping/api.ts b/web/src/shopping/api.ts index 8d3095a..c871979 100644 --- a/web/src/shopping/api.ts +++ b/web/src/shopping/api.ts @@ -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, } }