Module 10 · Capstone - Real-World Projects ⏱ 20 min

Project — Expense Tracker

By the end of this lesson you will be able to:
  • Store transactions as objects in an array and access them reliably
  • Filter and reduce arrays to compute category totals and overall summaries
  • Build a small tool by composing pure functions that transform data

Managing money is one of the oldest reasons to write a program. An expense tracker records what you spent, organises it by category, and tells you where the money went. The same pattern appears in inventory systems, time trackers, and fitness logs: append a record, then summarise it. Every application that tracks state over time uses this same append-then-summarise shape.

You will build the tracker in four milestones:

  1. Record a transaction.
  2. List transactions by category.
  3. Sum spending per category.
  4. Build a full summary report.

Each milestone is a function. The final report is just those functions composed together. This is how professional code is written: small pieces that do one thing well, then wired into a pipeline.

flowchart LR
  T["Transaction object"] --> A["Ledger array"]
  A --> F["Filter by category"]
  F --> R["Reduce to total"]
  R --> S["Summary object"]
  style A fill:#e0900b,color:#fff
  style F fill:#e0900b,color:#fff
  style R fill:#e0900b,color:#fff
The expense-tracker pipeline: record, filter, reduce, then summarise.

Milestone 1 — Record a transaction

A transaction is an object with three properties: description (what it was), amount (a number), and category (a string like 'food' or 'transport'). Storing it means appending that object to an array.

The function addExpense(expenses, description, amount, category) builds the object and pushes it onto the array. Because the array is passed in as a parameter, the function does not need to know where the rest of the program keeps its data — a small separation that makes testing easy. You can create an empty array in a test, call addExpense, and inspect the array to prove the function worked.

Create a ledger and append two transactions. Press Run.
function addExpense(expenses, description, amount, category) {
  expenses.push({ description, amount, category });
  return expenses;
}

const ledger = [];
addExpense(ledger, "Coffee", 4.5, "food");
addExpense(ledger, "Bus", 2.0, "transport");
console.log(ledger.length);
console.log(ledger[0].amount);
Exercise

Write addExpense(expenses, description, amount, category) that pushes an object onto the expenses array and returns the array.

function addExpense(expenses, description, amount, category) {
  // push an object and return expenses
}

Milestone 2 — Filter by category

Once you have a ledger, you will often want to see only the food, or only the transport. The filter method is perfect here: it walks the array and returns a new array containing only the items where your test returned true.

Notice that filter does not change the original ledger. It produces a new, smaller array. That immutability is safer: you can filter the same ledger ten different ways and never worry that one view corrupted another. The original stays intact for the next operation. This matters because later you might want the full ledger for a grand total and a filtered view for a category breakdown at the same time.

filter produces a new array without touching the original.
function byCategory(expenses, category) {
  return expenses.filter(e => e.category === category);
}

const ledger = [
  { description: "Coffee", amount: 4.5, category: "food" },
  { description: "Bus", amount: 2.0, category: "transport" },
  { description: "Bagel", amount: 3.0, category: "food" }
];
console.log(byCategory(ledger, "food").length);
console.log(byCategory(ledger, "transport")[0].description);
Exercise

Write byCategory(expenses, category) that returns a new array containing only the expenses whose category matches the given string.

function byCategory(expenses, category) {
  // return filtered array
}

Milestone 3 — Sum by category

To answer 'how much did I spend on food?' you need two steps: filter to the category, then add the amounts. The reduce method walks an array and accumulates a single value. Give it a starting total of 0 and an accumulator function that adds e.amount each step.

The pattern array.reduce((sum, item) => sum + item.property, 0) is one of the most common in JavaScript. Memorise its shape: the callback receives the running total and the current item, and returns the new total. If the array is empty and you omit the 0, reduce throws an error — a trap you will avoid by always providing that initial value.

flowchart TD
  A["array[0]"] --> R1["sum = 0 + amount0"]
  R1 --> R2["sum = amount0 + amount1"]
  R2 --> R3["sum = ... + amountN"]
  R3 --> F["final total"]
  style R1 fill:#e0900b,color:#fff
  style F fill:#1e7f3d,color:#fff
reduce walks the array, carrying a running sum that starts at the initial value.
Chain filter and reduce to sum a single category.
function totalForCategory(expenses, category) {
  return expenses
    .filter(e => e.category === category)
    .reduce((sum, e) => sum + e.amount, 0);
}

const ledger = [
  { description: "Coffee", amount: 4.5, category: "food" },
  { description: "Bagel", amount: 3.0, category: "food" }
];
console.log(totalForCategory(ledger, "food"));
console.log(totalForCategory(ledger, "transport"));
Exercise

Predict the output. The function filters by category, then reduces the amounts.

const items = [
  { amount: 10, category: "a" },
  { amount: 20, category: "a" },
  { amount: 5, category: "b" }
];
function totalFor(items, cat) {
  return items
    .filter(i => i.category === cat)
    .reduce((s, i) => s + i.amount, 0);
}
console.log(totalFor(items, "a"));
console.log(totalFor(items, "b"));

Milestone 4 — Full summary report

The final milestone assembles everything into one object. summary(expenses) returns an object with total (all spending), count (how many transactions), and average (total divided by count). If the ledger is empty, the average should be 0 to avoid dividing by zero. A divide-by-zero does not throw in JavaScript — it returns Infinity — which is almost never what you want a summary to show.

This function does not reach into the DOM or a database. It takes an array, transforms it, and returns an object. That purity is what makes it testable: you can hand it a fake array of three items and know exactly what you should get back. Testable functions are the payoff of good planning.

flowchart LR
  L["ledger array"] --> T["reduce: total"]
  L --> C["length: count"]
  T --> D["total / count"]
  D --> S["{ total, count, average }"]
  style T fill:#e0900b,color:#fff
  style S fill:#1e7f3d,color:#fff
The summary object is built from the ledger's total, length, and average.
Exercise

Write summary(expenses) that returns an object { total, count, average }. total is the sum of all amounts, count is the number of transactions, and average is total / count. If the array is empty, average must be 0.

function summary(expenses) {
  // return { total, count, average }
}
Exercise

Transfer task. Write categoryBreakdown(expenses) that returns an object mapping each category to its total spending. For example, two food items and one transport item produce { food: total, transport: total }. Use a loop and the || 0 guard to start each category at zero.

function categoryBreakdown(expenses) {
  // return { category: total, ... }
}

Recap

  • A transaction is an object; a ledger is an array of those objects.
  • filter selects a subset without mutating the original.
  • reduce accumulates a single value from an array; always provide an initial value.
  • Compose small functions — add, filter, reduce — into a complete summary report.
  • Guard against empty arrays to avoid divide-by-zero and reduce errors.

Next you will simulate how a module bundler resolves dependencies and executes code in the right order.

Checkpoint quiz

Why should you provide an initial value to reduce?

What does filter return when no items match the test?

Go deeper — technical resources