fire-planner: What-If gains the chart-first scenario editor
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

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.
This commit is contained in:
Viktor Barzin 2026-05-12 19:35:28 +00:00
parent eb0dd3ddbf
commit 70101c836c
7 changed files with 372 additions and 118 deletions

View file

@ -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.

View file

@ -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,
)