diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index 6ddf4e6..fd22963 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -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); } diff --git a/src/lib/utils/date-format.ts b/src/lib/utils/date-format.ts index ecaf700..a7b28d7 100644 --- a/src/lib/utils/date-format.ts +++ b/src/lib/utils/date-format.ts @@ -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');