From 85d3c1684a47d7e982b4388c2271bbff1d265444 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 11:06:09 -0400 Subject: [PATCH 1/2] Add the household settings screen /settings/household edits the time zone (a searchable IANA list with each zone's current UTC offset), dinner time, day-of reminder, rotation check, and the plan draft, new suggestions and Google Chat Supply run day and time, with plain-word copy for each and "Changes apply to everyone in the household." Save PATCHes only the changed fields; the API's invalid_settings problems show under their fields. The leave guard and confirm sheet move out of the AI settings page so both screens share them. Maestro task 48f8b8c3-7d5c-415d-9a61-980dc065bb90. Co-Authored-By: Claude Opus 5.5 --- web/src/App.test.tsx | 16 +- web/src/App.tsx | 2 + web/src/settings/AISettingsPage.tsx | 77 +--- .../settings/HouseholdSettingsPage.module.css | 231 +++++++++++ .../settings/HouseholdSettingsPage.test.tsx | 319 ++++++++++++++ web/src/settings/HouseholdSettingsPage.tsx | 389 ++++++++++++++++++ web/src/settings/LeaveGuard.module.css | 38 ++ web/src/settings/LeaveGuard.tsx | 80 ++++ web/src/settings/SettingsPage.test.tsx | 6 +- web/src/settings/api.ts | 126 +++++- web/src/settings/timezones.ts | 42 ++ web/src/test/ApiProviders.tsx | 22 +- 12 files changed, 1255 insertions(+), 93 deletions(-) create mode 100644 web/src/settings/HouseholdSettingsPage.module.css create mode 100644 web/src/settings/HouseholdSettingsPage.test.tsx create mode 100644 web/src/settings/HouseholdSettingsPage.tsx create mode 100644 web/src/settings/LeaveGuard.module.css create mode 100644 web/src/settings/LeaveGuard.tsx create mode 100644 web/src/settings/timezones.ts diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 77ff5db..82d8105 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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', () => { diff --git a/web/src/App.tsx b/web/src/App.tsx index 1f8553b..8c8e2ef 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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> = { '/settings': , '/settings/ai': , + '/settings/household': , } // AppRoutes is the shell and its pages, shown once /api/v1/me has loaded. diff --git a/web/src/settings/AISettingsPage.tsx b/web/src/settings/AISettingsPage.tsx index 18b8432..1be44e5 100644 --- a/web/src/settings/AISettingsPage.tsx +++ b/web/src/settings/AISettingsPage.tsx @@ -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 ( - blocker.reset?.()} - onConfirm={() => blocker.proceed?.()} - > - Your changes on this screen have not been saved. - - ) -} - function Header() { const navigate = useNavigate() return ( @@ -537,52 +513,3 @@ function PromptField({ value, onChange }: { value: string; onChange: (value: str ) } - -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 ( - -
-

- {title} -

-

{children}

- {error !== null && ( -

- {error} -

- )} -
- - -
-
-
- ) -} diff --git a/web/src/settings/HouseholdSettingsPage.module.css b/web/src/settings/HouseholdSettingsPage.module.css new file mode 100644 index 0000000..0ae02bc --- /dev/null +++ b/web/src/settings/HouseholdSettingsPage.module.css @@ -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; +} diff --git a/web/src/settings/HouseholdSettingsPage.test.tsx b/web/src/settings/HouseholdSettingsPage.test.tsx new file mode 100644 index 0000000..d94ed57 --- /dev/null +++ b/web/src/settings/HouseholdSettingsPage.test.tsx @@ -0,0 +1,319 @@ +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 + +// 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 = {}): Mock { + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + 'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), + ...responders, + } + const fetchMock = vi.fn((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) { + 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

{useLocation().pathname}

+} + +// 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( + + + + + } /> + } /> + + + + , + ) + 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( + + + + + + + , + ) + 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('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) + }) +}) diff --git a/web/src/settings/HouseholdSettingsPage.tsx b/web/src/settings/HouseholdSettingsPage.tsx new file mode 100644 index 0000000..4f8d1f9 --- /dev/null +++ b/web/src/settings/HouseholdSettingsPage.tsx @@ -0,0 +1,389 @@ +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 ( +
+
+

Changes apply to everyone in the household.

+ {settings.isPending && } + {settings.isError && ( + void settings.refetch()}> + Household settings could not load: {settings.error.message} + + )} + {settings.isSuccess && } + +
+ ) +} + +function Header() { + const navigate = useNavigate() + return ( +
+ void navigate('/settings')}> + + +
+

Settings

+

Household

+
+
+ ) +} + +// patchOf is the PATCH body for the fields draft changes. +function patchOf(saved: HouseholdSettings, draft: HouseholdSettings): HouseholdSettingsPatch { + const patch: Record = {} + 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) { + save.reset() + setDraft((current) => ({ ...current, ...change })) + } + + function onSave() { + save.mutate(patch, { onSuccess: setDraft }) + } + + return ( + <> +
+ edit({ timezone })} error={problems.fields.timezone} /> +

Every time on this screen is in this zone.

+
+ +
+ + + +
+ +
+ + + +
+ +
+ {save.isSuccess && !dirty && 'Changes saved.'} +
+ {save.isError && ( +
+ {Object.keys(problems.fields).length > 0 && ( +

Not saved: fix the settings marked below their fields.

+ )} + {problems.other.map((reason) => ( +

+ Not saved: {reason} +

+ ))} +
+ )} + +
+ +
+ + ) +} + +function Section({ title, children }: { title: string; children: ReactNode }) { + const titleId = useId() + return ( + +

+ {title} +

+ {children} +
+ ) +} + +function FieldError({ id, error }: { id: string; error: string | undefined }) { + if (error === undefined) return null + return ( +

+ {error} +

+ ) +} + +type FieldProps = { + draft: HouseholdSettings + edit: (change: Partial) => void + problems: Partial> + 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 ( +
+ edit({ [name]: event.target.value })} + /> + +

+ {hint} +

+
+ ) +} + +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 ( +
+
+ + edit({ [time]: event.target.value })} + /> +
+ + +

+ {hint} +

+
+ ) +} + +type TimeZoneFieldProps = { + value: 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, onChange, error }: TimeZoneFieldProps) { + const [open, setOpen] = useState(false) + const labelId = useId() + const errorId = useId() + const offset = useMemo(() => utcOffset(value, new Date()), [value]) + return ( +
+ + Time zone + +
+ + {zoneLabel(value)} + {offset} + + +
+ + {open && ( + setOpen(false)} + onChoose={(zone) => { + onChange(zone) + setOpen(false) + }} + /> + )} +
+ ) +} + +type TimeZoneSheetProps = { + value: string + onClose: () => void + onChoose: (zone: string) => void +} + +function TimeZoneSheet({ value, onClose, onChoose }: TimeZoneSheetProps) { + const [search, setSearch] = useState('') + const titleId = useId() + const options = useMemo(() => timeZoneOptions(value, new Date()), [value]) + const shown = options.filter((option) => matchesZone(option, search)) + return ( + +
+

+ Choose a time zone +

+ setSearch(event.target.value)} + /> +

+ {shown.length === 0 ? 'No time zone matches.' : `${shown.length} ${shown.length === 1 ? 'time zone' : 'time zones'}`} +

+
    + {shown.map((option) => { + const current = option.zone === value + return ( +
  • + +
  • + ) + })} +
+
+
+ ) +} diff --git a/web/src/settings/LeaveGuard.module.css b/web/src/settings/LeaveGuard.module.css new file mode 100644 index 0000000..aa98bea --- /dev/null +++ b/web/src/settings/LeaveGuard.module.css @@ -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; +} diff --git a/web/src/settings/LeaveGuard.tsx b/web/src/settings/LeaveGuard.tsx new file mode 100644 index 0000000..35ae711 --- /dev/null +++ b/web/src/settings/LeaveGuard.tsx @@ -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 ( + blocker.reset?.()} + onConfirm={() => blocker.proceed?.()} + > + Your changes on this screen have not been saved. + + ) +} + +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 ( + +
+

+ {title} +

+

{children}

+ {error !== null && ( +

+ {error} +

+ )} +
+ + +
+
+
+ ) +} diff --git a/web/src/settings/SettingsPage.test.tsx b/web/src/settings/SettingsPage.test.tsx index 5a0c28c..5a505bd 100644 --- a/web/src/settings/SettingsPage.test.tsx +++ b/web/src/settings/SettingsPage.test.tsx @@ -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') diff --git a/web/src/settings/api.ts b/web/src/settings/api.ts index 80f8c25..310368f 100644 --- a/web/src/settings/api.ts +++ b/web/src/settings/api.ts @@ -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 + +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(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(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(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> + 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): string { diff --git a/web/src/settings/timezones.ts b/web/src/settings/timezones.ts new file mode 100644 index 0000000..57097d9 --- /dev/null +++ b/web/src/settings/timezones.ts @@ -0,0 +1,42 @@ +// 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)}` +} + +// timeZoneOptions lists every zone the browser supports, plus current (the +// saved zone, which may be an alias the list leaves out), sorted by name. +export function timeZoneOptions(current: string, instant: Date): TimeZoneOption[] { + const zones = new Set(Intl.supportedValuesOf('timeZone')) + zones.add(current) + 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)) +} diff --git a/web/src/test/ApiProviders.tsx b/web/src/test/ApiProviders.tsx index 81d20c2..76d5f47 100644 --- a/web/src/test/ApiProviders.tsx +++ b/web/src/test/ApiProviders.tsx @@ -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), } -- 2.55.0 From 6e01f4e0692a295c5999d2c12511b99ad09bd0c7 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 11:14:49 -0400 Subject: [PATCH 2/2] Offer the UTC zones and the saved zone in the time zone picker Intl.supportedValuesOf('timeZone') lists only canonical zones, so UTC, Etc/UTC, GMT and Etc/GMT, which the server accepts, could not be chosen. Merge them into the list, and keep the saved zone listed after another is chosen so an alias such as US/Eastern can be chosen back. Co-Authored-By: Claude Opus 5.5 --- .../settings/HouseholdSettingsPage.test.tsx | 47 +++++++++++++++++++ web/src/settings/HouseholdSettingsPage.tsx | 17 +++++-- web/src/settings/timezones.ts | 14 ++++-- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/web/src/settings/HouseholdSettingsPage.test.tsx b/web/src/settings/HouseholdSettingsPage.test.tsx index d94ed57..a42e055 100644 --- a/web/src/settings/HouseholdSettingsPage.test.tsx +++ b/web/src/settings/HouseholdSettingsPage.test.tsx @@ -181,6 +181,53 @@ describe('HouseholdSettingsPage', () => { ]) }) + 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() diff --git a/web/src/settings/HouseholdSettingsPage.tsx b/web/src/settings/HouseholdSettingsPage.tsx index 4f8d1f9..6d763b9 100644 --- a/web/src/settings/HouseholdSettingsPage.tsx +++ b/web/src/settings/HouseholdSettingsPage.tsx @@ -101,7 +101,12 @@ function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormPr return ( <>
- edit({ timezone })} error={problems.fields.timezone} /> + edit({ timezone })} + error={problems.fields.timezone} + />

Every time on this screen is in this zone.

@@ -289,13 +294,14 @@ function DayTimeField({ draft, edit, problems, day, time, label, hint }: DayTime 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, onChange, error }: TimeZoneFieldProps) { +function TimeZoneField({ value, saved, onChange, error }: TimeZoneFieldProps) { const [open, setOpen] = useState(false) const labelId = useId() const errorId = useId() @@ -323,6 +329,7 @@ function TimeZoneField({ value, onChange, error }: TimeZoneFieldProps) { {open && ( setOpen(false)} onChoose={(zone) => { onChange(zone) @@ -336,14 +343,16 @@ function TimeZoneField({ value, onChange, error }: TimeZoneFieldProps) { type TimeZoneSheetProps = { value: string + // The saved zone, offered even after another is chosen. + saved: string onClose: () => void onChoose: (zone: string) => void } -function TimeZoneSheet({ value, onClose, onChoose }: TimeZoneSheetProps) { +function TimeZoneSheet({ value, saved, onClose, onChoose }: TimeZoneSheetProps) { const [search, setSearch] = useState('') const titleId = useId() - const options = useMemo(() => timeZoneOptions(value, new Date()), [value]) + const options = useMemo(() => timeZoneOptions([saved, value], new Date()), [saved, value]) const shown = options.filter((option) => matchesZone(option, search)) return ( diff --git a/web/src/settings/timezones.ts b/web/src/settings/timezones.ts index 57097d9..a05bfb9 100644 --- a/web/src/settings/timezones.ts +++ b/web/src/settings/timezones.ts @@ -18,11 +18,15 @@ export function utcOffset(zone: string, instant: Date): string { return `UTC${name.slice(3)}` } -// timeZoneOptions lists every zone the browser supports, plus current (the -// saved zone, which may be an alias the list leaves out), sorted by name. -export function timeZoneOptions(current: string, instant: Date): TimeZoneOption[] { - const zones = new Set(Intl.supportedValuesOf('timeZone')) - zones.add(current) +// 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) })) } -- 2.55.0