Home Module 15 Notes App

Project Overview

Notes apps look simple but pack in a surprising amount of JavaScript: debounced auto-save, live search filtering, inline editing, and a two-panel layout. This is a polished, job-portfolio-quality project that demonstrates everything you have learned.

What you will build

  • Two-panel layout: notes list on the left, editor on the right
  • Create, edit, and delete notes
  • Auto-save while typing (debounced, 500ms)
  • Live search that filters the list as you type
  • Pin notes to keep them at the top of the list
  • Colour labels (6 pastel colours per note)
  • Word count and last-edited timestamp in the editor
  • All notes persisted in localStorage

Concepts used

  • Two-panel layout with CSS Grid
  • Debouncing with setTimeout / clearTimeout
  • Active-note state management
  • Live filtering with Array.filter() and string search
  • Relative timestamps with Intl.RelativeTimeFormat

Key Logic

Debounced auto-save

let saveTimer = null;

function scheduleSave() {
  clearTimeout(saveTimer);
  saveTimer = setTimeout(() => {
    saveNote(activeId, editorEl.value);
    renderList();
    showSavedIndicator();
  }, 500);
}

editorEl.addEventListener('input', scheduleSave);

Live search filtering

function getVisible() {
  const q = searchEl.value.toLowerCase();
  if (!q) return notes;

  return notes.filter(n =>
    n.title.toLowerCase().includes(q) ||
    n.body.toLowerCase().includes(q)
  );
}

Relative time formatting

const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });

function timeAgo(timestamp) {
  const diff = (timestamp - Date.now()) / 1000; // seconds (negative = past)
  if (diff > -60)     return rtf.format(Math.round(diff),       'second');
  if (diff > -3600)   return rtf.format(Math.round(diff / 60),  'minute');
  if (diff > -86400)  return rtf.format(Math.round(diff / 3600),'hour');
  return               rtf.format(Math.round(diff / 86400),     'day');
}
// e.g. "2 minutes ago", "yesterday"

Note data structure

const note = {
  id:        Date.now().toString(),
  title:     'Untitled',
  body:      '',
  color:     '#fef9c3',   // pastel yellow
  pinned:    false,
  updatedAt: Date.now()
};

Complete Working Code

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Notes</title>
  <style>
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: system-ui, sans-serif; background: #1e1e2e; color: #cdd6f4;
           height: 100vh; overflow: hidden; }
    .layout { display: grid; grid-template-columns: 280px 1fr; height: 100vh; }

    /* Sidebar */
    .sidebar-panel { background: #181825; display: flex; flex-direction: column;
                     border-right: 1px solid #313244; }
    .sidebar-top { padding: .9rem; border-bottom: 1px solid #313244; display: flex; flex-direction: column; gap: .6rem; }
    .app-title { font-size: 1.1rem; font-weight: 700; color: #cba6f7; }
    .search-input {
      padding: .5rem .75rem; background: #313244; border: none; border-radius: .5rem;
      font-size: .85rem; color: #cdd6f4; outline: none; width: 100%;
    }
    .search-input::placeholder { color: #6c7086; }
    .new-btn {
      padding: .55rem; background: #cba6f7; color: #1e1e2e; border: none;
      border-radius: .5rem; font-size: .85rem; font-weight: 700; cursor: pointer;
    }
    .new-btn:hover { background: #b4befe; }
    .notes-list { flex: 1; overflow-y: auto; }
    .note-item {
      padding: .75rem .9rem; cursor: pointer; border-bottom: 1px solid #313244;
      transition: background .1s; position: relative;
    }
    .note-item:hover { background: #313244; }
    .note-item.active { background: #45475a; }
    .note-item-title {
      font-size: .9rem; font-weight: 600; color: #cdd6f4;
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
      padding-right: 1.5rem;
    }
    .note-item-preview {
      font-size: .75rem; color: #6c7086; margin-top: .2rem;
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
    }
    .note-item-time { font-size: .7rem; color: #6c7086; margin-top: .2rem; }
    .note-pin-icon { position: absolute; top: .6rem; right: .6rem; font-size: .75rem; }
    .color-strip { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; border-radius: 4px 0 0 4px; }
    .note-empty-msg { text-align: center; padding: 2rem 1rem; color: #6c7086; font-size: .85rem; }

    /* Editor */
    .editor-panel { display: flex; flex-direction: column; }
    .editor-toolbar {
      display: flex; align-items: center; gap: .6rem; padding: .75rem 1.1rem;
      border-bottom: 1px solid #313244; background: #181825;
    }
    .title-input {
      flex: 1; background: none; border: none; font-size: 1.2rem; font-weight: 700;
      color: #cdd6f4; outline: none;
    }
    .title-input::placeholder { color: #6c7086; }
    .icon-btn {
      background: none; border: none; color: #6c7086; font-size: 1.1rem;
      cursor: pointer; padding: .3rem; border-radius: .3rem; transition: color .15s;
    }
    .icon-btn:hover { color: #cdd6f4; }
    .icon-btn.pinned { color: #cba6f7; }
    .editor-area {
      flex: 1; padding: 1.25rem; background: #1e1e2e; border: none; resize: none;
      font-size: .95rem; line-height: 1.7; color: #cdd6f4; font-family: inherit; outline: none;
    }
    .editor-area::placeholder { color: #6c7086; }
    .editor-footer {
      display: flex; justify-content: space-between; align-items: center;
      padding: .5rem 1.1rem; border-top: 1px solid #313244; background: #181825;
      font-size: .75rem; color: #6c7086;
    }
    .saved-badge { color: #a6e3a1; transition: opacity .3s; }
    .colors-row { display: flex; gap: .35rem; }
    .color-dot {
      width: 16px; height: 16px; border-radius: 50%; cursor: pointer;
      border: 2px solid transparent; transition: transform .1s;
    }
    .color-dot:hover { transform: scale(1.25); }
    .color-dot.selected { border-color: #fff; }
    .del-btn { background: none; border: none; color: #f38ba8; font-size: .8rem; cursor: pointer; }
    .empty-editor { flex: 1; display: flex; align-items: center; justify-content: center;
                    color: #6c7086; font-size: .95rem; }
    [hidden] { display: none !important; }
  </style>
</head>
<body>
  <div class="layout">
    <!-- Left panel -->
    <div class="sidebar-panel">
      <div class="sidebar-top">
        <span class="app-title">Notes</span>
        <input class="search-input" id="search" placeholder="Search notes…" autocomplete="off">
        <button class="new-btn" id="new-btn">+ New Note</button>
      </div>
      <div class="notes-list" id="notes-list"></div>
    </div>

    <!-- Right panel -->
    <div class="editor-panel">
      <div id="empty-editor" class="empty-editor">Select a note or create a new one</div>

      <div id="editor-wrap" hidden style="display:flex;flex-direction:column;height:100vh">
        <div class="editor-toolbar">
          <input class="title-input" id="title-input" placeholder="Note title…">
          <button class="icon-btn" id="pin-btn" title="Pin note">📌</button>
          <button class="del-btn" id="del-btn">Delete</button>
        </div>
        <textarea class="editor-area" id="editor" placeholder="Start writing…"></textarea>
        <div class="editor-footer">
          <div class="colors-row" id="colors-row"></div>
          <span id="word-count"></span>
          <span class="saved-badge" id="saved-badge" style="opacity:0">Saved</span>
        </div>
      </div>
    </div>
  </div>

  <script>
    const STORAGE_KEY = 'notes_v1';
    const COLORS = ['#fef9c3','#dcfce7','#dbeafe','#fce7f3','#f3e8ff','#ffedd5'];

    function load() {
      try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? []; }
      catch { return []; }
    }
    function save() { localStorage.setItem(STORAGE_KEY, JSON.stringify(notes)); }

    let notes    = load();
    let activeId = notes[0]?.id ?? null;
    let saveTimer = null;

    const notesList  = document.querySelector('#notes-list');
    const titleInput = document.querySelector('#title-input');
    const editorEl   = document.querySelector('#editor');
    const pinBtn     = document.querySelector('#pin-btn');
    const delBtn     = document.querySelector('#del-btn');
    const searchEl   = document.querySelector('#search');
    const wordCount  = document.querySelector('#word-count');
    const savedBadge = document.querySelector('#saved-badge');
    const editorWrap = document.querySelector('#editor-wrap');
    const emptyEl    = document.querySelector('#empty-editor');
    const colorsRow  = document.querySelector('#colors-row');

    // Build colour dots
    COLORS.forEach(c => {
      const dot = document.createElement('div');
      dot.className = 'color-dot';
      dot.style.background = c;
      dot.dataset.color = c;
      colorsRow.appendChild(dot);
    });

    const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
    function timeAgo(ts) {
      const diff = (ts - Date.now()) / 1000;
      if (diff > -60)    return rtf.format(Math.round(diff),       'second');
      if (diff > -3600)  return rtf.format(Math.round(diff / 60),  'minute');
      if (diff > -86400) return rtf.format(Math.round(diff / 3600),'hour');
      return              rtf.format(Math.round(diff / 86400),     'day');
    }

    function getVisible() {
      const q = searchEl.value.toLowerCase();
      const filtered = q ? notes.filter(n =>
        n.title.toLowerCase().includes(q) || n.body.toLowerCase().includes(q)
      ) : [...notes];
      // Pinned first, then by updatedAt desc
      return filtered.sort((a, b) => {
        if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
        return b.updatedAt - a.updatedAt;
      });
    }

    function renderList() {
      notesList.innerHTML = '';
      const visible = getVisible();
      if (!visible.length) {
        notesList.innerHTML = '<p class="note-empty-msg">No notes found.</p>';
        return;
      }
      visible.forEach(n => {
        const item = document.createElement('div');
        item.className = `note-item${n.id === activeId ? ' active' : ''}`;
        item.dataset.id = n.id;

        const strip = document.createElement('div');
        strip.className = 'color-strip';
        strip.style.background = n.color;

        const title = document.createElement('div');
        title.className = 'note-item-title';
        title.textContent = n.title || 'Untitled';

        const preview = document.createElement('div');
        preview.className = 'note-item-preview';
        preview.textContent = n.body.slice(0, 60) || 'No content';

        const time = document.createElement('div');
        time.className = 'note-item-time';
        time.textContent = timeAgo(n.updatedAt);

        item.append(strip, title, preview, time);
        if (n.pinned) {
          const pin = document.createElement('span');
          pin.className = 'note-pin-icon';
          pin.textContent = '📌';
          item.appendChild(pin);
        }
        notesList.appendChild(item);
      });
    }

    function renderEditor() {
      const note = notes.find(n => n.id === activeId);
      if (!note) {
        editorWrap.hidden = true;
        emptyEl.style.display = 'flex';
        return;
      }
      editorWrap.hidden = false;
      emptyEl.style.display = 'none';

      titleInput.value = note.title;
      editorEl.value   = note.body;
      pinBtn.classList.toggle('pinned', note.pinned);
      pinBtn.title = note.pinned ? 'Unpin note' : 'Pin note';

      const words = note.body.trim() ? note.body.trim().split(/\s+/).length : 0;
      wordCount.textContent = `${words} word${words !== 1 ? 's' : ''}`;

      document.querySelectorAll('.color-dot').forEach(d => {
        d.classList.toggle('selected', d.dataset.color === note.color);
      });
    }

    function updateNote(id, changes) {
      notes = notes.map(n => n.id === id ? { ...n, ...changes, updatedAt: Date.now() } : n);
      save();
    }

    function showSaved() {
      savedBadge.style.opacity = '1';
      setTimeout(() => { savedBadge.style.opacity = '0'; }, 1500);
    }

    function scheduleSave() {
      clearTimeout(saveTimer);
      saveTimer = setTimeout(() => {
        if (!activeId) return;
        updateNote(activeId, { title: titleInput.value, body: editorEl.value });
        const words = editorEl.value.trim() ? editorEl.value.trim().split(/\s+/).length : 0;
        wordCount.textContent = `${words} word${words !== 1 ? 's' : ''}`;
        renderList();
        showSaved();
      }, 500);
    }

    // Events
    document.querySelector('#new-btn').addEventListener('click', () => {
      const note = { id: Date.now().toString(), title: '', body: '', color: COLORS[0], pinned: false, updatedAt: Date.now() };
      notes = [note, ...notes];
      activeId = note.id;
      save();
      renderList();
      renderEditor();
      titleInput.focus();
    });

    notesList.addEventListener('click', e => {
      const item = e.target.closest('[data-id]');
      if (!item) return;
      activeId = item.dataset.id;
      renderList();
      renderEditor();
    });

    titleInput.addEventListener('input', scheduleSave);
    editorEl.addEventListener('input', scheduleSave);

    pinBtn.addEventListener('click', () => {
      if (!activeId) return;
      const note = notes.find(n => n.id === activeId);
      updateNote(activeId, { pinned: !note.pinned });
      renderList();
      renderEditor();
    });

    delBtn.addEventListener('click', () => {
      if (!activeId) return;
      if (!confirm('Delete this note?')) return;
      notes = notes.filter(n => n.id !== activeId);
      activeId = notes[0]?.id ?? null;
      save();
      renderList();
      renderEditor();
    });

    colorsRow.addEventListener('click', e => {
      const dot = e.target.closest('.color-dot');
      if (!dot || !activeId) return;
      updateNote(activeId, { color: dot.dataset.color });
      renderList();
      renderEditor();
    });

    searchEl.addEventListener('input', () => renderList());

    // Init
    renderList();
    renderEditor();
  </script>
</body>
</html>

Code Explained

Debounced auto-save

Every keystroke in the editor calls scheduleSave(). This clears any pending timeout and sets a new one for 500ms. The save only actually runs if the user stops typing for 500ms — otherwise it keeps resetting. This pattern (debounce) prevents saving dozens of times per second on fast typers.

Immutable note updates

notes.map(n => n.id === id ? { ...n, ...changes, updatedAt: Date.now() } : n) creates a new array with a new note object containing the spread of old fields overridden by changes. The timestamp is always refreshed on every update so the list sorts correctly.

Intl.RelativeTimeFormat

Rather than formatting a raw date string, Intl.RelativeTimeFormat produces human-readable relative strings like "2 minutes ago" or "yesterday". The sign of the difference tells it past vs future; dividing by the right number selects the unit.

Two-panel layout with CSS Grid

grid-template-columns: 280px 1fr creates a fixed-width left panel and a flexible right panel. Setting height: 100vh; overflow: hidden on the body and letting each panel scroll independently keeps the toolbar and footer always visible.

Challenges

  1. Add Markdown preview: render **bold**, *italic*, and # headings in a preview pane next to the raw text.
  2. Add drag-and-drop reordering of notes in the list using the HTML Drag and Drop API.
  3. Add tags to notes and filter the list by tag.
  4. Add a "Trash" screen — instead of permanent delete, move notes to trash and allow restore or permanent deletion.
  5. Add export: download all notes as a single JSON file, and import from JSON.