fire-planner: Wave 2 chart-first — flex spending, categorised life
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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:
parent
9cc781a8d6
commit
64eb90c3dc
19 changed files with 2581 additions and 88 deletions
|
|
@ -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),
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue