fire-planner/frontend/src/pages/RatesSettings.tsx
Viktor Barzin 9cc781a8d6
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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

186 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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