Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/lib/components/timestamp.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { derived } from 'svelte/store';

import {
hourFormat,
relativeTime,
timeFormat,
timestampFormat,
Expand All @@ -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,
});
};
},
Expand Down
16 changes: 16 additions & 0 deletions src/lib/components/timestamp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { get } from 'svelte/store';
import { beforeEach, describe, expect, it } from 'vitest';

import {
hourFormat,
relativeTime,
timeFormat,
timestampFormat,
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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
});
});
56 changes: 47 additions & 9 deletions src/lib/components/timezone-select.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -82,6 +84,10 @@
$timestampFormat = format;
};

const setHourFormat = (format: HourFormat) => {
$hourFormat = format;
};

$: timezone = Timezones[$timeFormat ?? '']?.abbr ?? $timeFormat;

openUnsubscriber = open.subscribe((isOpen) => {
Expand Down Expand Up @@ -151,16 +157,9 @@

{#if !$relativeTime}
<div
class="m-4 flex gap-2 max-md:flex-col md:flex-row md:items-center md:justify-between"
class="mx-4 mb-2 mt-4 flex gap-2 max-md:flex-col md:flex-row md:items-center md:justify-between"
>
<div>
<p class="font-medium">Timestamp Format</p>
<Timestamp
as="p"
class="text-xs text-secondary"
dateTime={currentDate}
/>
</div>
<p class="font-medium">Timestamp Format</p>
<ToggleButtons>
<ToggleButton
size="xs"
Expand All @@ -177,8 +176,47 @@
active={$timestampFormat === 'long'}
on:click={() => setTimestampFormat('long')}>Long</ToggleButton
>
<ToggleButton
size="xs"
active={$timestampFormat === 'iso'}
on:click={() => setTimestampFormat('iso')}>ISO</ToggleButton
>
</ToggleButtons>
</div>

<div
class="mx-4 mb-2 flex gap-2 max-md:flex-col md:flex-row md:items-center md:justify-between"
>
<p class="font-medium">Hour Format</p>
<ToggleButtons>
<ToggleButton
size="xs"
active={$hourFormat === 'system'}
disabled={$timestampFormat === 'iso'}
on:click={() => setHourFormat('system')}>System</ToggleButton
>
<ToggleButton
size="xs"
active={$hourFormat === '12'}
disabled={$timestampFormat === 'iso'}
on:click={() => setHourFormat('12')}>12-hour</ToggleButton
>
<ToggleButton
size="xs"
active={$hourFormat === '24'}
disabled={$timestampFormat === 'iso'}
on:click={() => setHourFormat('24')}>24-hour</ToggleButton
>
</ToggleButtons>
</div>

<div class="mx-4 mb-4 mt-3">
<Timestamp
as="p"
class="text-xs text-secondary"
dateTime={currentDate}
/>
</div>
{/if}

<MenuDivider />
Expand Down
4 changes: 3 additions & 1 deletion src/lib/stores/time-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,6 +18,8 @@ export const timestampFormat = persistStore<TimestampFormat>(
'medium',
);

export const hourFormat = persistStore<HourFormat>('hourFormat', 'system');

const DEFAULT_TIME_FORMAT = BASE_TIME_FORMAT_OPTIONS.LOCAL;
const persistedTimeFormat = persistStore('timeFormat', DEFAULT_TIME_FORMAT);
export const timeFormatType = persistStore(
Expand Down
92 changes: 88 additions & 4 deletions src/lib/utilities/format-date.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
48 changes: 32 additions & 16 deletions src/lib/utilities/format-date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -57,6 +60,7 @@ export const timestampFormats: Record<
timeZoneName: 'short',
fractionalSecondDigits: 2,
},
iso: {},
} as const;

export type TimestampFormat = keyof typeof timestampFormats;
Expand Down Expand Up @@ -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) {
Expand Down