activity log times now honor the configured timezone

This commit is contained in:
jarek committed 2026-08-09 10:31:27 +02:00
1 parent 8f47eef1b7
commit 1c1d7979d7
2 files changed
+46 -2

No files matched your search

+3 -2
View File
@@ -4,6 +4,7 @@ import {
buildFormatters,
formatDatePartWith,
formatTimePartWith,
parseTimestamp,
type DateTimeFormatters
} from '$lib/utils/date-format';
@@ -611,7 +612,7 @@ export function formatTime(
date: Date | string | number,
options: { includeDate?: boolean; includeSeconds?: boolean } = {}
): string {
const d = date instanceof Date ? date : new Date(date);
const d = parseTimestamp(date);
const { includeDate = false, includeSeconds = false } = options;
if (includeDate) {
@@ -633,7 +634,7 @@ export function formatDateTime(date: Date | string | number, includeSeconds = fa
* Format just the date part according to user's preferences.
*/
export function formatDate(date: Date | string | number): string {
const d = date instanceof Date ? date : new Date(date);
const d = parseTimestamp(date);
return formatDatePart(d);
}
+43
View File
@@ -14,6 +14,38 @@ export interface DateTimeFormatters {
time: Intl.DateTimeFormat;
}
// A timezone designator at the END of an ISO string: `Z`, or a `+hh:mm` / `-hh:mm`
// offset after the time part. The leading anchor requires a `T` or space + time first
// so the `-` in the DATE part (2026-08-08) never counts as an offset.
const HAS_TZ = /(\d{2}:\d{2}(:\d{2})?(\.\d+)?)(Z|[+-]\d{2}:?\d{2})$/;
/**
* Parse a server timestamp into a Date. Every timestamp the app stores is UTC
* (`.toISOString()` on SQLite, PG `NOW()`), but PostgreSQL columns are
* `timestamp without time zone`, so Drizzle hands back a NAIVE string
* (`2026-08-08 15:22:19.7`) with no `Z`. `new Date(naive)` would read that as
* BROWSER-LOCAL time, double-shifting once the formatter re-applies the zone.
* Treat a zone-less string as UTC; leave Date/number and already-zoned strings alone.
*/
export function parseTimestamp(value: Date | string | number): Date {
if (value instanceof Date) return value;
// Anything unparseable becomes Invalid Date, NEVER a throw - the formatters render
// hundreds of rows and guard on isValidDate, so one bad value degrades to a
// placeholder instead of blanking the list. `new Date(bigint)` throws TypeError, so
// the whole body is wrapped.
try {
if (value == null) return new Date(NaN); // also catches undefined (== not ===)
if (typeof value !== 'string') return new Date(value as number);
const s = value.trim();
if (!s) return new Date(NaN);
if (HAS_TZ.test(s)) return new Date(s);
// Zone-less -> it's UTC. Normalize the space form to ISO and append Z.
return new Date(s.replace(' ', 'T') + 'Z');
} catch {
return new Date(NaN);
}
}
// Intl.DateTimeFormat construction is expensive; the caller caches one pair per
// timezone and rebuilds only when the timezone setting changes.
export function buildFormatters(timeZone: string | undefined): DateTimeFormatters {
@@ -39,7 +71,17 @@ function pickPart(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPar
return parts.find((p) => p.type === type)?.value ?? '';
}
// Placeholder for an unparseable timestamp. `Intl.formatToParts(Invalid Date)`
// THROWS RangeError, and these formatters render hundreds of rows, so one bad
// value would blank the whole list. Guard here instead.
const INVALID_PLACEHOLDER = '-';
function isValidDate(d: Date): boolean {
return d instanceof Date && !Number.isNaN(d.getTime());
}
export function formatDatePartWith(d: Date, dateFormatter: Intl.DateTimeFormat, dateFormat: DateFormat): string {
if (!isValidDate(d)) return INVALID_PLACEHOLDER;
const parts = dateFormatter.formatToParts(d);
const day = pickPart(parts, 'day');
const month = pickPart(parts, 'month');
@@ -64,6 +106,7 @@ export function formatTimePartWith(
timeFormat: TimeFormat,
includeSeconds = false
): string {
if (!isValidDate(d)) return INVALID_PLACEHOLDER;
const parts = timeFormatter.formatToParts(d);
const hours = parseInt(pickPart(parts, 'hour'), 10);
const minutes = pickPart(parts, 'minute');