| // --------------------------------------------------------------------------- | |
| // 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<string, string> | |
| ): { 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<string, unknown>; | |
| 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<string, unknown>); | |
| if (keys.length === 0) return "{}"; | |
| if (keys.length === 1) | |
| return clip(`{${keys[0]}: ${scalarText((value as Record<string, unknown>)[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<string, unknown>; | |
| 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<string, unknown>; | |
| 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 ?? ""); | |
| } | |
| } | |