Add notification preferences API and screen (1n)
GET/PUT /api/v1/settings/notifications read and replace the event x
{push, chat} matrix for the five events and the reminder time. The PUT
validates every event exactly once and an HH:MM time, refuses unknown
events and fields, and writes the preferences and the household
settings' reminder_time (the one copy) in one transaction, publishing
the new settings to the scheduler as a household settings change does.
/settings/notifications renders mock 1n: the matrix with Toggle
switches, including the next-day "Keep it in rotation?" Rotation check
(D-021); the reminder time edited in a sheet; the push permission row
using pushStatus/subscribePush/unsubscribePush; the iOS InstallHint;
the Google Chat webhook card with the masked URL, "Connected · {space}"
and Send test showing success or the HTTP status; and the preview
bubble. The desktop 2e Notifications & AI tab shows the matrix and
webhook cards in its left column. The settings home row now reads Web
Push on this device and the Google Chat space, and the Household form
takes a reminder time saved elsewhere without undoing it.
Maestro task 308a460d-b22c-4179-acba-d5ea0968ccd5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,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'
|
||||
@@ -46,6 +47,7 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/plan/shopping/archive': <SupplyArchivePage />,
|
||||
'/discover': <DiscoverPage />,
|
||||
'/settings': <SettingsPage />,
|
||||
'/settings/notifications': <NotificationsPage />,
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
'/settings/household': <HouseholdSettingsPage />,
|
||||
'/settings/regulars': <RegularsSettingsPage />,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
decodeVapidKey,
|
||||
encodeBase64url,
|
||||
PermissionGestureError,
|
||||
pushStatus,
|
||||
subscribePush,
|
||||
subscriptionBody,
|
||||
unsubscribePush,
|
||||
@@ -306,3 +307,31 @@ describe('unsubscribePush', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,6 +19,11 @@ export type SubscribePushResult = 'subscribed' | 'denied' | 'dismissed' | 'unsup
|
||||
// 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
|
||||
@@ -207,3 +212,16 @@ export async function unsubscribePush(api: PushApi, env: PushEnvironment): Promi
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -2,25 +2,30 @@ 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 subscribePush and unsubscribePush to the app's API client
|
||||
// and this browser.
|
||||
// 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()),
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }])
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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' }), {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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'}`
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
Reference in New Issue
Block a user