Build a small study planner that adds topics, marks them complete and filters the list. The objective is to connect language knowledge with observable behavior. You will separate domain rules from the DOM, compile strict TypeScript and test emitted JavaScript. This is a local educational application: it has no login, backend or persistence. Reloading clears the planner. Account storage would require a separate authenticated API and database design, not merely a browser-storage shortcut.
Define acceptance criteria first
A title must contain non-whitespace text and be no longer than 80 UTF-16 code units, matching the input's maxlength behavior. IDs must be unique. Toggling an unknown ID fails visibly. Adding or toggling must leave the previous array and records unchanged. Filtering must not change the underlying list. A keyboard user must be able to submit a title, toggle a checkbox and choose a filter, and HTML-looking text must remain ordinary text.
Create src and tests directories. Save the project files below. The development dependency pins the compiler's minor line; commit the generated lockfile after installing so that automated builds can use npm ci.
{
"name": "typed-study-planner",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "npm run build && node --test tests/domain.test.mjs"
},
"devDependencies": { "typescript": "~5.9.3" }
}
{
"compilerOptions": {
"target": "ES2022", "module": "ES2022", "moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"], "types": [], "strict": true,
"noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true,
"noEmitOnError": true, "sourceMap": true, "outDir": "dist"
},
"include": ["src/**/*.ts"]
}
Keep domain rules independent
export type Task = Readonly<{ id: string; title: string; done: boolean }>;
export type Filter = "all" | "open" | "done";
export function addTask(tasks: readonly Task[], rawTitle: string, id: string): Task[] {
const title = rawTitle.trim();
if (!title || title.length > 80) throw new Error("Use a title from 1 to 80 characters.");
if (!/^[a-z0-9-]{1,40}$/.test(id) || tasks.some(task => task.id === id)) {
throw new Error("Use a unique valid task ID.");
}
return [...tasks, { id, title, done: false }];
}
export function toggleTask(tasks: readonly Task[], id: string): Task[] {
if (!tasks.some(task => task.id === id)) throw new Error("Task not found.");
return tasks.map(task => task.id === id ? { ...task, done: !task.done } : task);
}
export function visibleTasks(tasks: readonly Task[], filter: Filter): Task[] {
return tasks.filter(task => filter === "all" || task.done === (filter === "done"));
}
The UI supplies IDs, which makes domain tests deterministic. Readonly prevents mutation through the typed interface; the functions also implement immutable updates at runtime. No assertions are used to claim that arbitrary network data is Task data. A future import feature would need its own validator.
Build the browser boundary
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Study planner</title>
<body>
<main>
<h1>Study planner</h1>
<p>This practice list resets when the page reloads.</p>
<form id="planner">
<label for="title">Topic</label>
<input id="title" name="title" required maxlength="80">
<button type="submit">Add topic</button>
</form>
<label for="filter">Show topics</label>
<select id="filter">
<option value="all">All</option><option value="open">Open</option><option value="done">Completed</option>
</select>
<p id="status" role="status"></p>
<ul id="tasks"></ul>
</main>
<script type="module" src="./dist/main.js"></script>
</body>
</html>
import { addTask, toggleTask, visibleTasks } from "./domain.js";
import type { Task, Filter } from "./domain.js";
const form = document.querySelector("#planner");
const titleInput = document.querySelector("#title");
const filterInput = document.querySelector("#filter");
const list = document.querySelector("#tasks");
const status = document.querySelector("#status");
if (!(form instanceof HTMLFormElement) || !(titleInput instanceof HTMLInputElement) ||
!(filterInput instanceof HTMLSelectElement) || !(list instanceof HTMLUListElement) ||
!(status instanceof HTMLElement)) throw new Error("Missing planner elements.");
const ui = { form, titleInput, filterInput, list, status };
let tasks: Task[] = [];
let filter: Filter = "all";
let sequence = 0;
function render(focusId?: string): void {
ui.list.replaceChildren();
for (const task of visibleTasks(tasks, filter)) {
const item = document.createElement("li");
const label = document.createElement("label");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = task.done;
checkbox.addEventListener("change", () => {
tasks = toggleTask(tasks, task.id);
render(task.id);
});
label.append(checkbox, document.createTextNode(` ${task.title}`));
item.append(label);
ui.list.append(item);
if (task.id === focusId) checkbox.focus();
}
const done = tasks.filter(task => task.done).length;
ui.status.textContent = `${done} of ${tasks.length} completed; ${visibleTasks(tasks, filter).length} shown.`;
if (focusId && !visibleTasks(tasks, filter).some(task => task.id === focusId)) ui.filterInput.focus();
}
ui.form.addEventListener("submit", event => {
event.preventDefault();
try {
const next = addTask(tasks, ui.titleInput.value, `task-${sequence + 1}`);
sequence += 1;
tasks = next;
ui.form.reset();
render();
ui.titleInput.focus();
} catch (error) {
ui.status.textContent = error instanceof Error ? error.message : "Could not add topic.";
}
});
ui.filterInput.addEventListener("change", () => {
const value = ui.filterInput.value;
if (value === "all" || value === "open" || value === "done") {
filter = value;
render();
}
});
render();
Import paths use .js because that is the browser's emitted runtime file. TypeScript resolves the corresponding source during compilation. The DOM checks establish actual element kinds; a cast would not make a missing element exist. All titles are appended as text nodes. Focus returns to the replaced checkbox after a toggle, or to the filter when the chosen filter removes that row.
Test the contracts
import test from "node:test";
import assert from "node:assert/strict";
import { addTask, toggleTask, visibleTasks } from "../dist/domain.js";
test("adds trimmed text without changing previous state", () => {
const before = [];
const after = addTask(before, " Types ", "a");
assert.deepEqual(before, []);
assert.deepEqual(after, [{ id: "a", title: "Types", done: false }]);
});
test("rejects blank, oversized and duplicate input", () => {
assert.throws(() => addTask([], " ", "a"));
assert.throws(() => addTask([], "x".repeat(81), "a"));
assert.throws(() => addTask([], "Types", "bad id"));
assert.throws(() => addTask(addTask([], "Types", "a"), "More", "a"));
});
test("toggles immutably and rejects missing tasks", () => {
const before = addTask([], "Types", "a");
const after = toggleTask(before, "a");
assert.equal(before[0].done, false);
assert.equal(after[0].done, true);
assert.throws(() => toggleTask(before, "missing"), /not found/);
});
test("filters without losing tasks", () => {
const tasks = toggleTask(addTask(addTask([], "Types", "a"), "Promises", "b"), "a");
assert.equal(visibleTasks(tasks, "all").length, 2);
assert.deepEqual(visibleTasks(tasks, "open").map(task => task.id), ["b"]);
assert.deepEqual(visibleTasks(tasks, "done").map(task => task.id), ["a"]);
assert.equal(tasks.length, 2);
});
Run npm install, then npm test. Serve the exercise folder over loopback HTTP as in the DOM lesson and test keyboard submission, toggling, filters, whitespace-only text and HTML-looking text. Inspect the browser console and network panel: no external API or saved-account request should occur. Tests proving domain behavior do not replace those browser checks.
Extension exercise: add an explicit delete action and an empty-state message. Define focus behavior before implementing deletion. For an account-backed version, design ownership checks, input limits, durable storage and error recovery as a separate backend project. Do not present this memory-only exercise as a production-ready task service.
Review the earlier lessons whenever you cannot explain a line: module boundaries, readonly data, narrowing, event listeners, validation and tests all appear here for a reason.

