Commit graph

9 commits

Author SHA1 Message Date
Viktor Barzin
2f95c891fa fire-planner: SPA cache headers — index.html no-cache, hashed assets immutable
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Browsers were caching the old index.html which still pointed at the
pre-Wave-2 bundle hash. Hashed assets under /assets/ stay cacheable
for a year (immutable), but index.html (and any SPA fallback) must
revalidate every request so a fresh deploy is visible immediately.
2026-05-10 17:01:11 +00:00
Viktor Barzin
64eb90c3dc fire-planner: Wave 2 chart-first — flex spending, categorised life
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
events, interactive Visx Gantt + spending-profile chart

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

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

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

Wave 1 (9 features across 4 streams):

Stream A — dashboard skeleton
  1.A.1 ScenarioShell with top tabs (Plan/Cash Flow/Tax Analytics/Compare/
        Reports/Estate/Settings) + left Sidebar with Plans switcher.
  1.A.2 GET /scenarios/{id}/year-stats?year=N returning per-year metrics
        (NW, Δ NW, taxable income, taxes, eff. rate, spending, contribs,
        investment growth). YearScrubber + YearStatsPanel render the
        right-hand sidebar; URL ?year= preserves selection.
  1.A.3 FanChart gains optional `milestones` prop (lib/milestone.ts maps
        life_event.kind → emoji) + selectedYear marker line.

Stream B — goals + progress
  1.B.1 New goals_eval module: target_nw_by_year / never_run_out /
        target_real_income probability evaluation. Wired into POST
        /simulate (exact, per-path) and GET /scenarios/{id}/projection
        (approximated from persisted fan via percentile interpolation).
        GoalsSection renders pass/fail badges.
  1.B.2 GET /scenarios/{id}/progress overlays AccountSnapshot totals on
        the projection fan; ProgressPage shows variance side-panel.

Stream C — income + cashflow
  1.C.1 New IncomeStream model + alembic 0003 + CRUD endpoints. Engine
        aggregates streams into per-year inflows + taxable arrays;
        income tax routes through the jurisdiction tax engine.
        IncomeStreamsSection on Plan tab.
  1.C.2 GET /scenarios/{id}/cashflow?year=N returns sources/sinks for
        an ECharts Sankey (sums conserve). CashflowTab body.

Stream D — settings
  1.D.1 SettingsTab + sub-nav (Milestones/Rates/Dividends/Bonds/Tax/
        Metrics/Other/Notes); placeholder cards for unbuilt sub-pages.
  1.D.2 LifeEventsSection relocated to /scenarios/:id/settings.
  1.D.3 RatesSettings (Fixed/Historical/Advanced segmented + per-asset
        cards). SimulateRequest gains rates_mode, inflation_pct,
        stocks/bonds growth + dividend, stocks_allocation. New
        build_fixed_paths() in simulator. Real-return arithmetic
        verified against (1+g+d)/(1+i)−1 ≈ 5.4%.
  1.D.4 NotesSettings — markdown textarea, save-on-blur, stored in
        scenario.config_json.notes.

Backend: 238 pytest pass (+19 new), mypy + ruff clean.
Frontend: typecheck + 7 unit tests + production build clean.

Roadmap for Wave 2-N is documented in the implementation plan.
2026-05-10 12:49:44 +00:00
Viktor Barzin
3bfa46ad4f spending: prefill annual £ from actualbudget trailing 12mo
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Adds a thin read-only client for the actualbudget HTTP API
(`fire_planner/actualbudget.py`) and a `GET /spending/annual` endpoint
that returns trailing-N-month spending broken out by category group.

Default exclusions ("Investments and Savings", "Budget Reset") strip
out wealth transfers so the headline number reflects actual
consumption — for Viktor's data, ~£41k/yr instead of the raw £210k
total. Caller can pass `?exclude=...` to override.

Frontend uses the headline `total_gbp` to autofill the Annual spending
input (same pattern as nw_seed from networth), with a small
provenance line below the input showing the window + which groups
were excluded.

Auth: 3 new env vars (ACTUALBUDGET_API_URL/KEY/SYNC_ID) sourced from
Vault `secret/fire-planner` via the existing ExternalSecret —
infra/stacks/fire-planner applied separately. Backend silently keeps
the hardcoded default if the upstream is unreachable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 11:11:51 +00:00
Viktor Barzin
472acd5804 app: catch starlette.HTTPException in SPA fallback
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
StaticFiles is a Starlette primitive — its 404 raises
starlette.exceptions.HTTPException, NOT fastapi.HTTPException
(which subclasses Starlette's). My initial except clause caught the
subclass and let the base class propagate, so /scenarios still 404'd.

Switch to except StarletteHTTPException so both the parent and any
FastAPI subclass are caught. Verified end-to-end via chrome-service
in the next deploy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 23:00:58 +00:00
Viktor Barzin
d91473a018 app: SPA fallback for arbitrary client routes
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
StaticFiles(html=True) only serves index.html for directory paths,
which 404s on /scenarios, /what-if, /scenarios/123 — anything React
Router owns. Subclass StaticFiles to catch the 404 from get_response
and return index.html so the SPA can take over routing client-side.

API routes still match first (under /api/* in prod), so no risk of
shadowing.

Found via headless verification through chrome-service: dashboard
loaded 200 + nav rendered, but /scenarios + /what-if returned 404.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:56:37 +00:00
Viktor Barzin
b82770b5c4 deploy: combined Dockerfile — FastAPI serves the SPA in prod
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Three-stage build:
  1. node:22-alpine — `npm ci` + `npm run build` produces frontend/dist
  2. python:3.12-slim — poetry installs backend deps into a venv
  3. python:3.12-slim — runtime, copies the venv + frontend/dist,
     sets FRONTEND_DIST=/app/frontend_dist

Backend gates the API surface on FRONTEND_DIST:

- Unset (dev / tests): routers mount at root (/networth, /scenarios,
  …). 172 tests still pass unchanged. The Vite dev server proxies
  `/api/*` → backend stripping the prefix.
- Set (prod): routers mount under `/api/*`. The SPA bundle mounts at
  `/` with html=True so React Router owns client routing for paths
  like `/scenarios`, `/what-if`. Same-origin, no CORS, one deploy.

Operational endpoints (`/healthz`, `/metrics`, `/recompute`) stay at
root in both shapes.

Existing Woodpecker pipeline picks this up unchanged — same context,
same Dockerfile path, just produces a richer image.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:25:52 +00:00
Viktor Barzin
ee6ed1d3c4 api: expand FastAPI surface for scenarios, networth, life-events, goals, simulate
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Adds the read+write endpoints the frontend needs to drive a
ProjectionLab-style UX on top of the existing engine.

- /networth, /networth/history    — NW total + per-account from
                                     account_snapshot (frontend chart)
- /scenarios CRUD + projection    — list/get/create/patch/delete user
                                     scenarios; cartesian read-only
- /scenarios/{id}/life-events     — life event CRUD nested under scenario
- /life-events/{id}               — patch + delete by id
- /scenarios/{id}/goals,
  /goals/{id}                     — retirement goal CRUD
- /simulate, /compare             — sync, no-DB-write what-if endpoints

Auth: Bearer-token dependency on writes + simulate when API_BEARER_TOKEN
is set; reads always open (lock down via Authentik-fronted ingress in
prod). Existing /recompute keeps its bearer auth.

CORS middleware reads FRONTEND_ORIGINS (comma-separated) for the dev
SPA. Lifespan now provisions the SQLAlchemy engine + session_factory
on app.state and disposes them on shutdown.

40 new tests covering happy paths and validation. 172 tests total.
mypy strict + ruff clean (B008 ignore added — Depends() in defaults
is the canonical FastAPI pattern, not a bug).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 21:48:36 +00:00
Viktor Barzin
f7ef7ca4ab Initial extraction from monorepo 2026-05-07 17:06:19 +00:00