From 70101c836c62e2f7f88ae5bd40fdfb7f5165f9f2 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Tue, 12 May 2026 19:35:28 +0000 Subject: [PATCH] fire-planner: What-If gains the chart-first scenario editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Plan-tab editors (interactive Gantt for life events, flex spending rules) are now available in What-If too — with local state instead of DB persistence so users can tweak before committing to a scenario. Architecture refactor: - EventGantt is now a controlled component. The `scenarioId` prop + internal useMutation/useQueryClient hooks went away; the component takes a `persister: { create, patch, delete }` prop and delegates every mutation through it. Plan tab wires it to lifeEventsApi + cache invalidation; What-If wires it to React local state with negative ids for new events. - FlexRulesEditor is similarly controlled. Takes `rules + onChange` instead of a `scenario` object. Plan tab wraps it with the PATCH /scenarios/:id mutation; What-If wraps it with setFlexRules. Backend: - New stateless POST /scenarios/spending-profile-preview endpoint takes base_spending_gbp + horizon + life_events + flex_rules in the body and returns the same SpendingProfileResponse shape as the read-only /scenarios/{id}/spending-profile endpoint. Used by What-If to render the stacked-area chart against unsaved events. - SpendingProfileResponse.scenario_id loosened to int | None to support the preview variant. Frontend: - What-If page gains `events` + `flexRules` local state, an EventGantt + FlexRulesEditor wired through them, and a Visx spending-profile chart fed by /spending-profile-preview. - Sim auto-refresh: a 600ms debounced effect re-fires /simulate whenever the form / events / flex rules change. Manual "Run simulation" button stays as an immediate trigger. - "Save as scenario" still works — preserves the scenario params but not yet the life events / flex rules (a Wave-3 follow-up could POST them after the scenario is created). 247 pytest pass; mypy + ruff + frontend typecheck/test/build all clean. --- fire_planner/api/schemas.py | 17 +- fire_planner/api/spending_profile.py | 71 +++++++++ frontend/src/api/client.ts | 22 ++- frontend/src/components/EventGantt.tsx | 60 +++---- frontend/src/components/FlexRulesEditor.tsx | 85 +++------- frontend/src/pages/ScenarioDetail.tsx | 70 ++++++++- frontend/src/pages/WhatIf.tsx | 165 +++++++++++++++++++- 7 files changed, 372 insertions(+), 118 deletions(-) diff --git a/fire_planner/api/schemas.py b/fire_planner/api/schemas.py index c5c2ee3..3a968f1 100644 --- a/fire_planner/api/schemas.py +++ b/fire_planner/api/schemas.py @@ -427,11 +427,26 @@ class SpendingProfilePoint(BaseModel): class SpendingProfileResponse(BaseModel): - scenario_id: int + scenario_id: int | None = None horizon_years: int points: list[SpendingProfilePoint] +class SpendingProfilePreviewRequest(BaseModel): + """Stateless spending-profile preview — used by the What-If page where + the user is editing in-memory life events that aren't persisted yet. + + No scenario_id needed; the caller supplies the baseline spending, + horizon, and any flex rules. Flex-cut estimation against a portfolio + fan is skipped (caller already has a /simulate response to pair + with this if they want flex visualisation). + """ + base_spending_gbp: Decimal = Field(ge=0) + horizon_years: int = Field(ge=1, le=100) + life_events: list[LifeEventInput] = Field(default_factory=list) + flex_rules: list[FlexRule] = Field(default_factory=list) + + class SimulateRequest(BaseModel): """Sync, non-persisted simulate. Used by the React UI for what-if. diff --git a/fire_planner/api/spending_profile.py b/fire_planner/api/spending_profile.py index 3e91351..8ed3035 100644 --- a/fire_planner/api/spending_profile.py +++ b/fire_planner/api/spending_profile.py @@ -31,6 +31,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from fire_planner.api.dependencies import get_session from fire_planner.api.schemas import ( SpendingProfilePoint, + SpendingProfilePreviewRequest, SpendingProfileResponse, ) from fire_planner.db import LifeEvent, McRun, ProjectionYearly, Scenario @@ -183,3 +184,73 @@ async def get_spending_profile( horizon_years=horizon, points=points, ) + + +@router.post("/spending-profile-preview", response_model=SpendingProfileResponse) +async def preview_spending_profile( + req: SpendingProfilePreviewRequest, +) -> SpendingProfileResponse: + """Stateless variant — used by the What-If page where the user is + tweaking in-memory life events that aren't persisted yet.""" + points: list[SpendingProfilePoint] = [] + base = Decimal(str(req.base_spending_gbp)) + # Flex rules are accepted for forward-compat but not applied here: + # without a portfolio path we can't decide drawdown depth. The + # paired /simulate call gives the exact cut; this endpoint just + # gives the pre-cut profile for the stacked-area chart. + _ = req.flex_rules + + for year_idx in range(req.horizon_years): + essential = Decimal("0") + discretionary = Decimal("0") + not_spending = Decimal("0") + ess_inflow = Decimal("0") + disc_inflow = Decimal("0") + for ev in req.life_events: + if not ev.enabled: + continue + if year_idx < ev.year_start: + continue + end = ev.year_end if ev.year_end is not None else ev.year_start + if year_idx > end: + continue + delta = Decimal(str(ev.delta_gbp_per_year or 0)) + if ev.category == "not_spending": + not_spending += abs(delta) + continue + if delta < 0: + if ev.category == "essential": + essential += -delta + elif ev.category == "discretionary": + discretionary += -delta + elif delta > 0: + if ev.category == "essential": + ess_inflow += delta + elif ev.category == "discretionary": + disc_inflow += delta + if year_idx == ev.year_start and ev.one_time_amount_gbp is not None: + ot = Decimal(str(ev.one_time_amount_gbp)) + if ot < 0: + if ev.category == "essential": + essential += -ot + elif ev.category == "discretionary": + discretionary += -ot + net_base = base - ess_inflow - disc_inflow + if net_base < 0: + net_base = Decimal("0") + total = net_base + essential + discretionary + points.append( + SpendingProfilePoint( + year_idx=year_idx, + base_gbp=net_base, + essential_gbp=essential, + discretionary_gbp=discretionary, + not_spending_gbp=not_spending, + flex_cut_gbp=Decimal("0"), + total_gbp=total, + )) + return SpendingProfileResponse( + scenario_id=None, + horizon_years=req.horizon_years, + points=points, + ) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4302d9a..035fbb6 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -57,6 +57,11 @@ export const api = { request(`/scenarios/${id}/cashflow?year=${year}`), spendingProfile: (id: number) => request(`/scenarios/${id}/spending-profile`), + spendingProfilePreview: (body: SpendingProfilePreviewRequest) => + request(`/scenarios/spending-profile-preview`, { + method: 'POST', + body: JSON.stringify(body), + }), networth: { current: () => request<{ @@ -210,11 +215,26 @@ export interface SpendingProfilePoint { } export interface SpendingProfileResponse { - scenario_id: number; + scenario_id: number | null; horizon_years: number; points: SpendingProfilePoint[]; } +export interface SpendingProfilePreviewRequest { + base_spending_gbp: string; + horizon_years: number; + life_events?: Array<{ + kind?: string; + year_start: number; + year_end?: number | null; + delta_gbp_per_year?: string; + one_time_amount_gbp?: string | null; + category?: SpendingCategory; + enabled?: boolean; + }>; + flex_rules?: FlexRule[]; +} + // ── goals ──────────────────────────────────────────────────────────── export interface Goal { diff --git a/frontend/src/components/EventGantt.tsx b/frontend/src/components/EventGantt.tsx index d5e7d87..4c44b05 100644 --- a/frontend/src/components/EventGantt.tsx +++ b/frontend/src/components/EventGantt.tsx @@ -13,7 +13,6 @@ * fallback under the drawer remains for bulk edits and accessibility. */ import { useEffect, useMemo, useRef, useState } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; import { ParentSize } from '@visx/responsive'; import { scaleBand, scaleLinear } from '@visx/scale'; import { Group } from '@visx/group'; @@ -21,7 +20,6 @@ import { AxisBottom } from '@visx/axis'; import { localPoint } from '@visx/event'; import { - lifeEventsApi, type LifeEvent, type LifeEventCreateBody, type LifeEventPatchBody, @@ -30,10 +28,19 @@ import { import { gbp } from '@/lib/format'; import { emojiFor } from '@/lib/milestone'; +/** Persistence hooks — parent decides what create/patch/delete do. + * Plan tab wires these to the lifeEventsApi; What-If wires them to + * local React state. */ +export interface GanttPersister { + create: (body: LifeEventCreateBody) => void; + patch: (id: number, body: LifeEventPatchBody) => void; + delete: (id: number) => void; +} + interface Props { - scenarioId: number; events: LifeEvent[]; horizonYears: number; + persister: GanttPersister; height?: number; } @@ -77,9 +84,9 @@ export function EventGantt(props: Props) { } function Inner({ - scenarioId, events, horizonYears, + persister, width, height = 220, }: Props & { width: number }) { @@ -112,39 +119,8 @@ function Inner({ [sortedEvents], ); - const qc = useQueryClient(); - const invalidate = () => - qc.invalidateQueries({ - queryKey: ['scenarios', scenarioId, 'life-events'], - }); - const invalidateProfile = () => - qc.invalidateQueries({ - queryKey: ['spending-profile', scenarioId], - }); - - const patchMut = useMutation({ - mutationFn: ({ id, body }: { id: number; body: LifeEventPatchBody }) => - lifeEventsApi.patch(id, body), - onSuccess: () => { - invalidate(); - invalidateProfile(); - }, - }); - const createMut = useMutation({ - mutationFn: (body: LifeEventCreateBody) => - lifeEventsApi.create(scenarioId, body), - onSuccess: () => { - invalidate(); - invalidateProfile(); - }, - }); - const deleteMut = useMutation({ - mutationFn: (id: number) => lifeEventsApi.delete(id), - onSuccess: () => { - invalidate(); - invalidateProfile(); - }, - }); + // Mutations are delegated to the parent via `persister` (Plan tab + // wires to lifeEventsApi; What-If wires to local React state). // Drag state lives in a ref so mousemove handlers don't re-render. const drag = useRef(null); @@ -192,7 +168,7 @@ function Inner({ year_end: clamp(origYearEnd + dyears, origYearStart, horizonYears - 1), }; } - patchMut.mutate({ id: eventId, body }); + persister.patch(eventId, body); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); @@ -200,7 +176,7 @@ function Inner({ window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; - }, [horizonYears, patchMut, xScale]); + }, [horizonYears, persister, xScale]); const startDrag = (e: React.MouseEvent, ev: LifeEvent, mode: DragMode) => { e.stopPropagation(); @@ -401,15 +377,15 @@ function Inner({ state={popover} onClose={() => setPopover(null)} onCreate={(body) => { - createMut.mutate(body); + persister.create(body); setPopover(null); }} onPatch={(id, body) => { - patchMut.mutate({ id, body }); + persister.patch(id, body); setPopover(null); }} onDelete={(id) => { - deleteMut.mutate(id); + persister.delete(id); setPopover(null); }} horizonYears={horizonYears} diff --git a/frontend/src/components/FlexRulesEditor.tsx b/frontend/src/components/FlexRulesEditor.tsx index 092bf01..0dd62f1 100644 --- a/frontend/src/components/FlexRulesEditor.tsx +++ b/frontend/src/components/FlexRulesEditor.tsx @@ -1,79 +1,35 @@ /** * Flex-rules editor — list of {from_ath_pct, cut_discretionary_pct} - * tiers stored on `scenario.config_json.flex_rules`. Saves on blur via - * the existing PATCH /scenarios/:id (config_json is a free-form blob). + * tiers. Controlled component: parent owns the rules + persistence. + * + * Plan tab wraps this with a PATCH /scenarios/:id call. What-If wraps + * it with local React state. */ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; - -import { api, type Scenario } from '@/api/client'; - -interface Rule { +export interface FlexRule { from_ath_pct: number; cut_discretionary_pct: number; } interface Props { - scenario: Scenario; + rules: FlexRule[]; + onChange: (next: FlexRule[]) => void; + saving?: boolean; + error?: string | null; } -const DEFAULT_RULES: Rule[] = [ +const DEFAULT_RULES: FlexRule[] = [ { from_ath_pct: 0.10, cut_discretionary_pct: 0.20 }, { from_ath_pct: 0.30, cut_discretionary_pct: 0.60 }, ]; -function readRules(scen: Scenario): Rule[] { - const blob = scen.config_json as Record; - const raw = blob?.flex_rules; - if (!Array.isArray(raw)) return []; - return raw - .filter((r): r is { from_ath_pct: unknown; cut_discretionary_pct: unknown } => - typeof r === 'object' && r !== null, - ) - .map((r) => ({ - from_ath_pct: Number((r as { from_ath_pct: unknown }).from_ath_pct ?? 0), - cut_discretionary_pct: Number( - (r as { cut_discretionary_pct: unknown }).cut_discretionary_pct ?? 0, - ), - })); -} - -export function FlexRulesEditor({ scenario }: Props) { - const qc = useQueryClient(); - const [rules, setRules] = useState(() => readRules(scenario)); - - useEffect(() => { - setRules(readRules(scenario)); - }, [scenario.id, scenario.config_json]); - - const save = useMutation({ - mutationFn: (next: Rule[]) => - api.scenarios.patch(scenario.id, { - config_json: { - ...((scenario.config_json as Record) ?? {}), - flex_rules: next, - }, - } as never), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['scenarios', scenario.id] }); - qc.invalidateQueries({ - queryKey: ['spending-profile', scenario.id], - }); - }, - }); - - const persist = (next: Rule[]) => { - setRules(next); - save.mutate(next); +export function FlexRulesEditor({ rules, onChange, saving, error }: Props) { + const update = (idx: number, patch: Partial) => { + onChange(rules.map((r, i) => (i === idx ? { ...r, ...patch } : r))); }; - - const update = (idx: number, patch: Partial) => { - persist(rules.map((r, i) => (i === idx ? { ...r, ...patch } : r))); - }; - - const remove = (idx: number) => persist(rules.filter((_, i) => i !== idx)); - const add = () => persist([...rules, { from_ath_pct: 0.20, cut_discretionary_pct: 0.40 }]); - const seedDefaults = () => persist(DEFAULT_RULES); + const remove = (idx: number) => onChange(rules.filter((_, i) => i !== idx)); + const add = () => + onChange([...rules, { from_ath_pct: 0.20, cut_discretionary_pct: 0.40 }]); + const seedDefaults = () => onChange(DEFAULT_RULES); return (
@@ -129,11 +85,8 @@ export function FlexRulesEditor({ scenario }: Props) { > + Add tier - {save.isError && ( -

- {String((save.error as Error)?.message ?? save.error)} -

- )} + {saving &&

Saving…

} + {error &&

{error}

}
); } diff --git a/frontend/src/pages/ScenarioDetail.tsx b/frontend/src/pages/ScenarioDetail.tsx index df8934e..c4dbd06 100644 --- a/frontend/src/pages/ScenarioDetail.tsx +++ b/frontend/src/pages/ScenarioDetail.tsx @@ -29,7 +29,7 @@ import { import { ApiError } from '@/api/client'; import { EventGantt } from '@/components/EventGantt'; import { FanChart } from '@/components/FanChart'; -import { FlexRulesEditor } from '@/components/FlexRulesEditor'; +import { FlexRulesEditor, type FlexRule } from '@/components/FlexRulesEditor'; import { GoalsSection } from '@/components/GoalsSection'; import { IncomeStreamsSection } from '@/components/IncomeStreamsSection'; import { LifeEventsSection } from '@/components/LifeEventsSection'; @@ -95,6 +95,61 @@ export function ScenarioDetail() { }, }); + // API-backed persisters for the chart-first editors. Each invalidates + // the queries that drive the Plan tab so the spending profile + fan + // re-fetch after the mutation lands. + const invalidateScenarioCaches = () => { + qc.invalidateQueries({ queryKey: ['scenarios', id, 'life-events'] }); + qc.invalidateQueries({ queryKey: ['spending-profile', id] }); + }; + const lifeEventCreate = useMutation({ + mutationFn: (body: Parameters[1]) => + lifeEventsApi.create(id, body), + onSuccess: invalidateScenarioCaches, + }); + const lifeEventPatch = useMutation({ + mutationFn: ({ eid, body }: { eid: number; body: Parameters[1] }) => + lifeEventsApi.patch(eid, body), + onSuccess: invalidateScenarioCaches, + }); + const lifeEventDelete = useMutation({ + mutationFn: (eid: number) => lifeEventsApi.delete(eid), + onSuccess: invalidateScenarioCaches, + }); + const ganttPersister = useMemo( + () => ({ + create: (body: Parameters[1]) => + lifeEventCreate.mutate(body), + patch: (eid: number, body: Parameters[1]) => + lifeEventPatch.mutate({ eid, body }), + delete: (eid: number) => lifeEventDelete.mutate(eid), + }), + // mutate is stable across renders by useMutation contract + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const flexRulesSave = useMutation({ + mutationFn: (next: FlexRule[]) => + api.scenarios.patch(id, { + config_json: { + ...((scen.data?.config_json as Record) ?? {}), + flex_rules: next, + }, + } as never), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['scenarios', id] }); + qc.invalidateQueries({ queryKey: ['spending-profile', id] }); + }, + }); + const persistedFlexRules: FlexRule[] = useMemo( + () => (scen.data ? readFlexRules(scen.data).map((r) => ({ + from_ath_pct: Number(r.from_ath_pct), + cut_discretionary_pct: Number(r.cut_discretionary_pct), + })) : []), + [scen.data], + ); + const sim = useMutation({ mutationFn: (req: SimulateRequest) => api.simulate(req), }); @@ -402,13 +457,22 @@ export function ScenarioDetail() { - + flexRulesSave.mutate(next)} + saving={flexRulesSave.isPending} + error={ + flexRulesSave.isError + ? String((flexRulesSave.error as Error)?.message ?? flexRulesSave.error) + : null + } + /> ) : projection404 ? (
diff --git a/frontend/src/pages/WhatIf.tsx b/frontend/src/pages/WhatIf.tsx index bd5f0fa..63574c9 100644 --- a/frontend/src/pages/WhatIf.tsx +++ b/frontend/src/pages/WhatIf.tsx @@ -13,18 +13,25 @@ * `api/simulate.py::_project`. */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { api, type AnnualSpending, + type LifeEvent, + type LifeEventCreateBody, + type LifeEventPatchBody, type SimulateRequest, type SimulateResult, + type SpendingCategory, } from '@/api/client'; +import { EventGantt, type GanttPersister } from '@/components/EventGantt'; import { FanChart } from '@/components/FanChart'; +import { FlexRulesEditor, type FlexRule } from '@/components/FlexRulesEditor'; import { InfoTip } from '@/components/InfoTip'; import { SegmentedControl, type SegmentedOption } from '@/components/SegmentedControl'; +import { SpendingProfileChart } from '@/components/SpendingProfileChart'; import { gbp, pct } from '@/lib/format'; const JURISDICTIONS = ['uk', 'cyprus', 'bulgaria', 'malaysia', 'thailand', 'uae', 'nomad']; @@ -150,6 +157,72 @@ export function WhatIf() { void spending.refetch(); }; + // Local life-events + flex rules. Editing these never hits the DB — + // What-If is for tweaking before committing to a scenario. + const [events, setEvents] = useState([]); + const [flexRules, setFlexRules] = useState([]); + const nextLocalId = useRef(-1); + const allocLocalId = () => { + const n = nextLocalId.current; + nextLocalId.current -= 1; + return n; + }; + + const ganttPersister: GanttPersister = useMemo( + () => ({ + create: (body: LifeEventCreateBody) => { + const ev: LifeEvent = { + id: allocLocalId(), + scenario_id: 0, + kind: body.kind, + name: body.name, + year_start: body.year_start, + year_end: body.year_end ?? null, + delta_gbp_per_year: String(body.delta_gbp_per_year ?? '0'), + one_time_amount_gbp: body.one_time_amount_gbp ?? null, + category: (body.category as SpendingCategory) ?? 'essential', + enabled: body.enabled ?? true, + payload: body.payload ?? null, + created_at: new Date().toISOString(), + }; + setEvents((prev) => [...prev, ev]); + }, + patch: (id: number, body: LifeEventPatchBody) => { + setEvents((prev) => + prev.map((e) => + e.id === id + ? { + ...e, + ...(body.name !== undefined && { name: body.name }), + ...(body.kind !== undefined && { kind: body.kind }), + ...(body.year_start !== undefined && { + year_start: body.year_start, + }), + ...(body.year_end !== undefined && { + year_end: body.year_end, + }), + ...(body.delta_gbp_per_year !== undefined && { + delta_gbp_per_year: body.delta_gbp_per_year, + }), + ...(body.one_time_amount_gbp !== undefined && { + one_time_amount_gbp: body.one_time_amount_gbp, + }), + ...(body.category !== undefined && { + category: body.category, + }), + ...(body.enabled !== undefined && { enabled: body.enabled }), + } + : e, + ), + ); + }, + delete: (id: number) => { + setEvents((prev) => prev.filter((e) => e.id !== id)); + }, + }), + [], + ); + const sim = useMutation({ mutationFn: (req: SimulateRequest) => api.simulate(req), }); @@ -173,10 +246,9 @@ export function WhatIf() { }, }); - const onSubmit = (e: React.FormEvent) => { - e.preventDefault(); + const buildRequest = (): SimulateRequest => { const isCustom = form.strategy === 'custom'; - sim.mutate({ + return { ...form, floor_gbp: form.strategy === 'vpw_floor' ? form.floor_gbp : null, manual_real_return_pct: @@ -184,9 +256,60 @@ export function WhatIf() { annual_real_adjust_pct: isCustom ? form.annual_real_adjust_pct : '0', guardrail_threshold_pct: isCustom ? form.guardrail_threshold_pct : null, guardrail_cut_pct: isCustom ? form.guardrail_cut_pct : '0.10', - }); + life_events: events.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.map((r) => ({ + from_ath_pct: String(r.from_ath_pct), + cut_discretionary_pct: String(r.cut_discretionary_pct), + })), + }; }; + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + sim.mutate(buildRequest()); + }; + + // Auto-refresh sim whenever the form / events / flex rules change. + // Debounced 600ms so rapid edits (drag, slider) don't fire 10x. + const signature = JSON.stringify({ form, events, flexRules }); + useEffect(() => { + const t = window.setTimeout(() => { + sim.mutate(buildRequest()); + }, 600); + return () => window.clearTimeout(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signature]); + + // Spending profile preview — recomputes on every signature change. + const profile = useQuery({ + queryKey: ['spending-profile-preview', signature], + queryFn: () => + api.spendingProfilePreview({ + base_spending_gbp: form.spending_gbp, + horizon_years: form.horizon_years ?? 60, + life_events: events.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.map((r) => ({ + from_ath_pct: String(r.from_ath_pct), + cut_discretionary_pct: String(r.cut_discretionary_pct), + })), + }), + staleTime: 0, + }); + const onSaveAs = () => { const suggested = `${form.jurisdiction}-${form.strategy}-leave-y${form.leave_uk_year}`; const name = prompt('Save as scenario — name:', suggested); @@ -264,6 +387,38 @@ export function WhatIf() { {sim.data && ( <> + + {/* Spending profile (stacked area) */} + {profile.data && ( +
+
+

Spending profile

+ + base · essential · discretionary + +
+ +
+ )} + + {/* Interactive Gantt — chart-as-SoT for life events */} +
+
+

Life events

+ + Click empty space to add · drag bars to move · drag edges to resize + +
+ +
+ + {/* Flex spending rules */} + +