loopable / web /src /ui /fmt.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
c14ceee verified
Raw
History Blame Contribute Delete
7.58 kB
// ---------------------------------------------------------------------------
// 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);
}
}