diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx
index fc76865..bdfdd47 100644
--- a/web/src/plan/PlanPage.test.tsx
+++ b/web/src/plan/PlanPage.test.tsx
@@ -774,6 +774,27 @@ describe('PlanPage', () => {
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
+ it("shows a failed read of the week's shopping list with Retry, the link still opening the Supply run", async () => {
+ let failing = true
+ const fetchMock = stubServer({
+ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
+ 'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(19))),
+ })
+ await renderPage()
+
+ const alert = await screen.findByRole('alert')
+ expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message")
+ expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
+ // Retry reads it again.
+ failing = false
+ fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
+ const shopping = await screen.findByRole('link', { name: 'Shopping list · 19' })
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/shopping/2026-10-05')).toHaveLength(2)
+ fireEvent.click(shopping)
+ expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
+ })
+
it('links to the Supply run with no count while the week has no shopping list', async () => {
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) })
await renderPage()
@@ -782,6 +803,8 @@ describe('PlanPage', () => {
await waitFor(() => expect(fetchMock.mock.calls.map(([input]) => String(input))).toContain('/api/v1/shopping/2026-10-05'))
const shopping = screen.getByRole('link', { name: 'Shopping list' })
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
+ // No list is not a failure.
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
fireEvent.click(shopping)
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
@@ -1154,6 +1177,27 @@ describe.each([
expect(status).toHaveTextContent('Synced to Google Calendar 1 hr ago')
})
+ it("shows a failed read of the week's shopping list with Retry, the action still opening the Supply run", async () => {
+ let failing = true
+ stubServer({
+ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
+ 'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(28))),
+ ...lists,
+ })
+ await renderPage()
+
+ const alert = await screen.findByRole('alert')
+ expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message")
+ expect(screen.getByText('Spaghetti bolognese')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
+ failing = false
+ fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
+ const shopping = await screen.findByRole('link', { name: 'Shopping list · 28' })
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ fireEvent.click(shopping)
+ expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
+ })
+
it("opens the week's Supply run from the Shopping list action, saying how many items are still to buy", async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx
index 385ad63..cdb4f32 100644
--- a/web/src/plan/PlanPage.tsx
+++ b/web/src/plan/PlanPage.tsx
@@ -196,15 +196,15 @@ function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone
// ShoppingList opens the week's Supply run: an outline action in the plan
// manager's header, a grape link under the Draft board on mobile. Once the
// week has a Supply run it says how many items are still to buy, "Shopping
-// list · 28"; until then, or while that is read, it says no count, and the
-// Supply run screen offers to make the list.
-function ShoppingList({ weekStart, layout }: { weekStart: string; layout: Breakpoint }) {
- const run = useSupplyRun(weekStart)
+// list · 28"; until then, while that is read, or when reading it failed, it
+// says no count, and the Supply run screen offers to make the list or shows
+// the failure.
+function ShoppingList({ weekStart, neededCount, layout }: { weekStart: string; neededCount: number | undefined; layout: Breakpoint }) {
const className = layout === 'mobile' ? styles.shoppingLink : [buttonStyles.button, buttonStyles.outline, styles.action].join(' ')
return (
- {run.data ? `Shopping list · ${run.data.needed_count}` : 'Shopping list'}
+ {neededCount !== undefined ? `Shopping list · ${neededCount}` : 'Shopping list'}
)
}
@@ -258,6 +258,7 @@ function WeekBoard({
const [confirming, setConfirming] = useState(false)
const swap = useSwapDay(weekStart)
const push = usePushToCalendar(weekStart)
+ const run = useSupplyRun(weekStart)
const pushing = push.pending
// The date of the day being swapped, while the swap sheet is open.
const [swappingDate, setSwappingDate] = useState(null)
@@ -375,6 +376,13 @@ function WeekBoard({
) : (
Couldn't check on the draft. {job.error.message}
)
+ // A failed read of the week's Supply run is shown, with Retry, after the
+ // Draft board's Shopping list link or under the plan manager's board; the
+ // Shopping list action still opens the Supply run.
+ const shoppingList =
+ const supplyRunFailure = run.isError && (
+ void run.refetch()}>Couldn't load this week's shopping list. {run.error.message}
+ )
// swapIn sends the swap chosen in the sheet and closes it.
function swapIn(chosen: Swap) {
@@ -477,7 +485,8 @@ function WeekBoard({
{pushing ? 'Pushing to Calendar…' : 'Looks good — push to Calendar'}
-
+ {shoppingList}
+ {supplyRunFailure}
{sheet}
>
)
@@ -509,7 +518,7 @@ function WeekBoard({