frontend: scaffold Vite + React 19 + TS + Tailwind v4 + TanStack Query
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
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:
parent
ee6ed1d3c4
commit
f4539f9e6d
16 changed files with 6145 additions and 0 deletions
16
frontend/.gitignore
vendored
Normal file
16
frontend/.gitignore
vendored
Normal 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
12
frontend/index.html
Normal 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
5630
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
45
frontend/package.json
Normal file
45
frontend/package.json
Normal 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
36
frontend/src/App.tsx
Normal 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
147
frontend/src/api/client.ts
Normal 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
15
frontend/src/index.css
Normal 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%;
|
||||
}
|
||||
50
frontend/src/lib/format.ts
Normal file
50
frontend/src/lib/format.ts
Normal 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
29
frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
75
frontend/src/pages/Dashboard.tsx
Normal file
75
frontend/src/pages/Dashboard.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
1
frontend/src/test/setup.ts
Normal file
1
frontend/src/test/setup.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom/vitest';
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
21
frontend/tsconfig.node.json
Normal file
21
frontend/tsconfig.node.json
Normal 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
32
frontend/vite.config.ts
Normal 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'],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue