SEV-378: add tolerant formatDate/formatDateTime to shared (v0.3.0)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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' })
|
||||
: '';
|
||||
}
|
||||
|
||||
+19
-1
@@ -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'), '');
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user