// --------------------------------------------------------------------------- // ui / fmt.ts — EXIT wave 2 (W2-6, contract Y2). // // THE formatters. Y1's rule 3 is the reason this file exists: *"the server // NEVER sends a pre-formatted string ⇒ one formatter change fixes every page."* // A number arrives as a number with an `fmt` code beside it, and this module is // the only place that decides how it reads. // // ⚠⚠ TWO `fmt` VOCABULARIES EXIST AND THEY ARE NOT INTERCHANGEABLE (Y1 rule 1). // This file owns the **Y1 fmt enum** — `money · num · pct · int · date`, CLOSED — // used by `kpis`, `table` and `validation` blocks. A `chart` block's `fields[]` // instead carries the **viz `FieldType`** vocabulary (`currency · int · pct · // date · text · status`), because those objects are fed straight into // `chartData(spec, rows, fieldByKey)`. `fmt:"money"` inside a chart field // typechecks nowhere near here and renders as raw text — it is the bug that // looks fine. Keep the two apart: `Fmt` below is never assigned to a viz field. // // The rules are the SHIPPED ones, mirrored from `platform/ui/primitives.py` // so a number reads identically in the Streamlit page and in the shell during // the strangle. Where this file deliberately differs, it says so. // --------------------------------------------------------------------------- /** Y1's closed format enum. A column with NO `fmt` renders verbatim (rule 2) — * that is how a text column is expressed without opening the enum. */ export type Fmt = "money" | "num" | "pct" | "int" | "date"; /** What every formatter returns for a value it has nothing to say about. * An em dash, never "0" and never "" — a missing number and a zero are * different facts and a KPI card must not conflate them. */ export const BLANK = "—"; /** Above this, a percentage stops being readable and becomes a multiple. * `+14,975%` reads as noise; `+151×` reads as a fact (Y1 rule 3). */ export const PCT_AS_MULTIPLE_AT = 999; function toNumber(v: unknown): number | null { if (v == null || v === "") return null; const n = typeof v === "number" ? v : Number(v); return Number.isFinite(n) ? n : null; } /** * The house number rule, from `ui/primitives.py:num` verbatim: comma-grouped, * NO decimals — except a non-whole single-digit value (|v| < 10), which keeps * one. So big numbers read clean (1,204,882), small ratios keep their meaning * (7.7), and whole numbers never carry a trailing `.0` ($0, not $0.0). */ export function num(v: unknown, dp?: number): string { const n = toNumber(v); if (n == null) return BLANK; const places = dp ?? (Math.abs(n) < 10 && !Number.isInteger(n) ? 1 : 0); return n.toLocaleString(undefined, { minimumFractionDigits: places, maximumFractionDigits: places, }); } /** Money. The sign goes OUTSIDE the currency mark (`-$1,204`, not `$-1,204`) — * `primitives.py:money`'s shape, and the one that reads as a loss at a glance. */ export function money(v: unknown, dp?: number): string { const n = toNumber(v); if (n == null) return BLANK; return n < 0 ? `-$${num(Math.abs(n), dp)}` : `$${num(n, dp)}`; } /** Whole counts. Always 0 dp — an order count with a decimal is a bug wearing * a format. */ export function int(v: unknown): string { const n = toNumber(v); if (n == null) return BLANK; return Math.round(n).toLocaleString(); } export interface PctOptions { /** Render a leading `+` on non-negative values (deltas do; shares do not). */ signed?: boolean; dp?: number; } /** * Percentages, and the ONE place the big-ratio rule lives (Y1 rule 3). * * A percentage over ~1000% is arithmetically fine and rhetorically useless: it * means the prior-year base was tiny, and `+14,975%` invites the reader to * treat a rounding artefact as a result. Past `PCT_AS_MULTIPLE_AT` the value is * restated as the MULTIPLE it actually is — `1 + v/100`, zero decimals, exactly * `app.py:2851`'s formula. The caller supplies the comparison word (Y1 sends * `delta_label: "vs LY"`), so this returns `+151×` and never invents a suffix. * * ⚠ The value is a PERCENTAGE ALREADY — 12.4 means 12.4%. It is never * multiplied by 100 here. Semantic percentages elsewhere in this codebase are * 0–1 while transform `*_pct` values are 0–100 ([[analyst-chart-library]]), and * a blanket rescale would be silently wrong for half of them. */ export function pct(v: unknown, opts: PctOptions = {}): string { const n = toNumber(v); if (n == null) return BLANK; if (n >= PCT_AS_MULTIPLE_AT) { const mult = (1 + n / 100).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0, }); return `+${mult}×`; } const sign = opts.signed && n >= 0 ? "+" : ""; return `${sign}${num(n, opts.dp)}%`; } const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; /** * Dates, from what the server actually sends: an ISO date, an ISO datetime, or * a bare `YYYY-MM` month key. * * ⚠ Every branch is computed in UTC ON PURPOSE. `new Date("2026-05-01")` parses * as UTC midnight, so `toLocaleDateString()` in any negative-offset timezone * renders it as 30 April — a report row dated the last day of the previous * month. The grid's cell formatter (`customer-grid/display.ts:dateTimeText`) * still has that behaviour and documents it as byte-compatibility with a * pre-wave-5 path; a formatter written today should not inherit it, so this one * does not. That is a deliberate difference, not a drift. * * An unparseable value renders VERBATIM rather than "Invalid Date" — the raw * string is at least true. */ export function date(v: unknown): string { if (v == null || v === "") return BLANK; const s = String(v); const month = /^(\d{4})-(\d{2})$/.exec(s); if (month) { const m = Number(month[2]); if (m >= 1 && m <= 12) return `${MONTHS[m - 1]} ${month[1].slice(2)}`; } const iso = s.includes(" ") ? s.replace(" ", "T") : s; const withZone = /T\d{2}:\d{2}/.test(iso) && !/(?:[zZ]|[+-]\d{2}:?\d{2})$/.test(iso) ? `${iso}Z` : iso; const d = new Date(withZone); if (Number.isNaN(d.getTime())) return s; return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric", timeZone: "UTC", }); } /** Y2's named surface. The contract fixes these five names. */ export const fmt = { money, num, pct, int, date } as const; /** * Render one value under an OPTIONAL format code — the dispatcher every block * renderer uses. * * ⛔ `code` undefined means RENDER VERBATIM (Y1 rule 2), which is how a text * column exists without opening the closed enum. It does NOT mean "guess": a * formatter picked from the value's runtime type would format an account number * as `4,412` and a postcode as `01,234`. * * An UNKNOWN code also renders verbatim rather than throwing — same reasoning * as Y1 rule 7's unknown-block rule: the server may learn a format before this * client does, and one new code must not blank a whole page. */ export function formatValue(v: unknown, code?: string, opts?: PctOptions): string { if (code === undefined || code === null || code === "") { return v == null || v === "" ? BLANK : String(v); } switch (code) { case "money": return money(v); case "num": return num(v); case "pct": return pct(v, opts); case "int": return int(v); case "date": return date(v); default: return v == null || v === "" ? BLANK : String(v); } }