diff --git a/AGENTS.md b/AGENTS.md index 00a5d77..27fda4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,13 +107,14 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude ## Current state -_**Box live at v0.1.0:94**; `main` ahead by mobile Phases 0–7 + P3b + drag-reorder + **8a + 8b + 8c** — **all deploy-pending** (no s9pk built). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign → Phase 8**, building to `design/phase8-conformance.md` (the 8a–8i spec, anchored on each `*.dc.html` DEFAULT `data-props` — NOT the `screenshots/` PNGs). **Plan (Grant, 2026-06-19): finish features first → then Grant device-tests + deploys** (nothing verified on a real phone). History: git log + `start9/0.4/startos/versions/`._ +_**Box live at v0.1.0:94**; `main` ahead by mobile Phases 0–7 + P3b + drag-reorder + **8a + 8b + 8c + 8d** — **all deploy-pending** (no s9pk built). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign → Phase 8**, building to `design/phase8-conformance.md` (the 8a–8i spec, anchored on each `*.dc.html` DEFAULT `data-props` — NOT the `screenshots/` PNGs). **Plan (Grant, 2026-06-19): finish features first → then Grant device-tests + deploys** (nothing verified on a real phone). History: git log + `start9/0.4/startos/versions/`._ - **Mobile redesign — 4 core surfaces built (Grid · Contacts · Pipeline · Reminders), each a rules-of-hooks-safe `useIsMobile()` → `Mobile*`/`Desktop*` pair (desktop untouched).** Foundation: bottom-tab bar + `:root` mobile vars; 4-stage enum; derived grid signals injected-on-GET/stripped-on-write at both points; mobile writes use **one-row endpoints only** (log-communication, pipeline link/stage, reminders, `update-row`) — never whole-grid PUT. - **Phase 8a — Grid + Contacts cards re-authored (this session).** Grid card: existing-LP **earmark** corner-triangle (replaces left-border), right-side **PRIORITY pill** (replaces ★), 4-stage chip, zero-commit dim; detail ★→"Existing LP" pill. Contacts card: two-letter avatar initials + existing-LP **ring** + stage pill + recency; disposition badge dropped. Backend: `contact_grid_signals()` injects derived read-only `committed`/`pipeline_stage` on the contacts read path (see Design convention). `DESIGN.md` §4/§8 reconciled. - **Phase 8b — Contacts + Pipeline detail → drag-dismiss bottom sheets (this session).** Contacts: email-copy pill, Log/Email actions, Organization card (earmark·stage·committed·last-contact·last-note·Open-in-Grid). Pipeline: stat tiles, **inline move-stage list**, notes **timeline** + Log sheet. Both log via `POST /api/communications`; `` layers the Log sheet over a detail. Reviewer pass applied: stale-fetch race guard (cancelled-flag effects + reload key), keyed single-contact signals query, dedup test. - **Phase 8c — Grid-detail notes timeline + top-bar quick-log pencil (this session).** Grid detail (G6): the single `row.notes` blob → a `` fed by a new investor-level read `GET /api/communications?source_row_id=` (backend filter maps row → `fundraising_investors.source_row_id` → `fundraising_contacts.contact_id` → comms, soft-delete-respecting; cancelled-flag fetch + `commsReload` after a log); note-logging switched to the shared `` (dropped `noteForm` + dead `.fs-note-log`). New `MobileQuickLog` (shell `.mobile-only` top-bar pencil, all 4 tabs): two-step sheet — pick investor (search + recent-first pool) → inline log form → one-row `/api/fundraising/log-communication`. Reviewer pass: `contact_id`/`source_row_id` kept mutually exclusive (`elif`), re-open guard, dropped the rejected ★ from the picker. Guarded by `test_grid_comm_timeline.py`. +- **Phase 8d — sort controls across Grid · Pipeline · Contacts (this session).** Shared `SortPill` + `SortSheet` (label+hint option rows) + per-surface sort tables: Grid (Name/Stage/Committed/Staleness/Priority), Pipeline (Name/Amount/Staleness/Priority, sorted **within each stage**; staleness = `opp.updated_at` proxy until 8f wires real recency), Contacts (**type tabs dropped** per Grant; Name A–Z/Z–A, Last contact, **Priority**). Backend: `contact_grid_signals()` now also injects the investor's `priority` flag on both contacts read paths (powers the Contacts Priority sort). Reviewer pass: type-annotated the boolean-vs-`'high'` priority comparators, `sortPillLabel` empty-guard. Guarded by extended `test_contacts_grid_signals.py` (priority signal + dedup). - **Live (deployed):** W2 NL query (v94); W1 reminders (v93); grid Pipeline (v88); Matrix intake + Gmail capture (DWD) + daily digest; Thesis/Architect (dual-approval); outreach — all draft-only. -- **Tests:** **39/39 backend green** (`python3 backend/run_tests.py`; +`test_grid_comm_timeline.py`), `py_compile` clean, render-smoke green; 8c surfaces (quick-log + grid timeline) interaction-verified via a throwaway 375px jsdom harness (deleted after). -- **Next — Phase 8, in order, build to `design/phase8-conformance.md`:** **8d** sort (Grid+Pipeline sort sheet; Contacts = drop type tabs + add Priority sort) → **8e** reminders (due-chip + Overdue/Today/This-week/Later buckets + dots + snooze sheet + investor picker) → **8f** Pipeline card (earmark/Priority/recency + horizontal-scroll stage pills + dots) → **8g** add-investor stage+priority → **8h** loose ends (incl. Grid detail G4/G5/G6 stage-card/reminder-card/timeline; "Open-in-Grid" deep-link-to-investor) → **8i** shell SVG icons + `·Ten31·` wordmark. **Skip Pipeline accordion** (Grant). **Then (after feature-complete):** deploy P0–P8 + P3b in one s9pk (**authorize + version-bump first**) and device-test light/dark on a phone. +- **Tests:** **39/39 backend green** (`python3 backend/run_tests.py`; +`test_grid_comm_timeline.py` for the 8c timeline filter, +priority assertions in `test_contacts_grid_signals.py`), `py_compile` clean; 8c+8d surfaces interaction-verified via throwaway 375px jsdom harnesses (deleted after). +- **Next — Phase 8, in order, build to `design/phase8-conformance.md`:** **8e** reminders (due-chip + Overdue/Today/This-week/Later buckets + dots + snooze sheet + investor picker) → **8f** Pipeline card (earmark/Priority/recency + horizontal-scroll stage pills + dots) → **8g** add-investor stage+priority → **8h** loose ends (incl. Grid detail G4/G5/G6 stage-card/reminder-card/timeline; "Open-in-Grid" deep-link-to-investor) → **8i** shell SVG icons + `·Ten31·` wordmark. **Skip Pipeline accordion** (Grant). **Then (after feature-complete):** deploy P0–P8 + P3b in one s9pk (**authorize + version-bump first**) and device-test light/dark on a phone. - **Open / risks:** all mobile work + light theme **built but never deployed or device-tested** (smoke/jsdom only); `MobileDetailRow` now unused-but-retained (legacy-usage sweep); Pipeline detail "Committed" tile shows grid-committed not deal-expected (deal forecast in a footnote); W2 happy-path only; **Claude/Architect path unverified live on the box**; v2.0 reserve-asset spine **not canonical**; doc drift — `crm-overview.md`/`EVALUATION.md` still call `lp_profiles` live. diff --git a/backend/server.py b/backend/server.py index 7d2800c..3dbf8f1 100644 --- a/backend/server.py +++ b/backend/server.py @@ -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}) diff --git a/backend/test_contacts_grid_signals.py b/backend/test_contacts_grid_signals.py index cdd1bbc..62f83ee 100644 --- a/backend/test_contacts_grid_signals.py +++ b/backend/test_contacts_grid_signals.py @@ -7,7 +7,8 @@ sourced from the fundraising grid (the canonical investor model), for the mobile mirroring existing_investor_by_source_row (committed capital, not graveyard); - `pipeline_stage` -> that investor's live derived stage (drives the card's stage pill), or null when the investor isn't in the pipeline. -A contact with no grid link (pure classic/legacy contact) gets committed 0 / stage null. + - `priority` -> that investor's priority flag (drives the mobile Contacts Priority sort, 8d). +A contact with no grid link (pure classic/legacy contact) gets committed 0 / stage null / priority false. Signals are derived fresh on read and never stored on the contact. Synthetic data only. Run: cd backend && python3 test_contacts_grid_signals.py @@ -138,16 +139,27 @@ def main(): check((vince or {}).get("pipeline_stage") is None, f"Vince.pipeline_stage is None (no grid link) (got {(vince or {}).get('pipeline_stage')!r})") + # ── priority signal: flagged investor → contact's Priority-sort key (8d) ── + print("\n[priority: Contacts Priority sort driven by the investor's priority flag]") + check((jane or {}).get("priority") is True, + f"Jane.priority is True (Acme flagged) (got {(jane or {}).get('priority')!r})") + check((pat or {}).get("priority") is False, + f"Pat.priority is False (Beta not flagged) (got {(pat or {}).get('priority')!r})") + check((vince or {}).get("priority") is False, + f"Vince.priority is False (no grid link) (got {(vince or {}).get('priority')!r})") + # ── the get-by-id endpoint carries the same signals (mobile detail sheet, 8b) ── print("\n[get-by-id: /api/contacts/{id} also injects committed + pipeline_stage]") st, d = _req(port, "GET", f"/api/contacts/{jane['id']}", token) detail = (d or {}).get("data") or {} - check(st == 200 and detail.get("committed") == 250000 and detail.get("pipeline_stage") == "engaged", - f"detail carries committed/pipeline_stage (got committed={detail.get('committed')}, stage={detail.get('pipeline_stage')!r})") + check(st == 200 and detail.get("committed") == 250000 and detail.get("pipeline_stage") == "engaged" + and detail.get("priority") is True, + f"detail carries committed/pipeline_stage/priority (got committed={detail.get('committed')}, stage={detail.get('pipeline_stage')!r}, priority={detail.get('priority')!r})") st, d = _req(port, "GET", f"/api/contacts/{vince['id']}", token) vdetail = (d or {}).get("data") or {} - check(st == 200 and vdetail.get("committed") == 0 and vdetail.get("pipeline_stage") is None, - f"unlinked contact detail has committed 0 / stage None (got {vdetail.get('committed')}, {vdetail.get('pipeline_stage')!r})") + check(st == 200 and vdetail.get("committed") == 0 and vdetail.get("pipeline_stage") is None + and vdetail.get("priority") is False, + f"unlinked contact detail has committed 0 / stage None / priority False (got {vdetail.get('committed')}, {vdetail.get('pipeline_stage')!r}, {vdetail.get('priority')!r})") # ── stage tracks the board: advancing the opp re-derives the contact's stage ── print("\n[derived-live: advancing the board stage re-derives the contact's pill]") @@ -178,6 +190,9 @@ def main(): jd = (d or {}).get("data") or {} check(jd.get("committed") == 500000, f"multi-linked contact exposes the higher committed (500000 > 250000) (got {jd.get('committed')})") + # The winning (higher-committed) link is Mega Fund LP, which is not flagged → priority follows it. + check(jd.get("priority") is False, + f"multi-linked contact's priority follows the higher-committed investor (Mega, unflagged) (got {jd.get('priority')!r})") finally: httpd.shutdown() diff --git a/frontend/index.html b/frontend/index.html index 6fe749c..0ed3b7c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2198,11 +2198,27 @@ .mobile-seg-tab.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-light); } .mobile-sortbar { display: flex; justify-content: space-between; align-items: center; } .mobile-count { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-muted); } - .mobile-sort-btn { - background: transparent; border: none; color: var(--text-muted); - font-size: 13px; font-family: inherit; cursor: pointer; padding: 6px 2px; - display: inline-flex; align-items: center; gap: 6px; + /* Shared sort control (8d): a mono uppercase pill (dc GridApp:72) + a label+hint option sheet. */ + .sort-pill { + flex: none; display: inline-flex; align-items: center; gap: 6px; + height: 30px; padding: 0 12px; border-radius: 999px; + border: 1px solid var(--border); background: var(--bg-input); color: var(--text-secondary); + font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 600; + letter-spacing: 0.04em; text-transform: uppercase; cursor: pointer; } + .sort-pill:active { background: var(--bg-hover); } + .sort-list { display: flex; flex-direction: column; gap: 8px; } + .sort-row { + width: 100%; text-align: left; cursor: pointer; font-family: inherit; + display: flex; align-items: center; justify-content: space-between; gap: 10px; + min-height: 52px; padding: 0 15px; border-radius: 10px; + border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); + } + .sort-row.active { border-color: var(--border-strong); background: var(--bg-panel-elevated); } + .sort-row-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; } + .sort-row-label { font-size: 15px; font-weight: 500; color: var(--text-primary); } + .sort-row-hint { font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: var(--text-subtle); } + .sort-row-check { flex: none; color: var(--accent); font-size: 15px; } /* A–Z directory: sticky letter headers over a card list. */ .az-header { @@ -4226,6 +4242,52 @@ ); }; + // Shared sort control (8d, dc GridApp:72 / PipelineApp:64) — a mono uppercase pill with a + // sort glyph + the active sort's short label, opening a sort sheet. Reused by Grid / Pipeline + // / Contacts. Sort-option lists carry {id, pill (short label), label + hint (sheet rows)}. + const SortPill = ({ label, onClick }) => ( + + ); + const SortSheet = ({ open, onClose, title, options, value, onPick }) => ( + +
+ {options.map((o) => ( + + ))} +
+
+ ); + const sortPillLabel = (options, value) => ((options.find((o) => o.id === value) || options[0] || {}).pill || ''); + + // Grid sort keys (dc GridApp:632 opts + sortList). Comparators live in the surface. + const GRID_SORTS = [ + { id: 'name', pill: 'Name', label: 'Name', hint: 'A → Z' }, + { id: 'stage', pill: 'Stage', label: 'Pipeline stage', hint: 'Lead → Commitment' }, + { id: 'committed', pill: 'Committed', label: 'Committed', hint: 'Most first' }, + { id: 'staleness', pill: 'Staleness', label: 'Last contact', hint: 'Most stale first' }, + { id: 'priority', pill: 'Priority', label: 'Priority', hint: 'Flagged first' }, + ]; + // Pipeline sorts within a stage (dc PipelineApp:580). "Staleness" uses the opp's updated_at as + // an activity proxy until the real last-contact recency lands on the Pipeline card (8f). + const PIPELINE_SORTS = [ + { id: 'name', pill: 'Name', label: 'Name', hint: 'A → Z' }, + { id: 'amount', pill: 'Amount', label: 'Committed', hint: 'Most first' }, + { id: 'staleness', pill: 'Staleness', label: 'Last activity', hint: 'Most stale first' }, + { id: 'priority', pill: 'Priority', label: 'Priority', hint: 'Flagged first' }, + ]; + const LoginPage = () => { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -5279,10 +5341,13 @@ // Phase 2 of the mobile-first redesign — the lowest-risk surface, validating the // list→detail→sheet pattern + the shared BottomSheet/useIsMobile before the Grid (Phase 3). // Contacts is READ-ONLY on mobile (BRIEF §3b): create/edit live on the Grid, never here. + // Contacts sorts (8d). dc Contacts was search-only; the Priority sort is a Grant-decided + // enhancement (the type tabs were dropped). `priority` rides the grid signal the API injects. const SORT_OPTIONS = [ - { id: 'name-asc', label: 'Name (A–Z)', short: 'A–Z' }, - { id: 'name-desc', label: 'Name (Z–A)', short: 'Z–A' }, - { id: 'recent', label: 'Recently contacted', short: 'Recent' }, + { id: 'name-asc', pill: 'A–Z', label: 'Name', hint: 'A → Z' }, + { id: 'name-desc', pill: 'Z–A', label: 'Name', hint: 'Z → A' }, + { id: 'recent', pill: 'Recent', label: 'Last contact', hint: 'Recent first' }, + { id: 'priority', pill: 'Priority', label: 'Priority', hint: 'Flagged first' }, ]; // Contacts detail — a drag-dismiss bottom sheet (8b / dc ContactsApp:118-179). Identity + @@ -5398,7 +5463,6 @@ const [contacts, setContacts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const [tab, setTab] = useState('all'); const [search, setSearch] = useState(''); const [sort, setSort] = useState('name-asc'); const [sortOpen, setSortOpen] = useState(false); @@ -5431,8 +5495,6 @@ const filtered = useMemo(() => { const q = search.trim().toLowerCase(); const list = contacts.filter((c) => { - if (tab === 'investors' && c.contact_type !== 'investor') return false; - if (tab === 'prospects' && c.contact_type !== 'prospect') return false; if (!q) return true; const org = c.organization || c.organization_name || ''; return displayName(c).toLowerCase().includes(q) @@ -5441,16 +5503,20 @@ }); if (sort === 'recent') { list.sort((a, b) => lastContactTs(b) - lastContactTs(a)); + } else if (sort === 'priority') { + // Flagged (grid investor priority) first, then A–Z. c.priority: boolean (API-injected). + list.sort((a, b) => (b.priority ? 1 : 0) - (a.priority ? 1 : 0) + || sortBasis(a).localeCompare(sortBasis(b), undefined, { sensitivity: 'base' })); } else { const dir = sort === 'name-desc' ? -1 : 1; list.sort((a, b) => dir * sortBasis(a).localeCompare(sortBasis(b), undefined, { sensitivity: 'base' })); } return list; - }, [contacts, tab, search, sort]); + }, [contacts, search, sort]); - // A–Z letter groups only in name-sort modes; "recently contacted" is a flat list. + // A–Z letter groups only in name-sort modes; recent / priority are flat lists. const groups = useMemo(() => { - if (sort === 'recent') return null; + if (sort === 'recent' || sort === 'priority') return null; const m = new Map(); for (const c of filtered) { let letter = (sortBasis(c).charAt(0) || '#').toUpperCase(); @@ -5486,8 +5552,6 @@ ); }; - const sortShort = (SORT_OPTIONS.find((o) => o.id === sort) || SORT_OPTIONS[0]).short; - return (
Read-only directory — people are added and edited from the Fundraising Grid.
@@ -5499,16 +5563,9 @@ value={search} onChange={(e) => setSearch(e.target.value)} /> -
- {['all', 'investors', 'prospects'].map((t) => ( - - ))} -
{filtered.length} {filtered.length === 1 ? 'contact' : 'contacts'} - + setSortOpen(true)} />
@@ -5529,18 +5586,8 @@ filtered.map(renderCard) )} - setSortOpen(false)} title="Sort"> - {SORT_OPTIONS.map((o) => ( - - ))} - + setSortOpen(false)} title="Sort contacts" + options={SORT_OPTIONS} value={sort} onPick={setSort} /> {selected && ( { out[s] = []; }); opportunities.forEach((o) => { if (out[o.stage]) out[o.stage].push(o); }); + // Sort within each stage by the active key (dc PipelineApp sortCards). Name is the + // tiebreak. "staleness" uses updated_at (oldest activity first) as the recency proxy + // until the Pipeline card wires true last-contact recency (8f); missing date = stalest. + const byName = (a, b) => String(a.name || '').localeCompare(String(b.name || ''), undefined, { sensitivity: 'base' }); + const updatedTs = (o) => { const t = o.updated_at ? new Date(o.updated_at).getTime() : NaN; return isNaN(t) ? -Infinity : t; }; + const cmp = { + name: byName, + amount: (a, b) => ((Number(b.expected_amount) || 0) - (Number(a.expected_amount) || 0)) || byName(a, b), + staleness: (a, b) => (updatedTs(a) - updatedTs(b)) || byName(a, b), // older ts first = most stale first + priority: (a, b) => ((b.priority === 'high' ? 1 : 0) - (a.priority === 'high' ? 1 : 0)) || byName(a, b), // opp.priority: 'high'|'medium'|'low' + }; + stages.forEach((s) => { out[s].sort(cmp[sortKey] || byName); }); return out; - }, [opportunities]); + }, [opportunities, sortKey]); const stageTotals = useMemo(() => stages.map((s) => byStage[s].reduce((sum, o) => sum + (Number(o.expected_amount) || 0), 0)), [byStage]); @@ -6245,6 +6306,10 @@
No deals in the pipeline yet. Add them from the Fundraising Grid — open an investor and tap “Add to pipeline.”
) : ( <> +
+ {opportunities.length} {opportunities.length === 1 ? 'deal' : 'deals'} + setSortOpen(true)} /> +
{stages.map((s, i) => (
); }; @@ -9759,7 +9827,8 @@ const [error, setError] = useState(''); const [search, setSearch] = useState(''); const [selectedId, setSelectedId] = useState(null); - const [sheet, setSheet] = useState(null); // 'view' | 'create' | 'note' | 'stage' | 'reminder' + const [sheet, setSheet] = useState(null); // 'view' | 'create' | 'note' | 'stage' | 'reminder' | 'sort' + const [sortKey, setSortKey] = useState('name'); // GRID_SORTS const [busy, setBusy] = useState(false); const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '' }); const [reminderForm, setReminderForm] = useState({ title: '', due_date: '', details: '' }); @@ -9817,9 +9886,24 @@ const contactText = (r.contacts || []).map((c) => `${c.name || ''} ${c.email || ''} ${c.city || ''} ${c.state || ''} ${c.country || ''}`).join(' '); return `${r.investor_name || ''} ${r.notes || ''} ${contactText}`.toLowerCase().includes(q); }); - return [...searched].sort((a, b) => String(a.investor_name || '') - .localeCompare(String(b.investor_name || ''), undefined, { sensitivity: 'base' })); - }, [rows, activeViewObj, columns, fundColumnIds, search]); + // Sort by the active key (dc GridApp sortList). Name is the tiebreak for every + // non-name key. Staleness ranks longest-since-contact first; rows with no recorded + // activity sort as most stale (they most need a touch). Committed uses the fund rollup. + const byName = (a, b) => String(a.investor_name || '') + .localeCompare(String(b.investor_name || ''), undefined, { sensitivity: 'base' }); + const staleDays = (r) => { const d = daysSince(r.last_activity_at); return d == null ? Number.MAX_SAFE_INTEGER : d; }; + const cmp = { + name: byName, + stage: (a, b) => { + const oi = (r) => (r.pipeline_stage ? PIPELINE_STAGES.indexOf(r.pipeline_stage) : 99); + return (oi(a) - oi(b)) || byName(a, b); + }, + committed: (a, b) => (gridRollup(b, fundColumnIds) - gridRollup(a, fundColumnIds)) || byName(a, b), + staleness: (a, b) => (staleDays(b) - staleDays(a)) || byName(a, b), // larger days first = most stale first + priority: (a, b) => ((b.priority ? 1 : 0) - (a.priority ? 1 : 0)) || byName(a, b), // row.priority: boolean + }; + return [...searched].sort(cmp[sortKey] || byName); + }, [rows, activeViewObj, columns, fundColumnIds, search, sortKey]); const selectedRow = useMemo(() => rows.find((r) => r.id === selectedId) || null, [rows, selectedId]); const closeSheet = () => setSheet(null); @@ -10018,6 +10102,7 @@ setSearch(e.target.value)} />
{displayed.length} {displayed.length === 1 ? 'investor' : 'investors'} + setSheet('sort')} />
@@ -10031,6 +10116,9 @@ displayed.map(renderCard) )} + + {views.map((v) => (