This is the initial push of the outline of the life dashboard

This commit is contained in:
YOUNG
2026-03-09 12:52:48 -05:00
parent bf1e4e754f
commit 7596a5f382
47 changed files with 6522 additions and 3 deletions

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>Life Dashboard</title>
</head>
<body class="min-h-screen bg-background text-foreground antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4154
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
frontend/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "life-dashboard",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.1",
"recharts": "^2.15.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.6.0",
"class-variance-authority": "^0.7.1",
"lucide-react": "^0.468.0",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

55
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,55 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { isAuthenticated } from "@/api/client";
import { Shell } from "@/components/layout/Shell";
import Login from "@/pages/Login";
import Dashboard from "@/pages/Dashboard";
import Weight from "@/pages/Weight";
import Workouts from "@/pages/Workouts";
import Finances from "@/pages/Finances";
function ProtectedRoute({ children }: { children: React.ReactNode }) {
if (!isAuthenticated()) {
return <Navigate to="/login" replace />;
}
return <Shell>{children}</Shell>;
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/weight"
element={
<ProtectedRoute>
<Weight />
</ProtectedRoute>
}
/>
<Route
path="/workouts"
element={
<ProtectedRoute>
<Workouts />
</ProtectedRoute>
}
/>
<Route
path="/finances"
element={
<ProtectedRoute>
<Finances />
</ProtectedRoute>
}
/>
</Routes>
);
}

View File

@@ -0,0 +1,56 @@
const API_BASE = "/api";
function getToken(): string | null {
return localStorage.getItem("token");
}
export function setToken(token: string) {
localStorage.setItem("token", token);
}
export function clearToken() {
localStorage.removeItem("token");
}
export function isAuthenticated(): boolean {
return !!getToken();
}
async function request<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (res.status === 401) {
clearToken();
window.location.href = "/login";
throw new Error("Unauthorized");
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Request failed: ${res.status}`);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, data: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(data) }),
put: <T>(path: string, data: unknown) =>
request<T>(path, { method: "PUT", body: JSON.stringify(data) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
};

50
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,50 @@
export interface WeightEntry {
id: number;
date: string;
weight_lbs: number;
body_fat_pct: number | null;
notes: string | null;
}
export interface Exercise {
id: number;
name: string;
category: string;
}
export interface WorkoutSet {
id: number;
exercise_id: number;
set_number: number;
reps: number | null;
weight_lbs: number | null;
duration_min: number | null;
}
export interface Workout {
id: number;
date: string;
name: string | null;
notes: string | null;
sets: WorkoutSet[];
}
export interface Transaction {
id: number;
date: string;
amount: number;
category: string;
description: string | null;
}
export interface Snapshot {
id: number;
date: string;
net_worth: number;
notes: string | null;
}
export interface CategorySummary {
category: string;
total: number;
}

View File

@@ -0,0 +1,67 @@
import { Link, useLocation, useNavigate } from "react-router-dom";
import {
LayoutDashboard,
Scale,
Dumbbell,
DollarSign,
LogOut,
} from "lucide-react";
import { clearToken } from "@/api/client";
import { cn } from "@/lib/utils";
const navItems = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
{ to: "/weight", label: "Weight", icon: Scale },
{ to: "/workouts", label: "Workouts", icon: Dumbbell },
{ to: "/finances", label: "Finances", icon: DollarSign },
];
export function Shell({ children }: { children: React.ReactNode }) {
const location = useLocation();
const navigate = useNavigate();
const logout = () => {
clearToken();
navigate("/login");
};
return (
<div className="flex h-screen">
{/* Sidebar */}
<aside className="w-56 border-r bg-card flex flex-col">
<div className="p-4 border-b">
<h1 className="text-lg font-bold">Life Dashboard</h1>
</div>
<nav className="flex-1 p-2 space-y-1">
{navItems.map((item) => (
<Link
key={item.to}
to={item.to}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors",
location.pathname === item.to
? "bg-primary text-primary-foreground"
: "hover:bg-accent"
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
</nav>
<div className="p-2 border-t">
<button
onClick={logout}
className="flex items-center gap-3 px-3 py-2 rounded-md text-sm w-full hover:bg-accent transition-colors"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
)
);
CardTitle.displayName = "CardTitle";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
);
CardContent.displayName = "CardContent";
export { Card, CardHeader, CardTitle, CardContent };

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,20 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

55
frontend/src/index.css Normal file
View File

@@ -0,0 +1,55 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71% 4%;
--card: 0 0% 100%;
--card-foreground: 224 71% 4%;
--primary: 220 90% 56%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14% 96%;
--secondary-foreground: 220 9% 46%;
--muted: 220 14% 96%;
--muted-foreground: 220 9% 46%;
--accent: 220 14% 96%;
--accent-foreground: 224 71% 4%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 220 90% 56%;
--radius: 0.5rem;
}
.dark {
--background: 224 71% 4%;
--foreground: 210 20% 98%;
--card: 224 71% 4%;
--card-foreground: 210 20% 98%;
--primary: 217 91% 60%;
--primary-foreground: 224 71% 4%;
--secondary: 215 28% 17%;
--secondary-foreground: 210 20% 98%;
--muted: 215 28% 17%;
--muted-foreground: 218 11% 65%;
--accent: 215 28% 17%;
--accent-foreground: 210 20% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 20% 98%;
--border: 215 28% 17%;
--input: 215 28% 17%;
--ring: 216 34% 17%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

13
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import {
LineChart,
Line,
ResponsiveContainer,
YAxis,
} from "recharts";
import { Scale, Dumbbell, DollarSign } from "lucide-react";
import { api } from "@/api/client";
import type { WeightEntry, Workout, CategorySummary } from "@/api/types";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
function Sparkline({ data, dataKey }: { data: unknown[]; dataKey: string }) {
return (
<ResponsiveContainer width="100%" height={40}>
<LineChart data={data}>
<YAxis domain={["dataMin - 1", "dataMax + 1"]} hide />
<Line
type="monotone"
dataKey={dataKey}
stroke="hsl(220 90% 56%)"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
);
}
export default function Dashboard() {
const [weights, setWeights] = useState<WeightEntry[]>([]);
const [workouts, setWorkouts] = useState<Workout[]>([]);
const [spending, setSpending] = useState<CategorySummary[]>([]);
useEffect(() => {
api.get<WeightEntry[]>("/weight?from=" + thirtyDaysAgo()).then(setWeights);
api.get<Workout[]>("/workouts?from=" + sevenDaysAgo()).then(setWorkouts);
api.get<CategorySummary[]>("/finances/summary?period=month").then(setSpending);
}, []);
const latestWeight = weights.length > 0 ? weights[0] : null;
const weightChartData = [...weights].reverse();
const workoutCount = workouts.length;
const totalSpending = spending.reduce((sum, c) => sum + Math.abs(c.total), 0);
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold">Dashboard</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Weight card */}
<Link to="/weight">
<Card className="hover:border-primary/50 transition-colors">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Weight
</CardTitle>
<Scale className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{latestWeight ? `${latestWeight.weight_lbs} lbs` : "—"}
</div>
{weightChartData.length > 1 && (
<Sparkline data={weightChartData} dataKey="weight_lbs" />
)}
</CardContent>
</Card>
</Link>
{/* Workouts card */}
<Link to="/workouts">
<Card className="hover:border-primary/50 transition-colors">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Workouts (7d)
</CardTitle>
<Dumbbell className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{workoutCount}</div>
<p className="text-xs text-muted-foreground mt-1">this week</p>
</CardContent>
</Card>
</Link>
{/* Spending card */}
<Link to="/finances">
<Card className="hover:border-primary/50 transition-colors">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Monthly Spending
</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
${totalSpending.toFixed(2)}
</div>
<p className="text-xs text-muted-foreground mt-1">
{spending.length} categories
</p>
</CardContent>
</Card>
</Link>
</div>
</div>
);
}
function thirtyDaysAgo() {
const d = new Date();
d.setDate(d.getDate() - 30);
return d.toISOString().slice(0, 10);
}
function sevenDaysAgo() {
const d = new Date();
d.setDate(d.getDate() - 7);
return d.toISOString().slice(0, 10);
}

View File

@@ -0,0 +1,278 @@
import { useEffect, useState } from "react";
import {
PieChart,
Pie,
Cell,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
} from "recharts";
import { Trash2 } from "lucide-react";
import { api } from "@/api/client";
import type { Transaction, Snapshot, CategorySummary } from "@/api/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
const COLORS = [
"hsl(220 90% 56%)",
"hsl(150 60% 50%)",
"hsl(40 90% 56%)",
"hsl(0 84% 60%)",
"hsl(270 60% 56%)",
"hsl(180 60% 45%)",
"hsl(30 80% 55%)",
"hsl(320 60% 50%)",
];
export default function Finances() {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [snapshots, setSnapshots] = useState<Snapshot[]>([]);
const [summary, setSummary] = useState<CategorySummary[]>([]);
const [tab, setTab] = useState<"transactions" | "networth">("transactions");
// Transaction form
const [tDate, setTDate] = useState(today());
const [tAmount, setTAmount] = useState("");
const [tCategory, setTCategory] = useState("");
const [tDesc, setTDesc] = useState("");
// Snapshot form
const [sDate, setSDate] = useState(today());
const [sNetWorth, setSNetWorth] = useState("");
const [sNotes, setSNotes] = useState("");
const load = () => {
api.get<Transaction[]>("/finances/transactions").then(setTransactions);
api.get<Snapshot[]>("/finances/snapshots").then(setSnapshots);
api.get<CategorySummary[]>("/finances/summary?period=month").then(setSummary);
};
useEffect(() => {
load();
}, []);
const handleAddTransaction = async (e: React.FormEvent) => {
e.preventDefault();
await api.post("/finances/transactions", {
date: tDate,
amount: parseFloat(tAmount),
category: tCategory,
description: tDesc || null,
});
setTAmount("");
setTCategory("");
setTDesc("");
load();
};
const handleDeleteTransaction = async (id: number) => {
await api.delete(`/finances/transactions/${id}`);
load();
};
const handleAddSnapshot = async (e: React.FormEvent) => {
e.preventDefault();
await api.post("/finances/snapshots", {
date: sDate,
net_worth: parseFloat(sNetWorth),
notes: sNotes || null,
});
setSNetWorth("");
setSNotes("");
load();
};
const pieData = summary.map((s) => ({
name: s.category,
value: Math.abs(s.total),
}));
const nwData = [...snapshots].reverse();
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold">Finances</h2>
{/* Tab switcher */}
<div className="flex gap-2">
<Button
variant={tab === "transactions" ? "default" : "outline"}
size="sm"
onClick={() => setTab("transactions")}
>
Transactions
</Button>
<Button
variant={tab === "networth" ? "default" : "outline"}
size="sm"
onClick={() => setTab("networth")}
>
Net Worth
</Button>
</div>
{tab === "transactions" && (
<>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Transaction form */}
<Card>
<CardHeader>
<CardTitle>Add Transaction</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleAddTransaction} className="space-y-4">
<div className="space-y-2">
<Label>Date</Label>
<Input type="date" value={tDate} onChange={(e) => setTDate(e.target.value)} required />
</div>
<div className="space-y-2">
<Label>Amount (negative = expense)</Label>
<Input type="number" step="0.01" value={tAmount} onChange={(e) => setTAmount(e.target.value)} required />
</div>
<div className="space-y-2">
<Label>Category</Label>
<Input value={tCategory} onChange={(e) => setTCategory(e.target.value)} placeholder="e.g. food, rent" required />
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input value={tDesc} onChange={(e) => setTDesc(e.target.value)} />
</div>
<Button type="submit" className="w-full">Save</Button>
</form>
</CardContent>
</Card>
{/* Category pie chart */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Monthly Spending by Category</CardTitle>
</CardHeader>
<CardContent>
{pieData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
outerRadius={100}
dataKey="value"
label={({ name, value }) => `${name}: $${value.toFixed(0)}`}
>
{pieData.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(v: number) => `$${v.toFixed(2)}`} />
</PieChart>
</ResponsiveContainer>
) : (
<p className="text-muted-foreground text-sm">No spending data this month.</p>
)}
</CardContent>
</Card>
</div>
{/* Transactions table */}
<Card>
<CardHeader>
<CardTitle>Recent Transactions</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-2">Date</th>
<th className="text-left py-2 px-2">Amount</th>
<th className="text-left py-2 px-2">Category</th>
<th className="text-left py-2 px-2">Description</th>
<th className="py-2 px-2"></th>
</tr>
</thead>
<tbody>
{transactions.map((t) => (
<tr key={t.id} className="border-b">
<td className="py-2 px-2">{t.date}</td>
<td className={`py-2 px-2 font-medium ${t.amount >= 0 ? "text-green-600" : "text-red-500"}`}>
{t.amount >= 0 ? "+" : ""}${Math.abs(t.amount).toFixed(2)}
</td>
<td className="py-2 px-2">{t.category}</td>
<td className="py-2 px-2 text-muted-foreground">{t.description || "—"}</td>
<td className="py-2 px-2">
<Button variant="ghost" size="icon" onClick={() => handleDeleteTransaction(t.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</>
)}
{tab === "networth" && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Snapshot form */}
<Card>
<CardHeader>
<CardTitle>Log Net Worth</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleAddSnapshot} className="space-y-4">
<div className="space-y-2">
<Label>Date</Label>
<Input type="date" value={sDate} onChange={(e) => setSDate(e.target.value)} required />
</div>
<div className="space-y-2">
<Label>Net Worth</Label>
<Input type="number" step="0.01" value={sNetWorth} onChange={(e) => setSNetWorth(e.target.value)} required />
</div>
<div className="space-y-2">
<Label>Notes</Label>
<Input value={sNotes} onChange={(e) => setSNotes(e.target.value)} />
</div>
<Button type="submit" className="w-full">Save</Button>
</form>
</CardContent>
</Card>
{/* Net worth chart */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Net Worth Over Time</CardTitle>
</CardHeader>
<CardContent>
{nwData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={nwData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="date" className="text-xs" />
<YAxis className="text-xs" />
<Tooltip formatter={(v: number) => `$${v.toLocaleString()}`} />
<Line type="monotone" dataKey="net_worth" stroke="hsl(150 60% 50%)" strokeWidth={2} name="Net Worth" />
</LineChart>
</ResponsiveContainer>
) : (
<p className="text-muted-foreground text-sm">No snapshots yet.</p>
)}
</CardContent>
</Card>
</div>
)}
</div>
);
}
function today() {
return new Date().toISOString().slice(0, 10);
}

View File

@@ -0,0 +1,66 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api, setToken } from "@/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
export default function Login() {
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
const res = await api.post<{ access_token: string }>("/auth/login", {
username,
password,
});
setToken(res.access_token);
navigate("/");
} catch {
setError("Invalid credentials");
}
};
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle className="text-2xl text-center">Life Dashboard</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full">
Sign in
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,197 @@
import { useEffect, useState } from "react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { Trash2 } from "lucide-react";
import { api } from "@/api/client";
import type { WeightEntry } from "@/api/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
export default function Weight() {
const [entries, setEntries] = useState<WeightEntry[]>([]);
const [date, setDate] = useState(today());
const [weightLbs, setWeightLbs] = useState("");
const [bodyFatPct, setBodyFatPct] = useState("");
const [notes, setNotes] = useState("");
const load = () => api.get<WeightEntry[]>("/weight").then(setEntries);
useEffect(() => {
load();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await api.post("/weight", {
date,
weight_lbs: parseFloat(weightLbs),
body_fat_pct: bodyFatPct ? parseFloat(bodyFatPct) : null,
notes: notes || null,
});
setWeightLbs("");
setBodyFatPct("");
setNotes("");
load();
};
const handleDelete = async (id: number) => {
await api.delete(`/weight/${id}`);
load();
};
const chartData = [...entries].reverse();
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold">Weight Tracker</h2>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Entry form */}
<Card>
<CardHeader>
<CardTitle>Log Weight</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Date</Label>
<Input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>Weight (lbs)</Label>
<Input
type="number"
step="0.1"
value={weightLbs}
onChange={(e) => setWeightLbs(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>Body Fat %</Label>
<Input
type="number"
step="0.1"
value={bodyFatPct}
onChange={(e) => setBodyFatPct(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Notes</Label>
<Input
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<Button type="submit" className="w-full">
Save
</Button>
</form>
</CardContent>
</Card>
{/* Chart */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Weight Over Time</CardTitle>
</CardHeader>
<CardContent>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="date" className="text-xs" />
<YAxis domain={["dataMin - 2", "dataMax + 2"]} className="text-xs" />
<Tooltip />
<Line
type="monotone"
dataKey="weight_lbs"
stroke="hsl(220 90% 56%)"
strokeWidth={2}
name="Weight (lbs)"
/>
{chartData.some((d) => d.body_fat_pct) && (
<Line
type="monotone"
dataKey="body_fat_pct"
stroke="hsl(150 60% 50%)"
strokeWidth={2}
name="Body Fat %"
/>
)}
</LineChart>
</ResponsiveContainer>
) : (
<p className="text-muted-foreground text-sm">
No data yet. Log your first entry!
</p>
)}
</CardContent>
</Card>
</div>
{/* History table */}
<Card>
<CardHeader>
<CardTitle>History</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-2">Date</th>
<th className="text-left py-2 px-2">Weight</th>
<th className="text-left py-2 px-2">Body Fat</th>
<th className="text-left py-2 px-2">Notes</th>
<th className="py-2 px-2"></th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.id} className="border-b">
<td className="py-2 px-2">{e.date}</td>
<td className="py-2 px-2">{e.weight_lbs} lbs</td>
<td className="py-2 px-2">
{e.body_fat_pct ? `${e.body_fat_pct}%` : "—"}
</td>
<td className="py-2 px-2 text-muted-foreground">
{e.notes || "—"}
</td>
<td className="py-2 px-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(e.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
);
}
function today() {
return new Date().toISOString().slice(0, 10);
}

View File

@@ -0,0 +1,265 @@
import { useEffect, useState } from "react";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { Plus, Trash2 } from "lucide-react";
import { api } from "@/api/client";
import type { Exercise, Workout } from "@/api/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
interface SetForm {
exercise_id: number;
set_number: number;
reps: string;
weight_lbs: string;
duration_min: string;
}
export default function Workouts() {
const [workouts, setWorkouts] = useState<Workout[]>([]);
const [exercises, setExercises] = useState<Exercise[]>([]);
const [date, setDate] = useState(today());
const [name, setName] = useState("");
const [wNotes, setWNotes] = useState("");
const [sets, setSets] = useState<SetForm[]>([]);
const [newExName, setNewExName] = useState("");
const [newExCategory, setNewExCategory] = useState("");
const [showNewExercise, setShowNewExercise] = useState(false);
const load = () => {
api.get<Workout[]>("/workouts").then(setWorkouts);
api.get<Exercise[]>("/exercises").then(setExercises);
};
useEffect(() => {
load();
}, []);
const addSet = () => {
setSets([
...sets,
{
exercise_id: exercises[0]?.id || 0,
set_number: sets.length + 1,
reps: "",
weight_lbs: "",
duration_min: "",
},
]);
};
const updateSet = (i: number, field: keyof SetForm, value: string | number) => {
const next = [...sets];
(next[i] as any)[field] = value;
next[i].set_number = i + 1;
setSets(next);
};
const removeSet = (i: number) => {
setSets(sets.filter((_, idx) => idx !== i).map((s, idx) => ({ ...s, set_number: idx + 1 })));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await api.post("/workouts", {
date,
name: name || null,
notes: wNotes || null,
sets: sets.map((s) => ({
exercise_id: s.exercise_id,
set_number: s.set_number,
reps: s.reps ? parseInt(s.reps) : null,
weight_lbs: s.weight_lbs ? parseFloat(s.weight_lbs) : null,
duration_min: s.duration_min ? parseFloat(s.duration_min) : null,
})),
});
setName("");
setWNotes("");
setSets([]);
load();
};
const handleAddExercise = async (e: React.FormEvent) => {
e.preventDefault();
await api.post("/exercises", { name: newExName, category: newExCategory });
setNewExName("");
setNewExCategory("");
setShowNewExercise(false);
load();
};
const handleDeleteWorkout = async (id: number) => {
await api.delete(`/workouts/${id}`);
load();
};
const exerciseMap = Object.fromEntries(exercises.map((e) => [e.id, e.name]));
// Volume chart: total sets per day
const volumeData = [...workouts]
.reverse()
.map((w) => ({ date: w.date, sets: w.sets.length }));
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold">Workouts</h2>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Workout form */}
<Card>
<CardHeader>
<CardTitle>Log Workout</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Date</Label>
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} required />
</div>
<div className="space-y-2">
<Label>Name (optional)</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Push Day" />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Sets</Label>
<Button type="button" variant="ghost" size="sm" onClick={addSet} disabled={exercises.length === 0}>
<Plus className="h-4 w-4 mr-1" /> Add Set
</Button>
</div>
{exercises.length === 0 && (
<p className="text-xs text-muted-foreground">Create an exercise first</p>
)}
{sets.map((s, i) => (
<div key={i} className="flex gap-2 items-end">
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
value={s.exercise_id}
onChange={(e) => updateSet(i, "exercise_id", parseInt(e.target.value))}
>
{exercises.map((ex) => (
<option key={ex.id} value={ex.id}>{ex.name}</option>
))}
</select>
<Input className="w-16" placeholder="Reps" value={s.reps} onChange={(e) => updateSet(i, "reps", e.target.value)} />
<Input className="w-20" placeholder="Wt (lbs)" value={s.weight_lbs} onChange={(e) => updateSet(i, "weight_lbs", e.target.value)} />
<Button type="button" variant="ghost" size="icon" onClick={() => removeSet(i)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
<div className="space-y-2">
<Label>Notes</Label>
<Input value={wNotes} onChange={(e) => setWNotes(e.target.value)} />
</div>
<Button type="submit" className="w-full">Save Workout</Button>
</form>
<div className="mt-4 pt-4 border-t">
{!showNewExercise ? (
<Button variant="outline" size="sm" onClick={() => setShowNewExercise(true)} className="w-full">
<Plus className="h-4 w-4 mr-1" /> New Exercise
</Button>
) : (
<form onSubmit={handleAddExercise} className="space-y-2">
<Input placeholder="Exercise name" value={newExName} onChange={(e) => setNewExName(e.target.value)} required />
<Input placeholder="Category (e.g. chest)" value={newExCategory} onChange={(e) => setNewExCategory(e.target.value)} required />
<div className="flex gap-2">
<Button type="submit" size="sm">Add</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setShowNewExercise(false)}>Cancel</Button>
</div>
</form>
)}
</div>
</CardContent>
</Card>
{/* Volume chart */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Sets per Workout</CardTitle>
</CardHeader>
<CardContent>
{volumeData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={volumeData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="date" className="text-xs" />
<YAxis className="text-xs" />
<Tooltip />
<Bar dataKey="sets" fill="hsl(220 90% 56%)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-muted-foreground text-sm">No workouts logged yet.</p>
)}
</CardContent>
</Card>
</div>
{/* History */}
<Card>
<CardHeader>
<CardTitle>History</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{workouts.map((w) => (
<div key={w.id} className="border rounded-md p-4">
<div className="flex items-center justify-between mb-2">
<div>
<span className="font-medium">{w.date}</span>
{w.name && <span className="ml-2 text-muted-foreground"> {w.name}</span>}
</div>
<Button variant="ghost" size="icon" onClick={() => handleDeleteWorkout(w.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{w.sets.length > 0 && (
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground">
<th className="text-left py-1">#</th>
<th className="text-left py-1">Exercise</th>
<th className="text-left py-1">Reps</th>
<th className="text-left py-1">Weight</th>
</tr>
</thead>
<tbody>
{w.sets.map((s) => (
<tr key={s.id}>
<td className="py-1">{s.set_number}</td>
<td className="py-1">{exerciseMap[s.exercise_id] || `#${s.exercise_id}`}</td>
<td className="py-1">{s.reps ?? (s.duration_min ? `${s.duration_min} min` : "—")}</td>
<td className="py-1">{s.weight_lbs ? `${s.weight_lbs} lbs` : "—"}</td>
</tr>
))}
</tbody>
</table>
)}
{w.notes && <p className="text-xs text-muted-foreground mt-2">{w.notes}</p>}
</div>
))}
{workouts.length === 0 && (
<p className="text-muted-foreground text-sm">No workouts yet.</p>
)}
</CardContent>
</Card>
</div>
);
}
function today() {
return new Date().toISOString().slice(0, 10);
}

View File

@@ -0,0 +1,45 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
};

24
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
},
"baseUrl": "."
},
"include": ["src"]
}

View File

@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/types.ts","./src/components/layout/shell.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/lib/utils.ts","./src/pages/dashboard.tsx","./src/pages/finances.tsx","./src/pages/login.tsx","./src/pages/weight.tsx","./src/pages/workouts.tsx"],"version":"5.9.3"}

17
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
"/api": "http://localhost:8000",
},
},
});