From e405985c342f85153ee49ebc5e457e5ade3b03f9 Mon Sep 17 00:00:00 2001 From: Bastian de Byl Date: Wed, 10 Jun 2026 16:36:24 -0400 Subject: [PATCH] SEV-378: add tolerant formatDate/formatDateTime to shared (v0.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Our Go/Turso layer emits SQLite datetime('now') as "YYYY-MM-DD HH:MM:SS" (UTC, no zone), which new Date() can't parse → "Invalid Date" in admin. formatDate/formatDateTime normalize that shape (space→T + Z) and RFC3339, returning '' for falsy/invalid. Tests cover both formats + empties. Co-Authored-By: Claude Fable 5 --- package.json | 2 +- src/format.js | 26 ++++++++++++++++++++++++++ src/format.test.js | 20 +++++++++++++++++++- src/index.js | 1 + 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4baa1ee..dda766b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchev/web-shared", - "version": "0.2.0", + "version": "0.3.0", "description": "Shared browser helpers (formatters, markdown, image polling) for the SwitchEV frontends", "type": "module", "exports": { diff --git a/src/format.js b/src/format.js index 82d81b1..c1a0084 100644 --- a/src/format.js +++ b/src/format.js @@ -95,3 +95,29 @@ export function vehicleName({ year, make, model, commissioned } = {}, fallback = const v = [year, make, model].filter(Boolean).join(' '); return v || fallback; } + +// SEV-378: our Go/Turso layer emits SQLite `datetime('now')` timestamps as +// "YYYY-MM-DD HH:MM:SS" (UTC, no zone), which `new Date()` can't reliably +// parse → "Invalid Date" in the admin UI. normalizeTs handles both that shape +// and RFC3339; returns null for falsy/invalid so callers render ''. +function normalizeTs(ts) { + if (!ts) return null; + const s = String(ts).trim(); + const sqlite = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s); + const d = new Date(sqlite ? s.replace(' ', 'T') + 'Z' : s); + return isNaN(d.getTime()) ? null : d; +} + +/** Localized date (e.g. "Jun 10, 2026"). '' for missing/invalid input. */ +export function formatDate(ts) { + const d = normalizeTs(ts); + return d ? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : ''; +} + +/** Localized date + time (e.g. "Jun 10, 2026, 2:42 PM"). '' for missing/invalid. */ +export function formatDateTime(ts) { + const d = normalizeTs(ts); + return d + ? d.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }) + : ''; +} diff --git a/src/format.test.js b/src/format.test.js index 60d6cb8..efc5560 100644 --- a/src/format.test.js +++ b/src/format.test.js @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { formatLabel, formatStatus, formatTimeline, formatRange, - formatBudget, formatBudgetBucket, vehicleName, + formatBudget, formatBudgetBucket, vehicleName, formatDate, formatDateTime, } from './format.js'; test('formatLabel replaces _ and - and title-cases', () => { @@ -68,3 +68,21 @@ test('vehicleName handles commissioned and sparse vehicles', () => { assert.equal(vehicleName({ make: 'BMW' }, 'Lead'), 'BMW'); assert.equal(vehicleName(undefined), 'Project'); }); + +test('formatDate handles SQLite and RFC3339 timestamps (SEV-378)', () => { + // SQLite "YYYY-MM-DD HH:MM:SS" (UTC) + assert.equal(formatDate('2026-06-10 14:42:27'), 'Jun 10, 2026'); + // RFC3339 + assert.equal(formatDate('2026-06-10T14:42:27Z'), 'Jun 10, 2026'); + // falsy / invalid → '' + assert.equal(formatDate(''), ''); + assert.equal(formatDate(null), ''); + assert.equal(formatDate('not a date'), ''); +}); + +test('formatDateTime handles both formats + empties (SEV-378)', () => { + assert.match(formatDateTime('2026-06-10 14:42:27'), /Jun 10, 2026/); + assert.match(formatDateTime('2026-06-10T14:42:27Z'), /Jun 10, 2026/); + assert.equal(formatDateTime(''), ''); + assert.equal(formatDateTime('garbage'), ''); +}); diff --git a/src/index.js b/src/index.js index 2b1df5e..3c42fc6 100644 --- a/src/index.js +++ b/src/index.js @@ -3,6 +3,7 @@ export { formatLabel, formatStatus, formatTimeline, formatRange, formatBudget, formatBudgetBucket, vehicleName, + formatDate, formatDateTime, } from './format.js'; export { renderMarkdown } from './markdown.js'; export { waitForImage } from './wait-image.js';