diff --git a/AGENTS.md b/AGENTS.md index 1f62773..00a5d77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,12 +107,13 @@ 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–7 + P3b + drag-reorder + **8a + 8b** — **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 8a–8i 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 0–7 + P3b + drag-reorder + **8a + 8b + 8c** — **all deploy-pending** (no s9pk built). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign → Phase 8**, building to `design/phase8-conformance.md` (the 8a–8i 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 8a — Grid + Contacts cards re-authored (this session).** Grid card: existing-LP **earmark** corner-triangle (replaces left-border), right-side **PRIORITY pill** (replaces ★), 4-stage chip, zero-commit dim; detail ★→"Existing LP" pill. Contacts card: two-letter avatar initials + existing-LP **ring** + stage pill + recency; disposition badge dropped. Backend: `contact_grid_signals()` injects derived read-only `committed`/`pipeline_stage` on the contacts read path (see Design convention). `DESIGN.md` §4/§8 reconciled. - **Phase 8b — Contacts + Pipeline detail → drag-dismiss bottom sheets (this session).** Contacts: email-copy pill, Log/Email actions, Organization card (earmark·stage·committed·last-contact·last-note·Open-in-Grid). Pipeline: stat tiles, **inline move-stage list**, notes **timeline** + Log sheet. Both log via `POST /api/communications`; `` layers the Log sheet over a detail. Reviewer pass applied: stale-fetch race guard (cancelled-flag effects + reload key), keyed single-contact signals query, dedup test. +- **Phase 8c — Grid-detail notes timeline + top-bar quick-log pencil (this session).** Grid detail (G6): the single `row.notes` blob → a `` fed by a new investor-level read `GET /api/communications?source_row_id=` (backend filter maps row → `fundraising_investors.source_row_id` → `fundraising_contacts.contact_id` → comms, soft-delete-respecting; cancelled-flag fetch + `commsReload` after a log); note-logging switched to the shared `` (dropped `noteForm` + dead `.fs-note-log`). New `MobileQuickLog` (shell `.mobile-only` top-bar pencil, all 4 tabs): two-step sheet — pick investor (search + recent-first pool) → inline log form → one-row `/api/fundraising/log-communication`. Reviewer pass: `contact_id`/`source_row_id` kept mutually exclusive (`elif`), re-open guard, dropped the rejected ★ from the picker. Guarded by `test_grid_comm_timeline.py`. - **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:** **38/38 backend green** (`python3 backend/run_tests.py`; +`test_contacts_grid_signals.py`), `py_compile` clean, render-smoke green; both mobile surfaces interaction-verified via throwaway 375px jsdom harnesses (deleted after). -- **Next — Phase 8, in order, build to `design/phase8-conformance.md`:** **8c** quick-log pencil (dc top-bar) + Grid-detail notes timeline → **8d** sort (Grid+Pipeline sort sheet; Contacts = drop type tabs + add Priority sort) → **8e** reminders (due-chip + Overdue/Today/This-week/Later buckets + dots + snooze sheet + investor picker) → **8f** Pipeline card (earmark/Priority/recency + horizontal-scroll stage pills + dots) → **8g** add-investor stage+priority → **8h** loose ends (incl. Grid detail G4/G5/G6 stage-card/reminder-card/timeline; "Open-in-Grid" deep-link-to-investor) → **8i** shell SVG icons + `·Ten31·` wordmark. **Skip Pipeline accordion** (Grant). **Then (after feature-complete):** deploy P0–P8 + 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`), `py_compile` clean, render-smoke green; 8c surfaces (quick-log + grid timeline) interaction-verified via a throwaway 375px jsdom harness (deleted after). +- **Next — Phase 8, in order, build to `design/phase8-conformance.md`:** **8d** sort (Grid+Pipeline sort sheet; Contacts = drop type tabs + add Priority sort) → **8e** reminders (due-chip + Overdue/Today/This-week/Later buckets + dots + snooze sheet + investor picker) → **8f** Pipeline card (earmark/Priority/recency + horizontal-scroll stage pills + dots) → **8g** add-investor stage+priority → **8h** loose ends (incl. Grid detail G4/G5/G6 stage-card/reminder-card/timeline; "Open-in-Grid" deep-link-to-investor) → **8i** shell SVG icons + `·Ten31·` wordmark. **Skip Pipeline accordion** (Grant). **Then (after feature-complete):** deploy P0–P8 + 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. diff --git a/backend/server.py b/backend/server.py index 4c110ce..7d2800c 100644 --- a/backend/server.py +++ b/backend/server.py @@ -3238,6 +3238,20 @@ class CRMHandler(BaseHTTPRequestHandler): if params.get('contact_id'): query += " AND cm.contact_id = ?" args.append(params['contact_id']) + # source_row_id is an investor-scope filter; contact_id is a contact-scope filter. They are + # never combined by any caller (the /contacts/{id}/communications subpath sets contact_id; + # the mobile Grid detail sets source_row_id) — keep them mutually exclusive so a future + # caller passing both can't silently get the empty intersection. + elif params.get('source_row_id'): + # Investor-level timeline for a fundraising-grid row (the mobile Grid detail): map the + # grid JSON row id → its canonical contacts via the relational mirror, then return every + # communication across those contacts. cm.deleted_at is still filtered above (soft-delete). + query += """ AND cm.contact_id IN ( + SELECT fc.contact_id FROM fundraising_contacts fc + JOIN fundraising_investors fi ON fc.investor_id = fi.id + WHERE fi.source_row_id = ? AND fc.contact_id IS NOT NULL + )""" + args.append(params['source_row_id']) if params.get('type'): query += " AND cm.type = ?" args.append(params['type']) diff --git a/backend/test_grid_comm_timeline.py b/backend/test_grid_comm_timeline.py new file mode 100644 index 0000000..5403e8a --- /dev/null +++ b/backend/test_grid_comm_timeline.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Regression test for the Grid-detail communications timeline filter (Phase 8c, G6). + +The mobile Grid detail's notes timeline pulls an investor-level communication stream +via GET /api/communications?source_row_id=. That filter (added to +handle_list_communications) maps the grid JSON row id → fundraising_investors.source_row_id +→ fundraising_contacts.contact_id → communications, so it must: + - return every communication across ALL the investor's contacts, + - stay isolated (one investor's row id never returns another's comms), + - respect soft-delete (cm.deleted_at IS NULL) through the join. + +Boots the REAL server, seeds investors by driving the one-row log path (which creates the +grid row + contact + communication AND syncs the relational mirror the filter joins on), +then drives the live read path with a real token. Synthetic only (guardrail #9). + +Run: cd backend && python3 test_grid_comm_timeline.py +""" +import http.client +import json +import os +import sqlite3 +import sys +import tempfile +import threading +from http.server import ThreadingHTTPServer + +_DATA = tempfile.mkdtemp() +os.environ["CRM_DATA_DIR"] = _DATA +os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db") + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import server # noqa: E402 + +FAILS = [] +DEL = "2026-06-01T00:00:00" + + +def check(cond, msg): + print((" PASS " if cond else " FAIL ") + msg) + if not cond: + FAILS.append(msg) + + +class _Quiet(server.CRMHandler): + def log_message(self, *a): + pass + + +def _req(method, port, path, token, body=None): + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + headers = {"Authorization": "Bearer " + token} + payload = None + if body is not None: + payload = json.dumps(body) + headers["Content-Type"] = "application/json" + conn.request(method, path, body=payload, headers=headers) + resp = conn.getresponse() + raw = resp.read().decode("utf-8", "replace") + conn.close() + data = None + if raw: + try: + data = json.loads(raw) + except ValueError: + pass + return resp.status, data + + +def _log_comm(port, token, investor_name, contact, subject, create=False): + """Drive the one-row log path; returns (status, grid_row_id).""" + st, data = _req("POST", port, "/api/fundraising/log-communication", token, { + "investor_name": investor_name, + "create_investor_if_missing": create, + "contact": contact, + "type": "note", + "subject": subject, + "body": subject, + "append_note": True, + }) + row_id = ((data or {}).get("data", {}).get("row") or {}).get("id") + return st, row_id + + +def main(): + server.init_db() + conn = sqlite3.connect(os.environ["CRM_DB_PATH"]) + # password_hash is intentionally a non-bcrypt placeholder — we mint the token directly via + # create_token(), so the password-verify path is never exercised. + conn.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) " + "VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)") + conn.commit() + conn.close() + token = server.create_token("u1", "grant", "admin") + + httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet) + port = httpd.server_address[1] + threading.Thread(target=httpd.serve_forever, daemon=True).start() + try: + # Investor A: two contacts, one communication per contact. Create seeds the row with + # Jane + logs "Intro call"; update-row adds John as a second pill (so the relational mirror + # links BOTH contacts to A's row); then a comm is logged against John. The timeline must + # aggregate across both contacts — the point of the source_row_id join over a single contact. + st, rowA = _log_comm(port, token, "Acme Capital", + {"name": "Jane Doe", "email": "jane@acme.example"}, "Intro call", create=True) + check(st == 201 and bool(rowA), f"create investor A via log path -> 201 + row id (got {st}, {rowA})") + st, _ = _req("POST", port, "/api/fundraising/update-row", token, { + "row_id": rowA, "investor_name": "Acme Capital", + "contacts": [{"name": "Jane Doe", "email": "jane@acme.example"}, + {"name": "John Roe", "email": "john@acme.example"}], + }) + check(st == 200, f"add John as a second contact on A via update-row (got {st})") + st, _ = _log_comm(port, token, "Acme Capital", + {"name": "John Roe", "email": "john@acme.example"}, "Follow-up email") + check(st == 201, f"second contact's comm logged on A (got {st})") + + # Investor B: a separate investor, one communication (isolation control). + st, rowB = _log_comm(port, token, "Beacon Ventures", + {"name": "Sam Poe", "email": "sam@beacon.example"}, "Beacon note", create=True) + check(st == 201 and bool(rowB), f"create investor B via log path -> 201 + row id (got {st}, {rowB})") + + # ── source_row_id returns the whole investor (across contacts) ── + print("\n[source_row_id timeline]") + st, data = _req("GET", port, f"/api/communications?source_row_id={rowA}", token) + subsA = {c.get("subject") for c in (data or {}).get("data", [])} + check(st == 200, f"GET timeline for A -> 200 (got {st})") + check(subsA == {"Intro call", "Follow-up email"}, + f"A's timeline spans both contacts' comms (got {subsA})") + + # ── isolation: A's row id never returns B's comms ── + print("\n[isolation]") + check("Beacon note" not in subsA, "A's timeline excludes investor B's comm") + st, dataB = _req("GET", port, f"/api/communications?source_row_id={rowB}", token) + subsB = {c.get("subject") for c in (dataB or {}).get("data", [])} + check(subsB == {"Beacon note"}, f"B's timeline is its own comm only (got {subsB})") + + # ── soft-delete respected through the join ── + print("\n[soft-delete]") + c = sqlite3.connect(os.environ["CRM_DB_PATH"]) + c.execute("UPDATE communications SET deleted_at=? WHERE subject='Intro call'", (DEL,)) + c.commit() + c.close() + st, data2 = _req("GET", port, f"/api/communications?source_row_id={rowA}", token) + subsA2 = {c.get("subject") for c in (data2 or {}).get("data", [])} + check(subsA2 == {"Follow-up email"}, + f"soft-deleted comm filtered from A's timeline (got {subsA2})") + + # ── unknown row id returns empty, not an error ── + st, data3 = _req("GET", port, "/api/communications?source_row_id=does-not-exist", token) + check(st == 200 and (data3 or {}).get("data") == [], + f"unknown source_row_id -> 200 + empty (got {st}, {(data3 or {}).get('data')})") + finally: + httpd.shutdown() + + print() + if FAILS: + print(f"FAILED ({len(FAILS)}):") + for f in FAILS: + print(f" - {f}") + sys.exit(1) + print("ALL PASS (grid comm timeline source_row_id filter)") + + +if __name__ == "__main__": + main() diff --git a/frontend/index.html b/frontend/index.html index f3c5714..6fe749c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2472,6 +2472,37 @@ } .log-type-btn.active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-light); } + /* Quick-log — top-bar pencil button + two-step picker sheet (dc GridApp:53-55). */ + .quicklog-btn { + width: 36px; height: 36px; border-radius: 50%; + border: 1px solid var(--border); background: var(--bg-panel-elevated); + color: var(--text-muted); cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; + } + .quicklog-btn:active { background: var(--bg-hover); } + .quicklog-hint { font-size: 13px; color: var(--text-subtle); line-height: 1.5; margin: 0 0 12px; } + .quicklog-pool { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; } + .quicklog-empty { font-size: 13px; color: var(--text-subtle); padding: 16px 4px; } + .quicklog-row { + width: 100%; text-align: left; cursor: pointer; font-family: inherit; + background: var(--bg-input); border: 1px solid var(--border); border-radius: 10px; + padding: 11px 13px; display: flex; align-items: center; justify-content: space-between; gap: 10px; + color: var(--text-primary); + } + .quicklog-row:active { background: var(--bg-hover); } + .quicklog-row-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; } + .quicklog-row-name { font-size: 15px; font-weight: 500; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .quicklog-row-sub { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .quicklog-target { + display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 16px; + background: var(--bg-input); border: 1px solid var(--border); border-radius: 10px; padding: 11px 13px; + } + .quicklog-target-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; } + .quicklog-target-label { font-family: 'IBM Plex Mono', monospace; font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); } + .quicklog-target-name { font-size: 15px; font-weight: 600; color: var(--text-primary); } + .quicklog-change { flex: none; background: none; border: none; color: var(--accent-light); font-size: 13px; cursor: pointer; font-family: inherit; } + .quicklog-warn { font-size: 13px; color: var(--text-subtle); line-height: 1.5; margin-bottom: 14px; } + /* Full-screen detail: read-only sections + edit-entry buttons. */ .fs-detail-chips { flex: none; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; } .fs-action-row { display: flex; gap: 8px; flex-wrap: wrap; } @@ -2485,7 +2516,6 @@ .fs-pill { background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius); padding: 9px 12px; margin-bottom: 8px; } .fs-pill-name { font-size: var(--mobile-font-body); color: var(--text-primary); } .fs-pill-email { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-muted); margin-top: 2px; word-break: break-word; } - .fs-note-log { white-space: pre-wrap; font-size: 13px; color: var(--text-secondary); line-height: 1.5; } /* Bottom-sheet form fields (shared by the log-note / stage / reminder / create sheets). */ .sheet-field { margin-bottom: 14px; } @@ -9732,8 +9762,11 @@ const [sheet, setSheet] = useState(null); // 'view' | 'create' | 'note' | 'stage' | 'reminder' const [busy, setBusy] = useState(false); const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '' }); - const [noteForm, setNoteForm] = useState({ type: 'note', subject: '', body: '' }); const [reminderForm, setReminderForm] = useState({ title: '', due_date: '', details: '' }); + // G6 — investor-level communications timeline for the open detail. Fetched on open + // (source_row_id → canonical contacts → communications); commsReload re-runs it after a log. + const [comms, setComms] = useState([]); + const [commsReload, setCommsReload] = useState(0); // P3b edit sheet: investor name + the full contacts array (pills carry their other // fields — title/location/linkedin — through unedited; we only surface name/email/title). const [editForm, setEditForm] = useState({ name: '', contacts: [] }); @@ -9755,6 +9788,21 @@ useEffect(() => { reload(); }, [reload]); + // G6 timeline fetch — keyed on the open investor (+ commsReload after a log). A cancelled + // flag drops a stale response so opening row A then B can't leave B's detail showing A's + // notes (the 8b reviewer race-guard pattern). Non-fatal: timeline stays empty on failure. + useEffect(() => { + if (!selectedId) { setComms([]); return undefined; } + let cancelled = false; + (async () => { + try { + const r = await api(`/api/communications?source_row_id=${encodeURIComponent(selectedId)}&limit=50`, {}, token); + if (!cancelled) setComms(Array.isArray(r.data) ? r.data : []); + } catch (_) { if (!cancelled) setComms([]); } + })(); + return () => { cancelled = true; }; + }, [selectedId, token, commsReload]); + const fundColumnIds = useMemo(() => columns.filter((c) => c && c.isFund).map((c) => c.id), [columns]); const fundColumns = useMemo(() => columns.filter((c) => c && c.isFund), [columns]); const activeViewObj = useMemo(() => views.find((v) => v.id === activeView) || null, [views, activeView]); @@ -9777,20 +9825,22 @@ const closeSheet = () => setSheet(null); // ── writes (targeted one-row endpoints only) ── - const submitNote = async () => { + // Log a communication against the open investor's first contact via the one-row grid path + // (creates a communications row + appends the notes blob). Refreshes the G6 timeline + + // card recency. Payload shape matches the shared LogCommunicationSheet ({type,subject,body}). + const submitNote = async ({ type, subject, body }) => { const row = selectedRow; if (!row) return; const contact = (Array.isArray(row.contacts) && row.contacts[0]) || null; if (!contact) { onShowToast('This investor has no contact yet — add one on desktop first', 'error'); return; } - if (!String(noteForm.body || noteForm.subject || '').trim()) { onShowToast('Add a note', 'error'); return; } setBusy(true); try { await api('/api/fundraising/log-communication', { method: 'POST', body: JSON.stringify({ row_id: row.id, investor_name: row.investor_name || '', contact, - type: noteForm.type || 'note', subject: noteForm.subject || '', body: noteForm.body || '', append_note: true, + type: type || 'note', subject: subject || '', body: body || '', append_note: true, }) }, token); - onShowToast('Note logged', 'success'); - setNoteForm({ type: 'note', subject: '', body: '' }); + onShowToast('Communication logged', 'success'); closeSheet(); + setCommsReload((n) => n + 1); await reload(true); } catch (err) { onShowToast(getErrorMessage(err, 'Failed to log note'), 'error'); } finally { setBusy(false); } @@ -10084,34 +10134,23 @@ {row.reminder_status && (
Reminder{String(row.reminder_status).replace('_', ' ')}
)} - {row.notes &&
{row.notes}
}
-
+ + {/* G6 — notes/communication timeline (dc GridApp:265-290): dot-and-line rail + of the investor's logged communications, "+ Log" via the shared sheet. */} +
+
+ Notes / communication + +
+ +
- -
- - -
-
- - setNoteForm((f) => ({ ...f, subject: e.target.value }))} placeholder="e.g. Intro call" /> -
-
- -