diff --git a/AGENTS.md b/AGENTS.md index ebf8278..aedef26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,11 +107,12 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude ## Current state -_**Box live at v0.1.0:94**; `main` ahead by mobile Phases 0–2 + 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** — P0–P3a 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 0–2 + 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 (P0–P5)**, **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 ``/`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 A–Z 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). +- **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 ``/`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 (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 A–Z 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. - **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 + ``/`StageChip`); 2) **P5 Reminders → P6 light theme**; 3) **P3b** name/pill edit; 4) **deploy** P0–P3a + 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** P0–P5 + 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. diff --git a/ROADMAP.md b/ROADMAP.md index 9f12700..efb963b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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, +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. -- **Phase 4 — Pipeline.** ~7 inline styles. Swipe-between-stages (snap-scroll + segmented control + - dots), per-card stage move sharing the Grid detail's opportunities endpoints. -- **Phase 5 — Reminders.** ~18 inline styles. Urgency-grouped list, swipe complete/snooze, add/edit - sheets on `/api/reminders`. +- **Phase 4 — Pipeline — BUILT 2026-06-19 (deploy pending).** Lean **`MobilePipeline`** (separate + component; `PipelinePage` is now a `useIsMobile()` wrapper → `Desktop`/`Mobile`, desktop kanban + untouched, just renamed `DesktopPipelinePage`). **Swipe-between-stages:** a count-forward segmented + 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 P1–P3a). 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 P1–P4). **Deploy:** + folds into the next s9pk. - **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 `[data-theme]` switch + a top-bar toggle; dark stays the default. Mechanical; co-lands after the diff --git a/frontend/index.html b/frontend/index.html index 1c71127..95eaffc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2225,6 +2225,93 @@ .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; } + /* ─── 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. */ .mobile-only { display: none; } @@ -4040,7 +4127,7 @@ ); }; - const RemindersPage = ({ token, onShowToast, user }) => { + const DesktopRemindersPage = ({ token, onShowToast, user }) => { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); 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 ( +
+ {dx < 0 &&
Done ✓
} + {dx > 0 &&
⏰ Snooze +7d
} +
+
+ {r.title} + {r.status !== 'open' && {r.status}} +
+
+ {r.investor_name ? {r.investor_name} · : null} + {formatDueShort(r.due_date)} + {r.assignee_name ? · {r.assignee_name} : null} +
+ {r.details ?
{r.details}
: null} +
+
+ ); + }; + + // 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 ( +
+
+
+
+ {[['active', 'Active'], ['done', 'Done'], ['all', 'All']].map(([k, label]) => ( + + ))} +
+ +
+
+ + {loading ? ( + + ) : error ? ( +
{error}
+ ) : items.length === 0 ? ( +
{statusFilter === 'done' ? 'No completed reminders' : 'No reminders — tap “+ New” to add one.'}
+ ) : ( + REMINDER_BUCKETS.filter((b) => groups[b.key].length).map((b) => ( +
+
+ {b.label}{groups[b.key].length} +
+ {groups[b.key].map((r) => ( + openEdit(r)} + onDone={() => markDone(r)} + onSnooze={() => snooze(r)} + /> + ))} +
+ )) + )} + + +
+ + setForm((f) => ({ ...f, title: e.target.value }))} placeholder="What needs doing?" /> +
+
+ + setForm((f) => ({ ...f, due_date: e.target.value }))} /> +
+ {isCreate ? ( +
+ + setForm((f) => ({ ...f, investor_name: e.target.value }))} placeholder="Name label, or blank for a team task" /> +
+ ) : (form.investor_name ? ( +
Investor{form.investor_name}
+ ) : null)} + {users.length > 0 && ( +
+ + +
+ )} + {!isCreate && ( +
+ + +
+ )} +
+ +