frontend: scaffold Vite + React 19 + TS + Tailwind v4 + TanStack Query
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled

Bare-minimum SPA that wires up to the FastAPI backend:

- Vite 6 + React 19 + TS strict, alias @/* to src/*
- Tailwind v4 via @tailwindcss/vite (no postcss)
- TanStack Query v5 with sane defaults (30s staleTime, no auto-refetch)
- React Router 7 for routing
- ECharts + Recharts available (charts land in Phase 1a)
- Vitest + @testing-library/react for tests
- Dev proxy /api → http://localhost:8080 (FastAPI)

Pages:
- Dashboard — pulls /networth, shows total + per-account cards.
  No chart yet (Phase 1a). Empty/error states for "no data" cases
  point users to the ingest CLI.

Header shows live API health (queue depth from /healthz). 274 KB JS
gzipped to 87 KB. typecheck + build pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-05-09 21:53:59 +00:00
parent ee6ed1d3c4
commit f4539f9e6d
16 changed files with 6145 additions and 0 deletions

16
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
node_modules
dist
.npm
*.tsbuildinfo
.env.local
.env.*.local
*.log
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

12
frontend/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>fire-planner</title>
</head>
<body class="min-h-screen bg-slate-50 text-slate-900 antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5630
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

45
frontend/package.json Normal file
View file

@ -0,0 +1,45 @@
{
"name": "fire-planner-frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"typecheck": "tsc -b --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0",
"recharts": "^3.0.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.0",
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.0",
"globals": "^15.14.0",
"jsdom": "^26.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"typescript-eslint": "^8.18.0",
"vite": "^6.0.0",
"vitest": "^3.0.0"
}
}

36
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,36 @@
import { useQuery } from '@tanstack/react-query';
import { Route, Routes, Link } from 'react-router-dom';
import { api } from '@/api/client';
import { Dashboard } from '@/pages/Dashboard';
export function App() {
const health = useQuery({ queryKey: ['health'], queryFn: api.health });
return (
<div className="min-h-screen flex flex-col">
<header className="border-b border-slate-200 bg-white">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<Link to="/" className="text-xl font-semibold tracking-tight">
fire-planner
</Link>
<div className="text-xs text-slate-500">
api:{' '}
<span className={health.data ? 'text-emerald-600' : 'text-amber-600'}>
{health.isLoading
? '…'
: health.data
? `ok (queue=${health.data.queue_depth})`
: 'unreachable'}
</span>
</div>
</div>
</header>
<main className="flex-1 max-w-7xl w-full mx-auto px-6 py-8">
<Routes>
<Route path="/" element={<Dashboard />} />
</Routes>
</main>
</div>
);
}

147
frontend/src/api/client.ts Normal file
View file

@ -0,0 +1,147 @@
/**
* Thin API client over the FastAPI backend.
*
* In dev, requests go through Vite's proxy at `/api` http://localhost:8080.
* In prod, the SPA is served from the same origin as the API.
*/
const API_BASE = '/api';
export class ApiError extends Error {
status: number;
detail: unknown;
constructor(status: number, detail: unknown, message: string) {
super(message);
this.status = status;
this.detail = detail;
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: {
'content-type': 'application/json',
...(init?.headers ?? {}),
},
...init,
});
if (!res.ok) {
let detail: unknown = null;
try {
detail = await res.json();
} catch {
detail = await res.text();
}
throw new ApiError(res.status, detail, `${res.status} ${res.statusText}`);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const api = {
health: () => request<{ status: string; queue_depth: number }>('/healthz'),
networth: {
current: () =>
request<{
snapshot_date: string;
total_gbp: string;
accounts: Array<{
account_id: string;
account_name: string;
account_type: string;
currency: string;
snapshot_date: string;
market_value: string;
market_value_gbp: string;
cost_basis_gbp: string | null;
}>;
}>('/networth'),
history: (days = 365) =>
request<{
points: Array<{
snapshot_date: string;
total_gbp: string;
by_account: Record<string, string>;
}>;
}>(`/networth/history?days=${days}`),
},
scenarios: {
list: (kind?: 'cartesian' | 'user') =>
request<Scenario[]>(`/scenarios${kind ? `?kind=${kind}` : ''}`),
get: (id: number) => request<Scenario>(`/scenarios/${id}`),
projection: (id: number) => request<ScenarioProjection>(`/scenarios/${id}/projection`),
},
simulate: (req: SimulateRequest) =>
request<SimulateResult>('/simulate', { method: 'POST', body: JSON.stringify(req) }),
};
export interface Scenario {
id: number;
external_id: string;
kind: 'cartesian' | 'user';
name: string | null;
description: string | null;
parent_scenario_id: number | null;
jurisdiction: string;
strategy: string;
leave_uk_year: number;
glide_path: string;
spending_gbp: string;
horizon_years: number;
nw_seed_gbp: string;
savings_per_year_gbp: string;
config_json: Record<string, unknown>;
created_at: string;
}
export interface ProjectionPoint {
year_idx: number;
p10_portfolio_gbp: string;
p25_portfolio_gbp: string;
p50_portfolio_gbp: string;
p75_portfolio_gbp: string;
p90_portfolio_gbp: string;
p50_withdrawal_gbp: string;
p50_tax_gbp: string;
survival_rate: string;
}
export interface ScenarioProjection {
scenario_id: number;
external_id: string;
mc_run_id: number;
run_at: string;
n_paths: number;
success_rate: string;
p10_ending_gbp: string;
p50_ending_gbp: string;
p90_ending_gbp: string;
median_lifetime_tax_gbp: string;
median_years_to_ruin: string | null;
yearly: ProjectionPoint[];
}
export interface SimulateRequest {
jurisdiction: string;
strategy: string;
leave_uk_year: number;
glide_path: string;
spending_gbp: string;
nw_seed_gbp: string;
savings_per_year_gbp?: string;
horizon_years?: number;
floor_gbp?: string | null;
n_paths?: number;
seed?: number;
}
export interface SimulateResult {
success_rate: string;
p10_ending_gbp: string;
p50_ending_gbp: string;
p90_ending_gbp: string;
median_lifetime_tax_gbp: string;
median_years_to_ruin: string | null;
elapsed_seconds: string;
yearly: ProjectionPoint[];
}

15
frontend/src/index.css Normal file
View file

@ -0,0 +1,15 @@
@import "tailwindcss";
@theme {
--font-sans: ui-sans-serif, system-ui, -apple-system, "Inter", "Segoe UI", Roboto, sans-serif;
--color-brand-50: oklch(97% 0.02 260);
--color-brand-500: oklch(60% 0.18 260);
--color-brand-700: oklch(45% 0.20 260);
--color-success: oklch(60% 0.20 145);
--color-warning: oklch(70% 0.20 80);
--color-danger: oklch(60% 0.22 30);
}
html, body, #root {
height: 100%;
}

View file

@ -0,0 +1,50 @@
/**
* Currency / number formatting helpers.
*
* Backend returns Decimals as strings; we format them with Intl.NumberFormat
* for display and parseFloat them when we need a number for charting.
*/
const GBP = new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP',
maximumFractionDigits: 0,
});
const GBP_PRECISE = new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP',
maximumFractionDigits: 2,
});
const PCT = new Intl.NumberFormat('en-GB', {
style: 'percent',
maximumFractionDigits: 1,
});
const COMPACT = new Intl.NumberFormat('en-GB', {
notation: 'compact',
maximumFractionDigits: 1,
});
export function gbp(value: string | number, precise = false): string {
const n = typeof value === 'string' ? Number(value) : value;
if (!Number.isFinite(n)) return '—';
return (precise ? GBP_PRECISE : GBP).format(n);
}
export function gbpCompact(value: string | number): string {
const n = typeof value === 'string' ? Number(value) : value;
if (!Number.isFinite(n)) return '—';
return `£${COMPACT.format(n)}`;
}
export function pct(value: string | number): string {
const n = typeof value === 'string' ? Number(value) : value;
if (!Number.isFinite(n)) return '—';
return PCT.format(n);
}
export function num(value: string | number): number {
return typeof value === 'string' ? Number(value) : value;
}

29
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,29 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import './index.css';
import { App } from '@/App';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
},
},
});
const root = document.getElementById('root');
if (!root) throw new Error('#root element missing');
createRoot(root).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);

View file

@ -0,0 +1,75 @@
/**
* Dashboard landing page. Wire up gets fleshed out in Phase 1a.
* For now: confirm we can reach /networth and render today's NW.
*/
import { useQuery } from '@tanstack/react-query';
import { api } from '@/api/client';
import { gbp } from '@/lib/format';
export function Dashboard() {
const nw = useQuery({ queryKey: ['networth', 'current'], queryFn: api.networth.current });
if (nw.isLoading) {
return <p className="text-slate-500">Loading net worth</p>;
}
if (nw.isError) {
return (
<div className="rounded-md border border-amber-200 bg-amber-50 p-4 text-amber-800">
<p className="font-medium">Couldn&apos;t reach the API.</p>
<p className="text-sm mt-1">
Make sure FastAPI is running on :8080 and Wealthfolio ingest has populated
<code className="px-1">account_snapshot</code>.
</p>
</div>
);
}
const data = nw.data!;
if (data.accounts.length === 0) {
return (
<div className="rounded-md border border-slate-200 bg-white p-6">
<p className="font-medium">No snapshots yet.</p>
<p className="text-sm text-slate-500 mt-1">
Run <code className="px-1">python -m fire_planner ingest</code> to pull from the
wealthfolio_sync mirror.
</p>
</div>
);
}
return (
<section className="space-y-6">
<header>
<h1 className="text-3xl font-semibold tracking-tight">Net worth</h1>
<p className="text-sm text-slate-500">As of {data.snapshot_date}</p>
</header>
<div className="rounded-lg border border-slate-200 bg-white p-6">
<div className="text-4xl font-semibold tabular-nums">{gbp(data.total_gbp)}</div>
<p className="text-sm text-slate-500 mt-1">{data.accounts.length} accounts</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data.accounts.map((a) => (
<div
key={a.account_id}
className="rounded-lg border border-slate-200 bg-white p-4 flex flex-col gap-1"
>
<div className="flex items-center justify-between">
<span className="font-medium truncate">{a.account_name}</span>
<span className="text-xs uppercase tracking-wide text-slate-500">
{a.account_type}
</span>
</div>
<div className="text-2xl font-semibold tabular-nums">{gbp(a.market_value_gbp)}</div>
{a.currency !== 'GBP' && (
<div className="text-xs text-slate-500">
{gbp(a.market_value, true)} {a.currency}
</div>
)}
</div>
))}
</div>
</section>
);
}

View file

@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';

1
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"noImplicitOverride": true,
"isolatedModules": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"isolatedModules": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

32
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,32 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'node:path';
const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:8080';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
host: true,
port: 5173,
proxy: {
'/api': {
target: apiTarget,
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, ''),
},
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
});