Offer the UTC zones and the saved zone in the time zone picker
CI / ci (push) Successful in 1m57s
CI / ci (pull_request) Successful in 2m0s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 11:14:49 -04:00
co-authored by Claude Opus 5.5
parent 85d3c1684a
commit 6e01f4e069
3 changed files with 69 additions and 9 deletions
@@ -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()
+13 -4
View File
@@ -101,7 +101,12 @@ function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormPr
return (
<>
<Section title="Time zone">
<TimeZoneField value={draft.timezone} onChange={(timezone) => edit({ timezone })} error={problems.fields.timezone} />
<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>
@@ -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 && (
<TimeZoneSheet
value={value}
saved={saved}
onClose={() => 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 (
<Sheet open onClose={onClose} aria-labelledby={titleId}>
+9 -5
View File
@@ -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) }))
}