diff --git a/web/src/plan/PlanPage.module.css b/web/src/plan/PlanPage.module.css index 49acb3b..9cac0e3 100644 --- a/web/src/plan/PlanPage.module.css +++ b/web/src/plan/PlanPage.module.css @@ -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; diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index f3d73bf..fc76865 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -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) { const all: Record = { 'GET /api/v1/me': () => jsonResponse(200, ME), @@ -110,7 +114,7 @@ function stubServer(responders: Record) { } const fetchMock = vi.fn((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() { - + + } /> + } /> + , ) } +// Opened stands in for the screen a link opened, saying where it is. +function Opened() { + const { pathname, search } = useLocation() + return

Opened {pathname + search}

+} + +// 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,35 @@ 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('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') + 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 +1083,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 +1095,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 +1154,27 @@ describe.each([ expect(status).toHaveTextContent('Synced to Google Calendar 1 hr ago') }) + 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() diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index c5bf6ce..385ad63 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -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 = { }, } -// 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 = { 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, 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) + const className = layout === 'mobile' ? styles.shoppingLink : [buttonStyles.button, buttonStyles.outline, styles.action].join(' ') + return ( + + + {run.data ? `Shopping list · ${run.data.needed_count}` : 'Shopping list'} + + ) +} + // 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 @@ -462,6 +477,7 @@ function WeekBoard({ {pushing ? 'Pushing to Calendar…' : 'Looks good — push to Calendar'} + {sheet} ) @@ -493,10 +509,7 @@ function WeekBoard({
{plan.data ? : }
- - - Shopping list - +