Feature 19: Notifications - Web Push and Google Chat (#169)
CI / ci (push) Successful in 8m31s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #169.
This commit is contained in:
2026-10-09 18:04:05 -04:00
committed by approver_agent
parent 4b592fdbbb
commit dcd35e78ec
111 changed files with 11455 additions and 494 deletions
+1
View File
@@ -123,6 +123,7 @@ describe('App routes', () => {
title: null,
status: 'current',
sent_to_chat_at: null,
chat_send_unconfirmed_at: null,
needed_count: 1,
total_count: 1,
groups: [{ dinner: { id: 'd1', name: 'Beef tacos' }, date: week, day: 'Monday', items: [{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true }] }],
+2
View File
@@ -16,6 +16,7 @@ import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
import { AISettingsPage } from './settings/AISettingsPage.tsx'
import { CalendarSettingsPage } from './settings/CalendarSettingsPage.tsx'
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
import { NotificationsPage } from './settings/NotificationsPage.tsx'
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
import { SettingsPage } from './settings/SettingsPage.tsx'
import { AppShell } from './shell/AppShell.tsx'
@@ -52,6 +53,7 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/cook-from-pantry': <CookFromPantryPage />,
'/dinner/:id': <DinnerDetailPage />,
'/settings': <SettingsPage />,
'/settings/notifications': <NotificationsPage />,
'/settings/ai': <AISettingsPage />,
'/settings/household': <HouseholdSettingsPage />,
'/settings/regulars': <RegularsSettingsPage />,
+148 -76
View File
@@ -11,12 +11,19 @@ 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 { PUSH_ENDPOINT, restorePushEnvironment, stubPushEnvironment, VAPID_PUBLIC_KEY } from '../test/push.ts'
import { NotificationsStep } 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 IPHONE_CHROME =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/138.0.7204.119 Mobile/15E148 Safari/604.1'
const IOS_UNSUPPORTED =
"Notifications aren't on: on iPhone and iPad they only work once MealQuest is on the Home Screen. Tap Share, then Add to Home Screen, open MealQuest from there, and turn them on in Settings under Notifications."
const COMPLETED_AT = '2026-10-06T21:30:00.000Z'
// requestPermission stands in for the browser's Notification API, which
@@ -29,6 +36,7 @@ beforeEach(() => {
})
afterEach(() => {
restorePushEnvironment()
vi.restoreAllMocks()
vi.unstubAllGlobals()
Reflect.deleteProperty(navigator, 'maxTouchPoints')
@@ -41,13 +49,20 @@ 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() {
// `held` until the test answers it. It hands out the VAPID public key with
// vapidKey and stores push subscriptions in `subscriptions`.
function stubFirstRunServer(vapidKey: () => Response = () => jsonResponse(200, VAPID_PUBLIC_KEY)) {
const held: Held[] = []
const subscriptions: unknown[] = []
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/push/vapid-public-key') return Promise.resolve(vapidKey())
if (method === 'POST' && path === '/api/v1/push/subscriptions') {
subscriptions.push(JSON.parse(String(init?.body)))
return Promise.resolve(jsonResponse(200, { id: 'sub-1', endpoint: PUSH_ENDPOINT }))
}
if (method === 'GET' && path === '/api/v1/settings/household') {
return Promise.resolve(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }))
}
@@ -59,7 +74,7 @@ function stubFirstRunServer() {
throw new Error(`unexpected fetch: ${method} ${path}`)
})
vi.stubGlobal('fetch', fetchMock)
return { fetchMock, held }
return { fetchMock, held, subscriptions }
}
function completed(request: Held) {
@@ -85,20 +100,16 @@ async function renderApp() {
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()
// renderStep renders step 3 alone, and a Home that says it was reached.
// AuthBootstrap reads /api/v1/me for the CSRF token every write needs.
async function renderStep(vapidKey?: () => Response) {
const server = stubFirstRunServer(vapidKey)
render(
<InDataRouter path="/onboarding/notifications">
<ApiProviders>
<AuthBootstrap>
<Routes>
<Route
path="/onboarding/notifications"
element={<NotificationsStep enableNotifications={enableNotifications} />}
/>
<Route path="/onboarding/notifications" element={<NotificationsStep />} />
<Route path="/" element={<h1>Home</h1>} />
<Route path="/onboarding/regulars" element={<h1>Regulars step</h1>} />
</Routes>
@@ -110,6 +121,33 @@ async function renderStep(enableNotifications?: EnableNotifications) {
return server
}
// finishFromResult taps "Finish setting up", the only action once a result
// shows, and expects onboarding finished and Home reached.
async function finishFromResult(server: ReturnType<typeof stubFirstRunServer>) {
expect(screen.queryByRole('button', { name: 'Turn on notifications' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Maybe later' })).not.toBeInTheDocument()
const finish = screen.getByRole('button', { name: 'Finish setting up' })
expectMinHitArea(finish)
// Not a silent success: onboarding is not finished behind the household's back.
expect(server.held).toHaveLength(0)
fireEvent.click(finish)
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()
}
function useIphoneSafariTab() {
useIphoneTab(IPHONE_SAFARI)
}
function useIphoneTab(userAgent: string) {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(userAgent)
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)
}
function turnOn() {
return screen.getByRole('button', { name: 'Turn on notifications' })
}
@@ -157,10 +195,7 @@ describe('onboarding step 3', () => {
})
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)
useIphoneSafariTab()
await renderStep()
expect(screen.getByRole('note')).toHaveTextContent('Tap Share, then Add to Home Screen to get notifications')
})
@@ -180,91 +215,135 @@ describe('onboarding step 3', () => {
expect(requestPermission).not.toHaveBeenCalled()
})
it('"Turn on notifications" says plainly that nothing was turned on while notifications are unavailable', async () => {
it('"Turn on notifications" asks permission from the tap, subscribes this device and says it is on', async () => {
const browser = stubPushEnvironment({ permission: 'default', answer: 'granted' })
const server = await renderStep()
expect(browser.notification.requestPermission).not.toHaveBeenCalled()
fireEvent.click(turnOn())
expect(browser.notification.requestPermission).toHaveBeenCalledOnce()
expect(turnOn()).toBeDisabled()
expect(later()).toBeDisabled()
expect(await screen.findByRole('status')).toHaveTextContent('Notifications are on for this device.')
expect(browser.pushManager.subscribe).toHaveBeenCalledOnce()
expect(server.subscriptions).toEqual([expect.objectContaining({ endpoint: PUSH_ENDPOINT })])
await finishFromResult(server)
expect(browser.notification.requestPermission).toHaveBeenCalledOnce()
})
it('"Turn on notifications" says the browser blocked them, and how to allow them later', async () => {
const browser = stubPushEnvironment({ permission: 'default', answer: 'denied' })
const server = await renderStep()
fireEvent.click(turnOn())
const status = await screen.findByRole('status')
expect(status).toHaveTextContent("Notifications aren't on: this browser blocked them.")
expect(status).toHaveTextContent("allow notifications for this site in the browser's site settings")
expect(status).toHaveTextContent('turn them on in Settings under Notifications')
expect(browser.pushManager.subscribe).not.toHaveBeenCalled()
expect(server.subscriptions).toEqual([])
await finishFromResult(server)
})
it('"Turn on notifications" says blocked without asking again when the browser already denied them', async () => {
const browser = stubPushEnvironment({ permission: 'denied' })
const server = await renderStep()
fireEvent.click(turnOn())
expect(await screen.findByRole('status')).toHaveTextContent("Notifications aren't on: this browser blocked them.")
expect(browser.notification.requestPermission).not.toHaveBeenCalled()
await finishFromResult(server)
})
it('"Turn on notifications" says plainly that a browser without Web Push turned nothing on', async () => {
// The app as a whole: jsdom has no service worker or PushManager.
const server = await renderApp()
fireEvent.click(turnOn())
expect(await screen.findByRole('status')).toHaveTextContent(
"Notifications aren't on yet: MealQuest can't send them until the notification feature ships, so nothing was turned on.",
"Notifications aren't on: this browser can't receive them, so nothing was turned on.",
)
// Not a silent success: onboarding is not finished behind the household's back.
expect(server.held).toHaveLength(0)
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Turn on notifications' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Maybe later' })).not.toBeInTheDocument()
const finish = screen.getByRole('button', { name: 'Finish setting up' })
expectMinHitArea(finish)
fireEvent.click(finish)
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()
await finishFromResult(server)
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
expect(requestPermission).not.toHaveBeenCalled()
})
it('"Turn on notifications" says the browser blocked them when denied, then finishes on request', async () => {
const server = await renderStep(() => Promise.resolve('denied'))
it('"Turn on notifications" in an iOS Safari tab says to add MealQuest to the Home Screen first', async () => {
useIphoneSafariTab()
const server = await renderStep()
fireEvent.click(turnOn())
expect(await screen.findByRole('status')).toHaveTextContent("Notifications aren't on: this browser blocked them.")
expect(server.held).toHaveLength(0)
fireEvent.click(screen.getByRole('button', { name: 'Finish setting up' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
completed(server.held[0]!)
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
expect(await screen.findByRole('status')).toHaveTextContent(IOS_UNSUPPORTED)
expect(screen.getByRole('note')).toHaveTextContent('Tap Share, then Add to Home Screen to get notifications')
expect(requestPermission).not.toHaveBeenCalled()
await finishFromResult(server)
})
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
}),
it('"Turn on notifications" in another iOS browser also says to add MealQuest to the Home Screen first', async () => {
useIphoneTab(IPHONE_CHROME)
const server = await renderStep()
fireEvent.click(turnOn())
expect(await screen.findByRole('status')).toHaveTextContent(IOS_UNSUPPORTED)
expect(requestPermission).not.toHaveBeenCalled()
await finishFromResult(server)
})
it('"Turn on notifications" can ask again after the prompt is dismissed', async () => {
const browser = stubPushEnvironment({ permission: 'default', answer: 'default' })
const server = await renderStep()
fireEvent.click(turnOn())
expect(await screen.findByRole('status')).toHaveTextContent(
'The permission prompt was closed without an answer, so notifications are still off.',
)
const server = await renderStep(enable)
expect(enable).not.toHaveBeenCalled()
expect(server.held).toHaveLength(0)
await vi.waitFor(() => expect(turnOn()).toBeEnabled())
expect(later()).toBeEnabled()
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()
await vi.waitFor(() => expect(browser.notification.requestPermission).toHaveBeenCalledTimes(2))
})
it('"Maybe later" does not ask the injected enableNotifications', async () => {
const enable = vi.fn<EnableNotifications>(() => Promise.resolve('enabled'))
const server = await renderStep(enable)
it('"Maybe later" finishes without asking for permission or subscribing', async () => {
const browser = stubPushEnvironment({ permission: 'default', answer: 'granted' })
const server = await renderStep()
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()
expect(browser.notification.requestPermission).not.toHaveBeenCalled()
expect(browser.pushManager.subscribe).not.toHaveBeenCalled()
expect(server.subscriptions).toEqual([])
})
it('says why and stays when enableNotifications fails', async () => {
const server = await renderStep(() => Promise.reject(new Error('push service down')))
it('says why and stays when turning on fails', async () => {
stubPushEnvironment({ permission: 'granted' })
const server = await renderStep(() => errorResponse(503, 'push_unavailable'))
fireEvent.click(turnOn())
expect(await screen.findByRole('alert')).toHaveTextContent('Could not turn on notifications: push service down')
expect(await screen.findByRole('alert')).toHaveTextContent('Could not turn on notifications: push_unavailable message')
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(server.held).toHaveLength(0)
expect(turnOn()).toBeEnabled()
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
})
it('refuses to ask for permission outside a tap, and says so', async () => {
const browser = stubPushEnvironment({ permission: 'default' })
Object.defineProperty(navigator, 'userActivation', { configurable: true, value: { isActive: false } })
await renderStep()
fireEvent.click(turnOn())
expect(await screen.findByRole('alert')).toHaveTextContent(
'Could not turn on notifications: subscribePush: notification permission can only be asked for from a tap on a button',
)
expect(browser.notification.requestPermission).not.toHaveBeenCalled()
})
it('says why and stays when the server refuses to finish onboarding, then finishes on retry', async () => {
const server = await renderApp()
fireEvent.click(later())
@@ -317,10 +396,3 @@ describe('onboarding step 3 with too few Regulars', () => {
expect(screen.queryByRole('button', { name: 'Add Regulars' })).not.toBeInTheDocument()
})
})
describe('notificationsUnavailable', () => {
it('answers "unavailable" without asking for permission', async () => {
await expect(notificationsUnavailable()).resolves.toBe('unavailable')
expect(requestPermission).not.toHaveBeenCalled()
})
})
+47 -45
View File
@@ -1,32 +1,38 @@
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { CalendarDays, ShoppingBasket, Sparkles, UtensilsCrossed, type LucideIcon } from 'lucide-react'
import { useNavigate } from 'react-router'
import { InstallHint } from '../pwa/InstallHint.tsx'
import { InstallHint, isIos, isStandalone } from '../pwa/InstallHint.tsx'
import type { SubscribePushResult } from '../push/subscribe.ts'
import { usePush } from '../push/usePush.ts'
import { PUSH_STATUS_KEY } from '../settings/notificationsApi.ts'
import { needsMoreRegulars, 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'
// IOS_UNSUPPORTED says how to get notifications on iPhone and iPad, where
// Web Push reaches only the app installed to the Home Screen, whichever
// browser the page is open in.
const IOS_UNSUPPORTED =
"Notifications aren't on: on iPhone and iPad they only work once MealQuest is on the Home Screen. Tap Share, then Add to Home Screen, open MealQuest from there, and turn them on in Settings under Notifications."
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. This placeholder
// is the interim state: "Turn on notifications" answers 'unavailable', and
// the step tells the household plainly that nothing was turned on.
export function notificationsUnavailable(): Promise<NotificationsResult> {
return Promise.resolve('unavailable')
}
// notOnReasons says why notifications are not on after asking, for each
// result other than 'enabled'.
const notOnReasons: Record<Exclude<NotificationsResult, 'enabled'>, string> = {
unavailable:
"Notifications aren't on yet: MealQuest can't send them until the notification feature ships, so nothing was turned on. You can finish setting up now.",
denied: "Notifications aren't on: this browser blocked them. You can allow them in the browser's settings later.",
// resultText says how turning on notifications ended. 'dismissed' leaves the
// choice open, so it is the only result that keeps "Turn on notifications".
function resultText(result: SubscribePushResult): string {
switch (result) {
case 'subscribed':
return "Notifications are on for this device. MealQuest will let the household know when there's something to look at."
case 'denied':
return "Notifications aren't on: this browser blocked them. To turn them on later, allow notifications for this site in the browser's site settings, then turn them on in Settings under Notifications."
case 'unsupported':
return isIos(navigator) && !isStandalone()
? IOS_UNSUPPORTED
: "Notifications aren't on: this browser can't receive them, so nothing was turned on."
case 'dismissed':
return "The permission prompt was closed without an answer, so notifications are still off. Turn them on to be asked again, or choose Maybe later."
}
}
const topics: { icon: LucideIcon; name: string; meaning: string }[] = [
@@ -52,27 +58,26 @@ const topics: { icon: LucideIcon; name: string; meaning: string }[] = [
},
]
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. "Maybe later", and "Turn on
// notifications" once they are enabled, record that onboarding is finished
// and go Home. When they are not enabled, the step says so and offers only
// "Finish setting up". When the server refuses because the household has
// fewer than three Regulars, the reason shows with a way back to step 2 to
// add them.
export function NotificationsStep({ enableNotifications = notificationsUnavailable }: NotificationsStepProps) {
// iOS install hint, and finishing onboarding. "Turn on notifications" turns
// on Web Push on this device from that tap — the only place onboarding asks
// for permission — and says how it ended: on, blocked with how to allow it
// later, or not supported with how to get it on iPhone. Then only "Finish
// setting up" is offered, except after a dismissed prompt, which can be
// asked again. "Maybe later" finishes without asking. When the server
// refuses to finish because the household has fewer than three Regulars,
// the reason shows with a way back to step 2 to add them.
export function NotificationsStep() {
const navigate = useNavigate()
const push = usePush()
const queryClient = useQueryClient()
const complete = useCompleteOnboarding()
const [enabling, setEnabling] = useState(false)
const [problem, setProblem] = useState<string | null>(null)
const [fewRegulars, setFewRegulars] = useState(false)
const [notOn, setNotOn] = useState<string | null>(null)
const [result, setResult] = useState<SubscribePushResult | null>(null)
const busy = enabling || complete.isPending
const decided = result !== null && result !== 'dismissed'
async function finish() {
try {
@@ -90,23 +95,20 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
setFewRegulars(false)
}
// turnOn calls subscribePush straight from the tap, before anything is
// awaited, so the browser lets it ask for permission.
async function turnOn() {
clearProblem()
setResult(null)
setEnabling(true)
let result: NotificationsResult
try {
result = await enableNotifications()
setResult(await push.subscribePush())
} catch (error) {
setProblem(`Could not turn on notifications: ${(error as Error).message}`)
return
} finally {
setEnabling(false)
void queryClient.invalidateQueries({ queryKey: PUSH_STATUS_KEY })
}
if (result !== 'enabled') {
setNotOn(notOnReasons[result])
return
}
await finish()
}
function later() {
@@ -119,7 +121,7 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
step={3}
title="Stay in the loop"
action={
notOn ? (
decided ? (
<div className={styles.actions}>
<Button className={styles.action} disabled={busy} onClick={later}>
Finish setting up
@@ -154,9 +156,9 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
<div className={styles.hint}>
<InstallHint />
</div>
{notOn && (
<p role="status" className={styles.notOn}>
{notOn}
{result !== null && (
<p role="status" className={styles.result}>
{resultText(result)}
</p>
)}
{problem && (
+2 -2
View File
@@ -229,8 +229,8 @@
color: var(--color-accent-700);
}
/* Step 3: why notifications are not on after asking for them. */
.notOn {
/* Step 3: whether notifications are on after asking for them, and why not. */
.result {
margin: var(--space-4) 0 0;
padding: var(--space-4);
border-radius: var(--radius-lg);
-65
View File
@@ -258,75 +258,10 @@
color: var(--color-neutral-700);
}
/* An action with nothing behind it yet (Send to Google Chat until Feature
19): it keeps focus, so hovering, tapping or focusing it shows the
tooltip. */
.unavailable {
position: relative;
display: flex;
flex-direction: column;
}
.pushButton {
min-height: 50px;
}
.unavailable [aria-disabled='true'] {
opacity: 0.45;
cursor: not-allowed;
}
/* Unavailable, it neither darkens nor presses. */
.unavailable .primary[aria-disabled='true']:hover,
.unavailable .primary[aria-disabled='true']:active {
background: var(--color-accent);
transform: none;
}
.unavailable .outline[aria-disabled='true']:hover,
.unavailable .outline[aria-disabled='true']:active {
background: transparent;
transform: none;
}
.hint {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: 100%;
padding: 8px 12px;
border-radius: var(--radius-md);
background: var(--color-text);
color: var(--color-bg);
font-family: var(--font-body);
font-size: 13px;
line-height: 1.4;
text-align: center;
box-shadow: var(--shadow-md);
visibility: hidden;
pointer-events: none;
}
/* In the plan manager's header the tooltip opens below, at the action's
right edge. */
.below .hint {
top: calc(100% + 8px);
bottom: auto;
left: auto;
right: 0;
transform: none;
width: 240px;
max-width: none;
z-index: 1;
}
.unavailable:hover .hint,
.unavailable [aria-disabled='true']:focus + .hint {
visibility: visible;
}
/* The plan manager's header: kicker and title beside the week buttons, over
the sync status and the actions. */
.wideHeader {
+1
View File
@@ -215,6 +215,7 @@ function supplyRun(needed: number) {
title: null,
status: 'current',
sent_to_chat_at: null,
chat_send_unconfirmed_at: null,
needed_count: needed,
total_count: 32,
groups: [],
+2 -36
View File
@@ -1,6 +1,6 @@
import { useQueryClient } from '@tanstack/react-query'
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router'
import { ApiError, useJob } from '../api/index.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
@@ -9,7 +9,7 @@ import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button, type ButtonVariant } from '../ui/Button.tsx'
import { Button } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
@@ -663,40 +663,6 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
)
}
// Unavailable is an action with nothing behind it yet (Send to Google
// Chat): it stays focusable, so a tap or focus shows the tooltip saying
// why, above it or, at the top of the page, below.
export function Unavailable({
hint,
variant = 'primary',
below = false,
className,
children,
}: {
hint: string
variant?: ButtonVariant
below?: boolean
className?: string
children: ReactNode
}) {
const hintId = useId()
return (
<div className={[styles.unavailable, below && styles.below].filter(Boolean).join(' ')}>
<Button
variant={variant}
className={[className, styles[variant]].filter(Boolean).join(' ')}
aria-disabled="true"
aria-describedby={hintId}
>
{children}
</Button>
<span id={hintId} role="tooltip" className={styles.hint}>
{hint}
</span>
</div>
)
}
// PlanPage is the Draft board on the week chosen with the previous / next
// week buttons: mock 1f on mobile; from tablet up the plan manager, with the
// Quest log as a strip under the grid on tablet (2c) and the Quest log and
+231
View File
@@ -0,0 +1,231 @@
import { describe, expect, it, vi } from 'vitest'
import {
APP_BADGE,
APP_ICON,
GENERIC_NOTICE,
appPath,
clickPath,
openNotification,
parsePushPayload,
planClick,
showPush,
type ClickWindow,
} from './notification.ts'
const origin = 'https://mealquest.example'
describe('appPath', () => {
it('keeps a same-origin path with its query and fragment', () => {
expect(appPath('/plan/shopping?week=2026-10-05#tuesday', origin)).toBe('/plan/shopping?week=2026-10-05#tuesday')
})
it.each([
'https://evil.example/plan',
'//evil.example/plan',
'/\\evil.example/plan',
'javascript:alert(1)',
'plan',
'',
])('refuses %s', (raw) => {
expect(appPath(raw, origin)).toBeNull()
})
it('resolves dot segments and keeps the result on origin', () => {
expect(appPath('/plan/../discover', origin)).toBe('/discover')
})
// Each of these starts with a single / but resolves to a path starting with
// //, which a browser reads as another host.
it.each([
'/..//evil.example/path',
'/plan/..//evil.example/path',
'/..\\\\evil.example/path',
'/./\\evil.example',
'/.\\/evil.example',
'/%2e%2e//evil.example/path',
'/.\t.//evil.example/path',
'/..\n//evil.example',
])('refuses %j, which resolves to a protocol-relative path', (raw) => {
expect(appPath(raw, origin)).toBeNull()
})
})
describe('parsePushPayload', () => {
it('reads title, body, tag and url', () => {
const text = JSON.stringify({ title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' })
expect(parsePushPayload(text, origin)).toEqual({
notice: { title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' },
problem: null,
})
})
it('opens home with an empty body and no tag when only a title is sent', () => {
expect(parsePushPayload(JSON.stringify({ title: 'Hi' }), origin)).toEqual({
notice: { title: 'Hi', body: '', url: '/' },
problem: null,
})
})
it('ignores fields it does not know', () => {
const parsed = parsePushPayload(JSON.stringify({ title: 'Hi', event: 'plan_ready' }), origin)
expect(parsed.problem).toBeNull()
expect(parsed.notice).toEqual({ title: 'Hi', body: '', url: '/' })
})
it.each([
[null, 'push message has no payload'],
['', 'push message has no payload'],
['\u0000ÿ not json', 'push payload is not JSON'],
['[1,2]', 'push payload is not a JSON object'],
['null', 'push payload is not a JSON object'],
['{"body":"no title"}', 'push payload has no title'],
['{"title":" "}', 'push payload has no title'],
['{"title":42}', 'push payload has no title'],
['{"title":"Hi","body":7}', 'push payload body is not a string'],
['{"title":"Hi","tag":""}', 'push payload tag is not a non-empty string'],
['{"title":"Hi","url":"https://evil.example/"}', 'push payload url is not a same-origin path'],
['{"title":"Hi","url":5}', 'push payload url is not a same-origin path'],
])('shows the generic notice for %j', (text, problem) => {
expect(parsePushPayload(text, origin)).toEqual({ notice: GENERIC_NOTICE, problem })
})
})
describe('showPush', () => {
it('shows the payload with the app icon and badge, its tag and its url as data', async () => {
const registration = { showNotification: vi.fn(async () => {}) }
const warn = vi.fn()
await showPush(
JSON.stringify({ title: 'Supply run ready', body: 'Time to shop.', tag: 'list_ready', url: '/plan/shopping' }),
registration,
origin,
warn,
)
expect(registration.showNotification).toHaveBeenCalledWith('Supply run ready', {
body: 'Time to shop.',
tag: 'list_ready',
icon: APP_ICON,
badge: APP_BADGE,
data: { url: '/plan/shopping' },
})
expect(warn).not.toHaveBeenCalled()
})
it('shows the generic notification for a malformed payload and logs why, without throwing', async () => {
const registration = { showNotification: vi.fn(async () => {}) }
const warn = vi.fn()
await expect(showPush('{not json', registration, origin, warn)).resolves.toBeUndefined()
expect(registration.showNotification).toHaveBeenCalledWith('MealQuest', {
body: GENERIC_NOTICE.body,
icon: APP_ICON,
badge: APP_BADGE,
data: { url: '/' },
})
expect(warn).toHaveBeenCalledWith('MealQuest: push payload is not JSON; showing the generic notification')
})
})
describe('clickPath', () => {
it('opens the url the notification carries', () => {
expect(clickPath({ url: '/discover' }, origin)).toBe('/discover')
})
it.each([undefined, null, 'x', {}, { url: 'https://evil.example/' }])('opens home for data %j', (data) => {
expect(clickPath(data, origin)).toBe('/')
})
})
describe('planClick', () => {
it('refuses a path that resolves off origin', () => {
expect(() => planClick([], '//evil.example/path', origin)).toThrow(/resolves off origin/)
})
const at = (path: string, focused = false) => ({ url: origin + path, focused })
it('opens a new window when no MealQuest window is open', () => {
expect(planClick([], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` })
})
it('ignores windows on another origin', () => {
const other = { url: 'https://other.example/plan', focused: true }
expect(planClick([other], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` })
})
it('focuses the focused MealQuest window and navigates it', () => {
const first = at('/')
const focused = at('/discover', true)
expect(planClick([first, focused], '/plan', origin)).toEqual({
action: 'focus',
window: focused,
url: `${origin}/plan`,
navigate: true,
})
})
it('focuses the first MealQuest window when none is focused', () => {
const first = at('/')
expect(planClick([first, at('/discover')], '/plan', origin)).toMatchObject({ action: 'focus', window: first })
})
it('does not reload a window already at the url', () => {
expect(planClick([at('/plan')], '/plan', origin)).toMatchObject({ action: 'focus', navigate: false })
})
})
describe('openNotification', () => {
function window(path: string, focused = false) {
return {
url: origin + path,
focused,
focus: vi.fn(async () => {}),
navigate: vi.fn(async () => {}),
} satisfies ClickWindow
}
function clients(windows: ClickWindow[]) {
return { matchAll: vi.fn(async () => windows), openWindow: vi.fn(async () => null) }
}
it('focuses an open window, then navigates it to the url', async () => {
const win = window('/', true)
const all = clients([win])
await openNotification({ url: '/plan/shopping' }, all, origin)
expect(all.matchAll).toHaveBeenCalledWith({ type: 'window' })
expect(win.focus).toHaveBeenCalled()
expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`)
expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!)
expect(all.openWindow).not.toHaveBeenCalled()
})
it('opens a new window at the url when none is open', async () => {
const all = clients([])
await openNotification({ url: '/discover' }, all, origin)
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/discover`)
})
it('never opens a url off origin, even from tampered data', async () => {
const all = clients([])
await openNotification({ url: 'https://evil.example/' }, all, origin)
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/`)
})
it.each(['/..//evil.example/path', '/..\\\\evil.example/path', '/%2e%2e//evil.example/path', '/.\t.//evil.example/path'])(
'never leaves origin for %j, which resolves to a protocol-relative path',
async (url) => {
const win = window('/plan', true)
const all = clients([win])
await openNotification({ url }, all, origin)
expect(win.navigate).toHaveBeenCalledWith(`${origin}/`)
expect(all.openWindow).not.toHaveBeenCalled()
const none = clients([])
await openNotification({ url }, none, origin)
expect(none.openWindow).toHaveBeenCalledWith(`${origin}/`)
},
)
it('still opens an internal path that uses dot segments', async () => {
const all = clients([])
await openNotification({ url: '/plan/../discover' }, all, origin)
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/discover`)
})
})
+174
View File
@@ -0,0 +1,174 @@
// Web Push notifications as the service worker shows them, and where a tap on
// one takes the household. The worker's push and notificationclick listeners
// (src/sw.ts) only adapt these to the worker's globals, so every decision runs
// under test without a real ServiceWorkerGlobalScope. This file is checked
// with both the DOM and the WebWorker libraries, so it names only the narrow
// shapes it uses.
// APP_ICON and APP_BADGE are the app icon from the web manifest.
export const APP_ICON = '/icons/icon-192.png'
export const APP_BADGE = '/icons/icon-192.png'
// PushNotice is a notification to show: its title, body and tag, and the
// same-origin path a tap opens.
export type PushNotice = { title: string; body: string; tag?: string; url: string }
// GENERIC_NOTICE is shown for a push whose payload is not a notice: the
// household still learns that MealQuest has something for them.
export const GENERIC_NOTICE: PushNotice = {
title: 'MealQuest',
body: "There's something new in MealQuest.",
url: '/',
}
// ParsedPush is the notice to show for a push, and why its payload was not
// used, when it was not.
export type ParsedPush = { notice: PushNotice; problem: string | null }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
// appPath returns raw as a path on origin (path, query and fragment), or null
// when raw is not a path-absolute URL that stays on origin. A payload never
// sends the household to another site. The path is checked after the parser
// has resolved it: dot segments (also as %2e), backslashes and stripped tabs
// and newlines can all turn an innocent-looking path into one starting with
// //, which a browser would read as another host.
export function appPath(raw: string, origin: string): string | null {
if (!raw.startsWith('/') || raw.startsWith('//')) return null
let url: URL
try {
url = new URL(raw, origin)
} catch {
return null
}
if (url.origin !== origin || url.pathname.startsWith('//')) return null
return url.pathname + url.search + url.hash
}
// parsePushPayload reads a push message's payload, which may be any bytes:
// a JSON object with a non-empty string title, and optionally a string body,
// a non-empty string tag and a url that is a same-origin path. Other fields
// are ignored. Anything else — no payload, not JSON, a missing title, a
// field of the wrong type, a url off origin — is shown as GENERIC_NOTICE,
// with the problem for the worker to log. It never throws.
export function parsePushPayload(text: string | null, origin: string): ParsedPush {
const generic = (problem: string): ParsedPush => ({ notice: GENERIC_NOTICE, problem })
if (text === null || text === '') return generic('push message has no payload')
let payload: unknown
try {
payload = JSON.parse(text)
} catch {
return generic('push payload is not JSON')
}
if (!isRecord(payload)) return generic('push payload is not a JSON object')
const { title, body, tag, url } = payload
if (typeof title !== 'string' || title.trim() === '') return generic('push payload has no title')
if (body !== undefined && typeof body !== 'string') return generic('push payload body is not a string')
if (tag !== undefined && (typeof tag !== 'string' || tag === '')) {
return generic('push payload tag is not a non-empty string')
}
let path = '/'
if (url !== undefined) {
const safe = typeof url === 'string' ? appPath(url, origin) : null
if (safe === null) return generic('push payload url is not a same-origin path')
path = safe
}
const notice: PushNotice = { title, body: body ?? '', url: path }
if (tag !== undefined) notice.tag = tag
return { notice, problem: null }
}
// NoticeData is the data a shown notification carries for its click.
export type NoticeData = { url: string }
// notificationOptions is what showNotification is given for notice: its body
// and tag, the app icon and badge, and the path to open as its data.
export function notificationOptions(notice: PushNotice): NotificationOptions & { data: NoticeData } {
const options: NotificationOptions & { data: NoticeData } = {
body: notice.body,
icon: APP_ICON,
badge: APP_BADGE,
data: { url: notice.url },
}
if (notice.tag !== undefined) options.tag = notice.tag
return options
}
export type NotificationShower = {
showNotification(title: string, options: NotificationOptions): Promise<void>
}
// showPush shows the notification for a push message's payload text,
// reporting through warn why a payload was not used.
export function showPush(
text: string | null,
registration: NotificationShower,
origin: string,
warn: (message: string) => void,
): Promise<void> {
const { notice, problem } = parsePushPayload(text, origin)
if (problem !== null) warn(`MealQuest: ${problem}; showing the generic notification`)
return registration.showNotification(notice.title, notificationOptions(notice))
}
// clickPath is the same-origin path a tapped notification opens: the url in
// its data, which showPush put there, or the app's home when the data is not
// ours (a notification shown by an older worker).
export function clickPath(data: unknown, origin: string): string {
if (isRecord(data) && typeof data.url === 'string') {
const path = appPath(data.url, origin)
if (path !== null) return path
}
return '/'
}
// AppWindow is the part of a window client a click reads.
export type AppWindow = { url: string; focused: boolean }
// ClickPlan is what a notification tap does: focus an open MealQuest window
// and navigate it to url, or open a new window at url. url is absolute.
export type ClickPlan<W> = { action: 'focus'; window: W; url: string; navigate: boolean } | { action: 'open'; url: string }
// planClick decides a tap on a notification that opens path: the focused
// MealQuest window if there is one, else the first open one, navigated to
// path unless it is already there; with none open, a new window. A path that
// resolves off origin is an error: a tap never leaves MealQuest.
export function planClick<W extends AppWindow>(windows: readonly W[], path: string, origin: string): ClickPlan<W> {
const resolved = new URL(path, origin)
if (resolved.origin !== origin) {
throw new Error(`MealQuest: notification path ${JSON.stringify(path)} resolves off origin to ${resolved.origin}`)
}
const url = resolved.href
const ours = windows.filter((w) => URL.canParse(w.url) && new URL(w.url).origin === origin)
const target = ours.find((w) => w.focused) ?? ours[0]
if (target === undefined) return { action: 'open', url }
return { action: 'focus', window: target, url, navigate: target.url !== url }
}
export type ClickWindow = AppWindow & {
focus(): Promise<unknown>
navigate(url: string): Promise<unknown>
}
export type ClickClients<W extends ClickWindow> = {
matchAll(options: { type: 'window' }): Promise<readonly W[]>
openWindow(url: string): Promise<unknown>
}
// openNotification carries out a tap on a notification with data: focus
// first, while the tap still allows it, then navigate.
export async function openNotification<W extends ClickWindow>(
data: unknown,
clients: ClickClients<W>,
origin: string,
): Promise<void> {
const plan = planClick(await clients.matchAll({ type: 'window' }), clickPath(data, origin), origin)
if (plan.action === 'open') {
await clients.openWindow(plan.url)
return
}
await plan.window.focus()
if (plan.navigate) await plan.window.navigate(plan.url)
}
+337
View File
@@ -0,0 +1,337 @@
import { describe, expect, it, vi } from 'vitest'
import { ApiError, type ApiRequest } from '../api/index.ts'
import {
SUBSCRIPTIONS_PATH,
VAPID_PUBLIC_KEY_PATH,
decodeVapidKey,
encodeBase64url,
PermissionGestureError,
pushStatus,
subscribePush,
subscriptionBody,
unsubscribePush,
type BrowserPushSubscription,
type PushEnvironment,
} from './subscribe.ts'
function bytes(length: number, first: number): Uint8Array<ArrayBuffer> {
const out = new Uint8Array(new ArrayBuffer(length))
for (let i = 0; i < length; i++) out[i] = (first + i) % 256
return out
}
const vapidKey = bytes(65, 4)
const vapidKeyText = encodeBase64url(vapidKey.buffer)
const p256dh = bytes(65, 4).buffer
const auth = bytes(16, 200).buffer
const endpoint = 'https://push.example/send/abc'
function subscription(key: ArrayBuffer | null = vapidKey.buffer, at = endpoint) {
return {
endpoint: at,
options: { applicationServerKey: key },
getKey: (name: 'p256dh' | 'auth') => (name === 'p256dh' ? p256dh : auth),
unsubscribe: vi.fn(async () => true),
} satisfies BrowserPushSubscription
}
type Setup = {
permission?: NotificationPermission
answer?: NotificationPermission
active?: boolean
current?: BrowserPushSubscription | null
registered?: boolean
subscribeError?: unknown
}
function setup({
permission = 'default',
answer = 'granted',
active = true,
current = null,
registered = true,
subscribeError,
}: Setup = {}) {
const created = subscription()
const pushManager = {
getSubscription: vi.fn(async () => current),
subscribe: vi.fn(async () => {
if (subscribeError !== undefined) throw subscribeError
return created
}),
}
const notification = { permission, requestPermission: vi.fn(async () => answer) }
const env: PushEnvironment = {
serviceWorker: { getRegistration: vi.fn(async () => (registered ? { pushManager } : undefined)) },
pushManager: true,
notification,
userActivation: { isActive: active },
userAgent: 'Mozilla/5.0 Test',
}
const calls: { path: string; request?: ApiRequest }[] = []
const api = {
apiFetch: vi.fn(async (path: string, request?: ApiRequest) => {
calls.push({ path, request })
if (path === VAPID_PUBLIC_KEY_PATH) return { public_key: vapidKeyText }
return undefined
}) as <T>(path: string, request?: ApiRequest) => Promise<T>,
}
return { env, api, calls, pushManager, notification, created }
}
describe('decodeVapidKey', () => {
it('decodes the unpadded base64url point', () => {
expect(decodeVapidKey(vapidKeyText)).toEqual(vapidKey)
})
it.each(['', 'abc=', 'not base64!', encodeBase64url(bytes(64, 4).buffer), encodeBase64url(bytes(65, 2).buffer)])(
'refuses %j',
(key) => {
expect(() => decodeVapidKey(key)).toThrow(/65-byte uncompressed P-256 point/)
},
)
})
describe('subscriptionBody', () => {
it('sends exactly the endpoint, the keys in unpadded base64url and the user agent', () => {
const body = subscriptionBody(subscription(), 'Mozilla/5.0 Test')
expect(body).toEqual({
endpoint,
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
user_agent: 'Mozilla/5.0 Test',
})
expect(body.keys.auth).toMatch(/^[A-Za-z0-9_-]{22}$/)
expect(JSON.stringify(body)).not.toContain('expirationTime')
})
it.each(['', ' ', 'x'.repeat(513)])('leaves out a blank or too-long user agent', (userAgent) => {
expect(subscriptionBody(subscription(), userAgent)).not.toHaveProperty('user_agent')
})
it('refuses a subscription without keys', () => {
const keyless = { ...subscription(), getKey: () => null }
expect(() => subscriptionBody(keyless, 'ua')).toThrow(/without its p256dh and auth keys/)
})
})
describe('subscribePush', () => {
it.each([
['serviceWorker', { serviceWorker: undefined }],
['PushManager', { pushManager: false }],
['Notification', { notification: undefined }],
])('answers unsupported without %s, asking nothing', async (_, missing) => {
const { env, api, notification } = setup()
expect(await subscribePush(api, { ...env, ...missing })).toBe('unsupported')
expect(notification.requestPermission).not.toHaveBeenCalled()
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('asks for permission during a tap, subscribes with the server key and stores the subscription', async () => {
const { env, api, calls, pushManager, notification } = setup()
expect(await subscribePush(api, env)).toBe('subscribed')
expect(notification.requestPermission).toHaveBeenCalledTimes(1)
expect(pushManager.subscribe).toHaveBeenCalledWith({ userVisibleOnly: true, applicationServerKey: vapidKey })
expect(calls).toEqual([
{ path: VAPID_PUBLIC_KEY_PATH, request: undefined },
{
path: SUBSCRIPTIONS_PATH,
request: {
method: 'POST',
body: {
endpoint,
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
user_agent: 'Mozilla/5.0 Test',
},
},
},
])
})
it('asks for permission before awaiting anything, so the tap still counts', async () => {
const { env, api, notification } = setup()
const pending = subscribePush(api, env)
expect(notification.requestPermission).toHaveBeenCalledTimes(1)
expect(api.apiFetch).not.toHaveBeenCalled()
await pending
})
it('refuses to ask for permission outside a tap', async () => {
const { env, api, notification } = setup({ active: false })
const error = await subscribePush(api, env).catch((e: unknown) => e)
expect(error).toBeInstanceOf(PermissionGestureError)
expect(error).toMatchObject({ reason: 'inactive', message: expect.stringMatching(/only be asked for from a tap/) })
expect(notification.requestPermission).not.toHaveBeenCalled()
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('refuses to ask for permission where the browser cannot say whether a tap is active', async () => {
const { env, api, notification } = setup()
const error = await subscribePush(api, { ...env, userActivation: undefined }).catch((e: unknown) => e)
expect(error).toBeInstanceOf(PermissionGestureError)
expect(error).toMatchObject({ reason: 'unobservable', message: expect.stringMatching(/no navigator.userActivation/) })
expect(notification.requestPermission).not.toHaveBeenCalled()
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('does not need to observe a tap once permission is granted', async () => {
const { env, api, notification } = setup({ permission: 'granted' })
expect(await subscribePush(api, { ...env, userActivation: undefined })).toBe('subscribed')
expect(notification.requestPermission).not.toHaveBeenCalled()
})
it('does not ask again once permission is granted, even outside a tap', async () => {
const { env, api, notification } = setup({ permission: 'granted', active: false })
expect(await subscribePush(api, env)).toBe('subscribed')
expect(notification.requestPermission).not.toHaveBeenCalled()
})
it('reports a permission denied earlier without asking or subscribing', async () => {
const { env, api, notification, pushManager } = setup({ permission: 'denied' })
expect(await subscribePush(api, env)).toBe('denied')
expect(notification.requestPermission).not.toHaveBeenCalled()
expect(pushManager.subscribe).not.toHaveBeenCalled()
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('reports a permission denied when asked, without subscribing', async () => {
const { env, api, pushManager } = setup({ answer: 'denied' })
expect(await subscribePush(api, env)).toBe('denied')
expect(pushManager.subscribe).not.toHaveBeenCalled()
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('reports a dismissed prompt', async () => {
const { env, api, pushManager } = setup({ answer: 'default' })
expect(await subscribePush(api, env)).toBe('dismissed')
expect(pushManager.subscribe).not.toHaveBeenCalled()
})
it('reports denied when the browser refuses the subscription for permission', async () => {
const { env, api, calls } = setup({ subscribeError: new DOMException('Registration failed - permission denied', 'NotAllowedError') })
expect(await subscribePush(api, env)).toBe('denied')
expect(calls.map((c) => c.path)).toEqual([VAPID_PUBLIC_KEY_PATH])
})
it('rejects with an Error, not a raw DOMException, when the browser cannot subscribe', async () => {
const cause = new DOMException('push service not available', 'AbortError')
const { env, api } = setup({ subscribeError: cause })
const error = await subscribePush(api, env).catch((e: unknown) => e)
expect(error).not.toBeInstanceOf(DOMException)
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toBe('Web Push: the browser could not subscribe: push service not available')
expect((error as Error).cause).toBe(cause)
})
it('rejects when no service worker is registered', async () => {
const { env, api } = setup({ registered: false })
await expect(subscribePush(api, env)).rejects.toThrow(/no service worker is registered/)
})
it('stores an existing subscription made with the same key again, without resubscribing', async () => {
const current = subscription()
const { env, api, calls, pushManager } = setup({ permission: 'granted', current })
expect(await subscribePush(api, env)).toBe('subscribed')
expect(pushManager.subscribe).not.toHaveBeenCalled()
expect(current.unsubscribe).not.toHaveBeenCalled()
expect(calls.map((c) => c.request?.method ?? 'GET')).toEqual(['GET', 'POST'])
})
it('replaces a subscription made with an older key', async () => {
const old = subscription(bytes(65, 9).buffer, 'https://push.example/send/old')
const { env, api, calls, pushManager } = setup({ permission: 'granted', current: old })
expect(await subscribePush(api, env)).toBe('subscribed')
expect(old.unsubscribe).toHaveBeenCalled()
expect(pushManager.subscribe).toHaveBeenCalled()
expect(calls.map((c) => [c.request?.method ?? 'GET', c.request?.body])).toEqual([
['GET', undefined],
['DELETE', { endpoint: 'https://push.example/send/old' }],
['POST', expect.objectContaining({ endpoint })],
])
})
it('passes on an API failure storing the subscription', async () => {
const { env, api } = setup()
const refused = new ApiError(400, 'invalid_subscription', 'endpoint: want an absolute https:// URL', false)
vi.mocked(api.apiFetch).mockImplementation(async (path: string) => {
if (path === VAPID_PUBLIC_KEY_PATH) return { public_key: vapidKeyText } as never
throw refused
})
await expect(subscribePush(api, env)).rejects.toBe(refused)
})
it('rejects a VAPID key response without a public_key', async () => {
const { env, api } = setup()
vi.mocked(api.apiFetch).mockResolvedValue({} as never)
await expect(subscribePush(api, env)).rejects.toThrow(`GET ${VAPID_PUBLIC_KEY_PATH}: response has no public_key`)
})
})
describe('unsubscribePush', () => {
it('deletes the subscription on the server, then in the browser', async () => {
const current = subscription()
const { env, api, calls } = setup({ current })
expect(await unsubscribePush(api, env)).toBe('unsubscribed')
expect(calls).toEqual([{ path: SUBSCRIPTIONS_PATH, request: { method: 'DELETE', body: { endpoint } } }])
expect(current.unsubscribe).toHaveBeenCalled()
})
it('answers not-subscribed without calling the server when there is no subscription', async () => {
const { env, api } = setup({ current: null })
expect(await unsubscribePush(api, env)).toBe('not-subscribed')
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('answers not-subscribed when no service worker is registered', async () => {
const { env, api } = setup({ registered: false })
expect(await unsubscribePush(api, env)).toBe('not-subscribed')
expect(api.apiFetch).not.toHaveBeenCalled()
})
it('answers unsupported without Web Push', async () => {
const { env, api } = setup()
expect(await unsubscribePush(api, { ...env, pushManager: false })).toBe('unsupported')
})
it('keeps the browser subscribed when the server delete fails, so trying again works', async () => {
const current = subscription()
const { env, api } = setup({ current })
vi.mocked(api.apiFetch).mockRejectedValue(new TypeError('Failed to fetch'))
await expect(unsubscribePush(api, env)).rejects.toThrow('Failed to fetch')
expect(current.unsubscribe).not.toHaveBeenCalled()
})
it('rejects when the browser does not remove the subscription', async () => {
const current = subscription()
current.unsubscribe.mockResolvedValue(false)
const { env, api } = setup({ current })
await expect(unsubscribePush(api, env)).rejects.toThrow(/did not remove its push subscription/)
})
})
describe('pushStatus', () => {
it('is on with permission granted and a subscription', async () => {
const { env } = setup({ permission: 'granted', current: subscription() })
expect(await pushStatus(env)).toBe('on')
})
it.each([
['not subscribed', { permission: 'granted' as const }],
['permission not yet asked', { permission: 'default' as const }],
['no service worker registered', { permission: 'granted' as const, registered: false }],
])('is off when %s', async (_, options) => {
const { env, notification } = setup(options)
expect(await pushStatus(env)).toBe('off')
expect(notification.requestPermission).not.toHaveBeenCalled()
})
it('is denied when the browser blocks notifications', async () => {
const { env } = setup({ permission: 'denied', current: subscription() })
expect(await pushStatus(env)).toBe('denied')
})
it('is unsupported without Web Push', async () => {
const { env } = setup()
expect(await pushStatus({ ...env, pushManager: false })).toBe('unsupported')
expect(await pushStatus({ ...env, notification: undefined })).toBe('unsupported')
})
})
+227
View File
@@ -0,0 +1,227 @@
import type { ApiClient } from '../api/client.ts'
import { isRecord } from '../api/errors.ts'
export const VAPID_PUBLIC_KEY_PATH = '/api/v1/push/vapid-public-key'
export const SUBSCRIPTIONS_PATH = '/api/v1/push/subscriptions'
// MAX_USER_AGENT_BYTES is the server's limit on a subscription's user_agent
// (internal/push MaxUserAgentBytes).
export const MAX_USER_AGENT_BYTES = 512
// SubscribePushResult is how turning on Web Push on this device ended:
// subscribed, the browser's permission denied (now or earlier), the
// permission prompt dismissed without an answer, or a browser without Web
// Push. Any other failure rejects with an Error (an ApiError from the API).
export type SubscribePushResult = 'subscribed' | 'denied' | 'dismissed' | 'unsupported'
// UnsubscribePushResult is how turning off Web Push on this device ended:
// its subscription removed from the browser and the server, no subscription
// to remove, or a browser without Web Push. Any other failure rejects.
export type UnsubscribePushResult = 'unsubscribed' | 'not-subscribed' | 'unsupported'
// PushStatus is whether Web Push is on for this device: subscribed with
// permission granted, off (not subscribed, or permission not yet given),
// blocked by the browser's permission, or a browser without Web Push.
export type PushStatus = 'on' | 'off' | 'denied' | 'unsupported'
// The parts of the browser's Push API this module uses.
export type BrowserPushSubscription = {
readonly endpoint: string
readonly options: { readonly applicationServerKey: ArrayBuffer | null }
getKey(name: 'p256dh' | 'auth'): ArrayBuffer | null
unsubscribe(): Promise<boolean>
}
export type BrowserPushManager = {
getSubscription(): Promise<BrowserPushSubscription | null>
subscribe(options: { userVisibleOnly: boolean; applicationServerKey: Uint8Array<ArrayBuffer> }): Promise<BrowserPushSubscription>
}
// PushEnvironment is what the browser offers for Web Push. A field is
// undefined (or pushManager false) where the browser lacks it.
export type PushEnvironment = {
serviceWorker: { getRegistration(): Promise<{ readonly pushManager: BrowserPushManager } | undefined> } | undefined
pushManager: boolean
notification:
| { readonly permission: NotificationPermission; requestPermission(): Promise<NotificationPermission> }
| undefined
// Whether the page is handling a user gesture right now.
userActivation: { readonly isActive: boolean } | undefined
userAgent: string
}
// browserPushEnvironment reads the PushEnvironment from this window.
export function browserPushEnvironment(): PushEnvironment {
return {
serviceWorker: 'serviceWorker' in navigator ? navigator.serviceWorker : undefined,
pushManager: 'PushManager' in window,
notification: 'Notification' in window ? Notification : undefined,
userActivation: 'userActivation' in navigator ? navigator.userActivation : undefined,
userAgent: navigator.userAgent,
}
}
type PushApi = Pick<ApiClient, 'apiFetch'>
// PermissionGestureError is why subscribePush refused to ask for notification
// permission: no tap is active right now ('inactive'), or the browser cannot
// say whether one is ('unobservable', no navigator.userActivation).
export class PermissionGestureError extends Error {
readonly reason: 'inactive' | 'unobservable'
constructor(reason: 'inactive' | 'unobservable') {
super(
reason === 'inactive'
? 'subscribePush: notification permission can only be asked for from a tap on a button'
: 'subscribePush: this browser cannot report whether a tap is active (no navigator.userActivation), so notification permission is not asked for',
)
this.name = 'PermissionGestureError'
this.reason = reason
}
}
// encodeBase64url encodes bytes as unpadded base64url, as the server wants
// subscription keys.
export function encodeBase64url(bytes: ArrayBuffer): string {
let binary = ''
for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte)
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
}
// decodeVapidKey decodes the server's VAPID public key, unpadded base64url
// of the 65-byte uncompressed P-256 point, into an applicationServerKey.
export function decodeVapidKey(key: string): Uint8Array<ArrayBuffer> {
const problem = `GET ${VAPID_PUBLIC_KEY_PATH}: public_key is not the unpadded base64url of a 65-byte uncompressed P-256 point`
if (!/^[A-Za-z0-9_-]+$/.test(key) || key.length % 4 === 1) throw new Error(problem)
const binary = atob(key.replaceAll('-', '+').replaceAll('_', '/'))
const bytes = new Uint8Array(new ArrayBuffer(binary.length))
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
if (bytes.length !== 65 || bytes[0] !== 0x04) throw new Error(problem)
return bytes
}
function sameKey(current: ArrayBuffer | null, key: Uint8Array): boolean {
if (current === null || current.byteLength !== key.length) return false
const bytes = new Uint8Array(current)
return bytes.every((byte, i) => byte === key[i])
}
// SubscriptionBody is the POST /api/v1/push/subscriptions body. The server
// refuses unknown fields and nulls, so it is built field by field rather
// than from PushSubscription.toJSON(), whose expirationTime may be null.
export type SubscriptionBody = { endpoint: string; keys: { p256dh: string; auth: string }; user_agent?: string }
// subscriptionBody is the body that stores subscription. The user agent only
// labels the device, so one that is blank or over the server's limit is left
// out rather than sent to be refused.
export function subscriptionBody(subscription: BrowserPushSubscription, userAgent: string): SubscriptionBody {
const p256dh = subscription.getKey('p256dh')
const auth = subscription.getKey('auth')
if (p256dh === null || auth === null) {
throw new Error('Web Push: the browser gave a subscription without its p256dh and auth keys')
}
const body: SubscriptionBody = {
endpoint: subscription.endpoint,
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
}
if (userAgent.trim() !== '' && new TextEncoder().encode(userAgent).length <= MAX_USER_AGENT_BYTES) {
body.user_agent = userAgent
}
return body
}
async function fetchVapidKey(api: PushApi): Promise<Uint8Array<ArrayBuffer>> {
const body = await api.apiFetch<unknown>(VAPID_PUBLIC_KEY_PATH)
if (!isRecord(body) || typeof body.public_key !== 'string') {
throw new Error(`GET ${VAPID_PUBLIC_KEY_PATH}: response has no public_key`)
}
return decodeVapidKey(body.public_key)
}
// removeSubscription deletes subscription on the server, then in the
// browser. Deleting on the server first means a failure leaves both still
// subscribed, so trying again finds the subscription and finishes the job.
async function removeSubscription(api: PushApi, subscription: BrowserPushSubscription): Promise<void> {
await api.apiFetch<void>(SUBSCRIPTIONS_PATH, { method: 'DELETE', body: { endpoint: subscription.endpoint } })
if (!(await subscription.unsubscribe())) {
throw new Error('Web Push: the browser did not remove its push subscription')
}
}
function errorName(error: unknown): string | undefined {
return isRecord(error) && typeof error.name === 'string' ? error.name : undefined
}
// subscribePush turns on Web Push on this device. Call it only from the
// household's tap on a button: when the browser has not yet been asked, it
// asks for notification permission, which browsers allow only during a user
// gesture, and it refuses to ask outside one — or where the browser cannot
// say whether a gesture is active — with a PermissionGestureError. Permission
// is asked before
// anything is awaited so the gesture still counts. With permission, it
// subscribes the browser with the server's VAPID public key — replacing a
// subscription made with an older key — and stores the subscription on the
// server. Subscribing again when already subscribed stores it again.
export async function subscribePush(api: PushApi, env: PushEnvironment): Promise<SubscribePushResult> {
const { serviceWorker, notification } = env
if (serviceWorker === undefined || !env.pushManager || notification === undefined) return 'unsupported'
let permission = notification.permission
if (permission === 'default') {
if (env.userActivation === undefined) throw new PermissionGestureError('unobservable')
if (!env.userActivation.isActive) throw new PermissionGestureError('inactive')
permission = await notification.requestPermission()
}
if (permission === 'denied') return 'denied'
if (permission === 'default') return 'dismissed'
const found = await serviceWorker.getRegistration()
if (found === undefined) {
throw new Error('Web Push: no service worker is registered for this page, so it cannot receive notifications')
}
const { pushManager } = found
const key = await fetchVapidKey(api)
let subscription = await pushManager.getSubscription()
if (subscription !== null && !sameKey(subscription.options.applicationServerKey, key)) {
await removeSubscription(api, subscription)
subscription = null
}
if (subscription === null) {
try {
subscription = await pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key })
} catch (error) {
if (errorName(error) === 'NotAllowedError') return 'denied'
throw new Error(`Web Push: the browser could not subscribe: ${(error as Error).message}`, { cause: error })
}
}
await api.apiFetch(SUBSCRIPTIONS_PATH, { method: 'POST', body: subscriptionBody(subscription, env.userAgent) })
return 'subscribed'
}
// unsubscribePush turns off Web Push on this device: it removes the
// browser's subscription on the server and in the browser. With no
// subscription there is nothing to remove, and it answers 'not-subscribed'
// without calling the server.
export async function unsubscribePush(api: PushApi, env: PushEnvironment): Promise<UnsubscribePushResult> {
const { serviceWorker } = env
if (serviceWorker === undefined || !env.pushManager) return 'unsupported'
const found = await serviceWorker.getRegistration()
if (found === undefined) return 'not-subscribed'
const subscription = await found.pushManager.getSubscription()
if (subscription === null) return 'not-subscribed'
await removeSubscription(api, subscription)
return 'unsubscribed'
}
// pushStatus reads whether Web Push is on for this device, without asking for
// permission or calling the server. A page with no service worker registered
// is off: subscribePush then says why it cannot turn on.
export async function pushStatus(env: PushEnvironment): Promise<PushStatus> {
const { serviceWorker, notification } = env
if (serviceWorker === undefined || !env.pushManager || notification === undefined) return 'unsupported'
if (notification.permission === 'denied') return 'denied'
const found = await serviceWorker.getRegistration()
if (found === undefined) return 'off'
const subscription = await found.pushManager.getSubscription()
return subscription !== null && notification.permission === 'granted' ? 'on' : 'off'
}
+34
View File
@@ -0,0 +1,34 @@
import { useMemo } from 'react'
import { useApi } from '../api/ApiContext.tsx'
import {
browserPushEnvironment,
pushStatus,
subscribePush,
unsubscribePush,
type PushStatus,
type SubscribePushResult,
type UnsubscribePushResult,
} from './subscribe.ts'
export type Push = {
// pushStatus reads whether Web Push is on for this device.
pushStatus(): Promise<PushStatus>
// subscribePush turns on Web Push on this device; call it only from a tap.
subscribePush(): Promise<SubscribePushResult>
// unsubscribePush turns off Web Push on this device.
unsubscribePush(): Promise<UnsubscribePushResult>
}
// usePush binds pushStatus, subscribePush and unsubscribePush to the app's
// API client and this browser.
export function usePush(): Push {
const api = useApi()
return useMemo(
() => ({
pushStatus: () => pushStatus(browserPushEnvironment()),
subscribePush: () => subscribePush(api, browserPushEnvironment()),
unsubscribePush: () => unsubscribePush(api, browserPushEnvironment()),
}),
[api],
)
}
+11 -6
View File
@@ -16,13 +16,18 @@ export const DISMISSED_KEY = 'mealquest.installHint.dismissed'
// ...) or drops Version/ or Safari/, so only this exact tail is Safari.
const SAFARI_TAIL = /\(KHTML, like Gecko\) Version\/[\d.]+ (?:Mobile\/\w+ )?Safari\/[\d.]+$/
// isIosSafari reports whether the page is open in Safari on iPhone, iPod or
// iPad. iPadOS 13+ asks for the desktop site and reports itself as a Mac, so
// a "Macintosh" with a touch screen is an iPad.
export function isIosSafari(nav: Pick<Navigator, 'userAgent' | 'maxTouchPoints'>): boolean {
// isIos reports whether the page is open on iPhone, iPod or iPad, in any
// browser. iPadOS 13+ asks for the desktop site and reports itself as a Mac,
// so a "Macintosh" with a touch screen is an iPad.
export function isIos(nav: Pick<Navigator, 'userAgent' | 'maxTouchPoints'>): boolean {
const ua = nav.userAgent
const ios = /iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && nav.maxTouchPoints > 1)
return ios && SAFARI_TAIL.test(ua)
return /iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && nav.maxTouchPoints > 1)
}
// isIosSafari reports whether the page is open in Safari on iPhone, iPod or
// iPad.
function isIosSafari(nav: Pick<Navigator, 'userAgent' | 'maxTouchPoints'>): boolean {
return isIos(nav) && SAFARI_TAIL.test(nav.userAgent)
}
// isStandalone reports whether the app is running from the Home Screen.
@@ -91,8 +91,24 @@ type HouseholdSettingsFormProps = HouseholdSettingsProps & {
saved: HouseholdSettings
}
// rebase moves draft from before to saved, the settings now stored: a field
// the household has not edited takes its stored value, so a change saved
// elsewhere (the Notifications screen's reminder time) is shown, not undone.
function rebase(before: HouseholdSettings, saved: HouseholdSettings, draft: HouseholdSettings): HouseholdSettings {
const next: Record<string, unknown> = { ...draft }
for (const field of Object.keys(saved) as (keyof HouseholdSettings)[]) {
if (draft[field] === before[field]) next[field] = saved[field]
}
return next as HouseholdSettings
}
function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormProps) {
const [draft, setDraft] = useState(saved)
const [base, setBase] = useState(saved)
if (base !== saved) {
setBase(saved)
setDraft(rebase(base, saved, draft))
}
const save = useSaveHouseholdSettings()
const patch = patchOf(saved, draft)
const dirty = Object.keys(patch).length > 0
@@ -0,0 +1,289 @@
/* Notifications & Google Chat, handoff mock 1n; its cards also sit in the
desktop settings' left column (mock 2e). */
.page {
box-sizing: border-box;
max-width: 560px;
display: flex;
flex-direction: column;
gap: 22px;
padding-top: 6px;
}
.header {
display: flex;
align-items: center;
gap: 16px;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 32px;
font-weight: 400;
}
.loading {
height: 260px;
border-radius: var(--radius-lg);
}
.card {
display: flex;
flex-direction: column;
gap: 14px;
}
.cardTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.cardLoading {
height: 120px;
border-radius: var(--radius-lg);
}
/* Event | PUSH | CHAT, the channel columns 46 px wide. */
.matrix {
display: grid;
grid-template-columns: 1fr 46px 46px;
align-items: center;
row-gap: 18px;
}
.head {
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-neutral-700);
}
.head:not(:first-child) {
justify-self: center;
}
.event {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
}
.eventName {
font-family: var(--font-body);
font-size: 16px;
font-weight: 700;
}
.toggle {
justify-self: center;
}
/* The reminder time: tappable text with a 44 px hit box. */
.time {
min-width: 44px;
min-height: 44px;
margin: -10px 0 -12px;
padding: 0;
border: 0;
background: transparent;
color: var(--color-neutral-700);
font-family: var(--font-body);
font-size: 14px;
text-align: left;
text-decoration: underline dotted;
text-underline-offset: 3px;
cursor: pointer;
}
.time:disabled {
cursor: not-allowed;
}
.permission {
display: flex;
flex-direction: column;
gap: 8px;
}
.permissionRow {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
min-height: 52px;
padding: 6px 6px 6px 18px;
border-radius: 20px;
background: var(--color-neutral-100);
}
.permissionText {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
}
.permissionRow button {
min-height: 44px;
}
.statusSkeleton {
display: inline-block;
width: 3em;
height: 1em;
vertical-align: middle;
}
.input {
background: var(--color-bg);
}
.mono {
overflow: hidden;
font-family: var(--font-mono);
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The status shares its line with Send test, wrapping its own text first. */
.webhookRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.webhookRow .connected {
flex: 1;
min-width: 0;
}
.webhookRow button {
flex: none;
}
.webhookRow button,
.webhookActions button {
min-height: 48px;
}
.webhookActions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.connected,
.testOk {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-accent-2-800);
}
.note,
.error {
margin: 0;
font-family: var(--font-body);
font-size: 13px;
line-height: 1.4;
}
.note {
color: var(--color-neutral-700);
}
.error {
font-weight: 700;
color: var(--color-accent-700);
}
.link {
align-self: flex-start;
min-width: 44px;
min-height: 44px;
padding: 0 4px;
border: 0;
background: transparent;
color: var(--color-accent-700);
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
text-decoration: underline;
cursor: pointer;
}
/* The webhook form while its save is pending; it lays out as if it were
not there. */
.lock {
display: contents;
}
.preview {
display: flex;
gap: 12px;
margin: 0;
padding: 14px 16px;
border-radius: 20px;
background: var(--color-neutral-100);
}
.avatar {
flex: none;
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: var(--radius-pill);
background: var(--color-accent);
color: var(--on-accent);
font-family: var(--font-heading);
font-size: 16px;
}
.previewText {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.5;
}
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 560px;
margin: 0 auto;
}
.sheetTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.sheetText {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-neutral-700);
}
.sheetActions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.sheetActions button {
min-height: 48px;
}
+317
View File
@@ -0,0 +1,317 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { CHAT_WEBHOOK, CHAT_WEBHOOK_NONE, NOTIFICATION_SETTINGS } from '../test/ApiProviders.tsx'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { PUSH_ENDPOINT, restorePushEnvironment, stubPushEnvironment, VAPID_PUBLIC_KEY } from '../test/push.ts'
import { NotificationsPage } from './NotificationsPage.tsx'
type Responder = (body: unknown) => Response
// stubAPI answers "METHOD path" from responders, each called with the
// request's decoded JSON body; the settings the page loads answer their
// fixtures unless overridden. Any other request fails the test.
function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch> {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined
return Promise.resolve(respond(body))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// bodiesOf is the decoded JSON body of every "METHOD path" request sent.
function bodiesOf(fetchMock: Mock<typeof fetch>, method: string, path: string): unknown[] {
return fetchMock.mock.calls
.filter(([input, init]) => String(input) === path && (init?.method ?? 'GET') === method)
.map(([, init]) => JSON.parse(String(init?.body)) as unknown)
}
afterEach(() => {
vi.unstubAllGlobals()
restorePushEnvironment()
})
async function renderPage() {
const api = createApiClient(
fakeLocation('/settings/notifications'),
createNetworkStatus(window),
createVerifiedSession(localStorage),
)
await api.loadMe()
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings/notifications">
<NotificationsPage />
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
const toggle = (name: string) => screen.getByRole('switch', { name })
// withChange is the seed notification settings with one change.
function withChange(event: string, channel: 'push' | 'chat', on: boolean) {
return {
...NOTIFICATION_SETTINGS,
prefs: NOTIFICATION_SETTINGS.prefs.map((p) => (p.event === event ? { ...p, [channel]: on } : p)),
}
}
describe('NotificationsPage (mock 1n)', () => {
it('shows the back button, the title and the five events in order with their channels', async () => {
stubAPI()
await renderPage()
expect(screen.getByRole('button', { name: 'Back to Settings' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
const matrix = await screen.findByRole('group', { name: 'Notification channels' })
expect(
Array.from(matrix.children)
.slice(0, 3)
.map((cell) => cell.textContent),
).toEqual(['Event', 'Push', 'Chat'])
const switches = within(matrix).getAllByRole('switch')
expect(switches.map((s) => s.getAttribute('aria-label'))).toEqual([
'New weekly plan ready: Push',
'New weekly plan ready: Google Chat',
'Shopping list ready: Push',
'Shopping list ready: Google Chat',
'Day-of dinner reminder: Push',
'Day-of dinner reminder: Google Chat',
'New suggestions to swipe: Push',
'New suggestions to swipe: Google Chat',
'Keep it in rotation?: Push',
'Keep it in rotation?: Google Chat',
])
expect(switches.map((s) => s.getAttribute('aria-checked'))).toEqual([
'true', 'true', 'true', 'true', 'true', 'false', 'true', 'false', 'true', 'false',
])
switches.forEach(expectMinHitArea)
const time = within(matrix).getByRole('button', { name: 'Reminder time, 4:30 pm. Change' })
expect(time).toHaveTextContent('at 4:30 pm')
expectMinHitArea(time)
})
it('saves a toggled channel with every other event unchanged', async () => {
const fetchMock = stubAPI({
'PUT /api/v1/settings/notifications': (body) => jsonResponse(200, body),
})
await renderPage()
fireEvent.click(await screen.findByRole('switch', { name: 'Keep it in rotation?: Google Chat' }))
await waitFor(() => expect(toggle('Keep it in rotation?: Google Chat')).toHaveAttribute('aria-checked', 'true'))
expect(bodiesOf(fetchMock, 'PUT', '/api/v1/settings/notifications')).toEqual([
withChange('rotation_review', 'chat', true),
])
await waitFor(() => expect(toggle('Keep it in rotation?: Google Chat')).toBeEnabled())
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('shows the stored channels and why when a save fails, and retries it', async () => {
let fail = true
const fetchMock = stubAPI({
'PUT /api/v1/settings/notifications': (body) =>
fail ? errorResponse(500, 'internal') : jsonResponse(200, body),
})
await renderPage()
fireEvent.click(await screen.findByRole('switch', { name: 'New weekly plan ready: Push' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('Notification settings were not saved')
expect(toggle('New weekly plan ready: Push')).toHaveAttribute('aria-checked', 'true')
fail = false
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(toggle('New weekly plan ready: Push')).toHaveAttribute('aria-checked', 'false'))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(bodiesOf(fetchMock, 'PUT', '/api/v1/settings/notifications')).toEqual([
withChange('plan_ready', 'push', false),
withChange('plan_ready', 'push', false),
])
})
it('edits the reminder time in a sheet', async () => {
const fetchMock = stubAPI({
'PUT /api/v1/settings/notifications': (body) => jsonResponse(200, body),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Reminder time, 4:30 pm. Change' }))
const sheet = screen.getByRole('dialog', { name: 'Day-of dinner reminder' })
const input = within(sheet).getByLabelText('Reminder time')
expect(input).toHaveValue('16:30')
expect(within(sheet).getByRole('button', { name: 'Save' })).toBeDisabled()
fireEvent.change(input, { target: { value: '18:15' } })
fireEvent.click(within(sheet).getByRole('button', { name: 'Save' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(screen.getByRole('button', { name: 'Reminder time, 6:15 pm. Change' })).toHaveTextContent('at 6:15 pm')
expect(bodiesOf(fetchMock, 'PUT', '/api/v1/settings/notifications')).toEqual([
{ ...NOTIFICATION_SETTINGS, reminder_time: '18:15' },
])
})
it('keeps the reminder sheet open and says why when the time is refused', async () => {
stubAPI({
'PUT /api/v1/settings/notifications': () => errorResponse(400, 'invalid_settings'),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Reminder time, 4:30 pm. Change' }))
const sheet = screen.getByRole('dialog', { name: 'Day-of dinner reminder' })
fireEvent.change(within(sheet).getByLabelText('Reminder time'), { target: { value: '07:00' } })
fireEvent.click(within(sheet).getByRole('button', { name: 'Save' }))
expect(await within(sheet).findByRole('alert')).toHaveTextContent('The reminder time was not saved')
expect(screen.getByRole('button', { name: 'Reminder time, 4:30 pm. Change' })).toBeInTheDocument()
})
it('shows the preview bubble', async () => {
stubAPI()
await renderPage()
const preview = screen.getByRole('figure', { name: 'Google Chat message preview' })
expect(preview).toHaveTextContent('MealQuest · preview')
expect(preview).toHaveTextContent(
"This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →",
)
})
})
describe('NotificationsPage Google Chat webhook', () => {
it('shows the masked URL and the connected space', async () => {
stubAPI()
await renderPage()
const card = screen.getByRole('region', { name: 'Google Chat webhook' })
expect(await within(card).findByRole('textbox', { name: 'Webhook URL' })).toHaveValue(CHAT_WEBHOOK.url_masked)
expect(within(card).getByRole('textbox', { name: 'Webhook URL' })).toHaveAttribute('readonly')
expect(within(card).getByText('Connected · Dinner Crew space')).toBeInTheDocument()
})
it.each([
['success', () => jsonResponse(200, { http_status: 200, ...CHAT_WEBHOOK, last_test_status: 'ok' }), 'Test message sent.'],
[
'the HTTP status Google Chat refused it with',
() => jsonResponse(200, { http_status: 403, ...CHAT_WEBHOOK, last_test_status: 'failed' }),
'Google Chat refused the test message: HTTP 403.',
],
[
'why it was not sent',
() => errorResponse(502, 'chat_unreachable'),
'Test message not sent',
],
])('shows %s under the field after Send test', async (_, respond, said) => {
const fetchMock = stubAPI({ 'POST /api/v1/settings/chat-webhook/test': respond })
await renderPage()
const card = screen.getByRole('region', { name: 'Google Chat webhook' })
fireEvent.click(await within(card).findByRole('button', { name: 'Send test' }))
const result = await within(card).findByText(said, { exact: false })
// Under the field: the result follows the URL input.
const input = within(card).getByRole('textbox', { name: 'Webhook URL' })
expect(input.compareDocumentPosition(result) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/settings/chat-webhook/test')).toHaveLength(1)
})
it('asks for a webhook when none is stored and saves it', async () => {
const fetchMock = stubAPI({
'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK_NONE),
'PUT /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK),
})
await renderPage()
const card = screen.getByRole('region', { name: 'Google Chat webhook' })
const save = await within(card).findByRole('button', { name: 'Save webhook' })
expect(save).toBeDisabled()
fireEvent.change(within(card).getByRole('textbox', { name: 'Webhook URL' }), {
target: { value: ' https://chat.googleapis.com/v1/spaces/AAQA/messages?key=k&token=t ' },
})
fireEvent.change(within(card).getByRole('textbox', { name: 'Space name' }), { target: { value: 'Dinner Crew space' } })
fireEvent.click(save)
expect(await within(card).findByText('Connected · Dinner Crew space')).toBeInTheDocument()
expect(bodiesOf(fetchMock, 'PUT', '/api/v1/settings/chat-webhook')).toEqual([
{ url: 'https://chat.googleapis.com/v1/spaces/AAQA/messages?key=k&token=t', space_label: 'Dinner Crew space' },
])
})
})
describe('NotificationsPage push permission row', () => {
const row = () => screen.getByRole('group', { name: 'Notifications on this device' })
it('says a browser without Web Push is not supported, with no button', async () => {
stubAPI()
await renderPage()
expect(await within(row()).findByText('Not supported')).toBeInTheDocument()
expect(within(row()).queryByRole('button')).not.toBeInTheDocument()
expect(screen.getByText("This browser can't receive notifications.")).toBeInTheDocument()
})
it('turns push on from the tap: asks permission, subscribes and stores the subscription', async () => {
const browser = stubPushEnvironment({ permission: 'default', answer: 'granted' })
const fetchMock = stubAPI({
'GET /api/v1/push/vapid-public-key': () => jsonResponse(200, VAPID_PUBLIC_KEY),
'POST /api/v1/push/subscriptions': () => new Response(null, { status: 201 }),
})
await renderPage()
expect(await within(row()).findByText('Off')).toBeInTheDocument()
fireEvent.click(within(row()).getByRole('button', { name: 'Turn on' }))
expect(await within(row()).findByText('On')).toBeInTheDocument()
expect(browser.notification.requestPermission).toHaveBeenCalledTimes(1)
expect(browser.pushManager.subscribe).toHaveBeenCalledTimes(1)
const [stored] = bodiesOf(fetchMock, 'POST', '/api/v1/push/subscriptions')
expect(stored).toMatchObject({ endpoint: PUSH_ENDPOINT })
expect(within(row()).getByRole('button', { name: 'Turn off' })).toBeInTheDocument()
})
it('says the prompt was dismissed and push is still off', async () => {
stubPushEnvironment({ permission: 'default', answer: 'default' })
stubAPI()
await renderPage()
fireEvent.click(await within(row()).findByRole('button', { name: 'Turn on' }))
expect(await screen.findByText(/closed without an answer/)).toBeInTheDocument()
expect(within(row()).getByText('Off')).toBeInTheDocument()
})
it('shows Blocked once the browser denies permission, with no button', async () => {
stubPushEnvironment({ permission: 'default', answer: 'denied' })
stubAPI()
await renderPage()
fireEvent.click(await within(row()).findByRole('button', { name: 'Turn on' }))
expect(await within(row()).findByText('Blocked')).toBeInTheDocument()
expect(within(row()).queryByRole('button')).not.toBeInTheDocument()
expect(screen.getByText(/blocks notifications from MealQuest/)).toBeInTheDocument()
})
it('shows why turning on failed', async () => {
stubPushEnvironment({ permission: 'granted' })
stubAPI({ 'GET /api/v1/push/vapid-public-key': () => errorResponse(503, 'push_unavailable') })
await renderPage()
fireEvent.click(await within(row()).findByRole('button', { name: 'Turn on' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Notifications could not be turned on')
expect(within(row()).getByText('Off')).toBeInTheDocument()
})
it('turns push off on this device', async () => {
stubPushEnvironment({ permission: 'granted', subscribed: true })
const fetchMock = stubAPI({ 'DELETE /api/v1/push/subscriptions': () => new Response(null, { status: 204 }) })
await renderPage()
fireEvent.click(await within(row()).findByRole('button', { name: 'Turn off' }))
expect(await within(row()).findByText('Off')).toBeInTheDocument()
expect(bodiesOf(fetchMock, 'DELETE', '/api/v1/push/subscriptions')).toEqual([{ endpoint: PUSH_ENDPOINT }])
})
})
+481
View File
@@ -0,0 +1,481 @@
import { useQueryClient } from '@tanstack/react-query'
import { ChevronLeft } from 'lucide-react'
import { Fragment, useEffect, useId, useState } from 'react'
import { useNavigate } from 'react-router'
import { InstallHint } from '../pwa/InstallHint.tsx'
import type { PushStatus, SubscribePushResult, UnsubscribePushResult } from '../push/subscribe.ts'
import { usePush } from '../push/usePush.ts'
import { Button } from '../ui/Button.tsx'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { TextInput } from '../ui/TextInput.tsx'
import { Toggle } from '../ui/Toggle.tsx'
import { clockTime, formatClockTime } from './api.ts'
import { LeaveGuard } from './LeaveGuard.tsx'
import {
PUSH_STATUS_KEY,
useChatWebhook,
useNotificationSettings,
usePushStatus,
useSaveChatWebhook,
useSaveNotificationSettings,
useTestChatWebhook,
type ChatWebhook,
type NotificationEvent,
type NotificationSettings,
} from './notificationsApi.ts'
import styles from './NotificationsPage.module.css'
// eventLabels names each event as the Notifications screen lists it.
// rotation_review is the next-day Rotation check.
const eventLabels: Record<NotificationEvent, string> = {
plan_ready: 'New weekly plan ready',
list_ready: 'Shopping list ready',
day_of_reminder: 'Day-of dinner reminder',
new_suggestions: 'New suggestions to swipe',
rotation_review: 'Keep it in rotation?',
}
// Notifications & Google Chat (handoff mock 1n): which events go by push
// and to Google Chat, when the day-of reminder is sent, Web Push on this
// device, and the household's Google Chat webhook. Each change is saved as
// it is made; only a webhook being replaced is a draft.
export function NotificationsPage() {
const [dirty, setDirty] = useState(false)
return (
<div className={styles.page}>
<Header />
<NotificationPrefs />
<ChatWebhookCard onDirtyChange={setDirty} />
<ChatPreview />
<LeaveGuard dirty={dirty} />
</div>
)
}
function Header() {
const navigate = useNavigate()
return (
<header className={styles.header}>
<IconButton label="Back to Settings" onClick={() => void navigate('/settings')}>
<Icon icon={ChevronLeft} size="nav" />
</IconButton>
<h1 className={styles.title}>Notifications</h1>
</header>
)
}
// NotificationsCard is the notification preferences as a card, for the
// desktop settings (handoff mock 2e).
export function NotificationsCard() {
const titleId = useId()
return (
<Card className={styles.card} aria-labelledby={titleId} role="region">
<h2 id={titleId} className={styles.cardTitle}>
Notifications
</h2>
<NotificationPrefs />
</Card>
)
}
// NotificationPrefs is the event × channel matrix once it loads, then Web
// Push on this device.
function NotificationPrefs() {
const settings = useNotificationSettings()
return (
<>
{settings.isPending && <Skeleton className={styles.loading} />}
{settings.isError && (
<ErrorBanner onRetry={() => void settings.refetch()}>
Notification settings could not load: {settings.error.message}
</ErrorBanner>
)}
{settings.isSuccess && <NotificationMatrix saved={settings.data} />}
<PushPermission />
<InstallHint />
</>
)
}
type Channel = 'push' | 'chat'
const channelNames: Record<Channel, string> = { push: 'Push', chat: 'Google Chat' }
// NotificationMatrix saves every change at once. While a save is pending it
// shows the settings being saved and takes no other change; a failed save
// shows the stored settings again and says why.
function NotificationMatrix({ saved }: { saved: NotificationSettings }) {
const save = useSaveNotificationSettings()
const [editingTime, setEditingTime] = useState(false)
const shown = save.isPending ? save.variables : saved
const id = useId()
const reminder = formatClockTime(shown.reminder_time)
function toggle(event: NotificationEvent, channel: Channel, on: boolean) {
save.mutate({ ...shown, prefs: shown.prefs.map((p) => (p.event === event ? { ...p, [channel]: on } : p)) })
}
return (
<>
<div className={styles.matrix} role="group" aria-label="Notification channels">
<span className={styles.head}>Event</span>
<span className={styles.head} aria-hidden="true">
Push
</span>
<span className={styles.head} aria-hidden="true">
Chat
</span>
{shown.prefs.map(({ event, push, chat }) => (
<Fragment key={event}>
<div className={styles.event}>
<span id={`${id}-${event}`} className={styles.eventName}>
{eventLabels[event]}
</span>
{event === 'day_of_reminder' && (
<button
type="button"
className={styles.time}
aria-label={`Reminder time, ${reminder}. Change`}
disabled={save.isPending}
onClick={() => {
save.reset()
setEditingTime(true)
}}
>
at {reminder}
</button>
)}
</div>
{(['push', 'chat'] as const).map((channel) => (
<Toggle
key={channel}
className={styles.toggle}
aria-label={`${eventLabels[event]}: ${channelNames[channel]}`}
checked={channel === 'push' ? push : chat}
disabled={save.isPending}
onCheckedChange={(on) => toggle(event, channel, on)}
/>
))}
</Fragment>
))}
</div>
{save.isError && !editingTime && (
<ErrorBanner onRetry={() => save.mutate(save.variables)}>
Notification settings were not saved: {save.error.message}
</ErrorBanner>
)}
<ReminderTimeSheet
open={editingTime}
saved={saved}
busy={save.isPending}
error={save.isError ? save.error.message : null}
onClose={() => {
save.reset()
setEditingTime(false)
}}
onSave={(reminder_time) =>
save.mutate({ ...saved, reminder_time }, { onSuccess: () => setEditingTime(false) })
}
/>
</>
)
}
type ReminderTimeSheetProps = {
open: boolean
saved: NotificationSettings
busy: boolean
// Why the last save failed, or null.
error: string | null
onClose: () => void
onSave: (reminderTime: string) => void
}
// ReminderTimeSheet edits when the day-of dinner reminder is sent.
function ReminderTimeSheet({ open, saved, busy, error, onClose, onSave }: ReminderTimeSheetProps) {
const titleId = useId()
const [time, setTime] = useState(saved.reminder_time)
useEffect(() => {
if (open) setTime(saved.reminder_time)
}, [open, saved.reminder_time])
const valid = clockTime.test(time)
return (
<Sheet open={open} onClose={onClose} aria-labelledby={titleId}>
<div className={styles.sheet}>
<h2 id={titleId} className={styles.sheetTitle}>
Day-of dinner reminder
</h2>
<p className={styles.sheetText}>When the reminder is sent on each planned day. This changes it for everyone in the household.</p>
<TextInput
label="Reminder time"
type="time"
value={time}
required
onChange={(event) => setTime(event.target.value)}
/>
{!valid && <p className={styles.note}>Choose a time.</p>}
{error !== null && (
<p className={styles.error} role="alert">
The reminder time was not saved: {error}
</p>
)}
<div className={styles.sheetActions}>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button disabled={busy || !valid || time === saved.reminder_time} onClick={() => onSave(time)}>
{busy ? 'Saving…' : 'Save'}
</Button>
</div>
</div>
</Sheet>
)
}
const pushStatusText: Record<PushStatus, string> = {
on: 'On',
off: 'Off',
denied: 'Blocked',
unsupported: 'Not supported',
}
type PushOutcome = SubscribePushResult | UnsubscribePushResult
// PushPermission is whether Web Push is on for this device, with a button
// to turn it on or off. Turning on asks for notification permission, which
// browsers allow only from a tap, so subscribePush is called straight from
// the button's click.
function PushPermission() {
const status = usePushStatus()
const push = usePush()
const queryClient = useQueryClient()
const [busy, setBusy] = useState(false)
const [outcome, setOutcome] = useState<PushOutcome | null>(null)
const [failure, setFailure] = useState<{ action: 'on' | 'off'; message: string } | null>(null)
const labelId = useId()
function run(action: 'on' | 'off', pending: Promise<PushOutcome>) {
setBusy(true)
setOutcome(null)
setFailure(null)
void pending
.then(setOutcome, (error: unknown) =>
setFailure({ action, message: error instanceof Error ? error.message : String(error) }),
)
.finally(() => {
setBusy(false)
void queryClient.invalidateQueries({ queryKey: PUSH_STATUS_KEY })
})
}
return (
<div className={styles.permission}>
<div className={styles.permissionRow} role="group" aria-labelledby={labelId}>
<p className={styles.permissionText}>
<span id={labelId}>Notifications on this device</span>
{': '}
{status.isPending && <Skeleton className={styles.statusSkeleton} />}
{status.isSuccess && <strong>{pushStatusText[status.data]}</strong>}
</p>
{status.data === 'off' && (
<Button variant="outline" disabled={busy} onClick={() => run('on', push.subscribePush())}>
{busy ? 'Turning on…' : 'Turn on'}
</Button>
)}
{status.data === 'on' && (
<Button variant="outline" disabled={busy} onClick={() => run('off', push.unsubscribePush())}>
{busy ? 'Turning off…' : 'Turn off'}
</Button>
)}
</div>
{status.data === 'denied' && (
<p className={styles.note}>This browser blocks notifications from MealQuest. Allow them in the browser's site settings.</p>
)}
{status.data === 'unsupported' && (
<p className={styles.note}>This browser can't receive notifications.</p>
)}
{outcome === 'dismissed' && (
<p className={styles.note} role="status">
The permission prompt was closed without an answer, so notifications are still off.
</p>
)}
{status.isError && (
<ErrorBanner onRetry={() => void status.refetch()}>
Could not check notifications on this device: {status.error.message}
</ErrorBanner>
)}
{failure !== null && (
<ErrorBanner>
Notifications could not be turned {failure.action}: {failure.message}
</ErrorBanner>
)}
</div>
)
}
type ChatWebhookCardProps = {
// Called with whether a webhook being entered is unsaved.
onDirtyChange: (dirty: boolean) => void
}
// ChatWebhookCard is the household's Google Chat webhook: its masked URL,
// the space it posts to, and a test message.
export function ChatWebhookCard({ onDirtyChange }: ChatWebhookCardProps) {
const webhook = useChatWebhook()
const titleId = useId()
return (
<Card className={styles.card} aria-labelledby={titleId} role="region">
<h2 id={titleId} className={styles.cardTitle}>
Google Chat webhook
</h2>
{webhook.isPending && <Skeleton className={styles.cardLoading} />}
{webhook.isError && (
<ErrorBanner onRetry={() => void webhook.refetch()}>
The Google Chat webhook could not load: {webhook.error.message}
</ErrorBanner>
)}
{webhook.isSuccess && <ChatWebhookForm saved={webhook.data} onDirtyChange={onDirtyChange} />}
</Card>
)
}
function ChatWebhookForm({ saved, onDirtyChange }: ChatWebhookCardProps & { saved: ChatWebhook }) {
const connected = saved.url_masked !== null
const [replacing, setReplacing] = useState(!connected)
const [url, setUrl] = useState('')
const [space, setSpace] = useState('')
const save = useSaveChatWebhook()
const test = useTestChatWebhook()
const dirty = replacing && (url !== '' || space !== '')
useEffect(() => {
onDirtyChange(dirty)
}, [dirty, onDirtyChange])
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
function close() {
save.reset()
setReplacing(false)
setUrl('')
setSpace('')
}
if (replacing) {
return (
<fieldset className={styles.lock} disabled={save.isPending}>
<TextInput
label="Webhook URL"
type="url"
className={`${styles.input} ${styles.mono}`}
value={url}
placeholder="https://chat.googleapis.com/v1/spaces/…"
autoComplete="off"
spellCheck={false}
onChange={(event) => setUrl(event.target.value)}
/>
<TextInput
label="Space name"
className={styles.input}
value={space}
placeholder="Dinner Crew"
autoComplete="off"
onChange={(event) => setSpace(event.target.value)}
/>
{save.isError && (
<p className={styles.error} role="alert">
The webhook was not saved: {save.error.message}
</p>
)}
<div className={styles.webhookActions}>
{connected && (
<Button variant="outline" onClick={close}>
Cancel
</Button>
)}
<Button
disabled={url.trim() === '' || space.trim() === '' || save.isPending}
onClick={() => {
test.reset()
save.mutate({ url: url.trim(), space_label: space.trim() }, { onSuccess: close })
}}
>
{save.isPending ? 'Saving…' : 'Save webhook'}
</Button>
</div>
</fieldset>
)
}
return (
<>
<TextInput
label="Webhook URL"
labelHidden
className={`${styles.input} ${styles.mono}`}
value={saved.url_masked ?? ''}
readOnly
/>
<TestResult test={test} />
<div className={styles.webhookRow}>
<p className={styles.connected}>Connected · {saved.space_label}</p>
<Button variant="outline" disabled={test.isPending} onClick={() => test.mutate()}>
{test.isPending ? 'Sending…' : 'Send test'}
</Button>
</div>
<button
type="button"
className={styles.link}
onClick={() => {
test.reset()
setReplacing(true)
}}
>
Replace webhook
</button>
</>
)
}
// TestResult says how the last Send test ended, under the URL: sent, the
// HTTP status Google Chat refused it with, or why it was not sent.
function TestResult({ test }: { test: ReturnType<typeof useTestChatWebhook> }) {
if (test.isSuccess) {
const status = test.data.http_status
const ok = status >= 200 && status < 300
return (
<p className={ok ? styles.testOk : styles.error} role="status">
{ok ? 'Test message sent.' : `Google Chat refused the test message: HTTP ${status}.`}
</p>
)
}
if (test.isError) {
return (
<p className={styles.error} role="alert">
Test message not sent: {test.error.message}
</p>
)
}
return null
}
// ChatPreview shows what a Google Chat message from MealQuest looks like.
function ChatPreview() {
return (
<figure className={styles.preview} aria-label="Google Chat message preview">
<span className={styles.avatar} aria-hidden="true">
Q
</span>
<p className={styles.previewText}>
<strong>MealQuest</strong> · preview
<br />
This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →
</p>
</figure>
)
}
+15 -3
View File
@@ -4,12 +4,20 @@ import { Route, Routes, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { AI_SETTINGS, GOOGLE_NOT_CONNECTED, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
import {
AI_SETTINGS,
CHAT_WEBHOOK,
CHAT_WEBHOOK_NONE,
GOOGLE_NOT_CONNECTED,
HOUSEHOLD_SETTINGS,
SETTINGS_API,
} from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { restorePushEnvironment, stubPushEnvironment } from '../test/push.ts'
import { formatClockTime, modelDisplayName } from './api.ts'
import { SettingsPage } from './SettingsPage.tsx'
@@ -18,12 +26,14 @@ const AI = AI_SETTINGS
afterEach(() => {
vi.unstubAllGlobals()
restorePushEnvironment()
Reflect.deleteProperty(window, 'matchMedia')
})
const REGULARS_PATH = '/api/v1/dinners?status=regular'
const CALENDAR_PATH = '/api/v1/integrations/google'
const NOTIFICATIONS_PATH = '/api/v1/settings/notifications'
const CHAT_WEBHOOK_PATH = '/api/v1/settings/chat-webhook'
// stubSettings answers as today's server does (see SETTINGS_API), with
// overrides for the paths a test changes.
@@ -99,6 +109,7 @@ describe('SettingsPage', () => {
CALENDAR_PATH,
'/api/v1/settings/ai',
'/api/v1/settings/ai/models',
CHAT_WEBHOOK_PATH,
'/api/v1/settings/household',
NOTIFICATIONS_PATH,
'/api/v1/settings/recipe-search',
@@ -120,8 +131,9 @@ describe('SettingsPage', () => {
[REGULARS_PATH]: () => jsonResponse(200, { dinners: Array.from({ length: 7 }, (_, i) => ({ id: `d${i}` })) }),
[CALENDAR_PATH]: () =>
jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'connected', calendar_id: 'c1', calendar_name: 'Dinners calendar' }),
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: true, chat_space: 'Dinner Crew space' }),
[CHAT_WEBHOOK_PATH]: () => jsonResponse(200, CHAT_WEBHOOK),
})
stubPushEnvironment({ permission: 'granted', subscribed: true })
renderSettings()
expect(await within(row('Regular dinners')).findByText('7 on rotation')).toBeInTheDocument()
expect(await within(row('Google Calendar')).findByText('Connected · Dinners calendar')).toBeInTheDocument()
@@ -134,7 +146,7 @@ describe('SettingsPage', () => {
stubSettings({
[REGULARS_PATH]: () => jsonResponse(200, { dinners: [] }),
[CALENDAR_PATH]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: false, chat_space: null }),
[CHAT_WEBHOOK_PATH]: () => jsonResponse(200, CHAT_WEBHOOK_NONE),
})
renderSettings()
expect(await within(row('Regular dinners')).findByText('None on rotation yet')).toBeInTheDocument()
+12 -8
View File
@@ -12,14 +12,13 @@ import { useBreakpoint } from '../shell/useBreakpoint.ts'
import {
aiStatus,
formatClockTime,
notificationsStatus,
regularDinnersStatus,
useAISettings,
useHouseholdSettings,
useNotificationsStatus,
useRegularDinnersCount,
} from './api.ts'
import { calendarStatus, useGoogleConnection } from './calendarApi.ts'
import { notificationsStatus, useChatWebhook, usePushStatus } from './notificationsApi.ts'
import styles from './SettingsPage.module.css'
import { SettingsTabs } from './SettingsTabs.tsx'
@@ -38,16 +37,17 @@ export function SettingsPage() {
}
// The mobile settings list (handoff mock 1p). Each row opens its settings
// page and shows a live status line. Regular dinners and Notifications &
// Google Chat read endpoints their features have not served yet; until then
// each shows its not-yet-available state (see api.ts).
// page and shows a live status line. Regular dinners reads an endpoint its
// feature has not served yet; until then it shows its not-yet-available
// state (see api.ts).
function SettingsList() {
const household = useHouseholdSettings()
const ai = useAISettings()
const regulars = useRegularDinnersCount()
const calendar = useGoogleConnection()
const notifications = useNotificationsStatus()
const failed = [regulars, calendar, household, notifications, ai].filter((query) => query.isError)
const push = usePushStatus()
const chat = useChatWebhook()
const failed = [regulars, calendar, household, push, chat, ai].filter((query) => query.isError)
return (
<div className={styles.page}>
@@ -93,7 +93,11 @@ function SettingsList() {
to="/settings/notifications"
icon={Bell}
title="Notifications & Google Chat"
status={status(notifications, notificationsStatus)}
status={
push.isSuccess
? status(chat, (webhook) => notificationsStatus(push.data, webhook))
: status(push, () => '')
}
/>
</Group>
<Group label="Admin" admin>
+45 -4
View File
@@ -3,7 +3,15 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { AI_MODELS, AI_SETTINGS, GOOGLE_NOT_CONNECTED, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
import {
AI_MODELS,
AI_SETTINGS,
CHAT_WEBHOOK,
GOOGLE_NOT_CONNECTED,
HOUSEHOLD_SETTINGS,
NOTIFICATION_SETTINGS,
RECIPE_SEARCH_SETTINGS,
} from '../test/ApiProviders.tsx'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
@@ -22,6 +30,8 @@ function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch>
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'GET /api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
'GET /api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
@@ -84,7 +94,12 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
const columns = panel('Notifications & AI')
expect(columns).toBeVisible()
const [left, right] = Array.from(columns.children)
expect(within(left as HTMLElement).getByRole('region', { name: 'Notifications & Google Chat' })).toBeInTheDocument()
const notifications = within(left as HTMLElement).getByRole('region', { name: 'Notifications' })
expect(await within(notifications).findAllByRole('switch')).toHaveLength(10)
expect(within(notifications).getByRole('button', { name: 'Reminder time, 4:30 pm. Change' })).toBeInTheDocument()
const chat = within(left as HTMLElement).getByRole('region', { name: 'Google Chat webhook' })
expect(await within(chat).findByText('Connected · Dinner Crew space')).toBeInTheDocument()
expect(within(chat).getByRole('button', { name: 'Send test' })).toBeInTheDocument()
const ai = within(right as HTMLElement).getByRole('region', { name: 'AI assistant' })
expect(within(ai).getByText('Admin')).toBeInTheDocument()
expect(await within(ai).findByRole('combobox', { name: 'Provider' })).toHaveValue('anthropic')
@@ -150,12 +165,39 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
})
})
describe('SettingsPage reminder time across tabs', () => {
it('shows a reminder time saved on Notifications in the Household tab, keeping its draft', async () => {
const saved = { ...HOUSEHOLD_SETTINGS }
stubAPI({
'GET /api/v1/settings/household': () => jsonResponse(200, saved),
'PUT /api/v1/settings/notifications': (body) => {
saved.reminder_time = (body as { reminder_time: string }).reminder_time
return jsonResponse(200, body)
},
})
await renderAt(1440)
fireEvent.click(tab('Household'))
const household = panel('Household')
fireEvent.change(await within(household).findByLabelText('Dinner time'), { target: { value: '18:15' } })
fireEvent.click(tab('Notifications & AI'))
fireEvent.click(await screen.findByRole('button', { name: 'Reminder time, 4:30 pm. Change' }))
const sheet = screen.getByRole('dialog', { name: 'Day-of dinner reminder' })
fireEvent.change(within(sheet).getByLabelText('Reminder time'), { target: { value: '15:00' } })
fireEvent.click(within(sheet).getByRole('button', { name: 'Save' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
fireEvent.click(tab('Household'))
await waitFor(() => expect(within(household).getByLabelText('Day-of reminder')).toHaveValue('15:00'))
expect(within(household).getByLabelText('Dinner time')).toHaveValue('18:15')
})
})
describe('SettingsPage across the 768 px breakpoint', () => {
it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => {
stubAPI({
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
})
const resize = await renderAt(767)
expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai')
@@ -170,7 +212,6 @@ describe('SettingsPage across the 768 px breakpoint', () => {
stubAPI({
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
})
const resize = await renderAt(1180)
fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), {
+5 -6
View File
@@ -3,6 +3,7 @@ import { Card } from '../ui/Card.tsx'
import { AIAssistantCard } from './AISettingsPage.tsx'
import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx'
import { LeaveGuard } from './LeaveGuard.tsx'
import { ChatWebhookCard, NotificationsCard } from './NotificationsPage.tsx'
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
import styles from './SettingsTabs.module.css'
@@ -25,6 +26,7 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
const [householdDirty, setHouseholdDirty] = useState(false)
const [aiDirty, setAIDirty] = useState(false)
const [recipeSearchDirty, setRecipeSearchDirty] = useState(false)
const [chatDirty, setChatDirty] = useState(false)
const refs = useRef<(HTMLButtonElement | null)[]>([])
const id = useId()
const tabId = (value: Tab) => `${id}-tab-${value}`
@@ -105,11 +107,8 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
styles.columns,
<>
<div className={styles.column}>
{/* TODO(Feature 19): the Notifications matrix and Google Chat
webhook cards replace this placeholder. */}
<Placeholder title="Notifications & Google Chat">
Notification and Google Chat webhook settings arrive in a later update.
</Placeholder>
<NotificationsCard />
<ChatWebhookCard onDirtyChange={setChatDirty} />
</div>
<div className={styles.column}>
<AIAssistantCard onDirtyChange={setAIDirty} />
@@ -118,7 +117,7 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
</>,
)}
<LeaveGuard dirty={householdDirty || aiDirty || recipeSearchDirty} />
<LeaveGuard dirty={householdDirty || aiDirty || recipeSearchDirty || chatDirty} />
</div>
)
}
+12 -34
View File
@@ -14,10 +14,6 @@ export const RECIPE_SEARCH_TEST_PATH = '/api/v1/settings/recipe-search/test'
// dinners read API. Until it lands the server answers 404 and the Regular
// dinners row says the count is unavailable.
export const REGULAR_DINNERS_PATH = '/api/v1/dinners?status=regular'
// TODO(notifications feature): the Web Push and Google Chat status. Until it
// is served the server answers 404, which is the truth: push is off and no
// Google Chat space is connected.
export const NOTIFICATIONS_STATUS_PATH = '/api/v1/settings/notifications'
// HouseholdSettings is the part of GET /api/v1/settings/household the
// settings pages read. Times are zero-padded 24-hour HH:MM in timezone; days
@@ -332,7 +328,8 @@ export function useTestRecipeSearch() {
})
}
const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
// clockTime matches a zero-padded 24-hour HH:MM time.
export const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
// parseHouseholdSettings refuses a body that is not the household settings,
// or has a time that is not HH:MM, so formatClockTime never sees one.
@@ -377,6 +374,12 @@ export function useHouseholdSettings() {
})
}
// NOTIFICATIONS_KEY is the notification settings (notificationsApi.ts),
// whose reminder_time is the household settings'.
export const NOTIFICATIONS_KEY = ['settings', 'notifications'] as const
// useSaveHouseholdSettings saves a change and reads the notification
// settings again, which show reminder_time.
export function useSaveHouseholdSettings() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
@@ -386,7 +389,10 @@ export function useSaveHouseholdSettings() {
`PATCH ${HOUSEHOLD_SETTINGS_PATH}`,
await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH, { method: 'PATCH', body: patch }),
),
onSuccess: (settings) => queryClient.setQueryData(HOUSEHOLD_SETTINGS_KEY, settings),
onSuccess: (settings) => {
queryClient.setQueryData(HOUSEHOLD_SETTINGS_KEY, settings)
void queryClient.invalidateQueries({ queryKey: NOTIFICATIONS_KEY })
},
})
}
@@ -513,34 +519,6 @@ export function regularDinnersStatus(count: number | null): string {
return `${count} on rotation`
}
// NotificationsStatus is whether Web Push is on and the connected Google
// Chat space's name, null when none is.
export type NotificationsStatus = { push: boolean; chat_space: string | null }
export function useNotificationsStatus() {
const { apiFetch } = useApi()
return useQuery({
queryKey: ['settings', 'notifications'],
queryFn: () =>
fetchUnlessAbsent(apiFetch, NOTIFICATIONS_STATUS_PATH, (body): NotificationsStatus => {
if (
!isRecord(body) ||
typeof body.push !== 'boolean' ||
(body.chat_space !== null && typeof body.chat_space !== 'string')
) {
throw new Error(`GET ${NOTIFICATIONS_STATUS_PATH}: response is not a notifications status`)
}
return { push: body.push, chat_space: body.chat_space }
}),
})
}
// notificationsStatus is the handoff's "Push on · Dinner Crew space".
export function notificationsStatus(status: NotificationsStatus | null): string {
const push = status?.push === true ? 'Push on' : 'Push off'
return `${push} · ${status?.chat_space ?? 'Google Chat not connected'}`
}
// maskedKey shows a stored key as dots and its last four characters, all
// the client ever knows of it.
export function maskedKey(last4: string): string {
+183
View File
@@ -0,0 +1,183 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import type { PushStatus } from '../push/subscribe.ts'
import { usePush } from '../push/usePush.ts'
import { clockTime, HOUSEHOLD_SETTINGS_KEY, NOTIFICATIONS_KEY } from './api.ts'
export const NOTIFICATIONS_PATH = '/api/v1/settings/notifications'
export const CHAT_WEBHOOK_PATH = '/api/v1/settings/chat-webhook'
export const CHAT_WEBHOOK_TEST_PATH = '/api/v1/settings/chat-webhook/test'
// notificationEvents are the events the household chooses channels for
// (internal/household NotificationEvents), in the order the Notifications
// screen lists them. rotation_review is the next-day Rotation check, "Keep it
// in rotation?".
export const notificationEvents = [
'plan_ready',
'list_ready',
'day_of_reminder',
'new_suggestions',
'rotation_review',
] as const
export type NotificationEvent = (typeof notificationEvents)[number]
export type NotificationPref = { event: NotificationEvent; push: boolean; chat: boolean }
// NotificationSettings is GET and the PUT /api/v1/settings/notifications
// body: every event's channels, in notificationEvents order, and when the
// day-of dinner reminder is sent (the household settings' reminder_time,
// 24-hour HH:MM).
export type NotificationSettings = { prefs: NotificationPref[]; reminder_time: string }
// parseNotificationSettings refuses a body that is not every event's
// channels, in order, with an HH:MM reminder time.
function parseNotificationSettings(where: string, body: unknown): NotificationSettings {
if (!isRecord(body) || !Array.isArray(body.prefs) || typeof body.reminder_time !== 'string') {
throw new Error(`${where}: response is not the notification settings`)
}
if (!clockTime.test(body.reminder_time)) {
throw new Error(`${where}: reminder_time ${JSON.stringify(body.reminder_time)} is not HH:MM`)
}
const prefs: unknown[] = body.prefs
if (prefs.length !== notificationEvents.length) {
throw new Error(`${where}: want ${notificationEvents.length} events, got ${prefs.length}`)
}
return {
prefs: notificationEvents.map((event, i) => {
const pref = prefs[i]
if (!isRecord(pref) || pref.event !== event || typeof pref.push !== 'boolean' || typeof pref.chat !== 'boolean') {
throw new Error(`${where}: event ${i} is not ${event}'s channels`)
}
return { event, push: pref.push, chat: pref.chat }
}),
reminder_time: body.reminder_time,
}
}
export function useNotificationSettings() {
const { apiFetch } = useApi()
return useQuery({
queryKey: NOTIFICATIONS_KEY,
queryFn: async () =>
parseNotificationSettings(`GET ${NOTIFICATIONS_PATH}`, await apiFetch<unknown>(NOTIFICATIONS_PATH)),
})
}
// useSaveNotificationSettings replaces every event's channels and the
// reminder time. reminder_time is a household setting, so the household
// settings are read again.
export function useSaveNotificationSettings() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (settings: NotificationSettings) =>
parseNotificationSettings(
`PUT ${NOTIFICATIONS_PATH}`,
await apiFetch<unknown>(NOTIFICATIONS_PATH, { method: 'PUT', body: settings }),
),
onSuccess: (settings) => {
queryClient.setQueryData(NOTIFICATIONS_KEY, settings)
void queryClient.invalidateQueries({ queryKey: HOUSEHOLD_SETTINGS_KEY })
},
})
}
// ChatWebhook is GET /api/v1/settings/chat-webhook. The URL itself is never
// sent: url_masked has no key and only the token's last few characters.
// Every field is null while no webhook is stored.
export type ChatWebhook = {
space_label: string | null
url_masked: string | null
// When a test message was last sent to this webhook and whether Google
// Chat accepted it; null until one is.
last_test_at: string | null
last_test_status: 'ok' | 'failed' | null
}
// ChatWebhookTest is POST /api/v1/settings/chat-webhook/test: the status
// Google Chat answered the test message with, whatever it was.
export type ChatWebhookTest = { http_status: number; settings: ChatWebhook }
function isStringOrNull(value: unknown): value is string | null {
return value === null || typeof value === 'string'
}
function parseChatWebhook(where: string, body: unknown): ChatWebhook {
if (
!isRecord(body) ||
!isStringOrNull(body.space_label) ||
!isStringOrNull(body.url_masked) ||
!isStringOrNull(body.last_test_at) ||
!(body.last_test_status === null || body.last_test_status === 'ok' || body.last_test_status === 'failed')
) {
throw new Error(`${where}: response is not the Google Chat webhook settings`)
}
if ((body.url_masked === null) !== (body.space_label === null)) {
throw new Error(`${where}: a webhook URL and its space name must be stored together`)
}
return {
space_label: body.space_label,
url_masked: body.url_masked,
last_test_at: body.last_test_at,
last_test_status: body.last_test_status,
}
}
const CHAT_WEBHOOK_KEY = ['settings', 'chat-webhook'] as const
export function useChatWebhook() {
const { apiFetch } = useApi()
return useQuery({
queryKey: CHAT_WEBHOOK_KEY,
queryFn: async () => parseChatWebhook(`GET ${CHAT_WEBHOOK_PATH}`, await apiFetch<unknown>(CHAT_WEBHOOK_PATH)),
})
}
// useSaveChatWebhook stores a webhook URL and its space's name, replacing
// the stored one.
export function useSaveChatWebhook() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (webhook: { url: string; space_label: string }) =>
parseChatWebhook(
`PUT ${CHAT_WEBHOOK_PATH}`,
await apiFetch<unknown>(CHAT_WEBHOOK_PATH, { method: 'PUT', body: webhook }),
),
onSuccess: (webhook) => queryClient.setQueryData(CHAT_WEBHOOK_KEY, webhook),
})
}
// useTestChatWebhook sends a test message to the stored webhook.
export function useTestChatWebhook() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (): Promise<ChatWebhookTest> => {
const where = `POST ${CHAT_WEBHOOK_TEST_PATH}`
const body = await apiFetch<unknown>(CHAT_WEBHOOK_TEST_PATH, { method: 'POST' })
if (!isRecord(body) || typeof body.http_status !== 'number' || !Number.isInteger(body.http_status)) {
throw new Error(`${where}: response has no http_status`)
}
return { http_status: body.http_status, settings: parseChatWebhook(where, body) }
},
onSuccess: (test) => queryClient.setQueryData(CHAT_WEBHOOK_KEY, test.settings),
})
}
export const PUSH_STATUS_KEY = ['push', 'status'] as const
// usePushStatus is whether Web Push is on for this device. It asks only the
// browser, so it runs offline too.
export function usePushStatus() {
const push = usePush()
return useQuery({ queryKey: PUSH_STATUS_KEY, queryFn: () => push.pushStatus(), networkMode: 'always' })
}
// notificationsStatus is the settings home's "Push on · Dinner Crew space"
// line: whether Web Push is on for this device, and the Google Chat space.
export function notificationsStatus(push: PushStatus, chat: ChatWebhook): string {
return `${push === 'on' ? 'Push on' : 'Push off'} · ${chat.space_label ?? 'Google Chat not connected'}`
}
@@ -308,6 +308,7 @@ const FROZEN = {
title: 'Pizza, pot roast & the great orzo trial',
status: 'archived',
sent_to_chat_at: null,
chat_send_unconfirmed_at: null,
needed_count: 1,
total_count: 2,
groups: [
@@ -238,6 +238,10 @@
color: var(--color-accent-2-800);
}
.unconfirmed {
color: var(--color-accent-700);
}
.footer {
display: flex;
flex-direction: column;
+111 -6
View File
@@ -13,6 +13,7 @@ import { sentAt, SupplyRunPage } from './SupplyRunPage.tsx'
const THIS_WEEK = '/api/v1/shopping/2026-10-05'
const GENERATE = `${THIS_WEEK}/generate`
const SEND_CHAT = `${THIS_WEEK}/send-chat`
const ITEM = (id: string) => `/api/v1/shopping/items/${id}`
type Item = { id: string; name: string; qty: string | null; needed: boolean }
@@ -52,6 +53,7 @@ function supplyRun(overrides: Record<string, unknown> = {}, weekStart = '2026-10
title: null,
status: 'current',
sent_to_chat_at: null,
chat_send_unconfirmed_at: null,
needed_count: items.filter((item) => item.needed).length,
total_count: items.length,
groups: GROUPS,
@@ -372,19 +374,122 @@ describe('SupplyRunPage', () => {
expect(archive).toHaveAttribute('href', '/plan/shopping/archive')
expect(archive).toHaveClass(buttonStyles.outline!)
// Send to Google Chat waits for Feature 19: primary, unavailable, and
// its tooltip says so. Pressing it sends nothing.
const send = screen.getByRole('button', { name: 'Send to Google Chat' })
// Already sent, the primary action sends it again.
const send = screen.getByRole('button', { name: 'Send again' })
expect(send).toHaveClass(buttonStyles.primary!)
expect(send).toHaveAttribute('aria-disabled', 'true')
expect(send).toHaveAccessibleDescription('Sending the shopping list to Google Chat arrives in a later update.')
fireEvent.click(send)
expect(send).toBeEnabled()
expect(writes(fetchMock)).toEqual([])
expect(screen.getByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Supply archive' })).not.toBeInTheDocument()
})
it('sends the list to Google Chat once and shows when it was sent', async () => {
mockViewport(1180)
const sent = deferred()
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`POST ${SEND_CHAT}`]: () => sent.response,
})
await renderPage()
await screen.findByText('4 / 6')
expect(screen.queryByText(/Sent to Google Chat ·/)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Send to Google Chat' }))
const sending = await screen.findByRole('button', { name: 'Sending…' })
expect(sending).toBeDisabled()
expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":false}`])
// Saturday Oct 10, 8:00 am in New York.
sent.send(jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })))
expect(await screen.findByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Send again' })).toBeEnabled()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('sends a list already sent again with resend', async () => {
mockViewport(1440)
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })),
[`POST ${SEND_CHAT}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T16:30:00Z' })),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Send again' }))
expect(await screen.findByText('Sent to Google Chat · Sat 12:30 pm')).toBeInTheDocument()
expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":true}`])
})
it('says why a send to Google Chat failed, and retries it', async () => {
mockViewport(1180)
const answers = [
() => errorResponse(409, 'not_configured'),
() => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })),
]
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
[`POST ${SEND_CHAT}`]: () => answers.shift()!(),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Send to Google Chat' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't send the shopping list to Google Chat. not_configured message")
expect(screen.queryByText(/Sent to Google Chat ·/)).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Send to Google Chat' })).toBeEnabled()
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":false}`, `POST ${SEND_CHAT} {"resend":false}`])
})
it('says a send whose outcome is not known was not confirmed, offers no retry, and sends again only with resend', async () => {
mockViewport(1180)
let run = supplyRun()
const answers = [
() => {
// Saturday Oct 10, 8:00 am in New York.
run = supplyRun({ chat_send_unconfirmed_at: '2026-10-10T12:00:00Z' })
return errorResponse(502, 'chat_unconfirmed')
},
() => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:05:00Z' })),
]
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, run),
[`POST ${SEND_CHAT}`]: () => answers.shift()!(),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Send to Google Chat' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't send the shopping list to Google Chat. chat_unconfirmed message")
expect(within(alert).queryByRole('button')).not.toBeInTheDocument()
// The Supply run is read again, and says the send is unconfirmed.
expect(
await screen.findByText('Send to Google Chat not confirmed · Sat 8:00 am — check the space before sending again'),
).toBeInTheDocument()
expect(screen.queryByText(/Sent to Google Chat ·/)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Send again' }))
expect(await screen.findByText('Sent to Google Chat · Sat 8:05 am')).toBeInTheDocument()
expect(screen.queryByText(/not confirmed/)).not.toBeInTheDocument()
expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":false}`, `POST ${SEND_CHAT} {"resend":true}`])
})
it('shows an unconfirmed send on mobile', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ chat_send_unconfirmed_at: '2026-10-10T12:00:00Z' })) })
await renderPage()
expect(
await screen.findByText('Send to Google Chat not confirmed · Sat 8:00 am — check the space before sending again'),
).toBeInTheDocument()
})
it('renders an archived list read-only, tagged Archived', async () => {
const fetchMock = stubServer({
'GET /api/v1/shopping/2026-09-28': () =>
+44 -12
View File
@@ -1,10 +1,11 @@
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router'
import { Unavailable } from '../plan/PlanPage.tsx'
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
import { ApiError } from '../api/errors.ts'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
import { Checkbox } from '../ui/Checkbox.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
@@ -14,6 +15,7 @@ import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import {
useGenerateSupplyRun,
useSendToChat,
useSupplyRun,
useToggles,
WEEK_PARAM,
@@ -27,10 +29,6 @@ import styles from './SupplyRunPage.module.css'
// SUPPLY_ARCHIVE is the Supply archive screen.
export const SUPPLY_ARCHIVE = '/plan/shopping/archive'
// CHAT_HINT is why Send to Google Chat is unavailable.
// TODO(Feature 19): send the Supply run to Google Chat once it is built.
const CHAT_HINT = 'Sending the shopping list to Google Chat arrives in a later update.'
// sentAt is when the Supply run was sent to Google Chat as its status line
// says it, in the household's zone: "Sat 8:00 am".
export function sentAt(at: Date, timezone: string): string {
@@ -104,6 +102,29 @@ function SentStatus({ at, timezone }: { at: Date; timezone: string }) {
)
}
// UnconfirmedStatus is the status line while a send to Google Chat whose
// outcome is not known is recorded: it may or may not have been posted.
function UnconfirmedStatus({ at, timezone }: { at: Date; timezone: string }) {
return (
<p className={`${styles.sent} ${styles.unconfirmed}`}>
<Icon icon={MessageSquare} size="inline" />
Send to Google Chat not confirmed · {sentAt(at, timezone)} — check the space before sending again
</p>
)
}
// SendToChat posts the Supply run to Google Chat: "Send to Google Chat"
// until it is sent, or a send is unconfirmed, then "Send again", which
// posts it once more.
function SendToChat({ sent, pending, onSend }: { sent: boolean; pending: boolean; onSend: () => void }) {
return (
<Button className={styles.action} disabled={pending} onClick={onSend}>
<Icon icon={MessageSquare} size="inline" />
{pending ? 'Sending…' : sent ? 'Send again' : 'Send to Google Chat'}
</Button>
)
}
// ArchiveLink opens the Supply archive: an outline action from tablet up, a
// grape link on mobile.
function ArchiveLink({ layout }: { layout: Breakpoint }) {
@@ -120,13 +141,22 @@ function ArchiveLink({ layout }: { layout: Breakpoint }) {
// the week's dates, the title with the count still to buy of all items, and
// one card per dinner, stacked on mobile and in three masonry columns from
// tablet up, where Archive and Send to Google Chat are actions in the
// header. On a current Supply run each item toggles at once (useToggles); a
// header; a send that fails says why, and one whose outcome is not known
// offers no retry and leaves the status line saying so. On a current Supply run each item toggles at once (useToggles); a
// toggle refused puts the item back and says why. An archived one, or any
// opened from the Supply archive (readOnly), is read-only and tagged
// Archived, and a toggle it refused is not offered again.
function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
const toggles = useToggles(weekStart)
const send = useSendToChat(weekStart)
const run = toggles.shown
const sent = run.sent_to_chat_at !== null
// A send whose outcome is not known is posted again only on request.
const resend = sent || run.chat_send_unconfirmed_at !== null
const unconfirmed = !send.isPending && run.chat_send_unconfirmed_at !== null && (
<UnconfirmedStatus at={run.chat_send_unconfirmed_at} timezone={timezone} />
)
const sendUnconfirmed = send.error instanceof ApiError && send.error.code === 'chat_unconfirmed'
const viewOnly = readOnly || run.status === 'archived'
const wide = layout !== 'mobile'
const failed = toggles.failed
@@ -155,12 +185,7 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
{wide && (
<div className={styles.actions}>
<ArchiveLink layout={layout} />
{!viewOnly && (
<Unavailable hint={CHAT_HINT} below className={styles.action}>
<Icon icon={MessageSquare} size="inline" />
Send to Google Chat
</Unavailable>
)}
{!viewOnly && <SendToChat sent={resend} pending={send.isPending} onSend={() => send.mutate(resend)} />}
</div>
)}
</header>
@@ -170,7 +195,13 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
{failed.error.message}
</ErrorBanner>
)}
{send.isError && !viewOnly && (
<ErrorBanner onRetry={sendUnconfirmed ? undefined : () => send.mutate(resend)}>
Couldn't send the shopping list to Google Chat. {send.error.message}
</ErrorBanner>
)}
{wide && run.sent_to_chat_at !== null && <SentStatus at={run.sent_to_chat_at} timezone={timezone} />}
{wide && unconfirmed}
{run.groups.length === 0 ? (
<EmptyState title="Nothing to buy">None of this week's dinners has a recipe with ingredients yet.</EmptyState>
) : (
@@ -183,6 +214,7 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
{!wide && (
<footer className={styles.footer}>
{run.sent_to_chat_at !== null && <SentStatus at={run.sent_to_chat_at} timezone={timezone} />}
{unconfirmed}
<ArchiveLink layout={layout} />
</footer>
)}
+31 -6
View File
@@ -72,14 +72,17 @@ export type SupplyRunGroup = { dinner: { id: string; name: string }; date: strin
export type Counts = { needed_count: number; total_count: number }
// SupplyRun is GET /api/v1/shopping/{weekStart}: label is the week label,
// "Week 41 · Oct 5 – 11", title the AI's title once written, and
// sent_to_chat_at when it was sent to Google Chat, if it was.
// "Week 41 · Oct 5 – 11", title the AI's title once written,
// sent_to_chat_at when it was sent to Google Chat, if it was, and
// chat_send_unconfirmed_at when a send whose outcome is not known began,
// if one did: it may or may not have reached Google Chat.
export type SupplyRun = Counts & {
weekStart: string
label: string
title: string | null
status: SupplyRunStatus
sent_to_chat_at: Date | null
chat_send_unconfirmed_at: Date | null
groups: SupplyRunGroup[]
}
@@ -125,12 +128,12 @@ function parseGroup(where: string, body: unknown): SupplyRunGroup {
}
}
// sentAt reads sent_to_chat_at, an RFC 3339 instant or null.
function sentAt(where: string, value: unknown): Date | null {
// instant reads field, an RFC 3339 instant or null.
function instant(where: string, field: string, value: unknown): Date | null {
if (value === null) return null
const at = typeof value === 'string' ? new Date(value) : null
if (at === null || Number.isNaN(at.getTime())) {
throw new Error(`${where}: sent_to_chat_at ${JSON.stringify(value)} is not an instant`)
throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not an instant`)
}
return at
}
@@ -150,7 +153,8 @@ export function parseSupplyRun(where: string, weekStart: string, body: unknown):
label: body.label,
title: body.title,
status: oneOf(where, 'status', SUPPLY_RUN_STATUSES, body.status),
sent_to_chat_at: sentAt(where, body.sent_to_chat_at),
sent_to_chat_at: instant(where, 'sent_to_chat_at', body.sent_to_chat_at),
chat_send_unconfirmed_at: instant(where, 'chat_send_unconfirmed_at', body.chat_send_unconfirmed_at),
...parseCounts(where, body),
groups: body.groups.map((group) => parseGroup(where, group)),
}
@@ -206,6 +210,27 @@ export function useGenerateSupplyRun(weekStart: string) {
})
}
// useSendToChat posts the week's Supply run to Google Chat (POST
// /api/v1/shopping/{weekStart}/send-chat); the Supply run it answers with,
// sent_to_chat_at recorded, is the one shown. A week already sent, or with
// a send unconfirmed, is posted again only with resend: without it the
// server answers with the Supply run as it is. A send that fails re-reads
// the Supply run, so a send left unconfirmed shows as such.
export function useSendToChat(weekStart: string) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (resend: boolean): Promise<SupplyRun> => {
const path = `${supplyRunPath(weekStart)}/send-chat`
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST', body: { resend } }))
},
onSuccess: (run) => {
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
},
onError: () => queryClient.invalidateQueries({ queryKey: supplyRunKey(weekStart) }),
})
}
// Toggle is one item marked to buy (needed) or "have it" (not needed).
export type Toggle = { id: string; needed: boolean }
+1
View File
@@ -93,6 +93,7 @@ function stubServer(
title: null,
status: week === '2026-10-05' ? status : 'current',
sent_to_chat_at: null,
chat_send_unconfirmed_at: null,
...counts(week),
groups: [
{
+54
View File
@@ -50,3 +50,57 @@ describe('service worker activation', () => {
expect(cacheStorage.delete).toHaveBeenCalledTimes(2)
})
})
// The push listeners only hand src/push/notification.ts the worker's globals;
// jsdom has no ServiceWorkerGlobalScope, so registration and clients are
// stubbed and the events are plain Events with the worker event's fields.
describe('service worker push listeners', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
async function dispatch(type: string, fields: object): Promise<void> {
vi.stubGlobal('caches', { keys: vi.fn(async () => []), delete: vi.fn() })
vi.stubGlobal('__WB_MANIFEST', [])
await import('./sw')
const pending: Promise<unknown>[] = []
self.dispatchEvent(
Object.assign(new Event(type), { waitUntil: (promise: Promise<unknown>) => pending.push(promise) }, fields),
)
expect(pending).toHaveLength(1)
await Promise.all(pending)
}
it('shows a push message as a notification', async () => {
const registration = { showNotification: vi.fn(async () => {}) }
vi.stubGlobal('registration', registration)
const payload = JSON.stringify({ title: 'Plan ready', body: 'Adjust the Draft board.', tag: 'plan_ready', url: '/plan' })
await dispatch('push', { data: { text: () => payload } })
expect(registration.showNotification).toHaveBeenCalledWith('Plan ready', {
body: 'Adjust the Draft board.',
tag: 'plan_ready',
icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png',
data: { url: '/plan' },
})
})
it('shows the generic notification for a push without a payload', async () => {
const registration = { showNotification: vi.fn(async () => {}) }
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.stubGlobal('registration', registration)
await dispatch('push', { data: null })
expect(registration.showNotification).toHaveBeenCalledWith('MealQuest', expect.objectContaining({ data: { url: '/' } }))
expect(warn).toHaveBeenCalledWith('MealQuest: push message has no payload; showing the generic notification')
warn.mockRestore()
})
it('closes a tapped notification and opens its url', async () => {
const clients = { matchAll: vi.fn(async () => []), openWindow: vi.fn(async () => null) }
vi.stubGlobal('clients', clients)
const notification = { data: { url: '/discover' }, close: vi.fn() }
await dispatch('notificationclick', { notification })
expect(notification.close).toHaveBeenCalled()
expect(clients.openWindow).toHaveBeenCalledWith(`${self.location.origin}/discover`)
})
})
+16
View File
@@ -15,6 +15,7 @@ import { ExpirationPlugin } from 'workbox-expiration'
import { PrecacheFallbackPlugin, matchPrecache, precacheAndRoute } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
import { openNotification, showPush } from './push/notification'
import { createApiStrategy } from './sw/apiStrategy'
import {
cacheNames,
@@ -113,3 +114,18 @@ self.addEventListener('activate', (event) => {
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') void self.skipWaiting()
})
// Web Push: src/push/notification.ts decides what to show and where a tap
// goes; these listeners only hand it the worker's globals. A payload that is
// not a MealQuest notice still shows a generic notification, and the reason
// is logged.
self.addEventListener('push', (event) => {
event.waitUntil(
showPush(event.data?.text() ?? null, self.registration, self.location.origin, (message) => console.warn(message)),
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
event.waitUntil(openNotification(event.notification.data, self.clients, self.location.origin))
})
+10
View File
@@ -44,3 +44,13 @@ describe('service worker source', () => {
)
})
})
describe('service worker push listeners source', () => {
it('delegates push and notificationclick to src/push/notification.ts', () => {
expect(source).toContain("import { openNotification, showPush } from './push/notification'")
expect(source).toMatch(/self\.addEventListener\('push', \(event\) => \{\s*event\.waitUntil\(\s*showPush\(event\.data\?\.text\(\) \?\? null, self\.registration, self\.location\.origin,/)
expect(source).toMatch(
/self\.addEventListener\('notificationclick', \(event\) => \{\s*event\.notification\.close\(\)\s*event\.waitUntil\(openNotification\(event\.notification\.data, self\.clients, self\.location\.origin\)\)/,
)
})
})
+30 -3
View File
@@ -61,14 +61,41 @@ export const GOOGLE_NOT_CONNECTED = {
last_sync_error: null,
}
// NOTIFICATION_SETTINGS is a GET /api/v1/settings/notifications body: the
// seed channels and reminder time.
export const NOTIFICATION_SETTINGS = {
prefs: [
{ event: 'plan_ready', push: true, chat: true },
{ event: 'list_ready', push: true, chat: true },
{ event: 'day_of_reminder', push: true, chat: false },
{ event: 'new_suggestions', push: true, chat: false },
{ event: 'rotation_review', push: true, chat: false },
],
reminder_time: '16:30',
}
// CHAT_WEBHOOK_NONE is a GET /api/v1/settings/chat-webhook body: no webhook
// stored.
export const CHAT_WEBHOOK_NONE = { space_label: null, url_masked: null, last_test_at: null, last_test_status: null }
// CHAT_WEBHOOK is a GET /api/v1/settings/chat-webhook body: a webhook
// stored and never tested.
export const CHAT_WEBHOOK = {
space_label: 'Dinner Crew space',
url_masked: 'https://chat.googleapis.com/v1/spaces/AAQA1b2C3d4/messages?token=…qQ%3D',
last_test_at: null,
last_test_status: null,
}
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
// today's server does: the dinners and notifications status endpoints do not
// exist yet, and Google Calendar is not connected. The home's sub-tabs stay mounted on mobile, so
// today's server does: the dinners endpoint does not exist yet, and neither
// Google Calendar nor Google Chat is connected. The home's sub-tabs stay mounted on mobile, so
// it also reads the AI models and recipe search settings.
export const SETTINGS_API = {
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'/api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
'/api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
'/api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK_NONE),
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'/api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
+68
View File
@@ -0,0 +1,68 @@
import { vi } from 'vitest'
import { encodeBase64url, type BrowserPushSubscription } from '../push/subscribe.ts'
function bytes(length: number, first: number): Uint8Array<ArrayBuffer> {
const out = new Uint8Array(new ArrayBuffer(length))
for (let i = 0; i < length; i++) out[i] = (first + i) % 256
return out
}
const vapidKey = bytes(65, 4)
// VAPID_PUBLIC_KEY is a GET /api/v1/push/vapid-public-key body whose key
// the stubbed subscriptions were made with.
export const VAPID_PUBLIC_KEY = { public_key: encodeBase64url(vapidKey.buffer) }
export const PUSH_ENDPOINT = 'https://push.example/send/abc'
type BrowserPush = {
// The browser's notification permission before any prompt.
permission?: NotificationPermission
// How the permission prompt is answered.
answer?: NotificationPermission
// Whether this browser already holds a push subscription.
subscribed?: boolean
}
// stubPushEnvironment gives jsdom, which has no Web Push, a browser with a
// registered service worker, PushManager, Notification and an active tap.
// Undo with restorePushEnvironment and vi.unstubAllGlobals.
export function stubPushEnvironment({ permission = 'default', answer = 'granted', subscribed = false }: BrowserPush = {}) {
const subscription: BrowserPushSubscription = {
endpoint: PUSH_ENDPOINT,
options: { applicationServerKey: vapidKey.buffer },
getKey: (name) => (name === 'p256dh' ? bytes(65, 4).buffer : bytes(16, 200).buffer),
unsubscribe: vi.fn(async () => {
current = null
return true
}),
}
let current: BrowserPushSubscription | null = subscribed ? subscription : null
const pushManager = {
getSubscription: vi.fn(async () => current),
subscribe: vi.fn(async () => {
current = subscription
return subscription
}),
}
const notification = {
permission,
requestPermission: vi.fn(async () => {
notification.permission = answer
return answer
}),
}
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: { getRegistration: async () => ({ pushManager }) },
})
Object.defineProperty(navigator, 'userActivation', { configurable: true, value: { isActive: true } })
vi.stubGlobal('PushManager', function PushManager() {})
vi.stubGlobal('Notification', notification)
return { notification, pushManager }
}
export function restorePushEnvironment() {
Reflect.deleteProperty(navigator, 'serviceWorker')
Reflect.deleteProperty(navigator, 'userActivation')
}