diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8faa66e..c33d566 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/web/src/onboarding/NotificationsStep.test.tsx b/web/src/onboarding/NotificationsStep.test.tsx new file mode 100644 index 0000000..a34c56c --- /dev/null +++ b/web/src/onboarding/NotificationsStep.test.tsx @@ -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> + +beforeEach(() => { + requestPermission = vi.fn<() => Promise>(() => 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((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((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( + + + , + ) + 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( + + + + + } + /> + Home} /> + + + + , + ) + 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( + () => + 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(() => 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() + }) +}) diff --git a/web/src/onboarding/NotificationsStep.tsx b/web/src/onboarding/NotificationsStep.tsx new file mode 100644 index 0000000..d8d5ed0 --- /dev/null +++ b/web/src/onboarding/NotificationsStep.tsx @@ -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 + +// 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 { + 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(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 ( + + + + + } + > +

MealQuest can let the household know when there's something to look at:

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

    + {name} + {meaning} +

    +
  • + ))} +
+
+ +
+ {problem && ( +

+ {problem} +

+ )} +
+ ) +} diff --git a/web/src/onboarding/Onboarding.module.css b/web/src/onboarding/Onboarding.module.css index 3ab1a7e..477e2da 100644 --- a/web/src/onboarding/Onboarding.module.css +++ b/web/src/onboarding/Onboarding.module.css @@ -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; diff --git a/web/src/onboarding/OnboardingPage.tsx b/web/src/onboarding/OnboardingPage.tsx index d8f9853..48e3924 100644 --- a/web/src/onboarding/OnboardingPage.tsx +++ b/web/src/onboarding/OnboardingPage.tsx @@ -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() { ) } - -// 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/settings/api.ts b/web/src/settings/api.ts index 540f3ea..b32b127 100644 --- a/web/src/settings/api.ts +++ b/web/src/settings/api.ts @@ -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(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 = {