Merge pull request 'SEV-362: format v0.2.0 — boundary spacing, acronyms, formatRange, numeric budget' (#2) from sev-362/format-v0.2.0 into main
This commit was merged in pull request #2.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@switchev/web-shared",
|
"name": "@switchev/web-shared",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Shared browser helpers (formatters, markdown, image polling) for the SwitchEV frontends",
|
"description": "Shared browser helpers (formatters, markdown, image polling) for the SwitchEV frontends",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
+67
-9
@@ -1,13 +1,35 @@
|
|||||||
// SEV-329: shared display formatters. These were copy-pasted (and subtly
|
// SEV-329/SEV-362: shared display formatters. These were copy-pasted (and
|
||||||
// divergent — SEV-310) across public/vendor/admin. Single source of truth here.
|
// 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
|
* Friendly label for an internal slug: replaces '-' and '_' with spaces,
|
||||||
* Title-Cases each word. e.g. "contract_pending" → "Contract Pending".
|
* 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) {
|
export function formatLabel(val) {
|
||||||
if (!val) return '';
|
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). */
|
/** 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);
|
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',
|
'under-10k': 'Under $10,000',
|
||||||
'10k-25k': '$10,000 – $25,000',
|
'10k-25k': '$10,000 – $25,000',
|
||||||
'25k-50k': '$25,000 – $50,000',
|
'25k-50k': '$25,000 – $50,000',
|
||||||
@@ -33,7 +81,17 @@ const BUDGET_LABELS = {
|
|||||||
'100k-plus': '$100,000+',
|
'100k-plus': '$100,000+',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Friendly budget-range label for the wizard's slug values. */
|
/** Friendly budget label for the wizard's slug buckets (pre-submit UI only). */
|
||||||
export function formatBudget(val) {
|
export function formatBudgetBucket(val) {
|
||||||
return BUDGET_LABELS[val] || 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;
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-5
@@ -1,6 +1,9 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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', () => {
|
test('formatLabel replaces _ and - and title-cases', () => {
|
||||||
assert.equal(formatLabel('contract_pending'), 'Contract Pending');
|
assert.equal(formatLabel('contract_pending'), 'Contract Pending');
|
||||||
@@ -9,6 +12,22 @@ test('formatLabel replaces _ and - and title-cases', () => {
|
|||||||
assert.equal(formatLabel(null), '');
|
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', () => {
|
test('formatStatus is formatLabel', () => {
|
||||||
assert.equal(formatStatus('matched'), 'Matched');
|
assert.equal(formatStatus('matched'), 'Matched');
|
||||||
});
|
});
|
||||||
@@ -19,8 +38,33 @@ test('formatTimeline maps known slugs, falls back otherwise', () => {
|
|||||||
assert.equal(formatTimeline('something_else'), 'Something Else');
|
assert.equal(formatTimeline('something_else'), 'Something Else');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('formatBudget maps known ranges', () => {
|
test('formatRange formats wizard range slugs (SEV-362)', () => {
|
||||||
assert.equal(formatBudget('25k-50k'), '$25,000 – $50,000');
|
assert.equal(formatRange('150-200'), '150–200 mi');
|
||||||
assert.equal(formatBudget('100k-plus'), '$100,000+');
|
assert.equal(formatRange('100-150'), '100–150 mi');
|
||||||
assert.equal(formatBudget(''), '');
|
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');
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-1
@@ -1,5 +1,8 @@
|
|||||||
// @switchev/web-shared — shared browser helpers for the public/vendor/admin
|
// @switchev/web-shared — shared browser helpers for the public/vendor/admin
|
||||||
// SwitchEV frontends. SEV-329.
|
// 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 { renderMarkdown } from './markdown.js';
|
||||||
export { waitForImage } from './wait-image.js';
|
export { waitForImage } from './wait-image.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user