Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
events, interactive Visx Gantt + spending-profile chart
Charts are now the primary editor for life events. The Plan-tab body
re-orders to make charts ~80% of viewport real-estate; legacy form
sections are collapsed into a drawer.
Backend:
- alembic 0004: life_event.category enum (essential / discretionary /
not_spending). Defaults to essential so existing rows keep their
full spending impact.
- Simulator gains discretionary_outflows + flex_rules params. Tracks
per-path running ATH, applies the deepest applicable cut to
discretionary outflows when portfolio drops vs ATH (PLab-style flex
spending). Cut amount stays in the portfolio (refund pattern).
- New flex_spending module with FlexRule + applicable_cut +
cuts_per_year (vectorised). Sortable rules; "deepest cut wins" so
users specify cumulative cuts at each tier.
- New /scenarios/{id}/spending-profile endpoint returning per-year
base / essential / discretionary / flex_cut / total breakdown.
- SimulateRequest gains flex_rules + life_event.category roundtrip.
- 8 new tests; 246 total pytest pass; mypy + ruff clean.
Frontend (Visx + ECharts):
- Installed @visx/{scale,shape,group,axis,event,responsive,tooltip}
for native SVG drag interactions.
- New <SpendingProfileChart> — Visx stacked-area of base/essential/
discretionary with red flex-cut overlay, hover tooltip, click-to-
scrub-year.
- New <EventGantt> — interactive Visx Gantt:
* Click empty space → popover create at that year (default
essential spending event)
* Click a bar → inline edit popover (name, kind, range, £/y,
category) with delete button
* Drag bar middle → moves the whole event (year-resolution snap)
* Drag bar edges → resizes year_start / year_end
* All gestures persist via PATCH /life-events/{id}
- New <FlexRulesEditor> — list of {from_ath_pct, cut} tiers, save-on-
change to scenario.config_json.flex_rules.
- Plan-tab redesign: NW fan dominant top with floating stat badges
(Year/Age/NW/Δ NW/Spending/Eff. tax) over the chart; spending-
profile chart middle; Gantt bottom; flex-rules editor; legacy form
sections in a collapsed <details> drawer.
- Frontend typecheck + 7 vitest tests + production build all clean.
187 lines
7.2 KiB
Python
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)
|