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