Mobile Phase 8d: sort controls across Grid, Pipeline, Contacts

Add a shared SortPill + SortSheet (label+hint option rows) and per-surface sort
tables:
- Grid: Name / Pipeline stage / Committed / Last contact / Priority, applied in
  the displayed memo (name is the tiebreak; staleness ranks longest-since-contact
  first, no-activity treated as most stale; committed uses the fund rollup).
- Pipeline: Name / Amount / Last activity / Priority, sorted within each stage.
  "Last activity" uses opp.updated_at as a recency proxy until the Pipeline card
  wires true last-contact recency (8f).
- Contacts: drop the investor/prospect type tabs (the prospect type is unused);
  add a Priority sort alongside Name A-Z/Z-A and Last contact.

contact_grid_signals() now also surfaces the linked investor's priority flag,
injected on both contact read paths (same derive-on-read contract as committed /
pipeline_stage), powering the Contacts Priority sort. Extended
test_contacts_grid_signals.py covers it; 39/39 backend green.
This commit is contained in:
Keysat
2026-06-19 22:06:14 -05:00
parent 93ac0c240f
commit 42c169559c
4 changed files with 165 additions and 56 deletions
+4 -3
View File
@@ -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 07 + 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 8a8i 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 07 + 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 8a8i 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`; `<BottomSheet stacked>` 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 `<NoteTimeline>` fed by a new investor-level read `GET /api/communications?source_row_id=<grid 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 `<LogCommunicationSheet>` (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 AZ/ZA, 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 P0P8 + 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 P0P8 + 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.
+13 -8
View File
@@ -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})
+20 -5
View File
@@ -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()
+128 -40
View File
@@ -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; }
/* AZ 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 }) => (
<button type="button" className="sort-pill" onClick={onClick} aria-label="Sort">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h11M3 12h7M3 18h4" /><path d="M18 8v9m0 0 3-3m-3 3-3-3" />
</svg>
{label}
</button>
);
const SortSheet = ({ open, onClose, title, options, value, onPick }) => (
<BottomSheet open={open} onClose={onClose} title={title}>
<div className="sort-list">
{options.map((o) => (
<button key={o.id} type="button" className={`sort-row ${value === o.id ? 'active' : ''}`}
onClick={() => { onPick(o.id); onClose(); }}>
<span className="sort-row-main">
<span className="sort-row-label">{o.label}</span>
{o.hint && <span className="sort-row-hint">{o.hint}</span>}
</span>
{value === o.id && <span className="sort-row-check"></span>}
</button>
))}
</div>
</BottomSheet>
);
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 (AZ)', short: 'AZ' },
{ id: 'name-desc', label: 'Name (ZA)', short: 'ZA' },
{ id: 'recent', label: 'Recently contacted', short: 'Recent' },
{ id: 'name-asc', pill: 'AZ', label: 'Name', hint: 'AZ' },
{ id: 'name-desc', pill: 'ZA', label: 'Name', hint: 'ZA' },
{ 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 AZ. 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]);
// AZ letter groups only in name-sort modes; "recently contacted" is a flat list.
// AZ 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 (
<div className="mobile-screen">
<div className="mobile-caption">Read-only directory — people are added and edited from the Fundraising Grid.</div>
@@ -5499,16 +5563,9 @@
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="mobile-seg">
{['all', 'investors', 'prospects'].map((t) => (
<button key={t} className={`mobile-seg-tab ${tab === t ? 'active' : ''}`} onClick={() => setTab(t)}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
<div className="mobile-sortbar">
<span className="mobile-count">{filtered.length} {filtered.length === 1 ? 'contact' : 'contacts'}</span>
<button className="mobile-sort-btn" onClick={() => setSortOpen(true)}>⇅ {sortShort}</button>
<SortPill label={sortPillLabel(SORT_OPTIONS, sort)} onClick={() => setSortOpen(true)} />
</div>
</div>
@@ -5529,18 +5586,8 @@
filtered.map(renderCard)
)}
<BottomSheet open={sortOpen} onClose={() => setSortOpen(false)} title="Sort">
{SORT_OPTIONS.map((o) => (
<button
key={o.id}
className={`sheet-option ${sort === o.id ? 'active' : ''}`}
onClick={() => { setSort(o.id); setSortOpen(false); }}
>
<span>{o.label}</span>
{sort === o.id && <span className="sheet-option-check"></span>}
</button>
))}
</BottomSheet>
<SortSheet open={sortOpen} onClose={() => setSortOpen(false)} title="Sort contacts"
options={SORT_OPTIONS} value={sort} onPick={setSort} />
{selected && (
<MobileContactDetail
@@ -6110,6 +6157,8 @@
const [logOpen, setLogOpen] = useState(false);
const [contactDetail, setContactDetail] = useState(null); // /api/contacts/{contact_id} for the open opp
const [busy, setBusy] = useState(false);
const [sortKey, setSortKey] = useState('name'); // PIPELINE_SORTS — applied within each stage
const [sortOpen, setSortOpen] = useState(false);
const swipeRef = useRef(null);
const stages = PIPELINE_STAGES;
@@ -6134,8 +6183,20 @@
const out = {};
stages.forEach((s) => { 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 @@
<div className="empty-state">No deals in the pipeline yet. Add them from the Fundraising Grid — open an investor and tap “Add to pipeline.”</div>
) : (
<>
<div className="mobile-sortbar" style={{ marginBottom: '12px' }}>
<span className="mobile-count">{opportunities.length} {opportunities.length === 1 ? 'deal' : 'deals'}</span>
<SortPill label={sortPillLabel(PIPELINE_SORTS, sortKey)} onClick={() => setSortOpen(true)} />
</div>
<div className="pipeline-seg" role="tablist" aria-label="Pipeline stages">
{stages.map((s, i) => (
<button
@@ -6345,6 +6410,9 @@
</BottomSheet>
);
})()}
<SortSheet open={sortOpen} onClose={() => setSortOpen(false)} title="Sort within stage"
options={PIPELINE_SORTS} value={sortKey} onPick={setSortKey} />
</div>
);
};
@@ -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 @@
<input className="mobile-search" type="text" placeholder="Search investors…" value={search} onChange={(e) => setSearch(e.target.value)} />
<div className="mobile-sortbar">
<span className="mobile-count">{displayed.length} {displayed.length === 1 ? 'investor' : 'investors'}</span>
<SortPill label={sortPillLabel(GRID_SORTS, sortKey)} onClick={() => setSheet('sort')} />
</div>
</div>
@@ -10031,6 +10116,9 @@
displayed.map(renderCard)
)}
<SortSheet open={sheet === 'sort'} onClose={closeSheet} title="Sort investors"
options={GRID_SORTS} value={sortKey} onPick={setSortKey} />
<BottomSheet open={sheet === 'view'} onClose={closeSheet} title="Views">
{views.map((v) => (
<button key={v.id} className={`sheet-option ${v.id === activeView ? 'active' : ''}`} onClick={() => { setActiveView(v.id); closeSheet(); }}>