Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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.
424 lines
16 KiB
TypeScript
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'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),
|
|
}));
|
|
}
|