Skip to content

Commit 309362d

Browse files
feat: add expense tracker pro play
1 parent a26e419 commit 309362d

9 files changed

Lines changed: 646 additions & 0 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import PlayHeader from 'common/playlists/PlayHeader';
2+
import useTransactions from './hooks/useTransaction';
3+
import SummaryCards from './components/SummaryCards';
4+
import TransactionForm from './components/TransactionForm';
5+
import TransactionList from './components/TransactionList';
6+
import Charts from './components/Charts';
7+
8+
function ExpenseTrackerPro(props) {
9+
const {
10+
filtered,
11+
summary,
12+
chartData,
13+
monthlyData,
14+
availableMonths,
15+
filterType,
16+
setFilterType,
17+
filterMonth,
18+
setFilterMonth,
19+
addTransaction,
20+
deleteTransaction,
21+
clearAll
22+
} = useTransactions();
23+
24+
return (
25+
<div className="play-details">
26+
<PlayHeader play={props} />
27+
<div className="grow p-4 overflow-y-auto bg-gray-50">
28+
<div className="max-w-4xl mx-auto">
29+
<SummaryCards summary={summary} />
30+
<Charts chartData={chartData} monthlyData={monthlyData} />
31+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
32+
<TransactionForm onAdd={addTransaction} />
33+
<TransactionList
34+
availableMonths={availableMonths}
35+
filterMonth={filterMonth}
36+
filterType={filterType}
37+
setFilterMonth={setFilterMonth}
38+
setFilterType={setFilterType}
39+
transactions={filtered}
40+
onDelete={deleteTransaction}
41+
/>
42+
</div>
43+
{filtered.length > 0 && (
44+
<div className="text-center mt-4">
45+
<button
46+
className="text-xs text-red-400 hover:text-red-600 transition-colors"
47+
onClick={clearAll}
48+
>
49+
Clear All Transactions
50+
</button>
51+
</div>
52+
)}
53+
</div>
54+
</div>
55+
</div>
56+
);
57+
}
58+
59+
export default ExpenseTrackerPro;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
@'
2+
## Expense Tracker Pro
3+
4+
A full-featured expense tracker built with React that helps you manage income and expenses.
5+
6+
### Features
7+
- Track income and expenses with categories
8+
- Summary cards showing balance, total income, total expenses
9+
- Visual charts — expenses by category (bar) and monthly overview
10+
- Filter by type (income/expense) and month
11+
- localStorage persistence — data saved between sessions
12+
- Delete individual transactions or clear all
13+
14+
### Concepts Used
15+
- useState, useEffect, useMemo
16+
- Custom hook (useTransactions)
17+
- localStorage for data persistence
18+
- CSS-based charts (no external chart library)
19+
- Tailwind CSS for styling
20+
'@ | Out-File -FilePath src/plays/expense-tracker-pro/Readme.md -Encoding utf8
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { getCategoryMeta, CHART_COLORS } from '../constants/categories';
2+
import { formatCurrency, getMonthLabel } from '../utils/format';
3+
4+
const ExpenseChart = ({ data }) => {
5+
if (!data.length)
6+
return (
7+
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
8+
No expense data yet
9+
</div>
10+
);
11+
const total = data.reduce((sum, d) => sum + d.amount, 0);
12+
13+
return (
14+
<div className="space-y-2">
15+
{data.map((item, i) => {
16+
const meta = getCategoryMeta(item.category);
17+
const pct = total ? (item.amount / total) * 100 : 0;
18+
19+
return (
20+
<div key={item.category}>
21+
<div className="flex justify-between text-xs text-gray-600 mb-0.5">
22+
<span>
23+
{meta.icon} {meta.label}
24+
</span>
25+
<span>
26+
{formatCurrency(item.amount)} ({pct.toFixed(0)}%)
27+
</span>
28+
</div>
29+
<div className="w-full bg-gray-100 rounded-full h-2">
30+
<div
31+
className="h-2 rounded-full transition-all duration-500"
32+
style={{ width: `${pct}%`, backgroundColor: CHART_COLORS[i % CHART_COLORS.length] }}
33+
/>
34+
</div>
35+
</div>
36+
);
37+
})}
38+
</div>
39+
);
40+
};
41+
42+
const MonthlyChart = ({ data }) => {
43+
if (!data.length)
44+
return (
45+
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
46+
No monthly data yet
47+
</div>
48+
);
49+
const max = Math.max(...data.flatMap((d) => [d.income, d.expense]));
50+
51+
return (
52+
<div className="flex items-end gap-3 h-32">
53+
{data.map((item) => (
54+
<div className="flex-1 flex flex-col items-center gap-1" key={item.month}>
55+
<div className="w-full flex gap-1 items-end" style={{ height: '96px' }}>
56+
<div
57+
className="flex-1 bg-green-400 rounded-t transition-all duration-500"
58+
style={{ height: `${max ? (item.income / max) * 96 : 0}px` }}
59+
/>
60+
<div
61+
className="flex-1 bg-orange-400 rounded-t transition-all duration-500"
62+
style={{ height: `${max ? (item.expense / max) * 96 : 0}px` }}
63+
/>
64+
</div>
65+
<p className="text-xs text-gray-400 truncate w-full text-center">
66+
{getMonthLabel(item.month).split(' ')[0]}
67+
</p>
68+
</div>
69+
))}
70+
</div>
71+
);
72+
};
73+
74+
const Charts = ({ chartData, monthlyData }) => (
75+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
76+
<div className="bg-white border border-gray-200 rounded-xl p-4">
77+
<h3 className="font-bold text-gray-700 text-sm mb-3">Expenses by Category</h3>
78+
<ExpenseChart data={chartData} />
79+
</div>
80+
<div className="bg-white border border-gray-200 rounded-xl p-4">
81+
<h3 className="font-bold text-gray-700 text-sm mb-3">Monthly Overview</h3>
82+
<div className="flex gap-3 text-xs text-gray-500 mb-2">
83+
<span className="flex items-center gap-1">
84+
<span className="w-3 h-3 bg-green-400 rounded-sm inline-block" /> Income
85+
</span>
86+
<span className="flex items-center gap-1">
87+
<span className="w-3 h-3 bg-orange-400 rounded-sm inline-block" /> Expense
88+
</span>
89+
</div>
90+
<MonthlyChart data={monthlyData} />
91+
</div>
92+
</div>
93+
);
94+
95+
export default Charts;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { formatCurrency } from '../utils/format';
2+
3+
const SummaryCards = ({ summary }) => {
4+
const { income, expense, balance } = summary;
5+
6+
return (
7+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
8+
<div
9+
className={`rounded-xl p-4 border ${balance >= 0 ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}
10+
>
11+
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Balance</p>
12+
<p className={`text-2xl font-bold ${balance >= 0 ? 'text-green-600' : 'text-red-600'}`}>
13+
{formatCurrency(balance)}
14+
</p>
15+
</div>
16+
<div className="rounded-xl p-4 border bg-blue-50 border-blue-200">
17+
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Income</p>
18+
<p className="text-2xl font-bold text-blue-600">{formatCurrency(income)}</p>
19+
</div>
20+
<div className="rounded-xl p-4 border bg-orange-50 border-orange-200">
21+
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Expenses</p>
22+
<p className="text-2xl font-bold text-orange-600">{formatCurrency(expense)}</p>
23+
</div>
24+
</div>
25+
);
26+
};
27+
28+
export default SummaryCards;
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { useState } from 'react';
2+
import { CATEGORIES } from '../constants/categories';
3+
4+
const defaultForm = {
5+
type: 'expense',
6+
amount: '',
7+
category: '',
8+
description: '',
9+
date: new Date().toISOString().split('T')[0]
10+
};
11+
12+
const TransactionForm = ({ onAdd }) => {
13+
const [form, setForm] = useState(defaultForm);
14+
const [error, setError] = useState('');
15+
16+
const handleChange = (e) => {
17+
const { name, value } = e.target;
18+
setForm((prev) => ({
19+
...prev,
20+
[name]: value,
21+
...(name === 'type' ? { category: '' } : {})
22+
}));
23+
};
24+
25+
const handleSubmit = (e) => {
26+
e.preventDefault();
27+
if (!form.amount || isNaN(form.amount) || parseFloat(form.amount) <= 0)
28+
return setError('Please enter a valid amount.');
29+
if (!form.category) return setError('Please select a category.');
30+
if (!form.description.trim()) return setError('Please enter a description.');
31+
setError('');
32+
onAdd(form);
33+
setForm({ ...defaultForm, type: form.type });
34+
};
35+
36+
const currentCategories = CATEGORIES[form.type];
37+
const inputClass =
38+
'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:border-blue-400 transition-colors';
39+
40+
return (
41+
<div className="bg-white border border-gray-200 rounded-xl p-4 mb-4">
42+
<h2 className="font-bold text-gray-700 mb-3">Add Transaction</h2>
43+
<div className="flex rounded-lg overflow-hidden border border-gray-200 mb-3">
44+
{['expense', 'income'].map((type) => (
45+
<button
46+
className={`flex-1 py-2 text-sm font-semibold capitalize transition-colors ${
47+
form.type === type
48+
? type === 'expense'
49+
? 'bg-red-500 text-white'
50+
: 'bg-green-500 text-white'
51+
: 'bg-gray-50 text-gray-500'
52+
}`}
53+
key={type}
54+
type="button"
55+
onClick={() => setForm({ ...defaultForm, type })}
56+
>
57+
{type === 'expense' ? 'Expense' : 'Income'}
58+
</button>
59+
))}
60+
</div>
61+
<form className="space-y-3" onSubmit={handleSubmit}>
62+
<div className="grid grid-cols-2 gap-2">
63+
<div>
64+
<label className="text-xs text-gray-500 mb-1 block">Amount</label>
65+
<input
66+
className={inputClass}
67+
min="0"
68+
name="amount"
69+
placeholder="0.00"
70+
step="0.01"
71+
type="number"
72+
value={form.amount}
73+
onChange={handleChange}
74+
/>
75+
</div>
76+
<div>
77+
<label className="text-xs text-gray-500 mb-1 block">Date</label>
78+
<input
79+
className={inputClass}
80+
name="date"
81+
type="date"
82+
value={form.date}
83+
onChange={handleChange}
84+
/>
85+
</div>
86+
</div>
87+
<div>
88+
<label className="text-xs text-gray-500 mb-1 block">Category</label>
89+
<div className="flex flex-wrap gap-1.5">
90+
{currentCategories.map((cat) => (
91+
<button
92+
className={`px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors ${
93+
form.category === cat.id
94+
? 'bg-blue-500 text-white border-blue-500'
95+
: 'bg-gray-50 text-gray-600 border-gray-200 hover:border-blue-300'
96+
}`}
97+
key={cat.id}
98+
type="button"
99+
onClick={() => setForm((p) => ({ ...p, category: cat.id }))}
100+
>
101+
{cat.icon} {cat.label}
102+
</button>
103+
))}
104+
</div>
105+
</div>
106+
<div>
107+
<label className="text-xs text-gray-500 mb-1 block">Description</label>
108+
<input
109+
className={inputClass}
110+
name="description"
111+
placeholder="What was this for?"
112+
type="text"
113+
value={form.description}
114+
onChange={handleChange}
115+
/>
116+
</div>
117+
{error && <p className="text-red-500 text-xs">{error}</p>}
118+
<button
119+
className="w-full bg-blue-500 hover:bg-blue-600 text-white font-bold py-2.5 rounded-lg transition-colors text-sm"
120+
type="submit"
121+
>
122+
Add Transaction
123+
</button>
124+
</form>
125+
</div>
126+
);
127+
};
128+
129+
export default TransactionForm;

0 commit comments

Comments
 (0)