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

@ -4,10 +4,18 @@ import { NavLink, Route, Routes, Link } from 'react-router-dom';
import { api } from '@/api/client';
import { Compare } from '@/pages/Compare';
import { Dashboard } from '@/pages/Dashboard';
import { CashflowTab } from '@/pages/CashflowTab';
import { MilestonesSettings } from '@/pages/MilestonesSettings';
import { NotesSettings } from '@/pages/NotesSettings';
import { PlaceholderTab } from '@/pages/PlaceholderTab';
import { ProgressPage } from '@/pages/ProgressPage';
import { RatesSettings } from '@/pages/RatesSettings';
import { ScenarioDetail } from '@/pages/ScenarioDetail';
import { ScenarioEdit } from '@/pages/ScenarioEdit';
import { ScenarioNew } from '@/pages/ScenarioNew';
import { ScenarioShell } from '@/pages/ScenarioShell';
import { Scenarios } from '@/pages/Scenarios';
import { SettingsTab } from '@/pages/SettingsTab';
import { WhatIf } from '@/pages/WhatIf';
export function App() {
@ -44,8 +52,53 @@ export function App() {
<Route path="/" element={<Dashboard />} />
<Route path="/scenarios" element={<Scenarios />} />
<Route path="/scenarios/new" element={<ScenarioNew />} />
<Route path="/scenarios/:id" element={<ScenarioDetail />} />
<Route path="/scenarios/:id/edit" element={<ScenarioEdit />} />
<Route path="/scenarios/:id/progress" element={<ProgressPage />} />
<Route path="/scenarios/:id" element={<ScenarioShell />}>
<Route index element={<ScenarioDetail />} />
<Route path="cash-flow" element={<CashflowTab />} />
<Route
path="tax-analytics"
element={<PlaceholderTab feature="Tax Analytics" wave={2} />}
/>
<Route
path="compare"
element={<PlaceholderTab feature="Side-by-side Compare" wave={2} />}
/>
<Route
path="reports"
element={<PlaceholderTab feature="Reports / PDF export" wave={2} />}
/>
<Route
path="estate"
element={<PlaceholderTab feature="Estate planning" wave={2} />}
/>
<Route path="settings" element={<SettingsTab />}>
<Route index element={<MilestonesSettings />} />
<Route path="rates" element={<RatesSettings />} />
<Route
path="dividends"
element={<PlaceholderTab feature="Dividends override matrix" wave={2} />}
/>
<Route
path="bonds"
element={<PlaceholderTab feature="Bond allocation editor" wave={2} />}
/>
<Route
path="tax"
element={<PlaceholderTab feature="Tax mode toggle + per-jurisdiction overrides" wave={2} />}
/>
<Route
path="metrics"
element={<PlaceholderTab feature="Right-sidebar metric picker" wave={2} />}
/>
<Route
path="other"
element={<PlaceholderTab feature="Currency / horizon / seed defaults" wave={2} />}
/>
<Route path="notes" element={<NotesSettings />} />
</Route>
</Route>
<Route path="/compare" element={<Compare />} />
<Route path="/what-if" element={<WhatIf />} />
</Routes>

View file

@ -45,6 +45,11 @@ export const api = {
method: 'POST',
body: JSON.stringify(body ?? {}),
}),
yearStats: (id: number, year: number) =>
request<YearStats>(`/scenarios/${id}/year-stats?year=${year}`),
progress: (id: number) => request<ProgressResponse>(`/scenarios/${id}/progress`),
cashflow: (id: number, year: number) =>
request<CashflowResponse>(`/scenarios/${id}/cashflow?year=${year}`),
networth: {
current: () =>
request<{
@ -197,6 +202,115 @@ export const goalsApi = {
delete: (goalId: number) => request<void>(`/goals/${goalId}`, { method: 'DELETE' }),
};
// ── income streams ───────────────────────────────────────────────────
export interface IncomeStream {
id: number;
scenario_id: number;
kind: string;
name: string;
start_year: number;
end_year: number | null;
amount_gbp_per_year: string;
growth_pct: string;
tax_treatment: string;
enabled: boolean;
payload: Record<string, unknown> | null;
created_at: string;
}
export interface IncomeStreamCreateBody {
kind: string;
name: string;
start_year?: number;
end_year?: number | null;
amount_gbp_per_year?: string;
growth_pct?: string;
tax_treatment?: string;
enabled?: boolean;
payload?: Record<string, unknown> | null;
}
export const incomeStreamsApi = {
list: (scenarioId: number) =>
request<IncomeStream[]>(`/scenarios/${scenarioId}/income-streams`),
create: (scenarioId: number, body: IncomeStreamCreateBody) =>
request<IncomeStream>(`/scenarios/${scenarioId}/income-streams`, {
method: 'POST',
body: JSON.stringify(body),
}),
patch: (streamId: number, body: Partial<IncomeStreamCreateBody>) =>
request<IncomeStream>(`/income-streams/${streamId}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
delete: (streamId: number) =>
request<void>(`/income-streams/${streamId}`, { method: 'DELETE' }),
};
// ── per-year stats / progress / cashflow ─────────────────────────────
export interface YearStats {
year_idx: number;
calendar_year: number;
age: number | null;
net_worth_p50: string;
change_in_nw: string;
taxable_income: string;
taxes: string;
effective_tax_rate: string;
spending: string;
contributions: string;
investment_growth: string;
liquid_nw: string | null;
expenses: string | null;
savings_rate: string | null;
portfolio_allocations: Record<string, string> | null;
}
export interface ProgressActualPoint {
snapshot_date: string;
total_gbp: string;
}
export interface ProgressProjectedPoint {
year_idx: number;
p10_portfolio_gbp: string;
p50_portfolio_gbp: string;
p90_portfolio_gbp: string;
}
export interface ProgressVariancePoint {
year_idx: number;
actual_avg_gbp: string;
projected_p50_gbp: string;
delta_gbp: string;
}
export interface ProgressResponse {
scenario_id: number;
alignment_anchor: string;
actual: ProgressActualPoint[];
projected: ProgressProjectedPoint[];
variance: ProgressVariancePoint[];
}
export interface CashflowResponse {
scenario_id: number;
year: number;
sources: Record<string, string>;
sinks: Record<string, string>;
}
export interface GoalProbability {
goal_id: number | null;
name: string;
kind: string;
probability: string;
threshold: string;
passed: boolean;
}
export interface Scenario {
id: number;
external_id: string;
@ -241,6 +355,7 @@ export interface ScenarioProjection {
median_lifetime_tax_gbp: string;
median_years_to_ruin: string | null;
yearly: ProjectionPoint[];
goals_probability?: GoalProbability[];
}
export interface SimulateRequest {
@ -267,6 +382,31 @@ export interface SimulateRequest {
annual_real_adjust_pct?: string;
guardrail_threshold_pct?: string | null;
guardrail_cut_pct?: string;
income_streams?: Array<{
kind?: string;
start_year: number;
end_year?: number | null;
amount_gbp_per_year: string;
growth_pct?: string;
tax_treatment?: string;
enabled?: boolean;
}>;
goals?: Array<{
kind: string;
name: string;
target_amount_gbp?: string | null;
target_year?: number | null;
comparator?: string;
success_threshold?: string;
enabled?: boolean;
}>;
rates_mode?: 'fixed' | 'historical' | 'advanced' | null;
inflation_pct?: string;
stocks_growth_pct?: string;
stocks_dividend_pct?: string;
bonds_growth_pct?: string;
bonds_dividend_pct?: string;
stocks_allocation?: string;
}
export interface SimulateResult {
@ -278,4 +418,5 @@ export interface SimulateResult {
median_years_to_ruin: string | null;
elapsed_seconds: string;
yearly: ProjectionPoint[];
goals_probability?: GoalProbability[];
}

View file

@ -0,0 +1,75 @@
/**
* Cash Flow Sankey for one year (Wave 1.C.2).
*
* Sources flow into a "Annual cashflow" node which then drains into
* sinks. Sums conserve at the node level.
*/
import { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import type { EChartsOption } from 'echarts';
import type { CashflowResponse } from '@/api/client';
import { gbpCompact } from '@/lib/format';
interface Props {
data: CashflowResponse;
height?: number;
}
const CENTER_NODE = 'Annual cashflow';
export function CashflowSankey({ data, height = 420 }: Props) {
const option = useMemo<EChartsOption>(() => buildSankey(data), [data]);
return (
<ReactECharts option={option} style={{ height, width: '100%' }} notMerge lazyUpdate />
);
}
function buildSankey(data: CashflowResponse): EChartsOption {
const sourceNames = Object.keys(data.sources);
const sinkNames = Object.keys(data.sinks);
const nodes = [
...sourceNames.map((n) => ({ name: n })),
{ name: CENTER_NODE },
...sinkNames.map((n) => ({ name: n })),
];
const links = [
...sourceNames.map((n) => ({
source: n,
target: CENTER_NODE,
value: Number(data.sources[n] ?? 0),
})),
...sinkNames.map((n) => ({
source: CENTER_NODE,
target: n,
value: Number(data.sinks[n] ?? 0),
})),
].filter((l) => l.value > 0);
return {
tooltip: {
trigger: 'item',
formatter: (params) => {
if (Array.isArray(params)) return '';
const p = params as { dataType?: string; data?: { value?: number; name?: string; source?: string; target?: string } };
if (p.dataType === 'edge' && p.data) {
return `${p.data.source}${p.data.target}: ${gbpCompact(p.data.value ?? 0)}`;
}
return p.data?.name ?? '';
},
},
series: [
{
type: 'sankey',
data: nodes,
links,
nodeAlign: 'justify',
nodeWidth: 18,
nodeGap: 8,
emphasis: { focus: 'adjacency' },
lineStyle: { color: 'gradient', curveness: 0.5 },
label: { color: '#1e293b', fontSize: 12 },
},
],
};
}

View file

@ -19,25 +19,57 @@ import type { EChartsOption } from 'echarts';
import type { ProjectionPoint } from '@/api/client';
import { gbpCompact } from '@/lib/format';
import type { Milestone } from '@/lib/milestone';
interface Props {
yearly: ProjectionPoint[];
height?: number;
showWithdrawal?: boolean;
milestones?: Milestone[];
selectedYear?: number | null;
onSelectYear?: (year: number) => void;
}
export function FanChart({ yearly, height = 360, showWithdrawal = false }: Props) {
const option = useMemo<EChartsOption>(() => buildFan(yearly, showWithdrawal), [
yearly,
showWithdrawal,
]);
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],
);
if (yearly.length === 0) {
return <p className="text-sm text-slate-500">No projection data.</p>;
}
return <ReactECharts option={option} style={{ height, width: '100%' }} notMerge lazyUpdate />;
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}
/>
);
}
function buildFan(yearly: ProjectionPoint[], showWithdrawal: boolean): EChartsOption {
function buildFan(
yearly: ProjectionPoint[],
showWithdrawal: boolean,
milestones?: Milestone[],
selectedYear?: number | null,
): 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));
@ -122,6 +154,50 @@ function buildFan(yearly: ProjectionPoint[], showWithdrawal: boolean): EChartsOp
},
];
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,
});
}
if (showWithdrawal) {
series.push({
name: 'median withdrawal',

View file

@ -4,7 +4,12 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { goalsApi, type Goal, type GoalCreateBody } from '@/api/client';
import {
goalsApi,
type Goal,
type GoalCreateBody,
type GoalProbability,
} from '@/api/client';
import { gbp, pct } from '@/lib/format';
const KIND_SUGGESTIONS = ['target_nw', 'never_run_out', 'inheritance', 'spending_floor'];
@ -20,7 +25,13 @@ const EMPTY_FORM: GoalCreateBody = {
enabled: true,
};
export function GoalsSection({ scenarioId }: { scenarioId: number }) {
export function GoalsSection({
scenarioId,
probabilities,
}: {
scenarioId: number;
probabilities?: GoalProbability[];
}) {
const goals = useQuery({
queryKey: ['scenarios', scenarioId, 'goals'],
queryFn: () => goalsApi.list(scenarioId),
@ -34,14 +45,20 @@ export function GoalsSection({ scenarioId }: { scenarioId: number }) {
) : goals.isError ? (
<p className="text-sm text-red-700">Failed to load goals.</p>
) : (
<GoalsList goals={goals.data ?? []} />
<GoalsList goals={goals.data ?? []} probabilities={probabilities ?? []} />
)}
<AddGoalForm scenarioId={scenarioId} />
</div>
);
}
function GoalsList({ goals }: { goals: Goal[] }) {
function GoalsList({
goals,
probabilities,
}: {
goals: Goal[];
probabilities: GoalProbability[];
}) {
const qc = useQueryClient();
const del = useMutation({
mutationFn: (id: number) => goalsApi.delete(id),
@ -53,34 +70,52 @@ function GoalsList({ goals }: { goals: Goal[] }) {
return <p className="text-sm text-slate-500 mb-4">No goals yet.</p>;
}
const probByName = new Map(probabilities.map((p) => [p.name, p]));
return (
<ul className="space-y-2 mb-5">
{goals.map((g) => (
<li
key={g.id}
className="flex items-center justify-between rounded-md border border-slate-200 bg-slate-50 px-4 py-2 text-sm"
>
<div>
<div className="font-medium">{g.name}</div>
<div className="text-xs text-slate-500">
{g.kind}
{g.target_amount_gbp ? ` · ${g.comparator} ${gbp(g.target_amount_gbp)}` : ''}
{g.target_year !== null ? ` · year ${g.target_year}` : ''}
{' · threshold '}
{pct(g.success_threshold)}
</div>
</div>
<button
type="button"
onClick={() => del.mutate(g.id)}
disabled={del.isPending}
aria-label={`Delete ${g.name}`}
className="text-xs text-slate-500 hover:text-red-700 px-2 py-1 disabled:opacity-50"
{goals.map((g) => {
const p = probByName.get(g.name);
return (
<li
key={g.id}
className="flex items-center justify-between rounded-md border border-slate-200 bg-slate-50 px-4 py-2 text-sm"
>
Delete
</button>
</li>
))}
<div>
<div className="font-medium">{g.name}</div>
<div className="text-xs text-slate-500">
{g.kind}
{g.target_amount_gbp ? ` · ${g.comparator} ${gbp(g.target_amount_gbp)}` : ''}
{g.target_year !== null ? ` · year ${g.target_year}` : ''}
{' · threshold '}
{pct(g.success_threshold)}
</div>
</div>
<div className="flex items-center gap-3">
{p && (
<span
className={`text-xs font-medium tabular-nums px-2 py-1 rounded ${
p.passed
? 'bg-emerald-100 text-emerald-800'
: 'bg-amber-100 text-amber-800'
}`}
title={`Probability ${pct(p.probability)} (threshold ${pct(p.threshold)})`}
>
{pct(p.probability)} {p.passed ? '✓' : '✗'}
</span>
)}
<button
type="button"
onClick={() => del.mutate(g.id)}
disabled={del.isPending}
aria-label={`Delete ${g.name}`}
className="text-xs text-slate-500 hover:text-red-700 px-2 py-1 disabled:opacity-50"
>
Delete
</button>
</div>
</li>
);
})}
</ul>
);
}

View file

@ -0,0 +1,255 @@
/**
* Typed income streams (Wave 1.C.1) first-class objects on the Plan
* tab, below Life Events.
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
incomeStreamsApi,
type IncomeStream,
type IncomeStreamCreateBody,
} from '@/api/client';
import { gbp, pct } from '@/lib/format';
const KINDS = [
'salary',
'dividend',
'rental',
'pension',
'social_security',
'rsu',
'other',
];
const TAX_TREATMENTS = ['income', 'dividend', 'cgt', 'tax_free'];
const EMPTY: IncomeStreamCreateBody = {
kind: 'salary',
name: '',
start_year: 0,
end_year: null,
amount_gbp_per_year: '50000',
growth_pct: '0.02',
tax_treatment: 'income',
enabled: true,
};
export function IncomeStreamsSection({ scenarioId }: { scenarioId: number }) {
const list = useQuery({
queryKey: ['scenarios', scenarioId, 'income-streams'],
queryFn: () => incomeStreamsApi.list(scenarioId),
});
return (
<div className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="text-lg font-semibold mb-4">Income streams</h2>
{list.isLoading ? (
<p className="text-sm text-slate-500">Loading</p>
) : list.isError ? (
<p className="text-sm text-red-700">Failed to load streams.</p>
) : (
<StreamsList scenarioId={scenarioId} streams={list.data ?? []} />
)}
<AddStreamForm scenarioId={scenarioId} />
</div>
);
}
function StreamsList({
scenarioId,
streams,
}: {
scenarioId: number;
streams: IncomeStream[];
}) {
const qc = useQueryClient();
const del = useMutation({
mutationFn: (id: number) => incomeStreamsApi.delete(id),
onSettled: () =>
qc.invalidateQueries({
queryKey: ['scenarios', scenarioId, 'income-streams'],
}),
});
if (streams.length === 0) {
return <p className="text-sm text-slate-500 mb-4">No income streams yet.</p>;
}
return (
<ul className="space-y-2 mb-5">
{streams.map((s) => (
<li
key={s.id}
className="flex items-center justify-between rounded-md border border-slate-200 bg-slate-50 px-4 py-2 text-sm"
>
<div>
<div className="font-medium">{s.name}</div>
<div className="text-xs text-slate-500">
{s.kind} · year {s.start_year}
{s.end_year !== null && s.end_year !== s.start_year ? `${s.end_year}` : ''}
{' · '}
{gbp(s.amount_gbp_per_year)}/y
{Number(s.growth_pct) !== 0 ? ` · +${pct(s.growth_pct)} growth` : ''}
{' · '}
{s.tax_treatment}
</div>
</div>
<button
type="button"
onClick={() => del.mutate(s.id)}
disabled={del.isPending}
aria-label={`Delete ${s.name}`}
className="text-xs text-slate-500 hover:text-red-700 px-2 py-1 disabled:opacity-50"
>
Delete
</button>
</li>
))}
</ul>
);
}
function AddStreamForm({ scenarioId }: { scenarioId: number }) {
const [form, setForm] = useState<IncomeStreamCreateBody>(EMPTY);
const [err, setErr] = useState<string | null>(null);
const qc = useQueryClient();
const create = useMutation({
mutationFn: (body: IncomeStreamCreateBody) =>
incomeStreamsApi.create(scenarioId, body),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['scenarios', scenarioId, 'income-streams'],
});
setForm(EMPTY);
setErr(null);
},
onError: (e) => setErr(String((e as Error)?.message ?? e)),
});
const update = <K extends keyof IncomeStreamCreateBody>(
k: K,
v: IncomeStreamCreateBody[K],
) => setForm((f) => ({ ...f, [k]: v }));
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim()) {
setErr('Name required');
return;
}
if (
form.end_year !== null &&
form.end_year !== undefined &&
form.start_year !== undefined &&
form.end_year < form.start_year
) {
setErr('end_year must be ≥ start_year');
return;
}
setErr(null);
create.mutate(form);
};
return (
<form
onSubmit={onSubmit}
className="grid grid-cols-1 md:grid-cols-7 gap-3 items-end pt-3 border-t border-slate-100"
>
<label className="md:col-span-2 text-xs">
<span className="uppercase tracking-wide text-slate-500">Name</span>
<input
type="text"
value={form.name}
onChange={(e) => update('name', e.target.value)}
placeholder="Day job"
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
/>
</label>
<label className="text-xs">
<span className="uppercase tracking-wide text-slate-500">Kind</span>
<select
value={form.kind}
onChange={(e) => update('kind', e.target.value)}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
>
{KINDS.map((k) => (
<option key={k} value={k}>
{k}
</option>
))}
</select>
</label>
<label className="text-xs">
<span className="uppercase tracking-wide text-slate-500">Year start</span>
<input
type="number"
value={form.start_year ?? 0}
min={0}
max={100}
onChange={(e) => update('start_year', Number(e.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
/>
</label>
<label className="text-xs">
<span className="uppercase tracking-wide text-slate-500">Year end</span>
<input
type="number"
value={form.end_year ?? ''}
min={0}
max={100}
placeholder="—"
onChange={(e) =>
update('end_year', e.target.value === '' ? null : Number(e.target.value))
}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
/>
</label>
<label className="text-xs">
<span className="uppercase tracking-wide text-slate-500">£/year</span>
<input
type="number"
value={Number(form.amount_gbp_per_year ?? 0)}
step={1000}
onChange={(e) => update('amount_gbp_per_year', String(e.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
/>
</label>
<label className="text-xs">
<span className="uppercase tracking-wide text-slate-500">Growth</span>
<input
type="number"
value={Number(form.growth_pct ?? 0)}
step={0.005}
onChange={(e) => update('growth_pct', String(e.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
/>
</label>
<label className="md:col-span-1 text-xs">
<span className="uppercase tracking-wide text-slate-500">Tax</span>
<select
value={form.tax_treatment ?? 'income'}
onChange={(e) => update('tax_treatment', e.target.value)}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
>
{TAX_TREATMENTS.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</label>
<div className="md:col-span-7 flex items-center gap-3">
<button
type="submit"
disabled={create.isPending}
className="rounded-md bg-slate-900 text-white text-sm font-medium px-3 py-1.5 hover:bg-slate-800 disabled:opacity-60"
>
{create.isPending ? 'Adding…' : 'Add stream'}
</button>
{err && <span className="text-xs text-red-700">{err}</span>}
</div>
</form>
);
}

View file

@ -0,0 +1,95 @@
/**
* Overlay actual NW (from account_snapshot) on top of the projected
* fan. Wave 1.B.2.
*/
import { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import type { EChartsOption } from 'echarts';
import type { ProgressResponse } from '@/api/client';
import { gbpCompact } from '@/lib/format';
interface Props {
data: ProgressResponse;
height?: number;
}
export function ProgressOverlay({ data, height = 380 }: Props) {
const option = useMemo<EChartsOption>(() => buildOption(data), [data]);
return (
<ReactECharts option={option} style={{ height, width: '100%' }} notMerge lazyUpdate />
);
}
function buildOption(data: ProgressResponse): EChartsOption {
const projected = data.projected;
const actuals = data.actual.map((p) => [p.snapshot_date, Number(p.total_gbp)]);
const anchorYear = new Date(data.alignment_anchor).getUTCFullYear();
const projectedSeries = projected.map((p) => [
`${anchorYear + p.year_idx}-01-01`,
Number(p.p50_portfolio_gbp),
]);
const p10 = projected.map((p) => [
`${anchorYear + p.year_idx}-01-01`,
Number(p.p10_portfolio_gbp),
]);
const p90 = projected.map((p) => [
`${anchorYear + p.year_idx}-01-01`,
Number(p.p90_portfolio_gbp),
]);
return {
tooltip: {
trigger: 'axis',
valueFormatter: (v) => gbpCompact(Number(v)),
},
legend: {
top: 4,
data: ['actual', 'projected (p50)', 'p10', 'p90'],
},
grid: { left: 60, right: 24, top: 36, bottom: 40 },
xAxis: { type: 'time' },
yAxis: {
type: 'value',
axisLabel: { formatter: (v: number) => gbpCompact(v) },
},
series: [
{
name: 'p10',
type: 'line',
data: p10,
lineStyle: { width: 1, type: 'dashed', color: 'rgba(40,70,200,0.5)' },
symbol: 'none',
smooth: true,
},
{
name: 'p90',
type: 'line',
data: p90,
lineStyle: { width: 1, type: 'dashed', color: 'rgba(40,70,200,0.5)' },
symbol: 'none',
smooth: true,
},
{
name: 'projected (p50)',
type: 'line',
data: projectedSeries,
lineStyle: { width: 2, color: 'rgb(40,70,200)' },
symbol: 'none',
smooth: true,
z: 5,
},
{
name: 'actual',
type: 'line',
data: actuals,
lineStyle: { width: 2, color: 'rgb(16,185,129)' },
itemStyle: { color: 'rgb(16,185,129)' },
symbol: 'circle',
symbolSize: 6,
z: 10,
},
],
};
}

View file

@ -0,0 +1,67 @@
/**
* One rate card (Inflation / Stocks / Bonds) collapsed view shows the
* pair "growth% / dividend%" PLab style; expanded shows two inputs.
*/
import { useState } from 'react';
interface Props {
title: string;
growth: number;
dividend?: number | null;
onChange: (next: { growth: number; dividend?: number | null }) => void;
}
export function RateCard({ title, growth, dividend, onChange }: Props) {
const [open, setOpen] = useState(false);
const hasDividend = dividend != null;
return (
<div className="rounded-md border border-slate-200 bg-white p-3">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="w-full flex items-baseline justify-between text-left"
>
<span className="text-sm font-medium text-slate-800">{title}</span>
<span className="text-sm tabular-nums text-slate-700">
{fmt(growth)}
{hasDividend ? ` / ${fmt(dividend)}` : ''}
</span>
</button>
{open && (
<div className="grid grid-cols-2 gap-3 mt-3">
<label className="text-xs">
<span className="uppercase tracking-wide text-slate-500">Growth</span>
<input
type="number"
step={0.001}
value={growth}
onChange={(e) =>
onChange({ growth: Number(e.target.value), dividend })
}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
/>
</label>
{hasDividend && (
<label className="text-xs">
<span className="uppercase tracking-wide text-slate-500">Dividend</span>
<input
type="number"
step={0.001}
value={dividend ?? 0}
onChange={(e) =>
onChange({ growth, dividend: Number(e.target.value) })
}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
/>
</label>
)}
</div>
)}
</div>
);
}
function fmt(value: number): string {
return `${(value * 100).toFixed(2)}%`;
}

View file

@ -0,0 +1,37 @@
/**
* Vertical sub-nav inside the Settings tab (Wave 1.D.1).
*/
import { NavLink } from 'react-router-dom';
interface Item {
to: string;
label: string;
end?: boolean;
}
export function SettingsSubnav({ items }: { items: Item[] }) {
return (
<nav className="w-44 shrink-0 border-r border-slate-200 pr-3">
<ul className="space-y-1">
{items.map((it) => (
<li key={it.to}>
<NavLink
to={it.to}
end={it.end}
className={({ isActive }) =>
[
'block rounded-md px-3 py-1.5 text-sm',
isActive
? 'bg-slate-100 text-slate-900 font-medium'
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50',
].join(' ')
}
>
{it.label}
</NavLink>
</li>
))}
</ul>
</nav>
);
}

View file

@ -0,0 +1,125 @@
/**
* Left sidebar (Current Finances · Dashboard · Progress · Plans switcher).
*
* Plans switcher lists user-defined scenarios; clicking one swaps the
* scenario in the URL while keeping the active tab.
*/
import { useQuery } from '@tanstack/react-query';
import { Link, NavLink, useParams } from 'react-router-dom';
import { api } from '@/api/client';
interface Props {
activeScenarioId?: number;
}
export function Sidebar({ activeScenarioId }: Props) {
return (
<aside className="w-56 shrink-0 border-r border-slate-200 bg-white py-4 px-2">
<SidebarSection title="Navigate">
<SidebarLink to="/scenarios">Current Finances</SidebarLink>
<SidebarLink to="/" end>
Dashboard
</SidebarLink>
{activeScenarioId != null && (
<SidebarLink to={`/scenarios/${activeScenarioId}/progress`}>Progress</SidebarLink>
)}
</SidebarSection>
<PlansSwitcher activeScenarioId={activeScenarioId} />
</aside>
);
}
function SidebarSection({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="mb-6">
<div className="px-3 text-[10px] uppercase tracking-wider text-slate-500 mb-2">
{title}
</div>
<ul className="space-y-1">{children}</ul>
</div>
);
}
function SidebarLink({
to,
children,
end,
}: {
to: string;
children: React.ReactNode;
end?: boolean;
}) {
return (
<li>
<NavLink
to={to}
end={end}
className={({ isActive }) =>
[
'block rounded-md px-3 py-1.5 text-sm',
isActive
? 'bg-slate-100 text-slate-900 font-medium'
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50',
].join(' ')
}
>
{children}
</NavLink>
</li>
);
}
function PlansSwitcher({ activeScenarioId }: { activeScenarioId?: number }) {
const params = useParams();
const scenarios = useQuery({
queryKey: ['scenarios', 'list', 'user'],
queryFn: () => api.scenarios.list('user'),
});
const active = activeScenarioId ?? Number(params.id);
return (
<SidebarSection title="Plans">
{scenarios.isLoading && (
<li className="px-3 py-1.5 text-xs text-slate-500">Loading</li>
)}
{scenarios.data?.map((s) => (
<li key={s.id}>
<NavLink
to={`/scenarios/${s.id}`}
className={({ isActive }) => {
const highlight = isActive || s.id === active;
return [
'block rounded-md px-3 py-1.5 text-sm',
highlight
? 'bg-slate-100 text-slate-900 font-medium'
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50',
].join(' ');
}}
>
<span className="truncate inline-block max-w-full">
{s.name ?? s.external_id}
</span>
</NavLink>
</li>
))}
{scenarios.data && scenarios.data.length === 0 && (
<li className="px-3 py-1.5 text-xs text-slate-500">No saved plans.</li>
)}
<li>
<Link
to="/scenarios/new"
className="block rounded-md px-3 py-1.5 text-sm text-emerald-700 hover:bg-emerald-50 font-medium"
>
+ New Plan
</Link>
</li>
</SidebarSection>
);
}

View file

@ -0,0 +1,36 @@
/**
* ProjectionLab-style top tab bar for the scenario shell. Active tab
* gets a slate-900 bottom border + bolder text; inactive tabs are
* slate-500 with hover.
*/
import { NavLink } from 'react-router-dom';
export interface TabSpec {
to: string;
label: string;
end?: boolean;
}
export function TabBar({ tabs }: { tabs: TabSpec[] }) {
return (
<nav className="border-b border-slate-200 flex gap-6 text-sm overflow-x-auto">
{tabs.map((t) => (
<NavLink
key={t.to}
to={t.to}
end={t.end}
className={({ isActive }) =>
[
'py-3 px-1 -mb-px border-b-2 whitespace-nowrap',
isActive
? 'border-slate-900 text-slate-900 font-medium'
: 'border-transparent text-slate-500 hover:text-slate-800',
].join(' ')
}
>
{t.label}
</NavLink>
))}
</nav>
);
}

View file

@ -0,0 +1,34 @@
/**
* Year picker that drives the right-hand stats panel.
*
* Range slider mirrors PLab's chart scrubber. The selected year is
* controlled the parent owns `value` and propagates URL search-param
* persistence. ENTER + arrow keys also work via the native range input.
*/
interface Props {
value: number;
min: number;
max: number;
onChange: (year: number) => void;
}
export function YearScrubber({ value, min, max, onChange }: Props) {
return (
<div className="rounded-md border border-slate-200 bg-white p-3 flex items-center gap-3">
<span className="text-xs uppercase tracking-wide text-slate-500">Year</span>
<input
type="range"
min={min}
max={max}
step={1}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="flex-1 accent-slate-900"
aria-label="Year scrubber"
/>
<span className="text-sm tabular-nums font-medium text-slate-900 w-12 text-right">
{value}
</span>
</div>
);
}

View file

@ -0,0 +1,88 @@
/**
* Right-hand stats sidebar (PLab-style). Reads /scenarios/:id/year-stats.
*
* Future-Wave fields (liquid_nw, expenses, savings_rate, allocations)
* render as "—" until their respective features ship.
*/
import { useQuery } from '@tanstack/react-query';
import { api } from '@/api/client';
import { gbp, pct } from '@/lib/format';
interface Props {
scenarioId: number;
year: number;
}
export function YearStatsPanel({ scenarioId, year }: Props) {
const stats = useQuery({
queryKey: ['year-stats', scenarioId, year],
queryFn: () => api.yearStats(scenarioId, year),
staleTime: 0,
refetchOnWindowFocus: true,
});
if (stats.isLoading) {
return (
<aside className="rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-500">
Loading
</aside>
);
}
if (stats.isError || !stats.data) {
return (
<aside className="rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-500">
No stats for year {year}. Run /recompute first.
</aside>
);
}
const s = stats.data;
return (
<aside className="rounded-lg border border-slate-200 bg-white divide-y divide-slate-100">
<Cell label="Year" value={String(s.calendar_year)} />
<Cell label="Age" value={s.age != null ? String(s.age) : '—'} />
<Cell label="Net Worth" value={gbp(s.net_worth_p50)} accent />
<Cell label="Δ Net Worth" value={gbp(s.change_in_nw)} signed />
<Cell label="Liquid NW" value={s.liquid_nw ? gbp(s.liquid_nw) : '—'} />
<Cell label="Income" value={gbp(s.contributions)} />
<Cell label="Taxable Income" value={gbp(s.taxable_income)} />
<Cell label="Taxes" value={gbp(s.taxes)} />
<Cell label="Effective Tax Rate" value={pct(s.effective_tax_rate)} />
<Cell label="Spending" value={gbp(s.spending)} />
<Cell label="Expenses" value={s.expenses ? gbp(s.expenses) : '—'} />
<Cell label="Savings Rate" value={s.savings_rate ? pct(s.savings_rate) : '—'} />
<Cell label="Contributions" value={gbp(s.contributions)} />
<Cell label="Investment Growth" value={gbp(s.investment_growth)} signed />
</aside>
);
}
function Cell({
label,
value,
accent,
signed,
}: {
label: string;
value: string;
accent?: boolean;
signed?: boolean;
}) {
let signColor = '';
if (signed) {
if (value.startsWith('-')) signColor = 'text-red-600';
else if (value !== '—' && value !== '£0') signColor = 'text-emerald-700';
}
return (
<div className="flex items-baseline justify-between px-4 py-2">
<span className="text-xs uppercase tracking-wide text-slate-500">{label}</span>
<span
className={`text-sm tabular-nums font-medium ${
accent ? 'text-slate-900 text-base' : signColor || 'text-slate-700'
}`}
>
{value}
</span>
</div>
);
}

View file

@ -0,0 +1,33 @@
/**
* Map life_event.kind emoji + short label for chart markers.
*
* Unknown kinds get the bell so we always render *something* the user
* can always edit the event to a known kind.
*/
export interface Milestone {
year_idx: number;
emoji: string;
label: string;
delta_gbp?: string | null;
}
const KIND_EMOJI: Record<string, string> = {
retirement: '🏖️',
partner_retirement: '👫',
kid_born: '👶',
kids_leave_home: '🪺',
mortgage_payoff: '🏡',
home_purchase: '🏠',
sabbatical: '🌴',
inheritance: '💰',
expense_range: '💸',
one_time_income: '💵',
death: '🪦',
marriage: '💍',
car: '🚗',
};
export function emojiFor(kind: string): string {
return KIND_EMOJI[kind] ?? '🔔';
}

View file

@ -0,0 +1,94 @@
/**
* Cash Flow tab body Sankey + year scrubber (Wave 1.C.2).
*/
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { api } from '@/api/client';
import { CashflowSankey } from '@/components/CashflowSankey';
import { YearScrubber } from '@/components/YearScrubber';
import { gbp } from '@/lib/format';
export function CashflowTab() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const [year, setYear] = useState(0);
const proj = useQuery({
queryKey: ['scenarios', id, 'projection'],
queryFn: () => api.scenarios.projection(id),
enabled: Number.isFinite(id),
});
const cashflow = useQuery({
queryKey: ['cashflow', id, year],
queryFn: () => api.cashflow(id, year),
enabled: Number.isFinite(id) && proj.isSuccess,
staleTime: 0,
refetchOnWindowFocus: true,
});
if (!Number.isFinite(id)) return <p className="text-red-700">Invalid scenario id.</p>;
const horizon = (proj.data?.yearly.length ?? 60) - 1;
return (
<section className="space-y-5">
<header>
<h1 className="text-2xl font-semibold tracking-tight">Cash Flow</h1>
<p className="text-sm text-slate-500 mt-1">
Sources and sinks of the median path for the selected year. Sums conserve.
</p>
</header>
<div className="rounded-lg border border-slate-200 bg-white p-4">
<YearScrubber value={year} min={0} max={Math.max(horizon, 0)} onChange={setYear} />
</div>
{cashflow.isLoading && <p className="text-slate-500">Loading</p>}
{cashflow.isError && (
<div className="rounded-md border border-red-200 bg-red-50 p-4 text-red-800 text-sm">
{String((cashflow.error as Error)?.message ?? cashflow.error)}
</div>
)}
{cashflow.data && (
<div className="rounded-lg border border-slate-200 bg-white p-5">
<CashflowSankey data={cashflow.data} height={460} />
<SankeyTotals data={cashflow.data} />
</div>
)}
</section>
);
}
function SankeyTotals({
data,
}: {
data: { sources: Record<string, string>; sinks: Record<string, string> };
}) {
const sources = Object.entries(data.sources);
const sinks = Object.entries(data.sinks);
return (
<div className="grid md:grid-cols-2 gap-6 mt-6 text-sm">
<div>
<div className="font-medium text-slate-800 mb-2">Sources</div>
<ul className="space-y-1">
{sources.map(([k, v]) => (
<li key={k} className="flex justify-between">
<span className="text-slate-600">{k}</span>
<span className="tabular-nums">{gbp(v)}</span>
</li>
))}
</ul>
</div>
<div>
<div className="font-medium text-slate-800 mb-2">Sinks</div>
<ul className="space-y-1">
{sinks.map(([k, v]) => (
<li key={k} className="flex justify-between">
<span className="text-slate-600">{k}</span>
<span className="tabular-nums">{gbp(v)}</span>
</li>
))}
</ul>
</div>
</div>
);
}

View file

@ -0,0 +1,20 @@
/**
* Settings Milestones (Wave 1.D.2) relocated home for life events.
*/
import { useParams } from 'react-router-dom';
import { LifeEventsSection } from '@/components/LifeEventsSection';
export function MilestonesSettings() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
if (!Number.isFinite(id)) return <p className="text-red-700">Invalid scenario id.</p>;
return (
<div className="space-y-4">
<p className="text-sm text-slate-500">
Life events drive milestone markers on the Plan-tab fan chart.
</p>
<LifeEventsSection scenarioId={id} />
</div>
);
}

View file

@ -0,0 +1,81 @@
/**
* Settings Notes (Wave 1.D.4) markdown textarea, save-on-blur.
*
* Stored in `scenario.config_json.notes` via PATCH /scenarios/:id.
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { api } from '@/api/client';
export function NotesSettings() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const qc = useQueryClient();
const scen = useQuery({
queryKey: ['scenarios', id],
queryFn: () => api.scenarios.get(id),
enabled: Number.isFinite(id),
});
const [draft, setDraft] = useState('');
const [savedAt, setSavedAt] = useState<Date | null>(null);
useEffect(() => {
if (scen.data?.config_json) {
const blob = scen.data.config_json as Record<string, unknown>;
const stored = typeof blob.notes === 'string' ? blob.notes : '';
setDraft(stored);
}
}, [scen.data?.config_json]);
const save = useMutation({
mutationFn: (text: string) =>
api.scenarios.patch(id, {
config_json: {
...((scen.data?.config_json as Record<string, unknown>) ?? {}),
notes: text,
},
} as never),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['scenarios', id] });
setSavedAt(new Date());
},
});
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>;
const onBlur = () => {
const original =
(scen.data?.config_json as Record<string, unknown>)?.notes ?? '';
if (draft !== original) save.mutate(draft);
};
return (
<div className="space-y-3 max-w-3xl">
<p className="text-sm text-slate-500">
Free-form notes. Saved when you click outside the box.
</p>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={onBlur}
rows={16}
placeholder="Paste assumptions, links, decisions…"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm font-mono"
/>
<div className="text-xs text-slate-500">
{save.isPending
? 'Saving…'
: save.isError
? `Save failed: ${String((save.error as Error)?.message ?? save.error)}`
: savedAt
? `Saved at ${savedAt.toLocaleTimeString()}`
: ''}
</div>
</div>
);
}

View file

@ -0,0 +1,18 @@
/**
* Placeholder body for tabs / sub-pages that are stubbed in Wave 1.
*/
interface Props {
feature: string;
wave: number;
}
export function PlaceholderTab({ feature, wave }: Props) {
return (
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-10 text-center">
<div className="text-3xl mb-2">🚧</div>
<div className="text-base font-medium text-slate-800">{feature}</div>
<div className="text-sm text-slate-500 mt-1">Coming in Wave {wave}.</div>
</div>
);
}

View file

@ -0,0 +1,78 @@
/**
* Progress page (Wave 1.B.2) actual NW from snapshots vs projection
* fan, with a variance side panel.
*/
import { useQuery } from '@tanstack/react-query';
import { Link, useParams } from 'react-router-dom';
import { api } from '@/api/client';
import { ProgressOverlay } from '@/components/ProgressOverlay';
import { gbp } from '@/lib/format';
export function ProgressPage() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const progress = useQuery({
queryKey: ['progress', id],
queryFn: () => api.progress(id),
enabled: Number.isFinite(id),
staleTime: 0,
refetchOnWindowFocus: true,
});
if (!Number.isFinite(id)) return <p className="text-red-700">Invalid scenario id.</p>;
return (
<section className="space-y-5">
<div className="text-sm">
<Link to={`/scenarios/${id}`} className="text-slate-500 hover:text-slate-900">
Plan
</Link>
</div>
<header>
<h1 className="text-2xl font-semibold tracking-tight">Progress</h1>
<p className="text-sm text-slate-500 mt-1">
Actual net worth (from Wealthfolio snapshots) overlaid on the projection fan.
</p>
</header>
{progress.isLoading && <p className="text-slate-500">Loading</p>}
{progress.isError && (
<div className="rounded-md border border-red-200 bg-red-50 p-4 text-red-800 text-sm">
{String((progress.error as Error)?.message ?? progress.error)}
</div>
)}
{progress.data && (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-6">
<div className="rounded-lg border border-slate-200 bg-white p-5">
<ProgressOverlay data={progress.data} height={420} />
</div>
<aside className="rounded-lg border border-slate-200 bg-white p-4">
<div className="text-xs uppercase tracking-wide text-slate-500 mb-2">
Variance vs p50
</div>
{progress.data.variance.length === 0 ? (
<p className="text-sm text-slate-500">
No yearly overlap yet populate <code>account_snapshot</code> via the
Wealthfolio ingest to see variance.
</p>
) : (
<ul className="space-y-2 text-sm">
{progress.data.variance.map((v) => {
const delta = Number(v.delta_gbp);
const cls =
delta > 0 ? 'text-emerald-700' : delta < 0 ? 'text-red-600' : '';
return (
<li key={v.year_idx} className="flex items-baseline justify-between">
<span className="text-slate-600">y{v.year_idx}</span>
<span className={`tabular-nums ${cls}`}>{gbp(v.delta_gbp)}</span>
</li>
);
})}
</ul>
)}
</aside>
</div>
)}
</section>
);
}

View file

@ -0,0 +1,186 @@
/**
* Settings Rates (Wave 1.D.3).
*
* Stores its state in the scenario's `config_json.rates` blob via PATCH
* /scenarios/:id. The simulator reads them off the SimulateRequest at
* /simulate time; the Cartesian recompute path will pick them up in a
* follow-up wave.
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { api } from '@/api/client';
import { RateCard } from '@/components/RateCard';
import { SegmentedControl } from '@/components/SegmentedControl';
type RatesMode = 'fixed' | 'historical' | 'advanced';
interface RatesConfig {
mode: RatesMode;
inflation_pct: number;
stocks_growth_pct: number;
stocks_dividend_pct: number;
bonds_growth_pct: number;
bonds_dividend_pct: number;
stocks_allocation: number;
}
const DEFAULT: RatesConfig = {
mode: 'historical',
inflation_pct: 0.03,
stocks_growth_pct: 0.06,
stocks_dividend_pct: 0.025,
bonds_growth_pct: 0.015,
bonds_dividend_pct: 0.035,
stocks_allocation: 1,
};
function readConfig(blob: Record<string, unknown>): RatesConfig {
const raw = (blob?.rates ?? {}) as Partial<RatesConfig>;
return {
mode: (raw.mode as RatesMode) ?? DEFAULT.mode,
inflation_pct: numFallback(raw.inflation_pct, DEFAULT.inflation_pct),
stocks_growth_pct: numFallback(raw.stocks_growth_pct, DEFAULT.stocks_growth_pct),
stocks_dividend_pct: numFallback(raw.stocks_dividend_pct, DEFAULT.stocks_dividend_pct),
bonds_growth_pct: numFallback(raw.bonds_growth_pct, DEFAULT.bonds_growth_pct),
bonds_dividend_pct: numFallback(raw.bonds_dividend_pct, DEFAULT.bonds_dividend_pct),
stocks_allocation: numFallback(raw.stocks_allocation, DEFAULT.stocks_allocation),
};
}
function numFallback(value: unknown, fallback: number): number {
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : fallback;
}
export function RatesSettings() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const qc = useQueryClient();
const scen = useQuery({
queryKey: ['scenarios', id],
queryFn: () => api.scenarios.get(id),
enabled: Number.isFinite(id),
});
const [config, setConfig] = useState<RatesConfig>(DEFAULT);
useEffect(() => {
if (scen.data?.config_json) {
setConfig(readConfig(scen.data.config_json as Record<string, unknown>));
}
}, [scen.data?.config_json]);
const save = useMutation({
mutationFn: (next: RatesConfig) =>
api.scenarios.patch(id, {
config_json: {
...((scen.data?.config_json as Record<string, unknown>) ?? {}),
rates: next,
},
} as never),
onSuccess: () => qc.invalidateQueries({ queryKey: ['scenarios', id] }),
});
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.data) return null;
const realStock =
(1 + config.stocks_growth_pct + config.stocks_dividend_pct) /
(1 + config.inflation_pct) -
1;
const update = (patch: Partial<RatesConfig>) => {
const next = { ...config, ...patch };
setConfig(next);
save.mutate(next);
};
return (
<div className="space-y-5 max-w-2xl">
<div>
<p className="text-sm text-slate-500 mb-3">
Choose how returns are generated. Fixed mode synthesises a deterministic real
return from the per-asset growth + dividend numbers minus inflation. Historical
uses the Shiller bootstrap. Advanced is a Wave 2 placeholder.
</p>
<SegmentedControl
options={[
{ value: 'fixed', label: 'Fixed' },
{ value: 'historical', label: 'Historical' },
{ value: 'advanced', label: 'Advanced (Wave 2)', title: 'Coming in Wave 2' },
]}
value={config.mode}
onChange={(v) => update({ mode: v as RatesMode })}
/>
</div>
<div className="space-y-3">
<RateCard
title="Inflation"
growth={config.inflation_pct}
onChange={(n) => update({ inflation_pct: n.growth })}
/>
<RateCard
title="Stocks"
growth={config.stocks_growth_pct}
dividend={config.stocks_dividend_pct}
onChange={(n) =>
update({
stocks_growth_pct: n.growth,
stocks_dividend_pct: n.dividend ?? config.stocks_dividend_pct,
})
}
/>
<RateCard
title="Bonds"
growth={config.bonds_growth_pct}
dividend={config.bonds_dividend_pct}
onChange={(n) =>
update({
bonds_growth_pct: n.growth,
bonds_dividend_pct: n.dividend ?? config.bonds_dividend_pct,
})
}
/>
</div>
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm">
<div className="text-xs uppercase tracking-wide text-slate-500">
Computed real stock return
</div>
<div className="text-lg font-semibold tabular-nums text-slate-800 mt-1">
{(realStock * 100).toFixed(2)}%
</div>
<div className="text-xs text-slate-500 mt-1">
(1 + growth + dividend) / (1 + inflation) 1
</div>
</div>
<label className="block text-xs">
<span className="uppercase tracking-wide text-slate-500">Stocks allocation</span>
<input
type="number"
min={0}
max={1}
step={0.05}
value={config.stocks_allocation}
onChange={(e) => update({ stocks_allocation: Number(e.target.value) })}
className="mt-1 w-32 rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
/>
<span className="ml-3 text-xs text-slate-500">
(only used in Fixed mode; bonds = 1 allocation)
</span>
</label>
{save.isPending && <p className="text-xs text-slate-500">Saving</p>}
{save.isError && (
<p className="text-xs text-red-700">
{String((save.error as Error)?.message ?? save.error)}
</p>
)}
</div>
);
}

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>
);
}

View file

@ -0,0 +1,52 @@
/**
* Tabbed shell for a scenario (Wave 1.A.1) top tab bar + left sidebar.
*
* The body is owned by nested routes so each tab can manage its own
* data without re-mounting the chrome.
*/
import { Outlet, useParams } from 'react-router-dom';
import { Sidebar } from '@/components/Sidebar';
import { TabBar, type TabSpec } from '@/components/TabBar';
export function ScenarioShell() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const tabs: TabSpec[] = [
{ to: `/scenarios/${id}`, label: 'Plan', end: true },
{ to: `/scenarios/${id}/cash-flow`, label: 'Cash Flow' },
{ to: `/scenarios/${id}/tax-analytics`, label: 'Tax Analytics' },
{ to: `/scenarios/${id}/compare`, label: 'Compare' },
{ to: `/scenarios/${id}/reports`, label: 'Reports' },
{ to: `/scenarios/${id}/estate`, label: 'Estate' },
{ to: `/scenarios/${id}/settings`, label: 'Settings' },
];
return (
<div className="flex gap-4 -my-8 -mx-6">
<Sidebar activeScenarioId={Number.isFinite(id) ? id : undefined} />
<div className="flex-1 min-w-0 px-6 py-6">
<TabBar tabs={tabs} />
<div className="pt-6">
<Outlet />
</div>
</div>
</div>
);
}
export function ComingInWaveCard({
wave,
feature,
}: {
wave: number;
feature: string;
}) {
return (
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-8 text-center">
<div className="text-2xl mb-2">🚧</div>
<div className="text-sm font-medium text-slate-800">{feature}</div>
<div className="text-xs text-slate-500 mt-1">Coming in Wave {wave}.</div>
</div>
);
}

View file

@ -0,0 +1,35 @@
/**
* Settings tab (Wave 1.D.1) sub-nav + nested route outlet.
*/
import { Outlet, useParams } from 'react-router-dom';
import { SettingsSubnav } from '@/components/SettingsSubnav';
export function SettingsTab() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const items = [
{ to: `/scenarios/${id}/settings`, label: 'Milestones', end: true },
{ to: `/scenarios/${id}/settings/rates`, label: 'Rates' },
{ to: `/scenarios/${id}/settings/dividends`, label: 'Dividends' },
{ to: `/scenarios/${id}/settings/bonds`, label: 'Bonds' },
{ to: `/scenarios/${id}/settings/tax`, label: 'Tax' },
{ to: `/scenarios/${id}/settings/metrics`, label: 'Metrics' },
{ to: `/scenarios/${id}/settings/other`, label: 'Other Settings' },
{ to: `/scenarios/${id}/settings/notes`, label: 'Notes' },
];
return (
<section>
<header className="mb-4">
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
</header>
<div className="flex gap-6">
<SettingsSubnav items={items} />
<div className="flex-1 min-w-0">
<Outlet />
</div>
</div>
</section>
);
}