Mobile Phase 8d: sort controls across Grid, Pipeline, Contacts
Add a shared SortPill + SortSheet (label+hint option rows) and per-surface sort tables: - Grid: Name / Pipeline stage / Committed / Last contact / Priority, applied in the displayed memo (name is the tiebreak; staleness ranks longest-since-contact first, no-activity treated as most stale; committed uses the fund rollup). - Pipeline: Name / Amount / Last activity / Priority, sorted within each stage. "Last activity" uses opp.updated_at as a recency proxy until the Pipeline card wires true last-contact recency (8f). - Contacts: drop the investor/prospect type tabs (the prospect type is unused); add a Priority sort alongside Name A-Z/Z-A and Last contact. contact_grid_signals() now also surfaces the linked investor's priority flag, injected on both contact read paths (same derive-on-read contract as committed / pipeline_stage), powering the Contacts Priority sort. Extended test_contacts_grid_signals.py covers it; 39/39 backend green.
This commit is contained in:
+13
-8
@@ -1876,11 +1876,12 @@ def existing_investor_by_source_row(conn):
|
||||
|
||||
|
||||
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
|
||||
"""Return {contacts.id: {'committed': float, 'pipeline_stage': str|None, 'priority': bool}} 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),
|
||||
its live derived pipeline stage (→ the card's stage pill), and its priority flag (→ the mobile
|
||||
Contacts Priority sort, 8d). 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
|
||||
@@ -1895,7 +1896,8 @@ def contact_grid_signals(conn, contact_id=None):
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT fc.contact_id AS cid, fi.total_invested AS committed, fi.source_row_id AS srid
|
||||
SELECT fc.contact_id AS cid, fi.total_invested AS committed, fi.source_row_id AS srid,
|
||||
fi.priority AS priority
|
||||
FROM fundraising_contacts fc
|
||||
JOIN fundraising_investors fi ON fc.investor_id = fi.id
|
||||
{where}
|
||||
@@ -1912,9 +1914,10 @@ def contact_grid_signals(conn, contact_id=None):
|
||||
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.
|
||||
# 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 ''))}
|
||||
out[cid] = {'committed': committed, 'pipeline_stage': stage_by_srid.get(str(r['srid'] or '')),
|
||||
'priority': bool(r['priority'])}
|
||||
return out
|
||||
|
||||
|
||||
@@ -2722,6 +2725,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
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
|
||||
conn.close()
|
||||
|
||||
return self.send_json({
|
||||
@@ -2765,6 +2769,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
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
|
||||
|
||||
conn.close()
|
||||
return self.send_json({"data": result})
|
||||
|
||||
@@ -7,7 +7,8 @@ sourced from the fundraising grid (the canonical investor model), for the mobile
|
||||
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.
|
||||
- `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.
|
||||
Signals are derived fresh on read and never stored on the contact. Synthetic data only.
|
||||
|
||||
Run: cd backend && python3 test_contacts_grid_signals.py
|
||||
@@ -138,16 +139,27 @@ def main():
|
||||
check((vince or {}).get("pipeline_stage") is None,
|
||||
f"Vince.pipeline_stage is None (no grid link) (got {(vince or {}).get('pipeline_stage')!r})")
|
||||
|
||||
# ── priority signal: flagged investor → contact's Priority-sort key (8d) ──
|
||||
print("\n[priority: Contacts Priority sort driven by the investor's priority flag]")
|
||||
check((jane or {}).get("priority") is True,
|
||||
f"Jane.priority is True (Acme flagged) (got {(jane or {}).get('priority')!r})")
|
||||
check((pat or {}).get("priority") is False,
|
||||
f"Pat.priority is False (Beta not flagged) (got {(pat or {}).get('priority')!r})")
|
||||
check((vince or {}).get("priority") is False,
|
||||
f"Vince.priority is False (no grid link) (got {(vince or {}).get('priority')!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})")
|
||||
check(st == 200 and detail.get("committed") == 250000 and detail.get("pipeline_stage") == "engaged"
|
||||
and detail.get("priority") is True,
|
||||
f"detail carries committed/pipeline_stage/priority (got committed={detail.get('committed')}, stage={detail.get('pipeline_stage')!r}, priority={detail.get('priority')!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})")
|
||||
check(st == 200 and vdetail.get("committed") == 0 and vdetail.get("pipeline_stage") is None
|
||||
and vdetail.get("priority") is False,
|
||||
f"unlinked contact detail has committed 0 / stage None / priority False (got {vdetail.get('committed')}, {vdetail.get('pipeline_stage')!r}, {vdetail.get('priority')!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]")
|
||||
@@ -178,6 +190,9 @@ def main():
|
||||
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')})")
|
||||
# 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})")
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user