/** * Interactive Gantt of life events (Wave 2 chart-first redesign). * * Visualises every life event as a horizontal bar over the scenario * horizon. Bar color encodes spending category. Drag the middle to slide * the whole event. Drag the left/right edge handles to resize. Click an * empty slot to create a new event at that year. Click an existing bar * to edit it inline via a popover. Persists every interaction through * the existing life-events CRUD endpoints (PATCH for moves/resizes, * POST for create, DELETE from the popover). * * Source-of-truth pattern: this chart IS the editor. The list-form * fallback under the drawer remains for bulk edits and accessibility. */ import { useEffect, useMemo, useRef, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { ParentSize } from '@visx/responsive'; import { scaleBand, scaleLinear } from '@visx/scale'; import { Group } from '@visx/group'; import { AxisBottom } from '@visx/axis'; import { localPoint } from '@visx/event'; import { lifeEventsApi, type LifeEvent, type LifeEventCreateBody, type LifeEventPatchBody, type SpendingCategory, } from '@/api/client'; import { gbp } from '@/lib/format'; import { emojiFor } from '@/lib/milestone'; interface Props { scenarioId: number; events: LifeEvent[]; horizonYears: number; height?: number; } const CATEGORY_FILL: Record = { essential: 'rgb(16, 185, 129)', // emerald-500 discretionary: 'rgb(245, 158, 11)', // amber-500 not_spending: 'rgb(100, 116, 139)', // slate-500 }; const CATEGORY_BORDER: Record = { essential: 'rgb(5, 150, 105)', discretionary: 'rgb(217, 119, 6)', not_spending: 'rgb(71, 85, 105)', }; type DragMode = 'move' | 'left' | 'right'; interface DragState { eventId: number; mode: DragMode; startMouseX: number; origYearStart: number; origYearEnd: number; } interface PopoverState { // Either editing an existing event, or creating one at year_start=N kind: 'edit' | 'create'; x: number; y: number; event?: LifeEvent; createYear?: number; } export function EventGantt(props: Props) { return ( {({ width }) => (width > 0 ? : null)} ); } function Inner({ scenarioId, events, horizonYears, width, height = 220, }: Props & { width: number }) { const margin = { top: 12, right: 24, bottom: 28, left: 96 }; const innerW = Math.max(0, width - margin.left - margin.right); const sortedEvents = useMemo( () => [...events].sort((a, b) => a.year_start - b.year_start || a.id - b.id), [events], ); const rowHeight = 22; const minRows = Math.max(sortedEvents.length, 1); const innerH = Math.max(rowHeight * minRows + 32, height - margin.top - margin.bottom); const xScale = useMemo( () => scaleLinear({ domain: [0, Math.max(1, horizonYears - 1)], range: [0, innerW], }), [horizonYears, innerW], ); const yScale = useMemo( () => scaleBand({ domain: sortedEvents.map((e) => e.id), range: [0, rowHeight * sortedEvents.length], padding: 0.2, }), [sortedEvents], ); const qc = useQueryClient(); const invalidate = () => qc.invalidateQueries({ queryKey: ['scenarios', scenarioId, 'life-events'], }); const invalidateProfile = () => qc.invalidateQueries({ queryKey: ['spending-profile', scenarioId], }); const patchMut = useMutation({ mutationFn: ({ id, body }: { id: number; body: LifeEventPatchBody }) => lifeEventsApi.patch(id, body), onSuccess: () => { invalidate(); invalidateProfile(); }, }); const createMut = useMutation({ mutationFn: (body: LifeEventCreateBody) => lifeEventsApi.create(scenarioId, body), onSuccess: () => { invalidate(); invalidateProfile(); }, }); const deleteMut = useMutation({ mutationFn: (id: number) => lifeEventsApi.delete(id), onSuccess: () => { invalidate(); invalidateProfile(); }, }); // Drag state lives in a ref so mousemove handlers don't re-render. const drag = useRef(null); // Pixel-level offset applied during drag (without committing). const [dragOffset, setDragOffset] = useState<{ id: number; dx: number; mode: DragMode } | null>(null); const [popover, setPopover] = useState(null); useEffect(() => { const onMove = (e: MouseEvent) => { if (!drag.current) return; setDragOffset({ id: drag.current.eventId, dx: e.clientX - drag.current.startMouseX, mode: drag.current.mode, }); }; const onUp = (e: MouseEvent) => { if (!drag.current) return; const dx = e.clientX - drag.current.startMouseX; const dyears = pxToYears(dx, xScale, horizonYears); const { eventId, mode, origYearStart, origYearEnd } = drag.current; drag.current = null; setDragOffset(null); if (Math.abs(dyears) < 1) return; // sub-year drag = ignore let body: LifeEventPatchBody = {}; if (mode === 'move') { const newStart = clamp(origYearStart + dyears, 0, horizonYears - 1); const span = origYearEnd - origYearStart; body = { year_start: newStart, year_end: clamp(newStart + span, newStart, horizonYears - 1), }; } else if (mode === 'left') { body = { year_start: clamp(origYearStart + dyears, 0, origYearEnd), }; } else if (mode === 'right') { body = { year_end: clamp(origYearEnd + dyears, origYearStart, horizonYears - 1), }; } patchMut.mutate({ id: eventId, body }); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, [horizonYears, patchMut, xScale]); const startDrag = (e: React.MouseEvent, ev: LifeEvent, mode: DragMode) => { e.stopPropagation(); const yearEnd = ev.year_end ?? ev.year_start; drag.current = { eventId: ev.id, mode, startMouseX: e.clientX, origYearStart: ev.year_start, origYearEnd: yearEnd, }; setDragOffset({ id: ev.id, dx: 0, mode }); }; const onBackgroundClick = (e: React.MouseEvent) => { const point = localPoint(e); if (!point) return; const x = point.x - margin.left; const year = Math.round(xScale.invert(Math.max(0, Math.min(innerW, x)))); setPopover({ kind: 'create', x: point.x, y: point.y, createYear: year, }); }; const onBarClick = (e: React.MouseEvent, ev: LifeEvent) => { e.stopPropagation(); const point = localPoint(e); if (!point) return; setPopover({ kind: 'edit', x: point.x, y: point.y, event: ev }); }; return (
{/* Faint year gridlines */} {Array.from({ length: horizonYears }).map((_, i) => { const x = xScale(i) ?? 0; return ( ); })} {/* Background click capture — drawn before bars so they win z-order, but `pointerEvents=all` ensures the empty regions between bars still bubble clicks here. */} {/* Bars */} {sortedEvents.map((ev) => { const yearEnd = ev.year_end ?? ev.year_start; const offsetDx = dragOffset && dragOffset.id === ev.id ? dragOffset.dx : 0; const dyears = pxToYears(offsetDx, xScale, horizonYears); const isMove = dragOffset?.id === ev.id && dragOffset.mode === 'move'; const isLeft = dragOffset?.id === ev.id && dragOffset.mode === 'left'; const isRight = dragOffset?.id === ev.id && dragOffset.mode === 'right'; const startY = isMove ? ev.year_start + dyears : isLeft ? ev.year_start + dyears : ev.year_start; const endY = isMove ? yearEnd + dyears : isRight ? yearEnd + dyears : yearEnd; const x = xScale(Math.max(0, startY)) ?? 0; const w = Math.max(8, (xScale(Math.max(startY + 0.5, endY)) ?? 0) - x); const y = yScale(ev.id) ?? 0; const h = yScale.bandwidth(); const fill = CATEGORY_FILL[ev.category] ?? CATEGORY_FILL.essential; const border = CATEGORY_BORDER[ev.category] ?? CATEGORY_BORDER.essential; // Edge handles are only useful on multi-year ranges. For // single-year (point) events the bar is too narrow to // distinguish "edge" from "middle" so the whole bar // moves and the user resizes via the popover. const showHandles = w >= 24 && yearEnd > ev.year_start; const handleW = Math.min(8, w / 4); return ( onBarClick(e, ev)} style={{ cursor: 'grab' }} > startDrag(e, ev, 'move')} /> {showHandles && ( <> startDrag(e, ev, 'left')} style={{ cursor: 'ew-resize' }} /> startDrag(e, ev, 'right')} style={{ cursor: 'ew-resize' }} /> )} {emojiFor(ev.kind)} {ev.name} ); })} `y${Math.round(Number(v))}`} tickStroke="#94a3b8" stroke="#94a3b8" tickLabelProps={{ fill: '#64748b', fontSize: 10, textAnchor: 'middle' }} /> {/* Row labels */} {sortedEvents.map((ev) => { const y = (yScale(ev.id) ?? 0) + yScale.bandwidth() / 2; return ( {ev.kind} ); })} {sortedEvents.length === 0 && (
Click anywhere on the timeline to add a life event.
)} {popover && ( setPopover(null)} onCreate={(body) => { createMut.mutate(body); setPopover(null); }} onPatch={(id, body) => { patchMut.mutate({ id, body }); setPopover(null); }} onDelete={(id) => { deleteMut.mutate(id); setPopover(null); }} horizonYears={horizonYears} /> )}
); } function pxToYears( px: number, scale: { invert: (n: number) => number }, _horizon: number, ): number { const yearsAtZero = scale.invert(0); const yearsAtPx = scale.invert(px); return Math.round(yearsAtPx - yearsAtZero); } function clamp(n: number, lo: number, hi: number): number { return Math.max(lo, Math.min(hi, n)); } interface PopoverProps { state: PopoverState; horizonYears: number; onClose: () => void; onCreate: (body: LifeEventCreateBody) => void; onPatch: (id: number, body: LifeEventPatchBody) => void; onDelete: (id: number) => void; } const KIND_OPTIONS = [ 'kid_at_home', 'kid_born', 'kids_leave_home', 'mortgage_payoff', 'home_purchase', 'sabbatical', 'inheritance', 'travel', 'expense_range', 'one_time_income', ]; function EventPopover({ state, horizonYears, onClose, onCreate, onPatch, onDelete, }: PopoverProps) { const isEdit = state.kind === 'edit' && state.event; const [name, setName] = useState(isEdit ? state.event!.name : ''); const [kind, setKind] = useState(isEdit ? state.event!.kind : 'kid_at_home'); const [yearStart, setYearStart] = useState( isEdit ? state.event!.year_start : state.createYear ?? 0, ); const [yearEnd, setYearEnd] = useState( isEdit ? state.event!.year_end : null, ); const [delta, setDelta] = useState( isEdit ? Number(state.event!.delta_gbp_per_year) : -10000, ); const [category, setCategory] = useState( isEdit ? state.event!.category : 'essential', ); const submit = (e: React.FormEvent) => { e.preventDefault(); if (isEdit) { onPatch(state.event!.id, { name, kind, year_start: yearStart, year_end: yearEnd, delta_gbp_per_year: String(delta), category, }); } else { onCreate({ kind, name: name || titleize(kind), year_start: yearStart, year_end: yearEnd, delta_gbp_per_year: String(delta), category, }); } }; return (
e.stopPropagation()} >

{isEdit ? 'Edit event' : `Add event @ y${state.createYear}`}

Category
{(['essential', 'discretionary', 'not_spending'] as const).map((c) => ( ))}
{isEdit && ( )}
); } function titleize(kind: string): string { return kind .split('_') .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) .join(' '); }