diff --git a/src/lib/components/timestamp.svelte b/src/lib/components/timestamp.svelte
index 087020553f..c1a34f9f4d 100644
--- a/src/lib/components/timestamp.svelte
+++ b/src/lib/components/timestamp.svelte
@@ -2,6 +2,7 @@
import { derived } from 'svelte/store';
import {
+ hourFormat,
relativeTime,
timeFormat,
timestampFormat,
@@ -13,21 +14,22 @@
} from '$lib/utilities/format-date';
export const timestamp = derived(
- [timeFormat, relativeTime, timestampFormat],
- ([$timeFormat, $relativeTime, $timestampFormat]) => {
+ [timeFormat, relativeTime, timestampFormat, hourFormat],
+ ([$timeFormat, $relativeTime, $timestampFormat, $hourFormat]) => {
return (
date: ValidTime | undefined | null,
options: FormatDateOptions = {},
): string => {
const format = options?.format ?? $timestampFormat;
+ const hourFormat = options?.hourFormat ?? $hourFormat;
const relative = options?.relative ?? $relativeTime;
const relativeLabel = options?.relativeLabel;
return formatDate(date, $timeFormat, {
- ...options,
relative,
format,
relativeLabel,
+ hourFormat,
});
};
},
diff --git a/src/lib/components/timestamp.test.ts b/src/lib/components/timestamp.test.ts
index 286b3141ec..8e7333fab8 100644
--- a/src/lib/components/timestamp.test.ts
+++ b/src/lib/components/timestamp.test.ts
@@ -3,6 +3,7 @@ import { get } from 'svelte/store';
import { beforeEach, describe, expect, it } from 'vitest';
import {
+ hourFormat,
relativeTime,
timeFormat,
timestampFormat,
@@ -17,6 +18,7 @@ describe('timestamp', () => {
timeFormat.set('UTC');
relativeTime.set(false);
timestampFormat.set('medium');
+ hourFormat.set('system');
});
it('should format date with default settings', () => {
@@ -114,4 +116,18 @@ describe('timestamp', () => {
const result = get(timestamp)(date, { format: 'short' });
expect(result).toContain('ago');
});
+
+ it('should respect hourFormat store', () => {
+ hourFormat.set('24');
+ const result = get(timestamp)(date);
+ expect(result).not.toContain('PM');
+ expect(result).toContain('16:29:35');
+ });
+
+ it('should use 12-hour format when hourFormat is set to 12', () => {
+ hourFormat.set('12');
+ const result = get(timestamp)(date);
+ expect(result).toContain('4:29:35');
+ expect(result).not.toContain('16:29:35'); // Should not be 24-hour format
+ });
});
diff --git a/src/lib/components/timezone-select.svelte b/src/lib/components/timezone-select.svelte
index 370e7ee8f8..4124714878 100644
--- a/src/lib/components/timezone-select.svelte
+++ b/src/lib/components/timezone-select.svelte
@@ -19,12 +19,14 @@
import ToggleSwitch from '$lib/holocene/toggle-switch.svelte';
import { translate } from '$lib/i18n/translate';
import {
+ hourFormat,
relativeTime,
timeFormat,
timestampFormat,
} from '$lib/stores/time-format';
import {
formatUTCOffset,
+ type HourFormat,
type TimestampFormat,
} from '$lib/utilities/format-date';
import {
@@ -82,6 +84,10 @@
$timestampFormat = format;
};
+ const setHourFormat = (format: HourFormat) => {
+ $hourFormat = format;
+ };
+
$: timezone = Timezones[$timeFormat ?? '']?.abbr ?? $timeFormat;
openUnsubscriber = open.subscribe((isOpen) => {
@@ -151,16 +157,9 @@
{#if !$relativeTime}
-
+
Timestamp Format
setTimestampFormat('long')}>Long
+ setTimestampFormat('iso')}>ISO
+
+
+
+
+
Hour Format
+
+ setHourFormat('system')}>System
+ setHourFormat('12')}>12-hour
+ setHourFormat('24')}>24-hour
+
+
+
+
{/if}
diff --git a/src/lib/stores/time-format.ts b/src/lib/stores/time-format.ts
index ed9f5b21ee..929d9e2a42 100644
--- a/src/lib/stores/time-format.ts
+++ b/src/lib/stores/time-format.ts
@@ -3,7 +3,7 @@ import { get, type Subscriber } from 'svelte/store';
import { startOfDay } from 'date-fns';
import { persistStore } from '$lib/stores/persist-store';
-import { type TimestampFormat } from '$lib/utilities/format-date';
+import type { HourFormat, TimestampFormat } from '$lib/utilities/format-date';
import {
BASE_TIME_FORMAT_OPTIONS,
getAdjustedTimeformat,
@@ -18,6 +18,8 @@ export const timestampFormat = persistStore(
'medium',
);
+export const hourFormat = persistStore('hourFormat', 'system');
+
const DEFAULT_TIME_FORMAT = BASE_TIME_FORMAT_OPTIONS.LOCAL;
const persistedTimeFormat = persistStore('timeFormat', DEFAULT_TIME_FORMAT);
export const timeFormatType = persistStore(
diff --git a/src/lib/utilities/format-date.test.ts b/src/lib/utilities/format-date.test.ts
index e256a7e5b9..042b73211a 100644
--- a/src/lib/utilities/format-date.test.ts
+++ b/src/lib/utilities/format-date.test.ts
@@ -10,11 +10,17 @@ import {
import { getLocalTime } from './timezone';
// // force GH action runners to use en-US and 12-hour clocks starting at 0:00
+// but respect explicit hour12 option when provided
const DateTimeFormat = Intl.DateTimeFormat;
-vi.spyOn(global.Intl, 'DateTimeFormat').mockImplementation(
- (_, options) =>
- new DateTimeFormat('en-US', { ...options, hour12: true, hourCycle: 'h11' }),
-);
+vi.spyOn(global.Intl, 'DateTimeFormat').mockImplementation((_, options) => {
+ const hour12 = options?.hour12 !== undefined ? options.hour12 : true;
+ const hourCycle = options?.hour12 !== undefined ? undefined : 'h11';
+ return new DateTimeFormat('en-US', {
+ ...options,
+ hour12,
+ ...(hourCycle && { hourCycle }),
+ });
+});
describe('formatDate', () => {
const date = '2022-04-13T16:29:35.630571Z';
@@ -148,6 +154,84 @@ describe('formatDate', () => {
'April 13, 2022 at 4:29:35.63 PM UTC',
);
});
+
+ describe('hourFormat option', () => {
+ it('should use 24-hour format when hourFormat is "24"', () => {
+ const result = formatDate(date, 'UTC', { hourFormat: '24' });
+ expect(result).toContain('16:29:35');
+ expect(result).not.toContain('PM');
+ expect(result).not.toContain('AM');
+ });
+
+ it('should use 12-hour format when hourFormat is "12"', () => {
+ const result = formatDate(date, 'UTC', { hourFormat: '12' });
+ expect(result).toContain('4:29:35');
+ expect(result).toContain('PM');
+ });
+
+ it('should use system default when hourFormat is "system"', () => {
+ const result = formatDate(date, 'UTC', { hourFormat: 'system' });
+ // System default is mocked to be 12-hour format
+ expect(result).toContain('4:29:35');
+ expect(result).toContain('PM');
+ });
+
+ it('should default to system format when hourFormat is not specified', () => {
+ const result = formatDate(date, 'UTC');
+ // System default is mocked to be 12-hour format
+ expect(result).toContain('4:29:35');
+ expect(result).toContain('PM');
+ });
+
+ it('should work with 24-hour format in different timezones', () => {
+ const result = formatDate(date, 'Central Standard Time', {
+ hourFormat: '24',
+ });
+ expect(result).toContain('11:29:35');
+ expect(result).not.toContain('AM');
+ expect(result).not.toContain('PM');
+ });
+
+ it('should work with 12-hour format in different timezones', () => {
+ const result = formatDate(date, 'Central Standard Time', {
+ hourFormat: '12',
+ });
+ expect(result).toContain('11:29:35');
+ expect(result).toContain('AM');
+ });
+
+ it('should work with different timestamp formats', () => {
+ expect(
+ formatDate(date, 'UTC', { format: 'short', hourFormat: '24' }),
+ ).toContain('16:29:35');
+ expect(
+ formatDate(date, 'UTC', { format: 'long', hourFormat: '24' }),
+ ).toContain('16:29:35');
+ });
+
+ it('should not affect relative time formatting', () => {
+ const result = formatDate(date, 'local', {
+ relative: true,
+ hourFormat: '24',
+ });
+ expect(result).toContain('ago');
+ expect(result).not.toContain(':');
+ });
+
+ it('should handle midnight correctly in 24-hour format', () => {
+ const midnight = '2022-04-13T00:00:00.000Z';
+ const result = formatDate(midnight, 'UTC', { hourFormat: '24' });
+ expect(result).toContain('0:00:00');
+ expect(result).not.toContain('12:00:00');
+ });
+
+ it('should handle noon correctly in 24-hour format', () => {
+ const noon = '2022-04-13T12:00:00.000Z';
+ const result = formatDate(noon, 'UTC', { hourFormat: '24' });
+ expect(result).toContain('12:00:00');
+ expect(result).not.toContain('PM');
+ });
+ });
});
describe('isValidDate', () => {
diff --git a/src/lib/utilities/format-date.ts b/src/lib/utilities/format-date.ts
index e0c5d7cceb..532e5235e1 100644
--- a/src/lib/utilities/format-date.ts
+++ b/src/lib/utilities/format-date.ts
@@ -16,11 +16,14 @@ import { isTimestamp, timestampToDate, type ValidTime } from './format-time';
export type { ValidTime };
+export type HourFormat = 'system' | '12' | '24';
+
export type FormatDateOptions = {
format?: TimestampFormat;
relative?: boolean;
relativeLabel?: string;
flexibleUnits?: boolean;
+ hourFormat?: HourFormat;
};
export const timestampFormats: Record<
@@ -57,6 +60,7 @@ export const timestampFormats: Record<
timeZoneName: 'short',
fractionalSecondDigits: 2,
},
+ iso: {},
} as const;
export type TimestampFormat = keyof typeof timestampFormats;
@@ -96,33 +100,45 @@ export function formatDate(
relativeLabel = isFuture(date) ? 'from now' : 'ago',
flexibleUnits = false,
format = 'medium',
+ hourFormat = 'system',
} = options;
const currentDate = Date.now();
const parsed = parseJSON(new Date(date));
+ // Handle relative time first (takes precedence over format)
+ if (timeFormat === BASE_TIME_FORMAT_OPTIONS.LOCAL && relative) {
+ return (
+ formatDistanceToNowStrict(parsed, {
+ ...(!flexibleUnits &&
+ Math.abs(differenceInHours(currentDate, parsed)) > 24 && {
+ unit: 'day',
+ }),
+ }) + ` ${relativeLabel}`
+ );
+ }
+
+ // Handle ISO format
+ if (format === 'iso') {
+ return parsed.toISOString();
+ }
+
+ const hour12 =
+ hourFormat === 'system' ? undefined : hourFormat === '12' ? true : false;
+
+ const formatOptions = {
+ ...timestampFormats[format],
+ ...(hour12 !== undefined && { hour12 }),
+ };
+
if (timeFormat === BASE_TIME_FORMAT_OPTIONS.LOCAL) {
- if (relative) {
- return (
- formatDistanceToNowStrict(parsed, {
- ...(!flexibleUnits &&
- Math.abs(differenceInHours(currentDate, parsed)) > 24 && {
- unit: 'day',
- }),
- }) + ` ${relativeLabel}`
- );
- }
-
- return new Intl.DateTimeFormat(
- undefined,
- timestampFormats[format],
- ).format(parsed);
+ return new Intl.DateTimeFormat(undefined, formatOptions).format(parsed);
}
const timeZone = getTimezone(timeFormat);
return new Intl.DateTimeFormat(undefined, {
- ...timestampFormats[format],
+ ...formatOptions,
timeZone,
}).format(parsed);
} catch (e) {