fire-planner/frontend/src/pages/RatesSettings.tsx

187 lines
6.1 KiB
TypeScript
Raw Normal View History

fire-planner: ProjectionLab parity Wave 1 — tabbed shell, year stats, goals, income streams, Sankey cashflow, progress overlay, settings sub-pages Wave 1 (9 features across 4 streams): Stream A — dashboard skeleton 1.A.1 ScenarioShell with top tabs (Plan/Cash Flow/Tax Analytics/Compare/ Reports/Estate/Settings) + left Sidebar with Plans switcher. 1.A.2 GET /scenarios/{id}/year-stats?year=N returning per-year metrics (NW, Δ NW, taxable income, taxes, eff. rate, spending, contribs, investment growth). YearScrubber + YearStatsPanel render the right-hand sidebar; URL ?year= preserves selection. 1.A.3 FanChart gains optional `milestones` prop (lib/milestone.ts maps life_event.kind → emoji) + selectedYear marker line. Stream B — goals + progress 1.B.1 New goals_eval module: target_nw_by_year / never_run_out / target_real_income probability evaluation. Wired into POST /simulate (exact, per-path) and GET /scenarios/{id}/projection (approximated from persisted fan via percentile interpolation). GoalsSection renders pass/fail badges. 1.B.2 GET /scenarios/{id}/progress overlays AccountSnapshot totals on the projection fan; ProgressPage shows variance side-panel. Stream C — income + cashflow 1.C.1 New IncomeStream model + alembic 0003 + CRUD endpoints. Engine aggregates streams into per-year inflows + taxable arrays; income tax routes through the jurisdiction tax engine. IncomeStreamsSection on Plan tab. 1.C.2 GET /scenarios/{id}/cashflow?year=N returns sources/sinks for an ECharts Sankey (sums conserve). CashflowTab body. Stream D — settings 1.D.1 SettingsTab + sub-nav (Milestones/Rates/Dividends/Bonds/Tax/ Metrics/Other/Notes); placeholder cards for unbuilt sub-pages. 1.D.2 LifeEventsSection relocated to /scenarios/:id/settings. 1.D.3 RatesSettings (Fixed/Historical/Advanced segmented + per-asset cards). SimulateRequest gains rates_mode, inflation_pct, stocks/bonds growth + dividend, stocks_allocation. New build_fixed_paths() in simulator. Real-return arithmetic verified against (1+g+d)/(1+i)−1 ≈ 5.4%. 1.D.4 NotesSettings — markdown textarea, save-on-blur, stored in scenario.config_json.notes. Backend: 238 pytest pass (+19 new), mypy + ruff clean. Frontend: typecheck + 7 unit tests + production build clean. Roadmap for Wave 2-N is documented in the implementation plan.
2026-05-10 12:49:44 +00:00
/**
* 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>
);
}