Add onboarding steps 1 and 2 with the first-run redirect
CI / ci (push) Successful in 2m20s
CI / ci (pull_request) Successful in 2m21s

While household_settings.onboarding_completed_at is null, OnboardingGate
sends every app route to /onboarding, a full screen outside the shell.
The household settings parser now reads onboarding_completed_at from the
existing GET /api/v1/settings/household.

Step 1 welcomes the household and explains Regulars, Side quests and the
Quest log. Step 2 follows mock 1h: progress pills, the add pill with its
grape "+" (Enter also adds), Regulars chips with remove, dashed Common
picks, and "Next: notifications" enabled at 3 Regulars. Adds and removes
go through new dinners API hooks (POST /api/v1/dinners, DELETE
/api/v1/dinners/{id}) optimistically, rolled back if the server refuses.
Step 3 is a placeholder until its task lands.

Maestro task 5a43f813-c940-4f79-a2be-027644ae9e6c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 16:48:42 -04:00
co-authored by Claude Opus 5.5
parent 423b898f54
commit b568558875
15 changed files with 1054 additions and 67 deletions
+9
View File
@@ -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
+64 -12
View File
@@ -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()
})
+13 -4
View File
@@ -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<Record<(typeof routes)[number]['path'], ReactNode>> = {
}
// 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>
<Route element={<AppShell />}>
{routes.map(({ path, title }) => (
<Route key={path} path={path} element={screens[path] ?? <PlaceholderPage title={title} />} />
))}
<Route element={<OnboardingGate />}>
<Route path={`${ONBOARDING_PATH}/*`} element={<OnboardingPage />} />
<Route element={<AppShell />}>
{routes
.filter(({ path }) => path !== ONBOARDING_PATH)
.map(({ path, title }) => (
<Route key={path} path={path} element={screens[path] ?? <PlaceholderPage title={title} />} />
))}
</Route>
</Route>
</Routes>
)
+5
View File
@@ -41,6 +41,11 @@ export function AuthBootstrap({ children }: { children: ReactNode }) {
</main>
)
}
return <LoadingScreen />
}
// LoadingScreen is the full-screen skeleton shown while MealQuest loads.
export function LoadingScreen() {
return (
<main className={styles.screen} aria-busy="true" aria-label="Loading MealQuest">
<div className={styles.loading}>
+112
View File
@@ -0,0 +1,112 @@
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 }
// 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<unknown>(REGULARS_PATH)
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
return body.dinners.map((dinner) => parseDinner(where, dinner))
},
})
}
// changeOptimistically stops Regulars reads in flight, so they cannot
// overwrite the optimistic list, then applies change to it. It returns the
// list as it was.
async function changeOptimistically(queryClient: QueryClient, change: (list: Regular[]) => Regular[]) {
await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY })
const before = queryClient.getQueryData<Regular[]>(REGULARS_LIST_KEY) ?? []
queryClient.setQueryData<Regular[]>(REGULARS_LIST_KEY, change(before))
return before
}
// 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<unknown>(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) => {
queryClient.setQueryData<Regular[]>(REGULARS_LIST_KEY, (list) =>
(list ?? []).map((regular) => (regular.id === context.id ? dinner : regular)),
)
},
onError: (_error, _name, context) => {
if (context === undefined) return
queryClient.setQueryData<Regular[]>(REGULARS_LIST_KEY, (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 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<unknown>(`${DINNERS_PATH}/${encodeURIComponent(regular.id)}`, { method: 'DELETE' })
},
onMutate: async (regular) => {
const before = await changeOptimistically(queryClient, (list) => list.filter((r) => r.id !== regular.id))
return { index: before.findIndex((r) => r.id === regular.id) }
},
onError: (_error, regular, context) => {
if (context === undefined) return
queryClient.setQueryData<Regular[]>(REGULARS_LIST_KEY, (list) => {
const next = [...(list ?? [])]
next.splice(context.index === -1 ? next.length : context.index, 0, regular)
return next
})
},
onSettled: () => settle(queryClient),
})
}
+228
View File
@@ -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;
}
}
+52
View File
@@ -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 <Navigate to={ONBOARDING_PATH} replace />
}
return <Outlet />
}
if (offline) return <Outlet />
if (settings.isError) {
return (
<main className={styles.screen}>
<ErrorBanner onRetry={() => void refetch()}>MealQuest could not load: {settings.error.message}</ErrorBanner>
</main>
)
}
return <LoadingScreen />
}
+259
View File
@@ -0,0 +1,259 @@
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.
function stubDinnersServer(regulars: Dinner[] = []) {
const held: Held[] = []
const fetchMock: Mock<typeof fetch> = vi.fn<typeof fetch>((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') {
return Promise.resolve(jsonResponse(200, { dinners: regulars }))
}
if ((method === 'POST' && path === '/api/v1/dinners') || (method === 'DELETE' && path.startsWith('/api/v1/dinners/'))) {
return new Promise<Response>((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 }
}
// 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<typeof stubDinnersServer>, 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(
<InDataRouter path={path}>
<App api={api} serviceWorker={undefined} />
</InDataRouter>,
)
}
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('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('enables "Next: notifications" once there are 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 vi.waitFor(() => expect(next()).toBeEnabled())
await vi.waitFor(() => expect(server.held).toHaveLength(1))
created(server, server.held[0]!, 'd3')
await screen.findByRole('button', { name: 'Remove Burgers' })
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()
})
})
+77
View File
@@ -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 (
<Routes>
<Route index element={<WelcomeStep />} />
<Route path="regulars" element={<RegularsStep />} />
<Route path="notifications" element={<NotificationsStep />} />
</Routes>
)
}
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 (
<OnboardingStep
step={1}
title="Welcome to MealQuest"
action={
<Button className={styles.action} onClick={() => void navigate('/onboarding/regulars')}>
Let&apos;s start
</Button>
}
>
<p className={styles.helper}>MealQuest plans the household&apos;s dinners around what you already love to cook.</p>
<ul className={styles.terms}>
{terms.map(({ icon, name, meaning }) => (
<li key={name} className={styles.term}>
<span className={styles.termIcon}>
<Icon icon={icon} size="nav" />
</span>
<p className={styles.termText}>
<span className={styles.termName}>{name}</span>
{meaning}
</p>
</li>
))}
</ul>
</OnboardingStep>
)
}
// TODO(Feature 12): onboarding step 3 — notifications and finishing
// onboarding — is a later task; until then the step only says so.
function NotificationsStep() {
return (
<OnboardingStep step={3} title="Stay in the loop">
<p className={styles.helper}>Setting up notifications arrives in a later update.</p>
</OnboardingStep>
)
}
+35
View File
@@ -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 (
<main className={styles.screen}>
<div className={styles.column}>
<div className={styles.progress} aria-hidden="true">
{Array.from({ length: ONBOARDING_STEPS }, (_, i) => (
<span key={i} className={[styles.pill, i < step && styles.filled].filter(Boolean).join(' ')} />
))}
</div>
<p className={styles.kicker}>
Step {step} of {ONBOARDING_STEPS}
</p>
<h1 className={styles.title}>{title}</h1>
{children}
</div>
{action && <div className={styles.footer}>{action}</div>}
</main>
)
}
+139
View File
@@ -0,0 +1,139 @@
import { 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 household adds before moving 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()
}
// 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('')
const list = regulars.data ?? []
const picks = commonPicks.filter((pick) => !list.some((regular) => sameName(regular.name, pick)))
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const name = draft.trim()
if (name === '') return
add.mutate(name)
setDraft('')
}
return (
<OnboardingStep
step={2}
title="What's on your usual rotation?"
action={
<Button
className={styles.action}
disabled={list.length < MIN_REGULARS}
onClick={() => void navigate('/onboarding/notifications')}
>
Next: notifications
</Button>
}
>
<p className={styles.helper}>
Add the dinners your household makes on repeat. MealQuest builds suggestions from these.
</p>
<form className={styles.add} onSubmit={onSubmit}>
<TextInput
label="Dinner name"
labelHidden
className={styles.addInput}
placeholder="e.g. Chicken enchiladas"
autoComplete="off"
enterKeyHint="done"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<button type="submit" className={styles.addButton} aria-label="Add Regular" disabled={draft.trim() === ''}>
<span className={styles.addFace}>
<Icon icon={Plus} size="nav" />
</span>
</button>
</form>
{add.isError && (
<p role="alert" className={styles.problem}>
Could not add {add.variables}: {add.error.message}
</p>
)}
{remove.isError && (
<p role="alert" className={styles.problem}>
Could not remove {remove.variables.name}: {remove.error.message}
</p>
)}
<h2 className={styles.groupLabel}>Your regulars · {list.length}</h2>
{regulars.isPending && <Skeleton className={styles.status} />}
{regulars.isError && (
<div className={styles.status}>
<ErrorBanner onRetry={() => void regulars.refetch()}>
Your Regulars could not load: {regulars.error.message}
</ErrorBanner>
</div>
)}
{list.length > 0 && (
<ul className={styles.chips}>
{list.map((regular: Regular) => (
<li key={regular.id}>
<Chip
label={regular.name}
className={regular.pending && styles.pending}
aria-busy={regular.pending}
onRemove={regular.pending ? undefined : () => remove.mutate(regular)}
/>
</li>
))}
</ul>
)}
{regulars.isSuccess && picks.length > 0 && (
<>
<h2 className={styles.groupLabel}>Common picks</h2>
<ul className={styles.chips}>
{picks.map((pick) => (
<li key={pick}>
<button type="button" className={styles.pick} aria-label={`Add ${pick}`} onClick={() => add.mutate(pick)}>
<span aria-hidden="true">+</span>
{pick}
</button>
</li>
))}
</ul>
</>
)}
</OnboardingStep>
)
}
+12 -5
View File
@@ -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<keyof HouseholdSettings, 'onboarding_completed_at'>
// HouseholdSettingsPatch is the PATCH /api/v1/settings/household body: only
// the fields that change.
export type HouseholdSettingsPatch = Partial<HouseholdSettings>
export type HouseholdSettingsField = keyof HouseholdSettings
export type HouseholdSettingsPatch = Partial<Pick<HouseholdSettings, HouseholdSettingsField>>
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()
+21 -20
View File
@@ -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) {
</ApiProviders>
</InDataRouter>,
)
// 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)
})
})
+26 -24
View File
@@ -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) {
</ApiProviders>
</InDataRouter>,
)
// 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<void>((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')
}
+2 -2
View File
@@ -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',