Pipeline funnel v2: 4-stage enum + migration 0007 + derived grid signals
Collapse the inherited 6-stage opportunity funnel to the locked 4-stage
per-investor funnel (lead -> engaged -> diligence -> commitment), terminal at
commitment. Migration 0007 remaps existing stage values (outreach/meeting ->
engaged, due_diligence -> diligence, committed/funded -> commitment) and
archives the stray 'lost' value (the grid row is left intact). Inject read-only
existing_investor (total_invested>0), last_activity_at, and staleness
(''/'aging'>=30d/'stale'>=60d) into the grid GET, stripped on write. Frontend:
4-stage chip tints + Pipeline board / opp-form / mock on the new enum.
The visible desktop existing-investor star + staleness recency column + the
Stale saved view are deferred to mobile Phase 3 (data is injected + test-locked
now, so that phase stays pure-frontend). Longshot was already retired by prior
cleanup -- no-op.
Tests: test_pipeline_stages_v2.py (migration remap + derivation boundaries) +
updated grid-pipeline-link / soft-delete / nl_query; 36/36 green, render-smoke
green, fresh-DB migrate clean.
This commit is contained in:
@@ -107,13 +107,14 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
|
||||
|
||||
## Current state
|
||||
|
||||
_Phase 0 + Phase 1 built; **box + repo live at v0.1.0:94** (`main` ahead by docs/design-only commits since). **The fundraising grid + email capture is the canonical system of record.** Active threads: **mobile-first redesign** (design DONE → implementation planning next) and **W2 NL query** (live; web "Ask" box outstanding). History: git log + `start9/0.4/startos/versions/`; backlog/debt: `ROADMAP.md` / `EVALUATION.md`._
|
||||
_Phase 0 + Phase 1 built; **box + repo live at v0.1.0:94** (`main` ahead by docs/design-only commits since). **The fundraising grid + email capture is the canonical system of record.** Active threads: **mobile-first redesign** (design DONE → scoped + **Phase 0 data layer BUILT 2026-06-19**, deploy pending; mobile foundation next) and **W2 NL query** (live; web "Ask" box outstanding). History: git log + `start9/0.4/startos/versions/`; backlog/debt: `ROADMAP.md` / `EVALUATION.md`._
|
||||
|
||||
- **Mobile-first redesign — design phase COMPLETE; implementation not started.** This session ran the `/design` round-trip Phase C/D: distilled the Claude Design cloud output ("Venture-CRM mobile redesign") into the contract — `DESIGN.md` §8 (responsive) + §4 (mobile component states) + §3 (15px scale), tokens `mobile` group + `color.light`, provenance + per-surface interaction reference in `design/_imports/2026-06-19/`. **Light theme adopted as a planned, toggle-gated feature** (dark default). Comps are Claude Design **runtime prototypes** — re-author each surface in React against the real API, not drop-in. Process learnings pushed to `standards/guides/design.md`.
|
||||
- **Mobile implementation — SCOPED 2026-06-19 (plan in `ROADMAP.md` "Mobile-first implementation").** Key finding: the inline-style→CSS "blocker" is **~114 inline styles across the 4 surfaces + shell** (Grid 70 / Reminders 18 / Contacts 17 / Pipeline 7 / shell 2), **not ~1,300** — the app is already majority class-based (1,861-line `<style>`, 1,088 `className`s, 4 media queries). So it's **divisible per-surface, no upfront sweep**, and splits into two axes: *responsive* (layout→classes, gates mobile) vs *theming* (inline-hex→`var()`, 183 literals, gates light theme). Sequence: **Phase 0** pipeline-stages/flags data layer (standalone) → **Phase 1** shared foundation (tokens/`:root` + shell + bottom-tabs + sheet primitive) → **Phase 2** Contacts (validator) → **Phase 3** Grid (crux) → **Phase 4** Pipeline → **Phase 5** Reminders → **Phase 6** light theme. **Phase 0 is BUILT + tested (2026-06-19, deploy pending)** — enum→4 stages + migration `0007` + `existing_investor`/`staleness` injection; the *visible* existing-investor star + staleness recency column + Stale view deferred to Phase 3 (data injected + test-locked now). See ROADMAP for the change set.
|
||||
- **Built, deploy pending:** **drag-reorder grid views** (frontend-only; `moveViewBefore` in `index.html`; persists via autosave → `views_json`; render-smoke green, browser-interaction untested).
|
||||
- **W2 — NL query (read-only): LIVE** (v93; matched-only fix v94). Local-Qwen translate → curated intents + slot validator (no generic SQL), `POST /api/query/nl`, audited; Matrix Q&A + intake `?`/`@bot` live. Remaining: **in-room human smoke** + **step-4 web "Ask" box**. Guides: `docs/guides/nl-query.md` + matrix-intake.
|
||||
- **W1 — reminders: LIVE (v93).** Grid-tied tickler (migration `0006`, `/api/reminders`, derived `reminder_status`, `last_activity_at` rollup). Deferred **W1b** = nurture-gap auto-suggested reminders (staleness nudge → Engaged/Diligence).
|
||||
- **Done & live:** email-proposal Matrix review + `bot` role (v91); grid-driven Pipeline (v88); Matrix intake bot; Gmail capture (DWD) + propose→approve + daily digest; Thesis Workshop + Architect (Claude, dual-approval); outreach drafts. All draft-only.
|
||||
- **Tests:** **35/35 backend green** (`python3 backend/run_tests.py`), `py_compile` clean; render-smoke gates `make`. (Docs/design only this session — no code touched.)
|
||||
- **Next (priority order):** 1) **mobile implementation plan** — scope the **inline-style→CSS migration** first (responsive can't live in ~1300 inline styles, still unscoped); 2) land the **locked pipeline-stages/flags** data layer (enum + migration + Existing-Investor derive + staleness); 3) build the 4 mobile surfaces (Grid first; writes via one-row `log-communication` + pipeline link→stage, never whole-grid PUT; light theme co-lands with the CSS migration); 4) **deploy** view-reorder (next s9pk build); 5) **W2 step-4** web Ask box + in-room smoke; 6) **W3** bot grid-mutations behind Matrix gate; 7) **W1b** nurture-gap reminders; then P2 debt (reports comms-aggregate soft-delete sweep, `?limit=abc` crash, auth regression test, oversized icon).
|
||||
- **Open / risks:** mobile implementation **blocked on the unscoped inline-style→CSS migration**; W2 translation only **happy-path-validated**; **Claude/Architect path still unverified live on the box**; v2.0 reserve-asset spine approved but **not canonical** (needs dual sign-off); doc drift — `crm-overview.md` + `EVALUATION.md` still call `lp_profiles` live.
|
||||
- **Tests:** **36/36 backend green** (`python3 backend/run_tests.py`; +`test_pipeline_stages_v2.py`), `py_compile` clean, render-smoke green, fresh-DB migrate clean. (Phase 0 code shipped this session.)
|
||||
- **Next (priority order):** 1) **deploy Phase 0** (s9pk build + install — pipeline-stages/flags data layer; bundle view-reorder, which is also deploy-pending; **authorize first**); 2) **Phase 1 — mobile foundation** (tokens/`:root` + viewport-gated shell + 4-tab bottom bar + bottom-sheet primitive); 3) mobile surfaces **Contacts → Grid → Pipeline → Reminders** (writes via one-row `log-communication` + pipeline link→stage, never whole-grid PUT; the deferred existing-investor star + staleness recency column + Stale view co-land here); 4) **Phase 6 light theme** (inline-hex→`var()` + `[data-theme]` toggle); 5) **W2 step-4** web Ask box + in-room smoke; 6) **W3** bot grid-mutations behind Matrix gate; 7) **W1b** nurture-gap reminders (target Engaged/Diligence); then P2 debt (reports comms-aggregate soft-delete sweep, `?limit=abc` crash, auth regression test, oversized icon).
|
||||
- **Open / risks:** **Phase 0 built but not yet deployed** — the `0007` enum migration is a no-op on the live DB (0 opps today, so near-zero remap risk), and the derived `existing_investor`/`staleness` signals are injected + test-locked but not yet *rendered* on desktop (that lands in Phase 3); the mobile UI itself is still unbuilt (Grid is the heavy surface at ~70 inline styles + the two-call stage write path); W2 translation only **happy-path-validated**; **Claude/Architect path still unverified live on the box**; v2.0 reserve-asset spine approved but **not canonical** (needs dual sign-off); doc drift — `crm-overview.md` + `EVALUATION.md` still call `lp_profiles` live.
|
||||
|
||||
+65
-27
@@ -274,42 +274,80 @@ Items 3–6 are cheap (derived/read-time/frontend, reuse `last_activity_at`, no
|
||||
(`design/DESIGN.md` §8 + the `mobile` token group), provenance + per-surface interaction model
|
||||
are in `design/_imports/2026-06-19/`, and the input brief is `design/BRIEF.md`. This is the gap
|
||||
between that contract and the current desktop-only `frontend/index.html` — the implementation
|
||||
backlog. **Not yet started; scope/plan to be developed next (the user's stated next step).**
|
||||
backlog. **Scoped 2026-06-19 (plan below); not yet started.**
|
||||
The comps are signed-off prototypes, **not drop-in** (Claude Design runtime, seed data) — each
|
||||
surface is re-authored in the app's React idiom and wired to the **real API**.*
|
||||
|
||||
**Hard prerequisite — inline-style→CSS migration.** Responsive layout cannot live in the
|
||||
~1,300 inline `style={{}}` objects (they can't carry media queries). Mobile-first means
|
||||
authoring a 375px base + `min-width` enhancements in the CSS `<style>` block / utility classes.
|
||||
This migration is **large and not yet scoped** — it gates everything below and is the first
|
||||
thing the implementation plan must size. (Precedent for a mechanical sweep: the design guide's
|
||||
inline-hex→`var()` field notes; this is bigger — structural layout, not just values.)
|
||||
**Prerequisite — inline-style→CSS migration: SCOPED 2026-06-19 — much smaller/divisible than
|
||||
the "~1,300 inline styles" framing suggested.** Ground truth from `index.html`: **370** total
|
||||
`style={{}}` objects (not 1,300), against an existing **1,861-line `<style>` block** (with
|
||||
`:root` vars + ~all the `.nav-item`/`.sidebar`/`.table` classes + **4 media queries already**,
|
||||
incl. a `min-width` one) and **1,088 `className=` usages** — the app is already majority
|
||||
class-based. Two consequences:
|
||||
- **The responsive migration that gates mobile is only ~114 inline styles**, confined to the four
|
||||
mobile surfaces + shell: FundraisingGrid **70**, Reminders **18**, Contacts **17**, Pipeline **7**,
|
||||
App shell **2**. The other **240** inline styles live on desktop-only pages (Settings 104,
|
||||
Outreach/Email/Status 57, Thesis 44, Comms 31, Dashboard 4) that are **absent on mobile**, so they
|
||||
never block it. → **Not a monolithic blocker; it divides per-surface** and folds into each surface's
|
||||
build (no upfront sweep).
|
||||
- **Two separable axes, not one.** (1) *Responsive* = layout-bearing inline styles → CSS classes +
|
||||
`min-width` queries (the ~114 above; gates mobile layout). (2) *Theming* = inline **hex → `var()`**
|
||||
so `[data-theme="light"]` can re-bind them — **183 hex literals** in the JSX region, app-wide but
|
||||
mechanical (precedent: the design guide's inline-hex→`var()` field notes); gates the **light theme**
|
||||
only. Sequence them apart.
|
||||
|
||||
**Data-layer dependency — the locked pipeline-stages/flags spec** (see the section above) lands
|
||||
**first or together**: the mobile cards render the 4-stage chip, the auto-derived
|
||||
**first, standalone (Phase 0 below)**: the mobile cards render the 4-stage chip, the auto-derived
|
||||
Existing-Investor star, and the staleness overlay, all of which need the stage enum + migration +
|
||||
`total_invested>0` derivation + the `last_activity_at` ramp. Building the cards before the data
|
||||
layer means hardcoding against a model that's about to change.
|
||||
|
||||
**UI workstreams (rough order; real sequencing comes with the plan):**
|
||||
1. **Responsive shell + nav:** viewport-gated mobile shell, the safe-area-aware **4-tab bottom
|
||||
bar** (Grid·Pipeline·Reminders·Contacts), the top-bar account/logout control, the bottom-sheet
|
||||
primitive, and the type/touch bump — the chrome every surface shares.
|
||||
2. **Grid (do first — canonical + the crux):** card list + bottom-sheet **view picker** + search;
|
||||
full-screen detail with per-field bottom-sheet edits (name, contact pills, stage, reminder, log
|
||||
note) + the `+`-create flow with client-side dedup typeahead. **Writes go through the targeted
|
||||
one-row `POST /api/fundraising/log-communication` path + the pipeline link→`PATCH stage` flow —
|
||||
never whole-grid `PUT /state`** (the `BRIEF.md` §3a "Backend reality"). Commitments read-only.
|
||||
3. **Contacts (lowest-risk validator):** read-only A–Z list + full-screen detail; proves the
|
||||
list+detail+sheet pattern before the heavier surfaces.
|
||||
4. **Pipeline:** swipe-between-stages (snap-scroll + segmented control + dots), per-card stage move
|
||||
sharing the same opportunities endpoints as the Grid detail.
|
||||
5. **Reminders:** urgency-grouped list, swipe complete/snooze, add/edit sheets on `/api/reminders`.
|
||||
6. **Light theme + toggle (adopted as a planned feature, 2026-06-19).** Ship the light palette
|
||||
(`tokens.tokens.json` `color.light`) behind a `[data-theme]` switch + a top-bar toggle; dark
|
||||
stays the default. Naturally co-lands with the inline-style→CSS migration (theming wants CSS
|
||||
custom properties, not per-element inline values). Per-component light tints (stage/staleness/
|
||||
note badges) are in `_imports/2026-06-19/GridApp.dc.html`.
|
||||
**Implementation plan (sequenced; decisions confirmed with Grant 2026-06-19) — fold the per-surface
|
||||
migration into each surface's build, behind one shared foundation step. No upfront sweep.**
|
||||
|
||||
- **Phase 0 — Pipeline-stages/flags data layer — BUILT + tested locally 2026-06-19 (deploy pending).**
|
||||
The locked spec above. **Shipped:** enum → `['lead','engaged','diligence','commitment']`
|
||||
(`server.py`) + all mirror sites (report CASEs/filters, `total_funded`→`commitment`,
|
||||
`nl_query/intents.py`); reversible migration **`0007_pipeline_stages_v2`** (outreach/meeting→engaged,
|
||||
due_diligence→diligence, committed/funded→commitment, stray `lost`→archived; up+down verified on
|
||||
synthetic data — the live DB has 0 opps so it's a real no-op there); backend injection of
|
||||
`existing_investor` (`total_invested>0`), `last_activity_at`, and `staleness` (`''`/`aging`≥30d/
|
||||
`stale`≥60d, boundaries inclusive) into the grid GET + stripped on write (`_computed_row_values` +
|
||||
frontend `stripComputedRows`); frontend enum sites (Pipeline board, opp-form, mock) + a 4-stage
|
||||
`pipeline_stage` chip with DESIGN tints. **Drop Longshot (spec item 4) was already done** by prior
|
||||
cleanup (vestigial empty column + strip code) — left as-is (still cleans legacy blobs). Tests:
|
||||
`test_pipeline_stages_v2.py` (migration remap + derivation values/boundaries) + updated
|
||||
`test_grid_pipeline_link`/`test_soft_delete_reads`/`nl_query`; **36/36 suite green, render-smoke
|
||||
green, fresh-DB migrate clean**. **Deferred to Phase 3 (co-lands with the mobile cards, where the
|
||||
card design specifies them):** the *visible* desktop rendering of the existing-investor star + the
|
||||
staleness-colored recency column + the seeded "Stale" saved view — the data is injected and
|
||||
test-locked now, so Phase 3 is pure frontend. W1b nudge specialization is a separate fast-follow.
|
||||
**Deploy:** needs an s9pk build + install (**authorize first**).
|
||||
- **Phase 1 — Shared mobile foundation (the only do-once part of the migration).** Extend `:root` with
|
||||
the missing tokens (semantic colors + the `mobile` token group + a `[data-theme="light"]` block); add
|
||||
CSS for the bottom-tab-bar, the bottom-sheet primitive, `env(safe-area-inset-bottom)`, and the 13→15px
|
||||
type bump; build the viewport-gated shell in `App` (bottom 4-tab bar <768px, hide sidebar, top-bar
|
||||
account/logout control). Touches ~2 inline styles.
|
||||
- **Phase 2 — Contacts (pattern-validator spike, BEFORE the Grid).** ~17 inline styles; read-only A–Z
|
||||
list + segmented tabs + search → full-screen read-only detail. Proves the list→detail→sheet pattern
|
||||
and the per-surface migration mechanics on the lowest-risk surface before the crux. *(Reorders the
|
||||
earlier "Grid first" draft — de-risk the pattern cheaply, then attack the Grid.)*
|
||||
- **Phase 3 — Fundraising Grid (the crux).** ~70 inline styles → classes. Card list + bottom-sheet view
|
||||
picker + search; full-screen detail with per-field bottom-sheet edits (name, contact pills, stage,
|
||||
reminder, log note) + the `+`-create flow with client-side dedup typeahead. **Writes per `BRIEF.md`
|
||||
§3a "Backend reality":** single-investor edits → `POST /api/fundraising/log-communication` (one-row,
|
||||
no version race; can create investor+contact); stage → `POST /api/fundraising/pipeline/link` (needs ≥1
|
||||
contact) then `PATCH /api/opportunities/{id}/stage`; commitments/amounts read-only; **never whole-grid
|
||||
`PUT /state`**. Renders Phase 0's stage chip + Existing-Investor star + staleness ramp.
|
||||
- **Phase 4 — Pipeline.** ~7 inline styles. Swipe-between-stages (snap-scroll + segmented control +
|
||||
dots), per-card stage move sharing the Grid detail's opportunities endpoints.
|
||||
- **Phase 5 — Reminders.** ~18 inline styles. Urgency-grouped list, swipe complete/snooze, add/edit
|
||||
sheets on `/api/reminders`.
|
||||
- **Phase 6 — Light theme + toggle (adopted as a planned feature, 2026-06-19).** The inline-hex→`var()`
|
||||
axis (183 literals) + ship the light palette (`tokens.tokens.json` `color.light`) behind a
|
||||
`[data-theme]` switch + a top-bar toggle; dark stays the default. Mechanical; co-lands after the
|
||||
surfaces. Per-component light tints (stage/staleness/note badges) are in
|
||||
`_imports/2026-06-19/GridApp.dc.html`.
|
||||
|
||||
**Note on `design-checker`:** not run for this round-trip — it audits *existing* UI conformance,
|
||||
and the desktop UI still conforms to §1–7 (unchanged). The mobile gap is greenfield
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Reversal of 0007_pipeline_stages_v2.sql (manual; .down files are never auto-applied).
|
||||
--
|
||||
-- BEST-EFFORT: the 6->4 stage collapse is lossy and cannot be perfectly inverted (the
|
||||
-- pattern other .down files here share -- e.g. 0005 cannot DROP COLUMN on old SQLite). It
|
||||
-- restores VALID legacy 6-stage values, choosing a representative for each collapsed pair:
|
||||
-- engaged was outreach OR meeting -> 'meeting' (representative)
|
||||
-- diligence -> 'due_diligence' (exact)
|
||||
-- commitment was committed OR funded -> 'committed' (representative)
|
||||
-- Opportunities archived from the stray 'lost' value still carry stage = 'lost' but cannot be
|
||||
-- re-identified as "archived by this migration" vs archived for other reasons, so they are
|
||||
-- left archived; un-archive (clear deleted_at) manually if a rollback truly needs them back.
|
||||
UPDATE opportunities SET stage = 'meeting' WHERE stage = 'engaged';
|
||||
UPDATE opportunities SET stage = 'due_diligence' WHERE stage = 'diligence';
|
||||
UPDATE opportunities SET stage = 'committed' WHERE stage = 'commitment';
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Pipeline funnel v2 — collapse the inherited 6-stage opportunity funnel into the locked
|
||||
-- 4-stage per-investor funnel: lead -> engaged -> diligence -> commitment, terminal at
|
||||
-- commitment. See ROADMAP "Pipeline stages + investor flags/labels -- LOCKED SPEC" (2026-06-19)
|
||||
-- and server.PIPELINE_STAGES.
|
||||
--
|
||||
-- DATA-ONLY + DEPLOYMENT-STATE-INVARIANT (migrations guide): targets stage values
|
||||
-- structurally, so it is a no-op on a fresh DB (no opportunities) and remaps deterministically
|
||||
-- on a populated one.
|
||||
-- outreach, meeting -> engaged (a two-way conversation has begun; "meeting" was an
|
||||
-- activity, not a position, so it folds in here)
|
||||
-- due_diligence -> diligence
|
||||
-- committed, funded -> commitment (terminal; post-commit $ lives in the grid fund cell,
|
||||
-- and fund admin owns post-commitment -- no "funded" stage)
|
||||
UPDATE opportunities SET stage = 'engaged' WHERE stage IN ('outreach', 'meeting');
|
||||
UPDATE opportunities SET stage = 'diligence' WHERE stage = 'due_diligence';
|
||||
UPDATE opportunities SET stage = 'commitment' WHERE stage IN ('committed', 'funded');
|
||||
|
||||
-- The stray legacy 'lost' value is not in the new settable enum, and a lost deal is a dead
|
||||
-- deal: ARCHIVE (soft-delete) the opportunity rather than leave an un-settable stage on a live
|
||||
-- row. The grid investor row is left fully intact (the grid is canonical); graveyarding the
|
||||
-- investor stays a human action, never an auto-mutation (human-in-the-loop guardrail). The
|
||||
-- stage text is left as 'lost' on the archived row for provenance -- it is filtered out
|
||||
-- everywhere by deleted_at IS NULL.
|
||||
UPDATE opportunities SET deleted_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE stage = 'lost' AND deleted_at IS NULL;
|
||||
@@ -28,13 +28,13 @@ from datetime import datetime, timedelta
|
||||
# scan flooding a response. A list intent past this is reported truncated, never silently cut.
|
||||
MAX_ROWS = 500
|
||||
|
||||
# Live, non-terminal pipeline stages in funnel order (mirrors server.PIPELINE_STAGES; 'lost'
|
||||
# is the terminal drop). Kept here so the pipeline intents have a stable rank without importing
|
||||
# the server module (helpers take a conn; they never import server — house convention).
|
||||
_STAGE_ORDER = ['lead', 'outreach', 'meeting', 'due_diligence', 'committed', 'funded']
|
||||
# 4-stage per-investor funnel in order, terminal at 'commitment' (mirrors server.PIPELINE_STAGES).
|
||||
# Kept here so the pipeline intents have a stable rank without importing the server module
|
||||
# (helpers take a conn; they never import server — house convention).
|
||||
_STAGE_ORDER = ['lead', 'engaged', 'diligence', 'commitment']
|
||||
_STAGE_RANK_SQL = (
|
||||
"CASE stage WHEN 'lead' THEN 1 WHEN 'outreach' THEN 2 WHEN 'meeting' THEN 3 "
|
||||
"WHEN 'due_diligence' THEN 4 WHEN 'committed' THEN 5 WHEN 'funded' THEN 6 ELSE 0 END")
|
||||
"CASE stage WHEN 'lead' THEN 1 WHEN 'engaged' THEN 2 "
|
||||
"WHEN 'diligence' THEN 3 WHEN 'commitment' THEN 4 ELSE 0 END")
|
||||
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -237,7 +237,7 @@ def run_pipeline_top(conn, slots):
|
||||
"o.probability, u.full_name AS owner FROM opportunities o "
|
||||
"LEFT JOIN fundraising_investors i ON i.id = o.fundraising_investor_id "
|
||||
"LEFT JOIN users u ON u.id = o.owner_id "
|
||||
"WHERE o.deleted_at IS NULL AND o.stage != 'lost' "
|
||||
"WHERE o.deleted_at IS NULL "
|
||||
f"ORDER BY {_STAGE_RANK_SQL} DESC, o.expected_amount DESC LIMIT ?", (n,)))
|
||||
for r in rows:
|
||||
r["last_activity_at"] = last.get(r.pop("inv_id"))
|
||||
@@ -248,11 +248,11 @@ def run_pipeline_top(conn, slots):
|
||||
|
||||
|
||||
def run_pipeline_totals(conn, slots):
|
||||
"""Total pipeline dollars and the split across each stage (excludes lost)."""
|
||||
"""Total pipeline dollars and the split across each stage."""
|
||||
rows = _rows(conn.execute(
|
||||
"SELECT stage, COUNT(*) AS count, COALESCE(SUM(expected_amount),0) AS expected_total, "
|
||||
"COALESCE(SUM(commitment_amount),0) AS committed_total FROM opportunities "
|
||||
f"WHERE deleted_at IS NULL AND stage != 'lost' GROUP BY stage ORDER BY {_STAGE_RANK_SQL}"))
|
||||
f"WHERE deleted_at IS NULL GROUP BY stage ORDER BY {_STAGE_RANK_SQL}"))
|
||||
total = sum(r["expected_total"] for r in rows)
|
||||
count = sum(r["count"] for r in rows)
|
||||
return {"columns": ["stage", "count", "expected_total", "committed_total"],
|
||||
|
||||
@@ -129,16 +129,17 @@ def seed(conn):
|
||||
rem("r_del", "i_acme", "Tombstoned", (TODAY - timedelta(days=2)).isoformat(), deleted=True)
|
||||
rem("r_standalone", None, "Team chore", (TODAY - timedelta(days=1)).isoformat())
|
||||
|
||||
# opportunities — committed / meeting (live) / lost (terminal) / deleted
|
||||
# opportunities — commitment / engaged (live) / two archived (the new model has no 'lost'
|
||||
# stage: a dead deal is soft-deleted, so both excluded cases ride deleted_at)
|
||||
def opp(oid, inv_id, contact, stage, expected, owner, deleted=False):
|
||||
c("INSERT INTO opportunities (id, name, contact_id, stage, expected_amount, owner_id, "
|
||||
"fundraising_investor_id, deleted_at) VALUES (?,?,?,?,?,?,?,?)",
|
||||
(oid, oid, contact, stage, expected, owner, inv_id, _ago(0) if deleted else None))
|
||||
# opp contact_id must reference a real contacts row (FK on); reuse the two we made
|
||||
opp("o_acme", "i_acme", "cc_alice", "committed", 4_000_000, "u_jon")
|
||||
opp("o_beta", "i_beta", "cc_dana", "meeting", 1_000_000, "u_grant")
|
||||
opp("o_lost", "i_acme", "cc_alice", "lost", 9_000_000, "u_jon")
|
||||
opp("o_del", "i_beta", "cc_dana", "due_diligence", 7_000_000, "u_grant", deleted=True)
|
||||
opp("o_acme", "i_acme", "cc_alice", "commitment", 4_000_000, "u_jon")
|
||||
opp("o_beta", "i_beta", "cc_dana", "engaged", 1_000_000, "u_grant")
|
||||
opp("o_lost", "i_acme", "cc_alice", "diligence", 9_000_000, "u_jon", deleted=True)
|
||||
opp("o_del", "i_beta", "cc_dana", "diligence", 7_000_000, "u_grant", deleted=True)
|
||||
conn.commit()
|
||||
|
||||
|
||||
@@ -182,8 +183,8 @@ def main():
|
||||
print("pipeline")
|
||||
r = run("pipeline_totals")
|
||||
stages = {row["stage"]: row for row in r["rows"]}
|
||||
check(set(stages) == {"committed", "meeting"}, f"pipeline_totals excludes lost+deleted: {set(stages)}")
|
||||
check(stages["committed"]["expected_total"] == 4_000_000, "pipeline_totals stage sum")
|
||||
check(set(stages) == {"commitment", "engaged"}, f"pipeline_totals excludes archived/deleted: {set(stages)}")
|
||||
check(stages["commitment"]["expected_total"] == 4_000_000, "pipeline_totals stage sum")
|
||||
r = run("pipeline_top", {"limit": 10})
|
||||
check(names(r) == ["Acme Capital", "Beta Partners"], "pipeline_top furthest-stage first")
|
||||
check(r["rows"][0]["last_activity_at"] is not None, "pipeline_top enriches last activity")
|
||||
|
||||
@@ -193,8 +193,8 @@ def main():
|
||||
"expected_amount, probability, fund_name, description, next_step, owner_id, priority, updated_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(gen(), f"{org_name or person} — {fund_label}", cid, org_ids.get(org_name) if org_name else None,
|
||||
stage, amt if stage in ("committed", "funded") else 0, amt,
|
||||
{"lead": 10, "outreach": 25, "meeting": 40, "due_diligence": 60, "committed": 90, "funded": 100}[stage],
|
||||
stage, amt if stage == "commitment" else 0, amt,
|
||||
{"lead": 10, "engaged": 35, "diligence": 60, "commitment": 90}[stage],
|
||||
fund_label, f"Potential {fund_label} allocation for {person}.",
|
||||
random.choice(["Send deck", "Schedule call", "Await IC", "Send subdocs"]),
|
||||
uid, random.choice(["low", "medium", "high"]), now()))
|
||||
|
||||
+85
-16
@@ -1596,7 +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')
|
||||
_computed_row_values = ('longshot_followup', 'pipeline', 'pipeline_stage', 'reminder_status',
|
||||
'existing_investor', 'last_activity_at', 'staleness')
|
||||
|
||||
clean_columns = []
|
||||
seen = set()
|
||||
@@ -1794,6 +1795,59 @@ def reminder_status_by_source_row(conn):
|
||||
out[srid] = st
|
||||
return out
|
||||
|
||||
|
||||
# Staleness ramp — one global threshold set (locked spec 2026-06-19): the last-contact recency
|
||||
# value colors fresh (grey) -> aging (amber) >= STALE_AGING_DAYS -> stale (red) >= STALE_DAYS.
|
||||
# Not stage-aware for v1. The same `staleness`/`last_activity_at` the grid injects also drives the
|
||||
# mobile card and (a user-built) "Stale" view, so everything color-codes off one server signal.
|
||||
STALE_AGING_DAYS = 30
|
||||
STALE_DAYS = 60
|
||||
|
||||
def existing_investor_by_source_row(conn):
|
||||
"""Return the set of grid source_row_ids whose investor has any committed capital
|
||||
(fundraising_investors.total_invested > 0) — the auto-derived "Existing Investor" flag
|
||||
(locked spec 2026-06-19). Injected read-only on grid read like pipeline_stage; never a
|
||||
maintained column. Orthogonal to stage: a re-solicited LP shows the star AND a live stage."""
|
||||
out = set()
|
||||
for r in conn.execute(
|
||||
"SELECT source_row_id FROM fundraising_investors WHERE total_invested > 0"
|
||||
).fetchall():
|
||||
srid = str(r['source_row_id'] or '')
|
||||
if srid:
|
||||
out.add(srid)
|
||||
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
|
||||
'stale' (>= STALE_DAYS). Derived from the SAME last_activity_by_investor signal the reminders
|
||||
surface uses, so desktop grid + mobile card color-code identically. A row with no recorded
|
||||
activity gets '' (no false "stale" on a brand-new lead); the W1b nurture-gap nudge handles
|
||||
in-pipeline-with-no-activity separately."""
|
||||
last_by_inv = last_activity_by_investor(conn)
|
||||
out = {}
|
||||
today = datetime.utcnow().date()
|
||||
for r in conn.execute("SELECT id, source_row_id FROM fundraising_investors").fetchall():
|
||||
srid = str(r['source_row_id'] or '')
|
||||
if not srid:
|
||||
continue
|
||||
ts = last_by_inv.get(r['id'])
|
||||
level = ''
|
||||
if ts:
|
||||
try:
|
||||
d = datetime.strptime(str(ts)[:10], '%Y-%m-%d').date()
|
||||
age = (today - d).days
|
||||
if age >= STALE_DAYS:
|
||||
level = 'stale'
|
||||
elif age >= STALE_AGING_DAYS:
|
||||
level = 'aging'
|
||||
except ValueError:
|
||||
pass
|
||||
out[srid] = (ts, level)
|
||||
return out
|
||||
|
||||
|
||||
def maybe_run_scheduled_backup():
|
||||
conn = get_db()
|
||||
try:
|
||||
@@ -1830,7 +1884,11 @@ def start_backup_scheduler():
|
||||
|
||||
# ─── Request Handler ──────────────────────────────────────────────────────────
|
||||
|
||||
PIPELINE_STAGES = ['lead', 'outreach', 'meeting', 'due_diligence', 'committed', 'funded']
|
||||
# 4-stage per-investor funnel, terminal at 'commitment' (locked spec 2026-06-19). On commit the
|
||||
# deal is handed to fund admin + the $ recorded in the grid fund cell — there is no 'funded'/'lost'
|
||||
# stage (the grid's committed $ and the graveyard flag carry those). Migration 0007 remapped the
|
||||
# legacy 6-stage values. Keep the frontend kanban + opp-form + nl_query rank in sync with this list.
|
||||
PIPELINE_STAGES = ['lead', 'engaged', 'diligence', 'commitment']
|
||||
CONTACT_TYPES = ['investor', 'prospect', 'advisor', 'other']
|
||||
COMM_TYPES = ['email', 'call', 'meeting', 'note', 'text']
|
||||
|
||||
@@ -2718,7 +2776,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
query = """
|
||||
SELECT o.*,
|
||||
(SELECT COUNT(*) FROM contacts WHERE organization_id = o.id AND deleted_at IS NULL) as contact_count,
|
||||
(SELECT COALESCE(SUM(commitment_amount), 0) FROM opportunities WHERE organization_id = o.id AND stage = 'funded' AND deleted_at IS NULL) as total_funded
|
||||
(SELECT COALESCE(SUM(commitment_amount), 0) FROM opportunities WHERE organization_id = o.id AND stage = 'commitment' AND deleted_at IS NULL) as total_funded
|
||||
FROM organizations o WHERE 1=1 AND o.deleted_at IS NULL
|
||||
"""
|
||||
args = []
|
||||
@@ -3763,11 +3821,11 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
).fetchone()['total']
|
||||
|
||||
pipeline_value = conn.execute(
|
||||
"SELECT COALESCE(SUM(expected_amount), 0) as total FROM opportunities WHERE stage NOT IN ('funded', 'lost') AND deleted_at IS NULL"
|
||||
"SELECT COALESCE(SUM(expected_amount), 0) as total FROM opportunities WHERE stage != 'commitment' AND deleted_at IS NULL"
|
||||
).fetchone()['total']
|
||||
|
||||
active_opportunities = conn.execute(
|
||||
"SELECT COUNT(*) as c FROM opportunities WHERE stage NOT IN ('funded', 'lost') AND deleted_at IS NULL"
|
||||
"SELECT COUNT(*) as c FROM opportunities WHERE stage != 'commitment' AND deleted_at IS NULL"
|
||||
).fetchone()['c']
|
||||
|
||||
# Pipeline by stage
|
||||
@@ -3775,11 +3833,11 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
SELECT stage, COUNT(*) as count, COALESCE(SUM(expected_amount), 0) as total_value,
|
||||
COALESCE(SUM(commitment_amount), 0) as committed_value
|
||||
FROM opportunities
|
||||
WHERE stage != 'lost' AND deleted_at IS NULL
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY stage
|
||||
ORDER BY CASE stage
|
||||
WHEN 'lead' THEN 1 WHEN 'outreach' THEN 2 WHEN 'meeting' THEN 3
|
||||
WHEN 'due_diligence' THEN 4 WHEN 'committed' THEN 5 WHEN 'funded' THEN 6
|
||||
WHEN 'lead' THEN 1 WHEN 'engaged' THEN 2
|
||||
WHEN 'diligence' THEN 3 WHEN 'commitment' THEN 4
|
||||
END
|
||||
""").fetchall())
|
||||
|
||||
@@ -3855,8 +3913,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY stage
|
||||
ORDER BY CASE stage
|
||||
WHEN 'lead' THEN 1 WHEN 'outreach' THEN 2 WHEN 'meeting' THEN 3
|
||||
WHEN 'due_diligence' THEN 4 WHEN 'committed' THEN 5 WHEN 'funded' THEN 6
|
||||
WHEN 'lead' THEN 1 WHEN 'engaged' THEN 2
|
||||
WHEN 'diligence' THEN 3 WHEN 'commitment' THEN 4
|
||||
END
|
||||
""").fetchall())
|
||||
|
||||
@@ -3874,7 +3932,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
SELECT priority, COUNT(*) as count,
|
||||
COALESCE(SUM(expected_amount), 0) as total_expected
|
||||
FROM opportunities
|
||||
WHERE stage NOT IN ('funded', 'lost') AND deleted_at IS NULL
|
||||
WHERE stage != 'commitment' AND deleted_at IS NULL
|
||||
GROUP BY priority
|
||||
""").fetchall())
|
||||
|
||||
@@ -5492,6 +5550,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
row = conn.execute("SELECT * FROM fundraising_state WHERE id = 'main'").fetchone()
|
||||
stage_by_row = pipeline_stage_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)
|
||||
conn.close()
|
||||
|
||||
try:
|
||||
@@ -5523,6 +5583,15 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
# in the blob). '' = no open reminder; a saved view can filter on this column to
|
||||
# supersede the binary follow_up checkbox.
|
||||
r['reminder_status'] = reminder_by_row.get(str(r.get('id') or ''), '')
|
||||
# Auto-derived "Existing Investor" flag (total_invested > 0) + last-contact recency
|
||||
# and its staleness ramp ('' / 'aging' / 'stale'). All read-only, computed fresh on
|
||||
# read like the columns above (stripped on write), so the desktop grid and the mobile
|
||||
# card render the star + the grey/amber/red recency off one server signal.
|
||||
srid = str(r.get('id') or '')
|
||||
r['existing_investor'] = srid in existing_by_row
|
||||
last_activity, staleness = recency_by_row.get(srid, (None, ''))
|
||||
r['last_activity_at'] = last_activity
|
||||
r['staleness'] = staleness
|
||||
|
||||
return self.send_json({
|
||||
"data": {
|
||||
@@ -5955,12 +6024,12 @@ def seed_demo_data():
|
||||
|
||||
# Create opportunities
|
||||
opp_data = [
|
||||
(contacts[6][0], orgs[6][0], "Cascade Wealth - Fund II", "meeting", 10000000, 10000000, 40, user2_id),
|
||||
(contacts[7][0], orgs[7][0], "Blue Harbor - Fund II", "due_diligence", 5000000, 5000000, 60, user2_id),
|
||||
(contacts[8][0], None, "William Johnson - Direct", "outreach", 0, 2000000, 20, admin_id),
|
||||
(contacts[6][0], orgs[6][0], "Cascade Wealth - Fund II", "engaged", 10000000, 10000000, 40, user2_id),
|
||||
(contacts[7][0], orgs[7][0], "Blue Harbor - Fund II", "diligence", 5000000, 5000000, 60, user2_id),
|
||||
(contacts[8][0], None, "William Johnson - Direct", "lead", 0, 2000000, 20, admin_id),
|
||||
(contacts[9][0], None, "Garcia Family Office - Fund II", "lead", 0, 15000000, 10, admin_id),
|
||||
(contacts[10][0], None, "Thomas Brown - WM Referral", "meeting", 0, 3000000, 30, user2_id),
|
||||
(contacts[11][0], None, "Linda Wilson - PM Intro", "outreach", 0, 5000000, 15, admin_id),
|
||||
(contacts[10][0], None, "Thomas Brown - WM Referral", "engaged", 0, 3000000, 30, user2_id),
|
||||
(contacts[11][0], None, "Linda Wilson - PM Intro", "lead", 0, 5000000, 15, admin_id),
|
||||
]
|
||||
for opp in opp_data:
|
||||
conn.execute("""
|
||||
|
||||
@@ -121,11 +121,11 @@ def main():
|
||||
print("\n[link: creates one linked opportunity with seeds]")
|
||||
st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {
|
||||
"source_row_id": "rowAcme", "fund_name": "Fund III",
|
||||
"expected_amount": 250000, "probability": 40, "stage": "outreach",
|
||||
"expected_amount": 250000, "probability": 40, "stage": "engaged",
|
||||
})
|
||||
opp = (d or {}).get("data") or {}
|
||||
check(st == 201 and (d or {}).get("already_linked") is False, f"link -> 201 new (got {st}, {d})")
|
||||
check(opp.get("stage") == "outreach" and opp.get("expected_amount") == 250000
|
||||
check(opp.get("stage") == "engaged" and opp.get("expected_amount") == 250000
|
||||
and opp.get("probability") == 40 and opp.get("fund_name") == "Fund III",
|
||||
f"seeds applied (got {{stage:{opp.get('stage')}, amt:{opp.get('expected_amount')}, "
|
||||
f"prob:{opp.get('probability')}, fund:{opp.get('fund_name')}}})")
|
||||
@@ -138,14 +138,14 @@ def main():
|
||||
|
||||
# ── idempotent re-link: returns existing, board-owned stage NOT reseeded ──
|
||||
print("\n[idempotent: re-link returns existing opp without reseeding funnel fields]")
|
||||
st, _ = _req(port, "PATCH", f"/api/opportunities/{opp_id}/stage", token, {"stage": "meeting"})
|
||||
check(st == 200, f"advance stage on the board -> meeting (got {st})")
|
||||
st, _ = _req(port, "PATCH", f"/api/opportunities/{opp_id}/stage", token, {"stage": "diligence"})
|
||||
check(st == 200, f"advance stage on the board -> diligence (got {st})")
|
||||
st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {
|
||||
"source_row_id": "rowAcme", "stage": "lead", "expected_amount": 999, "probability": 5,
|
||||
})
|
||||
opp2 = (d or {}).get("data") or {}
|
||||
check(st == 200 and (d or {}).get("already_linked") is True, f"re-link -> already_linked (got {st}, {d})")
|
||||
check(opp2.get("stage") == "meeting" and opp2.get("expected_amount") == 250000,
|
||||
check(opp2.get("stage") == "diligence" and opp2.get("expected_amount") == 250000,
|
||||
f"funnel fields preserved, not reseeded (got stage={opp2.get('stage')}, amt={opp2.get('expected_amount')})")
|
||||
check(_opp_count_live(fr_id) == 1, "still exactly one live opp (no duplicate)")
|
||||
|
||||
@@ -154,9 +154,13 @@ def main():
|
||||
st, d = _req(port, "GET", "/api/fundraising/state", token)
|
||||
rows = {r["id"]: r for r in (d or {}).get("data", {}).get("grid", {}).get("rows", [])}
|
||||
check(rows.get("rowAcme", {}).get("pipeline") is True
|
||||
and rows.get("rowAcme", {}).get("pipeline_stage") == "meeting",
|
||||
f"rowAcme pipeline true @meeting (got {rows.get('rowAcme', {}).get('pipeline')}, "
|
||||
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')})")
|
||||
# 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')})")
|
||||
@@ -172,12 +176,15 @@ 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("pipeline" not in stored_acme and "pipeline_stage" not in stored_acme,
|
||||
"computed keys are NOT persisted into the grid blob")
|
||||
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")
|
||||
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") == "meeting",
|
||||
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(all(k in rt for k in ("existing_investor", "staleness", "last_activity_at")),
|
||||
"derived signals re-injected after round-trip")
|
||||
|
||||
# ── guards ──
|
||||
print("\n[guard: a contactless row cannot be added to the pipeline]")
|
||||
@@ -234,7 +241,7 @@ def main():
|
||||
# ── re-link after unlink: a fresh opp is created (the archived one stays archived) ──
|
||||
print("\n[re-link after unlink: creates a new opp, flag reappears]")
|
||||
st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {
|
||||
"source_row_id": "rowAcme", "stage": "outreach", "expected_amount": 50000,
|
||||
"source_row_id": "rowAcme", "stage": "engaged", "expected_amount": 50000,
|
||||
})
|
||||
relinked = (d or {}).get("data") or {}
|
||||
check(st == 201 and (d or {}).get("already_linked") is False and relinked.get("id") != opp_id,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression test for Phase 0 — the 4-stage pipeline funnel + the derived grid signals
|
||||
(ROADMAP "Pipeline stages + investor flags/labels -- LOCKED SPEC", 2026-06-19).
|
||||
|
||||
Covers the parts the round-trip test in test_grid_pipeline_link.py only checks structurally:
|
||||
|
||||
* migration 0007 stage remap — outreach/meeting -> engaged, due_diligence -> diligence,
|
||||
committed/funded -> commitment, and the stray 'lost' value archived (soft-deleted), with
|
||||
'lead' left untouched;
|
||||
* existing_investor_by_source_row — total_invested > 0 is the auto-derived "Existing Investor";
|
||||
* staleness_by_source_row — last-contact age maps to '' / 'aging' (>= 30d) / 'stale' (>= 60d),
|
||||
boundaries inclusive, and NO recorded activity -> '' (no false "stale" on a fresh lead).
|
||||
|
||||
Synthetic only (guardrail #9). Run: cd backend && python3 test_pipeline_stages_v2.py
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
_DATA = tempfile.mkdtemp()
|
||||
os.environ["CRM_DATA_DIR"] = _DATA
|
||||
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import server # noqa: E402
|
||||
|
||||
FAILS = []
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
print((" PASS " if cond else " FAIL ") + msg)
|
||||
if not cond:
|
||||
FAILS.append(msg)
|
||||
|
||||
|
||||
def _days_ago(n):
|
||||
return (datetime.utcnow() - timedelta(days=n)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def test_migration_remap(conn):
|
||||
print("\n[migration 0007: stage remap + lost archived]")
|
||||
c = conn.cursor()
|
||||
olds = [("m_out", "outreach"), ("m_meet", "meeting"), ("m_dd", "due_diligence"),
|
||||
("m_comm", "committed"), ("m_fund", "funded"), ("m_lost", "lost"),
|
||||
("m_lead", "lead")]
|
||||
for oid, stage in olds:
|
||||
c.execute("INSERT INTO opportunities (id, name, stage, contact_id, owner_id) VALUES (?,?,?,?,?)",
|
||||
(oid, oid, stage, "c0", "u1"))
|
||||
conn.commit()
|
||||
# init_db already applied 0007; re-run its SQL against the rows we just inserted with
|
||||
# legacy values (the UPDATEs target by old stage value, so this is exactly the remap).
|
||||
with open(os.path.join(_HERE, "migrations", "0007_pipeline_stages_v2.sql")) as fh:
|
||||
conn.executescript(fh.read())
|
||||
conn.commit()
|
||||
|
||||
def stage_of(oid):
|
||||
return c.execute("SELECT stage FROM opportunities WHERE id=?", (oid,)).fetchone()[0]
|
||||
|
||||
def archived(oid):
|
||||
return c.execute("SELECT deleted_at FROM opportunities WHERE id=?", (oid,)).fetchone()[0] is not None
|
||||
|
||||
check(stage_of("m_out") == "engaged" and stage_of("m_meet") == "engaged",
|
||||
"outreach + meeting -> engaged")
|
||||
check(stage_of("m_dd") == "diligence", "due_diligence -> diligence")
|
||||
check(stage_of("m_comm") == "commitment" and stage_of("m_fund") == "commitment",
|
||||
"committed + funded -> commitment")
|
||||
check(stage_of("m_lead") == "lead", "lead unchanged")
|
||||
check(archived("m_lost"), "lost opp archived (deleted_at set)")
|
||||
check(not archived("m_lead") and not archived("m_comm"),
|
||||
"non-lost opps NOT archived by the migration")
|
||||
# Cleanup so these rows don't perturb the derivation seed below.
|
||||
c.execute("DELETE FROM opportunities WHERE id IN ('m_out','m_meet','m_dd','m_comm','m_fund','m_lost','m_lead')")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _investor(conn, srid, total, contact_id=None, comm_days_ago=None):
|
||||
"""Seed a grid investor (+ optional linked contact & dated communication)."""
|
||||
iid = "i_" + srid
|
||||
conn.execute("INSERT INTO fundraising_investors (id, investor_name, source_row_id, total_invested) "
|
||||
"VALUES (?,?,?,?)", (iid, srid, srid, total))
|
||||
if contact_id:
|
||||
conn.execute("INSERT INTO contacts (id, first_name, last_name) VALUES (?,?,?)",
|
||||
(contact_id, srid, "Person"))
|
||||
conn.execute("INSERT INTO fundraising_contacts (id, investor_id, full_name, contact_id) "
|
||||
"VALUES (?,?,?,?)", ("fc_" + srid, iid, srid + " Person", contact_id))
|
||||
if comm_days_ago is not None:
|
||||
conn.execute("INSERT INTO communications (id, contact_id, communication_date, created_by, subject) "
|
||||
"VALUES (?,?,?,?,?)",
|
||||
("cm_" + srid, contact_id, _days_ago(comm_days_ago), "u1", "note"))
|
||||
|
||||
|
||||
def test_derivations(conn):
|
||||
print("\n[existing_investor + staleness derivations]")
|
||||
# Existing flag: only total_invested > 0.
|
||||
_investor(conn, "rowExist", 5_000_000, contact_id="c_exist", comm_days_ago=100)
|
||||
_investor(conn, "rowProspect", 0)
|
||||
# Staleness ramp + boundaries (>=30 aging, >=60 stale; inclusive).
|
||||
_investor(conn, "rowStale", 0, contact_id="c_stale", comm_days_ago=70)
|
||||
_investor(conn, "rowAging", 0, contact_id="c_aging", comm_days_ago=45)
|
||||
_investor(conn, "rowFresh", 0, contact_id="c_fresh", comm_days_ago=5)
|
||||
_investor(conn, "rowNoAct", 0)
|
||||
_investor(conn, "rowB60", 0, contact_id="c_b60", comm_days_ago=60) # boundary -> stale
|
||||
_investor(conn, "rowB59", 0, contact_id="c_b59", comm_days_ago=59) # -> aging
|
||||
_investor(conn, "rowB30", 0, contact_id="c_b30", comm_days_ago=30) # boundary -> aging
|
||||
_investor(conn, "rowB29", 0, contact_id="c_b29", comm_days_ago=29) # -> fresh
|
||||
conn.commit()
|
||||
|
||||
existing = server.existing_investor_by_source_row(conn)
|
||||
check(existing == {"rowExist"}, f"existing_investor = total_invested>0 only (got {sorted(existing)})")
|
||||
|
||||
st = server.staleness_by_source_row(conn)
|
||||
level = lambda srid: st.get(srid, (None, "MISSING"))[1]
|
||||
check(level("rowStale") == "stale", f"70d -> stale (got {level('rowStale')})")
|
||||
check(level("rowAging") == "aging", f"45d -> aging (got {level('rowAging')})")
|
||||
check(level("rowFresh") == "", f"5d -> fresh/'' (got {level('rowFresh')!r})")
|
||||
check(level("rowNoAct") == "", f"no activity -> '' (got {level('rowNoAct')!r})")
|
||||
check(level("rowExist") == "stale", "existing + stale coexist (orthogonal axes)")
|
||||
check(level("rowB60") == "stale" and level("rowB59") == "aging",
|
||||
f"60d boundary inclusive -> stale; 59d -> aging (got {level('rowB60')}, {level('rowB59')})")
|
||||
check(level("rowB30") == "aging" and level("rowB29") == "",
|
||||
f"30d boundary inclusive -> aging; 29d -> '' (got {level('rowB30')}, {level('rowB29')!r})")
|
||||
# last_activity_at is carried through alongside the level for the recency display.
|
||||
check(st.get("rowStale", (None, ""))[0] is not None, "stale row carries a last_activity_at value")
|
||||
check(st.get("rowNoAct", ("X", ""))[0] is None, "no-activity row has last_activity_at None")
|
||||
|
||||
|
||||
def main():
|
||||
server.init_db()
|
||||
conn = server.get_db()
|
||||
conn.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
|
||||
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
|
||||
conn.execute("INSERT INTO contacts (id, first_name, last_name) VALUES ('c0','Seed','Contact')")
|
||||
conn.commit()
|
||||
test_migration_remap(conn)
|
||||
test_derivations(conn)
|
||||
conn.close()
|
||||
print("\n" + ("ALL PASS (pipeline stages v2)" if not FAILS else f"{len(FAILS)} FAILED"))
|
||||
sys.exit(1 if FAILS else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -77,9 +77,10 @@ def seed():
|
||||
# opportunities on cLive (also tied to orgA so they appear in the org detail too)
|
||||
c.execute("INSERT INTO opportunities (id,name,contact_id,organization_id,owner_id) VALUES ('opLive','Live Opp','cLive','orgA','u1')")
|
||||
c.execute("INSERT INTO opportunities (id,name,contact_id,organization_id,owner_id,deleted_at) VALUES ('opDead','Dead Opp','cLive','orgA','u1',?)", (DEL,))
|
||||
# funded opportunities on orgA — one live, one soft-deleted (for the org-list total_funded aggregate)
|
||||
c.execute("INSERT INTO opportunities (id,name,contact_id,organization_id,owner_id,stage,commitment_amount) VALUES ('opFundLive','Funded Live','cLive','orgA','u1','funded',1000000)")
|
||||
c.execute("INSERT INTO opportunities (id,name,contact_id,organization_id,owner_id,stage,commitment_amount,deleted_at) VALUES ('opFundDead','Funded Dead','cLive','orgA','u1','funded',500000,?)", (DEL,))
|
||||
# committed-stage opportunities on orgA — one live, one soft-deleted (for the org-list
|
||||
# total_funded aggregate, which now sums stage='commitment' after the 4-stage migration)
|
||||
c.execute("INSERT INTO opportunities (id,name,contact_id,organization_id,owner_id,stage,commitment_amount) VALUES ('opFundLive','Funded Live','cLive','orgA','u1','commitment',1000000)")
|
||||
c.execute("INSERT INTO opportunities (id,name,contact_id,organization_id,owner_id,stage,commitment_amount,deleted_at) VALUES ('opFundDead','Funded Dead','cLive','orgA','u1','commitment',500000,?)", (DEL,))
|
||||
# communications on cLive
|
||||
c.execute("INSERT INTO communications (id,contact_id,communication_date,created_by,subject) VALUES ('cmLive','cLive','2026-05-01','u1','Live note')")
|
||||
c.execute("INSERT INTO communications (id,contact_id,communication_date,created_by,subject,deleted_at) VALUES ('cmDead','cLive','2026-05-02','u1','Dead note',?)", (DEL,))
|
||||
|
||||
+40
-16
@@ -1900,6 +1900,20 @@
|
||||
return err.message || err.error || fallback;
|
||||
};
|
||||
|
||||
// 4-stage per-investor funnel, terminal at commitment (locked spec 2026-06-19; mirrors
|
||||
// server.PIPELINE_STAGES). Labels + chip tints reuse existing semantic colors per
|
||||
// design/DESIGN.md §2: lead = subtle grey, engaged = accent blue, diligence = due-soon
|
||||
// amber, commitment = success green — tinted-fill + tinted-text badges (no new hues).
|
||||
const PIPELINE_STAGES = ['lead', 'engaged', 'diligence', 'commitment'];
|
||||
const PIPELINE_STAGE_LABELS = { lead: 'Lead', engaged: 'Engaged', diligence: 'Diligence', commitment: 'Commitment' };
|
||||
const pipelineStageLabel = (stage) => PIPELINE_STAGE_LABELS[stage] || (stage ? String(stage).replace(/_/g, ' ') : '');
|
||||
const PIPELINE_STAGE_CHIP = {
|
||||
lead: { color: '#8ea2b7', border: '#3a4a5e' },
|
||||
engaged: { color: '#93c5fd', border: '#2f5170' },
|
||||
diligence: { color: '#e0b341', border: '#7a6320' },
|
||||
commitment: { color: '#6ee7b7', border: '#2f6f4f' },
|
||||
};
|
||||
|
||||
const contactName = (row) => {
|
||||
if (!row) return '-';
|
||||
if (row.contact_name) return row.contact_name;
|
||||
@@ -2039,9 +2053,9 @@
|
||||
{ id: 'c-1004', first_name: 'Jennifer', last_name: 'Taylor', email: 'jtaylor@blueharbor.org', phone: '555-1004', title: 'Executive Director', organization: 'Blue Harbor Foundation', organization_name: 'Blue Harbor Foundation', contact_type: 'prospect', status: 'active', last_contact_date: '2026-02-09T08:30:00Z', communication_count: 1, comm_count: 1 }
|
||||
],
|
||||
opportunities: [
|
||||
{ id: 'o-2001', name: 'Cascade - Fund II', contact_id: 'c-1003', contact_name: 'David Martinez', stage: 'meeting', expected_amount: 10000000, commitment_amount: 0, probability: 35, priority: 'high', fund_name: 'Fund II', organization_name: 'Cascade Wealth Management', updated_at: '2026-02-12T10:00:00Z' },
|
||||
{ id: 'o-2002', name: 'Blue Harbor - Fund II', contact_id: 'c-1004', contact_name: 'Jennifer Taylor', stage: 'due_diligence', expected_amount: 5000000, commitment_amount: 0, probability: 60, priority: 'medium', fund_name: 'Fund II', organization_name: 'Blue Harbor Foundation', updated_at: '2026-02-11T10:00:00Z' },
|
||||
{ id: 'o-2003', name: 'Sovereign Re-up', contact_id: 'c-1001', contact_name: 'James Chen', stage: 'committed', expected_amount: 25000000, commitment_amount: 10000000, probability: 85, priority: 'high', fund_name: 'Fund III', organization_name: 'Sovereign Wealth Holdings', updated_at: '2026-02-10T10:00:00Z' }
|
||||
{ id: 'o-2001', name: 'Cascade - Fund II', contact_id: 'c-1003', contact_name: 'David Martinez', stage: 'engaged', expected_amount: 10000000, commitment_amount: 0, probability: 35, priority: 'high', fund_name: 'Fund II', organization_name: 'Cascade Wealth Management', updated_at: '2026-02-12T10:00:00Z' },
|
||||
{ id: 'o-2002', name: 'Blue Harbor - Fund II', contact_id: 'c-1004', contact_name: 'Jennifer Taylor', stage: 'diligence', expected_amount: 5000000, commitment_amount: 0, probability: 60, priority: 'medium', fund_name: 'Fund II', organization_name: 'Blue Harbor Foundation', updated_at: '2026-02-11T10:00:00Z' },
|
||||
{ id: 'o-2003', name: 'Sovereign Re-up', contact_id: 'c-1001', contact_name: 'James Chen', stage: 'commitment', expected_amount: 25000000, commitment_amount: 10000000, probability: 85, priority: 'high', fund_name: 'Fund III', organization_name: 'Sovereign Wealth Holdings', updated_at: '2026-02-10T10:00:00Z' }
|
||||
],
|
||||
communications: [
|
||||
{ id: 'm-3001', contact_id: 'c-1001', contact_name: 'James Chen', type: 'meeting', subject: 'Q1 Strategy Review', body: 'Discussed deployment pace.', communication_date: '2026-02-12T15:00:00Z', outcome: 'positive' },
|
||||
@@ -2175,11 +2189,11 @@
|
||||
total_lps: mockDb.contacts.filter((c) => c.contact_type === 'investor').length,
|
||||
total_prospects: mockDb.contacts.filter((c) => c.contact_type === 'prospect').length,
|
||||
total_committed: mockDb.lp_profiles.reduce((s, lp) => s + (lp.commitment_amount || 0), 0),
|
||||
pipeline_value: mockDb.opportunities.filter((o) => !['funded', 'lost'].includes(o.stage)).reduce((s, o) => s + (o.expected_amount || 0), 0),
|
||||
active_opportunities: mockDb.opportunities.filter((o) => !['funded', 'lost'].includes(o.stage)).length,
|
||||
pipeline_value: mockDb.opportunities.filter((o) => o.stage !== 'commitment').reduce((s, o) => s + (o.expected_amount || 0), 0),
|
||||
active_opportunities: mockDb.opportunities.filter((o) => o.stage !== 'commitment').length,
|
||||
comms_this_month: mockDb.communications.length
|
||||
};
|
||||
const stages = ['lead', 'outreach', 'meeting', 'due_diligence', 'committed', 'funded']
|
||||
const stages = PIPELINE_STAGES
|
||||
.map((stage) => {
|
||||
const rows = mockDb.opportunities.filter((o) => o.stage === stage);
|
||||
return { stage, count: rows.length, total_value: rows.reduce((s, r) => s + (r.expected_amount || 0), 0) };
|
||||
@@ -4173,7 +4187,7 @@
|
||||
const [selectedOpp, setSelectedOpp] = useState(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
|
||||
const stages = ['lead', 'outreach', 'meeting', 'due_diligence', 'committed', 'funded'];
|
||||
const stages = PIPELINE_STAGES;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOpportunities = async () => {
|
||||
@@ -4259,7 +4273,7 @@
|
||||
<div className="pipeline-summary">
|
||||
{stages.map(stage => (
|
||||
<div key={stage} className="pipeline-stage-card">
|
||||
<div className="pipeline-stage-name">{stage.replace('_', ' ')}</div>
|
||||
<div className="pipeline-stage-name">{pipelineStageLabel(stage)}</div>
|
||||
<div className="pipeline-stage-count">{opportunitiesByStage[stage].length}</div>
|
||||
<div className="pipeline-stage-amount">{formatCurrencyLong(stageTotals[stage])}</div>
|
||||
</div>
|
||||
@@ -4269,7 +4283,7 @@
|
||||
<div className="kanban-board">
|
||||
{stages.map(stage => (
|
||||
<div key={stage} className="kanban-column">
|
||||
<div className="kanban-header">{stage.replace(/_/g, ' ')}</div>
|
||||
<div className="kanban-header">{pipelineStageLabel(stage)}</div>
|
||||
{opportunitiesByStage[stage].map(opp => (
|
||||
<div
|
||||
key={opp.id}
|
||||
@@ -5271,7 +5285,12 @@
|
||||
// autosave + version bump. Strip them at every snapshot / persist boundary.
|
||||
const stripComputedRows = (rs) => (Array.isArray(rs) ? rs.map((r) => {
|
||||
if (!r || typeof r !== 'object') return r;
|
||||
const { pipeline, pipeline_stage, reminder_status, ...rest } = r;
|
||||
// existing_investor / last_activity_at / staleness are server-derived read-only
|
||||
// signals (like pipeline_stage) — strip them so they never dirty the autosave or
|
||||
// get persisted into the blob. Their desktop column + mobile-card rendering lands
|
||||
// with the mobile surfaces (Phase 3); injecting them now keeps that pure-frontend.
|
||||
const { pipeline, pipeline_stage, reminder_status,
|
||||
existing_investor, last_activity_at, staleness, ...rest } = r;
|
||||
return rest;
|
||||
}) : rs);
|
||||
|
||||
@@ -6719,7 +6738,14 @@
|
||||
if (col.id === 'pipeline_stage') {
|
||||
const stage = String(row.pipeline_stage || '');
|
||||
if (!stage) return <span style={{ color: '#70859b' }}>—</span>;
|
||||
return <span style={{ textTransform: 'capitalize' }}>{stage.replace(/_/g, ' ')}</span>;
|
||||
const sc = PIPELINE_STAGE_CHIP[stage] || { color: '#8ea2b7', border: '#3a4a5e' };
|
||||
return (
|
||||
<span style={{ display: 'inline-block', padding: '2px 8px', borderRadius: '4px',
|
||||
fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px',
|
||||
color: sc.color, border: `1px solid ${sc.border}`, backgroundColor: sc.color + '1a' }}>
|
||||
{pipelineStageLabel(stage)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (col.id === 'reminder_status') {
|
||||
const rs = String(row.reminder_status || '');
|
||||
@@ -7735,11 +7761,9 @@
|
||||
<label className="form-label">Stage</label>
|
||||
<select className="select-input" value={createOppForm.stage} onChange={(e) => setCreateOppForm((f) => ({ ...f, stage: e.target.value }))}>
|
||||
<option value="lead">Lead</option>
|
||||
<option value="outreach">Outreach</option>
|
||||
<option value="meeting">Meeting</option>
|
||||
<option value="due_diligence">Due Diligence</option>
|
||||
<option value="committed">Committed</option>
|
||||
<option value="funded">Funded</option>
|
||||
<option value="engaged">Engaged</option>
|
||||
<option value="diligence">Diligence</option>
|
||||
<option value="commitment">Commitment</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
|
||||
Reference in New Issue
Block a user