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:
- Record a transaction.
- List transactions by category.
- Sum spending per category.
- 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
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.
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);
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
}
Use expenses.push({ description, amount, category }).
Return the expenses array so the caller can chain calls if they want.
function addExpense(expenses, description, amount, category) {
expenses.push({ description, amount, category });
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.
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);
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
}
Use expenses.filter with a test e.category === category.
filter returns a new array; it does not mutate the original.
function byCategory(expenses, category) {
return expenses.filter(e => e.category === category);
}
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
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"));
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"));
Category a has 10 + 20 = 30.
Category b has 5.
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
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 }
}
Use reduce with an initial value of 0 to compute the total safely.
Guard against division by zero: if count is 0, set average to 0.
function summary(expenses) {
const total = expenses.reduce((sum, e) => sum + e.amount, 0);
const count = expenses.length;
const average = count === 0 ? 0 : total / count;
return { total, count, average };
}
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, ... }
}
Loop through expenses and accumulate into a totals object.
totals[e.category] = (totals[e.category] || 0) + e.amount starts unknown categories at 0.
function categoryBreakdown(expenses) {
const totals = {};
for (const e of expenses) {
totals[e.category] = (totals[e.category] || 0) + e.amount;
}
return totals;
}
Recap
- A transaction is an object; a ledger is an array of those objects.
filterselects a subset without mutating the original.reduceaccumulates 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.