@crossworks/share-ui 0.231.0 → 0.232.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crossworks/share-ui",
3
- "version": "0.231.0",
3
+ "version": "0.232.0",
4
4
  "description": "The server-rendered share surface — the /s/<token> presenters, view-payload contract, mini-app sandbox, and the few primitives they need. Lives in MANTLE (the server renders these), published for jackdaw to consume; may depend only on @mantle/{client-types,content-core} (the jackdaw-repo-split boundary).",
5
5
  "exports": {
6
6
  "./app-presenter": "./src/app-presenter.tsx",
@@ -32,8 +32,8 @@
32
32
  "./styles/app.css": "./styles/app.css"
33
33
  },
34
34
  "dependencies": {
35
- "@mantle/client-types": "npm:@crossworks/client-types@0.231.0",
36
- "@mantle/content-core": "npm:@crossworks/content-core@0.231.0",
35
+ "@mantle/client-types": "npm:@crossworks/client-types@0.232.0",
36
+ "@mantle/content-core": "npm:@crossworks/content-core@0.232.0",
37
37
  "@radix-ui/react-label": "^2.1.12",
38
38
  "@radix-ui/react-slot": "^1.3.0",
39
39
  "class-variance-authority": "^0.7.1",
@@ -1,25 +1,102 @@
1
1
  'use client';
2
2
 
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
- import { Loader2, Table2 } from 'lucide-react';
5
- import { Button } from './ui/button';
4
+ import {
5
+ ArrowUpRight,
6
+ Calendar,
7
+ CalendarClock,
8
+ DollarSign,
9
+ Hash,
10
+ Link as LinkIcon,
11
+ List,
12
+ Loader2,
13
+ Percent,
14
+ SquareCheck,
15
+ Tags,
16
+ Type,
17
+ Variable,
18
+ type LucideIcon,
19
+ } from 'lucide-react';
20
+ import { AGGREGATE_KINDS, type AggregateKind } from '@mantle/content-core/table-model';
6
21
  import { cn } from './lib/utils';
7
22
  import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
8
23
 
9
24
  type CellValue = string | number | boolean | string[] | null;
10
25
  type PublicRow = { id: string; cells: Record<string, CellValue> };
11
26
  type PublicColumn = { id: string; name: string; type: string };
12
- type PublicTab = { id: string; name: string; rowCount: number; columns: PublicColumn[] };
27
+ type PublicTab = {
28
+ id: string;
29
+ name: string;
30
+ rowCount: number;
31
+ columns: PublicColumn[];
32
+ aggregates?: Record<string, AggregateKind>;
33
+ aggregateValues?: Record<string, number | null>;
34
+ };
13
35
 
14
36
  const PAGE = 200;
15
37
 
38
+ /** Header glyphs, the owner grid's map (table-grid.tsx) — the two surfaces
39
+ * should not disagree about what a currency column looks like. */
40
+ const TYPE_ICON: Record<string, LucideIcon> = {
41
+ text: Type,
42
+ number: Hash,
43
+ currency: DollarSign,
44
+ percent: Percent,
45
+ date: Calendar,
46
+ datetime: CalendarClock,
47
+ checkbox: SquareCheck,
48
+ select: List,
49
+ multiselect: Tags,
50
+ url: LinkIcon,
51
+ formula: Variable,
52
+ reference: ArrowUpRight,
53
+ };
54
+
55
+ const AGG_LABEL: Record<AggregateKind, string> = {
56
+ none: 'None',
57
+ sum: 'Sum',
58
+ avg: 'Average',
59
+ count: 'Count',
60
+ min: 'Min',
61
+ max: 'Max',
62
+ empty: 'Empty',
63
+ filled: 'Filled',
64
+ };
65
+
66
+ const NUMERIC_TYPES = new Set(['number', 'currency', 'percent', 'formula']);
67
+
68
+ /** Aggregate values are plain numbers by the time they reach here. An average
69
+ * keeps two decimals; everything else renders as a grouped integer-ish number,
70
+ * which is what a total of a currency or a count wants. */
71
+ function formatAggregate(value: number, kind: AggregateKind): string {
72
+ if (kind === 'avg') return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
73
+ return value.toLocaleString(undefined, { maximumFractionDigits: 4 });
74
+ }
75
+
16
76
  /**
17
- * Public read-only grid for a shared table. File-backed workbooks page their
18
- * PUBLISHED rows through GET /s/[token]/rows (offset windows, "Load more");
19
- * legacy JSONB tables arrive whole in the share view and render inline.
20
- * Raw fetch on purpose: apiFetch is the app shell's authenticated wrapper.
21
- * No edit affordances of any kind — the server surface is read-only anyway;
22
- * this component simply has nothing to strip.
77
+ * Read-only grid for a shared table.
78
+ *
79
+ * File-backed workbooks page their PUBLISHED rows through GET /s/[token]/rows;
80
+ * legacy JSONB tables arrive whole in the share view and render inline. Raw
81
+ * fetch on purpose: apiFetch is the app shell's authenticated wrapper. No edit
82
+ * affordances of any kind — the server surface is read-only anyway; this
83
+ * component simply has nothing to strip.
84
+ *
85
+ * ── Totals, and the one thing worth being careful about ────────────────────
86
+ *
87
+ * A footer total must never be computed from the rows this component happens to
88
+ * be holding. It pages 200 at a time, so a client-side sum over a partial
89
+ * window is not a smaller number — it is a WRONG one that looks exactly as
90
+ * authoritative as a right one. So for a file-backed tab every total comes from
91
+ * the server: the owner's presets ride in the share view already computed, and
92
+ * a total the READER picks is fetched from /s/[token]/aggregate, which runs it
93
+ * in SQL over every row.
94
+ *
95
+ * A legacy table is the exception, and only because it genuinely arrives whole:
96
+ * there, `computeAggregate` over the doc in hand is the complete answer.
97
+ *
98
+ * A reader's own choice is view-local and never persisted. Nothing here writes;
99
+ * reloading restores whatever the owner set.
23
100
  */
24
101
  export function TablePresenter({
25
102
  view,
@@ -30,7 +107,11 @@ export function TablePresenter({
30
107
  title: string;
31
108
  icon: string | null;
32
109
  tabs: PublicTab[] | null;
33
- legacyDoc: { columns: PublicColumn[]; rows: PublicRow[] } | null;
110
+ legacyDoc: {
111
+ columns: PublicColumn[];
112
+ rows: PublicRow[];
113
+ aggregates?: Record<string, AggregateKind>;
114
+ } | null;
34
115
  };
35
116
  token: string;
36
117
  chrome?: PresenterChrome;
@@ -49,6 +130,12 @@ export function TablePresenter({
49
130
  // Tab-switch race guard: a stale page for the previous tab must not land.
50
131
  const reqSeq = useRef(0);
51
132
 
133
+ /** The reader's own picks, per column — view-local, never sent anywhere that
134
+ * writes. Keyed per tab so switching tabs does not carry a choice across to
135
+ * a column that merely shares an id position. */
136
+ const [readerAggs, setReaderAggs] = useState<Record<string, AggregateKind>>({});
137
+ const [readerVals, setReaderVals] = useState<Record<string, number | null>>({});
138
+
52
139
  const fetchPage = useCallback(
53
140
  async (offset: number, forTab: string) => {
54
141
  const seq = ++reqSeq.current;
@@ -77,6 +164,8 @@ export function TablePresenter({
77
164
  if (tab) {
78
165
  setRows([]);
79
166
  setTotal(tab.rowCount);
167
+ setReaderAggs({});
168
+ setReaderVals({});
80
169
  void fetchPage(0, tab.id);
81
170
  }
82
171
  // Deliberately narrower than exhaustive-deps: refetch when the SELECTED TAB
@@ -98,117 +187,302 @@ export function TablePresenter({
98
187
  const hasMore = !view.legacyDoc && shownRows.length < totalRows;
99
188
 
100
189
  const numericCols = useMemo(
101
- () =>
102
- new Set(columns.filter((c) => c.type === 'number' || c.type === 'currency').map((c) => c.id)),
190
+ () => new Set(columns.filter((c) => NUMERIC_TYPES.has(c.type)).map((c) => c.id)),
103
191
  [columns],
104
192
  );
105
193
 
194
+ /** The owner's presets for whatever is on screen. */
195
+ const ownerAggs = view.legacyDoc ? (view.legacyDoc.aggregates ?? {}) : (tab?.aggregates ?? {});
196
+
197
+ const kindFor = (colId: string): AggregateKind => readerAggs[colId] ?? ownerAggs[colId] ?? 'none';
198
+
199
+ /**
200
+ * The total to draw under a column, or null for "nothing to show".
201
+ *
202
+ * Three sources, and which one applies is entirely about whether the rows in
203
+ * hand are the whole story:
204
+ * - legacy → compute locally; the doc IS complete.
205
+ * - a reader's pick on a file-backed tab → the value fetched for it.
206
+ * - otherwise → the owner's preset, computed server-side in the share view.
207
+ */
208
+ const valueFor = (colId: string): number | null => {
209
+ const kind = kindFor(colId);
210
+ if (kind === 'none') return null;
211
+ if (view.legacyDoc) return legacyAggregate(view.legacyDoc.rows, colId, kind);
212
+ if (colId in readerAggs) return readerVals[colId] ?? null;
213
+ return tab?.aggregateValues?.[colId] ?? null;
214
+ };
215
+
216
+ const pickAggregate = async (colId: string, kind: AggregateKind) => {
217
+ setReaderAggs((prev) => ({ ...prev, [colId]: kind }));
218
+ if (kind === 'none' || view.legacyDoc || !tab) return;
219
+ setReaderVals((prev) => ({ ...prev, [colId]: null }));
220
+ try {
221
+ const qs = new URLSearchParams({ tab: tab.id, col: colId, kind });
222
+ const r = await fetch(`/s/${token}/aggregate?${qs.toString()}`, { cache: 'no-store' });
223
+ if (!r.ok) return;
224
+ const d = (await r.json()) as { value: number | null };
225
+ setReaderVals((prev) => ({ ...prev, [colId]: d.value }));
226
+ } catch {
227
+ // A failed total stays blank. There is no honest fallback — the rows in
228
+ // hand are a window, and a number from them would be wrong.
229
+ }
230
+ };
231
+
106
232
  const embedded = isEmbedded(chrome);
233
+ const anyTotal = columns.some((c) => kindFor(c.id) !== 'none');
107
234
 
108
- return (
109
- // A table is the clearest case for the embedded shell: every extra pixel is
110
- // another column a member can read without scrolling sideways, and the
111
- // centred 6xl cap was throwing that width away.
112
- <div className={embedded ? 'w-full px-6 py-6' : 'mx-auto max-w-6xl px-6 py-12'}>
113
- <header className={embedded ? 'mb-3' : 'mb-6 text-center'}>
114
- {!embedded && (
115
- <h1 className="text-xl font-semibold tracking-tight">
116
- {view.icon ? `${view.icon} ` : ''}
117
- {view.title}
118
- </h1>
119
- )}
120
- <p className={cn('text-xs text-muted-foreground', !embedded && 'mt-1')}>
121
- {totalRows} row{totalRows === 1 ? '' : 's'}
122
- </p>
123
- </header>
124
-
125
- {tabs.length > 1 && (
126
- <div className="mb-3 flex flex-wrap gap-1">
127
- {tabs.map((t) => (
128
- <button
129
- key={t.id}
130
- type="button"
131
- onClick={() => setTabId(t.id)}
132
- className={cn(
133
- 'rounded-md px-3 py-1.5 text-sm transition-colors',
134
- t.id === (tab?.id ?? null)
135
- ? 'bg-primary text-primary-foreground'
136
- : 'text-muted-foreground hover:bg-muted hover:text-foreground',
137
- )}
138
- >
139
- {t.name}
140
- </button>
141
- ))}
142
- </div>
235
+ const header = (
236
+ <header className={embedded ? 'mb-3 shrink-0' : 'mb-6 shrink-0 text-center'}>
237
+ {!embedded && (
238
+ <h1 className="text-xl font-semibold tracking-tight">
239
+ {view.icon ? `${view.icon} ` : ''}
240
+ {view.title}
241
+ </h1>
143
242
  )}
243
+ <p className={cn('text-xs text-muted-foreground', !embedded && 'mt-1')}>
244
+ {view.legacyDoc || !hasMore
245
+ ? `${totalRows} row${totalRows === 1 ? '' : 's'}`
246
+ : `${shownRows.length} of ${totalRows} rows`}
247
+ </p>
248
+ </header>
249
+ );
144
250
 
145
- <div className="overflow-x-auto rounded-lg border border-border">
146
- <table className="w-full border-collapse text-sm">
147
- <thead>
148
- <tr className="border-b border-border bg-muted/50">
149
- {columns.map((c) => (
150
- <th
151
- key={c.id}
251
+ const tabStrip = tabs.length > 1 && (
252
+ <div className="mb-3 flex shrink-0 flex-wrap gap-1">
253
+ {tabs.map((t) => (
254
+ <button
255
+ key={t.id}
256
+ type="button"
257
+ onClick={() => setTabId(t.id)}
258
+ className={cn(
259
+ 'rounded-md px-3 py-1.5 text-sm transition-colors',
260
+ t.id === (tab?.id ?? null)
261
+ ? 'bg-primary text-primary-foreground'
262
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground',
263
+ )}
264
+ >
265
+ {t.name}
266
+ </button>
267
+ ))}
268
+ </div>
269
+ );
270
+
271
+ const grid = (
272
+ <table className="w-full border-collapse text-sm">
273
+ {/* Sticky against the scroll box below, not the page: a grid whose
274
+ headers leave the screen at row 40 is a grid of unlabelled numbers. */}
275
+ <thead className="sticky top-0 z-10">
276
+ <tr className="border-b border-border bg-muted">
277
+ {columns.map((c) => {
278
+ const Icon = TYPE_ICON[c.type] ?? Type;
279
+ return (
280
+ <th
281
+ key={c.id}
282
+ className={cn(
283
+ 'whitespace-nowrap px-3 py-2 text-left font-medium text-muted-foreground',
284
+ numericCols.has(c.id) && 'text-right',
285
+ )}
286
+ >
287
+ <span
152
288
  className={cn(
153
- 'whitespace-nowrap px-3 py-2 text-left font-medium text-muted-foreground',
154
- numericCols.has(c.id) && 'text-right',
289
+ 'inline-flex items-center gap-1.5',
290
+ numericCols.has(c.id) && 'flex-row-reverse',
155
291
  )}
156
292
  >
293
+ <Icon className="size-3.5 shrink-0 opacity-70" aria-hidden />
157
294
  {c.name}
158
- </th>
159
- ))}
160
- </tr>
161
- </thead>
162
- <tbody>
163
- {shownRows.map((r) => (
164
- <tr key={r.id} className="border-b border-border/60 last:border-0 hover:bg-muted/30">
165
- {columns.map((c) => (
166
- <td
167
- key={c.id}
295
+ </span>
296
+ </th>
297
+ );
298
+ })}
299
+ </tr>
300
+ </thead>
301
+ <tbody>
302
+ {shownRows.map((r) => (
303
+ <tr key={r.id} className="border-b border-border/60 last:border-0 hover:bg-muted/30">
304
+ {columns.map((c) => (
305
+ <td
306
+ key={c.id}
307
+ className={cn(
308
+ 'max-w-96 truncate px-3 py-1.5',
309
+ numericCols.has(c.id) && 'text-right tabular-nums',
310
+ )}
311
+ >
312
+ <Cell value={r.cells[c.id] ?? null} type={c.type} />
313
+ </td>
314
+ ))}
315
+ </tr>
316
+ ))}
317
+ {shownRows.length === 0 && !loading && (
318
+ <tr>
319
+ <td
320
+ colSpan={Math.max(columns.length, 1)}
321
+ className="px-3 py-8 text-center text-muted-foreground"
322
+ >
323
+ {failed ? 'Could not load rows.' : 'No rows.'}
324
+ </td>
325
+ </tr>
326
+ )}
327
+ </tbody>
328
+ {/* Always rendered, even with nothing set: the row IS the affordance.
329
+ A member who wants a total needs somewhere to ask for one, and a
330
+ footer that appears only once the owner has already chosen leaves
331
+ them no door. `bg-background` because a transparent sticky row shows
332
+ the scrolling cells through itself. */}
333
+ <tfoot className="sticky bottom-0 z-10">
334
+ <tr className={cn('border-t-2 border-border bg-muted', !anyTotal && 'text-xs')}>
335
+ {columns.map((c) => {
336
+ const kind = kindFor(c.id);
337
+ const value = valueFor(c.id);
338
+ return (
339
+ <td
340
+ key={c.id}
341
+ className={cn(
342
+ 'border-l border-border/60 px-1.5 py-1 text-xs first:border-l-0',
343
+ numericCols.has(c.id) && 'text-right',
344
+ )}
345
+ >
346
+ <label className="flex items-center justify-end gap-1.5">
347
+ <span className="sr-only">Total for {c.name}</span>
348
+ {kind !== 'none' && (
349
+ <span className="min-w-0 truncate font-medium tabular-nums">
350
+ {value === null ? (
351
+ // null is a real answer — "this column cannot be
352
+ // totalled that way". A 0 here would be a lie.
353
+ <span className="text-muted-foreground/50">—</span>
354
+ ) : (
355
+ formatAggregate(value, kind)
356
+ )}
357
+ </span>
358
+ )}
359
+ <select
360
+ value={kind}
361
+ onChange={(e) => void pickAggregate(c.id, e.target.value as AggregateKind)}
168
362
  className={cn(
169
- 'max-w-96 truncate px-3 py-1.5',
170
- numericCols.has(c.id) && 'text-right tabular-nums',
363
+ 'cursor-pointer rounded-sm border-0 bg-transparent py-0 text-xs outline-none',
364
+ kind === 'none'
365
+ ? 'text-muted-foreground/60 hover:text-foreground'
366
+ : 'text-muted-foreground',
171
367
  )}
172
368
  >
173
- <Cell value={r.cells[c.id] ?? null} type={c.type} />
174
- </td>
175
- ))}
176
- </tr>
177
- ))}
178
- {shownRows.length === 0 && !loading && (
179
- <tr>
180
- <td
181
- colSpan={Math.max(columns.length, 1)}
182
- className="px-3 py-8 text-center text-muted-foreground"
183
- >
184
- {failed ? 'Could not load rows.' : 'No rows.'}
185
- </td>
186
- </tr>
187
- )}
188
- </tbody>
189
- </table>
190
- </div>
369
+ {AGGREGATE_KINDS.map((k) => (
370
+ <option key={k} value={k}>
371
+ {k === 'none' ? 'Σ' : AGG_LABEL[k]}
372
+ </option>
373
+ ))}
374
+ </select>
375
+ </label>
376
+ </td>
377
+ );
378
+ })}
379
+ </tr>
380
+ </tfoot>
381
+ </table>
382
+ );
191
383
 
192
- <div className="mt-3 flex items-center justify-center gap-3">
193
- {loading && (
194
- <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
195
- <Loader2 className="size-3 animate-spin" aria-hidden /> Loading…
196
- </span>
197
- )}
198
- {!loading && hasMore && tab && (
199
- <Button
200
- variant="outline"
201
- size="sm"
202
- onClick={() => void fetchPage(shownRows.length, tab.id)}
203
- >
204
- <Table2 /> Load more ({shownRows.length} of {totalRows})
205
- </Button>
206
- )}
384
+ const foot = (
385
+ <>
386
+ {loading && (
387
+ <div className="flex shrink-0 items-center justify-center gap-1.5 py-2 text-xs text-muted-foreground">
388
+ <Loader2 className="size-3 animate-spin" aria-hidden /> Loading…
389
+ </div>
390
+ )}
391
+ {/* Auto-fetch rather than a "Load more" button: scrolling toward the end
392
+ is already the gesture that means "show me the rest". */}
393
+ {!loading && hasMore && tab && (
394
+ <MoreSentinel onReveal={() => void fetchPage(shownRows.length, tab.id)} />
395
+ )}
396
+ </>
397
+ );
398
+
399
+ // Embedded, the grid OWNS the height it was given: the header and tab strip
400
+ // are fixed, and the table scrolls inside a bounded box so its sticky header
401
+ // and footer have something to stick to. On the standalone /s page there is
402
+ // no height to own — the page scrolls, the table simply grows, and sticky
403
+ // has nothing to do.
404
+ if (embedded) {
405
+ return (
406
+ <div className="flex h-full min-h-0 w-full flex-col px-6 py-6">
407
+ {header}
408
+ {tabStrip}
409
+ <div className="min-h-0 flex-1 overflow-auto rounded-lg border border-border scrollbar-thin">
410
+ {grid}
411
+ {foot}
412
+ </div>
207
413
  </div>
414
+ );
415
+ }
416
+
417
+ return (
418
+ <div className="mx-auto max-w-6xl px-6 py-12">
419
+ {header}
420
+ {tabStrip}
421
+ <div className="overflow-x-auto rounded-lg border border-border">{grid}</div>
422
+ {foot}
208
423
  </div>
209
424
  );
210
425
  }
211
426
 
427
+ /**
428
+ * The legacy path's total. Safe ONLY here: a legacy table's rows arrive whole
429
+ * in the share view, so this is the complete set, not a window. Mirrors
430
+ * computeAggregate's numeric semantics — non-numeric cells are ignored by the
431
+ * numeric kinds rather than counted as zero.
432
+ */
433
+ function legacyAggregate(rows: PublicRow[], colId: string, kind: AggregateKind): number | null {
434
+ const isEmptyCell = (v: CellValue) =>
435
+ v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0);
436
+ if (kind === 'count') return rows.length;
437
+ if (kind === 'filled') return rows.filter((r) => !isEmptyCell(r.cells[colId] ?? null)).length;
438
+ if (kind === 'empty') return rows.filter((r) => isEmptyCell(r.cells[colId] ?? null)).length;
439
+ const nums = rows
440
+ .map((r) => {
441
+ const v = r.cells[colId];
442
+ if (typeof v === 'number') return v;
443
+ if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v);
444
+ if (typeof v === 'boolean') return v ? 1 : 0;
445
+ return null;
446
+ })
447
+ .filter((n): n is number => n !== null);
448
+ if (nums.length === 0) return null;
449
+ switch (kind) {
450
+ case 'sum':
451
+ return nums.reduce((a, b) => a + b, 0);
452
+ case 'avg':
453
+ return nums.reduce((a, b) => a + b, 0) / nums.length;
454
+ case 'min':
455
+ return Math.min(...nums);
456
+ case 'max':
457
+ return Math.max(...nums);
458
+ default:
459
+ return null;
460
+ }
461
+ }
462
+
463
+ /** Fetches the next page when it scrolls into view. */
464
+ function MoreSentinel({ onReveal }: { onReveal: () => void }) {
465
+ const ref = useRef<HTMLDivElement>(null);
466
+ // The callback changes identity every render, so the observer reads it from a
467
+ // ref — re-creating the observer each render would disconnect it mid-scroll
468
+ // and the grid would stop growing until the reader scrolled again.
469
+ const reveal = useRef(onReveal);
470
+ reveal.current = onReveal;
471
+
472
+ useEffect(() => {
473
+ const el = ref.current;
474
+ if (!el) return;
475
+ const io = new IntersectionObserver(
476
+ (entries) => entries[0]?.isIntersecting && reveal.current(),
477
+ { rootMargin: '400px' },
478
+ );
479
+ io.observe(el);
480
+ return () => io.disconnect();
481
+ }, []);
482
+
483
+ return <div ref={ref} className="h-8 shrink-0" aria-hidden />;
484
+ }
485
+
212
486
  function Cell({ value, type }: { value: CellValue; type: string }) {
213
487
  if (value === null || value === undefined || value === '') {
214
488
  return <span className="text-muted-foreground/50">—</span>;
@@ -16,7 +16,7 @@ import type { TocEntry } from '@mantle/content-core/page-toc';
16
16
  import type { CoverageGap, FormulaSpec } from '@mantle/content-core/formula-spec';
17
17
  import type { TargetSignature } from '@mantle/content-core/formula-signature';
18
18
  import type { DimensionIssue } from '@mantle/content-core/formula-dimensions';
19
- import type { Column, Row } from '@mantle/content-core/table-model';
19
+ import type { AggregateKind, Column, Row } from '@mantle/content-core/table-model';
20
20
 
21
21
  export type ShareFolderListing = {
22
22
  /** ltree path currently being listed (the shared root or a descendant). */
@@ -75,8 +75,29 @@ export type ShareViewPayload =
75
75
  name: string;
76
76
  rowCount: number;
77
77
  columns: Array<{ id: string; name: string; type: string }>;
78
+ /**
79
+ * The owner's footer totals for this tab: colId → kind, and the VALUE
80
+ * computed server-side over the whole tab.
81
+ *
82
+ * The value has to come from the server and this is the whole reason
83
+ * the field exists. A reader holds one 200-row window at a time, so a
84
+ * sum taken over what it happens to have loaded is not a smaller
85
+ * number — it is a WRONG one, and wrong quietly, which is worse than
86
+ * absent. `aggregateWindow` runs it in SQL over every row.
87
+ *
88
+ * Optional: an older server sends neither, and the footer simply does
89
+ * not render.
90
+ */
91
+ aggregates?: Record<string, AggregateKind>;
92
+ aggregateValues?: Record<string, number | null>;
78
93
  }> | null;
79
- legacyDoc: { columns: Column[]; rows: Row[] } | null;
94
+ legacyDoc: {
95
+ columns: Column[];
96
+ rows: Row[];
97
+ /** Legacy tables arrive WHOLE, so the reader can compute these itself
98
+ * with `computeAggregate` and no endpoint is involved. Settings only. */
99
+ aggregates?: Record<string, AggregateKind>;
100
+ } | null;
80
101
  }
81
102
  | {
82
103
  kind: 'formula';