File size: 7,577 Bytes
092334a c14ceee 092334a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 | // ---------------------------------------------------------------------------
// 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);
}
}
|