/** * What-If — interactive Monte Carlo. Form on the left, fan chart on the * right. Hits POST /simulate (no DB write); ~1-3s for 5k paths. */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { api, type SimulateRequest, type SimulateResult } from '@/api/client'; import { FanChart } from '@/components/FanChart'; import { gbp, pct } from '@/lib/format'; const JURISDICTIONS = ['uk', 'cyprus', 'bulgaria', 'malaysia', 'thailand', 'uae', 'nomad']; const STRATEGIES = ['trinity', 'guyton_klinger', 'vpw', 'vpw_floor']; const GLIDES = ['rising', 'static_60_40']; const DEFAULTS: SimulateRequest = { jurisdiction: 'cyprus', strategy: 'guyton_klinger', leave_uk_year: 2, glide_path: 'rising', spending_gbp: '60000', nw_seed_gbp: '1500000', savings_per_year_gbp: '0', horizon_years: 60, floor_gbp: null, n_paths: 5000, seed: 42, }; export function WhatIf() { const [form, setForm] = useState(DEFAULTS); const [nwAutoFilled, setNwAutoFilled] = useState(false); const navigate = useNavigate(); const qc = useQueryClient(); // Pre-fill NW seed from the latest Wealthfolio snapshot the first // time it loads, so opening /what-if always starts from real numbers. // The user can still edit; we won't clobber their input on later // refetches. const nw = useQuery({ queryKey: ['networth', 'current'], queryFn: api.networth.current }); useEffect(() => { if (nwAutoFilled || !nw.data || nw.data.accounts.length === 0) return; // Round to whole pounds — pence from the API don't survive HTML5 // step validation, and the user doesn't think in pence at this scale. const rounded = String(Math.round(Number(nw.data.total_gbp))); setForm((f) => ({ ...f, nw_seed_gbp: rounded })); setNwAutoFilled(true); }, [nw.data, nwAutoFilled]); const sim = useMutation({ mutationFn: (req: SimulateRequest) => api.simulate(req), }); const save = useMutation({ mutationFn: (name: string) => api.scenarios.create({ name, jurisdiction: form.jurisdiction, strategy: form.strategy, leave_uk_year: form.leave_uk_year, glide_path: form.glide_path, spending_gbp: form.spending_gbp, nw_seed_gbp: form.nw_seed_gbp, savings_per_year_gbp: form.savings_per_year_gbp, horizon_years: form.horizon_years, }), onSuccess: (s) => { qc.invalidateQueries({ queryKey: ['scenarios'] }); navigate(`/scenarios/${s.id}`); }, }); const onSubmit = (e: React.FormEvent) => { e.preventDefault(); sim.mutate({ ...form, floor_gbp: form.strategy === 'vpw_floor' ? form.floor_gbp : null, }); }; const onSaveAs = () => { const suggested = `${form.jurisdiction}-${form.strategy}-leave-y${form.leave_uk_year}`; const name = prompt('Save as scenario — name:', suggested); if (!name?.trim()) return; save.mutate(name.trim()); }; const update = (key: K, value: SimulateRequest[K]) => setForm((f) => ({ ...f, [key]: value })); return (

What if…

Run a single Monte Carlo against the engine. No data persisted.

update('strategy', v)} options={STRATEGIES} /> onChange(e.target.value)} className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400" > {options.map((o) => ( ))} ); } 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" /> ); }