refresh on scenario edits
Two fixes for the user's report that adding a £100k life-event spend
didn't change the chart:
Engine (simulator.py)
- New `extra_outflows` param. cashflow_adjustments still drains the
portfolio at start-of-year as before, but the simulator now ALSO
records the spending in `withdrawal_hist[p, y]` so the chart's red
median-withdrawal trace shows the bump. Without this, the £100k
silently came out of the portfolio but the user-facing withdrawal
trace stayed at the strategy's flat 4% draw.
- simulate.py wires extra_outflows = essential + discretionary
category outflows from life events.
UX (ScenarioDetail.tsx)
- New auto-refresh: when life events / income streams / flex rules
change for a scenario, the page fires `/simulate` automatically
with 2,000 paths and uses the result as the primary fan/year-stats
source. The persisted MC run is only consulted as a fallback for
scenarios with no overrides.
- Fan chart title gains a "live preview · Xs · Ny" pill while a sim
is current, and "re-running…" while a fresh one is in flight.
- Removed the now-redundant "Live preview run" duplicate card lower
down — the main chart IS the live preview.
- Year-stats badge row reads from sim.data when available so changes
propagate immediately to NW / Δ NW / Spending / Taxes.
247 pytest pass (+1 new); mypy + ruff clean; frontend typecheck/test/
build green.
Without this, dragging a bar fired mouseup → onClick in the same tick,
opening the edit popover with stale (pre-mutation) year values. The
PATCH succeeded but the popover showed the old year_start until React
Query refetched. Adding a 250ms debounce after drag-end before
re-enabling click-to-edit.
Tests + build green.
single-year drag, Settings highlight, Dashboard empty state
Round 2 of agent-driven UX review.
- api status badge was always "unreachable" because client called
/api/healthz but FastAPI mounts /healthz at root (so k8s probes
hit it without the SPA prefix). Client now calls /healthz directly.
- Progress page rendered without the shell sidebar/tabs because its
route was sibling to ScenarioShell; moved it inside as a nested
route (also drops the redundant "← Plan" breadcrumb since the tab
bar handles that now).
- EventGantt: single-year (point) events no longer render edge handles
since the bar is too narrow to distinguish "edge grab" from "middle
grab" — for points the whole bar moves; resize via the popover.
Bars wider than 24px keep their edge handles.
- Settings sub-nav: Milestones now points at /settings/milestones
(consistent active highlight); /settings index redirects there.
- Dashboard "Last 12 months" chart shows an explainer when the
history has fewer than 2 snapshots instead of an empty axis.
- Stub tabs that are currently active get a slate-300 underline +
slate-500 text rather than the bold slate-900 — visually honest
about what's a placeholder.
Frontend typecheck/test/build green.
Round-1 fixes from the headless UI review:
Backend
- scenarios PATCH now allows config_json/name/description on cartesian
scenarios (so users can pin flex_rules + notes that recompute will
preserve). Core fields (jurisdiction/strategy/etc.) still blocked
because they're rebuilt on recompute. Existing test updated.
Frontend
- Sidebar Plans switcher: drop the kind=user filter so the switcher
surfaces all 120 cartesian scenarios that ship out of the box.
- Settings → Milestones now reachable at both /settings (index) and
/settings/milestones (explicit) — the agent navigated to the latter
and got a blank page.
- EventGantt background click capture: explicit pointerEvents="all" +
fillOpacity=0 so click-to-add reliably fires on empty regions
between bars.
- Plan tab stat badges moved out of the chart card into a dedicated
row above the fan — previously they overlapped the chart's title,
legend caption ("p10/p50/p..."), and right-side withdrawal axis.
- Stub tabs (Tax Analytics / Compare / Reports / Estate) and stub
Settings sub-pages (Dividends / Bonds / Tax / Metrics / Other) get
a "soon" badge + slate-300 styling so they're clearly placeholders.
- New "Portfolio depleted at this year" pill renders in the badge
row when the scrubbed year's NW is 0 — previously the badges
silently went to £0 with no UI cue.
- Test life-event from the smoke run cleaned up from prod DB.
246 pytest pass; mypy/ruff clean; frontend typecheck/test/build green.
Visx 3.12 peer-deps cap React at ^18 but we're on React 19. The
Dockerfile uses 'npm ci' (no flag overrides), so the build failed
with ERESOLVE. Adding the .npmrc file persists the legacy-peer-deps
flag for both 'npm install' and 'npm ci' without changing the
Dockerfile.
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.
Three follow-ups to the actualbudget integration:
**Always-fresh autofill.** Drop the one-shot `*AutoFilled` boolean
gates; replace with `nwUserEdited` / `spendingUserEdited` flags. Until
the user types into either field, every refetch (mount, window
focus) updates the form value. Once they edit, we leave it alone. A
small ↻ button next to each anchor input flips the edited flag back
off so the user can re-snap to live data on demand. React Query
configured with staleTime=0 + refetchOnMount='always' +
refetchOnWindowFocus=true so the cache never serves stale numbers.
NW provenance shows the snapshot date.
**Inflation-adjusted spending.** Backend now revalues each trailing
month's nominal pence forward to today's £ using monthly compounding
of `inflation_pct` (default 0.03 ≈ UK CPI 2024-26). Headline
`total_gbp` is the real-£ figure — matches the simulator's
real-GBP convention. Response also includes `nominal_total_gbp` and
`inflation_pct` for transparency. New /spending/annual?inflation_pct=
override param. 10/10 actualbudget tests pass.
**FanChart legend.** The bottom-anchored legend was overlapping the
x-axis label. Moved to top: 8 with itemGap=18 + type=scroll for
narrow viewports; bumped grid top→48 / bottom→56 + xAxis nameGap→28
so nothing collides.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a thin read-only client for the actualbudget HTTP API
(`fire_planner/actualbudget.py`) and a `GET /spending/annual` endpoint
that returns trailing-N-month spending broken out by category group.
Default exclusions ("Investments and Savings", "Budget Reset") strip
out wealth transfers so the headline number reflects actual
consumption — for Viktor's data, ~£41k/yr instead of the raw £210k
total. Caller can pass `?exclude=...` to override.
Frontend uses the headline `total_gbp` to autofill the Annual spending
input (same pattern as nw_seed from networth), with a small
provenance line below the input showing the window + which groups
were excluded.
Auth: 3 new env vars (ACTUALBUDGET_API_URL/KEY/SYNC_ID) sourced from
Vault `secret/fire-planner` via the existing ExternalSecret —
infra/stacks/fire-planner applied separately. Backend silently keeps
the hardcoded default if the upstream is unreachable.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
NW seed at £1.5M was nearly clipping in the BigNumber inputs. Widened
the form column 420→480px, switched the anchor row to a weighted
1.4/1.2/0.7 grid so NW seed and Spending get more room than Horizon,
dropped the BigNumber font from text-2xl to text-xl, and tightened
prefix/suffix padding to claw back digits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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 <noreply@anthropic.com>
The user noticed the "Annual spending" field was a no-op for Trinity,
GK, VPW, VPW+floor — the strategies internally hardcoded the year-0
withdrawal as `initial_portfolio × initial_rate` (4% / 5.5%) and
ignored what the user typed. Two fixes:
(1) Trinity + GK now use state.initial_withdrawal (= the user's
spending_target) as the year-0 draw. GK's guardrail anchor
becomes the implied initial rate (initial_withdrawal /
initial_portfolio), so the rule shape adapts to the user's
chosen rate. Both strategies still fall back to their preset
rate × initial_portfolio when initial_withdrawal isn't set
(test paths). VPW and VPW+floor stay algorithmic — they're
"withdraw-what's-sustainable" by design and don't take a
spending input.
(2) New "custom" preset (SpendingPlanStrategy) exposing all the
knobs:
- initial_spend = "Annual spending" input
- annual_real_adjust_pct = scale last year's withdrawal by N%
each year (0 = constant real £, +0.02 = 2%/yr healthcare
creep, -0.005 = -0.5%/yr slow-down with age)
- guardrail_threshold_pct = if portfolio falls below X% of
starting NW, trigger a cut (None = disabled)
- guardrail_cut_pct = cut last year's withdrawal by Y% each
triggered year
Adjust applies first, then guardrail cut — so a triggered year in
+2% adjust mode goes 40k → 40.8k → 36.7k.
UI: "custom" added to the strategy dropdown; when selected, three
extra fields appear (annual real adjustment %, guardrail trigger
threshold, guardrail cut size) with hints. The existing inputs
(spending, NW seed) drive year 0 across all strategies that use
them. About-the-model panel updated.
10 new tests on SpendingPlanStrategy + adjusted GK tests for the
new spending_target-aware behaviour. 209 backend tests + 7
frontend tests. mypy + ruff + tsc all pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a "Returns model" picker on /what-if that switches how the
simulator's `paths` (n_paths × n_years × 3) is built:
1. shiller (default) — current behaviour, block-bootstrap of the
Shiller 1871+ historical series (or its synthetic-calibrated
fallback when the CSV isn't mounted).
2. manual — every year of every path = the user's "real return %"
input. Deterministic, no fan, useful for sanity checks. New
helper `constant_real_return_paths` constructs the (n_paths,
n_years, 3) tensor with stock=bond=real, cpi=0 so the simulator's
`(1+nominal)/(1+cpi)-1` short-circuits to exactly the input.
3. wealthfolio — pulls daily_account_valuation from the wealthfolio_sync
PG mirror, sums total_value + net_contribution across accounts per
day (FX-adjusted), strips contribution deltas to isolate market
return, compounds daily returns into per-calendar-year samples,
block-bootstraps with block_size=1 (only ~6 distinct samples
available, no serial-correlation signal to preserve). Glide path
is a no-op in this mode — the user's actual blended portfolio is
treated as a single asset.
API: SimulateRequest gains `returns_mode` ("shiller"|"manual"|
"wealthfolio") + `manual_real_return_pct`. simulate.py's `_build_paths`
dispatches; wealthfolio mode opens a transient session against the
mirror DB.
UI: new Field on the form (next to Strategy / Glide path) with a
contextual hint that explains each option's tradeoff. The "About the
model" panel at the bottom now has a "Returns model" section
mirroring the same content. The Manual % input only shows when
returns_mode='manual'.
10 new tests on the Wealthfolio helper (contribution-stripping,
multi-account aggregation, FX, partial-year drop, TOTAL filter,
empty-input, plus 3 deterministic-paths tests). 198 backend tests +
7 frontend tests. mypy strict + ruff + tsc strict all pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The user kept asking what each strategy means. Adding two layers:
1. Inline hint under each dropdown (Jurisdiction, Strategy, Glide
path) — short one-liner that updates as the selected option
changes. So no clicking required to see what trinity vs
guyton_klinger does.
2. <details> panel at the bottom: "About the model" with all
strategies, glide paths, jurisdictions described in plain
English, plus a note on how the engine treats tax (post-fix:
drain = w + tax(w)) and the returns model (Shiller block
bootstrap, 60/40 ≈ 4.6% real long run).
Plain-English originals — Trinity / Guyton-Klinger / VPW are
public-domain finance concepts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
step={1000}/step={10000} on £ inputs blocked any non-multiple value
from passing browser validation. Surfaced when the Wealthfolio
autofill landed `1050195.09` into the NW field — Chrome rejected
it: "the two nearest valid values are 1050000 and 1060000". Same
class as the n_paths bug from earlier today.
- Remove step on spending_gbp / nw_seed_gbp / savings_per_year_gbp /
floor_gbp across What-If, ScenarioNew, ScenarioEdit. Default step=1
(any positive integer).
- Round NW autofill to whole pounds (Math.round) since the user
doesn't think in pence at this scale and the rounded value
satisfies any integer step.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two fixes:
(1) Simulator: portfolio drain is now `w + tax(w)`, not just `w`.
The pre-2026-05-10 engine recorded tax in tax_hist but never
subtracted it from the portfolio, so changing jurisdiction only
moved the median_lifetime_tax cell — the fan chart, success
rate, and ending percentiles were identical for UK vs Cyprus
vs Malaysia. (The PLAYBOOK_VIKTOR.md memo from 2026-04-26
explicitly noted this: "Success rate is regime-independent…
tax doesn't drain the portfolio in this simulator.")
Mental model now: spending_target is what the user takes home;
the tax bill is an additional drag on the same pool. Higher-tax
jurisdictions therefore drain faster and lower the success
rate, which is the user's intuition. Trinity 4% effectively
becomes "4% take-home + tax overhead". 188 tests still pass —
most use Malaysia (0%) or hit the regime-independent code paths.
(2) /what-if and /scenarios/new now pre-fill nw_seed_gbp from
GET /networth on first mount (when the wealthfolio_sync mirror
has data), so opening the form starts from the user's real
portfolio total instead of the £1.5M placeholder. Once the user
edits the field, subsequent NW refetches don't clobber it
(nwAutoFilled latch).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
step=500 with min=100 made valid values 100, 600, 1100, … so typing
a round number like 500 or 1000 tripped browser validation
("Please enter a valid value. The two nearest valid values are 100
and 600."). Found via headless click-through against the deployed
instance — the Run-simulation submit was silently rejected.
step=100 keeps a sensible up/down increment while accepting any
multiple of 100 (100, 200, …, 5000, …, 50000).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Header gets a third button: "Recompute all". POSTs /recompute
(202-accepted, async background worker drains the queue). Once
work is in flight, the existing /healthz query auto-refetches
every 3s and the button label switches to "Computing… (queue N)";
flips back to idle when the queue drains.
Removes the need to drop to CLI (`python -m fire_planner
recompute-all`) for routine refreshes. Bearer auth still applies in
prod once API_BEARER_TOKEN is set — frontend will need a token
header in that mode (TODO).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Until now life events were stored but ignored by the engine — pure
metadata. Now they actually move portfolios.
Engine:
- simulator.simulate() takes optional cashflow_adjustments: a (n_years,)
real-GBP array applied each year *after* savings + return but
*before* withdrawal. Positive = inflow, negative = outflow.
- New fire_planner/life_events.py with EventInput dataclass +
events_to_cashflow_array(events, horizon). Handles ranged deltas,
one-time amounts, disabled events, year clipping past horizon,
negative year_start (clipped to 0), and summing multiple events.
API:
- /simulate accepts optional life_events list. Server converts each
to EventInput, builds cashflow_adjustments, passes to simulate().
- Frontend Run-now on scenario detail now fetches the scenario's
life events and includes them in the request — projections finally
reflect "retire at 50, kid born at y3, inheritance at y22".
Tests: 11 events helper + 4 end-to-end engine + 1 API integration =
16 new tests. 188 total (was 172). mypy strict + ruff clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three small UX wins:
- /scenarios/:id Run now — POSTs /simulate with the scenario's params
and renders the result in a "Live preview run" card below the
persisted projection. Removes the recompute-or-wait friction.
- /what-if Save as scenario — appears once a simulation has run.
Prompts for a name (with a sensible default), POSTs the form values
to /scenarios as a user scenario, redirects to its detail page.
- /scenarios/:id/edit — PATCH form for user scenarios. Pre-fills from
current scenario; on save invalidates the scenarios query and
navigates back to detail. Backend already rejects PATCH on
cartesian; the UI also hides the Edit button for them.
api.scenarios gained patch(). 7 tests pass, typecheck + build clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two new nested CRUD sections on /scenarios/:id, each list + add form
in one card:
- Life events: name, kind (free-text with datalist suggestions —
retirement, kid_born, mortgage_payoff, sabbatical, inheritance...),
year_start, optional year_end (one-time vs ranged), £/year delta.
One-line summary per row; Delete button per item.
- Goals: name, kind (target_nw, never_run_out, inheritance,
spending_floor), comparator (>= < etc), target amount, target year,
success threshold (probability bar). Same list+add+delete layout.
Both wire through the existing FastAPI endpoints (POST/GET on
nested paths, DELETE on flat /life-events/{id} and /goals/{id})
already shipped in Phase 0c. Mutations invalidate per-scenario
queries so the list refreshes immediately.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Multi-select on /scenarios — checkbox per row, capped at 5. When ≥2
are picked, a "Compare N" button appears that navigates to
/compare?ids=1,2,3.
/compare page pulls each scenario + its latest projection in
parallel via useQueries. Each scenario gets a distinct hue (5 in
the palette); median lines drawn solid, p10 + p90 dashed at 60%
opacity. Stats table below shows success / p10 / p50 / p90 endings
+ median lifetime tax per scenario, with inline "no run yet" rows
for scenarios missing a projection (404 from /projection treated
as soft state, not error).
7 tests pass (was 5). Bundle still ~470 KB gz.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
/scenarios/new — form posts to POST /scenarios with name, description,
jurisdiction, strategy, glide path, leave-UK year, spending, NW seed,
savings, horizon. Required-name validation; on success invalidates the
scenarios query and navigates to the new detail page.
/scenarios/:id — Delete button (user scenarios only; cartesian are
backend-protected). Browser confirm prompt + DELETE /scenarios/{id} +
invalidate + redirect to list.
api.scenarios gains create() and delete(). New ScenarioCreateBody type.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
/scenarios — table of all scenarios with filter chips (all/cartesian/
user). Cartesian scenarios get a neutral badge; user-defined get an
emerald accent. Empty-state nudges the user to run /recompute.
/scenarios/:id — params summary + the latest persisted MC projection.
Reuses FanChart so chart code is shared with /what-if. 404 on the
projection endpoint is treated as "no run yet" (don't retry); other
errors surface normally.
Nav grew a Scenarios tab. typecheck + 5 tests + build pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New /what-if route. Sticky form on the left (jurisdiction, strategy,
glide, NW seed, spending, savings, horizon, optional floor for
vpw_floor, MC paths) submits to POST /simulate; results panel renders
summary stats + the new FanChart.
FanChart component layers seven series:
- p10 invisible baseline (line, transparent)
- p10→p25 stacked area (low opacity)
- p25→p75 stacked area (IQR, mid opacity)
- p75→p90 stacked area (low opacity)
- p50 solid median line (drawn last, prominent)
- p10 + p90 dashed lines on top of the bands
Stacking deltas keeps the band fills clean — plotting raw quantiles
each as their own area would overlap badly. Reusable by scenario
detail in the next chunk (same ProjectionPoint[] shape).
5 tests pass (was 2). 470 KB gzipped (ECharts).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First end-to-end view: ECharts stacked area of net worth by account
over the last 365 days, fed from GET /networth/history.
- NetWorthChart component with empty-state fallback
- Mocked ReactECharts in tests so they run without a DOM canvas
- Dashboard now: headline NW + history chart + per-account cards
Bundle grew to 467 KB gzipped — ECharts is heavy by design. Will
tree-shake via echarts/core imports once the chart surface stabilises.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bare-minimum SPA that wires up to the FastAPI backend:
- Vite 6 + React 19 + TS strict, alias @/* to src/*
- Tailwind v4 via @tailwindcss/vite (no postcss)
- TanStack Query v5 with sane defaults (30s staleTime, no auto-refetch)
- React Router 7 for routing
- ECharts + Recharts available (charts land in Phase 1a)
- Vitest + @testing-library/react for tests
- Dev proxy /api → http://localhost:8080 (FastAPI)
Pages:
- Dashboard — pulls /networth, shows total + per-account cards.
No chart yet (Phase 1a). Empty/error states for "no data" cases
point users to the ingest CLI.
Header shows live API health (queue depth from /healthz). 274 KB JS
gzipped to 87 KB. typecheck + build pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>