From 3a0459cce3b0ae32eab9cb10f272f6a2345e2900 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Fri, 9 Oct 2026 21:02:03 -0400 Subject: [PATCH 1/3] Build the Home screen (mock 1g) from GET /api/v1/home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the / placeholder with HomePage: the household-local date over "Tonight's quest", the Recent updates bell with its grape unread dot, the Tonight card (washed photo, name, "Regular · 40 min" / "Side quest · 30 min", dark Recipe pill to /dinner/:id), the Quick dinner and Cook from what I have tiles, and the status rows to /discover and the week's Supply run. With no plan the card is "No quest planned tonight" with "Plan this week"; rows that do not apply are hidden. Loading shows skeletons; a failure shows an ErrorBanner with Retry. The offline bootstrap tests now emit their online reset before cleanup: TanStack's online manager only listens while a query client is mounted, so the reset was lost and Home's query stayed paused in later tests. Maestro task 5170be5d-4787-4ea8-8530-c19e98e591bc (Feature 21). Co-Authored-By: Claude Opus 5.5 --- web/src/App.test.tsx | 30 ++- web/src/App.tsx | 2 + web/src/home/HomePage.module.css | 193 ++++++++++++++++ web/src/home/HomePage.test.tsx | 218 ++++++++++++++++++ web/src/home/HomePage.tsx | 147 ++++++++++++ web/src/home/api.ts | 82 +++++++ web/src/onboarding/NotificationsStep.test.tsx | 13 +- web/src/test/ApiProviders.tsx | 11 + 8 files changed, 682 insertions(+), 14 deletions(-) create mode 100644 web/src/home/HomePage.module.css create mode 100644 web/src/home/HomePage.test.tsx create mode 100644 web/src/home/HomePage.tsx create mode 100644 web/src/home/api.ts diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index af91d47..f73ad84 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -5,7 +5,7 @@ import { createNetworkStatus } from './pwa/networkStatus.ts' import { mockViewport } from './test/matchMedia.ts' import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts' import App, { routes } from './App.tsx' -import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx' +import { AI_SETTINGS, HOME, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx' import { InDataRouter } from './test/dataRouter.tsx' import { dinnerBody } from './test/dinners.ts' import { defaultWeek } from './plan/week.ts' @@ -30,6 +30,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) { // The routes that render a built screen rather than a placeholder page. const SCREENS = { + '/': true, '/onboarding': true, '/plan': true, '/plan/shopping': true, @@ -85,6 +86,16 @@ describe('App routes', () => { }, ) + it('/ renders the Home screen inside the shell', async () => { + mockViewport(390) + stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME) }) + renderAt('/') + const heading = await screen.findByRole('heading', { level: 1, name: "Tonight's quest" }) + expect(screen.getByRole('main')).toContainElement(heading) + expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument() + expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument() + }) + it('/settings renders the settings home inside the shell', async () => { mockViewport(390) stubFetchByPath({ @@ -262,12 +273,13 @@ describe('App bootstrap', () => { it('reports a /me failure with a retry instead of rendering the routes', async () => { mockViewport(390) - stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS)) + stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS), jsonResponse(200, HOME)) renderAt('/') expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message') screen.getByRole('button', { name: 'Retry' }).click() - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() + expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument() }) }) @@ -282,9 +294,9 @@ describe('App offline bootstrap', () => { // visitOnline is an earlier online visit on this device: /me answers with a // member, then the app is closed. async function visitOnline() { - stubFetchByPath(SIGNED_IN) + stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME) }) const { unmount } = renderAt('/') - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument() unmount() } @@ -295,14 +307,16 @@ describe('App offline bootstrap', () => { }) // A test that emits an offline event leaves TanStack Query's online - // manager, shared by every test, offline until an online event. + // manager, shared by every test, offline until an online event. The + // manager listens for it only while a query client is mounted, so the + // event goes out before cleanup unmounts the app. afterEach(() => { - cleanup() window.dispatchEvent(new Event('online')) + cleanup() }) it.each([ - ['/', 'Home'], + ['/', "Tonight's quest"], ['/settings/notifications', 'Notifications'], ])('an offline reload of %s after an online visit opens the saved page', async (path, title) => { await visitOnline() diff --git a/web/src/App.tsx b/web/src/App.tsx index deea5b5..54ad6d7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts' import { AuthBootstrap } from './AuthBootstrap.tsx' import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx' import { DiscoverPage } from './discover/DiscoverPage.tsx' +import { HomePage } from './home/HomePage.tsx' import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx' import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx' import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx' @@ -45,6 +46,7 @@ export const routes = [ // The screens built so far; every other route shows its placeholder page. const screens: Partial> = { + '/': , '/plan': , '/plan/shopping': , '/plan/shopping/archive': , diff --git a/web/src/home/HomePage.module.css b/web/src/home/HomePage.module.css new file mode 100644 index 0000000..9cd4b56 --- /dev/null +++ b/web/src/home/HomePage.module.css @@ -0,0 +1,193 @@ +/* Home, handoff mock 1g. */ +.page { + box-sizing: border-box; + max-width: 560px; + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 6px; +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 2px; +} + +.date { + margin: 0; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 28px; + font-weight: 400; + line-height: 1.1; +} + +/* The bell's 8 px grape unread dot sits on the 40 px face, which is + centred in the IconButton's 44 px hit box. */ +.bell { + position: relative; +} + +.bell .bellIcon { + width: 19px; + height: 19px; +} + +.dot { + position: absolute; + top: 10px; + right: 11px; + width: 8px; + height: 8px; + border-radius: var(--radius-pill); + background: var(--color-accent); +} + +.card { + display: flex; + flex-direction: column; + border-radius: 32px; + background: var(--color-surface); + overflow: hidden; +} + +.photo { + height: 150px; +} + +.cardFoot { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 10px; + padding: 2px 18px 16px; +} + +.cardText { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.name { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; + line-height: 1.1; +} + +.meta { + font-family: var(--font-body); + font-size: 12px; + color: var(--color-neutral-700); +} + +/* The dark "Recipe" pill: the mock's 38 px pill at the 44 px tap minimum. */ +.recipe { + flex: none; + min-height: 44px; + padding: 0 16px; + font-size: 13px; + text-decoration: none; +} + +.tiles { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.tile { + box-sizing: border-box; + height: 104px; + padding: 14px; + display: flex; + flex-direction: column; + justify-content: space-between; + border-radius: 26px; + text-decoration: none; +} + +.quick { + background: var(--color-accent); + color: var(--on-accent); +} + +.pantry { + background: var(--color-accent-2-300); + color: var(--color-accent-2-900); +} + +.tileLabel { + font-family: var(--font-heading); + font-size: 16px; + line-height: 1.1; +} + +.statuses { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.status { + box-sizing: border-box; + min-height: 44px; + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: 20px; + background: var(--color-neutral-100); + color: var(--color-text); + text-decoration: none; +} + +.status .statusIcon { + width: 18px; + height: 18px; + color: var(--color-accent-700); +} + +.statusText { + flex: 1; + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; +} + +.tile:focus-visible, +.status:focus-visible { + outline: 3px solid var(--color-accent); + outline-offset: 2px; +} + +.skeletonDate { + width: 96px; + height: 14px; + margin-bottom: 2px; +} + +.skeletonCard { + height: 238px; + border-radius: 32px; +} + +.skeletonStatus { + height: 44px; + border-radius: 20px; +} diff --git a/web/src/home/HomePage.test.tsx b/web/src/home/HomePage.test.tsx new file mode 100644 index 0000000..9416039 --- /dev/null +++ b/web/src/home/HomePage.test.tsx @@ -0,0 +1,218 @@ +import { fireEvent, render, screen, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider, useLocation } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ApiProviders, HOME } from '../test/ApiProviders.tsx' +import { declaredStyle } from '../test/cssRules.ts' +import { errorResponse, jsonResponse, stubFetch, stubFetchByPath } from '../test/fetchStub.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { HOME_PATH } from './api.ts' +import { HomePage } from './HomePage.tsx' + +// NOTHING_YET is the home of a household with no plan, no Side quests, no +// Supply run and nothing unread. +const NOTHING_YET = { + ...HOME, + tonight: null, + sideQuestCount: 0, + shoppingList: { weekStart: '2026-09-28', label: 'Week 40 · Sep 28 – Oct 4', ready: false, sentToChatAt: null }, + recentUpdates: { unread: false }, +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +// Where shows the route a link went to. +function Where() { + const location = useLocation() + return

{location.pathname + location.search}

+} + +function renderHome(body: unknown) { + stubFetchByPath({ [HOME_PATH]: () => jsonResponse(200, body) }) + return renderRouter() +} + +function renderRouter() { + const router = createMemoryRouter( + [ + { path: '/', element: }, + { path: '*', element: }, + ], + { initialEntries: ['/'] }, + ) + render( + + + , + ) +} + +describe('HomePage', () => { + it('renders mock 1g from GET /api/v1/home', async () => { + renderHome(HOME) + + expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() + + const tonight = screen.getByRole('region', { name: 'Tonight' }) + expect(within(tonight).getByRole('heading', { name: 'Pizza night' })).toBeInTheDocument() + expect(within(tonight).getByText('Regular · 40 min')).toBeInTheDocument() + expect(within(tonight).getByRole('img', { name: 'Pizza night' })).toHaveAttribute('src', '/media/photos/p1-400.jpg') + expect(within(tonight).getByRole('link', { name: 'Recipe' })).toHaveAttribute('href', '/dinner/d1') + + expect(screen.getByRole('link', { name: 'Quick dinner' })).toHaveAttribute('href', '/quick') + expect(screen.getByRole('link', { name: 'Cook from what I have' })).toHaveAttribute('href', '/cook-from-pantry') + expect(screen.getByRole('link', { name: '10 new side quests to swipe' })).toHaveAttribute('href', '/discover') + expect(screen.getByRole('link', { name: 'Shopping list ready · Oct 5–11' })).toHaveAttribute( + 'href', + '/plan/shopping?week=2026-10-05', + ) + expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument() + }) + + it('shows the grape unread dot on the bell when there are unread updates', async () => { + renderHome(HOME) + + const bell = await screen.findByRole('button', { name: 'Recent updates, unread' }) + const dot = within(bell).getByTestId('unread-dot') + expect(declaredStyle(dot).background).toBe('var(--color-accent)') + expect(declaredStyle(dot).width).toBe('8px') + expectMinHitArea(bell) + }) + + it('shows the bell without a dot when every update is seen', async () => { + renderHome(NOTHING_YET) + + const bell = await screen.findByRole('button', { name: 'Recent updates' }) + expect(within(bell).queryByTestId('unread-dot')).not.toBeInTheDocument() + }) + + it('labels a Side quest tonight "Side quest"', async () => { + renderHome({ ...HOME, tonight: { ...HOME.tonight, origin: 'side_quest', minutes_total: 30 } }) + expect(await screen.findByText('Side quest · 30 min')).toBeInTheDocument() + }) + + it('shows only the origin when tonight has no minutes', async () => { + renderHome({ ...HOME, tonight: { ...HOME.tonight, minutes_total: null, photo: null } }) + const tonight = await screen.findByRole('region', { name: 'Tonight' }) + expect(within(tonight).getByText('Regular')).toBeInTheDocument() + // No photo: the striped placeholder, named for the dinner. + expect(within(tonight).getByRole('img', { name: 'Pizza night' })).toHaveTextContent('dish photo') + }) + + it('offers to plan the week when tonight has no plan, and hides the rows that do not apply', async () => { + renderHome(NOTHING_YET) + + expect(await screen.findByRole('heading', { name: 'No quest planned tonight' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: /side quests? to swipe/ })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument() + expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument() + // The tiles stay. + expect(screen.getByRole('link', { name: 'Quick dinner' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Plan this week' })) + expect(await screen.findByTestId('where')).toHaveTextContent('/plan') + }) + + it('hides the Side quests row at zero and keeps a ready Supply run', async () => { + renderHome({ ...HOME, sideQuestCount: 0 }) + + expect(await screen.findByRole('link', { name: 'Shopping list ready · Oct 5–11' })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /side quests? to swipe/ })).not.toBeInTheDocument() + }) + + it('hides the Supply run row when no list is ready', async () => { + renderHome({ ...HOME, shoppingList: NOTHING_YET.shoppingList }) + + expect(await screen.findByRole('link', { name: '10 new side quests to swipe' })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument() + }) + + it('counts a single Side quest in the singular', async () => { + renderHome({ ...HOME, sideQuestCount: 1 }) + expect(await screen.findByRole('link', { name: '1 new side quest to swipe' })).toBeInTheDocument() + }) + + it('shows a week spanning two months in full', async () => { + renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, weekStart: '2026-09-28', label: 'Week 40 · Sep 28 – Oct 4' } }) + expect(await screen.findByRole('link', { name: 'Shopping list ready · Sep 28–Oct 4' })).toBeInTheDocument() + }) + + it.each([ + ['Recipe', '/dinner/d1'], + ['Quick dinner', '/quick'], + ['Cook from what I have', '/cook-from-pantry'], + ['10 new side quests to swipe', '/discover'], + ['Shopping list ready · Oct 5–11', '/plan/shopping?week=2026-10-05'], + ])('%s goes to %s', async (name, to) => { + renderHome(HOME) + + fireEvent.click(await screen.findByRole('link', { name })) + expect(await screen.findByTestId('where')).toHaveTextContent(to) + }) + + it('styles the card, tiles and rows as mock 1g with tap targets of at least 44 px', async () => { + renderHome(HOME) + + const tonight = await screen.findByRole('region', { name: 'Tonight' }) + expect(declaredStyle(tonight).borderRadius).toBe('32px') + expect(declaredStyle(within(tonight).getByRole('img')).height).toBe('150px') + expect(declaredStyle(within(tonight).getByRole('heading')).fontSize).toBe('22px') + + const quick = screen.getByRole('link', { name: 'Quick dinner' }) + expect(declaredStyle(quick).height).toBe('104px') + expect(declaredStyle(quick).background).toBe('var(--color-accent)') + expect(declaredStyle(quick).color).toBe('var(--on-accent)') + const pantry = screen.getByRole('link', { name: 'Cook from what I have' }) + expect(declaredStyle(pantry).height).toBe('104px') + expect(declaredStyle(pantry).background).toBe('var(--color-accent-2-300)') + + expect(declaredStyle(screen.getByText('Friday, Oct 2')).fontSize).toBe('12px') + + expectMinHitArea(within(tonight).getByRole('link', { name: 'Recipe' })) + // The tiles are 104 px tall (above); the status rows fill the width at 44 px or more. + for (const row of within(screen.getByRole('list', { name: 'Status' })).getAllByRole('link')) { + expect(declaredStyle(row).minHeight).toBe('44px') + } + }) + + it('shows skeletons while the home loads', async () => { + let answer!: (response: Response) => void + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise((resolve) => (answer = resolve))), + ) + renderRouter() + + expect(screen.getByTestId('skeleton-tonight')).toBeInTheDocument() + expect(screen.getByTestId('skeleton-date')).toBeInTheDocument() + expect(screen.getAllByTestId('skeleton-status')).toHaveLength(2) + expect(screen.getByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() + + answer(jsonResponse(200, HOME)) + expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument() + expect(screen.queryByTestId('skeleton-tonight')).not.toBeInTheDocument() + expect(screen.queryByTestId('skeleton-status')).not.toBeInTheDocument() + }) + + it('reports a failure with Retry, and no made-up content', async () => { + stubFetch(errorResponse(500, 'internal'), jsonResponse(200, HOME)) + renderRouter() + + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load tonight's quest. internal message") + expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument() + expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('reports a home it cannot read rather than guessing', async () => { + renderHome({ ...HOME, tonight: { ...HOME.tonight, origin: 'leftovers' } }) + expect(await screen.findByRole('alert')).toHaveTextContent('origin "leftovers" is not one of regular, side_quest') + }) +}) diff --git a/web/src/home/HomePage.tsx b/web/src/home/HomePage.tsx new file mode 100644 index 0000000..fe88894 --- /dev/null +++ b/web/src/home/HomePage.tsx @@ -0,0 +1,147 @@ +import { Bell, ChevronRight, Compass, Search, ShoppingCart, Zap, type LucideIcon } from 'lucide-react' +import { Link, useNavigate } from 'react-router' +import { duration } from '../plan/PlanPage.tsx' +import { supplyRunScreen } from '../shopping/api.ts' +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' +import { IconButton } from '../ui/IconButton.tsx' +import { Photo } from '../ui/Photo.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { useHome, type Home, type Origin, type Tonight } from './api.ts' +import styles from './HomePage.module.css' + +const ORIGIN_LABEL: Record = { regular: 'Regular', side_quest: 'Side quest' } + +// meta is the Tonight card's "Regular · 40 min" line, the time only when +// the recipe says it. +function meta({ origin, minutes_total }: Tonight): string { + return minutes_total === null ? ORIGIN_LABEL[origin] : `${ORIGIN_LABEL[origin]} · ${duration(minutes_total)}` +} + +// TonightCard is the dinner planned for today: its washed photo, name and +// meta line, and the dark Recipe pill to its recipe. +function TonightCard({ tonight }: { tonight: Tonight }) { + return ( +
+ +
+
+

{tonight.name}

+ {meta(tonight)} +
+ + Recipe + +
+
+ ) +} + +// NoPlan stands in for the Tonight card when today has no plan. +function NoPlan() { + const navigate = useNavigate() + return ( +
+ void navigate('/plan') }}> + Draft this week's dinners and tonight's quest shows up here. + +
+ ) +} + +// StatusRow is one neutral row under the tiles: a grape-700 icon, its line +// and a chevron, linking to the screen it is about. +function StatusRow({ to, icon, children }: { to: string; icon: LucideIcon; children: string }) { + return ( +
  • + + + {children} + + +
  • + ) +} + +// StatusRows are the rows that apply: Side quests waiting in the deck, and +// a Supply run ready. With neither, there are no rows. +function StatusRows({ home }: { home: Home }) { + const count = home.sideQuestCount + const list = home.shoppingList + if (count === 0 && !list.ready) return null + return ( +
      + {count > 0 && ( + + {`${count} new side ${count === 1 ? 'quest' : 'quests'} to swipe`} + + )} + {list.ready && ( + + {`Shopping list ready · ${list.dates}`} + + )} +
    + ) +} + +// Tiles are the two shortcuts, Quick dinner and Cook from what I have. +function Tiles() { + return ( +
    + + + Quick dinner + + + + Cook from what I have + +
    + ) +} + +// HomePage is mock 1g, Home: today's date over "Tonight's quest" and the +// Recent updates bell, the Tonight card (or the way to plan the week), the +// Quick dinner and Cook from what I have tiles, and the status rows, all +// from GET /api/v1/home. +// +// The bell only shows whether there are unread updates; the Recent updates +// sheet it opens is still to come. +export function HomePage() { + const home = useHome() + const data = home.data + return ( +
    +
    +
    + {data !== undefined ? ( +

    {data.dateLabel}

    + ) : ( + home.isPending && + )} +

    Tonight's quest

    +
    + + + {data?.unread && } + +
    + {home.isPending && } + {home.isError && ( + void home.refetch()}>Couldn't load tonight's quest. {home.error.message} + )} + {data !== undefined && (data.tonight === null ? : )} + + {home.isPending && ( + + )} + {data !== undefined && } +
    + ) +} diff --git a/web/src/home/api.ts b/web/src/home/api.ts new file mode 100644 index 0000000..ce597a5 --- /dev/null +++ b/web/src/home/api.ts @@ -0,0 +1,82 @@ +import { useQuery } from '@tanstack/react-query' +import { useApi } from '../api/index.ts' +import { isRecord } from '../api/errors.ts' +import { oneOf, photoId } from '../dinners/api.ts' +import { weekLabel } from '../plan/week.ts' + +export const HOME_PATH = '/api/v1/home' +export const HOME_KEY = ['home'] as const + +export const ORIGINS = ['regular', 'side_quest'] as const +export type Origin = (typeof ORIGINS)[number] + +// Tonight is the dinner planned for today: photo_id is its stored photo's +// id, from photo, and minutes_total its minutes from prep to table, null +// when the recipe does not say. +export type Tonight = { id: string; name: string; photo_id: string | undefined; origin: Origin; minutes_total: number | null } + +// ShoppingListStatus is the Supply run Home points at: next week's once it +// has one, otherwise this week's, ready when that week has a list. +// weekStart is the week's Monday, YYYY-MM-DD, and dates the week as the +// status row shows it, "Oct 5–11" (the API's label is the long "Week 41 · +// Oct 5 – 11"). +export type ShoppingListStatus = { weekStart: string; dates: string; ready: boolean } + +// Home is GET /api/v1/home: dateLabel is today, household-local, "Friday, +// Oct 2"; tonight is null when today has no plan; sideQuestCount is how +// many Side quests wait in the deck; unread says the Recent updates hold +// something not yet seen. +export type Home = { + dateLabel: string + tonight: Tonight | null + sideQuestCount: number + shoppingList: ShoppingListStatus + unread: boolean +} + +function parseTonight(where: string, body: unknown): Tonight | null { + if (body === null) return null + if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') { + throw new Error(`${where}: tonight is not a dinner`) + } + if (body.minutes_total !== null && !Number.isInteger(body.minutes_total)) { + throw new Error(`${where}: tonight's minutes_total is not a whole number`) + } + return { + id: body.id, + name: body.name, + photo_id: photoId(where, body.photo), + origin: oneOf(where, 'origin', ORIGINS, body.origin), + minutes_total: body.minutes_total as number | null, + } +} + +// parseHome checks a GET /api/v1/home body. +export function parseHome(where: string, body: unknown): Home { + if (!isRecord(body) || typeof body.dateLabel !== 'string') throw new Error(`${where}: response is not the home`) + if (!Number.isInteger(body.sideQuestCount) || (body.sideQuestCount as number) < 0) { + throw new Error(`${where}: sideQuestCount is not a count`) + } + const list = body.shoppingList + if (!isRecord(list) || typeof list.weekStart !== 'string' || typeof list.ready !== 'boolean') { + throw new Error(`${where}: shoppingList is not a week's Supply run status`) + } + const updates = body.recentUpdates + if (!isRecord(updates) || typeof updates.unread !== 'boolean') throw new Error(`${where}: recentUpdates has no unread flag`) + return { + dateLabel: body.dateLabel, + tonight: parseTonight(where, body.tonight), + sideQuestCount: body.sideQuestCount as number, + shoppingList: { weekStart: list.weekStart, dates: weekLabel(list.weekStart).dates.replace(' – ', '–'), ready: list.ready }, + unread: updates.unread, + } +} + +// useHome is the Home screen's summary (GET /api/v1/home). +export function useHome() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: HOME_KEY, + queryFn: async () => parseHome(`GET ${HOME_PATH}`, await apiFetch(HOME_PATH)), + }) +} diff --git a/web/src/onboarding/NotificationsStep.test.tsx b/web/src/onboarding/NotificationsStep.test.tsx index 55ec2a4..9876fe7 100644 --- a/web/src/onboarding/NotificationsStep.test.tsx +++ b/web/src/onboarding/NotificationsStep.test.tsx @@ -5,7 +5,7 @@ import { createApiClient, createVerifiedSession } from '../api/index.ts' import App from '../App.tsx' import { AuthBootstrap } from '../AuthBootstrap.tsx' import { createNetworkStatus } from '../pwa/networkStatus.ts' -import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' +import { ApiProviders, HOME, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' import { declaredStyle } from '../test/cssRules.ts' import { InDataRouter } from '../test/dataRouter.tsx' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' @@ -58,6 +58,7 @@ function stubFirstRunServer(vapidKey: () => Response = () => jsonResponse(200, V const path = String(input) const method = init?.method ?? 'GET' if (method === 'GET' && path === '/api/v1/me') return Promise.resolve(jsonResponse(200, ME)) + if (method === 'GET' && path === '/api/v1/home') return Promise.resolve(jsonResponse(200, HOME)) if (method === 'GET' && path === '/api/v1/push/vapid-public-key') return Promise.resolve(vapidKey()) if (method === 'POST' && path === '/api/v1/push/subscriptions') { subscriptions.push(JSON.parse(String(init?.body))) @@ -110,7 +111,7 @@ async function renderStep(vapidKey?: () => Response) { } /> - Home} /> + Tonight's quest} /> Regulars step} /> @@ -134,7 +135,7 @@ async function finishFromResult(server: ReturnType) { await vi.waitFor(() => expect(server.held).toHaveLength(1)) expect(server.held[0]!.body).toEqual({ onboarding_completed: true }) completed(server.held[0]!) - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() } function useIphoneSafariTab() { @@ -210,7 +211,7 @@ describe('onboarding step 3', () => { expect(later()).toBeDisabled() completed(server.held[0]!) - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument() expect(requestPermission).not.toHaveBeenCalled() }) @@ -314,7 +315,7 @@ describe('onboarding step 3', () => { await vi.waitFor(() => expect(server.held).toHaveLength(1)) completed(server.held[0]!) - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() expect(browser.notification.requestPermission).not.toHaveBeenCalled() expect(browser.pushManager.subscribe).not.toHaveBeenCalled() expect(server.subscriptions).toEqual([]) @@ -358,7 +359,7 @@ describe('onboarding step 3', () => { expect(screen.queryByRole('alert')).not.toBeInTheDocument() await vi.waitFor(() => expect(server.held).toHaveLength(2)) completed(server.held[1]!) - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() }) }) diff --git a/web/src/test/ApiProviders.tsx b/web/src/test/ApiProviders.tsx index 4405812..caeaaa6 100644 --- a/web/src/test/ApiProviders.tsx +++ b/web/src/test/ApiProviders.tsx @@ -61,6 +61,17 @@ export const GOOGLE_NOT_CONNECTED = { last_sync_error: null, } +// HOME is a GET /api/v1/home body as mock 1g shows it: Pizza night, a +// Regular, tonight; ten Side quests; next week's Supply run ready; an +// unread update. +export const HOME = { + dateLabel: 'Friday, Oct 2', + tonight: { id: 'd1', name: 'Pizza night', photo: '/media/photos/p1-400.jpg', origin: 'regular', minutes_total: 40 }, + sideQuestCount: 10, + shoppingList: { weekStart: '2026-10-05', label: 'Week 41 · Oct 5 – 11', ready: true, sentToChatAt: null }, + recentUpdates: { unread: true }, +} + // NOTIFICATION_SETTINGS is a GET /api/v1/settings/notifications body: the // seed channels and reminder time. export const NOTIFICATION_SETTINGS = { -- 2.55.0 From 8e18c0b37c04afaa2cdfe036ccf0bac3ea7ca87a Mon Sep 17 00:00:00 2001 From: dev_agent Date: Fri, 9 Oct 2026 21:15:12 -0400 Subject: [PATCH 2/3] Reject malformed Home minutes and Supply run labels (review H1) parseHome now fails fast into the query error instead of rendering bad values: tonight's minutes_total must be a positive whole number or null, and a ready shoppingList must carry a non-empty label that is the label of its weekStart. Task: 5170be5d-4787-4ea8-8530-c19e98e591bc Co-Authored-By: Claude Opus 5.5 --- web/src/home/HomePage.test.tsx | 39 ++++++++++++++++++++++++++++++++++ web/src/home/api.ts | 36 ++++++++++++++++++++++--------- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/web/src/home/HomePage.test.tsx b/web/src/home/HomePage.test.tsx index 9416039..59fe8ef 100644 --- a/web/src/home/HomePage.test.tsx +++ b/web/src/home/HomePage.test.tsx @@ -215,4 +215,43 @@ describe('HomePage', () => { renderHome({ ...HOME, tonight: { ...HOME.tonight, origin: 'leftovers' } }) expect(await screen.findByRole('alert')).toHaveTextContent('origin "leftovers" is not one of regular, side_quest') }) + + it.each([0, -1, 2.5])('reports tonight with minutes_total %s rather than showing it', async (minutes) => { + renderHome({ ...HOME, tonight: { ...HOME.tonight, minutes_total: minutes } }) + + expect(await screen.findByRole('alert')).toHaveTextContent( + `tonight's minutes_total ${minutes} is not a positive whole number`, + ) + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument() + expect(screen.queryByText(/min$/)).not.toBeInTheDocument() + }) + + it.each([ + ['a missing', undefined], + ['an empty', ''], + ])('reports a ready Supply run with %s label rather than showing it', async (_, label) => { + renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, label } }) + + expect(await screen.findByRole('alert')).toHaveTextContent('the ready shoppingList has no label') + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument() + expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument() + }) + + it('reports a ready Supply run whose label is not its week rather than showing it', async () => { + renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, label: 'Week 40 · Sep 28 – Oct 4' } }) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'shoppingList label "Week 40 · Sep 28 – Oct 4" is not the week of 2026-10-05', + ) + expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument() + }) + + it('reports a Supply run whose weekStart is not a date rather than showing it', async () => { + renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, weekStart: 'Oct 5' } }) + + expect(await screen.findByRole('alert')).toHaveTextContent('not a YYYY-MM-DD date') + expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument() + }) }) diff --git a/web/src/home/api.ts b/web/src/home/api.ts index ce597a5..ef5b7cd 100644 --- a/web/src/home/api.ts +++ b/web/src/home/api.ts @@ -11,8 +11,8 @@ export const ORIGINS = ['regular', 'side_quest'] as const export type Origin = (typeof ORIGINS)[number] // Tonight is the dinner planned for today: photo_id is its stored photo's -// id, from photo, and minutes_total its minutes from prep to table, null -// when the recipe does not say. +// id, from photo, and minutes_total its minutes from prep to table, a +// positive whole number, or null when the recipe does not say. export type Tonight = { id: string; name: string; photo_id: string | undefined; origin: Origin; minutes_total: number | null } // ShoppingListStatus is the Supply run Home points at: next week's once it @@ -39,35 +39,51 @@ function parseTonight(where: string, body: unknown): Tonight | null { if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') { throw new Error(`${where}: tonight is not a dinner`) } - if (body.minutes_total !== null && !Number.isInteger(body.minutes_total)) { - throw new Error(`${where}: tonight's minutes_total is not a whole number`) + const minutes = body.minutes_total ?? null + if (minutes !== null && (!Number.isInteger(minutes) || (minutes as number) <= 0)) { + throw new Error(`${where}: tonight's minutes_total ${String(minutes)} is not a positive whole number`) } return { id: body.id, name: body.name, photo_id: photoId(where, body.photo), origin: oneOf(where, 'origin', ORIGINS, body.origin), - minutes_total: body.minutes_total as number | null, + minutes_total: minutes as number | null, } } +// parseShoppingList checks the shoppingList of a GET /api/v1/home body. A +// ready list must carry the API's label for its week, which must be the +// label weekStart has, since the status row shows dates from weekStart. +function parseShoppingList(where: string, list: unknown): ShoppingListStatus { + if (!isRecord(list) || typeof list.weekStart !== 'string' || typeof list.ready !== 'boolean') { + throw new Error(`${where}: shoppingList is not a week's Supply run status`) + } + const week = weekLabel(list.weekStart) + if (list.ready) { + if (typeof list.label !== 'string' || list.label === '') { + throw new Error(`${where}: the ready shoppingList has no label`) + } + if (list.label !== `${week.week} · ${week.dates}`) { + throw new Error(`${where}: shoppingList label "${list.label}" is not the week of ${list.weekStart}`) + } + } + return { weekStart: list.weekStart, dates: week.dates.replace(' – ', '–'), ready: list.ready } +} + // parseHome checks a GET /api/v1/home body. export function parseHome(where: string, body: unknown): Home { if (!isRecord(body) || typeof body.dateLabel !== 'string') throw new Error(`${where}: response is not the home`) if (!Number.isInteger(body.sideQuestCount) || (body.sideQuestCount as number) < 0) { throw new Error(`${where}: sideQuestCount is not a count`) } - const list = body.shoppingList - if (!isRecord(list) || typeof list.weekStart !== 'string' || typeof list.ready !== 'boolean') { - throw new Error(`${where}: shoppingList is not a week's Supply run status`) - } const updates = body.recentUpdates if (!isRecord(updates) || typeof updates.unread !== 'boolean') throw new Error(`${where}: recentUpdates has no unread flag`) return { dateLabel: body.dateLabel, tonight: parseTonight(where, body.tonight), sideQuestCount: body.sideQuestCount as number, - shoppingList: { weekStart: list.weekStart, dates: weekLabel(list.weekStart).dates.replace(' – ', '–'), ready: list.ready }, + shoppingList: parseShoppingList(where, body.shoppingList), unread: updates.unread, } } -- 2.55.0 From d572ee192dc98f9f31ee596e7108c876e6e135a9 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Fri, 9 Oct 2026 21:21:34 -0400 Subject: [PATCH 3/3] Require Home minutes_total and Supply run label for every list (review H1) parseTonight now rejects a tonight dinner without the minutes_total key instead of reading it as null; an explicit null is still valid. The Home API always emits the key. parseShoppingList checks the label against weekStart for unready lists too, since the API supplies it regardless of readiness. Co-Authored-By: Claude Opus 5.5 --- web/src/home/HomePage.test.tsx | 31 +++++++++++++++++++++++++++++++ web/src/home/api.ts | 21 ++++++++++----------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/web/src/home/HomePage.test.tsx b/web/src/home/HomePage.test.tsx index 59fe8ef..5a2f838 100644 --- a/web/src/home/HomePage.test.tsx +++ b/web/src/home/HomePage.test.tsx @@ -227,6 +227,16 @@ describe('HomePage', () => { expect(screen.queryByText(/min$/)).not.toBeInTheDocument() }) + it('reports tonight without a minutes_total rather than showing it as unknown', async () => { + const { minutes_total: _, ...tonight } = HOME.tonight + renderHome({ ...HOME, tonight }) + + expect(await screen.findByRole('alert')).toHaveTextContent('tonight has no minutes_total') + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument() + expect(screen.queryByText('Regular')).not.toBeInTheDocument() + }) + it.each([ ['a missing', undefined], ['an empty', ''], @@ -248,6 +258,27 @@ describe('HomePage', () => { expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument() }) + it.each([ + ['a missing', undefined], + ['an empty', ''], + ])('reports an unready Supply run with %s label rather than showing the home', async (_, label) => { + renderHome({ ...HOME, shoppingList: { ...NOTHING_YET.shoppingList, label } }) + + expect(await screen.findByRole('alert')).toHaveTextContent('the unready shoppingList has no label') + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument() + }) + + it('reports an unready Supply run whose label is not its week rather than showing the home', async () => { + renderHome({ ...HOME, shoppingList: { ...NOTHING_YET.shoppingList, label: 'Week 41 · Oct 5 – 11' } }) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'shoppingList label "Week 41 · Oct 5 – 11" is not the week of 2026-09-28', + ) + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument() + }) + it('reports a Supply run whose weekStart is not a date rather than showing it', async () => { renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, weekStart: 'Oct 5' } }) diff --git a/web/src/home/api.ts b/web/src/home/api.ts index ef5b7cd..fb836cc 100644 --- a/web/src/home/api.ts +++ b/web/src/home/api.ts @@ -39,7 +39,8 @@ function parseTonight(where: string, body: unknown): Tonight | null { if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') { throw new Error(`${where}: tonight is not a dinner`) } - const minutes = body.minutes_total ?? null + if (!('minutes_total' in body)) throw new Error(`${where}: tonight has no minutes_total`) + const minutes = body.minutes_total if (minutes !== null && (!Number.isInteger(minutes) || (minutes as number) <= 0)) { throw new Error(`${where}: tonight's minutes_total ${String(minutes)} is not a positive whole number`) } @@ -52,21 +53,19 @@ function parseTonight(where: string, body: unknown): Tonight | null { } } -// parseShoppingList checks the shoppingList of a GET /api/v1/home body. A -// ready list must carry the API's label for its week, which must be the -// label weekStart has, since the status row shows dates from weekStart. +// parseShoppingList checks the shoppingList of a GET /api/v1/home body. The +// list, ready or not, must carry the API's label for its week, which must be +// the label weekStart has, since the status row shows dates from weekStart. function parseShoppingList(where: string, list: unknown): ShoppingListStatus { if (!isRecord(list) || typeof list.weekStart !== 'string' || typeof list.ready !== 'boolean') { throw new Error(`${where}: shoppingList is not a week's Supply run status`) } const week = weekLabel(list.weekStart) - if (list.ready) { - if (typeof list.label !== 'string' || list.label === '') { - throw new Error(`${where}: the ready shoppingList has no label`) - } - if (list.label !== `${week.week} · ${week.dates}`) { - throw new Error(`${where}: shoppingList label "${list.label}" is not the week of ${list.weekStart}`) - } + if (typeof list.label !== 'string' || list.label === '') { + throw new Error(`${where}: the ${list.ready ? 'ready' : 'unready'} shoppingList has no label`) + } + if (list.label !== `${week.week} · ${week.dates}`) { + throw new Error(`${where}: shoppingList label "${list.label}" is not the week of ${list.weekStart}`) } return { weekStart: list.weekStart, dates: week.dates.replace(' – ', '–'), ready: list.ready } } -- 2.55.0