fire-planner: ProjectionLab parity Wave 1 — tabbed shell, year stats, goals,
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
income streams, Sankey cashflow, progress overlay, settings sub-pages
Wave 1 (9 features across 4 streams):
Stream A — dashboard skeleton
1.A.1 ScenarioShell with top tabs (Plan/Cash Flow/Tax Analytics/Compare/
Reports/Estate/Settings) + left Sidebar with Plans switcher.
1.A.2 GET /scenarios/{id}/year-stats?year=N returning per-year metrics
(NW, Δ NW, taxable income, taxes, eff. rate, spending, contribs,
investment growth). YearScrubber + YearStatsPanel render the
right-hand sidebar; URL ?year= preserves selection.
1.A.3 FanChart gains optional `milestones` prop (lib/milestone.ts maps
life_event.kind → emoji) + selectedYear marker line.
Stream B — goals + progress
1.B.1 New goals_eval module: target_nw_by_year / never_run_out /
target_real_income probability evaluation. Wired into POST
/simulate (exact, per-path) and GET /scenarios/{id}/projection
(approximated from persisted fan via percentile interpolation).
GoalsSection renders pass/fail badges.
1.B.2 GET /scenarios/{id}/progress overlays AccountSnapshot totals on
the projection fan; ProgressPage shows variance side-panel.
Stream C — income + cashflow
1.C.1 New IncomeStream model + alembic 0003 + CRUD endpoints. Engine
aggregates streams into per-year inflows + taxable arrays;
income tax routes through the jurisdiction tax engine.
IncomeStreamsSection on Plan tab.
1.C.2 GET /scenarios/{id}/cashflow?year=N returns sources/sinks for
an ECharts Sankey (sums conserve). CashflowTab body.
Stream D — settings
1.D.1 SettingsTab + sub-nav (Milestones/Rates/Dividends/Bonds/Tax/
Metrics/Other/Notes); placeholder cards for unbuilt sub-pages.
1.D.2 LifeEventsSection relocated to /scenarios/:id/settings.
1.D.3 RatesSettings (Fixed/Historical/Advanced segmented + per-asset
cards). SimulateRequest gains rates_mode, inflation_pct,
stocks/bonds growth + dividend, stocks_allocation. New
build_fixed_paths() in simulator. Real-return arithmetic
verified against (1+g+d)/(1+i)−1 ≈ 5.4%.
1.D.4 NotesSettings — markdown textarea, save-on-blur, stored in
scenario.config_json.notes.
Backend: 238 pytest pass (+19 new), mypy + ruff clean.
Frontend: typecheck + 7 unit tests + production build clean.
Roadmap for Wave 2-N is documented in the implementation plan.
This commit is contained in:
parent
e12e8f9290
commit
9cc781a8d6
42 changed files with 3765 additions and 80 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
75
frontend/src/components/CashflowSankey.tsx
Normal file
75
frontend/src/components/CashflowSankey.tsx
Normal 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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
255
frontend/src/components/IncomeStreamsSection.tsx
Normal file
255
frontend/src/components/IncomeStreamsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
frontend/src/components/ProgressOverlay.tsx
Normal file
95
frontend/src/components/ProgressOverlay.tsx
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
67
frontend/src/components/RateCard.tsx
Normal file
67
frontend/src/components/RateCard.tsx
Normal 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)}%`;
|
||||
}
|
||||
37
frontend/src/components/SettingsSubnav.tsx
Normal file
37
frontend/src/components/SettingsSubnav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
frontend/src/components/Sidebar.tsx
Normal file
125
frontend/src/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
frontend/src/components/TabBar.tsx
Normal file
36
frontend/src/components/TabBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/YearScrubber.tsx
Normal file
34
frontend/src/components/YearScrubber.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
frontend/src/components/YearStatsPanel.tsx
Normal file
88
frontend/src/components/YearStatsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
frontend/src/lib/milestone.ts
Normal file
33
frontend/src/lib/milestone.ts
Normal 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] ?? '🔔';
|
||||
}
|
||||
94
frontend/src/pages/CashflowTab.tsx
Normal file
94
frontend/src/pages/CashflowTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/src/pages/MilestonesSettings.tsx
Normal file
20
frontend/src/pages/MilestonesSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
frontend/src/pages/NotesSettings.tsx
Normal file
81
frontend/src/pages/NotesSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
frontend/src/pages/PlaceholderTab.tsx
Normal file
18
frontend/src/pages/PlaceholderTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
frontend/src/pages/ProgressPage.tsx
Normal file
78
frontend/src/pages/ProgressPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
frontend/src/pages/RatesSettings.tsx
Normal file
186
frontend/src/pages/RatesSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
52
frontend/src/pages/ScenarioShell.tsx
Normal file
52
frontend/src/pages/ScenarioShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
frontend/src/pages/SettingsTab.tsx
Normal file
35
frontend/src/pages/SettingsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue