You have the plan, the data model, rules R1–R15 and a green repository with an empty page. Now you have to fill it, and the question that decides whether the project moves forward or gets stuck is not "what do I write?" but "where do I start?". The intuitive answer — open index.html and lay out the screen, because it is the only thing you can see — is exactly the wrong one, and it is why so many personal projects have a beautiful interface sitting on top of logic that cannot be tested or changed. In this lesson you will learn the order that works: from the inside out — domain, data, view, application — and vertical before horizontal, one complete feature end to end before all the layers half-done. You will see how to build the domain with TDD on the new rules, how to solve the subtask tree with the recursion from 03-07, how to set up an in-memory data layer that does not block you and that leaves the boundary ready for the next lesson, how to write framework-free view components with the state → render → event cycle, how to govern state with a single source of truth and immutable updates, how to make the application accessible from the first commit instead of at the end, and how to handle and log errors across the whole system. And you will finish with two things that are not code but that set the project's rhythm: a quality checklist for closing each increment, and a concrete method for when you get stuck — because you will get stuck.
Contents
- From the inside out: why that order
- Vertical before horizontal
- The recommended order of work
- The domain: entities and invariants
- TDD on rules R11 to R15
- The subtask tree with recursion
- The data layer: the in-memory repository first
- The repository contract as a boundary
- Application state: a single source of truth
- Immutable updates and events
- The framework-free view: state → render → event
- Event delegation and the controller
- The accessible form
- The three new screens
- Accessibility from the start
- Application-wide logging and error handling
- Verifiable increments and small commits
- When to refactor and how not to break anything
- The checklist before closing an increment
- What to do when you get stuck
- Common Mistakes and Tips
- Exercises
- Conclusion
- From the inside out: why that order
There are two ways to build an application, and the choice determines almost everything else.
From the outside in is the intuitive one: you start with the HTML and CSS, because that is what you see and what gives you a sense of progress. Once the screen looks nice, you add JavaScript so the buttons do something. Once the buttons do something, you save to localStorage. And the business logic ends up scattered among the event handlers, because that is where it was needed.
From the inside out is the one you are going to use: you start with the domain — the entities and the rules — then the data, then the view, and last the application that joins them. For the first few days there is nothing to show in the browser, and that is uncomfortable.
The honest comparison:
| Aspect | Outside in | Inside out |
|---|---|---|
| Initial sense of progress | High: you see something right away | Low: all you have is green tests |
| Where the business logic ends up | Scattered among event handlers | Concentrated in domain/ |
| Can it be tested without a browser | No: everything needs the DOM | Yes: the domain is tested in Node in milliseconds |
| Cost of changing the interface | High: the logic goes with it | Low: only view/ changes |
| Where the bugs show up | Late, in the interface, hard to isolate | Early, in unit tests, with an obvious cause |
| When you discover the model was wrong | In week 5 | On day 2 |
The last row is the decisive one. Model errors are the most expensive that exist, because they contaminate everything built on top of them. If you discover on day 2 that estimatedHours cannot be an editable field on a task with children (R12), you change three functions. If you discover it in week 5, you change three functions, two views, a form, the report, the persistence and the data your test users have already saved.
And there is an extra argument from the course itself: 10-06 showed that domain/rules.js was identical across the four versions of the same screen. The domain is the part of the project with the most value and the longest useful life. Building it first is building what lasts longest first.
flowchart LR
A["1 · domain/<br/>entities + R1-R15<br/><i>tested in Node</i>"] --> B["2 · data/<br/>in-memory repository<br/><i>boundary defined</i>"]
B --> C["3 · view/<br/>components + events<br/><i>receives state and functions</i>"]
C --> D["4 · application/<br/>use cases + state<br/><i>joins it all</i>"]
D -.->|"and only then"| E["Working<br/>browser"]
style A fill:#dcfce7,stroke:#16a34a
style B fill:#dbeafe,stroke:#2563eb
style C fill:#fef3c7,stroke:#d97706
style D fill:#f3e8ff,stroke:#9333ea
The reasonable objection, and its answer. "If I see nothing for three days, I lose motivation." That is a real problem and it has two concrete solutions:
- Green tests are your screen. Seeing
47 passedwith the domain rules covered is exactly the same progress signal as seeing a rendered list. Change what counts as progress. - Section 2 shortens those days drastically. You do not build all of the domain before touching the view: you build the slice of the domain that the first feature needs, and you go out to the browser with it.
- Vertical before horizontal
This is the second principle, and it corrects the danger of the first one.
- Horizontal means finishing an entire layer before moving to the next: all of the domain, then all of the data, then all of the view.
- Vertical means finishing one complete feature crossing all four layers before starting the next one.
flowchart TB
subgraph H["❌ Horizontal: 3 weeks with nothing to show"]
direction LR
H1["All of the domain"] --> H2["All of the data"] --> H3["All of the view"] --> H4["Does it work?<br/>You find out here"]
end
subgraph V["✅ Vertical: something works on day 3"]
direction LR
V1["See the board<br/>domain→data→view→app"] --> V2["Create task<br/>domain→data→view→app"] --> V3["Subtasks<br/>domain→data→view→app"]
end
style H fill:#fee2e2,stroke:#b91c1c
style V fill:#dcfce7,stroke:#16a34a
Why vertical wins, with three reasons that are not about style:
1 · It validates the architecture early. The first vertical slice is what reveals whether your boundaries work. If while rendering the first list you discover that you need to import data/ from view/, you have a design problem — and you have it on day 3, when changing it costs an hour.
2 · It produces something shippable at all times. At the end of each slice, the application works. With fewer features, but it works. If the project were interrupted tomorrow, you would have a small product instead of three incomplete layers that do not even start.
3 · It uncovers the hidden requirements. Every complete slice brings to light things the plan did not consider: what happens if the list is empty, where focus goes after creating a task, what is shown while loading. Discovering them one at a time is manageable; discovering thirty at once in week 6 is overwhelming.
Orbita's first vertical slice — the one to do literally first — is this:
See the board with the sample tasks. No creating, no editing, no filtering. Just: the seed data arrives from the in-memory repository, domain entities are built, and they are rendered in an accessible list.
It sounds like little. It crosses all four layers, forces you to define the repository contract, the state shape, the view structure and the entry point. When that slice works, the skeleton of the whole project is decided and tested.
- The recommended order of work
Combining the two principles, this is the concrete order for milestones H2 and H3, with the estimated hours from the plan in 11-01:
| # | Increment | Layers it touches | Est. | How you know it is done |
|---|---|---|---|---|
| 1 | Base entities with their invariants (Task, User) |
domain | 6 h | Tests for R1–R5, R8, R9, R11 green |
| 2 | Status transitions (R6) and overdue detection (R10) | domain | 3 h | Transition matrix tested, valid and invalid |
| 3 | In-memory repository + fictional seed | data | 3 h | You can list, add and look up from a test |
| 4 | Vertical slice 1: see the board | all 4 | 8 h | The list is visible in the browser, accessible |
| 5 | Vertical slice 2: create a task | all 4 | 8 h | Form with real domain validation |
| 6 | Vertical slice 3: change status | all 4 | 5 h | R6 enforced from the interface, with a visible error |
| 7 | Subtask tree (R12) | domain | 7 h | Cycles detected, hours from leaves, depth limited |
| 8 | Vertical slice 4: subtasks in the interface (R13) | all 4 | 7 h | It is created, seen nested, and the parent cannot be closed |
| 9 | Assigning an assignee (R11, R15) | domain + view | 6 h | Inactive user rejected; guest read-only |
| 10 | Edit and delete with history (R14) | all 4 | 6 h | Every change produces an immutable entry |
Notice the pattern: three pure-domain increments at the start (the first three, 12 hours) and from then on vertical slices. That domain warm-up is not horizontalism: it is the minimum portion without which the first slice has nothing to render.
And notice increment 7, which is pure domain in the middle of the slices. That is correct: when a feature has complex business logic — the tree — it is worth solving and testing it in the domain before exposing it. Debugging a cycle in a tree through the interface is a nightmare; debugging it in a unit test is trivial.
- The domain: entities and invariants
An invariant is a statement about an object that is always true, from the moment it is created until it is destroyed. "A task always has a non-empty title" is an invariant. "A task sometimes has a title" is not.
The idea that makes the domain worth the trouble is this:
If an object cannot exist in an invalid state, half your bugs cannot happen.
You achieve that with two techniques you already know from 05-02 and 05-03: validate in the constructor and encapsulate mutable state.
4.1 The structure of an entity
This is the skeleton of src/domain/task.js. It is not the complete code: it is the contract you have to fill in, with the decision points marked.
// src/domain/task.js
import { ValidationError, RuleError } from './errors.js';
import { TRANSITIONS, PRIORITIES, MAX_HOURS } from './rules.js';
export class Task {
#status; // R5, R6: only changes through changeStatus()
#hours; // R12: derived if there are subtasks
constructor({ id, title, priority, estimatedHours, /* … */ }) {
// 1 · Validate EVERYTHING before assigning anything.
// A half-built object is an invalid object.
// 2 · Normalize: trim the title, tags to lowercase and without
// duplicates (R9), assignee '' -> null (R8).
// 3 · Freeze whatever must not change: Object.freeze on
// tags, read-only id.
// 4 · R5: the initial status is ALWAYS 'pending'; it is not accepted
// as a parameter except in fromJSON().
}
get status() { return this.#status; }
changeStatus(next, { subtasks = [] } = {}) {
// R6: is the transition in TRANSITIONS[current]?
// R13: if next === 'done' and some subtask is not done -> RuleError
// Returns the change made so the application can record history (R14)
}
isOverdue(today) {
// R10. CAREFUL: 'today' is PASSED as a parameter, not read from Date.now().
// That is what keeps the test independent of the day it runs.
}
toJSON() { /* … */ }
static fromJSON(plain) { /* … rebuilds with validation … */ }
}Five decisions embedded in that skeleton that are worth understanding properly:
1 · Validate everything before assigning anything. If you alternate validating and assigning, an error halfway through leaves the object half-built. In JavaScript a constructor that throws returns no object, so in practice it does not matter… unless the constructor has already modified something external (an id counter, for instance). Validate first, always.
2 · Normalize at the boundary. The title arrives with whitespace, the tags with capitals, the assignee as ''. The domain normalizes them once, on the way in, and from then on all the code can trust the format. It is the same idea R8 and R9 have been defending since Module 1.
3 · Freeze what is immutable. Object.freeze(this.tags) stops anyone doing task.tags.push('X') and bypassing R9. It is cheap and it closes off an entire class of bugs.
4 · today as a parameter. This is the decision you will be most grateful for in 11-04. If isOverdue() calls Date.now(), the test "a task from 5 September is overdue" works today and fails the day you change the sample data. By passing today, the test is deterministic forever. The same technique applies to the id generator and to any source of non-determinism: inject it, do not invoke it.
5 · changeStatus returns the change. It does not return void or this: it returns an object { field, before, after } that the application layer will use to build the history entry (R14). That way the domain does not need to know a history exists, and the history does not need to know how a change is computed.
4.2 rules.js: the rules in one place
// src/domain/rules.js — the single source of truth for the rules
export const STATUSES = Object.freeze(['pending', 'in-progress', 'done']);
export const PRIORITIES = Object.freeze(['high', 'medium', 'low']);
export const ROLES = Object.freeze(['coordination', 'team', 'guest']);
// R6: matrix of valid transitions
export const TRANSITIONS = Object.freeze({
'pending': ['in-progress'],
'in-progress': ['done', 'pending'],
'done': ['in-progress']
});
export const MAX_HOURS = 40; // R3
export const MAX_WEEKLY_HOURS = 40; // R7, R15
export const MAX_DEPTH = 3; // R12
// R15: who can write
export const CAN_WRITE = Object.freeze({
coordination: true, team: true, guest: false
});Having the rules as named constants, in a single file, gives you three immediate benefits:
- They read as documentation. Anyone who opens
rules.jsunderstands the business in two minutes. - Changing them is changing one line. If the maximum goes from 40 to 45 hours, there is a single place.
- Tests can import them. And that avoids the classic mistake of hard-coding
40in the test: if the rule changes, the test changes with it… which is good for limits and bad for sample values. Be aware of which one you are using in each case.
- TDD on rules R11 to R15
TDD (Test-Driven Development) is the cycle 08-03 introduced: red → green → refactor. You write a failing test, you write the minimum code that makes it pass, and then you clean up.
Not the whole project is done with TDD, and saying so is more honest than pretending otherwise. But there is one part where it pays off enormously:
| Where | TDD? | Why |
|---|---|---|
| Domain business rules | Yes, always | The criteria are already written as Given/When/Then: the test is a translation, not an invention |
| Calculations (workload, progress, tree) | Yes | Clear input and output, obvious edge cases |
| Repositories | Sometimes | The contract test (section 8) yes; the details no |
| View and layout | No | You do not know how it will look until you see it; you test it afterwards |
| Exploring a new API | No | First you understand, then you test what you understood |
5.1 The cycle applied to R13
This is a complete cycle, exactly as you should run it. Red first:
// test/domain/r13-closing.test.js
import { Task } from '../../src/domain/task.js';
import { RuleError } from '../../src/domain/errors.js';
describe('R13 · a task with open subtasks cannot be closed', () => {
test('rejects moving to done if a subtask is still pending', () => {
const parent = createInProgressTask();
const subtasks = [doneTask(), pendingTask({ title: 'Measure the gap' })];
expect(() => parent.changeStatus('done', { subtasks }))
.toThrow(RuleError);
});
test('the error message names the subtask that blocks it', () => {
const parent = createInProgressTask();
const subtasks = [pendingTask({ title: 'Measure the gap' })];
expect(() => parent.changeStatus('done', { subtasks }))
.toThrow(/Measure the gap/);
});
test('allows closing when every subtask is done', () => {
const parent = createInProgressTask();
const change = parent.changeStatus('done', { subtasks: [doneTask(), doneTask()] });
expect(parent.status).toBe('done');
expect(change).toEqual({ field: 'status', before: 'in-progress', after: 'done' });
});
test('allows closing a task with no subtasks', () => {
const leaf = createInProgressTask();
leaf.changeStatus('done');
expect(leaf.status).toBe('done');
});
});Four tests, and none of them is redundant:
- The first checks that it fails.
- The second checks that the message is useful to the user. A
RuleErrorwith the text "Operation not allowed" is useless in the interface. Testing the message forces you to write it properly. - The third checks the happy path and, along the way, the change object R14 needs.
- The fourth checks the degenerate case: with no subtasks, the rule must not get in the way. It is the case most often broken when the rule is implemented with too much enthusiasm.
The helper functions (createInProgressTask, doneTask, pendingTask) are the secret of a readable suite. Building a valid Task requires six fields; repeating them across forty tests makes none of them readable. Write a test/helpers/factories.js file with constructors that take only what the test wants to highlight and fill the rest with valid defaults. It is half an hour of work that pays for itself by the third test.
5.2 Parameterized tests for the transitions
R6 has 3 statuses × 3 destinations = 9 combinations, of which 4 are valid. Writing nine nearly identical tests is noise; Jest's test.each solves it:
describe.each([
['pending', 'in-progress', true],
['pending', 'done', false],
['pending', 'pending', false],
['in-progress', 'done', true],
['in-progress', 'pending', true],
['in-progress', 'in-progress', false],
['done', 'in-progress', true],
['done', 'pending', false],
['done', 'done', false]
])('R6 · from %s to %s', (from, to, allowed) => {
test(allowed ? 'is allowed' : 'is rejected', () => {
const task = taskInStatus(from);
if (allowed) {
task.changeStatus(to);
expect(task.status).toBe(to);
} else {
expect(() => task.changeStatus(to)).toThrow(RuleError);
}
});
});The table is the specification. If somebody asks which transitions are valid, those nine lines answer better than any document, and they are verified on top of that.
An important tip about rule coverage: for every rule, always write at least the case that passes, the case that does not, and the exact boundary case. For R3 (estimatedHours from 0 to 40): 0 rejected, 0.5 accepted, 40 accepted, 40.1 rejected. Bugs live at the edges, not in the middle.
- The subtask tree with recursion
This is the part of the domain with the most technical substance and the one that picks up directly from lesson 03-07. Remember the decision from 11-01: the tree is stored flat (each task with parentTaskId) and it is built in memory when needed.
6.1 Building the tree from the flat list
// src/domain/tree.js
export function buildTree(tasks) {
// 1 · An index by id so we do not search in a loop: O(n) instead of O(n²)
const byId = new Map(tasks.map((t) => [t.id, { task: t, children: [] }]));
const roots = [];
// 2 · Hook each node to its parent, or to the roots
for (const node of byId.values()) {
const parentId = node.task.parentTaskId;
if (parentId === null) { roots.push(node); continue; }
const parent = byId.get(parentId);
if (!parent) throw new DataError(`Task ${node.task.id}: parent ${parentId} does not exist`);
parent.children.push(node);
}
return roots;
}Two details that separate a correct implementation from one that works by accident:
- The
Mapby id turns the build into a single pass. Withtasks.find(...)inside the loop, the cost would be quadratic: irrelevant with 6 tasks, noticeable with 600 (which is exactly the 09-01 scenario). - A missing parent throws
DataError, it is not ignored. If a stored record points to a deleted parent, you want to find out, not have the task silently vanish from the interface. This case will genuinely happen in lesson 11-03, when you migrate data.
6.2 The four recursive functions you need
| Function | What it returns | Base case | Rule |
|---|---|---|---|
depth(node) |
Levels below the node | No children → 1 | R12 (max. 3) |
leaves(node) |
Every childless task in the subtree | No children → [task] |
R12 (hours from leaves) |
totalHours(node) |
Sum of the leaves' hours | No children → task.estimatedHours |
R12 |
isDescendant(node, id) |
true if id is in the subtree |
No children → false |
R12 (no cycles) |
And cycle detection, which is the rule most people implement wrong:
// R12: link a subtask without creating cycles
export function canLink(parentNode, childId, tree) {
if (parentNode.task.id === childId) return false; // not its own parent
if (isDescendant(findNode(tree, childId), parentNode.task.id)) return false; // cycle
if (depthFromRoot(parentNode, tree) + depth(findNode(tree, childId)) > MAX_DEPTH) return false;
return true;
}The three conditions are different and you have to test them separately:
- Self-reference: A cannot be the parent of A. It is the trivial case and the only one everybody checks.
- Indirect cycle: if A is the parent of B and B of C, C cannot be the parent of A. This is the one that gets forgotten, and it produces a stack overflow the moment anyone renders the tree.
- Depth: linking a 2-level subtree under a node that is already at level 2 would give 4. This one gets forgotten too, because the naive check only looks at the node, not the whole subtree.
Careful with recursion and the stack. With a maximum depth of 3 there is no risk at all. But if your domain allows arbitrarily deep trees (an inventory category, for instance), an undetected cycle produces infinite recursion and RangeError: Maximum call stack size exceeded. Lesson 03-07 explained the iterative alternative with an explicit stack; keep it in mind if your domain does not cap depth.
- The data layer: the in-memory repository first
There is a strong temptation here worth defusing right away: starting with localStorage because "that way it already persists". Do not. Start with an in-memory repository, for four very concrete reasons:
- It does not block you. Serializing, migrating formats and handling quotas are real problems that deserve a whole lesson — the next one. Solving them now pulls you away from what you are building.
- Tests are instant and clean. An in-memory repository is created fresh in every test. With
localStorage, every test drags along the previous one's state unless you clear it, and that forgottenbeforeEachis a classic source of flaky tests. - It forces you to define the boundary. If you write the easy implementation first, the contract comes out clean; if you write the hard one first, the contract ends up contaminated with
localStoragedetails (keys,JSON.stringify, quotas) that should never leave that file. - It is the implementation your tests will use forever. It is not throwaway code:
MemoryRepositorywill still be alive in the 11-04 suite as a test double.
// src/data/memory-repository.js
export class MemoryRepository {
#tasks = new Map();
#users = new Map();
#history = [];
#nextId = 1;
async listTasks() { return [...this.#tasks.values()]; }
async saveTask(task) {
// R1: if it has no id, it is assigned here; the user never supplies it
// Returns the saved task (with its final id)
}
async deleteTask(id) { /* … */ }
async listUsers() { /* … */ }
async addChange(change) { /* R14: append-only, never modifies */ }
async listHistory(taskId) { /* … */ }
}Why async if everything is in memory? Because the contract has to be the same as the API repository's in lesson 11-03, where asynchrony is unavoidable. If the in-memory repository were synchronous, the whole application layer would be written in a synchronous style and changing it later would mean rewriting it entirely. Design the boundary for the most demanding implementation, not the most convenient one. It is one of the most profitable decisions in the whole project and it costs nothing.
The seed. src/data/seed.js contains the fictional startup data: three or four invented users and eight or ten tasks that include at least one edge case for each new rule — an overdue task (R10), one with mixed subtasks (R13), an inactive user with tasks assigned (R11), a person over 40 h in one week (R7/R15). That way, every time you open the application, the interesting cases are right in front of you and you do not have to build them by hand.
- The repository contract as a boundary
src/data/repository.js contains no implementation: it contains the contract, documented.
// src/data/repository.js
/**
* Contract that ALL repository implementations satisfy.
* Implementations: memory (11-02), local (11-03), api (11-03).
*
* Contract rules:
* - Every method is asynchronous and returns a promise.
* - They return domain ENTITIES, never plain objects or HTML.
* - On failure they throw DataError (they never return a silent null).
* - list*() always returns an array, empty if there is nothing. Never null.
* - save*() returns the saved entity, with its final id.
* - The repository does NOT validate business rules: that is the domain's job.
*
* @typedef {Object} Repository
* @property {() => Promise<Task[]>} listTasks
* @property {(t: Task) => Promise<Task>} saveTask
* @property {(id: number) => Promise<void>} deleteTask
* @property {() => Promise<User[]>} listUsers
* @property {(c: Change) => Promise<void>} addChange
* @property {(id: number) => Promise<Change[]>} listHistory
*/The six contract rules are not bureaucracy; each one prevents a concrete problem:
| Rule | Problem it prevents |
|---|---|
| Everything asynchronous | Rewriting the application when you change implementation |
| Returns entities, not plain objects | Validation being skipped when loading saved data |
Throws DataError, does not return null |
Silent nulls that blow up three layers higher |
list* never returns null |
if (list) scattered all over the view |
save* returns the entity |
Having to reload everything to learn the new id |
| Does not validate business rules | Duplicated rules in two places that drift apart |
The contract test is the technique that makes this work in practice: a single battery of tests run against all three implementations.
// test/data/repository-contract.js
export function contractTests(name, createRepository) {
describe(`Repository contract · ${name}`, () => {
let repo;
beforeEach(async () => { repo = await createRepository(); });
test('listTasks returns an empty array when there is nothing', async () => {
expect(await repo.listTasks()).toEqual([]);
});
test('saveTask assigns an id and the task shows up on list', async () => { /* … */ });
test('returns Task instances, not plain objects', async () => { /* … */ });
test('deleting a non-existent task throws DataError', async () => { /* … */ });
test('the history is append-only', async () => { /* … */ });
});
}In lesson 11-03 you will call this same function with LocalRepository and ApiRepository, and if all three pass, they are interchangeable. That is the guarantee that makes switching storage a one-line change in main.js. Write it now, with the in-memory implementation, even though it feels excessive for a single case: two lessons from now it will save you an entire day.
- Application state: a single source of truth
Here is the most common design error in framework-free applications, and it is worth naming precisely: scattered state. The active filter lives in a variable on the filter bar, the visible tasks in an array on the board view, the current user in a data- attribute on the header, and the form's mode in a property of the form itself.
It works. Until the day two of those places stop agreeing, and then you have an interface showing "12 tasks" above a list of 9, with no way to know which of the two is lying.
The rule that prevents it:
A single object contains everything the interface needs to render itself. Nothing else governs what is shown.
// src/application/state.js
export function initialState() {
return Object.freeze({
// Domain data
tasks: [],
users: [],
// Session
currentUser: null,
// Interface
screen: 'board', // 'board' | 'report' | 'history'
filters: { assigneeId: null, statuses: [], tags: [], tagsMode: 'or' },
openTaskId: null,
// Loading lifecycle
loading: false,
error: null,
// Timestamp of the last change, useful for debugging
version: 0
});
}Three things that do belong in the state and are often forgotten:
loadinganderror. Every asynchronous operation has three outcomes and the interface has to be able to render all three. If they are not in the state, they will end up as loose variables and as interfaces that hang.screen. Navigation is state, not a side effect of the router. The router translates the URL to state and back; it is not the source of truth.- The full
filtersobject, including its mode. It is what makes criterion 3 of H-07 possible: serializing the filter to the URL and restoring it.
And one that does not belong: derived data. visibleTasks, openHours or tasksByPerson are computed from the state, not stored in it. Storing them creates two sources of truth, which is exactly the problem we are avoiding. If the computation is expensive, memoize it (09-02), but keep computing it.
// src/application/selectors.js — pure functions, derived from the state
export const visibleTasks = (state) => applyFilters(state.tasks, state.filters);
export const openHours = (state) => visibleTasks(state)
.filter((t) => t.status !== 'done')
.reduce((sum, t) => sum + t.estimatedHours, 0);These functions are called selectors and they have three advantages: they are pure (they can be tested without mounting anything), they compose with each other, and they are the natural place to memoize if performance demands it. It is the same concept 10-03 introduced in Redux, here with no library at all: forty lines of your own.
- Immutable updates and events
The state is replaced, not modified. Each action produces a new state from the previous one, with the destructuring and spread from 04-07:
// src/application/store.js
export function createStore(initialState) {
let state = initialState;
const subscribers = new Set();
return {
get: () => state,
update(change) {
const previous = state;
state = Object.freeze({ ...state, ...change, version: state.version + 1 });
for (const fn of subscribers) fn(state, previous);
},
subscribe(fn) {
subscribers.add(fn);
return () => subscribers.delete(fn); // unsubscribe function
}
};
}Five decisions in twenty lines:
1 · Object.freeze on every update. It turns any attempt to mutate the state directly into an error (in strict mode, which you are already using). It is a cheap guardrail that catches the bug where it happens.
2 · version always increments. It is useful for debugging ("how many updates have I done?") and for spotting unnecessary renders. It is the same idea as the version counter that invalidated the Board cache in Nómada Tasks.
3 · Subscribers receive the previous state. Without it, a view cannot know what changed and has to repaint everything. With it, it can compare and update only what is needed, which is the basis of the update() from 09-04.
4 · subscribe returns the unsubscribe function. This detail is worth a memory leak. If subscribing does not give you a way to unsubscribe, destroyed views stay in the Set forever holding on to their DOM: exactly the leak you studied in 09-03. By returning the unsubscribe, destroy() is one line.
5 · Shallow update. { ...state, ...change } merges one level. To change a filter you have to write filters: { ...state.filters, tags: [...] }, which is a bit verbose but explicit. Resisting the temptation of a "magic" deep merge is right: a deep merge hides what is being changed and produces surprises with arrays.
How this fits with the events from 06-04. There are two mechanisms and they do different things; it is worth not mixing them:
| Mechanism | What for | Direction |
|---|---|---|
| Store subscription | Letting views know the state changed | Application → view |
CustomEvent with delegation |
Getting the user's interaction to the application | View → application |
The view emits an intent ({ action: 'change-status', id: 4, to: 'done' }), the application runs the use case, the use case calls the domain and the repository, and the store notifies the new state. It is a unidirectional cycle, and that unidirectionality is why you can reason about it.
sequenceDiagram
participant U as User
participant V as view/
participant A as application/
participant D as domain/
participant R as data/
U->>V: clicks "Mark done"
V->>A: CustomEvent {action, id, to}
A->>D: task.changeStatus('done', {subtasks})
D-->>A: {field, before, after} or RuleError
A->>R: saveTask(task) + addChange(change)
A->>A: store.update({tasks, error:null})
A-->>V: subscriber(newState, previousState)
V->>U: repaints only what changed
That diagram is your application's entire architecture. If you can draw it from memory, you can explain it in an interview (lesson 11-06).
- The framework-free view: state → render → event
A framework-free view component has a very specific shape, and all of the ones in your project should share it. This is the template:
// src/view/board-view.js
export function createBoardView(container, { onEmit }) {
let lastState = null;
const unsubscribes = [];
function render(state) {
// First time: build the stable structure (header, <ul>, footer)
}
function update(state, previous) {
// Subsequent times: compare and touch only what changed (09-04)
}
function destroy() {
unsubscribes.forEach((off) => off()); // remove listeners
container.replaceChildren(); // empty the DOM
lastState = null; // release references
}
return { render, update, destroy };
}Four demands of this shape:
1 · It receives its container, it does not look for it. A component that does document.querySelector('#board') internally can only exist once and only works if that id exists. By receiving it, it can be mounted twice, in a fragment, or in jsdom during a test (08-05).
2 · It receives onEmit, it does not import the application. It is the boundary from section 10 of 11-01 turned into code: the view does not know what happens when it emits, only that somebody is listening. This is what lets you test it with a jest.fn() instead of with the whole application.
3 · It separates render from update. render builds; update reconciles. Repainting everything on every change is what pushed render() to 310 ms with 600 tasks in 09-01; reconciling by data-id is what brought it down to 31 ms in 09-04. If your project is going to have long lists, the separation is mandatory; if not, it is still good discipline.
4 · It has destroy() and you use it. Every listener added, every setInterval, every subscription and every IntersectionObserver is undone there. Without this, navigating between screens leaks memory — and in 11-04 you will have to debug exactly that leak.
Templates with <template>. For repeated structure (the task row), use <template> in the HTML plus cloneNode(true), as in 06-06. It is faster than building node by node with createElement and, above all, it keeps the structure visible in the HTML where the semantics can be reviewed.
<template id="task-row-template">
<li class="task" data-id="">
<span class="task__status" aria-hidden="true"></span>
<h3 class="task__title"></h3>
<p class="task__meta"></p>
<button type="button" data-action="change-status">Start</button>
<button type="button" data-action="open-detail">View details</button>
</li>
</template>Never use innerHTML with user data. A task title containing <img src=x onerror=alert(1)> gets executed. Use textContent for anything coming from data, and innerHTML only with your own literals. That is the lesson from 06-02, and in 11-03 we will come back to it because the risk multiplies as soon as the data comes from an API.
- Event delegation and the controller
With 600 tasks and three buttons each, adding individual listeners means 1,800 listeners. The delegation from 06-04 solves it with one:
// src/view/controller.js
export function connectController(root, onEmit) {
function onClick(event) {
const trigger = event.target.closest('[data-action]');
if (!trigger || !root.contains(trigger)) return;
const row = trigger.closest('[data-id]');
onEmit({
action: trigger.dataset.action,
id: row ? Number(row.dataset.id) : null,
value: trigger.dataset.value ?? null
});
}
root.addEventListener('click', onClick);
return () => root.removeEventListener('click', onClick); // unsubscribe
}Four details of the code that matter:
closest('[data-action]')finds the button even when the click lands on an icon inside it. Without this, half the clicks do nothing and the bug is intermittent and baffling.root.contains(trigger)avoids acting on elements that are no longer in the tree (which can happen if the DOM changed between the click and the handler).- The identifier is read from
data-id, the single source of truth about which row the event belongs to. No array indexes: they change when you filter. - It returns the unsubscribe function. Again. It is a pattern, not a coincidence: everything that connects must be able to disconnect.
Delegation and the keyboard. A click on a <button> also fires with Enter and Space: the browser does it for you. That is the practical reason — not the aesthetic one — why a clickable <div> is wrong: it forces you to add tabindex, role="button" and a keyboard handler of your own to replicate what the correct element already does. Accessibility is almost always less work, not more.
- The accessible form
The form is where accessibility errors concentrate and where the domain proves its worth. The rule that governs everything:
Form validation and domain validation are the same thing. The form anticipates it; the domain decides it.
In practice, this means the form calls the domain and translates its errors, instead of duplicating the rules:
// src/view/task-form.js (submission fragment)
function onSubmit(event) {
event.preventDefault();
clearErrors();
const data = Object.fromEntries(new FormData(form));
try {
// The DOMAIN validates. The form does not repeat the rules.
const task = buildTaskFromForm(data);
onEmit({ action: 'create-task', task });
} catch (error) {
if (error instanceof ValidationError) {
showFieldError(error.field, error.message);
} else {
showGeneralError(error);
}
}
}That error.field is the reason ValidationError has carried that property since Module 5: it lets the view know which field to mark without parsing the message.
The accessible-form checklist, which you have to satisfy in every one:
| # | Requirement | How it is done | Why |
|---|---|---|---|
| 1 | Every input has a label | <label for="title"> |
Without it, the screen reader says "text field" |
| 2 | The field with an error is marked | aria-invalid="true" |
The state is announced, not just the red border |
| 3 | The message is associated with the field | aria-describedby="title-error" |
It is read when the field is focused |
| 4 | Focus goes to the first error | field.focus() after validating |
Without this, a keyboard user has no idea where the problem is |
| 5 | The error summary is announced | role="alert" or aria-live="assertive" |
A silent error does not exist |
| 6 | The button says what it does | "Create task", not "Submit" | It is read out of context in the element list |
| 7 | Required fields are indicated | required + text, not just an asterisk |
The asterisk is only a visual convention |
| 8 | It can be submitted with Enter | Use <form> and submit, not a click |
Behavior everybody expects |
| 9 | Nothing is lost on failure | Do not clear the form on error | Redoing a long form is the worst possible experience |
| 10 | Success is communicated | Message in aria-live="polite" + focus |
Otherwise you cannot tell whether it worked |
Points 4, 5 and 10 are the ones almost never implemented in portfolio projects and the ones most noticed in an accessibility review (11-04).
- The three new screens
Extensions E4, E5 and E6 from 11-01 produce three screens that are not lists of cards, and that is where their training value lies: they are the first ones you cannot solve by copying BoardView.
14.1 Calendar (E4)
| Aspect | Decision and why |
|---|---|
| Structure | A real <table> with <th scope="col"> for the days of the week. A calendar is a data table; faking it with <div>s means rebuilding all the semantics by hand |
| Empty cells | Days from other months go with aria-hidden="true" or simply with no interactive content |
| Many tasks in one day | Show two and "+3 more" that opens the day. Do not stack without a limit: it breaks the grid and the CLS |
No dueDate |
A separate "No date" section, never spread arbitrarily |
| Navigation | Previous/next buttons and keyboard: arrows move by day, PageUp/PageDown by month |
| Dates | Intl.DateTimeFormat for month and day names (07-06), never your own arrays of strings |
| Today | Marked with text (aria-current="date"), not just with color |
Computing the first day of the grid and the number of weeks is the classic date exercise: write it in util/dates.js as a pure function and test it with February of a leap year, with a month starting on Sunday and with the year boundary. Those three cases cover almost every possible bug.
14.2 Workload report (E5)
| Aspect | Decision and why |
|---|---|
| Calculation | A pure selector: workloadByPersonAndWeek(state). No DOM. Tested with input/output tables |
| ISO week | Your own function in util/dates.js, with the 1 January case tested explicitly (R15) |
| Double counting | Only tree leaves (R12). It is the most likely bug on the whole screen |
| Overload | Background and text ("46 h · over the limit"), never color alone |
| Table | <table> with <caption>, <th scope="col"> for weeks and <th scope="row"> for people |
| Export | Out of the MVP; when it arrives, Blob + URL.createObjectURL (07-06). Be careful escaping commas and quotes in CSV |
| Performance | If the computation goes over 16 ms with realistic data, memoize it (09-02) |
14.3 History (E6)
| Aspect | Decision and why |
|---|---|
| Structure | An <ol> in reverse chronological order. It is a genuinely ordered list |
| Each entry | "Marta changed the status from pending to done", with <time datetime="…"> |
| Volume | It grows without limit: paginate or load in batches from the start (09-05) |
| Immutability | No edit or delete buttons. None. It is R14 turned into interface |
| Deleted user | Show the name stored in the entry, not the current one: the history is a snapshot of the past |
| Filter | By task and by person; reuse the state's filter mechanism |
That detail about the deleted user is a modeling decision you may not have anticipated: if the history entry stores only userId and that user is deactivated or disappears, the history loses readability. Storing the name as well, at the moment of the change, is deliberate duplication, and it is the right thing to do with historical data. Note it down as an ADR (11-06).
- Accessibility from the start
The decisive argument for not leaving it until the end is about cost:
| When | Typical cost | What it involves |
|---|---|---|
| From the first commit | ~5 % of the time | Choosing the right element and adding an attribute |
| At the end of the project | ~30 % of the time | Rewriting half the view, redoing focus, restructuring the HTML |
And most of that 5 % comes down to using the HTML element that already exists:
| You need | Correct element | What it gives you for free |
|---|---|---|
| Something clickable | <button type="button"> |
Focus, Enter, Space, role, disabled state |
| Navigating to another view | <a href="…"> |
Focus, Enter, context menu, open in a new tab |
| A list | <ul> / <ol> + <li> |
"List of 12 items" announced |
| Tabular data | <table> with <th scope> |
Cell navigation with headers read out |
| Grouping fields | <fieldset> + <legend> |
The group is announced on entry |
| Collapsible content | <details> / <summary> |
Expanded/collapsed state with no JavaScript |
| Modal dialog | <dialog> with showModal() |
Trapped focus, Escape, inert background |
That last one deserves emphasis: <dialog> with showModal() handles focus trapping, closing with Escape and making the background inert all by itself. Reimplementing that by hand is a hundred fragile lines.
Accessibility checklist per increment (it goes into the Definition of Done from 11-01):
| # | Check | How to do it in 30 seconds |
|---|---|---|
| 1 | Everything reachable with Tab | Put the mouse away and walk the whole screen |
| 2 | Focus always visible | Look at where you are at every stop; :focus-visible with contrast ≥ 3:1 |
| 3 | Logical tab order | Does it follow the visual order? If not, the DOM order is wrong |
| 4 | No focus traps | Can you get out of everything you got into? |
| 5 | Focus managed on open and close | On opening a dialog, focus enters; on closing, it returns to the trigger |
| 6 | Changes are announced | An aria-live="polite" region for "12 tasks visible of 47" |
| 7 | Errors are announced | role="alert" on the error summary |
| 8 | Images and icons | Descriptive alt, or aria-hidden="true" if decorative |
| 9 | Contrast | DevTools on the text; ≥ 4.5:1 normal, ≥ 3:1 large |
| 10 | Nothing by color alone | Overdue (R10) and overload (R7) with text or an icon |
| 11 | 200 % zoom | Ctrl + wheel; is any content or function lost? |
| 12 | Language declared | <html lang="en"> |
The twelve points take about five minutes per screen. Do it when you close each increment, not at the end of the project.
The announcement region is a single one, alive from startup, and used by the whole application:
// src/view/announce.js
let region = null;
export function announce(text) {
region ??= document.getElementById('announcements');
region.textContent = ''; // force a re-announcement
requestAnimationFrame(() => { region.textContent = text; });
}The trick of emptying it and setting it again on the next frame is necessary because many screen readers do not announce text identical to the previous one. Without it, "Task created" is announced the first time and never again.
- Application-wide logging and error handling
Your project has three kinds of error, and each one is handled differently:
| Kind | Example | Who throws it | What the user sees | Logged |
|---|---|---|---|---|
| Validation | Empty title (R2) | Domain | Message next to the field | No |
| Rule | Closing with open subtasks (R13) | Domain | Explanatory warning with the cause | No |
| Data / technical | Quota full, network down, corrupt JSON | Data | "Something went wrong" + a retry action | Yes |
The distinction is not academic: the first two are expected behavior and must not pollute the error log. If you log every failed validation, the log fills with noise and the real failures get lost in it.
// src/application/use-cases.js (general pattern of a use case)
export async function createTask(store, repo, data) {
store.update({ loading: true, error: null });
try {
const task = new Task(data); // may throw ValidationError
const saved = await repo.saveTask(task); // may throw DataError
await repo.addChange(creationChange(saved, store.get().currentUser));
store.update({ tasks: [...store.get().tasks, saved], loading: false });
announce(`Task "${saved.title}" created`);
return saved;
} catch (error) {
store.update({ loading: false, error: toUiError(error) });
if (error instanceof DataError) log(error, { useCase: 'createTask', data });
throw error;
}
}Five points of the pattern, which you should repeat in every use case:
loading: trueanderror: nullat the start. Clearing the previous error stops a message from two operations ago hanging around.try/catcharound everything, withloading: falsein thecatch. Aloadingthat never switches off is a spinner turning forever.toUiError(error)translates a technical error into something a person understands. It is the application's responsibility, not the domain's or the data layer's.- Only technical errors are logged.
- The error is rethrown. Whoever called (the form) needs to know it failed so it does not close or clear the fields.
The logger, minimal and sufficient:
// src/util/logger.js
const MAX = 50;
const entries = [];
export function log(error, context = {}) {
const entry = {
date: new Date().toISOString(),
name: error.name,
message: error.message,
context, // NEVER personal data here
stack: error.stack?.split('\n').slice(0, 5).join('\n')
};
entries.push(entry);
if (entries.length > MAX) entries.shift(); // do not grow without limit
if (import.meta.env.DEV) console.error('[Orbita]', entry);
}
export const errorHistory = () => [...entries];And the two global safety nets, in main.js:
window.addEventListener('error', (e) => log(e.error ?? new Error(e.message), { type: 'global' }));
window.addEventListener('unhandledrejection', (e) => log(e.reason, { type: 'promise' }));The second catches the most silent failures: a rejected promise with no catch shows up nowhere but the console, and in production nobody looks at their users' consoles.
Privacy warning about logging. The
contextfield is tempting as a place for "the data that caused the failure". Never put personal data, tokens or user-written content there. Log identifiers and field names ({ useCase: 'createTask', fields: ['title', 'estimatedHours'] }), not values. When you connect an error-tracking service in production in 11-05, that discipline becomes a legal obligation, not a recommendation.
- Verifiable increments and small commits
A verifiable increment is a chunk of work that meets three conditions:
- The application still works when you finish it.
- It can be demonstrated with something: a new green test or a visible behavior.
- It fits in half a day or less.
A comparison of two ways of tackling story H-04 (subtasks), with the same amount of work:
| ❌ A single commit | ✅ Six increments |
|---|---|
feat: subtasks (18 files, 640 lines) |
feat(domain): add parentTaskId with validation |
feat(domain): build the tree from the flat list |
|
feat(domain): detect cycles when linking (R12) |
|
feat(domain): compute hours from the leaves (R12) |
|
feat(domain): block closing with open children (R13) |
|
feat(view): show nested subtasks on the board |
|
If it fails, git bisect points at 640 lines |
If it fails, it points at 40 |
| Impossible to review | Each one is reviewed in five minutes |
| Impossible to partially revert | Only the one that is wrong gets reverted |
The git bisect argument deserves attention: it is the command that automatically finds which commit introduced a bug, testing half the history each time. With 40-line commits it gives you the exact cause; with 640-line commits it gives you a range you still have to search through. That tool only works well if your commits are small and each one leaves the project working.
The rhythm that works, and that you can time:
1 · Pick the smallest increment that contributes something (2 min)
2 · Write the failing test (10 min)
3 · Write code until it passes (30 min)
4 · Clean up with the tests green (10 min)
5 · npm run verify (1 min)
6 · Commit with a message that explains the why (2 min)
→ back to 1About 55 minutes per loop. Four loops is a productive work session, and it ends with four commits that tell a readable story.
- When to refactor and how not to break anything
Refactoring is changing the internal structure of the code without changing its behavior. That definition contains the most important rule:
Never refactor and add functionality in the same commit.
If you mix them, and something breaks, you do not know whether it was the structural change or the behavioral one. Kept apart, the refactoring commit has an incredibly valuable property: all the tests passed before and pass after, without touching any test. If you have to modify tests, you were not refactoring: you were changing behavior.
When to refactor, with objective signals:
| Signal | What it indicates | What to do |
|---|---|---|
| You copy and paste for the third time | An abstraction is missing | Extract the function (on the third time, not the second) |
| A function does not fit on the screen | It does too much | Extract the steps with explanatory names |
The name has an "and" (validateAndSave) |
Two responsibilities | Split it |
| You need a comment to explain a block | The code does not explain itself | Extract that block into a function named after the comment |
| The test is hard to write | There are too many dependencies | Inject what it needs instead of creating it inside |
| Changing something forces you to touch five files | High coupling | Check whether you are crossing a layer boundary |
| You are afraid to touch it | Coverage is missing | Tests first, then refactoring |
That last row is the golden rule: you do not refactor code without tests. If you have to touch something with no coverage, the order is: write tests for what it does now (even if what it does is odd), see them green, and only then change the structure. Tests are the safety net; without a net, it is not refactoring, it is blind rewriting.
And the rule that prevents the other extreme: do not refactor code that works, that you are not going to touch and that nobody has reported. Refactoring has a cost and a risk, and it is only justified when the current structure is getting in the way of something concrete you are about to do. "It is ugly" is not sufficient reason when you have a plan with milestones to hit.
- The checklist before closing an increment
Five minutes per increment. It is the operational version of the Definition of Done from 11-01.
| # | Check | How |
|---|---|---|
| 1 | Does it do one thing? | Read it out loud; if you use "and", split it |
| 2 | Is there a test that fails without this change? | git stash the code, run the tests |
| 3 | Are the edge cases covered? | Empty, null, extremely long text, out-of-range number |
| 4 | Does it respect the layer boundaries? | npm run lint |
| 5 | No console.log, no commented-out code? |
Read the full git diff |
| 6 | Do the names tell the truth? | Does calculateWorkload calculate, or does it also save? |
| 7 | Accessible with the keyboard? | Walk the screen with no mouse |
| 8 | Is what changes announced? | Is there an announce() where it is needed? |
| 9 | Do the errors have a path? | Trigger one on purpose and look at what you see |
| 10 | Is the state still the single source of truth? | Have you created any state variable in the view? |
| 11 | Is npm run verify green? |
Run it |
| 12 | Does the commit message explain the why? | Read it as if somebody else had written it |
Point 2 has a concrete procedure worth learning: stash your code change with git stash keeping the new tests, run them and check that they fail; restore with git stash pop and check that they pass. That proves the test proves something. A test that passes with and without the change is not testing anything, and it is more common than it looks.
- What to do when you get stuck
You are going to get stuck. It is not a possibility: it is part of the job, and knowing how to get out is as technical a skill as knowing how to write a reduce.
20.1 The three-question method
When something does not work and you have been at it for more than fifteen minutes, stop typing and answer in writing:
1 · What exactly did I expect to happen?
Not "for it to work". Something verifiable: "I expected workloadByPerson(state) to return { 'u-1': 25 }". If you cannot phrase it that way, the problem is not the code: it is that you do not know what you want. And that is the real blockage.
2 · What actually happens?
With the data in front of you: "it returns { 'u-1': 25, 'u-2': undefined }". Not "it errors", but the exact message, the exact line, the exact value.
3 · What is the smallest difference between what I expected and what happens?
"There is one key too many, with the value undefined". That sentence usually contains the cause: if a key shows up that should not, something is iterating over users instead of over assigned tasks.
In my experience this method resolves more than half of blockages without running anything. It works because the blockage is almost never "I do not know how to program this": it is "I have three assumptions mixed together and one is false". Writing them down separates them.
20.2 The time box
Set a timer for 45 minutes. If it goes off and you have made no progress, you must change strategy:
| Attempt | Strategy | Time |
|---|---|---|
| 1 | The three questions + debugger (08-01) | 45 min |
| 2 | Reproduce in isolation (20.3) | 45 min |
| 3 | Search the official documentation — MDN, not a random forum | 30 min |
| 4 | Explain it to somebody (or to an inanimate object, out loud) | 15 min |
| 5 | Drop it and do something else on the project | Until tomorrow |
Step 5 is not giving up: it is the strategy with the best cost-to-result ratio of the five. A surprising number of blockages get resolved in the shower the next day, because the problem was a fixed assumption you only let go of once you stop staring at it.
Step 4 has its own name — rubber duck debugging — and it works for a concrete reason: explaining out loud forces you to make explicit the assumptions you were taking for granted. Very often the sentence gets cut off halfway with an "…oh, wait".
20.3 Reproducing the problem in isolation
It is the most powerful technique of all and the least used. It consists of building the smallest possible example that reproduces the bug, outside the application.
Procedure:
- A new test file,
test/isolated.test.js, with only the essentials. - Minimal data: two tasks instead of forty.
- No view, no repository, no state: just the suspect function.
- Remove things until it stops failing. The last thing you removed is involved.
- When it fails with ten lines, the cause is usually obvious.
A real example from this project:
// test/isolated.test.js — the report gave 71 h where it should give 48
test('hours of a 3-task tree', () => {
const parent = task({ id: 1, hours: 0, parent: null });
const child1 = task({ id: 2, hours: 10, parent: 1 });
const child2 = task({ id: 3, hours: 5, parent: 1 });
const tree = buildTree([parent, child1, child2]);
expect(totalHours(tree[0])).toBe(15); // ← fails: returns 30
});With three tasks, "it returns 30 instead of 15" immediately tells you that something is being counted twice. With forty tasks and the interface mounted, that same bug is "the report's numbers look odd" and it can cost you an afternoon.
And an extra benefit that often goes unnoticed: that isolated test stays in the suite. Once you fix it, you already have the regression test written, for free. It is exactly the method you will apply to the three bugs in lesson 11-04.
20.4 When being stuck means something else
Sometimes the blockage is not technical. These signals mean the problem is one level up:
| Signal | What it usually means | What to do |
|---|---|---|
| Days without closing an increment | The increment is too big | Split it until it fits in half a day |
| Every change breaks three things | Coverage is missing or there is coupling | Stop and write tests before going on |
| You do not know where to start the story | It is not well defined | Go back to the acceptance criteria in 11-01 |
| You switch tasks without finishing any | An explicit order is missing | Close the current one before touching another |
| Two weeks with no visible progress | You are working horizontally | Go back to section 2: do a vertical slice |
Common Mistakes and Tips
Starting with the HTML because it is what you can see. It is the most natural trap in the world and it produces applications with the business logic scattered among click handlers. If your rule R13 lives inside an onclick, it cannot be tested without a browser, it cannot be reused from the report and it will disappear the day you change the interface. Domain first, always.
Building the entire domain before touching the browser. The opposite mistake, and also a real one. Three domain increments and out: do the first vertical slice. Architecture is only validated when something crosses it end to end.
State scattered across the interface. The filter in the bar, the list in the view, the mode in the form. It works until the day they stop agreeing, and then there is no way to know which one is lying. One state object, and the views read from it.
Mutating the state directly. state.tasks.push(newOne) triggers no subscription, so the interface never finds out and you get "it does not update" with no apparent cause. Object.freeze in the store turns that silent bug into an immediate exception: use it.
Duplicating the rules in the form. If you validate "maximum 40 hours" in the form and in the domain, you have two truths that will drift apart. The form calls the domain and translates the error; the number 40 appears exactly once, in rules.js.
Forgetting destroy(). Every view that is mounted and not unmounted properly leaves listeners and subscriptions alive. With two screens you do not notice; in lesson 11-04 it will show up as a memory leak while navigating and you will have to hunt it down. Write destroy() at the same time as render(), not afterwards.
Reading Date.now() inside the domain. It makes tests depend on the day they run and produces failures that appear on their own one Monday. Pass today as a parameter. The same rule applies to Math.random() and to ids.
Giant commits. "feat: subtasks" with 640 lines is impossible to review, to revert and to bisect. If the message needs an "and", it is two commits.
Refactoring and adding functionality at the same time. When something breaks, you will not know which of the two did it. And if you have to touch tests while refactoring, you are not refactoring.
Tip · Write the test helper first. Half an hour building test/helpers/factories.js makes your next hundred tests readable in one line each. It is the best-return investment in the whole module.
Tip · Always have a "green" increment to fall back to. Before starting something risky, make sure the last commit works. That way, if you get lost, git restore . puts you back on solid ground instead of in a swamp of half-finished changes.
Tip · Write down decisions as you make them. A docs/decisions.md file with three lines per decision. In 11-06 you will turn it into formal ADRs, and you will be grateful not to have to reconstruct from memory why the tree is flat.
Tip · End the session leaving a failing test written. It is the best resumption point there is: the next day you know exactly what you were doing and what comes next, without rereading anything.
Exercises
These exercises are milestones H2 and H3 of your project: the domain with its tests green and the first complete feature end to end.
Exercise 1 — The complete domain with TDD.
Implement all of src/domain/ with its tests, following the red → green → refactor cycle:
errors.jswithValidationError(with.field),RuleErrorandDataError, all inheriting fromErrorand with the correctname.rules.jswith all the constants: statuses, priorities, roles, transition matrix and limits.task.jsanduser.jswith full validation in the constructor, private fields for what is mutable andtoJSON/fromJSON.tree.jswithbuildTree,leaves,totalHours,depth,isDescendantandcanLink.board.jswith the set operations and the calculations (summary,openHours,workloadByPersonAndWeek).- Tests: every rule R1–R15 with a passing case, a failing case and a boundary case. The transitions with
test.each(9 combinations). The tree cycles with all three kinds: self-reference, indirect cycle and excess depth. test/helpers/factories.jswith constructors for valid-by-default entities.- Domain coverage ≥ 90 % of branches.
Strict requirement: the domain may not import anything from data/, view/ or application/, nor use document, localStorage, fetch, Date.now() or Math.random(). npm run lint must verify it (boundary configured in 11-01).
Exercise 2 — The first vertical slice.
Implement "see the board" end to end:
MemoryRepositorywith the complete contract and the fictional seed (at least 8 tasks with an edge case for each new rule).test/data/repository-contract.jswith at least 8 contract tests, run against the in-memory implementation.createStorewithget,update,subscribe(returning the unsubscribe) and state freezing.- Pure selectors:
visibleTasks,openHours,summaryByStatus. BoardViewwithrender,updateanddestroy, using<template>and list semantics.- A controller with delegation via
[data-action]and[data-id]. main.jsthat wires it all together, loads the seed and renders.- An
aria-liveregion working and announcing the number of visible tasks. - An integration test with Testing Library: query by role (
getAllByRole('listitem')) and check the content.
Exercise 3 — The next two slices and the tree.
- Create a task: a form with
FormData, validation delegated to the domain,aria-invalidandaria-describedby, focus on the first error, and success announced. Meet all 10 points from the checklist in section 13. - Change status: R6 enforced from the interface, with the rule error shown comprehensibly and announced.
- Subtasks: create a subtask, see it nested, and check that R13 blocks closing the parent with open children, with the message naming the subtask.
- Each one on its own branch, with at least four conventional commits, and closed with the checklist from section 19.
- Document at least three decisions made during implementation in
docs/decisions.md.
Solutions
Again, there is no solution code: there are acceptance criteria and rubrics. These are the lists you should walk through before declaring yourself satisfied.
Acceptance criteria for exercise 1 — Domain
| # | Criterion | How it is checked |
|---|---|---|
| 1 | An invalid object cannot be constructed | new Task({ title: ' ' }) throws ValidationError with .field === 'title' |
| 2 | The initial status is always 'pending' |
Passing status: 'done' to the constructor does not change it (R5) |
| 3 | The 9 transitions are covered | test.each with the 9 rows, 4 valid and 5 rejected |
| 4 | Tags are normalized and frozen | ['Workshop','workshop'] → ['workshop']; push throws in strict mode |
| 5 | isOverdue is deterministic |
The same test passes today and a year from now |
| 6 | All three kinds of cycle are detected | Self-reference, indirect and depth, each with its own test |
| 7 | Hours are not duplicated | A tree of 1 parent + 2 children (10 h and 5 h) → 15 h, not 30 |
| 8 | R11 rejects inactive users | Assigning to a user with active: false throws |
| 9 | R11 rejects assignee = reviewer | The same person in both fields throws |
| 10 | R13 names the blocking subtask | The message contains the title |
| 11 | The ISO week is correct | 1 January of a year starting on a Friday falls in week 53 of the previous one |
| 12 | The domain is tested without jsdom | testEnvironment: 'node' for test/domain/ and everything passes |
Criterion 12 is the definitive proof that your architecture works. Configure Jest with separate projects: node for the domain, jsdom for the rest. If the domain needs jsdom, you have crossed the boundary without noticing.
Rubric for exercise 1 (24 points)
| Dimension | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| Invariants | Invalid objects possible | Basic validation | Full validation | Plus normalization and freezing |
| Rule coverage | < 8 rules tested | 10 rules | All 15 | All 15 with the exact boundary case |
| Determinism | Uses Date.now() inside |
Partially injected | Fully injected | Plus a fake clock in tests |
| Recursion | Happy paths only | Direct cycle | All 3 kinds of cycle | Plus a deep tree tested |
| Test readability | Repetitive | With factories | With test.each where appropriate |
They read as a specification |
| Errors | Generic | Typed | Typed with .field |
With messages useful to the user |
| Boundaries | Crossed | Respected de facto | Verified by ESLint | Plus Jest in the node environment |
| Coverage | < 70 % | 70–85 % | 85–90 % | ≥ 90 % of branches, no gaps in rules |
Threshold: 17/24, with a mandatory 3 in "Boundaries" and ≥ 2 in "Rule coverage".
Acceptance criteria for exercise 2 — Vertical slice
| # | Criterion | How it is checked |
|---|---|---|
| 1 | The seed tasks are visible on opening | Manual, npm run dev |
| 2 | The list is a genuine list | getAllByRole('listitem') returns as many as there are tasks |
| 3 | Every row has its data-id |
No array indexes in the DOM |
| 4 | Overdue ones are distinguished with text | Not color alone (R10 + accessibility budget) |
| 5 | The state is frozen | state.tasks.push(x) throws |
| 6 | The subscription can be canceled | subscribe() returns a function; after calling it, no notification |
| 7 | The contract passes against memory | The 8 contract tests green |
| 8 | The view does not import data/ |
ESLint verifies it |
| 9 | destroy() leaves the container empty and listener-free |
A test counting listeners before and after |
| 10 | The number of tasks is announced | The aria-live region contains the text after the render |
Acceptance criteria for exercise 3 — Slices 2, 3 and 4
| # | Criterion | How it is checked |
|---|---|---|
| 1 | An empty title marks the field, not an alert |
aria-invalid="true" + associated message |
| 2 | Focus goes to the first field with an error | document.activeElement is that field |
| 3 | The form is not cleared on failure | The values are still there |
| 4 | Creating announces success and returns focus | aria-live with text + focus on the new row or on the trigger |
| 5 | An invalid transition does not change the status | The domain throws and the interface still shows the previous status |
| 6 | The rule error is comprehensible | No technical trace visible to the user |
| 7 | The subtask appears semantically nested | A <ul> inside the parent's <li> |
| 8 | Closing the parent with open children is blocked | A message that names the subtask |
| 9 | Everything works without a mouse | Full walkthrough with Tab, Enter and Escape |
| 10 | Each branch has ≥ 4 conventional commits | git log --oneline |
Self-assessment for milestone H3. Before moving on to the next lesson:
| Question | Yes / No |
|---|---|
| Can I run the domain tests without jsdom and do they all pass? | |
Is there any business rule outside domain/? |
|
Could I swap MemoryRepository for another one without touching view/? |
|
| Can I use the whole application without a mouse? | |
Does every view I mount have its destroy() and do I call it? |
|
| Do my last ten commits have messages that explain the why? | |
Is npm run verify green right now? |
A "yes" on the second question (which is the only one phrased in reverse) or a "no" on any of the others is debt that lesson 11-03 will multiply. Fix it now.
Conclusion
You have built the real skeleton of your product, and you have done it in the order that holds up.
You know why you build from the inside out: because model errors are the most expensive that exist and they show up on day 2 in a unit test instead of in week 5 spread across six files; because the domain is tested in Node in milliseconds; and because it is the part with the longest useful life, as demonstrated by domain/rules.js being identical across the four versions in 10-06. And you know why that does not mean horizontalism: vertical before horizontal, one complete slice crossing all four layers before all the layers half-done, because that is what validates the architecture early, leaves something shippable at all times and brings the hidden requirements to light one at a time.
You have the domain built on invariants: an object that cannot exist in an invalid state eliminates half the possible bugs. Validate everything before assigning anything, normalize at the boundary, freeze what is immutable, inject today instead of invoking Date.now() — which makes tests deterministic forever — and return the change object from changeStatus so that R14's history does not force the domain to know a history exists. With the rules in a single rules.js that reads as documentation.
You know how to apply TDD where it pays off — business rules and calculations, not layout or exploration — and you know that the Given / When / Then criteria from 11-01 translate into tests almost word for word. You have the four R13 tests — that it fails, that the message is useful, that the happy path works, and that the rule does not get in the way with no subtasks — the nine parameterized transitions with test.each as a verified specification, and the discipline of always testing the case that passes, the one that does not and the exact boundary, because bugs live at the edges.
You have the tree solved with the recursion from 03-07: a single-pass build with a Map by id, an explicit error on a missing parent, and the three cycle conditions to check separately — self-reference, indirect cycle and excess depth — of which only the first is obvious and the other two are the ones that produce stack overflows.
You have the data layer started where it does not block you: an in-memory repository that is not throwaway code — it will be your test double forever — with an asynchronous contract designed for the most demanding implementation and not the most convenient one, and a battery of contract tests that in the next lesson you will run against localStorage and against the API to prove they are interchangeable.
You have the state governed: a single frozen source of truth, with loading and error inside because every asynchronous operation has three outcomes; derived data computed by pure selectors and never stored; immutable updates with spread; subscriptions that return their unsubscribe, which is the line that separates a clean application from a leaky one; and the unidirectional cycle view → application → domain → data → store → view that you can draw from memory.
You have framework-free views with a common shape: they receive their container and their onEmit, they separate render from update — the 310 ms versus the 31 ms from 09-04 — and they have destroy() written at the same time as the rest. With delegation via [data-action] and closest, <template> templates, textContent for all user data, and forms that call the domain instead of duplicating its rules, with the ten accessibility points almost nobody implements: focus on the first error, error announced, and the form that does not clear itself when something fails.
You know how to build the three screens that are not lists — a calendar that is a genuine <table>, a report whose calculation is a pure selector and whose greatest risk is counting the tree's hours twice, and a history with not a single edit button because R14 is a promise — and you know that accessibility costs 5 % from the start and 30 % at the end, and that most of that 5 % simply consists of using the HTML element that already exists.
You have error handling with three categories handled differently, with validations and rules kept out of the log so that noise does not hide the real failures, a use-case pattern with loading, try/catch, translation into human language and rethrowing, the two global safety nets — including the one for rejected promises, which is what catches the silent failures — and the discipline of never putting personal data into the log context.
And you have the rhythm: half-day increments that leave the application working, small commits that make git bisect useful, the rule of not refactoring and adding functionality in the same commit, the seven objective signals for when to refactor — and the one for when not to — the twelve-point checklist for closing an increment, and a concrete method for blockages: the three written questions, the 45-minute time box with a mandatory change of strategy, and reproducing in isolation, which turns "the report's numbers look odd" into "with three tasks it returns 30 instead of 15" and, along the way, hands you the regression test.
Milestone H3 is closed: the domain green and the first complete feature end to end in the browser. But there is still something that is a lie: when you reload the page, everything disappears. The in-memory repository did its job — not blocking you — and now it is time to replace it without touching a single line of view, which is exactly what you defined the contract for. That, plus versioning the stored format and migrating it without losing data, talking to a real API with its states and its errors, and working offline with a queue of pending changes, is Data Persistence and Synchronization.
JavaScript Course: From Beginner to Advanced
Module 1: Introduction to JavaScript
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
