From 39396f5f1399f7d5fb855f28730f185a8ffff871 Mon Sep 17 00:00:00 2001 From: Andrew Zamojc Date: Fri, 23 Jan 2026 13:13:29 -0500 Subject: [PATCH 1/4] add 12/24 hour option --- src/lib/components/timestamp.svelte | 8 ++- src/lib/components/timestamp.test.ts | 16 +++++ src/lib/stores/time-format.ts | 4 +- src/lib/utilities/format-date.test.ts | 92 +++++++++++++++++++++++++-- src/lib/utilities/format-date.ts | 19 ++++-- 5 files changed, 126 insertions(+), 13 deletions(-) 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/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..4a934d4690 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< @@ -96,12 +99,21 @@ 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)); + 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 ( @@ -114,15 +126,12 @@ export function formatDate( ); } - 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) { From 10a35870bc39a7d3736dab5eda2f99a1a14ccde2 Mon Sep 17 00:00:00 2001 From: Andrew Zamojc Date: Fri, 23 Jan 2026 16:14:29 -0500 Subject: [PATCH 2/4] add hour format to the timezone popover --- src/lib/components/timezone-select.svelte | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/lib/components/timezone-select.svelte b/src/lib/components/timezone-select.svelte index 370e7ee8f8..69f77f8787 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) => { @@ -179,6 +185,29 @@ > + +
+

Hour Format

+ + setHourFormat('system')}>System + setHourFormat('12')}>12-hour + setHourFormat('24')}>24-hour + +
{/if} From 1a76d559dc2f3f9a1c3d1c7ddd221383310af180 Mon Sep 17 00:00:00 2001 From: Andrew Zamojc Date: Fri, 23 Jan 2026 16:24:05 -0500 Subject: [PATCH 3/4] adjust layout and reposition timestamp format display --- src/lib/components/timezone-select.svelte | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/lib/components/timezone-select.svelte b/src/lib/components/timezone-select.svelte index 69f77f8787..c633309861 100644 --- a/src/lib/components/timezone-select.svelte +++ b/src/lib/components/timezone-select.svelte @@ -157,16 +157,9 @@ {#if !$relativeTime}
-
-

Timestamp Format

- -
+

Timestamp Format

Hour Format

@@ -208,6 +201,14 @@ >
+ +
+ +
{/if} From 212d6d4cfc59d9aba28785adaa12176c8f95b8b5 Mon Sep 17 00:00:00 2001 From: Andrew Zamojc Date: Fri, 23 Jan 2026 16:53:58 -0500 Subject: [PATCH 4/4] add iso option to timezone popover --- src/lib/components/timezone-select.svelte | 8 +++++++ src/lib/utilities/format-date.ts | 29 ++++++++++++++--------- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/lib/components/timezone-select.svelte b/src/lib/components/timezone-select.svelte index c633309861..4124714878 100644 --- a/src/lib/components/timezone-select.svelte +++ b/src/lib/components/timezone-select.svelte @@ -176,6 +176,11 @@ active={$timestampFormat === 'long'} on:click={() => setTimestampFormat('long')}>Long
+ setTimestampFormat('iso')}>ISO
@@ -187,16 +192,19 @@ setHourFormat('system')}>System setHourFormat('12')}>12-hour setHourFormat('24')}>24-hour diff --git a/src/lib/utilities/format-date.ts b/src/lib/utilities/format-date.ts index 4a934d4690..532e5235e1 100644 --- a/src/lib/utilities/format-date.ts +++ b/src/lib/utilities/format-date.ts @@ -60,6 +60,7 @@ export const timestampFormats: Record< timeZoneName: 'short', fractionalSecondDigits: 2, }, + iso: {}, } as const; export type TimestampFormat = keyof typeof timestampFormats; @@ -106,6 +107,23 @@ export function formatDate( 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; @@ -115,17 +133,6 @@ export function formatDate( }; 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, formatOptions).format(parsed); }