fire-planner/frontend/src/components/EventGantt.tsx
Viktor Barzin 9fd8389c26
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fire-planner: UX review pass 2 — health URL, Progress in shell, Gantt
single-year drag, Settings highlight, Dashboard empty state

Round 2 of agent-driven UX review.

- api status badge was always "unreachable" because client called
  /api/healthz but FastAPI mounts /healthz at root (so k8s probes
  hit it without the SPA prefix). Client now calls /healthz directly.
- Progress page rendered without the shell sidebar/tabs because its
  route was sibling to ScenarioShell; moved it inside as a nested
  route (also drops the redundant "← Plan" breadcrumb since the tab
  bar handles that now).
- EventGantt: single-year (point) events no longer render edge handles
  since the bar is too narrow to distinguish "edge grab" from "middle
  grab" — for points the whole bar moves; resize via the popover.
  Bars wider than 24px keep their edge handles.
- Settings sub-nav: Milestones now points at /settings/milestones
  (consistent active highlight); /settings index redirects there.
- Dashboard "Last 12 months" chart shows an explainer when the
  history has fewer than 2 snapshots instead of an empty axis.
- Stub tabs that are currently active get a slate-300 underline +
  slate-500 text rather than the bold slate-900 — visually honest
  about what's a placeholder.

Frontend typecheck/test/build green.
2026-05-10 17:49:05 +00:00

634 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<SpendingCategory, string> = {
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<SpendingCategory, string> = {
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 (
<ParentSize>
{({ width }) => (width > 0 ? <Inner {...props} width={width} /> : null)}
</ParentSize>
);
}
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<number>({
domain: [0, Math.max(1, horizonYears - 1)],
range: [0, innerW],
}),
[horizonYears, innerW],
);
const yScale = useMemo(
() =>
scaleBand<number>({
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<DragState | null>(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<PopoverState | null>(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<SVGRectElement>) => {
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 (
<div className="relative">
<svg width={width} height={Math.max(height, innerH + margin.top + margin.bottom)}>
<Group left={margin.left} top={margin.top}>
{/* Faint year gridlines */}
{Array.from({ length: horizonYears }).map((_, i) => {
const x = xScale(i) ?? 0;
return (
<line
key={i}
x1={x}
x2={x}
y1={0}
y2={innerH}
stroke={i % 5 === 0 ? '#cbd5e1' : '#e2e8f0'}
strokeWidth={1}
/>
);
})}
{/* Background click capture — drawn before bars so they win
z-order, but `pointerEvents=all` ensures the empty regions
between bars still bubble clicks here. */}
<rect
x={0}
y={0}
width={innerW}
height={innerH}
fill="white"
fillOpacity={0}
pointerEvents="all"
onClick={onBackgroundClick}
style={{ cursor: 'crosshair' }}
/>
{/* 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 (
<g
key={ev.id}
opacity={ev.enabled ? 1 : 0.4}
onClick={(e) => onBarClick(e, ev)}
style={{ cursor: 'grab' }}
>
<rect
x={x}
y={y}
width={w}
height={h}
fill={fill}
stroke={border}
strokeWidth={1}
rx={3}
onMouseDown={(e) => startDrag(e, ev, 'move')}
/>
{showHandles && (
<>
<rect
x={x - 1}
y={y}
width={handleW}
height={h}
fill="rgba(0,0,0,0.25)"
rx={3}
onMouseDown={(e) => startDrag(e, ev, 'left')}
style={{ cursor: 'ew-resize' }}
/>
<rect
x={x + w - handleW + 1}
y={y}
width={handleW}
height={h}
fill="rgba(0,0,0,0.25)"
rx={3}
onMouseDown={(e) => startDrag(e, ev, 'right')}
style={{ cursor: 'ew-resize' }}
/>
</>
)}
<text
x={x + 6}
y={y + h / 2}
fill="#fff"
fontSize={11}
dominantBaseline="middle"
pointerEvents="none"
style={{ userSelect: 'none' }}
>
{emojiFor(ev.kind)} {ev.name}
</text>
</g>
);
})}
<AxisBottom
top={innerH}
scale={xScale}
numTicks={Math.min(10, horizonYears)}
tickFormat={(v) => `y${Math.round(Number(v))}`}
tickStroke="#94a3b8"
stroke="#94a3b8"
tickLabelProps={{ fill: '#64748b', fontSize: 10, textAnchor: 'middle' }}
/>
</Group>
{/* Row labels */}
<Group left={0} top={margin.top}>
{sortedEvents.map((ev) => {
const y = (yScale(ev.id) ?? 0) + yScale.bandwidth() / 2;
return (
<text
key={ev.id}
x={margin.left - 8}
y={y}
fill="#475569"
fontSize={11}
textAnchor="end"
dominantBaseline="middle"
>
{ev.kind}
</text>
);
})}
</Group>
</svg>
{sortedEvents.length === 0 && (
<div className="absolute top-12 left-0 right-0 text-center text-xs text-slate-500 pointer-events-none">
Click anywhere on the timeline to add a life event.
</div>
)}
{popover && (
<EventPopover
state={popover}
onClose={() => 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}
/>
)}
</div>
);
}
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<number | null>(
isEdit ? state.event!.year_end : null,
);
const [delta, setDelta] = useState(
isEdit ? Number(state.event!.delta_gbp_per_year) : -10000,
);
const [category, setCategory] = useState<SpendingCategory>(
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 (
<div
className="absolute z-50 rounded-lg border border-slate-200 bg-white shadow-lg p-3 w-72 text-sm"
style={{
left: Math.min(state.x + 8, window.innerWidth - 290),
top: state.y + 8,
}}
onClick={(e) => e.stopPropagation()}
>
<form onSubmit={submit} className="space-y-2">
<div className="flex items-baseline justify-between">
<h3 className="font-semibold text-slate-900">
{isEdit ? 'Edit event' : `Add event @ y${state.createYear}`}
</h3>
<button
type="button"
onClick={onClose}
className="text-slate-400 hover:text-slate-700"
aria-label="Close"
>
×
</button>
</div>
<label className="block text-xs">
<span className="uppercase tracking-wide text-slate-500">Name</span>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={titleize(kind)}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1 text-sm"
autoFocus
/>
</label>
<label className="block text-xs">
<span className="uppercase tracking-wide text-slate-500">Kind</span>
<select
value={kind}
onChange={(e) => setKind(e.target.value)}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1 text-sm"
>
{KIND_OPTIONS.map((k) => (
<option key={k} value={k}>
{emojiFor(k)} {k}
</option>
))}
</select>
</label>
<div className="grid grid-cols-2 gap-2">
<label className="block text-xs">
<span className="uppercase tracking-wide text-slate-500">Year start</span>
<input
type="number"
min={0}
max={horizonYears - 1}
value={yearStart}
onChange={(e) => setYearStart(clamp(Number(e.target.value), 0, horizonYears - 1))}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1 text-sm tabular-nums"
/>
</label>
<label className="block text-xs">
<span className="uppercase tracking-wide text-slate-500">Year end</span>
<input
type="number"
min={yearStart}
max={horizonYears - 1}
value={yearEnd ?? ''}
placeholder="—"
onChange={(e) =>
setYearEnd(e.target.value === '' ? null : Number(e.target.value))
}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1 text-sm tabular-nums"
/>
</label>
</div>
<label className="block text-xs">
<span className="uppercase tracking-wide text-slate-500">
£ per year ({delta < 0 ? 'expense' : 'income'})
</span>
<input
type="number"
value={delta}
step={1000}
onChange={(e) => setDelta(Number(e.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1 text-sm tabular-nums"
/>
<div className="text-xs text-slate-500 mt-0.5">
{delta !== 0 && `${delta < 0 ? '' : '+'}${gbp(Math.abs(delta))}/y`}
</div>
</label>
<fieldset className="text-xs">
<legend className="uppercase tracking-wide text-slate-500 mb-1">Category</legend>
<div className="flex gap-1">
{(['essential', 'discretionary', 'not_spending'] as const).map((c) => (
<button
key={c}
type="button"
onClick={() => setCategory(c)}
className={[
'flex-1 rounded-md px-2 py-1 text-xs border',
category === c
? 'border-slate-900 bg-slate-900 text-white'
: 'border-slate-300 text-slate-600 hover:bg-slate-50',
].join(' ')}
>
{c.replace('_', ' ')}
</button>
))}
</div>
</fieldset>
<div className="flex items-center justify-between pt-1">
{isEdit && (
<button
type="button"
onClick={() => onDelete(state.event!.id)}
className="text-xs text-red-700 hover:underline"
>
Delete
</button>
)}
<div className="flex-1" />
<button
type="submit"
className="rounded-md bg-slate-900 text-white text-xs font-medium px-3 py-1.5 hover:bg-slate-800"
>
{isEdit ? 'Save' : 'Create'}
</button>
</div>
</form>
</div>
);
}
function titleize(kind: string): string {
return kind
.split('_')
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join(' ');
}