From 6e01f4e0692a295c5999d2c12511b99ad09bd0c7 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 11:14:49 -0400 Subject: [PATCH] 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) })) }