Mobile Phase 8h: Grid detail stage/reminder cards + Open-in-Grid deep-link

Grid full-screen investor detail, conformed to the dc anatomy:
- G4: pipeline stage as a single tappable .detail-tap-card (chip + Change/Add)
- G5: dedicated Reminder card fed by the soonest active reminder; tri-state
  (loading → disabled "Checking…" so a pre-load tap can't POST a duplicate;
  none → "No reminder set"; object → edit). Edits PATCH in place, else POST.
- G6 (notes timeline) was already in place.

Open-in-Grid deep-link, now on all three mobile detail surfaces (Contacts,
Pipeline, Reminders): a shared shell openInvestorInGrid(rowId) sets a one-shot
gridUiAction object the mobile grid consumes on mount to open that investor's
detail; the desktop grid drains the unrecognized object so it can't linger.
Each surface gets its grid row id from a server-injected source_row_id:
contacts via contact_grid_signals, opportunities via the durable
fundraising_investor_id join, reminders via the investor_id join. All are
read-only/GET-only or field-allowlist writes, so none need a strip point.

Tests: source_row_id injection assertions for contacts, opportunities, and
reminders; full suite 40/40. Client surfaces jsdom-verified.
This commit is contained in:
Keysat
2026-06-20 07:08:29 -05:00
parent f645288fc3
commit 707a270922
6 changed files with 200 additions and 44 deletions
+18
View File
@@ -135,6 +135,7 @@ def main():
check(bool(fr_id), f"opportunity carries fundraising_investor_id (got {fr_id})")
check(_opp_count_live(fr_id) == 1, "exactly one live opp linked to the investor")
opp_id = opp.get("id")
jane_contact_id = opp.get("contact_id")
# ── idempotent re-link: returns existing, board-owned stage NOT reseeded ──
print("\n[idempotent: re-link returns existing opp without reseeding funnel fields]")
@@ -222,6 +223,11 @@ def main():
st, d = _req(port, "GET", "/api/opportunities?limit=1000", token)
ids = [o["id"] for o in (d or {}).get("data", [])]
check(opp_id in ids, "linked opp appears on the board")
# 8h deep-link: the opp list injects source_row_id from the durable fundraising_investor_id
# (the mobile Pipeline detail's "Open investor in Grid" target).
acme_opp = next((o for o in (d or {}).get("data", []) if o["id"] == opp_id), {})
check(acme_opp.get("source_row_id") == "rowAcme",
f"opp list injects source_row_id == 'rowAcme' (got {acme_opp.get('source_row_id')!r})")
st, d = _req(port, "GET", "/api/reports/dashboard", token)
active = (d or {}).get("data", {}).get("metrics", {}).get("active_opportunities")
check(active == 1, f"dashboard active_opportunities == 1 (got {active})")
@@ -281,6 +287,18 @@ def main():
check(_opp_count_live(beta_fr) == 0, "beta's orphaned opp archived by the reconciler")
st, d = _req(port, "GET", "/api/opportunities?limit=1000", token)
check(beta_opp_id not in [o["id"] for o in (d or {}).get("data", [])], "orphaned opp left the board")
# ── 8h: a manually-created deal (no fundraising_investor_id) has a null source_row_id, so
# the mobile Pipeline detail hides "Open investor in Grid" for it ──
print("\n[8h: opp source_row_id is null for a deal with no grid link]")
st, d = _req(port, "POST", "/api/opportunities", token,
{"name": "Manual deal", "contact_id": jane_contact_id, "stage": "lead"})
manual_id = (d or {}).get("data", {}).get("id")
check(st in (200, 201) and bool(manual_id), f"create a manual (non-grid) opp (got {st})")
st, d = _req(port, "GET", "/api/opportunities?limit=1000", token)
manual = next((o for o in (d or {}).get("data", []) if o["id"] == manual_id), {})
check(manual.get("source_row_id") in (None, ""),
f"manual opp has null source_row_id (got {manual.get('source_row_id')!r})")
finally:
httpd.shutdown()