// --------------------------------------------------------------------------- // viz / seriesData.ts — EXIT wave 2 (W2-7/W2-9). The Y1 chart block's data // preparation, pure and React-free so `verify_ui.py` can run it under node. // // Both functions here guard a MEASURED trap rather than a hypothetical one: // · `inServerOrder` — the engine sorts a non-date category axis by VALUE // DESCENDING, so a trend renders as a revenue-ordered sawtooth unless the // server's chronology is reapplied (Y1 amendment 7). // · `asFields` — a chart field carries the viz `FieldType` vocabulary, NOT // Y1's `fmt` enum, and mixing them typechecks nowhere and renders as raw // text (Y1 rule 1). // --------------------------------------------------------------------------- import type { Bucket } from "./chartData"; import type { Field, FieldType } from "./types"; /** The Y1 `fields[]` entry — the server's minimal field descriptor. */ export interface WireField { key: string; label: string; type: string; source?: string; } /** The viz field-type vocabulary as a runtime set. ⚠ `money` is deliberately * absent: it belongs to Y1's `fmt` enum, and a chart field carrying it is the * mix-up rule 1 warns about. It lands on `text`, which is the visible symptom. */ export const VIZ_FIELD_TYPES: ReadonlySet = new Set([ "text", "status", "currency", "int", "date", "pct", "select", "user", "multiselect", "checkbox", "phone", "email", "url", "rating", "created_time", "formula", // Wave 18 C5 ④b — the RUNTIME copy of the vocabulary. A `Set` is not a `Record`: the // compiler stays green when this goes stale and `asFields` silently downgrades the column // to text. When a `FieldType` joins the union, this line moves WITH the batch. "automation", // Wave 22 C7 — `metric` is numeric (a measure over a snapshot window) and PLOTS. "metric", // Wave 19 R7 — `image`. Listed for the reason the comment above gives, not because a chart // plots pictures: it does not. `isNumericFieldType` leaves it categorical, so it groups like // any other reference string. What listing it BUYS is honesty — without it `asFields` would // report the column as `text`, and the one thing a viz field type decides is what the surface // is allowed to claim about the column. "image", // Wave 23 C7 — `json`. THE BATCH LINE the union's own comment names: a `Set` is not a // `Record`, so the compiler stays green when this goes stale and `asFields` silently reports // a json column as `text`. Same reasoning as `image` — the type is categorical either way, // and what listing it buys is that the surface stops mis-naming the column. "json", // 2026-08-07 — the relational pair. THE BATCH LINE again: a `Set` is not a `Record`, so // without this `asFields` would report a rollup column as `text` and the chart builder would // refuse to plot the one column the feature exists to produce. "link", "rollup", // ⭐ Wave-27 item 13 (R13) — `code`. THE BATCH LINE a fourth time, and this one was caught by // `verify_ui` rather than by the compiler, which is the whole point of that gate existing: the // union in `viz/types.ts` went red under `tsc` the moment `code` joined the grid's, and this // Set stayed green while `asFields` quietly reported a code column as `text`. Categorical // either way (`isNumericFieldType` does not name it); what listing it buys is that the surface // stops mis-naming the column. "code", ]); /** * Y1 `fields[]` → viz `Field[]`. * * ⚠ An UNRECOGNISED `type` falls back to `text` rather than throwing — the * server may learn a field type before this client does (Y1 rule 7's reasoning, * one level down), and one unknown type must not blank a whole chart. `text` is * the safe landing: it groups, and it never claims to be measurable. */ export function asFields(wire: WireField[]): Field[] { return wire.map((f) => ({ key: f.key, label: f.label, type: (VIZ_FIELD_TYPES.has(f.type) ? f.type : "text") as FieldType, source: f.source === "overlay" ? "overlay" : "odoo", })); } /** * Re-order buckets to the server's declared chronology. * * ⛔ ANYTHING THE LIST DOES NOT NAME IS APPENDED, NEVER DROPPED. Filtering to * `x_order` would silently delete a period the server plotted but forgot to * order — a bar disappearing from a revenue trend, with nothing anywhere going * red. Sorting unnamed keys to the end keeps every value on screen and makes * the omission visible instead. */ export function inServerOrder(buckets: Bucket[], xOrder?: string[]): Bucket[] { if (!xOrder || xOrder.length === 0) return buckets; const rank = new Map(xOrder.map((k, i) => [k, i])); return [...buckets].sort( (a, b) => (rank.get(a.key) ?? Number.MAX_SAFE_INTEGER) - (rank.get(b.key) ?? Number.MAX_SAFE_INTEGER) ); }