// --------------------------------------------------------------------------- // customer-grid / display.ts // The pure DISPLAY-STRING half of cells.ts, split out in wave 7 (item W2): the // export builders run under node in the verify gates, and importing them // through cells.ts dragged the whole glide-data-grid package into a plain // script. Everything here depends only on types.ts. cells.ts re-exports these // names, so every existing import keeps working; the formatting is the same // bytes it was — this is a move, not a change. // --------------------------------------------------------------------------- import type { Field, FieldFormat } from "./types"; import { ratingMax } from "./types"; export type CellValue = string | number | null | undefined; // --- C-AVATAR (wave-14 item 11): the two PURE halves of the assignee avatar --- // They live here rather than in cells.ts for this module's founding reason: cells.ts imports // glide, so nothing in it can be reached by a node gate. The canvas drawing stays there; the // arithmetic and the string handling — the parts that can be wrong in ways a screenshot of one // avatar will not show — are here, and `cells.ts` re-exports both. /** * Up to two letters for the fallback circle. * * ⚠ The LOCAL PART only. Usernames in this tenant are email-shaped, and splitting * "fsanyoto@gmail.com" on its separators gives ["fsanyoto@gmail", "com"] → "FC" — a person's * avatar reading as their mail provider. Cutting at "@" first gives "FS". * * ⚠ A HYPHEN IS NOT A SEPARATOR. Space, dot and underscore divide a given name from a family * name in a username; a hyphen almost always JOINS a compound one — Jo-Anne, Marie-Claire, * Al-Rashid. Treating it as a separator turns "jo-anne_smith" into "JA", which is not that * person's initials and is wrong in the one way nobody can spot: it looks like initials. */ export function avatarInitials(name: string): string { const local = name.split("@")[0]; const parts = local.trim().split(/[\s._]+/).filter(Boolean); if (parts.length === 0) return "?"; if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return (parts[0][0] + parts[1][0]).toUpperCase(); } /** * The avatar's diameter for a row of `height` px: 22 on a comfortable row, never larger, and * never so small it stops being a face. Bounded on BOTH sides on purpose — a `tall` row (48px) * must not grow a 42px portrait, and a `short` one (28px) must not shrink to a dot. */ export function avatarSize(height: number): number { return Math.max(14, Math.min(22, height - 6)); } export interface UserCellData { kind: "aios-user"; /** The assignee's username. "" = unassigned; the cell then paints nothing at all. */ name: string; /** C-AVATAR data URL, when the workspace served one for this user. */ photo?: string; } /** * The `user` cell's payload — HERE rather than inline in `cells.ts` for one reason: `copyData`. * * ⚠ R6 removed the NAME from the cell, so the cell renders no text at all. glide's copy and * export take `copyData`, which means an assignee column silently copies and exports as EMPTY * unless it is set — a real regression that appears in no screenshot, because the screenshot * shows the avatar working perfectly. `cells.ts` imports glide and is therefore unreachable from * a node gate; this is, so the claim can actually be asserted. */ export function userCellPayload( name: string, userAvatars?: Record ): { data: UserCellData; copyData: string } { return { data: { kind: "aios-user", name, photo: userAvatars?.[name] }, copyData: name, }; } /** * The number to PAINT, with `0` as the fallback every existing caller already relies on. * * ⛔⛔ IT MUST PARSE THE SAME WAY THE FOLD DOES, AND FOR ONE COMMIT IT DID NOT. This was a bare * `Number(v)`, so `"1,234.50"` → `NaN` → **0**: the canvas painted `$0` while the totals row * underneath counted 1,234.5. The T81 fix that introduced `numericIsBlank` made the BLANK * decision consistent and left the VALUE on this function, so the cell stopped being blank and * started being a fabricated zero — the exact defect T81 exists to kill, one step to the right. * ⚠ Caught by an adversarial read, NOT by the leg named "the fold counts exactly what the cell * shows" — which only ever called `computeAggs` and never evaluated what the cell shows. A test * whose NAME makes a claim its assertion does not is worse than no test. * ⚠ REACHABLE, and by more than one path: the import door refuses-without-rewriting (so `$45` * is storable by design, and `api_api` asserts it imports), and `automation_engine._rollup_fold` * returns raw source cells as STRINGS for `latest`/`min`/`max`. */ export function num(v: CellValue): number { return numericOrUndefined(v) ?? 0; } /** * ⛔ W29-T81 — HAS THIS NUMERIC CELL NOTHING TO SHOW? Blank, or a stored value that is not a * number at all. * * `num()` above answers **0** for anything unparseable, which is a measurement where there was * none. That is survivable for a column only this app writes; it stopped being survivable when * the import door began accepting rows from a spreadsheet — a `qty` column holding * "seventeen-ish" painted `0` on the canvas while the record panel honestly showed an em-dash. * Two surfaces disagreeing about the same cell, and the one that looks authoritative is the one * making the number up. * * ⚠ The FOLD was already right (`aggregations.numOf` skips what will not parse), so the totals * row never counted these — it is the painted cell alone. Same family as `formulaIsText` below, * asked of the RAW value for the same reason. * ⚠ `" "` is blank, not a zero: `Number(" ")` is 0, which is how a whitespace cell becomes a * number nobody typed. */ export function numericIsBlank(v: CellValue): boolean { return numericOrUndefined(v) === undefined; } /** * ⛔⛔ THE ONE NUMERIC READING OF A STORED CELL, and it has to be one. * * `numericIsBlank` decides whether the CANVAS paints anything; `aggregations.numOf` decides * whether the TOTALS ROW counts it. They were written separately and normalised differently — * this one trimmed, that one stripped `$`, `,` and spaces — so `"1,234.50"` painted BLANK in the * cell and was COUNTED in the total underneath it. That is T81's own defect ("two surfaces * disagreeing about one cell") reintroduced in the opposite direction, by the fix for it, and no * test could see it because every value in the legs was comma-free. * * ⚠ Human spellings are ACCEPTED here on purpose. `coerceClipboardValue` canonicalises what the * UI writes, but the import door refuses-without-rewriting by design, so a `"$45"` posted by curl * is storable — and the honest reading of that cell is 45 on BOTH surfaces, never 45 in the total * and nothing in the cell ([[one-evaluator-per-question]]). */ export function numericOrUndefined(v: CellValue): number | undefined { if (v === null || v === undefined || v === "") return undefined; if (typeof v === "number") return Number.isFinite(v) ? v : undefined; const cleaned = String(v).replace(/[$,\s]/g, ""); if (cleaned === "") return undefined; // " " is blank, not the zero `Number("")` gives const n = Number(cleaned); return Number.isFinite(n) ? n : undefined; } /** * ⛔ IS THIS FORMULA RESULT TEXT? — and it must be asked of the RAW value. * * A formula may return text since 2026-07-31 (owner item 2: CONCATENATE, `&`, TEXT(), * TRUE/FALSE, and any `IF(cond, "yes", "no")`). Both renderers tried to detect that with * `!Number.isFinite(num(v))` — and `num()` above returns **0** for anything non-finite, so the * test was `Number.isFinite(0)`, which is always true. The text branch therefore never ran in * EITHER renderer, and every text-returning formula printed as `0`: on the canvas, in the list, * on kanban cards, in the record panel and in all four export formats. * * The feature had never worked. It surfaced by rendering the owner's own Buy signal formula * through the shipped bundle rather than by asking whether the code looked right. * * ONE function, exported, called by both `formatDisplay` and `makeCell` — they are meant to be * one rendering, and the way they drifted was each holding its own copy of this test. * `Number("")` is 0, so the empty case is excluded explicitly rather than relied upon. * * A TYPE PREDICATE, not a bare boolean: `makeCell` builds a `TextCell` from the value straight * after this test, and without the narrowing the caller has to re-assert the string it just * proved — which is the kind of cast that outlives the reason for it. */ export function formulaIsText(v: CellValue): v is string { return typeof v === "string" && v.trim() !== "" && !Number.isFinite(Number(v)); } /** * ⛔ AND THE BLANK TEST HAS TO AGREE WITH IT, or a value falls between the two. * * `formulaIsText` excludes whitespace-only strings (a `" "` result is not TEXT worth painting). * The callers' own blank guard was `v === ""`, which does not catch `" "` — so a formula * returning a space matched NEITHER, fell through to the numeric path, and printed `0`, because * `Number(" ")` is 0. The exact bug this pair was written to fix, surviving in a narrower case. * * A formula's result is therefore one of exactly three things, and these two predicates make the * three TOTAL: blank (null, empty, or whitespace), text, or a number. Reachable in practice — * `CONCATENATE(" ", "")` is a space, and so is `TRIM()` of one. */ export function formulaIsBlank(v: CellValue): boolean { return v == null || (typeof v === "string" && v.trim() === ""); } /** A checkbox cell's boolean, out of the overlay's '1'-or-empty contract. */ export function checkboxOn(v: CellValue | boolean): boolean { return v === "1" || v === 1 || v === true; } /** * Item 10 — the number DISPLAY string. With no format: exactly the pre-wave-5 * rendering (toLocaleString). `abbrev` wins over decimals when the magnitude * calls for it (34.0M — one decimal, k/M/B); `thousands: false` drops the * separators; `decimals` fixes 0..4 places. */ export function numberText(v: number, fmt: FieldFormat | undefined): string { if (fmt?.abbrev && Math.abs(v) >= 1000) { const abs = Math.abs(v); const [div, suffix] = abs >= 1e9 ? [1e9, "B"] : abs >= 1e6 ? [1e6, "M"] : [1e3, "k"]; return (v / div).toFixed(1) + suffix; } const d = fmt && Number.isInteger(fmt.decimals) && (fmt.decimals as number) >= 0 && (fmt.decimals as number) <= 4 ? (fmt.decimals as number) : null; const thousands = fmt?.thousands !== false; if (d != null) return thousands ? v.toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d, }) : v.toFixed(d); return thousands ? v.toLocaleString() : String(v); } /** * Parse a stored date/datetime string. Odoo datetimes arrive as * "YYYY-MM-DD HH:MM:SS" and are UTC by Odoo convention, so a time-carrying * value with no zone gets a Z; a bare date keeps today's parse (UTC midnight) * so format-less rendering stays byte-identical to the pre-wave-5 path. * * ⭐ EXPORTED in wave 29 (W29-T30) because the xlsx writer needs a Date to turn * into an Excel serial, and the alternative was a SECOND date parser in * `export.ts` — two evaluators for one question, which is how the two drift and * a stamp exports as a different day than it renders * ([[one-evaluator-per-question]]). It stays the only parser in this module. */ export function parseStamp(raw: string): Date | null { const s = raw.includes(" ") ? raw.replace(" ", "T") : raw; const iso = /T\d{2}:\d{2}/.test(s) && !/(?:[zZ]|[+-]\d{2}:?\d{2})$/.test(s) ? s + "Z" : s; const d = new Date(iso); return Number.isNaN(d.getTime()) ? null : d; } /** A BARE calendar day: the shape Odoo sends for a `date`, and the shape wave 26's migration * converted the Instagram preset stamps into. `parseStamp` gives it UTC midnight. */ const BARE_DAY = /^\d{4}-\d{2}-\d{2}$/; /** * Is this stored value a calendar DAY rather than an INSTANT? — the question that decides which * clock renders it (see `dateTimeText`). * * Exported, and it is the whole reason the test for this is worth anything: the alternative is * asserting a rendered string, which depends on the machine's timezone — so the check would pass * on the owner's box (+07:00, where the two clocks agree on the day) and could only ever go red * somewhere else. A predicate is the same decision with no ambient state in it. * * ⚠ The SHAPE OF THE RAW STRING, never `field.type`: wave 26 re-typed columns that hold full ISO * stamps to `date`, so one `date` field can carry both shapes at once during a migration. */ export function isBareDay(v: CellValue): boolean { return BARE_DAY.test(String(v ?? "").trim()); } /** * ⭐ Wave-26 item 3 (owner, R3) — the day, spelled. `Aug 5, 2026`, never `8/5/2026` and never * the raw stamp. One vocabulary for the date half whether or not a time follows it, so a * `date` column and a `created_time` column in the same table read alike. */ const DAY_PARTS = { year: "numeric", month: "short", day: "numeric" } as const; /** * Item 10 — the date DISPLAY string. `time` includes the time of day * (created_time defaults to true — a creation stamp without its time reads as * a duplicate of nothing); `tz: 'utc'` renders the tenant-neutral clock. * Unparseable input renders VERBATIM rather than "Invalid Date" — the raw * string is at least true. * * ⭐ WAVE 26 ITEM 3 (owner, via R3). The complaint was literal: cells read * `2026-08-05T14:03:11+07:00`. SESSION A's half re-typed the Instagram preset * columns from `text` to `date` (which is what routed them here at all) and * migrated the stored cells to bare `YYYY-MM-DD`; this half is that a date must * READ as one. Two changes, and the second is the one that is not cosmetic: * * ⛔ **A BARE `YYYY-MM-DD` IS A CALENDAR DAY, NOT AN INSTANT, so it renders in * UTC.** `parseStamp` gives a bare date UTC midnight, so rendering it in the * viewer's local zone shows the PREVIOUS DAY to everyone west of Greenwich — a * value stored as the 5th reading as the 4th, silently, for a whole hemisphere. * `ui/fmt.ts:date()` already refused to inherit this and its comment names this * function as the holdout; it is no longer one. The tenant runs at +07:00, where * the two agree, which is exactly why it could sit here unnoticed — and why the * US market in COMMERCIALIZATION_PLAN C1b would have met it first. * * A value that CARRIES a time is a real instant and keeps the existing zone * behaviour (local, or UTC when the field says so): shifting those to UTC would * introduce the same off-by-a-day from the other direction. */ export function dateTimeText( // ⚠ STRUCTURAL, not `Field`: this reads exactly two members, and item 8's post rows hold a // raw `posted_at` with no column behind it. Widening the parameter (rather than fabricating a // fake Field at the call site) is what lets the posts list render its days through the SAME // formatter as a date cell instead of growing a second one. Every existing caller passes a // `Field`, which is assignable. field: { type: Field["type"]; format?: FieldFormat }, v: CellValue ): string { if (v == null || v === "") return ""; const raw = String(v); const d = parseStamp(raw); if (!d) return raw; const fmt = field.format; const withTime = fmt?.time ?? field.type === "created_time"; const utc = fmt?.tz === "utc" || isBareDay(raw); const zone = utc ? { timeZone: "UTC" as const } : undefined; return withTime ? d.toLocaleString(undefined, { ...DAY_PARTS, hour: "numeric", minute: "2-digit", second: "2-digit", ...zone, }) : d.toLocaleDateString(undefined, { ...DAY_PARTS, ...zone }); } /** * The plain-string rendering of one (field, value), mirroring makeCell's * displayData. Reused by the record-detail panel and the W2 export builders so * a value reads identically on the canvas, in the panel and in a file. Returns * "" for empty so callers can substitute their own placeholder. */ /** * Wave-18 C5-AUTOFIELD — the STATE word of an automation cell. * * An automation cell holds one machine-written line: `state · when · detail`, e.g. * `ok · 2026-08-03 14:10 · 12 posts`. The state is the first token, and every surface that * paints the cell — canvas tint, record modal, the rail — reads it through HERE rather than * re-splitting the string, because two parsers of one format is how the canvas and the panel * end up disagreeing about whether a run succeeded (the exact way `formula` broke above). * * `"none"` is the honest answer for a cell nothing has written yet — NOT "ok". A column that * has never run must not look like a column that ran and found nothing. */ export type AutomationState = "ok" | "partial" | "error" | "blocked" | "queued" | "none"; const AUTOMATION_STATES: AutomationState[] = ["ok", "partial", "error", "blocked", "queued"]; export function automationState(v: CellValue): AutomationState { const head = String(v ?? "").split("·")[0].trim().toLowerCase(); return (AUTOMATION_STATES as string[]).includes(head) ? (head as AutomationState) : "none"; } /** Everything after the state word — the timestamp and the run's own detail. */ export function automationDetail(v: CellValue): string { const parts = String(v ?? "").split("·"); return parts.length > 1 ? parts.slice(1).join("·").trim() : ""; } /** Sentence-cased state, for anywhere a word reads better than a token. */ export function automationStateLabel(v: CellValue): string { const s = automationState(v); return s === "none" ? "Not run yet" : s[0].toUpperCase() + s.slice(1); } /** * ⭐ Wave-23 C7 (owner item 5) — THE JSON PREVIEW: what a 200px cell says about a document. * * `MAX_JSON_BYTES` is the contract's ceiling, mirrored from the host's write validation so the * viewer can refuse a paste with the same number the server would (a client that lets you type * 40 KB and then shows you a server refusal has wasted the edit). * * The four cases, and each one is a decision rather than a formatting preference: * · **a single-pair object shows THE PAIR.** `{handle: "royalimports"}` is more useful than * "1 key" and it is the shape most machine writes actually have. Past one pair the pairs * stop fitting and the honest answer is the count. * · **many keys / many items → `{…} N keys` / `[…] N items`.** Showing the FIRST pair of a * twelve-key object would let a reader take one arbitrary value — whichever key the writer's * serializer happened to emit first — for the cell's content. * · **a bare scalar renders as itself.** `12`, `"ok"`, `true` and `null` are all valid JSON * documents, and wrapping them in braces would describe a shape they do not have. * · ⛔ **text that does not parse renders AS ITSELF, never as a shape.** The host validates on * write, so this only happens to a value that predates the validation or arrived another * way — and the one thing the preview must never do is claim a document is well-formed. The * viewer's raw tab is where such a value gets read and repaired. * * Blank stays blank: an empty json cell is a document nobody has written, and `{}` is a document * somebody wrote that is empty. Two different facts, two different cells. */ export const MAX_JSON_BYTES = 32 * 1024; /** * ⭐ WAVE 31 · T22 (D-173) — is this parsed value the SERVER'S STAND-IN for a document it did not * send, and if so how big was the real one? Returns a short size string, or null. * * ⛔ THE SHAPE IS `routes_tables._thin_json`'s OWN, and it is matched on `_truncated === true` * plus a numeric `bytes` — never on the key alone, because a person's own document could contain * a `_truncated` key and must not be reported as absent. `_url` is deliberately NOT required: the * viewer keys its fetch on it, but a preview that refused to warn when it was missing would go * quiet in exactly the degraded case that most needs a warning. */ export function truncatedDoc(value: unknown): string | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const v = value as Record; if (v._truncated !== true) return null; const bytes = typeof v.bytes === "number" && isFinite(v.bytes) ? Math.max(0, v.bytes) : 0; if (!bytes) return "not shown"; return bytes >= 1024 ? `${Math.round(bytes / 1024).toLocaleString()} KB not shown` : `${bytes.toLocaleString()} bytes not shown`; } /** Does this text parse as JSON? The ONE test, shared by the preview, the cell and the viewer's * save — three copies of a try/catch is how they end up disagreeing about `""` or `NaN`. */ export function jsonParse(v: CellValue): { ok: boolean; value?: unknown } { const s = String(v ?? "").trim(); if (s === "") return { ok: false }; try { return { ok: true, value: JSON.parse(s) as unknown }; } catch { return { ok: false }; } } /** * ⭐ WAVE-27 item 13 (R13) — one compact line for a CODE cell. * * Deliberately NOT `jsonPreview`, and the difference is the whole reason `code` is its own kind: * a json preview SUMMARISES a parsed document ("{…} 5 keys"), which it can only do because the * value is guaranteed to parse. A snippet has no structure to summarise and often does not parse * at all — half-written SQL is the normal state of one — so the honest preview is the first * non-blank LINE, clipped, plus the line count when there is more underneath. That way the cell * says what the snippet starts with AND that there is more, instead of inventing a shape for it. */ export function codePreview(v: CellValue): string { const raw = String(v ?? ""); if (raw.trim() === "") return ""; const lines = raw.split("\n"); const firstIdx = lines.findIndex((l) => l.trim() !== ""); const first = clip((lines[firstIdx < 0 ? 0 : firstIdx] ?? "").trim(), 60); // The count is of REAL lines, so a snippet padded with blank lines does not claim depth it // does not have — and it is checkable against what opening the editor shows // ([[no-unverifiable-aggregates]]). const n = lines.filter((l) => l.trim() !== "").length; return n > 1 ? `${first} +${n - 1} more` : first; } /** One compact line for a json cell. See the note above for why each case reads as it does. */ export function jsonPreview(v: CellValue): string { const raw = String(v ?? "").trim(); if (raw === "") return ""; const parsed = jsonParse(raw); // ⚠ First line only, and clipped: an unparseable value is often a whole pasted response, and // a cell is not where a 4 KB blob gets read. It is shown rather than hidden because the // reader has to be able to see that the column holds something the app could not open. if (!parsed.ok) return clip(raw.split("\n")[0], 60); const value = parsed.value; // ⭐ ITEM 8 — a posts window says what it holds. Before wave 26 this document fell through to // the generic object branch and every creator's `posts` cell read `{…} 4 keys`, which is true // about JSON and says nothing about the record. // ⚠ The ARRAY LENGTH, never the document's own `n`: the count in a cell has to be checkable // against what opening it shows ([[no-unverifiable-aggregates]]). // ⭐⭐ WAVE 31 · T22 (D-173) — A DOCUMENT THE LIST DID NOT SEND SAYS SO, WITH ITS SIZE. // // ⛔ THE SWALLOW, and it is one branch below this one. `routes_tables._thin_json` replaces an // oversized `json` cell with a stand-in — `{_truncated, bytes, _url}` — so the big document // does not ride a list response (measured: `source_payload` was 95.6–98.5% of every IG grid's // bytes). That stand-in is itself valid JSON with three keys, so it fell through to the generic // object branch and the cell read **`{…} 3 keys`** — a confident, checkable-looking claim about // a document that is not there, and indistinguishable from a real three-key document. R6's // second sentence applies to our own wire: a value we declined to send is reported, never // disguised. // ⚠ THE SIZE IS THE SERVER'S OWN `bytes`, not a guess, and it is what makes the cell honest — // "this column holds 41 KB you have not been shown" is a different fact from "3 keys". const thinned = truncatedDoc(value); if (thinned) return `{…} ${thinned}`; const shown = postsWindowOf(value); if (shown) return shown.posts.length === 0 ? "No posts" : `${shown.posts.length} post${shown.posts.length === 1 ? "" : "s"}`; if (Array.isArray(value)) return value.length === 0 ? "[]" : `[…] ${value.length} item${value.length === 1 ? "" : "s"}`; if (value !== null && typeof value === "object") { const keys = Object.keys(value as Record); if (keys.length === 0) return "{}"; if (keys.length === 1) return clip(`{${keys[0]}: ${scalarText((value as Record)[keys[0]])}}`, 60); return `{…} ${keys.length} keys`; } return clip(scalarText(value), 60); } /** The day, spelled, for a caller holding a raw value and no column — the posts list's * `posted_at`. Routed through `dateTimeText` so it can never drift from a date CELL. */ export function dayText(v: CellValue): string { return dateTimeText({ type: "date" }, v); } /* ═══ ⭐ WAVE 26 ITEM 8 (contract C2) — THE `posts` WINDOW ═══ The preset `posts` cell holds ONE object written by the engine's `posts_window`: { n, metrics, as_of, posts: [{ shortcode, url, posted_at, type, caption, views?, likes?, comments? }] } Two laws come with it and both are enforced below rather than in the components: ⛔ `metrics: false` ⇒ views/likes/comments are ABSENT KEYS, NEVER 0. A reader must render an absent metric as NOTHING. Coercing it to a number here — `Number(p.views) || 0` is the natural thing to type — would fabricate a measurement, which is the engine's own blank-never-zero law broken at the display seam, silently and plausibly (a creator with no metrics bought would read as a creator with no engagement). ⛔ IT IS A WINDOW, NOT THE HISTORY. `ut_ig_posts` / `ut_ig_post_snapshots` accumulate; this cell is the last N (R1/R3 — "one store for one series"). So nothing here may present the cell as a total. ⚠ DETECTED BY SHAPE, never by the field's key. A user may name a column `posts`, and the preset key is not a contract the renderer can see from the value alone. An object carrying a `posts` ARRAY OF OBJECTS is what makes "3 posts" a true sentence about it, whoever wrote it. ═══ */ export interface PostSummary { shortcode?: string; url?: string; posted_at?: string; type?: string; caption?: string; /** ⛔ `undefined` when the metric was not bought. Never 0 — see the note above. */ views?: number; likes?: number; comments?: number; } export interface PostsWindow { /** The engine's own count. May differ from `posts.length`; readers show the LENGTH, because * that is the number a reader can check against what is in front of them. */ n: number; metrics: boolean; as_of?: string; posts: PostSummary[]; } function str(v: unknown): string | undefined { return typeof v === "string" && v !== "" ? v : undefined; } /** ⛔ A number ONLY when the key really holds one. `undefined` for absent, for null, and for a * non-numeric — anything else invents a measurement. */ function metric(v: unknown): number | undefined { return typeof v === "number" && Number.isFinite(v) ? v : undefined; } /** The window behind an ALREADY-PARSED value, or null when this is not one. */ export function postsWindowOf(value: unknown): PostsWindow | null { if (value === null || typeof value !== "object" || Array.isArray(value)) return null; const doc = value as Record; const rows = doc.posts; if (!Array.isArray(rows)) return null; if (!rows.every((r) => r !== null && typeof r === "object" && !Array.isArray(r))) return null; const posts: PostSummary[] = rows.map((r) => { const p = r as Record; return { shortcode: str(p.shortcode), url: str(p.url), posted_at: str(p.posted_at), type: str(p.type), caption: str(p.caption), views: metric(p.views), likes: metric(p.likes), comments: metric(p.comments), }; }); return { n: typeof doc.n === "number" ? doc.n : posts.length, metrics: doc.metrics === true, as_of: str(doc.as_of), posts, }; } /** The window behind a stored CELL, or null. */ export function postsWindow(v: CellValue): PostsWindow | null { const parsed = jsonParse(v); return parsed.ok ? postsWindowOf(parsed.value) : null; } /** A nested value, small enough to sit inside a one-pair preview. Objects and arrays collapse * to their own marks rather than recursing — a preview that unfolds is not a preview. */ function scalarText(v: unknown): string { if (v === null) return "null"; if (Array.isArray(v)) return `[…] ${v.length}`; if (typeof v === "object") return `{…} ${Object.keys(v as object).length}`; return typeof v === "string" ? v : String(v); } /** ⚠ An ellipsis CHARACTER, not three dots: the grid's canvas measures text and three periods * are three glyphs wide. Same mark the group-bar fitter uses. */ function clip(s: string, n: number): string { return s.length <= n ? s : s.slice(0, n - 1) + "…"; } /** * The document, indented for the viewer's pretty tab. Returns the RAW TEXT UNCHANGED when it * does not parse — re-indenting is not repair, and handing a reader a "prettified" version of * something the app could not read would hide the only thing they need to see. */ export function jsonPretty(v: CellValue): string { const raw = String(v ?? ""); const parsed = jsonParse(raw); return parsed.ok ? JSON.stringify(parsed.value, null, 2) : raw; } /** * Wave-5 item 11 — the actionable link behind a url/email/phone value, or null when the value * does not parse as one (the raw text still shows; a link that goes nowhere is worse than no * link). url without a scheme gets https://; `javascript:` can never come out of here. * * ⭐ MOVED here from `RecordDetail.tsx` for wave-26 item 11, when the KANBAN CARD became a * second surface that needs it. Two copies of a scheme guard is how one of them ends up * accepting `javascript:` — the same argument that moved `formatDisplay`'s formula test into one * function after the canvas and the panel had drifted. It is also the only reason this claim is * testable at all: both call sites are React components, and this module is glide-free and * React-free, so a node gate can reach it. */ export function actionHref(field: Field, v: CellValue): string | null { const s = String(v ?? "").trim(); if (!s) return null; if (field.type === "url") { if (/^https?:\/\//i.test(s)) return s; if (/^[\w-]+(\.[\w-]+)+/.test(s)) return `https://${s}`; return null; } if (field.type === "email") return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s) ? `mailto:${s}` : null; if (field.type === "phone") { const digits = s.replace(/[\s().-]/g, ""); return /^\+?\d{5,}$/.test(digits) ? `tel:${digits}` : null; } return null; } export function formatDisplay(field: Field, v: CellValue): string { switch (field.type) { case "currency": return v == null || v === "" ? "" : "$" + numberText(num(v), field.format); case "formula": { // ⛔ A FORMULA MAY RETURN TEXT (owner item 2, 2026-07-31 — CONCATENATE, `&`, TEXT(), // and any `IF(cond, "yes", "no")`). `makeCell` learned that; this function did not, and // the two are supposed to be one rendering. So every surface that reads THIS one — // ListView, KanbanView, the calendar's summary cells, the record-detail panel and ALL // FOUR export formats — printed a text result as `0`, because `num("Buy now")` is NaN. // The canvas showed the words and the file showed a zero, for the same cell. // // Found by rendering the owner's own Buy signal formula through the shipped bundle // (_qa_owner_20260803). The branch below is `makeCell`'s test, verbatim: a non-blank, // non-numeric STRING is its own display. // The three states, in the one order that makes them total — see `formulaIsBlank`. if (formulaIsBlank(v)) return ""; if (formulaIsText(v)) return v; return numberText(num(v), field.format); } case "int": return v == null || v === "" ? "" : numberText(num(v), field.format); case "pct": return v == null || v === "" ? "" : num(v).toFixed(1) + "%"; case "date": case "created_time": return dateTimeText(field, v); case "checkbox": return checkboxOn(v) ? "Checked" : ""; case "rating": { const n = num(v); return n >= 1 ? `${Math.round(n)} of ${ratingMax(field)}` : ""; } case "json": // Wave-23 C7 — the SAME compact line the canvas cell paints. Explicit here rather than // left to `default` for the reason the `formula` note above records: this function feeds // ListView, KanbanView, the calendar, the record panel and all four EXPORT formats, and // falling through would dump a whole 32 KB document into a CSV cell. return jsonPreview(v); case "code": // ⭐ Wave-27 item 13 (R13) — the same compact line the canvas cell paints, and explicit // for `json`'s exact reason: a snippet is multi-LINE, and falling through to `default` // would put raw newlines into a CSV cell, a kanban card and a calendar chip. return codePreview(v); case "automation": // The machine-written line, verbatim. Explicit rather than left to the `default` branch // below: `formula` fell through a default once and printed every text result as `0` for // months, and the lesson recorded there is that a type whose rendering is deliberate // should SAY so where a reader looks for it. return String(v ?? ""); case "metric": // Wave-22 C7 — a server-computed number over the master snapshot series. BLANK IS A // STATE, never zero: the engine sends "" when the window holds no snapshots, and // rendering that as 0 would fabricate a measurement (the engine's own blank-never-zero // law, kept at the display seam too). return v == null || v === "" ? "" : numberText(num(v), field.format); case "rollup": { // ⭐ WAVE 29 (W29-T30) — THE MISSING CASE. `rollup` fell through to `default` and printed // its raw fold string, so ONE cell rendered two ways: the canvas showed `1,491,552.43` // (`cells.ts`'s rollup branch, comma-fixed 2026-08-10 on the owner's instruction) while // every CSV, PDF, JSON-display and Excel export showed `1491552.43`. Same family as D-121 // (the record panel ignoring a field's format) — three surfaces, one concept. // // ⛔ THE BLANK RULE IS THE POINT, and it is why this is a guarded branch and not a call to // `numberText(num(v), ...)`. `_rollup_fold` returns "" for "no rows to aggregate" and only // the count family ever returns a real 0, so `num("")` — which is 0 — would paint the // measurement the server just refused to invent. A fold that is not a number at all // (`concatenate`, `arrayunique`, `latest` over text) keeps its own text. // ⇒ Mirrors `cells.ts`'s branch line for line, deliberately: one concept, one rendering. const raw = String(v ?? ""); const asNum = raw.trim() === "" ? NaN : Number(raw); return Number.isFinite(asNum) ? numberText(asNum, field.format) : raw; } case "multiselect": // The comma-joined SET, read back with breathing room ("A, B" not "A,B"). return String(v ?? "") .split(",") .map((s) => s.trim()) .filter((s) => s !== "") .join(", "); default: return String(v ?? ""); } }