From 1d347ff65b16e225f9559ca51084d96d51a28678 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 10 May 2026 01:51:24 +0000 Subject: [PATCH] whatif: drop glide-path, compact form into 4 sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The What-If form was a 14-field stack with always-visible hint paragraphs — ~1500px scroll before "Run". The user is single-allocation (100% stocks), so the glide-path knob was noise. Hardcoded `static(1.0)` at the API layer; dropped `glide_path` from `SimulateRequest` (extra field on persisted Scenario rows still honoured for Cartesian sweeps). Frontend reorganised into anchor numbers (NW / spend / horizon at text-2xl), a Plan card (jurisdiction + leave-UK + strategy chips + conditional Floor/Custom sub-card), a Returns card (3-chip segmented control with inline manual %), and a folded Advanced section (savings, MC paths, seed). Verbose hints moved into ⓘ popovers next to each label. Two new primitives: SegmentedControl + InfoTip. Co-Authored-By: Claude Opus 4.7 --- fire_planner/api/schemas.py | 9 +- fire_planner/api/simulate.py | 4 +- frontend/src/api/client.ts | 1 - frontend/src/components/InfoTip.tsx | 40 ++ frontend/src/components/SegmentedControl.tsx | 59 ++ frontend/src/pages/ScenarioDetail.tsx | 1 - frontend/src/pages/WhatIf.tsx | 686 +++++++++++-------- 7 files changed, 501 insertions(+), 299 deletions(-) create mode 100644 frontend/src/components/InfoTip.tsx create mode 100644 frontend/src/components/SegmentedControl.tsx diff --git a/fire_planner/api/schemas.py b/fire_planner/api/schemas.py index b64aa5c..0d89998 100644 --- a/fire_planner/api/schemas.py +++ b/fire_planner/api/schemas.py @@ -213,11 +213,16 @@ class LifeEventInput(BaseModel): class SimulateRequest(BaseModel): - """Sync, non-persisted simulate. Used by the React UI for what-if.""" + """Sync, non-persisted simulate. Used by the React UI for what-if. + + Allocation is hardcoded to 100% stocks at the engine layer + (`api/simulate.py::_project`). The UI removed the glide-path knob + in 2026-05; persisted Cartesian scenarios still carry their own + `glide_path` string on the `scenario` table. + """ jurisdiction: str strategy: str leave_uk_year: int = Field(ge=0, le=60) - glide_path: str = "rising" spending_gbp: Decimal = Field(gt=0) nw_seed_gbp: Decimal = Field(ge=0) savings_per_year_gbp: Decimal = Decimal("0") diff --git a/fire_planner/api/simulate.py b/fire_planner/api/simulate.py index 5e3e4ff..8666b41 100644 --- a/fire_planner/api/simulate.py +++ b/fire_planner/api/simulate.py @@ -25,7 +25,7 @@ from fire_planner.api.schemas import ( SimulateRequest, SimulateResult, ) -from fire_planner.glide_path import get as get_glide +from fire_planner.glide_path import static from fire_planner.ingest.wealthfolio_pg import create_wf_sync_engine_from_env from fire_planner.life_events import EventInput, events_to_cashflow_array from fire_planner.returns.bootstrap import block_bootstrap @@ -119,7 +119,7 @@ def _project(req: SimulateRequest, paths: np.ndarray) -> tuple[SimulationResult, paths=paths, initial_portfolio=float(req.nw_seed_gbp), spending_target=float(req.spending_gbp), - glide=get_glide(req.glide_path), + glide=static(1.0), strategy=strategy, regime=build_regime_schedule(req.jurisdiction, req.leave_uk_year), horizon_years=req.horizon_years, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6484886..896945d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -226,7 +226,6 @@ export interface SimulateRequest { jurisdiction: string; strategy: string; leave_uk_year: number; - glide_path: string; spending_gbp: string; nw_seed_gbp: string; savings_per_year_gbp?: string; diff --git a/frontend/src/components/InfoTip.tsx b/frontend/src/components/InfoTip.tsx new file mode 100644 index 0000000..e76a48d --- /dev/null +++ b/frontend/src/components/InfoTip.tsx @@ -0,0 +1,40 @@ +/** + * Small ⓘ button that reveals a popover hint on hover or focus. Used to + * keep field labels short while still surfacing the long explanation + * for the curious. Click also toggles (mobile / no-hover devices). + */ +import { useState, type ReactNode } from 'react'; + +interface Props { + text: ReactNode; + label?: string; +} + +export function InfoTip({ text, label = 'Info' }: Props) { + const [open, setOpen] = useState(false); + return ( + + + {open && ( + + {text} + + )} + + ); +} diff --git a/frontend/src/components/SegmentedControl.tsx b/frontend/src/components/SegmentedControl.tsx new file mode 100644 index 0000000..00d7ebf --- /dev/null +++ b/frontend/src/components/SegmentedControl.tsx @@ -0,0 +1,59 @@ +/** + * One-row chip picker. All options visible at once, current pinned with a + * darker bg + ring. Use for low-cardinality enums where a ` update('jurisdiction', v)} - options={JURISDICTIONS} - /> - - - update('glide_path', v)} - options={GLIDES} - /> - - - update('leave_uk_year', v)} - min={0} - max={60} - /> - - - update('spending_gbp', String(v))} - min={0} - /> - - - update('nw_seed_gbp', String(v))} - min={0} - /> - - - update('savings_per_year_gbp', String(v))} - min={0} - /> - - - update('horizon_years', v)} - min={5} - max={100} - /> - - {form.strategy === 'vpw_floor' && ( - - update('floor_gbp', String(v))} - min={0} - /> - - )} - {form.strategy === 'custom' && ( - <> - - - update('annual_real_adjust_pct', e.target.value) - } - className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" - /> - - - - update( - 'guardrail_threshold_pct', - e.target.value === '' ? null : e.target.value, - ) - } - className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" - /> - - - update('guardrail_cut_pct', e.target.value)} - className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" - /> - - - )} - - - - {form.returns_mode === 'manual' && ( - - update('manual_real_return_pct', e.target.value)} - className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" - /> - - )} - - update('n_paths', v)} - min={100} - max={50000} - step={100} - /> - +
+
+ + + + + + + @@ -380,6 +230,327 @@ export function WhatIf() { ); } +// ── Sections ───────────────────────────────────────────────────────── + +function AnchorNumbers({ + form, + update, +}: { + form: SimulateRequest; + update: (k: K, v: SimulateRequest[K]) => void; +}) { + return ( +
+
+ update('nw_seed_gbp', v)} + /> + update('spending_gbp', v)} + /> + update('horizon_years', clampInt(v, 5, 100))} + step={1} + /> +
+
+ ); +} + +function PlanCard({ + form, + update, + strategy, +}: { + form: SimulateRequest; + update: (k: K, v: SimulateRequest[K]) => void; + strategy: Strategy; +}) { + return ( +
+
+
+ + +
+
+ + update('leave_uk_year', clampInt(e.target.value, 0, 60))} + className="mt-1 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" + /> +
+
+ +
+ +
+ + value={strategy} + onChange={(v) => update('strategy', v)} + options={STRATEGY_OPTIONS} + size="sm" + /> +
+
+ + {strategy === 'vpw_floor' && ( +
+ + update('floor_gbp', e.target.value)} + className="mt-1 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" + /> +
+ )} + + {strategy === 'custom' && ( +
+
+ + update('annual_real_adjust_pct', e.target.value)} + className="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" + /> +
+
+ + + update('guardrail_threshold_pct', e.target.value === '' ? null : e.target.value) + } + className="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" + /> +
+
+ + update('guardrail_cut_pct', e.target.value)} + className="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" + /> +
+
+ )} +
+ ); +} + +function ReturnsCard({ + form, + update, + returnsMode, +}: { + form: SimulateRequest; + update: (k: K, v: SimulateRequest[K]) => void; + returnsMode: ReturnsMode; +}) { + return ( +
+ +
+ + value={returnsMode} + onChange={(v) => update('returns_mode', v)} + options={RETURNS_OPTIONS} + size="sm" + /> + {returnsMode === 'manual' && ( + + )} +
+
+ ); +} + +function AdvancedCard({ + form, + update, +}: { + form: SimulateRequest; + update: (k: K, v: SimulateRequest[K]) => void; +}) { + return ( +
+ + Advanced + + +
+
+ + update('savings_per_year_gbp', e.target.value)} + className="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" + /> +
+
+ + update('n_paths', clampInt(e.target.value, 100, 50000))} + className="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" + /> +
+
+ + update('seed', clampInt(e.target.value, 0, 999999))} + className="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" + /> +
+
+
+ ); +} + +// ── Primitives ─────────────────────────────────────────────────────── + +function SmallLabel({ text, tip }: { text: string; tip?: string }) { + return ( +
+ {text} + {tip && } +
+ ); +} + +function BigNumber({ + label, + value, + onChange, + prefix, + suffix, + step = 1, +}: { + label: string; + value: string; + onChange: (v: string) => void; + prefix?: string; + suffix?: string; + step?: number; +}) { + return ( + + ); +} + +function clampInt(raw: string, min: number, max: number): number { + const n = Math.round(Number(raw)); + if (!Number.isFinite(n)) return min; + return Math.min(max, Math.max(min, n)); +} + +// ── Result panel + about ───────────────────────────────────────────── + function AboutTheModel() { return (
@@ -395,10 +566,6 @@ function AboutTheModel() { {STRATEGY_NOTES.vpw_floor} {STRATEGY_NOTES.custom} -
- {GLIDE_NOTES.rising} - {GLIDE_NOTES.static_60_40} -
{JURISDICTION_NOTES.uk} {JURISDICTION_NOTES.cyprus} @@ -418,15 +585,22 @@ function AboutTheModel() {

- {RETURNS_MODE_NOTES.shiller} - {RETURNS_MODE_NOTES.manual} - {RETURNS_MODE_NOTES.wealthfolio} + {RETURNS_NOTES.shiller} + {RETURNS_NOTES.manual} + {RETURNS_NOTES.wealthfolio}

Each path resamples blocks independently so sequence-of-returns risk is preserved. Long-run benchmarks for context: 60/40 real ≈ 4.6%; equities ~9.5% nominal / 17% volatility; bonds ~5%/8%.

+
+

+ All What-If runs use 100% stocks. The glide-path knob was removed in May 2026 + — the user is single-allocation in real life, so simulating 60/40 mixes was noise. + Persisted Cartesian scenarios still carry their own glide string. +

+

A path counts as a success if the portfolio stays positive through every interim @@ -466,10 +640,7 @@ function Results({ result, horizon }: { result: SimulateResult; horizon: number - +

@@ -503,74 +674,3 @@ function Stat({ ); } - -function Field({ - label, - hint, - children, -}: { - label: string; - hint?: string; - children: React.ReactNode; -}) { - return ( - - ); -} - -function Select({ - value, - onChange, - options, -}: { - value: string; - onChange: (v: string) => void; - options: string[]; -}) { - return ( - - ); -} - -function NumberInput({ - value, - onChange, - min, - max, - step = 1, -}: { - value: number; - onChange: (v: number) => void; - min?: number; - max?: number; - step?: number; -}) { - return ( - { - const n = Number(e.target.value); - if (Number.isFinite(n)) onChange(n); - }} - className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" - /> - ); -}