fsanyoto commited on
Commit
0c7b86d
Β·
verified Β·
1 Parent(s): 7127075

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
api/routes_grid.py CHANGED
@@ -677,6 +677,37 @@ def grid_events_route(body: dict = Body(default=None),
677
  out["doc"] = ctx.out.doc
678
  if ctx.out.toast is not None:
679
  out["toast"] = ctx.out.toast
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
680
  # ⚠ NOTHING IS INVALIDATED HERE, on purpose. The runtime cache holds ONLY the scope-shaped
681
  # Odoo pool (see `routes_customers._pool_rows`), and no event on this route can change an
682
  # Odoo column β€” Odoo is read-only. Everything an event DOES change (overlays, fields, views,
 
677
  out["doc"] = ctx.out.doc
678
  if ctx.out.toast is not None:
679
  out["toast"] = ctx.out.toast
680
+
681
+ # ── ⭐ owner item 2 (2026-08-03): THE NEW MEASURE COLUMN'S VALUES, ONE ROUND TRIP SOONER ──
682
+ #
683
+ # Creating a measure column cost the browser TWO sequential trips before a single number
684
+ # appeared: this one to persist the field, then a whole `/workspace` to compute it. The
685
+ # second cannot start until the first lands (the resolver reads the PERSISTED field), so the
686
+ # wait was structural, not slow code β€” the owner's "it takes some time for the data to
687
+ # populate". The values are computed here instead, immediately after the write, and ride
688
+ # this response.
689
+ #
690
+ # ⚠ IT COSTS NOTHING EXTRA TO COMPUTE. The expensive part is one DuckDB aggregate over the
691
+ # book, and `rt.measure_memo` is keyed on (pool stamp, scope, pool, measure, window) β€” so
692
+ # the `/workspace` re-read that still follows HITS the memo instead of doing this work. The
693
+ # query happens once either way; only its position moved.
694
+ #
695
+ # ⚠ NARROW ON PURPOSE. Gated to an actual measure-column write, so an overlay edit or a
696
+ # cohort add β€” the overwhelming majority of events β€” never pays for a second assembly.
697
+ #
698
+ # ⚠ AND IT IS A SHORTCUT, NOT A PATH. Any failure is swallowed: `WORKSPACE_STALE` still
699
+ # fires from `rerender`, and the re-read still delivers these values exactly as it does
700
+ # today. Nothing depends on this having worked.
701
+ if out["rerender"] and scope != "product" and any(
702
+ isinstance(e, dict) and e.get("type") == "field_upsert"
703
+ and str(((e.get("field") or {}) if isinstance(e.get("field"), dict) else {})
704
+ .get("key") or "").startswith("measure_")
705
+ for e in events):
706
+ try:
707
+ fresh = grid_assembly(session, scope=scope, consume_corrections=False)
708
+ out["derived"] = {str(pid): cells for pid, cells in fresh["derived"].items()}
709
+ except Exception:
710
+ pass
711
  # ⚠ NOTHING IS INVALIDATED HERE, on purpose. The runtime cache holds ONLY the scope-shaped
712
  # Odoo pool (see `routes_customers._pool_rows`), and no event on this route can change an
713
  # Odoo column β€” Odoo is read-only. Everything an event DOES change (overlays, fields, views,
royalimports_os/aios_grid_fields.json CHANGED
@@ -260,6 +260,7 @@
260
  }
261
  ],
262
  "_product_comment": "ADDITIVE, wave 15 C-TOPIC. The PRODUCT table's field contract. Kept as a SEPARATE top-level key rather than restructuring `fields` into {customer_data, product_data}: both existing readers (aios_grid._load_fields, aios-web/api/main.py) index doc['fields'] directly, and reshaping that mid-wave would break the embed for a cosmetic gain. The keyed shape can arrive when both readers move in ONE commit; until then this is the product half and `fields` is the customer half.",
 
263
  "product_data": {
264
  "identity": "pid",
265
  "business_key": "code",
@@ -400,18 +401,6 @@
400
  "default": false,
401
  "description": "Days of supply minus lead time. Negative means it runs out before a reorder lands."
402
  },
403
- {
404
- "key": "buy_now",
405
- "label": "Buy signal",
406
- "type": "select",
407
- "source": "odoo",
408
- "default": true,
409
- "description": "Buy now when cover is already shorter than lead time. Blank if either is unknown.",
410
- "options": [
411
- "Buy now",
412
- "OK"
413
- ]
414
- },
415
  {
416
  "key": "stock_bucket",
417
  "label": "Stock status",
 
260
  }
261
  ],
262
  "_product_comment": "ADDITIVE, wave 15 C-TOPIC. The PRODUCT table's field contract. Kept as a SEPARATE top-level key rather than restructuring `fields` into {customer_data, product_data}: both existing readers (aios_grid._load_fields, aios-web/api/main.py) index doc['fields'] directly, and reshaping that mid-wave would break the embed for a cosmetic gain. The keyed shape can arrive when both readers move in ONE commit; until then this is the product half and `fields` is the customer half.",
263
+ "_product_removed_buy_now": "OWNER, 2026-08-03: 'Buy signal' (key buy_now, a select of Buy now / OK) is NO LONGER A PRESET FIELD. It never earned one: it is a formula over two columns that are both still right here, and the platform has a formula field type for exactly that. THE FORMULA, which reproduces the retired column row for row (modules/product_data.validate proves the equivalence, and goes red if it ever stops holding): IF({lead_days} > 0, IF({dos} < {lead_days}, \"Buy now\", \"OK\"), \"\") . Every branch matches the old server rule, including the blanks - the formula engine refuses a comparison against a blank rather than coercing it to 0, so a SKU with no days-of-supply or no lead time comes out empty, which is 'we do not know' and not 'you are fine'. NOTE the column is still COMPUTED in product_data.pool(): it ships nowhere (rows_from_pool projects strictly through this contract, so no Field means no cell on the wire) and exists only as validate()'s oracle. A formula field is PER-USER, so nothing shared may filter on it - the Buy list view filters on dos/lead_days directly (_seed_wave17).",
264
  "product_data": {
265
  "identity": "pid",
266
  "business_key": "code",
 
401
  "default": false,
402
  "description": "Days of supply minus lead time. Negative means it runs out before a reorder lands."
403
  },
 
 
 
 
 
 
 
 
 
 
 
 
404
  {
405
  "key": "stock_bucket",
406
  "label": "Stock status",
royalimports_os/modules/product_data.py CHANGED
@@ -39,10 +39,17 @@ import core.periods as P
39
  import core.table_store as table_store
40
  import modules.products as products
41
 
42
- #: Columns that exist ONLY on a consolidated pull. See decision 2.
43
  #: `cover_gap_d` / `buy_now` (wave 17) join it because both are computed FROM `dos`, and a buy
44
  #: signal built on a stock number the caller cannot see would be a recommendation nobody could
45
  #: check ([[no-unverifiable-aggregates]]).
 
 
 
 
 
 
 
46
  CONSOLIDATED_ONLY = ("on_hand", "unit_cost", "inv_value", "qty_ltm", "dos", "stock_bucket",
47
  "cover_gap_d", "buy_now")
48
 
@@ -187,6 +194,18 @@ def pool(team_id=None, t=None):
187
  # no lead time; `dos` is null for anything that never sells through. "We don't know"
188
  # and "you're fine" are different sentences, and only one of them is safe to print
189
  # beside a purchasing decision.
 
 
 
 
 
 
 
 
 
 
 
 
190
  dos, lead = e.get("dos"), s.get("lead_days")
191
  if isinstance(dos, (int, float)) and isinstance(lead, (int, float)) and lead > 0:
192
  row["cover_gap_d"] = int(round(dos - lead))
@@ -201,6 +220,47 @@ def pool(team_id=None, t=None):
201
  return rows
202
 
203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  def validate(team_id=None, t=None):
205
  """Reconcile what SHIPS to an independent aggregate β€” the platform's own rule that a number
206
  which does not tie to Odoo does not ship.
@@ -241,4 +301,56 @@ def validate(team_id=None, t=None):
241
  "detail": {"buy_now": len(buy), "ok": len(ok_rows), "unknown": len(blank),
242
  "misclassified": mis},
243
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  return checks
 
39
  import core.table_store as table_store
40
  import modules.products as products
41
 
42
+ #: POOL-ROW KEYS that exist ONLY on a consolidated pull. See decision 2.
43
  #: `cover_gap_d` / `buy_now` (wave 17) join it because both are computed FROM `dos`, and a buy
44
  #: signal built on a stock number the caller cannot see would be a recommendation nobody could
45
  #: check ([[no-unverifiable-aggregates]]).
46
+ #:
47
+ #: ⚠ ROW KEYS, NOT FIELDS, and the distinction started mattering on 2026-08-03. Two readers:
48
+ #: `verify_perm_scope` asserts the shape of a POOL ROW against this, and `routes_products`
49
+ #: narrows the FIELD list with it. Since owner item 5 `buy_now` is a row key with no Field β€”
50
+ #: computed as `validate()`'s oracle, projected onto no wire β€” so it belongs here for the first
51
+ #: reader and is inert for the second. Removing it would stop the scope gate proving that a
52
+ #: BU-scoped pull withholds it.
53
  CONSOLIDATED_ONLY = ("on_hand", "unit_cost", "inv_value", "qty_ltm", "dos", "stock_bucket",
54
  "cover_gap_d", "buy_now")
55
 
 
194
  # no lead time; `dos` is null for anything that never sells through. "We don't know"
195
  # and "you're fine" are different sentences, and only one of them is safe to print
196
  # beside a purchasing decision.
197
+ #
198
+ # β›” `buy_now` SHIPS NOWHERE ANY MORE, and is computed anyway. OWNER 2026-08-03: it
199
+ # is not a preset field β€” it is a formula over the two columns beside it, and the
200
+ # platform has a formula field type for exactly that. Its Field is gone from
201
+ # `aios_grid_fields.json`, and `rows_from_pool` projects rows STRICTLY through that
202
+ # contract, so no Field means no cell on the wire. Nothing renders this key.
203
+ #
204
+ # It stays computed because deleting it would delete the PROOF. `validate()` below
205
+ # reconciles the formula's predicate against this one, row for row, which is the only
206
+ # thing that makes "the same exact figures" a claim rather than an assertion β€” and
207
+ # this repo has the scar already (wave 17: archiving `ar` "would have DELETED the
208
+ # proof"). Two comparisons per SKU is what that costs.
209
  dos, lead = e.get("dos"), s.get("lead_days")
210
  if isinstance(dos, (int, float)) and isinstance(lead, (int, float)) and lead > 0:
211
  row["cover_gap_d"] = int(round(dos - lead))
 
220
  return rows
221
 
222
 
223
+ #: ⭐ THE BUY SIGNAL, AS THE FORMULA FIELD EVALUATES IT (owner, 2026-08-03).
224
+ #:
225
+ #: The owner's ruling was that "Buy signal" is not a preset field β€” it is a formula over two
226
+ #: columns the product table already carries, and the platform has a formula field type for it.
227
+ #: The formula, verbatim, is what `_seed_wave17.BUY_SIGNAL_FORMULA` creates and what the JSON
228
+ #: contract's `_product_removed_buy_now` note records:
229
+ #:
230
+ #: IF({lead_days} > 0, IF({dos} < {lead_days}, "Buy now", "OK"), "")
231
+ #:
232
+ #: This function is a PORT of how `customer-grid/formulaEngine.ts` evaluates that tree, not a
233
+ #: restatement of the business rule β€” that is the whole point, because the two could drift and
234
+ #: `validate()` is where the drift must show. Three engine behaviours it reproduces exactly:
235
+ #:
236
+ #: Β· a `ref` to a missing/non-numeric cell is None (`case "ref"` returns null for anything
237
+ #: that is not a finite number or a string);
238
+ #: Β· `cmp` returns BLANK unless BOTH sides read as numbers β€” it never coerces a blank to 0,
239
+ #: which is the difference between this and a filter engine's `toNum(null) === 0`;
240
+ #: Β· `IF` with a non-boolean condition returns BLANK ("no truthiness"), so a blank comparison
241
+ #: propagates out as a blank cell rather than taking the false branch.
242
+ BUY_SIGNAL_FORMULA = 'IF({lead_days} > 0, IF({dos} < {lead_days}, "Buy now", "OK"), "")'
243
+
244
+
245
+ def _buy_signal_formula(row):
246
+ """Evaluate `BUY_SIGNAL_FORMULA` over one pool row -> 'Buy now' | 'OK' | '' (blank)."""
247
+ def num(v):
248
+ # The engine's `ref` + `asNumber`: booleans are not values here, and a non-finite
249
+ # number is null. `isinstance(True, int)` is True in Python, so bool is excluded first.
250
+ if isinstance(v, bool) or not isinstance(v, (int, float)):
251
+ return None
252
+ return v if v == v and v not in (float('inf'), float('-inf')) else None
253
+
254
+ lead, dos = num(row.get("lead_days")), num(row.get("dos"))
255
+ if lead is None: # `{lead_days} > 0` is blank -> IF(blank, …) is blank
256
+ return ""
257
+ if not lead > 0:
258
+ return "" # the formula's own else-branch
259
+ if dos is None: # `{dos} < {lead_days}` is blank -> IF(blank, …) is blank
260
+ return ""
261
+ return "Buy now" if dos < lead else "OK"
262
+
263
+
264
  def validate(team_id=None, t=None):
265
  """Reconcile what SHIPS to an independent aggregate β€” the platform's own rule that a number
266
  which does not tie to Odoo does not ship.
 
301
  "detail": {"buy_now": len(buy), "ok": len(ok_rows), "unknown": len(blank),
302
  "misclassified": mis},
303
  })
304
+ # ⭐ OWNER 2026-08-03 β€” "use the formula fields to come up to the same EXACT figures".
305
+ #
306
+ # This is that sentence, as a check. The retired preset column and the formula that
307
+ # replaces it must agree on every SKU, in all three states, or the replacement is not a
308
+ # replacement. `_buy_signal_formula` is a line-by-line port of the client formula
309
+ # engine's evaluation of
310
+ #
311
+ # IF({lead_days} > 0, IF({dos} < {lead_days}, "Buy now", "OK"), "")
312
+ #
313
+ # including the part that is easy to get wrong: a comparison against a BLANK is blank,
314
+ # never a coerced 0. (The client engine's `cmp` returns null unless both sides read as
315
+ # numbers, and `IF` refuses a non-boolean condition β€” so a missing `dos` yields "" and
316
+ # not "Buy now". A filter engine would have said `0 < 30` and swept in every SKU that
317
+ # never sells through; the formula engine does not, and this check is what holds it.)
318
+ #
319
+ # ⚠ It compares SETS OF SKUs, not counts. Two different partitions can share a shape.
320
+ disagree = sorted(r["code"] for r in rows
321
+ if (r.get("buy_now") or "") != _buy_signal_formula(r))
322
+ checks.append({
323
+ "check": 'Buy signal as a FORMULA field == the retired preset column, per SKU '
324
+ '(IF({lead_days} > 0, IF({dos} < {lead_days}, "Buy now", "OK"), ""))',
325
+ "ours": len(rows) - len(disagree), "theirs": len(rows),
326
+ "ok": not disagree,
327
+ "detail": {"disagreeing_skus": disagree[:10], "n_disagree": len(disagree)},
328
+ })
329
+ # The other half of "the same figures": the SHARED Buy list view can no longer filter on
330
+ # the retired column, and its replacement conditions must select the same SKUs. They are
331
+ # `dos isNotEmpty AND lead_days isNotEmpty AND lead_days > 0 AND dos < lead_days`
332
+ # (_seed_wave17.views), so this reproduces exactly that conjunction.
333
+ #
334
+ # β›” THE NEGATIVE CONTROL IS WHY THIS IS NOT `cover_gap_d < 0`, which reads like the
335
+ # obvious filter and is WRONG: `cover_gap_d` is `int(round(dos - lead))`, so a genuine
336
+ # gap of -0.4 days rounds to 0 and that SKU drops off a buy list it belongs on.
337
+ view_rows = {r["code"] for r in rows
338
+ if isinstance(r.get("dos"), (int, float))
339
+ and isinstance(r.get("lead_days"), (int, float))
340
+ and r["lead_days"] > 0 and r["dos"] < r["lead_days"]}
341
+ signal_rows = {r["code"] for r in buy}
342
+ rounding_would_miss = sorted(
343
+ c for c in signal_rows
344
+ if next((r for r in rows if r["code"] == c), {}).get("cover_gap_d") == 0)
345
+ checks.append({
346
+ "check": "Buy list view conditions (dos/lead_days, no retired column) select "
347
+ "exactly the Buy-now SKUs",
348
+ "ours": len(view_rows), "theirs": len(signal_rows),
349
+ "ok": view_rows == signal_rows,
350
+ "detail": {"only_in_view": sorted(view_rows - signal_rows)[:10],
351
+ "only_in_signal": sorted(signal_rows - view_rows)[:10],
352
+ # Reported, not asserted: how many SKUs a `cover_gap_d < 0` filter would
353
+ # have silently dropped. 0 today does not make that filter correct.
354
+ "cover_gap_rounds_to_zero": len(rounding_would_miss)},
355
+ })
356
  return checks
web/dist-embed/index.html CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/apiContract.ts CHANGED
@@ -46,6 +46,20 @@ export const DATA_ERROR_EVENT = "aios:data-error";
46
  * `detail` is the string. */
47
  export const TOAST_EVENT = "aios:toast";
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  /** A write CHANGED durable workspace state (a cohort's membership, a list add, a
50
  * folder move) and the client's copy is now stale.
51
  *
 
46
  * `detail` is the string. */
47
  export const TOAST_EVENT = "aios:toast";
48
 
49
+ /**
50
+ * owner item 2 (2026-08-03) β€” the events response carried DERIVED CELLS with it.
51
+ *
52
+ * Creating a measure column used to take two sequential round trips before a number appeared:
53
+ * one to persist the field, one to compute it. The server now computes right after the write
54
+ * and returns the values on the same response; `detail` is `{[pid: string]: {[key]: value}}`,
55
+ * exactly the `derived` shape `/workspace` sends.
56
+ *
57
+ * ⚠ A SHORTCUT, NEVER A PATH. `WORKSPACE_STALE_EVENT` still fires beside it and the re-read
58
+ * still delivers the same values β€” so a browser that misses this, or a server that could not
59
+ * compute it, behaves exactly as it did before. Nothing may be built on it arriving.
60
+ */
61
+ export const DERIVED_CELLS_EVENT = "aios:derived-cells";
62
+
63
  /** A write CHANGED durable workspace state (a cohort's membership, a list add, a
64
  * folder move) and the client's copy is now stale.
65
  *
web/src/customer-grid/CustomerGrid.tsx CHANGED
@@ -38,9 +38,12 @@ import ColumnMenu from "./ColumnMenu";
38
  import type { ColumnMenuState } from "./ColumnMenu";
39
  import { HEADER_ICONS } from "./iconShapes";
40
  import { emitHostEvent, eventId } from "./hostBridge";
41
- import { NAV_MINIMIZE_EVENT, signal } from "../apiContract";
42
- import { runExport } from "./export";
43
  import type { ExportFormat } from "./export";
 
 
 
44
  import { echoReemit, reconcileEchoView } from "./viewEcho";
45
  import { pruneStamps, reconcileFields } from "./optimism";
46
  import { newFolderId, pruneFolderStamps, reconcileFolders, resolveFolderId } from "./folders";
@@ -123,6 +126,20 @@ function frozenCountOf(config: ViewConfig): number {
123
  }
124
  const ALL_VIEW_ID = "all-customers";
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  /**
127
  * I2 β€” measure a cell's text in GLIDE'S OWN font, so "is this cut off?" is a fact rather than
128
  * a character-count guess (a guess is wrong in both directions: "IIIII" is narrow, "WWWWW" is
@@ -907,6 +924,70 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
907
  px += (visibleCols[i] as { width?: number } | undefined)?.width ?? 0;
908
  return Math.max(0, px - GROUP_LABEL_PAD);
909
  }, [config, visibleCols]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
910
  const getCellContent = useGetCellContent(
911
  displayRows,
912
  visibleCols,
@@ -916,7 +997,9 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
916
  userAvatars,
917
  avatarTick,
918
  groupLabelSpace,
919
- measureGroupText
 
 
920
  );
921
  const { gridSelection, selectedPids, onGridSelectionChange, selectPids, togglePid,
922
  setActiveCell, clearSelection } =
@@ -1986,14 +2069,90 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
1986
  [serverWindowed, fields, fieldByKey, measureSets, cohortSets, today, overlayEdits]
1987
  );
1988
 
1989
- /** W2 β€” the view menu's Export (Customer page): the named view over the whole pool. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1990
  const exportViewData = useCallback(
1991
  (viewId: string, format: ExportFormat) => {
1992
  const view = views.find((v) => v.id === viewId);
1993
  if (!view) return;
1994
- exportRows(view.name, normalizeConfig(view.config, fields), computedRows, format);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1995
  },
1996
- [views, fields, computedRows, exportRows]
1997
  );
1998
 
1999
  /**
@@ -3092,6 +3251,8 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
3092
  about (C-CAL). The server refuses `product` in words. */
3093
  scope={scope}
3094
  onOpen={setDetailPid}
 
 
3095
  />
3096
  ) : (
3097
  <div className="cg-mode-empty">
@@ -3148,6 +3309,8 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
3148
  rows={modeDataRows}
3149
  display={displaySpec}
3150
  onDisplay={onTimeseriesDisplay}
 
 
3151
  />
3152
  )}
3153
  {/* Wave-7 item W11c (C3) β€” Map is a VIEW: same pipeline rows, pins for rows
 
38
  import type { ColumnMenuState } from "./ColumnMenu";
39
  import { HEADER_ICONS } from "./iconShapes";
40
  import { emitHostEvent, eventId } from "./hostBridge";
41
+ import { NAV_MINIMIZE_EVENT, TOAST_EVENT, signal } from "../apiContract";
42
+ import { exportFilename, runExport, triggerDownload } from "./export";
43
  import type { ExportFormat } from "./export";
44
+ // owner item 3 (2026-08-03) β€” a time-series view exports its SHEET, not the rows under it.
45
+ import { buildTsCsv, tsSheetToTable } from "./timeSeriesData";
46
+ import type { TsSheet } from "./timeSeriesData";
47
  import { echoReemit, reconcileEchoView } from "./viewEcho";
48
  import { pruneStamps, reconcileFields } from "./optimism";
49
  import { newFolderId, pruneFolderStamps, reconcileFolders, resolveFolderId } from "./folders";
 
126
  }
127
  const ALL_VIEW_ID = "all-customers";
128
 
129
+ /**
130
+ * owner item 2 (2026-08-03) β€” the measure-cell skeleton's pulse, and its ceiling.
131
+ *
132
+ * 140ms against the 4-step colour ramp is a ~0.6s cycle: a wait, not a strobe. The ceiling is
133
+ * ~60s, after which the cells fall back to ordinary blanks β€” a measure that has not resolved in
134
+ * a minute is not "still loading", and a shimmer that never ends promises a number that is not
135
+ * coming. Shared constant so the two are read in one place rather than tuned apart.
136
+ */
137
+ const PULSE_MS = 140;
138
+ const PULSE_MAX_TICKS = Math.round(60_000 / PULSE_MS);
139
+ /** Referentially stable, so the fallback does not change `getCellContent`'s identity per render
140
+ * and repaint the canvas forever. */
141
+ const NO_PENDING_KEYS: ReadonlySet<string> = new Set<string>();
142
+
143
  /**
144
  * I2 β€” measure a cell's text in GLIDE'S OWN font, so "is this cut off?" is a fact rather than
145
  * a character-count guess (a guess is wrong in both directions: "IIIII" is narrow, "WWWWW" is
 
924
  px += (visibleCols[i] as { width?: number } | undefined)?.width ?? 0;
925
  return Math.max(0, px - GROUP_LABEL_PAD);
926
  }, [config, visibleCols]);
927
+ /**
928
+ * ⭐ owner item 2 (2026-08-03) β€” WHICH MEASURE COLUMNS ARE STILL BEING CALCULATED.
929
+ *
930
+ * A measure column's numbers are resolved by the server (one aggregate over the whole book)
931
+ * and reach the browser on the workspace re-read that follows the create β€” seconds later.
932
+ * In between, the column is on screen with nothing in it, which the owner correctly read as
933
+ * an error rather than as a wait.
934
+ *
935
+ * β›” `editRequestId` IS THE TEST, and it is the honest one. It is set optimistically at the
936
+ * moment the create is emitted and cleared by `reconcileFields` when the host's own copy of
937
+ * that field comes back β€” and the host's copy travels in the SAME `/workspace` response as
938
+ * `derived`, which is where the values are. So the flag is true across exactly the window
939
+ * where the column exists and its numbers do not, and false the instant they land.
940
+ *
941
+ * The alternative β€” "no value anywhere in the column" β€” cannot tell a pending column from
942
+ * one that permanently failed to resolve (a BU-scoped caller on a company-level measure,
943
+ * say), and would spin forever on the second. This one resolves either way: when the echo
944
+ * arrives with no values, the flag clears and the cells go honestly blank.
945
+ */
946
+ const pendingMeasureKeys = useMemo(() => {
947
+ const out = new Set<string>();
948
+ for (const f of fields)
949
+ if (f.editRequestId && f.key.startsWith("measure_")) out.add(f.key);
950
+ return out;
951
+ }, [fields]);
952
+ /**
953
+ * The skeleton's pulse. A canvas cell cannot hold a CSS animation, so the motion is repaints:
954
+ * this counter is a dependency of `getCellContent`, which is the only thing glide watches.
955
+ *
956
+ * ⚠ IT RUNS ONLY WHILE SOMETHING IS PENDING, and the effect's own guard is what stops it β€”
957
+ * an interval left running would repaint the whole canvas ~7Γ—/s forever, on every grid, to
958
+ * animate nothing. 140ms Γ— the 4-step ramp is a ~0.6s cycle: a wait, not a strobe.
959
+ */
960
+ const [pulse, setPulse] = useState(0);
961
+ /**
962
+ * β›” AND IT GIVES UP. A skeleton that never resolves is worse than the blank it replaced: a
963
+ * blank cell is at least honest about having no number, while a permanent shimmer promises
964
+ * one that is never coming.
965
+ *
966
+ * The window it guards is narrow but real. `editRequestId` clears when `reconcileFields` takes
967
+ * the host's copy β€” and that function has a branch (`hostAuthoritative: false`, i.e. a payload
968
+ * with no workspace) whose `{...local, ...host}` spread would PRESERVE the flag forever. Today
969
+ * that branch cannot be reached with a measure column on screen (measures are offered through
970
+ * the workspace, so a payload without one cannot have produced this field), which is an
971
+ * argument about the current call graph and not a property of the code. This bound holds
972
+ * whether or not the argument stays true, and it costs one boolean.
973
+ */
974
+ const [pendingGaveUp, setPendingGaveUp] = useState(false);
975
+ useEffect(() => {
976
+ setPendingGaveUp(false);
977
+ if (pendingMeasureKeys.size === 0) return;
978
+ let n = 0;
979
+ const id = window.setInterval(() => {
980
+ n += 1;
981
+ if (n > PULSE_MAX_TICKS) {
982
+ window.clearInterval(id);
983
+ setPendingGaveUp(true); // fall back to ordinary blank cells
984
+ return;
985
+ }
986
+ setPulse((p) => p + 1);
987
+ }, PULSE_MS);
988
+ return () => window.clearInterval(id);
989
+ }, [pendingMeasureKeys]);
990
+ const activePendingKeys = pendingGaveUp ? NO_PENDING_KEYS : pendingMeasureKeys;
991
  const getCellContent = useGetCellContent(
992
  displayRows,
993
  visibleCols,
 
997
  userAvatars,
998
  avatarTick,
999
  groupLabelSpace,
1000
+ measureGroupText,
1001
+ activePendingKeys,
1002
+ pulse
1003
  );
1004
  const { gridSelection, selectedPids, onGridSelectionChange, selectPids, togglePid,
1005
  setActiveCell, clearSelection } =
 
2069
  [serverWindowed, fields, fieldByKey, measureSets, cohortSets, today, overlayEdits]
2070
  );
2071
 
2072
+ /**
2073
+ * ⭐ owner item 3 (2026-08-03) β€” THE SHEET THE TIME-SERIES VIEW IS SHOWING, published by the
2074
+ * panel. A ref rather than state on purpose: nothing renders from it, and setting state on
2075
+ * every sheet rebuild would re-render the whole grid to feed a menu nobody has opened yet.
2076
+ */
2077
+ const tsSheetRef = useRef<TsSheet | null>(null);
2078
+ const onTsSheet = useCallback((s: TsSheet) => { tsSheetRef.current = s; }, []);
2079
+ /** The same channel for a SUMMARY calendar β€” the other mode whose content is not its rows.
2080
+ * `null` is CalendarView saying "records mode, export the rows" (see its note). */
2081
+ const calSheetRef = useRef<{ fields: Field[]; rows: Row[] } | null>(null);
2082
+ const onCalSheet = useCallback(
2083
+ (s: { fields: Field[]; rows: Row[] } | null) => { calSheetRef.current = s; },
2084
+ []
2085
+ );
2086
+
2087
+ /**
2088
+ * W2 β€” the view menu's Export (Customer page): the named view over the whole pool.
2089
+ *
2090
+ * ⭐ owner item 3 (2026-08-03) β€” AND IT EXPORTS WHAT THE VIEW SHOWS. Every mode used to
2091
+ * export the same thing: the matched customer rows. For grid / list / kanban / calendar /
2092
+ * map / chart that is right β€” those modes ARRANGE rows, so the rows are what they show, and
2093
+ * a calendar's dates and a kanban's lanes are columns already in the file.
2094
+ *
2095
+ * TWO modes are not arrangements of rows, and both were wrong in the direction that matters:
2096
+ *
2097
+ * Β· `timeseries` β€” metric ROWS over period COLUMNS. Exporting it handed you a customer list
2098
+ * that shares none of its numbers. It exports the SHEET.
2099
+ * Β· `calendar` IN SUMMARY MODE β€” metric values per DAY (C-DISP item 4). Same problem, and
2100
+ * the owner named this one by hand. A calendar in RECORDS mode is genuinely an
2101
+ * arrangement of rows, so it keeps the row export; `CalendarView` says which it is by
2102
+ * publishing a sheet or publishing null.
2103
+ *
2104
+ * ⚠ ONLY FOR THE ACTIVE VIEW, and this is a real limit, not an oversight. Both sheets are
2105
+ * built by the mounted view β€” the time series from a server round trip, the calendar from the
2106
+ * month on screen. A view sitting unopened in the rail has neither, and this component cannot
2107
+ * conjure one without fetching it. So that case SAYS SO and downloads nothing: the
2108
+ * alternative is silently handing over the customer rows under that view's name, which is the
2109
+ * exact substitution this branch exists to stop.
2110
+ */
2111
  const exportViewData = useCallback(
2112
  (viewId: string, format: ExportFormat) => {
2113
  const view = views.find((v) => v.id === viewId);
2114
  if (!view) return;
2115
+ const cfg = normalizeConfig(view.config, fields);
2116
+ const spec = cleanDisplay(cfg.display);
2117
+ const isActive = viewId === activeViewId;
2118
+ const refuse = (what: string) =>
2119
+ signal(
2120
+ TOAST_EVENT,
2121
+ `Open "${view.name}" first: ${what} is exported from what it draws on screen.`
2122
+ );
2123
+
2124
+ if (spec?.mode === "timeseries") {
2125
+ const sheet = isActive ? tsSheetRef.current : null;
2126
+ if (!sheet) return refuse("a time series");
2127
+ if (sheet.empty) {
2128
+ signal(TOAST_EVENT,
2129
+ `"${view.name}" has no metrics on its sheet yet β€” add one, then export.`);
2130
+ return;
2131
+ }
2132
+ if (format === "csv") {
2133
+ // The gated builder (verify_timeseries), kept as THE csv path so the file the owner
2134
+ // downloads is the one the gate proves β€” footnotes, grouped thousands and all.
2135
+ triggerDownload(
2136
+ exportFilename(view.name, today, format),
2137
+ new Blob(["ο»Ώ" + buildTsCsv(sheet)], { type: "text/csv;charset=utf-8" })
2138
+ );
2139
+ } else {
2140
+ const { fields: tf, rows: tr } = tsSheetToTable(sheet);
2141
+ runExport(format, view.name, today, tf, tr);
2142
+ }
2143
+ return;
2144
+ }
2145
+
2146
+ if (spec?.mode === "calendar" && spec.calendarMode === "summary") {
2147
+ const sheet = isActive ? calSheetRef.current : null;
2148
+ if (!sheet) return refuse("a calendar summary");
2149
+ runExport(format, view.name, today, sheet.fields, sheet.rows);
2150
+ return;
2151
+ }
2152
+
2153
+ exportRows(view.name, cfg, computedRows, format);
2154
  },
2155
+ [views, fields, computedRows, exportRows, activeViewId, today]
2156
  );
2157
 
2158
  /**
 
3251
  about (C-CAL). The server refuses `product` in words. */
3252
  scope={scope}
3253
  onOpen={setDetailPid}
3254
+ /* owner item 3 β€” the summary month, so the view menu's Export can carry it. */
3255
+ onSheet={onCalSheet}
3256
  />
3257
  ) : (
3258
  <div className="cg-mode-empty">
 
3309
  rows={modeDataRows}
3310
  display={displaySpec}
3311
  onDisplay={onTimeseriesDisplay}
3312
+ /* owner item 3 β€” the built sheet, so the view menu's Export can carry it. */
3313
+ onSheet={onTsSheet}
3314
  />
3315
  )}
3316
  {/* Wave-7 item W11c (C3) β€” Map is a VIEW: same pipeline rows, pins for rows
web/src/customer-grid/TimeSeriesPanel.tsx CHANGED
@@ -31,13 +31,11 @@
31
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
32
  import { fetchTimeseries } from "./apiBridge";
33
  import type { SurfaceScope } from "./apiBridge";
34
- import { triggerDownload } from "./export";
35
  import { isStreamlitComponent } from "./hostBridge";
36
  import {
37
  DEFAULT_TS_BUCKET,
38
  DEFAULT_TS_LAST_N,
39
  TS_DELTA_LABELS,
40
- buildTsCsv,
41
  buildTsRequest,
42
  buildTsSheet,
43
  buildTsSnapshotRow,
@@ -140,6 +138,11 @@ export interface TimeSeriesPanelProps {
140
  onDisplay?: (next: Partial<DisplaySpec>) => void;
141
  /** Item 13 β€” the pid set is the caller's and cannot be widened from here. */
142
  locked?: boolean;
 
 
 
 
 
143
  }
144
 
145
  /**
@@ -185,6 +188,7 @@ export default function TimeSeriesPanel({
185
  display,
186
  onDisplay,
187
  locked,
 
188
  }: TimeSeriesPanelProps) {
189
  const classed = useMemo(() => classifyFields(fields), [fields]);
190
  // The OFFERED list: everything with a way onto the sheet, server-served first so a real series
@@ -299,13 +303,26 @@ export default function TimeSeriesPanel({
299
  [table, customRows, deltaKinds, bucket, payload?.meta?.today]
300
  );
301
 
302
- const onCsv = useCallback(() => {
303
- const csv = buildTsCsv(sheet);
304
- triggerDownload(
305
- "Time series.csv",
306
- new Blob(["ο»Ώ" + csv], { type: "text/csv;charset=utf-8" })
307
- );
308
- }, [sheet]);
 
 
 
 
 
 
 
 
 
 
 
 
 
309
 
310
  const toggleChart = useCallback((key: string) => {
311
  setCharted((prev) => {
@@ -468,9 +485,10 @@ export default function TimeSeriesPanel({
468
  </button>
469
  </span>
470
 
471
- <button type="button" className="cg-tb-btn" onClick={onCsv} disabled={sheet.empty}>
472
- Export CSV
473
- </button>
 
474
  <span className="cg-ts-pool">
475
  {locked
476
  ? "This record"
@@ -500,15 +518,34 @@ export default function TimeSeriesPanel({
500
  {/* wave17 item 5 / R9 β€” "offered is the ruling, undisclosed is not", and its converse: a
501
  field that CANNOT be plotted is NAMED here rather than quietly missing from the picker.
502
  A reader who can see "Status" in the table and cannot find it in this list otherwise has
503
- no way to tell a refusal from an oversight. */}
 
 
 
 
 
 
 
 
 
 
 
 
504
  {classed.refused.length > 0 && (
505
- <div className="cg-ts-refused">
506
- {classed.refused.map(({ field, why }) => (
507
- <span key={field.key} className="cg-ts-refchip" title={why}>
508
- {field.label}
509
- </span>
510
- ))}
511
- </div>
 
 
 
 
 
 
 
512
  )}
513
 
514
  {/* wave17 GRID β€” item 3 / R6. Only the LOADING note becomes a spinner; the refusal notes
 
31
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
32
  import { fetchTimeseries } from "./apiBridge";
33
  import type { SurfaceScope } from "./apiBridge";
 
34
  import { isStreamlitComponent } from "./hostBridge";
35
  import {
36
  DEFAULT_TS_BUCKET,
37
  DEFAULT_TS_LAST_N,
38
  TS_DELTA_LABELS,
 
39
  buildTsRequest,
40
  buildTsSheet,
41
  buildTsSnapshotRow,
 
138
  onDisplay?: (next: Partial<DisplaySpec>) => void;
139
  /** Item 13 β€” the pid set is the caller's and cannot be widened from here. */
140
  locked?: boolean;
141
+ /**
142
+ * owner item 3 (2026-08-03) β€” publish the built sheet so the view menu's Export can carry it.
143
+ * Absent = this mount cannot be exported (RECORD's embed), which is the honest default.
144
+ */
145
+ onSheet?: (sheet: TsSheet) => void;
146
  }
147
 
148
  /**
 
188
  display,
189
  onDisplay,
190
  locked,
191
+ onSheet,
192
  }: TimeSeriesPanelProps) {
193
  const classed = useMemo(() => classifyFields(fields), [fields]);
194
  // The OFFERED list: everything with a way onto the sheet, server-served first so a real series
 
303
  [table, customRows, deltaKinds, bucket, payload?.meta?.today]
304
  );
305
 
306
+ /**
307
+ * ⭐ owner item 3 (2026-08-03) β€” the sheet is PUBLISHED, not downloaded from here.
308
+ *
309
+ * This panel used to carry its own "Export CSV" button. It was the only view in the product
310
+ * with a second export door, and the only one offering a single format, while every other
311
+ * view exports from the "…" menu in the rail with four. So the button is gone and the sheet
312
+ * goes UP instead: the owner of the view menu builds the file, and the time-series view
313
+ * exports its SHEET rather than the customer rows underneath it.
314
+ *
315
+ * ⚠ A PROP, WHICH IS WHAT KEEPS THIS FILE HOST-AGNOSTIC. The rule at the top of this file is
316
+ * that the panel reads no CustomerGrid state β€” a callback the caller supplies is the same
317
+ * shape `onDisplay` already is. RECORD's embed passes neither and simply cannot export, which
318
+ * is correct: there is no view menu in a record modal.
319
+ *
320
+ * Effect rather than a render-time call: publishing during render would set state in another
321
+ * component mid-render. `sheet` is a `useMemo`, so this fires on a real change, not per frame.
322
+ */
323
+ useEffect(() => {
324
+ onSheet?.(sheet);
325
+ }, [sheet, onSheet]);
326
 
327
  const toggleChart = useCallback((key: string) => {
328
  setCharted((prev) => {
 
485
  </button>
486
  </span>
487
 
488
+ {/* β›” NO EXPORT BUTTON HERE (owner item 3, 2026-08-03). Export lives in the view's "…"
489
+ menu, on every view, in four formats β€” and from a time-series view it now carries
490
+ THIS SHEET rather than the rows beneath it (see `onSheet` above). A second door on
491
+ one surface is how "where do I export from?" stops having an answer. */}
492
  <span className="cg-ts-pool">
493
  {locked
494
  ? "This record"
 
518
  {/* wave17 item 5 / R9 β€” "offered is the ruling, undisclosed is not", and its converse: a
519
  field that CANNOT be plotted is NAMED here rather than quietly missing from the picker.
520
  A reader who can see "Status" in the table and cannot find it in this list otherwise has
521
+ no way to tell a refusal from an oversight.
522
+
523
+ β›” COLLAPSED, AFTER THE OWNER READ IT AS BROKEN MARKUP (2026-08-03). R9's disclosure was
524
+ shipped as a bare strip of every refused label β€” struck through, unheaded, at the small
525
+ end of the type scale. On the customer table that is ~25 names, and 25 crossed-out words
526
+ in a row under a toolbar do not read as "these columns have no period"; they read as a
527
+ stylesheet that failed to load. The strip said WHAT but never WHY IT WAS THERE.
528
+
529
+ A `<details>` fixes exactly that and nothing else: the disclosure is still complete and
530
+ still one click from the panel, but it now leads with a SENTENCE that says what the list
531
+ is, and it is shut until asked. Native rather than a Popover β€” no state, keyboard and
532
+ screen-reader behaviour for free, and it degrades to an open list if CSS never arrives,
533
+ which is the failure this element is being fixed for. */}
534
  {classed.refused.length > 0 && (
535
+ <details className="cg-ts-refused">
536
+ <summary className="cg-ts-refsum">
537
+ {classed.refused.length === 1
538
+ ? "1 column has no period of its own"
539
+ : `${classed.refused.length} columns have no period of their own`}
540
+ </summary>
541
+ <div className="cg-ts-reflist">
542
+ {classed.refused.map(({ field, why }) => (
543
+ <span key={field.key} className="cg-ts-refchip" title={why}>
544
+ {field.label}
545
+ </span>
546
+ ))}
547
+ </div>
548
+ </details>
549
  )}
550
 
551
  {/* wave17 GRID β€” item 3 / R6. Only the LOADING note becomes a spinner; the refusal notes
web/src/customer-grid/Toolbar.tsx CHANGED
@@ -812,39 +812,6 @@ export default function Toolbar({
812
  )}
813
  </Popover>
814
 
815
- {/* ⭐ Wave-15 item 5c (C-LOCK / R10) β€” THE LOCK, SAID IN THE TOOLBAR.
816
- Until now a cohort lock was stated only INSIDE the filter popover, which meant the one
817
- control that explains a narrowed table was invisible until you opened something: the
818
- count disagreed with the conditions and nothing on screen said why. It sits with
819
- Filter / Sort / Group because it is the same kind of fact β€” what this table is showing
820
- β€” and it is NOT a button: the lock is set from the view rail's menu, and a chip that
821
- looked clickable here would promise an action this strip does not have. */}
822
- {lockOn && (
823
- <span
824
- className="cg-tb-lock"
825
- // The name and the count left the CHIP (R1) β€” they did not leave the product. The
826
- // rail row is named and selected; this is the hover that explains what the mark means.
827
- //
828
- // ⚠ Branches on `name`, NOT on `count`, and that is the honest test. A set the reader
829
- // may not see arrives with NEITHER field (`CustomerGrid.cohortLockChip` returns `{}`
830
- // for a lock whose set is not in `lists`) β€” while a set that IS found always reports a
831
- // number, because that producer reads `pids?.length ?? 0`. So `count == null` is not a
832
- // state this chip can observe, and testing it would be a branch that never runs. The
833
- // "unreadable set" sentence still has to exist: the table is empty for a reason that
834
- // is not a filter, and the reader has to be told which kind of nothing this is.
835
- title={
836
- (cohortLock?.name
837
- ? `Locked to ${cohortLock.name}. `
838
- : "Locked to a set of records you cannot see. ") +
839
- "Only the records locked into that view are shown. Filters and sort narrow WITHIN " +
840
- "the lock; the lock itself is changed from the view menu in the rail."
841
- }
842
- >
843
- <LockIcon />
844
- <span className="cg-tb-label">Locked</span>
845
- </span>
846
- )}
847
-
848
  <span className="cg-tb-divider" aria-hidden />
849
 
850
  {/* Color β€” by status */}
@@ -894,6 +861,46 @@ export default function Toolbar({
894
  )}
895
  </Popover>
896
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
897
  {/* Item 14 (owner, 2026-08-02) β€” SEARCH sits immediately right of "Rows", inside the
898
  control cluster, not marooned across the spacer. Deliberately BEFORE `cohortAction`:
899
  that slot is empty on the customer surface and holds "+ Add customers" on the cohort
 
812
  )}
813
  </Popover>
814
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
815
  <span className="cg-tb-divider" aria-hidden />
816
 
817
  {/* Color β€” by status */}
 
861
  )}
862
  </Popover>
863
 
864
+ {/* ⭐ THE LOCK, SAID IN THE TOOLBAR (wave-15 item 5c / C-LOCK / R10).
865
+ A cohort lock used to be stated only INSIDE the filter popover, which meant the one
866
+ control that explains a narrowed table was invisible until you opened something: the
867
+ count disagreed with the conditions and nothing on screen said why. It is NOT a
868
+ button β€” the lock is set from the view rail's menu, and a chip that looked clickable
869
+ here would promise an action this strip does not have.
870
+
871
+ ⚠ POSITION IS THE OWNER'S, 2026-08-03: immediately RIGHT of "Rows". It used to sit
872
+ left of the divider beside Filter / Sort / Group, on the reasoning that it states the
873
+ same kind of fact. Beside three CONTROLS, a thing that cannot be clicked reads as a
874
+ disabled fourth; over here it is plainly a status mark on the table rather than a
875
+ broken button. It also only renders on a locked view, so on every other view "Rows"
876
+ and the search box stay adjacent exactly as item 14 (2026-08-02) placed them β€” that
877
+ ruling is narrowed by this one, not overturned. */}
878
+ {lockOn && (
879
+ <span
880
+ className="cg-tb-lock"
881
+ // The name and the count left the CHIP (R1) β€” they did not leave the product. The
882
+ // rail row is named and selected; this is the hover that explains what the mark means.
883
+ //
884
+ // ⚠ Branches on `name`, NOT on `count`, and that is the honest test. A set the reader
885
+ // may not see arrives with NEITHER field (`CustomerGrid.cohortLockChip` returns `{}`
886
+ // for a lock whose set is not in `lists`) β€” while a set that IS found always reports a
887
+ // number, because that producer reads `pids?.length ?? 0`. So `count == null` is not a
888
+ // state this chip can observe, and testing it would be a branch that never runs. The
889
+ // "unreadable set" sentence still has to exist: the table is empty for a reason that
890
+ // is not a filter, and the reader has to be told which kind of nothing this is.
891
+ title={
892
+ (cohortLock?.name
893
+ ? `Locked to ${cohortLock.name}. `
894
+ : "Locked to a set of records you cannot see. ") +
895
+ "Only the records locked into that view are shown. Filters and sort narrow WITHIN " +
896
+ "the lock; the lock itself is changed from the view menu in the rail."
897
+ }
898
+ >
899
+ <LockIcon />
900
+ <span className="cg-tb-label">Locked</span>
901
+ </span>
902
+ )}
903
+
904
  {/* Item 14 (owner, 2026-08-02) β€” SEARCH sits immediately right of "Rows", inside the
905
  control cluster, not marooned across the spacer. Deliberately BEFORE `cohortAction`:
906
  that slot is empty on the customer surface and holds "+ Add customers" on the cohort
web/src/customer-grid/apiBridge.ts CHANGED
@@ -44,6 +44,7 @@ import {
44
  API_V1,
45
  CREDENTIALS,
46
  DATA_ERROR_EVENT,
 
47
  TOAST_EVENT,
48
  WORKSPACE_STALE_EVENT,
49
  UNAUTHORIZED_EVENT,
@@ -408,10 +409,19 @@ async function drain(): Promise<void> {
408
  * `doc` is deliberately unhandled; see this file's header.
409
  */
410
  function applyEventResult(body: unknown): void {
411
- const b = body as { toast?: unknown; rerender?: unknown; results?: unknown } | null;
 
 
412
  const toast = b?.toast;
413
  if (typeof toast === "string" && toast.trim() !== "") signal(TOAST_EVENT, toast.trim());
414
 
 
 
 
 
 
 
 
415
  // The server already tells us when a write changed durable state β€” `rerender`
416
  // is exactly the flag the Streamlit adapter uses to trigger its own rerun. We
417
  // were dropping it, which is why cohort membership, list adds and folder moves
 
44
  API_V1,
45
  CREDENTIALS,
46
  DATA_ERROR_EVENT,
47
+ DERIVED_CELLS_EVENT,
48
  TOAST_EVENT,
49
  WORKSPACE_STALE_EVENT,
50
  UNAUTHORIZED_EVENT,
 
409
  * `doc` is deliberately unhandled; see this file's header.
410
  */
411
  function applyEventResult(body: unknown): void {
412
+ const b = body as {
413
+ toast?: unknown; rerender?: unknown; results?: unknown; derived?: unknown;
414
+ } | null;
415
  const toast = b?.toast;
416
  if (typeof toast === "string" && toast.trim() !== "") signal(TOAST_EVENT, toast.trim());
417
 
418
+ // owner item 2 β€” a measure column's values, computed server-side right after the write and
419
+ // returned here rather than waiting for the `/workspace` re-read below. Raised BEFORE the
420
+ // stale signal so the numbers paint on the earlier of the two, whichever the server sent.
421
+ // Shape-checked, because a shortcut that corrupts rows is worse than no shortcut.
422
+ if (b?.derived && typeof b.derived === "object" && !Array.isArray(b.derived))
423
+ signal(DERIVED_CELLS_EVENT, b.derived);
424
+
425
  // The server already tells us when a write changed durable state β€” `rerender`
426
  // is exactly the flag the Streamlit adapter uses to trigger its own rerun. We
427
  // were dropping it, which is why cohort membership, list adds and folder moves
web/src/customer-grid/timeSeriesData.ts CHANGED
@@ -30,7 +30,7 @@
30
 
31
  import type { ChartAgg, ChartModel } from "../viz/chartData";
32
  import { TS_MAX_BUCKETS, TS_MAX_FIELDS, TS_MAX_LAST_N, TS_BUCKETS } from "./types";
33
- import type { TsBucket, TsCellStyle, TsCustomRow, TsDeltaKind } from "./types";
34
 
35
  // ---------------------------------------------------------------- the wire
36
 
@@ -1263,6 +1263,69 @@ export function buildTsCsv(sheet: TsSheet): string {
1263
  return lines.join("\r\n") + "\r\n";
1264
  }
1265
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1266
  /** The caps, re-exported so the panel and the gate read ONE source. */
1267
  export const TS_LIMITS = {
1268
  maxBuckets: TS_MAX_BUCKETS,
 
30
 
31
  import type { ChartAgg, ChartModel } from "../viz/chartData";
32
  import { TS_MAX_BUCKETS, TS_MAX_FIELDS, TS_MAX_LAST_N, TS_BUCKETS } from "./types";
33
+ import type { Field, Row, TsBucket, TsCellStyle, TsCustomRow, TsDeltaKind } from "./types";
34
 
35
  // ---------------------------------------------------------------- the wire
36
 
 
1263
  return lines.join("\r\n") + "\r\n";
1264
  }
1265
 
1266
+ /**
1267
+ * ⭐ owner item 3 (2026-08-03) β€” THE SHEET AS A RECTANGLE, so every export format can carry it.
1268
+ *
1269
+ * The panel used to own a lone "Export CSV" button, which made the time-series view the one
1270
+ * surface in the product with its own export door and its own single format. The door moved to
1271
+ * the view's "…" menu, where every other view already keeps it β€” and that menu offers four
1272
+ * formats. `buildCsv` / `buildXlsx` / `buildPdf` / `buildJson` all speak `(Field[], Row[])`, so
1273
+ * the cheapest honest way to give a SHEET all four is to hand them one.
1274
+ *
1275
+ * ⚠ THE VALUES ARE THE SHEET'S OWN DISPLAY STRINGS, and the fields are typed `text` so nothing
1276
+ * re-formats them. This is the same promise wave17 item 6 made for the CSV: a cell reading
1277
+ * `1,234.57` exports as `1,234.57`, not as `1234.5678901234`. Typing these columns as `currency`
1278
+ * would be a second formatter over an already-formatted string and would print `$1,234.57` for a
1279
+ * count. A sheet mixes dollars, counts and percents down one column; there is no one right
1280
+ * numeric type for it, which is exactly why the strings are the answer.
1281
+ *
1282
+ * ⚠ THE FOOTNOTES TRAVEL, in the Metric column after a blank row β€” the same tail `buildTsCsv`
1283
+ * appends, for the same reason: the dropped fields, the partial periods and the flat-by-
1284
+ * construction snapshot rows are the disclosures, and the one document a reader keeps must not
1285
+ * be the one without them.
1286
+ *
1287
+ * `pid` is positional and means nothing here β€” a sheet row is not a record. It exists because
1288
+ * `Row` requires it and `buildJson` emits it; it is never read back.
1289
+ */
1290
+ export function tsSheetToTable(sheet: TsSheet): { fields: Field[]; rows: Row[] } {
1291
+ // `source: "odoo"` is what "read-only, not a user column" spells in this contract β€” the
1292
+ // editability test at both ends is `source === "overlay"`. Nothing here is editable: these
1293
+ // are export columns that exist for the length of one download.
1294
+ const col = (key: string, label: string): Field =>
1295
+ ({ key, label, type: "text", source: "odoo" });
1296
+ const fields: Field[] = [
1297
+ col("metric", "Metric"),
1298
+ ...sheet.columns.map((c) =>
1299
+ col(`c_${c.key}`, c.partial ? `${c.label} (partial)` : c.label)
1300
+ ),
1301
+ col("total", "Total"),
1302
+ ];
1303
+ const rows: Row[] = [];
1304
+ const put = (label: string, cells: string[], total: string) => {
1305
+ const row: Row = { pid: rows.length + 1, metric: label, total };
1306
+ sheet.columns.forEach((c, i) => { row[`c_${c.key}`] = cells[i] ?? ""; });
1307
+ rows.push(row);
1308
+ };
1309
+ for (const r of sheet.rows) {
1310
+ put(
1311
+ r.sublabel ? `${r.label} (${r.sublabel})` : r.label,
1312
+ r.cells.map((c) => csvTsValue(c.value)),
1313
+ csvTsValue(r.total ?? null)
1314
+ );
1315
+ for (const d of r.deltas)
1316
+ put(
1317
+ `${r.label} β€” ${d.label}${d.percent ? " (%)" : ""}`,
1318
+ d.values.map((v) => csvTsDelta(v, d.percent)),
1319
+ ""
1320
+ );
1321
+ }
1322
+ if (sheet.notes.length > 0) {
1323
+ put("", [], "");
1324
+ for (const n of sheet.notes) put(n, [], "");
1325
+ }
1326
+ return { fields, rows };
1327
+ }
1328
+
1329
  /** The caps, re-exported so the panel and the gate read ONE source. */
1330
  export const TS_LIMITS = {
1331
  maxBuckets: TS_MAX_BUCKETS,
web/src/customer-grid/useCustomerData.ts CHANGED
@@ -27,7 +27,7 @@ import type { Dispatch, SetStateAction } from "react";
27
  import { topicForScope } from "./types";
28
  import type { CustomersPayload, Field, GridWorkspace, Row } from "./types";
29
  import { fetchTopicRows, fetchWorkspace, patchTopicRow, setSurfaceScope } from "./apiBridge";
30
- import { WORKSPACE_STALE_EVENT } from "../apiContract";
31
  import type { SurfaceScope } from "./apiBridge";
32
  import {
33
  emitHostEvent,
@@ -235,12 +235,52 @@ export function useCustomerData(scope: SurfaceScope = "customer"): CustomerData
235
  if (cancelled || !ws) return; // absent stays absent β€” never blank a live panel
236
  setData((prev) => (prev ? withWorkspace(prev, ws) : prev));
237
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  const onStale = () => { void reread(); };
239
- if (typeof window !== "undefined") window.addEventListener(WORKSPACE_STALE_EVENT, onStale);
 
 
 
240
 
241
  return () => {
242
  cancelled = true;
243
- if (typeof window !== "undefined") window.removeEventListener(WORKSPACE_STALE_EVENT, onStale);
 
 
 
244
  };
245
  // `topic` is a stable module constant per scope (topicForScope), so this cannot loop.
246
  }, [scope, topic]);
 
27
  import { topicForScope } from "./types";
28
  import type { CustomersPayload, Field, GridWorkspace, Row } from "./types";
29
  import { fetchTopicRows, fetchWorkspace, patchTopicRow, setSurfaceScope } from "./apiBridge";
30
+ import { DERIVED_CELLS_EVENT, WORKSPACE_STALE_EVENT } from "../apiContract";
31
  import type { SurfaceScope } from "./apiBridge";
32
  import {
33
  emitHostEvent,
 
235
  if (cancelled || !ws) return; // absent stays absent β€” never blank a live panel
236
  setData((prev) => (prev ? withWorkspace(prev, ws) : prev));
237
  }
238
+ /**
239
+ * ⭐ owner item 2 (2026-08-03) β€” THE SHORTCUT. The events response can carry the derived
240
+ * cells a write just made computable (a new measure column's numbers), which lands them a
241
+ * full round trip before `reread()` would.
242
+ *
243
+ * ⚠ IT ONLY EVER ADDS. Cells are merged OVER the row, so a key the shortcut does not
244
+ * mention is untouched β€” this can fill a blank column, never blank a filled one. And the
245
+ * stale re-read still runs beside it and still wins on arrival, so the shortcut cannot
246
+ * become a second, divergent source of truth for the same values.
247
+ */
248
+ const onDerived = (ev: Event) => {
249
+ const cells = (ev as CustomEvent).detail as
250
+ | Record<string, Record<string, unknown>>
251
+ | undefined;
252
+ if (!cells || typeof cells !== "object") return;
253
+ setData((prev) => {
254
+ if (!prev) return prev;
255
+ let touched = false;
256
+ const rows = prev.rows.map((r) => {
257
+ const patch = cells[String(r.pid)];
258
+ if (!patch || typeof patch !== "object") return r;
259
+ // Each VALUE is checked, not just the envelope. A derived cell is a scalar; anything
260
+ // else (an object, an array) is dropped rather than written into a row that the whole
261
+ // grid β€” formulas, filters, exports β€” then reads as a value.
262
+ const clean: Partial<Row> = {};
263
+ for (const [k, v] of Object.entries(patch))
264
+ if (v === null || typeof v === "string" || typeof v === "number") clean[k] = v;
265
+ if (Object.keys(clean).length === 0) return r;
266
+ touched = true;
267
+ return { ...r, ...clean };
268
+ });
269
+ return touched ? { ...prev, rows } : prev;
270
+ });
271
+ };
272
  const onStale = () => { void reread(); };
273
+ if (typeof window !== "undefined") {
274
+ window.addEventListener(WORKSPACE_STALE_EVENT, onStale);
275
+ window.addEventListener(DERIVED_CELLS_EVENT, onDerived);
276
+ }
277
 
278
  return () => {
279
  cancelled = true;
280
+ if (typeof window !== "undefined") {
281
+ window.removeEventListener(WORKSPACE_STALE_EVENT, onStale);
282
+ window.removeEventListener(DERIVED_CELLS_EVENT, onDerived);
283
+ }
284
  };
285
  // `topic` is a stable module constant per scope (topicForScope), so this cannot loop.
286
  }, [scope, topic]);
web/src/customer-grid/useGetCellContent.ts CHANGED
@@ -28,6 +28,10 @@ const EMPTY_CELL: GridCell = {
28
  allowOverlay: false,
29
  };
30
 
 
 
 
 
31
  type GroupHeaderRow = Extract<VisibleRow, { kind: "group-header" }>;
32
  type GroupFooterRow = Extract<VisibleRow, { kind: "group-footer" }>;
33
 
@@ -79,6 +83,46 @@ function groupHeaderCell(
79
  };
80
  }
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  /**
83
  * The per-group subtotal row: each column shows ITS OWN summed value (from the
84
  * shared aggs map) right-aligned under the data, blank for non-agg columns. No
@@ -131,7 +175,19 @@ export function useGetCellContent(
131
  * no canvas, so nothing is fitted, which is the safe direction.
132
  */
133
  groupLabelSpace: number | null = null,
134
- measureText?: (text: string) => number
 
 
 
 
 
 
 
 
 
 
 
 
135
  ) {
136
  return useCallback(
137
  ([col, row]: Item): GridCell => {
@@ -151,6 +207,15 @@ export function useGetCellContent(
151
  const pid = (vr.record as Row).pid;
152
  // Overlay edits win over the raw record (edit shows immediately).
153
  const raw = overlayEdits[pid]?.[field.key] ?? vr.record[field.key];
 
 
 
 
 
 
 
 
 
154
  return makeCell(field, raw, canEdit(field), userAvatars);
155
  },
156
  // ⚠ `avatarTick` is a dependency that is deliberately never READ in the body. Bumping it is
@@ -159,6 +224,6 @@ export function useGetCellContent(
159
  // unnecessary; removing it would silently strand every photo behind its initials.
160
  // eslint-disable-next-line react-hooks/exhaustive-deps
161
  [visibleRows, visibleCols, fieldByKey, overlayEdits, canEdit, userAvatars, avatarTick,
162
- groupLabelSpace, measureText]
163
  );
164
  }
 
28
  allowOverlay: false,
29
  };
30
 
31
+ /** A module constant, so the default argument is referentially stable β€” a fresh `new Set()`
32
+ * per render would change this hook's dep array every frame and repaint the canvas forever. */
33
+ const EMPTY_KEYS: ReadonlySet<string> = new Set<string>();
34
+
35
  type GroupHeaderRow = Extract<VisibleRow, { kind: "group-header" }>;
36
  type GroupFooterRow = Extract<VisibleRow, { kind: "group-footer" }>;
37
 
 
83
  };
84
  }
85
 
86
+ /**
87
+ * ⭐ owner item 2 (2026-08-03) β€” THE CELL THAT IS STILL BEING CALCULATED.
88
+ *
89
+ * A measure column's values are resolved SERVER-side (one aggregate over the whole book) and
90
+ * arrive on the workspace re-read, seconds after the column itself appears. Until then the row
91
+ * simply has no value at that key β€” and `makeCell` renders no value as an EMPTY CELL, which is
92
+ * indistinguishable from the two things it must never be confused with: a real blank ("could
93
+ * not compute") and a genuine zero. The owner read a screen of them as an error, which is the
94
+ * correct reading of what was on screen.
95
+ *
96
+ * So a pending measure cell paints a SKELETON. `GridCellKind.Loading` is glide's own β€” a
97
+ * rounded bar at 10% of `theme.textDark` β€” and the two decisions here are:
98
+ *
99
+ * Β· WIDTH VARIES per cell (`skeletonWidthVariability`, seeded by col/row inside glide), so a
100
+ * column of them reads as text-shaped placeholders rather than as a painted grey block,
101
+ * which is what a solid uniform bar looks like.
102
+ * Β· IT PULSES, and the pulse is a WAVE: the phase is offset by the row, so the column ripples
103
+ * instead of blinking in unison. Canvas cannot hold a CSS animation, so the motion is the
104
+ * caller's repaint tick driving `themeOverride.textDark` through the ramp below β€” the same
105
+ * mechanism `avatarTick` uses to land an async photo.
106
+ */
107
+ const SKELETON_RAMP = ["#1b1b22", "#33333d", "#4d4d59", "#33333d"] as const;
108
+
109
+ function loadingCell(width: number | undefined, row: number, pulse: number): GridCell {
110
+ const bar = Math.max(22, Math.round((width ?? 120) * 0.42));
111
+ return {
112
+ kind: GridCellKind.Loading,
113
+ allowOverlay: false,
114
+ skeletonWidth: bar,
115
+ // Bounded so a narrow column's bar cannot outgrow its cell.
116
+ skeletonWidthVariability: Math.min(20, Math.round(bar * 0.35)),
117
+ skeletonHeight: 10,
118
+ themeOverride: {
119
+ textDark: SKELETON_RAMP[(pulse + row) % SKELETON_RAMP.length],
120
+ },
121
+ // A skeleton is not data: copying one must yield nothing, not the word "loading".
122
+ copyData: "",
123
+ };
124
+ }
125
+
126
  /**
127
  * The per-group subtotal row: each column shows ITS OWN summed value (from the
128
  * shared aggs map) right-aligned under the data, blank for non-agg columns. No
 
175
  * no canvas, so nothing is fitted, which is the safe direction.
176
  */
177
  groupLabelSpace: number | null = null,
178
+ measureText?: (text: string) => number,
179
+ /**
180
+ * owner item 2 β€” the measure columns whose values are STILL BEING CALCULATED server-side.
181
+ * A key in here whose cell has no value paints a skeleton instead of an empty cell. Empty by
182
+ * default, so every other caller of this hook is unaffected.
183
+ */
184
+ pendingKeys: ReadonlySet<string> = EMPTY_KEYS,
185
+ /**
186
+ * ⚠ THE SECOND REPAINT KEY, and it is read here (unlike `avatarTick`) β€” it selects the frame
187
+ * of the skeleton's pulse. CustomerGrid bumps it on a timer for as long as `pendingKeys` is
188
+ * non-empty and then stops, so a settled grid repaints on nothing.
189
+ */
190
+ pulse = 0
191
  ) {
192
  return useCallback(
193
  ([col, row]: Item): GridCell => {
 
207
  const pid = (vr.record as Row).pid;
208
  // Overlay edits win over the raw record (edit shows immediately).
209
  const raw = overlayEdits[pid]?.[field.key] ?? vr.record[field.key];
210
+ // owner item 2 β€” still being calculated. Tested on the VALUE too, not on the column
211
+ // alone: the moment the server's answer lands the cell must show it, and a column-only
212
+ // test would keep skeletons over real numbers until the field echo caught up separately.
213
+ if ((raw == null || raw === "") && pendingKeys.has(field.key))
214
+ // `GridColumn` is a union and only its SIZED member declares `width` (the same narrowing
215
+ // `groupLabelSpace` does). `useGridColumns` only ever builds that one; an auto-sized
216
+ // column would read undefined and take the skeleton's default, which is the safe way to
217
+ // be wrong about a bar's width.
218
+ return loadingCell((colDef as { width?: number } | undefined)?.width, row, pulse);
219
  return makeCell(field, raw, canEdit(field), userAvatars);
220
  },
221
  // ⚠ `avatarTick` is a dependency that is deliberately never READ in the body. Bumping it is
 
224
  // unnecessary; removing it would silently strand every photo behind its initials.
225
  // eslint-disable-next-line react-hooks/exhaustive-deps
226
  [visibleRows, visibleCols, fieldByKey, overlayEdits, canEdit, userAvatars, avatarTick,
227
+ groupLabelSpace, measureText, pendingKeys, pulse]
228
  );
229
  }
web/src/customer-grid/viewModes.tsx CHANGED
@@ -499,6 +499,40 @@ function monthTitle(month: string): string {
499
  * be reconciled again just because the record modal opened. All five props are already
500
  * stable at the call site (`rows` is the `modeDataRows` useMemo, `field` comes out of
501
  * `dateFieldChoices`, `onOpen` is a setState), so this one needed no call-site change. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  export const CalendarView = memo(function CalendarView({
503
  rows,
504
  field,
@@ -509,6 +543,7 @@ export const CalendarView = memo(function CalendarView({
509
  fieldByKey,
510
  scope = "customer",
511
  onOpen,
 
512
  }: {
513
  /** DISTINCT data rows from the full pipeline (never the display slice β€” a
514
  * month is its own bound). */
@@ -526,6 +561,16 @@ export const CalendarView = memo(function CalendarView({
526
  * REFUSES `product` in words (measures are customer-grain), so this is never free text. */
527
  scope?: SurfaceScope;
528
  onOpen: (pid: number) => void;
 
 
 
 
 
 
 
 
 
 
529
  }) {
530
  const entries = useMemo(
531
  () =>
@@ -644,6 +689,52 @@ export const CalendarView = memo(function CalendarView({
644
  });
645
  }, [mode, measureMetricKeys, byDay, scope]);
646
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
647
  if (!shown) {
648
  return (
649
  <div className="cg-mode-empty">
@@ -739,22 +830,9 @@ export const CalendarView = memo(function CalendarView({
739
  summaryMetrics.map((m) => {
740
  // wave17 item 9 / R4 β€” a measure reads its AS-OF-THAT-DAY answer from the
741
  // channel; everything else keeps the client aggregation, which is still
742
- // exactly right for a column with no window.
743
- const served = m.field ? dayValues[m.field.key] : undefined;
744
- let v: number | null;
745
- if (served) {
746
- // `undefined` (the field was dropped) and `null` (unanswered) are the same
747
- // thing to a reader and both draw "β€”". Neither is 0.
748
- v = served[iso] ?? null;
749
- } else {
750
- const vals: number[] = [];
751
- if (m.field)
752
- for (const e of bucket) {
753
- const n = asChartNumber(e.row[m.field.key]);
754
- if (n != null) vals.push(n);
755
- }
756
- v = aggregateOrNull(m.agg, vals, bucket.length);
757
- }
758
  return (
759
  <div key={m.id} className="cg-cal-metric">
760
  {/* wave17 item 9 β€” the field's TYPE mark, so a row of figures in a
 
499
  * be reconciled again just because the record modal opened. All five props are already
500
  * stable at the call site (`rows` is the `modeDataRows` useMemo, `field` comes out of
501
  * `dateFieldChoices`, `onOpen` is a setState), so this one needed no call-site change. */
502
+ /** One summary metric as `summaryMetrics` builds it β€” named so the shared value function below
503
+ * and the two call sites all speak the same shape. */
504
+ type CalMetric = { id: string; label: string; field?: Field; agg: ChartAgg };
505
+
506
+ /**
507
+ * ⭐ ONE IMPLEMENTATION OF A SUMMARY DAY'S NUMBER (owner item 3, 2026-08-03).
508
+ *
509
+ * The day CELL and the day EXPORT must not compute this twice. They did, for one commit, and
510
+ * the reason to refuse that is written a few lines below in R4's own note: the served channel
511
+ * and the client aggregation answer different questions (as-of-that-day vs the row values held
512
+ * now), and two copies of the choice between them is how a file and a screen come to disagree
513
+ * about the same day while both look right.
514
+ *
515
+ * `served` wins wherever it exists β€” a measure's answer is the server's. `undefined` (the field
516
+ * was dropped) and `null` (unanswered) are the same thing to a reader; both are null here and
517
+ * both draw "β€”". Never 0.
518
+ */
519
+ function calDayValue(
520
+ m: CalMetric,
521
+ iso: string,
522
+ bucket: readonly { row: Row }[],
523
+ dayValues: Record<string, Record<string, number | null>>
524
+ ): number | null {
525
+ const served = m.field ? dayValues[m.field.key] : undefined;
526
+ if (served) return served[iso] ?? null;
527
+ const vals: number[] = [];
528
+ if (m.field)
529
+ for (const e of bucket) {
530
+ const n = asChartNumber(e.row[m.field.key]);
531
+ if (n != null) vals.push(n);
532
+ }
533
+ return aggregateOrNull(m.agg, vals, bucket.length);
534
+ }
535
+
536
  export const CalendarView = memo(function CalendarView({
537
  rows,
538
  field,
 
543
  fieldByKey,
544
  scope = "customer",
545
  onOpen,
546
+ onSheet,
547
  }: {
548
  /** DISTINCT data rows from the full pipeline (never the display slice β€” a
549
  * month is its own bound). */
 
561
  * REFUSES `product` in words (measures are customer-grain), so this is never free text. */
562
  scope?: SurfaceScope;
563
  onOpen: (pid: number) => void;
564
+ /**
565
+ * ⭐ owner item 3 (2026-08-03) β€” the SUMMARY month as an exportable rectangle, published so
566
+ * the view menu's Export can carry what this view actually shows.
567
+ *
568
+ * ⚠ `null` IS THE MESSAGE, not the absence of one. In RECORDS mode a calendar is an
569
+ * arrangement of rows β€” the rows ARE its content, so the caller must fall back to the ordinary
570
+ * row export. Publishing null says "there is no sheet here", which is different from having
571
+ * never published, and the caller distinguishes the two.
572
+ */
573
+ onSheet?: (sheet: { fields: Field[]; rows: Row[] } | null) => void;
574
  }) {
575
  const entries = useMemo(
576
  () =>
 
689
  });
690
  }, [mode, measureMetricKeys, byDay, scope]);
691
 
692
+ /**
693
+ * ⭐ owner item 3 (2026-08-03) β€” THE SUMMARY MONTH, PUBLISHED FOR EXPORT.
694
+ *
695
+ * The owner named Calendar by hand ("Export CSV / PDF based on what view (Grid/Calendar)"),
696
+ * and a calendar in SUMMARY mode has the same shape problem the time-series view had: what it
697
+ * shows is metric values per DAY, and the rows underneath share none of those numbers. So the
698
+ * month goes up as a rectangle β€” one row per dated day, one column per metric β€” and the view
699
+ * menu's Export carries it.
700
+ *
701
+ * ⚠ RECORDS mode publishes `null`, on purpose. There a calendar is an ARRANGEMENT of rows and
702
+ * the rows are its content, so the ordinary row export is already the right file.
703
+ *
704
+ * ⚠ THE VALUES ARE THE DISPLAY STRINGS, through `formatDisplay` β€” the same rule the
705
+ * time-series export follows, and for the same reason: a column here mixes a count with a
706
+ * currency, so there is no one numeric type for it and the cell's own text is the honest answer.
707
+ * `β€”` on screen is an EMPTY cell in the file: a spreadsheet's blank is its "no value", and an
708
+ * em dash in a numeric column is a string somebody has to strip.
709
+ */
710
+ useEffect(() => {
711
+ if (!onSheet) return;
712
+ if (mode !== "summary" || !shown) {
713
+ onSheet(null);
714
+ return;
715
+ }
716
+ const col = (key: string, label: string): Field =>
717
+ ({ key, label, type: "text", source: "odoo" });
718
+ const fields: Field[] = [
719
+ col("day", "Day"),
720
+ ...summaryMetrics.map((m) => col(`m_${m.id}`, m.label)),
721
+ ];
722
+ const out: Row[] = [];
723
+ for (const iso of [...byDay.keys()].sort()) {
724
+ const bucket = byDay.get(iso) ?? [];
725
+ // `pid` is positional here and means nothing β€” a calendar day is not a record. It exists
726
+ // because `Row` requires it and `buildJson` emits it.
727
+ const row: Row = { pid: out.length + 1, day: iso };
728
+ for (const m of summaryMetrics) {
729
+ const v = calDayValue(m, iso, bucket, dayValues);
730
+ row[`m_${m.id}`] =
731
+ v == null ? "" : m.field ? formatDisplay(m.field, v) : v.toLocaleString();
732
+ }
733
+ out.push(row);
734
+ }
735
+ onSheet({ fields, rows: out });
736
+ }, [onSheet, mode, shown, byDay, summaryMetrics, dayValues]);
737
+
738
  if (!shown) {
739
  return (
740
  <div className="cg-mode-empty">
 
830
  summaryMetrics.map((m) => {
831
  // wave17 item 9 / R4 β€” a measure reads its AS-OF-THAT-DAY answer from the
832
  // channel; everything else keeps the client aggregation, which is still
833
+ // exactly right for a column with no window. Both live in `calDayValue`,
834
+ // which the EXPORT calls too β€” see its note.
835
+ const v = calDayValue(m, iso, bucket, dayValues);
 
 
 
 
 
 
 
 
 
 
 
 
 
836
  return (
837
  <div key={m.id} className="cg-cal-metric">
838
  {/* wave17 item 9 β€” the field's TYPE mark, so a row of figures in a
web/src/index.css CHANGED
@@ -1121,6 +1121,15 @@ body {
1121
  background: transparent;
1122
  color: var(--lp-ink);
1123
  font: inherit;
 
 
 
 
 
 
 
 
 
1124
  line-height: 1;
1125
  cursor: pointer;
1126
  white-space: nowrap;
@@ -5678,12 +5687,19 @@ a.cg-map-ctl-b { text-decoration: none; }
5678
  /* formula rows, a sparkline column and an optional gridline grain. */
5679
  /* ------------------------------------------------------------------------- */
5680
 
 
 
 
 
 
 
5681
  .cg-ts {
5682
  display: flex;
5683
  flex-direction: column;
5684
  min-height: 0;
5685
  padding: 12px 14px 18px;
5686
  gap: 10px;
 
5687
  }
5688
  .cg-ts-bar {
5689
  display: flex;
@@ -5723,12 +5739,55 @@ a.cg-map-ctl-b { text-decoration: none; }
5723
  no way to tell a deliberate refusal from a bug. Quiet by design β€” struck-through muted text,
5724
  not a warning colour: nothing here is wrong, these columns simply have no time dimension. The
5725
  reason rides the `title`. */
 
 
 
 
5726
  .cg-ts-refused {
5727
- display: flex;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5728
  flex-wrap: wrap;
5729
  gap: 4px 8px;
5730
- padding: 6px 2px 2px;
5731
  }
 
5732
  .cg-ts-refchip {
5733
  font-size: var(--lp-fs-3xs);
5734
  color: var(--lp-muted);
@@ -6078,15 +6137,15 @@ a.cg-map-ctl-b { text-decoration: none; }
6078
  /* the Filter-popover banner (.cg-flt-lockchip, in their filters.css). */
6079
  /* ------------------------------------------------------------------------- */
6080
 
6081
- /* The rail mark. Yellow, matching `.cg-lock-tag` and RECORD's banner, because all three make
6082
- the same claim about the same kind of thing and a third colour would read as a third
6083
- concept. A 2px inset bar rather than a badge: the row is already carrying a mode icon, a
6084
- name, a note and a "…" β€” one more chip would crowd it, while an edge mark is legible at a
6085
- glance and costs no width. Per the brand rule the mark takes the -deep weight; the base
6086
- pastel measures too low to read as a line this thin. */
6087
- .cg-view-row.cg-view-lockset {
6088
- box-shadow: inset 2px 0 0 var(--lp-yellow-deep);
6089
- }
6090
  /* The picker. `is-on` marks the cohort currently locked, so re-opening the menu says which one
6091
  it is instead of making the reader remember. */
6092
  .cg-lock-pop { min-width: 240px; }
 
1121
  background: transparent;
1122
  color: var(--lp-ink);
1123
  font: inherit;
1124
+ /* β›” THE SIZE IS DECLARED, NOT INHERITED (owner, 2026-08-03 β€” "the font is un-uniform … fix
1125
+ it always"). `font: inherit` alone was correct only by luck: it took its size from
1126
+ `.cg-toolbar`, which happens to declare `--lp-fs-xs`. Mount the SAME button anywhere with
1127
+ no size on the ancestor β€” which is exactly what the time-series bar is β€” and it silently
1128
+ grew to the body's 14px beside 12px siblings, so the "Compare" button read as a heading.
1129
+ A shared component cannot depend on where it is hung. The longhand sits AFTER the shorthand
1130
+ on purpose: `font: inherit` still supplies the family and weight, this overrides only the
1131
+ size. Identical inside `.cg-toolbar` (same token), fixed everywhere else. */
1132
+ font-size: var(--lp-fs-xs);
1133
  line-height: 1;
1134
  cursor: pointer;
1135
  white-space: nowrap;
 
5687
  /* formula rows, a sparkline column and an optional gridline grain. */
5688
  /* ------------------------------------------------------------------------- */
5689
 
5690
+ /* β›” THE PANEL DECLARES ITS OWN BASE SIZE (owner, 2026-08-03).
5691
+ Everything inside `.cg-ts-table` was uniform because the TABLE declares `--lp-fs-xs`; every
5692
+ control OUTSIDE it β€” the bar, the notes, the empty states β€” inherited the app body instead
5693
+ and landed a step larger than the sheet it sits above. That is the un-uniformity the owner
5694
+ saw. One declaration here makes the panel's floor the same as the toolbar's, so a control
5695
+ with no size of its own is right by default rather than by accident of where it hangs. */
5696
  .cg-ts {
5697
  display: flex;
5698
  flex-direction: column;
5699
  min-height: 0;
5700
  padding: 12px 14px 18px;
5701
  gap: 10px;
5702
+ font-size: var(--lp-fs-xs);
5703
  }
5704
  .cg-ts-bar {
5705
  display: flex;
 
5739
  no way to tell a deliberate refusal from a bug. Quiet by design β€” struck-through muted text,
5740
  not a warning colour: nothing here is wrong, these columns simply have no time dimension. The
5741
  reason rides the `title`. */
5742
+ /* β›” AND IT IS SHUT BY DEFAULT (owner, 2026-08-03 β€” "seemingly broken HTML text").
5743
+ The strip above shipped as ~25 struck-through names in a row with no heading, which is not
5744
+ how a disclosure reads: it reads as a stylesheet that failed. The `<details>` keeps every
5745
+ name β€” R9 is not weakened β€” behind one sentence that says what they are. */
5746
  .cg-ts-refused {
5747
+ padding: 2px 2px 0;
5748
+ }
5749
+ .cg-ts-refsum {
5750
+ display: inline-flex;
5751
+ align-items: center;
5752
+ gap: 5px;
5753
+ width: fit-content;
5754
+ padding: 1px 2px;
5755
+ font-size: var(--lp-fs-2xs);
5756
+ color: var(--lp-muted);
5757
+ cursor: pointer;
5758
+ list-style: none; /* Safari/Chrome default triangle, replaced below */
5759
+ user-select: none;
5760
+ }
5761
+ .cg-ts-refsum::-webkit-details-marker { display: none; }
5762
+ /* The disclosure caret, drawn rather than borrowed: a `β–Έ` glyph renders at a different size in
5763
+ every font on the three platforms this runs on, and this one has to line up with 12px text. */
5764
+ .cg-ts-refsum::before {
5765
+ content: "";
5766
+ width: 0;
5767
+ height: 0;
5768
+ border-left: 4px solid currentColor;
5769
+ border-top: 3.5px solid transparent;
5770
+ border-bottom: 3.5px solid transparent;
5771
+ transition: transform 0.12s ease;
5772
+ }
5773
+ .cg-ts-refused[open] > .cg-ts-refsum::before { transform: rotate(90deg); }
5774
+ .cg-ts-refsum:hover { color: var(--lp-ink); }
5775
+ .cg-ts-refsum:focus-visible { outline: 2px solid var(--lp-blue-deep); outline-offset: 1px; }
5776
+ /* β›” THE CLOSED STATE IS DECLARED, NOT INHERITED FROM THE BROWSER β€” and this cost a real bug.
5777
+ `<details>` hides its non-summary children through a UA rule (`display: none`, or a
5778
+ `::details-content` content-visibility in newer Chrome). ANY author `display` beats a UA
5779
+ declaration regardless of specificity, so writing `display: flex` here re-showed the list
5780
+ while the disclosure was shut: the same wall of struck-through names the owner reported, now
5781
+ with a heading above it claiming to hide them. Caught by the screenshot gate, not by the
5782
+ markup β€” which is the whole reason item 7 said "pls visually check".
5783
+ Stating both states explicitly also makes this independent of which engine is rendering. */
5784
+ .cg-ts-reflist {
5785
+ display: none;
5786
  flex-wrap: wrap;
5787
  gap: 4px 8px;
5788
+ padding: 6px 0 2px 11px;
5789
  }
5790
+ .cg-ts-refused[open] > .cg-ts-reflist { display: flex; }
5791
  .cg-ts-refchip {
5792
  font-size: var(--lp-fs-3xs);
5793
  color: var(--lp-muted);
 
6137
  /* the Filter-popover banner (.cg-flt-lockchip, in their filters.css). */
6138
  /* ------------------------------------------------------------------------- */
6139
 
6140
+ /* β›” THE RAIL MARK IS THE PADLOCK, AND ONLY THE PADLOCK (owner, 2026-08-03).
6141
+ This rule used to also paint a 2px yellow inset bar down the row's left edge. The reasoning
6142
+ was that an edge mark costs no width on a crowded row β€” but it was written when a locked view
6143
+ was a projected row under its own heading. Wave 17 made it an ORDINARY view in the same list,
6144
+ and an edge bar in a flat list reads as a selection state or a drag handle, not as "these
6145
+ rows are fixed". Two marks for one fact, and the ugly one was the one carrying no meaning.
6146
+ The padlock stays: it is named (`aria-label`), it explains itself (`title`), and it is the
6147
+ same glyph the toolbar chip uses. The class is KEPT β€” `cg-view-lockset` is still how the row
6148
+ is found in QA and it may earn quieter styling later; it simply paints nothing today. */
6149
  /* The picker. `is-on` marks the cohort currently locked, so re-opening the menu says which one
6150
  it is instead of making the reader remember. */
6151
  .cg-lock-pop { min-width: 240px; }