Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- api/routes_grid.py +31 -0
- royalimports_os/aios_grid_fields.json +1 -12
- royalimports_os/modules/product_data.py +113 -1
- web/dist-embed/index.html +0 -0
- web/src/apiContract.ts +14 -0
- web/src/customer-grid/CustomerGrid.tsx +169 -6
- web/src/customer-grid/TimeSeriesPanel.tsx +57 -20
- web/src/customer-grid/Toolbar.tsx +40 -33
- web/src/customer-grid/apiBridge.ts +11 -1
- web/src/customer-grid/timeSeriesData.ts +64 -1
- web/src/customer-grid/useCustomerData.ts +43 -3
- web/src/customer-grid/useGetCellContent.ts +67 -2
- web/src/customer-grid/viewModes.tsx +94 -16
- web/src/index.css +70 -11
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 |
-
#:
|
| 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 |
-
/**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1990 |
const exportViewData = useCallback(
|
| 1991 |
(viewId: string, format: ExportFormat) => {
|
| 1992 |
const view = views.find((v) => v.id === viewId);
|
| 1993 |
if (!view) return;
|
| 1994 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
|
| 310 |
const toggleChart = useCallback((key: string) => {
|
| 311 |
setCharted((prev) => {
|
|
@@ -468,9 +485,10 @@ export default function TimeSeriesPanel({
|
|
| 468 |
</button>
|
| 469 |
</span>
|
| 470 |
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
|
|
|
| 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 |
-
<
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
| 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")
|
|
|
|
|
|
|
|
|
|
| 240 |
|
| 241 |
return () => {
|
| 242 |
cancelled = true;
|
| 243 |
-
if (typeof window !== "undefined")
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 744 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5728 |
flex-wrap: wrap;
|
| 5729 |
gap: 4px 8px;
|
| 5730 |
-
padding: 6px
|
| 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 |
-
/*
|
| 6082 |
-
|
| 6083 |
-
|
| 6084 |
-
|
| 6085 |
-
|
| 6086 |
-
|
| 6087 |
-
|
| 6088 |
-
|
| 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; }
|