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
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
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
<!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
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
{
"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
{
"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
- Startup crosses an untrusted storage boundary through parseTasks.
- crypto.randomUUID creates ids in a secure context; localhost is suitable for practice.
- 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.
