fire-planner/fire_planner/app.py
Viktor Barzin 64eb90c3dc
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fire-planner: Wave 2 chart-first — flex spending, categorised life
events, interactive Visx Gantt + spending-profile chart

Charts are now the primary editor for life events. The Plan-tab body
re-orders to make charts ~80% of viewport real-estate; legacy form
sections are collapsed into a drawer.

Backend:
- alembic 0004: life_event.category enum (essential / discretionary /
  not_spending). Defaults to essential so existing rows keep their
  full spending impact.
- Simulator gains discretionary_outflows + flex_rules params. Tracks
  per-path running ATH, applies the deepest applicable cut to
  discretionary outflows when portfolio drops vs ATH (PLab-style flex
  spending). Cut amount stays in the portfolio (refund pattern).
- New flex_spending module with FlexRule + applicable_cut +
  cuts_per_year (vectorised). Sortable rules; "deepest cut wins" so
  users specify cumulative cuts at each tier.
- New /scenarios/{id}/spending-profile endpoint returning per-year
  base / essential / discretionary / flex_cut / total breakdown.
- SimulateRequest gains flex_rules + life_event.category roundtrip.
- 8 new tests; 246 total pytest pass; mypy + ruff clean.

Frontend (Visx + ECharts):
- Installed @visx/{scale,shape,group,axis,event,responsive,tooltip}
  for native SVG drag interactions.
- New <SpendingProfileChart> — Visx stacked-area of base/essential/
  discretionary with red flex-cut overlay, hover tooltip, click-to-
  scrub-year.
- New <EventGantt> — interactive Visx Gantt:
    * Click empty space → popover create at that year (default
      essential spending event)
    * Click a bar → inline edit popover (name, kind, range, £/y,
      category) with delete button
    * Drag bar middle → moves the whole event (year-resolution snap)
    * Drag bar edges → resizes year_start / year_end
    * All gestures persist via PATCH /life-events/{id}
- New <FlexRulesEditor> — list of {from_ath_pct, cut} tiers, save-on-
  change to scenario.config_json.flex_rules.
- Plan-tab redesign: NW fan dominant top with floating stat badges
  (Year/Age/NW/Δ NW/Spending/Eff. tax) over the chart; spending-
  profile chart middle; Gantt bottom; flex-rules editor; legacy form
  sections in a collapsed <details> drawer.
- Frontend typecheck + 7 vitest tests + production build all clean.
2026-05-10 16:49:04 +00:00

187 lines
7.2 KiB
Python

"""FastAPI application — wires routers + middleware + lifespan.
Two deployment shapes:
- **Dev / tests**: `FRONTEND_DIST` unset. API routers mount at root
(e.g. `/networth`, `/scenarios`). The Vite dev server (port 5173)
hits `/api/*` and proxies to the backend stripping the prefix.
- **Prod**: `FRONTEND_DIST=/path/to/frontend/dist` set. API routers
mount under `/api/*`; the SPA static bundle mounts at `/` with
HTML fallback so React Router can own the URL.
Operational endpoints (`/healthz`, `/metrics`, `/recompute`) stay at
root in both shapes.
Auth: write/compute paths take Bearer auth via the `require_bearer`
dependency when `API_BEARER_TOKEN` is set. Read paths skip auth so the
local frontend can hit them without juggling tokens — production
deploys lock those down via Authentik-fronted ingress.
CORS: enabled for the frontend dev server. Comma-separated origins
in `FRONTEND_ORIGINS` (defaults to a typical Vite localhost).
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from fastapi import Depends, FastAPI, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from prometheus_fastapi_instrumentator import Instrumentator
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.types import Scope
from fire_planner.api.auth import require_bearer
from fire_planner.api.cashflow import router as cashflow_router
from fire_planner.api.goals import router as goals_router
from fire_planner.api.income_streams import router as income_streams_router
from fire_planner.api.life_events import router as life_events_router
from fire_planner.api.networth import router as networth_router
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
log = logging.getLogger(__name__)
def _frontend_origins() -> list[str]:
raw = os.environ.get(
"FRONTEND_ORIGINS",
"http://localhost:5173,http://localhost:4173,http://127.0.0.1:5173",
)
return [s.strip() for s in raw.split(",") if s.strip()]
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
app.state.queue = queue
if os.environ.get("DB_CONNECTION_STRING"):
engine = create_engine_from_env()
app.state.engine = engine
app.state.session_factory = make_session_factory(engine)
else:
# Tests inject these via dependency_overrides; nothing to wire.
log.warning("DB_CONNECTION_STRING unset; skipping engine init")
worker = asyncio.create_task(_drain_queue(app))
app.state._worker = worker
try:
yield
finally:
worker.cancel()
with contextlib.suppress(asyncio.CancelledError):
await worker
eng = getattr(app.state, "engine", None)
if eng is not None:
await eng.dispose()
async def _drain_queue(app: FastAPI) -> None:
"""Background task draining the recompute queue. Each item kicks
a full Cartesian recompute. Errors logged, don't crash."""
queue: asyncio.Queue[dict[str, Any]] = app.state.queue
while True:
item = await queue.get()
try:
from fire_planner.__main__ import _recompute_all
await _recompute_all(
n_paths=int(item.get("n_paths", 10_000)),
horizon=int(item.get("horizon", 60)),
spending=float(item.get("spending", 100_000.0)),
nw_seed=float(item.get("nw_seed", 1_000_000.0)),
savings=float(item.get("savings", 0.0)),
floor=(float(item["floor"]) if item.get("floor") is not None else None),
returns_csv=item.get("returns_csv"),
seed=int(item.get("seed", 42)),
)
except Exception:
log.exception("recompute failed")
finally:
queue.task_done()
_FRONTEND_DIST = os.environ.get("FRONTEND_DIST")
_API_PREFIX = "/api" if _FRONTEND_DIST else ""
app = FastAPI(title="fire-planner", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=_frontend_origins(),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Instrumentator().instrument(app).expose(app, endpoint="/metrics")
app.include_router(networth_router, prefix=_API_PREFIX)
app.include_router(scenarios_router, prefix=_API_PREFIX)
app.include_router(life_events_router, prefix=_API_PREFIX)
app.include_router(goals_router, prefix=_API_PREFIX)
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)
@app.post(
"/recompute",
status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_bearer)],
)
async def recompute(payload: dict[str, Any] | None = None) -> dict[str, Any]:
"""Queue a full Cartesian recompute (async, persisted). Returns 202."""
queue: asyncio.Queue[dict[str, Any]] = app.state.queue
await queue.put(payload or {})
return {"status": "accepted", "depth": queue.qsize()}
@app.get("/healthz")
async def healthz() -> dict[str, Any]:
queue = getattr(app.state, "queue", None)
depth = queue.qsize() if queue is not None else 0
return {"status": "ok", "queue_depth": depth}
class _SPAStaticFiles(StaticFiles):
"""StaticFiles with a SPA fallback: any 404 returns index.html so
React Router can own client-side routing for paths like /scenarios,
/what-if, /scenarios/123, etc.
`StaticFiles(html=True)` only serves index.html for *directories*,
not arbitrary not-found paths — that's not enough for a SPA.
"""
async def get_response(self, path: str, scope: Scope): # type: ignore[no-untyped-def]
try:
return await super().get_response(path, scope)
except StarletteHTTPException as exc:
if exc.status_code == 404:
index = Path(self.directory) / "index.html" # type: ignore[arg-type]
if index.is_file():
return FileResponse(index)
raise
# Mount the SPA last so it catches any path the API didn't claim.
if _FRONTEND_DIST and Path(_FRONTEND_DIST).is_dir():
app.mount("/", _SPAStaticFiles(directory=_FRONTEND_DIST, html=True), name="spa")
log.info("Mounted SPA from %s; API at %s/*", _FRONTEND_DIST, _API_PREFIX)
elif _FRONTEND_DIST:
log.warning("FRONTEND_DIST=%s does not exist; SPA not mounted", _FRONTEND_DIST)