Files
shared/src/format.js
T
Bastian de Byl e405985c34 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>
2026-06-10 16:36:24 -04:00

124 lines
4.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SEV-329/SEV-362: shared display formatters. These were copy-pasted (and
// subtly divergent — SEV-310, SEV-362) across public/vendor/admin. Single
// source of truth here.
// Words rendered as fixed acronyms/casings instead of Title Case.
const ACRONYMS = {
dcfc: 'DCFC',
ev: 'EV',
suv: 'SUV',
vin: 'VIN',
dc: 'DC',
ac: 'A/C',
};
/**
* Friendly label for an internal slug: replaces '-' and '_' with spaces,
* inserts a space at letter→digit boundaries ("level2" → "Level 2"), and
* Title-Cases each word with acronym fixups ("dcfc" → "DCFC").
* Purely-numeric tokens like "100-150" keep their hyphen — use formatRange
* for range slugs.
*/
export function formatLabel(val) {
if (!val) return '';
const s = String(val);
// A bare numeric range ("100-150", "200+") is data, not a slug — leave it.
if (/^\d+(-\d+)?\+?$/.test(s)) return s;
return s
.replace(/[-_]/g, ' ')
.replace(/([a-zA-Z])(\d)/g, '$1 $2')
.split(/\s+/)
.map((w) => ACRONYMS[w.toLowerCase()] || w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
/** Friendly status label (alias of formatLabel, named for intent at call sites). */
export const formatStatus = formatLabel;
const TIMELINE_LABELS = {
'4_to_6_months': '46 months',
'6_to_12_months': '612 months',
'12_to_24_months': '1224 months',
flexible: 'Flexible',
};
/** Friendly conversion timeline. Falls back to formatLabel for unknown slugs. */
export function formatTimeline(val) {
return TIMELINE_LABELS[val] || formatLabel(val);
}
/**
* Friendly range-goal label from the wizard's slug values: "150-200" →
* "150200 mi", "200-plus"/"200+" → "200+ mi", "250" → "250 mi". Returns ''
* for empty input — callers decide the placeholder (e.g. "Not specified").
*/
export function formatRange(val) {
if (!val) return '';
const s = String(val).trim().toLowerCase().replace(/\s*(mi|miles)$/, '');
if (/^\d+\s*(\+|-?\s*plus)$/.test(s)) return s.replace(/\s*(\+|-?\s*plus)$/, '') + '+ mi';
const m = s.match(/^(\d+)\s*-\s*(\d+)$/);
if (m) return `${m[1]}${m[2]} mi`;
if (/^\d+$/.test(s)) return `${s} mi`;
return formatLabel(val);
}
/**
* Budget label from the API's numeric range (dollars): "$30,000 $60,000",
* "$30,000+" (no max), "Up to $60,000" (no min), "Flexible" (neither).
*/
export function formatBudget(min, max) {
if (!min && !max) return 'Flexible';
if (!max) return '$' + Number(min).toLocaleString() + '+';
if (!min) return 'Up to $' + Number(max).toLocaleString();
return '$' + Number(min).toLocaleString() + ' $' + Number(max).toLocaleString();
}
const BUDGET_BUCKET_LABELS = {
'under-10k': 'Under $10,000',
'10k-25k': '$10,000 $25,000',
'25k-50k': '$25,000 $50,000',
'50k-100k': '$50,000 $100,000',
'100k-plus': '$100,000+',
};
/** Friendly budget label for the wizard's slug buckets (pre-submit UI only). */
export function formatBudgetBucket(val) {
return BUDGET_BUCKET_LABELS[val] || val || '';
}
/**
* Display name for a project's vehicle. Commissioned builds have no donor
* vehicle; otherwise join the parts that exist, falling back to a generic.
*/
export function vehicleName({ year, make, model, commissioned } = {}, fallback = 'Project') {
if (commissioned) return 'Commissioned build';
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' })
: '';
}