fix: parse SQLite timestamps as UTC, not local time #30

Merged
josh merged 1 commits from fix/sqlite-utc-timestamps into dev 2026-03-28 15:20:15 -04:00
2 changed files with 26 additions and 6 deletions

View File

@@ -39,17 +39,25 @@ function esc(str) {
return d.innerHTML; return d.innerHTML;
} }
// SQLite datetime('now') → 'YYYY-MM-DD HH:MM:SS' (UTC, no timezone marker).
// Appending 'Z' tells JS to parse it as UTC rather than local time.
function parseUtc(d) {
if (typeof d !== 'string') return new Date(d);
const hasZone = d.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(d);
return new Date(hasZone ? d : d.replace(' ', 'T') + 'Z');
}
function fmtDate(d) { function fmtDate(d) {
if (!d) return '—'; if (!d) return '—';
try { try {
return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: getTimezone() }); return parseUtc(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: getTimezone() });
} catch (e) { return d; } } catch (e) { return d; }
} }
function fmtDateFull(d) { function fmtDateFull(d) {
if (!d) return '—'; if (!d) return '—';
try { try {
return new Date(d).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: getTimezone(), timeZoneName: 'short' }); return parseUtc(d).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: getTimezone(), timeZoneName: 'short' });
} catch (e) { return d; } } catch (e) { return d; }
} }

View File

@@ -58,16 +58,22 @@ describe('esc', () => {
// ── fmtDate() ───────────────────────────────────────────────────────────────── // ── fmtDate() ─────────────────────────────────────────────────────────────────
function fmtDate(d) { function parseUtc(d) {
if (typeof d !== 'string') return new Date(d)
const hasZone = d.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(d)
return new Date(hasZone ? d : d.replace(' ', 'T') + 'Z')
}
function fmtDate(d, tz = 'UTC') {
if (!d) return '—' if (!d) return '—'
try { try {
return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) return parseUtc(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: tz })
} catch (e) { return d } } catch (e) { return d }
} }
describe('fmtDate', () => { describe('fmtDate', () => {
it('formats a valid ISO date string', () => { it('formats a valid ISO date string', () => {
const result = fmtDate('2024-03-15T00:00:00') const result = fmtDate('2024-03-15T12:00:00Z')
expect(result).toMatch(/Mar/) expect(result).toMatch(/Mar/)
expect(result).toMatch(/15/) expect(result).toMatch(/15/)
expect(result).toMatch(/2024/) expect(result).toMatch(/2024/)
@@ -91,7 +97,7 @@ describe('fmtDate', () => {
function fmtDateFull(d, tz = 'UTC') { function fmtDateFull(d, tz = 'UTC') {
if (!d) return '—' if (!d) return '—'
try { try {
return new Date(d).toLocaleString('en-US', { return parseUtc(d).toLocaleString('en-US', {
year: 'numeric', month: 'short', day: 'numeric', year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit', hour: '2-digit', minute: '2-digit',
timeZone: tz, timeZoneName: 'short', timeZone: tz, timeZoneName: 'short',
@@ -118,6 +124,12 @@ describe('fmtDateFull', () => {
expect(result).toMatch(/EDT/) expect(result).toMatch(/EDT/)
}) })
it('treats SQLite-format timestamps (space, no Z) as UTC', () => {
// SQLite datetime('now') → 'YYYY-MM-DD HH:MM:SS', no timezone marker.
// Must parse identically to the same moment expressed as ISO UTC.
expect(fmtDateFull('2024-03-15 18:30:00', 'UTC')).toBe(fmtDateFull('2024-03-15T18:30:00Z', 'UTC'))
})
it('returns — for null', () => { it('returns — for null', () => {
expect(fmtDateFull(null)).toBe('—') expect(fmtDateFull(null)).toBe('—')
}) })