Onboarding step 3: notifications and finishing onboarding
CI / ci (push) Successful in 2m17s
CI / ci (pull_request) Successful in 2m20s

Step 3 explains what MealQuest notifies about, shows the iOS install hint
and offers "Turn on notifications" and "Maybe later". Either one PATCHes
onboarding_completed and goes Home, ending the first-run redirect.
"Turn on notifications" first calls an injectable enableNotifications(),
a no-op answering "unavailable" until Feature 19 wires Web Push; nothing
asks for notification permission.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 17:42:52 -04:00
co-authored by Claude Opus 5.5
parent f53c33e456
commit d100f3e74a
6 changed files with 440 additions and 12 deletions
+8 -1
View File
@@ -489,7 +489,14 @@ First run: while `household_settings.onboarding_completed_at` (read from
`/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
notifications (`/onboarding/notifications`: what MealQuest notifies about,
the iOS install hint, "Turn on notifications" and "Maybe later"). Either
button sends `PATCH /api/v1/settings/household` with
`{"onboarding_completed": true}`; the server sets `onboarding_completed_at`
to the time of that update (keeping the first time if it is already set;
the field itself is read-only and `false` is refused) and the app goes Home.
Notification permission is never asked for on page load; until Feature 19
wires Web Push, "Turn on notifications" asks for nothing. 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.
@@ -0,0 +1,265 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { Route, Routes } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { createApiClient, createVerifiedSession } from '../api/index.ts'
import App from '../App.tsx'
import { AuthBootstrap } from '../AuthBootstrap.tsx'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { NotificationsStep, notificationsUnavailable, type EnableNotifications } from './NotificationsStep.tsx'
import styles from './Onboarding.module.css'
const IPHONE_SAFARI =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1'
const COMPLETED_AT = '2026-10-06T21:30:00.000Z'
// requestPermission stands in for the browser's Notification API, which
// jsdom lacks, so a test can prove nothing asks for permission.
let requestPermission: Mock<() => Promise<NotificationPermission>>
beforeEach(() => {
requestPermission = vi.fn<() => Promise<NotificationPermission>>(() => Promise.resolve('granted'))
vi.stubGlobal('Notification', { permission: 'default', requestPermission })
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
Reflect.deleteProperty(navigator, 'maxTouchPoints')
Reflect.deleteProperty(navigator, 'standalone')
localStorage.clear()
})
// A PATCH the fake server holds until the test answers it.
type Held = { body: unknown; answer: (response: Response) => void }
// stubFirstRunServer stands in for the server on first run: signed in,
// onboarding not finished. Each PATCH of the household settings is held in
// `held` until the test answers it.
function stubFirstRunServer() {
const held: Held[] = []
const fetchMock = 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 === 'PATCH' && path === '/api/v1/settings/household') {
return new Promise<Response>((resolve) => {
held.push({ body: JSON.parse(String(init?.body)), answer: resolve })
})
}
throw new Error(`unexpected fetch: ${method} ${path}`)
})
vi.stubGlobal('fetch', fetchMock)
return { fetchMock, held }
}
function completed(request: Held) {
request.answer(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: COMPLETED_AT }))
}
// renderApp renders the whole app at step 3, so the first-run redirect is
// what decides where finishing lands.
async function renderApp() {
mockViewport(390)
const server = stubFirstRunServer()
const api = createApiClient(
fakeLocation('/onboarding/notifications'),
createNetworkStatus(window),
createVerifiedSession(localStorage),
)
render(
<InDataRouter path="/onboarding/notifications">
<App api={api} serviceWorker={undefined} />
</InDataRouter>,
)
await screen.findByRole('heading', { level: 1, name: 'Stay in the loop' })
return server
}
// renderStep renders step 3 alone with the given enableNotifications, and a
// Home that says it was reached. AuthBootstrap reads /api/v1/me for the CSRF
// token every write needs.
async function renderStep(enableNotifications?: EnableNotifications) {
const server = stubFirstRunServer()
render(
<InDataRouter path="/onboarding/notifications">
<ApiProviders>
<AuthBootstrap>
<Routes>
<Route
path="/onboarding/notifications"
element={<NotificationsStep enableNotifications={enableNotifications} />}
/>
<Route path="/" element={<h1>Home</h1>} />
</Routes>
</AuthBootstrap>
</ApiProviders>
</InDataRouter>,
)
await screen.findByRole('heading', { level: 1, name: 'Stay in the loop' })
return server
}
function turnOn() {
return screen.getByRole('button', { name: 'Turn on notifications' })
}
function later() {
return screen.getByRole('button', { name: 'Maybe later' })
}
describe('onboarding step 3', () => {
it('explains what MealQuest notifies about, with all three progress pills filled', async () => {
await renderApp()
expect(screen.getByText('Step 3 of 3')).toBeInTheDocument()
const pills = Array.from(document.getElementsByClassName(styles.pill!))
expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, true, true])
const topics = within(screen.getByRole('list')).getAllByRole('listitem')
expect(topics.map((topic) => topic.textContent)).toEqual([
expect.stringMatching(/^Plan ready/),
expect.stringMatching(/^Supply run ready/),
expect.stringMatching(/^Tonight's dinner/),
expect.stringMatching(/^New Side quests/),
])
expect(turnOn()).toBeEnabled()
expect(later()).toBeEnabled()
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
})
it('never asks for notification permission on render', async () => {
await renderApp()
expect(requestPermission).not.toHaveBeenCalled()
})
it('sizes both actions as tap targets and fixes them to the bottom', async () => {
await renderApp()
expectMinHitArea(turnOn())
expectMinHitArea(later())
expect(declaredStyle(turnOn()).height).toBe('52px')
expect(declaredStyle(later()).height).toBe('52px')
expect(declaredStyle(turnOn().parentElement!.parentElement!).position).toBe('fixed')
})
it('shows no install hint outside iOS Safari', async () => {
await renderApp()
expect(screen.queryByText('Tap Share, then Add to Home Screen to get notifications')).not.toBeInTheDocument()
})
it('shows the install hint in an iOS Safari tab', async () => {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(IPHONE_SAFARI)
Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 })
Object.defineProperty(navigator, 'standalone', { configurable: true, value: false })
vi.stubGlobal('matchMedia', (query: string) => ({ matches: false, media: query }) as MediaQueryList)
await renderStep()
expect(screen.getByRole('note')).toHaveTextContent('Tap Share, then Add to Home Screen to get notifications')
})
it('"Maybe later" finishes onboarding and goes Home, ending the first-run redirect', async () => {
const server = await renderApp()
fireEvent.click(later())
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
expect(turnOn()).toBeDisabled()
expect(later()).toBeDisabled()
completed(server.held[0]!)
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
expect(requestPermission).not.toHaveBeenCalled()
})
it('"Turn on notifications" finishes onboarding and goes Home while notifications are unavailable', async () => {
const server = await renderApp()
fireEvent.click(turnOn())
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
completed(server.held[0]!)
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
expect(requestPermission).not.toHaveBeenCalled()
})
it('"Turn on notifications" asks the injected enableNotifications first, then finishes', async () => {
let answer: (result: 'enabled') => void = () => {}
const enable = vi.fn<EnableNotifications>(
() =>
new Promise((resolve) => {
answer = resolve
}),
)
const server = await renderStep(enable)
expect(enable).not.toHaveBeenCalled()
fireEvent.click(turnOn())
expect(enable).toHaveBeenCalledOnce()
expect(turnOn()).toBeDisabled()
expect(later()).toBeDisabled()
// Onboarding is not finished until enableNotifications has answered.
await Promise.resolve()
expect(server.held).toHaveLength(0)
answer('enabled')
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
completed(server.held[0]!)
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
})
it('"Maybe later" does not ask the injected enableNotifications', async () => {
const enable = vi.fn<EnableNotifications>(() => Promise.resolve('enabled'))
const server = await renderStep(enable)
fireEvent.click(later())
await vi.waitFor(() => expect(server.held).toHaveLength(1))
completed(server.held[0]!)
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
expect(enable).not.toHaveBeenCalled()
})
it('says why and stays when enableNotifications fails', async () => {
const server = await renderStep(() => Promise.reject(new Error('push service down')))
fireEvent.click(turnOn())
expect(await screen.findByRole('alert')).toHaveTextContent('Could not turn on notifications: push service down')
expect(server.held).toHaveLength(0)
expect(turnOn()).toBeEnabled()
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
})
it('says why and stays when the server refuses to finish onboarding, then finishes on retry', async () => {
const server = await renderApp()
fireEvent.click(later())
await vi.waitFor(() => expect(server.held).toHaveLength(1))
server.held[0]!.answer(errorResponse(500, 'internal'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not finish setting up: internal message')
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
expect(later()).toBeEnabled()
fireEvent.click(later())
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
await vi.waitFor(() => expect(server.held).toHaveLength(2))
completed(server.held[1]!)
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
})
})
describe('notificationsUnavailable', () => {
it('answers "unavailable" without asking for permission', async () => {
await expect(notificationsUnavailable()).resolves.toBe('unavailable')
expect(requestPermission).not.toHaveBeenCalled()
})
})
+129
View File
@@ -0,0 +1,129 @@
import { useState } from 'react'
import { CalendarDays, ShoppingBasket, Sparkles, UtensilsCrossed, type LucideIcon } from 'lucide-react'
import { useNavigate } from 'react-router'
import { InstallHint } from '../pwa/InstallHint.tsx'
import { useCompleteOnboarding } from '../settings/api.ts'
import { Button } from '../ui/Button.tsx'
import { Icon } from '../ui/Icon.tsx'
import { OnboardingStep } from './OnboardingStep.tsx'
import styles from './Onboarding.module.css'
// NotificationsResult is how asking to turn on notifications ended.
export type NotificationsResult = 'enabled' | 'denied' | 'unavailable'
export type EnableNotifications = () => Promise<NotificationsResult>
// TODO(Feature 19): Web Push. Until it is wired, notifications cannot be
// turned on, and nothing asks the browser for permission.
export function notificationsUnavailable(): Promise<NotificationsResult> {
return Promise.resolve('unavailable')
}
const topics: { icon: LucideIcon; name: string; meaning: string }[] = [
{
icon: CalendarDays,
name: 'Plan ready',
meaning: "The week's Draft board is ready for the household to adjust.",
},
{
icon: ShoppingBasket,
name: 'Supply run ready',
meaning: "The week's shopping list, grouped by dinner, is ready to shop.",
},
{
icon: UtensilsCrossed,
name: "Tonight's dinner",
meaning: "A reminder before dinner of what's on the plan tonight.",
},
{
icon: Sparkles,
name: 'New Side quests',
meaning: 'Fresh dinners to Try it or Pass, picked from your Regulars.',
},
]
export type NotificationsStepProps = {
// Asks to turn on notifications when the household taps "Turn on
// notifications" — never on render.
enableNotifications?: EnableNotifications
}
// NotificationsStep is onboarding step 3: what MealQuest notifies about, the
// iOS install hint, and finishing onboarding. Either button records that
// onboarding is finished and goes Home.
export function NotificationsStep({ enableNotifications = notificationsUnavailable }: NotificationsStepProps) {
const navigate = useNavigate()
const complete = useCompleteOnboarding()
const [enabling, setEnabling] = useState(false)
const [problem, setProblem] = useState<string | null>(null)
const busy = enabling || complete.isPending
async function finish() {
try {
await complete.mutateAsync()
} catch (error) {
setProblem(`Could not finish setting up: ${(error as Error).message}`)
return
}
void navigate('/', { replace: true })
}
async function turnOn() {
setProblem(null)
setEnabling(true)
try {
await enableNotifications()
} catch (error) {
setProblem(`Could not turn on notifications: ${(error as Error).message}`)
return
} finally {
setEnabling(false)
}
await finish()
}
function later() {
setProblem(null)
void finish()
}
return (
<OnboardingStep
step={3}
title="Stay in the loop"
action={
<div className={styles.actions}>
<Button className={styles.action} disabled={busy} onClick={() => void turnOn()}>
Turn on notifications
</Button>
<Button variant="outline" className={styles.action} disabled={busy} onClick={later}>
Maybe later
</Button>
</div>
}
>
<p className={styles.helper}>MealQuest can let the household know when there&apos;s something to look at:</p>
<ul className={styles.terms}>
{topics.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>
<div className={styles.hint}>
<InstallHint />
</div>
{problem && (
<p role="alert" className={styles.problem}>
{problem}
</p>
)}
</OnboardingStep>
)
}
+22 -1
View File
@@ -73,7 +73,19 @@
font-size: 17px;
}
/* Step 1: what MealQuest's words mean. */
/* Step 3: two stacked actions. */
.actions {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
/* Room for the second fixed action and the gap above it. */
.screen:has(.actions) {
padding-bottom: calc(2 * 52px + var(--space-3) + 2 * var(--space-6));
}
/* Step 1: what MealQuest's words mean; step 3: what it notifies about. */
.terms {
display: flex;
flex-direction: column;
@@ -221,6 +233,15 @@
margin-top: 10px;
}
/* Step 3: the iOS install hint, when it shows. */
.hint {
margin-top: var(--space-4);
}
.hint:empty {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.addFace {
transition: none;
+1 -10
View File
@@ -2,6 +2,7 @@ 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 { NotificationsStep } from './NotificationsStep.tsx'
import { OnboardingStep } from './OnboardingStep.tsx'
import { RegularsStep } from './RegularsStep.tsx'
import styles from './Onboarding.module.css'
@@ -65,13 +66,3 @@ function WelcomeStep() {
</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>
)
}
+15
View File
@@ -389,6 +389,21 @@ export function useSaveHouseholdSettings() {
})
}
// useCompleteOnboarding records that the household finished onboarding: the
// server sets onboarding_completed_at, which ends the first-run redirect.
export function useCompleteOnboarding() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async () =>
parseHouseholdSettings(
`PATCH ${HOUSEHOLD_SETTINGS_PATH}`,
await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH, { method: 'PATCH', body: { onboarding_completed: true } }),
),
onSuccess: (settings) => queryClient.setQueryData(HOUSEHOLD_SETTINGS_KEY, settings),
})
}
// HouseholdSettingsProblems is a refused household settings save: the reason
// for each field the server named, and any reasons that name no field.
export type HouseholdSettingsProblems = {