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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 () => {
|
it('says when no time zone matches the search', async () => {
|
||||||
stubAPI()
|
stubAPI()
|
||||||
await renderPage()
|
await renderPage()
|
||||||
|
|||||||
@@ -101,7 +101,12 @@ function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormPr
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section title="Time zone">
|
<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>
|
<p className={styles.hint}>Every time on this screen is in this zone.</p>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
@@ -289,13 +294,14 @@ function DayTimeField({ draft, edit, problems, day, time, label, hint }: DayTime
|
|||||||
|
|
||||||
type TimeZoneFieldProps = {
|
type TimeZoneFieldProps = {
|
||||||
value: string
|
value: string
|
||||||
|
saved: string
|
||||||
onChange: (zone: string) => void
|
onChange: (zone: string) => void
|
||||||
error: string | undefined
|
error: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// TimeZoneField shows the chosen zone and its current UTC offset; Change
|
// TimeZoneField shows the chosen zone and its current UTC offset; Change
|
||||||
// opens a searchable list of every IANA zone.
|
// 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 [open, setOpen] = useState(false)
|
||||||
const labelId = useId()
|
const labelId = useId()
|
||||||
const errorId = useId()
|
const errorId = useId()
|
||||||
@@ -323,6 +329,7 @@ function TimeZoneField({ value, onChange, error }: TimeZoneFieldProps) {
|
|||||||
{open && (
|
{open && (
|
||||||
<TimeZoneSheet
|
<TimeZoneSheet
|
||||||
value={value}
|
value={value}
|
||||||
|
saved={saved}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
onChoose={(zone) => {
|
onChoose={(zone) => {
|
||||||
onChange(zone)
|
onChange(zone)
|
||||||
@@ -336,14 +343,16 @@ function TimeZoneField({ value, onChange, error }: TimeZoneFieldProps) {
|
|||||||
|
|
||||||
type TimeZoneSheetProps = {
|
type TimeZoneSheetProps = {
|
||||||
value: string
|
value: string
|
||||||
|
// The saved zone, offered even after another is chosen.
|
||||||
|
saved: string
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onChoose: (zone: string) => void
|
onChoose: (zone: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimeZoneSheet({ value, onClose, onChoose }: TimeZoneSheetProps) {
|
function TimeZoneSheet({ value, saved, onClose, onChoose }: TimeZoneSheetProps) {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const titleId = useId()
|
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))
|
const shown = options.filter((option) => matchesZone(option, search))
|
||||||
return (
|
return (
|
||||||
<Sheet open onClose={onClose} aria-labelledby={titleId}>
|
<Sheet open onClose={onClose} aria-labelledby={titleId}>
|
||||||
|
|||||||
@@ -18,11 +18,15 @@ export function utcOffset(zone: string, instant: Date): string {
|
|||||||
return `UTC${name.slice(3)}`
|
return `UTC${name.slice(3)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// timeZoneOptions lists every zone the browser supports, plus current (the
|
// Zones the server accepts that Intl.supportedValuesOf('timeZone') leaves
|
||||||
// saved zone, which may be an alias the list leaves out), sorted by name.
|
// out: it lists only canonical zones, and UTC is not one of them.
|
||||||
export function timeZoneOptions(current: string, instant: Date): TimeZoneOption[] {
|
const unlistedZones = ['UTC', 'Etc/UTC', 'GMT', 'Etc/GMT']
|
||||||
const zones = new Set(Intl.supportedValuesOf('timeZone'))
|
|
||||||
zones.add(current)
|
// 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) }))
|
return [...zones].sort().map((zone) => ({ zone, offset: utcOffset(zone, instant) }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user