Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions src/plays/expense-tracker-pro/ExpenseTrackerPro.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import PlayHeader from 'common/playlists/PlayHeader';
import useTransactions from './hooks/useTransaction';
import SummaryCards from './components/SummaryCards';
import TransactionForm from './components/TransactionForm';
import TransactionList from './components/TransactionList';
import Charts from './components/Charts';

function ExpenseTrackerPro(props) {
const {
filtered,
summary,
chartData,
monthlyData,
availableMonths,
filterType,
setFilterType,
filterMonth,
setFilterMonth,
addTransaction,
deleteTransaction,
clearAll
} = useTransactions();

return (
<div className="play-details">
<PlayHeader play={props} />
<div className="grow p-4 overflow-y-auto bg-gray-50">
<div className="max-w-4xl mx-auto">
<SummaryCards summary={summary} />
<Charts chartData={chartData} monthlyData={monthlyData} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<TransactionForm onAdd={addTransaction} />
<TransactionList
availableMonths={availableMonths}
filterMonth={filterMonth}
filterType={filterType}
setFilterMonth={setFilterMonth}
setFilterType={setFilterType}
transactions={filtered}
onDelete={deleteTransaction}
/>
</div>
{filtered.length > 0 && (
<div className="text-center mt-4">
<button
className="text-xs text-red-400 hover:text-red-600 transition-colors"
onClick={clearAll}
>
Clear All Transactions
</button>
</div>
)}
</div>
</div>
</div>
);
}

export default ExpenseTrackerPro;
20 changes: 20 additions & 0 deletions src/plays/expense-tracker-pro/Readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
@'
## Expense Tracker Pro

A full-featured expense tracker built with React that helps you manage income and expenses.

### Features
- Track income and expenses with categories
- Summary cards showing balance, total income, total expenses
- Visual charts — expenses by category (bar) and monthly overview
- Filter by type (income/expense) and month
- localStorage persistence — data saved between sessions
- Delete individual transactions or clear all

### Concepts Used
- useState, useEffect, useMemo
- Custom hook (useTransactions)
- localStorage for data persistence
- CSS-based charts (no external chart library)
- Tailwind CSS for styling
'@ | Out-File -FilePath src/plays/expense-tracker-pro/Readme.md -Encoding utf8
95 changes: 95 additions & 0 deletions src/plays/expense-tracker-pro/components/Charts.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { getCategoryMeta, CHART_COLORS } from '../constants/categories';
import { formatCurrency, getMonthLabel } from '../utils/format';

const ExpenseChart = ({ data }) => {
if (!data.length)
return (
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
No expense data yet
</div>
);
const total = data.reduce((sum, d) => sum + d.amount, 0);

return (
<div className="space-y-2">
{data.map((item, i) => {
const meta = getCategoryMeta(item.category);
const pct = total ? (item.amount / total) * 100 : 0;

return (
<div key={item.category}>
<div className="flex justify-between text-xs text-gray-600 mb-0.5">
<span>
{meta.icon} {meta.label}
</span>
<span>
{formatCurrency(item.amount)} ({pct.toFixed(0)}%)
</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-2">
<div
className="h-2 rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: CHART_COLORS[i % CHART_COLORS.length] }}
/>
</div>
</div>
);
})}
</div>
);
};

const MonthlyChart = ({ data }) => {
if (!data.length)
return (
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
No monthly data yet
</div>
);
const max = Math.max(...data.flatMap((d) => [d.income, d.expense]));

return (
<div className="flex items-end gap-3 h-32">
{data.map((item) => (
<div className="flex-1 flex flex-col items-center gap-1" key={item.month}>
<div className="w-full flex gap-1 items-end" style={{ height: '96px' }}>
<div
className="flex-1 bg-green-400 rounded-t transition-all duration-500"
style={{ height: `${max ? (item.income / max) * 96 : 0}px` }}
/>
<div
className="flex-1 bg-orange-400 rounded-t transition-all duration-500"
style={{ height: `${max ? (item.expense / max) * 96 : 0}px` }}
/>
</div>
<p className="text-xs text-gray-400 truncate w-full text-center">
{getMonthLabel(item.month).split(' ')[0]}
</p>
</div>
))}
</div>
);
};

const Charts = ({ chartData, monthlyData }) => (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h3 className="font-bold text-gray-700 text-sm mb-3">Expenses by Category</h3>
<ExpenseChart data={chartData} />
</div>
<div className="bg-white border border-gray-200 rounded-xl p-4">
<h3 className="font-bold text-gray-700 text-sm mb-3">Monthly Overview</h3>
<div className="flex gap-3 text-xs text-gray-500 mb-2">
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-green-400 rounded-sm inline-block" /> Income
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-orange-400 rounded-sm inline-block" /> Expense
</span>
</div>
<MonthlyChart data={monthlyData} />
</div>
</div>
);

export default Charts;
28 changes: 28 additions & 0 deletions src/plays/expense-tracker-pro/components/SummaryCards.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { formatCurrency } from '../utils/format';

const SummaryCards = ({ summary }) => {
const { income, expense, balance } = summary;

return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
<div
className={`rounded-xl p-4 border ${balance >= 0 ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}
>
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Balance</p>
<p className={`text-2xl font-bold ${balance >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatCurrency(balance)}
</p>
</div>
<div className="rounded-xl p-4 border bg-blue-50 border-blue-200">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Income</p>
<p className="text-2xl font-bold text-blue-600">{formatCurrency(income)}</p>
</div>
<div className="rounded-xl p-4 border bg-orange-50 border-orange-200">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Expenses</p>
<p className="text-2xl font-bold text-orange-600">{formatCurrency(expense)}</p>
</div>
</div>
);
};

export default SummaryCards;
129 changes: 129 additions & 0 deletions src/plays/expense-tracker-pro/components/TransactionForm.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { useState } from 'react';
import { CATEGORIES } from '../constants/categories';

const defaultForm = {
type: 'expense',
amount: '',
category: '',
description: '',
date: new Date().toISOString().split('T')[0]
};

const TransactionForm = ({ onAdd }) => {
const [form, setForm] = useState(defaultForm);
const [error, setError] = useState('');

const handleChange = (e) => {
const { name, value } = e.target;
setForm((prev) => ({
...prev,
[name]: value,
...(name === 'type' ? { category: '' } : {})
}));
};

const handleSubmit = (e) => {
e.preventDefault();
if (!form.amount || isNaN(form.amount) || parseFloat(form.amount) <= 0)
return setError('Please enter a valid amount.');
if (!form.category) return setError('Please select a category.');
if (!form.description.trim()) return setError('Please enter a description.');
setError('');
onAdd(form);
setForm({ ...defaultForm, type: form.type });
};

const currentCategories = CATEGORIES[form.type];
const inputClass =
'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:border-blue-400 transition-colors';

return (
<div className="bg-white border border-gray-200 rounded-xl p-4 mb-4">
<h2 className="font-bold text-gray-700 mb-3">Add Transaction</h2>
<div className="flex rounded-lg overflow-hidden border border-gray-200 mb-3">
{['expense', 'income'].map((type) => (
<button
className={`flex-1 py-2 text-sm font-semibold capitalize transition-colors ${
form.type === type
? type === 'expense'
? 'bg-red-500 text-white'
: 'bg-green-500 text-white'
: 'bg-gray-50 text-gray-500'
}`}
key={type}
type="button"
onClick={() => setForm({ ...defaultForm, type })}
>
{type === 'expense' ? 'Expense' : 'Income'}
</button>
))}
</div>
<form className="space-y-3" onSubmit={handleSubmit}>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-500 mb-1 block">Amount</label>
<input
className={inputClass}
min="0"
name="amount"
placeholder="0.00"
step="0.01"
type="number"
value={form.amount}
onChange={handleChange}
/>
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block">Date</label>
<input
className={inputClass}
name="date"
type="date"
value={form.date}
onChange={handleChange}
/>
</div>
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block">Category</label>
<div className="flex flex-wrap gap-1.5">
{currentCategories.map((cat) => (
<button
className={`px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors ${
form.category === cat.id
? 'bg-blue-500 text-white border-blue-500'
: 'bg-gray-50 text-gray-600 border-gray-200 hover:border-blue-300'
}`}
key={cat.id}
type="button"
onClick={() => setForm((p) => ({ ...p, category: cat.id }))}
>
{cat.icon} {cat.label}
</button>
))}
</div>
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block">Description</label>
<input
className={inputClass}
name="description"
placeholder="What was this for?"
type="text"
value={form.description}
onChange={handleChange}
/>
</div>
{error && <p className="text-red-500 text-xs">{error}</p>}
<button
className="w-full bg-blue-500 hover:bg-blue-600 text-white font-bold py-2.5 rounded-lg transition-colors text-sm"
type="submit"
>
Add Transaction
</button>
</form>
</div>
);
};

export default TransactionForm;
Loading
Loading