diff --git a/src/plays/expense-tracker-pro/ExpenseTrackerPro.jsx b/src/plays/expense-tracker-pro/ExpenseTrackerPro.jsx new file mode 100644 index 000000000..84ad12b93 --- /dev/null +++ b/src/plays/expense-tracker-pro/ExpenseTrackerPro.jsx @@ -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 ( +
+ +
+
+ + +
+ + +
+ {filtered.length > 0 && ( +
+ +
+ )} +
+
+
+ ); +} + +export default ExpenseTrackerPro; diff --git a/src/plays/expense-tracker-pro/Readme.md b/src/plays/expense-tracker-pro/Readme.md new file mode 100644 index 000000000..37585355a --- /dev/null +++ b/src/plays/expense-tracker-pro/Readme.md @@ -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 \ No newline at end of file diff --git a/src/plays/expense-tracker-pro/components/Charts.jsx b/src/plays/expense-tracker-pro/components/Charts.jsx new file mode 100644 index 000000000..25d44cbe7 --- /dev/null +++ b/src/plays/expense-tracker-pro/components/Charts.jsx @@ -0,0 +1,95 @@ +import { getCategoryMeta, CHART_COLORS } from '../constants/categories'; +import { formatCurrency, getMonthLabel } from '../utils/format'; + +const ExpenseChart = ({ data }) => { + if (!data.length) + return ( +
+ No expense data yet +
+ ); + const total = data.reduce((sum, d) => sum + d.amount, 0); + + return ( +
+ {data.map((item, i) => { + const meta = getCategoryMeta(item.category); + const pct = total ? (item.amount / total) * 100 : 0; + + return ( +
+
+ + {meta.icon} {meta.label} + + + {formatCurrency(item.amount)} ({pct.toFixed(0)}%) + +
+
+
+
+
+ ); + })} +
+ ); +}; + +const MonthlyChart = ({ data }) => { + if (!data.length) + return ( +
+ No monthly data yet +
+ ); + const max = Math.max(...data.flatMap((d) => [d.income, d.expense])); + + return ( +
+ {data.map((item) => ( +
+
+
+
+
+

+ {getMonthLabel(item.month).split(' ')[0]} +

+
+ ))} +
+ ); +}; + +const Charts = ({ chartData, monthlyData }) => ( +
+
+

Expenses by Category

+ +
+
+

Monthly Overview

+
+ + Income + + + Expense + +
+ +
+
+); + +export default Charts; diff --git a/src/plays/expense-tracker-pro/components/SummaryCards.jsx b/src/plays/expense-tracker-pro/components/SummaryCards.jsx new file mode 100644 index 000000000..07afe5dba --- /dev/null +++ b/src/plays/expense-tracker-pro/components/SummaryCards.jsx @@ -0,0 +1,28 @@ +import { formatCurrency } from '../utils/format'; + +const SummaryCards = ({ summary }) => { + const { income, expense, balance } = summary; + + return ( +
+
= 0 ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`} + > +

Balance

+

= 0 ? 'text-green-600' : 'text-red-600'}`}> + {formatCurrency(balance)} +

+
+
+

Income

+

{formatCurrency(income)}

+
+
+

Expenses

+

{formatCurrency(expense)}

+
+
+ ); +}; + +export default SummaryCards; diff --git a/src/plays/expense-tracker-pro/components/TransactionForm.jsx b/src/plays/expense-tracker-pro/components/TransactionForm.jsx new file mode 100644 index 000000000..1bf801366 --- /dev/null +++ b/src/plays/expense-tracker-pro/components/TransactionForm.jsx @@ -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 ( +
+

Add Transaction

+
+ {['expense', 'income'].map((type) => ( + + ))} +
+
+
+
+ + +
+
+ + +
+
+
+ +
+ {currentCategories.map((cat) => ( + + ))} +
+
+
+ + +
+ {error &&

{error}

} + +
+
+ ); +}; + +export default TransactionForm; diff --git a/src/plays/expense-tracker-pro/components/TransactionList.jsx b/src/plays/expense-tracker-pro/components/TransactionList.jsx new file mode 100644 index 000000000..1a6c9094f --- /dev/null +++ b/src/plays/expense-tracker-pro/components/TransactionList.jsx @@ -0,0 +1,87 @@ +import { formatCurrency, formatDate } from '../utils/format'; +import { getCategoryMeta } from '../constants/categories'; + +const TransactionList = ({ + transactions, + onDelete, + filterType, + setFilterType, + filterMonth, + setFilterMonth, + availableMonths +}) => { + const selectClass = + 'border border-gray-200 text-sm rounded-lg px-2 py-1.5 outline-none focus:border-blue-400 bg-white'; + + return ( +
+
+

Transactions ({transactions.length})

+
+ + +
+
+ {transactions.length === 0 ? ( +
+

๐Ÿ“ญ

+

No transactions yet. Add one above!

+
+ ) : ( +
+ {transactions.map((t) => { + const meta = getCategoryMeta(t.category); + + return ( +
+ {meta.icon} +
+

{t.description}

+

+ {meta.label} ยท {formatDate(t.date)} +

+
+ + {t.type === 'income' ? '+' : '-'} + {formatCurrency(t.amount)} + + +
+ ); + })} +
+ )} +
+ ); +}; + +export default TransactionList; diff --git a/src/plays/expense-tracker-pro/constants/categories.js b/src/plays/expense-tracker-pro/constants/categories.js new file mode 100644 index 000000000..25d44cbe7 --- /dev/null +++ b/src/plays/expense-tracker-pro/constants/categories.js @@ -0,0 +1,95 @@ +import { getCategoryMeta, CHART_COLORS } from '../constants/categories'; +import { formatCurrency, getMonthLabel } from '../utils/format'; + +const ExpenseChart = ({ data }) => { + if (!data.length) + return ( +
+ No expense data yet +
+ ); + const total = data.reduce((sum, d) => sum + d.amount, 0); + + return ( +
+ {data.map((item, i) => { + const meta = getCategoryMeta(item.category); + const pct = total ? (item.amount / total) * 100 : 0; + + return ( +
+
+ + {meta.icon} {meta.label} + + + {formatCurrency(item.amount)} ({pct.toFixed(0)}%) + +
+
+
+
+
+ ); + })} +
+ ); +}; + +const MonthlyChart = ({ data }) => { + if (!data.length) + return ( +
+ No monthly data yet +
+ ); + const max = Math.max(...data.flatMap((d) => [d.income, d.expense])); + + return ( +
+ {data.map((item) => ( +
+
+
+
+
+

+ {getMonthLabel(item.month).split(' ')[0]} +

+
+ ))} +
+ ); +}; + +const Charts = ({ chartData, monthlyData }) => ( +
+
+

Expenses by Category

+ +
+
+

Monthly Overview

+
+ + Income + + + Expense + +
+ +
+
+); + +export default Charts; diff --git a/src/plays/expense-tracker-pro/hooks/useTransaction.js b/src/plays/expense-tracker-pro/hooks/useTransaction.js new file mode 100644 index 000000000..e7f971c50 --- /dev/null +++ b/src/plays/expense-tracker-pro/hooks/useTransaction.js @@ -0,0 +1,111 @@ +import { useState, useEffect, useMemo } from 'react'; + +const STORAGE_KEY = 'expense_tracker_pro_transactions'; + +const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + +const loadFromStorage = () => { + try { + const data = localStorage.getItem(STORAGE_KEY); + + return data ? JSON.parse(data) : []; + } catch { + return []; + } +}; + +const useTransactions = () => { + const [transactions, setTransactions] = useState(loadFromStorage); + const [filterType, setFilterType] = useState('all'); + const [filterMonth, setFilterMonth] = useState('all'); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(transactions)); + }, [transactions]); + + const addTransaction = (data) => { + const newTransaction = { + id: generateId(), + ...data, + amount: parseFloat(data.amount), + date: data.date || new Date().toISOString().split('T')[0], + createdAt: new Date().toISOString() + }; + setTransactions((prev) => [newTransaction, ...prev]); + }; + + const deleteTransaction = (id) => { + setTransactions((prev) => prev.filter((t) => t.id !== id)); + }; + + const clearAll = () => setTransactions([]); + + const summary = useMemo(() => { + const income = transactions + .filter((t) => t.type === 'income') + .reduce((sum, t) => sum + t.amount, 0); + const expense = transactions + .filter((t) => t.type === 'expense') + .reduce((sum, t) => sum + t.amount, 0); + + return { income, expense, balance: income - expense }; + }, [transactions]); + + const filtered = useMemo(() => { + return transactions.filter((t) => { + const matchType = filterType === 'all' || t.type === filterType; + const matchMonth = filterMonth === 'all' || t.date.startsWith(filterMonth); + + return matchType && matchMonth; + }); + }, [transactions, filterType, filterMonth]); + + const chartData = useMemo(() => { + const expenseMap = {}; + transactions + .filter((t) => t.type === 'expense') + .forEach((t) => { + expenseMap[t.category] = (expenseMap[t.category] || 0) + t.amount; + }); + + return Object.entries(expenseMap).map(([category, amount]) => ({ + category, + amount: parseFloat(amount.toFixed(2)) + })); + }, [transactions]); + + const monthlyData = useMemo(() => { + const map = {}; + transactions.forEach((t) => { + const month = t.date.slice(0, 7); + if (!map[month]) map[month] = { month, income: 0, expense: 0 }; + map[month][t.type] += t.amount; + }); + + return Object.values(map).sort((a, b) => a.month.localeCompare(b.month)); + }, [transactions]); + + const availableMonths = useMemo(() => { + const months = [...new Set(transactions.map((t) => t.date.slice(0, 7)))]; + + return months.sort((a, b) => b.localeCompare(a)); + }, [transactions]); + + return { + transactions, + filtered, + summary, + chartData, + monthlyData, + availableMonths, + filterType, + setFilterType, + filterMonth, + setFilterMonth, + addTransaction, + deleteTransaction, + clearAll + }; +}; + +export default useTransactions; diff --git a/src/plays/expense-tracker-pro/utils/format.js b/src/plays/expense-tracker-pro/utils/format.js new file mode 100644 index 000000000..cc706c07c --- /dev/null +++ b/src/plays/expense-tracker-pro/utils/format.js @@ -0,0 +1,22 @@ +export const formatCurrency = (amount) => + new Intl.NumberFormat('en-PH', { + style: 'currency', + currency: 'PHP', + minimumFractionDigits: 2 + }).format(amount); + +export const formatDate = (dateStr) => + new Date(dateStr).toLocaleDateString('en-PH', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + +export const getMonthLabel = (monthStr) => { + const [year, month] = monthStr.split('-'); + + return new Date(year, month - 1).toLocaleDateString('en-PH', { + month: 'long', + year: 'numeric' + }); +};