fsanyoto commited on
Commit
79c50b4
Β·
verified Β·
1 Parent(s): 5fe6f43

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
RELEASES.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "current": "b89a27e",
3
  "releases": [
4
  {
5
  "version": "v53",
 
1
  {
2
+ "current": "57896aa",
3
  "releases": [
4
  {
5
  "version": "v53",
VERSION CHANGED
@@ -1 +1 @@
1
- b89a27e
 
1
+ 57896aa
api/automation_engine.py CHANGED
@@ -1685,7 +1685,7 @@ def clean_config(kind, raw, previous=None):
1685
  seen.add(pid)
1686
  if not recipient:
1687
  return None, "choose the Inbox recipient for this route"
1688
- if not source_view or not revision:
1689
  return None, "a scheduled route needs the source View and its frozen revision"
1690
  if len(ordered) < 2:
1691
  return None, "a scheduled route needs at least two ordered stops"
 
1685
  seen.add(pid)
1686
  if not recipient:
1687
  return None, "choose the Inbox recipient for this route"
1688
+ if not source_view or source_view == "map-current" or not revision:
1689
  return None, "a scheduled route needs the source View and its frozen revision"
1690
  if len(ordered) < 2:
1691
  return None, "a scheduled route needs at least two ordered stops"
api/routes_customers.py CHANGED
@@ -463,6 +463,10 @@ def grid_assembly(session: Session, scope: str = "customer", storage_key: str =
463
  on_error=_measure_err) if may_metrics else {}).items():
464
  derived.setdefault(pid, {}).update(cells)
465
 
 
 
 
 
466
  return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
467
  "fields": fields, "views": views, "lists": lists, "derived": derived,
468
  "measures": measures, "measure_sets": measure_sets, "today": today,
@@ -486,6 +490,9 @@ def _payload(session: Session):
486
  g = grid_assembly(session)
487
  rows = aios_grid.rows_from_pool(
488
  g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
 
 
 
489
  # ⭐ C4 / D-138 (W30-T37) β€” THE DOCUMENTS PRODUCER FOR THE CUSTOMER SCOPE. The write door
490
  # (`doc_add`/`doc_fetch`/`doc_delete`) never stopped working and every client half is
491
  # complete; what vanished with `app.py` at EXIT-6 was the only thing that ever set this key.
@@ -1621,11 +1628,22 @@ def route_delivery_create(body: dict = Body(default=None),
1621
  body = body if isinstance(body, dict) else {}
1622
  recipient = _route_delivery_recipient_or_400(body.get("recipient"))
1623
  snapshot = body.get("routeSnapshot") if isinstance(body.get("routeSnapshot"), dict) else {}
1624
- # View IDs are usually supplied by the host. MapView cannot own generic View persistence, so
1625
- # its explicit snapshot fingerprint is accepted as the revision when the host has not supplied
1626
- # a persisted view id yet; it still makes the scheduled contents immutable and inspectable.
1627
- source_view_id = str(snapshot.get("sourceViewId") or "map-current").strip()
1628
- source_revision = str(snapshot.get("sourceViewRevision") or body.get("inputsHash") or "").strip()
 
 
 
 
 
 
 
 
 
 
 
1629
  raw = {
1630
  "name": " ".join(str(body.get("name") or snapshot.get("sourceViewName") or "Scheduled route").split())[:120],
1631
  "kind": "route_delivery",
@@ -1638,6 +1656,7 @@ def route_delivery_create(body: dict = Body(default=None),
1638
  **snapshot,
1639
  "sourceViewId": source_view_id,
1640
  "sourceViewRevision": source_revision,
 
1641
  "mapsPerLink": snapshot.get("mapsPerLink", body.get("mapsPerLink", 10)),
1642
  },
1643
  },
 
463
  on_error=_measure_err) if may_metrics else {}).items():
464
  derived.setdefault(pid, {}).update(cells)
465
 
466
+ # Zones are table-scoped tenant data, surfaced as the established comma-joined multiselect
467
+ # contract so saved Views and FilterBuilder evaluate them like every other membership field.
468
+ from core import user_tables as _zones
469
+ fields = _zones.with_zone_field("customer_data", fields, st=rt)
470
  return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
471
  "fields": fields, "views": views, "lists": lists, "derived": derived,
472
  "measures": measures, "measure_sets": measure_sets, "today": today,
 
490
  g = grid_assembly(session)
491
  rows = aios_grid.rows_from_pool(
492
  g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
493
+ from core import user_tables as _zones
494
+ rows = _zones.zone_cells("customer_data", rows, allowed_row_ids=g["pids"],
495
+ st=session.runtime)
496
  # ⭐ C4 / D-138 (W30-T37) β€” THE DOCUMENTS PRODUCER FOR THE CUSTOMER SCOPE. The write door
497
  # (`doc_add`/`doc_fetch`/`doc_delete`) never stopped working and every client half is
498
  # complete; what vanished with `app.py` at EXIT-6 was the only thing that ever set this key.
 
1628
  body = body if isinstance(body, dict) else {}
1629
  recipient = _route_delivery_recipient_or_400(body.get("recipient"))
1630
  snapshot = body.get("routeSnapshot") if isinstance(body.get("routeSnapshot"), dict) else {}
1631
+ # A route snapshot is allowed to freeze its *stops*, never to invent its View provenance.
1632
+ # `map-current`/a route-plan fingerprint were a synthetic fallback that made an automation
1633
+ # impossible to audit back to the actual filtered View. Resolve the submitted id against
1634
+ # this reader's visible View contract and retain the supplied canonical config revision.
1635
+ source_view_id = str(snapshot.get("sourceViewId") or "").strip()
1636
+ source_revision = str(snapshot.get("sourceViewRevision") or "").strip()
1637
+ if not source_view_id or source_view_id == "map-current" or not source_revision:
1638
+ raise err(400, "invalid_route_source_view",
1639
+ "choose an active saved View and include its frozen revision")
1640
+ source_views = {str(view.get("id") or ""): view for view in
1641
+ (grid_assembly(session, consume_corrections=False).get("views") or [])
1642
+ if isinstance(view, dict) and view.get("id")}
1643
+ source_view = source_views.get(source_view_id)
1644
+ if source_view is None:
1645
+ raise err(400, "unknown_route_source_view",
1646
+ "the route source View is no longer visible to this account")
1647
  raw = {
1648
  "name": " ".join(str(body.get("name") or snapshot.get("sourceViewName") or "Scheduled route").split())[:120],
1649
  "kind": "route_delivery",
 
1656
  **snapshot,
1657
  "sourceViewId": source_view_id,
1658
  "sourceViewRevision": source_revision,
1659
+ "sourceViewName": str(source_view.get("name") or source_view_id),
1660
  "mapsPerLink": snapshot.get("mapsPerLink", body.get("mapsPerLink", 10)),
1661
  },
1662
  },
api/routes_geo.py CHANGED
@@ -45,7 +45,7 @@ import time
45
  from collections import OrderedDict
46
 
47
  import requests
48
- from fastapi import APIRouter, Body, Depends
49
 
50
  from deps import Session, err, require_session
51
 
@@ -210,13 +210,47 @@ def _clean_zone(raw, previous=None, *, zone_id=""):
210
 
211
 
212
  def _zone_wire(zone):
213
- return {key: zone.get(key) for key in ("id", "name", "color", "geometryGeoJson", "visible",
214
  "geographicField", "memberships", "createdBy", "createdAt")}
215
-
216
-
217
- def _zone_subject(session, zone_id):
218
- zones = session.runtime.get(ZONES_KEY) or {}
219
- zone = zones.get(str(zone_id)) if isinstance(zones, dict) else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  if not isinstance(zone, dict):
221
  raise err(404, "unknown_zone", "no Zone with that id")
222
  if not session.admin and str(zone.get("createdBy") or "") != session.uname:
@@ -394,15 +428,13 @@ def geo_providers(session: Session = Depends(require_session)):
394
 
395
 
396
  @router.get("/geo/zones")
397
- def geo_zones(session: Session = Depends(require_session)):
398
- """The C3 Zone DTOs this tenant may draw and choose on its Map Views."""
399
- try:
400
- raw = session.runtime.get(ZONES_KEY) or {}
401
- except Exception:
402
- raw = {}
403
- zones = [zone for zone in raw.values() if isinstance(zone, dict)] if isinstance(raw, dict) else []
404
- # Visibility is a display choice carried by the durable zone itself. Creators and admins keep
405
- # seeing an invisible zone in the chooser so it can be restored or edited rather than orphaned.
406
  visible = [zone for zone in zones if zone.get("visible", True)
407
  or session.admin or str(zone.get("createdBy") or "") == session.uname]
408
  return {"zones": [_zone_wire(zone) for zone in sorted(visible,
@@ -411,71 +443,57 @@ def geo_zones(session: Session = Depends(require_session)):
411
 
412
  @router.post("/geo/zones")
413
  def geo_zone_create(body: dict = Body(default=None), session: Session = Depends(require_session)):
414
- """Persist one drawn Zone as C3's tenant record; no table-field writer is involved."""
 
415
  body = body if isinstance(body, dict) else {}
 
416
  try:
417
  zone = _clean_zone(body)
418
  except ValueError as exc:
419
  raise err(400, "invalid_zone", str(exc))
420
  if not zone["id"]:
421
  raise err(400, "invalid_zone", "name this Zone")
422
- created = {}
423
-
424
- def _write(current):
425
- current = current if isinstance(current, dict) else {}
426
- if zone["id"] in current:
427
- raise ValueError("a Zone with that name already exists")
428
- if len(current) >= MAX_ZONES:
429
- raise ValueError(f"this tenant has reached the {MAX_ZONES}-Zone limit")
430
- item = {**zone, "createdBy": session.uname, "createdAt": time.strftime("%Y-%m-%dT%H:%M:%S")}
431
- current[zone["id"]] = item
432
- created.update(item)
433
- return current
434
- try:
435
- session.runtime.update(ZONES_KEY, _write, flush="async")
436
- except ValueError as exc:
437
- raise err(409, "zone_exists", str(exc))
438
- return {"zone": _zone_wire(created)}
439
 
440
 
441
  @router.patch("/geo/zones/{zone_id}")
442
  def geo_zone_update(zone_id: str, body: dict = Body(default=None),
443
  session: Session = Depends(require_session)):
444
- """Edit geometry, membership, geographic source or visibility of a stored Zone."""
445
- previous = _zone_subject(session, zone_id)
 
 
 
446
  try:
447
  zone = _clean_zone(body, previous, zone_id=str(zone_id))
448
  except ValueError as exc:
449
  raise err(400, "invalid_zone", str(exc))
450
- updated = {}
451
-
452
- def _write(current):
453
- current = current if isinstance(current, dict) else {}
454
- old = current.get(str(zone_id))
455
- if not isinstance(old, dict):
456
- raise KeyError(zone_id)
457
- item = {**zone, "createdBy": old.get("createdBy") or session.uname,
458
- "createdAt": old.get("createdAt") or time.strftime("%Y-%m-%dT%H:%M:%S")}
459
- current[str(zone_id)] = item
460
- updated.update(item)
461
- return current
462
- try:
463
- session.runtime.update(ZONES_KEY, _write, flush="async")
464
- except KeyError:
465
- raise err(404, "unknown_zone", "no Zone with that id")
466
- return {"zone": _zone_wire(updated)}
467
 
468
 
469
  @router.delete("/geo/zones/{zone_id}")
470
- def geo_zone_delete(zone_id: str, session: Session = Depends(require_session)):
471
- """Delete exactly one Zone, leaving records and generic fields untouched."""
472
- _zone_subject(session, zone_id)
473
-
474
- def _write(current):
475
- current = current if isinstance(current, dict) else {}
476
- current.pop(str(zone_id), None)
477
- return current
478
- session.runtime.update(ZONES_KEY, _write, flush="async")
479
  return {"deleted": str(zone_id)}
480
 
481
 
 
45
  from collections import OrderedDict
46
 
47
  import requests
48
+ from fastapi import APIRouter, Body, Depends, Query
49
 
50
  from deps import Session, err, require_session
51
 
 
210
 
211
 
212
  def _zone_wire(zone):
213
+ wire = {key: zone.get(key) for key in ("id", "name", "color", "geometryGeoJson", "visible",
214
  "geographicField", "memberships", "createdBy", "createdAt")}
215
+ # Storage keeps ids as strings because table records may originate in several backends; Map
216
+ # selection is numeric `pid`s. The wire performs that one boundary conversion explicitly.
217
+ members = []
218
+ for raw in zone.get("memberships") or ():
219
+ try:
220
+ pid = int(raw)
221
+ except (TypeError, ValueError):
222
+ continue
223
+ if pid > 0:
224
+ members.append(pid)
225
+ wire["memberships"] = members
226
+ return wire
227
+
228
+
229
+ def _zone_context(session, raw_table_key):
230
+ """One table-scoped Zone context, using the existing grid assemblies as permission walls."""
231
+ table_key = str(raw_table_key or "").strip()
232
+ if table_key == "customer_data":
233
+ from routes_customers import MODULE as customer_module, grid_assembly
234
+ session.require(customer_module)
235
+ grid = grid_assembly(session, consume_corrections=False)
236
+ elif table_key == "product_data":
237
+ from routes_products import MODULE as product_module, product_assembly
238
+ session.require(product_module)
239
+ grid = product_assembly(session, consume_corrections=False)
240
+ elif table_key.startswith("ut_"):
241
+ from routes_tables import ut_assembly
242
+ grid = ut_assembly(session, table_key, consume_corrections=False, with_rows=False)
243
+ else:
244
+ raise err(400, "invalid_zone_table", "choose a database before working with Zones")
245
+ fields = {str(field.get("key") or "") for field in (grid.get("fields") or [])
246
+ if isinstance(field, dict) and field.get("key")}
247
+ return table_key, fields, set(grid.get("pids") or ())
248
+
249
+
250
+ def _zone_subject(session, table_key, zone_id):
251
+ from core import user_tables
252
+ zone = next((item for item in user_tables.zones(table_key, st=session.runtime)
253
+ if str(item.get("id") or "") == str(zone_id)), None)
254
  if not isinstance(zone, dict):
255
  raise err(404, "unknown_zone", "no Zone with that id")
256
  if not session.admin and str(zone.get("createdBy") or "") != session.uname:
 
428
 
429
 
430
  @router.get("/geo/zones")
431
+ def geo_zones(tableKey: str = Query(default=""), session: Session = Depends(require_session)):
432
+ """The canonical table-scoped Zone DTOs a Map may draw or filter by."""
433
+ from core import user_tables
434
+ table_key, _fields, _pids = _zone_context(session, tableKey)
435
+ zones = user_tables.zones(table_key, st=session.runtime)
436
+ # A hidden Zone remains recoverable to its creator/admin, but cannot become visible through a
437
+ # second table simply because row ids happen to collide there.
 
 
438
  visible = [zone for zone in zones if zone.get("visible", True)
439
  or session.admin or str(zone.get("createdBy") or "") == session.uname]
440
  return {"zones": [_zone_wire(zone) for zone in sorted(visible,
 
443
 
444
  @router.post("/geo/zones")
445
  def geo_zone_create(body: dict = Body(default=None), session: Session = Depends(require_session)):
446
+ """Persist one Zone in the existing table-scoped Zone store and field/filter contract."""
447
+ from core import user_tables
448
  body = body if isinstance(body, dict) else {}
449
+ table_key, field_keys, pids = _zone_context(session, body.get("tableKey"))
450
  try:
451
  zone = _clean_zone(body)
452
  except ValueError as exc:
453
  raise err(400, "invalid_zone", str(exc))
454
  if not zone["id"]:
455
  raise err(400, "invalid_zone", "name this Zone")
456
+ if any(str(item.get("id") or "") == zone["id"]
457
+ for item in user_tables.zones(table_key, st=session.runtime)):
458
+ raise err(409, "zone_exists", "a Zone with that name already exists")
459
+ saved = user_tables.upsert_zone(table_key, zone, field_keys=field_keys,
460
+ allowed_row_ids=pids, st=session.runtime,
461
+ created_by=session.uname)
462
+ if saved is None:
463
+ raise err(400, "invalid_zone", "the Zone does not match this database's fields and records")
464
+ return {"zone": _zone_wire(saved)}
 
 
 
 
 
 
 
 
465
 
466
 
467
  @router.patch("/geo/zones/{zone_id}")
468
  def geo_zone_update(zone_id: str, body: dict = Body(default=None),
469
  session: Session = Depends(require_session)):
470
+ """Edit a table-scoped Zone while retaining creator, geometry and membership invariants."""
471
+ from core import user_tables
472
+ body = body if isinstance(body, dict) else {}
473
+ table_key, field_keys, pids = _zone_context(session, body.get("tableKey"))
474
+ previous = _zone_subject(session, table_key, zone_id)
475
  try:
476
  zone = _clean_zone(body, previous, zone_id=str(zone_id))
477
  except ValueError as exc:
478
  raise err(400, "invalid_zone", str(exc))
479
+ saved = user_tables.upsert_zone(table_key, zone, field_keys=field_keys,
480
+ allowed_row_ids=pids, st=session.runtime,
481
+ created_by=session.uname)
482
+ if saved is None:
483
+ raise err(400, "invalid_zone", "the Zone does not match this database's fields and records")
484
+ return {"zone": _zone_wire(saved)}
 
 
 
 
 
 
 
 
 
 
 
485
 
486
 
487
  @router.delete("/geo/zones/{zone_id}")
488
+ def geo_zone_delete(zone_id: str, body: dict = Body(default=None),
489
+ session: Session = Depends(require_session)):
490
+ """Delete exactly one table-scoped Zone; record values are never mutated here."""
491
+ body = body if isinstance(body, dict) else {}
492
+ table_key, _fields, _pids = _zone_context(session, body.get("tableKey"))
493
+ _zone_subject(session, table_key, zone_id)
494
+ from core import user_tables
495
+ if not user_tables.delete_zone(table_key, zone_id, st=session.runtime):
496
+ raise err(404, "unknown_zone", "no Zone with that id")
497
  return {"deleted": str(zone_id)}
498
 
499
 
api/routes_grid.py CHANGED
@@ -840,6 +840,28 @@ def bulk_cells(body: dict = Body(default=None),
840
  return out
841
 
842
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
843
  @router.post("/grid/events")
844
  def grid_events_route(body: dict = Body(default=None),
845
  session: Session = Depends(require_session)):
 
840
  return out
841
 
842
 
843
+ @router.post("/grid/recurrence-preview")
844
+ def recurrence_preview(body: dict = Body(default=None),
845
+ session: Session = Depends(require_session)):
846
+ """Validate and preview a Date-field recurrence before the user saves it.
847
+
848
+ A preview is not a client-side convenience calculation: holiday relations and ordinal-month
849
+ rules must be evaluated by the same server normaliser that persists the field. This endpoint
850
+ is read-only and session-scoped only because it has no table or row to disclose.
851
+ """
852
+ import aios_grid
853
+
854
+ raw = (body or {}).get("recurrence")
855
+ clean = aios_grid.clean_date_recurrence(raw)
856
+ if clean is None:
857
+ raise err(400, "bad_recurrence",
858
+ "recurrence needs a start date and at least one supported repeat rule")
859
+ return {
860
+ "recurrence": aios_grid.recurrence_payload(clean, today=time.strftime("%Y-%m-%d")),
861
+ "calendars": list(aios_grid.PUBLIC_HOLIDAY_CALENDARS),
862
+ }
863
+
864
+
865
  @router.post("/grid/events")
866
  def grid_events_route(body: dict = Body(default=None),
867
  session: Session = Depends(require_session)):
api/routes_odoo_tables.py CHANGED
@@ -18,9 +18,13 @@ and deletes the rows that left the population. That is an operator action.
18
  READ-THROUGH WINDOW. The two above operate on the materialised copy; `/{table_key}/rows` does not
19
  read the copy at all. See the block above `GRID_SOURCES` for why that had to change.
20
  """
 
 
 
21
  import json
 
22
 
23
- from fastapi import Depends
24
  from fastapi import APIRouter, Query
25
 
26
  from deps import Session, err, require_session
@@ -780,6 +784,14 @@ def _source_for(cur, table_key):
780
  #: the person BEFORE the wait rather than after it.
781
  _DEEP_PAGE = 100_000
782
 
 
 
 
 
 
 
 
 
783
 
784
  #: `filter_sql` speaks the CLIENT's type vocabulary (`types.ts isNumericType`), and two of our
785
  #: field kinds are spelled differently there. Mapped in one place so the pushdown and the grid
@@ -831,6 +843,210 @@ def _json_arg(raw, what):
831
  return val
832
 
833
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
834
  @router.get("/odoo-tables/{table_key}/rows")
835
  def odoo_table_rows(table_key: str,
836
  offset: int = Query(default=0, ge=0),
 
18
  READ-THROUGH WINDOW. The two above operate on the materialised copy; `/{table_key}/rows` does not
19
  read the copy at all. See the block above `GRID_SOURCES` for why that had to change.
20
  """
21
+ import base64
22
+ import hashlib
23
+ import hmac
24
  import json
25
+ import time
26
 
27
+ from fastapi import Body, Depends
28
  from fastapi import APIRouter, Query
29
 
30
  from deps import Session, err, require_session
 
784
  #: the person BEFORE the wait rather than after it.
785
  _DEEP_PAGE = 100_000
786
 
787
+ # A filtered "select all" is a WRITE precursor, not a normal source read. The source itself is
788
+ # intentionally uncapped, but this descriptor is later used to write one shared field to every
789
+ # selected row in one durable overlay transaction. Keep the existing bulk-write ceiling explicit
790
+ # and report it before minting a descriptor; never turn a 34,000-row filter into an unannounced
791
+ # first-page selection.
792
+ MAX_FILTERED_SELECTION_ROWS = 10_000
793
+ FILTERED_SELECTION_SECONDS = 5 * 60
794
+
795
 
796
  #: `filter_sql` speaks the CLIENT's type vocabulary (`types.ts isNumericType`), and two of our
797
  #: field kinds are spelled differently there. Mapped in one place so the pushdown and the grid
 
843
  return val
844
 
845
 
846
+ # ---------------------------------------------------------------- filtered selection / bulk overlay writes
847
+
848
+ def _selection_query(body):
849
+ """The exact client predicate a server-windowed selection represents.
850
+
851
+ The normal rows route owns the SQL compiler, row wall and hidden-field closure. This helper
852
+ only validates the wire shape and preserves it verbatim so both descriptor minting and the
853
+ later write can call that same door. Falling back to an empty filter here would widen a bulk
854
+ write, which is worse than refusing a malformed request.
855
+ """
856
+ body = body if isinstance(body, dict) else {}
857
+ filters = body.get("filters", [])
858
+ sorts = body.get("sorts", [])
859
+ if filters is None:
860
+ filters = []
861
+ if sorts is None:
862
+ sorts = []
863
+ if not isinstance(filters, list):
864
+ raise err(400, "bad_selection", "filters must be a list")
865
+ if not isinstance(sorts, list):
866
+ raise err(400, "bad_selection", "sorts must be a list")
867
+ filter_conj = str(body.get("filterConj") or "and").lower()
868
+ if filter_conj not in ("and", "or"):
869
+ raise err(400, "bad_selection", "filterConj must be 'and' or 'or'")
870
+ search = body.get("search", "")
871
+ if search is None:
872
+ search = ""
873
+ if not isinstance(search, str) or len(search) > 1_000:
874
+ raise err(400, "bad_selection", "search must be text no longer than 1,000 characters")
875
+ return {"filters": filters, "filterConj": filter_conj, "sorts": sorts, "search": search}
876
+
877
+
878
+ def _selection_rows_args(query, *, offset, limit):
879
+ """Turn a canonical descriptor predicate back into `odoo_table_rows` query arguments."""
880
+ return {
881
+ "offset": offset,
882
+ "limit": limit,
883
+ "filters": json.dumps(query["filters"], separators=(",", ":")),
884
+ "filterConj": query["filterConj"],
885
+ "sorts": json.dumps(query["sorts"], separators=(",", ":")),
886
+ "search": query["search"],
887
+ }
888
+
889
+
890
+ def _pid_fingerprint(pids):
891
+ """Stable set fingerprint for a descriptor; duplicate/non-integer IDs are an internal refusal."""
892
+ try:
893
+ ordered = sorted(int(pid) for pid in pids)
894
+ except (TypeError, ValueError):
895
+ raise ValueError("the connected rows route returned a non-integer record id")
896
+ if len(set(ordered)) != len(ordered):
897
+ raise ValueError("the connected rows route returned a duplicate record id")
898
+ return hashlib.sha256(",".join(str(pid) for pid in ordered).encode("ascii")).hexdigest()
899
+
900
+
901
+ def _all_filtered_pids(table_key, query, session):
902
+ """Read every permitted ID through the existing read compiler, with an explicit write bound."""
903
+ from harness import datastore
904
+
905
+ first = odoo_table_rows(table_key, session=session,
906
+ **_selection_rows_args(query, offset=0,
907
+ limit=datastore.WINDOW_MAX))
908
+ total = int(first.get("total") or 0)
909
+ if total > MAX_FILTERED_SELECTION_ROWS:
910
+ raise err(400, "selection_too_large",
911
+ f"this filter matches {total:,} records; bulk updates allow at most "
912
+ f"{MAX_FILTERED_SELECTION_ROWS:,}. Narrow the filter and select all again")
913
+ if total == 0:
914
+ return [], 0
915
+
916
+ pages = [first]
917
+ for offset in range(datastore.WINDOW_MAX, total, datastore.WINDOW_MAX):
918
+ page = odoo_table_rows(table_key, session=session,
919
+ **_selection_rows_args(query, offset=offset,
920
+ limit=datastore.WINDOW_MAX))
921
+ if int(page.get("total") or 0) != total:
922
+ raise err(409, "selection_stale",
923
+ "the filtered result changed while it was being selected; review and select all again")
924
+ pages.append(page)
925
+ pids = [row.get("pid") for page in pages for row in (page.get("rows") or [])]
926
+ try:
927
+ fingerprint = _pid_fingerprint(pids)
928
+ except ValueError as exc:
929
+ raise err(409, "selection_stale", str(exc))
930
+ if len(pids) != total:
931
+ raise err(409, "selection_stale",
932
+ "the filtered result changed while it was being selected; review and select all again")
933
+ return pids, fingerprint
934
+
935
+
936
+ def _mint_selection_token(claims):
937
+ """A short, signed opaque descriptor; it carries no authority across user/tenant/table."""
938
+ import aios_session
939
+
940
+ raw = json.dumps(claims, separators=(",", ":"), sort_keys=True).encode("utf-8")
941
+ body = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
942
+ # Domain separation prevents an otherwise valid selection descriptor being confused with a
943
+ # session signature anywhere else in the process.
944
+ signature = aios_session._sign(b"filtered-selection:v1:" + raw)
945
+ return f"{body}.{signature}"
946
+
947
+
948
+ def _read_selection_token(token):
949
+ """Parse/verify an opaque descriptor without revealing which attacker-controlled part failed."""
950
+ import aios_session
951
+
952
+ if not isinstance(token, str) or len(token) > 16_384 or token.count(".") != 1:
953
+ return None
954
+ body, signature = token.split(".", 1)
955
+ try:
956
+ raw = base64.urlsafe_b64decode(body + "=" * (-len(body) % 4))
957
+ except Exception: # noqa: BLE001
958
+ return None
959
+ if not hmac.compare_digest(signature, aios_session._sign(b"filtered-selection:v1:" + raw)):
960
+ return None
961
+ try:
962
+ claims = json.loads(raw.decode("utf-8"))
963
+ except Exception: # noqa: BLE001
964
+ return None
965
+ return claims if isinstance(claims, dict) else None
966
+
967
+
968
+ @router.post("/odoo-tables/{table_key}/filtered-selection")
969
+ def create_filtered_selection(table_key: str, body: dict = Body(default=None),
970
+ session: Session = Depends(require_session)):
971
+ """Mint an opaque all-matching descriptor for a windowed grid.
972
+
973
+ The response count is the rows route's server count, and the descriptor fingerprints every
974
+ matching permitted PID. The browser never gets a fabricated set from its loaded page.
975
+ """
976
+ query = _selection_query(body)
977
+ pids, matched_or_fingerprint = _all_filtered_pids(table_key, query, session)
978
+ if not pids:
979
+ return {"ok": True, "selection": None, "matched": 0, "expiresAt": None}
980
+ now = int(time.time())
981
+ claims = {
982
+ "v": 1, "tenant": session.tenant, "user": session.uname, "table": table_key,
983
+ "query": query, "matched": len(pids), "fingerprint": matched_or_fingerprint,
984
+ "exp": now + FILTERED_SELECTION_SECONDS,
985
+ }
986
+ return {"ok": True, "selection": _mint_selection_token(claims), "matched": len(pids),
987
+ "expiresAt": claims["exp"]}
988
+
989
+
990
+ @router.post("/odoo-tables/{table_key}/filtered-bulk")
991
+ def apply_filtered_bulk(table_key: str, body: dict = Body(default=None),
992
+ session: Session = Depends(require_session)):
993
+ """Revalidate an all-matching descriptor and write one existing shared field atomically."""
994
+ import core.perm_scope as perm_scope
995
+ import core.shared_overlay as shared_overlay
996
+ import core.shares as shares
997
+ from routes_tables import _defn_or_refuse
998
+
999
+ body = body if isinstance(body, dict) else {}
1000
+ claims = _read_selection_token(body.get("selection"))
1001
+ if not claims:
1002
+ raise err(400, "bad_selection", "the filtered selection is invalid; select all again")
1003
+ if (claims.get("v") != 1 or claims.get("tenant") != session.tenant
1004
+ or claims.get("user") != session.uname or claims.get("table") != table_key):
1005
+ raise err(403, "selection_forbidden",
1006
+ "that filtered selection belongs to a different account, tenant, or database")
1007
+ if int(claims.get("exp") or 0) <= int(time.time()):
1008
+ raise err(409, "selection_stale", "the filtered selection expired; select all again")
1009
+ query = claims.get("query")
1010
+ try:
1011
+ query = _selection_query(query)
1012
+ except Exception:
1013
+ raise err(400, "bad_selection", "the filtered selection is invalid; select all again")
1014
+
1015
+ # Reuse the same compiler/walls and compare the actual matching set before touching a cell.
1016
+ pids, fingerprint = _all_filtered_pids(table_key, query, session)
1017
+ if (len(pids) != int(claims.get("matched") or -1)
1018
+ or fingerprint != claims.get("fingerprint")):
1019
+ raise err(409, "selection_stale",
1020
+ "the filtered result changed before this bulk update; review and select all again")
1021
+
1022
+ key = str(body.get("field") or "").strip()
1023
+ if not key:
1024
+ raise err(400, "bad_request", "a field key is required")
1025
+ # `_all_filtered_pids` already ran the read wall. This second, write-shaped check keeps a
1026
+ # projected definition from leaking into a write path and makes the capability explicit.
1027
+ _defn_or_refuse(session, table_key)
1028
+ if not shares.may_edit("database", table_key, session.uname, is_admin=session.admin,
1029
+ st=session.runtime):
1030
+ raise err(403, "forbidden",
1031
+ "an edit share on this database is required to update its shared field")
1032
+ definitions = shared_overlay.fields(table_key, st=session.runtime)
1033
+ if key not in definitions:
1034
+ raise err(400, "field_not_shared", "bulk updates on connected data require an existing shared field")
1035
+ if key in perm_scope.hidden_keys(session.user, table_key, list(definitions.values())):
1036
+ raise err(403, "field_hidden", "you do not have access to that field")
1037
+ value = body.get("value")
1038
+ if isinstance(value, (dict, list, tuple, set)):
1039
+ raise err(400, "bad_value", "a bulk value must be a scalar")
1040
+ try:
1041
+ written = shared_overlay.put_rows(table_key, {pid: {key: value} for pid in pids},
1042
+ st=session.runtime)
1043
+ except ValueError as exc:
1044
+ raise err(400, "bad_value", str(exc))
1045
+ return {"ok": True, "matched": len(pids), "rows_requested": len(pids),
1046
+ "rows_written": len(written),
1047
+ "cells_written": sum(len(cells) for cells in written.values())}
1048
+
1049
+
1050
  @router.get("/odoo-tables/{table_key}/rows")
1051
  def odoo_table_rows(table_key: str,
1052
  offset: int = Query(default=0, ge=0),
api/routes_products.py CHANGED
@@ -372,6 +372,11 @@ def product_assembly(session: Session, scope: str = "product", storage_key: str
372
  ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key,
373
  fields_base=fields_base)
374
 
 
 
 
 
 
375
  have = {field.get("key") for field in fields if isinstance(field, dict)}
376
  projected = []
377
  from core import shares
@@ -415,6 +420,8 @@ def product_assembly(session: Session, scope: str = "product", storage_key: str
415
  # would trade one honest absence for a spinner that never resolves.
416
  measure_sets = _measure_condition_sets(views, measures, rows_src, team_id, today)
417
 
 
 
418
  return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
419
  "fields": fields, "views": views, "lists": lists,
420
  "derived": derived,
@@ -439,6 +446,9 @@ def products(session: Session = Depends(module_gate(MODULE))):
439
  rows = aios_grid.rows_from_pool(
440
  g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
441
  rows = _seed_image(_seed_shared(rows, g["rows_src"], g["fields"]), g["fields"])
 
 
 
442
  # ⭐⭐ W41-T17 (owner instruction 25) β€” THE IDENTITY REPORT RIDES THE ENVELOPE, because a
443
  # report nothing reads is not a report. `product_data.identity_report` is standing rule 1's
444
  # second sentence as data for this grid's two identity columns: `product_id` (the Odoo
 
372
  ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key,
373
  fields_base=fields_base)
374
 
375
+ # W43: source-backed Product choices learn values in the shared overlay schema. Apply that
376
+ # durable vocabulary before the payload is served, so "Add option: et" is still available
377
+ # after a refresh and for collaborators, while canonical JSON choices retain their order.
378
+ fields = aios_grid.apply_choice_overrides(fields, shared_defs)
379
+
380
  have = {field.get("key") for field in fields if isinstance(field, dict)}
381
  projected = []
382
  from core import shares
 
420
  # would trade one honest absence for a spinner that never resolves.
421
  measure_sets = _measure_condition_sets(views, measures, rows_src, team_id, today)
422
 
423
+ from core import user_tables as _zones
424
+ fields = _zones.with_zone_field("product_data", fields, st=session.runtime)
425
  return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
426
  "fields": fields, "views": views, "lists": lists,
427
  "derived": derived,
 
446
  rows = aios_grid.rows_from_pool(
447
  g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
448
  rows = _seed_image(_seed_shared(rows, g["rows_src"], g["fields"]), g["fields"])
449
+ from core import user_tables as _zones
450
+ rows = _zones.zone_cells("product_data", rows, allowed_row_ids=g["pids"],
451
+ st=session.runtime)
452
  # ⭐⭐ W41-T17 (owner instruction 25) β€” THE IDENTITY REPORT RIDES THE ENVELOPE, because a
453
  # report nothing reads is not a report. `product_data.identity_report` is standing rule 1's
454
  # second sentence as data for this grid's two identity columns: `product_id` (the Odoo
api/routes_tables.py CHANGED
@@ -1340,6 +1340,9 @@ def ut_assembly(session: Session, table_key: str, storage_key: str = "",
1340
  # ⭐⭐ W36-T21 / R6 β€” the READ half, in `grid_assembly`'s own position: after `workspace_wire`,
1341
  # so the closure covers this user's `custom_` and `measure_` columns too.
1342
  fields, rows_src, hidden = _ut_field_wall(session, table_key, fields, rows_src, st=st)
 
 
 
1343
  if hidden and shared_cells:
1344
  # β›” BOTH WIRES, AND THE SHARED STRATUM IS A THIRD ONE. `strip_row` above cleaned the
1345
  # DEFINITION rows; these cells arrived by a different door and one narrowing cannot speak
@@ -2111,6 +2114,7 @@ def table_rows(table_key: str, session: Session = Depends(require_session)):
2111
  # so a tenant using `json` for a short config sees no change at all.
2112
  rows = aios_grid.rows_from_pool(
2113
  g["rows_src"], g["fields"], _thin_json(g["fields"], merged, table_key), derived=g["derived"])
 
2114
  # ⭐⭐ WAVE-34 (R13) β€” the per-cell enrichment STATE rides the row, beside `_created`/`lat`/
2115
  # `lon`. See `_stamp_ai_states` for why it is a row key rather than a map beside `rows`.
2116
  _stamp_ai_states(table_key, g["fields"], rows, st=_lent)
 
1340
  # ⭐⭐ W36-T21 / R6 β€” the READ half, in `grid_assembly`'s own position: after `workspace_wire`,
1341
  # so the closure covers this user's `custom_` and `measure_` columns too.
1342
  fields, rows_src, hidden = _ut_field_wall(session, table_key, fields, rows_src, st=st)
1343
+ # The canonical Zone field is derived from the same table-scoped store as the map DTO, so
1344
+ # FilterBuilder and saved Views use their ordinary multiselect path rather than a map-only one.
1345
+ fields = _ut().with_zone_field(table_key, fields, st=st)
1346
  if hidden and shared_cells:
1347
  # β›” BOTH WIRES, AND THE SHARED STRATUM IS A THIRD ONE. `strip_row` above cleaned the
1348
  # DEFINITION rows; these cells arrived by a different door and one narrowing cannot speak
 
2114
  # so a tenant using `json` for a short config sees no change at all.
2115
  rows = aios_grid.rows_from_pool(
2116
  g["rows_src"], g["fields"], _thin_json(g["fields"], merged, table_key), derived=g["derived"])
2117
+ rows = _ut().zone_cells(table_key, rows, allowed_row_ids=g["pids"], st=_lent)
2118
  # ⭐⭐ WAVE-34 (R13) β€” the per-cell enrichment STATE rides the row, beside `_created`/`lat`/
2119
  # `lon`. See `_stamp_ai_states` for why it is a row key rather than a map beside `rows`.
2120
  _stamp_ai_states(table_key, g["fields"], rows, st=_lent)
platform/aios_grid.py CHANGED
@@ -242,6 +242,47 @@ def _clean_options(raw):
242
  return out[:MAX_FIELD_OPTIONS]
243
 
244
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  def _clean_option_colors(raw, options):
246
  """Choice-label -> #RRGGBB, limited to the field's canonical option vocabulary."""
247
  if not isinstance(raw, dict):
@@ -500,7 +541,14 @@ def _clean_geocode(raw):
500
  # would make an identical record mean different things to two hosts. The host computes the
501
  # next occurrence and preview at projection time from this compact, durable shape.
502
  _RECURRENCE_KINDS = frozenset({'daily', 'weekly', 'monthly', 'yearly', 'holiday'})
503
- _PUBLIC_HOLIDAY_CALENDARS = frozenset({'US'})
 
 
 
 
 
 
 
504
 
505
 
506
  def _iso_day(value):
@@ -539,17 +587,63 @@ def _last_weekday(year, month, weekday):
539
  return day - _dt.timedelta(days=(day.weekday() - weekday) % 7)
540
 
541
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
542
  def public_holidays(calendar, year):
543
- """The supported public holiday calendar, returned as deterministic ISO-day objects.
544
 
545
  US federal holidays are intentionally calculated locally rather than fetched from a public
546
  endpoint during a render: a recurrence must keep its answer during an outage and historical
547
- previews must not move when a third party updates a feed. The field accepts other ISO
548
- country codes for forward compatibility, but an unavailable calendar yields no candidates
549
- rather than pretending it is the US calendar.
550
  """
551
- if str(calendar or '').upper() not in _PUBLIC_HOLIDAY_CALENDARS:
 
552
  return frozenset()
 
 
553
  days = {
554
  _observed(_dt.date(year, 1, 1)),
555
  _nth_weekday(year, 1, 0, 3), # Martin Luther King Jr. Day
@@ -598,10 +692,21 @@ def clean_date_recurrence(raw):
598
  and 0 <= day <= 6})
599
  rule['weekdays'] = weekdays or [start.weekday()]
600
  elif kind == 'monthly':
601
- day = raw_rule.get('day', start.day)
602
- if not isinstance(day, int) or isinstance(day, bool) or not 1 <= day <= 31:
603
- continue
604
- rule['day'] = day
 
 
 
 
 
 
 
 
 
 
 
605
  elif kind == 'yearly':
606
  month, day = raw_rule.get('month', start.month), raw_rule.get('day', start.day)
607
  if (not isinstance(month, int) or isinstance(month, bool) or not 1 <= month <= 12
@@ -615,9 +720,11 @@ def clean_date_recurrence(raw):
615
  rule.update({'month': month, 'day': day})
616
  elif kind == 'holiday':
617
  calendar = str(raw_rule.get('calendar') or 'US').strip().upper()
618
- if not re.fullmatch(r'[A-Z]{2}', calendar):
619
  continue
620
- offset = raw_rule.get('offset', 0)
 
 
621
  if not isinstance(offset, int) or isinstance(offset, bool) or offset not in (-1, 0, 1):
622
  continue
623
  rule.update({'calendar': calendar, 'offset': offset})
@@ -642,7 +749,14 @@ def _recurrence_matches(day, start, rule):
642
  return ((day - start).days // 7) % interval == 0 and day.weekday() in rule['weekdays']
643
  if kind == 'monthly':
644
  months = (day.year - start.year) * 12 + day.month - start.month
645
- return months >= 0 and months % interval == 0 and day.day == rule['day']
 
 
 
 
 
 
 
646
  if kind == 'yearly':
647
  return (day.year - start.year) % interval == 0 and day.month == rule['month'] and day.day == rule['day']
648
  return False
@@ -667,12 +781,15 @@ def recurrence_preview(raw, today=None, count=None):
667
  if any(_recurrence_matches(day, start, rule) for rule in ordinary_rules):
668
  found.add(day)
669
  for rule in holiday_rules:
670
- if (day.year - start.year) % rule['interval']:
671
- continue
672
- for holiday in public_holidays(rule['calendar'], day.year):
673
- occurrence = holiday + _dt.timedelta(days=rule['offset'])
674
- if occurrence == day and occurrence >= start:
675
- found.add(day)
 
 
 
676
  day += _dt.timedelta(days=1)
677
  return [item.isoformat() for item in sorted(found)[:want]]
678
 
@@ -923,6 +1040,22 @@ def fields_from_workspace(workspace=None, cohorts=False, scope_key=None, fields_
923
  field["measure"] = {"key": base["measure"]["key"], "window": window}
924
  if isinstance(meta.get("label"), str) and meta["label"].strip():
925
  field["label"] = meta["label"][:120]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
926
  out.append(field)
927
  for key, field in saved.items():
928
  if key in base_keys or not isinstance(field, dict):
 
242
  return out[:MAX_FIELD_OPTIONS]
243
 
244
 
245
+ def merge_choice_options(*sources):
246
+ """One canonical choice vocabulary from ordered option sources.
247
+
248
+ A source-backed column may learn values through an overlay while retaining contract choices.
249
+ Keeping the merge beside `_clean_options` makes the case-folding, 120-character label, and
250
+ `MAX_FIELD_OPTIONS` rules identical on the persistence and payload paths. The first spelling
251
+ wins: a later ``et`` therefore resolves to an existing ``ET`` rather than producing a second
252
+ visibly identical choice.
253
+ """
254
+ merged = []
255
+ for source in sources:
256
+ merged.extend(list(source or []))
257
+ return _clean_options(merged)
258
+
259
+
260
+ def apply_choice_overrides(fields, overrides):
261
+ """Return field copies with durable select-family option overrides applied.
262
+
263
+ `overrides` is deliberately only a field-key map, so a product route can use the tenant-wide
264
+ shared schema and a normal table workspace can use its personal source-column annotations.
265
+ It never changes a non-choice field, and it never replaces canonical choices with a stale
266
+ annotation: both vocabularies are merged through the one cleaner above.
267
+ """
268
+ saved = overrides if isinstance(overrides, dict) else {}
269
+ out = []
270
+ for raw in fields or []:
271
+ field = dict(raw) if isinstance(raw, dict) else raw
272
+ if not isinstance(field, dict) or field.get("type") not in ("select", "multiselect"):
273
+ out.append(field)
274
+ continue
275
+ extra = saved.get(field.get("key"))
276
+ if not isinstance(extra, dict) or "options" not in extra:
277
+ out.append(field)
278
+ continue
279
+ options = merge_choice_options(field.get("options"), extra.get("options"))
280
+ field["options"] = options
281
+ field.update(_choice_appearance(extra, options))
282
+ out.append(field)
283
+ return out
284
+
285
+
286
  def _clean_option_colors(raw, options):
287
  """Choice-label -> #RRGGBB, limited to the field's canonical option vocabulary."""
288
  if not isinstance(raw, dict):
 
541
  # would make an identical record mean different things to two hosts. The host computes the
542
  # next occurrence and preview at projection time from this compact, durable shape.
543
  _RECURRENCE_KINDS = frozenset({'daily', 'weekly', 'monthly', 'yearly', 'holiday'})
544
+ # A calendar appears here only when the server really evaluates it. The Date picker reads this
545
+ # same registry, so a selector cannot promise a country whose recurrence becomes a no-op later.
546
+ PUBLIC_HOLIDAY_CALENDARS = (
547
+ {'code': 'US', 'label': 'United States federal holidays'},
548
+ {'code': 'CA', 'label': 'Canada federal public holidays'},
549
+ )
550
+ _PUBLIC_HOLIDAY_CALENDARS = frozenset(item['code'] for item in PUBLIC_HOLIDAY_CALENDARS)
551
+ _HOLIDAY_RELATIONS = {'day_before': -1, 'day_on': 0, 'day_after': 1}
552
 
553
 
554
  def _iso_day(value):
 
587
  return day - _dt.timedelta(days=(day.weekday() - weekday) % 7)
588
 
589
 
590
+ def _easter_sunday(year):
591
+ """Gregorian computus, kept local so a recurrence preview needs no network."""
592
+ a = year % 19
593
+ b, c = divmod(year, 100)
594
+ d, e = divmod(b, 4)
595
+ f = (b + 8) // 25
596
+ g = (b - f + 1) // 3
597
+ h = (19 * a + b - d - g + 15) % 30
598
+ i, k = divmod(c, 4)
599
+ l = (32 + 2 * e + 2 * i - h - k) % 7
600
+ m = (a + 11 * h + 22 * l) // 451
601
+ month = (h + l - 7 * m + 114) // 31
602
+ day = (h + l - 7 * m + 114) % 31 + 1
603
+ return _dt.date(year, month, day)
604
+
605
+
606
+ def _canada_public_holidays(year):
607
+ """Canada's federal public-service holiday schedule, deterministically calculated.
608
+
609
+ This is deliberately not a claim about every provincial observance. Weekend fixed holidays
610
+ move to the next available weekday, including the Christmas/Boxing-Day collision.
611
+ """
612
+ days = {
613
+ _easter_sunday(year) - _dt.timedelta(days=2), # Good Friday
614
+ _last_weekday(year, 5, 0) - _dt.timedelta(days=7), # Monday before May 25
615
+ _nth_weekday(year, 9, 0, 1), # Labour Day
616
+ _nth_weekday(year, 10, 0, 2), # Thanksgiving
617
+ }
618
+ fixed = [_dt.date(year, 1, 1), _dt.date(year, 7, 1),
619
+ _dt.date(year, 11, 11), _dt.date(year, 12, 25), _dt.date(year, 12, 26)]
620
+ if year >= 2021:
621
+ fixed.append(_dt.date(year, 9, 30)) # Truth and Reconciliation
622
+ for holiday in fixed:
623
+ observed = holiday
624
+ if observed.weekday() == 5:
625
+ observed += _dt.timedelta(days=2)
626
+ elif observed.weekday() == 6:
627
+ observed += _dt.timedelta(days=1)
628
+ while observed in days:
629
+ observed += _dt.timedelta(days=1)
630
+ days.add(observed)
631
+ return frozenset(days)
632
+
633
+
634
  def public_holidays(calendar, year):
635
+ """A supported public holiday calendar, returned as deterministic ISO-day objects.
636
 
637
  US federal holidays are intentionally calculated locally rather than fetched from a public
638
  endpoint during a render: a recurrence must keep its answer during an outage and historical
639
+ previews must not move when a third party updates a feed. Unsupported calendars are
640
+ refused at the field door; the empty result here protects old malformed stored data.
 
641
  """
642
+ calendar = str(calendar or '').upper()
643
+ if calendar not in _PUBLIC_HOLIDAY_CALENDARS:
644
  return frozenset()
645
+ if calendar == 'CA':
646
+ return _canada_public_holidays(year)
647
  days = {
648
  _observed(_dt.date(year, 1, 1)),
649
  _nth_weekday(year, 1, 0, 3), # Martin Luther King Jr. Day
 
692
  and 0 <= day <= 6})
693
  rule['weekdays'] = weekdays or [start.weekday()]
694
  elif kind == 'monthly':
695
+ # ``day: 1`` means the first of every N months. ``ordinal`` + ``weekday`` adds
696
+ # Google-Calendar-style first Monday / last Friday without a competing model.
697
+ if 'ordinal' in raw_rule or 'weekday' in raw_rule:
698
+ ordinal, weekday = raw_rule.get('ordinal'), raw_rule.get('weekday')
699
+ if (not isinstance(ordinal, int) or isinstance(ordinal, bool)
700
+ or ordinal not in (-1, 1, 2, 3, 4, 5)
701
+ or not isinstance(weekday, int) or isinstance(weekday, bool)
702
+ or not 0 <= weekday <= 6):
703
+ continue
704
+ rule.update({'ordinal': ordinal, 'weekday': weekday})
705
+ else:
706
+ day = raw_rule.get('day', start.day)
707
+ if not isinstance(day, int) or isinstance(day, bool) or not 1 <= day <= 31:
708
+ continue
709
+ rule['day'] = day
710
  elif kind == 'yearly':
711
  month, day = raw_rule.get('month', start.month), raw_rule.get('day', start.day)
712
  if (not isinstance(month, int) or isinstance(month, bool) or not 1 <= month <= 12
 
720
  rule.update({'month': month, 'day': day})
721
  elif kind == 'holiday':
722
  calendar = str(raw_rule.get('calendar') or 'US').strip().upper()
723
+ if calendar not in _PUBLIC_HOLIDAY_CALENDARS:
724
  continue
725
+ relation = raw_rule.get('relation')
726
+ offset = (_HOLIDAY_RELATIONS.get(str(relation).strip().lower())
727
+ if relation is not None else raw_rule.get('offset', 0))
728
  if not isinstance(offset, int) or isinstance(offset, bool) or offset not in (-1, 0, 1):
729
  continue
730
  rule.update({'calendar': calendar, 'offset': offset})
 
749
  return ((day - start).days // 7) % interval == 0 and day.weekday() in rule['weekdays']
750
  if kind == 'monthly':
751
  months = (day.year - start.year) * 12 + day.month - start.month
752
+ if months < 0 or months % interval:
753
+ return False
754
+ if 'ordinal' not in rule:
755
+ return day.day == rule['day']
756
+ match = (_last_weekday(day.year, day.month, rule['weekday'])
757
+ if rule['ordinal'] == -1
758
+ else _nth_weekday(day.year, day.month, rule['weekday'], rule['ordinal']))
759
+ return match.month == day.month and match == day
760
  if kind == 'yearly':
761
  return (day.year - start.year) % interval == 0 and day.month == rule['month'] and day.day == rule['day']
762
  return False
 
781
  if any(_recurrence_matches(day, start, rule) for rule in ordinary_rules):
782
  found.add(day)
783
  for rule in holiday_rules:
784
+ # Day-before next New Year's Day belongs to the following holiday year. Looking at
785
+ # adjacent years makes those boundary dates real rather than silently absent.
786
+ for holiday_year in (day.year - 1, day.year, day.year + 1):
787
+ if holiday_year < start.year or (holiday_year - start.year) % rule['interval']:
788
+ continue
789
+ for holiday in public_holidays(rule['calendar'], holiday_year):
790
+ occurrence = holiday + _dt.timedelta(days=rule['offset'])
791
+ if occurrence == day and occurrence >= start:
792
+ found.add(day)
793
  day += _dt.timedelta(days=1)
794
  return [item.isoformat() for item in sorted(found)[:want]]
795
 
 
1040
  field["measure"] = {"key": base["measure"]["key"], "window": window}
1041
  if isinstance(meta.get("label"), str) and meta["label"].strip():
1042
  field["label"] = meta["label"][:120]
1043
+ # A source-backed choice field can learn vocabulary from accepted values. The annotation
1044
+ # is not a replacement for contract choices: an old workspace cannot make a currently
1045
+ # declared option disappear. This is the read half of TableStore's atomic overlay +
1046
+ # choice write, so an "Add option" survives a fresh payload rather than existing only in
1047
+ # the optimistic cell editor.
1048
+ if base.get("type") in ("select", "multiselect") and "options" in meta:
1049
+ options = merge_choice_options(base.get("options"), meta.get("options"))
1050
+ field["options"] = options
1051
+ field.update(_choice_appearance(meta, options))
1052
+ # Recurrence belongs to the Date FIELD, not to an individual ISO date cell. Source
1053
+ # fields need this explicit read half because their override is otherwise rebuilt from
1054
+ # the immutable contract on every workspace projection.
1055
+ if base.get("type") == "date" and "recurrence" in meta:
1056
+ recurrence = recurrence_payload(meta.get("recurrence"))
1057
+ if recurrence:
1058
+ field["recurrence"] = recurrence
1059
  out.append(field)
1060
  for key, field in saved.items():
1061
  if key in base_keys or not isinstance(field, dict):
platform/core/grid_events.py CHANGED
@@ -73,7 +73,8 @@ _DOC_MAX_PER_ROW = 50
73
  #: Host-only stamps (createdBy, permissions) are deliberately absent: the client renders
74
  #: nothing from its own copy of those, and a permissions refusal is answered explicitly.
75
  _FIELD_ECHO_PROPS = ('label', 'type', 'note', 'options', 'colorCodeOptions',
76
- 'optionColors', 'max', 'formula', 'agg', 'scope', 'format', 'measure')
 
77
 
78
 
79
  class StoreUnavailable(RuntimeError):
@@ -1236,6 +1237,29 @@ def handle_one(event, ctx):
1236
  seen.pop(stale, None)
1237
  kind = event.get('type')
1238
  field_by_key = {f['key']: f for f in fields}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1239
  valid_keys = set(field_by_key)
1240
  overlay_keys = {f['key'] for f in fields if f.get('source') == 'overlay'}
1241
  # Event validation is not a render payload. It must not consume a one-shot label
@@ -1906,6 +1930,22 @@ def handle_one(event, ctx):
1906
  # with no createdBy is admin-only (fail closed, both directions). The table workspace
1907
  # is per-user today, so this wall becomes load-bearing when sharing arrives β€” but the
1908
  # admin path and the legacy path are enforceable (and NC'd) right now.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1909
  needs_values = False
1910
  refused_perms = False
1911
  permission_sync = False
@@ -2851,6 +2891,11 @@ def handle_one(event, ctx):
2851
  refused = False
2852
  stage_moved = False
2853
  row_events = [] # C3 (wave 22): admitted writes, emitted AFTER they apply
 
 
 
 
 
2854
  raw_updates = event.get('updates') if isinstance(event.get('updates'), dict) else {}
2855
  for key, value in raw_updates.items():
2856
  if key not in overlay_keys or not isinstance(value, (str, int, float)):
@@ -3010,32 +3055,49 @@ def handle_one(event, ctx):
3010
  refused = True
3011
  definition = field_by_key.get(key) or {}
3012
  if definition.get('type') in ('select', 'multiselect') and clean:
3013
- options = [str(v) for v in definition.get('options') or []]
3014
- canonical = {v.strip().lower(): v for v in options}
3015
- if definition.get('type') == 'select':
3016
- normalized = canonical.get(clean.strip().lower())
3017
- if normalized is None:
3018
- refused = True
3019
- continue
3020
- else:
3021
- normalized_parts = []
3022
- seen = set()
3023
- invalid = False
3024
- for part in clean.split(','):
3025
- match = canonical.get(part.strip().lower())
3026
- if match is None:
 
 
 
 
 
 
 
 
 
 
3027
  invalid = True
3028
  break
3029
- if match.lower() not in seen:
3030
- seen.add(match.lower())
3031
- normalized_parts.append(match)
3032
- if invalid:
3033
- refused = True
3034
- continue
3035
- normalized = ','.join(normalized_parts)
 
 
 
 
 
3036
  if normalized != clean:
3037
  refused = True
3038
  clean = normalized
 
 
3039
  updates[key] = clean
3040
  if updates:
3041
  if str(ctx.scope_key or '').startswith('ut_') and ctx.table is not None:
@@ -3045,10 +3107,29 @@ def handle_one(event, ctx):
3045
  import core.user_tables as _ut_values
3046
  _ut_values.patch_cells(ctx.scope_key, pid, updates, st=ctx.table.st)
3047
  elif _store_of(ctx).available():
3048
- _tops(ctx).patch_overlay(uname, pid, updates)
 
3049
  else:
3050
  _session_ready()
3051
  ws['overlays'].setdefault(str(pid), {}).update(updates)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3052
  # C3 (wave 22): the admitted writes become ROW EVENTS β€” emitted AFTER they applied, on
3053
  # the ut scopes the event triggers watch this wave, through the seam in user_tables so
3054
  # this module never imports the engine. Human door only, which IS the loop law.
 
73
  #: Host-only stamps (createdBy, permissions) are deliberately absent: the client renders
74
  #: nothing from its own copy of those, and a permissions refusal is answered explicitly.
75
  _FIELD_ECHO_PROPS = ('label', 'type', 'note', 'options', 'colorCodeOptions',
76
+ 'optionColors', 'max', 'formula', 'agg', 'scope', 'format', 'measure',
77
+ 'recurrence')
78
 
79
 
80
  class StoreUnavailable(RuntimeError):
 
1237
  seen.pop(stale, None)
1238
  kind = event.get('type')
1239
  field_by_key = {f['key']: f for f in fields}
1240
+ # A Product shared-select option can have been admitted by an earlier event while this
1241
+ # EventCtx remains alive. The renderer gets the persisted choice projection on refresh,
1242
+ # but this write wall also has to learn it *before* it canonicalises a later paste/API
1243
+ # patch; otherwise ``ET`` followed by ``et`` sees an empty contract again and overwrites
1244
+ # the first spelling. This is intentionally a narrow vocabulary merge, never a second
1245
+ # field-definition reader: visibility, type and permissions remain the caller's served
1246
+ # schema.
1247
+ try:
1248
+ from core import shared_overlay as _shared_choices
1249
+ import aios_grid as _ag_choices
1250
+ _choice_table_key = getattr(getattr(ctx, 'table', None), 'table_key', None)
1251
+ if _choice_table_key:
1252
+ _stored_choices = _shared_choices.fields(_choice_table_key, st=_store_of(ctx)) or {}
1253
+ for _choice_key, _stored_def in _stored_choices.items():
1254
+ _served_def = field_by_key.get(_choice_key)
1255
+ if (_served_def and _served_def.get('type') in ('select', 'multiselect')
1256
+ and isinstance(_stored_def, dict)):
1257
+ _served_def['options'] = _ag_choices.merge_choice_options(
1258
+ _served_def.get('options'), _stored_def.get('options'))
1259
+ except Exception:
1260
+ # Field writes must not become unavailable merely because an optional shared-overlay
1261
+ # vocabulary is absent (ordinary customer and legacy in-memory contexts have none).
1262
+ pass
1263
  valid_keys = set(field_by_key)
1264
  overlay_keys = {f['key'] for f in fields if f.get('source') == 'overlay'}
1265
  # Event validation is not a render payload. It must not consume a one-shot label
 
1930
  # with no createdBy is admin-only (fail closed, both directions). The table workspace
1931
  # is per-user today, so this wall becomes load-bearing when sharing arrives β€” but the
1932
  # admin path and the legacy path are enforceable (and NC'd) right now.
1933
+ # W43: recurrence is configuration on a scalar Date field. The DatePicker sends the
1934
+ # complete server-shaped bag; an older field editor that omits it preserves the existing
1935
+ # setting rather than deleting a schedule merely by changing the note.
1936
+ if field.get('type') == 'date':
1937
+ recurrence_raw = raw.get('recurrence') if 'recurrence' in raw else field.get('recurrence')
1938
+ # An omitted recurrence is an older editor changing another Date property, so it
1939
+ # preserves the stored schedule. An explicit JSON null is the DatePicker's clear
1940
+ # command and must not fall through to that preservation branch.
1941
+ if 'recurrence' not in raw and recurrence_raw is None and not shared_field:
1942
+ previous = (ws.get('fields') or {}).get(key)
1943
+ recurrence_raw = previous.get('recurrence') if isinstance(previous, dict) else None
1944
+ recurrence = _ag2.clean_date_recurrence(recurrence_raw)
1945
+ if recurrence:
1946
+ field['recurrence'] = recurrence
1947
+ else:
1948
+ field.pop('recurrence', None)
1949
  needs_values = False
1950
  refused_perms = False
1951
  permission_sync = False
 
2891
  refused = False
2892
  stage_moved = False
2893
  row_events = [] # C3 (wave 22): admitted writes, emitted AFTER they apply
2894
+ # W43: labels learned from a normal cell edit are persisted with that cell. The actual
2895
+ # residency transaction belongs to TableStore / shared_overlay below; this map carries
2896
+ # only NEW canonical labels, after this wall has applied the same permission, case and cap
2897
+ # rules as every other choice write.
2898
+ choice_additions = {}
2899
  raw_updates = event.get('updates') if isinstance(event.get('updates'), dict) else {}
2900
  for key, value in raw_updates.items():
2901
  if key not in overlay_keys or not isinstance(value, (str, int, float)):
 
3055
  refused = True
3056
  definition = field_by_key.get(key) or {}
3057
  if definition.get('type') in ('select', 'multiselect') and clean:
3058
+ # A choice value is DATA, not a precondition the field editor must have visited.
3059
+ # Pasted/imported/API values therefore mint an option through this same write
3060
+ # door. It is deliberately still bounded and case-canonical: "ET" then "et"
3061
+ # is one option, and the 51st distinct label is refused rather than creating a
3062
+ # picker the contract cannot represent.
3063
+ import aios_grid as _ag_choices
3064
+ options = _ag_choices.merge_choice_options(definition.get('options'))
3065
+ canonical = {v.strip().casefold(): v for v in options}
3066
+ raw_parts = ([clean] if definition.get('type') == 'select'
3067
+ else clean.split(','))
3068
+ normalized_parts = []
3069
+ additions = []
3070
+ seen = set()
3071
+ invalid = False
3072
+ for part in raw_parts:
3073
+ candidate = _ag_choices._clean_options([part])
3074
+ if len(candidate) != 1:
3075
+ invalid = True
3076
+ break
3077
+ label = candidate[0]
3078
+ folded = label.casefold()
3079
+ match = canonical.get(folded)
3080
+ if match is None:
3081
+ if len(options) >= _ag_choices.MAX_FIELD_OPTIONS:
3082
  invalid = True
3083
  break
3084
+ match = label
3085
+ options.append(match)
3086
+ canonical[folded] = match
3087
+ additions.append(match)
3088
+ if folded not in seen:
3089
+ seen.add(folded)
3090
+ normalized_parts.append(match)
3091
+ if invalid:
3092
+ refused = True
3093
+ continue
3094
+ normalized = (normalized_parts[0] if definition.get('type') == 'select'
3095
+ else ','.join(normalized_parts))
3096
  if normalized != clean:
3097
  refused = True
3098
  clean = normalized
3099
+ if additions:
3100
+ choice_additions[key] = additions
3101
  updates[key] = clean
3102
  if updates:
3103
  if str(ctx.scope_key or '').startswith('ut_') and ctx.table is not None:
 
3107
  import core.user_tables as _ut_values
3108
  _ut_values.patch_cells(ctx.scope_key, pid, updates, st=ctx.table.st)
3109
  elif _store_of(ctx).available():
3110
+ _tops(ctx).patch_overlay_with_choice_options(
3111
+ uname, pid, updates, choice_additions)
3112
  else:
3113
  _session_ready()
3114
  ws['overlays'].setdefault(str(pid), {}).update(updates)
3115
+ if choice_additions:
3116
+ import aios_grid as _ag_choices
3117
+ for _key, _values in choice_additions.items():
3118
+ _prior = ws['fields'].get(_key)
3119
+ _entry = dict(_prior) if isinstance(_prior, dict) else {}
3120
+ _entry['options'] = _ag_choices.merge_choice_options(
3121
+ _entry.get('options'), _values)
3122
+ ws['fields'][_key] = _entry
3123
+ # Keep this long-lived event context in sync too. The following event may be a
3124
+ # paste/API retry before the next field projection is fetched, and it must retain
3125
+ # the original option's casing rather than treating it as another new choice.
3126
+ if choice_additions:
3127
+ import aios_grid as _ag_choices
3128
+ for _key, _values in choice_additions.items():
3129
+ _definition = field_by_key.get(_key)
3130
+ if _definition is not None:
3131
+ _definition['options'] = _ag_choices.merge_choice_options(
3132
+ _definition.get('options'), _values)
3133
  # C3 (wave 22): the admitted writes become ROW EVENTS β€” emitted AFTER they applied, on
3134
  # the ut scopes the event triggers watch this wave, through the seam in user_tables so
3135
  # this module never imports the engine. Human door only, which IS the loop law.
platform/core/shared_overlay.py CHANGED
@@ -101,7 +101,7 @@ ALLOWED_FIELD_KEYS = frozenset({
101
  'note', 'description', '_note',
102
  # display and behaviour
103
  'format', 'agg', 'options', 'optionColors', 'colorCodeOptions', 'default', 'pinned',
104
- 'filterable', 'multi', 'max', 'derived', 'preset', 'custom', 'profile', 'link',
105
  # the created strata's computation bags
106
  'formula', 'measure', 'metric', 'rollup', 'geocode', 'code', 'automation', 'aiEnrich',
107
  'scope',
@@ -449,6 +449,39 @@ def put_cells(table_key, pid, values, st=None):
449
  return clean
450
 
451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  def put_rows(table_key, rows, st=None):
453
  """Write shared cells on MANY rows in ONE store update. Returns `{row_id: {key: value}}`.
454
 
 
101
  'note', 'description', '_note',
102
  # display and behaviour
103
  'format', 'agg', 'options', 'optionColors', 'colorCodeOptions', 'default', 'pinned',
104
+ 'filterable', 'multi', 'max', 'derived', 'preset', 'custom', 'profile', 'link', 'recurrence',
105
  # the created strata's computation bags
106
  'formula', 'measure', 'metric', 'rollup', 'geocode', 'code', 'automation', 'aiEnrich',
107
  'scope',
 
449
  return clean
450
 
451
 
452
+ def put_cells_and_choice_options(table_key, pid, values, choice_values, st=None):
453
+ """Write shared cells plus their newly admitted choice labels in ONE mutation.
454
+
455
+ Product contract columns such as ``sub_category`` have shared values and therefore need their
456
+ learned vocabulary in this same shared schema document. A separate `put_cells` then
457
+ `put_field` sequence could leave a durable value that no fresh picker recognises if the second
458
+ write fails. The caller has already validated the labels; this function only merges them
459
+ atomically with the values using the canonical choice cleaner.
460
+ """
461
+ row_id = _pid(pid)
462
+ clean = {str(k): _value(v) for k, v in dict(values or {}).items() if str(k)}
463
+ additions = {str(key): list(labels or ()) for key, labels in
464
+ (choice_values or {}).items() if str(key) and labels}
465
+ if not clean:
466
+ return {}
467
+
468
+ def _patch(data):
469
+ data['cells'].setdefault(row_id, {}).update(clean)
470
+ if not additions:
471
+ return
472
+ # `aios_grid` deliberately has no app-internal imports; this core persistence layer can
473
+ # consume its pure normaliser to keep case folding and the 50-option cap identical.
474
+ from aios_grid import merge_choice_options
475
+ for key, labels in additions.items():
476
+ prior = data['fields'].get(key)
477
+ entry = dict(prior) if isinstance(prior, dict) else {'key': key}
478
+ entry['options'] = merge_choice_options(entry.get('options'), labels)
479
+ data['fields'][key] = entry
480
+
481
+ _write(table_key, _patch, st)
482
+ return clean
483
+
484
+
485
  def put_rows(table_key, rows, st=None):
486
  """Write shared cells on MANY rows in ONE store update. Returns `{row_id: {key: value}}`.
487
 
platform/core/table_store.py CHANGED
@@ -189,7 +189,15 @@ def source_override_is_empty(payload):
189
  return (not str(payload.get('note') or '').strip()
190
  and not isinstance(payload.get('measure'), dict)
191
  and not isinstance(payload.get('format'), dict)
192
- and not str(payload.get('agg') or '').strip())
 
 
 
 
 
 
 
 
193
 
194
 
195
  def _unique_name(wanted, existing, *, fallback='Untitled', max_len=120):
@@ -1138,6 +1146,36 @@ class TableStore:
1138
 
1139
  self._update(username, _patch)
1140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1141
 
1142
  def make(table_key, st=None):
1143
  return TableStore(table_key, st=st)
 
189
  return (not str(payload.get('note') or '').strip()
190
  and not isinstance(payload.get('measure'), dict)
191
  and not isinstance(payload.get('format'), dict)
192
+ and not str(payload.get('agg') or '').strip()
193
+ # W43: a learned source-column vocabulary is durable field state. Dropping an
194
+ # otherwise-empty override here made a successfully added option disappear on the
195
+ # next payload, which is indistinguishable from an input that never saved.
196
+ and not isinstance(payload.get('options'), list)
197
+ # A source Date field owns recurrence metadata. It is durable configuration just
198
+ # like display format, not a generated cell value, so a note-less field must retain
199
+ # the override that carries it.
200
+ and not isinstance(payload.get('recurrence'), dict))
201
 
202
 
203
  def _unique_name(wanted, existing, *, fallback='Untitled', max_len=120):
 
1146
 
1147
  self._update(username, _patch)
1148
 
1149
+ def patch_overlay_with_choice_options(self, username, pid, updates, choice_values):
1150
+ """Persist cell updates and newly admitted choice labels in one workspace update.
1151
+
1152
+ The event wall has already checked field visibility, edit permission, label size, and the
1153
+ 50-choice cap. This method owns only residency and atomicity: an overlay value must never
1154
+ commit while the field vocabulary remains stale, because a refresh would render a cell the
1155
+ picker cannot select.
1156
+ """
1157
+ clean = dict(updates or {})
1158
+ additions = {str(key): list(values or ()) for key, values in
1159
+ (choice_values or {}).items() if str(key) and values}
1160
+ if not clean:
1161
+ return
1162
+
1163
+ def _patch(ws):
1164
+ ws['overlays'].setdefault(str(int(pid)), {}).update(clean)
1165
+ if not additions:
1166
+ return
1167
+ # Import here rather than at module load: `aios_grid` is intentionally independent
1168
+ # of `core`, while this lower persistence layer may use its pure canonical cleaner.
1169
+ from aios_grid import merge_choice_options
1170
+ fields = ws.setdefault('fields', {})
1171
+ for key, values in additions.items():
1172
+ prior = fields.get(key)
1173
+ entry = dict(prior) if isinstance(prior, dict) else {}
1174
+ entry['options'] = merge_choice_options(entry.get('options'), values)
1175
+ fields[key] = entry
1176
+
1177
+ self._update(username, _patch)
1178
+
1179
 
1180
  def make(table_key, st=None):
1181
  return TableStore(table_key, st=st)
platform/core/user_tables.py CHANGED
@@ -55,6 +55,9 @@ import core.store as store
55
 
56
  STORE_KEY = 'user_tables'
57
  ZONE_STORE_KEY = 'table_zones'
 
 
 
58
  MAX_TABLES = 40
59
  MAX_LABEL = 60
60
 
@@ -1198,7 +1201,9 @@ def clean_zone(raw, field_keys=None, allowed_row_ids=None):
1198
  """One exact durable Zone shape, or ``None``. Unknown keys never reach the store."""
1199
  if not isinstance(raw, dict):
1200
  return None
1201
- name = ' '.join(str(raw.get('name') or '').split())[:120]
 
 
1202
  geometry = _clean_zone_geometry(raw.get('geometryGeoJson'))
1203
  if not name or geometry is None:
1204
  return None
@@ -1229,6 +1234,12 @@ def clean_zone(raw, field_keys=None, allowed_row_ids=None):
1229
  seen.add(pid)
1230
  memberships.append(pid)
1231
  out['memberships'] = memberships
 
 
 
 
 
 
1232
  return out
1233
 
1234
 
@@ -1249,7 +1260,8 @@ def zones(table_key, st=None):
1249
  return out
1250
 
1251
 
1252
- def upsert_zone(table_key, raw, field_keys=None, allowed_row_ids=None, st=None):
 
1253
  """Create/update a zone by id; the returned object is the exact persisted wire shape."""
1254
  key = str(table_key or '').strip()
1255
  if not key:
@@ -1267,22 +1279,69 @@ def upsert_zone(table_key, raw, field_keys=None, allowed_row_ids=None, st=None):
1267
  next_zones = []
1268
  for zone in existing[:200]:
1269
  if isinstance(zone, dict) and zone.get('id') == clean['id']:
1270
- next_zones.append(dict(clean))
 
 
1271
  replaced = True
1272
  else:
1273
  next_zones.append(zone)
1274
  if not replaced:
1275
  if len(next_zones) >= 200:
1276
  return current
1277
- next_zones.append(dict(clean))
 
 
1278
  bucket[key] = next_zones
1279
- result.update(clean)
 
 
1280
  return bucket
1281
 
1282
  _st(st).update(ZONE_STORE_KEY, _save, flush='sync')
1283
  return result or None
1284
 
1285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1286
  def delete_zone(table_key, zone_id, st=None):
1287
  """Delete only this named zone; no prefix/glob deletion exists in this contract."""
1288
  key, zid = str(table_key or '').strip(), str(zone_id or '').strip()
 
55
 
56
  STORE_KEY = 'user_tables'
57
  ZONE_STORE_KEY = 'table_zones'
58
+ # A reserved, derived multi-select field. It is table data backed by durable Zone membership,
59
+ # not a user-editable column and therefore cannot collide with a field a user creates.
60
+ ZONE_FIELD_KEY = '__zone__'
61
  MAX_TABLES = 40
62
  MAX_LABEL = 60
63
 
 
1201
  """One exact durable Zone shape, or ``None``. Unknown keys never reach the store."""
1202
  if not isinstance(raw, dict):
1203
  return None
1204
+ # A multiselect cell is comma-delimited across the whole grid/filter contract, so commas
1205
+ # cannot be legal inside one Zone label without making one Zone appear as two filter choices.
1206
+ name = ' '.join(str(raw.get('name') or '').replace(',', ' ').split())[:120]
1207
  geometry = _clean_zone_geometry(raw.get('geometryGeoJson'))
1208
  if not name or geometry is None:
1209
  return None
 
1234
  seen.add(pid)
1235
  memberships.append(pid)
1236
  out['memberships'] = memberships
1237
+ creator = str(raw.get('createdBy') or '').strip()[:120]
1238
+ created_at = str(raw.get('createdAt') or '').strip()[:64]
1239
+ if creator:
1240
+ out['createdBy'] = creator
1241
+ if created_at:
1242
+ out['createdAt'] = created_at
1243
  return out
1244
 
1245
 
 
1260
  return out
1261
 
1262
 
1263
+ def upsert_zone(table_key, raw, field_keys=None, allowed_row_ids=None, st=None,
1264
+ created_by=None):
1265
  """Create/update a zone by id; the returned object is the exact persisted wire shape."""
1266
  key = str(table_key or '').strip()
1267
  if not key:
 
1279
  next_zones = []
1280
  for zone in existing[:200]:
1281
  if isinstance(zone, dict) and zone.get('id') == clean['id']:
1282
+ next_zones.append({**clean,
1283
+ 'createdBy': zone.get('createdBy') or str(created_by or ''),
1284
+ 'createdAt': zone.get('createdAt') or _dt.datetime.utcnow().isoformat()})
1285
  replaced = True
1286
  else:
1287
  next_zones.append(zone)
1288
  if not replaced:
1289
  if len(next_zones) >= 200:
1290
  return current
1291
+ next_zones.append({**clean,
1292
+ 'createdBy': str(created_by or ''),
1293
+ 'createdAt': _dt.datetime.utcnow().isoformat()})
1294
  bucket[key] = next_zones
1295
+ persisted = next((zone for zone in next_zones
1296
+ if isinstance(zone, dict) and zone.get('id') == clean['id']), clean)
1297
+ result.update(persisted)
1298
  return bucket
1299
 
1300
  _st(st).update(ZONE_STORE_KEY, _save, flush='sync')
1301
  return result or None
1302
 
1303
 
1304
+ def zone_field(table_key, st=None):
1305
+ """The canonical derived multi-select Field for one table's durable Zones."""
1306
+ names, seen = [], set()
1307
+ for zone in zones(table_key, st=st):
1308
+ name = str(zone.get('name') or '').strip()
1309
+ if name and name not in seen:
1310
+ names.append(name)
1311
+ seen.add(name)
1312
+ return {'key': ZONE_FIELD_KEY, 'label': 'Zone', 'type': 'multiselect', 'source': 'odoo',
1313
+ 'derived': True, 'multi': True, 'default': True, 'options': names,
1314
+ 'colorCodeOptions': True}
1315
+
1316
+
1317
+ def with_zone_field(table_key, fields, st=None):
1318
+ """Append the table's derived Zone field once, without mutating a shared field list."""
1319
+ base = [dict(field) if isinstance(field, dict) else field for field in (fields or ())
1320
+ if not (isinstance(field, dict) and field.get('key') == ZONE_FIELD_KEY)]
1321
+ return base + [zone_field(table_key, st=st)]
1322
+
1323
+
1324
+ def zone_cells(table_key, rows, allowed_row_ids=None, st=None):
1325
+ """Layer canonical Zone memberships into ordinary row cells for FilterBuilder/evaluator use."""
1326
+ allowed = ({str(pid) for pid in allowed_row_ids} if allowed_row_ids is not None else None)
1327
+ labels_by_pid = {}
1328
+ for zone in zones(table_key, st=st):
1329
+ name = str(zone.get('name') or '').strip()
1330
+ if not name:
1331
+ continue
1332
+ for pid in zone.get('memberships') or ():
1333
+ key = str(pid)
1334
+ if allowed is not None and key not in allowed:
1335
+ continue
1336
+ labels_by_pid.setdefault(key, []).append(name)
1337
+ out = []
1338
+ for row in rows or ():
1339
+ item = dict(row)
1340
+ item[ZONE_FIELD_KEY] = ', '.join(labels_by_pid.get(str(item.get('pid')), ()))
1341
+ out.append(item)
1342
+ return out
1343
+
1344
+
1345
  def delete_zone(table_key, zone_id, st=None):
1346
  """Delete only this named zone; no prefix/glob deletion exists in this contract."""
1347
  key, zid = str(table_key or '').strip(), str(zone_id or '').strip()
platform/modules/product_data.py CHANGED
@@ -288,6 +288,32 @@ class _ProductTableStore(table_store.TableStore):
288
  if personal:
289
  super().patch_overlay(username, pid, personal)
290
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
 
292
  TABLE_OPS = _ProductTableStore(TABLE_KEY)
293
 
 
288
  if personal:
289
  super().patch_overlay(username, pid, personal)
290
 
291
+ def patch_overlay_with_choice_options(self, username, pid, updates, choice_values):
292
+ """Keep a Product cell and its select vocabulary in the same residency transaction.
293
+
294
+ `sub_category` is a contract-declared shared overlay column. Its values cannot go into a
295
+ person's workspace, and its options cannot either: a colleague must see the newly added
296
+ option after refreshing. Personal Product fields still use TableStore's one-workspace
297
+ transaction through the parent implementation.
298
+ """
299
+ clean = dict(updates or {})
300
+ additions = {str(key): list(values or ()) for key, values in
301
+ (choice_values or {}).items() if str(key) and values}
302
+ if not clean:
303
+ return
304
+ keys = set(SHARED_KEYS())
305
+ keys.update(shared_overlay.fields(self.table_key, st=self.st) or {})
306
+ shared = {k: v for k, v in clean.items() if k in keys}
307
+ personal = {k: v for k, v in clean.items() if k not in keys}
308
+ shared_choices = {k: v for k, v in additions.items() if k in keys}
309
+ personal_choices = {k: v for k, v in additions.items() if k not in keys}
310
+ if shared:
311
+ shared_overlay.put_cells_and_choice_options(
312
+ TABLE_KEY, pid, shared, shared_choices, st=self.st)
313
+ if personal:
314
+ super().patch_overlay_with_choice_options(
315
+ username, pid, personal, personal_choices)
316
+
317
 
318
  TABLE_OPS = _ProductTableStore(TABLE_KEY)
319
 
web/src/customer-grid/ColumnMenu.tsx CHANGED
@@ -100,7 +100,7 @@ interface FieldConfigExtra {
100
  geocode?: { addressField: string; country?: string };
101
  /** ⭐ 2026-08-07 β€” a `link` column's target. REQUIRED for the type: `_clean_field` returns
102
  * None for a link with no bag, so a create without this is a column that never appears. */
103
- link?: { table: string; single?: boolean };
104
  /** ⭐ 2026-08-07 β€” a `rollup` column's aggregate. Same requirement, same reason.
105
  * ⭐ 2026-08-09 β€” or, INSTEAD, a `source` binding: the read-through kind, which summarises
106
  * rows that were never copied into the workspace. The host's `_clean_rollup` reads `source`
@@ -1242,6 +1242,8 @@ function ExtraTypeEditor({
1242
  onLinkTable,
1243
  linkSingle = false,
1244
  onLinkSingle,
 
 
1245
  rollupMode = "link",
1246
  onRollupMode,
1247
  rollupSourceOffer = { topics: [], windows: [] },
@@ -1308,6 +1310,8 @@ function ExtraTypeEditor({
1308
  onLinkTable?: (v: string) => void;
1309
  linkSingle?: boolean;
1310
  onLinkSingle?: (v: boolean) => void;
 
 
1311
  /** ⭐ 2026-08-09 β€” which KIND of rollup is being built. `"link"` folds workspace rows;
1312
  * `"source"` reads through to a governed Odoo topic without copying a row into the store. */
1313
  rollupMode?: "link" | "source";
@@ -1678,11 +1682,28 @@ function ExtraTypeEditor({
1678
  There is no other database to link to yet. Create one first, then come back.
1679
  </div>
1680
  ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1681
  <label className="cg-cond-line">
1682
  <input
1683
  type="checkbox"
1684
  checked={linkSingle}
1685
  aria-label="Allow only one linked record"
 
1686
  onChange={(event) => onLinkSingle?.(event.target.checked)}
1687
  />
1688
  <span>Allow only one linked record</span>
@@ -2237,6 +2258,7 @@ export default function ColumnMenu({
2237
  */
2238
  const [linkTable, setLinkTable] = useState("");
2239
  const [linkSingle, setLinkSingle] = useState(false);
 
2240
  /** ⭐ 2026-08-09 β€” the read-through half. `rollupMode` decides which bag `extraFor` builds;
2241
  * the two are mutually exclusive because the host's `_clean_rollup` returns the `source`
2242
  * shape before it ever reads `link`. */
@@ -2355,6 +2377,9 @@ export default function ColumnMenu({
2355
  });
2356
  const [editLinkTable, setEditLinkTable] = useState(() => field.link?.table ?? "");
2357
  const [editLinkSingle, setEditLinkSingle] = useState(() => field.link?.single === true);
 
 
 
2358
 
2359
  const swapToNewType = swapTo.startsWith(NEW_PREFIX)
2360
  ? (swapTo.slice(NEW_PREFIX.length) as FieldType)
@@ -2512,7 +2537,8 @@ export default function ColumnMenu({
2512
  // picks records). The DERIVED mode is the automation's, spawned with its join declared β€”
2513
  // offering "join on a column of the other table" here would be a second, harder mental
2514
  // model for the same button.
2515
- if (t === "link") return { link: { table: linkTable, ...(linkSingle ? { single: true } : {}) } };
 
2516
  if (t === "rollup" && rollupMode === "source")
2517
  // β›” THE SOURCE BAG TRAVELS ALONE. `_clean_rollup` reads `source` first and returns
2518
  // immediately, so sending `link`/`fn` alongside it would ship keys the store drops β€”
@@ -2748,7 +2774,8 @@ export default function ColumnMenu({
2748
  (field.type === "rollup"
2749
  ? JSON.stringify(editRollup) !== JSON.stringify(field.rollup ?? {})
2750
  : editLinkTable !== (field.link?.table ?? "") ||
2751
- editLinkSingle !== (field.link?.single === true));
 
2752
  const canSaveEdit =
2753
  /**
2754
  * ⭐ W41-T31 β€” THE DESCRIPTION-ONLY PANE'S WHOLE SAVE CONDITION, and it is one dirty flag
@@ -2821,6 +2848,7 @@ export default function ColumnMenu({
2821
  ...(field.type === "rollup"
2822
  ? { rollup: editRollup as Record<string, unknown> }
2823
  : { link: { table: editLinkTable,
 
2824
  ...(editLinkSingle ? { single: true } : {}) } }),
2825
  });
2826
  if (editNoteDirty) onNote(note);
@@ -3179,6 +3207,8 @@ export default function ColumnMenu({
3179
  onLinkTable={setLinkTable}
3180
  linkSingle={linkSingle}
3181
  onLinkSingle={setLinkSingle}
 
 
3182
  rollupMode={rollupMode}
3183
  onRollupMode={setRollupMode}
3184
  rollupSourceOffer={rollupSourceOffer}
@@ -3488,6 +3518,8 @@ export default function ColumnMenu({
3488
  onLinkTable={setEditLinkTable}
3489
  linkSingle={editLinkSingle}
3490
  onLinkSingle={setEditLinkSingle}
 
 
3491
  rollupSourceOffer={rollupSourceOffer}
3492
  rollupMode={editRollup.source ? "source" : "link"}
3493
  // β›” A MODE SWITCH REPLACES THE BAG, it does not merge into it. `_clean_rollup`
 
100
  geocode?: { addressField: string; country?: string };
101
  /** ⭐ 2026-08-07 β€” a `link` column's target. REQUIRED for the type: `_clean_field` returns
102
  * None for a link with no bag, so a create without this is a column that never appears. */
103
+ link?: { table: string; single?: boolean; selectionMode?: "manual" | "automatic" };
104
  /** ⭐ 2026-08-07 β€” a `rollup` column's aggregate. Same requirement, same reason.
105
  * ⭐ 2026-08-09 β€” or, INSTEAD, a `source` binding: the read-through kind, which summarises
106
  * rows that were never copied into the workspace. The host's `_clean_rollup` reads `source`
 
1242
  onLinkTable,
1243
  linkSingle = false,
1244
  onLinkSingle,
1245
+ linkSelectionMode = "manual",
1246
+ onLinkSelectionMode,
1247
  rollupMode = "link",
1248
  onRollupMode,
1249
  rollupSourceOffer = { topics: [], windows: [] },
 
1310
  onLinkTable?: (v: string) => void;
1311
  linkSingle?: boolean;
1312
  onLinkSingle?: (v: boolean) => void;
1313
+ linkSelectionMode?: "manual" | "automatic";
1314
+ onLinkSelectionMode?: (v: "manual" | "automatic") => void;
1315
  /** ⭐ 2026-08-09 β€” which KIND of rollup is being built. `"link"` folds workspace rows;
1316
  * `"source"` reads through to a governed Odoo topic without copying a row into the store. */
1317
  rollupMode?: "link" | "source";
 
1682
  There is no other database to link to yet. Create one first, then come back.
1683
  </div>
1684
  ) : null}
1685
+ <label>
1686
+ <span>How links are chosen</span>
1687
+ <select
1688
+ id={`${idPrefix}-link-selection-mode`}
1689
+ className="cg-input"
1690
+ value={linkSelectionMode}
1691
+ aria-label="How linked records are selected"
1692
+ onPointerDown={(event) => event.stopPropagation()}
1693
+ onChange={(event) => onLinkSelectionMode?.(
1694
+ event.target.value === "automatic" ? "automatic" : "manual"
1695
+ )}
1696
+ >
1697
+ <option value="manual">Nothing, I will pick the records myself</option>
1698
+ <option value="automatic">Set links automatically</option>
1699
+ </select>
1700
+ </label>
1701
  <label className="cg-cond-line">
1702
  <input
1703
  type="checkbox"
1704
  checked={linkSingle}
1705
  aria-label="Allow only one linked record"
1706
+ onPointerDown={(event) => event.stopPropagation()}
1707
  onChange={(event) => onLinkSingle?.(event.target.checked)}
1708
  />
1709
  <span>Allow only one linked record</span>
 
2258
  */
2259
  const [linkTable, setLinkTable] = useState("");
2260
  const [linkSingle, setLinkSingle] = useState(false);
2261
+ const [linkSelectionMode, setLinkSelectionMode] = useState<"manual" | "automatic">("manual");
2262
  /** ⭐ 2026-08-09 β€” the read-through half. `rollupMode` decides which bag `extraFor` builds;
2263
  * the two are mutually exclusive because the host's `_clean_rollup` returns the `source`
2264
  * shape before it ever reads `link`. */
 
2377
  });
2378
  const [editLinkTable, setEditLinkTable] = useState(() => field.link?.table ?? "");
2379
  const [editLinkSingle, setEditLinkSingle] = useState(() => field.link?.single === true);
2380
+ const [editLinkSelectionMode, setEditLinkSelectionMode] = useState<"manual" | "automatic">(
2381
+ () => field.link?.selectionMode === "automatic" ? "automatic" : "manual"
2382
+ );
2383
 
2384
  const swapToNewType = swapTo.startsWith(NEW_PREFIX)
2385
  ? (swapTo.slice(NEW_PREFIX.length) as FieldType)
 
2537
  // picks records). The DERIVED mode is the automation's, spawned with its join declared β€”
2538
  // offering "join on a column of the other table" here would be a second, harder mental
2539
  // model for the same button.
2540
+ if (t === "link") return { link: { table: linkTable, selectionMode: linkSelectionMode,
2541
+ ...(linkSingle ? { single: true } : {}) } };
2542
  if (t === "rollup" && rollupMode === "source")
2543
  // β›” THE SOURCE BAG TRAVELS ALONE. `_clean_rollup` reads `source` first and returns
2544
  // immediately, so sending `link`/`fn` alongside it would ship keys the store drops β€”
 
2774
  (field.type === "rollup"
2775
  ? JSON.stringify(editRollup) !== JSON.stringify(field.rollup ?? {})
2776
  : editLinkTable !== (field.link?.table ?? "") ||
2777
+ editLinkSingle !== (field.link?.single === true) ||
2778
+ editLinkSelectionMode !== (field.link?.selectionMode === "automatic" ? "automatic" : "manual"));
2779
  const canSaveEdit =
2780
  /**
2781
  * ⭐ W41-T31 β€” THE DESCRIPTION-ONLY PANE'S WHOLE SAVE CONDITION, and it is one dirty flag
 
2848
  ...(field.type === "rollup"
2849
  ? { rollup: editRollup as Record<string, unknown> }
2850
  : { link: { table: editLinkTable,
2851
+ selectionMode: editLinkSelectionMode,
2852
  ...(editLinkSingle ? { single: true } : {}) } }),
2853
  });
2854
  if (editNoteDirty) onNote(note);
 
3207
  onLinkTable={setLinkTable}
3208
  linkSingle={linkSingle}
3209
  onLinkSingle={setLinkSingle}
3210
+ linkSelectionMode={linkSelectionMode}
3211
+ onLinkSelectionMode={setLinkSelectionMode}
3212
  rollupMode={rollupMode}
3213
  onRollupMode={setRollupMode}
3214
  rollupSourceOffer={rollupSourceOffer}
 
3518
  onLinkTable={setEditLinkTable}
3519
  linkSingle={editLinkSingle}
3520
  onLinkSingle={setEditLinkSingle}
3521
+ linkSelectionMode={editLinkSelectionMode}
3522
+ onLinkSelectionMode={setEditLinkSelectionMode}
3523
  rollupSourceOffer={rollupSourceOffer}
3524
  rollupMode={editRollup.source ? "source" : "link"}
3525
  // β›” A MODE SWITCH REPLACES THE BAG, it does not merge into it. `_clean_rollup`
web/src/customer-grid/CustomerGrid.tsx CHANGED
@@ -94,7 +94,7 @@ import { API_V1, CREDENTIALS, NAV_MINIMIZE_EVENT, ROWS_STALE_EVENT, TOAST_EVENT,
94
  from "../apiContract";
95
  import type { ViewOpenDetail } from "../apiContract";
96
  import { addTableField, addTableRow, clearTopicRowsCache, deleteRouteOrderField, deleteTableField,
97
- deleteTableRow, fetchLinkTargets,
98
  fetchRollupSources, patchRouteOrderField, patchTableField, enrichField } from "./apiBridge";
99
  import type { LinkTarget, RollupSourceOffer } from "./apiBridge";
100
  import SelectFromFile from "./SelectFromFile";
@@ -133,6 +133,7 @@ import { DashboardView } from "../viz/DashboardView";
133
  import { cleanCharts } from "../viz/chartData";
134
  import type { ChartSpec } from "../viz/chartData";
135
  import { MapView } from "./MapView";
 
136
  /* ═══ W18-C CATALOG ═══ (owner item 4, contract C6) */
137
  import { CatalogView } from "./CatalogView";
138
  import { CATALOG_CODE_FIELD } from "./catalogData";
@@ -166,6 +167,7 @@ import { ALL_VIEW_ID, MAX_CALENDAR_METRICS, allViewName,
166
  isPickType, mayEditField,
167
  isModeFrozen, isUndeletableView, mayEditView, mayToggleViewLock,
168
  measureColumnIndex, ratingMax, ruleColumnKeys,
 
169
  tableMode, uniqueDisplayName,
170
  /* ⭐ W41-T38 β€” the rail's OWN name resolver, so a collision test compares what the reader
171
  actually sees. The rail has always drawn `viewDisplayName(view)`; `renameView` was testing
@@ -264,6 +266,29 @@ const ONE_LINE = { whiteSpace: "nowrap" } as const;
264
  function frozenCountOf(config: ViewConfig): number {
265
  return Math.min(MAX_FROZEN, Math.max(1, config.frozenCount ?? 1));
266
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  /* WAVE 21 item 3 (R6): the id and the NAME both moved to `types.ts` β€” the id because a second
268
  copy of a pinned literal is the drift class this repo gates against, the name because it is
269
  now topic-derived and the host mints the same string. */
@@ -1357,7 +1382,14 @@ function CustomerGridSurface({
1357
  const [bulkUpdateOpen, setBulkUpdateOpen] = useState(false);
1358
  const [bulkFieldKey, setBulkFieldKey] = useState("");
1359
  const [bulkValue, setBulkValue] = useState("");
 
1360
  const bulkUpdateRef = useRef<HTMLButtonElement>(null);
 
 
 
 
 
 
1361
  /** Owner item 4 / C-ADDROW β€” the pid the trailing "+" just created, so the cursor can land on
1362
  * it once the re-read actually brings it back (the row does not exist locally before that). */
1363
  const [newRowPid, setNewRowPid] = useState<number | null>(null);
@@ -2972,17 +3004,69 @@ function CustomerGridSurface({
2972
  embeddedSelectable ? embeddedSelectedIds : [],
2973
  !embedded ? `aios:grid-selection:${scope}` : undefined
2974
  );
2975
- const embeddedSelectedKey = embeddedSelectedIds.join(",");
2976
- const selectedPidKey = [...selectedPids].join(",");
 
 
 
 
 
2977
  useEffect(() => {
2978
- if (!embeddedSelectable) return;
2979
- if (selectedPidKey !== embeddedSelectedKey)
 
 
 
 
 
 
2980
  selectPids(embeddedSelectedIds as number[], "replace");
2981
  }, [embeddedSelectable, embeddedSelectedKey, embeddedSelectedIds, selectedPidKey, selectPids]);
2982
  useEffect(() => {
2983
  if (!embeddedSelectable) return;
2984
  onEmbeddedSelectionChange?.([...selectedPids]);
2985
  }, [embeddedSelectable, onEmbeddedSelectionChange, selectedPids]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2986
  /* ════════════════════════ owner item 16 / R4 / C-UNDO ════════════════════════
2987
  THE RECORDING LAYER. `undoStack.ts` owns the stack and every inverse; this owns the one
2988
  thing it cannot: reading the value a cell held BEFORE the write, which only exists at the
@@ -3205,16 +3289,61 @@ function CustomerGridSurface({
3205
  [currentValue, patchOverlay, recordCells]
3206
  );
3207
  const bulkEditableFields = useMemo(
3208
- () => fields.filter((field) => canEditField(field) && field.type !== "rating"),
3209
- [fields, canEditField]
 
 
 
 
 
 
3210
  );
3211
  const bulkField = bulkEditableFields.find((field) => field.key === bulkFieldKey) ?? bulkEditableFields[0];
3212
- const applyBulkUpdate = useCallback(() => {
3213
- if (!bulkField || selectedPids.size === 0) return;
 
3214
  const value = bulkField.type === "checkbox" ? (bulkValue === "1" ? "1" : "") : bulkValue;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3215
  patchManyAndRecord([...selectedPids].map((pid) => ({ pid, updates: { [bulkField.key]: value } })), "a bulk update");
3216
  setBulkUpdateOpen(false);
3217
- }, [bulkField, bulkValue, patchManyAndRecord, selectedPids]);
 
3218
 
3219
  /**
3220
  * Apply one stack entry in one direction. The INVERSE lives in `undoStack.directed` β€” this
@@ -7066,6 +7195,13 @@ function CustomerGridSurface({
7066
  }
7067
 
7068
  const activeView = views.find((view) => view.id === activeViewId);
 
 
 
 
 
 
 
7069
  // Cohort mode's toolbar control (rendered by Toolbar via the `cohortAction` slot; the popover
7070
  // it opens is with the other overlays at the bottom). Disabled without an active cohort β€”
7071
  // zero cohorts is the host page's near-empty state, not this button's error to explain.
@@ -8671,6 +8807,8 @@ function CustomerGridSurface({
8671
  }}
8672
  onRouteFieldCreated={revealRouteField}
8673
  routePanelOpen={routePanelOpen}
 
 
8674
  /* ⭐ OWNER ITEM 9 β€” the route's "Add a record" search reaches PAST the view's own
8675
  filter, so it is handed `scopedRows`: the book after the row wall (`allowed_pids`
8676
  plus the business-unit scope) and before `useVisibleRows` applies filters, search
@@ -8799,6 +8937,42 @@ function CustomerGridSurface({
8799
  broke this gate's own coordinate math before it broke a user). It states the
8800
  count and offers "Add to cohort" over exactly the checked pids β€” the same guarded
8801
  add_to_list event the view menu uses, so the host treats both alike. */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8802
  {!embedded && selectedPids.size > 0 && !serverWindowed &&
8803
  (displayMode === "grid" || displayMode === "map") && (
8804
  <div className="cg-selbar">
@@ -9456,7 +9630,7 @@ function CustomerGridSurface({
9456
  {/* ⭐ Owner item 9 β€” the remove picker. Every row states how many of the CHECKED rows it
9457
  would take out ("3 of 4"), because the selection and the cohort are two different sets
9458
  and the difference is the whole reason this needs a picker rather than a button. */}
9459
- {bulkUpdateOpen && bulkUpdateRef.current && selectedPids.size > 0 && bulkField && (
9460
  <AnchoredOverlay
9461
  anchor={bulkUpdateRef.current}
9462
  className="cg-pop cg-bulk-update-pop"
@@ -9467,7 +9641,7 @@ function CustomerGridSurface({
9467
  dataKind="filtered-bulk-update"
9468
  >
9469
  <div className="cg-pop-title">Update field</div>
9470
- <div className="cg-pop-note">Updates all {selectedPids.size.toLocaleString()} selected records in this filtered view.</div>
9471
  <select className="cg-select" aria-label="Field to update" value={bulkFieldKey || bulkField.key} onChange={(event) => { setBulkFieldKey(event.target.value); setBulkValue(""); }}>
9472
  {bulkEditableFields.map((field) => <option key={field.key} value={field.key}>{field.label}</option>)}
9473
  </select>
@@ -9479,7 +9653,10 @@ function CustomerGridSurface({
9479
  <input className="cg-input" aria-label="New value" value={bulkValue} onChange={(event) => setBulkValue(event.target.value)} />
9480
  )}
9481
  <div className="cg-form-actions">
9482
- <button type="button" className="cg-btn cg-btn--primary" style={ONE_LINE} onClick={applyBulkUpdate}>Update records</button>
 
 
 
9483
  <button type="button" className="cg-btn" style={ONE_LINE} onClick={() => setBulkUpdateOpen(false)}>Cancel</button>
9484
  </div>
9485
  </AnchoredOverlay>
@@ -9640,7 +9817,7 @@ function CustomerGridSurface({
9640
  value={dateValue}
9641
  today={today}
9642
  onChange={(value) => patchAndRecord(datePicker.pid, { [dateField.key]: value }, "a date")}
9643
- onRecurrence={(recurrence, nextDate) => saveField({ ...dateField, recurrence, nextDate })}
9644
  onClose={() => {
9645
  setDatePicker(null);
9646
  requestAnimationFrame(() => gridRef.current?.focus());
 
94
  from "../apiContract";
95
  import type { ViewOpenDetail } from "../apiContract";
96
  import { addTableField, addTableRow, clearTopicRowsCache, deleteRouteOrderField, deleteTableField,
97
+ deleteTableRow, dropTableRowsCache, fetchLinkTargets, refusalMessage,
98
  fetchRollupSources, patchRouteOrderField, patchTableField, enrichField } from "./apiBridge";
99
  import type { LinkTarget, RollupSourceOffer } from "./apiBridge";
100
  import SelectFromFile from "./SelectFromFile";
 
133
  import { cleanCharts } from "../viz/chartData";
134
  import type { ChartSpec } from "../viz/chartData";
135
  import { MapView } from "./MapView";
136
+ import type { RouteSourceView } from "./MapView";
137
  /* ═══ W18-C CATALOG ═══ (owner item 4, contract C6) */
138
  import { CatalogView } from "./CatalogView";
139
  import { CATALOG_CODE_FIELD } from "./catalogData";
 
167
  isPickType, mayEditField,
168
  isModeFrozen, isUndeletableView, mayEditView, mayToggleViewLock,
169
  measureColumnIndex, ratingMax, ruleColumnKeys,
170
+ shouldAdoptEmbeddedSelection,
171
  tableMode, uniqueDisplayName,
172
  /* ⭐ W41-T38 β€” the rail's OWN name resolver, so a collision test compares what the reader
173
  actually sees. The rail has always drawn `viewDisplayName(view)`; `renameView` was testing
 
266
  function frozenCountOf(config: ViewConfig): number {
267
  return Math.min(MAX_FROZEN, Math.max(1, config.frozenCount ?? 1));
268
  }
269
+
270
+ /**
271
+ * A View has a durable id but no server-side revision counter on this wire. A key-sorted,
272
+ * deterministic configuration fingerprint is therefore the actual revision of the View state
273
+ * that produced a frozen route snapshot. It is provenance, not a permission token.
274
+ */
275
+ function routeViewRevision(viewId: string, config: ViewConfig): string {
276
+ const canonical = (value: unknown): unknown => {
277
+ if (Array.isArray(value)) return value.map(canonical);
278
+ if (value && typeof value === "object") {
279
+ const out: Record<string, unknown> = {};
280
+ for (const key of Object.keys(value as Record<string, unknown>).sort())
281
+ out[key] = canonical((value as Record<string, unknown>)[key]);
282
+ return out;
283
+ }
284
+ return value;
285
+ };
286
+ const text = JSON.stringify(canonical({ viewId, config }));
287
+ let hash = 0x811c9dc5;
288
+ for (let index = 0; index < text.length; index += 1)
289
+ hash = Math.imul(hash ^ text.charCodeAt(index), 0x01000193);
290
+ return `view-config-v1-${(hash >>> 0).toString(36)}`;
291
+ }
292
  /* WAVE 21 item 3 (R6): the id and the NAME both moved to `types.ts` β€” the id because a second
293
  copy of a pinned literal is the drift class this repo gates against, the name because it is
294
  now topic-derived and the host mints the same string. */
 
1382
  const [bulkUpdateOpen, setBulkUpdateOpen] = useState(false);
1383
  const [bulkFieldKey, setBulkFieldKey] = useState("");
1384
  const [bulkValue, setBulkValue] = useState("");
1385
+ const [serverBulkPending, setServerBulkPending] = useState(false);
1386
  const bulkUpdateRef = useRef<HTMLButtonElement>(null);
1387
+ /** A windowed "all filtered" selection lives on the server: a signed descriptor represents
1388
+ * every matching record, not the rows currently loaded in Glide. */
1389
+ const [serverFilteredSelection, setServerFilteredSelection] = useState<{
1390
+ token: string;
1391
+ matched: number;
1392
+ } | null>(null);
1393
  /** Owner item 4 / C-ADDROW β€” the pid the trailing "+" just created, so the cursor can land on
1394
  * it once the re-read actually brings it back (the row does not exist locally before that). */
1395
  const [newRowPid, setNewRowPid] = useState<number | null>(null);
 
3004
  embeddedSelectable ? embeddedSelectedIds : [],
3005
  !embedded ? `aios:grid-selection:${scope}` : undefined
3006
  );
3007
+ // A Link picker is controlled by its modal parent. Do not treat the parent's PREVIOUS prop as
3008
+ // a new command after the child has just ticked a row: doing that reset the checkbox before the
3009
+ // child effect could report the new selection upward, so it visibly flickered. Only a prop
3010
+ // value that actually changed since the last render is an external selection to apply.
3011
+ const embeddedSelectedKey = [...embeddedSelectedIds].sort((a, b) => a - b).join(",");
3012
+ const selectedPidKey = [...selectedPids].sort((a, b) => a - b).join(",");
3013
+ const embeddedPropKeyRef = useRef(embeddedSelectedKey);
3014
  useEffect(() => {
3015
+ if (!embeddedSelectable) {
3016
+ embeddedPropKeyRef.current = embeddedSelectedKey;
3017
+ return;
3018
+ }
3019
+ const priorParentKey = embeddedPropKeyRef.current;
3020
+ if (embeddedSelectedKey === priorParentKey) return;
3021
+ embeddedPropKeyRef.current = embeddedSelectedKey;
3022
+ if (shouldAdoptEmbeddedSelection(priorParentKey, embeddedSelectedKey, selectedPidKey))
3023
  selectPids(embeddedSelectedIds as number[], "replace");
3024
  }, [embeddedSelectable, embeddedSelectedKey, embeddedSelectedIds, selectedPidKey, selectPids]);
3025
  useEffect(() => {
3026
  if (!embeddedSelectable) return;
3027
  onEmbeddedSelectionChange?.([...selectedPids]);
3028
  }, [embeddedSelectable, onEmbeddedSelectionChange, selectedPids]);
3029
+ // A descriptor binds the exact filter/search/sort at the time the person chose "Select all".
3030
+ // Do not let it survive a predicate change and silently mean a different population.
3031
+ useEffect(() => {
3032
+ setServerFilteredSelection(null);
3033
+ }, [serverWindowed, windowPredicate]);
3034
+ const selectAllServerFiltered = useCallback(async () => {
3035
+ if (!serverWindowed) return;
3036
+ try {
3037
+ const response = await fetch(
3038
+ `${API_V1}/odoo-tables/${encodeURIComponent(scope)}/filtered-selection`,
3039
+ {
3040
+ method: "POST",
3041
+ credentials: CREDENTIALS,
3042
+ headers: { "Content-Type": "application/json" },
3043
+ body: JSON.stringify({
3044
+ filters: config.filters,
3045
+ filterConj: config.filterConj,
3046
+ sorts: config.sorts,
3047
+ search,
3048
+ }),
3049
+ }
3050
+ );
3051
+ const payload = await response.json().catch(() => null) as {
3052
+ selection?: unknown; matched?: unknown; error?: { message?: unknown };
3053
+ } | null;
3054
+ if (!response.ok) {
3055
+ signal(TOAST_EVENT, refusalMessage(payload, response.status));
3056
+ return;
3057
+ }
3058
+ const token = typeof payload?.selection === "string" ? payload.selection : "";
3059
+ const matched = typeof payload?.matched === "number" ? payload.matched : 0;
3060
+ if (!token || matched <= 0) {
3061
+ setServerFilteredSelection(null);
3062
+ signal(TOAST_EVENT, "No records match this filtered view.");
3063
+ return;
3064
+ }
3065
+ setServerFilteredSelection({ token, matched });
3066
+ } catch {
3067
+ signal(TOAST_EVENT, "Could not select all filtered records. Try again.");
3068
+ }
3069
+ }, [serverWindowed, scope, config.filters, config.filterConj, config.sorts, search]);
3070
  /* ════════════════════════ owner item 16 / R4 / C-UNDO ════════════════════════
3071
  THE RECORDING LAYER. `undoStack.ts` owns the stack and every inverse; this owns the one
3072
  thing it cannot: reading the value a cell held BEFORE the write, which only exists at the
 
3289
  [currentValue, patchOverlay, recordCells]
3290
  );
3291
  const bulkEditableFields = useMemo(
3292
+ () => fields.filter((field) => (
3293
+ serverWindowed
3294
+ // Connected records themselves are source-owned. Only an already-declared shared
3295
+ // overlay field is writable through the server descriptor, with the usual field grant.
3296
+ ? field.shared === true && mayEditField(field, viewer)
3297
+ : canEditField(field)
3298
+ ) && field.type !== "rating"),
3299
+ [fields, serverWindowed, viewer, canEditField]
3300
  );
3301
  const bulkField = bulkEditableFields.find((field) => field.key === bulkFieldKey) ?? bulkEditableFields[0];
3302
+ const bulkSelectedCount = serverFilteredSelection?.matched ?? selectedPids.size;
3303
+ const applyBulkUpdate = useCallback(async () => {
3304
+ if (!bulkField || bulkSelectedCount === 0 || serverBulkPending) return;
3305
  const value = bulkField.type === "checkbox" ? (bulkValue === "1" ? "1" : "") : bulkValue;
3306
+ if (serverFilteredSelection) {
3307
+ setServerBulkPending(true);
3308
+ try {
3309
+ const response = await fetch(
3310
+ `${API_V1}/odoo-tables/${encodeURIComponent(scope)}/filtered-bulk`,
3311
+ {
3312
+ method: "POST",
3313
+ credentials: CREDENTIALS,
3314
+ headers: { "Content-Type": "application/json" },
3315
+ body: JSON.stringify({ selection: serverFilteredSelection.token, field: bulkField.key, value }),
3316
+ }
3317
+ );
3318
+ const payload = await response.json().catch(() => null) as {
3319
+ rows_written?: unknown; matched?: unknown; error?: { message?: unknown };
3320
+ } | null;
3321
+ if (!response.ok) {
3322
+ signal(TOAST_EVENT, refusalMessage(payload, response.status));
3323
+ return;
3324
+ }
3325
+ const written = typeof payload?.rows_written === "number" ? payload.rows_written : 0;
3326
+ const matched = typeof payload?.matched === "number" ? payload.matched : bulkSelectedCount;
3327
+ // Do not paint loaded rows as though they were the whole update. Invalidate every cached
3328
+ // window and request a fresh one, then state exactly what the server says it wrote.
3329
+ dropTableRowsCache(scope);
3330
+ requestWindow(windowRequest(0));
3331
+ signal(ROWS_STALE_EVENT);
3332
+ setServerFilteredSelection(null);
3333
+ clearSelection();
3334
+ setBulkUpdateOpen(false);
3335
+ signal(TOAST_EVENT, `Updated ${written.toLocaleString()} of ${matched.toLocaleString()} filtered records.`);
3336
+ } catch {
3337
+ signal(TOAST_EVENT, "Could not update the filtered records. Try again.");
3338
+ } finally {
3339
+ setServerBulkPending(false);
3340
+ }
3341
+ return;
3342
+ }
3343
  patchManyAndRecord([...selectedPids].map((pid) => ({ pid, updates: { [bulkField.key]: value } })), "a bulk update");
3344
  setBulkUpdateOpen(false);
3345
+ }, [bulkField, bulkSelectedCount, bulkValue, clearSelection, dropTableRowsCache, patchManyAndRecord,
3346
+ requestWindow, scope, selectedPids, serverBulkPending, serverFilteredSelection, windowRequest]);
3347
 
3348
  /**
3349
  * Apply one stack entry in one direction. The INVERSE lives in `undoStack.directed` β€” this
 
7195
  }
7196
 
7197
  const activeView = views.find((view) => view.id === activeViewId);
7198
+ const routeSourceView: RouteSourceView | null = activeView
7199
+ ? {
7200
+ id: activeView.id,
7201
+ name: viewDisplayName(activeView),
7202
+ revision: routeViewRevision(activeView.id, config),
7203
+ }
7204
+ : null;
7205
  // Cohort mode's toolbar control (rendered by Toolbar via the `cohortAction` slot; the popover
7206
  // it opens is with the other overlays at the bottom). Disabled without an active cohort β€”
7207
  // zero cohorts is the host page's near-empty state, not this button's error to explain.
 
8807
  }}
8808
  onRouteFieldCreated={revealRouteField}
8809
  routePanelOpen={routePanelOpen}
8810
+ activeView={routeSourceView}
8811
+ tableKey={topic.key}
8812
  /* ⭐ OWNER ITEM 9 β€” the route's "Add a record" search reaches PAST the view's own
8813
  filter, so it is handed `scopedRows`: the book after the row wall (`allowed_pids`
8814
  plus the business-unit scope) and before `useVisibleRows` applies filters, search
 
8937
  broke this gate's own coordinate math before it broke a user). It states the
8938
  count and offers "Add to cohort" over exactly the checked pids β€” the same guarded
8939
  add_to_list event the view menu uses, so the host treats both alike. */}
8940
+ {!embedded && serverWindowed &&
8941
+ (selectedPids.size > 0 || serverFilteredSelection !== null) &&
8942
+ displayMode === "grid" && (
8943
+ <div className="cg-selbar" data-kind="server-filtered-selection">
8944
+ <span className="cg-selbar-count">
8945
+ {bulkSelectedCount.toLocaleString()} selected
8946
+ </span>
8947
+ {!serverFilteredSelection && (payload?.counts?.matched ?? 0) > 0 && (
8948
+ <button
8949
+ type="button"
8950
+ className="cg-btn cg-selbar-all"
8951
+ style={ONE_LINE}
8952
+ onClick={() => void selectAllServerFiltered()}
8953
+ >
8954
+ Select all {(payload?.counts?.matched ?? 0).toLocaleString()} filtered
8955
+ </button>
8956
+ )}
8957
+ {bulkEditableFields.length > 0 && (
8958
+ <button
8959
+ ref={bulkUpdateRef}
8960
+ type="button"
8961
+ className="cg-btn cg-selbar-update"
8962
+ style={ONE_LINE}
8963
+ aria-expanded={bulkUpdateOpen}
8964
+ aria-haspopup="dialog"
8965
+ onClick={() => setBulkUpdateOpen((open) => !open)}
8966
+ >
8967
+ Update field
8968
+ </button>
8969
+ )}
8970
+ <button type="button" className="cg-btn" style={ONE_LINE}
8971
+ onClick={() => { setServerFilteredSelection(null); clearSelection(); }}>
8972
+ Clear
8973
+ </button>
8974
+ </div>
8975
+ )}
8976
  {!embedded && selectedPids.size > 0 && !serverWindowed &&
8977
  (displayMode === "grid" || displayMode === "map") && (
8978
  <div className="cg-selbar">
 
9630
  {/* ⭐ Owner item 9 β€” the remove picker. Every row states how many of the CHECKED rows it
9631
  would take out ("3 of 4"), because the selection and the cohort are two different sets
9632
  and the difference is the whole reason this needs a picker rather than a button. */}
9633
+ {bulkUpdateOpen && bulkUpdateRef.current && bulkSelectedCount > 0 && bulkField && (
9634
  <AnchoredOverlay
9635
  anchor={bulkUpdateRef.current}
9636
  className="cg-pop cg-bulk-update-pop"
 
9641
  dataKind="filtered-bulk-update"
9642
  >
9643
  <div className="cg-pop-title">Update field</div>
9644
+ <div className="cg-pop-note">Updates all {bulkSelectedCount.toLocaleString()} selected records in this filtered view.</div>
9645
  <select className="cg-select" aria-label="Field to update" value={bulkFieldKey || bulkField.key} onChange={(event) => { setBulkFieldKey(event.target.value); setBulkValue(""); }}>
9646
  {bulkEditableFields.map((field) => <option key={field.key} value={field.key}>{field.label}</option>)}
9647
  </select>
 
9653
  <input className="cg-input" aria-label="New value" value={bulkValue} onChange={(event) => setBulkValue(event.target.value)} />
9654
  )}
9655
  <div className="cg-form-actions">
9656
+ <button type="button" className="cg-btn cg-btn--primary" style={ONE_LINE}
9657
+ disabled={serverBulkPending} onClick={() => void applyBulkUpdate()}>
9658
+ {serverBulkPending ? "Updating records…" : "Update records"}
9659
+ </button>
9660
  <button type="button" className="cg-btn" style={ONE_LINE} onClick={() => setBulkUpdateOpen(false)}>Cancel</button>
9661
  </div>
9662
  </AnchoredOverlay>
 
9817
  value={dateValue}
9818
  today={today}
9819
  onChange={(value) => patchAndRecord(datePicker.pid, { [dateField.key]: value }, "a date")}
9820
+ onRecurrence={(recurrence) => saveField({ ...dateField, recurrence: recurrence ?? null })}
9821
  onClose={() => {
9822
  setDatePicker(null);
9823
  requestAnimationFrame(() => gridRef.current?.focus());
web/src/customer-grid/DatePicker.tsx CHANGED
@@ -1,165 +1,106 @@
1
- import { useMemo, useState } from "react";
2
- import type { Field } from "./types";
 
3
  import { AnchoredOverlay } from "./OverlaySurface";
4
  import type { AnchorRect } from "./OverlaySurface";
5
 
6
- type Frequency = NonNullable<Field["recurrence"]>["frequency"];
7
-
8
- const MONTHS = [
9
- "January", "February", "March", "April", "May", "June",
10
- "July", "August", "September", "October", "November", "December",
11
- ];
12
  const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
 
 
 
 
13
 
14
  function parseIso(value: unknown): Date | null {
15
  if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
16
  const [year, month, day] = value.split("-").map(Number);
17
  const date = new Date(Date.UTC(year, month - 1, day));
18
- return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
19
- ? date
20
- : null;
21
- }
22
-
23
- function iso(date: Date): string {
24
- return date.toISOString().slice(0, 10);
25
- }
26
-
27
- function addMonths(date: Date, amount: number): Date {
28
- const out = new Date(date);
29
- out.setUTCMonth(out.getUTCMonth() + amount);
30
- return out;
31
- }
32
-
33
- function isUsHoliday(date: Date): boolean {
34
- const month = date.getUTCMonth();
35
- const day = date.getUTCDate();
36
- // Fixed federal holidays plus the common observed weekday. The API remains the authority for
37
- // an exact calendar; this prevents the local preview from presenting a known holiday as a work day.
38
- if ((month === 0 && day === 1) || (month === 6 && day === 4) || (month === 11 && day === 25)) return true;
39
- const weekday = date.getUTCDay();
40
- if (month === 0 && day === 1 && weekday === 6) return true;
41
- if (month === 0 && day === 2 && weekday === 1) return true;
42
- if (month === 6 && day === 3 && weekday === 5) return true;
43
- if (month === 6 && day === 5 && weekday === 1) return true;
44
- if (month === 11 && day === 24 && weekday === 5) return true;
45
- if (month === 11 && day === 26 && weekday === 1) return true;
46
- // MLK, Memorial, Labor and Thanksgiving.
47
- if (month === 0 && weekday === 1 && day >= 15 && day <= 21) return true;
48
- if (month === 4 && weekday === 1 && day + 7 > 31) return true;
49
- if (month === 8 && weekday === 1 && day <= 7) return true;
50
- return month === 10 && weekday === 4 && day >= 22 && day <= 28;
51
  }
52
-
53
- export function nextRecurringDates(
54
- start: string,
55
- recurrence: Field["recurrence"],
56
- count = 6,
57
- ): string[] {
58
- const seed = parseIso(start);
59
- const frequency: Frequency = recurrence?.frequency ?? "none";
60
- if (!seed || frequency === "none") return seed ? [iso(seed)] : [];
61
- const interval = Math.max(1, Math.min(365, Math.round(recurrence?.interval ?? 1)));
62
- const out: string[] = [];
63
- let candidate = new Date(seed);
64
- let guard = 0;
65
- while (out.length < count && guard++ < count * 400) {
66
- if (guard > 1) {
67
- if (frequency === "daily") candidate.setUTCDate(candidate.getUTCDate() + interval);
68
- else if (frequency === "weekly") candidate.setUTCDate(candidate.getUTCDate() + 7 * interval);
69
- else if (frequency === "monthly") candidate = addMonths(candidate, interval);
70
- else candidate.setUTCFullYear(candidate.getUTCFullYear() + interval);
71
- }
72
- const weekday = candidate.getUTCDay();
73
- const allowedWeekday = !recurrence?.weekdays?.length || recurrence.weekdays.includes(weekday);
74
- const holiday = recurrence?.skipHolidays && recurrence.holidayCalendar === "us" && isUsHoliday(candidate);
75
- if (allowedWeekday && !holiday) out.push(iso(candidate));
76
- }
77
- return out;
78
  }
 
79
 
80
- export function DatePicker({
81
- anchor,
82
- field,
83
- value,
84
- today,
85
- onChange,
86
- onRecurrence,
87
- onClose,
88
- }: {
89
- anchor: AnchorRect;
90
- field: Field;
91
- value: unknown;
92
- today?: string;
93
- onChange: (value: string) => void;
94
- onRecurrence: (recurrence: NonNullable<Field["recurrence"]>, nextDate: string | undefined) => void;
95
- onClose: () => void;
96
  }) {
97
  const selected = parseIso(value) ?? parseIso(today) ?? new Date();
 
98
  const [cursor, setCursor] = useState(() => new Date(Date.UTC(selected.getUTCFullYear(), selected.getUTCMonth(), 1)));
99
- const [frequency, setFrequency] = useState<Frequency>(field.recurrence?.frequency ?? "none");
100
- const [interval, setInterval] = useState(field.recurrence?.interval ?? 1);
101
- const [skipHolidays, setSkipHolidays] = useState(!!field.recurrence?.skipHolidays);
102
- const recurrence = useMemo<NonNullable<Field["recurrence"]>>(
103
- () => ({
104
- ...field.recurrence,
105
- frequency,
106
- interval: Math.max(1, Math.min(365, Math.round(Number(interval) || 1))),
107
- skipHolidays,
108
- holidayCalendar: skipHolidays ? "us" : undefined,
109
- }),
110
- [field.recurrence, frequency, interval, skipHolidays],
111
- );
112
- const next = useMemo(
113
- () => nextRecurringDates(iso(selected), recurrence),
114
- [recurrence, selected],
115
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  const monthStart = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth(), 1));
117
  const leading = (monthStart.getUTCDay() + 6) % 7;
118
  const days = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, 0)).getUTCDate();
119
  const cells = Array.from({ length: Math.ceil((leading + days) / 7) * 7 }, (_, index) => index - leading + 1);
120
- const selectedIso = iso(selected);
121
 
122
- return (
123
- <AnchoredOverlay
124
- anchor={anchor}
125
- className="cg-date-picker"
126
- placement="bottom-start"
127
- onDismiss={onClose}
128
- role="dialog"
129
- ariaLabel={`Choose a date for ${field.label}`}
130
- dataKind="date-picker"
131
- >
132
- <div className="cg-date-picker-head">
133
- <button type="button" className="cg-icon-button" aria-label="Previous month" onClick={() => setCursor(addMonths(cursor, -1))}>β€Ή</button>
134
- <strong>{MONTHS[cursor.getUTCMonth()]} {cursor.getUTCFullYear()}</strong>
135
- <button type="button" className="cg-icon-button" aria-label="Next month" onClick={() => setCursor(addMonths(cursor, 1))}>β€Ί</button>
136
- </div>
137
- <div className="cg-date-weekdays">{WEEKDAYS.map((day) => <span key={day}>{day}</span>)}</div>
138
- <div className="cg-date-grid">
139
- {cells.map((day, index) => {
140
- if (day < 1 || day > days) return <span key={`blank-${index}`} aria-hidden />;
141
- const date = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth(), day));
142
- const dateIso = iso(date);
143
- return <button key={dateIso} type="button" className={"cg-date-day" + (dateIso === selectedIso ? " is-selected" : "")} aria-pressed={dateIso === selectedIso} onClick={() => onChange(dateIso)}>{day}</button>;
144
- })}
145
- </div>
146
- <div className="cg-date-recurrence">
147
- <div className="cg-pop-title">Repeat</div>
148
- <label>Every <input className="cg-input cg-date-interval" min="1" max="365" type="number" value={interval} onChange={(event) => setInterval(Number(event.target.value))} /></label>
149
- <select className="cg-select" value={frequency} aria-label="Recurrence frequency" onChange={(event) => setFrequency(event.target.value as Frequency)}>
150
- <option value="none">Does not repeat</option>
151
- <option value="daily">day</option>
152
- <option value="weekly">week</option>
153
- <option value="monthly">month</option>
154
- <option value="yearly">year</option>
155
- </select>
156
- {frequency !== "none" && <label className="cg-date-holidays"><input type="checkbox" checked={skipHolidays} onChange={(event) => setSkipHolidays(event.target.checked)} /> Skip US public holidays</label>}
157
- {frequency !== "none" && <button type="button" className="cg-link-btn" onClick={() => onRecurrence(recurrence, next[0])}>Save recurrence</button>}
158
- </div>
159
- <div className="cg-date-preview" aria-live="polite">
160
- <div className="cg-pop-title">Next dates</div>
161
- {next.slice(0, 6).map((date) => <span key={date}>{date}</span>)}
162
- </div>
163
- </AnchoredOverlay>
164
- );
165
  }
 
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { API_V1, CREDENTIALS } from "../apiContract";
3
+ import type { DateRecurrence, DateRecurrenceRule, Field } from "./types";
4
  import { AnchoredOverlay } from "./OverlaySurface";
5
  import type { AnchorRect } from "./OverlaySurface";
6
 
7
+ const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
 
 
 
 
 
8
  const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
9
+ const ORDINALS: ReadonlyArray<readonly [DateRecurrenceRule["ordinal"], string]> = [[1, "First"], [2, "Second"], [3, "Third"], [4, "Fourth"], [5, "Fifth"], [-1, "Last"]];
10
+ const DEFAULT_CALENDARS = [{ code: "US" as const, label: "United States federal holidays" }, { code: "CA" as const, label: "Canada federal public holidays" }];
11
+ type RuleKind = DateRecurrenceRule["kind"] | "monthly_ordinal";
12
+ type CalendarOption = { code: "US" | "CA"; label: string };
13
 
14
  function parseIso(value: unknown): Date | null {
15
  if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
16
  const [year, month, day] = value.split("-").map(Number);
17
  const date = new Date(Date.UTC(year, month - 1, day));
18
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? date : null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  }
20
+ function iso(date: Date): string { return date.toISOString().slice(0, 10); }
21
+ function addMonths(date: Date, amount: number): Date { const out = new Date(date); out.setUTCMonth(out.getUTCMonth() + amount); return out; }
22
+ function bounded(value: unknown, lower: number, upper: number, fallback: number): number { const number = Math.round(Number(value)); return Number.isFinite(number) ? Math.max(lower, Math.min(upper, number)) : fallback; }
23
+ function ruleKind(rule: DateRecurrenceRule): RuleKind { return rule.kind === "monthly" && rule.ordinal !== undefined ? "monthly_ordinal" : rule.kind; }
24
+ function newRule(kind: RuleKind, selected: Date): DateRecurrenceRule {
25
+ if (kind === "weekly") return { kind, interval: 1, weekdays: [((selected.getUTCDay() + 6) % 7)] };
26
+ if (kind === "monthly_ordinal") return { kind: "monthly", interval: 1, ordinal: 1, weekday: 0 };
27
+ if (kind === "monthly") return { kind, interval: 1, day: selected.getUTCDate() };
28
+ if (kind === "yearly") return { kind, interval: 1, month: selected.getUTCMonth() + 1, day: selected.getUTCDate() };
29
+ if (kind === "holiday") return { kind, interval: 1, calendar: "US", offset: 0 };
30
+ return { kind, interval: 1 };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  }
32
+ function labelForKind(kind: RuleKind): string { return ({ daily: "Every N days", weekly: "Every N weeks", monthly: "Day of every N months", monthly_ordinal: "Ordinal weekday of every N months", yearly: "Every N years", holiday: "Public holiday" } as Record<RuleKind, string>)[kind]; }
33
 
34
+ export function DatePicker({ anchor, field, value, today, onChange, onRecurrence, onClose }: {
35
+ anchor: AnchorRect; field: Field; value: unknown; today?: string; onChange: (value: string) => void;
36
+ onRecurrence: (recurrence: DateRecurrence | null) => void; onClose: () => void;
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  }) {
38
  const selected = parseIso(value) ?? parseIso(today) ?? new Date();
39
+ const selectedIso = iso(selected);
40
  const [cursor, setCursor] = useState(() => new Date(Date.UTC(selected.getUTCFullYear(), selected.getUTCMonth(), 1)));
41
+ const [rules, setRules] = useState<DateRecurrenceRule[]>(() => field.recurrence?.rules ?? []);
42
+ const [previewCount, setPreviewCount] = useState(() => field.recurrence?.previewCount ?? 10);
43
+ const [preview, setPreview] = useState<string[]>(() => field.recurrence?.preview ?? []);
44
+ const [nextDate, setNextDate] = useState<string | null | undefined>(() => field.recurrence?.nextDate);
45
+ const [calendars, setCalendars] = useState<CalendarOption[]>(DEFAULT_CALENDARS);
46
+ const [previewError, setPreviewError] = useState("");
47
+ const recurrence = useMemo<DateRecurrence | null>(() => rules.length ? ({ startDate: selectedIso, rules, previewCount: bounded(previewCount, 1, 50, 10) }) : null, [previewCount, rules, selectedIso]);
48
+
49
+ // Server-evaluated preview: the exact payload saved below is sent here, so edited previews
50
+ // cannot differ from the default next date after refresh because of timezone/holiday drift.
51
+ useEffect(() => {
52
+ if (!recurrence) { setPreview([]); setNextDate(undefined); setPreviewError(""); return; }
53
+ const controller = new AbortController();
54
+ void fetch(`${API_V1}/grid/recurrence-preview`, { method: "POST", credentials: CREDENTIALS, signal: controller.signal,
55
+ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ recurrence })
56
+ }).then(async (response) => {
57
+ const data = await response.json().catch(() => ({}));
58
+ if (!response.ok) throw new Error(String(data?.detail?.message ?? "Could not preview recurrence"));
59
+ return data as { recurrence?: DateRecurrence; calendars?: CalendarOption[] };
60
+ }).then((data) => {
61
+ if (controller.signal.aborted) return;
62
+ if (data.recurrence) { setPreview(data.recurrence.preview ?? []); setNextDate(data.recurrence.nextDate); }
63
+ if (Array.isArray(data.calendars) && data.calendars.length) setCalendars(data.calendars);
64
+ setPreviewError("");
65
+ }).catch((error: unknown) => { if (!controller.signal.aborted) setPreviewError(error instanceof Error ? error.message : "Could not preview recurrence"); });
66
+ return () => controller.abort();
67
+ }, [recurrence]);
68
+
69
+ const updateRule = (index: number, patch: Partial<DateRecurrenceRule>) => setRules((current) => current.map((rule, item) => item === index ? { ...rule, ...patch } : rule));
70
+ const replaceKind = (index: number, kind: RuleKind) => setRules((current) => current.map((rule, item) => item === index ? newRule(kind, selected) : rule));
71
+ const toggleWeekday = (index: number, weekday: number, checked: boolean) => setRules((current) => current.map((rule, item) => {
72
+ if (item !== index) return rule;
73
+ const existing = rule.weekdays ?? [];
74
+ const weekdays = checked ? [...new Set([...existing, weekday])].sort() : existing.filter((day) => day !== weekday);
75
+ return { ...rule, weekdays: weekdays.length ? weekdays : existing };
76
+ }));
77
  const monthStart = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth(), 1));
78
  const leading = (monthStart.getUTCDay() + 6) % 7;
79
  const days = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, 0)).getUTCDate();
80
  const cells = Array.from({ length: Math.ceil((leading + days) / 7) * 7 }, (_, index) => index - leading + 1);
 
81
 
82
+ return <AnchoredOverlay anchor={anchor} className="cg-date-picker" placement="bottom-start" onDismiss={onClose} role="dialog" ariaLabel={`Choose a date for ${field.label}`} dataKind="date-picker">
83
+ <div className="cg-date-picker-head"><button type="button" className="cg-icon-button" aria-label="Previous month" onClick={() => setCursor(addMonths(cursor, -1))}>β€Ή</button><strong>{MONTHS[cursor.getUTCMonth()]} {cursor.getUTCFullYear()}</strong><button type="button" className="cg-icon-button" aria-label="Next month" onClick={() => setCursor(addMonths(cursor, 1))}>β€Ί</button></div>
84
+ <div className="cg-date-weekdays">{WEEKDAYS.map((day) => <span key={day}>{day}</span>)}</div>
85
+ <div className="cg-date-grid">{cells.map((day, index) => {
86
+ if (day < 1 || day > days) return <span key={`blank-${index}`} aria-hidden />;
87
+ const dateIso = iso(new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth(), day)));
88
+ return <button key={dateIso} type="button" className={"cg-date-day" + (dateIso === selectedIso ? " is-selected" : "")} aria-pressed={dateIso === selectedIso} onClick={() => onChange(dateIso)}>{day}</button>;
89
+ })}</div>
90
+ <div className="cg-date-recurrence"><div className="cg-pop-title">Repeat rules</div>
91
+ {rules.map((rule, index) => <div className="cg-date-rule" key={`${rule.kind}-${index}`}>
92
+ <select className="cg-select" value={ruleKind(rule)} aria-label={`Repeat rule ${index + 1}`} onChange={(event) => replaceKind(index, event.target.value as RuleKind)}>{(["daily", "weekly", "monthly", "monthly_ordinal", "yearly", "holiday"] as RuleKind[]).map((kind) => <option key={kind} value={kind}>{labelForKind(kind)}</option>)}</select>
93
+ <label>Every <input className="cg-input cg-date-interval" min="1" max="366" type="number" value={rule.interval ?? 1} onChange={(event) => updateRule(index, { interval: bounded(event.target.value, 1, 366, 1) })} /></label>
94
+ {rule.kind === "weekly" && <div className="cg-date-holidays" aria-label="Weekdays">{WEEKDAYS.map((day, weekday) => <label key={day}><input type="checkbox" checked={(rule.weekdays ?? []).includes(weekday)} onChange={(event) => toggleWeekday(index, weekday, event.target.checked)} />{day}</label>)}</div>}
95
+ {rule.kind === "monthly" && rule.ordinal === undefined && <label>Day <input className="cg-input cg-date-interval" min="1" max="31" type="number" value={rule.day ?? 1} onChange={(event) => updateRule(index, { day: bounded(event.target.value, 1, 31, 1) })} /></label>}
96
+ {rule.kind === "monthly" && rule.ordinal !== undefined && <div className="cg-date-holidays"><select className="cg-select" value={rule.ordinal} aria-label="Month ordinal" onChange={(event) => updateRule(index, { ordinal: Number(event.target.value) as DateRecurrenceRule["ordinal"] })}>{ORDINALS.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select><select className="cg-select" value={rule.weekday ?? 0} aria-label="Month weekday" onChange={(event) => updateRule(index, { weekday: Number(event.target.value) })}>{WEEKDAYS.map((day, weekday) => <option key={day} value={weekday}>{day}</option>)}</select></div>}
97
+ {rule.kind === "yearly" && <div className="cg-date-holidays"><label>Month <input className="cg-input cg-date-interval" min="1" max="12" type="number" value={rule.month ?? 1} onChange={(event) => updateRule(index, { month: bounded(event.target.value, 1, 12, 1) })} /></label><label>Day <input className="cg-input cg-date-interval" min="1" max="31" type="number" value={rule.day ?? 1} onChange={(event) => updateRule(index, { day: bounded(event.target.value, 1, 31, 1) })} /></label></div>}
98
+ {rule.kind === "holiday" && <div className="cg-date-holidays"><select className="cg-select" value={rule.calendar ?? "US"} aria-label="Public calendar" onChange={(event) => updateRule(index, { calendar: event.target.value as "US" | "CA" })}>{calendars.map((calendar) => <option key={calendar.code} value={calendar.code}>{calendar.label}</option>)}</select><select className="cg-select" value={rule.offset ?? 0} aria-label="Holiday relation" onChange={(event) => updateRule(index, { offset: Number(event.target.value) as -1 | 0 | 1 })}><option value={-1}>Day before</option><option value={0}>Day on</option><option value={1}>Day after</option></select></div>}
99
+ <button type="button" className="cg-link-btn" onClick={() => setRules((current) => current.filter((_, item) => item !== index))}>Remove rule</button>
100
+ </div>)}
101
+ <button type="button" className="cg-link-btn" onClick={() => setRules((current) => [...current, newRule("daily", selected)])}>+ Add repeat rule</button>
102
+ {rules.length > 0 && <><label>Show next <select className="cg-select" value={previewCount} aria-label="Number of preview dates" onChange={(event) => setPreviewCount(bounded(event.target.value, 1, 50, 10))}>{[3, 5, 10, 20, 50].map((count) => <option key={count} value={count}>{count}</option>)}</select> dates</label><div className="cg-date-holidays"><button type="button" className="cg-link-btn" onClick={() => recurrence && onRecurrence(recurrence)}>Save recurrence</button><button type="button" className="cg-link-btn" onClick={() => onRecurrence(null)}>Clear recurrence</button></div></>}
103
+ </div>
104
+ <div className="cg-date-preview" aria-live="polite"><div className="cg-pop-title">Next dates{nextDate ? ` Β· next ${nextDate}` : ""}</div>{preview.map((date) => <span key={date}>{date}</span>)}{rules.length > 0 && !preview.length && !previewError && <span>Calculating the server preview…</span>}{previewError && <span role="status">{previewError}</span>}</div>
105
+ </AnchoredOverlay>;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  }
web/src/customer-grid/MapView.tsx CHANGED
@@ -326,6 +326,22 @@ type ZoneGeometry = {
326
  coordinates: number[][][] | number[][][][];
327
  };
328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  /** C3's durable map-domain DTO. Zones are not generic fields: geometry, visibility and the
330
  * membership snapshot only make sense to this projection. */
331
  interface MapZone {
@@ -340,6 +356,11 @@ interface MapZone {
340
  createdAt?: string;
341
  }
342
 
 
 
 
 
 
343
  interface RouteDelivery {
344
  id: string;
345
  name: string;
@@ -356,6 +377,13 @@ interface RouteDelivery {
356
  };
357
  }
358
 
 
 
 
 
 
 
 
359
  interface MapPoint {
360
  pid: number;
361
  title: string;
@@ -400,6 +428,8 @@ export function MapView({
400
  onRouteFieldCreated,
401
  routePanelOpen,
402
  allRows,
 
 
403
  }: {
404
  /** DISTINCT data rows from the full pipeline, overlay edits layered β€” the
405
  * calendar/kanban contract, verbatim. */
@@ -437,6 +467,10 @@ export function MapView({
437
  onGeocoded?: (writes: { pid: number; value: string }[]) => void;
438
  onRouteFieldCreated?: (key: string) => void;
439
  routePanelOpen: boolean;
 
 
 
 
440
  /**
441
  * ⭐⭐ OWNER ITEM 9 β€” THE WHOLE BOOK, so a stop can be added that the VIEW filtered out.
442
  *
@@ -499,12 +533,15 @@ export function MapView({
499
  * array is an honest, editable no-Zones state. */
500
  const [zones, setZones] = useState<MapZone[] | null>(null);
501
  const [activeZoneIds, setActiveZoneIds] = useState<ReadonlySet<string>>(new Set());
 
502
  const [zoneEditorId, setZoneEditorId] = useState("");
503
  const [zoneName, setZoneName] = useState("");
504
  const [zoneColor, setZoneColor] = useState(ZONE_COLOR_CHOICES[0].value);
505
  const [zoneGeographicField, setZoneGeographicField] = useState("");
506
  const [zoneGeographicValue, setZoneGeographicValue] = useState("");
507
  const [drawnZoneGeometry, setDrawnZoneGeometry] = useState<ZoneGeometry | null>(null);
 
 
508
  const [zoneBusy, setZoneBusy] = useState(false);
509
  const [zoneErr, setZoneErr] = useState<string | null>(null);
510
  /**
@@ -788,13 +825,15 @@ export function MapView({
788
 
789
  const loadZones = useCallback(async () => {
790
  try {
791
- const res = await fetch("/api/v1/geo/zones", { credentials: "same-origin" });
 
 
792
  const body = res.ok ? await res.json().catch(() => null) : null;
793
  setZones(Array.isArray(body?.zones) ? body.zones : []);
794
  } catch {
795
  setZones([]);
796
  }
797
- }, []);
798
  useEffect(() => { void loadZones(); }, [loadZones]);
799
 
800
  const activeZones = useMemo(
@@ -810,8 +849,36 @@ export function MapView({
810
  () => (zones || []).find((zone) => zone.id === zoneEditorId) || null,
811
  [zones, zoneEditorId]
812
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
813
  const zonePolygons = useMemo(() => activeZones.flatMap((zone) => {
814
- const geometry = zone.geometryGeoJson;
 
 
815
  const polygons = geometry?.type === "Polygon"
816
  ? [geometry.coordinates as number[][][]]
817
  : geometry?.type === "MultiPolygon"
@@ -824,7 +891,7 @@ export function MapView({
824
  .filter((pair) => Array.isArray(pair) && Number.isFinite(pair[0]) && Number.isFinite(pair[1]))
825
  .map((pair) => project(pair[0], pair[1])),
826
  })).filter((ring) => ring.points.length >= 4));
827
- }), [activeZones]);
828
 
829
  const selectionZoneGeometry = useCallback((): ZoneGeometry | null => {
830
  if (drawnZoneGeometry) return drawnZoneGeometry;
@@ -858,6 +925,7 @@ export function MapView({
858
  credentials: "same-origin",
859
  headers: { "Content-Type": "application/json" },
860
  body: JSON.stringify({
 
861
  name,
862
  color: zoneColor || existing?.color || ZONE_COLOR_CHOICES[0].value,
863
  geometryGeoJson,
@@ -886,11 +954,7 @@ export function MapView({
886
  } finally {
887
  setZoneBusy(false);
888
  }
889
- }, [selectionZoneGeometry, zoneName, zoneColor, zoneGeographicField, selectedPids]);
890
-
891
- const selectActiveZoneRecords = useCallback(() => {
892
- if (activeZonePids.size) onSelectPids([...activeZonePids], "replace");
893
- }, [activeZonePids, onSelectPids]);
894
 
895
  const setZoneVisible = useCallback(async (zone: MapZone, visible: boolean) => {
896
  setZoneBusy(true);
@@ -900,7 +964,7 @@ export function MapView({
900
  method: "PATCH",
901
  credentials: "same-origin",
902
  headers: { "Content-Type": "application/json" },
903
- body: JSON.stringify({ visible }),
904
  });
905
  const body = await res.json().catch(() => null);
906
  if (!res.ok) {
@@ -914,7 +978,32 @@ export function MapView({
914
  } finally {
915
  setZoneBusy(false);
916
  }
917
- }, []);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
918
 
919
  // The fit is the INITIAL view, not the projection (see mapProjection.ts).
920
  const fit = useMemo(
@@ -1118,26 +1207,21 @@ export function MapView({
1118
  * this component through `useVisibleRows`' filter -> search -> SORT -> group pipeline, so
1119
  * "the first N" means the first N as the view is sorted -- the user's own ranking, which is
1120
  * the only ranking this surface has. The owner asked for "the top SKU"; the map has no SKU,
1121
- * and inventing one would be a plausible order nobody chose. `autoCapped` carries what was
1122
- * left out so the panel can report it rather than quietly routing a subset.
1123
  */
1124
- const autoRouteRef = useRef(false);
1125
- const [autoCapped, setAutoCapped] = useState(0);
1126
- useEffect(() => {
1127
- if (!routePanelOpen || !routeCreateOpen) {
1128
- autoRouteRef.current = false;
1129
- setAutoCapped(0);
1130
- return;
1131
- }
1132
- if (autoRouteRef.current || points.length < 2) return;
1133
- autoRouteRef.current = true;
1134
- if (selectedPids.size >= 2) return;
1135
- const take = points.slice(0, ROUTE_STOP_CAP);
1136
- setAutoCapped(points.length - take.length);
1137
- onSelectPids(take.map((p) => p.pid), "replace");
1138
  setRouteOn(true);
1139
  setRouteOrder(null);
1140
- }, [routePanelOpen, routeCreateOpen, points, selectedPids, onSelectPids]);
1141
 
1142
  /** The depot joins BOTH index-parallel arrays below at position zero, and only there. */
1143
  const depotPoint = useMemo((): MapPoint | null => {
@@ -1428,6 +1512,10 @@ export function MapView({
1428
 
1429
  const scheduleRouteDelivery = useCallback(async () => {
1430
  if (!plan || !routeRecipient.trim()) return;
 
 
 
 
1431
  const orderedStopIds = plan.ordered.map((point) => point.pid).filter((pid) => pid > 0);
1432
  if (orderedStopIds.length < 2) return;
1433
  setRouteScheduleBusy(true);
@@ -1446,11 +1534,11 @@ export function MapView({
1446
  recipient: routeRecipient.trim(),
1447
  schedule: { cron: "0 8 * * *", enabled: true },
1448
  routeSnapshot: {
1449
- // MapView deliberately consumes C2's snapshot seam rather than persisting anything
1450
- // through View/field machinery it does not own. The fingerprint is the frozen revision.
1451
- sourceViewId: "map-current",
1452
- sourceViewRevision: plan.fingerprint,
1453
- sourceViewName: `${field.label} map route`,
1454
  timezone,
1455
  routeField: saveTarget || openedRoute,
1456
  orderedStopIds,
@@ -1473,7 +1561,7 @@ export function MapView({
1473
  } finally {
1474
  setRouteScheduleBusy(false);
1475
  }
1476
- }, [plan, routeRecipient, openedRoute, routeCols, field.label, saveTarget, mapsPerLink]);
1477
 
1478
  /**
1479
  * ⭐⭐ OWNER, 2026-08-23 β€” **OPEN A SAVED ROUTE AND SEE IT ON THE MAP.**
@@ -1994,6 +2082,21 @@ export function MapView({
1994
 
1995
  const onPointerMove = useCallback(
1996
  (e: ReactPointerEvent<SVGSVGElement>) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1997
  // ⭐⭐ THE LAZY CAPTURE (W38-T09's fix). A gesture only becomes a drag once it has TRAVELLED,
1998
  // so the capture is taken here rather than on pointerdown. A zero-movement click therefore
1999
  // never captures and reaches the pin it was aimed at; a pan or a marquee captures the
@@ -2009,7 +2112,6 @@ export function MapView({
2009
  if (dn && dn.id === e.pointerId && !e.currentTarget.hasPointerCapture(e.pointerId)
2010
  && Math.abs(e.clientX - dn.cx) + Math.abs(e.clientY - dn.cy) > 3)
2011
  e.currentTarget.setPointerCapture(e.pointerId);
2012
- const { x, y } = localPoint(e.clientX, e.clientY);
2013
  if (drag) {
2014
  // ⚠ W37-T30: this used to set `movedRef` UNCONDITIONALLY, and that is what swallowed a
2015
  // shift-CLICK. Holding shift starts a marquee on pointerdown, so a shift-click is a
@@ -2040,7 +2142,7 @@ export function MapView({
2040
  touchedRef.current = true;
2041
  setView((v) => (v ? { ...v, tx: pan.tx + (x - pan.x), ty: pan.ty + (y - pan.y) } : v));
2042
  },
2043
- [drag, localPoint]
2044
  );
2045
 
2046
  /**
@@ -2095,6 +2197,17 @@ export function MapView({
2095
 
2096
  const onPointerUp = useCallback(
2097
  (e: ReactPointerEvent<SVGSVGElement>) => {
 
 
 
 
 
 
 
 
 
 
 
2098
  if (drag && view) {
2099
  // A gesture smaller than a few px is a mis-click, not a selection β€”
2100
  // clearing the user's set on a stray shift-click would be its own bug.
@@ -2137,7 +2250,7 @@ export function MapView({
2137
  if (e.currentTarget.hasPointerCapture(e.pointerId))
2138
  e.currentTarget.releasePointerCapture(e.pointerId);
2139
  },
2140
- [drag, view, points, onSelectPids]
2141
  );
2142
 
2143
  /**
@@ -2310,6 +2423,7 @@ export function MapView({
2310
  className="cg-route-index-create"
2311
  onClick={() => {
2312
  setOpenedRoute("");
 
2313
  setRouteOn(false);
2314
  setRouteOrder(null);
2315
  setRouteCreateOpen(true);
@@ -2431,7 +2545,7 @@ export function MapView({
2431
  β›” EVERY route this account may see, `mine` or not β€” `GET /customers/route-order`
2432
  has already applied the per-field wall, so what is listed here is exactly what
2433
  the grid would show. Whether you may RE-SOLVE one is a different question, and
2434
- it is answered by the Visit order field select further down. */}
2435
  {(routeCols || []).length > 0 && (
2436
  <>
2437
  <label className="cg-map-route-opt cg-route-field">
@@ -2588,21 +2702,6 @@ export function MapView({
2588
  ⚠ NOT a `cg-cal-nodate` (the red one). Neither of these is an error: the first
2589
  says what the panel took on the user's behalf, the second warns about a click
2590
  that has not been spent yet. */}
2591
- {/* ⚠ THE FIRST NOTE RETIRES ITSELF THE MOMENT IT STOPS BEING TRUE. `autoCapped` is
2592
- what the auto-fill left out, and it stays set for as long as the panel is open
2593
- -- but the sentence it prints is about the CURRENT cohort, and a person who
2594
- then deselects half the pins is no longer routing "the first 100". Gated on the
2595
- selection still being exactly the slice that was taken, so the disclosure
2596
- disappears with the situation it describes rather than becoming a small lie in
2597
- the corner of the panel. */}
2598
- {autoCapped > 0 && customerRoutable.length === ROUTE_STOP_CAP && (
2599
- <span className="cg-map-route-note">
2600
- Routing the first {ROUTE_STOP_CAP.toLocaleString()} pins in this view, in the
2601
- order the view is sorted. {autoCapped.toLocaleString()} more are mapped here:
2602
- the routing service takes {ROUTE_STOP_CAP.toLocaleString()} stops in one
2603
- request, so sort or filter the view to choose which ones travel.
2604
- </span>
2605
- )}
2606
  {customerRoutable.length > ROUTE_STOP_CAP && (
2607
  <span className="cg-map-route-note">
2608
  {customerRoutable.length.toLocaleString()} stops selected. The routing service
@@ -2624,6 +2723,15 @@ export function MapView({
2624
  Splitting them here to leave Save at the bottom would break the older instruction
2625
  while honouring the newer one. ══ */}
2626
  {/* ── ITEM 9: add a record the view is not showing ────────────────────────────── */}
 
 
 
 
 
 
 
 
 
2627
  {allRows && allRows.length > 0 && (
2628
  <RecordPicker
2629
  label="Add a record"
@@ -2645,29 +2753,11 @@ export function MapView({
2645
  vocabulary a person has to translate. The SERVER key is untouched. */}
2646
  {plan && (
2647
  <>
2648
- <label className="cg-map-route-opt cg-route-field">
2649
- Visit order field
2650
- <select
2651
- className="cg-route-select"
2652
- value={saveTarget}
2653
- aria-label="Which visit order field to write"
2654
- onChange={(e) => {
2655
- setSaveTarget(e.target.value);
2656
- setRouteStartPid(null);
2657
- setSaveMsg(null);
2658
- setSaveErr(null);
2659
- }}
2660
- >
2661
- <option value="">New field</option>
2662
- {(routeCols || [])
2663
- .filter((f) => f.mine)
2664
- .map((f) => (
2665
- <option key={f.key} value={f.key}>
2666
- {f.label}
2667
- </option>
2668
- ))}
2669
- </select>
2670
- </label>
2671
  {/* ⚠ THIS NOTE STAYS WITH THE SELECT, while items 8 and 9 send the Save
2672
  button and the Google links to the foot. It is a statement about what
2673
  the LIST ABOVE does not contain ("not listed above"), so it is the one
@@ -3078,32 +3168,34 @@ export function MapView({
3078
  {geoControl}
3079
  {zones !== null && (
3080
  <div className="cg-map-zones" role="group" aria-label="Map Zones">
3081
- <label className="cg-map-zone-control">
3082
- Zones
3083
- <select
3084
- className="cg-route-select"
3085
- multiple
3086
- value={[...activeZoneIds]}
3087
- aria-label="Visible map Zones"
3088
- onChange={(event) => {
3089
- const ids = [...event.currentTarget.selectedOptions].map((option) => option.value);
3090
- setActiveZoneIds(new Set(ids));
3091
- const chosenZone = (zones || []).find((zone) => zone.id === ids[0]);
3092
- setZoneEditorId(chosenZone?.id || "");
3093
- if (chosenZone) {
3094
- setZoneName(chosenZone.name);
3095
- setZoneColor(chosenZone.color);
3096
- setZoneGeographicField(chosenZone.geographicField || "");
3097
- }
3098
- }}
3099
  >
3100
- {(zones || []).map((zone) => (
3101
- <option key={zone.id} value={zone.id}>
3102
- {zone.visible ? zone.name : `${zone.name} (hidden)`}
3103
- </option>
3104
- ))}
3105
- </select>
3106
- </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
3107
  <button
3108
  type="button"
3109
  className="cg-btn"
@@ -3116,21 +3208,11 @@ export function MapView({
3116
  >
3117
  New Zone
3118
  </button>
3119
- <button
3120
- type="button"
3121
- className="cg-btn"
3122
- disabled={activeZonePids.size === 0}
3123
- onClick={selectActiveZoneRecords}
3124
- title="Select records in the chosen visible Zones"
3125
- >
3126
- Select Zone records
3127
- </button>
3128
  <button
3129
  type="button"
3130
  className="cg-btn"
3131
  disabled={activeZonePids.size < 2}
3132
  onClick={() => {
3133
- selectActiveZoneRecords();
3134
  setRouteCreateOpen(true);
3135
  setRouteOn(false);
3136
  setRouteOrder(null);
@@ -3216,7 +3298,9 @@ export function MapView({
3216
  className="cg-btn"
3217
  disabled={zoneBusy}
3218
  onClick={() => void saveZone(zoneEditor || undefined)}
3219
- title={drawnZoneGeometry ? "Save the drawn Zone" : "Save a Zone around the selected records"}
 
 
3220
  >
3221
  {zoneBusy ? "Saving Zone" : zoneEditor ? "Update Zone" : "Save Zone"}
3222
  </button>
@@ -3326,12 +3410,28 @@ export function MapView({
3326
  fill={ring.zone.color}
3327
  stroke={ring.zone.color}
3328
  strokeWidth={hair(1.4)}
 
 
 
 
 
 
 
 
 
 
 
3329
  onClick={(event) => {
3330
  event.stopPropagation();
3331
- onSelectPids(ring.zone.memberships, "replace");
 
 
 
 
 
3332
  }}
3333
  >
3334
- <title>{`${ring.zone.name}: select Zone records`}</title>
3335
  </polygon>
3336
  ))}
3337
  {/* The planned path, UNDER the pins so it never hides a stop. Inside
 
326
  coordinates: number[][][] | number[][][][];
327
  };
328
 
329
+ /** Move a durable GeoJSON boundary without changing its topology or membership snapshot. */
330
+ export function translateZoneGeometry(
331
+ geometry: ZoneGeometry, delta: { lon: number; lat: number }
332
+ ): ZoneGeometry {
333
+ const point = (pair: number[]) => [
334
+ Math.max(-180, Math.min(180, pair[0] + delta.lon)),
335
+ Math.max(-90, Math.min(90, pair[1] + delta.lat)),
336
+ ];
337
+ if (geometry.type === "Polygon") {
338
+ return { type: "Polygon", coordinates: (geometry.coordinates as number[][][])
339
+ .map((ring) => ring.map(point)) };
340
+ }
341
+ return { type: "MultiPolygon", coordinates: (geometry.coordinates as number[][][][])
342
+ .map((polygon) => polygon.map((ring) => ring.map(point))) };
343
+ }
344
+
345
  /** C3's durable map-domain DTO. Zones are not generic fields: geometry, visibility and the
346
  * membership snapshot only make sense to this projection. */
347
  interface MapZone {
 
356
  createdAt?: string;
357
  }
358
 
359
+ interface ZoneMove {
360
+ zone: MapZone;
361
+ start: Pt;
362
+ }
363
+
364
  interface RouteDelivery {
365
  id: string;
366
  name: string;
 
377
  };
378
  }
379
 
380
+ /** Immutable provenance supplied by the host grid for a scheduled route snapshot. */
381
+ export interface RouteSourceView {
382
+ id: string;
383
+ name: string;
384
+ revision: string;
385
+ }
386
+
387
  interface MapPoint {
388
  pid: number;
389
  title: string;
 
428
  onRouteFieldCreated,
429
  routePanelOpen,
430
  allRows,
431
+ activeView,
432
+ tableKey,
433
  }: {
434
  /** DISTINCT data rows from the full pipeline, overlay edits layered β€” the
435
  * calendar/kanban contract, verbatim. */
 
467
  onGeocoded?: (writes: { pid: number; value: string }[]) => void;
468
  onRouteFieldCreated?: (key: string) => void;
469
  routePanelOpen: boolean;
470
+ /** The real active View driving this map, never a map-local synthetic id. */
471
+ activeView: RouteSourceView | null;
472
+ /** Canonical database identity for durable, table-scoped Zones. */
473
+ tableKey: string;
474
  /**
475
  * ⭐⭐ OWNER ITEM 9 β€” THE WHOLE BOOK, so a stop can be added that the VIEW filtered out.
476
  *
 
533
  * array is an honest, editable no-Zones state. */
534
  const [zones, setZones] = useState<MapZone[] | null>(null);
535
  const [activeZoneIds, setActiveZoneIds] = useState<ReadonlySet<string>>(new Set());
536
+ const [zoneChooserOpen, setZoneChooserOpen] = useState(false);
537
  const [zoneEditorId, setZoneEditorId] = useState("");
538
  const [zoneName, setZoneName] = useState("");
539
  const [zoneColor, setZoneColor] = useState(ZONE_COLOR_CHOICES[0].value);
540
  const [zoneGeographicField, setZoneGeographicField] = useState("");
541
  const [zoneGeographicValue, setZoneGeographicValue] = useState("");
542
  const [drawnZoneGeometry, setDrawnZoneGeometry] = useState<ZoneGeometry | null>(null);
543
+ const [zoneMove, setZoneMove] = useState<ZoneMove | null>(null);
544
+ const [zoneGeometryDraft, setZoneGeometryDraft] = useState<{ id: string; geometry: ZoneGeometry } | null>(null);
545
  const [zoneBusy, setZoneBusy] = useState(false);
546
  const [zoneErr, setZoneErr] = useState<string | null>(null);
547
  /**
 
825
 
826
  const loadZones = useCallback(async () => {
827
  try {
828
+ const res = await fetch(`/api/v1/geo/zones?tableKey=${encodeURIComponent(tableKey)}`, {
829
+ credentials: "same-origin",
830
+ });
831
  const body = res.ok ? await res.json().catch(() => null) : null;
832
  setZones(Array.isArray(body?.zones) ? body.zones : []);
833
  } catch {
834
  setZones([]);
835
  }
836
+ }, [tableKey]);
837
  useEffect(() => { void loadZones(); }, [loadZones]);
838
 
839
  const activeZones = useMemo(
 
849
  () => (zones || []).find((zone) => zone.id === zoneEditorId) || null,
850
  [zones, zoneEditorId]
851
  );
852
+ /**
853
+ * Zone visibility is a checkbox chooser, not a ctrl-click native multi-select. A toggle also
854
+ * immediately projects the Zone's durable membership onto the grid selection; removing one
855
+ * Zone preserves records that are still held by another checked Zone.
856
+ */
857
+ const toggleZone = useCallback((zone: MapZone, checked: boolean) => {
858
+ const next = new Set(activeZoneIds);
859
+ if (checked) next.add(zone.id);
860
+ else next.delete(zone.id);
861
+ const stillHeld = new Set<number>();
862
+ for (const item of zones || []) {
863
+ if (!next.has(item.id)) continue;
864
+ for (const pid of item.memberships || []) stillHeld.add(pid);
865
+ }
866
+ const selection = new Set(selectedPids);
867
+ if (checked) for (const pid of zone.memberships || []) selection.add(pid);
868
+ else for (const pid of zone.memberships || []) if (!stillHeld.has(pid)) selection.delete(pid);
869
+ onSelectPids([...selection], "replace");
870
+ setActiveZoneIds(next);
871
+ if (checked) {
872
+ setZoneEditorId(zone.id);
873
+ setZoneName(zone.name);
874
+ setZoneColor(zone.color);
875
+ setZoneGeographicField(zone.geographicField || "");
876
+ }
877
+ }, [activeZoneIds, zones, selectedPids, onSelectPids]);
878
  const zonePolygons = useMemo(() => activeZones.flatMap((zone) => {
879
+ const geometry = zoneGeometryDraft?.id === zone.id
880
+ ? zoneGeometryDraft.geometry
881
+ : zone.geometryGeoJson;
882
  const polygons = geometry?.type === "Polygon"
883
  ? [geometry.coordinates as number[][][]]
884
  : geometry?.type === "MultiPolygon"
 
891
  .filter((pair) => Array.isArray(pair) && Number.isFinite(pair[0]) && Number.isFinite(pair[1]))
892
  .map((pair) => project(pair[0], pair[1])),
893
  })).filter((ring) => ring.points.length >= 4));
894
+ }), [activeZones, zoneGeometryDraft]);
895
 
896
  const selectionZoneGeometry = useCallback((): ZoneGeometry | null => {
897
  if (drawnZoneGeometry) return drawnZoneGeometry;
 
925
  credentials: "same-origin",
926
  headers: { "Content-Type": "application/json" },
927
  body: JSON.stringify({
928
+ tableKey,
929
  name,
930
  color: zoneColor || existing?.color || ZONE_COLOR_CHOICES[0].value,
931
  geometryGeoJson,
 
954
  } finally {
955
  setZoneBusy(false);
956
  }
957
+ }, [selectionZoneGeometry, zoneName, zoneColor, zoneGeographicField, selectedPids, tableKey]);
 
 
 
 
958
 
959
  const setZoneVisible = useCallback(async (zone: MapZone, visible: boolean) => {
960
  setZoneBusy(true);
 
964
  method: "PATCH",
965
  credentials: "same-origin",
966
  headers: { "Content-Type": "application/json" },
967
+ body: JSON.stringify({ tableKey, visible }),
968
  });
969
  const body = await res.json().catch(() => null);
970
  if (!res.ok) {
 
978
  } finally {
979
  setZoneBusy(false);
980
  }
981
+ }, [tableKey]);
982
+
983
+ /** Persist a direct boundary drag without re-deriving membership from the current selection. */
984
+ const persistZoneGeometry = useCallback(async (zone: MapZone, geometryGeoJson: ZoneGeometry) => {
985
+ setZoneBusy(true);
986
+ setZoneErr(null);
987
+ try {
988
+ const res = await fetch(`/api/v1/geo/zones/${encodeURIComponent(zone.id)}`, {
989
+ method: "PATCH",
990
+ credentials: "same-origin",
991
+ headers: { "Content-Type": "application/json" },
992
+ body: JSON.stringify({ tableKey, geometryGeoJson }),
993
+ });
994
+ const body = await res.json().catch(() => null);
995
+ if (!res.ok) {
996
+ setZoneErr(body?.error?.message || "The Zone boundary could not be saved.");
997
+ return;
998
+ }
999
+ const saved = body?.zone as MapZone | undefined;
1000
+ if (saved) setZones((current) => (current || []).map((item) => item.id === saved.id ? saved : item));
1001
+ } catch {
1002
+ setZoneErr("The Zone boundary could not be saved.");
1003
+ } finally {
1004
+ setZoneBusy(false);
1005
+ }
1006
+ }, [tableKey]);
1007
 
1008
  // The fit is the INITIAL view, not the projection (see mapProjection.ts).
1009
  const fit = useMemo(
 
1207
  * this component through `useVisibleRows`' filter -> search -> SORT -> group pipeline, so
1208
  * "the first N" means the first N as the view is sorted -- the user's own ranking, which is
1209
  * the only ranking this surface has. The owner asked for "the top SKU"; the map has no SKU,
1210
+ * and inventing one would be a plausible order nobody chose. Bulk selection now remains an
1211
+ * explicit action, so simply opening the route rail cannot alter the current selection.
1212
  */
1213
+ /**
1214
+ * Opening Routes is intentionally inert. This explicitly labelled control is the sole bulk
1215
+ * selection door: it takes every record matched by the current View (never a hidden cap of
1216
+ * mapped pins) and then starts the local planner.
1217
+ */
1218
+ const selectFilteredRouteRecords = useCallback(() => {
1219
+ const pids = rows.map((row) => row.pid).filter((pid) => Number.isFinite(pid) && pid > 0);
1220
+ if (!pids.length) return;
1221
+ onSelectPids(pids, "replace");
 
 
 
 
 
1222
  setRouteOn(true);
1223
  setRouteOrder(null);
1224
+ }, [rows, onSelectPids]);
1225
 
1226
  /** The depot joins BOTH index-parallel arrays below at position zero, and only there. */
1227
  const depotPoint = useMemo((): MapPoint | null => {
 
1512
 
1513
  const scheduleRouteDelivery = useCallback(async () => {
1514
  if (!plan || !routeRecipient.trim()) return;
1515
+ if (!activeView?.id || !activeView.revision) {
1516
+ setRouteScheduleErr("Open a saved View before scheduling a route.");
1517
+ return;
1518
+ }
1519
  const orderedStopIds = plan.ordered.map((point) => point.pid).filter((pid) => pid > 0);
1520
  if (orderedStopIds.length < 2) return;
1521
  setRouteScheduleBusy(true);
 
1534
  recipient: routeRecipient.trim(),
1535
  schedule: { cron: "0 8 * * *", enabled: true },
1536
  routeSnapshot: {
1537
+ // This route's stop ids are frozen below. The View tuple is provenance, supplied by
1538
+ // CustomerGrid from the actual active View and its canonical configuration revision.
1539
+ sourceViewId: activeView.id,
1540
+ sourceViewRevision: activeView.revision,
1541
+ sourceViewName: activeView.name,
1542
  timezone,
1543
  routeField: saveTarget || openedRoute,
1544
  orderedStopIds,
 
1561
  } finally {
1562
  setRouteScheduleBusy(false);
1563
  }
1564
+ }, [plan, routeRecipient, openedRoute, routeCols, activeView, saveTarget, mapsPerLink]);
1565
 
1566
  /**
1567
  * ⭐⭐ OWNER, 2026-08-23 β€” **OPEN A SAVED ROUTE AND SEE IT ON THE MAP.**
 
2082
 
2083
  const onPointerMove = useCallback(
2084
  (e: ReactPointerEvent<SVGSVGElement>) => {
2085
+ const { x, y } = localPoint(e.clientX, e.clientY);
2086
+ if (zoneMove && view) {
2087
+ if (Math.abs(x - zoneMove.start.x) + Math.abs(y - zoneMove.start.y) <= 2) return;
2088
+ movedRef.current = true;
2089
+ const origin = unproject(fromScreen(zoneMove.start, view));
2090
+ const current = unproject(fromScreen({ x, y }, view));
2091
+ setZoneGeometryDraft({
2092
+ id: zoneMove.zone.id,
2093
+ geometry: translateZoneGeometry(zoneMove.zone.geometryGeoJson, {
2094
+ lon: current.lon - origin.lon,
2095
+ lat: current.lat - origin.lat,
2096
+ }),
2097
+ });
2098
+ return;
2099
+ }
2100
  // ⭐⭐ THE LAZY CAPTURE (W38-T09's fix). A gesture only becomes a drag once it has TRAVELLED,
2101
  // so the capture is taken here rather than on pointerdown. A zero-movement click therefore
2102
  // never captures and reaches the pin it was aimed at; a pan or a marquee captures the
 
2112
  if (dn && dn.id === e.pointerId && !e.currentTarget.hasPointerCapture(e.pointerId)
2113
  && Math.abs(e.clientX - dn.cx) + Math.abs(e.clientY - dn.cy) > 3)
2114
  e.currentTarget.setPointerCapture(e.pointerId);
 
2115
  if (drag) {
2116
  // ⚠ W37-T30: this used to set `movedRef` UNCONDITIONALLY, and that is what swallowed a
2117
  // shift-CLICK. Holding shift starts a marquee on pointerdown, so a shift-click is a
 
2142
  touchedRef.current = true;
2143
  setView((v) => (v ? { ...v, tx: pan.tx + (x - pan.x), ty: pan.ty + (y - pan.y) } : v));
2144
  },
2145
+ [drag, localPoint, zoneMove, view]
2146
  );
2147
 
2148
  /**
 
2197
 
2198
  const onPointerUp = useCallback(
2199
  (e: ReactPointerEvent<SVGSVGElement>) => {
2200
+ if (zoneMove) {
2201
+ const geometry = zoneGeometryDraft?.id === zoneMove.zone.id
2202
+ ? zoneGeometryDraft.geometry
2203
+ : null;
2204
+ setZoneMove(null);
2205
+ setZoneGeometryDraft(null);
2206
+ if (geometry) void persistZoneGeometry(zoneMove.zone, geometry);
2207
+ if (e.currentTarget.hasPointerCapture(e.pointerId))
2208
+ e.currentTarget.releasePointerCapture(e.pointerId);
2209
+ return;
2210
+ }
2211
  if (drag && view) {
2212
  // A gesture smaller than a few px is a mis-click, not a selection β€”
2213
  // clearing the user's set on a stray shift-click would be its own bug.
 
2250
  if (e.currentTarget.hasPointerCapture(e.pointerId))
2251
  e.currentTarget.releasePointerCapture(e.pointerId);
2252
  },
2253
+ [drag, view, points, onSelectPids, zoneMove, zoneGeometryDraft, persistZoneGeometry]
2254
  );
2255
 
2256
  /**
 
2423
  className="cg-route-index-create"
2424
  onClick={() => {
2425
  setOpenedRoute("");
2426
+ setSaveTarget("");
2427
  setRouteOn(false);
2428
  setRouteOrder(null);
2429
  setRouteCreateOpen(true);
 
2545
  β›” EVERY route this account may see, `mine` or not β€” `GET /customers/route-order`
2546
  has already applied the per-field wall, so what is listed here is exactly what
2547
  the grid would show. Whether you may RE-SOLVE one is a different question, and
2548
+ it is answered by the saved route's own edit flow, never by a second selector. */}
2549
  {(routeCols || []).length > 0 && (
2550
  <>
2551
  <label className="cg-map-route-opt cg-route-field">
 
2702
  ⚠ NOT a `cg-cal-nodate` (the red one). Neither of these is an error: the first
2703
  says what the panel took on the user's behalf, the second warns about a click
2704
  that has not been spent yet. */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2705
  {customerRoutable.length > ROUTE_STOP_CAP && (
2706
  <span className="cg-map-route-note">
2707
  {customerRoutable.length.toLocaleString()} stops selected. The routing service
 
2723
  Splitting them here to leave Save at the bottom would break the older instruction
2724
  while honouring the newer one. ══ */}
2725
  {/* ── ITEM 9: add a record the view is not showing ────────────────────────────── */}
2726
+ <button
2727
+ type="button"
2728
+ className="cg-btn"
2729
+ disabled={rows.length === 0}
2730
+ onClick={selectFilteredRouteRecords}
2731
+ title="Select every record currently matched by this View and plan the route"
2732
+ >
2733
+ Select all {rows.length.toLocaleString()} filtered records
2734
+ </button>
2735
  {allRows && allRows.length > 0 && (
2736
  <RecordPicker
2737
  label="Add a record"
 
2753
  vocabulary a person has to translate. The SERVER key is untouched. */}
2754
  {plan && (
2755
  <>
2756
+ <span className="cg-map-route-note">
2757
+ {saveTarget
2758
+ ? `Updating ${(routeCols || []).find((route) => route.key === saveTarget)?.label || "this route"}.`
2759
+ : "Save this plan as a new route."}
2760
+ </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2761
  {/* ⚠ THIS NOTE STAYS WITH THE SELECT, while items 8 and 9 send the Save
2762
  button and the Google links to the foot. It is a statement about what
2763
  the LIST ABOVE does not contain ("not listed above"), so it is the one
 
3168
  {geoControl}
3169
  {zones !== null && (
3170
  <div className="cg-map-zones" role="group" aria-label="Map Zones">
3171
+ <div className="cg-map-zone-chooser">
3172
+ <button
3173
+ type="button"
3174
+ className="cg-btn"
3175
+ aria-expanded={zoneChooserOpen}
3176
+ aria-controls="cg-map-zone-choices"
3177
+ onClick={() => setZoneChooserOpen((open) => !open)}
 
 
 
 
 
 
 
 
 
 
 
3178
  >
3179
+ Zones{activeZoneIds.size ? ` (${activeZoneIds.size.toLocaleString()})` : ""}
3180
+ </button>
3181
+ {zoneChooserOpen && (
3182
+ <div id="cg-map-zone-choices" className="cg-map-zone-choices" role="group" aria-label="Visible map Zones">
3183
+ {(zones || []).length === 0 ? (
3184
+ <span className="cg-map-zone-empty">No Zones yet.</span>
3185
+ ) : (zones || []).map((zone) => (
3186
+ <label className="cg-map-zone-choice" key={zone.id}>
3187
+ <input
3188
+ type="checkbox"
3189
+ checked={activeZoneIds.has(zone.id)}
3190
+ onChange={(event) => toggleZone(zone, event.target.checked)}
3191
+ />
3192
+ <span className="cg-map-zone-swatch" style={{ backgroundColor: zone.color }} aria-hidden="true" />
3193
+ <span>{zone.visible ? zone.name : `${zone.name} (hidden)`}</span>
3194
+ </label>
3195
+ ))}
3196
+ </div>
3197
+ )}
3198
+ </div>
3199
  <button
3200
  type="button"
3201
  className="cg-btn"
 
3208
  >
3209
  New Zone
3210
  </button>
 
 
 
 
 
 
 
 
 
3211
  <button
3212
  type="button"
3213
  className="cg-btn"
3214
  disabled={activeZonePids.size < 2}
3215
  onClick={() => {
 
3216
  setRouteCreateOpen(true);
3217
  setRouteOn(false);
3218
  setRouteOrder(null);
 
3298
  className="cg-btn"
3299
  disabled={zoneBusy}
3300
  onClick={() => void saveZone(zoneEditor || undefined)}
3301
+ title={drawnZoneGeometry
3302
+ ? (zoneEditor ? "Replace this Zone boundary with the drawn shape" : "Save the drawn Zone")
3303
+ : "Save a Zone around the selected records"}
3304
  >
3305
  {zoneBusy ? "Saving Zone" : zoneEditor ? "Update Zone" : "Save Zone"}
3306
  </button>
 
3410
  fill={ring.zone.color}
3411
  stroke={ring.zone.color}
3412
  strokeWidth={hair(1.4)}
3413
+ onPointerDown={(event) => {
3414
+ if (event.button !== 0 || !view) return;
3415
+ event.stopPropagation();
3416
+ movedRef.current = false;
3417
+ const svg = event.currentTarget.ownerSVGElement;
3418
+ svg?.setPointerCapture(event.pointerId);
3419
+ setZoneMove({
3420
+ zone: ring.zone,
3421
+ start: localPoint(event.clientX, event.clientY),
3422
+ });
3423
+ }}
3424
  onClick={(event) => {
3425
  event.stopPropagation();
3426
+ if (movedRef.current) {
3427
+ movedRef.current = false;
3428
+ return;
3429
+ }
3430
+ if (!activeZoneIds.has(ring.zone.id)) toggleZone(ring.zone, true);
3431
+ else onSelectPids(ring.zone.memberships, "replace");
3432
  }}
3433
  >
3434
+ <title>{`${ring.zone.name}: click to select records, drag to move boundary`}</title>
3435
  </polygon>
3436
  ))}
3437
  {/* The planned path, UNDER the pins so it never hides a stop. Inside
web/src/customer-grid/map.css CHANGED
@@ -255,7 +255,55 @@
255
  font: 500 var(--lp-fs-3xs)/1.35 Inter, system-ui, sans-serif;
256
  }
257
 
258
- .cg-map-zone-control select[multiple] { min-height: 52px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
 
260
  /* --- the address lookup (T37) ---------------------------------------------
261
  *
 
255
  font: 500 var(--lp-fs-3xs)/1.35 Inter, system-ui, sans-serif;
256
  }
257
 
258
+ .cg-map-zone-chooser {
259
+ position: relative;
260
+ }
261
+
262
+ .cg-map-zone-choices {
263
+ position: absolute;
264
+ z-index: 4;
265
+ top: calc(100% + 4px);
266
+ left: 0;
267
+ display: grid;
268
+ width: min(280px, calc(100vw - 32px));
269
+ max-height: 248px;
270
+ overflow-y: auto;
271
+ padding: 5px;
272
+ border: 1px solid var(--lp-line);
273
+ border-radius: 6px;
274
+ background: var(--lp-panel, #fff);
275
+ box-shadow: 0 8px 20px rgba(24, 39, 55, 0.14);
276
+ }
277
+
278
+ .cg-map-zone-choice {
279
+ display: grid;
280
+ grid-template-columns: 14px 10px minmax(0, 1fr);
281
+ align-items: center;
282
+ gap: 7px;
283
+ min-height: 28px;
284
+ padding: 3px 5px;
285
+ border-radius: 4px;
286
+ color: var(--lp-ink);
287
+ cursor: pointer;
288
+ font: 500 var(--lp-fs-2xs)/1.35 Inter, system-ui, sans-serif;
289
+ }
290
+
291
+ .cg-map-zone-choice:hover { background: var(--lp-hover, #f3f6f9); }
292
+
293
+ .cg-map-zone-choice input { margin: 0; }
294
+
295
+ .cg-map-zone-swatch {
296
+ width: 9px;
297
+ height: 9px;
298
+ border: 1px solid color-mix(in srgb, var(--lp-ink) 30%, transparent);
299
+ border-radius: 50%;
300
+ }
301
+
302
+ .cg-map-zone-empty {
303
+ padding: 7px 6px;
304
+ color: var(--lp-muted);
305
+ font: 400 var(--lp-fs-2xs)/1.35 Inter, system-ui, sans-serif;
306
+ }
307
 
308
  /* --- the address lookup (T37) ---------------------------------------------
309
  *
web/src/customer-grid/types.ts CHANGED
@@ -104,6 +104,33 @@ export type AiCellState = "agent" | "human" | "stale" | "error";
104
 
105
  export type FieldSource = "odoo" | "overlay";
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  /**
108
  * R13 β€” the languages a `code` column may declare. Mirrors `aios_grid.CODE_LANGUAGES`
109
  * name-for-name; the fields-contract gate diffs the two, because a language the client offers
@@ -2150,17 +2177,8 @@ export interface Field {
2150
  * The host may add calendar-specific keys over time; this small, forward-compatible core is
2151
  * enough for the editor to describe the next occurrence without inventing a second cell value.
2152
  */
2153
- recurrence?: {
2154
- frequency?: "none" | "daily" | "weekly" | "monthly" | "yearly";
2155
- interval?: number;
2156
- weekdays?: number[];
2157
- skipHolidays?: boolean;
2158
- holidayCalendar?: "us";
2159
- /** Server-computed next dates win over the browser preview when supplied. */
2160
- preview?: string[];
2161
- };
2162
- /** The server-authoritative next date for a recurring Date field. */
2163
- nextDate?: string;
2164
  /**
2165
  * Whether select-family choices render with coloured pills. Absent means true so legacy
2166
  * fields keep their existing appearance; an explicit false is the user's opt-out.
@@ -2409,6 +2427,9 @@ export interface Field {
2409
  reciprocal?: string;
2410
  /** Airtable's `prefersSingleRecordLink` */
2411
  single?: boolean;
 
 
 
2412
  };
2413
  /**
2414
  * ⭐ 2026-08-07 β€” an AGGREGATE over the rows a `link` column resolves (Airtable's `rollup`).
@@ -3179,6 +3200,23 @@ export function rowsAuthoritative(sel: {
3179
  return sel.current === undefined || sel.rows.length > 0;
3180
  }
3181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3182
  /**
3183
  * ⭐ Wave-20 owner item 2 β€” **WHICH COLUMN A FILTER RULE IS ABOUT, and it is not always
3184
  * `rule.colId`.**
 
104
 
105
  export type FieldSource = "odoo" | "overlay";
106
 
107
+ /** The durable Date-field schedule. The API owns validation and every preview calculation. */
108
+ export type DateRecurrenceRule = {
109
+ kind: "daily" | "weekly" | "monthly" | "yearly" | "holiday";
110
+ interval?: number;
111
+ /** Monday = 0, matching the server's ISO weekday contract. */
112
+ weekdays?: number[];
113
+ /** Monthly day (1..31), or yearly month/day. */
114
+ day?: number;
115
+ month?: number;
116
+ /** Monthly first..fifth (1..5), or last (-1), plus an ISO weekday. */
117
+ ordinal?: -1 | 1 | 2 | 3 | 4 | 5;
118
+ weekday?: number;
119
+ /** Only an API-supplied public calendar can be saved. */
120
+ calendar?: "US" | "CA";
121
+ /** -1 = day before, 0 = day on, 1 = day after. */
122
+ offset?: -1 | 0 | 1;
123
+ };
124
+
125
+ export type DateRecurrence = {
126
+ startDate: string;
127
+ rules: DateRecurrenceRule[];
128
+ previewCount?: number;
129
+ /** Derived by the server; never written as an independent schedule value. */
130
+ nextDate?: string | null;
131
+ preview?: string[];
132
+ };
133
+
134
  /**
135
  * R13 β€” the languages a `code` column may declare. Mirrors `aios_grid.CODE_LANGUAGES`
136
  * name-for-name; the fields-contract gate diffs the two, because a language the client offers
 
2177
  * The host may add calendar-specific keys over time; this small, forward-compatible core is
2178
  * enough for the editor to describe the next occurrence without inventing a second cell value.
2179
  */
2180
+ /** `null` is an explicit clear in a field-upsert request; persisted payloads omit it. */
2181
+ recurrence?: DateRecurrence | null;
 
 
 
 
 
 
 
 
 
2182
  /**
2183
  * Whether select-family choices render with coloured pills. Absent means true so legacy
2184
  * fields keep their existing appearance; an explicit false is the user's opt-out.
 
2427
  reciprocal?: string;
2428
  /** Airtable's `prefersSingleRecordLink` */
2429
  single?: boolean;
2430
+ /** W43: how the relation is populated. Manual keeps the ordinary record picker available;
2431
+ * automatic is retained as schema state for a configured/engine-driven relation. */
2432
+ selectionMode?: "manual" | "automatic";
2433
  };
2434
  /**
2435
  * ⭐ 2026-08-07 β€” an AGGREGATE over the rows a `link` column resolves (Airtable's `rollup`).
 
3200
  return sel.current === undefined || sel.rows.length > 0;
3201
  }
3202
 
3203
+
3204
+ /**
3205
+ * Whether an embedded Link picker should adopt its parent's selection command.
3206
+ *
3207
+ * A parent prop that has not changed is merely the stale value from before the user's checkbox
3208
+ * click; applying it would erase that click before the child reports upward. The pure predicate
3209
+ * lives here so the controlled-selection race has a node-runnable regression rather than a JSX
3210
+ * comment that can drift.
3211
+ */
3212
+ export function shouldAdoptEmbeddedSelection(
3213
+ priorParentKey: string,
3214
+ parentKey: string,
3215
+ localKey: string
3216
+ ): boolean {
3217
+ return priorParentKey !== parentKey && localKey !== parentKey;
3218
+ }
3219
+
3220
  /**
3221
  * ⭐ Wave-20 owner item 2 β€” **WHICH COLUMN A FILTER RULE IS ABOUT, and it is not always
3222
  * `rule.colId`.**
web/src/shell/Shell.tsx CHANGED
@@ -1136,10 +1136,6 @@ function ShellFrame() {
1136
  const expandRail = useCallback(() => {
1137
  setNavCollapsed(false);
1138
  }, []);
1139
- /** …and every deliberate fold through here. The AUTOMATIC fold has its own handler, below. */
1140
- const collapseRail = useCallback(() => {
1141
- setNavCollapsed(true);
1142
- }, []);
1143
  // Owner item 3 (2026-07-31) β€” the collapsed strip names its icons on hover. A real DOM
1144
  // tooltip positioned FIXED beside the rail: the rail scrolls and clips (overflow), so a
1145
  // CSS ::after inside it could never escape; and it is pointer-events:none by standing rule
@@ -1224,6 +1220,11 @@ function ShellFrame() {
1224
  setRail((s) => ({ ...s, at: 0 }));
1225
  setDbQuery("");
1226
  }, []);
 
 
 
 
 
1227
  /** Drill into the database list. RESUMES the walk rather than restarting it β€” see `closeDbFly`. */
1228
  const openData = useCallback(() => {
1229
  setRail((s) =>
@@ -1292,13 +1293,13 @@ function ShellFrame() {
1292
  const onToast = (e: Event) => setToast(String((e as CustomEvent).detail ?? ""));
1293
  // ⭐⭐ W38-T04 (R9) β€” THE AUTOMATIC FOLD IS UNCONDITIONAL, AND THAT IS THE WHOLE RULE.
1294
  // `NAV_MINIMIZE_EVENT` fires from `CustomerGrid` on EVERY cell click and EVERY view switch;
1295
- // this is its only listener, and it sets, every time, with nothing in front of it. W34-T12's
1296
  // `railHeldOpen` guard stood here and is deleted (see the block by `expandRail` for why the
1297
  // ref went with it). β›” ANY early return, condition or stored preference added between this
1298
- // line and the setter re-creates the behaviour owner item 3 asked to be fixed FOREVER β€” and
1299
  // `verify_ui.py`'s `W38-T04 unconditional` leg exists to go red the moment one appears.
1300
  const onNavMinimize = () => {
1301
- setNavCollapsed(true);
1302
  };
1303
  // C-SHARE: the rail asks, the frame opens. A malformed detail opens NOTHING β€”
1304
  // `parseShareRequest` fail-closes on an unknown kind rather than launching a
@@ -1317,7 +1318,7 @@ function ShellFrame() {
1317
  window.removeEventListener(NAV_MINIMIZE_EVENT, onNavMinimize);
1318
  window.removeEventListener(SHARE_OPEN_EVENT, onShareOpen);
1319
  };
1320
- }, []);
1321
 
1322
  // The toast clears itself; nothing else depends on it having been seen.
1323
  useEffect(() => {
@@ -2272,7 +2273,7 @@ function ShellFrame() {
2272
  ⚠ THE TWO CLASSES COMPOSE, in the right order: while `/nav` is in flight the skeleton
2273
  rule hides `.shell-level` too, so a rail cannot paint a database list over a loading
2274
  frame. */}
2275
- <nav className={"shell-nav" + (railLoading ? " is-rail-loading" : "") + (dbAt ? " is-datalevel" : "")}>
2276
  {/* ⭐⭐ W33-T75 β€” THE ONE IN-FLIGHT STATE. `is-rail-loading` hides every sibling below
2277
  (`navExtras.css`), so a row cannot paint ahead of its neighbours. Rendering the rows
2278
  and hiding them β€” rather than not rendering them β€” is deliberate: the flyout portal,
@@ -2609,7 +2610,7 @@ function ShellFrame() {
2609
  spells the string it is retiring can hold a grep-gate green over an empty file.
2610
  `verify_home` strips comments before it looks, so this one could have quoted them
2611
  safely today; the next gate to read this file may not. */}
2612
- {dbAt ? (
2613
  <div className="shell-level">
2614
  {/* ⭐ THE PATH HEADER (owner instruction 29) β€” *"a back '<' and forward '>'
2615
  button so we can go to the previous path or next path that we have already
 
1136
  const expandRail = useCallback(() => {
1137
  setNavCollapsed(false);
1138
  }, []);
 
 
 
 
1139
  // Owner item 3 (2026-07-31) β€” the collapsed strip names its icons on hover. A real DOM
1140
  // tooltip positioned FIXED beside the rail: the rail scrolls and clips (overflow), so a
1141
  // CSS ::after inside it could never escape; and it is pointer-events:none by standing rule
 
1220
  setRail((s) => ({ ...s, at: 0 }));
1221
  setDbQuery("");
1222
  }, []);
1223
+ /** A folded rail is always the global rail, never a squeezed database path. */
1224
+ const collapseRail = useCallback(() => {
1225
+ closeDbFly();
1226
+ setNavCollapsed(true);
1227
+ }, [closeDbFly]);
1228
  /** Drill into the database list. RESUMES the walk rather than restarting it β€” see `closeDbFly`. */
1229
  const openData = useCallback(() => {
1230
  setRail((s) =>
 
1293
  const onToast = (e: Event) => setToast(String((e as CustomEvent).detail ?? ""));
1294
  // ⭐⭐ W38-T04 (R9) β€” THE AUTOMATIC FOLD IS UNCONDITIONAL, AND THAT IS THE WHOLE RULE.
1295
  // `NAV_MINIMIZE_EVENT` fires from `CustomerGrid` on EVERY cell click and EVERY view switch;
1296
+ // this is its only listener, and it folds, every time, with nothing in front of it. W34-T12's
1297
  // `railHeldOpen` guard stood here and is deleted (see the block by `expandRail` for why the
1298
  // ref went with it). β›” ANY early return, condition or stored preference added between this
1299
+ // line and the fold re-creates the behaviour owner item 3 asked to be fixed FOREVER β€” and
1300
  // `verify_ui.py`'s `W38-T04 unconditional` leg exists to go red the moment one appears.
1301
  const onNavMinimize = () => {
1302
+ collapseRail();
1303
  };
1304
  // C-SHARE: the rail asks, the frame opens. A malformed detail opens NOTHING β€”
1305
  // `parseShareRequest` fail-closes on an unknown kind rather than launching a
 
1318
  window.removeEventListener(NAV_MINIMIZE_EVENT, onNavMinimize);
1319
  window.removeEventListener(SHARE_OPEN_EVENT, onShareOpen);
1320
  };
1321
+ }, [collapseRail]);
1322
 
1323
  // The toast clears itself; nothing else depends on it having been seen.
1324
  useEffect(() => {
 
2273
  ⚠ THE TWO CLASSES COMPOSE, in the right order: while `/nav` is in flight the skeleton
2274
  rule hides `.shell-level` too, so a rail cannot paint a database list over a loading
2275
  frame. */}
2276
+ <nav className={"shell-nav" + (railLoading ? " is-rail-loading" : "") + (!navCollapsed && dbAt ? " is-datalevel" : "")}>
2277
  {/* ⭐⭐ W33-T75 β€” THE ONE IN-FLIGHT STATE. `is-rail-loading` hides every sibling below
2278
  (`navExtras.css`), so a row cannot paint ahead of its neighbours. Rendering the rows
2279
  and hiding them β€” rather than not rendering them β€” is deliberate: the flyout portal,
 
2610
  spells the string it is retiring can hold a grep-gate green over an empty file.
2611
  `verify_home` strips comments before it looks, so this one could have quoted them
2612
  safely today; the next gate to read this file may not. */}
2613
+ {!navCollapsed && dbAt ? (
2614
  <div className="shell-level">
2615
  {/* ⭐ THE PATH HEADER (owner instruction 29) β€” *"a back '<' and forward '>'
2616
  button so we can go to the previous path or next path that we have already
web/wiring_rows/wave43_shell_repair.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wave 43 continuation shell-repair β€” DatePicker consumer wiring.
2
+
3
+ The DatePicker implementation and CustomerGrid mount are owned by the grid lane. This module
4
+ records their completed cross-fence contract in the shared WIRINGS map, so the coverage ratchet
5
+ cannot mistake a mounted calendar for an unowned component again.
6
+ """
7
+ from __future__ import annotations
8
+
9
+
10
+ ROWS: list[tuple[str, str, str, str, str]] = [
11
+ ("W43C-T02 DatePicker is DECLARED and MOUNTED by the grid",
12
+ "customer-grid/DatePicker.tsx", r"export function DatePicker\(",
13
+ "customer-grid/CustomerGrid.tsx", r"<DatePicker"),
14
+ ("W43C-T02 a picked date writes through the grid's ordinary record patch door",
15
+ "customer-grid/DatePicker.tsx", r"onChange: \(value: string\) => void;",
16
+ "customer-grid/CustomerGrid.tsx",
17
+ r'onChange=\{\(value\) => patchAndRecord\(datePicker\.pid, \{ \[dateField\.key\]: value \}, "a date"\)\}'),
18
+ ("W43C-T02 recurrence is a required DatePicker contract and persists the field definition",
19
+ "customer-grid/DatePicker.tsx",
20
+ r'onRecurrence: \(recurrence: NonNullable<Field\["recurrence"\]>, nextDate: string \| undefined\) => void;',
21
+ "customer-grid/CustomerGrid.tsx",
22
+ r'onRecurrence=\{\(recurrence, nextDate\) => saveField\(\{ \.\.\.dateField, recurrence, nextDate \}\)\}'),
23
+ ]
24
+
25
+ DELETIONS: list[tuple[str, str, str, str]] = []