Skip to content

Commit 7eea0fd

Browse files
authored
feat: add a time zone selector to user preferences (#3449)
1 parent ce6654d commit 7eea0fd

8 files changed

Lines changed: 317 additions & 27 deletions

File tree

‎.changeset/time-zone-select.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hyperdx/app': minor
3+
---
4+
5+
feat: Pick any time zone in User Preferences, replacing the "Use UTC time" switch. Local and UTC stay pinned at the top of the list

‎packages/app/src/UserPreferencesModal.tsx‎

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,24 @@
11
import * as React from 'react';
22
import { Badge } from '@clickhouse/click-ui';
3-
import { UTC_TIME_ZONE } from '@hyperdx/common-utils/dist/core/utils';
43
import {
54
Autocomplete,
65
Divider,
76
Group,
87
Modal,
98
Select,
109
Stack,
11-
Switch,
1210
Text,
1311
Tooltip,
1412
} from '@mantine/core';
1513

14+
import { TimeZoneSelect } from './components/TimeZoneSelect';
1615
import {
1716
isContentFontSize,
1817
OPTIONS_CONTENT_FONT_SIZE,
1918
OPTIONS_FONTS,
2019
} from './config/fonts';
2120
import { useAppTheme } from './theme/ThemeProvider';
2221
import { isValidThemeName, themes } from './theme';
23-
import { LOCAL_TIME_ZONE } from './timeZone';
2422
import {
2523
DEFAULT_ROW_CLICK_ACTION,
2624
RowClickAction,
@@ -60,7 +58,7 @@ const SettingContainer = ({
6058
<div style={{ flex: 1 }}>
6159
{label}
6260
{!!description && (
63-
<Text size="xs" mt={2}>
61+
<Text size="xs" mt={2} c="var(--color-text-muted)">
6462
{description}
6563
</Text>
6664
)}
@@ -112,18 +110,14 @@ export const UserPreferencesModal = ({
112110
allowDeselect={false}
113111
/>
114112
</SettingContainer>
115-
<SettingContainer label="Use UTC time">
116-
<Switch
117-
size="md"
118-
onLabel="UTC"
119-
checked={userPreferences.timeZone === UTC_TIME_ZONE}
120-
onChange={e =>
121-
setUserPreference({
122-
timeZone: e.currentTarget.checked
123-
? UTC_TIME_ZONE
124-
: LOCAL_TIME_ZONE,
125-
})
126-
}
113+
<SettingContainer
114+
label="Time zone"
115+
description="Times are displayed and input in this zone."
116+
>
117+
<TimeZoneSelect
118+
data-testid="time-zone-select"
119+
value={userPreferences.timeZone}
120+
onChange={timeZone => setUserPreference({ timeZone })}
127121
/>
128122
</SettingContainer>
129123

@@ -194,11 +188,11 @@ export const UserPreferencesModal = ({
194188

195189
{/*
196190
Brand Theme Selector - DEV MODE ONLY
197-
191+
198192
This is intentionally NOT available in production. Brand theme (HyperDX vs ClickStack)
199193
is deployment-configured via NEXT_PUBLIC_THEME environment variable.
200194
Each deployment is branded for a specific product; users don't choose this.
201-
195+
202196
This dev-only UI exists for testing theme implementations during development.
203197
*/}
204198
{isDev && (

‎packages/app/src/__tests__/UserPreferencesModal.test.tsx‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,25 @@ const readStoredPreferences = (): UserPreferences =>
2727
JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '{}');
2828

2929
describe('UserPreferencesModal', () => {
30+
describe('Time zone', () => {
31+
it('reflects a stored zone', () => {
32+
renderModal({ timeZone: 'Etc/UTC' });
33+
34+
expect(screen.getByTestId('time-zone-select')).toHaveValue('UTC');
35+
});
36+
37+
it('stores the selected zone', async () => {
38+
renderModal({ timeZone: 'local' });
39+
40+
await userEvent.click(screen.getByTestId('time-zone-select'));
41+
await userEvent.click(
42+
await screen.findByRole('option', { name: /^UTC/, hidden: true }),
43+
);
44+
45+
expect(readStoredPreferences().timeZone).toBe('Etc/UTC');
46+
});
47+
});
48+
3049
describe('Row click', () => {
3150
it('shows the default action for users with no stored preference', () => {
3251
renderModal();

‎packages/app/src/__tests__/timeZone.test.ts‎

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import {
2+
formatTimeZoneName,
3+
getSupportedTimeZones,
4+
getTimeZoneOffsetLabel,
25
getTimeZoneShortName,
36
isValidTimeZone,
47
LOCAL_TIME_ZONE,
@@ -21,6 +24,39 @@ describe('resolveTimeZonePreference', () => {
2124
});
2225
});
2326

27+
describe('getTimeZoneOffsetLabel', () => {
28+
it('uses the offset in effect on the date', () => {
29+
expect(
30+
getTimeZoneOffsetLabel('America/Los_Angeles', new Date('2026-07-01Z')),
31+
).toBe('UTC-07:00');
32+
expect(
33+
getTimeZoneOffsetLabel('America/Los_Angeles', new Date('2026-12-01Z')),
34+
).toBe('UTC-08:00');
35+
expect(getTimeZoneOffsetLabel('Asia/Kolkata')).toBe('UTC+05:30');
36+
});
37+
38+
it('labels UTC with a zero offset', () => {
39+
expect(getTimeZoneOffsetLabel('Etc/UTC')).toBe('UTC+00:00');
40+
});
41+
});
42+
43+
describe('getSupportedTimeZones', () => {
44+
it('lists named zones without UTC', () => {
45+
const zones = getSupportedTimeZones();
46+
expect(zones).toContain('Asia/Tokyo');
47+
expect(zones).not.toContain('UTC');
48+
expect(zones).not.toContain('Etc/UTC');
49+
});
50+
});
51+
52+
describe('formatTimeZoneName', () => {
53+
it('replaces underscores with spaces', () => {
54+
expect(formatTimeZoneName('America/Los_Angeles')).toBe(
55+
'America/Los Angeles',
56+
);
57+
});
58+
});
59+
2460
describe('getTimeZoneShortName', () => {
2561
it('uses the abbreviation in effect on the date', () => {
2662
expect(
@@ -31,8 +67,15 @@ describe('getTimeZoneShortName', () => {
3167
).toBe('PST');
3268
});
3369

34-
it('names UTC and zones without an abbreviation', () => {
70+
it('names UTC and labels zones without an abbreviation from UTC', () => {
3571
expect(getTimeZoneShortName('Etc/UTC')).toBe('UTC');
36-
expect(getTimeZoneShortName('Asia/Tokyo')).toBe('GMT+9');
72+
expect(getTimeZoneShortName('Asia/Tokyo')).toBe('UTC+9');
73+
expect(getTimeZoneShortName('Asia/Kolkata')).toBe('UTC+5:30');
74+
expect(getTimeZoneShortName('Europe/London', new Date('2026-01-15Z'))).toBe(
75+
'UTC',
76+
);
77+
expect(getTimeZoneShortName('Europe/London', new Date('2026-07-15Z'))).toBe(
78+
'UTC+1',
79+
);
3780
});
3881
});

‎packages/app/src/components/TimePicker/utils.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import {
66
toWallClock,
77
} from '@hyperdx/common-utils/dist/core/utils';
88

9+
import { getLocalTimeZone } from '@/timeZone';
10+
911
function currentDate(): Date {
1012
// eslint-disable-next-line no-restricted-syntax
1113
return new Date();
1214
}
1315

1416
function resolveTimeZone(timeZone?: string): string {
15-
return timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
17+
return timeZone ?? getLocalTimeZone();
1618
}
1719

1820
// chrono reads text in one fixed offset. Using the zone's current offset makes
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { useCallback, useMemo } from 'react';
2+
import { UTC_TIME_ZONE } from '@hyperdx/common-utils/dist/core/utils';
3+
import {
4+
Box,
5+
ComboboxData,
6+
ComboboxItem,
7+
Group,
8+
Select,
9+
SelectProps,
10+
Text,
11+
} from '@mantine/core';
12+
import { IconCheck } from '@tabler/icons-react';
13+
14+
import {
15+
formatTimeZoneName,
16+
getLocalTimeZone,
17+
getSupportedTimeZones,
18+
getTimeZoneOffsetLabel,
19+
getTimeZoneShortName,
20+
LOCAL_TIME_ZONE,
21+
resolveTimeZonePreference,
22+
} from '@/timeZone';
23+
24+
type TimeZoneSelectProps = Omit<
25+
SelectProps,
26+
'value' | 'onChange' | 'data' | 'renderOption'
27+
> & {
28+
/** A time zone setting: `LOCAL_TIME_ZONE` or an IANA name. */
29+
value: string;
30+
onChange: (value: string) => void;
31+
};
32+
33+
/** Offsets shown next to each option, keyed by option value. */
34+
function getOffsetLabels(zones: string[]): Map<string, string> {
35+
const localZone = getLocalTimeZone();
36+
37+
return new Map([
38+
[
39+
LOCAL_TIME_ZONE,
40+
`${getTimeZoneShortName(localZone)} · ${getTimeZoneOffsetLabel(localZone)}`,
41+
],
42+
[UTC_TIME_ZONE, getTimeZoneOffsetLabel(UTC_TIME_ZONE)],
43+
...zones.map(zone => [zone, getTimeZoneOffsetLabel(zone)] as const),
44+
]);
45+
}
46+
47+
/**
48+
* Picks a time zone setting. Local and UTC are pinned above every zone the
49+
* browser knows.
50+
*/
51+
export function TimeZoneSelect({
52+
value,
53+
onChange,
54+
...props
55+
}: TimeZoneSelectProps) {
56+
const selected = resolveTimeZonePreference(value) ?? LOCAL_TIME_ZONE;
57+
58+
const zones = useMemo(() => {
59+
const supported = getSupportedTimeZones();
60+
61+
const isPinned = selected === LOCAL_TIME_ZONE || selected === UTC_TIME_ZONE;
62+
63+
const isListed = supported.includes(selected);
64+
65+
if (isPinned || isListed) {
66+
return supported;
67+
}
68+
69+
return [...supported, selected];
70+
}, [selected]);
71+
72+
const data = useMemo<ComboboxData>(
73+
() => [
74+
{
75+
value: LOCAL_TIME_ZONE,
76+
label: `Local (${formatTimeZoneName(getLocalTimeZone())})`,
77+
},
78+
{ value: UTC_TIME_ZONE, label: 'UTC' },
79+
{
80+
group: 'All time zones',
81+
items: zones.map(zone => ({
82+
value: zone,
83+
label: formatTimeZoneName(zone),
84+
})),
85+
},
86+
],
87+
[zones],
88+
);
89+
90+
const offsetLabels = useMemo(() => getOffsetLabels(zones), [zones]);
91+
92+
const renderOption = useCallback(
93+
({ option, checked }: { option: ComboboxItem; checked?: boolean }) => (
94+
<Group w="100%">
95+
<Box flex={1}>
96+
<Text fz="sm">{option.label}</Text>
97+
98+
<Text size="xs" c="dimmed">
99+
{offsetLabels.get(option.value)}
100+
</Text>
101+
</Box>
102+
103+
{checked && (
104+
<IconCheck size={14} color="var(--color-text-brand)" stroke={2.5} />
105+
)}
106+
</Group>
107+
),
108+
[offsetLabels],
109+
);
110+
111+
return (
112+
<Select
113+
searchable
114+
allowDeselect={false}
115+
maxDropdownHeight={280}
116+
nothingFoundMessage="No matching time zones"
117+
{...props}
118+
data={data}
119+
value={selected}
120+
onChange={next => next && onChange(next)}
121+
renderOption={renderOption}
122+
/>
123+
);
124+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
4+
import { TimeZoneSelect } from '@/components/TimeZoneSelect';
5+
6+
// Tests run with TZ=America/New_York.
7+
const renderSelect = (value: string, onChange = jest.fn()) => {
8+
renderWithMantine(
9+
<TimeZoneSelect
10+
data-testid="time-zone-select"
11+
value={value}
12+
onChange={onChange}
13+
/>,
14+
);
15+
return { onChange };
16+
};
17+
18+
const optionNames = () =>
19+
screen
20+
.getAllByRole('option', { hidden: true })
21+
.map(option => option.textContent);
22+
23+
describe('TimeZoneSelect', () => {
24+
it('shows the local zone for the local setting', () => {
25+
renderSelect('local');
26+
expect(screen.getByTestId('time-zone-select')).toHaveValue(
27+
'Local (America/New York)',
28+
);
29+
});
30+
31+
it('pins local and UTC above the other zones', async () => {
32+
renderSelect('local');
33+
await userEvent.click(screen.getByTestId('time-zone-select'));
34+
35+
const names = optionNames();
36+
expect(names[0]).toMatch(
37+
/^Local \(America\/New York\)E[SD]T · UTC-0[45]:00$/,
38+
);
39+
expect(names[1]).toBe('UTCUTC+00:00');
40+
expect(names.slice(2)).toContain('Asia/TokyoUTC+09:00');
41+
expect(names.filter(name => name?.startsWith('UTC'))).toHaveLength(1);
42+
});
43+
44+
it('finds zones by name and reports the IANA value', async () => {
45+
const { onChange } = renderSelect('local');
46+
47+
const input = screen.getByTestId('time-zone-select');
48+
await userEvent.clear(input);
49+
await userEvent.type(input, 'los ang');
50+
await userEvent.click(
51+
await screen.findByRole('option', {
52+
name: /America\/Los Angeles/,
53+
hidden: true,
54+
}),
55+
);
56+
57+
expect(onChange).toHaveBeenCalledWith('America/Los_Angeles');
58+
});
59+
60+
it('shows local for a zone the browser does not recognise', () => {
61+
renderSelect('Mars/Olympus_Mons');
62+
expect(screen.getByTestId('time-zone-select')).toHaveValue(
63+
'Local (America/New York)',
64+
);
65+
});
66+
});

0 commit comments

Comments
 (0)