Zum Inhalt springen
aviral gupta

// Projekt Einstieg · etwa 4 Stunden Arbeit

Haushaltsbuch

Sie bauen eine einzelne Webseite, die Ihre Ausgaben festhält. Ein Formular fügt eine Ausgabe mit Notiz, Betrag und Kategorie hinzu; die Liste zeigt jede Ausgabe mit einem Delete-Button; ein Filter zeigt eine Kategorie; und die Summe passt immer zu den gezeigten Zeilen. Beträge werden als ganze Cent gespeichert, denn in JavaScript ergibt 0.1 + 0.2 den Wert 0.30000000000000004, während 10 + 20 Cent genau 30 sind. Ein einziger Klick-Listener an der Liste bedient jeden Delete-Button, auch in Zeilen, die später hinzukommen. Die Liste wird als JSON in localStorage gespeichert und ist nach dem Neuladen noch da. Das Projekt dient nur der Übung: Die Daten bleiben in Ihrem Browser, und nichts wird irgendwohin gesendet.

Was das fertige Programm kann

  • index.html ist vorgegeben: ein Formular #add mit den Feldern note, amount und category, eine Meldungszeile #message, ein Filter #filter, die Liste #list, eine Zeile #empty, die Summe #total und ein <template id="row"> für eine Zeile. main.js ist das einzige Skript.
  • Das Absenden des Formulars hängt eine Ausgabe ans Ende der Liste und leert das Formular. Der submit-Handler ruft event.preventDefault() auf, damit der Browser das Formular nicht sendet und die Seite nicht neu lädt.
  • Ein Betrag wie 4, 4.5 oder 4.50 wird als ganze Cent gespeichert (450), berechnet mit Math.round(Number(text) * 100). Ein leerer, nicht numerischer, null oder negativer Betrag oder eine Notiz, die nach trim() leer ist, fügt nichts hinzu und zeigt eine Meldung in #message; die nächste gültige Ausgabe leert sie.
  • Jede Zeile ist eine Kopie des Templates: Notiz, Kategorie und Betrag (als €12.50) in .note, .category und .amount, dazu ein Button .delete. Text kommt mit textContent hinein, nie mit innerHTML.
  • #total zeigt die Summe der gezeigten Zeilen, in Cent addiert und als €29.74 geschrieben. #empty ist verborgen, solange die Liste Zeilen hat.
  • Ein einziger Klick-Listener an #list löscht die Ausgabe, deren Delete-Button geklickt wurde (Event-Delegation mit closest()). Ein Klick an anderer Stelle der Liste ändert nichts.
  • Der Filter zeigt alle Kategorien oder nur eine, und die Summe folgt dem Filter.
  • Nach jedem Hinzufügen und Löschen wird die ganze Liste in localStorage unter dem Schlüssel pocket-budget gespeichert, als JSON-Array aus {id, note, category, cents}. Beim Laden zeigt die Seite das Gespeicherte, und neue ids zählen ab der höchsten gespeicherten id weiter. Ein gespeicherter Wert, der kein JSON-Array ist, wird ignoriert: Die Liste beginnt leer, und #message sagt das.

Aufbau der Startdateien

index.html
Die Seite: Formular, Meldungszeile, Filter, Liste, Summe und das Template für eine Zeile. Vollständig vorgegeben; Sie müssen sie nicht ändern.
main.js
Ihre Arbeit: parseAmount, load, save, row, render und die drei Listener. Die Elementsuche und formatCents sind vorgegeben.
main.test.js
Die Abnahmetests (node:test). loadPage() öffnet index.html mit main.js in jsdom. Starten Sie sie mit node --test.
package.json
{"type": "module"}, damit .js-Dateien ES-Module sind. Im Starter-Download enthalten.
learnrun.js
Das Hilfsmodul des Kurses mit loadPage(), das die Tests importieren. Im Starter-Download enthalten.

Etappen

  1. Etappe 1

    Gespeicherte Ausgaben zeigen

    Lesen Sie in load den Wert localStorage.getItem(KEY). Er ist null, wenn nichts gespeichert ist; geben Sie dann [] zurück, sonst JSON.parse des Texts. Schreiben Sie row: Kopieren Sie das Template mit document.importNode(template.content, true), nehmen Sie dessen <li>, speichern Sie die id als data-id und füllen Sie die drei spans mit textContent. Schreiben Sie render: list.replaceChildren(...expenses.map(row)), die Summe mit reduce über die Cent und formatCents, dazu empty.hidden.

    Prüfungen, die nach dieser Etappe bestehen:

    • Eine neue Seite zeigt keine Ausgaben und eine Summe von €0.00
    • Gespeicherte Ausgaben erscheinen mit einer exakten Summe
  2. Etappe 2

    Eine Ausgabe hinzufügen

    Schreiben Sie parseAmount mit Number, Math.round und Number.isFinite, und geben Sie null für alles zurück, was nicht über 0 Cent liegt. Hören Sie auf submit am Formular und rufen Sie zuerst event.preventDefault() auf. Lesen Sie form.elements.note, amount und category; bei einem Problem setzen Sie #message und kehren zurück. Sonst fügen Sie {id: nextId, note, category, cents} an, erhöhen nextId um 1, leeren die Meldung, rufen form.reset() und render() auf.

    Prüfungen, die nach dieser Etappe bestehen:

    • Eine neue Ausgabe erscheint in der Liste und ändert die Summe
    • Ein ungültiger Betrag oder eine leere Notiz wird mit einer Meldung abgelehnt
  3. Etappe 3

    Löschen mit einem Listener

    Geben Sie #list einen einzigen Klick-Listener. event.target.closest('.delete') ist der geklickte Delete-Button oder null bei einem Klick an anderer Stelle; kehren Sie bei null früh zurück. Die data-id der Zeile sagt, welche Ausgabe wegfällt: Behalten Sie die anderen mit filter und rufen Sie render() auf.

    Prüfungen, die nach dieser Etappe bestehen:

    • Delete entfernt nur die eigene Ausgabe
    • Delete funktioniert auch für Ausgaben, die nach dem Laden hinzukamen
    • Ein Klick in eine Zeile außerhalb ihres Delete-Buttons ändert nichts
    • Ein Delete-Button in einer Zeile, die die Seite nicht selbst gebaut hat, funktioniert trotzdem
  4. Etappe 4

    Nach Kategorie filtern

    Zeigen Sie in render jede Ausgabe, wenn filter.value den Wert all hat, sonst nur die mit dieser Kategorie; die Summe addiert die gezeigten Zeilen. Ein change-Listener an #filter ruft render auf.

    Prüfungen, die nach dieser Etappe bestehen:

    • Der Filter zeigt eine Kategorie und ihre Summe
  5. Etappe 5

    Speichern und neu laden

    Schreiben Sie save mit localStorage.setItem(KEY, JSON.stringify(expenses)) und rufen Sie es nach jedem Hinzufügen und Löschen auf. Beginnen Sie nextId bei eins mehr als der höchsten gespeicherten id. Setzen Sie JSON.parse in try...catch und behandeln Sie einen Wert, der kein Array ist (Array.isArray), genauso: Beginnen Sie mit [] und setzen Sie #message.

    Prüfungen, die nach dieser Etappe bestehen:

    • Beträge werden als ganze Cent gespeichert
    • Ausgaben überstehen ein Neuladen, und neue ids wiederholen sich nicht
    • Ein gespeicherter Wert, der kein JSON ist, ergibt eine leere Liste mit Meldung

Im Browser bauen

Bearbeiten Sie unten main.js; die anderen Startdateien bleiben, wie sie sind. „Code prüfen“ führt alle Abnahmetests aus, rechnen Sie also bis zum letzten Meilenstein mit Fehlschlägen.

Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.

Beim ersten Ausführen lädt Ihr Browser den JavaScript-Runner herunter (bis zu 0.1 MB) und speichert ihn im Cache. Ihr Code läuft in der eigenen Engine Ihres Browsers und bleibt auf Ihrem Gerät.

Ihre Seite

Ihre Seite läuft in einem abgeschotteten Rahmen. Sie hat keinen Zugriff auf diese Website, und ihr localStorage liegt nur für diesen Lauf im Speicher: Der nächste Lauf beginnt leer.

Führen Sie das Programm aus, um seine Seite hier zu sehen.

Auf dem eigenen Rechner bauen

Legen Sie einen Ordner mit diesen Startdateien an, installieren Sie ECMAScript 2026 und arbeiten Sie die Etappen ab. Die Abnahmetests starten Sie jederzeit mit:

Startdateien als eine .zip herunterladen (Startdateien, main.test.js, package.json und learnrun.js)
npm install jsdom@28.1.0
node --test

package.json sagt Node.js, dass die .js-Dateien Module sind; lassen Sie die Datei im Ordner.

Um die Seite auf Ihrem Computer auszuführen, öffnen Sie index.html über einen lokalen Webserver, nicht als Datei: Browser führen Modul-Skripte von file://-Adressen nicht aus. Für die Prüfungen installieren Sie zuerst jsdom (die Befehle unten).

learnrun.js herunterladen

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"
}

Abnahmetests

Das Projekt ist fertig, wenn jede Prüfung in main.test.js besteht. Lesen Sie sie vor dem Start: Sie sind die Spezifikation, als Code geschrieben.

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('Eine neue Seite zeigt keine Ausgaben und eine Summe von €0.00', async () => {
  const page = await loadPage();
  assert.deepEqual(rows(page), [], `die Liste zeigt ${JSON.stringify(rows(page))}`);
  assert.equal(total(page), '€0.00', 'die Summe einer leeren Liste');
  assert.equal(page.document.querySelector('#empty').hidden, false, '#empty sollte sichtbar sein, solange die Liste leer ist');
});

test('Gespeicherte Ausgaben erscheinen mit einer exakten Summe', 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']
    ],
    `die Liste zeigt ${JSON.stringify(rows(page))}`
  );
  assert.equal(total(page), '€29.74', 'die Summe der vier gespeicherten Ausgaben');
  assert.equal(page.document.querySelector('#empty').hidden, true, '#empty sollte verborgen sein, wenn die Liste Zeilen hat');
});

test('Eine neue Ausgabe erscheint in der Liste und ändert die Summe', async () => {
  const page = await loadPage();
  add(page, 'Lunch', '12.50');
  assert.deepEqual(rows(page), [['Lunch', 'food', '€12.50']], `nach Lunch zeigt die Liste ${JSON.stringify(rows(page))}`);
  assert.equal(total(page), '€12.50', 'die Summe nach 12.50');
  add(page, 'Bus ticket', '3.4', 'transport');
  assert.deepEqual(notes(page), ['Lunch', 'Bus ticket'], 'neue Ausgaben kommen ans Ende der Liste');
  assert.equal(total(page), '€15.90', 'die Summe nach 3.4');
  assert.equal(page.document.querySelector('#add').elements.note.value, '', 'das Formular sollte nach dem Hinzufügen wieder leer sein');
});

test('Ein ungültiger Betrag oder eine leere Notiz wird mit einer Meldung abgelehnt', async () => {
  const page = await loadPage();
  for (const amount of ['', 'abc', '0', '-3']) {
    add(page, 'Snack', amount);
    assert.deepEqual(rows(page), [], `der Betrag ${JSON.stringify(amount)} sollte nicht hinzugefügt werden`);
    assert.notEqual(message(page), '', `der Betrag ${JSON.stringify(amount)} sollte eine Meldung in #message zeigen`);
  }
  add(page, '   ', '2');
  assert.deepEqual(rows(page), [], 'eine Ausgabe ohne Notiz sollte nicht hinzugefügt werden');
  add(page, 'Snack', '2');
  assert.equal(message(page), '', 'eine gültige Ausgabe sollte die Meldung leeren');
});

test('Delete entfernt nur die eigene Ausgabe', async () => {
  const page = await saved();
  remove(page, 'Bus ticket');
  assert.deepEqual(notes(page), ['Lunch', 'Coffee', 'Cinema'], `nach dem Löschen von Bus ticket zeigt die Liste ${JSON.stringify(notes(page))}`);
  assert.equal(total(page), '€26.34', 'die Summe nach dem Löschen von 3.40');
});

test('Delete funktioniert auch für Ausgaben, die nach dem Laden hinzukamen', async () => {
  const page = await loadPage();
  add(page, 'Tea', '2');
  add(page, 'Cake', '3.5');
  remove(page, 'Tea');
  assert.deepEqual(notes(page), ['Cake'], `nach dem Löschen von Tea zeigt die Liste ${JSON.stringify(notes(page))}`);
  assert.equal(total(page), '€3.50', 'die Summe nach dem Löschen von Tea');
});

test('Ein Klick in eine Zeile außerhalb ihres Delete-Buttons ändert nichts', 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'], `die Liste zeigt ${JSON.stringify(notes(page))}`);
});

test('Ein Delete-Button in einer Zeile, die die Seite nicht selbst gebaut hat, funktioniert trotzdem', 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, 'der Klick auf .delete sollte einen einzigen Klick-Listener an #list erreichen, der Ausgabe 2 löscht und die Liste neu zeigt');
  assert.deepEqual(notes(page), ['Lunch', 'Coffee', 'Cinema'], `nach dem Löschen von Ausgabe 2 zeigt die Liste ${JSON.stringify(notes(page))}`);
});

test('Der Filter zeigt eine Kategorie und ihre Summe', async () => {
  const page = await saved();
  show(page, 'food');
  assert.deepEqual(notes(page), ['Lunch', 'Coffee'], `mit dem Filter food zeigt die Liste ${JSON.stringify(notes(page))}`);
  assert.equal(total(page), '€15.35', 'die Summe der Ausgaben für food');
  show(page, 'home');
  assert.deepEqual(notes(page), [], 'keine Ausgabe hat die Kategorie home');
  assert.equal(total(page), '€0.00', 'die Summe einer leeren Kategorie');
  assert.equal(page.document.querySelector('#empty').hidden, false, '#empty sollte sichtbar sein, wenn der Filter nichts übrig lässt');
  show(page, 'all');
  assert.equal(notes(page).length, 4, 'all categories zeigt wieder jede Ausgabe');
});

test('Beträge werden als ganze Cent gespeichert', 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}" sollte ein JSON-Array enthalten`);
  assert.deepEqual(stored.map((expense) => expense.cents), [10, 20, 1999, 450], 'die gespeicherten Cent');
  assert.equal(total(page), '€24.79', 'die Summe von 0.1 + 0.2 + 19.99 + 4.5');
});

test('Ausgaben überstehen ein Neuladen, und neue ids wiederholen sich nicht', 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'], `nach dem Neuladen zeigt die Liste ${JSON.stringify(notes(second))}`);
  add(second, 'Soup', '4');
  remove(second, 'Tea');
  assert.deepEqual(notes(second), ['Cake', 'Soup'], `das Löschen von Tea nach dem Neuladen lässt ${JSON.stringify(notes(second))}`);
  const third = await loadPage({localStorage: second.window.localStorage});
  assert.deepEqual(notes(third), ['Cake', 'Soup'], 'auch das Löschen wird gespeichert');
});

test('Ein gespeicherter Wert, der kein JSON ist, ergibt eine leere Liste mit Meldung', async () => {
  const page = await loadPage({localStorage: {[KEY]: '{oops'}});
  assert.deepEqual(rows(page), [], 'die Liste sollte leer beginnen');
  assert.equal(total(page), '€0.00', 'die Summe einer leeren Liste');
  assert.notEqual(message(page), '', '#message sollte sagen, dass das gespeicherte Budget nicht lesbar war');
});

Das fertige Programm starten

node --test
Referenzlösung

Versuchen Sie zuerst die Etappen. Diese Lösung besteht alle Abnahmetests und die Typprüfung.

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>

Weiterentwickeln

  • Zeigen Sie unter der Liste eine Summe je Kategorie, für alle Kategorien zugleich.
  • Fügen Sie ein Datumsfeld hinzu und sortieren Sie die Liste nach Datum, die neueste zuerst.
  • Lassen Sie einen Klick auf eine Notiz daraus ein Eingabefeld machen, damit sich eine Ausgabe bearbeiten lässt, und speichern Sie die Änderung.
  • Fügen Sie einen Export-Button hinzu, der das gespeicherte JSON in einer <textarea> zeigt, damit es sich in einen anderen Browser kopieren lässt, und einen Import, der jeden Eintrag prüft, bevor er die Liste ersetzt.
  • Speichern Sie auch den gewählten Filter in localStorage, damit ein Neuladen dieselbe Kategorie zeigt.

Projekte sind Übung: Ihre Prüfungen laufen im Browser oder auf Ihrem Rechner und zählen nie für eine Bescheinigung.