Build the Supply run screen at /plan/shopping (Feature 17) (#136)
CI / ci (push) Successful in 5m1s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #136.
This commit is contained in:
2026-10-08 09:49:26 -04:00
committed by approver_agent
parent 428e87674e
commit 5d7906ae0d
10 changed files with 1545 additions and 30 deletions
+17 -2
View File
@@ -258,8 +258,8 @@
color: var(--color-neutral-700);
}
/* An action with nothing behind it yet (the shopping list until Feature
17): it keeps focus, so hovering, tapping or focusing it shows the
/* An action with nothing behind it yet (Send to Google Chat until Feature
19): it keeps focus, so hovering, tapping or focusing it shows the
tooltip. */
.unavailable {
position: relative;
@@ -414,6 +414,21 @@
text-align: center;
}
/* The way to the week's Supply run under the Draft board on mobile. */
.shoppingLink {
display: inline-flex;
align-self: center;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-accent-700);
text-decoration: none;
}
/* A push failure's way to the Google Calendar settings. */
.settingsLink {
color: inherit;
+158 -10
View File
@@ -1,6 +1,6 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { MemoryRouter, Route, Routes, useLocation } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
@@ -100,8 +100,12 @@ const lists = {
jsonResponse(200, { dinners: [dinner('d0', 'Beef tacos', 'easy', 30), dinner('r1', 'Fish tacos', 'medium', 35)] }),
}
// SUPPLY_RUN is the Supply run of a week, as GET /api/v1/shopping/{weekStart}.
const SUPPLY_RUN = /^GET \/api\/v1\/shopping\/\d{4}-\d{2}-\d{2}$/
// stubServer answers "METHOD path" requests from responders, with /me and
// the household settings; any other request fails the test.
// the household settings, and no Supply run (404) for a week given none;
// any other request fails the test.
function stubServer(responders: Record<string, Responder>) {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
@@ -110,7 +114,7 @@ function stubServer(responders: Record<string, Responder>) {
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
const respond = Object.hasOwn(all, key) ? all[key] : SUPPLY_RUN.test(key) ? () => errorResponse(404, 'not_found') : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init))
})
@@ -139,13 +143,37 @@ async function renderPage() {
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<MemoryRouter initialEntries={['/plan']}>
<PlanPage />
<Routes>
<Route path="/plan" element={<PlanPage />} />
<Route path="/plan/shopping" element={<Opened />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
// Opened stands in for the screen a link opened, saying where it is.
function Opened() {
const { pathname, search } = useLocation()
return <p>Opened {pathname + search}</p>
}
// supplyRun is the Supply run of the week of Oct 5 with needed of its 32
// items still to buy.
function supplyRun(needed: number) {
return {
weekStart: '2026-10-05',
label: 'Week 41 · Oct 5 – 11',
title: null,
status: 'current',
sent_to_chat_at: null,
needed_count: needed,
total_count: 32,
groups: [],
}
}
// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so
// the page opens on this week.
beforeEach(() => {
@@ -729,6 +757,85 @@ describe('PlanPage', () => {
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
})
it("links to the week's Supply run under the Draft board, saying how many items are still to buy", async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, supplyRun(19)),
})
await renderPage()
const shopping = await screen.findByRole('link', { name: 'Shopping list · 19' })
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
expect(declaredStyle(shopping).color).toBe('var(--color-accent-700)')
// It follows the push button and the sync status.
const push = screen.getByRole('button', { name: 'Looks good — push to Calendar' })
expect(push.compareDocumentPosition(shopping) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
fireEvent.click(shopping)
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("drops the count when reading the week's shopping list again fails, showing the failure with Retry", async () => {
let failing = false
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()
expect(await screen.findByRole('link', { name: 'Shopping list · 19' })).toBeInTheDocument()
// Coming back to the page once the Supply run is stale reads it again,
// and that read fails.
failing = true
vi.setSystemTime(new Date('2026-10-07T16:01:00Z'))
act(() => {
window.dispatchEvent(new Event('visibilitychange'))
})
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')
expect(screen.queryByRole('link', { name: 'Shopping list · 19' })).not.toBeInTheDocument()
// Retry reads it again, and the count is back.
failing = false
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByRole('link', { name: 'Shopping list · 19' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.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()
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
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()
})
it('moves between weeks with the previous and next week buttons', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
@@ -1026,11 +1133,11 @@ describe.each([
expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop')
// Actions: the shopping list unavailable, saying why; Rebuild week
// outlined and Push to Calendar primary, both working.
const shopping = screen.getByRole('button', { name: 'Shopping list' })
expect(shopping).toHaveAttribute('aria-disabled', 'true')
expect(shopping).toHaveAccessibleDescription('The shopping list arrives once the Supply run is built.')
// Actions: the shopping list, with no count while the week has no
// Supply run, and Rebuild week outlined; Push to Calendar primary.
const shopping = await screen.findByRole('link', { name: 'Shopping list' })
expect(shopping).not.toHaveAttribute('aria-disabled')
expect(shopping).not.toHaveAccessibleDescription()
const push = screen.getByRole('button', { name: 'Push to Calendar' })
expect(push).toBeEnabled()
expect(push).not.toHaveAttribute('aria-disabled')
@@ -1038,7 +1145,6 @@ describe.each([
const rebuild = screen.getByRole('button', { name: 'Rebuild week' })
expect(rebuild).toBeEnabled()
expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)')
fireEvent.click(shopping)
expect(posts(fetchMock)).toEqual([])
// Mobile's own pieces are not shown.
expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument()
@@ -1098,6 +1204,48 @@ 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')),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, supplyRun(28)),
...lists,
})
await renderPage()
const shopping = await screen.findByRole('link', { name: 'Shopping list · 28' })
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
expect(declaredStyle(shopping).borderColor).toBe('var(--color-neutral-300)')
// Another week's action opens that week's, which has no list yet.
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-12')
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
fireEvent.click(await screen.findByRole('link', { name: 'Shopping list · 28' }))
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
})
it('says changes to a pushed plan are not on Google Calendar', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), ...lists })
await renderPage()
+41 -15
View File
@@ -7,8 +7,10 @@ import { QUEST_LOG_KEY } from '../discover/api.ts'
import { Confirm } from '../settings/LeaveGuard.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { supplyRunScreen, useSupplyRun } from '../shopping/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button, type ButtonVariant } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
@@ -79,10 +81,6 @@ const settingsFailures: Record<string, { reason: string; fix: string }> = {
},
}
// SHOPPING_HINT is why the shopping list is unavailable.
// TODO(Feature 17): open the Supply run, with its count, once it is built.
const SHOPPING_HINT = 'The shopping list arrives once the Supply run is built.'
// syncStatuses is the plan manager's line on whether the plan is on Calendar.
const syncStatuses: Record<PlanStatus, string> = {
draft: 'Not pushed to Google Calendar yet',
@@ -195,6 +193,22 @@ 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, 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 (
<Link to={supplyRunScreen(weekStart)} className={className}>
<Icon icon={ShoppingCart} size="inline" />
{neededCount !== undefined ? `Shopping list · ${neededCount}` : 'Shopping list'}
</Link>
)
}
// SkeletonTiles stands in for the seven days while the plan loads or the AI
// drafts it, as two columns of tiles on mobile and seven columns wider.
function SkeletonTiles({ label, layout, children }: { label: string; layout: Breakpoint; children?: string }) {
@@ -217,9 +231,10 @@ function SkeletonTiles({ label, layout, children }: { label: string; layout: Bre
// the plan is read again. Rebuilding a plan pushed to Calendar asks first,
// and sends confirm. A failed draft is shown with Retry; no plan is made up.
//
// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week, Push to
// Calendar and the unavailable Shopping list are actions in the header,
// over the plan's sync status and the 7-column grid.
// From tablet up it is the plan manager (mocks 2c, 2d): Shopping list,
// Rebuild week and Push to Calendar are actions in the header, over the
// plan's sync status and the 7-column grid. On mobile the Shopping list
// link sits under the push button.
//
// Push to Calendar, on either layout, sends the plan shown and shows it as
// synced once the server answers; a failure says why, linking to the Google
@@ -243,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<string | null>(null)
@@ -360,6 +376,16 @@ function WeekBoard({
) : (
<ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner>
)
// 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. The count is that of a
// successful read only: a failed refetch keeps the earlier Supply run in
// run.data, and its count is then no longer current.
const neededCount = run.isSuccess ? run.data?.needed_count : undefined
const shoppingList = <ShoppingList weekStart={weekStart} neededCount={neededCount} layout={layout} />
const supplyRunFailure = run.isError && (
<ErrorBanner onRetry={() => void run.refetch()}>Couldn't load this week's shopping list. {run.error.message}</ErrorBanner>
)
// swapIn sends the swap chosen in the sheet and closes it.
function swapIn(chosen: Swap) {
@@ -462,6 +488,8 @@ function WeekBoard({
{pushing ? 'Pushing to Calendar…' : 'Looks good — push to Calendar'}
</Button>
<SyncStatus plan={plan.data} className={styles.mobileStatus} />
{shoppingList}
{supplyRunFailure}
{sheet}
</>
)
@@ -493,10 +521,7 @@ function WeekBoard({
<div className={styles.actionBar}>
{plan.data ? <SyncStatus plan={plan.data} /> : <span className={styles.status} />}
<div className={styles.actions}>
<Unavailable hint={SHOPPING_HINT} variant="outline" below className={styles.action}>
<Icon icon={ShoppingCart} size="inline" />
Shopping list
</Unavailable>
{shoppingList}
<Button
variant="outline"
className={styles.action}
@@ -537,6 +562,7 @@ function WeekBoard({
{header}
{body}
{failure}
{layout !== 'mobile' && supplyRunFailure}
<Confirm
open={confirming}
title="Rebuild this week?"
@@ -600,10 +626,10 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
)
}
// Unavailable is an action with nothing behind it yet (the shopping list):
// it stays focusable, so a tap or focus shows the tooltip saying why, above
// it or, at the top of the page, below.
function Unavailable({
// Unavailable is an action with nothing behind it yet (Send to Google
// Chat): it stays focusable, so a tap or focus shows the tooltip saying
// why, above it or, at the top of the page, below.
export function Unavailable({
hint,
variant = 'primary',
below = false,