Day 30: Capstone Project — Personal Finance Tracker
Day 30: Capstone Project — Personal Finance Tracker
1. Learning Objectives
By the end of this lesson, you will be able to:
- Integrate all 29 days of JavaScript knowledge into a single, production-quality application
- Build a complete CRUD application with persistent data storage
- Implement client-side routing, data validation, and error handling
- Use classes, modules, and design patterns for clean architecture
- Write and run unit tests for core business logic
- Deploy a finished web application
2. Theory — Full Course Recap
Course Roadmap
| Week | Topics | Days |
|---|---|---|
| Week 1 | Fundamentals: Variables, Data Types, Operators, Strings, Numbers, Conditionals, Loops, Functions | 1–7 |
| Week 2 | Intermediate: Arrays, Objects, Destructuring, Closures, HOFs, Error Handling | 8–14 |
| Week 3 | Browser APIs: DOM, Events, Forms, Storage, JSON, Fetch, Async | 15–21 |
| Week 4 | Advanced: Classes, Prototypes, Modules, Generators, Event Loop, Performance, Design Patterns, Testing | 22–29 |
Capstone Requirements
Your final project must demonstrate proficiency in:
- ✅ DOM manipulation — dynamic UI updates, creating/removing elements
- ✅ Event handling — click, submit, input, delegation
- ✅ Forms & validation — user input, real-time validation, error messages
- ✅ Storage — LocalStorage for persistence
- ✅ Fetch API — (optional: external API integration)
- ✅ Async/await — handling asynchronous operations
- ✅ Classes & inheritance — object-oriented design
- ✅ Modules — code organization (ES6 import/export)
- ✅ Error handling — try/catch, custom errors
- ✅ Higher-order functions — array methods (map, filter, reduce)
- ✅ Testing — unit tests with Jest
3. Code Examples — Building Blocks
Core Data Model (Review)
class Transaction {
constructor({ id, description, amount, type, category, date }) {
this.id = id || Date.now();
this.description = description;
this.amount = Number(amount);
this.type = type; // 'income' | 'expense'
this.category = category;
this.date = date || new Date().toISOString().split('T')[0];
this.createdAt = new Date().toISOString();
}
get formattedAmount() {
const sign = this.type === 'income' ? '+' : '-';
return `${sign}$${Math.abs(this.amount).toFixed(2)}`;
}
get isExpense() { return this.type === 'expense'; }
get isIncome() { return this.type === 'income'; }
}
Validation Module (Review)
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = 'ValidationError';
this.field = field;
}
}
function validateTransaction({ description, amount, type, category }) {
if (!description || description.trim().length === 0) {
throw new ValidationError('Description is required', 'description');
}
if (!amount || isNaN(amount)) {
throw new ValidationError('Amount must be a valid number', 'amount');
}
if (Number(amount) <= 0) {
throw new ValidationError('Amount must be greater than zero', 'amount');
}
if (!['income', 'expense'].includes(type)) {
throw new ValidationError('Type must be income or expense', 'type');
}
if (!category || category.trim().length === 0) {
throw new ValidationError('Category is required', 'category');
}
}
Storage Module (Review)
const StorageManager = {
key: 'financeTracker',
save(data) {
try {
localStorage.setItem(this.key, JSON.stringify(data));
return true;
} catch (e) {
console.error('Storage save failed:', e);
return false;
}
},
load() {
try {
const raw = localStorage.getItem(this.key);
return raw ? JSON.parse(raw) : [];
} catch (e) {
console.error('Storage load failed:', e);
return [];
}
},
clear() {
localStorage.removeItem(this.key);
}
};
4. Exercises (Review Problems)
Beginner
- Create a
Transactionclass and instantiate one income and one expense transaction. - Use
localStorage.setItem()to save an array of transactions. - Write a function
calculateBalance(transactions)that returns income minus expenses.
Intermediate
- Implement a
filterTransactions(transactions, filters)that filters by type, category, and date range. - Add a
sortTransactions(transactions, key, order)function. - Create a simple bar chart using Canvas that shows total income vs expenses per month.
Advanced
- Implement an undo/redo feature using a command stack pattern.
- Build an export to CSV function that downloads transactions as a CSV file.
5. Mini Project: Personal Finance Tracker
A complete, production-ready personal finance tracking application.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Personal Finance Tracker</title>
<style>
/* ---- Reset & Base ---- */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #f0f2f5;
color: #333;
line-height: 1.6;
}
.container { max-width: 1100px; margin: 0 auto; padding: 20px; }
/* ---- Header ---- */
header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px 0;
text-align: center;
margin-bottom: 30px;
}
header h1 { font-size: 28px; margin-bottom: 5px; }
header .balance {
font-size: 36px;
font-weight: bold;
color: #fff;
}
header .balance.positive { color: #b7f7b7; }
header .balance.negative { color: #ff9a9a; }
/* ---- Summary Cards ---- */
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 25px;
}
.summary-card {
background: white;
border-radius: 8px;
padding: 15px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
}
.summary-card h3 { font-size: 14px; color: #888; margin-bottom: 5px; }
.summary-card .amount { font-size: 24px; font-weight: bold; }
.summary-card .amount.income { color: #28a745; }
.summary-card .amount.expense { color: #dc3545; }
/* ---- Form ---- */
.form-section {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 25px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.form-section h2 { margin-bottom: 15px; color: #555; }
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
margin-bottom: 10px;
}
.form-row input, .form-row select {
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
width: 100%;
}
.form-row input:focus, .form-row select:focus {
outline: none;
border-color: #667eea;
}
.form-row .error {
color: #dc3545;
font-size: 12px;
margin-top: 2px;
min-height: 1.2em;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 10px;
}
.form-actions button {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.btn-primary { background: #667eea; color: white; }
.btn-primary:hover { background: #5a6fd6; }
.btn-secondary { background: #6c757d; color: white; }
.btn-secondary:hover { background: #5a6268; }
.btn-danger { background: #dc3545; color: white; }
.btn-danger:hover { background: #c82333; }
.btn-primary:disabled { background: #ccc; cursor: not-allowed; }
/* ---- Filters ---- */
.filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 15px;
align-items: center;
}
.filters input, .filters select {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.filters .search { flex: 1; min-width: 200px; }
/* ---- Transactions List ---- */
.transactions-section {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.transactions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.transactions-header h2 { color: #555; }
.transaction-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
transition: background 0.15s;
}
.transaction-item:hover { background: #fafafa; }
.transaction-item:last-child { border-bottom: none; }
.transaction-info { flex: 1; }
.transaction-info .description { font-weight: 600; }
.transaction-info .meta {
font-size: 12px;
color: #888;
margin-top: 2px;
}
.transaction-info .category-tag {
display: inline-block;
background: #e8f0fe;
color: #0366d6;
padding: 1px 8px;
border-radius: 10px;
font-size: 12px;
margin-left: 5px;
}
.transaction-amount {
font-size: 18px;
font-weight: bold;
margin-right: 15px;
min-width: 100px;
text-align: right;
}
.transaction-amount.income { color: #28a745; }
.transaction-amount.expense { color: #dc3545; }
.transaction-actions button {
background: none;
border: none;
cursor: pointer;
padding: 5px;
font-size: 16px;
opacity: 0.6;
transition: opacity 0.2s;
}
.transaction-actions button:hover { opacity: 1; }
/* ---- Chart ---- */
.chart-section {
background: white;
border-radius: 8px;
padding: 20px;
margin-top: 25px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.chart-section h2 { margin-bottom: 15px; color: #555; }
canvas { width: 100% !important; max-height: 300px; }
/* ---- Empty State ---- */
.empty-state {
text-align: center;
padding: 40px;
color: #999;
}
/* ---- Responsive ---- */
@media (max-width: 600px) {
.transaction-item {
flex-direction: column;
align-items: flex-start;
gap: 5px;
}
.transaction-amount {
text-align: left;
margin-right: 0;
}
.filters .search { min-width: 100%; }
}
</style>
</head>
<body>
<header>
<div class="container">
<h1>💰 Personal Finance Tracker</h1>
<div class="balance" id="balance-display">$0.00</div>
<p style="opacity:0.8;">Track your income and expenses</p>
</div>
</header>
<div class="container">
<!-- Summary Cards -->
<div class="summary" id="summary-cards">
<div class="summary-card">
<h3>Total Income</h3>
<div class="amount income" id="total-income">$0.00</div>
</div>
<div class="summary-card">
<h3>Total Expenses</h3>
<div class="amount expense" id="total-expenses">$0.00</div>
</div>
<div class="summary-card">
<h3>Transaction Count</h3>
<div class="amount" id="tx-count" style="color:#667eea;">0</div>
</div>
</div>
<!-- Form -->
<div class="form-section" id="form-section">
<h2 id="form-title">Add New Transaction</h2>
<form id="transaction-form" novalidate>
<div class="form-row">
<div>
<input type="text" id="description" name="description" placeholder="Description (e.g., Groceries)" required>
<div class="error" id="description-error"></div>
</div>
<div>
<input type="number" id="amount" name="amount" placeholder="Amount" step="0.01" min="0.01" required>
<div class="error" id="amount-error"></div>
</div>
<div>
<select id="type" name="type">
<option value="expense">Expense</option>
<option value="income">Income</option>
</select>
</div>
<div>
<input type="text" id="category" name="category" placeholder="Category (e.g., Food)" required>
<div class="error" id="category-error"></div>
</div>
<div>
<input type="date" id="date" name="date">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" id="submit-btn">Add Transaction</button>
<button type="button" class="btn-secondary" id="cancel-btn" style="display:none;">Cancel</button>
</div>
</form>
</div>
<!-- Filters -->
<div class="filters">
<input type="text" class="search" id="search-input" placeholder="Search transactions...">
<select id="filter-type">
<option value="all">All Types</option>
<option value="income">Income</option>
<option value="expense">Expense</option>
</select>
<select id="filter-category">
<option value="all">All Categories</option>
</select>
<select id="sort-select">
<option value="date-desc">Newest First</option>
<option value="date-asc">Oldest First</option>
<option value="amount-desc">Highest Amount</option>
<option value="amount-asc">Lowest Amount</option>
</select>
</div>
<!-- Transactions List -->
<div class="transactions-section">
<div class="transactions-header">
<h2>Transactions</h2>
<button class="btn-danger" id="clear-btn" style="padding:5px 10px;font-size:12px;">Clear All</button>
</div>
<div id="transactions-list">
<div class="empty-state">No transactions yet. Add one above!</div>
</div>
</div>
<!-- Chart -->
<div class="chart-section" id="chart-section" style="display:none;">
<h2>Monthly Overview</h2>
<canvas id="chart-canvas"></canvas>
</div>
</div>
<script>
// =============================================
// CAPSTONE: PERSONAL FINANCE TRACKER
// All code in one file for simplicity.
// In production, split into modules.
// =============================================
// ---- 1. Custom Error Classes ----
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = 'ValidationError';
this.field = field;
}
}
// ---- 2. Transaction Class ----
class Transaction {
constructor({ id, description, amount, type, category, date }) {
this.id = id || Date.now();
this.description = description;
this.amount = Number(amount);
this.type = type;
this.category = category;
this.date = date || new Date().toISOString().split('T')[0];
this.createdAt = new Date().toISOString();
}
get formattedAmount() {
const sign = this.type === 'income' ? '+' : '-';
return `${sign}$${Math.abs(this.amount).toFixed(2)}`;
}
}
// ---- 3. Validation Functions ----
function validateTransaction({ description, amount, type, category }) {
if (!description || description.trim().length === 0) {
throw new ValidationError('Description is required', 'description');
}
if (!amount || isNaN(amount) || Number(amount) <= 0) {
throw new ValidationError('Amount must be a positive number', 'amount');
}
if (!['income', 'expense'].includes(type)) {
throw new ValidationError('Type must be income or expense', 'type');
}
if (!category || category.trim().length === 0) {
throw new ValidationError('Category is required', 'category');
}
}
// ---- 4. Storage Manager ----
const StorageManager = {
key: 'financeTracker',
save(data) {
try {
localStorage.setItem(this.key, JSON.stringify(data));
return true;
} catch (e) {
console.error('Storage save failed:', e);
return false;
}
},
load() {
try {
const raw = localStorage.getItem(this.key);
return raw ? JSON.parse(raw) : [];
} catch (e) {
console.error('Storage load failed:', e);
return [];
}
},
clear() {
localStorage.removeItem(this.key);
}
};
// ---- 5. Finance Tracker Module ----
const FinanceTracker = (function() {
let transactions = [];
let nextId = Date.now();
function load() {
const data = StorageManager.load();
transactions = data.map(t => new Transaction(t));
// update nextId
if (transactions.length > 0) {
const maxId = Math.max(...transactions.map(t => t.id));
nextId = maxId + 1;
}
}
function save() {
StorageManager.save(transactions);
}
return {
init() {
load();
},
getAll() {
return [...transactions];
},
add(data) {
validateTransaction(data);
const txn = new Transaction({
...data,
id: nextId++,
date: data.date || new Date().toISOString().split('T')[0]
});
transactions.push(txn);
save();
return txn;
},
update(id, data) {
const index = transactions.findIndex(t => t.id === id);
if (index === -1) throw new Error('Transaction not found');
// Validate only provided fields
const existing = transactions[index];
const merged = { ...existing, ...data };
validateTransaction(merged);
transactions[index] = new Transaction(merged);
save();
return transactions[index];
},
delete(id) {
const index = transactions.findIndex(t => t.id === id);
if (index === -1) throw new Error('Transaction not found');
transactions.splice(index, 1);
save();
},
clearAll() {
transactions = [];
save();
},
getBalance() {
const income = transactions
.filter(t => t.type === 'income')
.reduce((sum, t) => sum + t.amount, 0);
const expenses = transactions
.filter(t => t.type === 'expense')
.reduce((sum, t) => sum + t.amount, 0);
return income - expenses;
},
getSummary() {
const income = transactions
.filter(t => t.type === 'income')
.reduce((sum, t) => sum + t.amount, 0);
const expenses = transactions
.filter(t => t.type === 'expense')
.reduce((sum, t) => sum + t.amount, 0);
return { income, expenses, count: transactions.length };
},
getCategories() {
const cats = new Set(transactions.map(t => t.category));
return [...cats].sort();
},
search(query, filters = {}) {
let result = [...transactions];
// Search text
if (query && query.trim()) {
const q = query.toLowerCase();
result = result.filter(t =>
t.description.toLowerCase().includes(q) ||
t.category.toLowerCase().includes(q)
);
}
// Filter by type
if (filters.type && filters.type !== 'all') {
result = result.filter(t => t.type === filters.type);
}
// Filter by category
if (filters.category && filters.category !== 'all') {
result = result.filter(t => t.category === filters.category);
}
// Sort
if (filters.sort) {
const [field, order] = filters.sort.split('-');
result.sort((a, b) => {
let valA, valB;
if (field === 'date') {
valA = a.date;
valB = b.date;
} else if (field === 'amount') {
valA = a.amount;
valB = b.amount;
}
if (valA < valB) return order === 'asc' ? -1 : 1;
if (valA > valB) return order === 'asc' ? 1 : -1;
return 0;
});
}
return result;
},
getMonthlyData() {
const monthly = {};
transactions.forEach(t => {
const month = t.date.substring(0, 7); // YYYY-MM
if (!monthly[month]) monthly[month] = { income: 0, expense: 0 };
monthly[month][t.type] += t.amount;
});
// Sort months
const sorted = Object.keys(monthly).sort();
return sorted.map(m => ({
month: m,
income: monthly[m].income,
expense: monthly[m].expense
}));
}
};
})();
// ---- 6. UI Controller ----
const UI = (function() {
const DOM = {
balanceDisplay: document.getElementById('balance-display'),
totalIncome: document.getElementById('total-income'),
totalExpenses: document.getElementById('total-expenses'),
txCount: document.getElementById('tx-count'),
form: document.getElementById('transaction-form'),
formTitle: document.getElementById('form-title'),
submitBtn: document.getElementById('submit-btn'),
cancelBtn: document.getElementById('cancel-btn'),
description: document.getElementById('description'),
amount: document.getElementById('amount'),
type: document.getElementById('type'),
category: document.getElementById('category'),
date: document.getElementById('date'),
searchInput: document.getElementById('search-input'),
filterType: document.getElementById('filter-type'),
filterCategory: document.getElementById('filter-category'),
sortSelect: document.getElementById('sort-select'),
transactionsList: document.getElementById('transactions-list'),
clearBtn: document.getElementById('clear-btn'),
chartSection: document.getElementById('chart-section'),
chartCanvas: document.getElementById('chart-canvas'),
descError: document.getElementById('description-error'),
amountError: document.getElementById('amount-error'),
categoryError: document.getElementById('category-error')
};
let editingId = null;
function getFilters() {
return {
type: DOM.filterType.value,
category: DOM.filterCategory.value,
sort: DOM.sortSelect.value,
query: DOM.searchInput.value
};
}
function showError(el, msg) {
el.textContent = msg;
}
function clearErrors() {
showError(DOM.descError, '');
showError(DOM.amountError, '');
showError(DOM.categoryError, '');
}
function updateSummary() {
const summary = FinanceTracker.getSummary();
const balance = FinanceTracker.getBalance();
DOM.balanceDisplay.textContent = `$${balance.toFixed(2)}`;
DOM.balanceDisplay.className = 'balance ' + (balance >= 0 ? 'positive' : 'negative');
DOM.totalIncome.textContent = `$${summary.income.toFixed(2)}`;
DOM.totalExpenses.textContent = `$${summary.expenses.toFixed(2)}`;
DOM.txCount.textContent = summary.count;
}
function updateCategoryFilter() {
const cats = FinanceTracker.getCategories();
const currentVal = DOM.filterCategory.value;
DOM.filterCategory.innerHTML = '<option value="all">All Categories</option>';
cats.forEach(c => {
const opt = document.createElement('option');
opt.value = c;
opt.textContent = c;
DOM.filterCategory.appendChild(opt);
});
DOM.filterCategory.value = currentVal;
}
function renderTransactions() {
const filters = getFilters();
const results = FinanceTracker.search(filters.query, {
type: filters.type,
category: filters.category,
sort: filters.sort
});
const container = DOM.transactionsList;
container.innerHTML = '';
if (results.length === 0) {
container.innerHTML = '<div class="empty-state">No transactions found.</div>';
DOM.chartSection.style.display = 'none';
return;
}
results.forEach(t => {
const div = document.createElement('div');
div.className = 'transaction-item';
div.innerHTML = `
<div class="transaction-info">
<div class="description">${escapeHtml(t.description)}</div>
<div class="meta">
${formatDate(t.date)}
<span class="category-tag">${escapeHtml(t.category)}</span>
</div>
</div>
<div class="transaction-amount ${t.type}">${t.formattedAmount}</div>
<div class="transaction-actions">
<button class="edit-btn" data-id="${t.id}" title="Edit">✏️</button>
<button class="delete-btn" data-id="${t.id}" title="Delete">🗑️</button>
</div>
`;
container.appendChild(div);
});
// Attach event listeners using delegation on container
container.addEventListener('click', function(e) {
const editBtn = e.target.closest('.edit-btn');
const deleteBtn = e.target.closest('.delete-btn');
if (editBtn) {
const id = Number(editBtn.dataset.id);
startEdit(id);
}
if (deleteBtn) {
const id = Number(deleteBtn.dataset.id);
deleteTransaction(id);
}
});
updateChart();
DOM.chartSection.style.display = 'block';
}
function updateChart() {
const data = FinanceTracker.getMonthlyData();
if (data.length === 0) return;
const canvas = DOM.chartCanvas;
const ctx = canvas.getContext('2d');
const width = canvas.parentElement.clientWidth;
canvas.width = width * 2; // Retina
canvas.height = 300 * 2;
canvas.style.width = width + 'px';
canvas.style.height = '300px';
ctx.scale(2, 2);
const w = width;
const h = 300;
const padding = { top: 20, bottom: 40, left: 60, right: 20 };
const chartW = w - padding.left - padding.right;
const chartH = h - padding.top - padding.bottom;
// Clear
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#f0f2f5';
ctx.fillRect(0, 0, w, h);
// Find max value
const maxVal = Math.max(...data.flatMap(d => [d.income, d.expense])) * 1.2 || 1;
const barWidth = Math.min(chartW / data.length / 3, 30);
const gap = chartW / data.length;
ctx.font = '10px Arial';
ctx.fillStyle = '#555';
data.forEach((d, i) => {
const x = padding.left + i * gap + gap / 2;
// Income bar
const incH = (d.income / maxVal) * chartH;
ctx.fillStyle = '#28a745';
ctx.fillRect(x - barWidth, padding.top + chartH - incH, barWidth, incH);
// Expense bar
const expH = (d.expense / maxVal) * chartH;
ctx.fillStyle = '#dc3545';
ctx.fillRect(x, padding.top + chartH - expH, barWidth, expH);
// Month label
ctx.fillStyle = '#555';
ctx.textAlign = 'center';
const label = d.month.substring(5); // MM
ctx.fillText(label, x + barWidth / 2, h - padding.bottom + 15);
});
// Legend
ctx.fillStyle = '#28a745';
ctx.fillRect(w - 120, 5, 12, 12);
ctx.fillStyle = '#333';
ctx.textAlign = 'left';
ctx.fillText('Income', w - 103, 15);
ctx.fillStyle = '#dc3545';
ctx.fillRect(w - 120, 22, 12, 12);
ctx.fillStyle = '#333';
ctx.fillText('Expense', w - 103, 32);
}
function startEdit(id) {
const txn = FinanceTracker.getAll().find(t => t.id === id);
if (!txn) return;
editingId = id;
DOM.formTitle.textContent = 'Edit Transaction';
DOM.submitBtn.textContent = 'Update Transaction';
DOM.cancelBtn.style.display = 'inline-block';
DOM.description.value = txn.description;
DOM.amount.value = txn.amount;
DOM.type.value = txn.type;
DOM.category.value = txn.category;
DOM.date.value = txn.date;
DOM.description.focus();
clearErrors();
}
function cancelEdit() {
editingId = null;
DOM.formTitle.textContent = 'Add New Transaction';
DOM.submitBtn.textContent = 'Add Transaction';
DOM.cancelBtn.style.display = 'none';
DOM.form.reset();
clearErrors();
}
function deleteTransaction(id) {
if (!confirm('Delete this transaction?')) return;
try {
FinanceTracker.delete(id);
updateAll();
} catch (e) {
alert(e.message);
}
}
function updateAll() {
updateSummary();
updateCategoryFilter();
renderTransactions();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDate(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
// ---- Event Handlers ----
function handleSubmit(e) {
e.preventDefault();
clearErrors();
const data = {
description: DOM.description.value.trim(),
amount: DOM.amount.value,
type: DOM.type.value,
category: DOM.category.value.trim(),
date: DOM.date.value || undefined
};
try {
if (editingId) {
FinanceTracker.update(editingId, data);
} else {
FinanceTracker.add(data);
}
cancelEdit();
updateAll();
} catch (error) {
if (error instanceof ValidationError) {
if (error.field === 'description') showError(DOM.descError, error.message);
else if (error.field === 'amount') showError(DOM.amountError, error.message);
else if (error.field === 'category') showError(DOM.categoryError, error.message);
else alert(error.message);
} else {
alert(error.message);
}
}
}
function handleClearAll() {
if (!confirm('Delete ALL transactions? This cannot be undone.')) return;
FinanceTracker.clearAll();
cancelEdit();
updateAll();
}
// ---- Initialize ----
function init() {
FinanceTracker.init();
updateAll();
// Set default date to today
DOM.date.value = new Date().toISOString().split('T')[0];
// Bind events
DOM.form.addEventListener('submit', handleSubmit);
DOM.cancelBtn.addEventListener('click', cancelEdit);
DOM.clearBtn.addEventListener('click', handleClearAll);
// Filter events
DOM.searchInput.addEventListener('input', renderTransactions);
DOM.filterType.addEventListener('change', renderTransactions);
DOM.filterCategory.addEventListener('change', renderTransactions);
DOM.sortSelect.addEventListener('change', renderTransactions);
// Keyboard shortcut: Escape to cancel edit
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && editingId) cancelEdit();
});
}
return { init };
})();
// ---- 7. Start the App ----
document.addEventListener('DOMContentLoaded', () => UI.init());
</script>
</body>
</html>
6. Testing the Capstone
Create a file financeTracker.test.js to test the core logic:
// ---- financeTracker.test.js ----
// These tests verify the FinanceTracker module logic
// Run with: npx jest financeTracker.test.js
// We need to simulate localStorage for Node.js environment
class LocalStorageMock {
constructor() { this.store = {}; }
getItem(key) { return this.store[key] || null; }
setItem(key, value) { this.store[key] = String(value); }
removeItem(key) { delete this.store[key]; }
clear() { this.store = {}; }
}
global.localStorage = new LocalStorageMock();
// Import or copy the relevant parts
// Since the code is in one file, we extract the core modules:
const { ValidationError, Transaction, validateTransaction, StorageManager, FinanceTracker } = require('./financeApp.js');
describe('Transaction Class', () => {
test('creates transaction with defaults', () => {
const t = new Transaction({ description: 'Test', amount: 100, type: 'income', category: 'Salary' });
expect(t.description).toBe('Test');
expect(t.amount).toBe(100);
expect(t.type).toBe('income');
expect(t.category).toBe('Salary');
expect(t.id).toBeDefined();
expect(t.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
test('formattedAmount returns correct string for income', () => {
const t = new Transaction({ description: 'Test', amount: 150.5, type: 'income', category: 'Freelance' });
expect(t.formattedAmount).toBe('+$150.50');
});
test('formattedAmount returns correct string for expense', () => {
const t = new Transaction({ description: 'Test', amount: 75, type: 'expense', category: 'Food' });
expect(t.formattedAmount).toBe('-$75.00');
});
});
describe('Validation', () => {
test('throws for empty description', () => {
expect(() => validateTransaction({ description: '', amount: 10, type: 'expense', category: 'Food' }))
.toThrow(ValidationError);
});
test('throws for non-positive amount', () => {
expect(() => validateTransaction({ description: 'Test', amount: -5, type: 'expense', category: 'Food' }))
.toThrow('Amount must be a positive number');
});
test('throws for invalid type', () => {
expect(() => validateTransaction({ description: 'Test', amount: 10, type: 'invalid', category: 'Food' }))
.toThrow('Type must be income or expense');
});
test('throws for empty category', () => {
expect(() => validateTransaction({ description: 'Test', amount: 10, type: 'expense', category: '' }))
.toThrow('Category is required');
});
test('passes with valid data', () => {
expect(() => validateTransaction({ description: 'Salary', amount: 5000, type: 'income', category: 'Work' }))
.not.toThrow();
});
});
describe('FinanceTracker', () => {
beforeEach(() => {
StorageManager.clear();
FinanceTracker.init();
});
test('starts with empty transactions', () => {
expect(FinanceTracker.getAll()).toHaveLength(0);
});
test('adds a transaction', () => {
const t = FinanceTracker.add({ description: 'Test', amount: 100, type: 'income', category: 'Salary' });
expect(FinanceTracker.getAll()).toHaveLength(1);
expect(t.id).toBeDefined();
});
test('calculates balance correctly', () => {
FinanceTracker.add({ description: 'Salary', amount: 5000, type: 'income', category: 'Work' });
FinanceTracker.add({ description: 'Rent', amount: 1200, type: 'expense', category: 'Housing' });
FinanceTracker.add({ description: 'Food', amount: 300, type: 'expense', category: 'Food' });
expect(FinanceTracker.getBalance()).toBe(3500);
});
test('returns summary', () => {
FinanceTracker.add({ description: 'Income', amount: 1000, type: 'income', category: 'Work' });
FinanceTracker.add({ description: 'Expense', amount: 400, type: 'expense', category: 'Food' });
const summary = FinanceTracker.getSummary();
expect(summary.income).toBe(1000);
expect(summary.expenses).toBe(400);
expect(summary.count).toBe(2);
});
test('deletes a transaction', () => {
const t = FinanceTracker.add({ description: 'Temp', amount: 50, type: 'expense', category: 'Other' });
expect(FinanceTracker.getAll()).toHaveLength(1);
FinanceTracker.delete(t.id);
expect(FinanceTracker.getAll()).toHaveLength(0);
});
test('updates a transaction', () => {
const t = FinanceTracker.add({ description: 'Old', amount: 100, type: 'expense', category: 'Food' });
FinanceTracker.update(t.id, { description: 'Updated', amount: 200 });
const updated = FinanceTracker.getAll()[0];
expect(updated.description).toBe('Updated');
expect(updated.amount).toBe(200);
});
test('searches transactions', () => {
FinanceTracker.add({ description: 'Grocery store', amount: 50, type: 'expense', category: 'Food' });
FinanceTracker.add({ description: 'Gas station', amount: 40, type: 'expense', category: 'Transport' });
FinanceTracker.add({ description: 'Salary', amount: 3000, type: 'income', category: 'Work' });
const results = FinanceTracker.search('grocery');
expect(results).toHaveLength(1);
expect(results[0].description).toBe('Grocery store');
});
test('filters by type', () => {
FinanceTracker.add({ description: 'Income', amount: 1000, type: 'income', category: 'Work' });
FinanceTracker.add({ description: 'Expense', amount: 100, type: 'expense', category: 'Food' });
const incomes = FinanceTracker.search('', { type: 'income' });
expect(incomes).toHaveLength(1);
expect(incomes[0].type).toBe('income');
});
test('gets categories', () => {
FinanceTracker.add({ description: 'A', amount: 10, type: 'expense', category: 'Food' });
FinanceTracker.add({ description: 'B', amount: 20, type: 'expense', category: 'Transport' });
FinanceTracker.add({ description: 'C', amount: 30, type: 'income', category: 'Work' });
const cats = FinanceTracker.getCategories();
expect(cats).toEqual(['Food', 'Transport', 'Work']);
});
test('persists data across init calls', () => {
FinanceTracker.add({ description: 'Persisted', amount: 99, type: 'income', category: 'Test' });
FinanceTracker.init(); // re-initialize from storage
expect(FinanceTracker.getAll()).toHaveLength(1);
expect(FinanceTracker.getAll()[0].description).toBe('Persisted');
});
});
7. Common Mistakes
| Mistake | Why it's wrong | Correct approach |
|---|---|---|
| Mixing concerns in one large function | Hard to test, maintain, and debug | Separate data logic, validation, and UI into modules |
| Not handling localStorage errors | App crashes silently if storage is full | Use try/catch around all storage operations |
Mutating state directly (e.g., transactions.push) | Can cause subtle bugs and undo/redo failures | Use immutable updates or a centralized state manager |
Forgetting to bind this in class methods inside callbacks | this becomes undefined in strict mode | Use arrow functions or .bind() |
| Not resetting the form after edit/cancel | Stale data remains in input fields | Always call form.reset() or manually clear fields |
Using innerHTML with user input | XSS vulnerability | Use textContent or escapeHtml() utility or createTextNode |
| Not testing edge cases (empty list, invalid input, special characters) | Hidden bugs in production | Write tests for empty state, validation, and boundary conditions |
| Over-engineering the architecture | Too complex for the project scope | Balance clean code with simplicity; refactor when needed |
8. Best Practices
- Organize code into modules — separate data, UI, validation, and storage.
- Use a centralized state — the
FinanceTrackermodule is the single source of truth. - Validate at boundaries — validate input when it enters the system (form submit and
add/updatemethods). - Keep the UI dumb — UI controller should only render state and call data methods.
- Write tests first (TDD) for critical business logic.
- Use event delegation for dynamic lists to avoid memory leaks.
- Handle all states — loading, empty, error, and success states.
- Use
constandletappropriately — never usevar. - Add keyboard shortcuts — Escape to cancel edit, Enter to submit.
- Use semantic HTML and proper ARIA attributes for accessibility.
- Make it responsive — test on mobile and desktop viewports.
- Add confirmation dialogs for destructive actions (delete, clear all).
9. Challenge Assignment (Optional)
"Finance Tracker Pro" — Extend the Application
Add the following advanced features to demonstrate mastery:
- Import/Export CSV — allow users to download transactions as CSV and upload CSV to bulk import.
- Recurring Transactions — add a
recurringproperty ('none','weekly','monthly','yearly'). When a recurring transaction is added, automatically create future transactions on the schedule. - Budget Categories — allow users to set monthly budgets per category. Show a progress bar for each category comparing spending to budget.
- Charts & Visualization — add a pie chart showing expense breakdown by category (use Canvas or a library like Chart.js).
- Undo/Redo — implement a command history stack. Each mutation pushes the previous state. Ctrl+Z undoes the last action.
- Multi-currency — allow users to set a currency symbol and format amounts accordingly.
- Tags — add support for tagging transactions with multiple tags and filtering by tags.
- Dark Mode — implement a theme toggle that persists in LocalStorage.
Bonus:
- Add a service worker for offline support
- Deploy to GitHub Pages or Netlify
- Add end-to-end tests using Cypress or Playwright
10. Knowledge Check (Quiz)
What pattern does the
FinanceTrackermodule use?- a) Singleton
- b) Module/Revealing Module
- c) Observer
- d) Factory
Why is
localStoragewrapped in try/catch?- a) It's required syntax
- b) Storage can fail (quota exceeded, private browsing)
- c) To convert data to JSON
- d) To improve performance
How does the app handle the edit state?
- a) By modifying the existing DOM element directly
- b) By storing the editing ID and re-populating the form
- c) By opening a separate modal
- d) By creating a new transaction and deleting the old one
What does
FinanceTracker.search()return?- a) A filtered copy of the transactions array
- b) The original array mutated
- c) A promise that resolves with results
- d) The first matching transaction
Which method is used to persist data across page reloads?
- a)
sessionStorage - b)
localStorage - c)
cookies - d)
IndexedDB
- a)
What is the purpose of the
ValidationErrorclass?- a) To log errors to the console
- b) To provide structured error information including the field name
- c) To format error messages
- d) To suppress errors
How are event listeners attached to edit/delete buttons in the transaction list?
- a) One listener per button added when rendering
- b) Event delegation on the container element
- c) Inline
onclickattributes - d) A global event listener
What should you do before refactoring the code into separate files?
- a) Add more features
- b) Write tests for existing functionality
- c) Delete the old code
- d) Change the styling
Answers: 1-b, 2-b, 3-b, 4-a, 5-b, 6-b, 7-b, 8-b
11. Additional Resources
- MDN: LocalStorage → developer.mozilla.org/en-US/docs/Web/API/Window/localStorage
- MDN: Canvas API → developer.mozilla.org/en-US/docs/Web/API/Canvas_API
- MDN: Fetch API → developer.mozilla.org/en-US/docs/Web/API/Fetch_API
- Jest Documentation → jestjs.io
- JavaScript.info: Modules → javascript.info/modules
- Refactoring Guru: Design Patterns → refactoring.guru/design-patterns
- Eloquent JavaScript (free online) → eloquentjavascript.net
- GitHub Pages Deployment → pages.github.com
- Netlify Deployment → netlify.com
12. Summary
What You Built
Today you built a complete Personal Finance Tracker application that includes:
- Transaction management — Add, view, edit, delete transactions
- Data validation — Real-time field validation with custom errors
- Persistence — LocalStorage saves data across sessions
- Search & filtering — Search by text, filter by type/category, sort by date/amount
- Summary cards — Total income, expenses, transaction count, and balance
- Visual chart — Monthly income vs expense bar chart using Canvas
- Category management — Dynamic category list from existing transactions
- Responsive design — Adapts to mobile and desktop
- Error handling — Graceful error messages for validation and storage failures
- Clean architecture — Module pattern, separation of concerns, centralized state
Skills Demonstrated
| Skill | Where Used |
|---|---|
| Variables & Data Types | Transaction properties, state management |
| Conditionals & Loops | Filtering, sorting, rendering |
| Functions & Arrow Functions | Utility functions, callbacks, event handlers |
| Arrays & Methods | map, filter, reduce, find, sort, forEach |
| Objects & Destructuring | Transaction data, function parameters |
| Classes | Transaction, ValidationError |
| DOM Manipulation | Creating/updating elements, innerHTML, textContent |
| Event Handling | Click, submit, input, change, keyboard |
| Forms & Validation | Real-time validation, setCustomValidity |
| Storage APIs | LocalStorage CRUD |
| JSON | Serialization/deserialization |
| Async/await | (Optional: for future API extensions) |
| Error Handling | Try/catch, custom Error subclasses |
| Design Patterns | Module pattern, revealing module |
| Testing | Jest unit tests for core logic |
Course Complete! 🎉
Congratulations! You have completed the Mastering JavaScript — 30-Day Course. You've gone from absolute beginner to a confident JavaScript developer capable of building modern, production-ready applications.
You've mastered:
- ✅ Fundamentals — Variables, data types, operators, strings, numbers, conditionals, loops, functions
- ✅ Core Programming — Arrays, objects, destructuring, closures, higher-order functions, error handling
- ✅ Modern JavaScript & Browser APIs — DOM, events, forms, storage, JSON, Fetch, async/await
- ✅ Advanced Concepts — Classes, prototypes, modules, generators, event loop, performance, design patterns, testing
What's Next?
- Build more projects — solidify your skills by building applications
- Learn a framework — React, Vue, or Svelte (you have the JavaScript foundation to pick up any framework quickly)
- Back-end with Node.js — take your JavaScript to the server
- Full-stack development — combine front-end and back-end skills
- Contribute to open source — find projects on GitHub
- Prepare for technical interviews — practice algorithms and data structures
"The journey of a thousand miles begins with a single step. You've taken 30 steps and now stand at the threshold of mastery. Keep building, keep learning, and remember: the best way to learn is to build. "
Happy coding! 🚀

Comments
Post a Comment