Mobile Phase 3a: read + write-supported Fundraising Grid surface
Adds the mobile-first Fundraising Grid (<768px): a lean MobileFundraisingGrid that reads /api/fundraising/state once and renders an investor card list over the active view (name, committed $, pipeline-stage chip, staleness-colored recency, Existing-Investor accent, Priority corner; graveyard muted) with a bottom-sheet view picker and search. Tap a card -> full-screen detail with read-only commitments/contacts/notes plus edit sheets: log a note, pipeline stage, set a reminder, and a "+ New" investor create flow with client-side dedup typeahead. All writes go through the targeted one-row endpoints (log-communication, pipeline link, opportunities stage PATCH, reminders) — NEVER the whole-grid PUT, which would race the multi-user grid (BRIEF §3a). FundraisingGridPage is now a useIsMobile() wrapper over the renamed-but-untouched desktop grid and the new mobile one (rules-of-hooks-safe; desktop unchanged). Backend: inject a read-only opportunity_id into grid rows (opportunity_id_by_source_row; added to both strip points) so the mobile detail can PATCH a linked opp's stage directly. Earliest-opp-wins ordering keeps it consistent with pipeline_stage and the link's canonical pick. Editing an existing investor's name + contact pills stays read-only here (deferred to P3b — needs a narrow per-row PATCH + pill editor). Tests: test_grid_pipeline_link extended (opportunity_id inject/strip/round-trip); 36/36 backend green, render-smoke green.
This commit is contained in:
+34
-2
@@ -1596,8 +1596,8 @@ def sanitize_fundraising_grid(grid):
|
||||
# linked opportunity and injected on read — never persisted as row data (the GET handler
|
||||
# re-injects them after sanitize). The column DEFINITIONS persist like any other column
|
||||
# so their position / width / hidden state is kept.
|
||||
_computed_row_values = ('longshot_followup', 'pipeline', 'pipeline_stage', 'reminder_status',
|
||||
'existing_investor', 'last_activity_at', 'staleness')
|
||||
_computed_row_values = ('longshot_followup', 'pipeline', 'pipeline_stage', 'opportunity_id',
|
||||
'reminder_status', 'existing_investor', 'last_activity_at', 'staleness')
|
||||
|
||||
clean_columns = []
|
||||
seen = set()
|
||||
@@ -1698,12 +1698,17 @@ def pipeline_stage_by_source_row(conn):
|
||||
of truth, so this is always derived fresh and injected as read-only grid columns —
|
||||
never stored in the grid blob, where it could go stale."""
|
||||
out = {}
|
||||
# ORDER BY created_at DESC so the EARLIEST opp is processed last and wins the overwrite —
|
||||
# matching handle_pipeline_link's canonical choice (ORDER BY created_at LIMIT 1). The link
|
||||
# enforces one live opp per investor, so multi-opp is an out-of-band anomaly; this just keeps
|
||||
# the injection deterministic and consistent with opportunity_id_by_source_row if it occurs.
|
||||
for r in conn.execute(
|
||||
"""
|
||||
SELECT fi.source_row_id AS srid, o.stage AS stage
|
||||
FROM opportunities o
|
||||
JOIN fundraising_investors fi ON o.fundraising_investor_id = fi.id
|
||||
WHERE o.deleted_at IS NULL
|
||||
ORDER BY o.created_at DESC
|
||||
"""
|
||||
).fetchall():
|
||||
srid = str(r['srid'] or '')
|
||||
@@ -1712,6 +1717,29 @@ def pipeline_stage_by_source_row(conn):
|
||||
return out
|
||||
|
||||
|
||||
def opportunity_id_by_source_row(conn):
|
||||
"""Return {grid source_row_id: live opportunity id} for every investor with a non-deleted
|
||||
linked opportunity. Injected read-only alongside pipeline_stage so the mobile grid detail can
|
||||
PATCH /api/opportunities/{id}/stage directly (the same endpoint the Pipeline board uses) — the
|
||||
grid row otherwise carries no opp id. Never persisted; stripped on write like pipeline_stage."""
|
||||
out = {}
|
||||
# See pipeline_stage_by_source_row: earliest opp wins (ORDER BY created_at DESC + overwrite) so
|
||||
# opportunity_id and pipeline_stage always reference the SAME opp, matching the link's canonical pick.
|
||||
for r in conn.execute(
|
||||
"""
|
||||
SELECT fi.source_row_id AS srid, o.id AS opp_id
|
||||
FROM opportunities o
|
||||
JOIN fundraising_investors fi ON o.fundraising_investor_id = fi.id
|
||||
WHERE o.deleted_at IS NULL
|
||||
ORDER BY o.created_at DESC
|
||||
"""
|
||||
).fetchall():
|
||||
srid = str(r['srid'] or '')
|
||||
if srid:
|
||||
out[srid] = r['opp_id']
|
||||
return out
|
||||
|
||||
|
||||
def last_activity_by_investor(conn):
|
||||
"""Return {fundraising_investors.id: latest activity ISO timestamp} across captured
|
||||
emails (grid-linked) and logged communications — the per-investor recency signal behind
|
||||
@@ -5562,6 +5590,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
self._ensure_fundraising_state_row(conn)
|
||||
row = conn.execute("SELECT * FROM fundraising_state WHERE id = 'main'").fetchone()
|
||||
stage_by_row = pipeline_stage_by_source_row(conn)
|
||||
opp_id_by_row = opportunity_id_by_source_row(conn)
|
||||
reminder_by_row = reminder_status_by_source_row(conn)
|
||||
existing_by_row = existing_investor_by_source_row(conn)
|
||||
recency_by_row = staleness_by_source_row(conn)
|
||||
@@ -5592,6 +5621,9 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
stage = stage_by_row.get(str(r.get('id') or ''))
|
||||
r['pipeline'] = bool(stage)
|
||||
r['pipeline_stage'] = stage or ''
|
||||
# Live opportunity id for a linked row (read-only) — lets the mobile detail PATCH the
|
||||
# stage on the opportunities endpoint; '' when the row isn't in the pipeline.
|
||||
r['opportunity_id'] = opp_id_by_row.get(str(r.get('id') or ''), '')
|
||||
# Read-only reminder status, derived live from the reminders table (never stored
|
||||
# in the blob). '' = no open reminder; a saved view can filter on this column to
|
||||
# supersede the binary follow_up checkbox.
|
||||
|
||||
@@ -167,13 +167,19 @@ def main():
|
||||
and rows.get("rowAcme", {}).get("pipeline_stage") == "diligence",
|
||||
f"rowAcme pipeline true @diligence (got {rows.get('rowAcme', {}).get('pipeline')}, "
|
||||
f"{rows.get('rowAcme', {}).get('pipeline_stage')})")
|
||||
# Read-only opportunity_id is injected for a linked row so the mobile grid detail can
|
||||
# PATCH the stage on the opportunities endpoint (the grid row carries no opp id otherwise).
|
||||
check(rows.get("rowAcme", {}).get("opportunity_id") == opp_id,
|
||||
f"rowAcme carries the live opportunity_id (got {rows.get('rowAcme', {}).get('opportunity_id')}, want {opp_id})")
|
||||
# Phase 0 derived signals are injected read-only on every row (values depend on seed;
|
||||
# assert the keys are present so the strip/inject round-trip below is meaningful).
|
||||
check(all(k in rows.get("rowAcme", {}) for k in ("existing_investor", "staleness", "last_activity_at")),
|
||||
f"rowAcme carries derived existing_investor/staleness/last_activity (keys: {sorted(rows.get('rowAcme', {}).keys())})")
|
||||
check(rows.get("rowBeta", {}).get("pipeline") is False
|
||||
and rows.get("rowBeta", {}).get("pipeline_stage") == "",
|
||||
f"rowBeta not in pipeline (got {rows.get('rowBeta', {}).get('pipeline')})")
|
||||
and rows.get("rowBeta", {}).get("pipeline_stage") == ""
|
||||
and rows.get("rowBeta", {}).get("opportunity_id") == "",
|
||||
f"rowBeta not in pipeline (got {rows.get('rowBeta', {}).get('pipeline')}, "
|
||||
f"opp_id={rows.get('rowBeta', {}).get('opportunity_id')!r})")
|
||||
|
||||
# ── round-trip: a save echoing the injected read-only values is lossless ──
|
||||
print("\n[round-trip: PUT carrying injected pipeline values strips them, link intact]")
|
||||
@@ -186,13 +192,14 @@ def main():
|
||||
blob = json.loads(c.execute("SELECT grid_json FROM fundraising_state WHERE id='main'").fetchone()[0])
|
||||
c.close()
|
||||
stored_acme = {r["id"]: r for r in blob.get("rows", [])}.get("rowAcme", {})
|
||||
check(not any(k in stored_acme for k in ("pipeline", "pipeline_stage", "existing_investor",
|
||||
"staleness", "last_activity_at")),
|
||||
"computed keys (pipeline + existing_investor/staleness/last_activity) NOT persisted into the grid blob")
|
||||
check(not any(k in stored_acme for k in ("pipeline", "pipeline_stage", "opportunity_id",
|
||||
"existing_investor", "staleness", "last_activity_at")),
|
||||
"computed keys (pipeline + opportunity_id + existing_investor/staleness/last_activity) NOT persisted into the grid blob")
|
||||
st, d = _req(port, "GET", "/api/fundraising/state", token)
|
||||
rt = {r["id"]: r for r in (d or {}).get("data", {}).get("grid", {}).get("rows", [])}.get("rowAcme", {})
|
||||
check(rt.get("pipeline") is True and rt.get("pipeline_stage") == "diligence",
|
||||
f"pipeline values re-injected after round-trip (got {rt.get('pipeline')}, {rt.get('pipeline_stage')})")
|
||||
check(rt.get("pipeline") is True and rt.get("pipeline_stage") == "diligence"
|
||||
and rt.get("opportunity_id") == opp_id,
|
||||
f"pipeline values re-injected after round-trip (got {rt.get('pipeline')}, {rt.get('pipeline_stage')}, opp_id={rt.get('opportunity_id')!r})")
|
||||
check(all(k in rt for k in ("existing_investor", "staleness", "last_activity_at")),
|
||||
"derived signals re-injected after round-trip")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user