loopable / web /src /customer-grid /display.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
051f280 verified
Raw
History Blame Contribute Delete
37.1 kB
// ---------------------------------------------------------------------------
// 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 ?? "");
}
}