/** * Plan-tab body — Wave 2 chart-first redesign. * * Layout (chart is the SoT for editing life events): * ┌────────────────────────────────────────┐ * │ NW fan + floating stats badges (top-R) │ * │ year-scrubber along the bottom │ * ├────────────────────────────────────────┤ * │ Spending profile (stacked area) │ * ├────────────────────────────────────────┤ * │ Event Gantt (drag/click to edit) │ * ├────────────────────────────────────────┤ * │ Flex rules editor │ * ├────────────────────────────────────────┤ * │ Drawer: legacy form sections (collapsed)│ * └────────────────────────────────────────┘ */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; 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 { EventGantt } from '@/components/EventGantt'; import { FanChart } from '@/components/FanChart'; import { FlexRulesEditor } from '@/components/FlexRulesEditor'; import { GoalsSection } from '@/components/GoalsSection'; import { IncomeStreamsSection } from '@/components/IncomeStreamsSection'; import { LifeEventsSection } from '@/components/LifeEventsSection'; import { SpendingProfileChart } from '@/components/SpendingProfileChart'; import { YearScrubber } from '@/components/YearScrubber'; 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], queryFn: () => api.scenarios.get(id), enabled: Number.isFinite(id), }); const proj = useQuery({ queryKey: ['scenarios', id, 'projection'], queryFn: () => api.scenarios.projection(id), enabled: Number.isFinite(id), retry: (count, err) => { 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 profile = useQuery({ queryKey: ['spending-profile', id], queryFn: () => api.spendingProfile(id), enabled: Number.isFinite(id), staleTime: 0, refetchOnWindowFocus: true, }); const yearStats = useQuery({ queryKey: ['year-stats', id, parseInt(searchParams.get('year') ?? '0', 10)], queryFn: () => api.yearStats(id, parseInt(searchParams.get('year') ?? '0', 10)), enabled: Number.isFinite(id) && proj.isSuccess, staleTime: 0, }); const del = useMutation({ mutationFn: () => api.scenarios.delete(id), onSuccess: () => { qc.invalidateQueries({ queryKey: ['scenarios'] }); navigate('/scenarios'); }, }); const sim = useMutation({ 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(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 (!scen.data) return; if (!confirm(`Delete scenario "${scen.data.name ?? id}"? This can't be undone.`)) return; del.mutate(); }; const onRunNow = async (s: Scenario) => { const fresh = await lifeEventsApi.list(s.id); const flexRules = readFlexRules(s); sim.mutate({ jurisdiction: s.jurisdiction, strategy: s.strategy, leave_uk_year: s.leave_uk_year, spending_gbp: s.spending_gbp, nw_seed_gbp: s.nw_seed_gbp, savings_per_year_gbp: s.savings_per_year_gbp, horizon_years: s.horizon_years, n_paths: 5000, seed: 42, life_events: fresh.map((e) => ({ year_start: e.year_start, year_end: e.year_end, delta_gbp_per_year: e.delta_gbp_per_year, one_time_amount_gbp: e.one_time_amount_gbp, category: e.category, enabled: e.enabled, })), flex_rules: flexRules, }); }; if (!Number.isFinite(id)) return

Invalid scenario id.

; if (scen.isLoading) return

Loading…

; if (scen.isError || !scen.data) { return (
Couldn't load scenario {id}.
); } const s = scen.data; const projection = proj.data; const projection404 = proj.isError && proj.error instanceof ApiError && proj.error.status === 404; return (
← Scenarios

{s.name ?? s.external_id}

{s.kind} · {s.jurisdiction} · {s.strategy} · leave UK y{s.leave_uk_year} ·{' '} {s.glide_path} glide · {s.horizon_years}y horizon

{s.description &&

{s.description}

}
{s.kind === 'user' && ( <> Edit )}
{del.isError && (
{String((del.error as Error)?.message ?? del.error)}
)} {projection ? ( <> {/* NW fan with floating stat badges */}

Portfolio fan

p10/p50/p90 over {projection.yearly.length}y · {projection.n_paths.toLocaleString()} paths
{/* Spending profile */}

Spending profile

{profile.data ? ( ) : (

Loading…

)}
{/* Interactive Gantt */}

Life events

Click empty space to add · drag bars to move · drag edges to resize
) : projection404 ? (

No projection yet.

Run python -m fire_planner recompute-all or{' '} POST /recompute to fill in MC projections for all scenarios.

) : proj.isLoading ? (

Loading projection…

) : (
{String((proj.error as Error)?.message ?? proj.error)}
)} {sim.data && (

Live preview run

{sim.data.elapsed_seconds}s · 5,000 paths · success {pct(sim.data.success_rate)}
)} {sim.isError && (
{String((sim.error as Error)?.message ?? sim.error)}
)} {/* Legacy form sections — collapsed by default. The chart UI above is the primary editor; these stay for bulk edit + accessibility. */}
Form-based editors (income streams · goals · life events table)
); } function FloatingStats(props: { year: number; maxYear: number; successRate: string; p50End: string; netWorth?: string; changeNw?: string; spending?: string; taxes?: string; effectiveRate?: string; age: number | null; calendarYear?: number; }) { return (
); } function Badge({ label, value, accent, signed, }: { label: string; value: string; accent?: boolean; signed?: string; }) { let cls = 'text-slate-700'; if (accent) cls = 'text-emerald-700'; if (signed != null) { const n = Number(signed); if (n > 0) cls = 'text-emerald-700'; else if (n < 0) cls = 'text-red-600'; } return (
{label}
{value}
); } function Legend() { return (
); } function Swatch({ color, label }: { color: string; label: string }) { return ( {label} ); } function readFlexRules(s: Scenario): { from_ath_pct: string; cut_discretionary_pct: string }[] { const blob = s.config_json as Record; const raw = blob?.flex_rules; if (!Array.isArray(raw)) return []; return raw .filter((r): r is Record => typeof r === 'object' && r !== null) .map((r) => ({ from_ath_pct: String(r.from_ath_pct ?? 0), cut_discretionary_pct: String(r.cut_discretionary_pct ?? 0), })); }