Mobile Phases 4–5: Pipeline (swipe-between-stages) + Reminders

Completes the four mobile-first surfaces. Both phases follow the established
useIsMobile() wrapper → Mobile*/Desktop* pattern; desktop is untouched (only
renamed Desktop*). No backend change.

P4 Pipeline (MobilePipeline): CSS scroll-snap swipe between the four stages +
count-forward segmented control + page dots; per-card ‹/› stage move and
tap → full-screen opp detail with a stage-picker sheet. Opp-centric — shares
PATCH /api/opportunities/{id}/stage with the desktop board and the Grid's
stage edit; view + advance-stage only (removal stays on the Grid/desktop board).

P5 Reminders (MobileReminders): urgency-grouped list over /api/reminders
(Overdue/Due soon/Later/Done/Cancelled) with an Active/Done/All filter; each
row is a pointer-drag swipe (left = mark done, right = snooze +7d, keeping
status open and pushing due_date, per the desktop rationale); tap → create/edit
BottomSheet (investor is create-only, matching the backend PATCH field set).
formatDueShort/reminderDueDelta fix the desktop formatter mis-rendering future
due dates.

Verified: render-smoke + throwaway jsdom 375px interaction harnesses (12/12
each); reviewer passes applied — notably P5 pointercancel no longer fires a
spurious mark-done. Deploy-pending (no s9pk built); not yet tested on a phone.
This commit is contained in:
Keysat
2026-06-19 15:44:49 -05:00
parent 95beb7bb19
commit ee9db6425a
3 changed files with 655 additions and 10 deletions
+5 -4
View File
@@ -107,11 +107,12 @@ 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 02 + P3a + drag-reorder views — **all committed, deploy-pending** (no s9pk built yet). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign** — P0P3a done, **P4 Pipeline next**. Per-phase detail + backlog: `ROADMAP.md` / `EVALUATION.md`; history: git log + `start9/0.4/startos/versions/`._ _**Box live at v0.1.0:94**; `main` ahead by mobile Phases 02 + P3a + P4 + P5 + drag-reorder views — **P4+P5 uncommitted in the working tree, rest committed, all deploy-pending** (no s9pk built yet). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign** — **all 4 mobile surfaces done (P0P5)**, **P6 light theme next** (then P3b name/pill edit + deploy). Per-phase detail + backlog: `ROADMAP.md` / `EVALUATION.md`; history: git log + `start9/0.4/startos/versions/`._
- **Mobile redesign — 3 of 4 surfaces built (Grid + Contacts done; Pipeline + Reminders to go).** Foundation shipped: bottom-tab bar + `:root` mobile vars (P1 `634fc42`); 4-stage enum + read-only derived grid signals `existing_investor`/`last_activity_at`/`staleness`/`opportunity_id` injected on GET, **stripped on write at both points** (P0 `e46dd36`, P3a `e34a6fc`). React primitives `<BottomSheet>`/`useIsMobile()`/`StageChip`; each surface is a rules-of-hooks-safe `useIsMobile()` wrapper → `Mobile*`/`Desktop*` pair (**desktop untouched**), re-authored against the real API (comps aren't drop-in). **Mobile writes use one-row endpoints only — never whole-grid PUT** (BRIEF §3a). - **Mobile redesign — all 4 core surfaces built (Grid + Contacts + Pipeline + Reminders).** Foundation shipped: bottom-tab bar + `:root` mobile vars (P1 `634fc42`); 4-stage enum + read-only derived grid signals `existing_investor`/`last_activity_at`/`staleness`/`opportunity_id` injected on GET, **stripped on write at both points** (P0 `e46dd36`, P3a `e34a6fc`). React primitives `<BottomSheet>`/`useIsMobile()`/`StageChip`; each surface is a rules-of-hooks-safe `useIsMobile()` wrapper → `Mobile*`/`Desktop*` pair (**desktop untouched**), re-authored against the real API (comps aren't drop-in). **Mobile writes use one-row endpoints only — never whole-grid PUT** (BRIEF §3a).
- **Built this session:** **P2 Contacts** (`984b950`; read-only AZ list→detail→sort-sheet) + **P3a Grid** (`e34a6fc`; card list→detail→edit sheets: log-note / stage / reminder / `+`create with dedup; name+pill edit = **P3b, deferred**, needs a narrow per-row PATCH + pill editor). Both verified by render-smoke + throwaway jsdom 375px interaction harnesses; a `reviewer` pass was applied (stage already-linked→PATCH enforce; earliest-opp determinism; search parity). - **Built this session (both uncommitted; no backend change):** **P4 Pipeline** (`MobilePipeline` = swipe-between-stages via CSS scroll-snap + count-forward segmented control + dots, per-card / stage move + tap→detail w/ stage-picker sheet; opp-centric, shares `PATCH /api/opportunities/{id}/stage`; **view+advance-stage only**, removal stays on the Grid/desktop board) + **P5 Reminders** (`MobileReminders` = urgency-grouped list over `/api/reminders` w/ Active/Done/All filter; `ReminderRow` pointer-drag **swipe-left=done / swipe-right=snooze +7d**; tap → create/edit `BottomSheet`; `formatDueShort` fixes the future-date formatter). Each verified by render-smoke + a throwaway jsdom 375px harness (12/12 each); `reviewer` passes applied (P4: reset stage-sheet open-state on back, `moveStage` awaits; **P5: `pointercancel` no longer fires a spurious mark-done**, cancelled gets its own bucket).
- **Prior sessions:** **P2 Contacts** (`984b950`; read-only AZ list→detail→sort-sheet) + **P3a Grid** (`e34a6fc`; card list→detail→edit sheets: log-note / stage / reminder / `+`create with dedup; name+pill edit = **P3b, deferred**, needs a narrow per-row PATCH + pill editor). Both verified by render-smoke + throwaway jsdom 375px interaction harnesses; a `reviewer` pass was applied (stage already-linked→PATCH enforce; earliest-opp determinism; search parity).
- **Live (deployed):** W2 NL query (v94; remaining: in-room smoke + web "Ask" box); 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; remaining: in-room smoke + web "Ask" box); W1 reminders (v93); grid Pipeline (v88); Matrix intake + Gmail capture (DWD) + daily digest; Thesis/Architect (dual-approval); outreach — all draft-only.
- **Tests:** **36/36 backend green** (`python3 backend/run_tests.py`), `py_compile` clean, render-smoke green, fresh-DB migrate clean. - **Tests:** **36/36 backend green** (`python3 backend/run_tests.py`), `py_compile` clean, render-smoke green, fresh-DB migrate clean.
- **Next:** 1) **P4 Pipeline** (swipe-between-stages; reuses the opportunities endpoints + `<BottomSheet>`/`StageChip`); 2) **P5 Reminders → P6 light theme**; 3) **P3b** name/pill edit; 4) **deploy** P0P3a + view-reorder in one s9pk (**authorize first**); 5) W2 web Ask box + smoke; 6) W3 bot grid-mutations; 7) W1b nurture-gap. - **Next:** 1) **P6 light theme** (inline-hex→`var()` axis, 183 literals + ship `color.light` behind a `[data-theme]` toggle; dark stays default); 2) **P3b** name/pill edit (narrow per-row PATCH + pill editor); 3) **deploy** P0P5 + view-reorder in one s9pk (**authorize first**) — verify on a real phone; 4) W2 web Ask box + smoke; 5) W3 bot grid-mutations; 6) W1b nurture-gap.
- **Open / risks:** all mobile work **built but never deployed or tested on a real phone** (render-smoke + jsdom-at-375px only — verify on a device); **P3b deferred**; W2 happy-path only; **Claude/Architect path unverified live on the box**; v2.0 reserve-asset spine **not canonical** (needs dual sign-off); doc drift — `crm-overview.md`/`EVALUATION.md` still call `lp_profiles` live. - **Open / risks:** all mobile work **built but never deployed or tested on a real phone** (render-smoke + jsdom-at-375px only — verify on a device); **P3b deferred**; W2 happy-path only; **Claude/Architect path unverified live on the box**; v2.0 reserve-asset spine **not canonical** (needs dual sign-off); doc drift — `crm-overview.md`/`EVALUATION.md` still call `lp_profiles` live.
+35 -4
View File
@@ -377,10 +377,41 @@ migration into each surface's build, behind one shared foundation step. No upfro
- **P3b (deferred):** `POST /api/fundraising/update-row` (version-safe single-row name/contacts mutation, - **P3b (deferred):** `POST /api/fundraising/update-row` (version-safe single-row name/contacts mutation,
+test) + the bottom-sheet **pill editor** (add/edit/remove pills, client-side dedup). Then name + pills +test) + the bottom-sheet **pill editor** (add/edit/remove pills, client-side dedup). Then name + pills
become editable on an existing investor, completing BRIEF §3a's editable set. become editable on an existing investor, completing BRIEF §3a's editable set.
- **Phase 4 — Pipeline.** ~7 inline styles. Swipe-between-stages (snap-scroll + segmented control + - **Phase 4 — Pipeline — BUILT 2026-06-19 (deploy pending).** Lean **`MobilePipeline`** (separate
dots), per-card stage move sharing the Grid detail's opportunities endpoints. component; `PipelinePage` is now a `useIsMobile()` wrapper → `Desktop`/`Mobile`, desktop kanban
- **Phase 5 — Reminders.** ~18 inline styles. Urgency-grouped list, swipe complete/snooze, add/edit untouched, just renamed `DesktopPipelinePage`). **Swipe-between-stages:** a count-forward segmented
sheets on `/api/reminders`. stage control (`.pipeline-seg`) + a horizontal **CSS scroll-snap** container of four full-width stage
pages (`.pipeline-swipe`/`.pipeline-stage-page`) + page dots; tapping a segment scrolls to its page,
scrolling syncs the active segment/dots. Each card shows opp name · contact·org · expected $, with
per-card **/ stage move** (`PATCH /api/opportunities/{id}/stage`, disabled at the lead/commitment
boundaries) — the kanban "advance" without opening the detail. Tap a card → full-screen `.fs-detail`
(read-only `OpportunityDetailPanel`-equivalent fields via `MobileDetailRow` + a `StageChip`) with a
**stage-picker `BottomSheet`**. **Opp-centric** (operates on the same `opportunities` rows + stage
endpoint as the desktop board and the Grid detail's stage edit), amounts read-only; **no Existing-Investor
star** (opps carry `fundraising_investor_id` but not `total_invested`). Removal/deletion stays on the
desktop board + the Grid detail's "remove from pipeline" — the Pipeline tab is **view + advance-stage
only**. A `reviewer` pass was applied (reset the detail's stage-sheet open-state on back; `moveStage`
awaits the PATCH). Verified: render-smoke green + a throwaway jsdom 375px harness drove the real surface
(seg counts, stage pages, segment/dot sync, / move re-bucketing, detail + stage-sheet PATCH, back — 12/12).
No real-phone check yet (same deferral as P1P3a). Reuses the P2/P3a primitives directly; **no backend
change.** **Deploy:** folds into the next s9pk.
- **Phase 5 — Reminders — BUILT 2026-06-19 (deploy pending).** Lean **`MobileReminders`**
(`RemindersPage` is now a `useIsMobile()` wrapper → `Desktop`/`Mobile`; the desktop page renamed
`DesktopRemindersPage`, otherwise untouched). **Urgency-grouped list** over `/api/reminders`
(Overdue → Due soon → Later → Done → Cancelled buckets via `reminderBucket`; group headers carry the
overdue-red/due-soon-amber tint) with a compact **Active/Done/All** segmented filter + **`+ New`**.
Each row is a **`ReminderRow`** pointer-drag swipe (own per-row drag state): **swipe-left → mark done**,
**swipe-right → snooze +7d** (threshold 70px; snooze keeps status `open` and pushes `due_date`, mirroring
the desktop's "no wake mechanism" rationale), a **tap → create/edit `BottomSheet`** (title · due date ·
investor *(create-only free-text label — PATCH can't change investor, matching the backend)* · assignee
*(if `/api/users` is readable)* · details · status *(edit-only)* · Delete). Vertical-dominant drags release
to list scroll; non-swipeable (done/cancelled) rows stay tap-to-edit. Added `formatDueShort`/`reminderDueDelta`
(local-midnight delta — the desktop `formatDate` mis-renders FUTURE dates). A `reviewer` pass was applied
(**`pointercancel` no longer fires a spurious mark-done** — the key fix; stray drag on a non-swipeable row
recovers as a tap; cancelled gets its own bucket header). **No backend change.** Verified: render-smoke
green + a throwaway jsdom 375px harness (grouping/counts, swipe done + snooze PATCH, tap→edit prefilled,
create POST, Done-filter reload — 12/12). No real-phone check yet (same deferral as P1P4). **Deploy:**
folds into the next s9pk.
- **Phase 6 — Light theme + toggle (adopted as a planned feature, 2026-06-19).** The inline-hex→`var()` - **Phase 6 — Light theme + toggle (adopted as a planned feature, 2026-06-19).** The inline-hex→`var()`
axis (183 literals) + ship the light palette (`tokens.tokens.json` `color.light`) behind a axis (183 literals) + ship the light palette (`tokens.tokens.json` `color.light`) behind a
`[data-theme]` switch + a top-bar toggle; dark stays the default. Mechanical; co-lands after the `[data-theme]` switch + a top-bar toggle; dark stays the default. Mechanical; co-lands after the
+615 -2
View File
@@ -2225,6 +2225,93 @@
.dedup-box-title { font-size: 12px; color: var(--due-soon, #e0b341); margin-bottom: 4px; } .dedup-box-title { font-size: 12px; color: var(--due-soon, #e0b341); margin-bottom: 4px; }
.dedup-match { font-size: 13px; color: var(--text-secondary); padding: 3px 0; } .dedup-match { font-size: 13px; color: var(--text-secondary); padding: 3px 0; }
/* ─── 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; }
.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);
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-swipe {
display: flex; overflow-x: auto; scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch; scroll-behavior: smooth;
scrollbar-width: none; margin-top: 14px;
}
.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; }
.pipeline-card {
display: flex; align-items: stretch; 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;
}
.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: #6ee7b7; 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;
}
.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); }
/* ─── Phase 5 — Reminders mobile surface (urgency-grouped list + swipe actions) ───────
JS-gated to MobileReminders; reuses the .mobile-toolbar / .mobile-seg / .grid-new-btn /
BottomSheet patterns. Each row is a pointer-drag swipe: left reveals Done, right reveals
Snooze (threshold 70px); a tap (no drag) opens the edit sheet (BRIEF §3d). */
.reminder-group-header {
display: flex; align-items: baseline; gap: 8px; margin: 16px 2px 8px;
font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
}
.reminder-group-header:first-child { margin-top: 4px; }
.reminder-group-count { color: var(--text-subtle); font-weight: 500; }
.reminder-row { position: relative; overflow: hidden; border-radius: var(--mobile-card-radius); margin-bottom: var(--mobile-card-gap); }
.reminder-action {
position: absolute; inset: 0; display: flex; align-items: center; padding: 0 18px;
font-size: 13px; font-weight: 600; font-family: inherit;
}
.reminder-action.done { justify-content: flex-end; background: rgba(16,185,129,0.16); color: #6ee7b7; }
.reminder-action.snooze { justify-content: flex-start; background: rgba(224,179,65,0.14); color: #e0b341; }
.reminder-fg {
position: relative; z-index: 1; display: block; width: 100%; text-align: left; color: inherit;
background: var(--bg-panel); border: 1px solid var(--border);
border-radius: var(--mobile-card-radius); padding: 12px 14px; cursor: pointer;
box-shadow: 0 14px 26px rgba(2,12,24,0.28), inset 0 1px 0 #ffffff07;
transition: transform 0.18s ease; touch-action: pan-y;
}
.reminder-fg-head { display: flex; align-items: baseline; gap: 8px; }
.reminder-title { font-size: var(--mobile-font-card-title); font-weight: 600; color: var(--text-primary); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reminder-status-tag { flex: none; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.reminder-meta { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
.reminder-due { font-family: 'IBM Plex Mono', monospace; }
.reminder-details { font-size: 12px; color: var(--text-secondary); margin-top: 5px; white-space: pre-wrap; line-height: 1.45; }
/* Visibility utilities — base = desktop; flipped under the breakpoint. */ /* Visibility utilities — base = desktop; flipped under the breakpoint. */
.mobile-only { display: none; } .mobile-only { display: none; }
@@ -4040,7 +4127,7 @@
); );
}; };
const RemindersPage = ({ token, onShowToast, user }) => { const DesktopRemindersPage = ({ token, onShowToast, user }) => {
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -4269,6 +4356,315 @@
); );
}; };
// ─── Phase 5 — mobile Reminders (BRIEF §3d) ──────────────────────────────────────
// Date-only delta vs local midnight (formatDate mis-handles FUTURE dates — it returns
// "-3 days ago" — so due dates need their own formatter).
const reminderDueDelta = (iso) => {
if (!iso) return null;
const due = new Date(String(iso).slice(0, 10) + 'T00:00:00');
if (Number.isNaN(due.getTime())) return null;
const today = new Date(); today.setHours(0, 0, 0, 0);
return Math.round((due - today) / 86400000);
};
const formatDueShort = (iso) => {
const delta = reminderDueDelta(iso);
if (delta == null) return 'No due date';
const d = new Date(String(iso).slice(0, 10) + 'T00:00:00');
const label = d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
if (delta === 0) return 'Due today';
if (delta < 0) return `${label} · ${-delta}d overdue`;
if (delta <= 7) return `${label} · in ${delta}d`;
return `Due ${label}`;
};
// Urgency bucket: done/cancelled collapse to 'done'; open/snoozed split by due-date delta.
const reminderBucket = (r) => {
if (r.status === 'cancelled') return 'cancelled';
if (r.status === 'done') return 'done';
const delta = reminderDueDelta(r.due_date);
if (delta == null) return 'later';
if (delta < 0) return 'overdue';
if (delta <= 7) return 'soon';
return 'later';
};
const REMINDER_BUCKETS = [
{ key: 'overdue', label: 'Overdue' },
{ key: 'soon', label: 'Due soon' },
{ key: 'later', label: 'Later' },
{ key: 'done', label: 'Done' },
{ key: 'cancelled', label: 'Cancelled' },
];
const REMINDER_STATUS_COLOR = { open: '#7fb0d3', snoozed: '#b08fd3', done: '#7fd3a3', cancelled: '#70859b' };
// One reminder row: a pointer-drag swipe over a tappable foreground. Encapsulates its own
// drag state so rows don't share a translate. Tap (no drag) → onTap; swipe-left past 70px →
// onDone; swipe-right → onSnooze. Vertical-dominant drags are released to the list scroll.
const ReminderRow = ({ r, canSwipe, onTap, onDone, onSnooze }) => {
const [dx, setDx] = useState(0);
const drag = useRef(null);
const onPointerDown = (e) => {
drag.current = { x: e.clientX, y: e.clientY, active: false, moved: false };
try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {}
};
const onPointerMove = (e) => {
const d = drag.current; if (!d) return;
const ddx = e.clientX - d.x, ddy = e.clientY - d.y;
if (!d.active) {
if (Math.abs(ddx) < 6) return;
if (Math.abs(ddy) > Math.abs(ddx)) { drag.current = null; setDx(0); return; } // vertical → scroll
d.active = true;
}
d.moved = true;
if (canSwipe) setDx(Math.max(-120, Math.min(120, ddx)));
};
const end = (e) => {
const d = drag.current; drag.current = null; setDx(0);
if (!d) return;
// pointercancel (OS interrupt, multi-touch) reports clientX=0 — never treat it as a
// gesture (a 0-x would read as a big left-swipe and spuriously mark-done). Just snap back.
if (e && e.type === 'pointercancel') return;
if (!d.moved) { onTap(); return; }
// non-swipeable rows (done/cancelled) have no swipe action — recover a stray drag as a
// tap so the edit sheet (the only way to reopen them) stays reachable.
if (!canSwipe) { onTap(); return; }
const ddx = e && typeof e.clientX === 'number' ? e.clientX - d.x : 0;
if (ddx <= -70) onDone();
else if (ddx >= 70) onSnooze();
};
const dueDelta = reminderDueDelta(r.due_date);
const dueColor = (r.status === 'open' || r.status === 'snoozed')
? (dueDelta != null && dueDelta < 0 ? '#e06c6c' : dueDelta != null && dueDelta <= 7 ? '#e0b341' : 'var(--text-subtle)')
: 'var(--text-subtle)';
return (
<div className="reminder-row">
{dx < 0 && <div className="reminder-action done">Done ✓</div>}
{dx > 0 && <div className="reminder-action snooze">⏰ Snooze +7d</div>}
<div
className="reminder-fg" role="button" tabIndex={0}
style={dx ? { transform: `translateX(${dx}px)`, transition: 'none' } : undefined}
onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={end} onPointerCancel={end}
>
<div className="reminder-fg-head">
<span className="reminder-title">{r.title}</span>
{r.status !== 'open' && <span className="reminder-status-tag" style={{ color: REMINDER_STATUS_COLOR[r.status] || 'var(--text-subtle)' }}>{r.status}</span>}
</div>
<div className="reminder-meta">
{r.investor_name ? <span>{r.investor_name} · </span> : null}
<span className="reminder-due" style={{ color: dueColor }}>{formatDueShort(r.due_date)}</span>
{r.assignee_name ? <span> · {r.assignee_name}</span> : null}
</div>
{r.details ? <div className="reminder-details">{r.details}</div> : null}
</div>
</div>
);
};
// Mobile Reminders: an urgency-grouped tickler over /api/reminders. Reuses the desktop
// endpoints + snooze semantics (snooze = keep status open, push due_date +7d, since the
// 'snoozed' status has no wake mechanism — see DesktopRemindersPage). Investor linkage is
// grid-only (the create field is a free-text label; PATCH can't change it), matching desktop.
const MobileReminders = ({ token, user, onShowToast }) => {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [statusFilter, setStatusFilter] = useState('active'); // 'active' | 'done' | 'all'
const [users, setUsers] = useState([]);
const [editing, setEditing] = useState(null); // null | reminder | { create:true }
const [form, setForm] = useState({ title: '', due_date: '', details: '', investor_name: '', assignee_id: '', status: 'open' });
const [busy, setBusy] = useState(false);
const load = useCallback(async (silent) => {
if (!silent) setLoading(true);
try {
const params = new URLSearchParams();
if (statusFilter === 'active') params.set('status', 'active');
else if (statusFilter === 'done') params.set('status', 'done');
const res = await api(`/api/reminders?${params.toString()}`, {}, token);
setItems(Array.isArray(res?.data) ? res.data : []);
setError('');
} catch (err) {
setError(getErrorMessage(err, 'Failed to load reminders'));
} finally {
if (!silent) setLoading(false);
}
}, [token, statusFilter]);
useEffect(() => { load(); }, [load]);
useEffect(() => {
let cancelled = false;
(async () => {
try { const r = await api('/api/users', {}, token); if (!cancelled) setUsers(Array.isArray(r?.data) ? r.data : []); }
catch (_) { /* assignee dropdown is optional (members may lack /api/users) */ }
})();
return () => { cancelled = true; };
}, [token]);
const groups = useMemo(() => {
const out = {};
REMINDER_BUCKETS.forEach((b) => { out[b.key] = []; });
items.forEach((r) => { const k = reminderBucket(r); (out[k] || out.later).push(r); });
return out;
}, [items]);
const markDone = async (r) => {
setItems((xs) => xs.filter((x) => x.id !== r.id)); // optimistic: drop from the active list
try {
await api(`/api/reminders/${r.id}`, { method: 'PATCH', body: JSON.stringify({ status: 'done' }) }, token);
onShowToast('Marked done', 'success');
await load(true);
} catch (err) { onShowToast(getErrorMessage(err, 'Update failed'), 'error'); await load(true); }
};
const snooze = async (r) => {
const d = new Date(Date.now() + 7 * 864e5).toISOString().slice(0, 10);
try {
await api(`/api/reminders/${r.id}`, { method: 'PATCH', body: JSON.stringify({ status: 'open', due_date: d }) }, token);
onShowToast('Snoozed 7 days', 'success');
await load(true);
} catch (err) { onShowToast(getErrorMessage(err, 'Update failed'), 'error'); await load(true); }
};
const openCreate = () => {
setForm({ title: '', due_date: '', details: '', investor_name: '', assignee_id: '', status: 'open' });
setEditing({ create: true });
};
const openEdit = (r) => {
setForm({
title: r.title || '', due_date: (r.due_date || '').slice(0, 10), details: r.details || '',
investor_name: r.investor_name || '', assignee_id: r.assignee_id || '', status: r.status || 'open',
});
setEditing(r);
};
const closeSheet = () => setEditing(null);
const submit = async () => {
const title = (form.title || '').trim();
if (!title) { onShowToast('A reminder needs a title', 'error'); return; }
setBusy(true);
try {
if (editing && editing.create) {
await api('/api/reminders', { method: 'POST', body: JSON.stringify({
title, due_date: form.due_date || '', details: form.details || '',
investor_name: form.investor_name || '', assignee_id: form.assignee_id || '',
}) }, token);
onShowToast('Reminder created', 'success');
} else {
await api(`/api/reminders/${editing.id}`, { method: 'PATCH', body: JSON.stringify({
title, due_date: form.due_date || '', details: form.details || '',
status: form.status, assignee_id: form.assignee_id || '',
}) }, token);
onShowToast('Reminder updated', 'success');
}
closeSheet();
await load(true);
} catch (err) { onShowToast(getErrorMessage(err, 'Save failed'), 'error'); }
finally { setBusy(false); }
};
const removeReminder = async () => {
if (!editing || editing.create) return;
if (!window.confirm('Delete this reminder?')) return;
setBusy(true);
try {
await api(`/api/reminders/${editing.id}`, { method: 'DELETE' }, token);
onShowToast('Reminder deleted', 'success');
closeSheet();
await load(true);
} catch (err) { onShowToast(getErrorMessage(err, 'Delete failed'), 'error'); }
finally { setBusy(false); }
};
const isCreate = !!(editing && editing.create);
return (
<div className="mobile-screen">
<div className="mobile-toolbar">
<div className="grid-toolbar-row">
<div className="mobile-seg" style={{ flex: 1 }}>
{[['active', 'Active'], ['done', 'Done'], ['all', 'All']].map(([k, label]) => (
<button key={k} className={`mobile-seg-tab ${statusFilter === k ? 'active' : ''}`} onClick={() => setStatusFilter(k)}>{label}</button>
))}
</div>
<button className="grid-new-btn" onClick={openCreate}>+ New</button>
</div>
</div>
{loading ? (
<SkeletonBlock lines={7} />
) : error ? (
<div className="empty-state">{error}</div>
) : items.length === 0 ? (
<div className="empty-state">{statusFilter === 'done' ? 'No completed reminders' : 'No reminders — tap “+ New” to add one.'}</div>
) : (
REMINDER_BUCKETS.filter((b) => groups[b.key].length).map((b) => (
<div key={b.key}>
<div className="reminder-group-header" style={{ color: b.key === 'overdue' ? '#e06c6c' : b.key === 'soon' ? '#e0b341' : 'var(--text-subtle)' }}>
{b.label}<span className="reminder-group-count">{groups[b.key].length}</span>
</div>
{groups[b.key].map((r) => (
<ReminderRow
key={r.id} r={r}
canSwipe={r.status === 'open' || r.status === 'snoozed'}
onTap={() => openEdit(r)}
onDone={() => markDone(r)}
onSnooze={() => snooze(r)}
/>
))}
</div>
))
)}
<BottomSheet open={!!editing} onClose={closeSheet} title={isCreate ? 'New reminder' : 'Edit reminder'}>
<div className="sheet-field">
<label className="sheet-field-label">Title</label>
<input className="sheet-input" value={form.title} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))} placeholder="What needs doing?" />
</div>
<div className="sheet-field">
<label className="sheet-field-label">Due date</label>
<input className="sheet-input" type="date" value={form.due_date} onChange={(e) => setForm((f) => ({ ...f, due_date: e.target.value }))} />
</div>
{isCreate ? (
<div className="sheet-field">
<label className="sheet-field-label">Investor (optional)</label>
<input className="sheet-input" value={form.investor_name} onChange={(e) => setForm((f) => ({ ...f, investor_name: e.target.value }))} placeholder="Name label, or blank for a team task" />
</div>
) : (form.investor_name ? (
<div className="fs-row"><span className="fs-row-label">Investor</span><span className="fs-row-value">{form.investor_name}</span></div>
) : null)}
{users.length > 0 && (
<div className="sheet-field">
<label className="sheet-field-label">Assignee</label>
<select className="sheet-select" value={form.assignee_id} onChange={(e) => setForm((f) => ({ ...f, assignee_id: e.target.value }))}>
<option value="">Unassigned (team)</option>
{users.map((u) => <option key={u.id} value={u.id}>{u.full_name || u.username}</option>)}
</select>
</div>
)}
{!isCreate && (
<div className="sheet-field">
<label className="sheet-field-label">Status</label>
<select className="sheet-select" value={form.status} onChange={(e) => setForm((f) => ({ ...f, status: e.target.value }))}>
<option value="open">Open</option>
<option value="snoozed">Snoozed</option>
<option value="done">Done</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
)}
<div className="sheet-field">
<label className="sheet-field-label">Details (optional)</label>
<textarea className="sheet-textarea" value={form.details} onChange={(e) => setForm((f) => ({ ...f, details: e.target.value }))} />
</div>
<button className="sheet-submit" onClick={submit} disabled={busy}>{busy ? 'Saving…' : (isCreate ? 'Create reminder' : 'Save')}</button>
{!isCreate && <button className="sheet-remove" onClick={removeReminder} disabled={busy}>Delete reminder</button>}
</BottomSheet>
</div>
);
};
// Switch by viewport (rules-of-hooks-safe — only useIsMobile() runs here).
const RemindersPage = (props) => {
const isMobile = useIsMobile();
return isMobile ? <MobileReminders {...props} /> : <DesktopRemindersPage {...props} />;
};
// Desktop Contacts surface (table + slide-over). Unchanged; rendered on >768px via the // Desktop Contacts surface (table + slide-over). Unchanged; rendered on >768px via the
// ContactsPage switch below. Mobile (<768px) renders MobileContactsPage instead. // ContactsPage switch below. Mobile (<768px) renders MobileContactsPage instead.
const DesktopContactsPage = ({ token, onShowToast }) => { const DesktopContactsPage = ({ token, onShowToast }) => {
@@ -5004,7 +5400,7 @@
); );
}; };
const PipelinePage = ({ token, onShowToast }) => { const DesktopPipelinePage = ({ token, onShowToast }) => {
const [opportunities, setOpportunities] = useState([]); const [opportunities, setOpportunities] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedOpp, setSelectedOpp] = useState(null); const [selectedOpp, setSelectedOpp] = useState(null);
@@ -5266,6 +5662,223 @@
); );
}; };
// Phase 4 — mobile Pipeline (BRIEF §3c): swipe between full-width stage columns
// (CSS scroll-snap) with a count-forward segmented control + page dots, per-card /
// stage move, and tap → opp detail with a stage-picker sheet. Operates on the same
// `opportunities` rows + PATCH /api/opportunities/{id}/stage as the desktop board and the
// grid detail's stage edit — opp-centric (matches DesktopPipelinePage), read-only amounts.
// Removal/deletion stays on the desktop board + the Grid detail's "remove from pipeline";
// the Pipeline tab is view + advance-stage only.
const MobilePipeline = ({ token, onShowToast }) => {
const [opportunities, setOpportunities] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [activeStage, setActiveStage] = useState(0);
const [selectedId, setSelectedId] = useState(null);
const [sheetOpen, setSheetOpen] = useState(false);
const [busy, setBusy] = useState(false);
const swipeRef = useRef(null);
const stages = PIPELINE_STAGES;
useEffect(() => {
let cancelled = false;
(async () => {
try {
setLoading(true);
const result = await api('/api/opportunities?limit=1000', {}, token);
if (!cancelled) { setOpportunities(result.data || []); setError(''); }
} catch (err) {
if (!cancelled) setError(getErrorMessage(err, 'Failed to load pipeline'));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [token]);
const byStage = useMemo(() => {
const out = {};
stages.forEach((s) => { out[s] = []; });
opportunities.forEach((o) => { if (out[o.stage]) out[o.stage].push(o); });
return out;
}, [opportunities]);
const stageTotals = useMemo(() => stages.map((s) =>
byStage[s].reduce((sum, o) => sum + (Number(o.expected_amount) || 0), 0)), [byStage]);
const selectedOpp = useMemo(() => opportunities.find((o) => o.id === selectedId) || null, [opportunities, selectedId]);
const patchStage = async (oppId, stage) => {
setBusy(true);
try {
await api(`/api/opportunities/${oppId}/stage`, { method: 'PATCH', body: JSON.stringify({ stage }) }, token);
setOpportunities((os) => os.map((o) => (o.id === oppId ? { ...o, stage } : o)));
onShowToast('Stage updated', 'success');
return true;
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to update stage'), 'error');
return false;
} finally { setBusy(false); }
};
// / on a card: advance/retreat one stage (kanban move without opening the detail).
const moveStage = async (opp, dir) => {
if (busy) return;
const next = stages.indexOf(opp.stage) + dir;
if (next < 0 || next >= stages.length) return;
await patchStage(opp.id, stages[next]);
};
const goToStage = (i) => {
setActiveStage(i);
const el = swipeRef.current;
if (el && el.clientWidth) {
try { el.scrollTo({ left: i * el.clientWidth, behavior: 'smooth' }); }
catch (_) { el.scrollLeft = i * el.clientWidth; }
}
};
const onSwipeScroll = () => {
const el = swipeRef.current;
if (!el || !el.clientWidth) return;
const i = Math.round(el.scrollLeft / el.clientWidth);
if (i !== activeStage && i >= 0 && i < stages.length) setActiveStage(i);
};
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(' · ');
return (
<div className="pipeline-card" key={opp.id}>
<button className="pipeline-card-tap" onClick={() => setSelectedId(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>
</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>
</div>
</div>
);
};
return (
<div className="mobile-screen">
{loading ? (
<SkeletonBlock lines={8} />
) : error ? (
<div className="empty-state">{error}</div>
) : opportunities.length === 0 ? (
<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="pipeline-seg" role="tablist" aria-label="Pipeline stages">
{stages.map((s, i) => (
<button
key={s}
role="tab"
aria-selected={i === activeStage}
className={`pipeline-seg-tab ${i === activeStage ? 'active' : ''}`}
onClick={() => goToStage(i)}
>
<span className="pipeline-seg-count">{byStage[s].length}</span>
<span className="pipeline-seg-label">{pipelineStageLabel(s)}</span>
</button>
))}
</div>
<div className="pipeline-swipe" ref={swipeRef} onScroll={onSwipeScroll}>
{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>
</div>
{byStage[s].length === 0
? <div className="empty-state" style={{ padding: '24px 0' }}>No deals in this stage</div>
: byStage[s].map(renderCard)}
</section>
))}
</div>
<div className="pipeline-dots" aria-hidden="true">
{stages.map((s, i) => <span key={s} className={`pipeline-dot ${i === activeStage ? 'active' : ''}`} />)}
</div>
</>
)}
{selectedOpp && (() => {
const opp = selectedOpp;
const prob = (Number(opp.probability) || 0) > 1
? `${opp.probability}%`
: `${Math.round((Number(opp.probability) || 0) * 100)}%`;
return (
<div className="fs-detail" role="dialog" aria-modal="true">
<div className="fs-detail-header">
<button className="fs-detail-back" onClick={() => { setSelectedId(null); setSheetOpen(false); }}> Pipeline</button>
</div>
<div className="fs-detail-body">
<div className="fs-detail-id">
<span style={{ minWidth: 0, flex: 1 }}>
<div className="fs-detail-title">{opp.name}</div>
<div className="fs-detail-subtitle">{formatCurrencyLong(opp.expected_amount)} expected · {prob}</div>
</span>
{opp.priority === 'high' && <span className="badge" style={{ background: '#fcd34d22', color: '#fcd34d' }}>Priority</span>}
</div>
<div className="fs-section">
<div className="fs-section-label">Pipeline</div>
<div className="fs-row">
<span className="fs-row-label">Stage</span>
<span className="fs-row-value"><StageChip stage={opp.stage} /></span>
</div>
<div className="fs-action-row" style={{ marginTop: '10px' }}>
<button className="fs-action-btn" onClick={() => setSheetOpen(true)}>Change stage</button>
</div>
</div>
<div className="fs-section">
<div className="fs-section-label">Deal</div>
<MobileDetailRow label="Contact" value={contactName(opp) === '-' ? '' : contactName(opp)} />
<MobileDetailRow label="Organization" value={opp.organization_name} />
<MobileDetailRow label="Expected amount" value={formatCurrencyLong(opp.expected_amount)} mono />
<MobileDetailRow label="Probability" value={prob} mono />
<MobileDetailRow label="Fund" value={opp.fund_name} />
<MobileDetailRow label="Expected close" value={formatDateLong(opp.expected_close_date)} mono />
<MobileDetailRow label="Owner" value={opp.owner_name} />
<div style={{ fontSize: '12px', color: 'var(--text-subtle)', marginTop: '8px' }}>Amounts are read-only on mobile — edit on desktop.</div>
</div>
</div>
<BottomSheet open={sheetOpen} onClose={() => setSheetOpen(false)} title="Pipeline stage">
{stages.map((st) => (
<button
key={st}
className={`sheet-option ${opp.stage === st ? 'active' : ''}`}
disabled={busy}
onClick={async () => { if (await patchStage(opp.id, st)) setSheetOpen(false); }}
>
<span>{pipelineStageLabel(st)}</span>
{opp.stage === st && <span className="sheet-option-check"></span>}
</button>
))}
</BottomSheet>
</div>
);
})()}
</div>
);
};
// Switch by viewport (rules-of-hooks-safe — only useIsMobile() runs here; the surfaces
// mount/unmount on a breakpoint cross, each owning its own hooks).
const PipelinePage = (props) => {
const isMobile = useIsMobile();
return isMobile ? <MobilePipeline {...props} /> : <DesktopPipelinePage {...props} />;
};
const CommunicationsPage = ({ token, user, onShowToast }) => { const CommunicationsPage = ({ token, user, onShowToast }) => {
// Repurposed (v0.1.0:80): the Communications tab is now the admin-only // Repurposed (v0.1.0:80): the Communications tab is now the admin-only
// email-activity panel over the captured email_* tables. The classic // email-activity panel over the captured email_* tables. The classic