From 65889b6487128e9ebc26ad1a8a3274911d54818a Mon Sep 17 00:00:00 2001 From: Bastian de Byl Date: Tue, 9 Jun 2026 21:08:03 -0400 Subject: [PATCH] =?UTF-8?q?SEV-362:=20format=20v0.2.0=20=E2=80=94=20bounda?= =?UTF-8?q?ry=20spacing,=20acronyms,=20formatRange,=20numeric=20budget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package.json | 2 +- src/format.js | 76 ++++++++++++++++++++++++++++++++++++++++------ src/format.test.js | 54 +++++++++++++++++++++++++++++--- src/index.js | 5 ++- 4 files changed, 121 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index f8e114b..4baa1ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchev/web-shared", - "version": "0.1.0", + "version": "0.2.0", "description": "Shared browser helpers (formatters, markdown, image polling) for the SwitchEV frontends", "type": "module", "exports": { diff --git a/src/format.js b/src/format.js index 002982c..82d81b1 100644 --- a/src/format.js +++ b/src/format.js @@ -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" → + * "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', @@ -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; } diff --git a/src/format.test.js b/src/format.test.js index 080b396..60d6cb8 100644 --- a/src/format.test.js +++ b/src/format.test.js @@ -1,6 +1,9 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { formatLabel, formatStatus, formatTimeline, formatBudget } from './format.js'; +import { + formatLabel, formatStatus, formatTimeline, formatRange, + formatBudget, formatBudgetBucket, vehicleName, +} from './format.js'; test('formatLabel replaces _ and - and title-cases', () => { assert.equal(formatLabel('contract_pending'), 'Contract Pending'); @@ -9,6 +12,22 @@ test('formatLabel replaces _ and - and title-cases', () => { assert.equal(formatLabel(null), ''); }); +test('formatLabel spaces letter→digit boundaries (SEV-362)', () => { + assert.equal(formatLabel('level2'), 'Level 2'); + assert.equal(formatLabel('level1'), 'Level 1'); +}); + +test('formatLabel applies acronym casing (SEV-362)', () => { + assert.equal(formatLabel('dcfc'), 'DCFC'); + assert.equal(formatLabel('dc_fast'), 'DC Fast'); + assert.equal(formatLabel('keep_original_ac'), 'Keep Original A/C'); +}); + +test('formatLabel leaves bare numeric ranges intact (SEV-362)', () => { + assert.equal(formatLabel('100-150'), '100-150'); + assert.equal(formatLabel('200+'), '200+'); +}); + test('formatStatus is formatLabel', () => { assert.equal(formatStatus('matched'), 'Matched'); }); @@ -19,8 +38,33 @@ test('formatTimeline maps known slugs, falls back otherwise', () => { assert.equal(formatTimeline('something_else'), 'Something Else'); }); -test('formatBudget maps known ranges', () => { - assert.equal(formatBudget('25k-50k'), '$25,000 – $50,000'); - assert.equal(formatBudget('100k-plus'), '$100,000+'); - assert.equal(formatBudget(''), ''); +test('formatRange formats wizard range slugs (SEV-362)', () => { + assert.equal(formatRange('150-200'), '150–200 mi'); + assert.equal(formatRange('100-150'), '100–150 mi'); + assert.equal(formatRange('200+'), '200+ mi'); + assert.equal(formatRange('200-plus'), '200+ mi'); + assert.equal(formatRange('250'), '250 mi'); + assert.equal(formatRange(''), ''); + assert.equal(formatRange(null), ''); +}); + +test('formatBudget formats numeric dollar ranges (SEV-362)', () => { + assert.equal(formatBudget(30000, 60000), '$30,000 – $60,000'); + assert.equal(formatBudget(30000, 0), '$30,000+'); + assert.equal(formatBudget(0, 60000), 'Up to $60,000'); + assert.equal(formatBudget(0, 0), 'Flexible'); + assert.equal(formatBudget(undefined, undefined), 'Flexible'); +}); + +test('formatBudgetBucket keeps the wizard slug labels', () => { + assert.equal(formatBudgetBucket('25k-50k'), '$25,000 – $50,000'); + assert.equal(formatBudgetBucket(''), ''); +}); + +test('vehicleName handles commissioned and sparse vehicles', () => { + assert.equal(vehicleName({ year: 1980, make: 'BMW', model: 'E30' }), '1980 BMW E30'); + assert.equal(vehicleName({ commissioned: true }), 'Commissioned build'); + assert.equal(vehicleName({}), 'Project'); + assert.equal(vehicleName({ make: 'BMW' }, 'Lead'), 'BMW'); + assert.equal(vehicleName(undefined), 'Project'); }); diff --git a/src/index.js b/src/index.js index a2067d9..2b1df5e 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,8 @@ // @switchev/web-shared — shared browser helpers for the public/vendor/admin // SwitchEV frontends. SEV-329. -export { formatLabel, formatStatus, formatTimeline, formatBudget } from './format.js'; +export { + formatLabel, formatStatus, formatTimeline, formatRange, + formatBudget, formatBudgetBucket, vehicleName, +} from './format.js'; export { renderMarkdown } from './markdown.js'; export { waitForImage } from './wait-image.js';