fire-planner/frontend/src/pages/ScenarioDetail.tsx
Viktor Barzin 64eb90c3dc
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fire-planner: Wave 2 chart-first — flex spending, categorised life
events, interactive Visx Gantt + spending-profile chart

Charts are now the primary editor for life events. The Plan-tab body
re-orders to make charts ~80% of viewport real-estate; legacy form
sections are collapsed into a drawer.

Backend:
- alembic 0004: life_event.category enum (essential / discretionary /
  not_spending). Defaults to essential so existing rows keep their
  full spending impact.
- Simulator gains discretionary_outflows + flex_rules params. Tracks
  per-path running ATH, applies the deepest applicable cut to
  discretionary outflows when portfolio drops vs ATH (PLab-style flex
  spending). Cut amount stays in the portfolio (refund pattern).
- New flex_spending module with FlexRule + applicable_cut +
  cuts_per_year (vectorised). Sortable rules; "deepest cut wins" so
  users specify cumulative cuts at each tier.
- New /scenarios/{id}/spending-profile endpoint returning per-year
  base / essential / discretionary / flex_cut / total breakdown.
- SimulateRequest gains flex_rules + life_event.category roundtrip.
- 8 new tests; 246 total pytest pass; mypy + ruff clean.

Frontend (Visx + ECharts):
- Installed @visx/{scale,shape,group,axis,event,responsive,tooltip}
  for native SVG drag interactions.
- New <SpendingProfileChart> — Visx stacked-area of base/essential/
  discretionary with red flex-cut overlay, hover tooltip, click-to-
  scrub-year.
- New <EventGantt> — interactive Visx Gantt:
    * Click empty space → popover create at that year (default
      essential spending event)
    * Click a bar → inline edit popover (name, kind, range, £/y,
      category) with delete button
    * Drag bar middle → moves the whole event (year-resolution snap)
    * Drag bar edges → resizes year_start / year_end
    * All gestures persist via PATCH /life-events/{id}
- New <FlexRulesEditor> — list of {from_ath_pct, cut} tiers, save-on-
  change to scenario.config_json.flex_rules.
- Plan-tab redesign: NW fan dominant top with floating stat badges
  (Year/Age/NW/Δ NW/Spending/Eff. tax) over the chart; spending-
  profile chart middle; Gantt bottom; flex-rules editor; legacy form
  sections in a collapsed <details> drawer.
- Frontend typecheck + 7 vitest tests + production build all clean.
2026-05-10 16:49:04 +00:00

424 lines
16 KiB
TypeScript

/**
* Plan-tab body — Wave 2 chart-first redesign.
*
* Layout (chart is the SoT for editing life events):
* ┌────────────────────────────────────────┐
* │ NW fan + floating stats badges (top-R) │
* │ year-scrubber along the bottom │
* ├────────────────────────────────────────┤
* │ Spending profile (stacked area) │
* ├────────────────────────────────────────┤
* │ Event Gantt (drag/click to edit) │
* ├────────────────────────────────────────┤
* │ Flex rules editor │
* ├────────────────────────────────────────┤
* │ Drawer: legacy form sections (collapsed)│
* └────────────────────────────────────────┘
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { api, lifeEventsApi, type Scenario, type SimulateRequest } from '@/api/client';
import { ApiError } from '@/api/client';
import { EventGantt } from '@/components/EventGantt';
import { FanChart } from '@/components/FanChart';
import { FlexRulesEditor } from '@/components/FlexRulesEditor';
import { GoalsSection } from '@/components/GoalsSection';
import { IncomeStreamsSection } from '@/components/IncomeStreamsSection';
import { LifeEventsSection } from '@/components/LifeEventsSection';
import { SpendingProfileChart } from '@/components/SpendingProfileChart';
import { YearScrubber } from '@/components/YearScrubber';
import { gbp, pct } from '@/lib/format';
import { emojiFor } from '@/lib/milestone';
export function ScenarioDetail() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const navigate = useNavigate();
const qc = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const scen = useQuery({
queryKey: ['scenarios', id],
queryFn: () => api.scenarios.get(id),
enabled: Number.isFinite(id),
});
const proj = useQuery({
queryKey: ['scenarios', id, 'projection'],
queryFn: () => api.scenarios.projection(id),
enabled: Number.isFinite(id),
retry: (count, err) => {
if (err instanceof ApiError && err.status === 404) return false;
return count < 2;
},
});
const events = useQuery({
queryKey: ['scenarios', id, 'life-events'],
queryFn: () => lifeEventsApi.list(id),
enabled: Number.isFinite(id),
});
const profile = useQuery({
queryKey: ['spending-profile', id],
queryFn: () => api.spendingProfile(id),
enabled: Number.isFinite(id),
staleTime: 0,
refetchOnWindowFocus: true,
});
const yearStats = useQuery({
queryKey: ['year-stats', id, parseInt(searchParams.get('year') ?? '0', 10)],
queryFn: () => api.yearStats(id, parseInt(searchParams.get('year') ?? '0', 10)),
enabled: Number.isFinite(id) && proj.isSuccess,
staleTime: 0,
});
const del = useMutation({
mutationFn: () => api.scenarios.delete(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['scenarios'] });
navigate('/scenarios');
},
});
const sim = useMutation({
mutationFn: (req: SimulateRequest) => api.simulate(req),
});
const horizonYears = scen.data?.horizon_years ?? proj.data?.yearly.length ?? 60;
const maxYear = (proj.data?.yearly.length ?? horizonYears) - 1;
const yearFromUrl = Number(searchParams.get('year'));
const initialYear = Number.isFinite(yearFromUrl) && yearFromUrl >= 0 ? yearFromUrl : 0;
const [year, setYear] = useState<number>(initialYear);
useEffect(() => {
if (year > maxYear && maxYear >= 0) setYear(maxYear);
}, [maxYear, year]);
const setYearAndUrl = (y: number) => {
setYear(y);
const next = new URLSearchParams(searchParams);
next.set('year', String(y));
setSearchParams(next, { replace: true });
};
const milestones = useMemo(
() =>
(events.data ?? [])
.filter((e) => e.enabled)
.map((e) => ({
year_idx: e.year_start,
emoji: emojiFor(e.kind),
label: e.name,
delta_gbp: Number(e.delta_gbp_per_year) !== 0 ? gbp(e.delta_gbp_per_year) : null,
})),
[events.data],
);
const onDelete = () => {
if (!scen.data) return;
if (!confirm(`Delete scenario "${scen.data.name ?? id}"? This can't be undone.`)) return;
del.mutate();
};
const onRunNow = async (s: Scenario) => {
const fresh = await lifeEventsApi.list(s.id);
const flexRules = readFlexRules(s);
sim.mutate({
jurisdiction: s.jurisdiction,
strategy: s.strategy,
leave_uk_year: s.leave_uk_year,
spending_gbp: s.spending_gbp,
nw_seed_gbp: s.nw_seed_gbp,
savings_per_year_gbp: s.savings_per_year_gbp,
horizon_years: s.horizon_years,
n_paths: 5000,
seed: 42,
life_events: fresh.map((e) => ({
year_start: e.year_start,
year_end: e.year_end,
delta_gbp_per_year: e.delta_gbp_per_year,
one_time_amount_gbp: e.one_time_amount_gbp,
category: e.category,
enabled: e.enabled,
})),
flex_rules: flexRules,
});
};
if (!Number.isFinite(id)) return <p className="text-red-700">Invalid scenario id.</p>;
if (scen.isLoading) return <p className="text-slate-500">Loading</p>;
if (scen.isError || !scen.data) {
return (
<div className="rounded-md border border-red-200 bg-red-50 p-4 text-red-800 text-sm">
Couldn&apos;t load scenario {id}.
</div>
);
}
const s = scen.data;
const projection = proj.data;
const projection404 =
proj.isError && proj.error instanceof ApiError && proj.error.status === 404;
return (
<section className="space-y-6">
<div className="text-sm">
<Link to="/scenarios" className="text-slate-500 hover:text-slate-900">
Scenarios
</Link>
</div>
<header className="flex items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-semibold tracking-tight">{s.name ?? s.external_id}</h1>
<p className="text-sm text-slate-500 mt-1">
{s.kind} · {s.jurisdiction} · {s.strategy} · leave UK y{s.leave_uk_year} ·{' '}
{s.glide_path} glide · {s.horizon_years}y horizon
</p>
{s.description && <p className="text-sm text-slate-600 mt-2">{s.description}</p>}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void onRunNow(s)}
disabled={sim.isPending}
className="rounded-md border border-slate-300 bg-white text-sm px-3 py-1.5 hover:bg-slate-50 disabled:opacity-60"
title="Run a fresh MC including this scenario's life events + flex rules"
>
{sim.isPending ? 'Running…' : 'Run now'}
</button>
{s.kind === 'user' && (
<>
<Link
to={`/scenarios/${s.id}/edit`}
className="rounded-md border border-slate-300 bg-white text-sm px-3 py-1.5 hover:bg-slate-50"
>
Edit
</Link>
<button
type="button"
onClick={onDelete}
disabled={del.isPending}
className="rounded-md border border-red-300 text-red-700 text-sm px-3 py-1.5 hover:bg-red-50 disabled:opacity-60"
>
{del.isPending ? 'Deleting…' : 'Delete'}
</button>
</>
)}
</div>
</header>
{del.isError && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-red-800 text-sm">
{String((del.error as Error)?.message ?? del.error)}
</div>
)}
{projection ? (
<>
{/* NW fan with floating stat badges */}
<div className="relative rounded-lg border border-slate-200 bg-white p-5">
<div className="flex items-baseline justify-between mb-2">
<h2 className="text-lg font-semibold">Portfolio fan</h2>
<span className="text-xs text-slate-500">
p10/p50/p90 over {projection.yearly.length}y · {projection.n_paths.toLocaleString()} paths
</span>
</div>
<FanChart
yearly={projection.yearly}
height={460}
showWithdrawal
milestones={milestones}
selectedYear={year}
onSelectYear={setYearAndUrl}
/>
<div className="mt-3">
<YearScrubber value={year} min={0} max={maxYear} onChange={setYearAndUrl} />
</div>
<FloatingStats
year={year}
maxYear={maxYear}
successRate={projection.success_rate}
p50End={projection.p50_ending_gbp}
netWorth={yearStats.data?.net_worth_p50}
changeNw={yearStats.data?.change_in_nw}
spending={yearStats.data?.spending}
taxes={yearStats.data?.taxes}
effectiveRate={yearStats.data?.effective_tax_rate}
age={yearStats.data?.age ?? null}
calendarYear={yearStats.data?.calendar_year}
/>
</div>
{/* Spending profile */}
<div className="rounded-lg border border-slate-200 bg-white p-5">
<div className="flex items-baseline justify-between mb-2">
<h2 className="text-lg font-semibold">Spending profile</h2>
<Legend />
</div>
{profile.data ? (
<SpendingProfileChart
points={profile.data.points}
selectedYear={year}
onSelectYear={setYearAndUrl}
/>
) : (
<p className="text-sm text-slate-500">Loading</p>
)}
</div>
{/* Interactive Gantt */}
<div className="rounded-lg border border-slate-200 bg-white p-5">
<div className="flex items-baseline justify-between mb-2">
<h2 className="text-lg font-semibold">Life events</h2>
<span className="text-xs text-slate-500">
Click empty space to add · drag bars to move · drag edges to resize
</span>
</div>
<EventGantt
scenarioId={id}
events={events.data ?? []}
horizonYears={horizonYears}
/>
</div>
<FlexRulesEditor scenario={s} />
</>
) : projection404 ? (
<div className="rounded-lg border border-slate-200 bg-white p-8 text-center text-slate-500">
<p className="font-medium text-slate-700">No projection yet.</p>
<p className="text-sm mt-2">
Run <code className="px-1">python -m fire_planner recompute-all</code> or{' '}
<code>POST /recompute</code> to fill in MC projections for all scenarios.
</p>
</div>
) : proj.isLoading ? (
<p className="text-slate-500">Loading projection</p>
) : (
<div className="rounded-md border border-red-200 bg-red-50 p-4 text-red-800 text-sm">
{String((proj.error as Error)?.message ?? proj.error)}
</div>
)}
{sim.data && (
<div className="rounded-lg border border-slate-200 bg-white p-5">
<div className="flex items-baseline justify-between mb-3">
<h2 className="text-lg font-semibold">Live preview run</h2>
<span className="text-xs text-slate-500">
{sim.data.elapsed_seconds}s · 5,000 paths · success {pct(sim.data.success_rate)}
</span>
</div>
<FanChart yearly={sim.data.yearly} height={360} showWithdrawal />
</div>
)}
{sim.isError && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-red-800 text-sm">
{String((sim.error as Error)?.message ?? sim.error)}
</div>
)}
{/* Legacy form sections — collapsed by default. The chart UI above
is the primary editor; these stay for bulk edit + accessibility. */}
<details className="rounded-lg border border-slate-200 bg-white">
<summary className="cursor-pointer select-none px-5 py-3 text-sm font-medium text-slate-700">
Form-based editors (income streams · goals · life events table)
</summary>
<div className="p-5 space-y-5 border-t border-slate-100">
<IncomeStreamsSection scenarioId={id} />
<GoalsSection scenarioId={id} probabilities={projection?.goals_probability} />
<LifeEventsSection scenarioId={id} />
</div>
</details>
</section>
);
}
function FloatingStats(props: {
year: number;
maxYear: number;
successRate: string;
p50End: string;
netWorth?: string;
changeNw?: string;
spending?: string;
taxes?: string;
effectiveRate?: string;
age: number | null;
calendarYear?: number;
}) {
return (
<div className="absolute top-3 right-3 grid grid-cols-2 gap-2 max-w-xs pointer-events-none">
<Badge label="Year" value={String(props.calendarYear ?? `y${props.year}`)} />
<Badge label="Age" value={props.age != null ? String(props.age) : '—'} />
<Badge label="Net Worth" value={props.netWorth ? gbp(props.netWorth) : '—'} accent />
<Badge
label="Δ NW"
value={props.changeNw ? gbp(props.changeNw) : '—'}
signed={props.changeNw}
/>
<Badge label="Spending" value={props.spending ? gbp(props.spending) : '—'} />
<Badge label="Eff. tax" value={props.effectiveRate ? pct(props.effectiveRate) : '—'} />
</div>
);
}
function Badge({
label,
value,
accent,
signed,
}: {
label: string;
value: string;
accent?: boolean;
signed?: string;
}) {
let cls = 'text-slate-700';
if (accent) cls = 'text-emerald-700';
if (signed != null) {
const n = Number(signed);
if (n > 0) cls = 'text-emerald-700';
else if (n < 0) cls = 'text-red-600';
}
return (
<div className="rounded-md border border-slate-200 bg-white/90 backdrop-blur px-2 py-1 shadow-sm pointer-events-auto">
<div className="text-[9px] uppercase tracking-wide text-slate-500">{label}</div>
<div className={`text-xs font-semibold tabular-nums ${cls}`}>{value}</div>
</div>
);
}
function Legend() {
return (
<div className="flex items-center gap-3 text-xs">
<Swatch color="rgb(100, 116, 139)" label="Base" />
<Swatch color="rgb(16, 185, 129)" label="Essential" />
<Swatch color="rgb(245, 158, 11)" label="Discretionary" />
<Swatch color="rgb(239, 68, 68)" label="Flex cut" />
</div>
);
}
function Swatch({ color, label }: { color: string; label: string }) {
return (
<span className="flex items-center gap-1.5">
<span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: color }} />
<span className="text-slate-600">{label}</span>
</span>
);
}
function readFlexRules(s: Scenario): { from_ath_pct: string; cut_discretionary_pct: string }[] {
const blob = s.config_json as Record<string, unknown>;
const raw = blob?.flex_rules;
if (!Array.isArray(raw)) return [];
return raw
.filter((r): r is Record<string, unknown> => typeof r === 'object' && r !== null)
.map((r) => ({
from_ath_pct: String(r.from_ath_pct ?? 0),
cut_discretionary_pct: String(r.cut_discretionary_pct ?? 0),
}));
}