SEV-362: format v0.2.0 — boundary spacing, acronyms, formatRange, numeric budget

- formatLabel: letter→digit boundary spacing ("level2" → "Level 2"),
  acronym casing (DCFC/EV/SUV/VIN/DC/A\C), bare numeric ranges pass
  through untouched
- new formatRange: "150-200" → "150–200 mi", "200-plus"/"200+" →
  "200+ mi"; empty → '' (callers render their own placeholder)
- formatBudget is now the numeric (min, max) dollar formatter matching
  the API payload; the wizard's slug map moves to formatBudgetBucket
- new vehicleName({year, make, model, commissioned})

Breaking rename of formatBudget is safe: no frontend consumes 0.1.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bastian de Byl
2026-06-09 21:08:03 -04:00
parent 50379c1868
commit 65889b6487
4 changed files with 121 additions and 16 deletions
+67 -9
View File
@@ -1,13 +1,35 @@
// SEV-329: shared display formatters. These were copy-pasted (and subtly
// divergent — SEV-310) across public/vendor/admin. Single source of truth here.
// 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 and
* Title-Cases each word. e.g. "contract_pending" → "Contract Pending".
* 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 '';
return String(val).replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
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). */
@@ -25,7 +47,33 @@ export function formatTimeline(val) {
return TIMELINE_LABELS[val] || formatLabel(val);
}
const BUDGET_LABELS = {
/**
* 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',
@@ -33,7 +81,17 @@ const BUDGET_LABELS = {
'100k-plus': '$100,000+',
};
/** Friendly budget-range label for the wizard's slug values. */
export function formatBudget(val) {
return BUDGET_LABELS[val] || val || '';
/** 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;
}