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:
@@ -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})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user