fire-planner: ProjectionLab parity Wave 1 — tabbed shell, year stats, goals,
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
e12e8f9290
commit
9cc781a8d6
42 changed files with 3765 additions and 80 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue