Onboarding step 3 shell #92
@@ -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
|
`/onboarding`. Onboarding is a full screen outside the app shell, in three
|
||||||
steps: the welcome (`/onboarding`), the Regulars (`/onboarding/regulars`,
|
steps: the welcome (`/onboarding`), the Regulars (`/onboarding/regulars`,
|
||||||
mock 1h, adding and archiving through the dinners API optimistically) and
|
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`),
|
settings cannot be read (the service worker never caches `/api/v1/settings`),
|
||||||
so the saved pages open as they are.
|
so the saved pages open as they are.
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ type SettingsPatch struct {
|
|||||||
SuggestionsTime *string
|
SuggestionsTime *string
|
||||||
ChatAutosendDow *int
|
ChatAutosendDow *int
|
||||||
ChatAutosendTime *string
|
ChatAutosendTime *string
|
||||||
|
// OnboardingCompleted, when true, records that the household finished
|
||||||
|
// onboarding: onboarding_completed_at becomes the time of the update,
|
||||||
|
// unless it is already set, when the first time is kept. Onboarding is
|
||||||
|
// never undone, so false is invalid.
|
||||||
|
OnboardingCompleted *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Problem is one invalid field of a SettingsPatch, named by its JSON key.
|
// Problem is one invalid field of a SettingsPatch, named by its JSON key.
|
||||||
@@ -208,6 +213,9 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
|
|||||||
setString(&p.SuggestionsTime, patch.SuggestionsTime)
|
setString(&p.SuggestionsTime, patch.SuggestionsTime)
|
||||||
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
|
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
|
||||||
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
|
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
|
||||||
|
if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid {
|
||||||
|
p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true}
|
||||||
|
}
|
||||||
row, err = q.UpsertHouseholdSettings(ctx, p)
|
row, err = q.UpsertHouseholdSettings(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("household: update settings: %w", err)
|
return fmt.Errorf("household: update settings: %w", err)
|
||||||
@@ -291,6 +299,9 @@ func (p SettingsPatch) validate() error {
|
|||||||
fail(d.field, fmt.Sprintf("invalid day of the week %d (want 0-6, Sunday = 0)", *d.value))
|
fail(d.field, fmt.Sprintf("invalid day of the week %d (want 0-6, Sunday = 0)", *d.value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if p.OnboardingCompleted != nil && !*p.OnboardingCompleted {
|
||||||
|
fail("onboarding_completed", "onboarding cannot be undone (want true)")
|
||||||
|
}
|
||||||
if len(problems) > 0 {
|
if len(problems) > 0 {
|
||||||
return &ValidationError{Problems: problems}
|
return &ValidationError{Problems: problems}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,6 +217,39 @@ func TestUpdateAppliesPatchAndRecordsUpdatedBy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateCompletesOnboardingOnce(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := seededService(t)
|
||||||
|
|
||||||
|
got, err := svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "sam")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != got.UpdatedAt {
|
||||||
|
t.Fatalf("onboarding_completed_at = %v, want the update time %s", got.OnboardingCompletedAt, got.UpdatedAt)
|
||||||
|
}
|
||||||
|
first := *got.OnboardingCompletedAt
|
||||||
|
|
||||||
|
// Completing again keeps the first time.
|
||||||
|
got, err = svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "alex")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != first {
|
||||||
|
t.Errorf("second completion: onboarding_completed_at = %v, want %s", got.OnboardingCompletedAt, first)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other changes leave it alone.
|
||||||
|
got, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, "sam")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != first {
|
||||||
|
t.Errorf("after another change: onboarding_completed_at = %v, want %s", got.OnboardingCompletedAt, first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateRequiresMember(t *testing.T) {
|
func TestUpdateRequiresMember(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -259,6 +292,7 @@ func TestUpdateValidation(t *testing.T) {
|
|||||||
{"dow 7", SettingsPatch{PlanDraftDow: ptr(7)}, []string{"plan_draft_dow"}},
|
{"dow 7", SettingsPatch{PlanDraftDow: ptr(7)}, []string{"plan_draft_dow"}},
|
||||||
{"dow -1", SettingsPatch{SuggestionsDow: ptr(-1)}, []string{"suggestions_dow"}},
|
{"dow -1", SettingsPatch{SuggestionsDow: ptr(-1)}, []string{"suggestions_dow"}},
|
||||||
{"dow 100", SettingsPatch{ChatAutosendDow: ptr(100)}, []string{"chat_autosend_dow"}},
|
{"dow 100", SettingsPatch{ChatAutosendDow: ptr(100)}, []string{"chat_autosend_dow"}},
|
||||||
|
{"onboarding undone", SettingsPatch{OnboardingCompleted: ptr(false)}, []string{"onboarding_completed"}},
|
||||||
{
|
{
|
||||||
"every problem is reported",
|
"every problem is reported",
|
||||||
SettingsPatch{Timezone: ptr("Nowhere"), DinnerTime: ptr("5pm"), PlanDraftDow: ptr(9)},
|
SettingsPatch{Timezone: ptr("Nowhere"), DinnerTime: ptr("5pm"), PlanDraftDow: ptr(9)},
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ type settingsPatch struct {
|
|||||||
SuggestionsTime *string `json:"suggestions_time"`
|
SuggestionsTime *string `json:"suggestions_time"`
|
||||||
ChatAutosendDow *int `json:"chat_autosend_dow"`
|
ChatAutosendDow *int `json:"chat_autosend_dow"`
|
||||||
ChatAutosendTime *string `json:"chat_autosend_time"`
|
ChatAutosendTime *string `json:"chat_autosend_time"`
|
||||||
|
// OnboardingCompleted true sets onboarding_completed_at, which is
|
||||||
|
// itself read-only.
|
||||||
|
OnboardingCompleted *bool `json:"onboarding_completed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc {
|
func getHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc {
|
||||||
|
|||||||
@@ -115,6 +115,19 @@ func TestPatchHouseholdSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPatchHouseholdSettingsCompletesOnboarding(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec, body := serve(t, newTestRouter(t, "dev"), signedIn(http.MethodPatch, settingsPath,
|
||||||
|
strings.NewReader(`{"onboarding_completed":true}`)))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if at, ok := body["onboarding_completed_at"].(string); !ok || at != body["updated_at"] {
|
||||||
|
t.Errorf("onboarding_completed_at = %#v, want the update time %#v", body["onboarding_completed_at"], body["updated_at"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPatchHouseholdSettingsRejected(t *testing.T) {
|
func TestPatchHouseholdSettingsRejected(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -136,6 +149,8 @@ func TestPatchHouseholdSettingsRejected(t *testing.T) {
|
|||||||
{"null value", `{"timezone":null}`, "invalid_request", "timezone"},
|
{"null value", `{"timezone":null}`, "invalid_request", "timezone"},
|
||||||
{"unknown field", `{"dinner":"17:30"}`, "invalid_request", "dinner"},
|
{"unknown field", `{"dinner":"17:30"}`, "invalid_request", "dinner"},
|
||||||
{"read-only field", `{"updated_by":"someone"}`, "invalid_request", "updated_by"},
|
{"read-only field", `{"updated_by":"someone"}`, "invalid_request", "updated_by"},
|
||||||
|
{"read-only onboarding time", `{"onboarding_completed_at":"2026-10-06T12:00:00.000Z"}`, "invalid_request", "onboarding_completed_at"},
|
||||||
|
{"onboarding undone", `{"onboarding_completed":false}`, "invalid_settings", "onboarding_completed"},
|
||||||
{"not an object", `["timezone"]`, "invalid_request", "JSON object"},
|
{"not an object", `["timezone"]`, "invalid_request", "JSON object"},
|
||||||
{"null body", `null`, "invalid_request", "JSON object"},
|
{"null body", `null`, "invalid_request", "JSON object"},
|
||||||
{"trailing data", `{"dinner_time":"18:00"} {}`, "invalid_request", "JSON object"},
|
{"trailing data", `{"dinner_time":"18:00"} {}`, "invalid_request", "JSON object"},
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -73,7 +73,19 @@
|
|||||||
font-size: 17px;
|
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 {
|
.terms {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -221,6 +233,15 @@
|
|||||||
margin-top: 10px;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.addFace {
|
.addFace {
|
||||||
transition: none;
|
transition: none;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Repeat, ScrollText, Sparkles, type LucideIcon } from 'lucide-react'
|
|||||||
import { Route, Routes, useNavigate } from 'react-router'
|
import { Route, Routes, useNavigate } from 'react-router'
|
||||||
import { Button } from '../ui/Button.tsx'
|
import { Button } from '../ui/Button.tsx'
|
||||||
import { Icon } from '../ui/Icon.tsx'
|
import { Icon } from '../ui/Icon.tsx'
|
||||||
|
import { NotificationsStep } from './NotificationsStep.tsx'
|
||||||
import { OnboardingStep } from './OnboardingStep.tsx'
|
import { OnboardingStep } from './OnboardingStep.tsx'
|
||||||
import { RegularsStep } from './RegularsStep.tsx'
|
import { RegularsStep } from './RegularsStep.tsx'
|
||||||
import styles from './Onboarding.module.css'
|
import styles from './Onboarding.module.css'
|
||||||
@@ -65,13 +66,3 @@ function WelcomeStep() {
|
|||||||
</OnboardingStep>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
// HouseholdSettingsProblems is a refused household settings save: the reason
|
||||||
// for each field the server named, and any reasons that name no field.
|
// for each field the server named, and any reasons that name no field.
|
||||||
export type HouseholdSettingsProblems = {
|
export type HouseholdSettingsProblems = {
|
||||||
|
|||||||
Reference in New Issue
Block a user