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
+21 -6
View File
@@ -1917,7 +1917,10 @@ def contact_grid_signals(conn, contact_id=None):
# highest-committed one (its stage + priority) so the signals reflect the strongest link.
if prev is None or committed > prev['committed']:
out[cid] = {'committed': committed, 'pipeline_stage': stage_by_srid.get(str(r['srid'] or '')),
'priority': bool(r['priority'])}
'priority': bool(r['priority']),
# None (not '') when unset, matching the caller's `... if sig else None`;
# source_row_id is NOT NULL on real investors so this is belt-and-suspenders.
'source_row_id': str(r['srid']) if r['srid'] else None}
return out
@@ -2718,14 +2721,16 @@ class CRMHandler(BaseHTTPRequestHandler):
contacts = rows_to_list(conn.execute(query, args).fetchall())
# Enrich with read-only, live-derived grid signals (committed → existing-LP avatar ring,
# pipeline_stage → stage pill) for the mobile Contacts card. Harmless extra fields for the
# desktop page, which ignores them. Never persisted on the contact (the list is read-only).
# pipeline_stage → stage pill, source_row_id → the detail's "Open investor in Grid"
# deep-link target) for the mobile Contacts card. Harmless extra fields for the desktop
# page, which ignores them. Never persisted on the contact (the list is read-only).
signals = contact_grid_signals(conn)
for c in contacts:
sig = signals.get(str(c.get('id') or ''))
c['committed'] = sig['committed'] if sig else 0
c['pipeline_stage'] = sig['pipeline_stage'] if sig else None
c['priority'] = bool(sig['priority']) if sig else False
c['source_row_id'] = sig['source_row_id'] if sig else None
conn.close()
return self.send_json({
@@ -2764,12 +2769,13 @@ class CRMHandler(BaseHTTPRequestHandler):
).fetchall())
# Same read-only grid signals the list injects (committed → existing-LP ring/pill,
# pipeline_stage → stage pill), so the mobile detail sheet can render them without a
# second round-trip. Derived live; never stored on the contact.
# pipeline_stage → stage pill, source_row_id → "Open investor in Grid" deep-link), so the
# mobile detail sheet can render them without a second round-trip. Derived live; never stored.
sig = contact_grid_signals(conn, contact_id).get(contact_id)
result['committed'] = sig['committed'] if sig else 0
result['pipeline_stage'] = sig['pipeline_stage'] if sig else None
result['priority'] = bool(sig['priority']) if sig else False
result['source_row_id'] = sig['source_row_id'] if sig else None
conn.close()
return self.send_json({"data": result})
@@ -3015,12 +3021,14 @@ class CRMHandler(BaseHTTPRequestHandler):
query = """
SELECT op.*, c.first_name, c.last_name, c.email as contact_email,
o.name as organization_name, u.full_name as owner_name,
fi.source_row_id as source_row_id, -- the fundraising_investors row id (opportunities has no such column)
(SELECT MAX(communication_date) FROM communications
WHERE contact_id = op.contact_id AND deleted_at IS NULL) as last_contact_date
FROM opportunities op
LEFT JOIN contacts c ON op.contact_id = c.id
LEFT JOIN organizations o ON op.organization_id = o.id
LEFT JOIN users u ON op.owner_id = u.id
LEFT JOIN fundraising_investors fi ON fi.id = op.fundraising_investor_id
WHERE 1=1 AND op.deleted_at IS NULL
"""
args = []
@@ -3055,6 +3063,9 @@ class CRMHandler(BaseHTTPRequestHandler):
# committed > 0), so card earmark and detail pill agree. Derived on GET, never persisted;
# opportunity writes use a field allowlist (handle_update_opportunity) so it can't round-trip.
# `last_contact_date` (subselect above) drives the card recency line + the Staleness sort.
# `source_row_id` (joined above via the durable fundraising_investor_id, not the contact path)
# is the 8h "Open investor in Grid" deep-link target for the Pipeline detail; null for an opp
# not linked to a grid investor (a legacy/manual deal).
signals = contact_grid_signals(conn)
for op in opps:
sig = signals.get(str(op.get('contact_id') or ''))
@@ -3790,10 +3801,14 @@ class CRMHandler(BaseHTTPRequestHandler):
conn = get_db()
try:
rows = conn.execute(
"SELECT r.*, ua.username AS assignee_name, uc.username AS creator_name "
# fi.source_row_id (joined via the reminder's investor_id) is the 8h "Open investor
# in Grid" deep-link target for the Reminders edit sheet; null for an unlinked reminder.
"SELECT r.*, ua.username AS assignee_name, uc.username AS creator_name, "
"fi.source_row_id AS source_row_id "
"FROM reminders r "
"LEFT JOIN users ua ON ua.id = r.assignee_id "
"LEFT JOIN users uc ON uc.id = r.created_by "
"LEFT JOIN fundraising_investors fi ON fi.id = r.investor_id "
"WHERE " + " AND ".join(where) +
# dated reminders first (soonest due), then undated by recency
" ORDER BY (r.due_date IS NULL OR r.due_date = ''), r.due_date ASC, r.created_at ASC",
+16 -1
View File
@@ -8,7 +8,10 @@ sourced from the fundraising grid (the canonical investor model), for the mobile
- `pipeline_stage` -> that investor's live derived stage (drives the card's stage pill),
or null when the investor isn't in the pipeline.
- `priority` -> that investor's priority flag (drives the mobile Contacts Priority sort, 8d).
A contact with no grid link (pure classic/legacy contact) gets committed 0 / stage null / priority false.
- `source_row_id` -> that investor's grid row id (the "Open investor in Grid" deep-link target, 8h),
present for ANY grid-linked contact (even a zero-commit prospect), null otherwise.
A contact with no grid link (pure classic/legacy contact) gets committed 0 / stage null / priority false
/ source_row_id null.
Signals are derived fresh on read and never stored on the contact. Synthetic data only.
Run: cd backend && python3 test_contacts_grid_signals.py
@@ -148,6 +151,15 @@ def main():
check((vince or {}).get("priority") is False,
f"Vince.priority is False (no grid link) (got {(vince or {}).get('priority')!r})")
# ── source_row_id signal: the "Open investor in Grid" deep-link target (8h) ──
print("\n[source_row_id: Open-in-Grid deep-link target = the linked investor's grid row id]")
check((jane or {}).get("source_row_id") == "rowAcme",
f"Jane.source_row_id == 'rowAcme' (got {(jane or {}).get('source_row_id')!r})")
check((pat or {}).get("source_row_id") == "rowBeta",
f"Pat.source_row_id == 'rowBeta' (present for a zero-commit linked contact) (got {(pat or {}).get('source_row_id')!r})")
check((vince or {}).get("source_row_id") is None,
f"Vince.source_row_id is None (no grid link) (got {(vince or {}).get('source_row_id')!r})")
# ── the get-by-id endpoint carries the same signals (mobile detail sheet, 8b) ──
print("\n[get-by-id: /api/contacts/{id} also injects committed + pipeline_stage]")
st, d = _req(port, "GET", f"/api/contacts/{jane['id']}", token)
@@ -193,6 +205,9 @@ def main():
# The winning (higher-committed) link is Mega Fund LP, which is not flagged → priority follows it.
check(jd.get("priority") is False,
f"multi-linked contact's priority follows the higher-committed investor (Mega, unflagged) (got {jd.get('priority')!r})")
# The deep-link target also follows the winning link → Mega's grid row (rowMega), not rowAcme.
check(jd.get("source_row_id") == "rowMega",
f"multi-linked contact's source_row_id follows the higher-committed investor (rowMega) (got {jd.get('source_row_id')!r})")
finally:
httpd.shutdown()
+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()
+7
View File
@@ -158,6 +158,13 @@ def main():
items = (d or {}).get("data", [])
check(st == 200 and len(items) == 4, f"list returns all 4 open (got {st}, {len(items)})")
check(all("last_activity_at" in it for it in items), "each row carries last_activity_at")
# 8h deep-link: each row injects source_row_id (joined via investor_id) — the grid row id
# the mobile Reminders edit sheet uses for "Open investor in Grid"; null for a team task.
by_id = {it.get("id"): it for it in items}
check(by_id.get(acme_rem_id, {}).get("source_row_id") == "rowAcme",
f"investor reminder injects source_row_id == 'rowAcme' (got {by_id.get(acme_rem_id, {}).get('source_row_id')!r})")
check(by_id.get(standalone_id, {}).get("source_row_id") in (None, ""),
f"team task has null source_row_id -> Open-in-Grid hidden (got {by_id.get(standalone_id, {}).get('source_row_id')!r})")
# dated reminders sort before undated, soonest first -> YESTERDAY (beta) leads
check(items and items[0].get("id") == beta_rem_id, f"overdue sorts first (got {items[0].get('id') if items else None})")