Skip to main content

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.

Result0px
New tab (opens in a new tab)
Download
<!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

  1. 01

    Announce the total with aria-live

    aria-live marks an element whose updates screen readers should read out, and polite means they wait for a pause rather than interrupting. The total changes every time you add or delete an expense, so putting aria-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>
  2. 02

    Take money with a number input

    An input's type decides what it accepts and which keyboard appears. type="number" only takes numbers, step="0.01" allows two decimal places for naira and kobo, and min="0.01" rejects zero or negative amounts. inputmode="decimal" asks phones for a number keypad with a decimal point, and required stops 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>
  3. 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 no value attribute, its text is used as the value, so categorySelect.value gives "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

  1. 04

    Lay out the form with a grid

    CSS grid lays elements out in rows and columns. grid-template-columns: 1fr 1fr makes two equal columns, so Amount and Category sit side by side. Grid lines are numbered from the left, and -1 means the last one, so grid-column: 1 / -1 makes 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;
    }
  2. 05

    Line up each row with 1fr auto auto

    Each expense row is a three-column grid. fr means a share of the leftover space, and auto means only as wide as the content needs. With 1fr 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

  1. 06

    Format money and dates with Intl

    Intl is the browser's built-in tool for formatting numbers and dates the way a place expects. Intl.NumberFormat with currency: "NGN" writes amounts like ₦4,500.00, with the sign, commas and two decimals, and Intl.DateTimeFormat writes dates like 12 Oct. Each formatter is created once, as naira and shortDate, 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",
    });
  2. 07

    Count money in kobo

    Computers store decimals like 0.1 as close guesses, so 0.1 + 0.2 gives 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, and formatKobo() 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);
    }
  3. 08

    Add everything up with reduce

    reduce() goes through an array and combines every item into one value. It carries a running result, here called sum, and starts it from the 0 passed at the end. For each expense, the function returns sum + expense.kobo, which becomes the sum for 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);
  4. 09

    Group totals in an object

    An object stores values under named keys, so byCategory can hold one running total per category, like { Food: 450000 }. The first time a category appears, its key doesn't exist yet, and ?? supplies 0 in 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;
    	});
  5. 10

    Sort categories with Object.entries

    Objects have no order you can sort, but arrays do. Object.entries() turns byCategory into an array of [category, kobo] pairs. sort() then compares two pairs at a time, and returning b[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;
    		}),
    	);
  6. 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, then form.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();
    });
  7. 12

    Delete with one listener and filter

    Clicks 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, and filter() makes a new array keeping every expense except the one with that data-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.

  1. Challenge 01

    Set a monthly budget and warn when spending goes over it.

  2. Challenge 02

    Filter the list by category.

  3. Challenge 03

    Download the expenses as a CSV file that opens in a spreadsheet.

Articles that go further into ideas from this project.

Finished Expense Tracker?

Mark it done to track your progress.

You finished Expense Tracker!

0 of 15 projects done.

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)