Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- api/routes_customers.py +82 -43
- api/routes_grid.py +45 -22
- royalimports_os/aios_grid.py +5 -0
- royalimports_os/core/grid_events.py +30 -0
- royalimports_os/core/measure_resolve.py +136 -0
- royalimports_os/core/table_store.py +22 -1
- royalimports_os/core/users.py +20 -0
- royalimports_os/harness/runtime.py +5 -0
- royalimports_os/modules/customer_data.py +1 -0
- web/dist-embed/index.html +0 -0
- web/src/apiContract.ts +6 -0
- web/src/customer-grid/ColumnMenu.tsx +121 -33
- web/src/customer-grid/CustomerGrid.tsx +82 -18
- web/src/customer-grid/ViewSidebar.tsx +88 -3
- web/src/customer-grid/cells.ts +27 -11
- web/src/customer-grid/formulaEngine.ts +517 -101
- web/src/customer-grid/types.ts +16 -0
- web/src/customer-grid/useCustomerData.ts +30 -4
- web/src/customer-grid/useGridSelection.ts +25 -1
- web/src/index.css +235 -12
- web/src/shell/Shell.tsx +68 -7
api/routes_customers.py
CHANGED
|
@@ -93,60 +93,99 @@ def warm_default(rt):
|
|
| 93 |
_pool_for(rt, None, None)
|
| 94 |
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
def _payload(session: Session):
|
| 97 |
-
"""`{fields, rows, today, pulled_at}` β X2
|
| 98 |
-
|
|
|
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
to the pool build β and it is the only arrangement in which nothing user-shaped can outlive
|
| 103 |
-
the request that asked for it. See `_pool_rows` for what the cache holds and why.
|
| 104 |
"""
|
| 105 |
import aios_grid
|
| 106 |
-
from core import grid_events
|
| 107 |
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
ws = grid_events.table_workspace(_ctx_for(session, []), allowed_pids=None)
|
| 113 |
-
fields = aios_grid.fields_from_workspace(ws)
|
| 114 |
-
odoo_keys = [f["key"] for f in fields if f.get("source") == "odoo"]
|
| 115 |
-
overlay_keys = [f["key"] for f in fields if f.get("source") == "overlay"]
|
| 116 |
-
passthrough = {f["key"] for f in fields
|
| 117 |
-
if f.get("source") == "odoo" and f.get("type") in ("text", "status", "date")}
|
| 118 |
-
overlays = ws.get("overlays") or {}
|
| 119 |
-
|
| 120 |
-
# β `rows_src` IS THE SHARED CACHED LIST β read it, never mutate it. Every row below is a NEW
|
| 121 |
-
# dict; writing an overlay value onto `r` instead of `row` would put one user's private cell
|
| 122 |
-
# into the copy the next same-scope user is served, which is the leak this split exists to
|
| 123 |
-
# prevent, re-introduced one line lower down.
|
| 124 |
-
rows = []
|
| 125 |
-
for r in rows_src:
|
| 126 |
-
pid = r.get("pid")
|
| 127 |
-
row = {"pid": pid, "_created": r.get("_created") or ""}
|
| 128 |
-
for k in odoo_keys:
|
| 129 |
-
v = r.get(k)
|
| 130 |
-
row[k] = v if k in passthrough else _round(v)
|
| 131 |
-
stored = overlays.get(str(pid)) or {}
|
| 132 |
-
for k in overlay_keys:
|
| 133 |
-
row[k] = stored.get(k, "")
|
| 134 |
-
rows.append(row)
|
| 135 |
-
|
| 136 |
-
return {"fields": fields, "rows": rows,
|
| 137 |
# `today` rides the payload because every relative date condition must resolve against
|
| 138 |
# the TENANT's day, never the browser's β a client that falls back to its own clock
|
| 139 |
# disagrees with the server for everyone west of it.
|
| 140 |
-
"today":
|
| 141 |
"pulled_at": time.strftime("%Y-%m-%d %H:%M")}
|
| 142 |
|
| 143 |
|
| 144 |
-
def _round(v):
|
| 145 |
-
# bool is a subclass of int β guard it so True/False never becomes 1/0. Mirrors
|
| 146 |
-
# royalimports_os/aios_grid.py._round exactly (embed == standalone on boolean-valued fields).
|
| 147 |
-
return round(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else v
|
| 148 |
-
|
| 149 |
-
|
| 150 |
def _ctx_for(session: Session, pids):
|
| 151 |
"""An EventCtx for the READ path β no fallback workspace, so a store outage is a 503 rather
|
| 152 |
than a phantom in-memory workspace an API request cannot persist."""
|
|
|
|
| 93 |
_pool_for(rt, None, None)
|
| 94 |
|
| 95 |
|
| 96 |
+
def _team_agent(session: Session):
|
| 97 |
+
return perms.scope_team_id(session.user), perms.scope_agent(session.user)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _pool_stamp(rt, team_id, agent):
|
| 101 |
+
"""The cached pool's build timestamp β the DATA STAMP in every measure-memo key, so a pool
|
| 102 |
+
refresh invalidates the memoised answers exactly when the underlying rows changed."""
|
| 103 |
+
entry = rt.pool_cache.get(("pool", team_id, agent))
|
| 104 |
+
return entry[0] if isinstance(entry, tuple) and entry else 0
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _measure_err(tag, e):
|
| 108 |
+
try:
|
| 109 |
+
import harness.telemetry as _tel
|
| 110 |
+
_tel.error(f"api:{tag}", e)
|
| 111 |
+
except Exception:
|
| 112 |
+
pass
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def grid_assembly(session: Session, scope: str = "customer", storage_key: str = ""):
|
| 116 |
+
"""ONE assembly of this session's grid state, shared by `/customers`, `/workspace` and the
|
| 117 |
+
events route (2026-07-31 β the standalone measure gap, owner item 1).
|
| 118 |
+
|
| 119 |
+
What it adds over the pre-wave hand-rolled `_payload` loop, and why it replaced it:
|
| 120 |
+
|
| 121 |
+
* rows go through `aios_grid.rows_from_pool` β the SAME builder the embedded host uses,
|
| 122 |
+
so `lat`/`lon` (the Map view's data), `_created` and every derived column ride each row
|
| 123 |
+
by construction instead of by a second loop that drifts. The hand loop was written to
|
| 124 |
+
mirror the pre-Map contract and silently dropped the coordinates: the shell's Map view
|
| 125 |
+
had nothing to plot ("the Map no longer works").
|
| 126 |
+
* `derived` carries the cohort column's cells AND the measure columns' values, resolved
|
| 127 |
+
through `core.measure_resolve` (EXIT-5's extraction of the `_cl_measure_*` family).
|
| 128 |
+
Without them every measure column the owner built rendered BLANK in the shell.
|
| 129 |
+
* `measures` (the offer) and `measure_sets` (condition answers) are computed here so the
|
| 130 |
+
events route can finally validate measure fields/conditions instead of refusing them
|
| 131 |
+
(an empty `measure_offer` made `clean_measure_field` reject every create over HTTP).
|
| 132 |
+
|
| 133 |
+
Memos live on the TENANT RUNTIME (`rt.measure_memo` / `rt.mset_memo`) β bounded by the
|
| 134 |
+
module's own clear-past-cap rule, keyed on (stamp, scope, pool identity, question), nothing
|
| 135 |
+
user-shaped in them.
|
| 136 |
+
"""
|
| 137 |
+
import aios_grid
|
| 138 |
+
from core import grid_events, measure_resolve
|
| 139 |
+
|
| 140 |
+
rt = session.runtime
|
| 141 |
+
team_id, agent = _team_agent(session)
|
| 142 |
+
rows_src = _pool_for(rt, team_id, agent)
|
| 143 |
+
pids = frozenset(r["pid"] for r in rows_src if r.get("pid") is not None)
|
| 144 |
+
ws = grid_events.table_workspace(_ctx_for(session, pids), allowed_pids=pids)
|
| 145 |
+
workspace, fields, views, lists = aios_grid.workspace_wire(
|
| 146 |
+
ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key)
|
| 147 |
+
|
| 148 |
+
today = time.strftime("%Y-%m-%d")
|
| 149 |
+
stamp = _pool_stamp(rt, team_id, agent)
|
| 150 |
+
measures = measure_resolve.offer(team_id, on_error=_measure_err)
|
| 151 |
+
measure_sets = measure_resolve.condition_sets(
|
| 152 |
+
[v.get("config") or {} for v in (views or [])], None, team_id, pids, today, stamp,
|
| 153 |
+
rt.mset_memo, on_error=_measure_err)
|
| 154 |
+
# The derived channel: cohort membership cells + measure column values, ONE dict β the
|
| 155 |
+
# same read-only channel the embed host hands to rows_from_pool.
|
| 156 |
+
derived = aios_grid.cohort_cells(lists)
|
| 157 |
+
for pid, cells in measure_resolve.column_values(
|
| 158 |
+
fields, team_id, pids, today, stamp, rt.measure_memo,
|
| 159 |
+
on_error=_measure_err).items():
|
| 160 |
+
derived.setdefault(pid, {}).update(cells)
|
| 161 |
+
|
| 162 |
+
return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
|
| 163 |
+
"fields": fields, "views": views, "lists": lists, "derived": derived,
|
| 164 |
+
"measures": measures, "measure_sets": measure_sets, "today": today,
|
| 165 |
+
"team_id": team_id}
|
| 166 |
+
|
| 167 |
+
|
| 168 |
def _payload(session: Session):
|
| 169 |
+
"""`{fields, rows, today, pulled_at}` β X2's shape, which `verify_fields_contract.py`
|
| 170 |
+
referees. Rows are now built by `aios_grid.rows_from_pool` (embed == standalone by
|
| 171 |
+
construction); see `grid_assembly` for what that fixed.
|
| 172 |
|
| 173 |
+
β `rows_src` is the SHARED cached list β `rows_from_pool` reads it and builds NEW dicts,
|
| 174 |
+
never mutating a cached row (the same-scope-second-user leak rule).
|
|
|
|
|
|
|
| 175 |
"""
|
| 176 |
import aios_grid
|
|
|
|
| 177 |
|
| 178 |
+
g = grid_assembly(session)
|
| 179 |
+
rows = aios_grid.rows_from_pool(
|
| 180 |
+
g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
|
| 181 |
+
return {"fields": g["fields"], "rows": rows,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
# `today` rides the payload because every relative date condition must resolve against
|
| 183 |
# the TENANT's day, never the browser's β a client that falls back to its own clock
|
| 184 |
# disagrees with the server for everyone west of it.
|
| 185 |
+
"today": g["today"],
|
| 186 |
"pulled_at": time.strftime("%Y-%m-%d %H:%M")}
|
| 187 |
|
| 188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
def _ctx_for(session: Session, pids):
|
| 190 |
"""An EventCtx for the READ path β no fallback workspace, so a store outage is a 503 rather
|
| 191 |
than a phantom in-memory workspace an API request cannot persist."""
|
api/routes_grid.py
CHANGED
|
@@ -76,23 +76,10 @@ def workspace(scope: str = "customer",
|
|
| 76 |
wave-9 leak rule. Omitting it would hand a Fisch-scoped user a member list built by a
|
| 77 |
full-access user.
|
| 78 |
"""
|
| 79 |
-
import aios_grid
|
| 80 |
from core import grid_events
|
| 81 |
-
from routes_customers import
|
| 82 |
|
| 83 |
scope = _scope_or_400(scope)
|
| 84 |
-
try:
|
| 85 |
-
pids = allowed_pids(session)
|
| 86 |
-
ws = grid_events.table_workspace(_ctx(session, [], pids), allowed_pids=pids)
|
| 87 |
-
except grid_events.StoreUnavailable:
|
| 88 |
-
raise err(503, "store_unavailable", "the tenant store is unavailable")
|
| 89 |
-
|
| 90 |
-
# β THE WIRE SHAPE IS THE SHARED PROJECTION (`aios_grid.workspace_wire`), not the store
|
| 91 |
-
# shape. The first version returned the store dict with no `storageKey` β and the client
|
| 92 |
-
# validator requires one, so the standalone shell DISCARDED the whole workspace: saved views
|
| 93 |
-
# never rendered, `cohortMode` never arrived, and the Cohort route silently drew the Customer
|
| 94 |
-
# surface. One projection for both servers is the fix that cannot drift.
|
| 95 |
-
#
|
| 96 |
# The storage key mirrors the host's own convention byte-for-byte (app.py's `_table_grid`
|
| 97 |
# call sites) with the session's TENANT in the host's hardcoded slot, so localStorage state
|
| 98 |
# carries across embed β standalone on the same browser.
|
|
@@ -104,13 +91,40 @@ def workspace(scope: str = "customer",
|
|
| 104 |
else:
|
| 105 |
storage_key = f"{session.tenant}:customer-list:{session.uname}:{bu}:{agent or 'all'}"
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
# ADDITIVE to the wire: the caller's own overlay cells. The grid reads overlays from the
|
| 110 |
# /customers rows, but this route is the cheap read-back a write can be verified against
|
| 111 |
# (verify_api's overlay probe) without paying the pool call. Per-user by construction β
|
| 112 |
# `table_workspace` is this session's workspace.
|
| 113 |
-
workspace["overlays"] = ws.get("overlays") or {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
# THE SURFACE STAMP β a MIRROR of the host's own (`_table_grid`'s hide/cohort stamps).
|
| 116 |
# β Deliberately NOT `hideViews`: `cohortMode` is what swaps the Views panel for the cohort
|
|
@@ -128,7 +142,7 @@ def grid_events_route(body: dict = Body(default=None),
|
|
| 128 |
session: Session = Depends(module_gate(MODULE))):
|
| 129 |
"""`{events: [<component event objects, verbatim>]}` β `{results, doc?, toast?}`."""
|
| 130 |
from core import grid_events
|
| 131 |
-
from routes_customers import
|
| 132 |
|
| 133 |
events = (body or {}).get("events")
|
| 134 |
if events is None and isinstance(body, dict) and body.get("type"):
|
|
@@ -139,14 +153,23 @@ def grid_events_route(body: dict = Body(default=None),
|
|
| 139 |
raise err(400, "too_many_events",
|
| 140 |
f"at most {_MAX_EVENTS} events per request (the client's resend window)")
|
| 141 |
|
| 142 |
-
payload = _payload(session)
|
| 143 |
-
pids = frozenset(r["pid"] for r in payload["rows"] if r.get("pid") is not None)
|
| 144 |
# Validated with the SAME predicate as the read route. An unrecognised scopeKey used to be
|
| 145 |
# passed through verbatim, and `core.grid_events` only ever compares it to 'cohort' β so a
|
| 146 |
# typo degraded silently to customer-scope behaviour on a WRITE. Read and write must agree on
|
| 147 |
# what a scope is, or the surface you read is not the surface you wrote.
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
# Per-event results so the client can tell which of a batch landed β the component's own
|
| 152 |
# bridge has no response channel at all, so this is strictly more than the embed gets.
|
|
|
|
| 76 |
wave-9 leak rule. Omitting it would hand a Fisch-scoped user a member list built by a
|
| 77 |
full-access user.
|
| 78 |
"""
|
|
|
|
| 79 |
from core import grid_events
|
| 80 |
+
from routes_customers import grid_assembly
|
| 81 |
|
| 82 |
scope = _scope_or_400(scope)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
# The storage key mirrors the host's own convention byte-for-byte (app.py's `_table_grid`
|
| 84 |
# call sites) with the session's TENANT in the host's hardcoded slot, so localStorage state
|
| 85 |
# carries across embed β standalone on the same browser.
|
|
|
|
| 91 |
else:
|
| 92 |
storage_key = f"{session.tenant}:customer-list:{session.uname}:{bu}:{agent or 'all'}"
|
| 93 |
|
| 94 |
+
# β THE WIRE SHAPE IS THE SHARED PROJECTION (`aios_grid.workspace_wire`), not the store
|
| 95 |
+
# shape. The first version returned the store dict with no `storageKey` β and the client
|
| 96 |
+
# validator requires one, so the standalone shell DISCARDED the whole workspace: saved views
|
| 97 |
+
# never rendered, `cohortMode` never arrived, and the Cohort route silently drew the Customer
|
| 98 |
+
# surface. One projection for both servers is the fix that cannot drift.
|
| 99 |
+
try:
|
| 100 |
+
g = grid_assembly(session, scope=scope, storage_key=storage_key)
|
| 101 |
+
except grid_events.StoreUnavailable:
|
| 102 |
+
raise err(503, "store_unavailable", "the tenant store is unavailable")
|
| 103 |
+
workspace = g["workspace"]
|
| 104 |
# ADDITIVE to the wire: the caller's own overlay cells. The grid reads overlays from the
|
| 105 |
# /customers rows, but this route is the cheap read-back a write can be verified against
|
| 106 |
# (verify_api's overlay probe) without paying the pool call. Per-user by construction β
|
| 107 |
# `table_workspace` is this session's workspace.
|
| 108 |
+
workspace["overlays"] = g["ws"].get("overlays") or {}
|
| 109 |
+
# ββ the STANDALONE measure channel (owner item 1, 2026-07-31) ββββββββββββββββββββββββββββ
|
| 110 |
+
# The embed receives these as top-level render args; standalone lifts them off THIS route
|
| 111 |
+
# (useCustomerData merges them into the payload slots the grid already reads). They ride
|
| 112 |
+
# the workspace rather than /customers because a durable write re-reads exactly this route
|
| 113 |
+
# (WORKSPACE_STALE), so a new measure column populates without refetching the heavy pool.
|
| 114 |
+
workspace["measures"] = g["measures"]
|
| 115 |
+
workspace["measureSets"] = g["measure_sets"]
|
| 116 |
+
# Derived cells (cohort column + measure columns), keyed by pid. JSON object keys are
|
| 117 |
+
# strings; the client indexes with String(pid).
|
| 118 |
+
workspace["derived"] = {str(pid): cells for pid, cells in g["derived"].items()}
|
| 119 |
+
# Who is looking (permissions verdicts) + the assignee choices for `user`-typed columns β
|
| 120 |
+
# the two other host-only render args the shell was missing (fail-closed without them:
|
| 121 |
+
# restricted fields uneditable, assignee picker empty).
|
| 122 |
+
workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
|
| 123 |
+
try:
|
| 124 |
+
from core import users as _users
|
| 125 |
+
workspace["userOptions"] = _users.assignable_people()
|
| 126 |
+
except Exception:
|
| 127 |
+
workspace["userOptions"] = []
|
| 128 |
|
| 129 |
# THE SURFACE STAMP β a MIRROR of the host's own (`_table_grid`'s hide/cohort stamps).
|
| 130 |
# β Deliberately NOT `hideViews`: `cohortMode` is what swaps the Views panel for the cohort
|
|
|
|
| 142 |
session: Session = Depends(module_gate(MODULE))):
|
| 143 |
"""`{events: [<component event objects, verbatim>]}` β `{results, doc?, toast?}`."""
|
| 144 |
from core import grid_events
|
| 145 |
+
from routes_customers import grid_assembly
|
| 146 |
|
| 147 |
events = (body or {}).get("events")
|
| 148 |
if events is None and isinstance(body, dict) and body.get("type"):
|
|
|
|
| 153 |
raise err(400, "too_many_events",
|
| 154 |
f"at most {_MAX_EVENTS} events per request (the client's resend window)")
|
| 155 |
|
|
|
|
|
|
|
| 156 |
# Validated with the SAME predicate as the read route. An unrecognised scopeKey used to be
|
| 157 |
# passed through verbatim, and `core.grid_events` only ever compares it to 'cohort' β so a
|
| 158 |
# typo degraded silently to customer-scope behaviour on a WRITE. Read and write must agree on
|
| 159 |
# what a scope is, or the surface you read is not the surface you wrote.
|
| 160 |
+
scope = _scope_or_400((body or {}).get("scopeKey"))
|
| 161 |
+
g = grid_assembly(session, scope=scope)
|
| 162 |
+
# β THE MEASURE CONTEXT IS NOT OPTIONAL (2026-07-31). Without `measure_offer`,
|
| 163 |
+
# `clean_measure_field` had an empty admission list and every measure-column create over
|
| 164 |
+
# HTTP was silently refused; without `measure_keys`, `clean_filter_tree` stripped every
|
| 165 |
+
# measure CONDITION out of a saved view. The embed always passed these; the API adapter
|
| 166 |
+
# simply had not been given them β the standalone shell could read measures it could
|
| 167 |
+
# never write.
|
| 168 |
+
ctx = _ctx(session, g["fields"], g["pids"], scope_key=scope,
|
| 169 |
+
measure_keys=frozenset(m["key"] for m in g["measures"]),
|
| 170 |
+
resolved_ids=frozenset(g["measure_sets"]),
|
| 171 |
+
cohort_ids=frozenset(c["id"] for c in g["lists"]),
|
| 172 |
+
measure_offer=tuple(g["measures"]))
|
| 173 |
|
| 174 |
# Per-event results so the client can tell which of a batch landed β the component's own
|
| 175 |
# bridge has no response channel at all, so this is strictly more than the embed gets.
|
royalimports_os/aios_grid.py
CHANGED
|
@@ -1137,6 +1137,11 @@ def workspace_wire(ws, uname, pool_pids, defs=None, scope_key='customer', storag
|
|
| 1137 |
fields = fields_from_workspace(ws, cohorts=bool(cohort_lists), scope_key=scope_key)
|
| 1138 |
views = views_from_defs(defs or {}, ws.get('views'), fields)
|
| 1139 |
workspace = {'storageKey': storage_key, 'views': views, 'lists': cohort_lists}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1140 |
|
| 1141 |
# FOLDERS (owner item 11, contract C4), re-validated at SERVE time: a view or cohort can be
|
| 1142 |
# deleted by a path that knows nothing about folders, and the placement map must not outlive
|
|
|
|
| 1137 |
fields = fields_from_workspace(ws, cohorts=bool(cohort_lists), scope_key=scope_key)
|
| 1138 |
views = views_from_defs(defs or {}, ws.get('views'), fields)
|
| 1139 |
workspace = {'storageKey': storage_key, 'views': views, 'lists': cohort_lists}
|
| 1140 |
+
# Owner item 3 (2026-07-31): where this user left off. The client's own localStorage copy
|
| 1141 |
+
# wins when present; this is the server's answer for a FRESH browser, which used to fall
|
| 1142 |
+
# all the way to the system default view (and whatever display mode was stored on it).
|
| 1143 |
+
if ws.get('activeViewId'):
|
| 1144 |
+
workspace['activeViewId'] = str(ws['activeViewId'])
|
| 1145 |
|
| 1146 |
# FOLDERS (owner item 11, contract C4), re-validated at SERVE time: a view or cohort can be
|
| 1147 |
# deleted by a path that knows nothing about folders, and the placement map must not outlive
|
royalimports_os/core/grid_events.py
CHANGED
|
@@ -374,6 +374,20 @@ def handle_one(event, ctx):
|
|
| 374 |
ws.setdefault('fields', {})
|
| 375 |
ws.setdefault('overlays', {})
|
| 376 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
if kind == 'view_upsert':
|
| 378 |
raw = event.get('view')
|
| 379 |
if not isinstance(raw, dict) or not isinstance(raw.get('config'), dict):
|
|
@@ -470,6 +484,22 @@ def handle_one(event, ctx):
|
|
| 470 |
raw = dict(raw)
|
| 471 |
raw['permissions'] = prior.get('permissions')
|
| 472 |
raw['locked'] = prior.get('locked')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
# β A SYSTEM VIEW IS PER-USER STATE AND MUST NEVER BE SHARED. Caught in the wave-9
|
| 474 |
# integration pass, before deploy: `all-customers` is every user's DEFAULT view and it
|
| 475 |
# carries their own column widths, visible fields and row height. C4's read-default
|
|
|
|
| 374 |
ws.setdefault('fields', {})
|
| 375 |
ws.setdefault('overlays', {})
|
| 376 |
|
| 377 |
+
if kind == 'view_select':
|
| 378 |
+
# Owner item 3 (2026-07-31): remember WHERE THE USER IS so a fresh browser resumes
|
| 379 |
+
# there instead of the system default view. Presentation state only β the read side
|
| 380 |
+
# re-validates the id against what the caller may see, so nothing here needs an
|
| 381 |
+
# authorisation wall beyond the length cap. Never a re-render: the client is already
|
| 382 |
+
# on that view.
|
| 383 |
+
vid = str(event.get('viewId') or '').strip()[:120]
|
| 384 |
+
if vid and store.available():
|
| 385 |
+
try:
|
| 386 |
+
cl_mod.save_table_active_view(uname, vid)
|
| 387 |
+
except Exception as _ae:
|
| 388 |
+
_tel.error('grid:view-select', _ae)
|
| 389 |
+
return False
|
| 390 |
+
|
| 391 |
if kind == 'view_upsert':
|
| 392 |
raw = event.get('view')
|
| 393 |
if not isinstance(raw, dict) or not isinstance(raw.get('config'), dict):
|
|
|
|
| 484 |
raw = dict(raw)
|
| 485 |
raw['permissions'] = prior.get('permissions')
|
| 486 |
raw['locked'] = prior.get('locked')
|
| 487 |
+
# ββ I12/C3 BELT (2026-07-31): a LOCKED view's DISPLAY MODE is frozen HOST-SIDE too.
|
| 488 |
+
# The client's switcher already refuses (setDisplayMode), and its comment has claimed
|
| 489 |
+
# "the host refuses too" since wave 9 β this makes that claim true for direct API
|
| 490 |
+
# callers. β MIRRORS types.ts `isModeFrozen` EXACTLY: SYSTEM views and the undeletable
|
| 491 |
+
# list:/cohort: projections are EXEMPT β their `locked` is legacy "undeletable", not
|
| 492 |
+
# mode-frozen, and freezing them would trap the default view in whatever mode it last
|
| 493 |
+
# saved (the owner's stuck-in-Map report is that trap, client-made). An upsert that
|
| 494 |
+
# UNLOCKS in the same write (authorised above) may change display freely.
|
| 495 |
+
_mode_frozen = (prior.get('locked') and raw.get('locked') is not False
|
| 496 |
+
and prior.get('kind') != 'system'
|
| 497 |
+
and view_id != 'all-customers')
|
| 498 |
+
if _mode_frozen:
|
| 499 |
+
if prior.get('config', {}).get('display'):
|
| 500 |
+
config['display'] = dict(prior['config']['display'])
|
| 501 |
+
else:
|
| 502 |
+
config.pop('display', None)
|
| 503 |
# β A SYSTEM VIEW IS PER-USER STATE AND MUST NEVER BE SHARED. Caught in the wave-9
|
| 504 |
# integration pass, before deploy: `all-customers` is every user's DEFAULT view and it
|
| 505 |
# carries their own column widths, visible fields and row height. C4's read-default
|
royalimports_os/core/measure_resolve.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""core/measure_resolve.py β HOST-NEUTRAL measure resolution (EXIT-5, SHELL.md ladder step 3).
|
| 2 |
+
|
| 3 |
+
The `_cl_measure_offer` / `_cl_resolve_measures` / `_cl_measure_columns` family, OUT of app.py:
|
| 4 |
+
one implementation, two hosts. The Streamlit adapter passes its `st.session_state` memos and its
|
| 5 |
+
pending-view read; the API passes runtime-level dicts and no pending view (over HTTP the
|
| 6 |
+
`view_upsert` lands via `/grid/events` BEFORE the workspace re-read, so the saved views already
|
| 7 |
+
carry the freshest rule β there is no mid-run component value to read).
|
| 8 |
+
|
| 9 |
+
No streamlit import, no app import β harness only, so both adapters can call it.
|
| 10 |
+
|
| 11 |
+
Memo discipline (unchanged from the app.py originals):
|
| 12 |
+
* keys carry (stamp, scope, POOL IDENTITY, question) β the pool's identity, never its size,
|
| 13 |
+
because Cohort passes a different pool per cohort and two same-sized cohorts must not be
|
| 14 |
+
handed each other's answers.
|
| 15 |
+
* `column_values` memoises permanent failures as None but NEVER a transient one (datastore
|
| 16 |
+
still warming after a restart) β a memoised transient means blank measure cells that stay
|
| 17 |
+
blank for the whole session long after the data landed.
|
| 18 |
+
* both memos are bounded by clearing wholesale past a cap β session/runtime-lifetime caches,
|
| 19 |
+
not leaks.
|
| 20 |
+
"""
|
| 21 |
+
import hashlib
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def offer(team_id=None, on_error=None):
|
| 25 |
+
"""The measures the condition builder / measure columns may use, or [] if the semantic
|
| 26 |
+
store is unavailable. The caller's BU narrows the offer (a company-level measure cannot be
|
| 27 |
+
answered for one business unit)."""
|
| 28 |
+
try:
|
| 29 |
+
from harness import measure_filter as _mf
|
| 30 |
+
return _mf.measure_fields(team_id)
|
| 31 |
+
except Exception as e: # no store / model problem -> offer nothing
|
| 32 |
+
if on_error:
|
| 33 |
+
on_error('measures', e)
|
| 34 |
+
return []
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _pool_key(allowed_pids):
|
| 38 |
+
"""The pool's identity, hashed. Part of every ANSWER, not just the question: the zero-group
|
| 39 |
+
is reconstructed from it (a customer with no orders in the window has no row for SQL to
|
| 40 |
+
group, so `Sales < 100` would miss exactly the lapsed customers the filter is for)."""
|
| 41 |
+
return hashlib.blake2b(
|
| 42 |
+
repr(sorted(allowed_pids)).encode(), digest_size=8).hexdigest()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def condition_sets(view_configs, pending_cfg, team_id, allowed_pids, today, stamp, memo,
|
| 46 |
+
on_error=None):
|
| 47 |
+
"""Every measure condition across `view_configs` (+ the optional pending config, which goes
|
| 48 |
+
LAST and overwrites β the freshest statement of what the user wants) -> `{ruleId: [pid,β¦]}`.
|
| 49 |
+
|
| 50 |
+
A rule that is incomplete (mid-edit), unresolvable, or errors is left OUT of the answer β
|
| 51 |
+
the client reads a missing id as PENDING and says "Calculatingβ¦" rather than showing a count
|
| 52 |
+
it cannot stand behind. Never resolve an incomplete rule: `to_num(None)` is 0 by the
|
| 53 |
+
engines' shared contract, so a valueless `Sales > β¦` would become `Sales > 0` β a wrong set
|
| 54 |
+
under a confident count.
|
| 55 |
+
"""
|
| 56 |
+
from harness import measure_filter as _mf
|
| 57 |
+
by_id = {}
|
| 58 |
+
for cfg in list(view_configs or []) + [pending_cfg or {}]:
|
| 59 |
+
for rule in _mf.collect((cfg or {}).get('filters')):
|
| 60 |
+
rid = str(rule.get('id') or '')
|
| 61 |
+
if rid:
|
| 62 |
+
by_id[rid] = rule
|
| 63 |
+
rules = list(by_id.values())
|
| 64 |
+
if not rules:
|
| 65 |
+
return {}
|
| 66 |
+
pk = _pool_key(allowed_pids)
|
| 67 |
+
out = {}
|
| 68 |
+
for rule in rules:
|
| 69 |
+
if not _mf.rule_complete(rule):
|
| 70 |
+
continue
|
| 71 |
+
key = (stamp, team_id, pk, _mf.signature(rule))
|
| 72 |
+
if key not in memo:
|
| 73 |
+
try:
|
| 74 |
+
memo[key] = _mf.resolve_rule(rule, today, team_id, allowed_pids)
|
| 75 |
+
except Exception as e:
|
| 76 |
+
# An unresolvable condition must NOT become "everything" or "nothing" silently.
|
| 77 |
+
if on_error:
|
| 78 |
+
on_error('measure-resolve', e)
|
| 79 |
+
continue
|
| 80 |
+
out[str(rule['id'])] = sorted(memo[key])
|
| 81 |
+
if len(memo) > 200: # a lifetime cache, not a leak
|
| 82 |
+
memo.clear()
|
| 83 |
+
return out
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def column_values(fields, team_id, allowed_pids, today, stamp, memo, on_error=None):
|
| 87 |
+
"""Values for every FORMULA-MEASURE column among `fields` -> `{pid: {field key: value}}`,
|
| 88 |
+
handed to `rows_from_pool(derived=β¦)` beside the cohort cells.
|
| 89 |
+
|
| 90 |
+
A column that cannot be computed (store down, BU scope on a company-level measure,
|
| 91 |
+
unresolvable window) degrades to BLANK β the value dict simply omits that field key, and
|
| 92 |
+
the client renders empty cells, never $0: blank is "could not compute", 0 is a real zero.
|
| 93 |
+
"""
|
| 94 |
+
mfields = [f for f in fields or [] if isinstance(f.get('measure'), dict)]
|
| 95 |
+
if not mfields:
|
| 96 |
+
return {}
|
| 97 |
+
from harness import measure_filter as _mf
|
| 98 |
+
from harness import windows as _wn
|
| 99 |
+
pk = _pool_key(allowed_pids)
|
| 100 |
+
out = {}
|
| 101 |
+
for f in mfields:
|
| 102 |
+
spec = f['measure']
|
| 103 |
+
w = _wn.normalize(spec.get('window'))
|
| 104 |
+
key = (stamp, team_id, pk, spec.get('key'),
|
| 105 |
+
None if w is None else tuple(sorted(w.items())))
|
| 106 |
+
if key not in memo:
|
| 107 |
+
try:
|
| 108 |
+
memo[key] = _mf.resolve_values(spec.get('key'), spec.get('window'),
|
| 109 |
+
today, team_id, allowed_pids)
|
| 110 |
+
except Exception as e:
|
| 111 |
+
if on_error:
|
| 112 |
+
on_error('measure-column', e)
|
| 113 |
+
# β ONLY A PERMANENT FAILURE MAY BE MEMOISED. "The data cache is still warming
|
| 114 |
+
# up after a restart" is TRANSIENT and self-healing β memoising it means a user
|
| 115 |
+
# who opened the page during that window gets blank measure cells that STAY
|
| 116 |
+
# blank for the whole session. A permanent failure (an unadmitted measure, an
|
| 117 |
+
# unresolvable window) still memoises, so the retry-storm this guard was
|
| 118 |
+
# written for cannot come back.
|
| 119 |
+
transient = False
|
| 120 |
+
try:
|
| 121 |
+
from harness import datastore as _ds
|
| 122 |
+
transient = not _ds.ready()
|
| 123 |
+
except Exception:
|
| 124 |
+
transient = False
|
| 125 |
+
if transient:
|
| 126 |
+
continue # no memo entry -> the next call tries again
|
| 127 |
+
memo[key] = None
|
| 128 |
+
vals = memo[key]
|
| 129 |
+
if vals is None:
|
| 130 |
+
continue
|
| 131 |
+
fkey = f['key']
|
| 132 |
+
for pid, v in vals.items():
|
| 133 |
+
out.setdefault(pid, {})[fkey] = v
|
| 134 |
+
if len(memo) > 100:
|
| 135 |
+
memo.clear()
|
| 136 |
+
return out
|
royalimports_os/core/table_store.py
CHANGED
|
@@ -85,7 +85,7 @@ class TableStore:
|
|
| 85 |
ws = (store.get(self.table_key) or {}).get(username, {}) or {}
|
| 86 |
except Exception:
|
| 87 |
ws = {}
|
| 88 |
-
|
| 89 |
'views': dict(ws.get('views') or {}),
|
| 90 |
'fields': dict(ws.get('fields') or {}),
|
| 91 |
'overlays': dict(ws.get('overlays') or {}),
|
|
@@ -97,6 +97,12 @@ class TableStore:
|
|
| 97 |
'folders': dict(ws.get('folders') or {}),
|
| 98 |
'itemFolders': dict(ws.get('itemFolders') or {}),
|
| 99 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
# ---------------------------------------------------------------- write
|
| 102 |
def _update(self, username, change):
|
|
@@ -116,6 +122,21 @@ class TableStore:
|
|
| 116 |
# coalesces in the background. Registry/auth writes elsewhere stay flush='sync'.
|
| 117 |
store.update(self.table_key, _up, flush='async')
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
def save_folders(self, username, folders, item_folders):
|
| 120 |
"""Replace the folder stratum wholesale (wave-8 I11).
|
| 121 |
|
|
|
|
| 85 |
ws = (store.get(self.table_key) or {}).get(username, {}) or {}
|
| 86 |
except Exception:
|
| 87 |
ws = {}
|
| 88 |
+
out = {
|
| 89 |
'views': dict(ws.get('views') or {}),
|
| 90 |
'fields': dict(ws.get('fields') or {}),
|
| 91 |
'overlays': dict(ws.get('overlays') or {}),
|
|
|
|
| 97 |
'folders': dict(ws.get('folders') or {}),
|
| 98 |
'itemFolders': dict(ws.get('itemFolders') or {}),
|
| 99 |
}
|
| 100 |
+
# 2026-07-31 (owner item 3): WHERE THE USER LEFT OFF survives a new browser. The
|
| 101 |
+
# client's localStorage copy wins when present; this is the server's answer for a
|
| 102 |
+
# fresh profile, which used to fall all the way to the system default view.
|
| 103 |
+
if ws.get('activeViewId'):
|
| 104 |
+
out['activeViewId'] = str(ws['activeViewId'])
|
| 105 |
+
return out
|
| 106 |
|
| 107 |
# ---------------------------------------------------------------- write
|
| 108 |
def _update(self, username, change):
|
|
|
|
| 122 |
# coalesces in the background. Registry/auth writes elsewhere stay flush='sync'.
|
| 123 |
store.update(self.table_key, _up, flush='async')
|
| 124 |
|
| 125 |
+
def save_active_view(self, username, view_id):
|
| 126 |
+
"""Remember which view this user last opened (owner item 3, 2026-07-31).
|
| 127 |
+
|
| 128 |
+
Presentation state, not authorisation: the READ side re-validates the id against what
|
| 129 |
+
the caller may actually see, so a stale or foreign id degrades to the default view
|
| 130 |
+
rather than granting anything. Stored per user like every other stratum.
|
| 131 |
+
"""
|
| 132 |
+
vid = str(view_id or '').strip()[:120]
|
| 133 |
+
if not vid or username == SHARED_KEY:
|
| 134 |
+
return
|
| 135 |
+
|
| 136 |
+
def _set(ws):
|
| 137 |
+
ws['activeViewId'] = vid
|
| 138 |
+
self._update(username, _set)
|
| 139 |
+
|
| 140 |
def save_folders(self, username, folders, item_folders):
|
| 141 |
"""Replace the folder stratum wholesale (wave-8 I11).
|
| 142 |
|
royalimports_os/core/users.py
CHANGED
|
@@ -236,3 +236,23 @@ def allowed_bus_labels(user):
|
|
| 236 |
if not labels:
|
| 237 |
return ['All', 'Fisch', 'Royal']
|
| 238 |
return (['All'] + labels) if len(labels) > 1 else labels
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
if not labels:
|
| 237 |
return ['All', 'Fisch', 'Royal']
|
| 238 |
return (['All'] + labels) if len(labels) > 1 else labels
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def assignable_people():
|
| 242 |
+
"""Display names for `user`-typed overlay columns β the tenant's ACTIVE accounts.
|
| 243 |
+
|
| 244 |
+
Moved from app.py (2026-07-31) so both hosts serve the same choices. Resolved on every
|
| 245 |
+
call rather than persisted with the column: a snapshot would keep offering people who
|
| 246 |
+
have left and never offer people who joined. Deactivated accounts are excluded; a value
|
| 247 |
+
already stored on a row is untouched β history should still say who owned something.
|
| 248 |
+
"""
|
| 249 |
+
try:
|
| 250 |
+
reg = registry() or {}
|
| 251 |
+
except Exception:
|
| 252 |
+
return []
|
| 253 |
+
out = []
|
| 254 |
+
for username, u in reg.items():
|
| 255 |
+
if not isinstance(u, dict) or u.get('active') is False:
|
| 256 |
+
continue
|
| 257 |
+
out.append(str(u.get('name') or username))
|
| 258 |
+
return sorted(set(out))
|
royalimports_os/harness/runtime.py
CHANGED
|
@@ -75,6 +75,11 @@ class TenantRuntime:
|
|
| 75 |
tenant: object # harness.base.Tenant
|
| 76 |
store_namespace: str = ""
|
| 77 |
pool_cache: dict = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
_lock: threading.RLock = field(default_factory=threading.RLock, repr=False)
|
| 79 |
|
| 80 |
@property
|
|
|
|
| 75 |
tenant: object # harness.base.Tenant
|
| 76 |
store_namespace: str = ""
|
| 77 |
pool_cache: dict = field(default_factory=dict)
|
| 78 |
+
#: EXIT-5 β the API adapter's memos for `core.measure_resolve` (the Streamlit adapter keeps
|
| 79 |
+
#: its own in st.session_state). Bounded by the module's own clear-past-cap rule; keyed on
|
| 80 |
+
#: (stamp, scope, pool identity, question) so nothing user-shaped lives in them.
|
| 81 |
+
measure_memo: dict = field(default_factory=dict)
|
| 82 |
+
mset_memo: dict = field(default_factory=dict)
|
| 83 |
_lock: threading.RLock = field(default_factory=threading.RLock, repr=False)
|
| 84 |
|
| 85 |
@property
|
royalimports_os/modules/customer_data.py
CHANGED
|
@@ -518,6 +518,7 @@ delete_table_field = _TSTORE.delete_field
|
|
| 518 |
duplicate_table_field = _TSTORE.duplicate_field
|
| 519 |
patch_table_overlay = _TSTORE.patch_overlay
|
| 520 |
save_table_folders = _TSTORE.save_folders
|
|
|
|
| 521 |
|
| 522 |
|
| 523 |
# ------------------------------------------------------------------ digest compatibility
|
|
|
|
| 518 |
duplicate_table_field = _TSTORE.duplicate_field
|
| 519 |
patch_table_overlay = _TSTORE.patch_overlay
|
| 520 |
save_table_folders = _TSTORE.save_folders
|
| 521 |
+
save_table_active_view = _TSTORE.save_active_view # owner item 3: last-opened view, per user
|
| 522 |
|
| 523 |
|
| 524 |
# ------------------------------------------------------------------ digest compatibility
|
web/dist-embed/index.html
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/apiContract.ts
CHANGED
|
@@ -56,6 +56,12 @@ export const TOAST_EVENT = "aios:toast";
|
|
| 56 |
* membership. A toast is a receipt, not a refresh. */
|
| 57 |
export const WORKSPACE_STALE_EVENT = "aios:workspace-stale";
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
export function signal(name: string, detail?: unknown): void {
|
| 60 |
if (typeof window === "undefined") return;
|
| 61 |
window.dispatchEvent(new CustomEvent(name, { detail }));
|
|
|
|
| 56 |
* membership. A toast is a receipt, not a refresh. */
|
| 57 |
export const WORKSPACE_STALE_EVENT = "aios:workspace-stale";
|
| 58 |
|
| 59 |
+
/** Owner item 10 (2026-07-31): the user CLICKED INTO THE WORK SURFACE (a saved view, a grid
|
| 60 |
+
* cell) β the frame should fold its navigation rail down to the slim strip so the table gets
|
| 61 |
+
* the width. Raised by the grid, handled by the shell; a no-op in the embed (no listener),
|
| 62 |
+
* which is exactly the host-neutral contract the other signals follow. */
|
| 63 |
+
export const NAV_MINIMIZE_EVENT = "aios:nav-minimize";
|
| 64 |
+
|
| 65 |
export function signal(name: string, detail?: unknown): void {
|
| 66 |
if (typeof window === "undefined") return;
|
| 67 |
window.dispatchEvent(new CustomEvent(name, { detail }));
|
web/src/customer-grid/ColumnMenu.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
|
| 2 |
import type { ReactNode } from "react";
|
| 3 |
import { AnchoredOverlay } from "./OverlaySurface";
|
| 4 |
import type { AnchorRect } from "./OverlaySurface";
|
|
|
|
| 5 |
import { CREATABLE_TYPES, choiceOptions, directionLabel, parseOptions, ratingMax }
|
| 6 |
from "./types";
|
| 7 |
import type { Field, FieldFormat, FieldScope, FieldType, Measure, Viewer } from "./types";
|
|
@@ -293,6 +294,89 @@ function needsOptions(t: FieldType | "measure"): boolean {
|
|
| 293 |
*/
|
| 294 |
type CreateKind = FieldType | "measure";
|
| 295 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
/** The Change-field dropdown's "New field" entries. A colon cannot occur in a real field key
|
| 297 |
* (keys are slugs), so the prefix cannot collide with one. */
|
| 298 |
const NEW_PREFIX = "new:";
|
|
@@ -395,9 +479,9 @@ function ExtraTypeEditor({
|
|
| 395 |
idPrefix: string;
|
| 396 |
}) {
|
| 397 |
if (kind === "formula") {
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
);
|
| 401 |
return (
|
| 402 |
<>
|
| 403 |
<label>
|
|
@@ -435,7 +519,10 @@ function ExtraTypeEditor({
|
|
| 435 |
<div className="cg-field-hint cg-formula-error">{formulaError}</div>
|
| 436 |
) : (
|
| 437 |
<div className="cg-field-hint">
|
| 438 |
-
|
|
|
|
|
|
|
|
|
|
| 439 |
Errors show a blank cell.
|
| 440 |
</div>
|
| 441 |
)}
|
|
@@ -578,30 +665,37 @@ export default function ColumnMenu({
|
|
| 578 |
? parseOptions(cleanOptionText(retypeOptionText, retypeTo))
|
| 579 |
: [];
|
| 580 |
|
| 581 |
-
// Wave-5 item 9 β live formula validation for BOTH
|
| 582 |
-
//
|
| 583 |
-
//
|
|
|
|
|
|
|
| 584 |
const knownKeys = useMemo(
|
| 585 |
() => new Set(fields.filter((f) => f.type !== "formula").map((f) => f.key)),
|
| 586 |
[fields]
|
| 587 |
);
|
| 588 |
-
const
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
const formulaCheck = useMemo(
|
| 593 |
() =>
|
| 594 |
formulaText.trim() === ""
|
| 595 |
? { ok: false, error: "Type a formula", refs: [] as string[] }
|
| 596 |
-
: validateFormula(formulaText, knownKeys,
|
| 597 |
-
[formulaText, knownKeys,
|
| 598 |
);
|
| 599 |
const swapFormulaCheck = useMemo(
|
| 600 |
() =>
|
| 601 |
swapFormulaText.trim() === ""
|
| 602 |
? { ok: false, error: "Type a formula", refs: [] as string[] }
|
| 603 |
-
: validateFormula(swapFormulaText, knownKeys,
|
| 604 |
-
[swapFormulaText, knownKeys,
|
| 605 |
);
|
| 606 |
|
| 607 |
const chosenMeasure = measures.find((m) => m.key === measureKey);
|
|
@@ -778,25 +872,19 @@ export default function ColumnMenu({
|
|
| 778 |
onKeyDown={(event) => event.key === "Enter" && create()}
|
| 779 |
/>
|
| 780 |
</label>
|
| 781 |
-
<
|
| 782 |
-
<span>Field type</span>
|
| 783 |
-
|
| 784 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 785 |
value={kind}
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
{option.label}
|
| 791 |
-
</option>
|
| 792 |
-
))}
|
| 793 |
-
{/* Offered exactly when the host offers measures β the same availability rule as
|
| 794 |
-
the filter's measure conditions, because it is the same machinery. */}
|
| 795 |
-
{measures.length > 0 && (
|
| 796 |
-
<option value="measure">Formula measure (over a period)</option>
|
| 797 |
-
)}
|
| 798 |
-
</select>
|
| 799 |
-
</label>
|
| 800 |
{kind === "measure" && (
|
| 801 |
<>
|
| 802 |
<label>
|
|
|
|
| 2 |
import type { ReactNode } from "react";
|
| 3 |
import { AnchoredOverlay } from "./OverlaySurface";
|
| 4 |
import type { AnchorRect } from "./OverlaySurface";
|
| 5 |
+
import { FieldTypeIcon } from "./icons";
|
| 6 |
import { CREATABLE_TYPES, choiceOptions, directionLabel, parseOptions, ratingMax }
|
| 7 |
from "./types";
|
| 8 |
import type { Field, FieldFormat, FieldScope, FieldType, Measure, Viewer } from "./types";
|
|
|
|
| 294 |
*/
|
| 295 |
type CreateKind = FieldType | "measure";
|
| 296 |
|
| 297 |
+
/**
|
| 298 |
+
* Owner item 8 (2026-07-31) β the create pane's field-type picker: a find box over an icon
|
| 299 |
+
* listbox, replacing the icon-less native select. Each row wears the SAME mark its column
|
| 300 |
+
* header will wear (`FieldTypeIcon` β TYPE_SHAPES), so the vocabulary teaches itself. The
|
| 301 |
+
* "measure" pseudo-kind borrows the currency mark: a measure column's real type is
|
| 302 |
+
* currency/int/pct, and inventing a 17th glyph for a pseudo-kind would put a mark on screen
|
| 303 |
+
* that no column ever wears.
|
| 304 |
+
*/
|
| 305 |
+
function TypePicker({
|
| 306 |
+
value,
|
| 307 |
+
onPick,
|
| 308 |
+
offerMeasure,
|
| 309 |
+
}: {
|
| 310 |
+
value: CreateKind;
|
| 311 |
+
onPick: (k: CreateKind) => void;
|
| 312 |
+
offerMeasure: boolean;
|
| 313 |
+
}) {
|
| 314 |
+
const [q, setQ] = useState("");
|
| 315 |
+
const needle = q.trim().toLowerCase();
|
| 316 |
+
const rows: { value: CreateKind; label: string }[] = [
|
| 317 |
+
...FIELD_TYPES,
|
| 318 |
+
...(offerMeasure
|
| 319 |
+
? [{ value: "measure" as CreateKind, label: "Formula measure (over a period)" }]
|
| 320 |
+
: []),
|
| 321 |
+
].filter((r) => !needle || r.label.toLowerCase().includes(needle));
|
| 322 |
+
return (
|
| 323 |
+
<div className="cg-type-picker">
|
| 324 |
+
<div className="cg-type-search">
|
| 325 |
+
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
| 326 |
+
<circle cx="7" cy="7" r="4.4" stroke="currentColor" strokeWidth="1.35" />
|
| 327 |
+
<path
|
| 328 |
+
d="m10.4 10.4 3.1 3.1"
|
| 329 |
+
stroke="currentColor"
|
| 330 |
+
strokeWidth="1.35"
|
| 331 |
+
strokeLinecap="round"
|
| 332 |
+
/>
|
| 333 |
+
</svg>
|
| 334 |
+
<input
|
| 335 |
+
type="text"
|
| 336 |
+
value={q}
|
| 337 |
+
placeholder="Find a field type"
|
| 338 |
+
aria-label="Find a field type"
|
| 339 |
+
onChange={(event) => setQ(event.target.value)}
|
| 340 |
+
/>
|
| 341 |
+
</div>
|
| 342 |
+
<div className="cg-type-list" role="listbox" aria-label="Field type">
|
| 343 |
+
{rows.map((r) => (
|
| 344 |
+
<button
|
| 345 |
+
key={r.value}
|
| 346 |
+
type="button"
|
| 347 |
+
role="option"
|
| 348 |
+
aria-selected={value === r.value}
|
| 349 |
+
className={"cg-type-row" + (value === r.value ? " is-on" : "")}
|
| 350 |
+
onClick={() => onPick(r.value)}
|
| 351 |
+
>
|
| 352 |
+
<FieldTypeIcon type={r.value === "measure" ? "currency" : r.value} size={16} />
|
| 353 |
+
<span className="cg-type-label">{r.label}</span>
|
| 354 |
+
{value === r.value && (
|
| 355 |
+
<svg
|
| 356 |
+
className="cg-type-check"
|
| 357 |
+
width="14"
|
| 358 |
+
height="14"
|
| 359 |
+
viewBox="0 0 16 16"
|
| 360 |
+
fill="none"
|
| 361 |
+
aria-hidden="true"
|
| 362 |
+
>
|
| 363 |
+
<path
|
| 364 |
+
d="m3.5 8.5 3 3 6-6.5"
|
| 365 |
+
stroke="currentColor"
|
| 366 |
+
strokeWidth="1.6"
|
| 367 |
+
strokeLinecap="round"
|
| 368 |
+
strokeLinejoin="round"
|
| 369 |
+
/>
|
| 370 |
+
</svg>
|
| 371 |
+
)}
|
| 372 |
+
</button>
|
| 373 |
+
))}
|
| 374 |
+
{rows.length === 0 && <div className="cg-pop-note">No matching type.</div>}
|
| 375 |
+
</div>
|
| 376 |
+
</div>
|
| 377 |
+
);
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
/** The Change-field dropdown's "New field" entries. A colon cannot occur in a real field key
|
| 381 |
* (keys are slugs), so the prefix cannot collide with one. */
|
| 382 |
const NEW_PREFIX = "new:";
|
|
|
|
| 479 |
idPrefix: string;
|
| 480 |
}) {
|
| 481 |
if (kind === "formula") {
|
| 482 |
+
// 2026-07-31 (owner item 2): other FORMULA fields are referencable now β evaluation is
|
| 483 |
+
// topological and a cycle is refused at validation, so the old exclusion is gone.
|
| 484 |
+
const referencable = fields.filter((f) => f.key !== "__proto__");
|
| 485 |
return (
|
| 486 |
<>
|
| 487 |
<label>
|
|
|
|
| 519 |
<div className="cg-field-hint cg-formula-error">{formulaError}</div>
|
| 520 |
) : (
|
| 521 |
<div className="cg-field-hint">
|
| 522 |
+
Excel-style: numbers, text in quotes, {"{field}"} references, + β Γ Γ· ^ & ( ),
|
| 523 |
+
IF Β· AND Β· OR Β· SUM Β· AVERAGE Β· COUNT Β· MIN Β· MAX Β· ROUND(UP/DOWN) Β· ABS Β· MOD Β·
|
| 524 |
+
SQRT Β· CONCATENATE Β· LEFT Β· RIGHT Β· MID Β· LEN Β· TRIM Β· UPPER Β· LOWER Β· PROPER Β·
|
| 525 |
+
TEXT Β· VALUE Β· TODAY Β· DAYS Β· YEAR Β· MONTH Β· DAY Β· IFERROR Β· ISBLANK.
|
| 526 |
Errors show a blank cell.
|
| 527 |
</div>
|
| 528 |
)}
|
|
|
|
| 665 |
? parseOptions(cleanOptionText(retypeOptionText, retypeTo))
|
| 666 |
: [];
|
| 667 |
|
| 668 |
+
// Wave-5 item 9, amended 2026-07-31 (owner item 2) β live formula validation for BOTH
|
| 669 |
+
// create surfaces. Refs may now name OTHER formula fields (evaluation is topological);
|
| 670 |
+
// what is refused is a CYCLE, checked against every formula's SOURCE. Both surfaces here
|
| 671 |
+
// CREATE a field, so no cycle is possible yet and no selfKey is passed β the retype path
|
| 672 |
+
// (in-place formula edit) would pass the edited field's key.
|
| 673 |
const knownKeys = useMemo(
|
| 674 |
() => new Set(fields.filter((f) => f.type !== "formula").map((f) => f.key)),
|
| 675 |
[fields]
|
| 676 |
);
|
| 677 |
+
const formulaSources = useMemo(() => {
|
| 678 |
+
const out = new Map<string, string>();
|
| 679 |
+
for (const f of fields) {
|
| 680 |
+
if (f.type !== "formula") continue;
|
| 681 |
+
const src = typeof f.formula === "string" ? f.formula : "";
|
| 682 |
+
if (src) out.set(f.key, src);
|
| 683 |
+
}
|
| 684 |
+
return out;
|
| 685 |
+
}, [fields]);
|
| 686 |
const formulaCheck = useMemo(
|
| 687 |
() =>
|
| 688 |
formulaText.trim() === ""
|
| 689 |
? { ok: false, error: "Type a formula", refs: [] as string[] }
|
| 690 |
+
: validateFormula(formulaText, knownKeys, formulaSources),
|
| 691 |
+
[formulaText, knownKeys, formulaSources]
|
| 692 |
);
|
| 693 |
const swapFormulaCheck = useMemo(
|
| 694 |
() =>
|
| 695 |
swapFormulaText.trim() === ""
|
| 696 |
? { ok: false, error: "Type a formula", refs: [] as string[] }
|
| 697 |
+
: validateFormula(swapFormulaText, knownKeys, formulaSources),
|
| 698 |
+
[swapFormulaText, knownKeys, formulaSources]
|
| 699 |
);
|
| 700 |
|
| 701 |
const chosenMeasure = measures.find((m) => m.key === measureKey);
|
|
|
|
| 872 |
onKeyDown={(event) => event.key === "Enter" && create()}
|
| 873 |
/>
|
| 874 |
</label>
|
| 875 |
+
<div className="cg-type-block">
|
| 876 |
+
<span className="cg-type-title">Field type</span>
|
| 877 |
+
{/* Owner item 8 (2026-07-31): every type wears its own mark β the same TYPE_SHAPES
|
| 878 |
+
glyph its column header wears. A native <option> cannot hold an SVG (the ruling
|
| 879 |
+
iconShapes.ts records), so the control is a find box over a listbox of real
|
| 880 |
+
rows, Airtable-style. The measure row appears exactly when the host offers
|
| 881 |
+
measures β the same availability rule as the filter's measure conditions. */}
|
| 882 |
+
<TypePicker
|
| 883 |
value={kind}
|
| 884 |
+
onPick={setKind}
|
| 885 |
+
offerMeasure={measures.length > 0}
|
| 886 |
+
/>
|
| 887 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 888 |
{kind === "measure" && (
|
| 889 |
<>
|
| 890 |
<label>
|
web/src/customer-grid/CustomerGrid.tsx
CHANGED
|
@@ -38,6 +38,7 @@ import ColumnMenu from "./ColumnMenu";
|
|
| 38 |
import type { ColumnMenuState } from "./ColumnMenu";
|
| 39 |
import { HEADER_ICONS } from "./iconShapes";
|
| 40 |
import { emitHostEvent, eventId } from "./hostBridge";
|
|
|
|
| 41 |
import { runExport } from "./export";
|
| 42 |
import type { ExportFormat } from "./export";
|
| 43 |
import { echoReemit, reconcileEchoView } from "./viewEcho";
|
|
@@ -46,7 +47,7 @@ import { newFolderId, pruneFolderStamps, reconcileFolders, resolveFolderId } fro
|
|
| 46 |
import type { FolderStamps } from "./folders";
|
| 47 |
import type { GridFolder, ViewPermissions } from "./types";
|
| 48 |
import type { FieldStamps } from "./optimism";
|
| 49 |
-
import { evalFormula, parseFormula } from "./formulaEngine";
|
| 50 |
import type { FormulaAst } from "./formulaEngine";
|
| 51 |
import { CalendarView, KanbanView, ListView, ModeSwitch } from "./viewModes";
|
| 52 |
// The chart engine lives in `viz/` since EXIT wave 2 (W2-5/Y3) β the grid is now
|
|
@@ -328,6 +329,12 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
|
|
| 328 |
fieldKey: string;
|
| 329 |
anchor: AnchorRect;
|
| 330 |
} | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
/** The selection bar's "Add to cohort" popover (owner item 2's purpose for the checkboxes). */
|
| 332 |
const [selAddOpen, setSelAddOpen] = useState(false);
|
| 333 |
const [selListName, setSelListName] = useState("");
|
|
@@ -654,14 +661,23 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
|
|
| 654 |
// row WITH this session's overlay edits layered, so editing a referenced field recomputes
|
| 655 |
// live. A formula that does not parse, or errors on a row, yields BLANK β never a wrong
|
| 656 |
// number (formulaEngine.ts). `created_time` copies the row's `_created`.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 657 |
const formulaAsts = useMemo(() => {
|
| 658 |
-
const
|
| 659 |
for (const f of fields) {
|
| 660 |
if (f.type !== "formula") continue;
|
| 661 |
const src = formulaOf(f);
|
| 662 |
-
if (
|
| 663 |
-
|
| 664 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 665 |
}
|
| 666 |
return out;
|
| 667 |
}, [fields]);
|
|
@@ -671,17 +687,23 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
|
|
| 671 |
);
|
| 672 |
const computedRows = useMemo(() => {
|
| 673 |
if (formulaAsts.length === 0 && createdTimeKeys.length === 0) return rawRows;
|
|
|
|
| 674 |
return rawRows.map((r) => {
|
| 675 |
const edits = overlayEdits[r.pid];
|
| 676 |
-
const
|
| 677 |
const out: Row = { ...r };
|
| 678 |
-
for (const k of createdTimeKeys)
|
| 679 |
out[k] = (r._created as string | undefined) ?? null;
|
| 680 |
-
|
| 681 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 682 |
return out;
|
| 683 |
});
|
| 684 |
-
}, [rawRows, overlayEdits, formulaAsts, createdTimeKeys]);
|
| 685 |
|
| 686 |
// Wave-2 item 2c β COHORT MODE (the Cohort page). The host serves the WHOLE pool (rows +
|
| 687 |
// derived values over the pool); the ACTIVE cohort scopes the table to its pids CLIENT-side.
|
|
@@ -753,7 +775,8 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
|
|
| 753 |
overlayEdits,
|
| 754 |
canEditField
|
| 755 |
);
|
| 756 |
-
const { gridSelection, selectedPids, onGridSelectionChange, selectPids,
|
|
|
|
| 757 |
useGridSelection(
|
| 758 |
displayRows,
|
| 759 |
displayPidToIndex,
|
|
@@ -1034,6 +1057,9 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
|
|
| 1034 |
(cell: Item, event: CellClickedEventArgs) => {
|
| 1035 |
const row = displayRows[cell[1]];
|
| 1036 |
if (!row) return;
|
|
|
|
|
|
|
|
|
|
| 1037 |
if (row.kind === "group-header") {
|
| 1038 |
event.preventDefault();
|
| 1039 |
setCollapsed((current) => {
|
|
@@ -1086,6 +1112,14 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
|
|
| 1086 |
}
|
| 1087 |
}
|
| 1088 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1089 |
// Owner item 17 β A SINGLE CLICK HIGHLIGHTS. It used to open the record panel from here,
|
| 1090 |
// which meant a user could not select a cell, read across a row, or copy a value without
|
| 1091 |
// a drawer landing over the table. The highlight is glide's own doing: this handler
|
|
@@ -1097,7 +1131,7 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
|
|
| 1097 |
// open a record; the hover Expand button below is its replacement, and half of this
|
| 1098 |
// change is a table whose records cannot be opened at all.
|
| 1099 |
},
|
| 1100 |
-
[displayRows, visibleCols, fieldByKey, canEditField, overlayEdits]
|
| 1101 |
);
|
| 1102 |
/**
|
| 1103 |
* Double-click / Enter. KEPT as a door to the record on purpose (owner item 17 names the
|
|
@@ -1189,6 +1223,30 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
|
|
| 1189 |
);
|
| 1190 |
const onGridKeyDown = useCallback(
|
| 1191 |
(event: GridKeyEventArgs) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1192 |
const contextMenu =
|
| 1193 |
event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey);
|
| 1194 |
// DataEditor exposes public controlled selection without the row marker,
|
|
@@ -1212,7 +1270,7 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
|
|
| 1212 |
height: 36,
|
| 1213 |
});
|
| 1214 |
},
|
| 1215 |
-
[gridSelection.current, openHeaderMenu]
|
| 1216 |
);
|
| 1217 |
|
| 1218 |
const persistView = useCallback((view: SavedView) => {
|
|
@@ -1238,6 +1296,12 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
|
|
| 1238 |
setSearch("");
|
| 1239 |
setDetailPid(null);
|
| 1240 |
setDisplayCap(DISPLAY_PAGE);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1241 |
},
|
| 1242 |
[views, activeViewId, config, persistView, fields]
|
| 1243 |
);
|
|
@@ -3116,7 +3180,7 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
|
|
| 3116 |
placement="bottom-start"
|
| 3117 |
role="listbox"
|
| 3118 |
ariaLabel={`Choose ${pickerField.label}`}
|
| 3119 |
-
onDismiss={
|
| 3120 |
dataKind="cell-picker"
|
| 3121 |
>
|
| 3122 |
<div className="cg-pop-title">{pickerField.label}</div>
|
|
@@ -3138,7 +3202,7 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
|
|
| 3138 |
aria-label={`${n} star${n === 1 ? "" : "s"}`}
|
| 3139 |
onClick={() => {
|
| 3140 |
patchOverlay(picker.pid, { [pickerField.key]: String(n) });
|
| 3141 |
-
|
| 3142 |
}}
|
| 3143 |
>
|
| 3144 |
<StarIcon on={Number(pickerValue) >= n} />
|
|
@@ -3180,7 +3244,7 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
|
|
| 3180 |
return;
|
| 3181 |
}
|
| 3182 |
patchOverlay(picker.pid, { [pickerField.key]: choice });
|
| 3183 |
-
|
| 3184 |
}}
|
| 3185 |
>
|
| 3186 |
<span className="cg-pick-pill"
|
|
@@ -3196,7 +3260,7 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
|
|
| 3196 |
<button
|
| 3197 |
type="button"
|
| 3198 |
className="cg-btn"
|
| 3199 |
-
onClick={
|
| 3200 |
>
|
| 3201 |
Done
|
| 3202 |
</button>
|
|
@@ -3211,7 +3275,7 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
|
|
| 3211 |
className="cg-pick-row cg-pick-clear"
|
| 3212 |
onClick={() => {
|
| 3213 |
patchOverlay(picker.pid, { [pickerField.key]: "" });
|
| 3214 |
-
|
| 3215 |
}}
|
| 3216 |
>
|
| 3217 |
Clear
|
|
|
|
| 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";
|
|
|
|
| 47 |
import type { FolderStamps } from "./folders";
|
| 48 |
import type { GridFolder, ViewPermissions } from "./types";
|
| 49 |
import type { FieldStamps } from "./optimism";
|
| 50 |
+
import { evalFormula, orderFormulas, parseFormula } from "./formulaEngine";
|
| 51 |
import type { FormulaAst } from "./formulaEngine";
|
| 52 |
import { CalendarView, KanbanView, ListView, ModeSwitch } from "./viewModes";
|
| 53 |
// The chart engine lives in `viz/` since EXIT wave 2 (W2-5/Y3) β the grid is now
|
|
|
|
| 329 |
fieldKey: string;
|
| 330 |
anchor: AnchorRect;
|
| 331 |
} | null>(null);
|
| 332 |
+
/** Owner item 5 (2026-07-31) β closing a picker RETURNS FOCUS TO THE GRID, so Enter keeps
|
| 333 |
+
* navigating (pick β Enter β next record) instead of stranding focus on a dead overlay. */
|
| 334 |
+
const closePicker = useCallback(() => {
|
| 335 |
+
setPicker(null);
|
| 336 |
+
requestAnimationFrame(() => gridRef.current?.focus());
|
| 337 |
+
}, []);
|
| 338 |
/** The selection bar's "Add to cohort" popover (owner item 2's purpose for the checkboxes). */
|
| 339 |
const [selAddOpen, setSelAddOpen] = useState(false);
|
| 340 |
const [selListName, setSelListName] = useState("");
|
|
|
|
| 661 |
// row WITH this session's overlay edits layered, so editing a referenced field recomputes
|
| 662 |
// live. A formula that does not parse, or errors on a row, yields BLANK β never a wrong
|
| 663 |
// number (formulaEngine.ts). `created_time` copies the row's `_created`.
|
| 664 |
+
// 2026-07-31 (owner item 2): formulas may reference OTHER formulas now, so parse order is
|
| 665 |
+
// TOPOLOGICAL (orderFormulas) β a formula runs after the formulas it reads, cycle members
|
| 666 |
+
// never run (blank, never a stale number), and each row's results feed the next formula
|
| 667 |
+
// through a per-row scope.
|
| 668 |
const formulaAsts = useMemo(() => {
|
| 669 |
+
const sources = new Map<string, string>();
|
| 670 |
for (const f of fields) {
|
| 671 |
if (f.type !== "formula") continue;
|
| 672 |
const src = formulaOf(f);
|
| 673 |
+
if (src) sources.set(f.key, src);
|
| 674 |
+
}
|
| 675 |
+
const { order, cyclic } = orderFormulas(sources);
|
| 676 |
+
const out: { key: string; ast: FormulaAst }[] = [];
|
| 677 |
+
for (const key of order) {
|
| 678 |
+
if (cyclic.has(key)) continue;
|
| 679 |
+
const p = parseFormula(sources.get(key)!);
|
| 680 |
+
if (p.ok) out.push({ key, ast: p.ast });
|
| 681 |
}
|
| 682 |
return out;
|
| 683 |
}, [fields]);
|
|
|
|
| 687 |
);
|
| 688 |
const computedRows = useMemo(() => {
|
| 689 |
if (formulaAsts.length === 0 && createdTimeKeys.length === 0) return rawRows;
|
| 690 |
+
const env = { today: payload?.today };
|
| 691 |
return rawRows.map((r) => {
|
| 692 |
const edits = overlayEdits[r.pid];
|
| 693 |
+
const scope: Row = edits ? { ...r, ...edits } : { ...r };
|
| 694 |
const out: Row = { ...r };
|
| 695 |
+
for (const k of createdTimeKeys) {
|
| 696 |
out[k] = (r._created as string | undefined) ?? null;
|
| 697 |
+
scope[k] = out[k];
|
| 698 |
+
}
|
| 699 |
+
for (const { key, ast } of formulaAsts) {
|
| 700 |
+
const v = evalFormula(ast, (k) => scope[k], env);
|
| 701 |
+
out[key] = v;
|
| 702 |
+
scope[key] = v; // later formulas read this one's result β the topo order above
|
| 703 |
+
}
|
| 704 |
return out;
|
| 705 |
});
|
| 706 |
+
}, [rawRows, overlayEdits, formulaAsts, createdTimeKeys, payload?.today]);
|
| 707 |
|
| 708 |
// Wave-2 item 2c β COHORT MODE (the Cohort page). The host serves the WHOLE pool (rows +
|
| 709 |
// derived values over the pool); the ACTIVE cohort scopes the table to its pids CLIENT-side.
|
|
|
|
| 775 |
overlayEdits,
|
| 776 |
canEditField
|
| 777 |
);
|
| 778 |
+
const { gridSelection, selectedPids, onGridSelectionChange, selectPids, togglePid,
|
| 779 |
+
setActiveCell, clearSelection } =
|
| 780 |
useGridSelection(
|
| 781 |
displayRows,
|
| 782 |
displayPidToIndex,
|
|
|
|
| 1057 |
(cell: Item, event: CellClickedEventArgs) => {
|
| 1058 |
const row = displayRows[cell[1]];
|
| 1059 |
if (!row) return;
|
| 1060 |
+
// Owner item 10: clicking into the cells is "I am working now" β the frame folds its
|
| 1061 |
+
// navigation rail to the slim strip (a no-op in the embed; the shell listens).
|
| 1062 |
+
signal(NAV_MINIMIZE_EVENT);
|
| 1063 |
if (row.kind === "group-header") {
|
| 1064 |
event.preventDefault();
|
| 1065 |
setCollapsed((current) => {
|
|
|
|
| 1112 |
}
|
| 1113 |
}
|
| 1114 |
}
|
| 1115 |
+
// Owner item 6 (2026-07-31) β CLICKING THE CUSTOMER TICKS THE CHECKBOX. The row marker
|
| 1116 |
+
// is a ~32px strip; the identity cell is the widest, most natural target on the row, so
|
| 1117 |
+
// a click there toggles the same pid-anchored set the markers write ("more surface area
|
| 1118 |
+
// to select individual customers into a Cohort"). No preventDefault: glide still commits
|
| 1119 |
+
// the cell highlight below, so reading across the row keeps working.
|
| 1120 |
+
if (row.kind === "data" && field && field.key === lockedKey) {
|
| 1121 |
+
togglePid(row.record.pid);
|
| 1122 |
+
}
|
| 1123 |
// Owner item 17 β A SINGLE CLICK HIGHLIGHTS. It used to open the record panel from here,
|
| 1124 |
// which meant a user could not select a cell, read across a row, or copy a value without
|
| 1125 |
// a drawer landing over the table. The highlight is glide's own doing: this handler
|
|
|
|
| 1131 |
// open a record; the hover Expand button below is its replacement, and half of this
|
| 1132 |
// change is a table whose records cannot be opened at all.
|
| 1133 |
},
|
| 1134 |
+
[displayRows, visibleCols, fieldByKey, canEditField, overlayEdits, lockedKey, togglePid]
|
| 1135 |
);
|
| 1136 |
/**
|
| 1137 |
* Double-click / Enter. KEPT as a door to the record on purpose (owner item 17 names the
|
|
|
|
| 1223 |
);
|
| 1224 |
const onGridKeyDown = useCallback(
|
| 1225 |
(event: GridKeyEventArgs) => {
|
| 1226 |
+
// Owner item 5 (2026-07-31) β EXCEL-GRADE ENTER. When no editor is open (an open overlay
|
| 1227 |
+
// editor swallows its own keys before the canvas sees them), Enter moves the active cell
|
| 1228 |
+
// DOWN one record and Shift+Enter moves UP β never opening the record drawer, which is
|
| 1229 |
+
// what made keyboard runs down a column "really clunky". Glide's own overlay editor
|
| 1230 |
+
// already commits-and-moves-down on Enter, so typing β Enter β typing flows like Excel;
|
| 1231 |
+
// this handles the BETWEEN-edits half. Group headers are skipped in the direction of
|
| 1232 |
+
// travel. The drawer stays reachable by double-click and the hover Expand button.
|
| 1233 |
+
if (event.key === "Enter" && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
| 1234 |
+
const cur = gridSelection.current?.cell;
|
| 1235 |
+
if (cur) {
|
| 1236 |
+
event.preventDefault();
|
| 1237 |
+
event.stopPropagation();
|
| 1238 |
+
event.cancel();
|
| 1239 |
+
const dir = event.shiftKey ? -1 : 1;
|
| 1240 |
+
let row = cur[1] + dir;
|
| 1241 |
+
while (row >= 0 && row < displayRows.length && displayRows[row]?.kind !== "data")
|
| 1242 |
+
row += dir;
|
| 1243 |
+
if (row >= 0 && row < displayRows.length) {
|
| 1244 |
+
setActiveCell(cur[0], row);
|
| 1245 |
+
gridRef.current?.scrollTo(cur[0], row, "vertical", 0, 0);
|
| 1246 |
+
}
|
| 1247 |
+
return;
|
| 1248 |
+
}
|
| 1249 |
+
}
|
| 1250 |
const contextMenu =
|
| 1251 |
event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey);
|
| 1252 |
// DataEditor exposes public controlled selection without the row marker,
|
|
|
|
| 1270 |
height: 36,
|
| 1271 |
});
|
| 1272 |
},
|
| 1273 |
+
[gridSelection.current, openHeaderMenu, displayRows, setActiveCell]
|
| 1274 |
);
|
| 1275 |
|
| 1276 |
const persistView = useCallback((view: SavedView) => {
|
|
|
|
| 1296 |
setSearch("");
|
| 1297 |
setDetailPid(null);
|
| 1298 |
setDisplayCap(DISPLAY_PAGE);
|
| 1299 |
+
// Owner item 3 (2026-07-31): tell the host WHERE THE USER IS, so a fresh browser (no
|
| 1300 |
+
// localStorage copy) resumes on this view instead of the system default. Presentation
|
| 1301 |
+
// state β the host stores the id and the read side re-validates it.
|
| 1302 |
+
emitHostEvent({ id: eventId("view"), type: "view_select", viewId: id });
|
| 1303 |
+
// Owner item 10: opening a view is "I am working now" β the frame folds its nav rail.
|
| 1304 |
+
signal(NAV_MINIMIZE_EVENT);
|
| 1305 |
},
|
| 1306 |
[views, activeViewId, config, persistView, fields]
|
| 1307 |
);
|
|
|
|
| 3180 |
placement="bottom-start"
|
| 3181 |
role="listbox"
|
| 3182 |
ariaLabel={`Choose ${pickerField.label}`}
|
| 3183 |
+
onDismiss={closePicker}
|
| 3184 |
dataKind="cell-picker"
|
| 3185 |
>
|
| 3186 |
<div className="cg-pop-title">{pickerField.label}</div>
|
|
|
|
| 3202 |
aria-label={`${n} star${n === 1 ? "" : "s"}`}
|
| 3203 |
onClick={() => {
|
| 3204 |
patchOverlay(picker.pid, { [pickerField.key]: String(n) });
|
| 3205 |
+
closePicker();
|
| 3206 |
}}
|
| 3207 |
>
|
| 3208 |
<StarIcon on={Number(pickerValue) >= n} />
|
|
|
|
| 3244 |
return;
|
| 3245 |
}
|
| 3246 |
patchOverlay(picker.pid, { [pickerField.key]: choice });
|
| 3247 |
+
closePicker();
|
| 3248 |
}}
|
| 3249 |
>
|
| 3250 |
<span className="cg-pick-pill"
|
|
|
|
| 3260 |
<button
|
| 3261 |
type="button"
|
| 3262 |
className="cg-btn"
|
| 3263 |
+
onClick={closePicker}
|
| 3264 |
>
|
| 3265 |
Done
|
| 3266 |
</button>
|
|
|
|
| 3275 |
className="cg-pick-row cg-pick-clear"
|
| 3276 |
onClick={() => {
|
| 3277 |
patchOverlay(picker.pid, { [pickerField.key]: "" });
|
| 3278 |
+
closePicker();
|
| 3279 |
}}
|
| 3280 |
>
|
| 3281 |
Clear
|
web/src/customer-grid/ViewSidebar.tsx
CHANGED
|
@@ -211,9 +211,36 @@ export default function ViewSidebar({
|
|
| 211 |
const [addAnchor, setAddAnchor] = useState<HTMLElement | null>(null);
|
| 212 |
const [addName, setAddName] = useState("");
|
| 213 |
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
const foldersOn = !!folders && !!folderIdOf && !!onItemMove;
|
| 215 |
const groups = groupByFolder(
|
| 216 |
-
|
| 217 |
foldersOn ? (folders as GridFolder[]) : [],
|
| 218 |
(v) => (folderIdOf ? folderIdOf(v.id) : null)
|
| 219 |
);
|
|
@@ -299,7 +326,31 @@ export default function ViewSidebar({
|
|
| 299 |
};
|
| 300 |
|
| 301 |
return (
|
| 302 |
-
<aside
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
{/*
|
| 304 |
I13 β the "Views / All changes saved" header block is GONE and everything moved up.
|
| 305 |
|
|
@@ -337,6 +388,38 @@ export default function ViewSidebar({
|
|
| 337 |
</button>
|
| 338 |
</div>
|
| 339 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
{creating && (
|
| 341 |
<div className="cg-view-create cg-create-form">
|
| 342 |
<label htmlFor="cg-new-view">
|
|
@@ -543,9 +626,11 @@ export default function ViewSidebar({
|
|
| 543 |
</AnchoredOverlay>
|
| 544 |
)}
|
| 545 |
|
| 546 |
-
<div className="cg-view-list">
|
| 547 |
{groups.map((group) => {
|
| 548 |
const gid = group.folder?.id ?? null;
|
|
|
|
|
|
|
| 549 |
const isRoot = gid === null;
|
| 550 |
const shut = gid != null && collapsed.has(gid);
|
| 551 |
return (
|
|
|
|
| 211 |
const [addAnchor, setAddAnchor] = useState<HTMLElement | null>(null);
|
| 212 |
const [addName, setAddName] = useState("");
|
| 213 |
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
| 214 |
+
// Owner item 9 (2026-07-31) β "Find a view". Local and never persisted: a search is a
|
| 215 |
+
// moment, not a setting. Filtering runs BEFORE grouping so a folder with no matches
|
| 216 |
+
// simply drops out of the rail while the query is live.
|
| 217 |
+
const [viewQuery, setViewQuery] = useState("");
|
| 218 |
+
// Owner item 11 β this rail is the SECOND navigation bar: it folds to the same slim strip
|
| 219 |
+
// the shell nav does, remembered per browser under its own key.
|
| 220 |
+
const [railShut, setRailShut] = useState<boolean>(() => {
|
| 221 |
+
try {
|
| 222 |
+
return localStorage.getItem("aios-views-rail") === "1";
|
| 223 |
+
} catch {
|
| 224 |
+
return false;
|
| 225 |
+
}
|
| 226 |
+
});
|
| 227 |
+
const toggleRail = () =>
|
| 228 |
+
setRailShut((v) => {
|
| 229 |
+
const next = !v;
|
| 230 |
+
try {
|
| 231 |
+
localStorage.setItem("aios-views-rail", next ? "1" : "0");
|
| 232 |
+
} catch {
|
| 233 |
+
// storage can be blocked; the fold still works for the session
|
| 234 |
+
}
|
| 235 |
+
return next;
|
| 236 |
+
});
|
| 237 |
+
const viewNeedle = viewQuery.trim().toLowerCase();
|
| 238 |
+
const shownViews = viewNeedle
|
| 239 |
+
? views.filter((v) => (v.name || "").toLowerCase().includes(viewNeedle))
|
| 240 |
+
: views;
|
| 241 |
const foldersOn = !!folders && !!folderIdOf && !!onItemMove;
|
| 242 |
const groups = groupByFolder(
|
| 243 |
+
shownViews,
|
| 244 |
foldersOn ? (folders as GridFolder[]) : [],
|
| 245 |
(v) => (folderIdOf ? folderIdOf(v.id) : null)
|
| 246 |
);
|
|
|
|
| 326 |
};
|
| 327 |
|
| 328 |
return (
|
| 329 |
+
<aside
|
| 330 |
+
className={"cg-views" + (railShut ? " is-collapsed" : "")}
|
| 331 |
+
aria-label="Customer views"
|
| 332 |
+
>
|
| 333 |
+
{/* Owner item 11 β the rail's own minimize control: the same three-bars glyph as the
|
| 334 |
+
shell nav's, because the two rails are one idea ("a navigation bar folds"). */}
|
| 335 |
+
<div className="cg-views-top">
|
| 336 |
+
<button
|
| 337 |
+
type="button"
|
| 338 |
+
className="cg-rail-toggle"
|
| 339 |
+
aria-label={railShut ? "Expand views" : "Minimize views"}
|
| 340 |
+
aria-expanded={!railShut}
|
| 341 |
+
title={railShut ? "Expand views" : "Minimize views"}
|
| 342 |
+
onClick={toggleRail}
|
| 343 |
+
>
|
| 344 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
| 345 |
+
<path
|
| 346 |
+
d="M2.5 4.4h11M2.5 8h11M2.5 11.6h11"
|
| 347 |
+
stroke="currentColor"
|
| 348 |
+
strokeWidth="1.35"
|
| 349 |
+
strokeLinecap="round"
|
| 350 |
+
/>
|
| 351 |
+
</svg>
|
| 352 |
+
</button>
|
| 353 |
+
</div>
|
| 354 |
{/*
|
| 355 |
I13 β the "Views / All changes saved" header block is GONE and everything moved up.
|
| 356 |
|
|
|
|
| 388 |
</button>
|
| 389 |
</div>
|
| 390 |
|
| 391 |
+
{/* Owner item 9 β "Find a view". Borderless on the white rail (no box until it is
|
| 392 |
+
being used); focus paints the brand-purple outline. Clearing the query restores
|
| 393 |
+
the full rail β filtering never persists. */}
|
| 394 |
+
<div className="cg-find-view">
|
| 395 |
+
<svg
|
| 396 |
+
className="cg-find-view-icon"
|
| 397 |
+
width="14"
|
| 398 |
+
height="14"
|
| 399 |
+
viewBox="0 0 16 16"
|
| 400 |
+
fill="none"
|
| 401 |
+
aria-hidden="true"
|
| 402 |
+
>
|
| 403 |
+
<circle cx="7" cy="7" r="4.4" stroke="currentColor" strokeWidth="1.35" />
|
| 404 |
+
<path
|
| 405 |
+
d="m10.4 10.4 3.1 3.1"
|
| 406 |
+
stroke="currentColor"
|
| 407 |
+
strokeWidth="1.35"
|
| 408 |
+
strokeLinecap="round"
|
| 409 |
+
/>
|
| 410 |
+
</svg>
|
| 411 |
+
<input
|
| 412 |
+
type="text"
|
| 413 |
+
value={viewQuery}
|
| 414 |
+
placeholder="Find a view"
|
| 415 |
+
aria-label="Find a view"
|
| 416 |
+
onChange={(event) => setViewQuery(event.target.value)}
|
| 417 |
+
onKeyDown={(event) => {
|
| 418 |
+
if (event.key === "Escape") setViewQuery("");
|
| 419 |
+
}}
|
| 420 |
+
/>
|
| 421 |
+
</div>
|
| 422 |
+
|
| 423 |
{creating && (
|
| 424 |
<div className="cg-view-create cg-create-form">
|
| 425 |
<label htmlFor="cg-new-view">
|
|
|
|
| 626 |
</AnchoredOverlay>
|
| 627 |
)}
|
| 628 |
|
| 629 |
+
<div className="cg-view-list cg-views-scroll">
|
| 630 |
{groups.map((group) => {
|
| 631 |
const gid = group.folder?.id ?? null;
|
| 632 |
+
// Item 9 β while a search is live, a folder with no matches is noise, not a target.
|
| 633 |
+
if (viewNeedle && group.folder && group.items.length === 0) return null;
|
| 634 |
const isRoot = gid === null;
|
| 635 |
const shut = gid != null && collapsed.has(gid);
|
| 636 |
return (
|
web/src/customer-grid/cells.ts
CHANGED
|
@@ -46,14 +46,17 @@ export { checkboxOn, dateTimeText, formatDisplay } from "./display";
|
|
| 46 |
* its colour across rows, sessions and users without anyone picking one β and a renamed option
|
| 47 |
* simply gets a new colour rather than inheriting a stale mapping. */
|
| 48 |
const PICK_TINTS = [
|
| 49 |
-
//
|
| 50 |
-
//
|
| 51 |
-
//
|
| 52 |
-
//
|
| 53 |
-
|
| 54 |
-
{ bg: "#
|
| 55 |
-
{ bg: "#
|
| 56 |
-
{ bg: "#
|
|
|
|
|
|
|
|
|
|
| 57 |
];
|
| 58 |
|
| 59 |
export function pickTint(v: string): { bg: string; fg: string } {
|
|
@@ -153,17 +156,30 @@ export function makeCell(field: Field, v: CellValue, editable: boolean): GridCel
|
|
| 153 |
contentAlign: "right",
|
| 154 |
...ro,
|
| 155 |
};
|
| 156 |
-
case "formula":
|
| 157 |
// Computed client-side (formulaEngine.ts) over values ALREADY injected at this key by
|
| 158 |
// CustomerGrid's computedRows. Read-only by nature β the caller passes editable=false.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
return {
|
| 160 |
kind: GridCellKind.Number,
|
| 161 |
-
data: blank ? undefined :
|
| 162 |
-
displayData: blank ? "" : numberText(
|
| 163 |
contentAlign: "right",
|
| 164 |
allowOverlay: false,
|
| 165 |
readonly: true,
|
| 166 |
};
|
|
|
|
| 167 |
case "pct":
|
| 168 |
return {
|
| 169 |
kind: GridCellKind.Number,
|
|
|
|
| 46 |
* its colour across rows, sessions and users without anyone picking one β and a renamed option
|
| 47 |
* simply gets a new colour rather than inheriting a stale mapping. */
|
| 48 |
const PICK_TINTS = [
|
| 49 |
+
// 2026-07-31 (owner item 7): ONE construction β every pill is a `-tint` wash carrying its
|
| 50 |
+
// family's `-deep` ink, the standing brand rule ("pastels are fills; text takes the deep
|
| 51 |
+
// weight"). The old second pass (full pastel + near-black ink) is GONE: it is what read as
|
| 52 |
+
// "black font on a saturated chip". Six families now β the four C1 hues, the brand purple,
|
| 53 |
+
// and a neutral grey β all mirrored from index.css tokens; every pairing measures β₯ 5.0:1.
|
| 54 |
+
{ bg: "#EDF3FD", fg: "#4F6079" }, // blue-tint / LP_BLUE_TEXT
|
| 55 |
+
{ bg: "#EBF6EF", fg: "#35754E" }, // green-tint / green-deep
|
| 56 |
+
{ bg: "#FBF4E0", fg: "#7E6428" }, // yellow-tint/ yellow-deep
|
| 57 |
+
{ bg: "#FCEEEC", fg: "#A3453C" }, // red-tint / red-deep
|
| 58 |
+
{ bg: "#F1EEFB", fg: "#6B57A8" }, // purple-tint/ purple-deep (--lp-purple family)
|
| 59 |
+
{ bg: "#EEF1F4", fg: "#4B5563" }, // neutral wash / slate β the sixth distinct family
|
| 60 |
];
|
| 61 |
|
| 62 |
export function pickTint(v: string): { bg: string; fg: string } {
|
|
|
|
| 156 |
contentAlign: "right",
|
| 157 |
...ro,
|
| 158 |
};
|
| 159 |
+
case "formula": {
|
| 160 |
// Computed client-side (formulaEngine.ts) over values ALREADY injected at this key by
|
| 161 |
// CustomerGrid's computedRows. Read-only by nature β the caller passes editable=false.
|
| 162 |
+
// 2026-07-31 (owner item 2): a formula may now return TEXT (CONCATENATE, &, TEXT(),
|
| 163 |
+
// TRUE/FALSE) β a non-numeric result renders as a text cell, never as NaN.
|
| 164 |
+
const asNum = num(v);
|
| 165 |
+
if (!blank && typeof v === "string" && !Number.isFinite(asNum)) {
|
| 166 |
+
return {
|
| 167 |
+
kind: GridCellKind.Text,
|
| 168 |
+
data: v,
|
| 169 |
+
displayData: v,
|
| 170 |
+
allowOverlay: false,
|
| 171 |
+
readonly: true,
|
| 172 |
+
};
|
| 173 |
+
}
|
| 174 |
return {
|
| 175 |
kind: GridCellKind.Number,
|
| 176 |
+
data: blank ? undefined : asNum,
|
| 177 |
+
displayData: blank ? "" : numberText(asNum, field.format),
|
| 178 |
contentAlign: "right",
|
| 179 |
allowOverlay: false,
|
| 180 |
readonly: true,
|
| 181 |
};
|
| 182 |
+
}
|
| 183 |
case "pct":
|
| 184 |
return {
|
| 185 |
kind: GridCellKind.Number,
|
web/src/customer-grid/formulaEngine.ts
CHANGED
|
@@ -1,40 +1,49 @@
|
|
| 1 |
// ---------------------------------------------------------------------------
|
| 2 |
// customer-grid / formulaEngine.ts
|
| 3 |
-
// Wave-5 I-B
|
| 4 |
-
// no imports from the rest of the grid, so it is testable
|
| 5 |
-
// and portable into the
|
| 6 |
//
|
| 7 |
-
// GRAMMAR (
|
| 8 |
// numbers 1 0.5 .5 1200.75 (no separators, no exponents)
|
|
|
|
|
|
|
| 9 |
// field refs {revenue_ytd} (slug charset [A-Za-z0-9_])
|
| 10 |
-
// arithmetic + - * / ( )
|
| 11 |
-
//
|
| 12 |
-
//
|
| 13 |
-
//
|
| 14 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
//
|
| 16 |
-
// THE ONE RULE,
|
| 17 |
-
//
|
| 18 |
-
//
|
| 19 |
-
//
|
| 20 |
-
// a confident face
|
| 21 |
-
// sql-port]] / [[cg-measure-conditions]]); this engine refuses instead.
|
| 22 |
//
|
| 23 |
-
//
|
| 24 |
-
// -
|
| 25 |
-
// blank
|
| 26 |
-
//
|
| 27 |
-
//
|
| 28 |
-
//
|
| 29 |
-
//
|
| 30 |
-
//
|
| 31 |
-
//
|
| 32 |
-
//
|
| 33 |
-
//
|
| 34 |
-
//
|
| 35 |
-
// -
|
| 36 |
-
//
|
| 37 |
-
//
|
| 38 |
// ---------------------------------------------------------------------------
|
| 39 |
|
| 40 |
/** Source-length cap. The host's structural validation mirrors it; both sides
|
|
@@ -47,28 +56,75 @@ export const MAX_FORMULA_DEPTH = 24;
|
|
| 47 |
|
| 48 |
export type FormulaAst =
|
| 49 |
| { t: "num"; v: number }
|
|
|
|
|
|
|
| 50 |
| { t: "ref"; k: string }
|
| 51 |
| { t: "neg"; e: FormulaAst }
|
| 52 |
-
| { t: "bin"; op: "+" | "-" | "*" | "/"; l: FormulaAst; r: FormulaAst }
|
| 53 |
| { t: "cmp"; op: "<" | "<=" | ">" | ">=" | "=" | "!="; l: FormulaAst; r: FormulaAst }
|
| 54 |
| { t: "call"; fn: FormulaFn; args: FormulaAst[] };
|
| 55 |
|
| 56 |
-
export type FormulaFn =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
const FN_ARITY: Record<FormulaFn, [number, number]> = {
|
| 59 |
ABS: [1, 1],
|
| 60 |
ROUND: [1, 2],
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
IF: [2, 3],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
};
|
| 65 |
|
|
|
|
|
|
|
|
|
|
| 66 |
export type ParseResult =
|
| 67 |
| { ok: true; ast: FormulaAst; refs: string[] }
|
| 68 |
| { ok: false; error: string };
|
| 69 |
|
| 70 |
type Token =
|
| 71 |
| { t: "num"; v: number }
|
|
|
|
| 72 |
| { t: "ref"; k: string }
|
| 73 |
| { t: "ident"; v: string }
|
| 74 |
| { t: "op"; v: string };
|
|
@@ -97,6 +153,27 @@ function tokenize(src: string): Token[] | string {
|
|
| 97 |
i = end + 1;
|
| 98 |
continue;
|
| 99 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
// number: 1 1.5 .5 (a trailing dot β "5." β is refused)
|
| 101 |
if (/[0-9]/.test(c) || (c === "." && /[0-9]/.test(src[i + 1] ?? ""))) {
|
| 102 |
const m = /^(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)/.exec(src.slice(i));
|
|
@@ -107,21 +184,21 @@ function tokenize(src: string): Token[] | string {
|
|
| 107 |
i += m[0].length;
|
| 108 |
continue;
|
| 109 |
}
|
| 110 |
-
// function names
|
| 111 |
if (/[A-Za-z_]/.test(c)) {
|
| 112 |
const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i))!;
|
| 113 |
out.push({ t: "ident", v: m[0] });
|
| 114 |
i += m[0].length;
|
| 115 |
continue;
|
| 116 |
}
|
| 117 |
-
// operators β two-char first
|
| 118 |
const two = src.slice(i, i + 2);
|
| 119 |
-
if (two === "<=" || two === ">=" || two === "!=" || two === "==") {
|
| 120 |
-
out.push({ t: "op", v: two === "==" ? "=" : two });
|
| 121 |
i += 2;
|
| 122 |
continue;
|
| 123 |
}
|
| 124 |
-
if ("+-*/(),<>=".includes(c)) {
|
| 125 |
out.push({ t: "op", v: c });
|
| 126 |
i += 1;
|
| 127 |
continue;
|
|
@@ -133,11 +210,14 @@ function tokenize(src: string): Token[] | string {
|
|
| 133 |
|
| 134 |
/**
|
| 135 |
* Recursive-descent parser. Grammar, lowest binding first:
|
| 136 |
-
* expr :=
|
|
|
|
| 137 |
* additive:= mult ((+|-) mult)*
|
| 138 |
-
* mult :=
|
|
|
|
|
|
|
| 139 |
* unary := "-" unary | primary
|
| 140 |
-
* primary := number | ref | "(" expr ")" | FN "("
|
| 141 |
*/
|
| 142 |
export function parseFormula(src: string): ParseResult {
|
| 143 |
if (typeof src !== "string" || src.trim() === "")
|
|
@@ -164,12 +244,12 @@ export function parseFormula(src: string): ParseResult {
|
|
| 164 |
|
| 165 |
function parseExpr(depth: number): FormulaAst | null {
|
| 166 |
if (depth > MAX_FORMULA_DEPTH) return fail("formula is nested too deeply");
|
| 167 |
-
const left =
|
| 168 |
if (!left) return null;
|
| 169 |
const t = peek();
|
| 170 |
if (t?.t === "op" && CMP_OPS.has(t.v)) {
|
| 171 |
pos += 1;
|
| 172 |
-
const right =
|
| 173 |
if (!right) return null;
|
| 174 |
const again = peek();
|
| 175 |
if (again?.t === "op" && CMP_OPS.has(again.v))
|
|
@@ -179,6 +259,18 @@ export function parseFormula(src: string): ParseResult {
|
|
| 179 |
return left;
|
| 180 |
}
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
function parseAdditive(depth: number): FormulaAst | null {
|
| 183 |
let node = parseMult(depth);
|
| 184 |
if (!node) return null;
|
|
@@ -194,19 +286,32 @@ export function parseFormula(src: string): ParseResult {
|
|
| 194 |
}
|
| 195 |
|
| 196 |
function parseMult(depth: number): FormulaAst | null {
|
| 197 |
-
let node =
|
| 198 |
if (!node) return null;
|
| 199 |
for (;;) {
|
| 200 |
if (isOp("*") || isOp("/")) {
|
| 201 |
const op = (peek() as { v: "*" | "/" }).v;
|
| 202 |
pos += 1;
|
| 203 |
-
const r =
|
| 204 |
if (!r) return null;
|
| 205 |
node = { t: "bin", op, l: node, r };
|
| 206 |
} else return node;
|
| 207 |
}
|
| 208 |
}
|
| 209 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
function parseUnary(depth: number): FormulaAst | null {
|
| 211 |
if (depth > MAX_FORMULA_DEPTH) return fail("formula is nested too deeply");
|
| 212 |
if (isOp("-")) {
|
|
@@ -225,6 +330,10 @@ export function parseFormula(src: string): ParseResult {
|
|
| 225 |
pos += 1;
|
| 226 |
return { t: "num", v: t.v };
|
| 227 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
if (t.t === "ref") {
|
| 229 |
pos += 1;
|
| 230 |
return { t: "ref", k: t.k };
|
|
@@ -238,9 +347,14 @@ export function parseFormula(src: string): ParseResult {
|
|
| 238 |
return inner;
|
| 239 |
}
|
| 240 |
if (t.t === "ident") {
|
| 241 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
if (!(fn in FN_ARITY))
|
| 243 |
-
return fail(`unknown function ${
|
| 244 |
pos += 1;
|
| 245 |
if (!isOp("(")) return fail(`${fn} must be called with parentheses: ${fn}(β¦)`);
|
| 246 |
pos += 1;
|
|
@@ -277,7 +391,7 @@ export function parseFormula(src: string): ParseResult {
|
|
| 277 |
const t = toks[pos];
|
| 278 |
return {
|
| 279 |
ok: false,
|
| 280 |
-
error: `unexpected "${t.t === "op" || t.t === "ident" ? (t as { v: string }).v : t.t === "ref" ? `{${(t as { k: string }).k}}` : String((t as { v:
|
| 281 |
};
|
| 282 |
}
|
| 283 |
return { ok: true, ast, refs: collectRefs(ast) };
|
|
@@ -310,8 +424,15 @@ export function collectRefs(ast: FormulaAst): string[] {
|
|
| 310 |
return out;
|
| 311 |
}
|
| 312 |
|
| 313 |
-
/** number | boolean | null inside the tree; the CELL result is
|
| 314 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
|
| 316 |
/**
|
| 317 |
* A cell value coerced for arithmetic. Strict on purpose:
|
|
@@ -334,10 +455,27 @@ export function toOperand(raw: unknown): number | null {
|
|
| 334 |
}
|
| 335 |
|
| 336 |
function asNumber(v: Value): number | null {
|
| 337 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
}
|
| 339 |
|
| 340 |
-
/** Airtable
|
| 341 |
function roundHalfAway(x: number, p: number): number | null {
|
| 342 |
if (!Number.isInteger(p) || p < -10 || p > 10) return null;
|
| 343 |
const m = Math.pow(10, p);
|
|
@@ -346,20 +484,87 @@ function roundHalfAway(x: number, p: number): number | null {
|
|
| 346 |
return Number.isFinite(out) ? out : null;
|
| 347 |
}
|
| 348 |
|
| 349 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
switch (n.t) {
|
| 351 |
case "num":
|
| 352 |
return n.v;
|
| 353 |
-
case "
|
| 354 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
case "neg": {
|
| 356 |
-
const v = asNumber(
|
| 357 |
return v == null ? null : -v;
|
| 358 |
}
|
| 359 |
case "bin": {
|
| 360 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
if (l == null) return null;
|
| 362 |
-
const r = asNumber(
|
| 363 |
if (r == null) return null;
|
| 364 |
let out: number;
|
| 365 |
switch (n.op) {
|
|
@@ -370,54 +575,196 @@ function evalNode(n: FormulaAst, get: (key: string) => unknown): Value {
|
|
| 370 |
if (r === 0) return null;
|
| 371 |
out = l / r;
|
| 372 |
break;
|
|
|
|
|
|
|
|
|
|
| 373 |
}
|
| 374 |
return Number.isFinite(out) ? out : null;
|
| 375 |
}
|
| 376 |
case "cmp": {
|
| 377 |
-
const
|
| 378 |
-
|
| 379 |
-
const
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
|
|
|
|
|
|
|
|
|
| 388 |
}
|
|
|
|
| 389 |
return null;
|
| 390 |
}
|
| 391 |
case "call": {
|
| 392 |
switch (n.fn) {
|
| 393 |
case "ABS": {
|
| 394 |
-
const v = asNumber(
|
| 395 |
return v == null ? null : Math.abs(v);
|
| 396 |
}
|
| 397 |
-
case "ROUND":
|
| 398 |
-
|
|
|
|
|
|
|
| 399 |
if (v == null) return null;
|
| 400 |
-
const p = n.args.length > 1 ? asNumber(
|
| 401 |
-
if (p == null) return null;
|
| 402 |
-
return roundHalfAway(v, p);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
}
|
| 404 |
-
case "
|
| 405 |
-
|
| 406 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
for (const a of n.args) {
|
| 408 |
-
const v =
|
| 409 |
-
if (v =
|
| 410 |
-
vals.push(v);
|
| 411 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
return n.fn === "MIN" ? Math.min(...vals) : Math.max(...vals);
|
| 413 |
}
|
| 414 |
case "IF": {
|
| 415 |
-
const c =
|
| 416 |
if (typeof c !== "boolean") return null; // no truthiness
|
| 417 |
// LAZY: only the taken branch runs β IF({x} = 0, 0, 1/{x}) is the
|
| 418 |
// guard pattern this exists for.
|
| 419 |
-
if (c) return
|
| 420 |
-
return n.args.length > 2 ?
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
}
|
| 422 |
}
|
| 423 |
return null;
|
|
@@ -427,46 +774,115 @@ function evalNode(n: FormulaAst, get: (key: string) => unknown): Value {
|
|
| 427 |
}
|
| 428 |
}
|
| 429 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
/**
|
| 431 |
-
* Evaluate a parsed formula over one row's values. Returns a FINITE number
|
| 432 |
-
*
|
| 433 |
-
*
|
| 434 |
-
*
|
| 435 |
*/
|
| 436 |
export function evalFormula(
|
| 437 |
ast: FormulaAst,
|
| 438 |
-
get: (key: string) => unknown
|
| 439 |
-
|
|
|
|
| 440 |
try {
|
| 441 |
-
const v = evalNode(ast, get);
|
| 442 |
-
|
|
|
|
|
|
|
|
|
|
| 443 |
} catch {
|
| 444 |
return null;
|
| 445 |
}
|
| 446 |
}
|
| 447 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
/**
|
| 449 |
* Create-form validation: parse + check every ref against the fields that
|
| 450 |
-
* exist.
|
| 451 |
-
*
|
| 452 |
-
*
|
|
|
|
|
|
|
| 453 |
*/
|
| 454 |
export function validateFormula(
|
| 455 |
src: string,
|
| 456 |
knownKeys: ReadonlySet<string>,
|
| 457 |
-
|
|
|
|
| 458 |
): { ok: boolean; error?: string; refs: string[] } {
|
| 459 |
const parsed = parseFormula(src);
|
| 460 |
if (!parsed.ok) return { ok: false, error: parsed.error, refs: [] };
|
| 461 |
for (const k of parsed.refs) {
|
| 462 |
-
if (
|
|
|
|
|
|
|
| 463 |
return {
|
| 464 |
ok: false,
|
| 465 |
-
error:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
refs: parsed.refs,
|
| 467 |
};
|
| 468 |
-
if (!knownKeys.has(k))
|
| 469 |
-
return { ok: false, error: `{${k}} is not a field of this table`, refs: parsed.refs };
|
| 470 |
}
|
| 471 |
return { ok: true, refs: parsed.refs };
|
| 472 |
}
|
|
|
|
| 1 |
// ---------------------------------------------------------------------------
|
| 2 |
// customer-grid / formulaEngine.ts
|
| 3 |
+
// Wave-5 I-B, grown to EXCEL GRADE 2026-07-31 (owner item 2). A pure module:
|
| 4 |
+
// no React, no DOM, no imports from the rest of the grid, so it is testable
|
| 5 |
+
// under plain node and portable into the shell unchanged.
|
| 6 |
//
|
| 7 |
+
// GRAMMAR (v2 β the shared contract; v1 remains a strict subset):
|
| 8 |
// numbers 1 0.5 .5 1200.75 (no separators, no exponents)
|
| 9 |
+
// strings "text" with "" escaping a quote β Excel's own rule
|
| 10 |
+
// booleans TRUE FALSE (bare constants)
|
| 11 |
// field refs {revenue_ytd} (slug charset [A-Za-z0-9_])
|
| 12 |
+
// arithmetic + - * / ^ ( ) (usual precedence; ^ is
|
| 13 |
+
// right-associative and UNARY MINUS BINDS TIGHTER, Excel's
|
| 14 |
+
// famous quirk: -2^2 = 4)
|
| 15 |
+
// text & (concatenation; blank -> "")
|
| 16 |
+
// comparisons < <= > >= = != (== and <> accepted) β numbers with
|
| 17 |
+
// numbers, text with text (case-insensitive, Excel's rule);
|
| 18 |
+
// a mixed or blank comparison is BLANK, never a guess.
|
| 19 |
+
// functions numeric ABS ROUND ROUNDUP ROUNDDOWN INT MOD SQRT POWER
|
| 20 |
+
// EXP LN SUM AVERAGE COUNT COUNTA MIN MAX
|
| 21 |
+
// logic IF AND OR NOT IFERROR ISBLANK
|
| 22 |
+
// text CONCATENATE/CONCAT LEFT RIGHT MID LEN TRIM
|
| 23 |
+
// UPPER LOWER PROPER VALUE TEXT
|
| 24 |
+
// date TODAY YEAR MONTH DAY DAYS (ISO YYYY-MM-DD)
|
| 25 |
//
|
| 26 |
+
// THE ONE RULE, unchanged: **any error β BLANK, never a wrong number.** An
|
| 27 |
+
// unknown ref, a divide-by-zero, a boolean fed to arithmetic, an unreadable
|
| 28 |
+
// date, an unknown TEXT() format β every one yields null, and null propagates
|
| 29 |
+
// through ARITHMETIC. Inventing 0 for a missing value is how a wrong number
|
| 30 |
+
// gets a confident face; this engine refuses instead.
|
|
|
|
| 31 |
//
|
| 32 |
+
// Where v2 deliberately follows EXCEL rather than v1's stricter reading:
|
| 33 |
+
// - THE AGGREGATES SKIP BLANKS. SUM/AVERAGE/COUNT/COUNTA/MIN/MAX ignore
|
| 34 |
+
// blank arguments exactly as Excel ignores empty cells β that selectivity
|
| 35 |
+
// is the entire reason the aggregate forms exist beside `+`. (v1's
|
| 36 |
+
// MIN/MAX-propagate-blank rule is superseded; `+` still propagates.)
|
| 37 |
+
// - TEXT CONTEXT COERCES BLANK TO "". `{a} & {b}` with b blank is a, as in
|
| 38 |
+
// Excel. Text is display, not arithmetic β "" states nothing false.
|
| 39 |
+
// - A BOOLEAN RESULT DISPLAYS AS TRUE/FALSE (v1 blanked it). A comparison
|
| 40 |
+
// the user typed deserves its answer on screen.
|
| 41 |
+
// - FORMULA-OVER-FORMULA IS ALLOWED. Evaluation is topological
|
| 42 |
+
// (`orderFormulas`); a CYCLE refuses at authoring time and every member
|
| 43 |
+
// of one evaluates to blank β never a stale or half-updated number.
|
| 44 |
+
// - IF's condition must still be a real BOOLEAN (no numeric truthiness):
|
| 45 |
+
// this is a financial surface, and 0-means-false is a silent wrong branch.
|
| 46 |
+
// - ROUND stays HALF-AWAY-FROM-ZERO (Excel's convention too).
|
| 47 |
// ---------------------------------------------------------------------------
|
| 48 |
|
| 49 |
/** Source-length cap. The host's structural validation mirrors it; both sides
|
|
|
|
| 56 |
|
| 57 |
export type FormulaAst =
|
| 58 |
| { t: "num"; v: number }
|
| 59 |
+
| { t: "str"; v: string }
|
| 60 |
+
| { t: "bool"; v: boolean }
|
| 61 |
| { t: "ref"; k: string }
|
| 62 |
| { t: "neg"; e: FormulaAst }
|
| 63 |
+
| { t: "bin"; op: "+" | "-" | "*" | "/" | "^" | "&"; l: FormulaAst; r: FormulaAst }
|
| 64 |
| { t: "cmp"; op: "<" | "<=" | ">" | ">=" | "=" | "!="; l: FormulaAst; r: FormulaAst }
|
| 65 |
| { t: "call"; fn: FormulaFn; args: FormulaAst[] };
|
| 66 |
|
| 67 |
+
export type FormulaFn =
|
| 68 |
+
| "ABS" | "ROUND" | "ROUNDUP" | "ROUNDDOWN" | "INT" | "MOD" | "SQRT" | "POWER"
|
| 69 |
+
| "EXP" | "LN" | "SUM" | "AVERAGE" | "COUNT" | "COUNTA" | "MIN" | "MAX"
|
| 70 |
+
| "IF" | "AND" | "OR" | "NOT" | "IFERROR" | "ISBLANK"
|
| 71 |
+
| "CONCATENATE" | "LEFT" | "RIGHT" | "MID" | "LEN" | "TRIM"
|
| 72 |
+
| "UPPER" | "LOWER" | "PROPER" | "VALUE" | "TEXT"
|
| 73 |
+
| "TODAY" | "YEAR" | "MONTH" | "DAY" | "DAYS";
|
| 74 |
+
|
| 75 |
+
const MANY = 30; // variadic cap β bounded, far above any honest use
|
| 76 |
|
| 77 |
const FN_ARITY: Record<FormulaFn, [number, number]> = {
|
| 78 |
ABS: [1, 1],
|
| 79 |
ROUND: [1, 2],
|
| 80 |
+
ROUNDUP: [1, 2],
|
| 81 |
+
ROUNDDOWN: [1, 2],
|
| 82 |
+
INT: [1, 1],
|
| 83 |
+
MOD: [2, 2],
|
| 84 |
+
SQRT: [1, 1],
|
| 85 |
+
POWER: [2, 2],
|
| 86 |
+
EXP: [1, 1],
|
| 87 |
+
LN: [1, 1],
|
| 88 |
+
SUM: [1, MANY],
|
| 89 |
+
AVERAGE: [1, MANY],
|
| 90 |
+
COUNT: [1, MANY],
|
| 91 |
+
COUNTA: [1, MANY],
|
| 92 |
+
MIN: [1, MANY],
|
| 93 |
+
MAX: [1, MANY],
|
| 94 |
IF: [2, 3],
|
| 95 |
+
AND: [1, MANY],
|
| 96 |
+
OR: [1, MANY],
|
| 97 |
+
NOT: [1, 1],
|
| 98 |
+
IFERROR: [2, 2],
|
| 99 |
+
ISBLANK: [1, 1],
|
| 100 |
+
CONCATENATE: [1, MANY],
|
| 101 |
+
LEFT: [1, 2],
|
| 102 |
+
RIGHT: [1, 2],
|
| 103 |
+
MID: [3, 3],
|
| 104 |
+
LEN: [1, 1],
|
| 105 |
+
TRIM: [1, 1],
|
| 106 |
+
UPPER: [1, 1],
|
| 107 |
+
LOWER: [1, 1],
|
| 108 |
+
PROPER: [1, 1],
|
| 109 |
+
VALUE: [1, 1],
|
| 110 |
+
TEXT: [2, 2],
|
| 111 |
+
TODAY: [0, 0],
|
| 112 |
+
YEAR: [1, 1],
|
| 113 |
+
MONTH: [1, 1],
|
| 114 |
+
DAY: [1, 1],
|
| 115 |
+
DAYS: [2, 2],
|
| 116 |
};
|
| 117 |
|
| 118 |
+
/** Excel spells it CONCAT in modern versions; both names, one function. */
|
| 119 |
+
const FN_ALIASES: Record<string, FormulaFn> = { CONCAT: "CONCATENATE" };
|
| 120 |
+
|
| 121 |
export type ParseResult =
|
| 122 |
| { ok: true; ast: FormulaAst; refs: string[] }
|
| 123 |
| { ok: false; error: string };
|
| 124 |
|
| 125 |
type Token =
|
| 126 |
| { t: "num"; v: number }
|
| 127 |
+
| { t: "str"; v: string }
|
| 128 |
| { t: "ref"; k: string }
|
| 129 |
| { t: "ident"; v: string }
|
| 130 |
| { t: "op"; v: string };
|
|
|
|
| 153 |
i = end + 1;
|
| 154 |
continue;
|
| 155 |
}
|
| 156 |
+
// "string" β Excel's own escape: "" inside a string is one quote
|
| 157 |
+
if (c === '"') {
|
| 158 |
+
let j = i + 1;
|
| 159 |
+
let s = "";
|
| 160 |
+
for (;;) {
|
| 161 |
+
if (j >= src.length) return "a text value is missing its closing quote";
|
| 162 |
+
if (src[j] === '"') {
|
| 163 |
+
if (src[j + 1] === '"') {
|
| 164 |
+
s += '"';
|
| 165 |
+
j += 2;
|
| 166 |
+
continue;
|
| 167 |
+
}
|
| 168 |
+
break;
|
| 169 |
+
}
|
| 170 |
+
s += src[j];
|
| 171 |
+
j += 1;
|
| 172 |
+
}
|
| 173 |
+
out.push({ t: "str", v: s });
|
| 174 |
+
i = j + 1;
|
| 175 |
+
continue;
|
| 176 |
+
}
|
| 177 |
// number: 1 1.5 .5 (a trailing dot β "5." β is refused)
|
| 178 |
if (/[0-9]/.test(c) || (c === "." && /[0-9]/.test(src[i + 1] ?? ""))) {
|
| 179 |
const m = /^(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)/.exec(src.slice(i));
|
|
|
|
| 184 |
i += m[0].length;
|
| 185 |
continue;
|
| 186 |
}
|
| 187 |
+
// function names / TRUE / FALSE
|
| 188 |
if (/[A-Za-z_]/.test(c)) {
|
| 189 |
const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i))!;
|
| 190 |
out.push({ t: "ident", v: m[0] });
|
| 191 |
i += m[0].length;
|
| 192 |
continue;
|
| 193 |
}
|
| 194 |
+
// operators β two-char first. `<>` is Excel's not-equal; `==` tolerated as `=`.
|
| 195 |
const two = src.slice(i, i + 2);
|
| 196 |
+
if (two === "<=" || two === ">=" || two === "!=" || two === "==" || two === "<>") {
|
| 197 |
+
out.push({ t: "op", v: two === "==" ? "=" : two === "<>" ? "!=" : two });
|
| 198 |
i += 2;
|
| 199 |
continue;
|
| 200 |
}
|
| 201 |
+
if ("+-*/^&(),<>=".includes(c)) {
|
| 202 |
out.push({ t: "op", v: c });
|
| 203 |
i += 1;
|
| 204 |
continue;
|
|
|
|
| 210 |
|
| 211 |
/**
|
| 212 |
* Recursive-descent parser. Grammar, lowest binding first:
|
| 213 |
+
* expr := concat ((< <= > >= = !=) concat)? -- ONE comparison, no chains
|
| 214 |
+
* concat := additive (& additive)* -- Excel: & binds below + -
|
| 215 |
* additive:= mult ((+|-) mult)*
|
| 216 |
+
* mult := power ((*|/) power)*
|
| 217 |
+
* power := unary (^ power)? -- right-assoc; unary minus
|
| 218 |
+
* inside, so -2^2 = (-2)^2
|
| 219 |
* unary := "-" unary | primary
|
| 220 |
+
* primary := number | string | TRUE | FALSE | ref | "(" expr ")" | FN "(" β¦ ")"
|
| 221 |
*/
|
| 222 |
export function parseFormula(src: string): ParseResult {
|
| 223 |
if (typeof src !== "string" || src.trim() === "")
|
|
|
|
| 244 |
|
| 245 |
function parseExpr(depth: number): FormulaAst | null {
|
| 246 |
if (depth > MAX_FORMULA_DEPTH) return fail("formula is nested too deeply");
|
| 247 |
+
const left = parseConcat(depth);
|
| 248 |
if (!left) return null;
|
| 249 |
const t = peek();
|
| 250 |
if (t?.t === "op" && CMP_OPS.has(t.v)) {
|
| 251 |
pos += 1;
|
| 252 |
+
const right = parseConcat(depth);
|
| 253 |
if (!right) return null;
|
| 254 |
const again = peek();
|
| 255 |
if (again?.t === "op" && CMP_OPS.has(again.v))
|
|
|
|
| 259 |
return left;
|
| 260 |
}
|
| 261 |
|
| 262 |
+
function parseConcat(depth: number): FormulaAst | null {
|
| 263 |
+
let node = parseAdditive(depth);
|
| 264 |
+
if (!node) return null;
|
| 265 |
+
while (isOp("&")) {
|
| 266 |
+
pos += 1;
|
| 267 |
+
const r = parseAdditive(depth);
|
| 268 |
+
if (!r) return null;
|
| 269 |
+
node = { t: "bin", op: "&", l: node, r };
|
| 270 |
+
}
|
| 271 |
+
return node;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
function parseAdditive(depth: number): FormulaAst | null {
|
| 275 |
let node = parseMult(depth);
|
| 276 |
if (!node) return null;
|
|
|
|
| 286 |
}
|
| 287 |
|
| 288 |
function parseMult(depth: number): FormulaAst | null {
|
| 289 |
+
let node = parsePower(depth);
|
| 290 |
if (!node) return null;
|
| 291 |
for (;;) {
|
| 292 |
if (isOp("*") || isOp("/")) {
|
| 293 |
const op = (peek() as { v: "*" | "/" }).v;
|
| 294 |
pos += 1;
|
| 295 |
+
const r = parsePower(depth);
|
| 296 |
if (!r) return null;
|
| 297 |
node = { t: "bin", op, l: node, r };
|
| 298 |
} else return node;
|
| 299 |
}
|
| 300 |
}
|
| 301 |
|
| 302 |
+
function parsePower(depth: number): FormulaAst | null {
|
| 303 |
+
if (depth > MAX_FORMULA_DEPTH) return fail("formula is nested too deeply");
|
| 304 |
+
const base = parseUnary(depth);
|
| 305 |
+
if (!base) return null;
|
| 306 |
+
if (isOp("^")) {
|
| 307 |
+
pos += 1;
|
| 308 |
+
const exp = parsePower(depth + 1); // right-associative: 2^3^2 = 2^(3^2)
|
| 309 |
+
if (!exp) return null;
|
| 310 |
+
return { t: "bin", op: "^", l: base, r: exp };
|
| 311 |
+
}
|
| 312 |
+
return base;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
function parseUnary(depth: number): FormulaAst | null {
|
| 316 |
if (depth > MAX_FORMULA_DEPTH) return fail("formula is nested too deeply");
|
| 317 |
if (isOp("-")) {
|
|
|
|
| 330 |
pos += 1;
|
| 331 |
return { t: "num", v: t.v };
|
| 332 |
}
|
| 333 |
+
if (t.t === "str") {
|
| 334 |
+
pos += 1;
|
| 335 |
+
return { t: "str", v: t.v };
|
| 336 |
+
}
|
| 337 |
if (t.t === "ref") {
|
| 338 |
pos += 1;
|
| 339 |
return { t: "ref", k: t.k };
|
|
|
|
| 347 |
return inner;
|
| 348 |
}
|
| 349 |
if (t.t === "ident") {
|
| 350 |
+
const upper = t.v.toUpperCase();
|
| 351 |
+
if (upper === "TRUE" || upper === "FALSE") {
|
| 352 |
+
pos += 1;
|
| 353 |
+
return { t: "bool", v: upper === "TRUE" };
|
| 354 |
+
}
|
| 355 |
+
const fn = (FN_ALIASES[upper] ?? upper) as FormulaFn;
|
| 356 |
if (!(fn in FN_ARITY))
|
| 357 |
+
return fail(`unknown function ${upper} β see the formula help for what is available`);
|
| 358 |
pos += 1;
|
| 359 |
if (!isOp("(")) return fail(`${fn} must be called with parentheses: ${fn}(β¦)`);
|
| 360 |
pos += 1;
|
|
|
|
| 391 |
const t = toks[pos];
|
| 392 |
return {
|
| 393 |
ok: false,
|
| 394 |
+
error: `unexpected "${t.t === "op" || t.t === "ident" ? (t as { v: string }).v : t.t === "ref" ? `{${(t as { k: string }).k}}` : String((t as { v: unknown }).v)}" after the formula`,
|
| 395 |
};
|
| 396 |
}
|
| 397 |
return { ok: true, ast, refs: collectRefs(ast) };
|
|
|
|
| 424 |
return out;
|
| 425 |
}
|
| 426 |
|
| 427 |
+
/** number | string | boolean | null inside the tree; the CELL result is
|
| 428 |
+
* number | string | null (booleans display as "TRUE"/"FALSE"). */
|
| 429 |
+
type Value = number | string | boolean | null;
|
| 430 |
+
|
| 431 |
+
/** What TODAY() and friends resolve against. `today` is the TENANT's day
|
| 432 |
+
* (payload `today`), never the browser clock. */
|
| 433 |
+
export interface FormulaEnv {
|
| 434 |
+
today?: string;
|
| 435 |
+
}
|
| 436 |
|
| 437 |
/**
|
| 438 |
* A cell value coerced for arithmetic. Strict on purpose:
|
|
|
|
| 455 |
}
|
| 456 |
|
| 457 |
function asNumber(v: Value): number | null {
|
| 458 |
+
if (typeof v === "number" && Number.isFinite(v)) return v;
|
| 459 |
+
if (typeof v === "string") return toOperand(v);
|
| 460 |
+
return null;
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
/** Text context: blank -> "" (Excel), numbers in General format, booleans as
|
| 464 |
+
* their names. Text is display, not arithmetic β "" states nothing false. */
|
| 465 |
+
function asText(v: Value): string {
|
| 466 |
+
if (v == null) return "";
|
| 467 |
+
if (typeof v === "string") return v;
|
| 468 |
+
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
| 469 |
+
return numToText(v);
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
/** Excel's "General": no separators, no trailing zeros, up to ~10 dp. */
|
| 473 |
+
function numToText(n: number): string {
|
| 474 |
+
if (Number.isInteger(n)) return String(n);
|
| 475 |
+
return String(Number(n.toFixed(10)));
|
| 476 |
}
|
| 477 |
|
| 478 |
+
/** Airtable/Excel ROUND: half away from zero, precision may be negative (tens). */
|
| 479 |
function roundHalfAway(x: number, p: number): number | null {
|
| 480 |
if (!Number.isInteger(p) || p < -10 || p > 10) return null;
|
| 481 |
const m = Math.pow(10, p);
|
|
|
|
| 484 |
return Number.isFinite(out) ? out : null;
|
| 485 |
}
|
| 486 |
|
| 487 |
+
/** An ISO date's parts, or null. Accepts "YYYY-MM-DD" and anything that starts
|
| 488 |
+
* with it ("YYYY-MM-DD HH:MM:SS") β the shapes the grid's date fields hold. */
|
| 489 |
+
function dateParts(v: Value): { y: number; m: number; d: number } | null {
|
| 490 |
+
if (typeof v !== "string") return null;
|
| 491 |
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(v.trim());
|
| 492 |
+
if (!m) return null;
|
| 493 |
+
const y = Number(m[1]);
|
| 494 |
+
const mo = Number(m[2]);
|
| 495 |
+
const d = Number(m[3]);
|
| 496 |
+
if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
|
| 497 |
+
return { y, m: mo, d };
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
function dateSerial(p: { y: number; m: number; d: number }): number {
|
| 501 |
+
return Date.UTC(p.y, p.m - 1, p.d) / 86_400_000;
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
/**
|
| 505 |
+
* TEXT(value, format) β the common Excel number formats, fail-closed:
|
| 506 |
+
* "0" "0.00" fixed decimals
|
| 507 |
+
* "#,##0" "#,##0.00" thousands separators
|
| 508 |
+
* "$#,##0.00" β¦ a literal currency prefix
|
| 509 |
+
* "0%" "0.0%" percent of 1 (0.42 -> "42%")
|
| 510 |
+
* An unrecognised format is BLANK, never a guess at what it meant.
|
| 511 |
+
*/
|
| 512 |
+
function textFormat(n: number, fmt: string): string | null {
|
| 513 |
+
const m = /^(\$?)(#,##0|0)(?:\.(0+))?(%?)$/.exec(fmt.trim());
|
| 514 |
+
if (!m) return null;
|
| 515 |
+
const [, cur, intPart, decimals, pct] = m;
|
| 516 |
+
let x = n;
|
| 517 |
+
if (pct === "%") x *= 100;
|
| 518 |
+
const dp = decimals ? decimals.length : 0;
|
| 519 |
+
const neg = x < 0 || Object.is(x, -0) ? "-" : "";
|
| 520 |
+
const fixed = Math.abs(x).toFixed(dp);
|
| 521 |
+
let [ints, frac] = fixed.split(".");
|
| 522 |
+
if (intPart === "#,##0") ints = ints.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
| 523 |
+
return neg + cur + ints + (dp ? "." + frac : "") + (pct === "%" ? "%" : "");
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
/** The aggregate forms: Excel-style, blanks SKIPPED (that selectivity is why
|
| 527 |
+
* they exist beside `+`). Returns the numeric args that resolved. */
|
| 528 |
+
function numericArgs(nodes: FormulaAst[], ev: (n: FormulaAst) => Value): number[] {
|
| 529 |
+
const out: number[] = [];
|
| 530 |
+
for (const a of nodes) {
|
| 531 |
+
const v = ev(a);
|
| 532 |
+
if (v == null) continue;
|
| 533 |
+
const n = asNumber(v);
|
| 534 |
+
if (n != null) out.push(n);
|
| 535 |
+
}
|
| 536 |
+
return out;
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
function evalNode(n: FormulaAst, get: (key: string) => unknown, env: FormulaEnv): Value {
|
| 540 |
+
const ev = (x: FormulaAst): Value => evalNode(x, get, env);
|
| 541 |
switch (n.t) {
|
| 542 |
case "num":
|
| 543 |
return n.v;
|
| 544 |
+
case "str":
|
| 545 |
+
return n.v;
|
| 546 |
+
case "bool":
|
| 547 |
+
return n.v;
|
| 548 |
+
case "ref": {
|
| 549 |
+
const raw = get(n.k);
|
| 550 |
+
// Text stays text (LEFT/&/comparisons need it); numbers stay numbers.
|
| 551 |
+
if (typeof raw === "string") return raw;
|
| 552 |
+
if (typeof raw === "number") return Number.isFinite(raw) ? raw : null;
|
| 553 |
+
if (typeof raw === "boolean") return null; // a checkbox is not a value here
|
| 554 |
+
return null;
|
| 555 |
+
}
|
| 556 |
case "neg": {
|
| 557 |
+
const v = asNumber(ev(n.e));
|
| 558 |
return v == null ? null : -v;
|
| 559 |
}
|
| 560 |
case "bin": {
|
| 561 |
+
if (n.op === "&") {
|
| 562 |
+
// Concatenation is TEXT context: blanks become "", nothing errors.
|
| 563 |
+
return asText(ev(n.l)) + asText(ev(n.r));
|
| 564 |
+
}
|
| 565 |
+
const l = asNumber(ev(n.l));
|
| 566 |
if (l == null) return null;
|
| 567 |
+
const r = asNumber(ev(n.r));
|
| 568 |
if (r == null) return null;
|
| 569 |
let out: number;
|
| 570 |
switch (n.op) {
|
|
|
|
| 575 |
if (r === 0) return null;
|
| 576 |
out = l / r;
|
| 577 |
break;
|
| 578 |
+
case "^":
|
| 579 |
+
out = Math.pow(l, r);
|
| 580 |
+
break;
|
| 581 |
}
|
| 582 |
return Number.isFinite(out) ? out : null;
|
| 583 |
}
|
| 584 |
case "cmp": {
|
| 585 |
+
const lv = ev(n.l);
|
| 586 |
+
const rv = ev(n.r);
|
| 587 |
+
const ln = typeof lv === "number" ? lv : null;
|
| 588 |
+
const rn = typeof rv === "number" ? rv : null;
|
| 589 |
+
// numbers with numbers (numeric strings count as numbers, Excel's coercion)β¦
|
| 590 |
+
const lAsN = ln ?? (typeof lv === "string" ? toOperand(lv) : null);
|
| 591 |
+
const rAsN = rn ?? (typeof rv === "string" ? toOperand(rv) : null);
|
| 592 |
+
if (lAsN != null && rAsN != null) return cmpNums(n.op, lAsN, rAsN);
|
| 593 |
+
// β¦text with text, case-insensitively (Excel's rule)β¦
|
| 594 |
+
if (typeof lv === "string" && typeof rv === "string") {
|
| 595 |
+
const a = lv.trim().toLowerCase();
|
| 596 |
+
const b = rv.trim().toLowerCase();
|
| 597 |
+
if (a === "" || b === "") return null; // a blank comparison is not an answer
|
| 598 |
+
return cmpNums(n.op, a < b ? -1 : a > b ? 1 : 0, 0);
|
| 599 |
}
|
| 600 |
+
// β¦anything mixed or blank is BLANK, never a guess.
|
| 601 |
return null;
|
| 602 |
}
|
| 603 |
case "call": {
|
| 604 |
switch (n.fn) {
|
| 605 |
case "ABS": {
|
| 606 |
+
const v = asNumber(ev(n.args[0]));
|
| 607 |
return v == null ? null : Math.abs(v);
|
| 608 |
}
|
| 609 |
+
case "ROUND":
|
| 610 |
+
case "ROUNDUP":
|
| 611 |
+
case "ROUNDDOWN": {
|
| 612 |
+
const v = asNumber(ev(n.args[0]));
|
| 613 |
if (v == null) return null;
|
| 614 |
+
const p = n.args.length > 1 ? asNumber(ev(n.args[1])) : 0;
|
| 615 |
+
if (p == null || !Number.isInteger(p) || p < -10 || p > 10) return null;
|
| 616 |
+
if (n.fn === "ROUND") return roundHalfAway(v, p);
|
| 617 |
+
const m = Math.pow(10, p);
|
| 618 |
+
const scaled = v * m;
|
| 619 |
+
const r = n.fn === "ROUNDUP"
|
| 620 |
+
? Math.sign(scaled) * Math.ceil(Math.abs(scaled))
|
| 621 |
+
: Math.sign(scaled) * Math.floor(Math.abs(scaled));
|
| 622 |
+
const out = r / m;
|
| 623 |
+
return Number.isFinite(out) ? out : null;
|
| 624 |
}
|
| 625 |
+
case "INT": {
|
| 626 |
+
const v = asNumber(ev(n.args[0]));
|
| 627 |
+
return v == null ? null : Math.floor(v); // Excel INT floors toward -β
|
| 628 |
+
}
|
| 629 |
+
case "MOD": {
|
| 630 |
+
const a = asNumber(ev(n.args[0]));
|
| 631 |
+
const b = asNumber(ev(n.args[1]));
|
| 632 |
+
if (a == null || b == null || b === 0) return null;
|
| 633 |
+
return a - b * Math.floor(a / b); // Excel: sign follows the divisor
|
| 634 |
+
}
|
| 635 |
+
case "SQRT": {
|
| 636 |
+
const v = asNumber(ev(n.args[0]));
|
| 637 |
+
return v == null || v < 0 ? null : Math.sqrt(v);
|
| 638 |
+
}
|
| 639 |
+
case "POWER": {
|
| 640 |
+
const a = asNumber(ev(n.args[0]));
|
| 641 |
+
const b = asNumber(ev(n.args[1]));
|
| 642 |
+
if (a == null || b == null) return null;
|
| 643 |
+
const out = Math.pow(a, b);
|
| 644 |
+
return Number.isFinite(out) ? out : null;
|
| 645 |
+
}
|
| 646 |
+
case "EXP": {
|
| 647 |
+
const v = asNumber(ev(n.args[0]));
|
| 648 |
+
if (v == null) return null;
|
| 649 |
+
const out = Math.exp(v);
|
| 650 |
+
return Number.isFinite(out) ? out : null;
|
| 651 |
+
}
|
| 652 |
+
case "LN": {
|
| 653 |
+
const v = asNumber(ev(n.args[0]));
|
| 654 |
+
return v == null || v <= 0 ? null : Math.log(v);
|
| 655 |
+
}
|
| 656 |
+
case "SUM": {
|
| 657 |
+
const vals = numericArgs(n.args, ev);
|
| 658 |
+
return vals.reduce((a, b) => a + b, 0);
|
| 659 |
+
}
|
| 660 |
+
case "AVERAGE": {
|
| 661 |
+
const vals = numericArgs(n.args, ev);
|
| 662 |
+
return vals.length === 0 ? null : vals.reduce((a, b) => a + b, 0) / vals.length;
|
| 663 |
+
}
|
| 664 |
+
case "COUNT":
|
| 665 |
+
return numericArgs(n.args, ev).length;
|
| 666 |
+
case "COUNTA": {
|
| 667 |
+
let c = 0;
|
| 668 |
for (const a of n.args) {
|
| 669 |
+
const v = ev(a);
|
| 670 |
+
if (v != null && v !== "") c += 1;
|
|
|
|
| 671 |
}
|
| 672 |
+
return c;
|
| 673 |
+
}
|
| 674 |
+
case "MIN":
|
| 675 |
+
case "MAX": {
|
| 676 |
+
const vals = numericArgs(n.args, ev);
|
| 677 |
+
if (vals.length === 0) return null;
|
| 678 |
return n.fn === "MIN" ? Math.min(...vals) : Math.max(...vals);
|
| 679 |
}
|
| 680 |
case "IF": {
|
| 681 |
+
const c = ev(n.args[0]);
|
| 682 |
if (typeof c !== "boolean") return null; // no truthiness
|
| 683 |
// LAZY: only the taken branch runs β IF({x} = 0, 0, 1/{x}) is the
|
| 684 |
// guard pattern this exists for.
|
| 685 |
+
if (c) return ev(n.args[1]);
|
| 686 |
+
return n.args.length > 2 ? ev(n.args[2]) : null;
|
| 687 |
+
}
|
| 688 |
+
case "AND":
|
| 689 |
+
case "OR": {
|
| 690 |
+
// Strict booleans, evaluated lazily left to right (Excel short-circuits too).
|
| 691 |
+
for (const a of n.args) {
|
| 692 |
+
const v = ev(a);
|
| 693 |
+
if (typeof v !== "boolean") return null;
|
| 694 |
+
if (n.fn === "AND" && !v) return false;
|
| 695 |
+
if (n.fn === "OR" && v) return true;
|
| 696 |
+
}
|
| 697 |
+
return n.fn === "AND";
|
| 698 |
+
}
|
| 699 |
+
case "NOT": {
|
| 700 |
+
const v = ev(n.args[0]);
|
| 701 |
+
return typeof v === "boolean" ? !v : null;
|
| 702 |
+
}
|
| 703 |
+
case "IFERROR": {
|
| 704 |
+
// Our errors ARE blanks (the one rule), so IFERROR is the blank-coalesce.
|
| 705 |
+
const v = ev(n.args[0]);
|
| 706 |
+
return v == null ? ev(n.args[1]) : v;
|
| 707 |
+
}
|
| 708 |
+
case "ISBLANK": {
|
| 709 |
+
const v = ev(n.args[0]);
|
| 710 |
+
return v == null || v === "";
|
| 711 |
+
}
|
| 712 |
+
case "CONCATENATE":
|
| 713 |
+
return n.args.map((a) => asText(ev(a))).join("");
|
| 714 |
+
case "LEFT":
|
| 715 |
+
case "RIGHT": {
|
| 716 |
+
const s = asText(ev(n.args[0]));
|
| 717 |
+
const k = n.args.length > 1 ? asNumber(ev(n.args[1])) : 1;
|
| 718 |
+
if (k == null || !Number.isInteger(k) || k < 0) return null;
|
| 719 |
+
return n.fn === "LEFT" ? s.slice(0, k) : k === 0 ? "" : s.slice(-k);
|
| 720 |
+
}
|
| 721 |
+
case "MID": {
|
| 722 |
+
const s = asText(ev(n.args[0]));
|
| 723 |
+
const start = asNumber(ev(n.args[1]));
|
| 724 |
+
const len = asNumber(ev(n.args[2]));
|
| 725 |
+
if (start == null || len == null) return null;
|
| 726 |
+
if (!Number.isInteger(start) || !Number.isInteger(len) || start < 1 || len < 0)
|
| 727 |
+
return null; // Excel: MID is 1-based and refuses a 0 start
|
| 728 |
+
return s.slice(start - 1, start - 1 + len);
|
| 729 |
+
}
|
| 730 |
+
case "LEN":
|
| 731 |
+
return asText(ev(n.args[0])).length;
|
| 732 |
+
case "TRIM":
|
| 733 |
+
return asText(ev(n.args[0])).replace(/\s+/g, " ").trim();
|
| 734 |
+
case "UPPER":
|
| 735 |
+
return asText(ev(n.args[0])).toUpperCase();
|
| 736 |
+
case "LOWER":
|
| 737 |
+
return asText(ev(n.args[0])).toLowerCase();
|
| 738 |
+
case "PROPER":
|
| 739 |
+
return asText(ev(n.args[0])).replace(
|
| 740 |
+
/[A-Za-z]+/g,
|
| 741 |
+
(w) => w[0].toUpperCase() + w.slice(1).toLowerCase()
|
| 742 |
+
);
|
| 743 |
+
case "VALUE": {
|
| 744 |
+
const v = ev(n.args[0]);
|
| 745 |
+
return asNumber(v);
|
| 746 |
+
}
|
| 747 |
+
case "TEXT": {
|
| 748 |
+
const v = asNumber(ev(n.args[0]));
|
| 749 |
+
const fmt = ev(n.args[1]);
|
| 750 |
+
if (v == null || typeof fmt !== "string") return null;
|
| 751 |
+
return textFormat(v, fmt);
|
| 752 |
+
}
|
| 753 |
+
case "TODAY":
|
| 754 |
+
return typeof env.today === "string" && dateParts(env.today) ? env.today : null;
|
| 755 |
+
case "YEAR":
|
| 756 |
+
case "MONTH":
|
| 757 |
+
case "DAY": {
|
| 758 |
+
const p = dateParts(ev(n.args[0]));
|
| 759 |
+
if (!p) return null;
|
| 760 |
+
return n.fn === "YEAR" ? p.y : n.fn === "MONTH" ? p.m : p.d;
|
| 761 |
+
}
|
| 762 |
+
case "DAYS": {
|
| 763 |
+
// Excel argument order: DAYS(end, start).
|
| 764 |
+
const end = dateParts(ev(n.args[0]));
|
| 765 |
+
const start = dateParts(ev(n.args[1]));
|
| 766 |
+
if (!end || !start) return null;
|
| 767 |
+
return Math.round(dateSerial(end) - dateSerial(start));
|
| 768 |
}
|
| 769 |
}
|
| 770 |
return null;
|
|
|
|
| 774 |
}
|
| 775 |
}
|
| 776 |
|
| 777 |
+
function cmpNums(op: "<" | "<=" | ">" | ">=" | "=" | "!=", l: number, r: number): boolean {
|
| 778 |
+
switch (op) {
|
| 779 |
+
case "<": return l < r;
|
| 780 |
+
case "<=": return l <= r;
|
| 781 |
+
case ">": return l > r;
|
| 782 |
+
case ">=": return l >= r;
|
| 783 |
+
case "=": return l === r;
|
| 784 |
+
case "!=": return l !== r;
|
| 785 |
+
}
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
/**
|
| 789 |
+
* Evaluate a parsed formula over one row's values. Returns a FINITE number, a
|
| 790 |
+
* STRING (text results, and booleans as "TRUE"/"FALSE"), or null (blank).
|
| 791 |
+
* NEVER throws, whatever the inputs: the belt around the whole engine, because
|
| 792 |
+
* a cell renderer that throws takes the grid with it.
|
| 793 |
*/
|
| 794 |
export function evalFormula(
|
| 795 |
ast: FormulaAst,
|
| 796 |
+
get: (key: string) => unknown,
|
| 797 |
+
env: FormulaEnv = {}
|
| 798 |
+
): number | string | null {
|
| 799 |
try {
|
| 800 |
+
const v = evalNode(ast, get, env);
|
| 801 |
+
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
| 802 |
+
if (typeof v === "string") return v;
|
| 803 |
+
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
| 804 |
+
return null;
|
| 805 |
} catch {
|
| 806 |
return null;
|
| 807 |
}
|
| 808 |
}
|
| 809 |
|
| 810 |
+
/**
|
| 811 |
+
* Dependency order for FORMULA-OVER-FORMULA (2026-07-31). Given every formula
|
| 812 |
+
* field's source by key, returns the keys in an evaluation order where a
|
| 813 |
+
* formula runs AFTER the formulas it references, plus the set caught in a
|
| 814 |
+
* CYCLE β those evaluate to blank (their members never run), never to a stale
|
| 815 |
+
* or half-updated number. A formula depending on a cyclic one still runs; the
|
| 816 |
+
* cyclic ref simply reads blank.
|
| 817 |
+
*/
|
| 818 |
+
export function orderFormulas(sources: ReadonlyMap<string, string>): {
|
| 819 |
+
order: string[];
|
| 820 |
+
cyclic: Set<string>;
|
| 821 |
+
} {
|
| 822 |
+
const deps = new Map<string, string[]>();
|
| 823 |
+
for (const [key, src] of sources) {
|
| 824 |
+
const p = parseFormula(src);
|
| 825 |
+
deps.set(key, p.ok ? p.refs.filter((r) => sources.has(r) && r !== key) : []);
|
| 826 |
+
}
|
| 827 |
+
const order: string[] = [];
|
| 828 |
+
const state = new Map<string, 0 | 1 | 2>(); // 0 visiting-guard absent, 1 = on stack, 2 = done
|
| 829 |
+
const cyclic = new Set<string>();
|
| 830 |
+
const visit = (key: string, stack: string[]): boolean => {
|
| 831 |
+
const s = state.get(key);
|
| 832 |
+
if (s === 2) return !cyclic.has(key);
|
| 833 |
+
if (s === 1) {
|
| 834 |
+
// Everything from the first appearance of `key` on the stack is the cycle.
|
| 835 |
+
for (let i = stack.lastIndexOf(key); i < stack.length; i += 1) cyclic.add(stack[i]);
|
| 836 |
+
return false;
|
| 837 |
+
}
|
| 838 |
+
state.set(key, 1);
|
| 839 |
+
stack.push(key);
|
| 840 |
+
for (const d of deps.get(key) ?? []) visit(d, stack);
|
| 841 |
+
stack.pop();
|
| 842 |
+
state.set(key, 2);
|
| 843 |
+
if (!cyclic.has(key)) order.push(key);
|
| 844 |
+
return !cyclic.has(key);
|
| 845 |
+
};
|
| 846 |
+
for (const key of sources.keys()) visit(key, []);
|
| 847 |
+
return { order, cyclic };
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
/**
|
| 851 |
* Create-form validation: parse + check every ref against the fields that
|
| 852 |
+
* exist. Referencing ANOTHER formula is allowed (evaluation is topological);
|
| 853 |
+
* what is refused is a CYCLE β including the self-reference, its smallest
|
| 854 |
+
* case. `formulaSources` carries every formula field's source so the check
|
| 855 |
+
* can walk the graph; `selfKey` names the field being edited (absent on
|
| 856 |
+
* create, where no cycle is possible yet).
|
| 857 |
*/
|
| 858 |
export function validateFormula(
|
| 859 |
src: string,
|
| 860 |
knownKeys: ReadonlySet<string>,
|
| 861 |
+
formulaSources: ReadonlyMap<string, string>,
|
| 862 |
+
selfKey?: string
|
| 863 |
): { ok: boolean; error?: string; refs: string[] } {
|
| 864 |
const parsed = parseFormula(src);
|
| 865 |
if (!parsed.ok) return { ok: false, error: parsed.error, refs: [] };
|
| 866 |
for (const k of parsed.refs) {
|
| 867 |
+
if (!knownKeys.has(k) && !formulaSources.has(k))
|
| 868 |
+
return { ok: false, error: `{${k}} is not a field of this table`, refs: parsed.refs };
|
| 869 |
+
if (selfKey && k === selfKey)
|
| 870 |
return {
|
| 871 |
ok: false,
|
| 872 |
+
error: "a formula cannot reference itself",
|
| 873 |
+
refs: parsed.refs,
|
| 874 |
+
};
|
| 875 |
+
}
|
| 876 |
+
if (selfKey) {
|
| 877 |
+
const all = new Map(formulaSources);
|
| 878 |
+
all.set(selfKey, src);
|
| 879 |
+
const { cyclic } = orderFormulas(all);
|
| 880 |
+
if (cyclic.has(selfKey))
|
| 881 |
+
return {
|
| 882 |
+
ok: false,
|
| 883 |
+
error: "this formula would create a loop between formulas",
|
| 884 |
refs: parsed.refs,
|
| 885 |
};
|
|
|
|
|
|
|
| 886 |
}
|
| 887 |
return { ok: true, refs: parsed.refs };
|
| 888 |
}
|
web/src/customer-grid/types.ts
CHANGED
|
@@ -1241,6 +1241,19 @@ export interface GridWorkspace {
|
|
| 1241 |
* everything created is global, exactly today's behavior.
|
| 1242 |
*/
|
| 1243 |
scopeChoice?: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1244 |
}
|
| 1245 |
|
| 1246 |
/**
|
|
@@ -1361,6 +1374,9 @@ export function tableMode(counts: ScopeCounts | undefined): TableMode {
|
|
| 1361 |
|
| 1362 |
export type HostEvent =
|
| 1363 |
| { id: string; type: "view_upsert"; view: SavedView }
|
|
|
|
|
|
|
|
|
|
| 1364 |
/**
|
| 1365 |
* "Add to list" (owner item 6, 2026-07-26): push the customers a VIEW currently matches into
|
| 1366 |
* a Cohort β a fixed, hand-curated set that does NOT re-populate as the data moves.
|
|
|
|
| 1241 |
* everything created is global, exactly today's behavior.
|
| 1242 |
*/
|
| 1243 |
scopeChoice?: boolean;
|
| 1244 |
+
/**
|
| 1245 |
+
* 2026-07-31 (owner item 1) β the STANDALONE measure channel. The embed receives these as
|
| 1246 |
+
* top-level render args; over HTTP they ride `/api/v1/workspace` (re-read after every
|
| 1247 |
+
* durable write), and `useCustomerData` lifts them into the payload slots the grid already
|
| 1248 |
+
* reads. `derived` is keyed by String(pid) β JSON object keys β and carries the cohort
|
| 1249 |
+
* column + measure-column cells to merge over the rows.
|
| 1250 |
+
*/
|
| 1251 |
+
measures?: Measure[];
|
| 1252 |
+
measureSets?: Record<string, number[]>;
|
| 1253 |
+
derived?: Record<string, Record<string, string | number | null>>;
|
| 1254 |
+
viewer?: Viewer;
|
| 1255 |
+
userOptions?: string[];
|
| 1256 |
+
overlays?: Record<string, Record<string, string>>;
|
| 1257 |
}
|
| 1258 |
|
| 1259 |
/**
|
|
|
|
| 1374 |
|
| 1375 |
export type HostEvent =
|
| 1376 |
| { id: string; type: "view_upsert"; view: SavedView }
|
| 1377 |
+
/** Owner item 3 (2026-07-31): the user OPENED this view β presentation state the host
|
| 1378 |
+
* remembers so a fresh browser resumes there. Never a re-render; never authorisation. */
|
| 1379 |
+
| { id: string; type: "view_select"; viewId: string }
|
| 1380 |
/**
|
| 1381 |
* "Add to list" (owner item 6, 2026-07-26): push the customers a VIEW currently matches into
|
| 1382 |
* a Cohort β a fixed, hand-curated set that does NOT re-populate as the data moves.
|
web/src/customer-grid/useCustomerData.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
| 24 |
|
| 25 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 26 |
import type { Dispatch, SetStateAction } from "react";
|
| 27 |
-
import type { CustomersPayload, Field, Row } from "./types";
|
| 28 |
import { fetchCustomers, fetchWorkspace, patchCustomer, setSurfaceScope } from "./apiBridge";
|
| 29 |
import { WORKSPACE_STALE_EVENT } from "../apiContract";
|
| 30 |
import type { SurfaceScope } from "./apiBridge";
|
|
@@ -53,6 +53,30 @@ function readHostPayload(): CustomersPayload | null {
|
|
| 53 |
: null;
|
| 54 |
}
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
function readInitialPayload(): CustomersPayload | null {
|
| 57 |
return readHostPayload() ?? readEmbed();
|
| 58 |
}
|
|
@@ -186,7 +210,7 @@ export function useCustomerData(scope: SurfaceScope = "customer"): CustomerData
|
|
| 186 |
async function load() {
|
| 187 |
const [payload, workspace] = await Promise.all([fetchCustomers(), fetchWorkspace(scope)]);
|
| 188 |
if (cancelled || !payload) return;
|
| 189 |
-
setData(workspace ?
|
| 190 |
}
|
| 191 |
|
| 192 |
load();
|
|
@@ -195,11 +219,13 @@ export function useCustomerData(scope: SurfaceScope = "customer"): CustomerData
|
|
| 195 |
// left panel agrees with what the user just did. Only the WORKSPACE is re-read,
|
| 196 |
// never `/customers`: the rows come from a 15-minute-cached Odoo pull and no grid
|
| 197 |
// event can change an Odoo column (it is read-only), so refetching them would buy
|
| 198 |
-
// nothing and cost the expensive call.
|
|
|
|
|
|
|
| 199 |
async function reread() {
|
| 200 |
const ws = await fetchWorkspace(scope);
|
| 201 |
if (cancelled || !ws) return; // absent stays absent β never blank a live panel
|
| 202 |
-
setData((prev) => (prev ?
|
| 203 |
}
|
| 204 |
const onStale = () => { void reread(); };
|
| 205 |
if (typeof window !== "undefined") window.addEventListener(WORKSPACE_STALE_EVENT, onStale);
|
|
|
|
| 24 |
|
| 25 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 26 |
import type { Dispatch, SetStateAction } from "react";
|
| 27 |
+
import type { CustomersPayload, Field, GridWorkspace, Row } from "./types";
|
| 28 |
import { fetchCustomers, fetchWorkspace, patchCustomer, setSurfaceScope } from "./apiBridge";
|
| 29 |
import { WORKSPACE_STALE_EVENT } from "../apiContract";
|
| 30 |
import type { SurfaceScope } from "./apiBridge";
|
|
|
|
| 53 |
: null;
|
| 54 |
}
|
| 55 |
|
| 56 |
+
/**
|
| 57 |
+
* 2026-07-31 (owner item 1) β the STANDALONE measure channel. In the embed the host passes
|
| 58 |
+
* `measures`/`measureSets`/`viewer`/`userOptions` as top-level payload keys and merges derived
|
| 59 |
+
* cells (cohort column + measure columns) into the rows before render. Over HTTP those ride
|
| 60 |
+
* `/api/v1/workspace` β the cheap call a durable write re-reads β so this lift is what makes
|
| 61 |
+
* a measure column populate, a measure condition resolve, and the assignee picker fill,
|
| 62 |
+
* without refetching the heavy pool. `derived` is keyed by String(pid) (JSON object keys).
|
| 63 |
+
*/
|
| 64 |
+
function withWorkspace(payload: CustomersPayload, ws: GridWorkspace): CustomersPayload {
|
| 65 |
+
const out: CustomersPayload = { ...payload, workspace: ws };
|
| 66 |
+
if (Array.isArray(ws.measures)) out.measures = ws.measures;
|
| 67 |
+
if (ws.measureSets && typeof ws.measureSets === "object") out.measureSets = ws.measureSets;
|
| 68 |
+
if (ws.viewer && typeof ws.viewer.name === "string") out.viewer = ws.viewer;
|
| 69 |
+
if (Array.isArray(ws.userOptions)) out.userOptions = ws.userOptions;
|
| 70 |
+
const derived = ws.derived;
|
| 71 |
+
if (derived && typeof derived === "object" && Object.keys(derived).length > 0) {
|
| 72 |
+
out.rows = payload.rows.map((r) => {
|
| 73 |
+
const cells = derived[String(r.pid)];
|
| 74 |
+
return cells ? { ...r, ...cells } : r;
|
| 75 |
+
});
|
| 76 |
+
}
|
| 77 |
+
return out;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
function readInitialPayload(): CustomersPayload | null {
|
| 81 |
return readHostPayload() ?? readEmbed();
|
| 82 |
}
|
|
|
|
| 210 |
async function load() {
|
| 211 |
const [payload, workspace] = await Promise.all([fetchCustomers(), fetchWorkspace(scope)]);
|
| 212 |
if (cancelled || !payload) return;
|
| 213 |
+
setData(workspace ? withWorkspace(payload, workspace) : payload);
|
| 214 |
}
|
| 215 |
|
| 216 |
load();
|
|
|
|
| 219 |
// left panel agrees with what the user just did. Only the WORKSPACE is re-read,
|
| 220 |
// never `/customers`: the rows come from a 15-minute-cached Odoo pull and no grid
|
| 221 |
// event can change an Odoo column (it is read-only), so refetching them would buy
|
| 222 |
+
// nothing and cost the expensive call. The measure channel rides the workspace
|
| 223 |
+
// (see `withWorkspace`), so a NEW measure column's values arrive on this cheap
|
| 224 |
+
// re-read too β no pool refetch.
|
| 225 |
async function reread() {
|
| 226 |
const ws = await fetchWorkspace(scope);
|
| 227 |
if (cancelled || !ws) return; // absent stays absent β never blank a live panel
|
| 228 |
+
setData((prev) => (prev ? withWorkspace(prev, ws) : prev));
|
| 229 |
}
|
| 230 |
const onStale = () => { void reread(); };
|
| 231 |
if (typeof window !== "undefined") window.addEventListener(WORKSPACE_STALE_EVENT, onStale);
|
web/src/customer-grid/useGridSelection.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface GridSelectionApi {
|
|
| 29 |
* "N selected Β· Add to cohort" bar serves the map with no second selection
|
| 30 |
* model and no second cohort picker to keep in sync. */
|
| 31 |
selectPids: (pids: number[], mode: "replace" | "add") => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
clearSelection: () => void;
|
| 33 |
}
|
| 34 |
|
|
@@ -89,10 +95,28 @@ export function useGridSelection(
|
|
| 89 |
});
|
| 90 |
}, []);
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
const clearSelection = useCallback(() => {
|
| 93 |
setSelectedPids(new Set());
|
| 94 |
setCurrent(undefined);
|
| 95 |
}, []);
|
| 96 |
|
| 97 |
-
return { gridSelection, selectedPids, onGridSelectionChange, selectPids,
|
|
|
|
| 98 |
}
|
|
|
|
| 29 |
* "N selected Β· Add to cohort" bar serves the map with no second selection
|
| 30 |
* model and no second cohort picker to keep in sync. */
|
| 31 |
selectPids: (pids: number[], mode: "replace" | "add") => void;
|
| 32 |
+
/** Owner item 6 (2026-07-31) β clicking the identity cell ticks the row's checkbox: one
|
| 33 |
+
* pid in or out of the same pid-anchored set the markers write. */
|
| 34 |
+
togglePid: (pid: number) => void;
|
| 35 |
+
/** Owner item 5 (2026-07-31) β Excel-grade Enter navigation: set the ACTIVE cell
|
| 36 |
+
* programmatically (clamped by the caller). Positional and ephemeral, like `current`. */
|
| 37 |
+
setActiveCell: (col: number, row: number) => void;
|
| 38 |
clearSelection: () => void;
|
| 39 |
}
|
| 40 |
|
|
|
|
| 95 |
});
|
| 96 |
}, []);
|
| 97 |
|
| 98 |
+
const togglePid = useCallback((pid: number) => {
|
| 99 |
+
setSelectedPids((prev) => {
|
| 100 |
+
const next = new Set(prev);
|
| 101 |
+
if (next.has(pid)) next.delete(pid);
|
| 102 |
+
else next.add(pid);
|
| 103 |
+
return next;
|
| 104 |
+
});
|
| 105 |
+
}, []);
|
| 106 |
+
|
| 107 |
+
const setActiveCell = useCallback((col: number, row: number) => {
|
| 108 |
+
setCurrent({
|
| 109 |
+
cell: [col, row],
|
| 110 |
+
range: { x: col, y: row, width: 1, height: 1 },
|
| 111 |
+
rangeStack: [],
|
| 112 |
+
});
|
| 113 |
+
}, []);
|
| 114 |
+
|
| 115 |
const clearSelection = useCallback(() => {
|
| 116 |
setSelectedPids(new Set());
|
| 117 |
setCurrent(undefined);
|
| 118 |
}, []);
|
| 119 |
|
| 120 |
+
return { gridSelection, selectedPids, onGridSelectionChange, selectPids, togglePid,
|
| 121 |
+
setActiveCell, clearSelection };
|
| 122 |
}
|
web/src/index.css
CHANGED
|
@@ -102,6 +102,11 @@
|
|
| 102 |
--lp-r-md: 6px; /* buttons, cards β the NEW default (was 4px) */
|
| 103 |
--lp-r-lg: 10px; /* dialogs, panels */
|
| 104 |
--lp-r-pill: 999px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
--lp-surface: #ffffff;
|
| 106 |
--lp-surface-2: #f7f7f6; /* warm-neutral; was #f4f6f9 (cool) */
|
| 107 |
|
|
@@ -215,18 +220,173 @@ body {
|
|
| 215 |
font-size: var(--lp-fs-sm);
|
| 216 |
}
|
| 217 |
|
| 218 |
-
/* Saved-view rail
|
|
|
|
| 219 |
.cg-views {
|
| 220 |
-
flex: 0 0
|
| 221 |
-
width:
|
| 222 |
-
min-width:
|
| 223 |
height: 100%;
|
| 224 |
-
padding:
|
| 225 |
border-right: 1px solid var(--lp-line);
|
| 226 |
-
background: var(--
|
| 227 |
color: var(--cg-text);
|
| 228 |
user-select: none;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
}
|
|
|
|
| 230 |
.cg-views-head {
|
| 231 |
min-height: 44px;
|
| 232 |
display: flex;
|
|
@@ -281,7 +441,7 @@ body {
|
|
| 281 |
border-radius: var(--lp-r-md);
|
| 282 |
}
|
| 283 |
.cg-view-row:hover {
|
| 284 |
-
background:
|
| 285 |
}
|
| 286 |
.cg-view-row.is-active {
|
| 287 |
background: var(--lp-blue-tint);
|
|
@@ -2096,11 +2256,72 @@ body {
|
|
| 2096 |
.shell-side {
|
| 2097 |
display: flex;
|
| 2098 |
flex-direction: column;
|
| 2099 |
-
width:
|
| 2100 |
-
flex: 0 0
|
| 2101 |
background: var(--lp-surface);
|
| 2102 |
border-right: 1px solid var(--lp-line);
|
| 2103 |
overflow-y: auto;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2104 |
}
|
| 2105 |
|
| 2106 |
.shell-brand { display: flex; align-items: center; gap: 9px; padding: 18px 16px 14px; }
|
|
@@ -3406,13 +3627,15 @@ a.cg-map-ctl-b { text-decoration: none; }
|
|
| 3406 |
/* I14 β the two create doors, stacked and LEFT-ALIGNED (the owner's word). `.cg-link-btn`
|
| 3407 |
is a <button>, so it centres its label by default; every other use of that class sits in
|
| 3408 |
a centred footer, which is why the alignment is set here and not on the shared class. */
|
| 3409 |
-
|
|
|
|
|
|
|
| 3410 |
.cg-create-btn {
|
| 3411 |
width: 100%;
|
| 3412 |
text-align: left;
|
| 3413 |
-
padding: 0
|
| 3414 |
color: var(--lp-ink);
|
| 3415 |
-
font-weight:
|
| 3416 |
}
|
| 3417 |
/* Wave-10 item 13 β the trigger stays HELD while its flyout is open. Driven off the
|
| 3418 |
`aria-expanded` the button already publishes, so the visual state cannot drift from the
|
|
|
|
| 102 |
--lp-r-md: 6px; /* buttons, cards β the NEW default (was 4px) */
|
| 103 |
--lp-r-lg: 10px; /* dialogs, panels */
|
| 104 |
--lp-r-pill: 999px;
|
| 105 |
+
/* Owner item 12 (2026-07-31): ONE width for both rails β the shell navigation and the
|
| 106 |
+
views rail are the two halves of one chrome, and asymmetry read as unfinished. The
|
| 107 |
+
collapsed strip is the item-11 minimize target (both rails fold to it). */
|
| 108 |
+
--lp-rail-w: 240px;
|
| 109 |
+
--lp-rail-min: 48px;
|
| 110 |
--lp-surface: #ffffff;
|
| 111 |
--lp-surface-2: #f7f7f6; /* warm-neutral; was #f4f6f9 (cool) */
|
| 112 |
|
|
|
|
| 220 |
font-size: var(--lp-fs-sm);
|
| 221 |
}
|
| 222 |
|
| 223 |
+
/* Saved-view rail β the SECOND navigation bar (owner items 10/11/12, 2026-07-31): same
|
| 224 |
+
width as the shell nav, same white surface, same fold-to-strip minimize. */
|
| 225 |
.cg-views {
|
| 226 |
+
flex: 0 0 var(--lp-rail-w);
|
| 227 |
+
width: var(--lp-rail-w);
|
| 228 |
+
min-width: var(--lp-rail-w);
|
| 229 |
height: 100%;
|
| 230 |
+
padding: 6px 8px 10px;
|
| 231 |
border-right: 1px solid var(--lp-line);
|
| 232 |
+
background: var(--lp-surface);
|
| 233 |
color: var(--cg-text);
|
| 234 |
user-select: none;
|
| 235 |
+
display: flex;
|
| 236 |
+
flex-direction: column;
|
| 237 |
+
overflow: hidden;
|
| 238 |
+
}
|
| 239 |
+
.cg-views.is-collapsed {
|
| 240 |
+
flex-basis: var(--lp-rail-min);
|
| 241 |
+
width: var(--lp-rail-min);
|
| 242 |
+
min-width: var(--lp-rail-min);
|
| 243 |
+
padding: 6px 6px 10px;
|
| 244 |
+
}
|
| 245 |
+
.cg-views.is-collapsed > :not(.cg-views-top) {
|
| 246 |
+
display: none;
|
| 247 |
+
}
|
| 248 |
+
.cg-views-top {
|
| 249 |
+
display: flex;
|
| 250 |
+
align-items: center;
|
| 251 |
+
justify-content: flex-end;
|
| 252 |
+
padding: 0 0 2px;
|
| 253 |
+
}
|
| 254 |
+
.cg-rail-toggle {
|
| 255 |
+
width: 28px;
|
| 256 |
+
height: 28px;
|
| 257 |
+
display: inline-flex;
|
| 258 |
+
align-items: center;
|
| 259 |
+
justify-content: center;
|
| 260 |
+
border: none;
|
| 261 |
+
border-radius: var(--lp-r-md);
|
| 262 |
+
background: transparent;
|
| 263 |
+
color: var(--lp-muted);
|
| 264 |
+
cursor: pointer;
|
| 265 |
+
}
|
| 266 |
+
.cg-rail-toggle:hover {
|
| 267 |
+
background: var(--lp-surface-2);
|
| 268 |
+
color: var(--lp-ink);
|
| 269 |
+
}
|
| 270 |
+
.cg-rail-toggle:focus-visible {
|
| 271 |
+
outline: 2px solid var(--lp-blue-deep);
|
| 272 |
+
outline-offset: 1px;
|
| 273 |
+
}
|
| 274 |
+
/* Owner item 9 β "Find a view": borderless on the white rail; the outline appears only on
|
| 275 |
+
focus, and it is the brand purple by the owner's explicit ask. The 8-state discipline
|
| 276 |
+
holds β no border at rest means no border-width change on focus; state is the outline. */
|
| 277 |
+
.cg-find-view {
|
| 278 |
+
position: relative;
|
| 279 |
+
margin: 0 2px 6px;
|
| 280 |
+
}
|
| 281 |
+
.cg-find-view-icon {
|
| 282 |
+
position: absolute;
|
| 283 |
+
left: 8px;
|
| 284 |
+
top: 50%;
|
| 285 |
+
transform: translateY(-50%);
|
| 286 |
+
color: var(--lp-muted);
|
| 287 |
+
pointer-events: none;
|
| 288 |
+
}
|
| 289 |
+
.cg-find-view input {
|
| 290 |
+
width: 100%;
|
| 291 |
+
height: 28px;
|
| 292 |
+
padding: 0 8px 0 28px;
|
| 293 |
+
border: none;
|
| 294 |
+
border-radius: var(--lp-r-md);
|
| 295 |
+
background: transparent;
|
| 296 |
+
color: var(--lp-ink);
|
| 297 |
+
font: inherit;
|
| 298 |
+
font-size: var(--lp-fs-xs);
|
| 299 |
+
}
|
| 300 |
+
.cg-find-view input::placeholder {
|
| 301 |
+
color: var(--lp-muted);
|
| 302 |
+
}
|
| 303 |
+
.cg-find-view input:focus-visible {
|
| 304 |
+
outline: 2px solid var(--lp-purple-deep);
|
| 305 |
+
outline-offset: -1px;
|
| 306 |
+
}
|
| 307 |
+
.cg-views-scroll {
|
| 308 |
+
flex: 1 1 auto;
|
| 309 |
+
min-height: 0;
|
| 310 |
+
overflow-y: auto;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
/* Owner item 8 β the create pane's field-type picker: a find box over an icon listbox.
|
| 314 |
+
The rows reuse the type marks the column headers already wear (FieldTypeIcon). */
|
| 315 |
+
.cg-type-block { display: flex; flex-direction: column; gap: 4px; }
|
| 316 |
+
.cg-type-title { font-size: var(--lp-fs-2xs); color: var(--lp-muted); }
|
| 317 |
+
.cg-type-picker {
|
| 318 |
+
border: 1px solid var(--lp-line);
|
| 319 |
+
border-radius: var(--lp-r-md);
|
| 320 |
+
overflow: hidden;
|
| 321 |
+
background: var(--lp-surface);
|
| 322 |
+
}
|
| 323 |
+
.cg-type-search {
|
| 324 |
+
position: relative;
|
| 325 |
+
border-bottom: 1px solid var(--lp-line);
|
| 326 |
+
}
|
| 327 |
+
.cg-type-search svg {
|
| 328 |
+
position: absolute;
|
| 329 |
+
left: 8px;
|
| 330 |
+
top: 50%;
|
| 331 |
+
transform: translateY(-50%);
|
| 332 |
+
color: var(--lp-muted);
|
| 333 |
+
pointer-events: none;
|
| 334 |
+
}
|
| 335 |
+
.cg-type-search input {
|
| 336 |
+
width: 100%;
|
| 337 |
+
height: 30px;
|
| 338 |
+
border: none;
|
| 339 |
+
background: transparent;
|
| 340 |
+
padding: 0 8px 0 27px;
|
| 341 |
+
font: inherit;
|
| 342 |
+
font-size: var(--lp-fs-xs);
|
| 343 |
+
color: var(--lp-ink);
|
| 344 |
+
}
|
| 345 |
+
.cg-type-search input::placeholder { color: var(--lp-muted); }
|
| 346 |
+
.cg-type-search input:focus-visible {
|
| 347 |
+
outline: 2px solid var(--lp-purple-deep);
|
| 348 |
+
outline-offset: -2px;
|
| 349 |
+
border-radius: var(--lp-r-sm);
|
| 350 |
+
}
|
| 351 |
+
.cg-type-list {
|
| 352 |
+
max-height: 218px;
|
| 353 |
+
overflow-y: auto;
|
| 354 |
+
padding: 4px;
|
| 355 |
+
display: flex;
|
| 356 |
+
flex-direction: column;
|
| 357 |
+
gap: 1px;
|
| 358 |
+
}
|
| 359 |
+
.cg-type-row {
|
| 360 |
+
display: flex;
|
| 361 |
+
align-items: center;
|
| 362 |
+
gap: 8px;
|
| 363 |
+
width: 100%;
|
| 364 |
+
min-height: 30px;
|
| 365 |
+
padding: 4px 8px;
|
| 366 |
+
border: none;
|
| 367 |
+
border-radius: var(--lp-r-sm);
|
| 368 |
+
background: transparent;
|
| 369 |
+
color: var(--lp-ink);
|
| 370 |
+
font: inherit;
|
| 371 |
+
font-size: var(--lp-fs-xs);
|
| 372 |
+
text-align: left;
|
| 373 |
+
cursor: pointer;
|
| 374 |
+
}
|
| 375 |
+
.cg-type-row:hover { background: var(--lp-surface-2); }
|
| 376 |
+
.cg-type-row.is-on { background: var(--lp-blue-tint); font-weight: 600; }
|
| 377 |
+
.cg-type-row:focus-visible {
|
| 378 |
+
outline: 2px solid var(--lp-blue-deep);
|
| 379 |
+
outline-offset: -2px;
|
| 380 |
+
}
|
| 381 |
+
.cg-type-row .cg-type-icon { color: var(--lp-muted); flex: 0 0 auto; }
|
| 382 |
+
.cg-type-row.is-on .cg-type-icon { color: var(--lp-blue-deep); }
|
| 383 |
+
.cg-type-label {
|
| 384 |
+
flex: 1 1 auto;
|
| 385 |
+
overflow: hidden;
|
| 386 |
+
text-overflow: ellipsis;
|
| 387 |
+
white-space: nowrap;
|
| 388 |
}
|
| 389 |
+
.cg-type-check { color: var(--lp-blue-deep); flex: 0 0 auto; }
|
| 390 |
.cg-views-head {
|
| 391 |
min-height: 44px;
|
| 392 |
display: flex;
|
|
|
|
| 441 |
border-radius: var(--lp-r-md);
|
| 442 |
}
|
| 443 |
.cg-view-row:hover {
|
| 444 |
+
background: var(--lp-surface-2);
|
| 445 |
}
|
| 446 |
.cg-view-row.is-active {
|
| 447 |
background: var(--lp-blue-tint);
|
|
|
|
| 2256 |
.shell-side {
|
| 2257 |
display: flex;
|
| 2258 |
flex-direction: column;
|
| 2259 |
+
width: var(--lp-rail-w);
|
| 2260 |
+
flex: 0 0 var(--lp-rail-w);
|
| 2261 |
background: var(--lp-surface);
|
| 2262 |
border-right: 1px solid var(--lp-line);
|
| 2263 |
overflow-y: auto;
|
| 2264 |
+
overflow-x: hidden;
|
| 2265 |
+
}
|
| 2266 |
+
|
| 2267 |
+
/* Owner item 11 β the minimize control lives in the rail head, beside the brand. Collapsed,
|
| 2268 |
+
the rail is the slim strip: mark, toggle, icons, avatar β labels and badges go, widths come
|
| 2269 |
+
from the shared tokens so both rails fold identically. */
|
| 2270 |
+
.shell-side-head {
|
| 2271 |
+
display: flex;
|
| 2272 |
+
align-items: center;
|
| 2273 |
+
justify-content: space-between;
|
| 2274 |
+
padding-right: 10px;
|
| 2275 |
+
}
|
| 2276 |
+
.shell-rail-toggle {
|
| 2277 |
+
width: 28px;
|
| 2278 |
+
height: 28px;
|
| 2279 |
+
flex: 0 0 28px;
|
| 2280 |
+
display: inline-flex;
|
| 2281 |
+
align-items: center;
|
| 2282 |
+
justify-content: center;
|
| 2283 |
+
border: none;
|
| 2284 |
+
border-radius: var(--lp-r-md);
|
| 2285 |
+
background: transparent;
|
| 2286 |
+
color: var(--lp-muted);
|
| 2287 |
+
cursor: pointer;
|
| 2288 |
+
}
|
| 2289 |
+
.shell-rail-toggle:hover {
|
| 2290 |
+
background: var(--lp-surface-2);
|
| 2291 |
+
color: var(--lp-ink);
|
| 2292 |
+
}
|
| 2293 |
+
.shell-rail-toggle:focus-visible {
|
| 2294 |
+
outline: 2px solid var(--lp-blue-deep);
|
| 2295 |
+
outline-offset: 1px;
|
| 2296 |
+
}
|
| 2297 |
+
.shell-side.is-collapsed {
|
| 2298 |
+
width: var(--lp-rail-min);
|
| 2299 |
+
flex-basis: var(--lp-rail-min);
|
| 2300 |
+
}
|
| 2301 |
+
.shell-side.is-collapsed .shell-side-head {
|
| 2302 |
+
flex-direction: column;
|
| 2303 |
+
gap: 6px;
|
| 2304 |
+
padding: 12px 0 4px;
|
| 2305 |
+
}
|
| 2306 |
+
.shell-side.is-collapsed .shell-brand { padding: 0; }
|
| 2307 |
+
.shell-side.is-collapsed .lp-wordmark,
|
| 2308 |
+
.shell-side.is-collapsed .shell-nav-label,
|
| 2309 |
+
.shell-side.is-collapsed .shell-nav-badge,
|
| 2310 |
+
.shell-side.is-collapsed .shell-nav-open,
|
| 2311 |
+
.shell-side.is-collapsed .shell-nav-group,
|
| 2312 |
+
.shell-side.is-collapsed .shell-account-who,
|
| 2313 |
+
.shell-side.is-collapsed .shell-account-dots {
|
| 2314 |
+
display: none;
|
| 2315 |
+
}
|
| 2316 |
+
.shell-side.is-collapsed .shell-nav { padding: 0 6px; }
|
| 2317 |
+
.shell-side.is-collapsed .shell-nav-item {
|
| 2318 |
+
justify-content: center;
|
| 2319 |
+
padding: 7px 0;
|
| 2320 |
+
}
|
| 2321 |
+
.shell-side.is-collapsed .shell-nav-item.is-child { padding-left: 0; }
|
| 2322 |
+
.shell-side.is-collapsed .shell-account {
|
| 2323 |
+
justify-content: center;
|
| 2324 |
+
padding: 8px 0;
|
| 2325 |
}
|
| 2326 |
|
| 2327 |
.shell-brand { display: flex; align-items: center; gap: 9px; padding: 18px 16px 14px; }
|
|
|
|
| 3627 |
/* I14 β the two create doors, stacked and LEFT-ALIGNED (the owner's word). `.cg-link-btn`
|
| 3628 |
is a <button>, so it centres its label by default; every other use of that class sits in
|
| 3629 |
a centred footer, which is why the alignment is set here and not on the shared class. */
|
| 3630 |
+
/* Owner item 9 (2026-07-31): the create row is a QUIET affordance, not a headline β regular
|
| 3631 |
+
weight, flush-left with the search and the view names under it. */
|
| 3632 |
+
.cg-create-new { padding: 2px 2px 0; }
|
| 3633 |
.cg-create-btn {
|
| 3634 |
width: 100%;
|
| 3635 |
text-align: left;
|
| 3636 |
+
padding: 0 8px;
|
| 3637 |
color: var(--lp-ink);
|
| 3638 |
+
font-weight: 400;
|
| 3639 |
}
|
| 3640 |
/* Wave-10 item 13 β the trigger stays HELD while its flyout is open. Driven off the
|
| 3641 |
`aria-expanded` the button already publishes, so the visual state cannot drift from the
|
web/src/shell/Shell.tsx
CHANGED
|
@@ -34,7 +34,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
| 34 |
import CustomerGrid from "../customer-grid/CustomerGrid";
|
| 35 |
import { clearCustomersCache } from "../customer-grid/apiBridge";
|
| 36 |
import { OverlayProvider } from "../customer-grid/OverlaySurface";
|
| 37 |
-
import { DATA_ERROR_EVENT, TOAST_EVENT, UNAUTHORIZED_EVENT } from "../apiContract";
|
| 38 |
import { PageSurface } from "../pages/PageSurface";
|
| 39 |
import { SettingsModal } from "../settings/SettingsModal";
|
| 40 |
import type { SettingsSection } from "../settings/SettingsModal";
|
|
@@ -141,6 +141,21 @@ function DotsIcon() {
|
|
| 141 |
);
|
| 142 |
}
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
/**
|
| 145 |
* The account corner: ONE line (monogram + name + role, the host's
|
| 146 |
* `_account_css` card) that opens a small menu, instead of three buttons
|
|
@@ -276,22 +291,42 @@ export default function Shell() {
|
|
| 276 |
// "Users" are one component reached two ways rather than two dialogs.
|
| 277 |
const [settings, setSettings] = useState<SettingsSection | null>(null);
|
| 278 |
const route = useHashRoute();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
|
| 280 |
-
// The
|
| 281 |
-
//
|
| 282 |
-
// the
|
| 283 |
useEffect(() => {
|
| 284 |
const onUnauthorized = () => setSession({ phase: "anon" });
|
| 285 |
const onDataError = (e: Event) =>
|
| 286 |
setDataError(String((e as CustomEvent).detail ?? "") || "The data could not be loaded.");
|
| 287 |
const onToast = (e: Event) => setToast(String((e as CustomEvent).detail ?? ""));
|
|
|
|
| 288 |
window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
|
| 289 |
window.addEventListener(DATA_ERROR_EVENT, onDataError);
|
| 290 |
window.addEventListener(TOAST_EVENT, onToast);
|
|
|
|
| 291 |
return () => {
|
| 292 |
window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
|
| 293 |
window.removeEventListener(DATA_ERROR_EVENT, onDataError);
|
| 294 |
window.removeEventListener(TOAST_EVENT, onToast);
|
|
|
|
| 295 |
};
|
| 296 |
}, []);
|
| 297 |
|
|
@@ -401,11 +436,23 @@ export default function Shell() {
|
|
| 401 |
|
| 402 |
return (
|
| 403 |
<div className="shell-root">
|
| 404 |
-
<aside className="shell-side">
|
| 405 |
{/* The PRODUCT brand β the same mark the Streamlit host paints, from the
|
| 406 |
same generated file, so the two shells cannot drift. "Royal Imports"
|
| 407 |
stays on business documents only. */}
|
| 408 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
|
| 410 |
<nav className="shell-nav">
|
| 411 |
{/* The Analyst slot, above the database list β the host's own IA
|
|
@@ -474,7 +521,21 @@ export default function Shell() {
|
|
| 474 |
) : null}
|
| 475 |
</nav>
|
| 476 |
|
| 477 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
<AccountMenu
|
| 479 |
user={session.user}
|
| 480 |
utility={utility}
|
|
|
|
| 34 |
import CustomerGrid from "../customer-grid/CustomerGrid";
|
| 35 |
import { clearCustomersCache } from "../customer-grid/apiBridge";
|
| 36 |
import { OverlayProvider } from "../customer-grid/OverlaySurface";
|
| 37 |
+
import { DATA_ERROR_EVENT, NAV_MINIMIZE_EVENT, TOAST_EVENT, UNAUTHORIZED_EVENT } from "../apiContract";
|
| 38 |
import { PageSurface } from "../pages/PageSurface";
|
| 39 |
import { SettingsModal } from "../settings/SettingsModal";
|
| 40 |
import type { SettingsSection } from "../settings/SettingsModal";
|
|
|
|
| 141 |
);
|
| 142 |
}
|
| 143 |
|
| 144 |
+
/** Owner item 11 β the rail toggle: three horizontal bars. One glyph for both rails (the
|
| 145 |
+
* views rail draws the same geometry), so "minimize a panel" reads as one idea. */
|
| 146 |
+
function RailToggleIcon() {
|
| 147 |
+
return (
|
| 148 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
| 149 |
+
<path
|
| 150 |
+
d="M2.5 4.4h11M2.5 8h11M2.5 11.6h11"
|
| 151 |
+
stroke="currentColor"
|
| 152 |
+
strokeWidth="1.35"
|
| 153 |
+
strokeLinecap="round"
|
| 154 |
+
/>
|
| 155 |
+
</svg>
|
| 156 |
+
);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
/**
|
| 160 |
* The account corner: ONE line (monogram + name + role, the host's
|
| 161 |
* `_account_css` card) that opens a small menu, instead of three buttons
|
|
|
|
| 291 |
// "Users" are one component reached two ways rather than two dialogs.
|
| 292 |
const [settings, setSettings] = useState<SettingsSection | null>(null);
|
| 293 |
const route = useHashRoute();
|
| 294 |
+
// Owner items 10/11 β the navigation folds to a slim strip: by the toggle in the rail head,
|
| 295 |
+
// or automatically when the user clicks into the work surface (NAV_MINIMIZE_EVENT from the
|
| 296 |
+
// grid). Remembered per browser; expanding is always one click on the same toggle.
|
| 297 |
+
const [navCollapsed, setNavCollapsed] = useState<boolean>(() => {
|
| 298 |
+
try {
|
| 299 |
+
return localStorage.getItem("aios-nav-collapsed") === "1";
|
| 300 |
+
} catch {
|
| 301 |
+
return false;
|
| 302 |
+
}
|
| 303 |
+
});
|
| 304 |
+
useEffect(() => {
|
| 305 |
+
try {
|
| 306 |
+
localStorage.setItem("aios-nav-collapsed", navCollapsed ? "1" : "0");
|
| 307 |
+
} catch {
|
| 308 |
+
// storage can be blocked; the toggle still works for the session
|
| 309 |
+
}
|
| 310 |
+
}, [navCollapsed]);
|
| 311 |
|
| 312 |
+
// The window signals the data layer raises (apiContract.ts). It talks to the
|
| 313 |
+
// frame this way because `customer-grid/**` is host-neutral β the same tree
|
| 314 |
+
// the Streamlit embed ships β and must not import a shell.
|
| 315 |
useEffect(() => {
|
| 316 |
const onUnauthorized = () => setSession({ phase: "anon" });
|
| 317 |
const onDataError = (e: Event) =>
|
| 318 |
setDataError(String((e as CustomEvent).detail ?? "") || "The data could not be loaded.");
|
| 319 |
const onToast = (e: Event) => setToast(String((e as CustomEvent).detail ?? ""));
|
| 320 |
+
const onNavMinimize = () => setNavCollapsed(true);
|
| 321 |
window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
|
| 322 |
window.addEventListener(DATA_ERROR_EVENT, onDataError);
|
| 323 |
window.addEventListener(TOAST_EVENT, onToast);
|
| 324 |
+
window.addEventListener(NAV_MINIMIZE_EVENT, onNavMinimize);
|
| 325 |
return () => {
|
| 326 |
window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
|
| 327 |
window.removeEventListener(DATA_ERROR_EVENT, onDataError);
|
| 328 |
window.removeEventListener(TOAST_EVENT, onToast);
|
| 329 |
+
window.removeEventListener(NAV_MINIMIZE_EVENT, onNavMinimize);
|
| 330 |
};
|
| 331 |
}, []);
|
| 332 |
|
|
|
|
| 436 |
|
| 437 |
return (
|
| 438 |
<div className="shell-root">
|
| 439 |
+
<aside className={"shell-side" + (navCollapsed ? " is-collapsed" : "")}>
|
| 440 |
{/* The PRODUCT brand β the same mark the Streamlit host paints, from the
|
| 441 |
same generated file, so the two shells cannot drift. "Royal Imports"
|
| 442 |
stays on business documents only. */}
|
| 443 |
+
<div className="shell-side-head">
|
| 444 |
+
<Brand size={26} className="shell-brand" />
|
| 445 |
+
<button
|
| 446 |
+
type="button"
|
| 447 |
+
className="shell-rail-toggle"
|
| 448 |
+
aria-label={navCollapsed ? "Expand navigation" : "Minimize navigation"}
|
| 449 |
+
aria-expanded={!navCollapsed}
|
| 450 |
+
title={navCollapsed ? "Expand navigation" : "Minimize navigation"}
|
| 451 |
+
onClick={() => setNavCollapsed((v) => !v)}
|
| 452 |
+
>
|
| 453 |
+
<RailToggleIcon />
|
| 454 |
+
</button>
|
| 455 |
+
</div>
|
| 456 |
|
| 457 |
<nav className="shell-nav">
|
| 458 |
{/* The Analyst slot, above the database list β the host's own IA
|
|
|
|
| 521 |
) : null}
|
| 522 |
</nav>
|
| 523 |
|
| 524 |
+
<div
|
| 525 |
+
className="shell-side-bottom"
|
| 526 |
+
// Collapsed, the 56px strip cannot hold the account POPOVER β so the row's one
|
| 527 |
+
// honest behaviour is "expand me first". Capture-phase, so the menu never opens
|
| 528 |
+
// half-clipped behind the grid.
|
| 529 |
+
onClickCapture={
|
| 530 |
+
navCollapsed
|
| 531 |
+
? (e) => {
|
| 532 |
+
e.preventDefault();
|
| 533 |
+
e.stopPropagation();
|
| 534 |
+
setNavCollapsed(false);
|
| 535 |
+
}
|
| 536 |
+
: undefined
|
| 537 |
+
}
|
| 538 |
+
>
|
| 539 |
<AccountMenu
|
| 540 |
user={session.user}
|
| 541 |
utility={utility}
|