Skip to content

CAPSTONE PROJECT · LESSON 23 OF 24

Capstone: browser task manager with persistence

Combine typed models, DOM events, validation and local storage.

  • CRUD
  • localStorage
  • modules
  • runtime checks

Your runnable checkpoint

Extract into a new practice folder. Each download includes complete source, package.json, tsconfig.json and an offline lesson. Use Node.js 22 or 24. Each checkpoint is independent.

Install with npm install. Run npm run check, then npm run build. Terminal lessons use npm start; browser lessons use npm run serve and http://127.0.0.1:4173.

1. Understand the concepts

This complete browser project uses the tested domain functions from the previous lesson. main.ts handles page elements, events and persistence, while tasks.ts owns task rules. Keeping these responsibilities separate lets you test the rules without a browser.

On startup, read one storage key, parse the JSON and validate it with parseTasks. If data is malformed, show a visible message. Do not pretend the stored text became Task[] because a TypeScript annotation said so.

Each action computes the next state, attempts to save it and renders the list. Storage can fail because of browser settings or quota. The app reports that changes are temporary while allowing the current session to continue.

Run npm install, npm run check, npm run build and npm run serve. Open http://127.0.0.1:4173. Add a task, mark it complete, refresh, undo completion and delete it. Stop the server from an earlier browser lesson if the port is already in use.

This is a local single-browser application. Data is tied to its origin and does not sync between devices. Do not store passwords or API secrets in localStorage. A multi-user product needs a backend, authentication, authorization and a persistence design.

2. Build and run

These are complete files. Preserve the paths shown below, including the src directory. The package and compiler configuration are included at the end.

src/main.ts

src/main.ts
import { createTask, parseTasks, removeTask, toggleTask } from "./tasks.js";
import type { Task } from "./tasks.js";
const form = document.querySelector("#task-form");
const input = document.querySelector("#title");
const list = document.querySelector("#tasks");
const message = document.querySelector("#message");
if (!(form instanceof HTMLFormElement) || !(input instanceof HTMLInputElement)
    || !(list instanceof HTMLUListElement) || !(message instanceof HTMLParagraphElement)) {
  throw new Error("Required page elements are missing");
}
const taskList = list;
const status = message;
const storageKey = "edupoly-typescript-tasks-v1";
let tasks: Task[] = [];
try {
  const stored = localStorage.getItem(storageKey);
  if (stored !== null) tasks = parseTasks(JSON.parse(stored));
} catch { status.textContent = "Saved tasks could not be loaded. New changes will replace invalid saved data."; }
function saveAndRender(next: Task[]): void {
  tasks = next;
  try {
    localStorage.setItem(storageKey, JSON.stringify(tasks));
    status.textContent = "Tasks saved";
  } catch { status.textContent = "Storage unavailable. Changes last only until this page closes."; }
  render();
}
function render(): void {
  taskList.replaceChildren();
  for (const task of tasks) {
    const item = document.createElement("li");
    const label = document.createElement("span");
    label.textContent = `${task.done ? "✓" : "○"} ${task.title} `;
    const toggle = document.createElement("button");
    toggle.type = "button";
    toggle.textContent = task.done ? "Undo" : "Complete";
    toggle.setAttribute("aria-label", `${toggle.textContent} ${task.title}`);
    toggle.addEventListener("click", () => saveAndRender(toggleTask(tasks, task.id)));
    const remove = document.createElement("button");
    remove.type = "button";
    remove.textContent = "Delete";
    remove.setAttribute("aria-label", `Delete ${task.title}`);
    remove.addEventListener("click", () => saveAndRender(removeTask(tasks, task.id)));
    item.append(label, toggle, remove);
    taskList.append(item);
  }
}
form.addEventListener("submit", event => {
  event.preventDefault();
  try {
    const task = createTask(crypto.randomUUID(), input.value);
    saveAndRender([...tasks, task]);
    form.reset();
    input.focus();
  } catch (error: unknown) { status.textContent = error instanceof Error ? error.message : "Could not add task"; }
});
render();

src/tasks.ts

src/tasks.ts
export type Task = { readonly id: string; title: string; done: boolean };
export function createTask(id: string, title: string): Task {
  const clean = title.trim();
  if (!id.trim()) throw new Error("Id is required");
  if (clean.length < 1 || clean.length > 120) throw new Error("Use 1–120 characters");
  return { id, title: clean, done: false };
}
export function toggleTask(tasks: readonly Task[], id: string): Task[] {
  return tasks.map(task => task.id === id ? { ...task, done: !task.done } : task);
}
export function removeTask(tasks: readonly Task[], id: string): Task[] {
  return tasks.filter(task => task.id !== id);
}
export function parseTasks(value: unknown): Task[] {
  if (!Array.isArray(value)) throw new Error("Expected a task array");
  const ids = new Set<string>();
  return value.map((item: unknown) => {
    if (typeof item !== "object" || item === null
        || !("id" in item) || typeof item.id !== "string"
        || !("title" in item) || typeof item.title !== "string"
        || !("done" in item) || typeof item.done !== "boolean") throw new Error("Invalid task");
    const task = createTask(item.id, item.title);
    if (ids.has(task.id)) throw new Error("Duplicate task id");
    ids.add(task.id);
    return { ...task, done: item.done };
  });
}

index.html

index.html
<!doctype html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>EduPoly TypeScript Practice</title>
<style>body{font:18px system-ui;max-width:700px;margin:40px auto;padding:20px;background:#f6f8ff;color:#17263d}input,button{font:inherit;padding:10px}li{margin:12px 0}button{cursor:pointer}label{display:block;margin-bottom:8px}</style></head>
<body><h1>TypeScript task practice</h1><form id="task-form"><label for="title">Task title</label><input id="title" maxlength="120" required><button>Add task</button></form><p id="message" role="status"></p><ul id="tasks"></ul><script type="module" src="./dist/main.js"></script></body></html>

server.mjs

server.mjs
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { resolve, sep, extname } from "node:path";
const root = resolve(".");
const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".map": "application/json" };
createServer(async (request, response) => {
  try {
    const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
    const file = resolve(root, "." + (pathname === "/" ? "/index.html" : pathname));
    if (!file.startsWith(root + sep)) { response.writeHead(403).end(); return; }
    const content = await readFile(file);
    response.writeHead(200, { "Content-Type": types[extname(file)] ?? "application/octet-stream" });
    response.end(content);
  } catch { response.writeHead(404).end("Not found"); }
}).listen(4173, "127.0.0.1", () => console.log("Open http://127.0.0.1:4173"));

package.json

package.json
{
  "name": "edupoly-typescript-capstone-browser-task-manager",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "check": "tsc --noEmit",
    "build": "tsc",
    "serve": "node server.mjs"
  },
  "devDependencies": {
    "typescript": "5.9.3"
  }
}

tsconfig.json

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": [
      "ES2022",
      "DOM"
    ],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noEmitOnError": true,
    "verbatimModuleSyntax": true,
    "rootDir": "src",
    "outDir": "dist",
    "sourceMap": true,
    "types": [],
    "moduleDetection": "force"
  },
  "include": [
    "src/**/*"
  ]
}

3. Trace the code and check the result

  1. Startup crosses an untrusted storage boundary through parseTasks.
  2. crypto.randomUUID creates ids in a secure context; localhost is suitable for practice.
  3. The renderer uses textContent for task titles and explicit labels for action buttons.

Expected result

Browser checks: add → complete → refresh (still complete) → undo → delete. Empty titles must show an error.

4. Practice independently

Add an active/completed filter without changing saved data.

Show the explanation

Keep filter state separately from tasks. Filter only the array used by render; save the full task array. Test that switching filters never deletes tasks.

When something goes wrong

If tsc is missing, run npm install in the folder containing package.json. If the output looks old, rebuild before running. Read the first compiler diagnostic and check the indicated field or argument. For module errors, preserve the .js extensions in imports. Browser examples must run through the included local server, not directly as TypeScript files.

Read the related official TypeScript documentation ↗