You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
72 lines
2.7 KiB
TypeScript
72 lines
2.7 KiB
TypeScript
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">
|
|
{([['accent', 'ACTIVITY'], ['ok', 'PAID / POSITIVE'], ['warn', 'PENDING / RENEWAL'], ['err', 'NEEDS ATTENTION']] as const).map(([tone, label]) => (
|
|
<span key={tone}><span className="dot" style={{ background: TONE_VAR[tone] }} />{label}</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>
|
|
)
|
|
}
|