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:
Keysat
2026-06-19 22:06:14 -05:00
parent 93ac0c240f
commit 42c169559c
4 changed files with 165 additions and 56 deletions
+13 -8
View File
@@ -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})