Ad – 728×90
🛠️ Projects

JavaScript Expense Tracker – Build a Budget App

Build a complete personal budget tracker: add income and expense entries with categories, calculate real-time balance, filter by category, persist all data to localStorage, and visualize spending with a bar chart built entirely from DOM elements — no chart library required.

⏱️ 32 min read 🎯 Advanced 📅 Updated 2026

Project Overview

The expense tracker is a full CRUD application covering the complete Create-Read-Update-Delete lifecycle. It also introduces data aggregation with Array.reduce() and a custom data visualization built from pure DOM elements.

FeatureTechnique Used
Add expense/incomeForm submit → push to array → persist → re-render
Delete entryFilter by ID → persist → re-render
Balance calculationreduce() — income minus expenses
Category filterFilter array before rendering
localStorage persistJSON.stringify/parse on every change
Bar chartDOM div heights set proportionally
Category totalsreduce() grouped by category

HTML Structure

JavaScript
/* expense.html

<div class="expense-app">
  <!-- Summary Cards -->
  <div class="summary-cards">
    <div class="card card-income">  <h3>Income</h3>   <span id="total-income">$0.00</span></div>
    <div class="card card-balance"> <h3>Balance</h3>  <span id="balance">$0.00</span></div>
    <div class="card card-expense"> <h3>Expenses</h3> <span id="total-expense">$0.00</span></div>
  </div>

  <!-- Add Entry Form -->
  <form id="entry-form" class="entry-form">
    <input id="entry-name"    type="text"   placeholder="Description" required>
    <input id="entry-amount" type="number" placeholder="Amount" step="0.01" min="0.01" required>
    <select id="entry-type">
      <option value="expense">Expense</option>
      <option value="income">Income</option>
    </select>
    <select id="entry-category">
      <option value="Food">Food</option>
      <option value="Transport">Transport</option>
      <option value="Housing">Housing</option>
      <option value="Entertainment">Entertainment</option>
      <option value="Health">Health</option>
      <option value="Other">Other</option>
      <option value="Salary">Salary</option>
      <option value="Freelance">Freelance</option>
    </select>
    <button type="submit">Add Entry</button>
  </form>

  <!-- Filter + List -->
  <div class="filter-row">
    <select id="category-filter"><option value="all">All Categories</option></select>
    <select id="type-filter">
      <option value="all">All Types</option>
      <option value="income">Income</option>
      <option value="expense">Expense</option>
    </select>
  </div>
  <ul id="entry-list"></ul>

  <!-- Bar Chart -->
  <div class="chart-section">
    <h3>Spending by Category</h3>
    <div id="bar-chart" class="bar-chart"></div>
  </div>
</div>
*/

State and CRUD Operations

JavaScript
// expense-script.js — State & CRUD

const STORAGE_KEY = "ylearner-expenses";
const CATEGORIES  = ["Food","Transport","Housing","Entertainment","Health","Other","Salary","Freelance"];

let entries = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
let filterCategory = "all";
let filterType     = "all";

// Persistence
function persist() {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
}

// Create
function addEntry({ name, amount, type, category }) {
  entries.unshift({          // prepend so newest is first
    id:        crypto.randomUUID(),
    name,
    amount:    parseFloat(amount),
    type,      // "income" | "expense"
    category,
    date:      new Date().toLocaleDateString("en-GB", { day:"2-digit", month:"short", year:"numeric" })
  });
  persist();
}

// Delete
function deleteEntry(id) {
  entries = entries.filter(e => e.id !== id);
  persist();
}

// Computed values using reduce
function computeSummary() {
  return entries.reduce((acc, e) => {
    if (e.type === "income")  acc.income  += e.amount;
    else                      acc.expense += e.amount;
    return acc;
  }, { income: 0, expense: 0 });
}

function computeCategoryTotals() {
  return entries
    .filter(e => e.type === "expense")
    .reduce((acc, e) => {
      acc[e.category] = (acc[e.category] || 0) + e.amount;
      return acc;
    }, {});
}

const fmt = n => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n);

Render Function

JavaScript
function render() {
  renderSummary();
  renderList();
  renderChart();
  updateCategoryFilter();
}

function renderSummary() {
  const { income, expense } = computeSummary();
  const balance = income - expense;

  document.getElementById("total-income").textContent  = fmt(income);
  document.getElementById("total-expense").textContent = fmt(expense);
  const balEl = document.getElementById("balance");
  balEl.textContent = fmt(balance);
  balEl.className = balance >= 0 ? "positive" : "negative";
}

function renderList() {
  const list = document.getElementById("entry-list");
  const visible = entries.filter(e => {
    const matchCat  = filterCategory === "all" || e.category === filterCategory;
    const matchType = filterType     === "all" || e.type     === filterType;
    return matchCat && matchType;
  });

  list.innerHTML = visible.length === 0
    ? '<li class="empty">No entries match your filters.</li>'
    : visible.map(e => `
      <li class="entry-item ${e.type}" data-id="${e.id}">
        <div class="entry-icon">${categoryEmoji(e.category)}</div>
        <div class="entry-info">
          <strong>${e.name}</strong>
          <small>${e.category} · ${e.date}</small>
        </div>
        <span class="entry-amount ${e.type}">
          ${e.type === "income" ? "+" : "−"}${fmt(e.amount)}
        </span>
        <button class="delete-btn" title="Delete">✕</button>
      </li>
    `).join("");
}

function renderChart() {
  const chart  = document.getElementById("bar-chart");
  const totals = computeCategoryTotals();
  const cats   = Object.keys(totals);
  if (cats.length === 0) { chart.innerHTML = '<p class="no-data">No expense data yet.</p>'; return; }

  const max = Math.max(...Object.values(totals));
  chart.innerHTML = cats.map(cat => {
    const height = Math.round((totals[cat] / max) * 150); // max bar height 150px
    return `
      <div class="bar-col">
        <div class="bar-value">${fmt(totals[cat])}</div>
        <div class="bar" style="height:${height}px;" title="${cat}: ${fmt(totals[cat])}"></div>
        <div class="bar-label">${categoryEmoji(cat)} ${cat}</div>
      </div>
    `;
  }).join("");
}

function updateCategoryFilter() {
  const sel  = document.getElementById("category-filter");
  const used = [...new Set(entries.map(e => e.category))];
  sel.innerHTML = `<option value="all">All Categories</option>` +
    used.map(c => `<option value="${c}" ${c === filterCategory ? "selected" : ""}>${c}</option>`).join("");
}

function categoryEmoji(cat) {
  const map = { Food:"🍔", Transport:"🚗", Housing:"🏠", Entertainment:"🎬",
                Health:"💊", Other:"📦", Salary:"💼", Freelance:"💻" };
  return map[cat] || "📋";
}

Event Handlers

JavaScript
// Add entry
document.getElementById("entry-form").addEventListener("submit", e => {
  e.preventDefault();
  addEntry({
    name:     document.getElementById("entry-name").value.trim(),
    amount:   document.getElementById("entry-amount").value,
    type:     document.getElementById("entry-type").value,
    category: document.getElementById("entry-category").value
  });
  e.target.reset();
  render();
});

// Delete (event delegation)
document.getElementById("entry-list").addEventListener("click", e => {
  if (e.target.matches(".delete-btn")) {
    const id = e.target.closest("[data-id]").dataset.id;
    deleteEntry(id);
    render();
  }
});

// Category filter
document.getElementById("category-filter").addEventListener("change", e => {
  filterCategory = e.target.value;
  render();
});

// Type filter
document.getElementById("type-filter").addEventListener("change", e => {
  filterType = e.target.value;
  render();
});

// Seed with sample data if empty
if (entries.length === 0) {
  [
    { name: "Monthly Salary",   amount: 3000, type: "income",  category: "Salary" },
    { name: "Rent",             amount: 900,  type: "expense", category: "Housing" },
    { name: "Groceries",        amount: 120,  type: "expense", category: "Food" },
    { name: "Netflix",          amount: 15,   type: "expense", category: "Entertainment" },
    { name: "Gym",              amount: 40,   type: "expense", category: "Health" },
    { name: "Bus pass",         amount: 55,   type: "expense", category: "Transport" },
  ].forEach(addEntry);
}

render();
Initial State: Balance shows $1,870.00. Income card: $3,000. Expenses card: $1,130. Bar chart shows Housing as the tallest bar, then Food, Transport, Health, Entertainment.
Array.reduce() Power: Both computeSummary() and computeCategoryTotals() are single-pass reduce() calls. This is the most important array method for data aggregation. Master it: sum, group-by, flatten, and transform — all with reduce().
Concepts Practiced: Full CRUD with localStorage, Array.reduce() for aggregation, event delegation, Intl.NumberFormat for currency formatting, data visualization without libraries, and crypto.randomUUID() for unique IDs.
Ad – 336×280

Bar Chart CSS

JavaScript
/* Key chart styles

.bar-chart {
  display: flex;
  align-items: flex-end;
  gap: 16px;
  padding: 20px 0;
  min-height: 200px;
  border-bottom: 2px solid #e2e8f0;
}
.bar-col {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
}
.bar {
  width: 100%;
  background: linear-gradient(to top, #3b82f6, #8b5cf6);
  border-radius: 6px 6px 0 0;
  transition: height 0.5s ease;
  min-height: 4px;
}
.bar-value { font-size: 11px; color: #64748b; }
.bar-label { font-size: 11px; text-align: center; }
*/

Enhancements

Extend the Expense Tracker

  • Monthly view — add a month selector and filter entries to the chosen month
  • Export to CSV — implement exportToCSV() using Blob and a dynamically created download link
  • Budget limits — set a monthly budget per category and highlight over-budget categories in red
  • Edit entry — click an entry to edit its values inline (similar to the Todo App's contenteditable pattern)
  • Pie chart — convert the bar chart to a CSS conic-gradient pie chart

CSV Export Challenge

Implement a one-click CSV download of all entries:

function exportToCSV() {
  const headers = ["Date","Name","Category","Type","Amount"];
  const rows = entries.map(e =>
    [e.date, e.name, e.category, e.type, e.amount.toFixed(2)]
  );
  const csv = [headers, ...rows].map(r => r.join(",")).join("\n");
  const blob = new Blob([csv], { type: "text/csv" });
  const url  = URL.createObjectURL(blob);
  const a    = document.createElement("a");
  a.href = url; a.download = "expenses.csv";
  a.click();
  URL.revokeObjectURL(url); // clean up
}

FAQ

Why use crypto.randomUUID() instead of Date.now() for IDs?

crypto.randomUUID() generates a cryptographically random UUID — guaranteed unique even if multiple entries are added in the same millisecond. Supported in all modern browsers. Fall back to Date.now() + Math.random() for older environments.

How does the bar chart work without a library?

Each bar is a div with a height set as a percentage of the tallest category total. CSS transitions animate the height change. The approach works because SVG and Canvas are not required for simple bar charts — CSS is sufficient.

How does Intl.NumberFormat work?

new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(1234.5) produces "$1,234.50". It handles locale-specific formatting (commas vs periods, currency symbols, decimal places) automatically. Pass a different locale for other currencies.

Why prepend new entries instead of appending?

entries.unshift() adds to the front, so the newest entry is always first in the rendered list — a natural chronological expectation for a transaction log. Alternatively, sort by date descending.

How would I add a monthly budget backend?

Use IndexedDB for structured querying (filter by month, group by category). Or sync to a backend API (Node.js + PostgreSQL/MongoDB) so data is accessible across devices. The CRUD functions here map directly to REST API calls.

Summary

  • Full CRUD: add, delete, read with filters — all persisted to localStorage
  • Array.reduce(): powers both the balance summary and the grouped category chart data
  • Event delegation: one handler on #entry-list for all delete buttons
  • Intl.NumberFormat: proper currency display without manual string formatting
  • DOM bar chart: proportional heights via inline CSS — no chart library needed
  • Array.reduce() mastery is a top-tier JavaScript skill — it's asked in virtually every senior JS interview
  • Building a chart without a library shows DOM manipulation confidence
  • Using Intl.NumberFormat instead of manual string formatting demonstrates modern API awareness
  • CRUD + localStorage is the core pattern behind every local-first web application