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

WeekTopicsDays
Week 1Fundamentals: Variables, Data Types, Operators, Strings, Numbers, Conditionals, Loops, Functions1–7
Week 2Intermediate: Arrays, Objects, Destructuring, Closures, HOFs, Error Handling8–14
Week 3Browser APIs: DOM, Events, Forms, Storage, JSON, Fetch, Async15–21
Week 4Advanced: Classes, Prototypes, Modules, Generators, Event Loop, Performance, Design Patterns, Testing22–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

  1. Create a Transaction class and instantiate one income and one expense transaction.
  2. Use localStorage.setItem() to save an array of transactions.
  3. Write a function calculateBalance(transactions) that returns income minus expenses.

Intermediate

  1. Implement a filterTransactions(transactions, filters) that filters by type, category, and date range.
  2. Add a sortTransactions(transactions, key, order) function.
  3. Create a simple bar chart using Canvas that shows total income vs expenses per month.

Advanced

  1. Implement an undo/redo feature using a command stack pattern.
  2. 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

MistakeWhy it's wrongCorrect approach
Mixing concerns in one large functionHard to test, maintain, and debugSeparate data logic, validation, and UI into modules
Not handling localStorage errorsApp crashes silently if storage is fullUse try/catch around all storage operations
Mutating state directly (e.g., transactions.push)Can cause subtle bugs and undo/redo failuresUse immutable updates or a centralized state manager
Forgetting to bind this in class methods inside callbacksthis becomes undefined in strict modeUse arrow functions or .bind()
Not resetting the form after edit/cancelStale data remains in input fieldsAlways call form.reset() or manually clear fields
Using innerHTML with user inputXSS vulnerabilityUse textContent or escapeHtml() utility or createTextNode
Not testing edge cases (empty list, invalid input, special characters)Hidden bugs in productionWrite tests for empty state, validation, and boundary conditions
Over-engineering the architectureToo complex for the project scopeBalance 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 FinanceTracker module is the single source of truth.
  • Validate at boundaries — validate input when it enters the system (form submit and add/update methods).
  • 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 const and let appropriately — never use var.
  • 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:

  1. Import/Export CSV — allow users to download transactions as CSV and upload CSV to bulk import.
  2. Recurring Transactions — add a recurring property ('none', 'weekly', 'monthly', 'yearly'). When a recurring transaction is added, automatically create future transactions on the schedule.
  3. Budget Categories — allow users to set monthly budgets per category. Show a progress bar for each category comparing spending to budget.
  4. Charts & Visualization — add a pie chart showing expense breakdown by category (use Canvas or a library like Chart.js).
  5. Undo/Redo — implement a command history stack. Each mutation pushes the previous state. Ctrl+Z undoes the last action.
  6. Multi-currency — allow users to set a currency symbol and format amounts accordingly.
  7. Tags — add support for tagging transactions with multiple tags and filtering by tags.
  8. 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)

  1. What pattern does the FinanceTracker module use?

    • a) Singleton
    • b) Module/Revealing Module
    • c) Observer
    • d) Factory
  2. Why is localStorage wrapped 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
  3. 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
  4. 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
  5. Which method is used to persist data across page reloads?

    • a) sessionStorage
    • b) localStorage
    • c) cookies
    • d) IndexedDB
  6. What is the purpose of the ValidationError class?

    • 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
  7. 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 onclick attributes
    • d) A global event listener
  8. 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


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

SkillWhere Used
Variables & Data TypesTransaction properties, state management
Conditionals & LoopsFiltering, sorting, rendering
Functions & Arrow FunctionsUtility functions, callbacks, event handlers
Arrays & Methodsmap, filter, reduce, find, sort, forEach
Objects & DestructuringTransaction data, function parameters
ClassesTransaction, ValidationError
DOM ManipulationCreating/updating elements, innerHTML, textContent
Event HandlingClick, submit, input, change, keyboard
Forms & ValidationReal-time validation, setCustomValidity
Storage APIsLocalStorage CRUD
JSONSerialization/deserialization
Async/await(Optional: for future API extensions)
Error HandlingTry/catch, custom Error subclasses
Design PatternsModule pattern, revealing module
TestingJest 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:

  1. Fundamentals — Variables, data types, operators, strings, numbers, conditionals, loops, functions
  2. Core Programming — Arrays, objects, destructuring, closures, higher-order functions, error handling
  3. Modern JavaScript & Browser APIs — DOM, events, forms, storage, JSON, Fetch, async/await
  4. 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

EARN AT THE COMFORT OF YOUR HOME

Sponsored content

Popular posts from this blog

Day 5 – Functions, Scope, and Docstrings

Day 10 – Exception Handling and Debugging

Day 1 – Python Foundations for AEC Professionals