feat(hq-web): relationship-pulse ribbon on Client 360 overview
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/client-detail-redesign
parent
eaecaedd3f
commit
08bb25abfc
@ -0,0 +1,71 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { groupPulse, LANES, lastMonths, type PulseEvent } from '../pulse'
|
||||||
|
|
||||||
|
const TONE_VAR: Record<PulseEvent['tone'], string> = {
|
||||||
|
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<PulseEvent | undefined>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pulse">
|
||||||
|
<div className="pulse-head">
|
||||||
|
<h3>Relationship pulse — last 12 months</h3>
|
||||||
|
<div className="pulse-legend">
|
||||||
|
{LANES.map((lane, i) => (
|
||||||
|
<span key={lane}><span className="dot" style={{ background: TONE_VAR[i === 0 ? 'accent' : i === 1 ? 'ok' : i === 2 ? 'warn' : 'err'] }} />{lane.toUpperCase()}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pulse-grid" style={{ gridTemplateColumns: `110px repeat(${months.length}, 1fr)` }}>
|
||||||
|
{LANES.map((lane, li) => (
|
||||||
|
<div key={lane} style={{ display: 'contents' }}>
|
||||||
|
<div className="pulse-lane">{lane}</div>
|
||||||
|
{months.map((m, mi) => {
|
||||||
|
const evs = cells.get(`${li}:${mi}`) ?? []
|
||||||
|
return (
|
||||||
|
<div key={m} className="pulse-cell">
|
||||||
|
{evs.slice(0, 3).map((ev) => (
|
||||||
|
<button
|
||||||
|
key={ev.id}
|
||||||
|
type="button"
|
||||||
|
className="pulse-dot"
|
||||||
|
style={{ background: TONE_VAR[ev.tone] }}
|
||||||
|
title={ev.label}
|
||||||
|
onMouseEnter={() => setFocus(ev)}
|
||||||
|
onFocus={() => setFocus(ev)}
|
||||||
|
onClick={() => props.onOpen(ev)}
|
||||||
|
aria-label={ev.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{evs.length > 3 && <span className="pulse-more">+{evs.length - 3}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="pulse-lane" />
|
||||||
|
{months.map((m) => (
|
||||||
|
<div key={m} className="pulse-month">{m.slice(5)}/{m.slice(2, 4)}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="pulse-readout">
|
||||||
|
{focus !== undefined
|
||||||
|
? <><span className="mono">{focus.date}</span> · {focus.label} <span className="pulse-open">open →</span></>
|
||||||
|
: 'Hover an event · click to open it'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
/** Pure math for the relationship-pulse ribbon (Client 360 overview). */
|
||||||
|
|
||||||
|
export interface PulseEvent {
|
||||||
|
id: string
|
||||||
|
lane: number
|
||||||
|
date: string // ISO yyyy-mm-dd
|
||||||
|
label: string
|
||||||
|
tone: 'accent' | 'ok' | 'warn' | 'err'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LANES = ['Documents', 'Payments', 'Interactions', 'AMC & renewals'] as const
|
||||||
|
|
||||||
|
/** n month keys ('YYYY-MM') ending at todayIso's month, oldest first. */
|
||||||
|
export function lastMonths(todayIso: string, n = 12): string[] {
|
||||||
|
const year = Number(todayIso.slice(0, 4))
|
||||||
|
const month = Number(todayIso.slice(5, 7)) // 1-based
|
||||||
|
const out: string[] = []
|
||||||
|
for (let i = n - 1; i >= 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<string, PulseEvent[]> {
|
||||||
|
const index = new Map<string, number>()
|
||||||
|
months.forEach((m, i) => index.set(m, i))
|
||||||
|
const out = new Map<string, PulseEvent[]>()
|
||||||
|
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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue