From 64eb90c3dc96ea3dc3d8517d91fcf390a58a494b Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 10 May 2026 16:49:04 +0000 Subject: [PATCH] =?UTF-8?q?fire-planner:=20Wave=202=20chart-first=20?= =?UTF-8?q?=E2=80=94=20flex=20spending,=20categorised=20life=20events,=20i?= =?UTF-8?q?nteractive=20Visx=20Gantt=20+=20spending-profile=20chart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — Visx stacked-area of base/essential/ discretionary with red flex-cut overlay, hover tooltip, click-to- scrub-year. - New — 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 — 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
drawer. - Frontend typecheck + 7 vitest tests + production build all clean. --- alembic/versions/0004_life_event_category.py | 38 ++ fire_planner/api/schemas.py | 44 ++ fire_planner/api/simulate.py | 20 +- fire_planner/api/spending_profile.py | 185 ++++++ fire_planner/app.py | 2 + fire_planner/db.py | 9 + fire_planner/flex_spending.py | 89 +++ fire_planner/life_events.py | 54 +- fire_planner/simulator.py | 24 + frontend/package-lock.json | 512 +++++++++++++- frontend/package.json | 8 + frontend/src/api/client.ts | 47 ++ frontend/src/components/EventGantt.tsx | 622 ++++++++++++++++++ frontend/src/components/FlexRulesEditor.tsx | 165 +++++ .../src/components/SpendingProfileChart.tsx | 300 +++++++++ frontend/src/pages/ScenarioDetail.tsx | 254 +++++-- tests/test_api_spending_profile.py | 167 +++++ tests/test_flex_spending.py | 59 ++ tests/test_simulator_flex.py | 70 ++ 19 files changed, 2581 insertions(+), 88 deletions(-) create mode 100644 alembic/versions/0004_life_event_category.py create mode 100644 fire_planner/api/spending_profile.py create mode 100644 fire_planner/flex_spending.py create mode 100644 frontend/src/components/EventGantt.tsx create mode 100644 frontend/src/components/FlexRulesEditor.tsx create mode 100644 frontend/src/components/SpendingProfileChart.tsx create mode 100644 tests/test_api_spending_profile.py create mode 100644 tests/test_flex_spending.py create mode 100644 tests/test_simulator_flex.py diff --git a/alembic/versions/0004_life_event_category.py b/alembic/versions/0004_life_event_category.py new file mode 100644 index 0000000..9773547 --- /dev/null +++ b/alembic/versions/0004_life_event_category.py @@ -0,0 +1,38 @@ +"""add life_event.category for flex-spending classification + +Revision ID: 0004 +Revises: 0003 +Create Date: 2026-05-10 14:00:00.000000 + +Wave 2 chart-first redesign — life events tag their spending impact as +essential / discretionary / not_spending so flex-spending rules can +trim discretionary outflows when the portfolio drops below ATH. +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "0004" +down_revision: str | None = "0003" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +SCHEMA = "fire_planner" + + +def upgrade() -> None: + with op.batch_alter_table("life_event", schema=SCHEMA) as batch: + batch.add_column( + sa.Column( + "category", + sa.Text(), + nullable=False, + server_default=sa.text("'essential'"), + )) + + +def downgrade() -> None: + with op.batch_alter_table("life_event", schema=SCHEMA) as batch: + batch.drop_column("category") diff --git a/fire_planner/api/schemas.py b/fire_planner/api/schemas.py index 06eab06..c5c2ee3 100644 --- a/fire_planner/api/schemas.py +++ b/fire_planner/api/schemas.py @@ -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 diff --git a/fire_planner/api/simulate.py b/fire_planner/api/simulate.py index 68cefa5..882a206 100644 --- a/fire_planner/api/simulate.py +++ b/fire_planner/api/simulate.py @@ -26,11 +26,16 @@ from fire_planner.api.schemas import ( SimulateRequest, SimulateResult, ) +from fire_planner.flex_spending import FlexRule as EngineFlexRule from fire_planner.glide_path import static from fire_planner.goals_eval import evaluate_goals from fire_planner.income_streams import IncomeStreamInput, streams_to_arrays from fire_planner.ingest.wealthfolio_pg import create_wf_sync_engine_from_env -from fire_planner.life_events import EventInput, events_to_cashflow_array +from fire_planner.life_events import ( + EventInput, + events_to_cashflow_array, + events_to_category_outflows, +) from fire_planner.returns.bootstrap import block_bootstrap from fire_planner.returns.shiller import load_from_csv, synthetic_returns from fire_planner.returns.wealthfolio_returns import ( @@ -105,6 +110,7 @@ def _project(req: SimulateRequest, paths: np.ndarray) -> tuple[SimulationResult, floor = float(req.floor_gbp) if req.floor_gbp is not None else None cashflow_adjustments = None + discretionary_outflows = None if req.life_events: engine_events = [ EventInput( @@ -113,10 +119,20 @@ def _project(req: SimulateRequest, paths: np.ndarray) -> tuple[SimulationResult, delta_gbp_per_year=float(ev.delta_gbp_per_year), one_time_amount_gbp=(float(ev.one_time_amount_gbp) if ev.one_time_amount_gbp is not None else None), + category=ev.category, enabled=ev.enabled, ) for ev in req.life_events ] cashflow_adjustments = events_to_cashflow_array(engine_events, req.horizon_years) + category_outflows = events_to_category_outflows(engine_events, req.horizon_years) + discretionary_outflows = category_outflows.get("discretionary") + + engine_flex = [ + EngineFlexRule( + from_ath_pct=float(r.from_ath_pct), + cut_discretionary_pct=float(r.cut_discretionary_pct), + ) for r in req.flex_rules + ] if req.flex_rules else None income_inflows = None income_taxable = None @@ -158,6 +174,8 @@ def _project(req: SimulateRequest, paths: np.ndarray) -> tuple[SimulationResult, cashflow_adjustments=cashflow_adjustments, income_inflows=income_inflows, income_taxable=income_taxable, + discretionary_outflows=discretionary_outflows, + flex_rules=engine_flex, ) elapsed = time.perf_counter() - started return result, elapsed diff --git a/fire_planner/api/spending_profile.py b/fire_planner/api/spending_profile.py new file mode 100644 index 0000000..3e91351 --- /dev/null +++ b/fire_planner/api/spending_profile.py @@ -0,0 +1,185 @@ +"""Per-year spending breakdown — drives the Visx stacked-area chart on +the Plan tab. + +Returns one ``SpendingProfilePoint`` per year of the scenario horizon: + + base_gbp — scenario-level baseline spending (real GBP) + essential_gbp — sum of |delta| from active essential life events + discretionary_gbp — sum of |delta| from active discretionary events + not_spending_gbp — informational events that have a delta_gbp_per_year + but are tagged ``not_spending`` (rare, but possible) + flex_cut_gbp — discretionary £ trimmed by flex rules at p50 portfolio + drawdown vs running ATH (0 when no rules / no + drawdown). + total_gbp — base + essential + discretionary − flex_cut + +The flex-cut estimate uses the persisted p50 portfolio path (from +``projection_yearly``) because the user's scenario.config_json may carry +``flex_rules`` they've configured but not yet re-run a recompute against. +This gives an honest preview of how the rules would shape spending; the +exact per-path cut shows up in the next live ``POST /simulate``. +""" +from __future__ import annotations + +from collections.abc import Sequence +from decimal import Decimal + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from fire_planner.api.dependencies import get_session +from fire_planner.api.schemas import ( + SpendingProfilePoint, + SpendingProfileResponse, +) +from fire_planner.db import LifeEvent, McRun, ProjectionYearly, Scenario + +router = APIRouter(prefix="/scenarios", tags=["spending-profile"]) + + +def _category_outflow_at(events: Sequence[LifeEvent], year_idx: int, + category: str) -> Decimal: + total = Decimal("0") + for ev in events: + if not ev.enabled or ev.category != category: + 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 delta < 0: + total += -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: + total += -ot + return total + + +def _category_inflow_at(events: Sequence[LifeEvent], year_idx: int, + category: str) -> Decimal: + """Positive deltas are inflows — surface them as a negative spending + contribution so the stacked area sums correctly.""" + total = Decimal("0") + for ev in events: + if not ev.enabled or ev.category != category: + 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 delta > 0: + total += delta + return total + + +def _flex_rules_for(scenario: Scenario) -> list[dict[str, float]]: + blob = scenario.config_json or {} + rules = blob.get("flex_rules") if isinstance(blob, dict) else None + if not isinstance(rules, list): + return [] + out: list[dict[str, float]] = [] + for r in rules: + if not isinstance(r, dict): + continue + try: + out.append({ + "from_ath_pct": float(r.get("from_ath_pct", 0)), + "cut_discretionary_pct": float(r.get("cut_discretionary_pct", 0)), + }) + except (TypeError, ValueError): + continue + return out + + +def _flex_cut_at_year(year: ProjectionYearly, ath_so_far_gbp: Decimal, + rules: list[dict[str, float]], + discretionary_gbp: Decimal) -> Decimal: + if not rules or discretionary_gbp <= 0 or ath_so_far_gbp <= 0: + return Decimal("0") + p50 = Decimal(str(year.p50_portfolio_gbp)) + drawdown = max(Decimal("0"), Decimal("1") - p50 / ath_so_far_gbp) + best = Decimal("0") + for rule in rules: + thr = Decimal(str(rule["from_ath_pct"])) + cut = Decimal(str(rule["cut_discretionary_pct"])) + if drawdown >= thr and cut > best: + best = cut + return (discretionary_gbp * best).quantize(Decimal("0.01")) + + +@router.get("/{scenario_id}/spending-profile", + response_model=SpendingProfileResponse) +async def get_spending_profile( + scenario_id: int, + session: AsyncSession = Depends(get_session), +) -> SpendingProfileResponse: + scen = await session.get(Scenario, scenario_id) + if scen is None: + raise HTTPException(status_code=404, detail="Scenario not found") + + events = list((await session.execute( + select(LifeEvent).where(LifeEvent.scenario_id == scenario_id))).scalars().all()) + + yearly_rows: list[ProjectionYearly] = [] + run = (await session.execute( + select(McRun).where(McRun.scenario_id == scenario_id).order_by( + McRun.run_at.desc()).limit(1))).scalar_one_or_none() + if run is not None: + rows = (await session.execute( + select(ProjectionYearly).where(ProjectionYearly.mc_run_id == run.id).order_by( + ProjectionYearly.year_idx))).scalars().all() + yearly_rows = list(rows) + + rules = _flex_rules_for(scen) + base = Decimal(str(scen.spending_gbp)) + horizon = scen.horizon_years + by_year = {row.year_idx: row for row in yearly_rows} + + points: list[SpendingProfilePoint] = [] + ath = Decimal(str(scen.nw_seed_gbp)) + for year_idx in range(horizon): + essential = _category_outflow_at(events, year_idx, "essential") + discretionary = _category_outflow_at(events, year_idx, "discretionary") + not_spending = _category_outflow_at(events, year_idx, "not_spending") + + # Income-shaped life events (positive delta) reduce the net spend + # the user must sustain. We don't subtract them from any single + # bucket — they net against `base` for the chart's footprint. + ess_inflow = _category_inflow_at(events, year_idx, "essential") + disc_inflow = _category_inflow_at(events, year_idx, "discretionary") + net_base = base - ess_inflow - disc_inflow + if net_base < 0: + net_base = Decimal("0") + + flex_cut = Decimal("0") + row = by_year.get(year_idx) + if row is not None: + ath = max(ath, Decimal(str(row.p50_portfolio_gbp))) + flex_cut = _flex_cut_at_year(row, ath, rules, discretionary) + + total = net_base + essential + discretionary - flex_cut + if total < 0: + total = Decimal("0") + + 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=flex_cut, + total_gbp=total, + )) + + return SpendingProfileResponse( + scenario_id=scenario_id, + horizon_years=horizon, + points=points, + ) diff --git a/fire_planner/app.py b/fire_planner/app.py index a6f5d50..17190a0 100644 --- a/fire_planner/app.py +++ b/fire_planner/app.py @@ -50,6 +50,7 @@ from fire_planner.api.progress import router as progress_router from fire_planner.api.scenarios import router as scenarios_router from fire_planner.api.simulate import router as simulate_router from fire_planner.api.spending import router as spending_router +from fire_planner.api.spending_profile import router as spending_profile_router from fire_planner.api.year_stats import router as year_stats_router from fire_planner.db import create_engine_from_env, make_session_factory @@ -134,6 +135,7 @@ app.include_router(income_streams_router, prefix=_API_PREFIX) app.include_router(year_stats_router, prefix=_API_PREFIX) app.include_router(progress_router, prefix=_API_PREFIX) app.include_router(cashflow_router, prefix=_API_PREFIX) +app.include_router(spending_profile_router, prefix=_API_PREFIX) app.include_router(simulate_router, prefix=_API_PREFIX) app.include_router(spending_router, prefix=_API_PREFIX) diff --git a/fire_planner/db.py b/fire_planner/db.py index fa94992..fcdec5b 100644 --- a/fire_planner/db.py +++ b/fire_planner/db.py @@ -192,6 +192,15 @@ class LifeEvent(Base): nullable=False, server_default=text("0")) one_time_amount_gbp: Mapped[Decimal | None] = mapped_column(Numeric(14, 2), nullable=True) + # Spending category for flex-spending classification: + # essential — never trimmed by flex rules (mortgage, food, kids) + # discretionary — trimmed when portfolio drops vs ATH (travel, dining) + # not_spending — informational only (a milestone marker that doesn't + # change cashflow, e.g. a kid graduating) + # Default is `essential` so existing rows keep their full spending impact. + category: Mapped[str] = mapped_column(String(16), + nullable=False, + server_default=text("'essential'")) enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) payload: Mapped[dict[str, Any] | None] = mapped_column(JSON_TYPE, nullable=True) created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), diff --git a/fire_planner/flex_spending.py b/fire_planner/flex_spending.py new file mode 100644 index 0000000..1457cb9 --- /dev/null +++ b/fire_planner/flex_spending.py @@ -0,0 +1,89 @@ +"""ProjectionLab-style flex-spending rules. + +A `FlexRule` says "if the portfolio is at least ``from_ath_pct`` below its +running all-time-high, trim discretionary spending by ``cut_discretionary_pct``". +Multiple rules stack via "deepest applicable cut wins" — users specify +*cumulative* cuts at each tier, so a [-0.10 → 20%, -0.30 → 60%] config +trims by 60% (not 80%) at -30%. + +The engine path: + + per year y, per path p: + drawdown[p,y] = 1 - portfolio[p,y] / ath[p,y] + cut_pct[p,y] = max(rule.cut for rule in flex_rules if drawdown[p,y] >= rule.from_ath_pct) + discretionary_after_flex[p,y] = discretionary_baseline[y] * (1 - cut_pct[p,y]) + +The cuts are applied to the *baseline* discretionary spend each year (so a +£10k/y travel budget cut by 60% becomes £4k that year), and the saved +amount is *not* drawn from the portfolio. The simulator subtracts the +saved amount from the cashflow drawdown before calling the strategy. +""" +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import numpy.typing as npt + + +@dataclass(frozen=True) +class FlexRule: + """Engine-level flex rule. ``from_ath_pct`` is the absolute drop + magnitude (positive fraction); ``cut_discretionary_pct`` is the + fraction to remove from discretionary spending at that depth.""" + from_ath_pct: float + cut_discretionary_pct: float + + +def applicable_cut(drawdown: float, rules: list[FlexRule]) -> float: + """Return the cut fraction for a single (path, year) pair. + + ``drawdown`` is 1 − portfolio/ath (in the [0, 1] range — clamp inside + the simulator before calling). The deepest rule whose threshold is + satisfied wins. + """ + if not rules: + return 0.0 + best = 0.0 + for rule in rules: + if drawdown >= rule.from_ath_pct and rule.cut_discretionary_pct > best: + best = rule.cut_discretionary_pct + return best + + +def cuts_per_year( + portfolio_real: npt.NDArray[np.float64], + rules: list[FlexRule], +) -> npt.NDArray[np.float64]: + """Vectorised version of ``applicable_cut`` across every (path, year). + + ``portfolio_real`` shape: ``(n_paths, n_years + 1)`` — index 0 is the + seed, last column is the horizon. Returns ``(n_paths, n_years)``: the + cut applied at the start of year ``y`` is decided by the portfolio + *after year y-1's close* (i.e. column ``y`` in the input). + """ + if not rules or portfolio_real.size == 0: + return np.zeros((portfolio_real.shape[0], portfolio_real.shape[1] - 1), + dtype=np.float64) + n_paths, ncols = portfolio_real.shape + n_years = ncols - 1 + # Running ATH per path. np.maximum.accumulate over axis=1 gives us + # the running max — exactly what we want. + ath = np.maximum.accumulate(portfolio_real, axis=1) + # Avoid divide-by-zero. If ATH is 0 (only happens if seed is 0 and the + # portfolio never grew), drawdown is treated as 0. + safe_ath = np.where(ath > 0, ath, 1.0) + drawdown = np.clip(1.0 - portfolio_real / safe_ath, 0.0, 1.0) + cuts = np.zeros((n_paths, n_years), dtype=np.float64) + sorted_rules = sorted(rules, + key=lambda r: r.cut_discretionary_pct, + reverse=True) + for rule in sorted_rules: + # Each rule's cut applies wherever drawdown >= threshold AND a + # higher cut hasn't already been recorded (because we iterate + # rules from largest cut down). + # Drawdown at year y end-of-year-(y-1) — column y of drawdown. + mask = (drawdown[:, :n_years] >= rule.from_ath_pct) & ( + cuts < rule.cut_discretionary_pct) + cuts[mask] = rule.cut_discretionary_pct + return cuts diff --git a/fire_planner/life_events.py b/fire_planner/life_events.py index 65038d6..cbeb51c 100644 --- a/fire_planner/life_events.py +++ b/fire_planner/life_events.py @@ -26,11 +26,19 @@ import numpy.typing as npt @dataclass(frozen=True) class EventInput: """Engine-level event shape — decoupled from the SQLAlchemy ORM and - the API Pydantic schema so callers can construct them however.""" + the API Pydantic schema so callers can construct them however. + + `category` classifies the event for the flex-spending engine: + - "essential" — never trimmed + - "discretionary" — trimmed when the portfolio drops vs ATH + - "not_spending" — informational (no cashflow impact); still rendered + on the milestone timeline + """ year_start: int year_end: int | None = None delta_gbp_per_year: float = 0.0 one_time_amount_gbp: float | None = None + category: str = "essential" enabled: bool = True @@ -38,7 +46,14 @@ def events_to_cashflow_array( events: Iterable[EventInput], horizon_years: int, ) -> npt.NDArray[np.float64]: - """Sum a list of events into a `(horizon_years,)` real-GBP array.""" + """Sum a list of events into a single `(horizon_years,)` real-GBP array. + + Sign convention: ``delta_gbp_per_year > 0`` is an **inflow** (income or + delayed-pension start), ``< 0`` is an **outflow** (extra expense). + Categories are NOT consulted here — every event contributes to the + headline cashflow array. Flex spending (which trims discretionary + outflows) is layered on top via ``events_to_category_outflows``. + """ out = np.zeros(horizon_years, dtype=np.float64) for ev in events: if not ev.enabled: @@ -56,3 +71,38 @@ def events_to_cashflow_array( if ev.one_time_amount_gbp: out[start] += float(ev.one_time_amount_gbp) return out + + +def events_to_category_outflows( + events: Iterable[EventInput], + horizon_years: int, +) -> dict[str, npt.NDArray[np.float64]]: + """Per-category per-year **outflow magnitudes** (always >= 0). + + Used by flex-spending: each year's discretionary outflow is the + candidate the rules can trim. Inflow events (positive delta) and + ``not_spending`` events are excluded — flex rules only trim spending. + """ + out: dict[str, npt.NDArray[np.float64]] = { + "essential": np.zeros(horizon_years, dtype=np.float64), + "discretionary": np.zeros(horizon_years, dtype=np.float64), + } + for ev in events: + if not ev.enabled: + continue + if ev.category not in ("essential", "discretionary"): + continue + start = max(0, int(ev.year_start)) + if start >= horizon_years: + continue + + if ev.delta_gbp_per_year and ev.delta_gbp_per_year < 0: + outflow = -float(ev.delta_gbp_per_year) + end = ev.year_end if ev.year_end is not None else ev.year_start + end = min(int(end), horizon_years - 1) + if end >= start: + out[ev.category][start:end + 1] += outflow + + if ev.one_time_amount_gbp and ev.one_time_amount_gbp < 0: + out[ev.category][start] += -float(ev.one_time_amount_gbp) + return out diff --git a/fire_planner/simulator.py b/fire_planner/simulator.py index ffa8f1a..bc796b3 100644 --- a/fire_planner/simulator.py +++ b/fire_planner/simulator.py @@ -33,6 +33,7 @@ from decimal import Decimal import numpy as np import numpy.typing as npt +from fire_planner.flex_spending import FlexRule, applicable_cut from fire_planner.glide_path import GlideFn from fire_planner.strategies.base import StrategyState, WithdrawalStrategy from fire_planner.tax.base import TaxInputs, TaxRegime @@ -186,6 +187,8 @@ def simulate( bucket_split: _BucketSplit = default_bucket_split, income_inflows: npt.NDArray[np.float64] | None = None, income_taxable: npt.NDArray[np.float64] | None = None, + discretionary_outflows: npt.NDArray[np.float64] | None = None, + flex_rules: list[FlexRule] | None = None, ) -> SimulationResult: """Run the MC simulation. `paths` shape: (n_paths, n_years, 3). @@ -223,6 +226,11 @@ def simulate( income_inflows = np.zeros(n_years, dtype=np.float64) if income_taxable is None: income_taxable = np.zeros(n_years, dtype=np.float64) + if discretionary_outflows is None: + discretionary_outflows = np.zeros(n_years, dtype=np.float64) + rules = list(flex_rules) if flex_rules else [] + # Track running ATH per path so we can decide flex cuts each year. + ath = np.full(n_paths, float(initial_portfolio), dtype=np.float64) for y in range(n_years): alloc = glide(y) @@ -246,6 +254,19 @@ def simulate( income_tax_breakdown = regime_at(y).compute_tax( TaxInputs(earned_income=Decimal(str(round(float(income_taxable[y]), 2))))) portfolio = portfolio - float(income_tax_breakdown.total) + # Flex spending: per-path, decide the cut from this year's + # drawdown-from-ATH and refund the trimmed discretionary + # back to the portfolio. The cashflow_adjustments array already + # subtracted the *baseline* discretionary, so we add back + # `cut_pct * baseline` to leave only the post-cut amount drawn. + if rules and discretionary_outflows[y] > 0.0: + for p in range(n_paths): + if ath[p] <= 0: + continue + drawdown = max(0.0, 1.0 - portfolio[p] / ath[p]) + cut = applicable_cut(drawdown, rules) + if cut > 0: + portfolio[p] += cut * float(discretionary_outflows[y]) # Strategy is per-path Python — 600k iterations at 60y × 10k paths. # Profiled: ~3 seconds for the full Trinity / GK / VPW set. @@ -280,6 +301,9 @@ def simulate( portfolio_history[:, y + 1] = np.clip(portfolio, a_min=0.0, a_max=None) portfolio = portfolio_history[:, y + 1] + # Update running ATH per path so next year's flex decision uses + # the post-close peak. + np.maximum(ath, portfolio, out=ath) # Success = portfolio stayed positive through every interim year. # Excludes the very last year-end because VPW deliberately drains diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3ff0332..28ffe21 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,13 @@ "version": "0.0.1", "dependencies": { "@tanstack/react-query": "^5.62.0", + "@visx/axis": "^3.12.0", + "@visx/event": "^3.12.0", + "@visx/group": "^3.12.0", + "@visx/responsive": "^3.12.0", + "@visx/scale": "^3.12.0", + "@visx/shape": "^3.12.0", + "@visx/tooltip": "^3.12.0", "echarts": "^6.0.0", "echarts-for-react": "^3.0.2", "react": "^19.0.0", @@ -19,6 +26,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.0", @@ -1897,7 +1905,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -1986,8 +1993,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2057,12 +2063,33 @@ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz", + "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==", + "license": "MIT" + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "license": "MIT" }, + "node_modules/@types/d3-format": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", + "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -2102,6 +2129,12 @@ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT" }, + "node_modules/@types/d3-time-format": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz", + "integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==", + "license": "MIT" + }, "node_modules/@types/d3-timer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", @@ -2122,6 +2155,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2129,6 +2168,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.18", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", @@ -2143,7 +2188,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2153,7 +2197,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -2460,6 +2503,305 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@visx/axis": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/axis/-/axis-3.12.0.tgz", + "integrity": "sha512-8MoWpfuaJkhm2Yg+HwyytK8nk+zDugCqTT/tRmQX7gW4LYrHYLXFUXOzbDyyBakCVaUbUaAhVFxpMASJiQKf7A==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/group": "3.12.0", + "@visx/point": "3.12.0", + "@visx/scale": "3.12.0", + "@visx/shape": "3.12.0", + "@visx/text": "3.12.0", + "classnames": "^2.3.1", + "prop-types": "^15.6.0" + }, + "peerDependencies": { + "react": "^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0" + } + }, + "node_modules/@visx/bounds": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/bounds/-/bounds-3.12.0.tgz", + "integrity": "sha512-peAlNCUbYaaZ0IO6c1lDdEAnZv2iGPDiLIM8a6gu7CaMhtXZJkqrTh+AjidNcIqITktrICpGxJE/Qo9D099dvQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "prop-types": "^15.5.10" + }, + "peerDependencies": { + "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0", + "react-dom": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0" + } + }, + "node_modules/@visx/curve": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/curve/-/curve-3.12.0.tgz", + "integrity": "sha512-Ng1mefXIzoIoAivw7dJ+ZZYYUbfuwXgZCgQynShr6ZIVw7P4q4HeQfJP3W24ON+1uCSrzoycHSXRelhR9SBPcw==", + "license": "MIT", + "dependencies": { + "@types/d3-shape": "^1.3.1", + "d3-shape": "^1.0.6" + } + }, + "node_modules/@visx/curve/node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "license": "MIT" + }, + "node_modules/@visx/curve/node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1" + } + }, + "node_modules/@visx/curve/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/@visx/curve/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/@visx/event": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/event/-/event-3.12.0.tgz", + "integrity": "sha512-9Lvw6qJ0Fi+y1vsC1WspfdIKCxHTb7oy59Uql1uBdPGT8zChP0vuxW0jQNQRDbKgoefj4pCXAFi8+MF1mEtVTw==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/point": "3.12.0" + } + }, + "node_modules/@visx/group": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/group/-/group-3.12.0.tgz", + "integrity": "sha512-Dye8iS1alVXPv7nj/7M37gJe6sSKqJLH7x6sEWAsRQ9clI0kFvjbKcKgF+U3aAVQr0NCohheFV+DtR8trfK/Ag==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "classnames": "^2.3.1", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0" + } + }, + "node_modules/@visx/point": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/point/-/point-3.12.0.tgz", + "integrity": "sha512-I6UrHoJAEVbx3RORQNupgTiX5EzjuZpiwLPxn8L2mI5nfERotPKi1Yus12Cq2WtXqEBR/WgqTnoImlqOXBykcA==", + "license": "MIT" + }, + "node_modules/@visx/responsive": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-3.12.0.tgz", + "integrity": "sha512-GV1BTYwAGlk/K5c9vH8lS2syPnTuIqEacI7L6LRPbsuaLscXMNi+i9fZyzo0BWvAdtRV8v6Urzglo++lvAXT1Q==", + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.14.172", + "@types/react": "*", + "lodash": "^4.17.21", + "prop-types": "^15.6.1" + }, + "peerDependencies": { + "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0" + } + }, + "node_modules/@visx/scale": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-3.12.0.tgz", + "integrity": "sha512-+ubijrZ2AwWCsNey0HGLJ0YKNeC/XImEFsr9rM+Uef1CM3PNM43NDdNTrdBejSlzRq0lcfQPWYMYQFSlkLcPOg==", + "license": "MIT", + "dependencies": { + "@visx/vendor": "3.12.0" + } + }, + "node_modules/@visx/shape": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/shape/-/shape-3.12.0.tgz", + "integrity": "sha512-/1l0lrpX9tPic6SJEalryBKWjP/ilDRnQA+BGJTI1tj7i23mJ/J0t4nJHyA1GrL4QA/bM/qTJ35eyz5dEhJc4g==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1.0.8", + "@types/d3-shape": "^1.3.1", + "@types/lodash": "^4.14.172", + "@types/react": "*", + "@visx/curve": "3.12.0", + "@visx/group": "3.12.0", + "@visx/scale": "3.12.0", + "classnames": "^2.3.1", + "d3-path": "^1.0.5", + "d3-shape": "^1.2.0", + "lodash": "^4.17.21", + "prop-types": "^15.5.10" + }, + "peerDependencies": { + "react": "^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0" + } + }, + "node_modules/@visx/shape/node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "license": "MIT" + }, + "node_modules/@visx/shape/node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1" + } + }, + "node_modules/@visx/shape/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/@visx/shape/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/@visx/text": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/text/-/text-3.12.0.tgz", + "integrity": "sha512-0rbDYQlbuKPhBqXkkGYKFec1gQo05YxV45DORzr6hCyaizdJk1G+n9VkuKSHKBy1vVQhBA0W3u/WXd7tiODQPA==", + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.14.172", + "@types/react": "*", + "classnames": "^2.3.1", + "lodash": "^4.17.21", + "prop-types": "^15.7.2", + "reduce-css-calc": "^1.3.0" + }, + "peerDependencies": { + "react": "^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0" + } + }, + "node_modules/@visx/tooltip": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/tooltip/-/tooltip-3.12.0.tgz", + "integrity": "sha512-pWhsYhgl0Shbeqf80qy4QCB6zpq6tQtMQQxKlh3UiKxzkkfl+Metaf9p0/S0HexNi4vewOPOo89xWx93hBeh3A==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/bounds": "3.12.0", + "classnames": "^2.3.1", + "prop-types": "^15.5.10", + "react-use-measure": "^2.0.4" + }, + "peerDependencies": { + "react": "^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0", + "react-dom": "^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0" + } + }, + "node_modules/@visx/vendor": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-3.12.0.tgz", + "integrity": "sha512-SVO+G0xtnL9dsNpGDcjCgoiCnlB3iLSM9KLz1sLbSrV7RaVXwY3/BTm2X9OWN1jH2a9M+eHt6DJ6sE6CXm4cUg==", + "license": "MIT and ISC", + "dependencies": { + "@types/d3-array": "3.0.3", + "@types/d3-color": "3.1.0", + "@types/d3-delaunay": "6.0.1", + "@types/d3-format": "3.0.1", + "@types/d3-geo": "3.1.0", + "@types/d3-interpolate": "3.0.1", + "@types/d3-scale": "4.0.2", + "@types/d3-time": "3.0.0", + "@types/d3-time-format": "2.1.0", + "d3-array": "3.2.1", + "d3-color": "3.1.0", + "d3-delaunay": "6.0.2", + "d3-format": "3.1.0", + "d3-geo": "3.1.0", + "d3-interpolate": "3.0.1", + "d3-scale": "4.0.2", + "d3-time": "3.1.0", + "d3-time-format": "4.1.0", + "internmap": "2.0.3" + } + }, + "node_modules/@visx/vendor/node_modules/@types/d3-array": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", + "integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==", + "license": "MIT" + }, + "node_modules/@visx/vendor/node_modules/@types/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==", + "license": "MIT" + }, + "node_modules/@visx/vendor/node_modules/@types/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@visx/vendor/node_modules/@types/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@visx/vendor/node_modules/@types/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==", + "license": "MIT" + }, + "node_modules/@visx/vendor/node_modules/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@visx/vendor/node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2652,7 +2994,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2704,7 +3045,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/baseline-browser-mapping": { @@ -2850,6 +3190,12 @@ "node": ">= 16" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2946,7 +3292,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/d3-array": { @@ -2970,6 +3315,18 @@ "node": ">=12" } }, + "node_modules/d3-delaunay": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz", + "integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -2988,6 +3345,18 @@ "node": ">=12" } }, + "node_modules/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -3132,6 +3501,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3157,8 +3535,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/echarts": { "version": "6.0.0", @@ -3831,7 +4208,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -4235,6 +4611,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4242,6 +4624,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -4265,7 +4659,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4280,6 +4673,12 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-expression-evaluator": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.4.0.tgz", + "integrity": "sha512-4vRUvPyxdO8cWULGTh9dZWL2tZK6LDBvj+OGHBER7poH9Qdt7kXEoj20wiz4lQUbUXQZFjPbe5mVDo9nutizCw==", + "license": "MIT" + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -4350,6 +4749,15 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4528,7 +4936,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4544,7 +4951,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -4552,13 +4958,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/pretty-format/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", - "peer": true + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" }, "node_modules/punycode": { "version": "2.3.1", @@ -4592,11 +5007,11 @@ } }, "node_modules/react-is": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", - "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", - "license": "MIT", - "peer": true + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", @@ -4669,6 +5084,21 @@ "react-dom": ">=18" } }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/recharts": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", @@ -4713,6 +5143,32 @@ "node": ">=8" } }, + "node_modules/reduce-css-calc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz", + "integrity": "sha512-0dVfwYVOlf/LBA2ec4OwQ6p3X9mYxn/wOl2xTcLwjnPYrkgEfPx3VI4eGCH3rQLlPISG5v9I9bkZosKsNRTRKA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^0.4.2", + "math-expression-evaluator": "^1.2.14", + "reduce-function-call": "^1.0.1" + } + }, + "node_modules/reduce-css-calc/node_modules/balanced-match": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz", + "integrity": "sha512-STw03mQKnGUYtoNjmowo4F2cRmIIxYEGiMsjjwla/u5P1lxadj/05WkNaFjNiKTgJkj8KiXbgAiRTmcQRwQNtg==", + "license": "MIT" + }, + "node_modules/reduce-function-call": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.3.tgz", + "integrity": "sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -4744,6 +5200,12 @@ "node": ">=4" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.60.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3b8b182..edcb085 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,13 @@ }, "dependencies": { "@tanstack/react-query": "^5.62.0", + "@visx/axis": "^3.12.0", + "@visx/event": "^3.12.0", + "@visx/group": "^3.12.0", + "@visx/responsive": "^3.12.0", + "@visx/scale": "^3.12.0", + "@visx/shape": "^3.12.0", + "@visx/tooltip": "^3.12.0", "echarts": "^6.0.0", "echarts-for-react": "^3.0.2", "react": "^19.0.0", @@ -24,6 +31,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.0", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 9aab0e9..f6eb408 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -50,6 +50,8 @@ export const api = { progress: (id: number) => request(`/scenarios/${id}/progress`), cashflow: (id: number, year: number) => request(`/scenarios/${id}/cashflow?year=${year}`), + spendingProfile: (id: number) => + request(`/scenarios/${id}/spending-profile`), networth: { current: () => request<{ @@ -128,6 +130,8 @@ export interface ScenarioCreateBody { // ── life events ────────────────────────────────────────────────────── +export type SpendingCategory = 'essential' | 'discretionary' | 'not_spending'; + export interface LifeEvent { id: number; scenario_id: number; @@ -137,6 +141,7 @@ export interface LifeEvent { year_end: number | null; delta_gbp_per_year: string; one_time_amount_gbp: string | null; + category: SpendingCategory; enabled: boolean; payload: Record | null; created_at: string; @@ -149,10 +154,22 @@ export interface LifeEventCreateBody { year_end?: number | null; delta_gbp_per_year?: string; one_time_amount_gbp?: string | null; + category?: SpendingCategory; enabled?: boolean; payload?: Record | null; } +export interface LifeEventPatchBody { + kind?: string; + name?: string; + year_start?: number; + year_end?: number | null; + delta_gbp_per_year?: string; + one_time_amount_gbp?: string | null; + category?: SpendingCategory; + enabled?: boolean; +} + export const lifeEventsApi = { list: (scenarioId: number) => request(`/scenarios/${scenarioId}/life-events`), @@ -161,10 +178,38 @@ export const lifeEventsApi = { method: 'POST', body: JSON.stringify(body), }), + patch: (eventId: number, body: LifeEventPatchBody) => + request(`/life-events/${eventId}`, { + method: 'PATCH', + body: JSON.stringify(body), + }), delete: (eventId: number) => request(`/life-events/${eventId}`, { method: 'DELETE' }), }; +// ── flex spending + spending profile ───────────────────────────────── + +export interface FlexRule { + from_ath_pct: string; + cut_discretionary_pct: string; +} + +export interface SpendingProfilePoint { + year_idx: number; + base_gbp: string; + essential_gbp: string; + discretionary_gbp: string; + not_spending_gbp: string; + flex_cut_gbp: string; + total_gbp: string; +} + +export interface SpendingProfileResponse { + scenario_id: number; + horizon_years: number; + points: SpendingProfilePoint[]; +} + // ── goals ──────────────────────────────────────────────────────────── export interface Goal { @@ -374,6 +419,7 @@ export interface SimulateRequest { year_end?: number | null; delta_gbp_per_year?: string; one_time_amount_gbp?: string | null; + category?: SpendingCategory; enabled?: boolean; }>; returns_mode?: 'shiller' | 'manual' | 'wealthfolio'; @@ -391,6 +437,7 @@ export interface SimulateRequest { tax_treatment?: string; enabled?: boolean; }>; + flex_rules?: FlexRule[]; goals?: Array<{ kind: string; name: string; diff --git a/frontend/src/components/EventGantt.tsx b/frontend/src/components/EventGantt.tsx new file mode 100644 index 0000000..4bd96c1 --- /dev/null +++ b/frontend/src/components/EventGantt.tsx @@ -0,0 +1,622 @@ +/** + * Interactive Gantt of life events (Wave 2 chart-first redesign). + * + * Visualises every life event as a horizontal bar over the scenario + * horizon. Bar color encodes spending category. Drag the middle to slide + * the whole event. Drag the left/right edge handles to resize. Click an + * empty slot to create a new event at that year. Click an existing bar + * to edit it inline via a popover. Persists every interaction through + * the existing life-events CRUD endpoints (PATCH for moves/resizes, + * POST for create, DELETE from the popover). + * + * Source-of-truth pattern: this chart IS the editor. The list-form + * fallback under the drawer remains for bulk edits and accessibility. + */ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { ParentSize } from '@visx/responsive'; +import { scaleBand, scaleLinear } from '@visx/scale'; +import { Group } from '@visx/group'; +import { AxisBottom } from '@visx/axis'; +import { localPoint } from '@visx/event'; + +import { + lifeEventsApi, + type LifeEvent, + type LifeEventCreateBody, + type LifeEventPatchBody, + type SpendingCategory, +} from '@/api/client'; +import { gbp } from '@/lib/format'; +import { emojiFor } from '@/lib/milestone'; + +interface Props { + scenarioId: number; + events: LifeEvent[]; + horizonYears: number; + height?: number; +} + +const CATEGORY_FILL: Record = { + essential: 'rgb(16, 185, 129)', // emerald-500 + discretionary: 'rgb(245, 158, 11)', // amber-500 + not_spending: 'rgb(100, 116, 139)', // slate-500 +}; + +const CATEGORY_BORDER: Record = { + essential: 'rgb(5, 150, 105)', + discretionary: 'rgb(217, 119, 6)', + not_spending: 'rgb(71, 85, 105)', +}; + +type DragMode = 'move' | 'left' | 'right'; + +interface DragState { + eventId: number; + mode: DragMode; + startMouseX: number; + origYearStart: number; + origYearEnd: number; +} + +interface PopoverState { + // Either editing an existing event, or creating one at year_start=N + kind: 'edit' | 'create'; + x: number; + y: number; + event?: LifeEvent; + createYear?: number; +} + +export function EventGantt(props: Props) { + return ( + + {({ width }) => (width > 0 ? : null)} + + ); +} + +function Inner({ + scenarioId, + events, + horizonYears, + width, + height = 220, +}: Props & { width: number }) { + const margin = { top: 12, right: 24, bottom: 28, left: 96 }; + const innerW = Math.max(0, width - margin.left - margin.right); + const sortedEvents = useMemo( + () => [...events].sort((a, b) => a.year_start - b.year_start || a.id - b.id), + [events], + ); + const rowHeight = 22; + const minRows = Math.max(sortedEvents.length, 1); + const innerH = Math.max(rowHeight * minRows + 32, height - margin.top - margin.bottom); + + const xScale = useMemo( + () => + scaleLinear({ + domain: [0, Math.max(1, horizonYears - 1)], + range: [0, innerW], + }), + [horizonYears, innerW], + ); + + const yScale = useMemo( + () => + scaleBand({ + domain: sortedEvents.map((e) => e.id), + range: [0, rowHeight * sortedEvents.length], + padding: 0.2, + }), + [sortedEvents], + ); + + const qc = useQueryClient(); + const invalidate = () => + qc.invalidateQueries({ + queryKey: ['scenarios', scenarioId, 'life-events'], + }); + const invalidateProfile = () => + qc.invalidateQueries({ + queryKey: ['spending-profile', scenarioId], + }); + + const patchMut = useMutation({ + mutationFn: ({ id, body }: { id: number; body: LifeEventPatchBody }) => + lifeEventsApi.patch(id, body), + onSuccess: () => { + invalidate(); + invalidateProfile(); + }, + }); + const createMut = useMutation({ + mutationFn: (body: LifeEventCreateBody) => + lifeEventsApi.create(scenarioId, body), + onSuccess: () => { + invalidate(); + invalidateProfile(); + }, + }); + const deleteMut = useMutation({ + mutationFn: (id: number) => lifeEventsApi.delete(id), + onSuccess: () => { + invalidate(); + invalidateProfile(); + }, + }); + + // Drag state lives in a ref so mousemove handlers don't re-render. + const drag = useRef(null); + // Pixel-level offset applied during drag (without committing). + const [dragOffset, setDragOffset] = useState<{ id: number; dx: number; mode: DragMode } | null>(null); + const [popover, setPopover] = useState(null); + + useEffect(() => { + const onMove = (e: MouseEvent) => { + if (!drag.current) return; + setDragOffset({ + id: drag.current.eventId, + dx: e.clientX - drag.current.startMouseX, + mode: drag.current.mode, + }); + }; + const onUp = (e: MouseEvent) => { + if (!drag.current) return; + const dx = e.clientX - drag.current.startMouseX; + const dyears = pxToYears(dx, xScale, horizonYears); + const { eventId, mode, origYearStart, origYearEnd } = drag.current; + drag.current = null; + setDragOffset(null); + if (Math.abs(dyears) < 1) return; // sub-year drag = ignore + let body: LifeEventPatchBody = {}; + if (mode === 'move') { + const newStart = clamp(origYearStart + dyears, 0, horizonYears - 1); + const span = origYearEnd - origYearStart; + body = { + year_start: newStart, + year_end: clamp(newStart + span, newStart, horizonYears - 1), + }; + } else if (mode === 'left') { + body = { + year_start: clamp(origYearStart + dyears, 0, origYearEnd), + }; + } else if (mode === 'right') { + body = { + year_end: clamp(origYearEnd + dyears, origYearStart, horizonYears - 1), + }; + } + patchMut.mutate({ id: eventId, body }); + }; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + return () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + }, [horizonYears, patchMut, xScale]); + + const startDrag = (e: React.MouseEvent, ev: LifeEvent, mode: DragMode) => { + e.stopPropagation(); + const yearEnd = ev.year_end ?? ev.year_start; + drag.current = { + eventId: ev.id, + mode, + startMouseX: e.clientX, + origYearStart: ev.year_start, + origYearEnd: yearEnd, + }; + setDragOffset({ id: ev.id, dx: 0, mode }); + }; + + const onBackgroundClick = (e: React.MouseEvent) => { + const point = localPoint(e); + if (!point) return; + const x = point.x - margin.left; + const year = Math.round(xScale.invert(Math.max(0, Math.min(innerW, x)))); + setPopover({ + kind: 'create', + x: point.x, + y: point.y, + createYear: year, + }); + }; + + const onBarClick = (e: React.MouseEvent, ev: LifeEvent) => { + e.stopPropagation(); + const point = localPoint(e); + if (!point) return; + setPopover({ kind: 'edit', x: point.x, y: point.y, event: ev }); + }; + + return ( +
+ + + {/* Faint year gridlines */} + {Array.from({ length: horizonYears }).map((_, i) => { + const x = xScale(i) ?? 0; + return ( + + ); + })} + {/* Background click capture (must be drawn before bars) */} + + {/* Bars */} + {sortedEvents.map((ev) => { + const yearEnd = ev.year_end ?? ev.year_start; + const offsetDx = + dragOffset && dragOffset.id === ev.id ? dragOffset.dx : 0; + const dyears = pxToYears(offsetDx, xScale, horizonYears); + const isMove = dragOffset?.id === ev.id && dragOffset.mode === 'move'; + const isLeft = dragOffset?.id === ev.id && dragOffset.mode === 'left'; + const isRight = dragOffset?.id === ev.id && dragOffset.mode === 'right'; + const startY = isMove + ? ev.year_start + dyears + : isLeft + ? ev.year_start + dyears + : ev.year_start; + const endY = isMove + ? yearEnd + dyears + : isRight + ? yearEnd + dyears + : yearEnd; + const x = xScale(Math.max(0, startY)) ?? 0; + const w = Math.max(8, (xScale(Math.max(startY + 0.5, endY)) ?? 0) - x); + const y = yScale(ev.id) ?? 0; + const h = yScale.bandwidth(); + const fill = CATEGORY_FILL[ev.category] ?? CATEGORY_FILL.essential; + const border = + CATEGORY_BORDER[ev.category] ?? CATEGORY_BORDER.essential; + return ( + onBarClick(e, ev)} + style={{ cursor: 'grab' }} + > + startDrag(e, ev, 'move')} + /> + {/* Left handle */} + startDrag(e, ev, 'left')} + style={{ cursor: 'ew-resize' }} + /> + {/* Right handle */} + startDrag(e, ev, 'right')} + style={{ cursor: 'ew-resize' }} + /> + + {emojiFor(ev.kind)} {ev.name} + + + ); + })} + `y${Math.round(Number(v))}`} + tickStroke="#94a3b8" + stroke="#94a3b8" + tickLabelProps={{ fill: '#64748b', fontSize: 10, textAnchor: 'middle' }} + /> + + {/* Row labels */} + + {sortedEvents.map((ev) => { + const y = (yScale(ev.id) ?? 0) + yScale.bandwidth() / 2; + return ( + + {ev.kind} + + ); + })} + + + + {sortedEvents.length === 0 && ( +
+ Click anywhere on the timeline to add a life event. +
+ )} + + {popover && ( + setPopover(null)} + onCreate={(body) => { + createMut.mutate(body); + setPopover(null); + }} + onPatch={(id, body) => { + patchMut.mutate({ id, body }); + setPopover(null); + }} + onDelete={(id) => { + deleteMut.mutate(id); + setPopover(null); + }} + horizonYears={horizonYears} + /> + )} +
+ ); +} + +function pxToYears( + px: number, + scale: { invert: (n: number) => number }, + _horizon: number, +): number { + const yearsAtZero = scale.invert(0); + const yearsAtPx = scale.invert(px); + return Math.round(yearsAtPx - yearsAtZero); +} + +function clamp(n: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, n)); +} + +interface PopoverProps { + state: PopoverState; + horizonYears: number; + onClose: () => void; + onCreate: (body: LifeEventCreateBody) => void; + onPatch: (id: number, body: LifeEventPatchBody) => void; + onDelete: (id: number) => void; +} + +const KIND_OPTIONS = [ + 'kid_at_home', + 'kid_born', + 'kids_leave_home', + 'mortgage_payoff', + 'home_purchase', + 'sabbatical', + 'inheritance', + 'travel', + 'expense_range', + 'one_time_income', +]; + +function EventPopover({ + state, + horizonYears, + onClose, + onCreate, + onPatch, + onDelete, +}: PopoverProps) { + const isEdit = state.kind === 'edit' && state.event; + const [name, setName] = useState(isEdit ? state.event!.name : ''); + const [kind, setKind] = useState(isEdit ? state.event!.kind : 'kid_at_home'); + const [yearStart, setYearStart] = useState( + isEdit ? state.event!.year_start : state.createYear ?? 0, + ); + const [yearEnd, setYearEnd] = useState( + isEdit ? state.event!.year_end : null, + ); + const [delta, setDelta] = useState( + isEdit ? Number(state.event!.delta_gbp_per_year) : -10000, + ); + const [category, setCategory] = useState( + isEdit ? state.event!.category : 'essential', + ); + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + if (isEdit) { + onPatch(state.event!.id, { + name, + kind, + year_start: yearStart, + year_end: yearEnd, + delta_gbp_per_year: String(delta), + category, + }); + } else { + onCreate({ + kind, + name: name || titleize(kind), + year_start: yearStart, + year_end: yearEnd, + delta_gbp_per_year: String(delta), + category, + }); + } + }; + + return ( +
e.stopPropagation()} + > +
+
+

+ {isEdit ? 'Edit event' : `Add event @ y${state.createYear}`} +

+ +
+ + +
+ + +
+ +
+ Category +
+ {(['essential', 'discretionary', 'not_spending'] as const).map((c) => ( + + ))} +
+
+
+ {isEdit && ( + + )} +
+ +
+ +
+ ); +} + +function titleize(kind: string): string { + return kind + .split('_') + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(' '); +} diff --git a/frontend/src/components/FlexRulesEditor.tsx b/frontend/src/components/FlexRulesEditor.tsx new file mode 100644 index 0000000..092bf01 --- /dev/null +++ b/frontend/src/components/FlexRulesEditor.tsx @@ -0,0 +1,165 @@ +/** + * Flex-rules editor — list of {from_ath_pct, cut_discretionary_pct} + * tiers stored on `scenario.config_json.flex_rules`. Saves on blur via + * the existing PATCH /scenarios/:id (config_json is a free-form blob). + */ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; + +import { api, type Scenario } from '@/api/client'; + +interface Rule { + from_ath_pct: number; + cut_discretionary_pct: number; +} + +interface Props { + scenario: Scenario; +} + +const DEFAULT_RULES: Rule[] = [ + { from_ath_pct: 0.10, cut_discretionary_pct: 0.20 }, + { from_ath_pct: 0.30, cut_discretionary_pct: 0.60 }, +]; + +function readRules(scen: Scenario): Rule[] { + const blob = scen.config_json as Record; + const raw = blob?.flex_rules; + if (!Array.isArray(raw)) return []; + return raw + .filter((r): r is { from_ath_pct: unknown; cut_discretionary_pct: unknown } => + typeof r === 'object' && r !== null, + ) + .map((r) => ({ + from_ath_pct: Number((r as { from_ath_pct: unknown }).from_ath_pct ?? 0), + cut_discretionary_pct: Number( + (r as { cut_discretionary_pct: unknown }).cut_discretionary_pct ?? 0, + ), + })); +} + +export function FlexRulesEditor({ scenario }: Props) { + const qc = useQueryClient(); + const [rules, setRules] = useState(() => readRules(scenario)); + + useEffect(() => { + setRules(readRules(scenario)); + }, [scenario.id, scenario.config_json]); + + const save = useMutation({ + mutationFn: (next: Rule[]) => + api.scenarios.patch(scenario.id, { + config_json: { + ...((scenario.config_json as Record) ?? {}), + flex_rules: next, + }, + } as never), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['scenarios', scenario.id] }); + qc.invalidateQueries({ + queryKey: ['spending-profile', scenario.id], + }); + }, + }); + + const persist = (next: Rule[]) => { + setRules(next); + save.mutate(next); + }; + + const update = (idx: number, patch: Partial) => { + persist(rules.map((r, i) => (i === idx ? { ...r, ...patch } : r))); + }; + + const remove = (idx: number) => persist(rules.filter((_, i) => i !== idx)); + const add = () => persist([...rules, { from_ath_pct: 0.20, cut_discretionary_pct: 0.40 }]); + const seedDefaults = () => persist(DEFAULT_RULES); + + return ( +
+
+

Flex spending rules

+ + When portfolio drops vs ATH, cut discretionary by … + +
+ {rules.length === 0 ? ( +
+ No flex rules — discretionary spending stays flat. + +
+ ) : ( +
    + {rules.map((r, idx) => ( +
  • + If down + update(idx, { from_ath_pct: v })} + aria="Drawdown threshold" + /> + from ATH, cut discretionary by + update(idx, { cut_discretionary_pct: v })} + aria="Cut percent" + /> + +
  • + ))} +
+ )} + + {save.isError && ( +

+ {String((save.error as Error)?.message ?? save.error)} +

+ )} +
+ ); +} + +function PctInput({ + value, + onChange, + aria, +}: { + value: number; + onChange: (v: number) => void; + aria: string; +}) { + return ( + + onChange(Math.max(0, Math.min(100, Number(e.target.value))) / 100)} + className="w-14 rounded-md border border-slate-300 px-2 py-0.5 text-sm tabular-nums" + aria-label={aria} + /> + % + + ); +} diff --git a/frontend/src/components/SpendingProfileChart.tsx b/frontend/src/components/SpendingProfileChart.tsx new file mode 100644 index 0000000..6fcf02f --- /dev/null +++ b/frontend/src/components/SpendingProfileChart.tsx @@ -0,0 +1,300 @@ +/** + * Stacked-area spending profile per year (Wave 2 chart-first redesign). + * + * Stacks (bottom to top): + * - base spending (slate) + * - essential events (emerald) + * - discretionary events (amber) + * - flex_cut overlay (red, drawn as a hatched ribbon eaten from the + * discretionary band) + * + * Hover shows the full breakdown for that year. Click forwards a + * `year_idx` callback so the parent can sync the year-scrubber. + */ +import { useMemo, useState } from 'react'; +import { ParentSize } from '@visx/responsive'; +import { scaleLinear } from '@visx/scale'; +import { AreaStack, Bar, Line } from '@visx/shape'; +import { Group } from '@visx/group'; +import { AxisBottom, AxisLeft } from '@visx/axis'; +import { localPoint } from '@visx/event'; + +import type { SpendingProfilePoint } from '@/api/client'; +import { gbpCompact, gbp } from '@/lib/format'; + +interface Props { + points: SpendingProfilePoint[]; + height?: number; + selectedYear?: number | null; + onSelectYear?: (year: number) => void; +} + +const BAND_KEYS = ['base', 'essential', 'discretionary'] as const; +type BandKey = (typeof BAND_KEYS)[number]; + +const COLORS: Record = { + base: 'rgb(100, 116, 139)', // slate-500 + essential: 'rgb(16, 185, 129)', // emerald-500 + discretionary: 'rgb(245, 158, 11)', // amber-500 +}; + +const FLEX_CUT_COLOR = 'rgba(239, 68, 68, 0.55)'; // red-500 @ 55% + +interface BandRow { + year_idx: number; + base: number; + essential: number; + discretionary: number; +} + +export function SpendingProfileChart(props: Props) { + return ( + + {({ width }) => (width > 0 ? : null)} + + ); +} + +function Inner({ + points, + width, + height = 220, + selectedYear, + onSelectYear, +}: Props & { width: number }) { + const [hover, setHover] = useState<{ x: number; year: number } | null>(null); + const margin = { top: 12, right: 24, bottom: 32, left: 64 }; + const innerW = Math.max(0, width - margin.left - margin.right); + const innerH = Math.max(0, height - margin.top - margin.bottom); + + const rows = useMemo( + () => + points.map((p) => ({ + year_idx: p.year_idx, + base: Number(p.base_gbp), + essential: Number(p.essential_gbp), + discretionary: Math.max(0, Number(p.discretionary_gbp) - Number(p.flex_cut_gbp)), + })), + [points], + ); + + const maxTotal = useMemo(() => { + if (points.length === 0) return 1; + return Math.max(...points.map((p) => Number(p.total_gbp))) * 1.1; + }, [points]); + + const xScale = useMemo( + () => + scaleLinear({ + domain: [0, Math.max(0, points.length - 1)], + range: [0, innerW], + }), + [innerW, points.length], + ); + + const yScale = useMemo( + () => + scaleLinear({ + domain: [0, maxTotal], + range: [innerH, 0], + nice: true, + }), + [innerH, maxTotal], + ); + + if (points.length === 0) { + return ( +
+ No spending data — add a life event to populate the chart. +
+ ); + } + + const handleMove = (e: React.MouseEvent) => { + const point = localPoint(e); + if (!point) return; + const x = point.x - margin.left; + const idx = Math.round(xScale.invert(Math.max(0, Math.min(innerW, x)))); + setHover({ x: xScale(idx) ?? 0, year: idx }); + }; + + const handleLeave = () => setHover(null); + const handleClick = (e: React.MouseEvent) => { + if (!onSelectYear) return; + const point = localPoint(e); + if (!point) return; + const x = point.x - margin.left; + const idx = Math.round(xScale.invert(Math.max(0, Math.min(innerW, x)))); + onSelectYear(idx); + }; + + const hoverPoint = hover ? points[hover.year] : null; + + return ( +
+ + + + data={rows} + keys={BAND_KEYS as unknown as BandKey[]} + x={(d) => xScale(d.data.year_idx) ?? 0} + y0={(d) => yScale(d[0]) ?? 0} + y1={(d) => yScale(d[1]) ?? 0} + > + {({ stacks, path }) => + stacks.map((stack) => ( + + )) + } + + + {/* Flex-cut overlay: drawn as a thin red ribbon at the top of the + discretionary band so the user sees what was trimmed. */} + {points.some((p) => Number(p.flex_cut_gbp) > 0) && ( + + )} + + {selectedYear != null && selectedYear >= 0 && selectedYear < points.length && ( + + )} + + {hover && ( + + )} + + String(Math.round(Number(v)))} + tickStroke="#94a3b8" + stroke="#94a3b8" + tickLabelProps={{ fill: '#64748b', fontSize: 10, textAnchor: 'middle' }} + /> + gbpCompact(Number(v))} + tickStroke="#94a3b8" + stroke="#94a3b8" + tickLabelProps={{ fill: '#64748b', fontSize: 10, textAnchor: 'end', dx: -4 }} + /> + + {/* Capture mouse + clicks. Last so it sits above the bands. */} + + + + + {hoverPoint && hover && ( +
+
Year {hover.year}
+ + + + {Number(hoverPoint.flex_cut_gbp) > 0 && ( + + )} +
+ +
+ )} +
+ ); +} + +function FlexCutOverlay({ + points, + xScale, + yScale, +}: { + points: SpendingProfilePoint[]; + xScale: (n: number) => number; + yScale: (n: number) => number; +}) { + // Marker tick at the top of each year that has a cut, with height + // proportional to the cut amount. Keeps the area chart readable while + // surfacing where flex was trimmed. + return ( + + {points.map((p) => { + const cut = Number(p.flex_cut_gbp); + if (cut <= 0) return null; + const total = Number(p.total_gbp) + cut; + const top = yScale(total); + const bottom = yScale(total - cut); + const x = xScale(p.year_idx); + return ( + + ); + })} + + ); +} + +function Row({ + dot, + label, + value, + bold, +}: { + dot: string; + label: string; + value: string; + bold?: boolean; +}) { + return ( +
+ + {dot && } + {label} + + {value} +
+ ); +} diff --git a/frontend/src/pages/ScenarioDetail.tsx b/frontend/src/pages/ScenarioDetail.tsx index ca15932..f56323c 100644 --- a/frontend/src/pages/ScenarioDetail.tsx +++ b/frontend/src/pages/ScenarioDetail.tsx @@ -1,13 +1,19 @@ /** - * Plan-tab body for a scenario — Wave 1.A.x. + * Plan-tab body — Wave 2 chart-first redesign. * - * Layout: - * ┌──────────────────────────────────────────┬──────────────┐ - * │ header + summary cards │ │ - * │ FanChart with milestone markers │ Year stats │ - * │ Year scrubber │ panel │ - * │ Income streams · Goals · Life events │ │ - * └──────────────────────────────────────────┴──────────────┘ + * Layout (chart is the SoT for editing life events): + * ┌────────────────────────────────────────┐ + * │ NW fan + floating stats badges (top-R) │ + * │ year-scrubber along the bottom │ + * ├────────────────────────────────────────┤ + * │ Spending profile (stacked area) │ + * ├────────────────────────────────────────┤ + * │ Event Gantt (drag/click to edit) │ + * ├────────────────────────────────────────┤ + * │ Flex rules editor │ + * ├────────────────────────────────────────┤ + * │ Drawer: legacy form sections (collapsed)│ + * └────────────────────────────────────────┘ */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; @@ -15,12 +21,14 @@ import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { api, lifeEventsApi, type Scenario, type SimulateRequest } from '@/api/client'; import { ApiError } from '@/api/client'; +import { EventGantt } from '@/components/EventGantt'; import { FanChart } from '@/components/FanChart'; +import { FlexRulesEditor } from '@/components/FlexRulesEditor'; import { GoalsSection } from '@/components/GoalsSection'; import { IncomeStreamsSection } from '@/components/IncomeStreamsSection'; import { LifeEventsSection } from '@/components/LifeEventsSection'; +import { SpendingProfileChart } from '@/components/SpendingProfileChart'; import { YearScrubber } from '@/components/YearScrubber'; -import { YearStatsPanel } from '@/components/YearStatsPanel'; import { gbp, pct } from '@/lib/format'; import { emojiFor } from '@/lib/milestone'; @@ -52,6 +60,21 @@ export function ScenarioDetail() { enabled: Number.isFinite(id), }); + const profile = useQuery({ + queryKey: ['spending-profile', id], + queryFn: () => api.spendingProfile(id), + enabled: Number.isFinite(id), + staleTime: 0, + refetchOnWindowFocus: true, + }); + + const yearStats = useQuery({ + queryKey: ['year-stats', id, parseInt(searchParams.get('year') ?? '0', 10)], + queryFn: () => api.yearStats(id, parseInt(searchParams.get('year') ?? '0', 10)), + enabled: Number.isFinite(id) && proj.isSuccess, + staleTime: 0, + }); + const del = useMutation({ mutationFn: () => api.scenarios.delete(id), onSuccess: () => { @@ -103,6 +126,7 @@ export function ScenarioDetail() { const onRunNow = async (s: Scenario) => { const fresh = await lifeEventsApi.list(s.id); + const flexRules = readFlexRules(s); sim.mutate({ jurisdiction: s.jurisdiction, strategy: s.strategy, @@ -118,14 +142,14 @@ export function ScenarioDetail() { year_end: e.year_end, delta_gbp_per_year: e.delta_gbp_per_year, one_time_amount_gbp: e.one_time_amount_gbp, + category: e.category, enabled: e.enabled, })), + flex_rules: flexRules, }); }; - if (!Number.isFinite(id)) { - return

Invalid scenario id.

; - } + if (!Number.isFinite(id)) return

Invalid scenario id.

; if (scen.isLoading) return

Loading…

; if (scen.isError || !scen.data) { return ( @@ -163,7 +187,7 @@ export function ScenarioDetail() { onClick={() => void onRunNow(s)} disabled={sim.isPending} className="rounded-md border border-slate-300 bg-white text-sm px-3 py-1.5 hover:bg-slate-50 disabled:opacity-60" - title="Run a fresh MC including this scenario's life events" + title="Run a fresh MC including this scenario's life events + flex rules" > {sim.isPending ? 'Running…' : 'Run now'} @@ -193,39 +217,76 @@ export function ScenarioDetail() {
)} -
- - - - -
- {projection ? ( -
-
-
- - - - + <> + {/* NW fan with floating stat badges */} +
+
+

Portfolio fan

+ + p10/p50/p90 over {projection.yearly.length}y · {projection.n_paths.toLocaleString()} paths +
-
-

Portfolio fan

- +
+ +
+ +
+ + {/* Spending profile */} +
+
+

Spending profile

+ +
+ {profile.data ? ( + -
- -
-
+ ) : ( +

Loading…

+ )}
- -
+ + {/* Interactive Gantt */} +
+
+

Life events

+ + Click empty space to add · drag bars to move · drag edges to resize + +
+ +
+ + + ) : projection404 ? (

No projection yet.

@@ -259,32 +320,105 @@ export function ScenarioDetail() {
)} - - - + {/* Legacy form sections — collapsed by default. The chart UI above + is the primary editor; these stay for bulk edit + accessibility. */} +
+ + Form-based editors (income streams · goals · life events table) + +
+ + + +
+
); } -function Stat({ - label, - value, - accent, -}: { - label: string; - value: string | number; - accent?: boolean; +function FloatingStats(props: { + year: number; + maxYear: number; + successRate: string; + p50End: string; + netWorth?: string; + changeNw?: string; + spending?: string; + taxes?: string; + effectiveRate?: string; + age: number | null; + calendarYear?: number; }) { return ( -
-
{label}
-
- {value} -
+
+ + + + + +
); } + +function Badge({ + label, + value, + accent, + signed, +}: { + label: string; + value: string; + accent?: boolean; + signed?: string; +}) { + let cls = 'text-slate-700'; + if (accent) cls = 'text-emerald-700'; + if (signed != null) { + const n = Number(signed); + if (n > 0) cls = 'text-emerald-700'; + else if (n < 0) cls = 'text-red-600'; + } + return ( +
+
{label}
+
{value}
+
+ ); +} + +function Legend() { + return ( +
+ + + + +
+ ); +} + +function Swatch({ color, label }: { color: string; label: string }) { + return ( + + + {label} + + ); +} + +function readFlexRules(s: Scenario): { from_ath_pct: string; cut_discretionary_pct: string }[] { + const blob = s.config_json as Record; + const raw = blob?.flex_rules; + if (!Array.isArray(raw)) return []; + return raw + .filter((r): r is Record => typeof r === 'object' && r !== null) + .map((r) => ({ + from_ath_pct: String(r.from_ath_pct ?? 0), + cut_discretionary_pct: String(r.cut_discretionary_pct ?? 0), + })); +} diff --git a/tests/test_api_spending_profile.py b/tests/test_api_spending_profile.py new file mode 100644 index 0000000..c1f331d --- /dev/null +++ b/tests/test_api_spending_profile.py @@ -0,0 +1,167 @@ +"""Tests for the spending-profile endpoint.""" +from __future__ import annotations + +from collections.abc import AsyncIterator +from datetime import UTC, datetime +from decimal import Decimal + +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from fire_planner.api.dependencies import get_session +from fire_planner.app import app +from fire_planner.db import LifeEvent, McRun, ProjectionYearly, Scenario + + +@pytest_asyncio.fixture +async def client(engine: AsyncEngine, session: AsyncSession) -> AsyncIterator[AsyncClient]: + factory = async_sessionmaker(engine, expire_on_commit=False) + + async def _override() -> AsyncIterator[AsyncSession]: + async with factory() as s: + yield s + + app.dependency_overrides[get_session] = _override + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + app.dependency_overrides.clear() + + +async def _seed(session: AsyncSession, + flex_rules: list[dict] | None = None) -> int: + config: dict = {} + if flex_rules: + config["flex_rules"] = flex_rules + scen = Scenario( + external_id="user-sp", + kind="user", + name="SP test", + jurisdiction="uk", + strategy="trinity", + leave_uk_year=0, + glide_path="static", + spending_gbp=Decimal("60000"), + horizon_years=5, + nw_seed_gbp=Decimal("1000000"), + savings_per_year_gbp=Decimal("0"), + config_json=config, + ) + session.add(scen) + await session.commit() + await session.refresh(scen) + + # One persistent essential life event (kid at home), one + # discretionary (travel), one income inflow. + session.add_all([ + LifeEvent( + scenario_id=scen.id, + kind="kid_at_home", + name="Kid 1", + year_start=0, + year_end=4, + delta_gbp_per_year=Decimal("-15000"), + category="essential", + enabled=True, + ), + LifeEvent( + scenario_id=scen.id, + kind="travel", + name="Travel", + year_start=0, + year_end=4, + delta_gbp_per_year=Decimal("-10000"), + category="discretionary", + enabled=True, + ), + LifeEvent( + scenario_id=scen.id, + kind="rental", + name="Rental", + year_start=0, + year_end=4, + delta_gbp_per_year=Decimal("8000"), + category="essential", + enabled=True, + ), + ]) + await session.commit() + return scen.id + + +async def test_spending_profile_with_no_run( + client: AsyncClient, + session: AsyncSession, +) -> None: + sid = await _seed(session) + resp = await client.get(f"/scenarios/{sid}/spending-profile") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["horizon_years"] == 5 + assert len(body["points"]) == 5 + + p0 = body["points"][0] + # base = 60000 - 8000 inflow = 52000 + assert Decimal(p0["base_gbp"]) == Decimal("52000") + assert Decimal(p0["essential_gbp"]) == Decimal("15000") + assert Decimal(p0["discretionary_gbp"]) == Decimal("10000") + # No projection yet → no flex cut. + assert Decimal(p0["flex_cut_gbp"]) == Decimal("0") + # total = 52000 + 15000 + 10000 = 77000 + assert Decimal(p0["total_gbp"]) == Decimal("77000") + + +async def test_spending_profile_with_flex_rules( + client: AsyncClient, + session: AsyncSession, +) -> None: + flex = [{"from_ath_pct": 0.20, "cut_discretionary_pct": 0.50}] + sid = await _seed(session, flex_rules=flex) + + # Persist a fan that drops to 70% of seed (i.e. 30% drawdown vs ATH). + run = McRun( + scenario_id=sid, + run_at=datetime.now(UTC), + n_paths=10, + seed=1, + success_rate=Decimal("1"), + p10_ending_gbp=Decimal("0"), + p50_ending_gbp=Decimal("0"), + p90_ending_gbp=Decimal("0"), + median_lifetime_tax_gbp=Decimal("0"), + median_years_to_ruin=None, + elapsed_seconds=Decimal("0"), + ) + session.add(run) + await session.commit() + await session.refresh(run) + rows = [ + ProjectionYearly( + mc_run_id=run.id, + year_idx=y, + p10_portfolio_gbp=Decimal("0"), + p25_portfolio_gbp=Decimal("0"), + # year 0 = 1M (ATH); year 1 = 700k (down 30% — flex fires); + # years 2-4 = 800k (still down 20% from ATH 1M). + p50_portfolio_gbp=Decimal( + str([1_000_000, 700_000, 800_000, 800_000, 800_000][y])), + p75_portfolio_gbp=Decimal("0"), + p90_portfolio_gbp=Decimal("0"), + p50_withdrawal_gbp=Decimal("0"), + p50_tax_gbp=Decimal("0"), + survival_rate=Decimal("1"), + ) for y in range(5) + ] + session.add_all(rows) + await session.commit() + + resp = await client.get(f"/scenarios/{sid}/spending-profile") + assert resp.status_code == 200 + pts = resp.json()["points"] + # Year 0: portfolio == ATH → no cut. + assert Decimal(pts[0]["flex_cut_gbp"]) == Decimal("0") + # Year 1: drawdown 30% → 50% cut on £10k discretionary = £5k. + assert Decimal(pts[1]["flex_cut_gbp"]) == Decimal("5000.00") + # Year 1 total = 52000 + 15000 + 10000 - 5000 = 72000 + assert Decimal(pts[1]["total_gbp"]) == Decimal("72000.00") diff --git a/tests/test_flex_spending.py b/tests/test_flex_spending.py new file mode 100644 index 0000000..ce44160 --- /dev/null +++ b/tests/test_flex_spending.py @@ -0,0 +1,59 @@ +"""Tests for the flex-spending engine.""" +from __future__ import annotations + +import numpy as np +import pytest + +from fire_planner.flex_spending import FlexRule, applicable_cut, cuts_per_year + + +def test_applicable_cut_picks_deepest_rule() -> None: + rules = [ + FlexRule(from_ath_pct=0.10, cut_discretionary_pct=0.20), + FlexRule(from_ath_pct=0.30, cut_discretionary_pct=0.60), + FlexRule(from_ath_pct=0.50, cut_discretionary_pct=0.90), + ] + # No drawdown — no cut. + assert applicable_cut(0.0, rules) == 0.0 + # 9% drop — below first threshold. + assert applicable_cut(0.09, rules) == 0.0 + # 15% drop — only first rule fires. + assert applicable_cut(0.15, rules) == pytest.approx(0.20) + # 35% drop — first + second; deepest cut wins (0.60, not 0.80). + assert applicable_cut(0.35, rules) == pytest.approx(0.60) + # 60% drop — all three; 0.90 wins. + assert applicable_cut(0.60, rules) == pytest.approx(0.90) + + +def test_applicable_cut_empty_rules() -> None: + assert applicable_cut(0.5, []) == 0.0 + + +def test_cuts_per_year_handles_running_ath() -> None: + # Single path. Year 0 seed=1000, year 1 = 1200 (new ATH), year 2 = 800 + # (-33% from ATH 1200), year 3 = 900 (still -25% from ATH 1200), year + # 4 = 1300 (new ATH). + portfolio = np.array([[1000, 1200, 800, 900, 1300]], dtype=np.float64) + rules = [ + FlexRule(from_ath_pct=0.10, cut_discretionary_pct=0.20), + FlexRule(from_ath_pct=0.30, cut_discretionary_pct=0.60), + ] + cuts = cuts_per_year(portfolio, rules) + # cuts[:, y] uses portfolio[:, y] (start-of-year decision based on + # the prior year's close). + # y=0: portfolio=1000 == ATH → 0 + # y=1: portfolio=1200 == ATH → 0 + # y=2: drawdown = 1 - 800/1200 = 0.333 → 0.60 + # y=3: drawdown = 1 - 900/1200 = 0.25 → 0.20 + assert cuts.shape == (1, 4) + assert cuts[0, 0] == pytest.approx(0.0) + assert cuts[0, 1] == pytest.approx(0.0) + assert cuts[0, 2] == pytest.approx(0.60) + assert cuts[0, 3] == pytest.approx(0.20) + + +def test_cuts_per_year_no_rules_returns_zeros() -> None: + portfolio = np.array([[1000, 800, 600]], dtype=np.float64) + cuts = cuts_per_year(portfolio, []) + assert cuts.shape == (1, 2) + assert (cuts == 0).all() diff --git a/tests/test_simulator_flex.py b/tests/test_simulator_flex.py new file mode 100644 index 0000000..81d0f13 --- /dev/null +++ b/tests/test_simulator_flex.py @@ -0,0 +1,70 @@ +"""End-to-end test that flex-spending rules survive £ in the portfolio.""" +from __future__ import annotations + +import numpy as np + +from fire_planner.flex_spending import FlexRule +from fire_planner.glide_path import static +from fire_planner.simulator import simulate +from fire_planner.strategies.trinity import TrinityStrategy +from fire_planner.tax.uae import UaeTaxRegime + + +def _flat_paths(n_paths: int, n_years: int, real_return: float = 0.0) -> np.ndarray: + """Returns paths cube where real return == 0% — easy to reason about.""" + paths = np.zeros((n_paths, n_years, 3), dtype=np.float64) + paths[:, :, 0] = real_return # nominal stocks + paths[:, :, 1] = real_return # nominal bonds + paths[:, :, 2] = 0.0 # cpi + return paths + + +def test_flex_rule_saves_money_at_drawdown() -> None: + """A scenario that drops below ATH triggers a discretionary cut and + ends up richer than the same scenario with no flex rules.""" + paths = _flat_paths(n_paths=10, n_years=5, real_return=-0.05) + initial = 1_000_000.0 + + common = dict( + paths=paths, + initial_portfolio=initial, + spending_target=10_000.0, + glide=static(1.0), + strategy=TrinityStrategy(), + regime=UaeTaxRegime(), + horizon_years=5, + cashflow_adjustments=np.full(5, -20_000.0, dtype=np.float64), + discretionary_outflows=np.full(5, 20_000.0, dtype=np.float64), + ) + + no_flex = simulate(**common) + with_flex = simulate( + **common, + flex_rules=[FlexRule(from_ath_pct=0.05, cut_discretionary_pct=0.50)], + ) + no_flex_end = float(np.median(no_flex.portfolio_real[:, -1])) + with_flex_end = float(np.median(with_flex.portfolio_real[:, -1])) + assert with_flex_end > no_flex_end + assert no_flex_end > 0 # didn't ruin — meaningful comparison + + +def test_flex_rule_no_op_without_drawdown() -> None: + """Strong-positive returns, never below ATH → flex rules do nothing.""" + paths = _flat_paths(n_paths=10, n_years=5, real_return=0.10) + common = dict( + paths=paths, + initial_portfolio=1_000_000.0, + spending_target=40_000.0, + glide=static(1.0), + strategy=TrinityStrategy(), + regime=UaeTaxRegime(), + horizon_years=5, + cashflow_adjustments=np.full(5, -10_000.0, dtype=np.float64), + discretionary_outflows=np.full(5, 10_000.0, dtype=np.float64), + ) + no_flex = simulate(**common) + with_flex = simulate( + **common, + flex_rules=[FlexRule(from_ath_pct=0.10, cut_discretionary_pct=0.50)], + ) + assert np.allclose(no_flex.portfolio_real, with_flex.portfolio_real)