fire-planner: What-If gains the chart-first scenario editor
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
eb0dd3ddbf
commit
70101c836c
7 changed files with 372 additions and 118 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue