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:
Keysat
2026-06-19 14:49:49 -05:00
parent 984b950f80
commit e34a6fc672
5 changed files with 635 additions and 23 deletions
+14 -7
View File
@@ -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")