Codeskill

Learn to code, step by step

Mini project: offline-capable notes or task app

This mini project builds a notes or task app that works offline – local persistence, a service worker shell, and sync when the network returns. Plain HTML, CSS, and ES modules. No framework.

What you are building

A single-page app where users add, edit, complete, and delete notes or tasks. Data saves to IndexedDB immediately. The UI works with no network. When online, optional sync to a simple REST API (or a mocked endpoint) queues changes made offline.

Suggested layout

notes-app/
  index.html
  manifest.webmanifest
  styles.css
  offline.html
  sw.js
  js/
    main.js
    notes-store.js
    notes-view.js
    db.js
    sync.js

IndexedDB wrapper

// db.js
const DB_NAME = 'notes-db';
const STORE = 'notes';

export function openDb() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, 1);
    req.onupgradeneeded = () => {
      req.result.createObjectStore(STORE, { keyPath: 'id' });
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

export async function getAllNotes(db) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE, 'readonly');
    const store = tx.objectStore(STORE);
    const req = store.getAll();
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

export async function putNote(db, note) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE, 'readwrite');
    tx.objectStore(STORE).put(note);
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

Store with persistence

// notes-store.js
export async function createNotesStore(db) {
  const notes = await getAllNotes(db);
  const listeners = new Set();
  const pendingSync = new Set();

  function emit() {
    listeners.forEach((fn) => fn(getState()));
  }

  function getState() {
    return { notes: [...notes], pendingSync: pendingSync.size };
  }

  return {
    getState,
    subscribe(fn) {
      listeners.add(fn);
      fn(getState());
      return () => listeners.delete(fn);
    },
    async add(text) {
      const note = {
        id: crypto.randomUUID(),
        text: text.trim(),
        done: false,
        updatedAt: Date.now(),
      };
      notes.push(note);
      await putNote(db, note);
      pendingSync.add(note.id);
      emit();
      return note;
    },
    async toggle(id) {
      const note = notes.find((n) => n.id === id);
      if (!note) return;
      note.done = !note.done;
      note.updatedAt = Date.now();
      await putNote(db, note);
      pendingSync.add(id);
      emit();
    },
    getPendingIds: () => [...pendingSync],
    markSynced(id) {
      pendingSync.delete(id);
      emit();
    },
  };
}

View layer

// notes-view.js
export function renderNotes(root, store) {
  const form = root.querySelector('[data-note-form]');
  const list = root.querySelector('[data-note-list]');
  const status = root.querySelector('[data-sync-status]');

  form.addEventListener('submit', async (event) => {
    event.preventDefault();
    const input = form.querySelector('input');
    await store.add(input.value);
    input.value = '';
  });

  list.addEventListener('click', (event) => {
    const btn = event.target.closest('[data-toggle]');
    if (btn) store.toggle(btn.dataset.toggle);
  });

  store.subscribe(({ notes, pendingSync }) => {
    status.textContent = navigator.onLine
      ? pendingSync ? 'Syncing…' : 'Saved'
      : 'Offline - changes stored locally';

    list.replaceChildren(
      ...notes.map((note) => {
        const li = document.createElement('li');
        li.className = note.done ? 'done' : '';
        const span = document.createElement('span');
        span.textContent = note.text;
        const toggle = document.createElement('button');
        toggle.type = 'button';
        toggle.dataset.toggle = note.id;
        toggle.textContent = note.done ? 'Undo' : 'Done';
        li.append(span, toggle);
        return li;
      })
    );
  });
}

Background sync (simplified)

// sync.js
export async function flushPending(store) {
  if (!navigator.onLine) return;

  for (const id of store.getPendingIds()) {
    const note = store.getState().notes.find((n) => n.id === id);
    if (!note) continue;
    try {
      await fetch('/api/notes', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(note),
      });
      store.markSynced(id);
    } catch {
      break;
    }
  }
}

window.addEventListener('online', () => flushPending(store));

For a local-only demo, skip the API and drop sync.js – IndexedDB plus the service worker shell still proves offline UX.

Wire-up in main.js

import { openDb } from './db.js';
import { createNotesStore } from './notes-store.js';
import { renderNotes } from './notes-view.js';
import { flushPending } from './sync.js';

const db = await openDb();
const store = await createNotesStore(db);
renderNotes(document.querySelector('#app'), store);
flushPending(store);

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js');
}

Checklist

  • App shell caches and offline fallback page load with network disabled.
  • Notes survive refresh and tab close.
  • UI uses textContent – no innerHTML for user text.
  • Sync status reflects online/offline and pending queue.
  • Manifest allows add-to-home-screen on a test device.

The final tutorial builds a performant data table with virtual scrolling for large lists.

PreviousPatterns for large front ends