Home Module 15 Expense Tracker

Project Overview

A personal finance tracker that demonstrates the full power of Array.reduce() for aggregation, along with form validation, category filtering, and computed summaries. This is the kind of data-manipulation code you will write constantly in real jobs.

What you will build

  • Add transactions: description, amount (positive = income, negative = expense), category, date
  • Running balance displayed prominently (green/red)
  • Total income and total expenses summary
  • Transaction list with delete button per entry
  • Filter by category or transaction type (income / expense)
  • Category breakdown showing spend per category
  • All data persisted in localStorage

Concepts used

  • Array.reduce() to compute totals and group by category
  • Form validation with live feedback
  • Intl.NumberFormat for currency display
  • localStorage + JSON for persistence
  • Filter and sort on a data array

Key Logic

Computing the balance with reduce

const balance  = transactions.reduce((sum, t) => sum + t.amount, 0);
const income   = transactions.filter(t => t.amount > 0)
                              .reduce((sum, t) => sum + t.amount, 0);
const expenses = transactions.filter(t => t.amount < 0)
                              .reduce((sum, t) => sum + t.amount, 0);

Grouping by category with reduce

const byCategory = transactions
  .filter(t => t.amount < 0)           // expenses only
  .reduce((acc, t) => {
    acc[t.category] = (acc[t.category] ?? 0) + Math.abs(t.amount);
    return acc;
  }, {});
// Result: { Food: 45.50, Transport: 12.00, ... }

Currency formatting

const fmt = new Intl.NumberFormat('en-GB', {
  style:    'currency',
  currency: 'GBP',
  minimumFractionDigits: 2
});

fmt.format(1234.5);   // "£1,234.50"
fmt.format(-45);      // "-£45.00"

Transaction data structure

const transaction = {
  id:          Date.now().toString(),
  description: 'Grocery shopping',
  amount:      -42.50,     // negative = expense
  category:    'Food',
  date:        '2026-06-01'
};

Complete Working Code

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Expense Tracker</title>
  <style>
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: system-ui, sans-serif; background: #f8fafc; min-height: 100vh; padding: 1.5rem 1rem; }
    .app { max-width: 680px; margin: 0 auto; }
    h1 { font-size: 2rem; color: #1e293b; margin-bottom: 1.5rem; text-align: center; }

    /* Summary bar */
    .summary { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: .75rem; margin-bottom: 1.5rem; }
    .summary-box { background: #fff; border-radius: .75rem; padding: 1rem; text-align: center;
                   box-shadow: 0 1px 3px #0001; }
    .s-label { font-size: .75rem; color: #64748b; text-transform: uppercase; letter-spacing: .05em; }
    .s-val   { font-size: 1.5rem; font-weight: 800; margin-top: .25rem; }
    .s-val.pos { color: #22c55e; }
    .s-val.neg { color: #ef4444; }
    .s-val.neutral { color: #1e293b; }

    /* Form */
    .add-card { background: #fff; border-radius: .75rem; padding: 1.25rem; margin-bottom: 1.5rem;
                box-shadow: 0 1px 3px #0001; }
    .add-card h2 { font-size: 1rem; color: #1e293b; margin-bottom: .85rem; }
    .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; }
    .form-grid input, .form-grid select {
      padding: .6rem .75rem; border: 2px solid #e2e8f0; border-radius: .5rem;
      font-size: .9rem; width: 100%; outline: none;
    }
    .form-grid input:focus, .form-grid select:focus { border-color: #3b82f6; }
    .form-grid .span2 { grid-column: 1 / -1; }
    .add-btn { width: 100%; padding: .7rem; background: #3b82f6; color: #fff;
               border: none; border-radius: .5rem; font-size: .95rem;
               font-weight: 600; cursor: pointer; margin-top: .6rem; }
    .add-btn:hover { background: #2563eb; }
    .field-err { font-size: .75rem; color: #ef4444; display: none; margin-top: .2rem; }

    /* Filter bar */
    .filter-bar { display: flex; gap: .5rem; margin-bottom: 1rem; flex-wrap: wrap; }
    .filter-bar select {
      padding: .45rem .7rem; border: 2px solid #e2e8f0; border-radius: .4rem;
      font-size: .85rem; outline: none; background: #fff;
    }

    /* Transaction list */
    .tx-list { background: #fff; border-radius: .75rem; overflow: hidden;
               box-shadow: 0 1px 3px #0001; margin-bottom: 1.5rem; }
    .tx-item { display: flex; align-items: center; gap: .75rem; padding: .9rem 1.1rem;
               border-bottom: 1px solid #f1f5f9; }
    .tx-item:last-child { border-bottom: none; }
    .tx-cat-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
    .tx-desc  { flex: 1; font-size: .9rem; color: #1e293b; }
    .tx-meta  { font-size: .72rem; color: #94a3b8; }
    .tx-amount { font-weight: 700; font-size: .95rem; min-width: 80px; text-align: right; }
    .tx-amount.income  { color: #22c55e; }
    .tx-amount.expense { color: #ef4444; }
    .tx-del { background: none; border: none; color: #cbd5e1; font-size: 1rem;
              cursor: pointer; padding: .2rem; opacity: 0; transition: opacity .15s; }
    .tx-item:hover .tx-del { opacity: 1; }
    .tx-del:hover { color: #ef4444; }
    .tx-empty { text-align: center; padding: 2rem; color: #94a3b8; font-size: .9rem; }

    /* Category breakdown */
    .breakdown-card { background: #fff; border-radius: .75rem; padding: 1.25rem;
                      box-shadow: 0 1px 3px #0001; }
    .breakdown-card h2 { font-size: 1rem; color: #1e293b; margin-bottom: .85rem; }
    .breakdown-row { display: flex; align-items: center; gap: .75rem; margin-bottom: .6rem; }
    .b-label { width: 110px; font-size: .82rem; color: #475569; }
    .b-bar-wrap { flex: 1; background: #f1f5f9; border-radius: 999px; height: 8px; overflow: hidden; }
    .b-bar { height: 100%; border-radius: 999px; transition: width .4s; }
    .b-val { font-size: .82rem; color: #475569; min-width: 55px; text-align: right; }
  </style>
</head>
<body>
  <div class="app">
    <h1>Expense Tracker</h1>

    <div class="summary">
      <div class="summary-box">
        <div class="s-label">Balance</div>
        <div class="s-val neutral" id="s-balance">£0.00</div>
      </div>
      <div class="summary-box">
        <div class="s-label">Income</div>
        <div class="s-val pos" id="s-income">£0.00</div>
      </div>
      <div class="summary-box">
        <div class="s-label">Expenses</div>
        <div class="s-val neg" id="s-expense">£0.00</div>
      </div>
    </div>

    <div class="add-card">
      <h2>Add Transaction</h2>
      <form id="add-form">
        <div class="form-grid">
          <input id="f-desc" placeholder="Description" autocomplete="off" class="span2">
          <div>
            <input id="f-amount" type="number" step="0.01" placeholder="Amount (use − for expense)">
            <span class="field-err" id="err-amount">Enter a non-zero amount</span>
          </div>
          <input id="f-date" type="date">
          <select id="f-category">
            <option value="">Category…</option>
            <option>Food</option>
            <option>Transport</option>
            <option>Housing</option>
            <option>Health</option>
            <option>Entertainment</option>
            <option>Shopping</option>
            <option>Salary</option>
            <option>Other</option>
          </select>
          <select id="f-type">
            <option value="expense">Expense (−)</option>
            <option value="income">Income (+)</option>
          </select>
        </div>
        <button class="add-btn" type="submit">Add Transaction</button>
      </form>
    </div>

    <div class="filter-bar">
      <select id="filter-type">
        <option value="all">All types</option>
        <option value="income">Income only</option>
        <option value="expense">Expenses only</option>
      </select>
      <select id="filter-cat">
        <option value="all">All categories</option>
      </select>
    </div>

    <div class="tx-list" id="tx-list"></div>
    <div class="breakdown-card">
      <h2>Spending by Category</h2>
      <div id="breakdown"></div>
    </div>
  </div>

  <script>
    const STORAGE_KEY = 'expenses_v1';
    const CAT_COLORS = {
      Food: '#f59e0b', Transport: '#3b82f6', Housing: '#8b5cf6',
      Health: '#10b981', Entertainment: '#ec4899', Shopping: '#f97316',
      Salary: '#22c55e', Other: '#94a3b8'
    };

    const fmt = new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' });

    function load() {
      try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? sampleData(); }
      catch { return sampleData(); }
    }
    function save() { localStorage.setItem(STORAGE_KEY, JSON.stringify(transactions)); }

    function sampleData() {
      return [
        { id: '1', description: 'Monthly salary', amount: 2500, category: 'Salary',        date: '2026-06-01' },
        { id: '2', description: 'Grocery run',    amount: -52.40, category: 'Food',        date: '2026-06-01' },
        { id: '3', description: 'Bus pass',        amount: -28, category: 'Transport',     date: '2026-06-01' },
        { id: '4', description: 'Cinema tickets',  amount: -15, category: 'Entertainment', date: '2026-06-01' },
      ];
    }

    let transactions = load();
    let filterType = 'all';
    let filterCat  = 'all';

    // Populate category filter
    function buildCatFilter() {
      const sel = document.querySelector('#filter-cat');
      const cats = [...new Set(transactions.map(t => t.category))].sort();
      sel.innerHTML = '<option value="all">All categories</option>';
      cats.forEach(c => {
        const o = document.createElement('option');
        o.value = c; o.textContent = c;
        sel.appendChild(o);
      });
    }

    function render() {
      save();
      buildCatFilter();

      // Summary (always over ALL transactions)
      const balance  = transactions.reduce((s, t) => s + t.amount, 0);
      const income   = transactions.filter(t => t.amount > 0).reduce((s, t) => s + t.amount, 0);
      const expenses = transactions.filter(t => t.amount < 0).reduce((s, t) => s + t.amount, 0);

      const balEl = document.querySelector('#s-balance');
      balEl.textContent = fmt.format(balance);
      balEl.className = `s-val ${balance > 0 ? 'pos' : balance < 0 ? 'neg' : 'neutral'}`;
      document.querySelector('#s-income').textContent  = fmt.format(income);
      document.querySelector('#s-expense').textContent = fmt.format(Math.abs(expenses));

      // Filtered list
      const visible = transactions
        .filter(t => filterType === 'all' ? true : filterType === 'income' ? t.amount > 0 : t.amount < 0)
        .filter(t => filterCat === 'all' || t.category === filterCat)
        .sort((a, b) => b.date.localeCompare(a.date));

      const listEl = document.querySelector('#tx-list');
      listEl.innerHTML = '';

      if (!visible.length) {
        listEl.innerHTML = '<p class="tx-empty">No transactions match the filter.</p>';
      } else {
        visible.forEach(t => {
          const item = document.createElement('div');
          item.className = 'tx-item';

          const dot = document.createElement('div');
          dot.className = 'tx-cat-dot';
          dot.style.background = CAT_COLORS[t.category] ?? '#94a3b8';

          const info = document.createElement('div');
          info.style.flex = '1';
          const desc = document.createElement('div');
          desc.className = 'tx-desc';
          desc.textContent = t.description;
          const meta = document.createElement('div');
          meta.className = 'tx-meta';
          meta.textContent = `${t.category} · ${t.date}`;
          info.append(desc, meta);

          const amt = document.createElement('div');
          amt.className = `tx-amount ${t.amount > 0 ? 'income' : 'expense'}`;
          amt.textContent = (t.amount > 0 ? '+' : '') + fmt.format(t.amount);

          const del = document.createElement('button');
          del.className = 'tx-del';
          del.dataset.id = t.id;
          del.textContent = '✕';
          del.title = 'Delete';

          item.append(dot, info, amt, del);
          listEl.appendChild(item);
        });
      }

      // Category breakdown
      const byCategory = transactions
        .filter(t => t.amount < 0)
        .reduce((acc, t) => {
          acc[t.category] = (acc[t.category] ?? 0) + Math.abs(t.amount);
          return acc;
        }, {});

      const totalSpend = Object.values(byCategory).reduce((s, v) => s + v, 0);
      const breakdownEl = document.querySelector('#breakdown');
      breakdownEl.innerHTML = '';

      if (!totalSpend) {
        breakdownEl.innerHTML = '<p style="color:#94a3b8;font-size:.85rem">No expenses yet.</p>';
        return;
      }

      Object.entries(byCategory)
        .sort((a, b) => b[1] - a[1])
        .forEach(([cat, amount]) => {
          const pct = (amount / totalSpend) * 100;
          const row = document.createElement('div');
          row.className = 'breakdown-row';
          row.innerHTML = `
            <div class="b-label">${cat}</div>
            <div class="b-bar-wrap">
              <div class="b-bar" style="width:${pct}%;background:${CAT_COLORS[cat] ?? '#94a3b8'}"></div>
            </div>
            <div class="b-val">${fmt.format(amount)}</div>
          `;
          breakdownEl.appendChild(row);
        });
    }

    // Add form
    document.querySelector('#add-form').addEventListener('submit', e => {
      e.preventDefault();
      const desc   = document.querySelector('#f-desc').value.trim();
      const raw    = parseFloat(document.querySelector('#f-amount').value);
      const date   = document.querySelector('#f-date').value || new Date().toISOString().slice(0, 10);
      const cat    = document.querySelector('#f-category').value || 'Other';
      const type   = document.querySelector('#f-type').value;
      const errEl  = document.querySelector('#err-amount');

      if (!desc || isNaN(raw) || raw === 0) {
        errEl.style.display = 'block';
        return;
      }
      errEl.style.display = 'none';

      const amount = type === 'expense' ? -Math.abs(raw) : Math.abs(raw);
      transactions = [{ id: Date.now().toString(), description: desc, amount, category: cat, date }, ...transactions];
      e.target.reset();
      render();
    });

    // Delete via delegation
    document.querySelector('#tx-list').addEventListener('click', e => {
      const btn = e.target.closest('.tx-del');
      if (!btn) return;
      transactions = transactions.filter(t => t.id !== btn.dataset.id);
      render();
    });

    // Filters
    document.querySelector('#filter-type').addEventListener('change', e => { filterType = e.target.value; render(); });
    document.querySelector('#filter-cat').addEventListener('change',  e => { filterCat  = e.target.value; render(); });

    // Init
    document.querySelector('#f-date').value = new Date().toISOString().slice(0, 10);
    render();
  </script>
</body>
</html>

Code Explained

reduce() for aggregation

Three reduce() calls compute the balance, total income, and total expenses. reduce() is the correct tool whenever you need to collapse an array into a single value — whether a sum, count, or even a grouped object. The category breakdown uses a different form of reduce that accumulates into an object (acc[t.category] = ...).

Intl.NumberFormat for currency

Instead of manually formatting currency strings, the built-in Intl.NumberFormat handles locale-specific separators, decimal places, and currency symbols automatically. Creating the formatter once and reusing it (rather than calling new Intl.NumberFormat() inside a loop) is more efficient.

sign controlled by type dropdown

Rather than asking users to type negative numbers for expenses, the app has an "Expense / Income" dropdown and uses Math.abs() to normalise the amount, then applies the correct sign. This prevents user confusion and input errors.

Challenges

  1. Add a monthly budget per category — show a warning bar when spending exceeds 80% of the budget.
  2. Add a date range filter (from/to date pickers) to view a specific month or week.
  3. Draw a simple pie/donut chart using SVG paths to visualise the category breakdown.
  4. Add recurring transactions — a flag that automatically adds the same transaction on the first of each month.
  5. Export the transaction list as a CSV file using Blob and URL.createObjectURL().