e405985c34
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>
124 lines
4.4 KiB
JavaScript
124 lines
4.4 KiB
JavaScript
// 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': '4–6 months',
|
||
'6_to_12_months': '6–12 months',
|
||
'12_to_24_months': '12–24 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" →
|
||
* "150–200 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' })
|
||
: '';
|
||
}
|