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 () => {
|
||||
stubAPI()
|
||||
await renderPage()
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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) }))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user