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 = {