Skip to content
aviral gupta

// Beginner project · about 4 hours of work

Pocket budget

You build a single web page that keeps track of what you spend. A form adds an expense with a note, an amount and a category; the list shows each expense with a Delete button; a filter shows one category; and the total always matches the rows shown. Amounts are kept as whole cents, because in JavaScript 0.1 + 0.2 is 0.30000000000000004, while 10 + 20 cents is exactly 30. One click listener on the list handles every Delete button, including rows added later. The list is saved as JSON in localStorage, so it is still there after a reload. It is practice only: the data stays in your browser, and nothing is sent anywhere.

What the finished program does

  • index.html is given: a form #add with the fields note, amount and category, a message line #message, a filter #filter, the list #list, a line #empty, the total #total and a <template id="row"> for one row. main.js is its only script.
  • Submitting the form adds an expense to the end of the list and empties the form. The submit handler calls event.preventDefault(), so the browser does not send the form and reload the page.
  • An amount such as 4, 4.5 or 4.50 is stored as whole cents (450), computed with Math.round(Number(text) * 100). An empty, non-numeric, zero or negative amount, or a note that is empty after trim(), adds nothing and shows a message in #message; the next good expense clears it.
  • Each row is a copy of the template: the note, the category and the amount (as €12.50) in .note, .category and .amount, and a .delete button. Text goes in with textContent, never innerHTML.
  • #total shows the sum of the rows shown, added up in cents and written as €29.74. #empty is hidden while the list has rows.
  • One click listener on #list deletes the expense whose Delete button was clicked (event delegation with closest()). A click anywhere else in the list changes nothing.
  • The filter shows all categories or only one, and the total follows the filter.
  • After every add and delete, the whole list is saved in localStorage under the key pocket-budget, as a JSON array of {id, note, category, cents}. On load the page shows what was saved, and new ids count up from the highest saved id. A saved value that is not a JSON array is ignored: the list starts empty and #message says so.

Starter layout

index.html
The page: the form, the message line, the filter, the list, the total and the row template. Given complete; you do not need to change it.
main.js
Your work: parseAmount, load, save, row, render and the three listeners. The element lookups and formatCents are given.
main.test.js
The acceptance tests (node:test). loadPage() opens index.html with main.js in jsdom. Run them with node --test.
package.json
{"type": "module"}, so .js files are ES modules. In the starter download.
learnrun.js
The course helper with loadPage(), which the tests import. In the starter download.

Milestones

  1. Milestone 1

    Show the saved expenses

    In load, read localStorage.getItem(KEY). It returns null when nothing is saved, so return []; otherwise return JSON.parse of the text. Write row: copy the template with document.importNode(template.content, true), take its <li>, store the id as data-id and fill the three spans with textContent. Write render: list.replaceChildren(...expenses.map(row)), the total with reduce over cents and formatCents, and empty.hidden.

    Checks that pass once this milestone is done:

    • A new page lists nothing and shows a total of €0.00
    • Saved expenses are listed with an exact total
  2. Milestone 2

    Add an expense

    Write parseAmount with Number, Math.round and Number.isFinite, and return null for anything that is not above 0 cents. Listen for submit on the form and call event.preventDefault() first. Read form.elements.note, amount and category; on a problem, set #message and return. Otherwise push {id: nextId, note, category, cents}, add 1 to nextId, clear the message, call form.reset() and render().

    Checks that pass once this milestone is done:

    • Adding an expense lists it and updates the total
    • A bad amount or an empty note is refused with a message
  3. Milestone 3

    Delete with one listener

    Add one click listener to #list. event.target.closest('.delete') is the Delete button that was clicked, or null for a click anywhere else, so return early on null. The row's data-id says which expense to drop: keep the others with filter, then render().

    Checks that pass once this milestone is done:

    • Delete removes only its own expense
    • Delete works for expenses added after the page loaded
    • A click on a row outside its Delete button changes nothing
    • A Delete button in a row the page did not build itself still works
  4. Milestone 4

    Filter by category

    In render, show every expense when filter.value is all, and otherwise only those whose category equals it; the total adds up the rows shown. A change listener on #filter calls render.

    Checks that pass once this milestone is done:

    • The filter shows one category and its total
  5. Milestone 5

    Save and reload

    Write save with localStorage.setItem(KEY, JSON.stringify(expenses)) and call it after every add and delete. Start nextId at one more than the highest saved id. Put JSON.parse in try...catch, and treat a value that is not an array (Array.isArray) the same way: start with [] and set #message.

    Checks that pass once this milestone is done:

    • Amounts are saved as whole cents
    • Expenses survive a reload, and new ids do not repeat
    • A saved value that is not JSON starts an empty list with a message

Build it in the browser

Edit main.js below; the other starter files stay as they are. “Check my code” runs every acceptance test, so expect failures until the last milestone.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Build it on your computer

Make a folder with these starter files and ECMAScript 2026, then work through the milestones. Run the acceptance tests at any point with:

Download the starter as one .zip (starter files, main.test.js, package.json and learnrun.js)
npm install jsdom@28.1.0
node --test

package.json tells Node.js that the .js files are modules; keep it in the folder.

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Download learnrun.js

main.js

const KEY = 'pocket-budget';
const form = document.querySelector('#add');
const filter = document.querySelector('#filter');
const list = document.querySelector('#list');
const empty = document.querySelector('#empty');
const total = document.querySelector('#total');
const message = document.querySelector('#message');
const template = document.querySelector('#row');

// Money is kept as whole cents, so every sum is exact.
function formatCents(cents) {
  return '€' + (cents / 100).toFixed(2);
}

// Milestone 2: turn text such as "4.5" into whole cents (450), or null when it is not an amount above 0.
function parseAmount(text) {
  return null;
}

// Milestone 1: read the saved list from localStorage (an empty list when nothing is saved).
// Milestone 5: an empty list and a message when the saved text is not a JSON array.
function load() {
  return [];
}

// Milestone 5: write the whole list to localStorage as JSON.
function save() {}

let expenses = load();
let nextId = 1;

// Milestone 1: build one <li> from the #row template for an expense.
function row(expense) {
  return document.createElement('li');
}

// Milestone 1: show the expenses (Milestone 4: only the filtered ones), their total and #empty.
function render() {}

// Milestone 2: add an expense on submit.
// Milestone 3: one click listener on the list deletes an expense.
// Milestone 4: show again when the filter changes.

render();

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Pocket budget</title>
</head>
<body>
  <h1>Pocket budget</h1>
  <form id="add">
    <label>What for <input name="note"></label>
    <label>Amount in € <input name="amount" inputmode="decimal"></label>
    <label>Category
      <select name="category">
        <option>food</option>
        <option>transport</option>
        <option>home</option>
        <option>fun</option>
      </select>
    </label>
    <button>Add</button>
  </form>
  <p id="message" role="alert"></p>
  <label>Show
    <select id="filter">
      <option value="all">all categories</option>
      <option>food</option>
      <option>transport</option>
      <option>home</option>
      <option>fun</option>
    </select>
  </label>
  <ul id="list"></ul>
  <p id="empty">No expenses yet.</p>
  <p>Total: <strong id="total">€0.00</strong></p>
  <template id="row">
    <li><span class="note"></span> <span class="category"></span> <span class="amount"></span> <button type="button" class="delete">Delete</button></li>
  </template>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

Acceptance tests

The project is done when every check in main.test.js passes. Read them before you start: they are the spec, written as code.

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const KEY = 'pocket-budget';
const SAVED = [
  {id: 1, note: 'Lunch', category: 'food', cents: 1250},
  {id: 2, note: 'Bus ticket', category: 'transport', cents: 340},
  {id: 3, note: 'Coffee', category: 'food', cents: 285},
  {id: 4, note: 'Cinema', category: 'fun', cents: 1099}
];
const saved = () => loadPage({localStorage: {[KEY]: JSON.stringify(SAVED)}});

const add = (page, note, amount, category = 'food') => {
  const form = page.document.querySelector('#add');
  form.elements.note.value = note;
  form.elements.amount.value = amount;
  form.elements.category.value = category;
  form.requestSubmit();
};
const rows = (page) =>
  [...page.document.querySelectorAll('#list li')].map((li) =>
    ['.note', '.category', '.amount'].map((part) => li.querySelector(part)?.textContent)
  );
const notes = (page) => rows(page).map((row) => row[0]);
const total = (page) => page.document.querySelector('#total').textContent;
const message = (page) => page.document.querySelector('#message').textContent;
const remove = (page, note) => {
  const li = [...page.document.querySelectorAll('#list li')].find((item) => item.querySelector('.note')?.textContent === note);
  li.querySelector('.delete').click();
};
const show = (page, category) => {
  const filter = page.document.querySelector('#filter');
  filter.value = category;
  filter.dispatchEvent(new page.window.Event('change', {bubbles: true}));
};

test('A new page lists nothing and shows a total of €0.00', async () => {
  const page = await loadPage();
  assert.deepEqual(rows(page), [], `the list shows ${JSON.stringify(rows(page))}`);
  assert.equal(total(page), '€0.00', 'the total of an empty list');
  assert.equal(page.document.querySelector('#empty').hidden, false, '#empty should be visible while the list is empty');
});

test('Saved expenses are listed with an exact total', async () => {
  const page = await saved();
  assert.deepEqual(
    rows(page),
    [
      ['Lunch', 'food', '€12.50'],
      ['Bus ticket', 'transport', '€3.40'],
      ['Coffee', 'food', '€2.85'],
      ['Cinema', 'fun', '€10.99']
    ],
    `the list shows ${JSON.stringify(rows(page))}`
  );
  assert.equal(total(page), '€29.74', 'the total of the four saved expenses');
  assert.equal(page.document.querySelector('#empty').hidden, true, '#empty should be hidden when the list has rows');
});

test('Adding an expense lists it and updates the total', async () => {
  const page = await loadPage();
  add(page, 'Lunch', '12.50');
  assert.deepEqual(rows(page), [['Lunch', 'food', '€12.50']], `after adding Lunch the list shows ${JSON.stringify(rows(page))}`);
  assert.equal(total(page), '€12.50', 'the total after adding 12.50');
  add(page, 'Bus ticket', '3.4', 'transport');
  assert.deepEqual(notes(page), ['Lunch', 'Bus ticket'], 'new expenses go to the end of the list');
  assert.equal(total(page), '€15.90', 'the total after adding 3.4');
  assert.equal(page.document.querySelector('#add').elements.note.value, '', 'the form should be empty again after an add');
});

test('A bad amount or an empty note is refused with a message', async () => {
  const page = await loadPage();
  for (const amount of ['', 'abc', '0', '-3']) {
    add(page, 'Snack', amount);
    assert.deepEqual(rows(page), [], `the amount ${JSON.stringify(amount)} should not be added`);
    assert.notEqual(message(page), '', `the amount ${JSON.stringify(amount)} should show a message in #message`);
  }
  add(page, '   ', '2');
  assert.deepEqual(rows(page), [], 'an expense without a note should not be added');
  add(page, 'Snack', '2');
  assert.equal(message(page), '', 'a good expense should clear the message');
});

test('Delete removes only its own expense', async () => {
  const page = await saved();
  remove(page, 'Bus ticket');
  assert.deepEqual(notes(page), ['Lunch', 'Coffee', 'Cinema'], `after deleting Bus ticket the list shows ${JSON.stringify(notes(page))}`);
  assert.equal(total(page), '€26.34', 'the total after deleting 3.40');
});

test('Delete works for expenses added after the page loaded', async () => {
  const page = await loadPage();
  add(page, 'Tea', '2');
  add(page, 'Cake', '3.5');
  remove(page, 'Tea');
  assert.deepEqual(notes(page), ['Cake'], `after deleting Tea the list shows ${JSON.stringify(notes(page))}`);
  assert.equal(total(page), '€3.50', 'the total after deleting Tea');
});

test('A click on a row outside its Delete button changes nothing', async () => {
  const page = await saved();
  page.document.querySelector('#list .note').click();
  page.document.querySelector('#list').dispatchEvent(new page.window.MouseEvent('click', {bubbles: true}));
  assert.deepEqual(notes(page), ['Lunch', 'Bus ticket', 'Coffee', 'Cinema'], `the list shows ${JSON.stringify(notes(page))}`);
});

test('A Delete button in a row the page did not build itself still works', async () => {
  const page = await saved();
  const li = page.document.importNode(page.document.querySelector('#row').content, true).querySelector('li');
  li.dataset.id = '2';
  li.querySelector('.note').textContent = 'Added by the test';
  page.document.querySelector('#list').append(li);
  li.querySelector('.delete').dispatchEvent(new page.window.MouseEvent('click', {bubbles: true}));
  assert.equal(li.isConnected, false, 'the click on .delete should reach one click listener on #list, which deletes expense 2 and shows the list again');
  assert.deepEqual(notes(page), ['Lunch', 'Coffee', 'Cinema'], `after deleting expense 2 the list shows ${JSON.stringify(notes(page))}`);
});

test('The filter shows one category and its total', async () => {
  const page = await saved();
  show(page, 'food');
  assert.deepEqual(notes(page), ['Lunch', 'Coffee'], `with the filter on food the list shows ${JSON.stringify(notes(page))}`);
  assert.equal(total(page), '€15.35', 'the total of the food expenses');
  show(page, 'home');
  assert.deepEqual(notes(page), [], 'no expense has the category home');
  assert.equal(total(page), '€0.00', 'the total of an empty category');
  assert.equal(page.document.querySelector('#empty').hidden, false, '#empty should be visible when the filter leaves nothing');
  show(page, 'all');
  assert.equal(notes(page).length, 4, 'all categories shows every expense again');
});

test('Amounts are saved as whole cents', async () => {
  const page = await loadPage();
  add(page, 'Tea', '0.1');
  add(page, 'Cake', '0.2');
  add(page, 'Book', '19.99', 'fun');
  add(page, 'Bread', '4.5');
  const stored = JSON.parse(page.window.localStorage.getItem(KEY) ?? 'null');
  assert.ok(Array.isArray(stored), `localStorage "${KEY}" should hold a JSON array`);
  assert.deepEqual(stored.map((expense) => expense.cents), [10, 20, 1999, 450], 'the saved cents');
  assert.equal(total(page), '€24.79', 'the total of 0.1 + 0.2 + 19.99 + 4.5');
});

test('Expenses survive a reload, and new ids do not repeat', async () => {
  const first = await loadPage();
  add(first, 'Tea', '2');
  add(first, 'Cake', '3.5');
  const second = await loadPage({localStorage: first.window.localStorage});
  assert.deepEqual(notes(second), ['Tea', 'Cake'], `after a reload the list shows ${JSON.stringify(notes(second))}`);
  add(second, 'Soup', '4');
  remove(second, 'Tea');
  assert.deepEqual(notes(second), ['Cake', 'Soup'], `deleting Tea after a reload leaves ${JSON.stringify(notes(second))}`);
  const third = await loadPage({localStorage: second.window.localStorage});
  assert.deepEqual(notes(third), ['Cake', 'Soup'], 'a delete is saved too');
});

test('A saved value that is not JSON starts an empty list with a message', async () => {
  const page = await loadPage({localStorage: {[KEY]: '{oops'}});
  assert.deepEqual(rows(page), [], 'the list should start empty');
  assert.equal(total(page), '€0.00', 'the total of an empty list');
  assert.notEqual(message(page), '', '#message should say the saved budget could not be read');
});

Run the finished program

node --test
Reference solution

Try the milestones first. This solution passes every acceptance test and the type checker.

main.js

const KEY = 'pocket-budget';
const form = document.querySelector('#add');
const filter = document.querySelector('#filter');
const list = document.querySelector('#list');
const empty = document.querySelector('#empty');
const total = document.querySelector('#total');
const message = document.querySelector('#message');
const template = document.querySelector('#row');

// Money is kept as whole cents, so every sum is exact.
function formatCents(cents) {
  return '€' + (cents / 100).toFixed(2);
}

function parseAmount(text) {
  const cents = Math.round(Number(text) * 100);
  return Number.isFinite(cents) && cents > 0 ? cents : null;
}

function load() {
  const text = localStorage.getItem(KEY);
  if (text === null) return [];
  try {
    const saved = JSON.parse(text);
    if (Array.isArray(saved)) return saved;
  } catch (error) {
    console.log(`The saved budget was ignored: ${error.name}`);
  }
  message.textContent = 'The saved budget could not be read, so the list starts empty.';
  return [];
}

function save() {
  localStorage.setItem(KEY, JSON.stringify(expenses));
}

let expenses = load();
let nextId = expenses.reduce((max, expense) => Math.max(max, expense.id), 0) + 1;

function row(expense) {
  const li = document.importNode(template.content, true).querySelector('li');
  li.dataset.id = String(expense.id);
  li.querySelector('.note').textContent = expense.note;
  li.querySelector('.category').textContent = expense.category;
  li.querySelector('.amount').textContent = formatCents(expense.cents);
  li.querySelector('.delete').setAttribute('aria-label', `Delete ${expense.note}`);
  return li;
}

function render() {
  const shown = filter.value === 'all' ? expenses : expenses.filter((expense) => expense.category === filter.value);
  list.replaceChildren(...shown.map(row));
  total.textContent = formatCents(shown.reduce((sum, expense) => sum + expense.cents, 0));
  empty.hidden = shown.length > 0;
}

form.addEventListener('submit', (event) => {
  event.preventDefault();
  const note = form.elements.note.value.trim();
  const cents = parseAmount(form.elements.amount.value);
  if (note === '') {
    message.textContent = 'Write what the money was for.';
    return;
  }
  if (cents === null) {
    message.textContent = 'Enter an amount above 0, such as 4.50.';
    return;
  }
  expenses.push({id: nextId, note, category: form.elements.category.value, cents});
  nextId += 1;
  save();
  message.textContent = '';
  form.reset();
  render();
});

// One listener on the list handles every Delete button, including rows added later.
list.addEventListener('click', (event) => {
  const button = event.target.closest('.delete');
  if (!button) return;
  const id = Number(button.closest('li').dataset.id);
  expenses = expenses.filter((expense) => expense.id !== id);
  save();
  render();
});

filter.addEventListener('change', render);

render();

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Pocket budget</title>
</head>
<body>
  <h1>Pocket budget</h1>
  <form id="add">
    <label>What for <input name="note"></label>
    <label>Amount in € <input name="amount" inputmode="decimal"></label>
    <label>Category
      <select name="category">
        <option>food</option>
        <option>transport</option>
        <option>home</option>
        <option>fun</option>
      </select>
    </label>
    <button>Add</button>
  </form>
  <p id="message" role="alert"></p>
  <label>Show
    <select id="filter">
      <option value="all">all categories</option>
      <option>food</option>
      <option>transport</option>
      <option>home</option>
      <option>fun</option>
    </select>
  </label>
  <ul id="list"></ul>
  <p id="empty">No expenses yet.</p>
  <p>Total: <strong id="total">€0.00</strong></p>
  <template id="row">
    <li><span class="note"></span> <span class="category"></span> <span class="amount"></span> <button type="button" class="delete">Delete</button></li>
  </template>
  <script type="module" src="main.js"></script>
</body>
</html>

Take it further

  • Show a total per category under the list, for all categories at once.
  • Add a date field and sort the list by date, newest first.
  • Let a click on a note turn it into an input, so an expense can be edited, and save the change.
  • Add an Export button that shows the saved JSON in a <textarea>, so it can be copied to another browser, and an Import that checks every item before it replaces the list.
  • Keep the chosen filter in localStorage too, so a reload shows the same category.

Projects are practice: your checks run in your browser or on your computer and never count toward a certificate.