Project 14 · Advanced
Expense Tracker
A spending tracker in naira. Add what you spent and its category, and the total and each category's share update straight away. Everything is saved in the browser, and the money maths stays exact by counting in kobo.
- 3 HTML ideas
- 2 CSS ideas
- 7 JavaScript ideas
Why build it
Counting money in its smallest unit and formatting it with Intl lets you build a budget tracker, a shopping basket with totals and tax, an invoice generator, or an app that splits a bill between friends. Money has to add up exactly and look right for the reader's country, and these are the techniques real finance apps use.
Skills you'll earn
- Add up money exactly by counting in its smallest unit, like kobo or cents
- Show money and dates the way a reader's country writes them, with Intl
- Total and group data with reduce and objects
- Sort grouped data with Object.entries and sort
- Save data in the browser with localStorage
01
Try it
Play with it first, then read the code that makes it work.
<!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>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="tracker">
<h1 class="tracker-title">Expense tracker</h1>
<!-- aria-live reads the new total after every change. -->
<section class="summary" aria-label="Summary">
<p class="total-label">Total spent</p>
<p class="total" id="total" aria-live="polite"></p>
<ul class="categories" id="categories"></ul>
</section>
<form class="expense-form" id="expense-form">
<div class="field field-wide">
<label for="description">What was it?</label>
<input
id="description"
type="text"
maxlength="60"
autocomplete="off"
required
/>
</div>
<div class="field">
<label for="amount">Amount (₦)</label>
<!-- inputmode="decimal" shows a number keypad with a decimal point on phones. -->
<input
id="amount"
type="number"
inputmode="decimal"
min="0.01"
step="0.01"
required
/>
</div>
<div class="field">
<label for="category">Category</label>
<select id="category">
<option>Food</option>
<option>Transport</option>
<option>Bills</option>
<option>Fun</option>
<option>Other</option>
</select>
</div>
<button type="submit" class="add-button">Add expense</button>
</form>
<h2 class="list-title">Spending</h2>
<ul class="expenses" id="expenses"></ul>
<p class="empty" id="empty">No spending yet. Add your first expense above.</p>
</main>
<script src="script.js"></script>
</body>
</html>02
How it works
The ideas behind it, one at a time, each next to the code that does it.
HTMLthe structure
- 01
Announce the total with
aria-livearia-livemarks an element whose updates screen readers should read out, andpolitemeans they wait for a pause rather than interrupting. The total changes every time you add or delete an expense, so puttingaria-live="polite"on it means screen reader users hear the new total and know their change worked.<!-- aria-live reads the new total after every change. --> <section class="summary" aria-label="Summary"> <p class="total-label">Total spent</p> <p class="total" id="total" aria-live="polite"></p> <ul class="categories" id="categories"></ul> </section> - 02
Take money with a number input
An input's
typedecides what it accepts and which keyboard appears.type="number"only takes numbers,step="0.01"allows two decimal places for naira and kobo, andmin="0.01"rejects zero or negative amounts.inputmode="decimal"asks phones for a number keypad with a decimal point, andrequiredstops the form submitting with it empty.<div class="field"> <label for="amount">Amount (₦)</label> <!-- inputmode="decimal" shows a number keypad with a decimal point on phones. --> <input id="amount" type="number" inputmode="decimal" min="0.01" step="0.01" required /> </div> - 03
Pick a category with
<select>A
<select>is a dropdown that only allows choices from a fixed list. That means every expense lands in a category the totals know about, with no typos like "food" and "Food" counted apart. When an<option>has novalueattribute, its text is used as the value, socategorySelect.valuegives "Food", "Bills" and so on.<div class="field"> <label for="category">Category</label> <select id="category"> <option>Food</option> <option>Transport</option> <option>Bills</option> <option>Fun</option> <option>Other</option> </select> </div>
CSSthe look
- 04
Lay out the form with a grid
CSS grid lays elements out in rows and columns.
grid-template-columns: 1fr 1frmakes two equal columns, so Amount and Category sit side by side. Grid lines are numbered from the left, and-1means the last one, sogrid-column: 1 / -1makes the description field and the Add button stretch across both columns./* A two-column grid: the description spans both, the rest share a row. */ .expense-form { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-top: 1.5rem; } .field-wide { grid-column: 1 / -1; } - 05
Line up each row with
1fr auto autoEach expense row is a three-column grid.
frmeans a share of the leftover space, andautomeans only as wide as the content needs. With1fr auto auto, the description and details take all the spare room, and the amount and Delete button stay narrow, so every amount lines up on the right./* Description and details on the left, amount and Delete on the right. */ .expense { display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 0.75rem; padding: 0.75rem 0; border-top: 1px solid #e4e4e8; }
JavaScriptthe behaviour
- 06
Format money and dates with
IntlIntlis the browser's built-in tool for formatting numbers and dates the way a place expects.Intl.NumberFormatwithcurrency: "NGN"writes amounts like ₦4,500.00, with the sign, commas and two decimals, andIntl.DateTimeFormatwrites dates like 12 Oct. Each formatter is created once, asnairaandshortDate, then reused for every row.// Intl formats numbers for a place: the naira sign, commas, and two decimals. const naira = new Intl.NumberFormat("en-NG", { style: "currency", currency: "NGN", }); const shortDate = new Intl.DateTimeFormat("en-NG", { day: "numeric", month: "short", }); - 07
Count money in kobo
Computers store decimals like 0.1 as close guesses, so
0.1 + 0.2gives 0.30000000000000004 and totals can drift. Whole numbers don't have that problem.toKobo()turns the naira you type into whole kobo (times 100, rounded), every sum is done in kobo, andformatKobo()only turns it back into naira for display.// Money is kept in kobo, as whole numbers. Adding whole numbers is always // exact; adding decimals like 0.1 + 0.2 isn't. function toKobo(nairaText) { return Math.round(Number(nairaText) * 100); } function formatKobo(kobo) { return naira.format(kobo / 100); } - 08
Add everything up with
reducereduce()goes through an array and combines every item into one value. It carries a running result, here calledsum, and starts it from the0passed at the end. For each expense, the function returnssum + expense.kobo, which becomes thesumfor the next one. What's left at the end is the total.function renderSummary() { // reduce runs through the array, adding each amount to a running total. const total = expenses.reduce((sum, expense) => sum + expense.kobo, 0); totalDisplay.textContent = formatKobo(total); - 09
Group totals in an object
An object stores values under named keys, so
byCategorycan hold one running total per category, like{ Food: 450000 }. The first time a category appears, its key doesn't exist yet, and??supplies0in place of the missing value. After that, each expense adds its kobo to its category's total.// Group totals by category: { Food: 450000, Transport: 120000 }. const byCategory = {}; expenses.forEach((expense) => { byCategory[expense.category] = (byCategory[expense.category] ?? 0) + expense.kobo; }); - 10
Sort categories with
Object.entriesObjects have no order you can sort, but arrays do.
Object.entries()turnsbyCategoryinto an array of[category, kobo]pairs.sort()then compares two pairs at a time, and returningb[1] - a[1]puts the larger amount first, so the biggest spend leads.map()turns each pair into a list item.// Biggest spend first. const sorted = Object.entries(byCategory).sort((a, b) => b[1] - a[1]); categoryList.replaceChildren( ...sorted.map(([category, kobo]) => { const item = document.createElement("li"); item.textContent = `${category} ${formatKobo(kobo)}`; return item; }), ); - 11
Add an expense and reset the form
When the form submits, the code builds a new expense object, with a
Date.now()id and the amount in kobo.unshift()adds it to the start of the array, so the newest shows first. The list is saved and drawn again, thenform.reset()clears every field at once, and focus goes back to the description for the next entry.form.addEventListener("submit", (event) => { event.preventDefault(); const description = descriptionInput.value.trim(); if (!description) return; // unshift adds to the start, so the newest expense shows first. expenses.unshift({ id: Date.now(), description, kobo: toKobo(amountInput.value), category: categorySelect.value, date: new Date().toISOString(), }); saveExpenses(); render(); form.reset(); descriptionInput.focus(); }); - 12
Delete with one listener and
filterClicks bubble up from the element clicked to its parents, so one listener on the list hears every Delete button, even ones added later. This is called event delegation.
closest()finds the button that was clicked, andfilter()makes a new array keeping every expense except the one with thatdata-id. The list and totals are then saved and drawn again.expenseList.addEventListener("click", (event) => { const button = event.target.closest(".delete-button"); if (!button) return; expenses = expenses.filter((expense) => expense.id !== Number(button.dataset.id)); saveExpenses(); render(); descriptionInput.focus(); });
03
Wrap up
Track your progress, then go deeper or keep going.
Take it further
Once it works, try these on your own. Each one uses what you've just learned, plus one new idea.
- Challenge 01
Set a monthly budget and warn when spending goes over it.
- Challenge 02
Filter the list by category.
- Challenge 03
Download the expenses as a CSV file that opens in a spreadsheet.
Learn more on the blog
Articles that go further into ideas from this project.
Finished Expense Tracker?
Mark it done to track your progress.
Go deeper
The full article explains each idea in more depth, why the code is written this way, and more than fits on this page.
Article coming soon. See the series so far (opens in a new tab)