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

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" />