Mobile Phase 8e: Reminders due-chips, buckets, snooze sheet + investor picker

Re-author the mobile Reminders surface to the dc anatomy: title + urgency
summary line + gradient add (drop Active/Done/All tabs); four urgency buckets
(Overdue/Today/This week/Later) with colored dots; urgency-colored DueChip
pill (new --due-* theme vars, dark+light); collapsible Completed section
(done+cancelled, tap-check reopens). Load all statuses in one call, split
client-side.

Swipe-right opens a snooze preset sheet (+1/+3/+7/+14d) replacing the fixed
+7d. The add flow gains a stacked searchable investor picker over the
canonical grid that writes source_row_id -> a real server-resolved
investor_id link (replacing the free-text label that never linked; team-task
= no investor). Edit stays read-only for the investor (PATCH can't reassign).
This commit is contained in:
Keysat
2026-06-19 23:12:11 -05:00
parent 8e0f955342
commit 43c8048eab
2 changed files with 291 additions and 106 deletions
+4 -4
View File
@@ -107,11 +107,11 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
## Current state
_**Box live at v0.1.0:94**; `main` ahead by mobile Phases 07 + P3b + drag-reorder + **8a + 8b + 8c + 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/`._
_**Box live at v0.1.0:94**; `main` ahead by mobile Phases 07 + P3b + drag-reorder + **8a + 8b + 8c + 8d + 8e** — **all deploy-pending** (no s9pk built). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign → Phase 8**, building to `design/phase8-conformance.md` (the 8a8i spec, anchored on each `*.dc.html` DEFAULT `data-props` — NOT the `screenshots/` PNGs). **Plan (Grant, 2026-06-19): finish features first → then Grant device-tests + deploys** (nothing verified on a real phone). History: git log + `start9/0.4/startos/versions/`._
- **Mobile redesign — 4 core surfaces built (Grid · Contacts · Pipeline · Reminders), each a rules-of-hooks-safe `useIsMobile()``Mobile*`/`Desktop*` pair (desktop untouched).** Foundation: bottom-tab bar + `:root` mobile vars; 4-stage enum; derived grid signals injected-on-GET/stripped-on-write at both points; mobile writes use **one-row endpoints only** (log-communication, pipeline link/stage, reminders, `update-row`) — never whole-grid PUT.
- **Phase 8 done so far (8a8d):** cards re-authored (existing-LP **earmark**/avatar **ring**, PRIORITY/Existing-LP pills, 4-stage chip, recency; disposition badges dropped); **Contacts + Pipeline detail → drag-dismiss bottom sheets** (email-copy pill, Log/Email, Org card, stat tiles, inline move-stage, notes timeline); **Grid-detail notes timeline** + shared `LogCommunicationSheet`; **top-bar quick-log pencil** (`MobileQuickLog`, all tabs); **sort controls** on Grid/Pipeline/Contacts (shared `SortPill`/`SortSheet`; Contacts type-tabs dropped + Priority sort). Per-phase detail in git log + the Design convention's primitives list.
- **Phase 8 done so far (8a8e):** cards re-authored (existing-LP **earmark**/avatar **ring**, PRIORITY/Existing-LP pills, 4-stage chip, recency; disposition badges dropped); **Contacts + Pipeline detail → drag-dismiss bottom sheets** (email-copy pill, Log/Email, Org card, stat tiles, inline move-stage, notes timeline); **Grid-detail notes timeline** + shared `LogCommunicationSheet`; **top-bar quick-log pencil** (`MobileQuickLog`, all tabs); **sort controls** on Grid/Pipeline/Contacts (shared `SortPill`/`SortSheet`; Contacts type-tabs dropped + Priority sort); **8e Reminders re-bucketed** — Active/Done/All tabs dropped → title + urgency **summary line** + gradient add; **4 buckets** Overdue/Today/This-week/Later each with a colored **dot**; urgency-colored **`DueChip`** pill (`--due-*` theme vars) replaces plain due text; collapsible **Completed** section (done+cancelled, strikethrough, tap-check reopens); card now note + org + due-chip (assignee dropped from card, still in edit sheet); **swipe-right → snooze preset sheet** (+1/+3/+7/+14d, dc-style) replacing the old fixed +7d; **add-flow investor picker** — a stacked searchable sheet over the canonical grid investors that writes **`source_row_id` → a real server-resolved `investor_id` link** (replaces the old free-text label that never actually linked; "team task" = no investor). Per-phase detail in git log + the Design convention's primitives list.
- **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` for the 8c timeline filter, +priority assertions in `test_contacts_grid_signals.py`), `py_compile` clean; 8c+8d surfaces interaction-verified via throwaway 375px jsdom harnesses (deleted after).
- **Next — Phase 8, in order, build to `design/phase8-conformance.md`:** **8e** reminders (due-chip + Overdue/Today/This-week/Later buckets + dots + snooze sheet + investor picker) → **8f** Pipeline card (earmark/Priority/recency + horizontal-scroll stage pills + dots) → **8g** add-investor stage+priority → **8h** loose ends (incl. Grid detail G4/G5/G6 stage-card/reminder-card/timeline; "Open-in-Grid" deep-link-to-investor) → **8i** shell SVG icons + `·Ten31·` wordmark. **Skip Pipeline accordion** (Grant). **Then (after feature-complete):** deploy P0P8 + P3b in one s9pk (**authorize + version-bump first**) and device-test light/dark on a phone.
- **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+8e surfaces interaction-verified via throwaway 375px jsdom harnesses (deleted after). 8e was frontend-only — no backend touched, so the 39/39 count is unchanged (the reminders read path was untouched: the list endpoint with no `status` param already returns all statuses, which 8e now splits client-side).
- **Next — Phase 8, in order, build to `design/phase8-conformance.md`:** **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.
+275 -90
View File
@@ -93,6 +93,11 @@
/* reminder status badges (open/snoozed/done/cancelled) — light values derived,
since the comp models reminder URGENCY rather than these four statuses */
--rem-open: #7fb0d3; --rem-snoozed: #b08fd3; --rem-done: #7fd3a3; --rem-cancelled: #70859b;
/* reminder due-chips — bg / text / border, by urgency bucket (comp RemindersApp urgency, :228-240) */
--due-overdue-bg: #f8717118; --due-overdue-text: #f87171; --due-overdue-border: #f8717140;
--due-today-bg: #e0b3411f; --due-today-text: #e0b341; --due-today-border: #e0b3413d;
--due-week-bg: #3b82c422; --due-week-text: #93c5fd; --due-week-border: #3b82c44d;
--due-later-bg: #1b2a3a; --due-later-text: #8ea2b7; --due-later-border: #263548;
/* --- Phase 7 (2026-06-19): leftover literals that didn't flip in P6 (DESIGN §8). --- */
--nav-bg: rgba(17, 26, 39, 0.92); /* mobile bottom-tab-bar */
--panel-grad-end: #101926; /* sidebar / header gradient bottom stop */
@@ -159,6 +164,10 @@
--chip-commitment-bg: #10b98118; --chip-commitment-text: #057a55; --chip-commitment-border: #a9ddca;
--chip-default-bg: #5a6b7d12; --chip-default-text: #84909e; --chip-default-border: #d6dde7;
--rem-open: #2266a0; --rem-snoozed: #7a4fa8; --rem-done: #057a55; --rem-cancelled: #84909e;
--due-overdue-bg: #c0322f14; --due-overdue-text: #c0322f; --due-overdue-border: #e3b4b2;
--due-today-bg: #e0b34122; --due-today-text: #8a6c12; --due-today-border: #e4d29a;
--due-week-bg: #3b82c416; --due-week-text: #1f6fb8; --due-week-border: #bcd2ea;
--due-later-bg: #5a6b7d12; --due-later-text: #5a6b7d; --due-later-border: #d6dde7;
/* Phase 7 light overrides (accent-grad-end is theme-stable → dark only). */
--nav-bg: #ffffffd9;
--panel-grad-end: #f4f7fb;
@@ -2623,16 +2632,27 @@
.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). */
/* ─── Reminders mobile surface (8e: urgency-bucketed list + due-chips + swipe actions) ───
JS-gated to MobileReminders. Header = title + summary line + gradient add (dc :56-62);
four buckets Overdue/Today/This week/Later, each with a colored dot (dc :317-318); each
row is a pointer-drag swipe (left reveals Complete, right reveals Snooze, threshold 70px,
tap opens the edit sheet); terminal items live in a collapsible Completed section. */
.rem-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
.rem-header-titles { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.rem-title { font-size: var(--mobile-font-screen-title); font-weight: 600; letter-spacing: -0.01em; color: var(--text-primary); }
.rem-summary { font-family: 'IBM Plex Mono', monospace; font-size: 12px; } /* color set inline by urgency */
.rem-add-btn {
flex: none; width: 44px; height: 44px; border-radius: 10px; border: none;
background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%);
color: #fff; font-size: 22px; font-weight: 500; line-height: 1; cursor: pointer;
}
.reminder-group-header {
display: flex; align-items: baseline; gap: 8px; margin: 16px 2px 8px;
display: flex; align-items: center; gap: 8px; margin: 16px 2px 8px; color: var(--text-subtle);
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-dot { flex: none; width: 7px; height: 7px; border-radius: 999px; }
.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 {
@@ -2642,18 +2662,72 @@
.reminder-action.done { justify-content: flex-end; background: rgba(16,185,129,0.16); color: var(--money); }
.reminder-action.snooze { justify-content: flex-start; background: rgba(224,179,65,0.14); color: var(--due-soon); }
.reminder-fg {
position: relative; z-index: 1; display: block; width: 100%; text-align: left; color: inherit;
position: relative; z-index: 1; display: flex; flex-direction: column; gap: 5px;
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;
border-radius: var(--mobile-card-radius); padding: 13px 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; }
.reminder-title { font-size: var(--mobile-font-body); font-weight: 500; line-height: 1.3; color: var(--text-primary); }
.reminder-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.reminder-org { font-size: 12px; color: var(--text-muted); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Urgency-colored due-chip pill (dc RemindersApp:91, urgency :228-240). */
.due-chip {
flex: none; font-family: 'IBM Plex Mono', monospace; font-size: 10px; font-weight: 600;
letter-spacing: 0.04em; text-transform: uppercase; padding: 2px 8px; border-radius: 999px;
border: 1px solid transparent;
}
.due-chip--overdue { background: var(--due-overdue-bg); color: var(--due-overdue-text); border-color: var(--due-overdue-border); }
.due-chip--today { background: var(--due-today-bg); color: var(--due-today-text); border-color: var(--due-today-border); }
.due-chip--week { background: var(--due-week-bg); color: var(--due-week-text); border-color: var(--due-week-border); }
.due-chip--later { background: var(--due-later-bg); color: var(--due-later-text); border-color: var(--due-later-border); }
.reminder-details { font-size: 12px; color: var(--text-secondary); white-space: pre-wrap; line-height: 1.45; }
/* Collapsible Completed section at list end (dc :109-130). Done/cancelled rows, dimmed. */
.rem-completed-toggle {
width: 100%; background: none; border: none; cursor: pointer; margin-top: 8px;
display: flex; align-items: center; gap: 8px; padding: 0 2px 9px; color: var(--text-subtle);
font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
}
.rem-completed-caret { flex: none; width: 12px; font-size: 12px; transition: transform 0.15s ease; }
.rem-completed-caret.open { transform: rotate(90deg); }
.rem-done-row {
display: flex; align-items: center; gap: 12px; opacity: 0.6;
background: var(--bg-panel); border: 1px solid var(--border);
border-radius: var(--mobile-card-radius); padding: 12px 13px; margin-bottom: var(--mobile-card-gap);
}
.rem-done-check {
flex: none; width: 24px; height: 24px; border-radius: 999px; cursor: pointer; line-height: 1;
border: 2px solid var(--accent); background: var(--accent); color: #fff;
display: flex; align-items: center; justify-content: center; font-size: 12px;
}
.rem-done-check.cancelled { background: transparent; border-color: var(--danger-soft); color: var(--danger-soft); }
.rem-done-body { flex: 1; min-width: 0; text-align: left; background: none; border: none; cursor: pointer; display: flex; flex-direction: column; gap: 4px; color: inherit; }
.rem-done-note { font-size: var(--mobile-font-body); font-weight: 500; line-height: 1.3; text-decoration: line-through; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rem-done-org { font-size: 12px; color: var(--text-subtle); }
/* Add-flow investor picker field (tap → stacked picker sheet, dc :416-428). Styled like an input. */
.rem-investor-pick {
width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 8px;
height: var(--mobile-input-h); padding: 0 12px; cursor: pointer; text-align: left;
background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius);
color: var(--text-primary); font-size: var(--mobile-font-body); font-family: inherit;
}
.rem-investor-pick > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rem-investor-pick-empty { color: var(--text-subtle); }
.rem-investor-pick-caret { flex: none; color: var(--text-muted); }
.rem-investor-list { margin-top: 12px; max-height: 46vh; overflow-y: auto; }
.rem-investor-hint { font-size: 13px; color: var(--text-subtle); padding: 16px 4px; }
/* Snooze preset rows (dc :408-412): label left, resolved date right. */
.snooze-list { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
.snooze-row {
width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 10px;
min-height: 50px; padding: 0 16px; cursor: pointer; font-family: inherit;
background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius);
color: var(--text-primary); font-size: var(--mobile-font-body); font-weight: 500;
}
.snooze-row:active { background: var(--bg-hover); }
.snooze-date { font-family: 'IBM Plex Mono', monospace; font-size: 13px; color: var(--text-subtle); }
/* Visibility utilities — base = desktop; flipped under the breakpoint. */
.mobile-only { display: none; }
@@ -4837,39 +4911,55 @@
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';
// Short "Jun 24" month-day for a YYYY-MM-DD string (chip 'later' label + snooze/toast dates).
const reminderMonthDay = (iso) => {
if (!iso) return '';
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}`;
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
};
// Urgency bucket: done/cancelled collapse to 'done'; open/snoozed split by due-date delta.
// Due-chip: urgency bucket + short label for the card pill (dc RemindersApp urgency, :228-240).
// key drives the .due-chip--{key} color; text is "3d overdue" / "Today" / "Tomorrow" / "in 4d" / "Jun 24".
const reminderDueChip = (iso) => {
const d = reminderDueDelta(iso);
if (d == null) return { key: 'later', text: 'No date' };
if (d < 0) return { key: 'overdue', text: `${-d}d overdue` };
if (d === 0) return { key: 'today', text: 'Today' };
if (d === 1) return { key: 'week', text: 'Tomorrow' };
if (d <= 7) return { key: 'week', text: `in ${d}d` };
return { key: 'later', text: reminderMonthDay(iso) };
};
const DueChip = ({ iso }) => {
const { key, text } = reminderDueChip(iso);
return <span className={`due-chip due-chip--${key}`}>{text}</span>;
};
// Snooze presets relative to today (dc snoozePresets, :371-373) → [label, YYYY-MM-DD].
const reminderSnoozePresets = () => {
const mk = (days) => { const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + days); return d.toISOString().slice(0, 10); };
return [['Tomorrow', mk(1)], ['In 3 days', mk(3)], ['1 week', mk(7)], ['2 weeks', mk(14)]];
};
// Active-reminder bucket (Overdue/Today/This week/Later, dc :318) — split by due-date delta.
// Terminal items (done/cancelled) are filtered out before bucketing and rendered separately.
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';
if (delta === 0) return 'today';
if (delta <= 7) return 'week';
return 'later';
};
// Bucket dots (dc :317): red / amber / accent-blue / grey. Theme-bound so they flip in light.
const REMINDER_BUCKETS = [
{ key: 'overdue', label: 'Overdue' },
{ key: 'soon', label: 'Due soon' },
{ key: 'later', label: 'Later' },
{ key: 'done', label: 'Done' },
{ key: 'cancelled', label: 'Cancelled' },
{ key: 'overdue', label: 'Overdue', dot: 'var(--due-overdue-text)' },
{ key: 'today', label: 'Today', dot: 'var(--due-today-text)' },
{ key: 'week', label: 'This week', dot: 'var(--accent)' },
{ key: 'later', label: 'Later', dot: 'var(--text-subtle)' },
];
const REMINDER_STATUS_COLOR = { open: 'var(--rem-open)', snoozed: 'var(--rem-snoozed)', done: 'var(--rem-done)', cancelled: 'var(--rem-cancelled)' };
// 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 ReminderRow = ({ r, onTap, onDone, onSnooze }) => {
const [dx, setDx] = useState(0);
const drag = useRef(null);
const onPointerDown = (e) => {
@@ -4885,7 +4975,7 @@
d.active = true;
}
d.moved = true;
if (canSwipe) setDx(Math.max(-120, Math.min(120, ddx)));
setDx(Math.max(-120, Math.min(120, ddx)));
};
const end = (e) => {
const d = drag.current; drag.current = null; setDx(0);
@@ -4894,34 +4984,23 @@
// 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 ? 'var(--danger-soft)' : dueDelta != null && dueDelta <= 7 ? 'var(--due-soon)' : '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>}
{dx < 0 && <div className="reminder-action done">Complete ✓</div>}
{dx > 0 && <div className="reminder-action snooze">⏰ Snooze</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}
{r.investor_name ? <span className="reminder-org">{r.investor_name}</span> : null}
<DueChip iso={r.due_date} />
</div>
{r.details ? <div className="reminder-details">{r.details}</div> : null}
</div>
@@ -4929,27 +5008,31 @@
);
};
// 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.
// Mobile Reminders: an urgency-bucketed tickler over /api/reminders. Snooze keeps status
// 'open' and just pushes due_date (the 'snoozed' status has no wake mechanism — see
// DesktopRemindersPage); the snooze preset sheet offers +1/+3/+7/+14d. Create links a real
// investor via the canonical grid picker (source_row_id → server-resolved investor_id);
// PATCH still can't reassign the investor, so the edit sheet shows it read-only.
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 [completedOpen, setCompletedOpen] = useState(false);
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 [form, setForm] = useState({ title: '', due_date: '', details: '', investor_name: '', investor_source_row_id: '', assignee_id: '', status: 'open' });
const [busy, setBusy] = useState(false);
const [snoozing, setSnoozing] = useState(null); // reminder being snoozed → preset sheet
const [investorPicker, setInvestorPicker] = useState(false);
const [investorQuery, setInvestorQuery] = useState('');
const [investors, setInvestors] = useState(null); // null = not loaded yet; [] = loaded/empty
// Load every reminder (no status param → all statuses) and split client-side: active
// (open/snoozed) into the four urgency buckets, terminal (done/cancelled) into Completed.
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);
const res = await api('/api/reminders', {}, token);
setItems(Array.isArray(res?.data) ? res.data : []);
setError('');
} catch (err) {
@@ -4957,7 +5040,7 @@
} finally {
if (!silent) setLoading(false);
}
}, [token, statusFilter]);
}, [token]);
useEffect(() => { load(); }, [load]);
useEffect(() => {
@@ -4969,42 +5052,76 @@
return () => { cancelled = true; };
}, [token]);
const active = useMemo(() => items.filter((r) => r.status === 'open' || r.status === 'snoozed'), [items]);
const completed = useMemo(() => items.filter((r) => r.status === 'done' || r.status === 'cancelled'), [items]);
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); });
active.forEach((r) => { const k = reminderBucket(r); (out[k] || out.later).push(r); });
return out;
}, [items]);
}, [active]);
const { summary, summaryColor } = useMemo(() => {
if (active.length === 0) return { summary: 'All clear', summaryColor: 'var(--money)' };
const overdue = active.filter((r) => { const d = reminderDueDelta(r.due_date); return d != null && d < 0; }).length;
const today = active.filter((r) => reminderDueDelta(r.due_date) === 0).length;
return {
summary: `${overdue} overdue · ${today} today · ${active.length} open`,
summaryColor: overdue ? 'var(--danger-soft)' : 'var(--text-subtle)',
};
}, [active]);
const markDone = async (r) => {
setItems((xs) => xs.filter((x) => x.id !== r.id)); // optimistic: drop from the active list
// Optimistically move the row to its new status (so it leaves the active list / lands in
// Completed immediately), then PATCH and reconcile with a silent reload.
const setStatus = async (r, patch, toastMsg) => {
setItems((xs) => xs.map((x) => (x.id === r.id ? { ...x, ...patch } : x)));
try {
await api(`/api/reminders/${r.id}`, { method: 'PATCH', body: JSON.stringify({ status: 'done' }) }, token);
onShowToast('Marked done', 'success');
await api(`/api/reminders/${r.id}`, { method: 'PATCH', body: JSON.stringify(patch) }, token);
onShowToast(toastMsg, '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);
const markDone = (r) => setStatus(r, { status: 'done' }, 'Marked done');
const reopen = (r) => setStatus(r, { status: 'open' }, 'Reopened');
const snooze = (r) => setSnoozing(r); // swipe-right → preset sheet (dc :286)
const snoozeTo = (r, iso) => { setSnoozing(null); setStatus(r, { status: 'open', due_date: iso }, 'Snoozed to ' + reminderMonthDay(iso)); };
// Investor picker (add flow): list the canonical grid investors (row id = source_row_id,
// resolved server-side to a real investor_id on create). Loaded once, lazily, on first open.
const ensureInvestors = useCallback(async () => {
if (investors) return;
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 res = await api('/api/fundraising/state', {}, token);
const grid = (res && res.data && res.data.grid) || {};
const list = (Array.isArray(grid.rows) ? grid.rows : [])
.filter((row) => row && row.id && String(row.investor_name || '').trim())
.map((row) => ({ id: row.id, name: String(row.investor_name).trim() }))
.sort((a, b) => a.name.localeCompare(b.name));
setInvestors(list);
} catch (_) { setInvestors([]); }
}, [investors, token]);
const openInvestorPicker = () => { setInvestorQuery(''); setInvestorPicker(true); ensureInvestors(); };
const pickInvestor = (inv) => {
setForm((f) => ({ ...f, investor_source_row_id: inv ? inv.id : '', investor_name: inv ? inv.name : '' }));
setInvestorPicker(false);
};
const filteredInvestors = useMemo(() => {
const q = investorQuery.trim().toLowerCase();
const list = investors || [];
return q ? list.filter((i) => i.name.toLowerCase().includes(q)) : list;
}, [investors, investorQuery]);
const openCreate = () => {
setForm({ title: '', due_date: '', details: '', investor_name: '', assignee_id: '', status: 'open' });
setForm({ title: '', due_date: '', details: '', investor_name: '', investor_source_row_id: '', 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',
investor_name: r.investor_name || '', investor_source_row_id: '', assignee_id: r.assignee_id || '', status: r.status || 'open',
});
setEditing(r);
};
const closeSheet = () => setEditing(null);
const closeSheet = () => { setEditing(null); setInvestorPicker(false); }; // also drop a stacked picker
const submit = async () => {
const title = (form.title || '').trim();
@@ -5012,9 +5129,11 @@
setBusy(true);
try {
if (editing && editing.create) {
// source_row_id (a grid row id) → backend resolves it to the canonical
// investor_id + name; blank = a team task (no investor linkage).
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 || '',
source_row_id: form.investor_source_row_id || '', assignee_id: form.assignee_id || '',
}) }, token);
onShowToast('Reminder created', 'success');
} else {
@@ -5044,42 +5163,63 @@
const isCreate = !!(editing && editing.create);
const activeBuckets = REMINDER_BUCKETS.filter((b) => groups[b.key].length);
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 className="rem-header">
<div className="rem-header-titles">
<span className="rem-title">Reminders</span>
<span className="rem-summary" style={{ color: summaryColor }}>{summary}</span>
</div>
<button className="rem-add-btn" onClick={openCreate} aria-label="Add reminder">+</button>
</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) => (
<>
{activeBuckets.length === 0 && (
<div className="empty-state">{completed.length ? 'Inbox zero — no open reminders.' : 'No reminders — tap + to add one.'}</div>
)}
{activeBuckets.map((b) => (
<div key={b.key}>
<div className="reminder-group-header" style={{ color: b.key === 'overdue' ? 'var(--danger-soft)' : b.key === 'soon' ? 'var(--due-soon)' : 'var(--text-subtle)' }}>
<div className="reminder-group-header">
<span className="reminder-group-dot" style={{ background: b.dot }} />
{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>
))
))}
{completed.length > 0 && (
<div>
<button className="rem-completed-toggle" onClick={() => setCompletedOpen((o) => !o)}>
<span className={`rem-completed-caret ${completedOpen ? 'open' : ''}`}></span>
Completed<span className="reminder-group-count">{completed.length}</span>
</button>
{completedOpen && completed.map((r) => (
<div className="rem-done-row" key={r.id}>
<button className={`rem-done-check ${r.status === 'cancelled' ? 'cancelled' : ''}`} onClick={() => reopen(r)} aria-label="Reopen">
{r.status === 'cancelled' ? '✕' : '✓'}
</button>
<button className="rem-done-body" onClick={() => openEdit(r)}>
<span className="rem-done-note">{r.title}</span>
{r.investor_name ? <span className="rem-done-org">{r.investor_name}</span> : null}
</button>
</div>
))}
</div>
)}
</>
)}
<BottomSheet open={!!editing} onClose={closeSheet} title={isCreate ? 'New reminder' : 'Edit reminder'}>
@@ -5094,7 +5234,12 @@
{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" />
<button type="button" className="rem-investor-pick" onClick={openInvestorPicker}>
<span className={form.investor_name ? '' : 'rem-investor-pick-empty'}>
{form.investor_name || 'Choose investor — or leave as a team task'}
</span>
<span className="rem-investor-pick-caret"></span>
</button>
</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>
@@ -5126,6 +5271,46 @@
<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>
{/* Investor picker — stacked over the add sheet (dc add-flow :416-428). Selecting
sets a canonical grid row id; "team task" clears it. */}
<BottomSheet open={investorPicker} onClose={() => setInvestorPicker(false)} title="Choose investor" stacked>
<input className="sheet-input" value={investorQuery} onChange={(e) => setInvestorQuery(e.target.value)} placeholder="Search investor…" autoFocus />
<div className="rem-investor-list">
<button type="button" className="sheet-option" onClick={() => pickInvestor(null)}>
<span>No investor — team task</span>
{!form.investor_source_row_id && <span className="sheet-option-check"></span>}
</button>
{investors == null ? (
<div className="rem-investor-hint">Loading investors…</div>
) : filteredInvestors.length ? filteredInvestors.map((i) => (
<button key={i.id} type="button" className="sheet-option" onClick={() => pickInvestor(i)}>
<span>{i.name}</span>
{form.investor_source_row_id === i.id && <span className="sheet-option-check"></span>}
</button>
)) : (
<div className="rem-investor-hint">No matches.</div>
)}
</div>
</BottomSheet>
{/* Snooze preset sheet — opened on swipe-right (dc snooze sheet :404-414). */}
<BottomSheet open={!!snoozing} onClose={() => setSnoozing(null)} title="Snooze reminder">
{snoozing && (
<>
{snoozing.investor_name && <div className="sheet-subcaption">For {snoozing.investor_name}</div>}
<label className="sheet-field-label">Snooze until</label>
<div className="snooze-list">
{reminderSnoozePresets().map(([label, iso]) => (
<button key={iso} type="button" className="snooze-row" onClick={() => snoozeTo(snoozing, iso)}>
<span>{label}</span>
<span className="snooze-date">{reminderMonthDay(iso)}</span>
</button>
))}
</div>
</>
)}
</BottomSheet>
</div>
);
};