Skip to content

Commit ce6654d

Browse files
authored
refactor: store the time display setting as a time zone (#3447)
1 parent dab6eb8 commit ce6654d

13 files changed

Lines changed: 244 additions & 77 deletions

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@hyperdx/common-utils': patch
3+
'@hyperdx/app': patch
4+
'@hyperdx/api': patch
5+
---
6+
7+
refactor: Store the time display setting as a time zone instead of a UTC switch, converting existing settings, and remove the `isUTC` option from `formatDate`

‎packages/api/src/tasks/checkAlerts/template.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { Metadata } from '@hyperdx/common-utils/dist/core/metadata';
33
import { getPromqlSeries } from '@hyperdx/common-utils/dist/core/promql';
44
import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig';
55
import { buildSearchChartConfig } from '@hyperdx/common-utils/dist/core/searchChartConfig';
6-
import { formatDate, objectHash } from '@hyperdx/common-utils/dist/core/utils';
6+
import {
7+
formatDate,
8+
objectHash,
9+
UTC_TIME_ZONE,
10+
} from '@hyperdx/common-utils/dist/core/utils';
711
import {
812
isPromqlSavedChartConfig,
913
isRawSqlSavedChartConfig,
@@ -881,9 +885,9 @@ export const renderAlertTemplate = async ({
881885
};
882886

883887
const timeRangeMessage = `Time Range (UTC): [${formatDate(view.startTime, {
884-
isUTC: true,
888+
timeZone: UTC_TIME_ZONE,
885889
})} - ${formatDate(view.endTime, {
886-
isUTC: true,
890+
timeZone: UTC_TIME_ZONE,
887891
})})`;
888892
let rawTemplateBody;
889893

‎packages/app/src/UserPreferencesModal.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
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';
34
import {
45
Autocomplete,
56
Divider,
@@ -19,6 +20,7 @@ import {
1920
} from './config/fonts';
2021
import { useAppTheme } from './theme/ThemeProvider';
2122
import { isValidThemeName, themes } from './theme';
23+
import { LOCAL_TIME_ZONE } from './timeZone';
2224
import {
2325
DEFAULT_ROW_CLICK_ACTION,
2426
RowClickAction,
@@ -114,10 +116,12 @@ export const UserPreferencesModal = ({
114116
<Switch
115117
size="md"
116118
onLabel="UTC"
117-
checked={userPreferences.isUTC}
119+
checked={userPreferences.timeZone === UTC_TIME_ZONE}
118120
onChange={e =>
119121
setUserPreference({
120-
isUTC: e.currentTarget.checked,
122+
timeZone: e.currentTarget.checked
123+
? UTC_TIME_ZONE
124+
: LOCAL_TIME_ZONE,
121125
})
122126
}
123127
/>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {
2+
getTimeZoneShortName,
3+
isValidTimeZone,
4+
LOCAL_TIME_ZONE,
5+
resolveTimeZonePreference,
6+
} from '@/timeZone';
7+
8+
describe('resolveTimeZonePreference', () => {
9+
it('returns undefined for the local setting', () => {
10+
expect(resolveTimeZonePreference(LOCAL_TIME_ZONE)).toBeUndefined();
11+
});
12+
13+
it('returns a valid IANA name', () => {
14+
expect(resolveTimeZonePreference('Etc/UTC')).toBe('Etc/UTC');
15+
expect(resolveTimeZonePreference('Asia/Tokyo')).toBe('Asia/Tokyo');
16+
});
17+
18+
it('falls back to local for a zone the browser does not recognise', () => {
19+
expect(isValidTimeZone('Mars/Olympus_Mons')).toBe(false);
20+
expect(resolveTimeZonePreference('Mars/Olympus_Mons')).toBeUndefined();
21+
});
22+
});
23+
24+
describe('getTimeZoneShortName', () => {
25+
it('uses the abbreviation in effect on the date', () => {
26+
expect(
27+
getTimeZoneShortName('America/Los_Angeles', new Date('2026-07-01Z')),
28+
).toBe('PDT');
29+
expect(
30+
getTimeZoneShortName('America/Los_Angeles', new Date('2026-12-01Z')),
31+
).toBe('PST');
32+
});
33+
34+
it('names UTC and zones without an abbreviation', () => {
35+
expect(getTimeZoneShortName('Etc/UTC')).toBe('UTC');
36+
expect(getTimeZoneShortName('Asia/Tokyo')).toBe('GMT+9');
37+
});
38+
});

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

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ describe('migrateUserPreferences', () => {
6060
const result = migrateUserPreferences(legacyData);
6161

6262
expect(result).toEqual({
63-
isUTC: false,
63+
timeZone: 'local',
6464
timeFormat: '12h',
6565
colorMode: 'dark',
6666
font: 'IBM Plex Mono',
@@ -84,7 +84,7 @@ describe('migrateUserPreferences', () => {
8484
const result = migrateUserPreferences(legacyData);
8585

8686
expect(result).toEqual({
87-
isUTC: true,
87+
timeZone: 'Etc/UTC',
8888
timeFormat: '24h',
8989
colorMode: 'light',
9090
font: 'Inter',
@@ -137,7 +137,7 @@ describe('migrateUserPreferences', () => {
137137
const result = migrateUserPreferences(legacyData);
138138

139139
expect(result).toEqual({
140-
isUTC: true,
140+
timeZone: 'Etc/UTC',
141141
timeFormat: '24h',
142142
colorMode: 'light',
143143
font: 'Roboto Mono',
@@ -154,7 +154,7 @@ describe('migrateUserPreferences', () => {
154154
const result = migrateUserPreferences(legacyData);
155155

156156
expect(result).toEqual({
157-
isUTC: false, // From DEFAULT_PREFERENCES
157+
timeZone: 'local', // From DEFAULT_PREFERENCES
158158
timeFormat: '12h', // From DEFAULT_PREFERENCES
159159
colorMode: 'dark', // Migrated from theme
160160
font: 'IBM Plex Mono', // From DEFAULT_PREFERENCES
@@ -221,7 +221,7 @@ describe('migrateUserPreferences', () => {
221221
const result = migrateUserPreferences(partialData);
222222

223223
expect(result).toEqual({
224-
isUTC: false, // From DEFAULT_PREFERENCES
224+
timeZone: 'local', // From DEFAULT_PREFERENCES
225225
timeFormat: '12h', // From DEFAULT_PREFERENCES
226226
colorMode: 'light', // Migrated from theme
227227
font: 'IBM Plex Mono', // From DEFAULT_PREFERENCES
@@ -241,7 +241,7 @@ describe('migrateUserPreferences', () => {
241241
const result = migrateUserPreferences(legacyData);
242242

243243
expect(result).toEqual({
244-
isUTC: false,
244+
timeZone: 'local',
245245
timeFormat: '12h',
246246
colorMode: 'dark',
247247
font: 'IBM Plex Mono',
@@ -254,7 +254,7 @@ describe('migrateUserPreferences', () => {
254254
describe('Migration idempotency', () => {
255255
it('should return already migrated data unchanged', () => {
256256
const migratedData: UserPreferences = {
257-
isUTC: false,
257+
timeZone: 'local',
258258
timeFormat: '12h',
259259
colorMode: 'dark',
260260
font: 'IBM Plex Mono',
@@ -269,7 +269,7 @@ describe('migrateUserPreferences', () => {
269269

270270
it('should accept colorMode "system" as valid', () => {
271271
const dataWithSystem: UserPreferences = {
272-
isUTC: false,
272+
timeZone: 'local',
273273
timeFormat: '12h',
274274
colorMode: 'system',
275275
font: 'IBM Plex Mono',
@@ -286,7 +286,7 @@ describe('migrateUserPreferences', () => {
286286
const mixedData = JSON.stringify({
287287
theme: 'light',
288288
colorMode: 'dark',
289-
isUTC: false,
289+
timeZone: 'local',
290290
timeFormat: '12h',
291291
font: 'IBM Plex Mono',
292292
});
@@ -323,6 +323,53 @@ describe('migrateUserPreferences', () => {
323323
});
324324
});
325325

326+
describe('isUTC migration (isUTC → timeZone)', () => {
327+
it.each([
328+
[true, 'Etc/UTC'],
329+
[false, 'local'],
330+
])('replaces isUTC=%s with timeZone=%s and saves it', (isUTC, timeZone) => {
331+
const stored = JSON.stringify({
332+
isUTC,
333+
timeFormat: '24h',
334+
colorMode: 'dark',
335+
font: 'Inter',
336+
});
337+
338+
const result = migrateUserPreferences(stored);
339+
340+
expect(result).toEqual({
341+
timeZone,
342+
timeFormat: '24h',
343+
colorMode: 'dark',
344+
font: 'Inter',
345+
});
346+
expect(localStorageMock.setItem).toHaveBeenCalledWith(
347+
STORAGE_KEY,
348+
JSON.stringify(result),
349+
);
350+
});
351+
352+
it('defaults to local when neither isUTC nor timeZone is stored', () => {
353+
const result = migrateUserPreferences(
354+
JSON.stringify({ colorMode: 'dark', timeFormat: '12h' }),
355+
);
356+
expect(result?.timeZone).toBe('local');
357+
});
358+
359+
it('keeps a stored timeZone over isUTC', () => {
360+
const result = migrateUserPreferences(
361+
JSON.stringify({
362+
isUTC: true,
363+
timeZone: 'Asia/Tokyo',
364+
colorMode: 'dark',
365+
timeFormat: '12h',
366+
}),
367+
);
368+
expect(result?.timeZone).toBe('Asia/Tokyo');
369+
expect(result).not.toHaveProperty('isUTC');
370+
});
371+
});
372+
326373
describe('localStorage error handling', () => {
327374
it('should handle localStorage.setItem errors gracefully', () => {
328375
localStorageMock.setItem.mockImplementation(() => {
@@ -339,7 +386,7 @@ describe('migrateUserPreferences', () => {
339386
// Should not throw, should return migrated data even if save fails
340387
const result = migrateUserPreferences(legacyData);
341388
expect(result).toEqual({
342-
isUTC: false,
389+
timeZone: 'local',
343390
timeFormat: '12h',
344391
colorMode: 'dark',
345392
font: 'IBM Plex Mono',
@@ -365,7 +412,7 @@ describe('migrateUserPreferences', () => {
365412
// Should not throw - should return migrated data
366413
const result = migrateUserPreferences(legacyData);
367414
expect(result).toEqual({
368-
isUTC: false,
415+
timeZone: 'local',
369416
timeFormat: '12h',
370417
colorMode: 'dark',
371418
font: 'IBM Plex Mono',

‎packages/app/src/components/AppNav/AppNav.tsx‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ import InstallInstructionModal from '@/InstallInstructionsModal';
4040
import OnboardingChecklist from '@/OnboardingChecklist';
4141
import { useSavedSearches } from '@/savedSearch';
4242
import { useLogomark, useWordmark } from '@/theme/ThemeProvider';
43+
import { getTimeZoneShortName } from '@/timeZone';
44+
import { useDisplayTimeZone } from '@/useDisplayTimeZone';
4345
import { UserPreferencesModal } from '@/UserPreferencesModal';
44-
import { useUserPreferences } from '@/useUserPreferences';
4546

4647
import {
4748
AppNavCloudBanner,
@@ -276,9 +277,8 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
276277
{ close: closeUserPreferences, open: openUserPreferences },
277278
] = useDisclosure(false);
278279

279-
const {
280-
userPreferences: { isUTC },
281-
} = useUserPreferences();
280+
const timeZone = useDisplayTimeZone();
281+
const timeZoneShortName = timeZone && getTimeZoneShortName(timeZone);
282282

283283
const [
284284
showInstallInstructions,
@@ -373,12 +373,12 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
373373
would be redundant and crowd this already-tight row. */}
374374
<InstanceLabel
375375
fallback={
376-
isUTC && (
376+
timeZoneShortName && (
377377
<Badge
378378
size="sm"
379-
text="UTC"
379+
text={timeZoneShortName}
380380
state="neutral"
381-
title="Showing time in UTC"
381+
title={`Showing time in ${timeZoneShortName}`}
382382
/>
383383
)
384384
}

‎packages/app/src/components/DBRowTable.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ import {
9595
MIN_LAST_COLUMN_WIDTH,
9696
UNDEFINED_WIDTH,
9797
} from '@/tableUtils';
98+
import { getTimeZoneShortName } from '@/timeZone';
99+
import { useDisplayTimeZone } from '@/useDisplayTimeZone';
98100
import { FormatTime } from '@/useFormatTime';
99101
import {
100102
DEFAULT_ROW_CLICK_ACTION,
@@ -464,8 +466,9 @@ export const RawLogTable = memo(
464466
);
465467

466468
const {
467-
userPreferences: { isUTC, rowClickAction },
469+
userPreferences: { rowClickAction },
468470
} = useUserPreferences();
471+
const timeZone = useDisplayTimeZone();
469472
const { scale: contentFontScale } = useContentFontSize();
470473

471474
// The ClickHouse dashboard's slow-query list has no side panel to open, so
@@ -672,7 +675,7 @@ export const RawLogTable = memo(
672675
// TODO: add support for sorting on Dynamic JSON fields
673676
enableSorting: jsColumnType !== JSDataType.Dynamic,
674677
accessorFn: curry(retrieveColumnValue)(column), // Columns can contain '.' and will not work with accessorKey
675-
header: `${columnNameMap?.[column] ?? column}${isDate ? (isUTC ? ' (UTC)' : ' (Local)') : ''}`,
678+
header: `${columnNameMap?.[column] ?? column}${isDate ? ` (${timeZone ? getTimeZoneShortName(timeZone) : 'Local'})` : ''}`,
676679
cell: info => {
677680
const value = info.getValue<any>(); // This can be any type realistically (numbers, strings, etc.)
678681

@@ -743,7 +746,7 @@ export const RawLogTable = memo(
743746
}) as ColumnDef<any>[]),
744747
],
745748
[
746-
isUTC,
749+
timeZone,
747750
highlightedLineId,
748751
displayedColumns,
749752
columnSizeOpts,

‎packages/app/src/timeZone.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { UTC_TIME_ZONE } from '@hyperdx/common-utils/dist/core/utils';
2+
3+
/** Time zone setting value that follows the browser's zone. */
4+
export const LOCAL_TIME_ZONE = 'local';
5+
6+
export function isValidTimeZone(timeZone: string): boolean {
7+
try {
8+
new Intl.DateTimeFormat('en-US', { timeZone });
9+
return true;
10+
} catch {
11+
return false;
12+
}
13+
}
14+
15+
/**
16+
* The IANA zone a setting refers to, or undefined for the local zone. Zones
17+
* the browser doesn't recognise fall back to local.
18+
*/
19+
export function resolveTimeZonePreference(
20+
preference: string,
21+
): string | undefined {
22+
return preference !== LOCAL_TIME_ZONE && isValidTimeZone(preference)
23+
? preference
24+
: undefined;
25+
}
26+
27+
/** The setting that `isUTC`, the switch that predates it, maps to. */
28+
export function timeZonePreferenceFromIsUTC(isUTC: unknown) {
29+
return isUTC === true ? UTC_TIME_ZONE : LOCAL_TIME_ZONE;
30+
}
31+
32+
/**
33+
* Short name of `timeZone` at `date`, e.g. "UTC", "PDT" or "GMT+9". en-US is
34+
* used because it has abbreviations for the most zones.
35+
*/
36+
export function getTimeZoneShortName(
37+
timeZone: string,
38+
// eslint-disable-next-line no-restricted-syntax
39+
date: Date = new Date(),
40+
): string {
41+
return (
42+
new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'short' })
43+
.formatToParts(date)
44+
.find(part => part.type === 'timeZoneName')?.value ?? timeZone
45+
);
46+
}
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { UTC_TIME_ZONE } from '@hyperdx/common-utils/dist/core/utils';
1+
import { useMemo } from 'react';
22

3+
import { resolveTimeZonePreference } from './timeZone';
34
import { useUserPreferences } from './useUserPreferences';
45

56
/**
@@ -8,7 +9,7 @@ import { useUserPreferences } from './useUserPreferences';
89
*/
910
export function useDisplayTimeZone(): string | undefined {
1011
const {
11-
userPreferences: { isUTC },
12+
userPreferences: { timeZone },
1213
} = useUserPreferences();
13-
return isUTC ? UTC_TIME_ZONE : undefined;
14+
return useMemo(() => resolveTimeZonePreference(timeZone), [timeZone]);
1415
}

0 commit comments

Comments
 (0)