File size: 37,130 Bytes
a44526d 051f280 a44526d 051f280 a44526d | 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 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 | // ---------------------------------------------------------------------------
// 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 ?? "");
}
}
|