From 08bb25abfcf6558e138e1c1a2fe507a956f9fbb8 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 02:48:21 +0530 Subject: [PATCH] feat(hq-web): relationship-pulse ribbon on Client 360 overview Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq-web/src/app.css | 39 ++++++++++++ apps/hq-web/src/components/PulseRibbon.tsx | 71 ++++++++++++++++++++++ apps/hq-web/src/pages/ClientDetail.tsx | 39 +++++++++++- apps/hq-web/src/pulse.ts | 41 +++++++++++++ apps/hq-web/test/pulse.test.ts | 30 +++++++++ 5 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 apps/hq-web/src/components/PulseRibbon.tsx create mode 100644 apps/hq-web/src/pulse.ts create mode 100644 apps/hq-web/test/pulse.test.ts diff --git a/apps/hq-web/src/app.css b/apps/hq-web/src/app.css index 7acbd34..d32234c 100644 --- a/apps/hq-web/src/app.css +++ b/apps/hq-web/src/app.css @@ -120,3 +120,42 @@ .hq-content { overflow-x: hidden; } table.wf { display: block; overflow-x: auto; } @media (min-width: 901px) { table.wf { display: table; } } + +/* Relationship-pulse ribbon (Client 360 overview). */ +.pulse { + background: var(--bg-raised); border: 1px solid var(--border); + border-radius: var(--radius); padding: 14px 16px; margin: 16px 0; +} +.pulse-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; } +.pulse-head h3 { margin: 0; font-size: 15px; } +.pulse-legend { margin-left: auto; display: flex; gap: 12px; font-size: 9.5px; letter-spacing: 0.06em; color: var(--text-dim); } +.pulse-legend .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-right: 4px; } +.pulse-grid { display: grid; gap: 2px 0; margin-top: 12px; } +.pulse-lane { + font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; + color: var(--text-dim); padding: 6px 8px 6px 0; white-space: nowrap; +} +.pulse-cell { + border-left: 1px solid var(--border); + min-height: 26px; display: flex; align-items: center; gap: 3px; padding: 2px 3px; + flex-wrap: wrap; +} +.pulse-dot { + width: 9px; height: 9px; border-radius: 50%; border: none; cursor: pointer; padding: 0; + transition: transform 80ms; +} +.pulse-dot:hover { transform: scale(1.5); } +.pulse-more { font-size: 9.5px; color: var(--text-dim); font-family: var(--mono); } +.pulse-month { + border-left: 1px solid var(--border); + font-size: 9.5px; color: var(--text-dim); font-family: var(--mono); padding: 3px; +} +.pulse-readout { + margin-top: 10px; border-left: 2px solid var(--accent); + background: var(--bg-inset); border-radius: var(--radius-sm); + padding: 7px 10px; font-size: 12.5px; color: var(--text); + min-height: 32px; display: flex; align-items: center; gap: 6px; +} +.pulse-open { color: var(--accent-strong); margin-left: auto; font-size: 11.5px; } +@media (max-width: 900px) { .pulse-grid { display: none; } .pulse-readout { display: none; } + .pulse::after { content: 'Timeline hidden on small screens.'; color: var(--text-dim); font-size: 12px; } } diff --git a/apps/hq-web/src/components/PulseRibbon.tsx b/apps/hq-web/src/components/PulseRibbon.tsx new file mode 100644 index 0000000..f5b868e --- /dev/null +++ b/apps/hq-web/src/components/PulseRibbon.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { groupPulse, LANES, lastMonths, type PulseEvent } from '../pulse' + +const TONE_VAR: Record = { + accent: 'var(--accent)', ok: 'var(--ok)', warn: 'var(--warn)', err: 'var(--err)', +} + +/** + * Relationship pulse — last 12 months (H-04's timeline ribbon). + * Four lanes on a shared month axis; hover fills the readout bar; click opens + * the record. Plain DOM dots — no chart library. + */ +export function PulseRibbon(props: { + events: PulseEvent[] + todayIso: string + onOpen: (ev: PulseEvent) => void +}) { + const months = lastMonths(props.todayIso) + const cells = groupPulse(props.events, months) + const [focus, setFocus] = useState() + + return ( +
+
+

Relationship pulse — last 12 months

+
+ {LANES.map((lane, i) => ( + {lane.toUpperCase()} + ))} +
+
+
+ {LANES.map((lane, li) => ( +
+
{lane}
+ {months.map((m, mi) => { + const evs = cells.get(`${li}:${mi}`) ?? [] + return ( +
+ {evs.slice(0, 3).map((ev) => ( +
+ ) + })} +
+ ))} +
+ {months.map((m) => ( +
{m.slice(5)}/{m.slice(2, 4)}
+ ))} +
+
+ {focus !== undefined + ? <>{focus.date} · {focus.label} open → + : 'Hover an event · click to open it'} +
+
+ ) +} diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index 212c21c..4030c96 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -20,6 +20,8 @@ import { AccountOwnerSelect, AssignModuleForm, ClientModuleStatusSelect, LogInteractionForm, NewAmcForm, NewRecurringForm, PaymentForm, RowDate, } from './client-forms' +import { PulseRibbon } from '../components/PulseRibbon' +import { type PulseEvent } from '../pulse' const inr = (p: number) => formatINR(p, { symbol: false }) @@ -64,6 +66,41 @@ export function ClientDetail() { const activeModules = (cms.data ?? []).filter((m) => m.active) const lastInteraction = interactions.data?.[0]?.onDate + const todayIso = new Date().toISOString().slice(0, 10) + const DOC_PULSE_TONE: Record = { + paid: 'ok', part_paid: 'warn', lost: 'err', cancelled: 'err', + } + const pulseEvents: PulseEvent[] = [ + ...docs.map((d): PulseEvent => ({ + id: `doc:${d.id}`, lane: 0, date: d.docDate, + label: `${d.docType} ${d.docNo ?? '(draft)'} — ${formatINR(d.payablePaise)} · ${d.status}`, + tone: DOC_PULSE_TONE[d.status] ?? 'accent', + })), + ...(ledger.data?.payments ?? []).map((p): PulseEvent => ({ + id: `pay:${p.id}`, lane: 1, date: p.receivedOn, + label: `Payment ${formatINR(p.amountPaise)} · ${p.mode}`, tone: 'ok', + })), + ...(interactions.data ?? []).map((i): PulseEvent => ({ + id: `int:${i.id}`, lane: 2, date: i.onDate, + label: `${types.data?.find((t) => t.code === i.typeCode)?.label ?? i.typeCode}${i.notes !== '' ? ` — ${i.notes.slice(0, 60)}` : ''}`, + tone: i.outcome === 'negative' ? 'err' : i.outcome === 'positive' ? 'ok' : 'warn', + })), + ...(amc.data ?? []).flatMap((a): PulseEvent[] => [ + { id: `amc-from:${a.id}`, lane: 3, date: a.periodFrom, label: `AMC starts — ${a.coverage !== '' ? a.coverage : 'contract'}`, tone: 'accent' }, + { id: `amc-to:${a.id}`, lane: 3, date: a.periodTo, label: `AMC renewal — ${a.paidStatus}`, tone: a.paidStatus === 'unpaid' ? 'err' : 'warn' }, + ]), + ...activeModules.filter((cm) => cm.nextRenewal !== null).map((cm): PulseEvent => ({ + id: `ren:${cm.id}`, lane: 3, date: cm.nextRenewal!, + label: `${moduleName(cm.moduleId)} renews`, tone: 'warn', + })), + ] + const openPulse = (ev: PulseEvent) => { + if (ev.id.startsWith('doc:')) nav(`/documents/${ev.id.slice(4)}`) + else if (ev.id.startsWith('pay:')) setTab('payments') + else if (ev.id.startsWith('int:')) setTab('interactions') + else setTab('amc') + } + return (
Clients/{c.code}
@@ -133,7 +170,7 @@ export function ClientDetail() { - {/* Pulse ribbon lands here in the next task. */} +

Recent documents

{docs.length === 0 ? No documents yet. : ( = 0; i--) { + const total = year * 12 + (month - 1) - i + const y = Math.floor(total / 12) + const m = total % 12 + 1 + out.push(`${y}-${String(m).padStart(2, '0')}`) + } + return out +} + +/** Bucket events into `${lane}:${monthIndex}` cells; out-of-window events are dropped. */ +export function groupPulse(events: PulseEvent[], months: string[]): Map { + const index = new Map() + months.forEach((m, i) => index.set(m, i)) + const out = new Map() + for (const ev of events) { + const mi = index.get(ev.date.slice(0, 7)) + if (mi === undefined) continue + const key = `${ev.lane}:${mi}` + const cell = out.get(key) + if (cell !== undefined) cell.push(ev) + else out.set(key, [ev]) + } + return out +} diff --git a/apps/hq-web/test/pulse.test.ts b/apps/hq-web/test/pulse.test.ts new file mode 100644 index 0000000..22f6d8c --- /dev/null +++ b/apps/hq-web/test/pulse.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { groupPulse, lastMonths, type PulseEvent } from '../src/pulse' + +describe('lastMonths', () => { + it('returns 12 months ending at the given date, oldest first', () => { + const m = lastMonths('2026-07-17') + expect(m).toHaveLength(12) + expect(m[0]).toBe('2025-08') + expect(m[11]).toBe('2026-07') + }) + it('crosses year boundaries correctly', () => { + expect(lastMonths('2026-01-05', 3)).toEqual(['2025-11', '2025-12', '2026-01']) + }) +}) + +describe('groupPulse', () => { + const ev = (lane: number, date: string, id: string): PulseEvent => + ({ id, lane, date, label: id, tone: 'accent' }) + const months = lastMonths('2026-07-17') + + it('buckets events by lane and month', () => { + const g = groupPulse([ev(0, '2026-07-01', 'a'), ev(0, '2026-07-30', 'b'), ev(2, '2025-08-09', 'c')], months) + expect(g.get('0:11')?.map((e) => e.id)).toEqual(['a', 'b']) + expect(g.get('2:0')?.map((e) => e.id)).toEqual(['c']) + }) + it('drops events outside the window', () => { + const g = groupPulse([ev(1, '2024-01-01', 'old'), ev(1, '2027-01-01', 'future')], months) + expect(g.size).toBe(0) + }) +})