-
- fire-planner
-
+
+
+ fire-planner
+
+
+
api:{' '}
@@ -29,8 +36,23 @@ export function App() {
} />
+ } />
);
}
+
+function NavTab({ to, children }: { to: string; children: React.ReactNode }) {
+ return (
+
+ `${isActive ? 'text-slate-900 font-medium' : 'text-slate-500 hover:text-slate-800'}`
+ }
+ >
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/FanChart.test.tsx b/frontend/src/components/FanChart.test.tsx
new file mode 100644
index 0000000..b441d01
--- /dev/null
+++ b/frontend/src/components/FanChart.test.tsx
@@ -0,0 +1,41 @@
+import { render, screen } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+
+import { FanChart } from './FanChart';
+import type { ProjectionPoint } from '@/api/client';
+
+vi.mock('echarts-for-react', () => ({
+ default: ({ option }: { option: { series?: unknown[] } }) => (
+
+ ),
+}));
+
+const point = (year: number, p50 = 1_000_000): ProjectionPoint => ({
+ year_idx: year,
+ p10_portfolio_gbp: String(p50 * 0.7),
+ p25_portfolio_gbp: String(p50 * 0.85),
+ p50_portfolio_gbp: String(p50),
+ p75_portfolio_gbp: String(p50 * 1.15),
+ p90_portfolio_gbp: String(p50 * 1.3),
+ p50_withdrawal_gbp: '60000',
+ p50_tax_gbp: '8000',
+ survival_rate: '1.0',
+});
+
+describe('FanChart', () => {
+ it('shows empty state when there are no points', () => {
+ render(
);
+ expect(screen.getByText('No projection data.')).toBeInTheDocument();
+ });
+
+ it('renders 7 base series for fan + median + p10/p90 lines', () => {
+ render(
);
+ const chart = screen.getByTestId('chart');
+ expect(chart.dataset.seriesCount).toBe('7');
+ });
+
+ it('adds an 8th series when showWithdrawal is on', () => {
+ render(
);
+ expect(screen.getByTestId('chart').dataset.seriesCount).toBe('8');
+ });
+});
diff --git a/frontend/src/components/FanChart.tsx b/frontend/src/components/FanChart.tsx
new file mode 100644
index 0000000..7db9d41
--- /dev/null
+++ b/frontend/src/components/FanChart.tsx
@@ -0,0 +1,202 @@
+/**
+ * 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';
+
+interface Props {
+ yearly: ProjectionPoint[];
+ height?: number;
+ showWithdrawal?: boolean;
+}
+
+export function FanChart({ yearly, height = 360, showWithdrawal = false }: Props) {
+ const option = useMemo
(() => buildFan(yearly, showWithdrawal), [
+ yearly,
+ showWithdrawal,
+ ]);
+ if (yearly.length === 0) {
+ return No projection data.
;
+ }
+ return ;
+}
+
+function buildFan(yearly: ProjectionPoint[], showWithdrawal: boolean): EChartsOption {
+ 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,
+ },
+ ];
+
+ 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 {
+ grid: { left: 60, right: showWithdrawal ? 70 : 24, top: 30, bottom: 40 },
+ 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 [
+ `Year ${year}`,
+ `p10: ${gbpCompact(p10[idx]!)}`,
+ `p25: ${gbpCompact(p25[idx]!)}`,
+ `p50: ${gbpCompact(p50[idx]!)}`,
+ `p75: ${gbpCompact(p75[idx]!)}`,
+ `p90: ${gbpCompact(p90[idx]!)}`,
+ showWithdrawal ? `withdrawal: ${gbpCompact(wd[idx]!)}` : '',
+ ]
+ .filter(Boolean)
+ .join('
');
+ },
+ },
+ legend: {
+ bottom: 0,
+ data: [
+ 'median',
+ 'p10',
+ 'p90',
+ 'p25–p75 (IQR)',
+ ...(showWithdrawal ? ['median withdrawal'] : []),
+ ],
+ },
+ xAxis: {
+ type: 'category',
+ data: years,
+ name: 'years from now',
+ nameLocation: 'middle',
+ nameGap: 24,
+ },
+ 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);
+}
diff --git a/frontend/src/pages/WhatIf.tsx b/frontend/src/pages/WhatIf.tsx
new file mode 100644
index 0000000..8b4ec1d
--- /dev/null
+++ b/frontend/src/pages/WhatIf.tsx
@@ -0,0 +1,281 @@
+/**
+ * What-If — interactive Monte Carlo. Form on the left, fan chart on the
+ * right. Hits POST /simulate (no DB write); ~1-3s for 5k paths.
+ */
+import { useMutation } from '@tanstack/react-query';
+import { useState } from 'react';
+
+import { api, type SimulateRequest, type SimulateResult } from '@/api/client';
+import { FanChart } from '@/components/FanChart';
+import { gbp, pct } from '@/lib/format';
+
+const JURISDICTIONS = ['uk', 'cyprus', 'bulgaria', 'malaysia', 'thailand', 'uae', 'nomad'];
+const STRATEGIES = ['trinity', 'guyton_klinger', 'vpw', 'vpw_floor'];
+const GLIDES = ['rising', 'static_60_40'];
+
+const DEFAULTS: SimulateRequest = {
+ jurisdiction: 'cyprus',
+ strategy: 'guyton_klinger',
+ leave_uk_year: 2,
+ glide_path: 'rising',
+ spending_gbp: '60000',
+ nw_seed_gbp: '1500000',
+ savings_per_year_gbp: '0',
+ horizon_years: 60,
+ floor_gbp: null,
+ n_paths: 5000,
+ seed: 42,
+};
+
+export function WhatIf() {
+ const [form, setForm] = useState(DEFAULTS);
+ const sim = useMutation({
+ mutationFn: (req: SimulateRequest) => api.simulate(req),
+ });
+
+ const onSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ sim.mutate({
+ ...form,
+ floor_gbp: form.strategy === 'vpw_floor' ? form.floor_gbp : null,
+ });
+ };
+
+ const update = (key: K, value: SimulateRequest[K]) =>
+ setForm((f) => ({ ...f, [key]: value }));
+
+ return (
+
+
+
+
+
+
+
+ {sim.isError && (
+
+ {String((sim.error as Error)?.message ?? sim.error)}
+
+ )}
+ {!sim.data && !sim.isPending && !sim.isError && (
+
+ Set parameters on the left and run a simulation.
+
+ )}
+ {sim.isPending && (
+
+ Running Monte Carlo…
+
+ )}
+ {sim.data &&
}
+
+
+
+ );
+}
+
+function Results({ result, horizon }: { result: SimulateResult; horizon: number }) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
Portfolio fan
+
+
+ >
+ );
+}
+
+function Stat({
+ label,
+ value,
+ accent,
+}: {
+ label: string;
+ value: string | number;
+ accent?: boolean;
+}) {
+ return (
+
+
{label}
+
+ {value}
+
+
+ );
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+function Select({
+ value,
+ onChange,
+ options,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ options: string[];
+}) {
+ return (
+
+ );
+}
+
+function NumberInput({
+ value,
+ onChange,
+ min,
+ max,
+ step = 1,
+}: {
+ value: number;
+ onChange: (v: number) => void;
+ min?: number;
+ max?: number;
+ step?: number;
+}) {
+ return (
+ {
+ const n = Number(e.target.value);
+ if (Number.isFinite(n)) onChange(n);
+ }}
+ className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400"
+ />
+ );
+}