Supply archive: mark an unfrozen past run as still open (Maestro 29b629aa) #200
@@ -222,8 +222,9 @@ type archivePeriod struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// archivePost is one week's Supply run in the Supply archive. label is
|
// 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
|
// the week label, which stands in for title while it is null,
|
||||||
// onHandCount counts the items marked "have it".
|
// 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 {
|
type archivePost struct {
|
||||||
WeekStart string `json:"weekStart"`
|
WeekStart string `json:"weekStart"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
@@ -233,6 +234,7 @@ type archivePost struct {
|
|||||||
OnHandCount int `json:"onHandCount"`
|
OnHandCount int `json:"onHandCount"`
|
||||||
Dinners []string `json:"dinners"`
|
Dinners []string `json:"dinners"`
|
||||||
Current bool `json:"current"`
|
Current bool `json:"current"`
|
||||||
|
Frozen bool `json:"frozen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSupplyArchive answers GET /shopping/archive?by=week|month|year with
|
// 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,
|
OnHandCount: post.OnHand,
|
||||||
Dinners: post.Dinners,
|
Dinners: post.Dinners,
|
||||||
Current: post.Current,
|
Current: post.Current,
|
||||||
|
Frozen: post.Frozen,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.Periods[i] = archivePeriod{Key: p.Key, Heading: p.Heading, Posts: posts}
|
out.Periods[i] = archivePeriod{Key: p.Key, Heading: p.Heading, Posts: posts}
|
||||||
|
|||||||
@@ -720,6 +720,7 @@ type archiveJSON struct {
|
|||||||
OnHandCount int `json:"onHandCount"`
|
OnHandCount int `json:"onHandCount"`
|
||||||
Dinners []string `json:"dinners"`
|
Dinners []string `json:"dinners"`
|
||||||
Current bool `json:"current"`
|
Current bool `json:"current"`
|
||||||
|
Frozen bool `json:"frozen"`
|
||||||
} `json:"posts"`
|
} `json:"posts"`
|
||||||
} `json:"periods"`
|
} `json:"periods"`
|
||||||
}
|
}
|
||||||
@@ -798,7 +799,7 @@ func TestSupplyArchive(t *testing.T) {
|
|||||||
if k := keys(period); !reflect.DeepEqual(k, []string{"heading", "key", "posts"}) {
|
if k := keys(period); !reflect.DeepEqual(k, []string{"heading", "key", "posts"}) {
|
||||||
t.Errorf("period fields = %v", k)
|
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) {
|
if k := keys(period["posts"].([]any)[0].(map[string]any)); !reflect.DeepEqual(k, wantPost) {
|
||||||
t.Errorf("post fields = %v, want %v", 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 {
|
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)
|
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",
|
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.WeekStart, post.DinnerCount, post.ItemCount, post.OnHandCount, post.Dinners, post.Current, post.Frozen))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
want := []string{
|
want := []string{
|
||||||
thisWeek.String() + " 1 dinners 2 items 0 on hand [Lemon Chicken] current=true",
|
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",
|
lastWeek.String() + " 1 dinners 3 items 1 on hand [Beef Stew] current=false frozen=true",
|
||||||
}
|
}
|
||||||
if got.By != "week" || !reflect.DeepEqual(posts, want) {
|
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 "))
|
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
|
// TestSupplyArchiveKeepsFrozenNames freezes last week's Supply run, then
|
||||||
// renames its dinner and this week's through the dinners API: the frozen
|
// 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
|
// post and list keep the name the dinner had when frozen, while this
|
||||||
|
|||||||
@@ -166,6 +166,11 @@ type Post struct {
|
|||||||
Items, OnHand int
|
Items, OnHand int
|
||||||
// Current is true for the household's current week.
|
// Current is true for the household's current week.
|
||||||
Current bool
|
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
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
p, err := postOf(monday, l, monday == thisWeek)
|
p, err := postOf(monday, l, monday == thisWeek, frozen(row))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// postOf is l, the Supply run of the week starting monday, as a post in
|
||||||
// the Supply archive.
|
// 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)
|
label, err := household.WeekLabel(monday)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Post{}, fmt.Errorf("shopping: Supply run %s: %w", l.ID, err)
|
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,
|
Items: c.Total,
|
||||||
OnHand: c.Total - c.Needed,
|
OnHand: c.Total - c.Needed,
|
||||||
Current: current,
|
Current: current,
|
||||||
|
Frozen: isFrozen,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,6 +381,9 @@ func postLine(p shopping.Post) string {
|
|||||||
if p.Current {
|
if p.Current {
|
||||||
cur = " | current"
|
cur = " | current"
|
||||||
}
|
}
|
||||||
|
if !p.Frozen {
|
||||||
|
cur += " | open"
|
||||||
|
}
|
||||||
return p.WeekStart + " " + p.Label + " | " + title + " | " + strings.Join(p.Dinners, ", ") + " | " +
|
return p.WeekStart + " " + p.Label + " | " + title + " | " + strings.Join(p.Dinners, ", ") + " | " +
|
||||||
strconv.Itoa(p.Items) + " items, " + strconv.Itoa(p.OnHand) + " on hand" + cur
|
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{
|
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",
|
"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",
|
"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",
|
"nov": "2026-11-30 Week 49 · Nov 30 – Dec 6 | - | Pot Roast | 1 items, 1 on hand",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { mockViewport } from '../test/matchMedia.ts'
|
|||||||
import type { ArchivePost } from './api.ts'
|
import type { ArchivePost } from './api.ts'
|
||||||
import { PAGE, SupplyArchivePage } from './SupplyArchivePage.tsx'
|
import { PAGE, SupplyArchivePage } from './SupplyArchivePage.tsx'
|
||||||
import styles from './SupplyArchivePage.module.css'
|
import styles from './SupplyArchivePage.module.css'
|
||||||
|
import { SupplyRunPage } from './SupplyRunPage.tsx'
|
||||||
|
|
||||||
const ARCHIVE = (by: string) => `/api/v1/shopping/archive?by=${by}`
|
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,
|
onHandCount: 4,
|
||||||
dinners: ['Beef tacos', 'Tikka masala', 'Pizza', 'Pot roast', 'Orzo', 'Chili', 'Salmon'],
|
dinners: ['Beef tacos', 'Tikka masala', 'Pizza', 'Pot roast', 'Orzo', 'Chili', 'Salmon'],
|
||||||
current: false,
|
current: false,
|
||||||
|
frozen: true,
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The posts of mock 1m: this week's titled post, then the weeks across the
|
// 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.
|
// 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_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_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')
|
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
|
// 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.
|
// missing PATCH is not for want of it) and renders the Supply archive at path,
|
||||||
async function renderPage(path = '/plan/shopping/archive') {
|
// 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))
|
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||||
await api.loadMe()
|
await api.loadMe()
|
||||||
return render(
|
return render(
|
||||||
@@ -93,7 +96,7 @@ async function renderPage(path = '/plan/shopping/archive') {
|
|||||||
<MemoryRouter initialEntries={[path]}>
|
<MemoryRouter initialEntries={[path]}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/plan/shopping/archive" element={<SupplyArchivePage />} />
|
<Route path="/plan/shopping/archive" element={<SupplyArchivePage />} />
|
||||||
<Route path="/plan/shopping" element={<p>Supply run screen</p>} />
|
<Route path="/plan/shopping" element={supplyRun} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
@@ -137,7 +140,7 @@ describe('SupplyArchivePage', () => {
|
|||||||
expect(within(postCard('Chili season opens')).queryByText(/^\+/)).not.toBeInTheDocument()
|
expect(within(postCard('Chili season opens')).queryByText(/^\+/)).not.toBeInTheDocument()
|
||||||
|
|
||||||
expect(screen.getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([
|
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-28',
|
||||||
'/plan/shopping/archive?week=2026-09-21',
|
'/plan/shopping/archive?week=2026-09-21',
|
||||||
'/plan/shopping/archive?week=2025-12-29',
|
'/plan/shopping/archive?week=2025-12-29',
|
||||||
@@ -154,7 +157,7 @@ describe('SupplyArchivePage', () => {
|
|||||||
for (const period of BY.week) {
|
for (const period of BY.week) {
|
||||||
const section = screen.getByRole('region', { name: period.heading })
|
const section = screen.getByRole('region', { name: period.heading })
|
||||||
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
|
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]) {
|
for (const period of BY[by]) {
|
||||||
const section = screen.getByRole('region', { name: period.heading })
|
const section = screen.getByRole('region', { name: period.heading })
|
||||||
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
|
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()
|
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 () => {
|
it('opens the current week’s post, not yet frozen, in the Supply run, where it can still change', async () => {
|
||||||
const fetchMock = stubServer({
|
stubServer({
|
||||||
[`GET ${ARCHIVE('week')}`]: archive('week'),
|
[`GET ${ARCHIVE('week')}`]: archive('week'),
|
||||||
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
|
'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'))
|
fireEvent.click(await screen.findByText('Tacos, tikka & a salmon finale'))
|
||||||
|
|
||||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeEnabled()
|
||||||
expect(screen.getByText("This week's list as it stands, read-only in the Supply archive.")).toBeInTheDocument()
|
expect(screen.queryByText('Archived')).not.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 () => {
|
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)
|
mockViewport(1440)
|
||||||
const fetchMock = stubServer({
|
stubServer({
|
||||||
[`GET ${ARCHIVE('week')}`]: archive('week'),
|
[`GET ${ARCHIVE('week')}`]: archive('week'),
|
||||||
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
|
'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(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeEnabled()
|
||||||
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
|
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
expect(screen.queryByRole('complementary', { name: 'Supply archive' })).not.toBeInTheDocument()
|
||||||
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()
|
view.unmount()
|
||||||
|
|
||||||
await renderPage()
|
await renderPage()
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import { Icon } from '../ui/Icon.tsx'
|
|||||||
import { IconButton } from '../ui/IconButton.tsx'
|
import { IconButton } from '../ui/IconButton.tsx'
|
||||||
import { Segmented } from '../ui/Segmented.tsx'
|
import { Segmented } from '../ui/Segmented.tsx'
|
||||||
import { Skeleton } from '../ui/Skeleton.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 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
|
// PAGE is how many posts the Supply archive shows at first, and how many
|
||||||
// more each "Load older" shows.
|
// 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,
|
// 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
|
// 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
|
// first dinners as chips. The current week's post is ringed in grape. A
|
||||||
// opens the week's list, read-only.
|
// 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 }) {
|
function PostCard({ post }: { post: ArchivePost }) {
|
||||||
const rest = post.dinners.length - CHIPS
|
const rest = post.dinners.length - CHIPS
|
||||||
return (
|
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}>
|
<span className={styles.kicker}>
|
||||||
{post.label}
|
{post.label}
|
||||||
{post.current && ' · Current'}
|
{post.current ? ' · Current' : !post.frozen && ' · Still open'}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.postTitle}>{post.title ?? post.label}</span>
|
<span className={styles.postTitle}>{post.title ?? post.label}</span>
|
||||||
<span className={styles.meta}>
|
<span className={styles.meta}>
|
||||||
@@ -145,7 +150,7 @@ function SupplyArchive({ panel }: { panel: boolean }) {
|
|||||||
<div className={styles.archive}>
|
<div className={styles.archive}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
{!panel && (
|
{!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" />
|
<Icon icon={ChevronLeft} size="nav" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
@@ -157,10 +162,10 @@ function SupplyArchive({ panel }: { panel: boolean }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SupplyArchivePage is /plan/shopping/archive. A post opens it on its week
|
// SupplyArchivePage is /plan/shopping/archive. A frozen post opens it on its
|
||||||
// (?week=), whose list the Supply run screen shows read-only and tagged
|
// 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
|
// Archived; a post not yet frozen opens the Supply run instead, as does a
|
||||||
// viewed here, never changed. On desktop (mock 2f) the archive is a panel at
|
// ?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
|
// 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
|
// open; on mobile and tablet it is the archive or, with a post open, the
|
||||||
// post's list.
|
// post's list.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
|
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
|
||||||
import { useState } from '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 { defaultWeek, isMonday, localNow, weekdayKicker, weekLabel } from '../plan/week.ts'
|
||||||
import { ApiError } from '../api/errors.ts'
|
import { ApiError } from '../api/errors.ts'
|
||||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.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 { Tag } from '../ui/Tag.tsx'
|
||||||
import {
|
import {
|
||||||
useGenerateSupplyRun,
|
useGenerateSupplyRun,
|
||||||
|
supplyRunScreen,
|
||||||
useSendToChat,
|
useSendToChat,
|
||||||
useSupplyRun,
|
useSupplyRun,
|
||||||
useToggles,
|
useToggles,
|
||||||
@@ -26,6 +27,9 @@ import {
|
|||||||
import { keep, savedSchedule, useSavedProblem } from './offline.ts'
|
import { keep, savedSchedule, useSavedProblem } from './offline.ts'
|
||||||
import styles from './SupplyRunPage.module.css'
|
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.
|
// SUPPLY_ARCHIVE is the Supply archive screen.
|
||||||
export const SUPPLY_ARCHIVE = '/plan/shopping/archive'
|
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
|
// 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
|
// 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
|
// 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
|
// toggle refused puts the item back and says why. An archived one is
|
||||||
// opened from the Supply archive (readOnly), is read-only and tagged
|
// read-only and tagged Archived, and a toggle it refused is not offered
|
||||||
// Archived, and a toggle it refused is not offered again.
|
// again.
|
||||||
function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
function SupplyRunList({ weekStart, timezone, layout }: Omit<WeekProps, 'readOnly'>) {
|
||||||
const toggles = useToggles(weekStart)
|
const toggles = useToggles(weekStart)
|
||||||
const send = useSendToChat(weekStart)
|
const send = useSendToChat(weekStart)
|
||||||
const run = toggles.shown
|
const run = toggles.shown
|
||||||
@@ -157,7 +161,7 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
|||||||
<UnconfirmedStatus at={run.chat_send_unconfirmed_at} timezone={timezone} />
|
<UnconfirmedStatus at={run.chat_send_unconfirmed_at} timezone={timezone} />
|
||||||
)
|
)
|
||||||
const sendUnconfirmed = send.error instanceof ApiError && send.error.code === 'chat_unconfirmed'
|
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 wide = layout !== 'mobile'
|
||||||
const failed = toggles.failed
|
const failed = toggles.failed
|
||||||
const failedItem = failed && run.groups.flatMap((group) => group.items).find((item) => item.id === failed.toggle.id)
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className={styles.helper}>
|
<p className={styles.helper}>
|
||||||
{run.status === 'archived'
|
{viewOnly
|
||||||
? 'This list is in the Supply archive, as it was when the week ended.'
|
? 'This list is in the Supply archive, as it was when the week ended.'
|
||||||
: viewOnly
|
: "Uncheck anything you already have — it won't be counted."}
|
||||||
? "This week's list as it stands, read-only in the Supply archive."
|
|
||||||
: "Uncheck anything you already have — it won't be counted."}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{wide && (
|
{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,
|
// 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 —
|
// 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) {
|
function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
||||||
const run = useSupplyRun(weekStart)
|
const run = useSupplyRun(weekStart)
|
||||||
const generate = useGenerateSupplyRun(weekStart)
|
const generate = useGenerateSupplyRun(weekStart)
|
||||||
@@ -267,14 +270,16 @@ function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
|||||||
</div>
|
</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
|
// 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
|
// 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
|
// 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,
|
// ?week=. Opened from the Supply archive it is readOnly: a frozen Supply run
|
||||||
// with no checkbox and nothing to make or send, whatever the week. Offline,
|
// 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
|
// where the settings cannot be read, it chooses the week by the schedule
|
||||||
// saved on this device.
|
// saved on this device.
|
||||||
export function SupplyRunPage({ readOnly = false }: { readOnly?: boolean }) {
|
export function SupplyRunPage({ readOnly = false }: { readOnly?: boolean }) {
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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
|
// 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 = {
|
export type ArchivePost = {
|
||||||
weekStart: string
|
weekStart: string
|
||||||
label: string
|
label: string
|
||||||
@@ -555,6 +557,7 @@ export type ArchivePost = {
|
|||||||
onHandCount: number
|
onHandCount: number
|
||||||
dinners: string[]
|
dinners: string[]
|
||||||
current: boolean
|
current: boolean
|
||||||
|
frozen: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveGroup is one period of the Supply archive — a week, a month
|
// 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') ||
|
(body.title !== null && typeof body.title !== 'string') ||
|
||||||
!Array.isArray(body.dinners) ||
|
!Array.isArray(body.dinners) ||
|
||||||
!body.dinners.every((name) => typeof name === 'string') ||
|
!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`)
|
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),
|
onHandCount: count(where, 'onHandCount', body.onHandCount),
|
||||||
dinners: body.dinners as string[],
|
dinners: body.dinners as string[],
|
||||||
current: body.current,
|
current: body.current,
|
||||||
|
frozen: body.frozen,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user