Mobile Phase 8f: Pipeline card → dc anatomy (earmark/Priority/recency, scroll pills, dots)

Bring the mobile Pipeline surface to the PipelineApp.dc.html default anatomy:

- Segmented control → horizontal-scroll pills with label + count badge; the active
  pill tints to its own stage color via --seg-* (aliased to --chip-*, so it flips in light).
- Card → earmark corner + name + Priority pill / $amount · dot · recency / labeled
  ‹ Prev · Next › move footer (was name + contact·org sub + bare chevrons). Compact amount.
- Stage-column header → StageChip + investor count + committed sum.
- Page dots → tappable, active = 22px accent bar.

Backend: the opportunities list injects two derived read-only fields (mirroring the
Contacts-list pattern; opp writes use a field allowlist so neither round-trips):
- existing_investor (contact_grid_signals committed>0) so the card earmark agrees with
  the detail's "Existing LP" pill.
- last_contact_date (MAX communication_date on the deal's contact, deleted_at-filtered)
  → card recency line + the Staleness sort (replaces the updated_at proxy).

Guarded by new soft-delete assertions in test_soft_delete_reads.py. 39/39 green.
This commit is contained in:
Keysat
2026-06-20 05:38:15 -05:00
parent f0f1ed3bcd
commit e53a41ae80
4 changed files with 121 additions and 52 deletions
+6 -5
View File
File diff suppressed because one or more lines are too long
+13 -1
View File
@@ -3014,7 +3014,9 @@ class CRMHandler(BaseHTTPRequestHandler):
conn = get_db()
query = """
SELECT op.*, c.first_name, c.last_name, c.email as contact_email,
o.name as organization_name, u.full_name as owner_name
o.name as organization_name, u.full_name as owner_name,
(SELECT MAX(communication_date) FROM communications
WHERE contact_id = op.contact_id AND deleted_at IS NULL) as last_contact_date
FROM opportunities op
LEFT JOIN contacts c ON op.contact_id = c.id
LEFT JOIN organizations o ON op.organization_id = o.id
@@ -3047,6 +3049,16 @@ class CRMHandler(BaseHTTPRequestHandler):
args.extend([limit, offset])
opps = rows_to_list(conn.execute(query, args).fetchall())
# Read-only existing-LP signal for the mobile Pipeline card earmark (8f): the deal's linked
# contact rolls up to a grid investor with committed capital. Same source as the Contacts
# list's `committed` and the detail sheet's "Existing LP" pill (contact_grid_signals →
# committed > 0), so card earmark and detail pill agree. Derived on GET, never persisted;
# opportunity writes use a field allowlist (handle_update_opportunity) so it can't round-trip.
# `last_contact_date` (subselect above) drives the card recency line + the Staleness sort.
signals = contact_grid_signals(conn)
for op in opps:
sig = signals.get(str(op.get('contact_id') or ''))
op['existing_investor'] = bool(sig and sig['committed'] > 0)
conn.close()
return self.send_json({"data": opps, "total": total, "limit": limit, "offset": offset})
+12
View File
@@ -137,6 +137,18 @@ def main():
check(rowC is not None, "cLive present in contact list")
if rowC:
check(rowC.get("comm_count") == 1, f"contact comm_count: live only (cmLive -> 1; got {rowC.get('comm_count')})")
# opportunities list: the injected last_contact_date subselect (8f) excludes soft-deleted
# comms. opLive's contact cLive has cmLive (2026-05-01, live) + cmDead (2026-05-02, deleted);
# the recency must be the LIVE comm's date, never the later deleted one. existing_investor
# is a derived bool (cLive isn't grid-linked here -> False).
_, opplist = _get(port, "/api/opportunities", token)
rowO = next((x for x in (opplist or {}).get("data", []) if x.get("id") == "opLive"), None)
check(rowO is not None, "opLive present in opportunities list")
if rowO:
check(rowO.get("last_contact_date") == "2026-05-01",
f"opp last_contact_date: live comm only (2026-05-01, not deleted 2026-05-02; got {rowO.get('last_contact_date')})")
check(rowO.get("existing_investor") is False,
f"opp existing_investor present+bool (got {rowO.get('existing_investor')!r})")
finally:
httpd.shutdown()
+90 -46
View File
@@ -2579,23 +2579,32 @@
/* ─── Phase 4 — Pipeline mobile surface (swipe-between-stages) ─────────────────────
JS-gated to MobilePipeline; reuses the .fs-detail / .sheet / .stage-chip patterns.
Stage segmented control (count-forward) → horizontal scroll-snap stage pages → dots;
per-card / stage move shares PATCH /api/opportunities/{id}/stage (DESIGN §8 / BRIEF §3c). */
.pipeline-seg { display: flex; gap: 6px; }
Horizontal-scroll stage pills (label + count badge) → horizontal scroll-snap stage pages → tappable dots;
per-card Prev / Next stage move shares PATCH /api/opportunities/{id}/stage (DESIGN §8 / BRIEF §3c). */
/* Stage segmented control (P1): horizontal-scroll pills, label + count badge; the active
pill tints to its own stage color via --seg-* (set per .pipeline-seg-tab--{stage}). */
.pipeline-seg { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none; padding-bottom: 2px; }
.pipeline-seg::-webkit-scrollbar { display: none; }
.pipeline-seg-tab {
flex: 1; min-width: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 2px;
min-height: var(--mobile-touch-target); padding: 5px 2px;
background: var(--bg-panel); border: 1px solid var(--border);
border-radius: var(--mobile-control-radius);
flex: none; display: inline-flex; align-items: center; gap: 8px;
height: 36px; padding: 0 14px; border-radius: 999px;
background: var(--bg-input); border: 1px solid var(--border);
color: var(--text-subtle); font-family: inherit; cursor: pointer;
}
.pipeline-seg-tab.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-light); }
.pipeline-seg-count { font-family: 'IBM Plex Mono', monospace; font-size: 15px; font-weight: 600; line-height: 1; }
.pipeline-seg-label {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; line-height: 1;
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
.pipeline-seg-label { font-family: 'IBM Plex Mono', monospace; font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; line-height: 1; }
.pipeline-seg-count {
font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 600;
min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px;
display: inline-flex; align-items: center; justify-content: center;
background: var(--border); color: var(--text-subtle);
}
.pipeline-seg-tab--lead { --seg-bg: var(--chip-lead-bg); --seg-text: var(--chip-lead-text); --seg-border: var(--chip-lead-border); }
.pipeline-seg-tab--engaged { --seg-bg: var(--chip-engaged-bg); --seg-text: var(--chip-engaged-text); --seg-border: var(--chip-engaged-border); }
.pipeline-seg-tab--diligence { --seg-bg: var(--chip-diligence-bg); --seg-text: var(--chip-diligence-text); --seg-border: var(--chip-diligence-border); }
.pipeline-seg-tab--commitment { --seg-bg: var(--chip-commitment-bg); --seg-text: var(--chip-commitment-text); --seg-border: var(--chip-commitment-border); }
.pipeline-seg-tab.active { background: var(--seg-bg); border-color: var(--seg-border); color: var(--seg-text); }
.pipeline-seg-tab.active .pipeline-seg-count { background: var(--seg-border); color: var(--seg-text); }
.pipeline-swipe {
display: flex; overflow-x: auto; scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch; scroll-behavior: smooth;
@@ -2603,34 +2612,52 @@
}
.pipeline-swipe::-webkit-scrollbar { display: none; }
.pipeline-stage-page { flex: 0 0 100%; width: 100%; box-sizing: border-box; scroll-snap-align: start; padding: 0 1px; }
.pipeline-page-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.pipeline-page-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
.pipeline-page-total { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-muted); flex: none; }
/* Stage-column header (P6): stage chip + investor count + committed sum */
.pipeline-page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 4px 2px 12px; }
.pipeline-page-head-left { display: flex; align-items: center; gap: 9px; min-width: 0; }
.pipeline-page-count { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-subtle); white-space: nowrap; }
.pipeline-page-sum { font-family: 'IBM Plex Mono', monospace; font-size: 13px; font-weight: 600; color: var(--money); flex: none; }
.pipeline-page-sum.zero { color: var(--text-subtle); }
/* Card (P2): earmark corner + name + Priority pill / amount · dot · recency / labeled move footer (dc PipelineApp:97-116) */
.pipeline-card {
display: flex; align-items: stretch; overflow: hidden;
position: relative; overflow: hidden;
background: var(--bg-panel); border: 1px solid var(--border);
border-radius: var(--mobile-card-radius); margin-bottom: var(--mobile-card-gap);
box-shadow: 0 14px 26px rgba(2,12,24,0.28), inset 0 1px 0 #ffffff07;
}
.pipeline-card-tap {
flex: 1; min-width: 0; text-align: left; background: transparent; border: none;
color: inherit; font-family: inherit; cursor: pointer; padding: 12px 14px;
width: 100%; text-align: left; background: transparent; border: none; color: inherit;
font-family: inherit; cursor: pointer; display: flex; flex-direction: column; gap: 9px;
padding: 13px 14px 11px;
}
.pipeline-card-tap:active { background: var(--bg-hover); }
.pipeline-card-name { font-size: var(--mobile-font-card-title); font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pipeline-card-sub { font-size: 13px; color: var(--text-muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pipeline-card-amount { font-family: 'IBM Plex Mono', monospace; font-size: 13px; font-weight: 600; color: var(--money); margin-top: 8px; }
.pipeline-card-amount.zero { color: var(--text-subtle); }
.pipeline-card-move { flex: none; display: flex; align-items: stretch; }
.stage-move-btn {
width: 42px; background: transparent; border: none; border-left: 1px solid var(--border);
color: var(--accent); font-size: 22px; line-height: 1; font-family: inherit; cursor: pointer;
.pipeline-card-row1 { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.pipeline-card-name {
flex: 1; min-width: 0; font-size: var(--mobile-font-card-title); font-weight: 600;
color: var(--text-primary); line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.pipeline-card-row2 { display: flex; align-items: center; gap: 10px; }
.pipeline-card-amount { font-family: 'IBM Plex Mono', monospace; font-size: var(--mobile-font-body); font-weight: 600; color: var(--money); flex: none; }
.pipeline-card-amount.zero { color: var(--text-subtle); }
.pipeline-card-dot { flex: none; width: 3px; height: 3px; border-radius: 999px; background: var(--border-strong); }
.pipeline-card-recency { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-subtle); white-space: nowrap; }
.pipeline-card-recency.recency-aging { color: var(--recency-aging); }
.pipeline-card-recency.recency-stale { color: var(--recency-stale); }
.pipeline-card-move { display: flex; border-top: 1px solid var(--divider); }
.stage-move-btn {
flex: 1; height: 40px; min-width: 0; background: transparent; border: none;
color: var(--accent-light); font-family: 'IBM Plex Mono', monospace; font-size: 11px;
letter-spacing: 0.04em; text-transform: uppercase; cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 5px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.stage-move-btn.back { border-right: 1px solid var(--divider); color: var(--text-muted); }
.stage-move-btn:disabled { color: var(--text-subtle); opacity: 0.4; cursor: default; }
.stage-move-btn:active:not(:disabled) { background: var(--bg-hover); }
.pipeline-dots { display: flex; justify-content: center; gap: 7px; padding: 14px 0 2px; }
.pipeline-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--border-strong); transition: background 0.15s ease; }
.pipeline-dot.active { background: var(--accent); }
.pipeline-dots { display: flex; justify-content: center; align-items: center; gap: 9px; padding: 8px 0 4px; }
.pipeline-dot-btn { background: none; border: none; cursor: pointer; padding: 7px 3px; display: flex; align-items: center; justify-content: center; }
.pipeline-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--border-strong); transition: width 0.15s ease, background 0.15s ease; }
.pipeline-dot.active { width: 22px; background: var(--accent); }
/* ─── Reminders mobile surface (8e: urgency-bucketed list + due-chips + swipe actions) ───
JS-gated to MobileReminders. Header = title + summary line + gradient add (dc :56-62);
@@ -6369,14 +6396,14 @@
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.
// tiebreak. "staleness" sorts by last_contact_date (oldest contact first) the same
// server-injected recency the card shows; a missing date sorts as the 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 lastTs = (o) => { const t = o.last_contact_date ? new Date(o.last_contact_date).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
staleness: (a, b) => (lastTs(a) - lastTs(b)) || byName(a, b), // oldest contact 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); });
@@ -6465,17 +6492,27 @@
const renderCard = (opp) => {
const idx = stages.indexOf(opp.stage);
const amount = Number(opp.expected_amount) || 0;
const sub = [contactName(opp), opp.organization_name].filter((x) => x && x !== '-').join(' · ');
const days = daysSince(opp.last_contact_date);
const recCls = recencyClassForDays(days);
const prevLabel = idx > 0 ? pipelineStageLabel(stages[idx - 1]) : 'Start';
const nextLabel = idx < stages.length - 1 ? pipelineStageLabel(stages[idx + 1]) : 'End';
return (
<div className="pipeline-card" key={opp.id}>
{opp.existing_investor && <EarmarkCorner />}
<button className="pipeline-card-tap" onClick={() => openDetail(opp.id)}>
<div className="pipeline-card-name">{opp.name}</div>
{sub && <div className="pipeline-card-sub">{sub}</div>}
<div className={`pipeline-card-amount${amount > 0 ? '' : ' zero'}`}>{formatCurrencyLong(amount)}</div>
<div className="pipeline-card-row1">
<span className="pipeline-card-name">{opp.name}</span>
{opp.priority === 'high' && <span className="priority-pill">Priority</span>}
</div>
<div className="pipeline-card-row2">
<span className={`pipeline-card-amount${amount > 0 ? '' : ' zero'}`}>{formatMoneyMobile(amount)}</span>
<span className="pipeline-card-dot" />
<span className={`pipeline-card-recency ${recCls}`}>{days == null ? 'no activity' : formatAgeShort(days) + (days <= 0 ? '' : ' ago')}</span>
</div>
</button>
<div className="pipeline-card-move">
<button className="stage-move-btn" aria-label="Move back a stage" disabled={busy || idx <= 0} onClick={() => moveStage(opp, -1)}></button>
<button className="stage-move-btn" aria-label="Move forward a stage" disabled={busy || idx >= stages.length - 1} onClick={() => moveStage(opp, 1)}></button>
<button className="stage-move-btn back" aria-label={`Move back to ${prevLabel}`} disabled={busy || idx <= 0} onClick={() => moveStage(opp, -1)}> {prevLabel}</button>
<button className="stage-move-btn" aria-label={`Move forward to ${nextLabel}`} disabled={busy || idx >= stages.length - 1} onClick={() => moveStage(opp, 1)}>{nextLabel} </button>
</div>
</div>
);
@@ -6501,11 +6538,11 @@
key={s}
role="tab"
aria-selected={i === activeStage}
className={`pipeline-seg-tab ${i === activeStage ? 'active' : ''}`}
className={`pipeline-seg-tab pipeline-seg-tab--${s} ${i === activeStage ? 'active' : ''}`}
onClick={() => goToStage(i)}
>
<span className="pipeline-seg-count">{byStage[s].length}</span>
<span className="pipeline-seg-label">{pipelineStageLabel(s)}</span>
<span className="pipeline-seg-count">{byStage[s].length}</span>
</button>
))}
</div>
@@ -6514,8 +6551,11 @@
{stages.map((s, i) => (
<section className="pipeline-stage-page" key={s} aria-label={pipelineStageLabel(s)}>
<div className="pipeline-page-head">
<span className="pipeline-page-title">{pipelineStageLabel(s)}</span>
<span className="pipeline-page-total">{byStage[s].length} {byStage[s].length === 1 ? 'deal' : 'deals'} · {formatCurrencyLong(stageTotals[i])}</span>
<span className="pipeline-page-head-left">
<StageChip stage={s} />
<span className="pipeline-page-count">{byStage[s].length} {byStage[s].length === 1 ? 'investor' : 'investors'}</span>
</span>
<span className={`pipeline-page-sum${stageTotals[i] > 0 ? '' : ' zero'}`}>{formatMoneyMobile(stageTotals[i])}</span>
</div>
{byStage[s].length === 0
? <div className="empty-state" style={{ padding: '24px 0' }}>No deals in this stage</div>
@@ -6524,8 +6564,12 @@
))}
</div>
<div className="pipeline-dots" aria-hidden="true">
{stages.map((s, i) => <span key={s} className={`pipeline-dot ${i === activeStage ? 'active' : ''}`} />)}
<div className="pipeline-dots">
{stages.map((s, i) => (
<button key={s} className="pipeline-dot-btn" aria-label={`Go to ${pipelineStageLabel(s)}`} onClick={() => goToStage(i)}>
<span className={`pipeline-dot ${i === activeStage ? 'active' : ''}`} />
</button>
))}
</div>
</>
)}