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