Expense Tracker
Track income and expenses with categories, a running balance, category breakdown, and localStorage persistence.
Project Overview
A personal finance tracker that demonstrates the full power of Array.reduce() for aggregation, along with form validation, category filtering, and computed summaries. This is the kind of data-manipulation code you will write constantly in real jobs.
What you will build
- Add transactions: description, amount (positive = income, negative = expense), category, date
- Running balance displayed prominently (green/red)
- Total income and total expenses summary
- Transaction list with delete button per entry
- Filter by category or transaction type (income / expense)
- Category breakdown showing spend per category
- All data persisted in localStorage
Concepts used
Array.reduce()to compute totals and group by category- Form validation with live feedback
- Intl.NumberFormat for currency display
- localStorage + JSON for persistence
- Filter and sort on a data array
Key Logic
Computing the balance with reduce
const balance = transactions.reduce((sum, t) => sum + t.amount, 0);
const income = transactions.filter(t => t.amount > 0)
.reduce((sum, t) => sum + t.amount, 0);
const expenses = transactions.filter(t => t.amount < 0)
.reduce((sum, t) => sum + t.amount, 0);
Grouping by category with reduce
const byCategory = transactions
.filter(t => t.amount < 0) // expenses only
.reduce((acc, t) => {
acc[t.category] = (acc[t.category] ?? 0) + Math.abs(t.amount);
return acc;
}, {});
// Result: { Food: 45.50, Transport: 12.00, ... }
Currency formatting
const fmt = new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP',
minimumFractionDigits: 2
});
fmt.format(1234.5); // "£1,234.50"
fmt.format(-45); // "-£45.00"
Transaction data structure
const transaction = {
id: Date.now().toString(),
description: 'Grocery shopping',
amount: -42.50, // negative = expense
category: 'Food',
date: '2026-06-01'
};
Complete Working Code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Expense Tracker</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f8fafc; min-height: 100vh; padding: 1.5rem 1rem; }
.app { max-width: 680px; margin: 0 auto; }
h1 { font-size: 2rem; color: #1e293b; margin-bottom: 1.5rem; text-align: center; }
/* Summary bar */
.summary { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: .75rem; margin-bottom: 1.5rem; }
.summary-box { background: #fff; border-radius: .75rem; padding: 1rem; text-align: center;
box-shadow: 0 1px 3px #0001; }
.s-label { font-size: .75rem; color: #64748b; text-transform: uppercase; letter-spacing: .05em; }
.s-val { font-size: 1.5rem; font-weight: 800; margin-top: .25rem; }
.s-val.pos { color: #22c55e; }
.s-val.neg { color: #ef4444; }
.s-val.neutral { color: #1e293b; }
/* Form */
.add-card { background: #fff; border-radius: .75rem; padding: 1.25rem; margin-bottom: 1.5rem;
box-shadow: 0 1px 3px #0001; }
.add-card h2 { font-size: 1rem; color: #1e293b; margin-bottom: .85rem; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; }
.form-grid input, .form-grid select {
padding: .6rem .75rem; border: 2px solid #e2e8f0; border-radius: .5rem;
font-size: .9rem; width: 100%; outline: none;
}
.form-grid input:focus, .form-grid select:focus { border-color: #3b82f6; }
.form-grid .span2 { grid-column: 1 / -1; }
.add-btn { width: 100%; padding: .7rem; background: #3b82f6; color: #fff;
border: none; border-radius: .5rem; font-size: .95rem;
font-weight: 600; cursor: pointer; margin-top: .6rem; }
.add-btn:hover { background: #2563eb; }
.field-err { font-size: .75rem; color: #ef4444; display: none; margin-top: .2rem; }
/* Filter bar */
.filter-bar { display: flex; gap: .5rem; margin-bottom: 1rem; flex-wrap: wrap; }
.filter-bar select {
padding: .45rem .7rem; border: 2px solid #e2e8f0; border-radius: .4rem;
font-size: .85rem; outline: none; background: #fff;
}
/* Transaction list */
.tx-list { background: #fff; border-radius: .75rem; overflow: hidden;
box-shadow: 0 1px 3px #0001; margin-bottom: 1.5rem; }
.tx-item { display: flex; align-items: center; gap: .75rem; padding: .9rem 1.1rem;
border-bottom: 1px solid #f1f5f9; }
.tx-item:last-child { border-bottom: none; }
.tx-cat-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.tx-desc { flex: 1; font-size: .9rem; color: #1e293b; }
.tx-meta { font-size: .72rem; color: #94a3b8; }
.tx-amount { font-weight: 700; font-size: .95rem; min-width: 80px; text-align: right; }
.tx-amount.income { color: #22c55e; }
.tx-amount.expense { color: #ef4444; }
.tx-del { background: none; border: none; color: #cbd5e1; font-size: 1rem;
cursor: pointer; padding: .2rem; opacity: 0; transition: opacity .15s; }
.tx-item:hover .tx-del { opacity: 1; }
.tx-del:hover { color: #ef4444; }
.tx-empty { text-align: center; padding: 2rem; color: #94a3b8; font-size: .9rem; }
/* Category breakdown */
.breakdown-card { background: #fff; border-radius: .75rem; padding: 1.25rem;
box-shadow: 0 1px 3px #0001; }
.breakdown-card h2 { font-size: 1rem; color: #1e293b; margin-bottom: .85rem; }
.breakdown-row { display: flex; align-items: center; gap: .75rem; margin-bottom: .6rem; }
.b-label { width: 110px; font-size: .82rem; color: #475569; }
.b-bar-wrap { flex: 1; background: #f1f5f9; border-radius: 999px; height: 8px; overflow: hidden; }
.b-bar { height: 100%; border-radius: 999px; transition: width .4s; }
.b-val { font-size: .82rem; color: #475569; min-width: 55px; text-align: right; }
</style>
</head>
<body>
<div class="app">
<h1>Expense Tracker</h1>
<div class="summary">
<div class="summary-box">
<div class="s-label">Balance</div>
<div class="s-val neutral" id="s-balance">£0.00</div>
</div>
<div class="summary-box">
<div class="s-label">Income</div>
<div class="s-val pos" id="s-income">£0.00</div>
</div>
<div class="summary-box">
<div class="s-label">Expenses</div>
<div class="s-val neg" id="s-expense">£0.00</div>
</div>
</div>
<div class="add-card">
<h2>Add Transaction</h2>
<form id="add-form">
<div class="form-grid">
<input id="f-desc" placeholder="Description" autocomplete="off" class="span2">
<div>
<input id="f-amount" type="number" step="0.01" placeholder="Amount (use − for expense)">
<span class="field-err" id="err-amount">Enter a non-zero amount</span>
</div>
<input id="f-date" type="date">
<select id="f-category">
<option value="">Category…</option>
<option>Food</option>
<option>Transport</option>
<option>Housing</option>
<option>Health</option>
<option>Entertainment</option>
<option>Shopping</option>
<option>Salary</option>
<option>Other</option>
</select>
<select id="f-type">
<option value="expense">Expense (−)</option>
<option value="income">Income (+)</option>
</select>
</div>
<button class="add-btn" type="submit">Add Transaction</button>
</form>
</div>
<div class="filter-bar">
<select id="filter-type">
<option value="all">All types</option>
<option value="income">Income only</option>
<option value="expense">Expenses only</option>
</select>
<select id="filter-cat">
<option value="all">All categories</option>
</select>
</div>
<div class="tx-list" id="tx-list"></div>
<div class="breakdown-card">
<h2>Spending by Category</h2>
<div id="breakdown"></div>
</div>
</div>
<script>
const STORAGE_KEY = 'expenses_v1';
const CAT_COLORS = {
Food: '#f59e0b', Transport: '#3b82f6', Housing: '#8b5cf6',
Health: '#10b981', Entertainment: '#ec4899', Shopping: '#f97316',
Salary: '#22c55e', Other: '#94a3b8'
};
const fmt = new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' });
function load() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? sampleData(); }
catch { return sampleData(); }
}
function save() { localStorage.setItem(STORAGE_KEY, JSON.stringify(transactions)); }
function sampleData() {
return [
{ id: '1', description: 'Monthly salary', amount: 2500, category: 'Salary', date: '2026-06-01' },
{ id: '2', description: 'Grocery run', amount: -52.40, category: 'Food', date: '2026-06-01' },
{ id: '3', description: 'Bus pass', amount: -28, category: 'Transport', date: '2026-06-01' },
{ id: '4', description: 'Cinema tickets', amount: -15, category: 'Entertainment', date: '2026-06-01' },
];
}
let transactions = load();
let filterType = 'all';
let filterCat = 'all';
// Populate category filter
function buildCatFilter() {
const sel = document.querySelector('#filter-cat');
const cats = [...new Set(transactions.map(t => t.category))].sort();
sel.innerHTML = '<option value="all">All categories</option>';
cats.forEach(c => {
const o = document.createElement('option');
o.value = c; o.textContent = c;
sel.appendChild(o);
});
}
function render() {
save();
buildCatFilter();
// Summary (always over ALL transactions)
const balance = transactions.reduce((s, t) => s + t.amount, 0);
const income = transactions.filter(t => t.amount > 0).reduce((s, t) => s + t.amount, 0);
const expenses = transactions.filter(t => t.amount < 0).reduce((s, t) => s + t.amount, 0);
const balEl = document.querySelector('#s-balance');
balEl.textContent = fmt.format(balance);
balEl.className = `s-val ${balance > 0 ? 'pos' : balance < 0 ? 'neg' : 'neutral'}`;
document.querySelector('#s-income').textContent = fmt.format(income);
document.querySelector('#s-expense').textContent = fmt.format(Math.abs(expenses));
// Filtered list
const visible = transactions
.filter(t => filterType === 'all' ? true : filterType === 'income' ? t.amount > 0 : t.amount < 0)
.filter(t => filterCat === 'all' || t.category === filterCat)
.sort((a, b) => b.date.localeCompare(a.date));
const listEl = document.querySelector('#tx-list');
listEl.innerHTML = '';
if (!visible.length) {
listEl.innerHTML = '<p class="tx-empty">No transactions match the filter.</p>';
} else {
visible.forEach(t => {
const item = document.createElement('div');
item.className = 'tx-item';
const dot = document.createElement('div');
dot.className = 'tx-cat-dot';
dot.style.background = CAT_COLORS[t.category] ?? '#94a3b8';
const info = document.createElement('div');
info.style.flex = '1';
const desc = document.createElement('div');
desc.className = 'tx-desc';
desc.textContent = t.description;
const meta = document.createElement('div');
meta.className = 'tx-meta';
meta.textContent = `${t.category} · ${t.date}`;
info.append(desc, meta);
const amt = document.createElement('div');
amt.className = `tx-amount ${t.amount > 0 ? 'income' : 'expense'}`;
amt.textContent = (t.amount > 0 ? '+' : '') + fmt.format(t.amount);
const del = document.createElement('button');
del.className = 'tx-del';
del.dataset.id = t.id;
del.textContent = '✕';
del.title = 'Delete';
item.append(dot, info, amt, del);
listEl.appendChild(item);
});
}
// Category breakdown
const byCategory = transactions
.filter(t => t.amount < 0)
.reduce((acc, t) => {
acc[t.category] = (acc[t.category] ?? 0) + Math.abs(t.amount);
return acc;
}, {});
const totalSpend = Object.values(byCategory).reduce((s, v) => s + v, 0);
const breakdownEl = document.querySelector('#breakdown');
breakdownEl.innerHTML = '';
if (!totalSpend) {
breakdownEl.innerHTML = '<p style="color:#94a3b8;font-size:.85rem">No expenses yet.</p>';
return;
}
Object.entries(byCategory)
.sort((a, b) => b[1] - a[1])
.forEach(([cat, amount]) => {
const pct = (amount / totalSpend) * 100;
const row = document.createElement('div');
row.className = 'breakdown-row';
row.innerHTML = `
<div class="b-label">${cat}</div>
<div class="b-bar-wrap">
<div class="b-bar" style="width:${pct}%;background:${CAT_COLORS[cat] ?? '#94a3b8'}"></div>
</div>
<div class="b-val">${fmt.format(amount)}</div>
`;
breakdownEl.appendChild(row);
});
}
// Add form
document.querySelector('#add-form').addEventListener('submit', e => {
e.preventDefault();
const desc = document.querySelector('#f-desc').value.trim();
const raw = parseFloat(document.querySelector('#f-amount').value);
const date = document.querySelector('#f-date').value || new Date().toISOString().slice(0, 10);
const cat = document.querySelector('#f-category').value || 'Other';
const type = document.querySelector('#f-type').value;
const errEl = document.querySelector('#err-amount');
if (!desc || isNaN(raw) || raw === 0) {
errEl.style.display = 'block';
return;
}
errEl.style.display = 'none';
const amount = type === 'expense' ? -Math.abs(raw) : Math.abs(raw);
transactions = [{ id: Date.now().toString(), description: desc, amount, category: cat, date }, ...transactions];
e.target.reset();
render();
});
// Delete via delegation
document.querySelector('#tx-list').addEventListener('click', e => {
const btn = e.target.closest('.tx-del');
if (!btn) return;
transactions = transactions.filter(t => t.id !== btn.dataset.id);
render();
});
// Filters
document.querySelector('#filter-type').addEventListener('change', e => { filterType = e.target.value; render(); });
document.querySelector('#filter-cat').addEventListener('change', e => { filterCat = e.target.value; render(); });
// Init
document.querySelector('#f-date').value = new Date().toISOString().slice(0, 10);
render();
</script>
</body>
</html>
Code Explained
reduce() for aggregation
Three reduce() calls compute the balance, total income, and total expenses. reduce() is the correct tool whenever you need to collapse an array into a single value — whether a sum, count, or even a grouped object. The category breakdown uses a different form of reduce that accumulates into an object (acc[t.category] = ...).
Intl.NumberFormat for currency
Instead of manually formatting currency strings, the built-in Intl.NumberFormat handles locale-specific separators, decimal places, and currency symbols automatically. Creating the formatter once and reusing it (rather than calling new Intl.NumberFormat() inside a loop) is more efficient.
sign controlled by type dropdown
Rather than asking users to type negative numbers for expenses, the app has an "Expense / Income" dropdown and uses Math.abs() to normalise the amount, then applies the correct sign. This prevents user confusion and input errors.
Challenges
- Add a monthly budget per category — show a warning bar when spending exceeds 80% of the budget.
- Add a date range filter (from/to date pickers) to view a specific month or week.
- Draw a simple pie/donut chart using SVG paths to visualise the category breakdown.
- Add recurring transactions — a flag that automatically adds the same transaction on the first of each month.
- Export the transaction list as a CSV file using Blob and
URL.createObjectURL().