frontend: What-If page with fan chart driven by /simulate
New /what-if route. Sticky form on the left (jurisdiction, strategy,
glide, NW seed, spending, savings, horizon, optional floor for
vpw_floor, MC paths) submits to POST /simulate; results panel renders
summary stats + the new FanChart.
FanChart component layers seven series:
- p10 invisible baseline (line, transparent)
- p10→p25 stacked area (low opacity)
- p25→p75 stacked area (IQR, mid opacity)
- p75→p90 stacked area (low opacity)
- p50 solid median line (drawn last, prominent)
- p10 + p90 dashed lines on top of the bands
Stacking deltas keeps the band fills clean — plotting raw quantiles
each as their own area would overlap badly. Reusable by scenario
detail in the next chunk (same ProjectionPoint[] shape).
5 tests pass (was 2). 470 KB gzipped (ECharts).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Monte Carlo fan chart — confidence bands (p10/p25/p75/p90) + median line.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Renders five layered series in ECharts:
|
|
|
|
|
|
* 1. p10 invisible baseline (line, transparent, no fill)
|
|
|
|
|
|
* 2. (p25 - p10) band stacks on p10 with a low-opacity fill
|
|
|
|
|
|
* 3. (p75 - p25) band stacks on top with mid-opacity (the inter-quartile)
|
|
|
|
|
|
* 4. (p90 - p75) band stacks on top with low-opacity (upper tail)
|
|
|
|
|
|
* 5. p50 median standalone solid line, drawn last so it sits above
|
|
|
|
|
|
*
|
|
|
|
|
|
* Stacking the deltas instead of plotting raw quantiles keeps the band
|
|
|
|
|
|
* fills clean — ECharts area renderers fill to the *baseline* by default,
|
|
|
|
|
|
* which would overlap badly if we just plotted p10/p25/p50/p75/p90 lines
|
|
|
|
|
|
* each with their own area.
|
|
|
|
|
|
*/
|
|
|
|
|
|
import { useMemo } from 'react';
|
|
|
|
|
|
import ReactECharts from 'echarts-for-react';
|
|
|
|
|
|
import type { EChartsOption } from 'echarts';
|
|
|
|
|
|
|
|
|
|
|
|
import type { ProjectionPoint } from '@/api/client';
|
|
|
|
|
|
import { gbpCompact } from '@/lib/format';
|
fire-planner: ProjectionLab parity Wave 1 — tabbed shell, year stats, goals,
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.
2026-05-10 12:49:44 +00:00
|
|
|
|
import type { Milestone } from '@/lib/milestone';
|
frontend: What-If page with fan chart driven by /simulate
New /what-if route. Sticky form on the left (jurisdiction, strategy,
glide, NW seed, spending, savings, horizon, optional floor for
vpw_floor, MC paths) submits to POST /simulate; results panel renders
summary stats + the new FanChart.
FanChart component layers seven series:
- p10 invisible baseline (line, transparent)
- p10→p25 stacked area (low opacity)
- p25→p75 stacked area (IQR, mid opacity)
- p75→p90 stacked area (low opacity)
- p50 solid median line (drawn last, prominent)
- p10 + p90 dashed lines on top of the bands
Stacking deltas keeps the band fills clean — plotting raw quantiles
each as their own area would overlap badly. Reusable by scenario
detail in the next chunk (same ProjectionPoint[] shape).
5 tests pass (was 2). 470 KB gzipped (ECharts).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
|
yearly: ProjectionPoint[];
|
|
|
|
|
|
height?: number;
|
|
|
|
|
|
showWithdrawal?: boolean;
|
fire-planner: ProjectionLab parity Wave 1 — tabbed shell, year stats, goals,
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.
2026-05-10 12:49:44 +00:00
|
|
|
|
milestones?: Milestone[];
|
|
|
|
|
|
selectedYear?: number | null;
|
|
|
|
|
|
onSelectYear?: (year: number) => void;
|
frontend: What-If page with fan chart driven by /simulate
New /what-if route. Sticky form on the left (jurisdiction, strategy,
glide, NW seed, spending, savings, horizon, optional floor for
vpw_floor, MC paths) submits to POST /simulate; results panel renders
summary stats + the new FanChart.
FanChart component layers seven series:
- p10 invisible baseline (line, transparent)
- p10→p25 stacked area (low opacity)
- p25→p75 stacked area (IQR, mid opacity)
- p75→p90 stacked area (low opacity)
- p50 solid median line (drawn last, prominent)
- p10 + p90 dashed lines on top of the bands
Stacking deltas keeps the band fills clean — plotting raw quantiles
each as their own area would overlap badly. Reusable by scenario
detail in the next chunk (same ProjectionPoint[] shape).
5 tests pass (was 2). 470 KB gzipped (ECharts).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
fire-planner: ProjectionLab parity Wave 1 — tabbed shell, year stats, goals,
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.
2026-05-10 12:49:44 +00:00
|
|
|
|
export function FanChart({
|
|
|
|
|
|
yearly,
|
|
|
|
|
|
height = 360,
|
|
|
|
|
|
showWithdrawal = false,
|
|
|
|
|
|
milestones,
|
|
|
|
|
|
selectedYear,
|
|
|
|
|
|
onSelectYear,
|
|
|
|
|
|
}: Props) {
|
|
|
|
|
|
const option = useMemo<EChartsOption>(
|
|
|
|
|
|
() => buildFan(yearly, showWithdrawal, milestones, selectedYear),
|
|
|
|
|
|
[yearly, showWithdrawal, milestones, selectedYear],
|
|
|
|
|
|
);
|
frontend: What-If page with fan chart driven by /simulate
New /what-if route. Sticky form on the left (jurisdiction, strategy,
glide, NW seed, spending, savings, horizon, optional floor for
vpw_floor, MC paths) submits to POST /simulate; results panel renders
summary stats + the new FanChart.
FanChart component layers seven series:
- p10 invisible baseline (line, transparent)
- p10→p25 stacked area (low opacity)
- p25→p75 stacked area (IQR, mid opacity)
- p75→p90 stacked area (low opacity)
- p50 solid median line (drawn last, prominent)
- p10 + p90 dashed lines on top of the bands
Stacking deltas keeps the band fills clean — plotting raw quantiles
each as their own area would overlap badly. Reusable by scenario
detail in the next chunk (same ProjectionPoint[] shape).
5 tests pass (was 2). 470 KB gzipped (ECharts).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
|
|
|
|
if (yearly.length === 0) {
|
|
|
|
|
|
return <p className="text-sm text-slate-500">No projection data.</p>;
|
|
|
|
|
|
}
|
fire-planner: ProjectionLab parity Wave 1 — tabbed shell, year stats, goals,
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.
2026-05-10 12:49:44 +00:00
|
|
|
|
const handlers = onSelectYear
|
|
|
|
|
|
? {
|
|
|
|
|
|
click: (params: { name?: string }) => {
|
|
|
|
|
|
const year = Number(params.name);
|
|
|
|
|
|
if (!Number.isNaN(year)) onSelectYear(year);
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<ReactECharts
|
|
|
|
|
|
option={option}
|
|
|
|
|
|
style={{ height, width: '100%' }}
|
|
|
|
|
|
notMerge
|
|
|
|
|
|
lazyUpdate
|
|
|
|
|
|
onEvents={handlers}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
frontend: What-If page with fan chart driven by /simulate
New /what-if route. Sticky form on the left (jurisdiction, strategy,
glide, NW seed, spending, savings, horizon, optional floor for
vpw_floor, MC paths) submits to POST /simulate; results panel renders
summary stats + the new FanChart.
FanChart component layers seven series:
- p10 invisible baseline (line, transparent)
- p10→p25 stacked area (low opacity)
- p25→p75 stacked area (IQR, mid opacity)
- p75→p90 stacked area (low opacity)
- p50 solid median line (drawn last, prominent)
- p10 + p90 dashed lines on top of the bands
Stacking deltas keeps the band fills clean — plotting raw quantiles
each as their own area would overlap badly. Reusable by scenario
detail in the next chunk (same ProjectionPoint[] shape).
5 tests pass (was 2). 470 KB gzipped (ECharts).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
fire-planner: ProjectionLab parity Wave 1 — tabbed shell, year stats, goals,
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.
2026-05-10 12:49:44 +00:00
|
|
|
|
function buildFan(
|
|
|
|
|
|
yearly: ProjectionPoint[],
|
|
|
|
|
|
showWithdrawal: boolean,
|
|
|
|
|
|
milestones?: Milestone[],
|
|
|
|
|
|
selectedYear?: number | null,
|
|
|
|
|
|
): EChartsOption {
|
frontend: What-If page with fan chart driven by /simulate
New /what-if route. Sticky form on the left (jurisdiction, strategy,
glide, NW seed, spending, savings, horizon, optional floor for
vpw_floor, MC paths) submits to POST /simulate; results panel renders
summary stats + the new FanChart.
FanChart component layers seven series:
- p10 invisible baseline (line, transparent)
- p10→p25 stacked area (low opacity)
- p25→p75 stacked area (IQR, mid opacity)
- p75→p90 stacked area (low opacity)
- p50 solid median line (drawn last, prominent)
- p10 + p90 dashed lines on top of the bands
Stacking deltas keeps the band fills clean — plotting raw quantiles
each as their own area would overlap badly. Reusable by scenario
detail in the next chunk (same ProjectionPoint[] shape).
5 tests pass (was 2). 470 KB gzipped (ECharts).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
|
|
|
|
const years = yearly.map((p) => p.year_idx);
|
|
|
|
|
|
const p10 = yearly.map((p) => num(p.p10_portfolio_gbp));
|
|
|
|
|
|
const p25 = yearly.map((p) => num(p.p25_portfolio_gbp));
|
|
|
|
|
|
const p50 = yearly.map((p) => num(p.p50_portfolio_gbp));
|
|
|
|
|
|
const p75 = yearly.map((p) => num(p.p75_portfolio_gbp));
|
|
|
|
|
|
const p90 = yearly.map((p) => num(p.p90_portfolio_gbp));
|
|
|
|
|
|
const wd = yearly.map((p) => num(p.p50_withdrawal_gbp));
|
|
|
|
|
|
|
|
|
|
|
|
// Deltas — what each band stacks on top of the previous.
|
|
|
|
|
|
const d_25_10 = p25.map((v, i) => Math.max(0, v - p10[i]!));
|
|
|
|
|
|
const d_75_25 = p75.map((v, i) => Math.max(0, v - p25[i]!));
|
|
|
|
|
|
const d_90_75 = p90.map((v, i) => Math.max(0, v - p75[i]!));
|
|
|
|
|
|
|
|
|
|
|
|
const series: EChartsOption['series'] = [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'p10',
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
stack: 'fan',
|
|
|
|
|
|
data: p10,
|
|
|
|
|
|
lineStyle: { opacity: 0 },
|
|
|
|
|
|
itemStyle: { opacity: 0 },
|
|
|
|
|
|
symbol: 'none',
|
|
|
|
|
|
tooltip: { show: false },
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'p10–p25',
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
stack: 'fan',
|
|
|
|
|
|
data: d_25_10,
|
|
|
|
|
|
lineStyle: { opacity: 0 },
|
|
|
|
|
|
symbol: 'none',
|
|
|
|
|
|
areaStyle: { color: 'rgba(80, 110, 230, 0.12)' },
|
|
|
|
|
|
tooltip: { show: false },
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'p25–p75 (IQR)',
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
stack: 'fan',
|
|
|
|
|
|
data: d_75_25,
|
|
|
|
|
|
lineStyle: { opacity: 0 },
|
|
|
|
|
|
symbol: 'none',
|
|
|
|
|
|
areaStyle: { color: 'rgba(80, 110, 230, 0.28)' },
|
|
|
|
|
|
tooltip: { show: false },
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'p75–p90',
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
stack: 'fan',
|
|
|
|
|
|
data: d_90_75,
|
|
|
|
|
|
lineStyle: { opacity: 0 },
|
|
|
|
|
|
symbol: 'none',
|
|
|
|
|
|
areaStyle: { color: 'rgba(80, 110, 230, 0.12)' },
|
|
|
|
|
|
tooltip: { show: false },
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'median',
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
data: p50,
|
|
|
|
|
|
lineStyle: { width: 2, color: 'rgb(40, 70, 200)' },
|
|
|
|
|
|
itemStyle: { color: 'rgb(40, 70, 200)' },
|
|
|
|
|
|
symbol: 'none',
|
|
|
|
|
|
smooth: true,
|
|
|
|
|
|
z: 10,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'p10',
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
data: p10,
|
|
|
|
|
|
lineStyle: { width: 1, color: 'rgba(40, 70, 200, 0.5)', type: 'dashed' },
|
|
|
|
|
|
symbol: 'none',
|
|
|
|
|
|
smooth: true,
|
|
|
|
|
|
z: 5,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'p90',
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
data: p90,
|
|
|
|
|
|
lineStyle: { width: 1, color: 'rgba(40, 70, 200, 0.5)', type: 'dashed' },
|
|
|
|
|
|
symbol: 'none',
|
|
|
|
|
|
smooth: true,
|
|
|
|
|
|
z: 5,
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
fire-planner: ProjectionLab parity Wave 1 — tabbed shell, year stats, goals,
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.
2026-05-10 12:49:44 +00:00
|
|
|
|
if (milestones && milestones.length > 0) {
|
|
|
|
|
|
series.push({
|
|
|
|
|
|
name: 'milestones',
|
|
|
|
|
|
type: 'scatter',
|
|
|
|
|
|
data: milestones
|
|
|
|
|
|
.filter((m) => m.year_idx >= 0 && m.year_idx < yearly.length)
|
|
|
|
|
|
.map((m) => ({
|
|
|
|
|
|
name: m.label,
|
|
|
|
|
|
value: [m.year_idx, p50[m.year_idx] ?? 0],
|
|
|
|
|
|
symbol: `text:${m.emoji}`,
|
|
|
|
|
|
symbolSize: 26,
|
|
|
|
|
|
label: { show: true, formatter: m.emoji, fontSize: 18 },
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
formatter: () =>
|
|
|
|
|
|
[
|
|
|
|
|
|
`<b>${m.label}</b>`,
|
|
|
|
|
|
`year ${m.year_idx}`,
|
|
|
|
|
|
m.delta_gbp ? `Δ ${m.delta_gbp}` : '',
|
|
|
|
|
|
]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join('<br/>'),
|
|
|
|
|
|
},
|
|
|
|
|
|
})),
|
|
|
|
|
|
symbol: 'circle',
|
|
|
|
|
|
symbolSize: 24,
|
|
|
|
|
|
itemStyle: { color: '#f59e0b' },
|
|
|
|
|
|
z: 20,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
if (selectedYear != null && selectedYear >= 0 && selectedYear < yearly.length) {
|
|
|
|
|
|
series.push({
|
|
|
|
|
|
name: 'selected',
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
data: [],
|
|
|
|
|
|
markLine: {
|
|
|
|
|
|
symbol: 'none',
|
|
|
|
|
|
silent: true,
|
|
|
|
|
|
lineStyle: { color: 'rgba(15, 23, 42, 0.6)', width: 2, type: 'solid' },
|
|
|
|
|
|
label: { show: false },
|
|
|
|
|
|
data: [{ xAxis: selectedYear }],
|
|
|
|
|
|
},
|
|
|
|
|
|
z: 30,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
frontend: What-If page with fan chart driven by /simulate
New /what-if route. Sticky form on the left (jurisdiction, strategy,
glide, NW seed, spending, savings, horizon, optional floor for
vpw_floor, MC paths) submits to POST /simulate; results panel renders
summary stats + the new FanChart.
FanChart component layers seven series:
- p10 invisible baseline (line, transparent)
- p10→p25 stacked area (low opacity)
- p25→p75 stacked area (IQR, mid opacity)
- p75→p90 stacked area (low opacity)
- p50 solid median line (drawn last, prominent)
- p10 + p90 dashed lines on top of the bands
Stacking deltas keeps the band fills clean — plotting raw quantiles
each as their own area would overlap badly. Reusable by scenario
detail in the next chunk (same ProjectionPoint[] shape).
5 tests pass (was 2). 470 KB gzipped (ECharts).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
|
|
|
|
if (showWithdrawal) {
|
|
|
|
|
|
series.push({
|
|
|
|
|
|
name: 'median withdrawal',
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
yAxisIndex: 1,
|
|
|
|
|
|
data: wd,
|
|
|
|
|
|
lineStyle: { width: 1, color: 'rgb(190, 90, 60)' },
|
|
|
|
|
|
symbol: 'none',
|
|
|
|
|
|
smooth: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
2026-05-10 11:27:22 +00:00
|
|
|
|
// Grid: leave room above for the legend (top:48) and below for the
|
|
|
|
|
|
// x-axis name label so neither collides with the chart area.
|
|
|
|
|
|
grid: { left: 60, right: showWithdrawal ? 70 : 24, top: 48, bottom: 56 },
|
frontend: What-If page with fan chart driven by /simulate
New /what-if route. Sticky form on the left (jurisdiction, strategy,
glide, NW seed, spending, savings, horizon, optional floor for
vpw_floor, MC paths) submits to POST /simulate; results panel renders
summary stats + the new FanChart.
FanChart component layers seven series:
- p10 invisible baseline (line, transparent)
- p10→p25 stacked area (low opacity)
- p25→p75 stacked area (IQR, mid opacity)
- p75→p90 stacked area (low opacity)
- p50 solid median line (drawn last, prominent)
- p10 + p90 dashed lines on top of the bands
Stacking deltas keeps the band fills clean — plotting raw quantiles
each as their own area would overlap badly. Reusable by scenario
detail in the next chunk (same ProjectionPoint[] shape).
5 tests pass (was 2). 470 KB gzipped (ECharts).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'axis',
|
|
|
|
|
|
formatter: (params) => {
|
|
|
|
|
|
const arr = Array.isArray(params) ? params : [params];
|
|
|
|
|
|
const year = arr[0]?.name ?? '';
|
|
|
|
|
|
const idx = years.indexOf(Number(year));
|
|
|
|
|
|
if (idx < 0) return '';
|
|
|
|
|
|
return [
|
|
|
|
|
|
`<b>Year ${year}</b>`,
|
|
|
|
|
|
`p10: ${gbpCompact(p10[idx]!)}`,
|
|
|
|
|
|
`p25: ${gbpCompact(p25[idx]!)}`,
|
|
|
|
|
|
`<b>p50: ${gbpCompact(p50[idx]!)}</b>`,
|
|
|
|
|
|
`p75: ${gbpCompact(p75[idx]!)}`,
|
|
|
|
|
|
`p90: ${gbpCompact(p90[idx]!)}`,
|
|
|
|
|
|
showWithdrawal ? `withdrawal: ${gbpCompact(wd[idx]!)}` : '',
|
|
|
|
|
|
]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join('<br/>');
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
legend: {
|
2026-05-10 11:27:22 +00:00
|
|
|
|
// Above the chart, centred — out of the way of the x-axis label.
|
|
|
|
|
|
// `type: 'scroll'` lets ECharts paginate items on narrow viewports
|
|
|
|
|
|
// instead of wrapping them into the chart area.
|
|
|
|
|
|
top: 8,
|
|
|
|
|
|
left: 'center',
|
|
|
|
|
|
type: 'scroll',
|
|
|
|
|
|
itemGap: 18,
|
|
|
|
|
|
itemWidth: 14,
|
|
|
|
|
|
itemHeight: 10,
|
|
|
|
|
|
textStyle: { fontSize: 11, color: '#475569' },
|
frontend: What-If page with fan chart driven by /simulate
New /what-if route. Sticky form on the left (jurisdiction, strategy,
glide, NW seed, spending, savings, horizon, optional floor for
vpw_floor, MC paths) submits to POST /simulate; results panel renders
summary stats + the new FanChart.
FanChart component layers seven series:
- p10 invisible baseline (line, transparent)
- p10→p25 stacked area (low opacity)
- p25→p75 stacked area (IQR, mid opacity)
- p75→p90 stacked area (low opacity)
- p50 solid median line (drawn last, prominent)
- p10 + p90 dashed lines on top of the bands
Stacking deltas keeps the band fills clean — plotting raw quantiles
each as their own area would overlap badly. Reusable by scenario
detail in the next chunk (same ProjectionPoint[] shape).
5 tests pass (was 2). 470 KB gzipped (ECharts).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
|
|
|
|
data: [
|
|
|
|
|
|
'median',
|
|
|
|
|
|
'p10',
|
|
|
|
|
|
'p90',
|
|
|
|
|
|
'p25–p75 (IQR)',
|
|
|
|
|
|
...(showWithdrawal ? ['median withdrawal'] : []),
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
xAxis: {
|
|
|
|
|
|
type: 'category',
|
|
|
|
|
|
data: years,
|
|
|
|
|
|
name: 'years from now',
|
|
|
|
|
|
nameLocation: 'middle',
|
2026-05-10 11:27:22 +00:00
|
|
|
|
nameGap: 28,
|
|
|
|
|
|
nameTextStyle: { color: '#64748b' },
|
frontend: What-If page with fan chart driven by /simulate
New /what-if route. Sticky form on the left (jurisdiction, strategy,
glide, NW seed, spending, savings, horizon, optional floor for
vpw_floor, MC paths) submits to POST /simulate; results panel renders
summary stats + the new FanChart.
FanChart component layers seven series:
- p10 invisible baseline (line, transparent)
- p10→p25 stacked area (low opacity)
- p25→p75 stacked area (IQR, mid opacity)
- p75→p90 stacked area (low opacity)
- p50 solid median line (drawn last, prominent)
- p10 + p90 dashed lines on top of the bands
Stacking deltas keeps the band fills clean — plotting raw quantiles
each as their own area would overlap badly. Reusable by scenario
detail in the next chunk (same ProjectionPoint[] shape).
5 tests pass (was 2). 470 KB gzipped (ECharts).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
|
|
|
|
},
|
|
|
|
|
|
yAxis: [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'value',
|
|
|
|
|
|
name: 'portfolio (real GBP)',
|
|
|
|
|
|
nameLocation: 'middle',
|
|
|
|
|
|
nameGap: 50,
|
|
|
|
|
|
axisLabel: { formatter: (v: number) => gbpCompact(v) },
|
|
|
|
|
|
},
|
|
|
|
|
|
...(showWithdrawal
|
|
|
|
|
|
? [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'value' as const,
|
|
|
|
|
|
name: 'withdrawal',
|
|
|
|
|
|
nameLocation: 'middle' as const,
|
|
|
|
|
|
nameGap: 50,
|
|
|
|
|
|
axisLabel: { formatter: (v: number) => gbpCompact(v) },
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
: []),
|
|
|
|
|
|
],
|
|
|
|
|
|
series,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function num(s: string): number {
|
|
|
|
|
|
return Number(s);
|
|
|
|
|
|
}
|