Household settings screen #84
+14
-2
@@ -5,7 +5,7 @@ import { createNetworkStatus } from './pwa/networkStatus.ts'
|
||||
import { mockViewport } from './test/matchMedia.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
|
||||
import App, { routes } from './App.tsx'
|
||||
import { AI_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { InDataRouter } from './test/dataRouter.tsx'
|
||||
|
||||
afterEach(() => {
|
||||
@@ -27,7 +27,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
|
||||
}
|
||||
|
||||
// The routes that render a built screen rather than a placeholder page.
|
||||
const SCREENS = { '/settings': true, '/settings/ai': true }
|
||||
const SCREENS = { '/settings': true, '/settings/ai': true, '/settings/household': true }
|
||||
|
||||
describe('App routes', () => {
|
||||
it('registers every handoff route plus the extra settings pages', () => {
|
||||
@@ -87,6 +87,18 @@ describe('App routes', () => {
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/settings/household renders the household settings screen inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
'/api/v1/me': () => jsonResponse(200, ME),
|
||||
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||
})
|
||||
renderAt('/settings/household')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Household' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByLabelText('Dinner time')).toHaveValue('17:30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('App bootstrap', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
||||
import { OfflinePill } from './pwa/OfflinePill.tsx'
|
||||
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
||||
import { AISettingsPage } from './settings/AISettingsPage.tsx'
|
||||
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
|
||||
import { SettingsPage } from './settings/SettingsPage.tsx'
|
||||
import { AppShell } from './shell/AppShell.tsx'
|
||||
|
||||
@@ -33,6 +34,7 @@ export const routes = [
|
||||
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/settings': <SettingsPage />,
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
'/settings/household': <HouseholdSettingsPage />,
|
||||
}
|
||||
|
||||
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { useEffect, useId, useState, type ReactNode } from 'react'
|
||||
import { useBlocker, useNavigate } from 'react-router'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { ApiError } from '../api/index.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type AISettingsPatch,
|
||||
} from './api.ts'
|
||||
import styles from './AISettingsPage.module.css'
|
||||
import { Confirm, LeaveGuard } from './LeaveGuard.tsx'
|
||||
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
|
||||
|
||||
// TYPE_MODEL is the Model dropdown's "Type a model id" option.
|
||||
@@ -57,31 +58,6 @@ export function AISettingsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// LeaveGuard asks before leaving the screen with unsaved changes.
|
||||
function LeaveGuard({ dirty }: { dirty: boolean }) {
|
||||
const blocker = useBlocker(
|
||||
({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname,
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!dirty) return
|
||||
const onBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault()
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
}, [dirty])
|
||||
return (
|
||||
<Confirm
|
||||
open={blocker.state === 'blocked'}
|
||||
title="Leave without saving?"
|
||||
confirmLabel="Leave"
|
||||
cancelLabel="Keep editing"
|
||||
onCancel={() => blocker.reset?.()}
|
||||
onConfirm={() => blocker.proceed?.()}
|
||||
>
|
||||
Your changes on this screen have not been saved.
|
||||
</Confirm>
|
||||
)
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
@@ -537,52 +513,3 @@ function PromptField({ value, onChange }: { value: string; onChange: (value: str
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ConfirmProps = {
|
||||
open: boolean
|
||||
title: string
|
||||
children: ReactNode
|
||||
confirmLabel: string
|
||||
cancelLabel?: string
|
||||
busy?: boolean
|
||||
error?: string | null
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
function Confirm({
|
||||
open,
|
||||
title,
|
||||
children,
|
||||
confirmLabel,
|
||||
cancelLabel = 'Cancel',
|
||||
busy = false,
|
||||
error = null,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: ConfirmProps) {
|
||||
const titleId = useId()
|
||||
return (
|
||||
<Sheet open={open} onClose={onCancel} aria-labelledby={titleId}>
|
||||
<div className={styles.sheet}>
|
||||
<h2 id={titleId} className={styles.sheetTitle}>
|
||||
{title}
|
||||
</h2>
|
||||
<p className={styles.sheetText}>{children}</p>
|
||||
{error !== null && (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.sheetActions}>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/* Settings · Household. */
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.shared {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
border-radius: 26px;
|
||||
background: var(--color-accent-2-100);
|
||||
color: var(--color-accent-2-900);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.loading {
|
||||
height: 420px;
|
||||
border-radius: 26px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.time {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Beside a day dropdown, a time matches its height. */
|
||||
.pairedTime {
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.hint,
|
||||
.error,
|
||||
.status {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.error {
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.errors {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.status:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.zoneRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 56px;
|
||||
padding: 0 6px 0 20px;
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.zoneValue {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0 8px;
|
||||
}
|
||||
|
||||
.zoneName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.offset {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.change {
|
||||
flex: none;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 18px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.save {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.zoneList {
|
||||
max-height: 50vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.zoneOption {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-width: 44px;
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.zoneOption .zoneName {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.zoneOption[aria-pressed='true'] {
|
||||
background: var(--color-accent-2-100);
|
||||
color: var(--color-accent-2-900);
|
||||
}
|
||||
|
||||
.check {
|
||||
flex: none;
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { Route, Routes, useLocation } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, ApiError, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOUSEHOLD_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 { householdSettingsProblems } from './api.ts'
|
||||
import { HouseholdSettingsPage } from './HouseholdSettingsPage.tsx'
|
||||
import { matchesZone, utcOffset, zoneLabel } from './timezones.ts'
|
||||
|
||||
type Responder = (body: unknown) => Response | Promise<Response>
|
||||
|
||||
// stubAPI answers "METHOD path" from responders, each called with the
|
||||
// request's decoded JSON body; GET /settings/household answers
|
||||
// HOUSEHOLD_SETTINGS 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/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||
...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
|
||||
}
|
||||
|
||||
// sent lists the requests other than GETs, as "METHOD path" and body.
|
||||
function sent(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls
|
||||
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
|
||||
.map(([input, init]) => ({
|
||||
request: `${init?.method} ${String(input)}`,
|
||||
body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function CurrentPath() {
|
||||
return <p data-testid="path">{useLocation().pathname}</p>
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||
// the page at /settings/household in a data router, so its leave guard works.
|
||||
async function renderPage() {
|
||||
const api = createApiClient(
|
||||
fakeLocation('/settings/household'),
|
||||
createNetworkStatus(window),
|
||||
createVerifiedSession(localStorage),
|
||||
)
|
||||
await api.loadMe()
|
||||
render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<InDataRouter path="/settings/household">
|
||||
<Routes>
|
||||
<Route path="/settings/household" element={<HouseholdSettingsPage />} />
|
||||
<Route path="*" element={<CurrentPath />} />
|
||||
</Routes>
|
||||
</InDataRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
await screen.findByLabelText('Dinner time')
|
||||
}
|
||||
|
||||
const save = () => screen.getByRole('button', { name: 'Save changes' })
|
||||
const input = (name: string) => screen.getByLabelText(name)
|
||||
const zoneGroup = () => screen.getByRole('group', { name: 'Time zone' })
|
||||
|
||||
describe('HouseholdSettingsPage', () => {
|
||||
it('loads every household setting with plain-word copy', async () => {
|
||||
stubAPI()
|
||||
await renderPage()
|
||||
expect(screen.getByRole('button', { name: 'Back to Settings' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Household' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Changes apply to everyone in the household.')).toBeInTheDocument()
|
||||
|
||||
expect(zoneGroup()).toHaveTextContent('America / New York')
|
||||
expect(zoneGroup()).toHaveTextContent(utcOffset('America/New_York', new Date()))
|
||||
expect(input('Dinner time')).toHaveValue('17:30')
|
||||
expect(input('Dinner time')).toHaveAccessibleDescription(/crock-pot dinners count back from it/)
|
||||
expect(input('Day-of reminder')).toHaveValue('16:30')
|
||||
expect(input('Rotation check')).toHaveValue('09:00')
|
||||
expect(input('Rotation check')).toHaveAccessibleDescription(/When we ask whether to keep a new dinner/)
|
||||
expect(input('Plan draft day')).toHaveValue('5')
|
||||
expect(within(input('Plan draft day')).getByRole('option', { selected: true })).toHaveTextContent('Friday')
|
||||
expect(input('Plan draft time')).toHaveValue('09:00')
|
||||
expect(input('New suggestions day')).toHaveValue('0')
|
||||
expect(input('New suggestions time')).toHaveValue('08:00')
|
||||
expect(input('Google Chat Supply run day')).toHaveValue('6')
|
||||
expect(input('Google Chat Supply run time')).toHaveValue('08:00')
|
||||
expect(input('Google Chat Supply run time')).toHaveAccessibleDescription(/Supply run is sent to your Google Chat/)
|
||||
expect(save()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('reports a load failure with a retry', async () => {
|
||||
stubAPI({ 'GET /api/v1/settings/household': () => errorResponse(500, 'internal') })
|
||||
const api = createApiClient(
|
||||
fakeLocation('/settings/household'),
|
||||
createNetworkStatus(window),
|
||||
createVerifiedSession(localStorage),
|
||||
)
|
||||
render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<InDataRouter path="/settings/household">
|
||||
<HouseholdSettingsPage />
|
||||
</InDataRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Household settings could not load: internal message')
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves only the edited fields', async () => {
|
||||
const fetchMock = stubAPI({
|
||||
'PATCH /api/v1/settings/household': (body) => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, ...(body as object) }),
|
||||
})
|
||||
await renderPage()
|
||||
fireEvent.change(input('Dinner time'), { target: { value: '18:15' } })
|
||||
fireEvent.change(input('Rotation check'), { target: { value: '10:00' } })
|
||||
fireEvent.change(input('Plan draft day'), { target: { value: '4' } })
|
||||
fireEvent.change(input('Google Chat Supply run time'), { target: { value: '07:30' } })
|
||||
// Changed and changed back: not sent.
|
||||
fireEvent.change(input('Day-of reminder'), { target: { value: '16:00' } })
|
||||
fireEvent.change(input('Day-of reminder'), { target: { value: '16:30' } })
|
||||
fireEvent.click(save())
|
||||
|
||||
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
|
||||
expect(sent(fetchMock)).toEqual([
|
||||
{
|
||||
request: 'PATCH /api/v1/settings/household',
|
||||
body: { dinner_time: '18:15', review_time: '10:00', plan_draft_dow: 4, chat_autosend_time: '07:30' },
|
||||
},
|
||||
])
|
||||
expect(input('Dinner time')).toHaveValue('18:15')
|
||||
expect(save()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('chooses a time zone from the searchable list', async () => {
|
||||
const fetchMock = stubAPI({
|
||||
'PATCH /api/v1/settings/household': (body) => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, ...(body as object) }),
|
||||
})
|
||||
await renderPage()
|
||||
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
|
||||
const sheet = screen.getByRole('dialog', { name: 'Choose a time zone' })
|
||||
const list = within(sheet).getByRole('list', { name: 'Time zones' })
|
||||
expect(within(list).getByRole('button', { pressed: true })).toHaveAccessibleName(
|
||||
`America / New York, ${utcOffset('America/New_York', new Date())}`,
|
||||
)
|
||||
|
||||
fireEvent.change(within(sheet).getByRole('searchbox', { name: 'Search time zones' }), {
|
||||
target: { value: 'los angeles' },
|
||||
})
|
||||
const options = within(list).getAllByRole('button')
|
||||
expect(options).toHaveLength(1)
|
||||
expect(within(sheet).getByText('1 time zone')).toBeInTheDocument()
|
||||
expect(options[0]).toHaveAccessibleName(`America / Los Angeles, ${utcOffset('America/Los_Angeles', new Date())}`)
|
||||
fireEvent.click(options[0]!)
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(zoneGroup()).toHaveTextContent('America / Los Angeles')
|
||||
fireEvent.click(save())
|
||||
await screen.findByText('Changes saved.')
|
||||
expect(sent(fetchMock)).toEqual([
|
||||
{ request: 'PATCH /api/v1/settings/household', body: { timezone: 'America/Los_Angeles' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('offers the UTC zones the browser list leaves out', async () => {
|
||||
expect(Intl.supportedValuesOf('timeZone')).not.toContain('Etc/UTC')
|
||||
const fetchMock = stubAPI({
|
||||
'PATCH /api/v1/settings/household': (body) => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, ...(body as object) }),
|
||||
})
|
||||
await renderPage()
|
||||
expect(zoneGroup()).toHaveTextContent('America / New York')
|
||||
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
|
||||
const sheet = screen.getByRole('dialog', { name: 'Choose a time zone' })
|
||||
const list = within(sheet).getByRole('list', { name: 'Time zones' })
|
||||
fireEvent.change(within(sheet).getByRole('searchbox', { name: 'Search time zones' }), {
|
||||
target: { value: 'utc' },
|
||||
})
|
||||
expect(within(list).getByRole('button', { name: 'UTC, UTC+00:00' })).toBeInTheDocument()
|
||||
fireEvent.click(within(list).getByRole('button', { name: 'Etc / UTC, UTC+00:00' }))
|
||||
|
||||
expect(zoneGroup()).toHaveTextContent('Etc / UTC')
|
||||
fireEvent.click(save())
|
||||
await screen.findByText('Changes saved.')
|
||||
expect(sent(fetchMock)).toEqual([
|
||||
{ request: 'PATCH /api/v1/settings/household', body: { timezone: 'Etc/UTC' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps offering a saved zone the browser list leaves out after another is chosen', async () => {
|
||||
expect(Intl.supportedValuesOf('timeZone')).not.toContain('US/Eastern')
|
||||
stubAPI({
|
||||
'GET /api/v1/settings/household': () => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, timezone: 'US/Eastern' }),
|
||||
})
|
||||
await renderPage()
|
||||
const search = (value: string) =>
|
||||
fireEvent.change(screen.getByRole('searchbox', { name: 'Search time zones' }), { target: { value } })
|
||||
const option = (name: RegExp) =>
|
||||
within(screen.getByRole('list', { name: 'Time zones' })).getByRole('button', { name })
|
||||
|
||||
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
|
||||
search('los angeles')
|
||||
fireEvent.click(option(/^America \/ Los Angeles,/))
|
||||
expect(zoneGroup()).toHaveTextContent('America / Los Angeles')
|
||||
|
||||
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
|
||||
search('us eastern')
|
||||
fireEvent.click(option(/^US \/ Eastern,/))
|
||||
expect(zoneGroup()).toHaveTextContent('US / Eastern')
|
||||
expect(save()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('says when no time zone matches the search', async () => {
|
||||
stubAPI()
|
||||
await renderPage()
|
||||
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
|
||||
const sheet = screen.getByRole('dialog', { name: 'Choose a time zone' })
|
||||
fireEvent.change(within(sheet).getByRole('searchbox'), { target: { value: 'Atlantis' } })
|
||||
expect(within(sheet).getByText('No time zone matches.')).toBeInTheDocument()
|
||||
expect(within(sheet).queryAllByRole('listitem')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows the API validation errors beside their fields', async () => {
|
||||
stubAPI({
|
||||
'PATCH /api/v1/settings/household': () =>
|
||||
jsonResponse(400, {
|
||||
error: {
|
||||
code: 'invalid_settings',
|
||||
message:
|
||||
'invalid household settings: timezone: unknown time zone "Mars/Olympus" (want an IANA zone such as America/New_York); dinner_time: invalid time "" (want 24-hour HH:MM, 00:00-23:59)',
|
||||
retryable: false,
|
||||
},
|
||||
}),
|
||||
})
|
||||
await renderPage()
|
||||
fireEvent.change(input('Dinner time'), { target: { value: '' } })
|
||||
fireEvent.click(save())
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Not saved: fix the settings marked below their fields.')
|
||||
expect(input('Dinner time')).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(input('Dinner time')).toHaveAccessibleDescription(/^invalid time "" \(want 24-hour HH:MM, 00:00-23:59\)/)
|
||||
expect(zoneGroup()).toHaveAccessibleDescription(
|
||||
'unknown time zone "Mars/Olympus" (want an IANA zone such as America/New_York)',
|
||||
)
|
||||
expect(input('Day-of reminder')).toHaveAttribute('aria-invalid', 'false')
|
||||
|
||||
// Editing again clears the refused save's errors.
|
||||
fireEvent.change(input('Dinner time'), { target: { value: '18:00' } })
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(input('Dinner time')).toHaveAttribute('aria-invalid', 'false')
|
||||
})
|
||||
|
||||
it('shows an error that names no field as a whole-form error', async () => {
|
||||
stubAPI({ 'PATCH /api/v1/settings/household': () => errorResponse(500, 'internal') })
|
||||
await renderPage()
|
||||
fireEvent.change(input('Dinner time'), { target: { value: '18:00' } })
|
||||
fireEvent.click(save())
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Not saved: internal message')
|
||||
expect(input('Dinner time')).toHaveValue('18:00')
|
||||
expect(save()).toBeEnabled()
|
||||
})
|
||||
|
||||
it('asks before leaving with unsaved changes', async () => {
|
||||
stubAPI()
|
||||
await renderPage()
|
||||
fireEvent.change(input('New suggestions time'), { target: { value: '07:00' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
|
||||
const confirm = await screen.findByRole('dialog', { name: 'Leave without saving?' })
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: 'Keep editing' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(input('New suggestions time')).toHaveValue('07:00')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
|
||||
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Leave' }))
|
||||
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
|
||||
})
|
||||
|
||||
it('leaves without asking when nothing changed', async () => {
|
||||
stubAPI()
|
||||
await renderPage()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
|
||||
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
|
||||
})
|
||||
|
||||
it('keeps 44 px tap targets', async () => {
|
||||
stubAPI()
|
||||
await renderPage()
|
||||
for (const control of [
|
||||
screen.getByRole('button', { name: 'Back to Settings' }),
|
||||
within(zoneGroup()).getByRole('button', { name: 'Change' }),
|
||||
input('Dinner time'),
|
||||
input('Plan draft day'),
|
||||
input('Plan draft time'),
|
||||
save(),
|
||||
]) {
|
||||
expectMinHitArea(control)
|
||||
}
|
||||
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
|
||||
expectMinHitArea(within(screen.getByRole('dialog')).getAllByRole('button', { pressed: true })[0]!)
|
||||
})
|
||||
})
|
||||
|
||||
describe('householdSettingsProblems', () => {
|
||||
it('reads each field the server names', () => {
|
||||
const error = new ApiError(
|
||||
400,
|
||||
'invalid_settings',
|
||||
'invalid household settings: plan_draft_dow: invalid day of the week 9 (want 0-6, Sunday = 0); review_time: invalid time "9" (want 24-hour HH:MM, 00:00-23:59)',
|
||||
false,
|
||||
)
|
||||
expect(householdSettingsProblems(error)).toEqual({
|
||||
fields: {
|
||||
plan_draft_dow: 'invalid day of the week 9 (want 0-6, Sunday = 0)',
|
||||
review_time: 'invalid time "9" (want 24-hour HH:MM, 00:00-23:59)',
|
||||
},
|
||||
other: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a problem naming no field, and any other error, whole', () => {
|
||||
expect(
|
||||
householdSettingsProblems(
|
||||
new ApiError(400, 'invalid_settings', 'invalid household settings: settings: no settings to change', false),
|
||||
),
|
||||
).toEqual({ fields: {}, other: ['settings: no settings to change'] })
|
||||
expect(
|
||||
householdSettingsProblems(new ApiError(400, 'invalid_request', 'plan_draft_dow: want a JSON int', false)),
|
||||
).toEqual({ fields: {}, other: ['plan_draft_dow: want a JSON int'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('time zones', () => {
|
||||
it('formats the UTC offset', () => {
|
||||
const winter = new Date('2026-01-15T12:00:00Z')
|
||||
expect(utcOffset('America/New_York', winter)).toBe('UTC-05:00')
|
||||
expect(utcOffset('Asia/Kolkata', winter)).toBe('UTC+05:30')
|
||||
expect(utcOffset('UTC', winter)).toBe('UTC+00:00')
|
||||
})
|
||||
|
||||
it('matches every search word against the name and offset', () => {
|
||||
const option = { zone: 'America/Argentina/Buenos_Aires', offset: 'UTC-03:00' }
|
||||
expect(zoneLabel(option.zone)).toBe('America / Argentina / Buenos Aires')
|
||||
expect(matchesZone(option, 'buenos aires')).toBe(true)
|
||||
expect(matchesZone(option, 'Buenos_Aires')).toBe(true)
|
||||
expect(matchesZone(option, 'utc-03')).toBe(true)
|
||||
expect(matchesZone(option, 'buenos paris')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,398 @@
|
||||
import { Check, ChevronLeft } from 'lucide-react'
|
||||
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
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 { Select } from '../ui/Select.tsx'
|
||||
import { Sheet } from '../ui/Sheet.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { TextInput } from '../ui/TextInput.tsx'
|
||||
import {
|
||||
householdSettingsProblems,
|
||||
useHouseholdSettings,
|
||||
useSaveHouseholdSettings,
|
||||
type HouseholdSettings,
|
||||
type HouseholdSettingsField,
|
||||
type HouseholdSettingsPatch,
|
||||
} from './api.ts'
|
||||
import styles from './HouseholdSettingsPage.module.css'
|
||||
import { LeaveGuard } from './LeaveGuard.tsx'
|
||||
import { matchesZone, timeZoneOptions, utcOffset, zoneLabel } from './timezones.ts'
|
||||
|
||||
// Days of the week as the server counts them, from Sunday = 0.
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const
|
||||
|
||||
// Settings · Household: the time zone and the times the household's
|
||||
// reminders and weekly AI runs happen at. Every field is edited as a draft
|
||||
// and saved together; only the changed fields are sent.
|
||||
export function HouseholdSettingsPage() {
|
||||
const settings = useHouseholdSettings()
|
||||
const [dirty, setDirty] = useState(false)
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Header />
|
||||
<p className={styles.shared}>Changes apply to everyone in the household.</p>
|
||||
{settings.isPending && <Skeleton className={styles.loading} />}
|
||||
{settings.isError && (
|
||||
<ErrorBanner onRetry={() => void settings.refetch()}>
|
||||
Household settings could not load: {settings.error.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
{settings.isSuccess && <HouseholdSettingsForm saved={settings.data} onDirtyChange={setDirty} />}
|
||||
<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>
|
||||
<div>
|
||||
<p className={styles.kicker}>Settings</p>
|
||||
<h1 className={styles.title}>Household</h1>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
// patchOf is the PATCH body for the fields draft changes.
|
||||
function patchOf(saved: HouseholdSettings, draft: HouseholdSettings): HouseholdSettingsPatch {
|
||||
const patch: Record<string, string | number> = {}
|
||||
for (const field of Object.keys(saved) as HouseholdSettingsField[]) {
|
||||
if (draft[field] !== saved[field]) patch[field] = draft[field]
|
||||
}
|
||||
return patch as HouseholdSettingsPatch
|
||||
}
|
||||
|
||||
type HouseholdSettingsFormProps = {
|
||||
saved: HouseholdSettings
|
||||
// Called with whether the form holds unsaved changes.
|
||||
onDirtyChange: (dirty: boolean) => void
|
||||
}
|
||||
|
||||
function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormProps) {
|
||||
const [draft, setDraft] = useState(saved)
|
||||
const save = useSaveHouseholdSettings()
|
||||
const patch = patchOf(saved, draft)
|
||||
const dirty = Object.keys(patch).length > 0
|
||||
const problems = save.isError ? householdSettingsProblems(save.error) : { fields: {}, other: [] }
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange(dirty)
|
||||
}, [dirty, onDirtyChange])
|
||||
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
|
||||
|
||||
function edit(change: Partial<HouseholdSettings>) {
|
||||
save.reset()
|
||||
setDraft((current) => ({ ...current, ...change }))
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
save.mutate(patch, { onSuccess: setDraft })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section title="Time zone">
|
||||
<TimeZoneField
|
||||
value={draft.timezone}
|
||||
saved={saved.timezone}
|
||||
onChange={(timezone) => edit({ timezone })}
|
||||
error={problems.fields.timezone}
|
||||
/>
|
||||
<p className={styles.hint}>Every time on this screen is in this zone.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Dinner">
|
||||
<TimeField
|
||||
draft={draft}
|
||||
edit={edit}
|
||||
problems={problems.fields}
|
||||
name="dinner_time"
|
||||
label="Dinner time"
|
||||
hint="When dinner is on the table. Calendar events start then, and crock-pot dinners count back from it to show when to start."
|
||||
/>
|
||||
<TimeField
|
||||
draft={draft}
|
||||
edit={edit}
|
||||
problems={problems.fields}
|
||||
name="reminder_time"
|
||||
label="Day-of reminder"
|
||||
hint="When we remind you what's for dinner tonight."
|
||||
/>
|
||||
<TimeField
|
||||
draft={draft}
|
||||
edit={edit}
|
||||
problems={problems.fields}
|
||||
name="review_time"
|
||||
label="Rotation check"
|
||||
hint="When we ask whether to keep a new dinner: the day after you try a Quest-log dinner, we ask “Keep it in rotation?”"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Every week">
|
||||
<DayTimeField
|
||||
draft={draft}
|
||||
edit={edit}
|
||||
problems={problems.fields}
|
||||
day="plan_draft_dow"
|
||||
time="plan_draft_time"
|
||||
label="Plan draft"
|
||||
hint="When the AI drafts next week's plan on the Draft board for you to adjust."
|
||||
/>
|
||||
<DayTimeField
|
||||
draft={draft}
|
||||
edit={edit}
|
||||
problems={problems.fields}
|
||||
day="suggestions_dow"
|
||||
time="suggestions_time"
|
||||
label="New suggestions"
|
||||
hint="When a fresh batch of Side quests arrives to swipe through."
|
||||
/>
|
||||
<DayTimeField
|
||||
draft={draft}
|
||||
edit={edit}
|
||||
problems={problems.fields}
|
||||
day="chat_autosend_dow"
|
||||
time="chat_autosend_time"
|
||||
label="Google Chat Supply run"
|
||||
hint="When the week's Supply run is sent to your Google Chat space."
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<div className={styles.status} role="status">
|
||||
{save.isSuccess && !dirty && 'Changes saved.'}
|
||||
</div>
|
||||
{save.isError && (
|
||||
<div className={styles.errors} role="alert">
|
||||
{Object.keys(problems.fields).length > 0 && (
|
||||
<p className={styles.error}>Not saved: fix the settings marked below their fields.</p>
|
||||
)}
|
||||
{problems.other.map((reason) => (
|
||||
<p key={reason} className={styles.error}>
|
||||
Not saved: {reason}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button className={styles.save} disabled={!dirty || save.isPending} onClick={onSave}>
|
||||
{save.isPending ? 'Saving…' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
const titleId = useId()
|
||||
return (
|
||||
<Card role="region" aria-labelledby={titleId} className={styles.card}>
|
||||
<h2 id={titleId} className={styles.cardTitle}>
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({ id, error }: { id: string; error: string | undefined }) {
|
||||
if (error === undefined) return null
|
||||
return (
|
||||
<p id={id} className={styles.error}>
|
||||
{error}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
type FieldProps = {
|
||||
draft: HouseholdSettings
|
||||
edit: (change: Partial<HouseholdSettings>) => void
|
||||
problems: Partial<Record<HouseholdSettingsField, string>>
|
||||
label: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
type TimeFieldProps = FieldProps & { name: 'dinner_time' | 'reminder_time' | 'review_time' }
|
||||
|
||||
function TimeField({ draft, edit, problems, name, label, hint }: TimeFieldProps) {
|
||||
const hintId = useId()
|
||||
const errorId = useId()
|
||||
const error = problems[name]
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<TextInput
|
||||
label={label}
|
||||
type="time"
|
||||
className={styles.time}
|
||||
value={draft[name]}
|
||||
aria-invalid={error !== undefined}
|
||||
aria-describedby={error === undefined ? hintId : `${errorId} ${hintId}`}
|
||||
onChange={(event) => edit({ [name]: event.target.value })}
|
||||
/>
|
||||
<FieldError id={errorId} error={error} />
|
||||
<p id={hintId} className={styles.hint}>
|
||||
{hint}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type DayTimeFieldProps = FieldProps & {
|
||||
day: 'plan_draft_dow' | 'suggestions_dow' | 'chat_autosend_dow'
|
||||
time: 'plan_draft_time' | 'suggestions_time' | 'chat_autosend_time'
|
||||
}
|
||||
|
||||
function DayTimeField({ draft, edit, problems, day, time, label, hint }: DayTimeFieldProps) {
|
||||
const hintId = useId()
|
||||
const dayErrorId = useId()
|
||||
const timeErrorId = useId()
|
||||
const dayError = problems[day]
|
||||
const timeError = problems[time]
|
||||
return (
|
||||
<div className={styles.field} role="group" aria-label={label}>
|
||||
<div className={styles.columns}>
|
||||
<Select
|
||||
label={`${label} day`}
|
||||
value={draft[day]}
|
||||
aria-invalid={dayError !== undefined}
|
||||
aria-describedby={dayError === undefined ? hintId : `${dayErrorId} ${hintId}`}
|
||||
onChange={(event) => edit({ [day]: Number(event.target.value) })}
|
||||
>
|
||||
{days.map((name, index) => (
|
||||
<option key={name} value={index}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<TextInput
|
||||
label={`${label} time`}
|
||||
type="time"
|
||||
className={`${styles.time} ${styles.pairedTime}`}
|
||||
value={draft[time]}
|
||||
aria-invalid={timeError !== undefined}
|
||||
aria-describedby={timeError === undefined ? hintId : `${timeErrorId} ${hintId}`}
|
||||
onChange={(event) => edit({ [time]: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<FieldError id={dayErrorId} error={dayError} />
|
||||
<FieldError id={timeErrorId} error={timeError} />
|
||||
<p id={hintId} className={styles.hint}>
|
||||
{hint}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TimeZoneFieldProps = {
|
||||
value: string
|
||||
saved: string
|
||||
onChange: (zone: string) => void
|
||||
error: string | undefined
|
||||
}
|
||||
|
||||
// TimeZoneField shows the chosen zone and its current UTC offset; Change
|
||||
// opens a searchable list of every IANA zone.
|
||||
function TimeZoneField({ value, saved, onChange, error }: TimeZoneFieldProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const labelId = useId()
|
||||
const errorId = useId()
|
||||
const offset = useMemo(() => utcOffset(value, new Date()), [value])
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<span id={labelId} className={styles.groupLabel}>
|
||||
Time zone
|
||||
</span>
|
||||
<div
|
||||
className={styles.zoneRow}
|
||||
role="group"
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={error === undefined ? undefined : errorId}
|
||||
>
|
||||
<span className={styles.zoneValue}>
|
||||
<span className={styles.zoneName}>{zoneLabel(value)}</span>
|
||||
<span className={styles.offset}>{offset}</span>
|
||||
</span>
|
||||
<button type="button" className={styles.change} onClick={() => setOpen(true)}>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
<FieldError id={errorId} error={error} />
|
||||
{open && (
|
||||
<TimeZoneSheet
|
||||
value={value}
|
||||
saved={saved}
|
||||
onClose={() => setOpen(false)}
|
||||
onChoose={(zone) => {
|
||||
onChange(zone)
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TimeZoneSheetProps = {
|
||||
value: string
|
||||
// The saved zone, offered even after another is chosen.
|
||||
saved: string
|
||||
onClose: () => void
|
||||
onChoose: (zone: string) => void
|
||||
}
|
||||
|
||||
function TimeZoneSheet({ value, saved, onClose, onChoose }: TimeZoneSheetProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const titleId = useId()
|
||||
const options = useMemo(() => timeZoneOptions([saved, value], new Date()), [saved, value])
|
||||
const shown = options.filter((option) => matchesZone(option, search))
|
||||
return (
|
||||
<Sheet open onClose={onClose} aria-labelledby={titleId}>
|
||||
<div className={styles.sheet}>
|
||||
<h2 id={titleId} className={styles.cardTitle}>
|
||||
Choose a time zone
|
||||
</h2>
|
||||
<TextInput
|
||||
label="Search time zones"
|
||||
type="search"
|
||||
value={search}
|
||||
placeholder="City, region or UTC offset"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
<p className={styles.hint} role="status">
|
||||
{shown.length === 0 ? 'No time zone matches.' : `${shown.length} ${shown.length === 1 ? 'time zone' : 'time zones'}`}
|
||||
</p>
|
||||
<ul className={styles.zoneList} aria-label="Time zones">
|
||||
{shown.map((option) => {
|
||||
const current = option.zone === value
|
||||
return (
|
||||
<li key={option.zone}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.zoneOption}
|
||||
aria-pressed={current}
|
||||
aria-label={`${zoneLabel(option.zone)}, ${option.offset}`}
|
||||
onClick={() => onChoose(option.zone)}
|
||||
>
|
||||
<span className={styles.zoneName}>{zoneLabel(option.zone)}</span>
|
||||
<span className={styles.offset}>{option.offset}</span>
|
||||
{current && <Icon icon={Check} className={styles.check} />}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* The confirm sheet the settings screens share. */
|
||||
.sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useId, type ReactNode } from 'react'
|
||||
import { useBlocker } from 'react-router'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Sheet } from '../ui/Sheet.tsx'
|
||||
import styles from './LeaveGuard.module.css'
|
||||
|
||||
// LeaveGuard asks before leaving the screen with unsaved changes.
|
||||
export function LeaveGuard({ dirty }: { dirty: boolean }) {
|
||||
const blocker = useBlocker(
|
||||
({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname,
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!dirty) return
|
||||
const onBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault()
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
}, [dirty])
|
||||
return (
|
||||
<Confirm
|
||||
open={blocker.state === 'blocked'}
|
||||
title="Leave without saving?"
|
||||
confirmLabel="Leave"
|
||||
cancelLabel="Keep editing"
|
||||
onCancel={() => blocker.reset?.()}
|
||||
onConfirm={() => blocker.proceed?.()}
|
||||
>
|
||||
Your changes on this screen have not been saved.
|
||||
</Confirm>
|
||||
)
|
||||
}
|
||||
|
||||
type ConfirmProps = {
|
||||
open: boolean
|
||||
title: string
|
||||
children: ReactNode
|
||||
confirmLabel: string
|
||||
cancelLabel?: string
|
||||
busy?: boolean
|
||||
error?: string | null
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
// Confirm is a sheet asking the household to confirm one action.
|
||||
export function Confirm({
|
||||
open,
|
||||
title,
|
||||
children,
|
||||
confirmLabel,
|
||||
cancelLabel = 'Cancel',
|
||||
busy = false,
|
||||
error = null,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: ConfirmProps) {
|
||||
const titleId = useId()
|
||||
return (
|
||||
<Sheet open={open} onClose={onCancel} aria-labelledby={titleId}>
|
||||
<div className={styles.sheet}>
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
{title}
|
||||
</h2>
|
||||
<p className={styles.text}>{children}</p>
|
||||
{error !== null && (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.actions}>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -4,14 +4,14 @@ import { MemoryRouter, 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, SETTINGS_API } from '../test/ApiProviders.tsx'
|
||||
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { formatClockTime, modelDisplayName } from './api.ts'
|
||||
import { SettingsPage } from './SettingsPage.tsx'
|
||||
|
||||
const HOUSEHOLD = { timezone: 'America/New_York', dinner_time: '17:30' }
|
||||
const HOUSEHOLD = HOUSEHOLD_SETTINGS
|
||||
const AI = AI_SETTINGS
|
||||
|
||||
afterEach(() => {
|
||||
@@ -221,7 +221,7 @@ describe('SettingsPage', () => {
|
||||
|
||||
it('refuses a dinner time that is not HH:MM', async () => {
|
||||
stubSettings({
|
||||
'/api/v1/settings/household': () => jsonResponse(200, { timezone: 'UTC', dinner_time: '5:30pm' }),
|
||||
'/api/v1/settings/household': () => jsonResponse(200, { ...HOUSEHOLD, timezone: 'UTC', dinner_time: '5:30pm' }),
|
||||
})
|
||||
renderSettings()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('dinner_time "5:30pm" is not HH:MM')
|
||||
|
||||
+114
-12
@@ -23,13 +23,45 @@ export const CALENDAR_STATUS_PATH = '/api/v1/settings/calendar'
|
||||
export const NOTIFICATIONS_STATUS_PATH = '/api/v1/settings/notifications'
|
||||
|
||||
// HouseholdSettings is the part of GET /api/v1/settings/household the
|
||||
// settings pages read.
|
||||
// settings pages read. Times are zero-padded 24-hour HH:MM in timezone; days
|
||||
// of the week count from Sunday = 0.
|
||||
export type HouseholdSettings = {
|
||||
timezone: string
|
||||
// Zero-padded 24-hour HH:MM.
|
||||
dinner_time: string
|
||||
reminder_time: string
|
||||
// The rotation check: when the household is asked "Keep it in rotation?".
|
||||
review_time: string
|
||||
plan_draft_dow: number
|
||||
plan_draft_time: string
|
||||
suggestions_dow: number
|
||||
suggestions_time: string
|
||||
chat_autosend_dow: number
|
||||
chat_autosend_time: string
|
||||
}
|
||||
|
||||
// HouseholdSettingsPatch is the PATCH /api/v1/settings/household body: only
|
||||
// the fields that change.
|
||||
export type HouseholdSettingsPatch = Partial<HouseholdSettings>
|
||||
|
||||
export type HouseholdSettingsField = keyof HouseholdSettings
|
||||
|
||||
const householdTimeFields = [
|
||||
'dinner_time',
|
||||
'reminder_time',
|
||||
'review_time',
|
||||
'plan_draft_time',
|
||||
'suggestions_time',
|
||||
'chat_autosend_time',
|
||||
] as const satisfies readonly HouseholdSettingsField[]
|
||||
|
||||
const householdDayFields = [
|
||||
'plan_draft_dow',
|
||||
'suggestions_dow',
|
||||
'chat_autosend_dow',
|
||||
] as const satisfies readonly HouseholdSettingsField[]
|
||||
|
||||
const householdFields: readonly HouseholdSettingsField[] = ['timezone', ...householdTimeFields, ...householdDayFields]
|
||||
|
||||
// aiProviderLabels names every AI provider preset the server accepts
|
||||
// (internal/ai/llm/presets) as the household reads it.
|
||||
export const aiProviderLabels = {
|
||||
@@ -301,22 +333,92 @@ export function useTestRecipeSearch() {
|
||||
|
||||
const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
|
||||
|
||||
// useHouseholdSettings refuses a dinner time that is not HH:MM, so
|
||||
// formatClockTime never sees one.
|
||||
// parseHouseholdSettings refuses a body that is not the household settings,
|
||||
// or has a time that is not HH:MM, so formatClockTime never sees one.
|
||||
function parseHouseholdSettings(where: string, body: unknown): HouseholdSettings {
|
||||
if (!isRecord(body) || typeof body.timezone !== 'string') {
|
||||
throw new Error(`${where}: response is not the household settings`)
|
||||
}
|
||||
const times = {} as Record<(typeof householdTimeFields)[number], string>
|
||||
for (const field of householdTimeFields) {
|
||||
const value = body[field]
|
||||
if (typeof value !== 'string' || !clockTime.test(value)) {
|
||||
throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not HH:MM`)
|
||||
}
|
||||
times[field] = value
|
||||
}
|
||||
const days = {} as Record<(typeof householdDayFields)[number], number>
|
||||
for (const field of householdDayFields) {
|
||||
const value = body[field]
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 6) {
|
||||
throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not a day of the week (0-6)`)
|
||||
}
|
||||
days[field] = value
|
||||
}
|
||||
return { timezone: body.timezone, ...times, ...days }
|
||||
}
|
||||
|
||||
const HOUSEHOLD_SETTINGS_KEY = ['settings', 'household'] as const
|
||||
|
||||
export function useHouseholdSettings() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: ['settings', 'household'],
|
||||
queryFn: async () => {
|
||||
const settings = await apiFetch<HouseholdSettings>(HOUSEHOLD_SETTINGS_PATH)
|
||||
if (!clockTime.test(settings.dinner_time)) {
|
||||
throw new Error(`GET ${HOUSEHOLD_SETTINGS_PATH}: dinner_time "${settings.dinner_time}" is not HH:MM`)
|
||||
}
|
||||
return { timezone: settings.timezone, dinner_time: settings.dinner_time } satisfies HouseholdSettings
|
||||
},
|
||||
queryKey: HOUSEHOLD_SETTINGS_KEY,
|
||||
queryFn: async () =>
|
||||
parseHouseholdSettings(`GET ${HOUSEHOLD_SETTINGS_PATH}`, await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH)),
|
||||
})
|
||||
}
|
||||
|
||||
export function useSaveHouseholdSettings() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (patch: HouseholdSettingsPatch) =>
|
||||
parseHouseholdSettings(
|
||||
`PATCH ${HOUSEHOLD_SETTINGS_PATH}`,
|
||||
await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH, { method: 'PATCH', body: patch }),
|
||||
),
|
||||
onSuccess: (settings) => queryClient.setQueryData(HOUSEHOLD_SETTINGS_KEY, settings),
|
||||
})
|
||||
}
|
||||
|
||||
// HouseholdSettingsProblems is a refused household settings save: the reason
|
||||
// for each field the server named, and any reasons that name no field.
|
||||
export type HouseholdSettingsProblems = {
|
||||
fields: Partial<Record<HouseholdSettingsField, string>>
|
||||
other: string[]
|
||||
}
|
||||
|
||||
const invalidHouseholdPrefix = 'invalid household settings: '
|
||||
|
||||
// A problem starts with a field name and ": " (internal/household
|
||||
// ValidationError.Error joins them with "; ").
|
||||
const problemStart = /; (?=[a-z_]+: )/
|
||||
|
||||
// householdSettingsProblems reads the server's invalid_settings error into
|
||||
// the problems it lists. Any other error is one problem naming no field.
|
||||
export function householdSettingsProblems(error: Error): HouseholdSettingsProblems {
|
||||
const problems: HouseholdSettingsProblems = { fields: {}, other: [] }
|
||||
if (
|
||||
!(error instanceof ApiError) ||
|
||||
error.code !== 'invalid_settings' ||
|
||||
!error.message.startsWith(invalidHouseholdPrefix)
|
||||
) {
|
||||
problems.other.push(error.message)
|
||||
return problems
|
||||
}
|
||||
for (const problem of error.message.slice(invalidHouseholdPrefix.length).split(problemStart)) {
|
||||
const colon = problem.indexOf(': ')
|
||||
const field = problem.slice(0, colon)
|
||||
if (colon !== -1 && (householdFields as readonly string[]).includes(field)) {
|
||||
problems.fields[field as HouseholdSettingsField] = problem.slice(colon + 2)
|
||||
} else {
|
||||
problems.other.push(problem)
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// aiStatus is the AI assistant row's status line: the provider and the
|
||||
// model's display name, or that no model is chosen yet (the seed).
|
||||
export function aiStatus(settings: Pick<AISettings, 'provider' | 'model'>): string {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// The IANA time zones the browser knows, with their current UTC offsets, for
|
||||
// the household time zone picker.
|
||||
|
||||
export type TimeZoneOption = {
|
||||
zone: string
|
||||
// "UTC-04:00"; "UTC+00:00" for UTC itself.
|
||||
offset: string
|
||||
}
|
||||
|
||||
// utcOffset is zone's offset from UTC at instant, as "UTC+05:30".
|
||||
export function utcOffset(zone: string, instant: Date): string {
|
||||
const name = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'longOffset' })
|
||||
.formatToParts(instant)
|
||||
.find((part) => part.type === 'timeZoneName')?.value
|
||||
if (name === undefined) throw new Error(`no UTC offset for time zone "${zone}"`)
|
||||
if (name === 'GMT') return 'UTC+00:00'
|
||||
if (!/^GMT[+-]\d\d:\d\d$/.test(name)) throw new Error(`time zone "${zone}" has an unreadable offset "${name}"`)
|
||||
return `UTC${name.slice(3)}`
|
||||
}
|
||||
|
||||
// Zones the server accepts that Intl.supportedValuesOf('timeZone') leaves
|
||||
// out: it lists only canonical zones, and UTC is not one of them.
|
||||
const unlistedZones = ['UTC', 'Etc/UTC', 'GMT', 'Etc/GMT']
|
||||
|
||||
// timeZoneOptions lists every zone the browser supports, the unlisted UTC
|
||||
// zones, and kept (the saved and chosen zones, which may be aliases the
|
||||
// list leaves out), sorted by name.
|
||||
export function timeZoneOptions(kept: string[], instant: Date): TimeZoneOption[] {
|
||||
const zones = new Set([...Intl.supportedValuesOf('timeZone'), ...unlistedZones, ...kept])
|
||||
return [...zones].sort().map((zone) => ({ zone, offset: utcOffset(zone, instant) }))
|
||||
}
|
||||
|
||||
// zoneLabel reads a zone as words: "America/Argentina/Buenos_Aires" is
|
||||
// "America / Argentina / Buenos Aires".
|
||||
export function zoneLabel(zone: string): string {
|
||||
return zone.replaceAll('_', ' ').replaceAll('/', ' / ')
|
||||
}
|
||||
|
||||
// matchesZone is whether every word of the search text is in option's name
|
||||
// or offset, in any case.
|
||||
export function matchesZone(option: TimeZoneOption, search: string): boolean {
|
||||
const needle = search.trim().toLowerCase()
|
||||
if (needle === '') return true
|
||||
const haystack = `${option.zone} ${zoneLabel(option.zone)} ${option.offset}`.toLowerCase()
|
||||
return needle.split(/\s+/).every((word) => haystack.includes(word))
|
||||
}
|
||||
@@ -16,6 +16,26 @@ export const AI_SETTINGS = {
|
||||
updated_at: '2026-10-01T15:00:00.000Z',
|
||||
}
|
||||
|
||||
// HOUSEHOLD_SETTINGS is a GET /api/v1/settings/household body: the seed
|
||||
// times in New York.
|
||||
export const HOUSEHOLD_SETTINGS = {
|
||||
timezone: 'America/New_York',
|
||||
dinner_time: '17:30',
|
||||
reminder_time: '16:30',
|
||||
review_time: '09:00',
|
||||
plan_draft_dow: 5,
|
||||
plan_draft_time: '09:00',
|
||||
suggestions_dow: 0,
|
||||
suggestions_time: '08:00',
|
||||
chat_autosend_dow: 6,
|
||||
chat_autosend_time: '08:00',
|
||||
onboarding_completed_at: null,
|
||||
updates_seen_at: null,
|
||||
updated_by: null,
|
||||
created_at: '2026-10-01T15:00:00.000Z',
|
||||
updated_at: '2026-10-01T15:00:00.000Z',
|
||||
}
|
||||
|
||||
// RECIPE_SEARCH_SETTINGS is a GET /api/v1/settings/recipe-search body: the
|
||||
// SearXNG sidecar, no Brave key stored.
|
||||
export const RECIPE_SEARCH_SETTINGS = {
|
||||
@@ -34,7 +54,7 @@ export const SETTINGS_API = {
|
||||
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
|
||||
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
||||
'/api/v1/settings/household': () => jsonResponse(200, { timezone: 'America/New_York', dinner_time: '17:30' }),
|
||||
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user