fire-planner: ProjectionLab parity Wave 1 — tabbed shell, year stats, goals,
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

income streams, Sankey cashflow, progress overlay, settings sub-pages

Wave 1 (9 features across 4 streams):

Stream A — dashboard skeleton
  1.A.1 ScenarioShell with top tabs (Plan/Cash Flow/Tax Analytics/Compare/
        Reports/Estate/Settings) + left Sidebar with Plans switcher.
  1.A.2 GET /scenarios/{id}/year-stats?year=N returning per-year metrics
        (NW, Δ NW, taxable income, taxes, eff. rate, spending, contribs,
        investment growth). YearScrubber + YearStatsPanel render the
        right-hand sidebar; URL ?year= preserves selection.
  1.A.3 FanChart gains optional `milestones` prop (lib/milestone.ts maps
        life_event.kind → emoji) + selectedYear marker line.

Stream B — goals + progress
  1.B.1 New goals_eval module: target_nw_by_year / never_run_out /
        target_real_income probability evaluation. Wired into POST
        /simulate (exact, per-path) and GET /scenarios/{id}/projection
        (approximated from persisted fan via percentile interpolation).
        GoalsSection renders pass/fail badges.
  1.B.2 GET /scenarios/{id}/progress overlays AccountSnapshot totals on
        the projection fan; ProgressPage shows variance side-panel.

Stream C — income + cashflow
  1.C.1 New IncomeStream model + alembic 0003 + CRUD endpoints. Engine
        aggregates streams into per-year inflows + taxable arrays;
        income tax routes through the jurisdiction tax engine.
        IncomeStreamsSection on Plan tab.
  1.C.2 GET /scenarios/{id}/cashflow?year=N returns sources/sinks for
        an ECharts Sankey (sums conserve). CashflowTab body.

Stream D — settings
  1.D.1 SettingsTab + sub-nav (Milestones/Rates/Dividends/Bonds/Tax/
        Metrics/Other/Notes); placeholder cards for unbuilt sub-pages.
  1.D.2 LifeEventsSection relocated to /scenarios/:id/settings.
  1.D.3 RatesSettings (Fixed/Historical/Advanced segmented + per-asset
        cards). SimulateRequest gains rates_mode, inflation_pct,
        stocks/bonds growth + dividend, stocks_allocation. New
        build_fixed_paths() in simulator. Real-return arithmetic
        verified against (1+g+d)/(1+i)−1 ≈ 5.4%.
  1.D.4 NotesSettings — markdown textarea, save-on-blur, stored in
        scenario.config_json.notes.

Backend: 238 pytest pass (+19 new), mypy + ruff clean.
Frontend: typecheck + 7 unit tests + production build clean.

Roadmap for Wave 2-N is documented in the implementation plan.
This commit is contained in:
Viktor Barzin 2026-05-10 12:49:44 +00:00
parent e12e8f9290
commit 9cc781a8d6
42 changed files with 3765 additions and 80 deletions

View file

@ -1,24 +1,35 @@
/**
* Scenario detail params + the latest persisted MC projection.
* Plan-tab body for a scenario Wave 1.A.x.
*
* Reuses FanChart from the What-If page. If the scenario has no MC run
* yet, prompts the user to run /recompute.
* Layout:
*
* header + summary cards
* FanChart with milestone markers Year stats
* Year scrubber panel
* Income streams · Goals · Life events
*
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate, useParams } from 'react-router-dom';
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 { FanChart } from '@/components/FanChart';
import { GoalsSection } from '@/components/GoalsSection';
import { IncomeStreamsSection } from '@/components/IncomeStreamsSection';
import { LifeEventsSection } from '@/components/LifeEventsSection';
import { YearScrubber } from '@/components/YearScrubber';
import { YearStatsPanel } from '@/components/YearStatsPanel';
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],
@ -30,12 +41,17 @@ export function ScenarioDetail() {
queryFn: () => api.scenarios.projection(id),
enabled: Number.isFinite(id),
retry: (count, err) => {
// Don't retry the 404 — it's the "no run yet" empty state.
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 del = useMutation({
mutationFn: () => api.scenarios.delete(id),
onSuccess: () => {
@ -48,14 +64,45 @@ 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;
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 (!confirm(`Delete scenario "${scen.data?.name ?? id}"? This can't be undone.`)) return;
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) => {
// Pull events fresh so the run reflects whatever the user just edited.
const events = await lifeEventsApi.list(s.id);
const fresh = await lifeEventsApi.list(s.id);
sim.mutate({
jurisdiction: s.jurisdiction,
strategy: s.strategy,
@ -66,7 +113,7 @@ export function ScenarioDetail() {
horizon_years: s.horizon_years,
n_paths: 5000,
seed: 42,
life_events: events.map((e) => ({
life_events: fresh.map((e) => ({
year_start: e.year_start,
year_end: e.year_end,
delta_gbp_per_year: e.delta_gbp_per_year,
@ -79,7 +126,6 @@ export function ScenarioDetail() {
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 (
@ -91,7 +137,8 @@ export function ScenarioDetail() {
const s = scen.data;
const projection = proj.data;
const projection404 = proj.isError && proj.error instanceof ApiError && proj.error.status === 404;
const projection404 =
proj.isError && proj.error instanceof ApiError && proj.error.status === 404;
return (
<section className="space-y-6">
@ -154,26 +201,31 @@ export function ScenarioDetail() {
</div>
{projection ? (
<>
<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)} />
<Stat label="Median lifetime tax" value={gbp(projection.median_lifetime_tax_gbp)} />
<Stat
label="Median years to ruin"
value={projection.median_years_to_ruin ?? 'never'}
/>
<Stat label="MC paths" value={projection.n_paths.toLocaleString()} />
<Stat label="Run at" value={new Date(projection.run_at).toLocaleString()} />
<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)} />
</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}
selectedYear={year}
onSelectYear={setYearAndUrl}
/>
<div className="mt-3">
<YearScrubber value={year} min={0} max={maxYear} onChange={setYearAndUrl} />
</div>
</div>
</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 />
</div>
</>
<YearStatsPanel scenarioId={id} year={year} />
</div>
) : 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>
@ -207,8 +259,9 @@ export function ScenarioDetail() {
</div>
)}
<IncomeStreamsSection scenarioId={id} />
<GoalsSection scenarioId={id} probabilities={projection?.goals_probability} />
<LifeEventsSection scenarioId={id} />
<GoalsSection scenarioId={id} />
</section>
);
}