fire-planner: Wave 2 chart-first — flex spending, categorised life
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.
This commit is contained in:
Viktor Barzin 2026-05-10 16:49:04 +00:00
parent 9cc781a8d6
commit 64eb90c3dc
19 changed files with 2581 additions and 88 deletions

View file

@ -1,13 +1,19 @@
/**
* Plan-tab body for a scenario Wave 1.A.x.
* Plan-tab body Wave 2 chart-first redesign.
*
* Layout:
*
* header + summary cards
* FanChart with milestone markers Year stats
* Year scrubber panel
* Income streams · Goals · Life events
*
* 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';
@ -15,12 +21,14 @@ 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 { YearStatsPanel } from '@/components/YearStatsPanel';
import { gbp, pct } from '@/lib/format';
import { emojiFor } from '@/lib/milestone';
@ -52,6 +60,21 @@ export function ScenarioDetail() {
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: () => {
@ -103,6 +126,7 @@ export function ScenarioDetail() {
const onRunNow = async (s: Scenario) => {
const fresh = await lifeEventsApi.list(s.id);
const flexRules = readFlexRules(s);
sim.mutate({
jurisdiction: s.jurisdiction,
strategy: s.strategy,
@ -118,14 +142,14 @@ export function ScenarioDetail() {
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 (!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 (
@ -163,7 +187,7 @@ export function ScenarioDetail() {
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"
title="Run a fresh MC including this scenario's life events + flex rules"
>
{sim.isPending ? 'Running…' : 'Run now'}
</button>
@ -193,39 +217,76 @@ export function ScenarioDetail() {
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Stat label="Spending" value={gbp(s.spending_gbp)} />
<Stat label="NW seed" value={gbp(s.nw_seed_gbp)} />
<Stat label="Annual savings" value={gbp(s.savings_per_year_gbp)} />
<Stat label="Horizon" value={`${s.horizon_years}y`} />
</div>
{projection ? (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-6">
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Stat label="Success rate" value={pct(projection.success_rate)} accent />
<Stat label="Median ending NW" value={gbp(projection.p50_ending_gbp)} />
<Stat label="P10 ending" value={gbp(projection.p10_ending_gbp)} />
<Stat label="P90 ending" value={gbp(projection.p90_ending_gbp)} />
<>
{/* 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>
<div className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="text-lg font-semibold mb-2">Portfolio fan</h2>
<FanChart
yearly={projection.yearly}
height={420}
showWithdrawal
milestones={milestones}
<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}
/>
<div className="mt-3">
<YearScrubber value={year} min={0} max={maxYear} onChange={setYearAndUrl} />
</div>
</div>
) : (
<p className="text-sm text-slate-500">Loading</p>
)}
</div>
<YearStatsPanel scenarioId={id} year={year} />
</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>
@ -259,32 +320,105 @@ export function ScenarioDetail() {
</div>
)}
<IncomeStreamsSection scenarioId={id} />
<GoalsSection scenarioId={id} probabilities={projection?.goals_probability} />
<LifeEventsSection scenarioId={id} />
{/* 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 Stat({
label,
value,
accent,
}: {
label: string;
value: string | number;
accent?: boolean;
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="rounded-md border border-slate-200 bg-white p-3">
<div className="text-xs uppercase tracking-wide text-slate-500">{label}</div>
<div
className={`text-xl font-semibold tabular-nums mt-1 ${
accent ? 'text-emerald-700' : ''
}`}
>
{value}
</div>
<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),
}));
}