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 ## 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. - **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 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 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 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. - **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). - **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`:** **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. - **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. - **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): def contact_grid_signals(conn, contact_id=None):
"""Return {contacts.id: {'committed': float, 'pipeline_stage': str|None}} for every classic """Return {contacts.id: {'committed': float, 'pipeline_stage': str|None, 'priority': bool}} for
contact linked to a fundraising-grid investor (via fundraising_contacts.contact_id, migration every classic contact linked to a fundraising-grid investor (via fundraising_contacts.contact_id,
0004). Surfaces the canonical investor's committed rollup (total_invested → the mobile Contacts migration 0004). Surfaces the canonical investor's committed rollup (total_invested → the mobile
card's existing-LP avatar ring, committed > 0, mirroring existing_investor_by_source_row) and its Contacts card's existing-LP avatar ring, committed > 0, mirroring existing_investor_by_source_row),
live derived pipeline stage ( the card's stage pill). Derived fresh on read like the grid's 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 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 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 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: try:
rows = conn.execute( rows = conn.execute(
f""" 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 FROM fundraising_contacts fc
JOIN fundraising_investors fi ON fc.investor_id = fi.id JOIN fundraising_investors fi ON fc.investor_id = fi.id
{where} {where}
@@ -1912,9 +1914,10 @@ def contact_grid_signals(conn, contact_id=None):
committed = float(r['committed'] or 0) committed = float(r['committed'] or 0)
prev = out.get(cid) prev = out.get(cid)
# A contact normally links to exactly one investor; if it links to several, keep the # 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']: 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 return out
@@ -2722,6 +2725,7 @@ class CRMHandler(BaseHTTPRequestHandler):
sig = signals.get(str(c.get('id') or '')) sig = signals.get(str(c.get('id') or ''))
c['committed'] = sig['committed'] if sig else 0 c['committed'] = sig['committed'] if sig else 0
c['pipeline_stage'] = sig['pipeline_stage'] if sig else None c['pipeline_stage'] = sig['pipeline_stage'] if sig else None
c['priority'] = bool(sig['priority']) if sig else False
conn.close() conn.close()
return self.send_json({ return self.send_json({
@@ -2765,6 +2769,7 @@ class CRMHandler(BaseHTTPRequestHandler):
sig = contact_grid_signals(conn, contact_id).get(contact_id) sig = contact_grid_signals(conn, contact_id).get(contact_id)
result['committed'] = sig['committed'] if sig else 0 result['committed'] = sig['committed'] if sig else 0
result['pipeline_stage'] = sig['pipeline_stage'] if sig else None result['pipeline_stage'] = sig['pipeline_stage'] if sig else None
result['priority'] = bool(sig['priority']) if sig else False
conn.close() conn.close()
return self.send_json({"data": result}) 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); mirroring existing_investor_by_source_row (committed capital, not graveyard);
- `pipeline_stage` -> that investor's live derived stage (drives the card's stage pill), - `pipeline_stage` -> that investor's live derived stage (drives the card's stage pill),
or null when the investor isn't in the pipeline. 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. Signals are derived fresh on read and never stored on the contact. Synthetic data only.
Run: cd backend && python3 test_contacts_grid_signals.py Run: cd backend && python3 test_contacts_grid_signals.py
@@ -138,16 +139,27 @@ def main():
check((vince or {}).get("pipeline_stage") is None, check((vince or {}).get("pipeline_stage") is None,
f"Vince.pipeline_stage is None (no grid link) (got {(vince or {}).get('pipeline_stage')!r})") 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) ── # ── 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]") print("\n[get-by-id: /api/contacts/{id} also injects committed + pipeline_stage]")
st, d = _req(port, "GET", f"/api/contacts/{jane['id']}", token) st, d = _req(port, "GET", f"/api/contacts/{jane['id']}", token)
detail = (d or {}).get("data") or {} detail = (d or {}).get("data") or {}
check(st == 200 and detail.get("committed") == 250000 and detail.get("pipeline_stage") == "engaged", 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})") 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) st, d = _req(port, "GET", f"/api/contacts/{vince['id']}", token)
vdetail = (d or {}).get("data") or {} vdetail = (d or {}).get("data") or {}
check(st == 200 and vdetail.get("committed") == 0 and vdetail.get("pipeline_stage") is None, 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})") 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 ── # ── 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]") 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 {} jd = (d or {}).get("data") or {}
check(jd.get("committed") == 500000, check(jd.get("committed") == 500000,
f"multi-linked contact exposes the higher committed (500000 > 250000) (got {jd.get('committed')})") 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: finally:
httpd.shutdown() 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-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-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-count { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-muted); }
.mobile-sort-btn { /* Shared sort control (8d): a mono uppercase pill (dc GridApp:72) + a label+hint option sheet. */
background: transparent; border: none; color: var(--text-muted); .sort-pill {
font-size: 13px; font-family: inherit; cursor: pointer; padding: 6px 2px; flex: none; display: inline-flex; align-items: center; gap: 6px;
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 directory: sticky letter headers over a card list. */
.az-header { .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 LoginPage = () => {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -5279,10 +5341,13 @@
// Phase 2 of the mobile-first redesign — the lowest-risk surface, validating the // 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). // 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 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 = [ const SORT_OPTIONS = [
{ id: 'name-asc', label: 'Name (AZ)', short: 'AZ' }, { id: 'name-asc', pill: 'AZ', label: 'Name', hint: 'AZ' },
{ id: 'name-desc', label: 'Name (ZA)', short: 'ZA' }, { id: 'name-desc', pill: 'ZA', label: 'Name', hint: 'ZA' },
{ id: 'recent', label: 'Recently contacted', short: 'Recent' }, { 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 + // Contacts detail — a drag-dismiss bottom sheet (8b / dc ContactsApp:118-179). Identity +
@@ -5398,7 +5463,6 @@
const [contacts, setContacts] = useState([]); const [contacts, setContacts] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [tab, setTab] = useState('all');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sort, setSort] = useState('name-asc'); const [sort, setSort] = useState('name-asc');
const [sortOpen, setSortOpen] = useState(false); const [sortOpen, setSortOpen] = useState(false);
@@ -5431,8 +5495,6 @@
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
const list = contacts.filter((c) => { 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; if (!q) return true;
const org = c.organization || c.organization_name || ''; const org = c.organization || c.organization_name || '';
return displayName(c).toLowerCase().includes(q) return displayName(c).toLowerCase().includes(q)
@@ -5441,16 +5503,20 @@
}); });
if (sort === 'recent') { if (sort === 'recent') {
list.sort((a, b) => lastContactTs(b) - lastContactTs(a)); 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 { } else {
const dir = sort === 'name-desc' ? -1 : 1; const dir = sort === 'name-desc' ? -1 : 1;
list.sort((a, b) => dir * sortBasis(a).localeCompare(sortBasis(b), undefined, { sensitivity: 'base' })); list.sort((a, b) => dir * sortBasis(a).localeCompare(sortBasis(b), undefined, { sensitivity: 'base' }));
} }
return list; 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(() => { const groups = useMemo(() => {
if (sort === 'recent') return null; if (sort === 'recent' || sort === 'priority') return null;
const m = new Map(); const m = new Map();
for (const c of filtered) { for (const c of filtered) {
let letter = (sortBasis(c).charAt(0) || '#').toUpperCase(); 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 ( return (
<div className="mobile-screen"> <div className="mobile-screen">
<div className="mobile-caption">Read-only directory — people are added and edited from the Fundraising Grid.</div> <div className="mobile-caption">Read-only directory — people are added and edited from the Fundraising Grid.</div>
@@ -5499,16 +5563,9 @@
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} 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"> <div className="mobile-sortbar">
<span className="mobile-count">{filtered.length} {filtered.length === 1 ? 'contact' : 'contacts'}</span> <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>
</div> </div>
@@ -5529,18 +5586,8 @@
filtered.map(renderCard) filtered.map(renderCard)
)} )}
<BottomSheet open={sortOpen} onClose={() => setSortOpen(false)} title="Sort"> <SortSheet open={sortOpen} onClose={() => setSortOpen(false)} title="Sort contacts"
{SORT_OPTIONS.map((o) => ( options={SORT_OPTIONS} value={sort} onPick={setSort} />
<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>
{selected && ( {selected && (
<MobileContactDetail <MobileContactDetail
@@ -6110,6 +6157,8 @@
const [logOpen, setLogOpen] = useState(false); const [logOpen, setLogOpen] = useState(false);
const [contactDetail, setContactDetail] = useState(null); // /api/contacts/{contact_id} for the open opp const [contactDetail, setContactDetail] = useState(null); // /api/contacts/{contact_id} for the open opp
const [busy, setBusy] = useState(false); 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 swipeRef = useRef(null);
const stages = PIPELINE_STAGES; const stages = PIPELINE_STAGES;
@@ -6134,8 +6183,20 @@
const out = {}; const out = {};
stages.forEach((s) => { out[s] = []; }); stages.forEach((s) => { out[s] = []; });
opportunities.forEach((o) => { if (out[o.stage]) out[o.stage].push(o); }); 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; return out;
}, [opportunities]); }, [opportunities, sortKey]);
const stageTotals = useMemo(() => stages.map((s) => const stageTotals = useMemo(() => stages.map((s) =>
byStage[s].reduce((sum, o) => sum + (Number(o.expected_amount) || 0), 0)), [byStage]); 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="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"> <div className="pipeline-seg" role="tablist" aria-label="Pipeline stages">
{stages.map((s, i) => ( {stages.map((s, i) => (
<button <button
@@ -6345,6 +6410,9 @@
</BottomSheet> </BottomSheet>
); );
})()} })()}
<SortSheet open={sortOpen} onClose={() => setSortOpen(false)} title="Sort within stage"
options={PIPELINE_SORTS} value={sortKey} onPick={setSortKey} />
</div> </div>
); );
}; };
@@ -9759,7 +9827,8 @@
const [error, setError] = useState(''); const [error, setError] = useState('');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState(null); 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 [busy, setBusy] = useState(false);
const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '' }); const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '' });
const [reminderForm, setReminderForm] = useState({ title: '', due_date: '', details: '' }); 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(' '); 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 `${r.investor_name || ''} ${r.notes || ''} ${contactText}`.toLowerCase().includes(q);
}); });
return [...searched].sort((a, b) => String(a.investor_name || '') // Sort by the active key (dc GridApp sortList). Name is the tiebreak for every
.localeCompare(String(b.investor_name || ''), undefined, { sensitivity: 'base' })); // non-name key. Staleness ranks longest-since-contact first; rows with no recorded
}, [rows, activeViewObj, columns, fundColumnIds, search]); // 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 selectedRow = useMemo(() => rows.find((r) => r.id === selectedId) || null, [rows, selectedId]);
const closeSheet = () => setSheet(null); 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)} /> <input className="mobile-search" type="text" placeholder="Search investors…" value={search} onChange={(e) => setSearch(e.target.value)} />
<div className="mobile-sortbar"> <div className="mobile-sortbar">
<span className="mobile-count">{displayed.length} {displayed.length === 1 ? 'investor' : 'investors'}</span> <span className="mobile-count">{displayed.length} {displayed.length === 1 ? 'investor' : 'investors'}</span>
<SortPill label={sortPillLabel(GRID_SORTS, sortKey)} onClick={() => setSheet('sort')} />
</div> </div>
</div> </div>
@@ -10031,6 +10116,9 @@
displayed.map(renderCard) 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"> <BottomSheet open={sheet === 'view'} onClose={closeSheet} title="Views">
{views.map((v) => ( {views.map((v) => (
<button key={v.id} className={`sheet-option ${v.id === activeView ? 'active' : ''}`} onClick={() => { setActiveView(v.id); closeSheet(); }}> <button key={v.id} className={`sheet-option ${v.id === activeView ? 'active' : ''}`} onClick={() => { setActiveView(v.id); closeSheet(); }}>