From 0a5db6a9e69c72cee53a7daadc614530d07e36f7 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 17:34:52 -0400 Subject: [PATCH] Onboarding steps 1 and 2 (mock 1h) with first-run redirect (#91) Co-authored-by: dev_agent --- docs/ARCHITECTURE.md | 9 + web/src/App.test.tsx | 76 +++- web/src/App.tsx | 17 +- web/src/AuthBootstrap.tsx | 5 + web/src/dinners/api.ts | 119 ++++++ web/src/onboarding/Onboarding.module.css | 228 ++++++++++++ web/src/onboarding/OnboardingGate.tsx | 52 +++ web/src/onboarding/OnboardingPage.test.tsx | 410 +++++++++++++++++++++ web/src/onboarding/OnboardingPage.tsx | 77 ++++ web/src/onboarding/OnboardingStep.tsx | 35 ++ web/src/onboarding/RegularsStep.tsx | 166 +++++++++ web/src/settings/api.ts | 17 +- web/src/shell/AppShell.test.tsx | 41 ++- web/src/shell/BottomBar.test.tsx | 50 +-- web/src/test/ApiProviders.tsx | 4 +- 15 files changed, 1239 insertions(+), 67 deletions(-) create mode 100644 web/src/dinners/api.ts create mode 100644 web/src/onboarding/Onboarding.module.css create mode 100644 web/src/onboarding/OnboardingGate.tsx create mode 100644 web/src/onboarding/OnboardingPage.test.tsx create mode 100644 web/src/onboarding/OnboardingPage.tsx create mode 100644 web/src/onboarding/OnboardingStep.tsx create mode 100644 web/src/onboarding/RegularsStep.tsx diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2b64f31..8faa66e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -484,6 +484,15 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. `/settings/household`, `/settings/regulars` and `/settings/calendar` come from CTXD; the other routes are defined in the UI handoff. +First run: while `household_settings.onboarding_completed_at` (read from +`GET /api/v1/settings/household`) is null, every route redirects to +`/onboarding`. Onboarding is a full screen outside the app shell, in three +steps: the welcome (`/onboarding`), the Regulars (`/onboarding/regulars`, +mock 1h, adding and archiving through the dinners API optimistically) and +notifications (`/onboarding/notifications`). Offline on the saved pages the +settings cannot be read (the service worker never caches `/api/v1/settings`), +so the saved pages open as they are. + ## Boundaries and gotchas - **Secrets never reach the client** — API keys, OAuth tokens and the Chat diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 82d8105..94b54b0 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -27,7 +27,14 @@ function renderAppAt(path: string, location = fakeLocation(path)) { } // The routes that render a built screen rather than a placeholder page. -const SCREENS = { '/settings': true, '/settings/ai': true, '/settings/household': true } +const SCREENS = { '/onboarding': true, '/settings': true, '/settings/ai': true, '/settings/household': true } + +// SIGNED_IN answers what every route reads first: the session, then the +// household settings, onboarding finished. +const SIGNED_IN = { + '/api/v1/me': () => jsonResponse(200, ME), + '/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), +} describe('App routes', () => { it('registers every handoff route plus the extra settings pages', () => { @@ -54,7 +61,7 @@ describe('App routes', () => { '%s renders its placeholder page inside the shell', async (path, title) => { mockViewport(390) - stubFetch(jsonResponse(200, ME)) + stubFetchByPath(SIGNED_IN) renderAt(path) const heading = await screen.findByRole('heading', { level: 1, name: title }) expect(screen.getByRole('main')).toContainElement(heading) @@ -77,7 +84,7 @@ describe('App routes', () => { it('/settings/ai renders the AI assistant screen inside the shell', async () => { mockViewport(390) stubFetchByPath({ - '/api/v1/me': () => jsonResponse(200, ME), + ...SIGNED_IN, '/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), '/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }), '/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS), @@ -90,10 +97,7 @@ describe('App routes', () => { it('/settings/household renders the household settings screen inside the shell', async () => { mockViewport(390) - stubFetchByPath({ - '/api/v1/me': () => jsonResponse(200, ME), - '/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), - }) + stubFetchByPath(SIGNED_IN) renderAt('/settings/household') const heading = await screen.findByRole('heading', { level: 1, name: 'Household' }) expect(screen.getByRole('main')).toContainElement(heading) @@ -101,10 +105,53 @@ describe('App routes', () => { }) }) +describe('App first-run redirect', () => { + const FIRST_RUN = { + '/api/v1/me': () => jsonResponse(200, ME), + '/api/v1/settings/household': () => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }), + } + + it.each(routes.filter(({ path }) => path !== '/onboarding').map(({ path }) => path.replace(':id', 'abc123')))( + '%s redirects to /onboarding while onboarding is not finished', + async (path) => { + mockViewport(390) + stubFetchByPath(FIRST_RUN) + renderAt(path) + expect(await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })).toBeInTheDocument() + expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() + }, + ) + + it('stays on an onboarding step', async () => { + mockViewport(390) + stubFetchByPath({ ...FIRST_RUN, '/api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: [] }) }) + renderAt('/onboarding/regulars') + expect(await screen.findByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument() + }) + + it('opens the routes once onboarding is finished', async () => { + mockViewport(390) + stubFetchByPath(SIGNED_IN) + renderAt('/plan') + expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + }) + + it('reports a household settings failure with a retry instead of rendering the routes', async () => { + mockViewport(390) + stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS)) + renderAt('/plan') + + expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message') + expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() + screen.getByRole('button', { name: 'Retry' }).click() + expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + }) +}) + describe('App bootstrap', () => { it('shows the skeleton until /me loads, then the routes', async () => { mockViewport(390) - const fetchMock = stubFetch(jsonResponse(200, ME)) + const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS)) renderAt('/plan') expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true') @@ -138,7 +185,7 @@ 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)) + stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS)) renderAt('/') expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message') @@ -158,7 +205,7 @@ 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() { - stubFetch(jsonResponse(200, ME)) + stubFetchByPath(SIGNED_IN) const { unmount } = renderAt('/') expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() unmount() @@ -226,12 +273,17 @@ describe('App offline bootstrap', () => { expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/) - const fetchMock = stubFetch(jsonResponse(200, ME)) + const fetchMock = stubFetchByPath(SIGNED_IN) onLine = true act(() => { window.dispatchEvent(new Event('online')) }) - await vi.waitFor(() => expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' })) + await vi.waitFor(() => + expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([ + '/api/v1/me', + '/api/v1/settings/household', + ]), + ) await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull()) expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() }) diff --git a/web/src/App.tsx b/web/src/App.tsx index 8c8e2ef..b038eb2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,6 +3,8 @@ import { QueryClientProvider } from '@tanstack/react-query' import { Route, Routes } from 'react-router' import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts' import { AuthBootstrap } from './AuthBootstrap.tsx' +import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx' +import { OnboardingPage } from './onboarding/OnboardingPage.tsx' import { PlaceholderPage } from './pages/PlaceholderPage.tsx' import { OfflinePill } from './pwa/OfflinePill.tsx' import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx' @@ -38,13 +40,20 @@ const screens: Partial> = { } // AppRoutes is the shell and its pages, shown once /api/v1/me has loaded. +// Onboarding is a full screen of its own, outside the shell; until it is +// finished, OnboardingGate sends every other route to it. export function AppRoutes() { return ( - }> - {routes.map(({ path, title }) => ( - } /> - ))} + }> + } /> + }> + {routes + .filter(({ path }) => path !== ONBOARDING_PATH) + .map(({ path, title }) => ( + } /> + ))} + ) diff --git a/web/src/AuthBootstrap.tsx b/web/src/AuthBootstrap.tsx index d9c1aee..774ca5c 100644 --- a/web/src/AuthBootstrap.tsx +++ b/web/src/AuthBootstrap.tsx @@ -41,6 +41,11 @@ export function AuthBootstrap({ children }: { children: ReactNode }) { ) } + return +} + +// LoadingScreen is the full-screen skeleton shown while MealQuest loads. +export function LoadingScreen() { return (
diff --git a/web/src/dinners/api.ts b/web/src/dinners/api.ts new file mode 100644 index 0000000..28ed091 --- /dev/null +++ b/web/src/dinners/api.ts @@ -0,0 +1,119 @@ +import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' +import { useApi } from '../api/index.ts' +import { isRecord } from '../api/errors.ts' + +export const DINNERS_PATH = '/api/v1/dinners' +export const REGULARS_PATH = `${DINNERS_PATH}?status=regular` + +// Regular is the part of a dinner the Regulars list reads. pending is true +// while an optimistic add waits for the server; its id is not the server's. +export type Regular = { id: string; name: string; pending?: true } + +// CachedRegular is a Regular as the cache holds it. removing is true while +// an optimistic removal waits for the server: the entry keeps its place, so +// a refused removal returns exactly there whatever else changed meanwhile. +type CachedRegular = Regular & { removing?: true } + +function shown(list: CachedRegular[]): Regular[] { + return list.filter((regular) => !regular.removing) +} + +// REGULARS_KEY prefixes every Regulars query, so a change refreshes them all +// (the settings home's count included). +export const REGULARS_KEY = ['dinners', 'regular'] as const +const REGULARS_LIST_KEY = [...REGULARS_KEY, 'list'] as const +const REGULARS_MUTATION_KEY = ['dinners', 'regular', 'change'] as const + +function parseDinner(where: string, body: unknown): Regular { + if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') { + throw new Error(`${where}: response is not a dinner`) + } + return { id: body.id, name: body.name } +} + +// useRegulars is the household's Regulars, in the server's order. +export function useRegulars() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: REGULARS_LIST_KEY, + queryFn: async () => { + const where = `GET ${REGULARS_PATH}` + const body = await apiFetch(REGULARS_PATH) + if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`) + return body.dinners.map((dinner): CachedRegular => parseDinner(where, dinner)) + }, + select: shown, + }) +} + +// changeOptimistically stops Regulars reads in flight, so they cannot +// overwrite the optimistic list, then applies change to it. +async function changeOptimistically(queryClient: QueryClient, change: (list: CachedRegular[]) => CachedRegular[]) { + await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY }) + setRegulars(queryClient, change) +} + +function setRegulars(queryClient: QueryClient, change: (list: CachedRegular[]) => CachedRegular[]) { + queryClient.setQueryData(REGULARS_LIST_KEY, (list) => change(list ?? [])) +} + +// settle refetches the Regulars once the last change in flight is done, so +// an earlier change's refetch never drops a later change's optimistic entry. +function settle(queryClient: QueryClient) { + if (queryClient.isMutating({ mutationKey: REGULARS_MUTATION_KEY }) === 1) { + void queryClient.invalidateQueries({ queryKey: REGULARS_KEY }) + } +} + +let pendingIds = 0 + +// useAddRegular adds a Regular by name (POST /api/v1/dinners). It shows in +// the list at once and is taken out again if the server refuses it. +export function useAddRegular() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationKey: REGULARS_MUTATION_KEY, + mutationFn: async (name: string) => + parseDinner(`POST ${DINNERS_PATH}`, await apiFetch(DINNERS_PATH, { method: 'POST', body: { name } })), + onMutate: async (name) => { + const id = `pending-${++pendingIds}` + await changeOptimistically(queryClient, (list) => [...list, { id, name, pending: true }]) + return { id } + }, + onSuccess: (dinner, _name, context) => { + setRegulars(queryClient, (list) => list.map((regular) => (regular.id === context.id ? dinner : regular))) + }, + onError: (_error, _name, context) => { + if (context === undefined) return + setRegulars(queryClient, (list) => list.filter((regular) => regular.id !== context.id)) + }, + onSettled: () => settle(queryClient), + }) +} + +// useRemoveRegular archives a Regular (DELETE /api/v1/dinners/{id}). It +// leaves the list at once and is put back in its place if the server +// refuses. +export function useRemoveRegular() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationKey: REGULARS_MUTATION_KEY, + mutationFn: async (regular: Regular) => { + await apiFetch(`${DINNERS_PATH}/${encodeURIComponent(regular.id)}`, { method: 'DELETE' }) + }, + onMutate: async (regular) => { + await changeOptimistically(queryClient, (list) => + list.map((r): CachedRegular => (r.id === regular.id ? { ...r, removing: true } : r)), + ) + }, + onSuccess: (_data, regular) => { + setRegulars(queryClient, (list) => list.filter((r) => r.id !== regular.id)) + }, + onError: (_error, regular) => { + setRegulars(queryClient, (list) => list.map((r) => (r.id === regular.id ? { id: r.id, name: r.name } : r))) + }, + onSettled: () => settle(queryClient), + }) +} diff --git a/web/src/onboarding/Onboarding.module.css b/web/src/onboarding/Onboarding.module.css new file mode 100644 index 0000000..3ab1a7e --- /dev/null +++ b/web/src/onboarding/Onboarding.module.css @@ -0,0 +1,228 @@ +/* Onboarding, handoff mock 1h: a full screen outside the app shell with + the step's action fixed to the bottom. */ +.screen { + min-height: 100dvh; + box-sizing: border-box; + /* Room for the fixed 52 px action and its padding. */ + padding: var(--space-6) var(--space-4) calc(52px + 2 * var(--space-6)); + background: var(--color-bg); +} + +.column { + max-width: 560px; + margin: 0 auto; + display: flex; + flex-direction: column; +} + +.progress { + display: flex; + gap: 6px; +} + +.pill { + flex: 1; + height: 6px; + border-radius: var(--radius-pill); + background: var(--color-neutral-300); +} + +.filled { + background: var(--color-accent); +} + +.kicker { + margin: 30px 0 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + color: var(--color-accent-700); +} + +.title { + margin: 8px 0 0; + font-family: var(--font-heading); + font-size: 30px; + font-weight: 400; + line-height: 1.1; +} + +.helper { + margin: 12px 0 0; + font-family: var(--font-body); + font-size: 15px; + line-height: 1.45; + color: var(--color-neutral-800); +} + +.footer { + position: fixed; + left: 0; + right: 0; + bottom: 0; + padding: var(--space-4) var(--space-4) var(--space-6); + background: var(--color-bg); +} + +.action { + display: flex; + width: 100%; + max-width: 560px; + height: 52px; + margin: 0 auto; + font-size: 17px; +} + +/* Step 1: what MealQuest's words mean. */ +.terms { + display: flex; + flex-direction: column; + gap: var(--space-4); + margin: var(--space-6) 0 0; + padding: 0; + list-style: none; +} + +.term { + display: flex; + gap: var(--space-3); + align-items: flex-start; + padding: var(--space-4); + border-radius: var(--radius-lg); + background: var(--color-surface); +} + +.termIcon { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: var(--radius-pill); + background: var(--color-accent-200); + color: var(--color-accent); +} + +.termText { + margin: 0; + font-family: var(--font-body); + font-size: 15px; + line-height: 1.4; +} + +.termName { + display: block; + font-weight: 700; +} + +/* Step 2: the add pill, with the "+" inside it. */ +.add { + position: relative; + margin-top: var(--space-6); +} + +/* Beats the TextInput's own padding whatever order the sheets load in. */ +.add .addInput { + width: 100%; + padding-right: 52px; +} + +/* 44 px hit box around the 36 px grape face, inset 2 px in the 48 px pill. */ +.addButton { + position: absolute; + top: 2px; + right: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + padding: 0; + border: 0; + border-radius: var(--radius-pill); + background: transparent; + cursor: pointer; +} + +.addButton:disabled { + cursor: not-allowed; +} + +.addFace { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: var(--radius-pill); + background: var(--color-accent); + color: var(--on-accent); + transition: transform 100ms var(--ease-out-soft); +} + +.addButton:disabled .addFace { + opacity: 0.45; +} + +.addButton:active:not(:disabled) .addFace { + transform: scale(0.97); +} + +.groupLabel { + margin: var(--space-6) 0 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 10px 0 0; + padding: 0; + list-style: none; +} + +.pending { + opacity: 0.6; +} + +.pick { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 44px; + min-height: 44px; + padding: 0 16px; + border: 1.5px dashed var(--color-neutral-400); + border-radius: var(--radius-pill); + background: transparent; + color: var(--color-text); + font-family: var(--font-body); + font-size: 15px; + cursor: pointer; +} + +.pick:active { + background: var(--color-neutral-200); +} + +.problem { + margin: var(--space-3) 0 0; + font-family: var(--font-body); + font-size: 14px; + color: var(--color-accent-700); +} + +.status { + margin-top: 10px; +} + +@media (prefers-reduced-motion: reduce) { + .addFace { + transition: none; + } +} diff --git a/web/src/onboarding/OnboardingGate.tsx b/web/src/onboarding/OnboardingGate.tsx new file mode 100644 index 0000000..473b33d --- /dev/null +++ b/web/src/onboarding/OnboardingGate.tsx @@ -0,0 +1,52 @@ +import { useEffect } from 'react' +import { Navigate, Outlet, useLocation } from 'react-router' +import { useApi } from '../api/index.ts' +import { LoadingScreen } from '../AuthBootstrap.tsx' +import { useHouseholdSettings } from '../settings/api.ts' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import styles from '../pages/FullScreen.module.css' + +export const ONBOARDING_PATH = '/onboarding' + +function inOnboarding(pathname: string): boolean { + return pathname === ONBOARDING_PATH || pathname.startsWith(`${ONBOARDING_PATH}/`) +} + +// OnboardingGate reads the household settings before any page: while +// onboarding_completed_at is null, every route outside /onboarding +// redirects there. +// +// The service worker never caches the settings, so offline on the saved +// pages (see AuthBootstrap) they cannot be read: the saved pages open as they +// are, since onboarding needs the server anyway, and the settings are read +// again when the browser is back online. +export function OnboardingGate() { + const api = useApi() + const { pathname } = useLocation() + const settings = useHouseholdSettings() + const offline = settings.isError && settings.error instanceof TypeError && api.session.verified() + const { refetch } = settings + + useEffect(() => { + if (!offline) return + return api.network.subscribe(() => { + if (!api.network.offline()) void refetch() + }) + }, [offline, api, refetch]) + + if (settings.data !== undefined) { + if (settings.data.onboarding_completed_at === null && !inOnboarding(pathname)) { + return + } + return + } + if (offline) return + if (settings.isError) { + return ( +
+ void refetch()}>MealQuest could not load: {settings.error.message} +
+ ) + } + return +} diff --git a/web/src/onboarding/OnboardingPage.test.tsx b/web/src/onboarding/OnboardingPage.test.tsx new file mode 100644 index 0000000..41615b2 --- /dev/null +++ b/web/src/onboarding/OnboardingPage.test.tsx @@ -0,0 +1,410 @@ +import { fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' +import { createApiClient, createVerifiedSession } from '../api/index.ts' +import App from '../App.tsx' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { 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' +import { expectMinHitArea, hitArea } from '../test/hitArea.ts' +import styles from './Onboarding.module.css' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +type Dinner = { id: string; name: string } + +// A request the fake server holds until the test answers it. +type Held = { method: string; path: string; body: unknown; answer: (response: Response) => void } + +// stubDinnersServer stands in for the server a household meets on first +// run: signed in, onboarding not finished and the given Regulars. GET answers at once; +// each POST and DELETE is held in `held` until the test answers it, so the +// test sees the optimistic list first. Once failReads is set, every GET of +// the Regulars fails, so the test sees the list the client keeps by itself. +function stubDinnersServer(regulars: Dinner[] = []) { + const held: Held[] = [] + const reads = { fail: false } + const fetchMock: Mock = vi.fn((input, init) => { + 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/settings/household') { + return Promise.resolve(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null })) + } + if (method === 'GET' && path === '/api/v1/dinners?status=regular') { + if (reads.fail) return Promise.resolve(errorResponse(500, 'internal')) + return Promise.resolve(jsonResponse(200, { dinners: regulars })) + } + if ((method === 'POST' && path === '/api/v1/dinners') || (method === 'DELETE' && path.startsWith('/api/v1/dinners/'))) { + return new Promise((resolve) => { + held.push({ method, path, body: init?.body === undefined ? undefined : JSON.parse(String(init.body)), answer: resolve }) + }) + } + throw new Error(`unexpected fetch: ${method} ${path}`) + }) + vi.stubGlobal('fetch', fetchMock) + return { fetchMock, held, regulars, reads } +} + +// created answers a held POST /api/v1/dinners as the server does, adding the +// Regular to the list later GETs read. +function created(server: ReturnType, request: Held, id: string) { + const { name } = request.body as { name: string } + server.regulars.push({ id, name }) + request.answer(jsonResponse(201, { id, name, status: 'regular', grounding_status: 'pending', grounding_job_id: 'job-1' })) +} + +// renderAt renders the whole app, so /me supplies the CSRF token every +// write needs and OnboardingGate reads the household settings. +function renderAt(path: string) { + const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage)) + render( + + + , + ) +} + +async function regularsStep(regulars: Dinner[] = []) { + const server = stubDinnersServer(regulars) + renderAt('/onboarding/regulars') + // Common picks show once the Regulars have loaded. + await screen.findByRole('heading', { level: 2, name: 'Common picks' }) + expect(screen.getByRole('heading', { level: 2, name: `Your regulars · ${regulars.length}` })).toBeInTheDocument() + return server +} + +function input() { + return screen.getByRole('textbox', { name: 'Dinner name' }) +} + +function regularsList() { + return screen.getByRole('heading', { level: 2, name: /^Your regulars/ }).nextElementSibling as HTMLElement +} + +function regularNames() { + return within(regularsList()) + .queryAllByRole('listitem') + .map((item) => item.textContent) +} + +function next() { + return screen.getByRole('button', { name: 'Next: notifications' }) +} + +describe('onboarding step 1', () => { + it('welcomes the household, explains the words and starts with step 2', async () => { + stubDinnersServer() + renderAt('/onboarding') + + expect(await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })).toBeInTheDocument() + expect(screen.getByText('Step 1 of 3')).toBeInTheDocument() + const terms = within(screen.getByRole('list')).getAllByRole('listitem') + expect(terms.map((term) => term.textContent)).toEqual([ + expect.stringMatching(/^Regulars/), + expect.stringMatching(/^Side quests/), + expect.stringMatching(/^Quest log/), + ]) + expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: "Let's start" })) + expect(await screen.findByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument() + }) + + it('fills one progress pill of three', async () => { + stubDinnersServer() + renderAt('/onboarding') + await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' }) + const pills = Array.from(document.getElementsByClassName(styles.pill!)) + expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, false, false]) + }) +}) + +describe('onboarding step 2 (mock 1h)', () => { + it('shows the step as the mock does', async () => { + await regularsStep([{ id: 'd1', name: 'Beef tacos' }]) + + expect(screen.getByText('Step 2 of 3')).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument() + expect( + screen.getByText('Add the dinners your household makes on repeat. MealQuest builds suggestions from these.'), + ).toBeInTheDocument() + expect(input()).toHaveAttribute('placeholder', 'e.g. Chicken enchiladas') + expect(regularNames()).toEqual(['Beef tacos']) + expect(screen.getByRole('button', { name: 'Remove Beef tacos' })).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 2, name: 'Common picks' })).toBeInTheDocument() + // A common pick the household already has is not offered again. + expect(screen.queryByRole('button', { name: 'Add Beef tacos' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add Chili' })).toHaveTextContent('+Chili') + + const pills = Array.from(document.getElementsByClassName(styles.pill!)) + expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, true, false]) + expect(declaredStyle(pills[0]!).height).toBe('6px') + }) + + it('sizes the input pill, "+" button, picks and Next as the mock does', async () => { + await regularsStep() + expect(hitArea(input()).height).toBe(48) + const add = screen.getByRole('button', { name: 'Add Regular' }) + expectMinHitArea(add) + expect(declaredStyle(add.firstElementChild!).width).toBe('36px') + expect(declaredStyle(add.firstElementChild!).height).toBe('36px') + expectMinHitArea(screen.getByRole('button', { name: 'Add Chili' })) + expect(declaredStyle(next()).height).toBe('52px') + expect(declaredStyle(next().parentElement!).position).toBe('fixed') + }) + + it('adds a Regular on Enter, showing it before the server answers', async () => { + const server = await regularsStep() + fireEvent.change(input(), { target: { value: ' Chicken enchiladas ' } }) + // Enter in the input submits its form. + fireEvent.submit(input()) + + expect(input()).toHaveValue('') + await vi.waitFor(() => expect(regularNames()).toEqual(['Chicken enchiladas'])) + expect(screen.getByRole('heading', { level: 2, name: 'Your regulars · 1' })).toBeInTheDocument() + // Not removable until the server has given it an id. + expect(screen.queryByRole('button', { name: 'Remove Chicken enchiladas' })).not.toBeInTheDocument() + + await vi.waitFor(() => expect(server.held).toHaveLength(1)) + expect(server.held[0]).toMatchObject({ method: 'POST', path: '/api/v1/dinners', body: { name: 'Chicken enchiladas' } }) + created(server, server.held[0]!, 'd1') + expect(await screen.findByRole('button', { name: 'Remove Chicken enchiladas' })).toBeInTheDocument() + }) + + it('adds a Regular with the "+" button', async () => { + const server = await regularsStep() + const add = screen.getByRole('button', { name: 'Add Regular' }) + expect(add).toBeDisabled() + fireEvent.change(input(), { target: { value: 'Shrimp scampi' } }) + fireEvent.click(add) + + await vi.waitFor(() => expect(regularNames()).toEqual(['Shrimp scampi'])) + await vi.waitFor(() => expect(server.held).toHaveLength(1)) + expect(server.held[0]).toMatchObject({ method: 'POST', body: { name: 'Shrimp scampi' } }) + }) + + it('adds a common pick on tap and stops offering it', async () => { + const server = await regularsStep() + fireEvent.click(screen.getByRole('button', { name: 'Add Chili' })) + + await vi.waitFor(() => expect(regularNames()).toEqual(['Chili'])) + expect(screen.queryByRole('button', { name: 'Add Chili' })).not.toBeInTheDocument() + await vi.waitFor(() => expect(server.held).toHaveLength(1)) + expect(server.held[0]).toMatchObject({ method: 'POST', path: '/api/v1/dinners', body: { name: 'Chili' } }) + }) + + it('takes an add back out and says why when the server refuses it', async () => { + const server = await regularsStep([{ id: 'd1', name: 'Chili' }]) + fireEvent.change(input(), { target: { value: 'chili' } }) + fireEvent.submit(input()) + await vi.waitFor(() => expect(regularNames()).toEqual(['Chili', 'chili'])) + + await vi.waitFor(() => expect(server.held).toHaveLength(1)) + server.held[0]!.answer(errorResponse(409, 'duplicate_dinner')) + expect(await screen.findByRole('alert')).toHaveTextContent('Could not add chili: duplicate_dinner message') + expect(regularNames()).toEqual(['Chili']) + }) + + it('says why an add was refused while another add is still unanswered', async () => { + const server = await regularsStep() + fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' })) + await vi.waitFor(() => expect(server.held).toHaveLength(1)) + fireEvent.click(screen.getByRole('button', { name: 'Add Chili' })) + await vi.waitFor(() => expect(server.held).toHaveLength(2)) + expect(regularNames()).toEqual(['Burgers', 'Chili']) + + server.held[0]!.answer(errorResponse(409, 'duplicate_dinner')) + expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message') + expect(regularNames()).toEqual(['Chili']) + + // The other add's answer does not hide the refusal. + created(server, server.held[1]!, 'd1') + await screen.findByRole('button', { name: 'Remove Chili' }) + expect(screen.getByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message') + expect(regularNames()).toEqual(['Chili']) + }) + + it('says why each of two overlapping adds was refused', async () => { + const server = await regularsStep() + fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' })) + await vi.waitFor(() => expect(server.held).toHaveLength(1)) + fireEvent.click(screen.getByRole('button', { name: 'Add Chili' })) + await vi.waitFor(() => expect(server.held).toHaveLength(2)) + + server.held[0]!.answer(errorResponse(409, 'duplicate_dinner')) + server.held[1]!.answer(errorResponse(500, 'internal')) + await vi.waitFor(() => + expect(screen.getAllByRole('alert').map((alert) => alert.textContent)).toEqual([ + 'Could not add Burgers: duplicate_dinner message', + 'Could not add Chili: internal message', + ]), + ) + expect(regularNames()).toEqual([]) + }) + + it('says why each of two overlapping adds of the same name was refused', async () => { + const server = await regularsStep() + fireEvent.change(input(), { target: { value: 'Lasagne' } }) + fireEvent.submit(input()) + await vi.waitFor(() => expect(server.held).toHaveLength(1)) + fireEvent.change(input(), { target: { value: 'Lasagne' } }) + fireEvent.submit(input()) + await vi.waitFor(() => expect(server.held).toHaveLength(2)) + expect(regularNames()).toEqual(['Lasagne', 'Lasagne']) + + server.held[0]!.answer(errorResponse(409, 'duplicate_dinner')) + expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Lasagne: duplicate_dinner message') + expect(regularNames()).toEqual(['Lasagne']) + + // The second add's refusal shows beside the first instead of replacing it. + server.held[1]!.answer(errorResponse(500, 'internal')) + await vi.waitFor(() => + expect(screen.getAllByRole('alert').map((alert) => alert.textContent)).toEqual([ + 'Could not add Lasagne: duplicate_dinner message', + 'Could not add Lasagne: internal message', + ]), + ) + expect(regularNames()).toEqual([]) + }) + + it('removes a Regular at once and archives it on the server', async () => { + const server = await regularsStep([ + { id: 'd1', name: 'Beef tacos' }, + { id: 'd2', name: 'Pizza night' }, + ]) + fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' })) + + await vi.waitFor(() => expect(regularNames()).toEqual(['Pizza night'])) + expect(screen.getByRole('heading', { level: 2, name: 'Your regulars · 1' })).toBeInTheDocument() + // Removed, it is a common pick again. + expect(screen.getByRole('button', { name: 'Add Beef tacos' })).toBeInTheDocument() + await vi.waitFor(() => expect(server.held).toHaveLength(1)) + expect(server.held[0]).toMatchObject({ method: 'DELETE', path: '/api/v1/dinners/d1' }) + }) + + it('puts a removed Regular back in its place when the server refuses', async () => { + const server = await regularsStep([ + { id: 'd1', name: 'Beef tacos' }, + { id: 'd2', name: 'Pizza night' }, + ]) + fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' })) + await vi.waitFor(() => expect(server.held).toHaveLength(1)) + server.held[0]!.answer(errorResponse(500, 'internal')) + + expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message') + expect(regularNames()).toEqual(['Beef tacos', 'Pizza night']) + }) + + it('puts a refused removal back and says why while another removal is still unanswered', async () => { + const server = await regularsStep([ + { id: 'd1', name: 'Beef tacos' }, + { id: 'd2', name: 'Pizza night' }, + { id: 'd3', name: 'Chili' }, + ]) + fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' })) + await vi.waitFor(() => expect(server.held).toHaveLength(1)) + fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' })) + await vi.waitFor(() => expect(server.held).toHaveLength(2)) + expect(regularNames()).toEqual(['Chili']) + + server.held[0]!.answer(errorResponse(500, 'internal')) + expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message') + expect(regularNames()).toEqual(['Beef tacos', 'Chili']) + + // The other removal's answer does not hide the refusal. + server.regulars.splice(1, 1) + server.held[1]!.answer(new Response(null, { status: 204 })) + await vi.waitFor(() => expect(server.fetchMock.mock.calls.filter(([path]) => String(path).includes('status=regular'))).toHaveLength(2)) + await vi.waitFor(() => expect(regularNames()).toEqual(['Beef tacos', 'Chili'])) + expect(screen.getByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message') + }) + + it('puts two refused removals back in their places without rereading the list', async () => { + const server = await regularsStep([ + { id: 'd1', name: 'Beef tacos' }, + { id: 'd2', name: 'Pizza night' }, + { id: 'd3', name: 'Chili' }, + ]) + server.reads.fail = true + fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' })) + await vi.waitFor(() => expect(server.held).toHaveLength(1)) + fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' })) + await vi.waitFor(() => expect(server.held).toHaveLength(2)) + expect(regularNames()).toEqual(['Chili']) + + server.held[0]!.answer(errorResponse(500, 'internal')) + await screen.findByRole('alert') + server.held[1]!.answer(errorResponse(500, 'internal')) + await screen.findByText('Could not remove Pizza night: internal message') + // The reread after the last answer fails, so the order is the client's own. + expect(await screen.findByText(/Your Regulars could not load/)).toBeInTheDocument() + expect(screen.getAllByRole('button', { name: /^Remove / }).map((button) => button.getAttribute('aria-label'))).toEqual([ + 'Remove Beef tacos', + 'Remove Pizza night', + 'Remove Chili', + ]) + }) + + it('enables "Next: notifications" once the server has saved 3 Regulars', async () => { + const server = await regularsStep([ + { id: 'd1', name: 'Beef tacos' }, + { id: 'd2', name: 'Pizza night' }, + ]) + expect(next()).toBeDisabled() + + fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' })) + await screen.findByRole('heading', { level: 2, name: 'Your regulars · 3' }) + // The third chip shows at once, but Next waits for the server. + expect(next()).toBeDisabled() + await vi.waitFor(() => expect(server.held).toHaveLength(1)) + created(server, server.held[0]!, 'd3') + await screen.findByRole('button', { name: 'Remove Burgers' }) + expect(next()).toBeEnabled() + + fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' })) + await vi.waitFor(() => expect(next()).toBeDisabled()) + await vi.waitFor(() => expect(server.held).toHaveLength(2)) + server.held[1]!.answer(errorResponse(500, 'internal')) + await vi.waitFor(() => expect(next()).toBeEnabled()) + + fireEvent.click(next()) + expect(await screen.findByText('Step 3 of 3')).toBeInTheDocument() + }) + + it('keeps "Next: notifications" disabled when the third add is refused', async () => { + const server = await regularsStep([ + { id: 'd1', name: 'Beef tacos' }, + { id: 'd2', name: 'Pizza night' }, + ]) + fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' })) + await screen.findByRole('heading', { level: 2, name: 'Your regulars · 3' }) + expect(next()).toBeDisabled() + + await vi.waitFor(() => expect(server.held).toHaveLength(1)) + server.held[0]!.answer(errorResponse(409, 'duplicate_dinner')) + expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message') + expect(regularNames()).toEqual(['Beef tacos', 'Pizza night']) + expect(next()).toBeDisabled() + }) + + it('keeps "Next: notifications" disabled while a fourth add is unanswered', async () => { + const server = await regularsStep([ + { id: 'd1', name: 'Beef tacos' }, + { id: 'd2', name: 'Pizza night' }, + { id: 'd3', name: 'Chili' }, + ]) + expect(next()).toBeEnabled() + + fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' })) + await screen.findByRole('heading', { level: 2, name: 'Your regulars · 4' }) + expect(next()).toBeDisabled() + await vi.waitFor(() => expect(server.held).toHaveLength(1)) + server.held[0]!.answer(errorResponse(409, 'duplicate_dinner')) + await screen.findByRole('alert') + expect(next()).toBeEnabled() + }) +}) diff --git a/web/src/onboarding/OnboardingPage.tsx b/web/src/onboarding/OnboardingPage.tsx new file mode 100644 index 0000000..d8f9853 --- /dev/null +++ b/web/src/onboarding/OnboardingPage.tsx @@ -0,0 +1,77 @@ +import { Repeat, ScrollText, Sparkles, type LucideIcon } from 'lucide-react' +import { Route, Routes, useNavigate } from 'react-router' +import { Button } from '../ui/Button.tsx' +import { Icon } from '../ui/Icon.tsx' +import { OnboardingStep } from './OnboardingStep.tsx' +import { RegularsStep } from './RegularsStep.tsx' +import styles from './Onboarding.module.css' + +// OnboardingPage is /onboarding and its steps: the welcome, the Regulars +// (/onboarding/regulars) and notifications (/onboarding/notifications). +export function OnboardingPage() { + return ( + + } /> + } /> + } /> + + ) +} + +const terms: { icon: LucideIcon; name: string; meaning: string }[] = [ + { + icon: Repeat, + name: 'Regulars', + meaning: 'The dinners our kitchen makes on repeat. You add a few next.', + }, + { + icon: Sparkles, + name: 'Side quests', + meaning: 'New dinners MealQuest suggests from your Regulars. Try it or Pass.', + }, + { + icon: ScrollText, + name: 'Quest log', + meaning: 'The Side quests you said Try it to, waiting for a night on the plan.', + }, +] + +function WelcomeStep() { + const navigate = useNavigate() + return ( + void navigate('/onboarding/regulars')}> + Let's start + + } + > +

MealQuest plans the household's dinners around what you already love to cook.

+
    + {terms.map(({ icon, name, meaning }) => ( +
  • + + + +

    + {name} + {meaning} +

    +
  • + ))} +
+
+ ) +} + +// TODO(Feature 12): onboarding step 3 — notifications and finishing +// onboarding — is a later task; until then the step only says so. +function NotificationsStep() { + return ( + +

Setting up notifications arrives in a later update.

+
+ ) +} diff --git a/web/src/onboarding/OnboardingStep.tsx b/web/src/onboarding/OnboardingStep.tsx new file mode 100644 index 0000000..3e2cd6e --- /dev/null +++ b/web/src/onboarding/OnboardingStep.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react' +import styles from './Onboarding.module.css' + +export const ONBOARDING_STEPS = 3 + +export type OnboardingStepProps = { + // 1-based; the progress pills fill up to it. + step: number + title: string + children: ReactNode + // The step's primary action, fixed to the bottom of the screen. + action?: ReactNode +} + +// OnboardingStep is the frame every onboarding step shares: progress pills, +// the "Step N of 3" kicker and the Bagel title. +export function OnboardingStep({ step, title, children, action }: OnboardingStepProps) { + return ( +
+
+ +

+ Step {step} of {ONBOARDING_STEPS} +

+

{title}

+ {children} +
+ {action &&
{action}
} +
+ ) +} diff --git a/web/src/onboarding/RegularsStep.tsx b/web/src/onboarding/RegularsStep.tsx new file mode 100644 index 0000000..c867732 --- /dev/null +++ b/web/src/onboarding/RegularsStep.tsx @@ -0,0 +1,166 @@ +import { useRef, useState, type FormEvent } from 'react' +import { Plus } from 'lucide-react' +import { useNavigate } from 'react-router' +import { useAddRegular, useRegulars, useRemoveRegular, type Regular } from '../dinners/api.ts' +import { Button } from '../ui/Button.tsx' +import { Chip } from '../ui/Chip.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { TextInput } from '../ui/TextInput.tsx' +import { OnboardingStep } from './OnboardingStep.tsx' +import styles from './Onboarding.module.css' + +// MIN_REGULARS is how many Regulars the server has saved before the +// household moves on. +export const MIN_REGULARS = 3 + +// commonPicks are offered as one-tap Regulars until the household has them. +export const commonPicks = [ + 'Beef tacos', + 'Spaghetti bolognese', + 'Chicken stir-fry', + 'Pizza night', + 'Chili', + 'Pot roast', + 'Burgers', + 'Fried rice', +] + +function sameName(a: string, b: string): boolean { + return a.trim().toLowerCase() === b.trim().toLowerCase() +} + +// Refusal is one add or removal the server turned down. key is unique to +// that attempt, so two attempts of the same change in flight each keep their +// own refusal. change names what was tried, so trying it again once it has +// been refused clears the earlier refusal and no other. +type Refusal = { key: number; change: string; text: string } + +// RegularsStep is onboarding step 2, handoff mock 1h: the household adds +// the dinners it makes on repeat. Each add and remove shows at once and is +// undone if the server refuses it. +export function RegularsStep() { + const navigate = useNavigate() + const regulars = useRegulars() + const add = useAddRegular() + const remove = useRemoveRegular() + const [draft, setDraft] = useState('') + // Each change keeps its own refusal: the mutation hooks only report their + // latest change, which would hide a refusal while another change is in + // flight. + const [refusals, setRefusals] = useState([]) + const attempts = useRef(0) + const list = regulars.data ?? [] + const picks = commonPicks.filter((pick) => !list.some((regular) => sameName(regular.name, pick))) + // Next waits for every add to be answered, so a refused add's error shows + // here and an add the server takes back never counts towards the three. + const adding = list.some((regular) => regular.pending) + const saved = list.filter((regular) => !regular.pending).length + + function track(change: string, what: string, answer: Promise) { + const key = ++attempts.current + setRefusals((all) => all.filter((refusal) => refusal.change !== change)) + answer.catch((error: Error) => { + setRefusals((all) => [...all, { key, change, text: `Could not ${what}: ${error.message}` }]) + }) + } + + function addRegular(name: string) { + track(`add:${name.toLowerCase()}`, `add ${name}`, add.mutateAsync(name)) + } + + function removeRegular(regular: Regular) { + track(`remove:${regular.id}`, `remove ${regular.name}`, remove.mutateAsync(regular)) + } + + function onSubmit(event: FormEvent) { + event.preventDefault() + const name = draft.trim() + if (name === '') return + addRegular(name) + setDraft('') + } + + return ( + void navigate('/onboarding/notifications')} + > + Next: notifications + + } + > +

+ Add the dinners your household makes on repeat. MealQuest builds suggestions from these. +

+
+ setDraft(event.target.value)} + /> + + + {refusals.map((refusal) => ( +

+ {refusal.text} +

+ ))} + +

Your regulars · {list.length}

+ {regulars.isPending && } + {regulars.isError && ( +
+ void regulars.refetch()}> + Your Regulars could not load: {regulars.error.message} + +
+ )} + {list.length > 0 && ( +
    + {list.map((regular: Regular) => ( +
  • + removeRegular(regular)} + /> +
  • + ))} +
+ )} + + {regulars.isSuccess && picks.length > 0 && ( + <> +

Common picks

+
    + {picks.map((pick) => ( +
  • + +
  • + ))} +
+ + )} +
+ ) +} diff --git a/web/src/settings/api.ts b/web/src/settings/api.ts index 310368f..540f3ea 100644 --- a/web/src/settings/api.ts +++ b/web/src/settings/api.ts @@ -37,13 +37,17 @@ export type HouseholdSettings = { suggestions_time: string chat_autosend_dow: number chat_autosend_time: string + // When the household finished onboarding; null until it has, and every + // app route sends it to /onboarding. Not editable in settings. + onboarding_completed_at: string | null } +// HouseholdSettingsField is a household setting the settings pages edit. +export type HouseholdSettingsField = Exclude + // HouseholdSettingsPatch is the PATCH /api/v1/settings/household body: only // the fields that change. -export type HouseholdSettingsPatch = Partial - -export type HouseholdSettingsField = keyof HouseholdSettings +export type HouseholdSettingsPatch = Partial> const householdTimeFields = [ 'dinner_time', @@ -355,10 +359,13 @@ function parseHouseholdSettings(where: string, body: unknown): HouseholdSettings } days[field] = value } - return { timezone: body.timezone, ...times, ...days } + if (!isStringOrNull(body.onboarding_completed_at)) { + throw new Error(`${where}: onboarding_completed_at ${JSON.stringify(body.onboarding_completed_at)} is not a time or null`) + } + return { timezone: body.timezone, ...times, ...days, onboarding_completed_at: body.onboarding_completed_at } } -const HOUSEHOLD_SETTINGS_KEY = ['settings', 'household'] as const +export const HOUSEHOLD_SETTINGS_KEY = ['settings', 'household'] as const export function useHouseholdSettings() { const { apiFetch } = useApi() diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx index 1f2076d..b8b3ff6 100644 --- a/web/src/shell/AppShell.test.tsx +++ b/web/src/shell/AppShell.test.tsx @@ -21,7 +21,7 @@ afterEach(() => { vi.unstubAllGlobals() }) -function renderAt(path: string, width: number) { +async function renderAt(path: string, width: number) { const resize = mockViewport(width) stubFetchByPath(SETTINGS_API) render( @@ -31,6 +31,8 @@ function renderAt(path: string, width: number) { , ) + // The routes wait for the household settings (OnboardingGate). + await screen.findByRole('navigation', { name: 'Primary' }) return resize } @@ -49,8 +51,8 @@ function current() { } describe('AppShell', () => { - it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', (width) => { - renderAt('/', width) + it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', async (width) => { + await renderAt('/', width) expect(navLinks().map((link) => link.textContent)).toEqual(ORDER) expect(navLinks().map((link) => link.getAttribute('href'))).toEqual([ '/', @@ -61,8 +63,8 @@ describe('AppShell', () => { ]) }) - it('shows only the mobile bottom bar below 768 px', () => { - renderAt('/', 767) + it('shows only the mobile bottom bar below 768 px', async () => { + await renderAt('/', 767) expect(screen.getAllByRole('navigation')).toHaveLength(1) expect(nav()).toHaveClass(bottomBarStyles.bar!) expect(screen.queryByText('M')).not.toBeInTheDocument() @@ -80,8 +82,8 @@ describe('AppShell', () => { expect(bar.borderRadius).toBe('var(--radius-pill)') }) - it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', () => { - renderAt('/discover', MOBILE) + it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', async () => { + await renderAt('/discover', MOBILE) const pills = nav().querySelectorAll(`.${bottomBarStyles.pill}`) expect(pills).toHaveLength(1) const pill = declaredStyle(pills[0]!) @@ -101,24 +103,24 @@ describe('AppShell', () => { expect(within(inactive).getByText('Plan')).toHaveClass(bottomBarStyles.hiddenLabel!) }) - it('makes the full 62 px bar height the mobile tap target', () => { - renderAt('/', MOBILE) + it('makes the full 62 px bar height the mobile tap target', async () => { + await renderAt('/', MOBILE) for (const link of navLinks()) { expect(declaredStyle(link).height).toBe('62px') expectMinHitArea(link) } }) - it('reserves at least 100 px below the mobile scroll area', () => { - renderAt('/', MOBILE) + it('reserves at least 100 px below the mobile scroll area', async () => { + await renderAt('/', MOBILE) const padding = declaredStyle(screen.getByRole('main')).paddingBottom const reserved = /^calc\((\d+)px \+ env\(safe-area-inset-bottom\)\)$/.exec(padding) expect(reserved, `padding-bottom ${JSON.stringify(padding)}`).not.toBeNull() expect(Number(reserved![1])).toBeGreaterThanOrEqual(100) }) - it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', (width) => { - renderAt('/plan', width) + it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', async (width) => { + await renderAt('/plan', width) expect(screen.getAllByRole('navigation')).toHaveLength(1) expect(nav()).toHaveClass(railStyles.rail!) expect(declaredStyle(nav()).width).toBe('96px') @@ -146,8 +148,8 @@ describe('AppShell', () => { expect(screen.getByRole('link', { name: 'Home' }).firstElementChild).not.toHaveClass(railStyles.active!) }) - it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', (width) => { - renderAt('/quick', width) + it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', async (width) => { + await renderAt('/quick', width) expect(screen.getAllByRole('navigation')).toHaveLength(1) const sidebar = nav().closest('aside')! expect(sidebar).toHaveClass(sidebarStyles.sidebar!) @@ -176,8 +178,8 @@ describe('AppShell', () => { for (const link of navLinks()) expect(declaredStyle(link).minHeight).toBe('44px') }) - it('switches layout when the viewport crosses a breakpoint', () => { - const resize = renderAt('/', MOBILE) + it('switches layout when the viewport crosses a breakpoint', async () => { + const resize = await renderAt('/', MOBILE) expect(nav()).toHaveClass(bottomBarStyles.bar!) resize(TABLET) expect(nav()).toHaveClass(railStyles.rail!) @@ -206,11 +208,10 @@ describe('AppShell', () => { ['/settings/household', ['Settings']], ['/settings/regulars', ['Settings']], ['/settings/calendar', ['Settings']], - ['/onboarding', []], ['/cook-from-pantry', []], ['/dinner/abc123', []], - ])('%s marks %j current', (path, expected) => { - renderAt(path, width) + ])('%s marks %j current', async (path, expected) => { + await renderAt(path, width) expect(current()).toEqual(expected) }) }) diff --git a/web/src/shell/BottomBar.test.tsx b/web/src/shell/BottomBar.test.tsx index 500261d..9d7f32c 100644 --- a/web/src/shell/BottomBar.test.tsx +++ b/web/src/shell/BottomBar.test.tsx @@ -70,7 +70,7 @@ afterEach(() => { vi.unstubAllGlobals() }) -function renderAt(path: string, reducedMotion = false) { +async function renderAt(path: string, reducedMotion = false) { mockViewport(MOBILE, { reducedMotion }) stubFetchByPath(SETTINGS_API) render( @@ -80,6 +80,8 @@ function renderAt(path: string, reducedMotion = false) { , ) + // The routes wait for the household settings (OnboardingGate). + await screen.findByRole('navigation', { name: 'Primary' }) } function nav() { @@ -109,21 +111,21 @@ function animationsOf(element: Element) { } describe('BottomBar', () => { - it('places the pill on the active item without animating on first render', () => { - renderAt('/plan') + it('places the pill on the active item without animating on first render', async () => { + await renderAt('/plan') expect(pill().style.left).toBe('54px') expect(pill().style.width).toBe('110px') expect(pill()).toBeVisible() expect(animate).not.toHaveBeenCalled() }) - it('hides the pill when no item is active', () => { - renderAt('/onboarding') + it('hides the pill when no item is active', async () => { + await renderAt('/cook-from-pantry') expect(pill()).not.toBeVisible() }) - it('slides the pill from the old item to the new one over 420 ms on the spring curve', () => { - renderAt('/') + it('slides the pill from the old item to the new one over 420 ms on the spring curve', async () => { + await renderAt('/') fireEvent.click(link('Quick')) expect(animationsOf(pill())).toEqual([ @@ -139,8 +141,8 @@ describe('BottomBar', () => { expect(pill().style.width).toBe('110px') }) - it('FLIPs every item that moved from its old left to its new one with the same timing', () => { - renderAt('/') + it('FLIPs every item that moved from its old left to its new one with the same timing', async () => { + await renderAt('/') fireEvent.click(link('Quick')) // Home shrinks from 110 to 48 px, so Plan and Discover shift 62 px left @@ -163,8 +165,8 @@ describe('BottomBar', () => { expect(animationsOf(link('Settings'))).toEqual([]) }) - it('fades the new label in over 220 ms ease-out after 120 ms', () => { - renderAt('/') + it('fades the new label in over 220 ms ease-out after 120 ms', async () => { + await renderAt('/') fireEvent.click(link('Discover')) const label = within(link('Discover')).getByText('Discover') @@ -180,14 +182,14 @@ describe('BottomBar', () => { ]) }) - it('does not animate when the active item stays the same', () => { - renderAt('/plan') + it('does not animate when the active item stays the same', async () => { + await renderAt('/plan') fireEvent.click(link('Plan')) expect(animate).not.toHaveBeenCalled() }) - it('jumps the pill to the new item with no animation under reduced motion', () => { - renderAt('/', true) + it('jumps the pill to the new item with no animation under reduced motion', async () => { + await renderAt('/', true) fireEvent.click(link('Settings')) expect(link('Settings')).toHaveAttribute('aria-current', 'page') @@ -196,8 +198,8 @@ describe('BottomBar', () => { expect(pill().style.width).toBe('110px') }) - it('re-places the pill when the viewport resizes', () => { - renderAt('/discover') + it('re-places the pill when the viewport resizes', async () => { + await renderAt('/discover') expect(pill().style.left).toBe('102px') vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(120) vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96) @@ -207,8 +209,8 @@ describe('BottomBar', () => { expect(animate).not.toHaveBeenCalled() }) - it('starts a second slide from where the first one is on screen, not from its destination', () => { - renderAt('/') + it('starts a second slide from where the first one is on screen, not from its destination', async () => { + await renderAt('/') fireEvent.click(link('Quick')) const [firstPill] = runningOn(pill()) const firstPlan = runningOn(link('Plan'))[0]! @@ -247,8 +249,8 @@ describe('BottomBar', () => { ]) }) - it('stops a running slide and places the pill on the active item when the viewport resizes', () => { - renderAt('/') + it('stops a running slide and places the pill on the active item when the viewport resizes', async () => { + await renderAt('/') fireEvent.click(link('Quick')) const slide = [pill(), link('Plan'), link('Discover'), link('Quick')].flatMap(runningOn) expect(slide).toHaveLength(4) @@ -269,7 +271,7 @@ describe('BottomBar', () => { const ready = new Promise((resolve) => (fontsReady = resolve)) Object.defineProperty(document, 'fonts', { configurable: true, value: { ready } }) try { - renderAt('/discover') + await renderAt('/discover') vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(124) fontsReady() await ready @@ -279,8 +281,8 @@ describe('BottomBar', () => { } }) - it('crossfades the icon colour with a .2s colour transition', () => { - renderAt('/') + it('crossfades the icon colour with a .2s colour transition', async () => { + await renderAt('/') for (const item of within(nav()).getAllByRole('link')) { expect(declaredStyle(item).transition).toBe('color .2s') } diff --git a/web/src/test/ApiProviders.tsx b/web/src/test/ApiProviders.tsx index cca07f8..60c786f 100644 --- a/web/src/test/ApiProviders.tsx +++ b/web/src/test/ApiProviders.tsx @@ -21,7 +21,7 @@ export const AI_SETTINGS = { export const AI_MODELS = { models: [{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }] } // HOUSEHOLD_SETTINGS is a GET /api/v1/settings/household body: the seed -// times in New York. +// times in New York, onboarding finished. export const HOUSEHOLD_SETTINGS = { timezone: 'America/New_York', dinner_time: '17:30', @@ -33,7 +33,7 @@ export const HOUSEHOLD_SETTINGS = { suggestions_time: '08:00', chat_autosend_dow: 6, chat_autosend_time: '08:00', - onboarding_completed_at: null, + onboarding_completed_at: '2026-10-01T15:30:00.000Z', updates_seen_at: null, updated_by: null, created_at: '2026-10-01T15:00:00.000Z',