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 ?? "");
  }
}