fire-planner: life-event spending bumps now reflected in fan + auto-
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

refresh on scenario edits

Two fixes for the user's report that adding a £100k life-event spend
didn't change the chart:

Engine (simulator.py)
- New `extra_outflows` param. cashflow_adjustments still drains the
  portfolio at start-of-year as before, but the simulator now ALSO
  records the spending in `withdrawal_hist[p, y]` so the chart's red
  median-withdrawal trace shows the bump. Without this, the £100k
  silently came out of the portfolio but the user-facing withdrawal
  trace stayed at the strategy's flat 4% draw.
- simulate.py wires extra_outflows = essential + discretionary
  category outflows from life events.

UX (ScenarioDetail.tsx)
- New auto-refresh: when life events / income streams / flex rules
  change for a scenario, the page fires `/simulate` automatically
  with 2,000 paths and uses the result as the primary fan/year-stats
  source. The persisted MC run is only consulted as a fallback for
  scenarios with no overrides.
- Fan chart title gains a "live preview · Xs · Ny" pill while a sim
  is current, and "re-running…" while a fresh one is in flight.
- Removed the now-redundant "Live preview run" duplicate card lower
  down — the main chart IS the live preview.
- Year-stats badge row reads from sim.data when available so changes
  propagate immediately to NW / Δ NW / Spending / Taxes.

247 pytest pass (+1 new); mypy + ruff clean; frontend typecheck/test/
build green.
This commit is contained in:
Viktor Barzin 2026-05-10 19:17:57 +00:00
parent f9084d1a15
commit eb0dd3ddbf
4 changed files with 199 additions and 49 deletions

View file

@ -19,7 +19,13 @@ 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 {
api,
incomeStreamsApi,
lifeEventsApi,
type Scenario,
type SimulateRequest,
} from '@/api/client';
import { ApiError } from '@/api/client';
import { EventGantt } from '@/components/EventGantt';
import { FanChart } from '@/components/FanChart';
@ -60,6 +66,12 @@ export function ScenarioDetail() {
enabled: Number.isFinite(id),
});
const incomeStreams = useQuery({
queryKey: ['scenarios', id, 'income-streams'],
queryFn: () => incomeStreamsApi.list(id),
enabled: Number.isFinite(id),
});
const profile = useQuery({
queryKey: ['spending-profile', id],
queryFn: () => api.spendingProfile(id),
@ -87,8 +99,89 @@ export function ScenarioDetail() {
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;
// Auto-refresh: when life events / income streams / flex rules
// change, fire a fresh /simulate so the fan + year-stats reflect the
// user's edits without requiring a manual "Run now". Signature is a
// stable string so the effect only re-fires on real changes.
const simSignature = useMemo(() => {
if (!scen.data) return null;
const eventsSig = (events.data ?? []).map((e) => ({
ys: e.year_start,
ye: e.year_end,
d: e.delta_gbp_per_year,
ot: e.one_time_amount_gbp,
c: e.category,
en: e.enabled,
}));
const streamsSig = (incomeStreams.data ?? []).map((s) => ({
k: s.kind,
ys: s.start_year,
ye: s.end_year,
a: s.amount_gbp_per_year,
g: s.growth_pct,
tt: s.tax_treatment,
en: s.enabled,
}));
const flexSig = readFlexRules(scen.data);
return JSON.stringify({ eventsSig, streamsSig, flexSig });
}, [scen.data, events.data, incomeStreams.data]);
useEffect(() => {
if (!scen.data || simSignature === null) return;
const hasOverrides =
(events.data && events.data.length > 0) ||
(incomeStreams.data && incomeStreams.data.length > 0) ||
readFlexRules(scen.data).length > 0;
if (!hasOverrides) return;
void runLiveSim(scen.data, events.data ?? [], incomeStreams.data ?? []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [simSignature]);
const runLiveSim = async (
s: Scenario,
fresh: typeof events.data extends infer T ? (T extends readonly (infer U)[] ? U[] : never) : never,
streams: typeof incomeStreams.data extends infer T
? T extends readonly (infer U)[]
? U[]
: never
: never,
) => {
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: 2000,
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,
})),
income_streams: streams.map((s) => ({
kind: s.kind,
start_year: s.start_year,
end_year: s.end_year,
amount_gbp_per_year: s.amount_gbp_per_year,
growth_pct: s.growth_pct,
tax_treatment: s.tax_treatment,
enabled: s.enabled,
})),
flex_rules: readFlexRules(s),
});
};
// Primary chart source: live sim when available, else persisted run.
const liveYearly = sim.data?.yearly ?? proj.data?.yearly;
const horizonYears =
scen.data?.horizon_years ?? liveYearly?.length ?? 60;
const maxYear = (liveYearly?.length ?? horizonYears) - 1;
const yearFromUrl = Number(searchParams.get('year'));
const initialYear = Number.isFinite(yearFromUrl) && yearFromUrl >= 0 ? yearFromUrl : 0;
@ -126,29 +219,46 @@ 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,
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,
});
const streams = await incomeStreamsApi.list(s.id);
void runLiveSim(s, fresh, streams);
};
// Derive a year-N stats object from the live sim, when available.
// Falls back to the yearStats query (which reads the persisted MC run)
// for scenarios with no overrides where auto-refresh didn't fire.
const liveYearStats = useMemo(() => {
if (!sim.data || !liveYearly) return null;
const row = liveYearly[year];
if (!row) return null;
const prev = year > 0 ? liveYearly[year - 1] : null;
const nw = Number(row.p50_portfolio_gbp);
const prevNw = prev
? Number(prev.p50_portfolio_gbp)
: Number(scen.data?.nw_seed_gbp ?? 0);
return {
year_idx: year,
calendar_year: new Date().getFullYear() + year,
age: null as number | null,
net_worth_p50: String(nw),
change_in_nw: String(nw - prevNw),
taxable_income: '0',
taxes: row.p50_tax_gbp,
effective_tax_rate:
nw > 0
? String(Number(row.p50_tax_gbp) / Math.max(1, Number(row.p50_withdrawal_gbp)))
: '0',
spending: row.p50_withdrawal_gbp,
contributions: '0',
investment_growth: '0',
liquid_nw: null,
expenses: null,
savings_rate: null,
portfolio_allocations: null,
};
}, [sim.data, liveYearly, year, scen.data?.nw_seed_gbp]);
const activeYearStats = liveYearStats ?? yearStats.data;
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) {
@ -217,32 +327,44 @@ export function ScenarioDetail() {
</div>
)}
{projection ? (
{projection || sim.data ? (
<>
{/* Stats badges row — sits above the chart, not on top of it */}
<StatsBadges
year={year}
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}
successRate={projection.success_rate}
ruined={isRuined(yearStats.data?.net_worth_p50)}
netWorth={activeYearStats?.net_worth_p50}
changeNw={activeYearStats?.change_in_nw}
spending={activeYearStats?.spending}
taxes={activeYearStats?.taxes}
effectiveRate={activeYearStats?.effective_tax_rate}
age={activeYearStats?.age ?? null}
calendarYear={activeYearStats?.calendar_year}
successRate={sim.data?.success_rate ?? projection?.success_rate ?? '0'}
ruined={isRuined(activeYearStats?.net_worth_p50)}
/>
{/* NW fan + scrubber */}
<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">Portfolio fan</h2>
<h2 className="text-lg font-semibold">
Portfolio fan
{sim.data && (
<span className="ml-2 text-xs font-normal text-emerald-700 bg-emerald-50 border border-emerald-200 rounded px-1.5 py-0.5">
live preview · {sim.data.elapsed_seconds}s · {sim.data.yearly.length}y
</span>
)}
{sim.isPending && (
<span className="ml-2 text-xs font-normal text-slate-500">
re-running
</span>
)}
</h2>
<span className="text-xs text-slate-500">
p10/p50/p90 over {projection.yearly.length}y · {projection.n_paths.toLocaleString()} paths
p10/p50/p90 · {sim.data ? '2,000' : projection?.n_paths.toLocaleString()} paths
</span>
</div>
<FanChart
yearly={projection.yearly}
yearly={liveYearly ?? []}
height={420}
showWithdrawal
milestones={milestones}
@ -304,17 +426,6 @@ export function ScenarioDetail() {
</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)}