// 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' }) : ''; }