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.
| Feature | Technique Used |
|---|---|
| Add expense/income | Form submit → push to array → persist → re-render |
| Delete entry | Filter by ID → persist → re-render |
| Balance calculation | reduce() — income minus expenses |
| Category filter | Filter array before rendering |
| localStorage persist | JSON.stringify/parse on every change |
| Bar chart | DOM div heights set proportionally |
| Category totals | reduce() grouped by category |
HTML Structure
/* 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
// 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
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
// 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();
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().
Array.reduce() for aggregation, event delegation, Intl.NumberFormat for currency formatting, data visualization without libraries, and crypto.randomUUID() for unique IDs.
Bar Chart CSS
/* 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-listfor 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.NumberFormatinstead of manual string formatting demonstrates modern API awareness - CRUD + localStorage is the core pattern behind every local-first web application