Intermediate
Expense Tracker
Log expenses by category with running totals.
Complete Code
"use client";
import { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Plus, Trash2, DollarSign } from "lucide-react";
interface Expense {
id: string;
description: string;
amount: number;
category: string;
date: string;
}
const CATEGORIES = [
"Food & Dining",
"Transportation",
"Utilities",
"Entertainment",
"Shopping",
"Healthcare",
"Other",
];
const STORAGE_KEY = "work-construct-expense-tracker";
export default function App() {
const [expenses, setExpenses] = useState<Expense[]>(() => {
if (typeof window === "undefined") return [];
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : [];
});
const [description, setDescription] = useState("");
const [amount, setAmount] = useState("");
const [category, setCategory] = useState("");
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(expenses));
}, [expenses]);
const addExpense = () => {
if (!description.trim() || !amount || !category) return;
setExpenses([
...expenses,
{
id: crypto.randomUUID(),
description: description.trim(),
amount: parseFloat(amount),
category,
date: new Date().toISOString().split("T")[0],
},
]);
setDescription("");
setAmount("");
setCategory("");
};
const deleteExpense = (id: string) => {
setExpenses(expenses.filter((e) => e.id !== id));
};
const total = useMemo(
() => expenses.reduce((sum, e) => sum + e.amount, 0),
[expenses]
);
const byCategory = useMemo(() => {
const grouped: Record<string, number> = {};
expenses.forEach((e) => {
grouped[e.category] = (grouped[e.category] || 0) + e.amount;
});
return grouped;
}, [expenses]);
return (
<div className="min-h-screen bg-background p-4 sm:p-6">
<div className="mx-auto max-w-2xl space-y-6">
{/* Header Stats */}
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardDescription>Total Expenses</CardDescription>
<CardTitle className="text-3xl font-mono">
${total.toFixed(2)}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Transactions</CardDescription>
<CardTitle className="text-3xl font-mono">
{expenses.length}
</CardTitle>
</CardHeader>
</Card>
</div>
{/* Add Expense Form */}
<Card>
<CardHeader>
<CardTitle>Add Expense</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
placeholder="Coffee, Uber, etc."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="amount">Amount</Label>
<Input
id="amount"
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
</div>
</div>
<div className="flex gap-4">
<div className="flex-1 space-y-2">
<Label>Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button onClick={addExpense}>
<Plus className="mr-2 h-4 w-4" />
Add
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Category Breakdown */}
{Object.keys(byCategory).length > 0 && (
<Card>
<CardHeader>
<CardTitle>By Category</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{Object.entries(byCategory)
.sort((a, b) => b[1] - a[1])
.map(([cat, amt]) => (
<div
key={cat}
className="flex items-center justify-between"
>
<span className="text-sm">{cat}</span>
<span className="font-mono text-sm">
${amt.toFixed(2)}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Expense List */}
<Card>
<CardHeader>
<CardTitle>Recent Expenses</CardTitle>
</CardHeader>
<CardContent>
{expenses.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No expenses yet. Add one above!
</p>
) : (
<div className="space-y-2">
{[...expenses].reverse().map((expense) => (
<div
key={expense.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex-1">
<p className="font-medium">{expense.description}</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="secondary" className="text-xs">
{expense.category}
</Badge>
<span>{expense.date}</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="font-mono font-medium">
${expense.amount.toFixed(2)}
</span>
<Button
variant="ghost"
size="icon"
onClick={() => deleteExpense(expense.id)}
aria-label="Delete expense"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}