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.
events, interactive Visx Gantt + spending-profile chart
Charts are now the primary editor for life events. The Plan-tab body
re-orders to make charts ~80% of viewport real-estate; legacy form
sections are collapsed into a drawer.
Backend:
- alembic 0004: life_event.category enum (essential / discretionary /
not_spending). Defaults to essential so existing rows keep their
full spending impact.
- Simulator gains discretionary_outflows + flex_rules params. Tracks
per-path running ATH, applies the deepest applicable cut to
discretionary outflows when portfolio drops vs ATH (PLab-style flex
spending). Cut amount stays in the portfolio (refund pattern).
- New flex_spending module with FlexRule + applicable_cut +
cuts_per_year (vectorised). Sortable rules; "deepest cut wins" so
users specify cumulative cuts at each tier.
- New /scenarios/{id}/spending-profile endpoint returning per-year
base / essential / discretionary / flex_cut / total breakdown.
- SimulateRequest gains flex_rules + life_event.category roundtrip.
- 8 new tests; 246 total pytest pass; mypy + ruff clean.
Frontend (Visx + ECharts):
- Installed @visx/{scale,shape,group,axis,event,responsive,tooltip}
for native SVG drag interactions.
- New <SpendingProfileChart> — Visx stacked-area of base/essential/
discretionary with red flex-cut overlay, hover tooltip, click-to-
scrub-year.
- New <EventGantt> — interactive Visx Gantt:
* Click empty space → popover create at that year (default
essential spending event)
* Click a bar → inline edit popover (name, kind, range, £/y,
category) with delete button
* Drag bar middle → moves the whole event (year-resolution snap)
* Drag bar edges → resizes year_start / year_end
* All gestures persist via PATCH /life-events/{id}
- New <FlexRulesEditor> — list of {from_ath_pct, cut} tiers, save-on-
change to scenario.config_json.flex_rules.
- Plan-tab redesign: NW fan dominant top with floating stat badges
(Year/Age/NW/Δ NW/Spending/Eff. tax) over the chart; spending-
profile chart middle; Gantt bottom; flex-rules editor; legacy form
sections in a collapsed <details> drawer.
- Frontend typecheck + 7 vitest tests + production build all clean.