fire-planner: Wave 2 chart-first — flex spending, categorised life
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

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.
This commit is contained in:
Viktor Barzin 2026-05-10 16:49:04 +00:00
parent 9cc781a8d6
commit 64eb90c3dc
19 changed files with 2581 additions and 88 deletions

View file

@ -185,6 +185,9 @@ class AnnualSpending(BaseModel):
# ── life events ──────────────────────────────────────────────────────
_CATEGORY_PATTERN = "^(essential|discretionary|not_spending)$"
class LifeEventOut(_Base):
id: int
scenario_id: int
@ -194,6 +197,7 @@ class LifeEventOut(_Base):
year_end: int | None
delta_gbp_per_year: Decimal
one_time_amount_gbp: Decimal | None
category: str = "essential"
enabled: bool
payload: dict[str, Any] | None
created_at: datetime
@ -206,6 +210,7 @@ class LifeEventCreate(BaseModel):
year_end: int | None = Field(default=None, ge=0, le=100)
delta_gbp_per_year: Decimal = Decimal("0")
one_time_amount_gbp: Decimal | None = None
category: str = Field(default="essential", pattern=_CATEGORY_PATTERN)
enabled: bool = True
payload: dict[str, Any] | None = None
@ -217,6 +222,7 @@ class LifeEventPatch(BaseModel):
year_end: int | None = None
delta_gbp_per_year: Decimal | None = None
one_time_amount_gbp: Decimal | None = None
category: str | None = Field(default=None, pattern=_CATEGORY_PATTERN)
enabled: bool | None = None
payload: dict[str, Any] | None = None
@ -386,9 +392,46 @@ class LifeEventInput(BaseModel):
year_end: int | None = Field(default=None, ge=0, le=100)
delta_gbp_per_year: Decimal = Decimal("0")
one_time_amount_gbp: Decimal | None = None
category: str = Field(default="essential", pattern=_CATEGORY_PATTERN)
enabled: bool = True
class FlexRule(BaseModel):
"""ProjectionLab-style flex-spending rule.
When the portfolio falls ``from_ath_pct`` below its running all-time-high,
cut discretionary spending by ``cut_discretionary_pct``. Multiple rules
stack via "worst applicable threshold wins" at -30% from ATH a rule
keyed at -10% AND a rule keyed at -25% both apply, but only the deeper
cut takes effect (so users specify *cumulative* cuts, not per-tier).
`from_ath_pct` is the absolute drop magnitude as a positive fraction:
0.30 means "the portfolio is 30% below its ATH". This matches the way
PLab labels its sliders ("if down 30%, cut 60%").
"""
from_ath_pct: Decimal = Field(ge=0, le=1)
cut_discretionary_pct: Decimal = Field(ge=0, le=1)
# ── spending profile ────────────────────────────────────────────────
class SpendingProfilePoint(BaseModel):
year_idx: int
base_gbp: Decimal
essential_gbp: Decimal
discretionary_gbp: Decimal
not_spending_gbp: Decimal
flex_cut_gbp: Decimal
total_gbp: Decimal
class SpendingProfileResponse(BaseModel):
scenario_id: int
horizon_years: int
points: list[SpendingProfilePoint]
class SimulateRequest(BaseModel):
"""Sync, non-persisted simulate. Used by the React UI for what-if.
@ -422,6 +465,7 @@ class SimulateRequest(BaseModel):
manual_real_return_pct: Decimal | None = None
income_streams: list[IncomeStreamInput] = Field(default_factory=list)
goals: list[GoalCreate] = Field(default_factory=list)
flex_rules: list[FlexRule] = Field(default_factory=list)
# Rates settings (Wave 1.D.3). When `rates_mode='fixed'`, the engine
# synthesises a deterministic real-return path from the per-asset
# growth + dividend + inflation rates below, weighted by the static