Mobile Phase 8a+8b: re-author Grid/Contacts cards + Contacts/Pipeline detail bottom sheets

8a — Grid card: existing-LP earmark corner-triangle (replaces left-border), right-side
PRIORITY pill (replaces the rejected star), 4-stage chip, zero-commit dim; detail star ->
"Existing LP" pill. Contacts card: two-letter avatar initials + existing-LP ring + stage pill
+ recency; disposition badge dropped. New backend contact_grid_signals() injects derived
read-only committed/pipeline_stage on GET /api/contacts and /api/contacts/{id} (existing-LP
ring + stage pill); read-only directory, so no strip-point. DESIGN.md §4/§8 reconciled.

8b — Contacts and Pipeline detail surfaces converted from full-screen to drag-dismiss bottom
sheets matching the .dc.html anatomy: Contacts gets an email-copy pill, Log/Email actions, and
an Organization card; Pipeline gets stat tiles, an inline move-stage list, and a notes timeline
+ Log sheet. Both log via POST /api/communications; BottomSheet gains a `stacked` prop to layer
the Log sheet over a detail. Reviewer fixes: cancelled-flag fetch guards (stale-response race),
keyed single-contact signals query, multi-investor dedup test.

All deploy-pending (no s9pk built); not device-tested. 38/38 backend tests green.
This commit is contained in:
Keysat
2026-06-19 21:17:26 -05:00
parent 60d67f6b7d
commit e57b154a6d
5 changed files with 731 additions and 180 deletions
+58
View File
@@ -1875,6 +1875,49 @@ def existing_investor_by_source_row(conn):
return out
def contact_grid_signals(conn, contact_id=None):
"""Return {contacts.id: {'committed': float, 'pipeline_stage': str|None}} for every classic
contact linked to a fundraising-grid investor (via fundraising_contacts.contact_id, migration
0004). Surfaces the canonical investor's committed rollup (total_invested → the mobile Contacts
card's existing-LP avatar ring, committed > 0, mirroring existing_investor_by_source_row) and its
live derived pipeline stage ( the card's stage pill). Derived fresh on read like the grid's
injected columns never stored on the contact. A contact with no grid link gets nothing (a pure
classic/legacy contact is not an investor). The grid relational tables are rebuilt from the blob
on each save (no soft-delete axis), so no deleted_at filter is needed on the join same basis as
existing_investor_by_source_row. Pass `contact_id` to score a single contact (the detail path),
avoiding a scan of the whole directory the list path needs."""
out = {}
where = "WHERE fc.contact_id IS NOT NULL"
params = ()
if contact_id is not None:
where += " AND fc.contact_id = ?"
params = (contact_id,)
try:
rows = conn.execute(
f"""
SELECT fc.contact_id AS cid, fi.total_invested AS committed, fi.source_row_id AS srid
FROM fundraising_contacts fc
JOIN fundraising_investors fi ON fc.investor_id = fi.id
{where}
""",
params,
).fetchall()
except sqlite3.OperationalError:
return out
stage_by_srid = pipeline_stage_by_source_row(conn)
for r in rows:
cid = str(r['cid'] or '')
if not cid:
continue
committed = float(r['committed'] or 0)
prev = out.get(cid)
# A contact normally links to exactly one investor; if it links to several, keep the
# highest-committed one (and that investor's stage) so the ring reflects the strongest signal.
if prev is None or committed > prev['committed']:
out[cid] = {'committed': committed, 'pipeline_stage': stage_by_srid.get(str(r['srid'] or ''))}
return out
def staleness_by_source_row(conn):
"""Return {grid source_row_id: (last_activity_iso_or_None, staleness)} where staleness is
'' (fresh or no recorded activity), 'aging' (>= STALE_AGING_DAYS since last contact), or
@@ -2671,6 +2714,14 @@ class CRMHandler(BaseHTTPRequestHandler):
args.extend([limit, offset])
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).
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
conn.close()
return self.send_json({
@@ -2708,6 +2759,13 @@ class CRMHandler(BaseHTTPRequestHandler):
(contact_id,)
).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.
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
conn.close()
return self.send_json({"data": result})
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Tests for the mobile Contacts card's grid-derived signals (Phase 8a).
GET /api/contacts enriches each classic contact with two read-only, live-derived fields
sourced from the fundraising grid (the canonical investor model), for the mobile card:
- `committed` -> the linked investor's total_invested (>0 drives the existing-LP avatar ring),
mirroring existing_investor_by_source_row (committed capital, not graveyard);
- `pipeline_stage` -> that investor's live derived stage (drives the card's stage pill),
or null when the investor isn't in the pipeline.
A contact with no grid link (pure classic/legacy contact) gets committed 0 / stage 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
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
from http.server import ThreadingHTTPServer
_DATA = tempfile.mkdtemp()
os.environ["CRM_DATA_DIR"] = _DATA
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import server # noqa: E402
FAILS = []
def check(cond, msg):
print((" PASS " if cond else " FAIL ") + msg)
if not cond:
FAILS.append(msg)
class _Quiet(server.CRMHandler):
def log_message(self, *a):
pass
def _req(port, method, path, token=None, body=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
headers = {}
if token:
headers["Authorization"] = "Bearer " + token
payload = None
if body is not None:
payload = json.dumps(body)
headers["Content-Type"] = "application/json"
conn.request(method, path, body=payload, headers=headers)
resp = conn.getresponse()
raw = resp.read().decode("utf-8", "replace")
conn.close()
data = None
if raw:
try:
data = json.loads(raw)
except ValueError:
pass
return resp.status, data
# One fund column so a non-zero cell rolls up into total_invested (the "existing LP" signal).
COLUMNS = [{"id": "fund1", "label": "Fund III", "isFund": True}]
ROW_ACME = {"id": "rowAcme", "investor_name": "Acme Capital", "priority": True, "fund1": 250000,
"contacts": [{"name": "Jane Doe", "email": "jane@acme.com", "title": "GP"}]}
ROW_BETA = {"id": "rowBeta", "investor_name": "Beta Capital", "fund1": 0,
"contacts": [{"name": "Pat Roe", "email": "pat@beta.com", "title": ""}]}
def _db():
return sqlite3.connect(os.environ["CRM_DB_PATH"])
def seed():
c = _db()
c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
# A pure classic contact with NO fundraising-grid link (not an investor).
c.execute("INSERT INTO contacts (id,first_name,last_name,email,contact_type,status) "
"VALUES ('cLegacy','Vendor','Vince','vince@vendor.com','other','active')")
c.commit()
c.close()
def _by_email(contacts, email):
return next((c for c in contacts if (c.get("email") or "").lower() == email), None)
def main():
server.init_db()
seed()
token = server.create_token("u1", "grant", "admin")
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
try:
st, _ = _req(port, "PUT", "/api/fundraising/state", token,
{"grid": {"columns": COLUMNS, "rows": [ROW_ACME, ROW_BETA]}, "views": []})
check(st == 200, f"seed grid via PUT /state (got {st})")
# Put Acme into the pipeline at 'engaged' so its contact's card shows a stage pill.
st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token,
{"source_row_id": "rowAcme", "stage": "engaged"})
check(st in (200, 201), f"link Acme to pipeline @engaged (got {st}, {d})")
st, d = _req(port, "GET", "/api/contacts?limit=500", token)
contacts = (d or {}).get("data") or []
check(st == 200 and contacts, f"GET /api/contacts (got {st}, {len(contacts)} contacts)")
jane = _by_email(contacts, "jane@acme.com")
pat = _by_email(contacts, "pat@beta.com")
vince = _by_email(contacts, "vince@vendor.com")
check(jane is not None, "Acme's synced contact Jane Doe is in the directory")
check(pat is not None, "Beta's synced contact Pat Roe is in the directory")
check(vince is not None, "the pure classic contact Vince is in the directory")
# ── existing-LP ring signal: committed reflects the linked investor's rollup ──
print("\n[committed: existing-LP ring driven by the linked investor's total_invested]")
check((jane or {}).get("committed") == 250000,
f"Jane.committed == 250000 (existing LP) (got {(jane or {}).get('committed')})")
check((pat or {}).get("committed") == 0,
f"Pat.committed == 0 (zero-commit prospect, no ring) (got {(pat or {}).get('committed')})")
check((vince or {}).get("committed") == 0,
f"Vince.committed == 0 (no grid link) (got {(vince or {}).get('committed')})")
# ── stage-pill signal: pipeline_stage is the investor's live derived stage ──
print("\n[pipeline_stage: stage pill driven by the investor's live opp stage]")
check((jane or {}).get("pipeline_stage") == "engaged",
f"Jane.pipeline_stage == 'engaged' (got {(jane or {}).get('pipeline_stage')!r})")
check((pat or {}).get("pipeline_stage") is None,
f"Pat.pipeline_stage is None (not in pipeline) (got {(pat or {}).get('pipeline_stage')!r})")
check((vince or {}).get("pipeline_stage") is None,
f"Vince.pipeline_stage is None (no grid link) (got {(vince or {}).get('pipeline_stage')!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)
detail = (d or {}).get("data") or {}
check(st == 200 and detail.get("committed") == 250000 and detail.get("pipeline_stage") == "engaged",
f"detail carries committed/pipeline_stage (got committed={detail.get('committed')}, stage={detail.get('pipeline_stage')!r})")
st, d = _req(port, "GET", f"/api/contacts/{vince['id']}", token)
vdetail = (d or {}).get("data") or {}
check(st == 200 and vdetail.get("committed") == 0 and vdetail.get("pipeline_stage") is None,
f"unlinked contact detail has committed 0 / stage None (got {vdetail.get('committed')}, {vdetail.get('pipeline_stage')!r})")
# ── stage tracks the board: advancing the opp re-derives the contact's stage ──
print("\n[derived-live: advancing the board stage re-derives the contact's pill]")
opp_id = None
st, d = _req(port, "GET", "/api/fundraising/state", token)
for r in (d or {}).get("data", {}).get("grid", {}).get("rows", []):
if r.get("id") == "rowAcme":
opp_id = r.get("opportunity_id")
st, _ = _req(port, "PATCH", f"/api/opportunities/{opp_id}/stage", token, {"stage": "diligence"})
check(st == 200, f"advance Acme's opp -> diligence (got {st})")
st, d = _req(port, "GET", "/api/contacts?limit=500", token)
jane2 = _by_email((d or {}).get("data") or [], "jane@acme.com")
check((jane2 or {}).get("pipeline_stage") == "diligence",
f"Jane.pipeline_stage re-derives to 'diligence' (got {(jane2 or {}).get('pipeline_stage')!r})")
# ── dedup: a contact linked to two investors exposes the highest-committed one ──
print("\n[dedup: highest-committed linked investor wins for a multi-linked contact]")
c = _db()
# Link Jane's classic contact to a SECOND, richer investor (direct rows — the grid sync
# makes one link per pill; this exercises the multi-link branch in contact_grid_signals).
c.execute("INSERT INTO fundraising_investors (id, investor_name, source_row_id, total_invested) "
"VALUES ('inv2','Mega Fund LP','rowMega',500000)")
c.execute("INSERT INTO fundraising_contacts (id, investor_id, full_name, contact_id) "
"VALUES ('fc2','inv2','Jane Doe',?)", (jane['id'],))
c.commit()
c.close()
st, d = _req(port, "GET", f"/api/contacts/{jane['id']}", token)
jd = (d or {}).get("data") or {}
check(jd.get("committed") == 500000,
f"multi-linked contact exposes the higher committed (500000 > 250000) (got {jd.get('committed')})")
finally:
httpd.shutdown()
print()
if FAILS:
print(f"FAILED ({len(FAILS)}):")
for m in FAILS:
print(" - " + m)
sys.exit(1)
print("All contacts-grid-signals tests passed.")
if __name__ == "__main__":
main()