At the close of the previous lesson the Nómada Tasks model was complete and protected: Task with its private state, Board with its minimal public API, ValidationError with its hierarchy. The problem is that all of it lives in a single js/app.js that is already past three hundred lines, mixed together with the constants, the sample backlog and the code that runs it. Splitting it into several files and loading them with <script> solves nothing: they all share the same global space, load order becomes an invisible dependency nobody has written down, and it only takes two files declaring const WEIGHTS for the application to fall over. In this lesson you will learn JavaScript's module system: explicit boundaries between files, with export to declare what is on offer and import to declare what is needed. By the end you will have reorganized the whole project into six real files, with a dependency graph you can draw.

Contents

  1. The problem: global scope, order and invisible dependencies
  2. export: named exports
  3. import: bringing in what you need
  4. Renaming with as and importing everything with * as
  5. export default
  6. Re-exporting: the barrel file
  7. Own scope and single execution
  8. <script type="module"> and what changes
  9. Dynamic import() and module-level await
  10. CommonJS: require and module.exports
  11. Node and "type": "module"
  12. Circular dependencies
  13. Nómada Tasks, reorganized
  14. Common Mistakes and Tips
  15. Exercises
  16. Conclusion

  1. The problem: global scope, order and invisible dependencies

Suppose you split the big file into three and load them as you learned in Module 1:

<script src="js/constants.js"></script>
<script src="js/task.js"></script>
<script src="js/app.js"></script>

This has three serious problems, and none of them gives a clear error message.

Problem 1: everything shares the same global scope. Every classic <script> runs its code in the global scope, so all top-level declarations live together in the same namespace.

// js/constants.js
const WEIGHTS = { high: 3, medium: 2, low: 1 };

// js/format.js
const WEIGHTS = { bold: 700, normal: 400 };
// ✗ SyntaxError: Identifier 'WEIGHTS' has already been declared

Two files written by two different people, each one perfectly reasonable, and the application does not start. With var it would be even worse: there would be no error, the second would overwrite the first silently, and the effort calculation would come out NaN somewhere unexpected.

Problem 2: order matters and it is written nowhere. If somebody moves the <script> for task.js above the one for constants.js, the Task class will try to use WEIGHTS before it exists. The HTML becomes a critical configuration file nobody documents.

Problem 3: dependencies are invisible. Open task.js and tell me what it depends on. You cannot know without reading the whole thing and hunting for the identifiers it does not declare. There is no line saying "this file needs WEIGHTS and ValidationError".

The classic workaround, before modules existed, was the module pattern from 03-04: wrapping everything in a self-invoking function and hanging a single variable off the global object.

var NomadaTasks = (function () {
  const WEIGHTS = { high: 3, medium: 2, low: 1 };   // private thanks to the closure
  class Task { /* … */ }
  return { Task };                                   // the only public thing
})();

It works —it was best practice for years— but it still does not solve ordering or make dependencies explicit, and it only takes two libraries picking the same global name to be back at problem 1. Since ES2015, the language has shipped the solution.

  1. export: named exports

A module is simply a .js file that uses export or import. Everything it declares is private to that file except what it exports explicitly.

// util/dates.js
export const TODAY = '2026-09-20';

export function daysBetween(from, to) {
  const ms = new Date(to) - new Date(from);
  return Math.round(ms / (1000 * 60 * 60 * 24));
}

export function isOverdue(dueDate, status, today = TODAY) {
  return dueDate < today && status !== 'done';         // R10
}

// This one is NOT exported: it is an internal detail, invisible from outside
function normalizeDate(text) {
  return new Date(text).toISOString().slice(0, 10);
}

There are two ways of writing the same thing. The first is putting export in front of each declaration, as above. The second is grouping them at the end of the file, which gives you a very convenient "table of contents":

// util/dates.js — variant with an export list
const TODAY = '2026-09-20';
function daysBetween(from, to) { /* … */ }
function isOverdue(dueDate, status, today = TODAY) { /* … */ }
function normalizeDate(text) { /* … */ }

export { TODAY, daysBetween, isOverdue };     // normalizeDate stays inside

Anything with a name can be exported: const, let, function, class. And it can be renamed in the export itself:

export { daysBetween as calculateDays, TODAY as REFERENCE_DATE };

export and import declarations must be at the top level of the module, never inside an if, a loop or a function. The reason is that they are analyzed before anything runs: that lets the browser discover the file graph and download the files in parallel, and it is also what makes it possible to detect import errors without ever executing the program. For conditional importing there is dynamic import() (section 9).

  1. import: bringing in what you need

At the other end, import declares what is needed and where it comes from.

// model/task.js
import { TODAY, isOverdue } from '../util/dates.js';

export class Task {
  // …
  isOverdue(today = TODAY) {
    return isOverdue(this.dueDate, this.status, today);
  }
}

The braces of import { … } are not destructuring, even though they look a lot like the ones in 04-07. It is a syntax of its own belonging to the module system: the names have to match the exported ones exactly, it takes no default values or nested patterns, and it is resolved before anything runs.

About the module path (the specifier), three rules that trip people up in the browser:

Specifier Meaning Valid in the browser?
'./dates.js' Relative to this file, same folder Yes
'../util/dates.js' Relative, going up one level Yes
'/js/util/dates.js' Absolute from the site root Yes
'dates.js' Bare specifier (no ./) No: the browser reads it as a package name

And the most important one: in the browser, the .js extension is mandatory. import { TODAY } from './util/dates' fails with a 404. Node in ESM mode requires it too. It is the number one mistake for people coming from other environments where it can be omitted.

  1. Renaming with as and importing everything with * as

If two modules export something with the same name, or if the original name clashes with a local variable, you rename it on import:

import { isOverdue as dateOverdue } from '../util/dates.js';
import { isOverdue as taskOverdue } from './rules.js';

console.log(dateOverdue('2026-09-05', 'pending'));   // true

And if you want the whole module grouped under one name:

import * as dates from '../util/dates.js';

console.log(dates.TODAY);                                 // '2026-09-20'
console.log(dates.daysBetween('2026-09-20', '2026-09-30'));   // 10
console.log(dates.normalizeDate);                         // undefined  ← it was not exported

That dates is the module namespace object: it holds one property per export. It has two peculiarities: it is frozen (you cannot add to it or change it) and its properties are live bindings, not copies, something you will see in section 7.

Form When to use it
import { a, b } from '…' The normal case: you see at a glance what is used
import { a as x } from '…' There is a name clash or the original is unclear here
import * as ns from '…' The module exports many related things and the prefix adds clarity (dates.daysBetween)
import '…' Only the effect of running it matters (registering something, loading styles); no names are brought in

  1. export default

Each module can have one default export, meant for when the file represents a single thing.

// model/board.js
export default class Board { /* … */ }
// app.js
import Board from './model/board.js';           // no braces, and you choose the name
import TheWorkshopBoard from './model/board.js';   // also valid: it is the same thing

The difference from named exports:

Named Default
How many per module As many as you like One
Import syntax import { X } from … import X from …
Must the name match? Yes No, the importer chooses it
Typos Caught at load time Go unnoticed
Editor autocompletion Works well Worse

They can be combined in the same file:

// model/task.js
export default class Task { /* … */ }
export class RecurringTask extends Task { /* … */ }
export const STATUSES = ['pending', 'in-progress', 'done'];
import Task, { RecurringTask, STATUSES } from './model/task.js';

In this project we will use named exports in every file. It is not the only valid option —many teams use default for each module's main piece— but named exports have two practical advantages: the name is the same across the whole code base, which means searching for Board finds every use, and a typo shows up instantly instead of giving you undefined halfway through execution.

  1. Re-exporting: the barrel file

A module can re-export what it imports from others, without ever using it. It serves to offer a single entry point to a group of files.

// model/index.js — a "barrel"
export { Task, RecurringTask } from './task.js';
export { Board } from './board.js';
export { ValidationError, DataError } from './errors.js';

// You can also re-export everything from a module:
export * from './rules.js';

// Or re-export a default under a name:
export { default as Formatter } from './format.js';

Whoever consumes it sees a single module:

import { Task, Board, ValidationError } from './model/index.js';

Barrels are convenient, but an honest warning is in order: importing from the barrel drags in the load of every file it re-exports, even if you only use one. With six files it is irrelevant; in large projects it is one of the typical reasons startup becomes slow, and its relationship with code splitting is covered in 09-05. For a project the size of Nómada Tasks, importing directly from each file is clearer.

  1. Own scope and single execution

Two properties of modules that solve exactly the problems in section 1.

Every module has its own scope. Nothing you declare is global, so two modules can declare const WEIGHTS without either one noticing. Problem 1 is gone.

// util/format.js
const WEIGHTS = { bold: 700, normal: 400 };   // ✓ no conflict with the one in rules.js

A module runs only once, however many times it is imported. The first import loads and runs it; the rest receive the already-computed result. That makes every module a de facto singleton:

// data/counter.js
console.log('⚙ counter.js is running');
export const log = [];
export function record(text) { log.push(text); }
// model/board.js
import { record } from '../data/counter.js';
record('board loaded');

// model/task.js
import { record, log } from '../data/counter.js';
record('task loaded');
console.log(log);   // [ 'board loaded', 'task loaded' ]  ← the SAME array

In the console, '⚙ counter.js is running' appears only once. The two modules share the same log because they share the same module instance. That property is extremely useful —a configuration module, a cache, a shared store— but it is worth keeping in mind: any state you put at module level is global to the whole application, with all the drawbacks that has for the tests in Module 8.

And a fine detail: imports are live bindings, not copies. If the source module reassigns an exported variable, whoever imported it sees the new value.

// counter.js
export let calls = 0;
export function increment() { calls += 1; }
// app.js
import { calls, increment } from './counter.js';
console.log(calls);         // 0
increment();
console.log(calls);         // 1   ← it updated by itself
// calls = 5;               // ✗ TypeError: Assignment to constant variable

Imported variables are read-only from the importing module: only the owning module can change them. It is a form of encapsulation that fits perfectly with what you learned in 05-03.

  1. <script type="module"> and what changes

For the browser to treat a file as a module you have to say so:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Nómada Tasks</title>
  <link rel="stylesheet" href="css/styles.css">
</head>
<body>
  <h1>Nómada Tasks · Taller Nómada</h1>

  <!-- ONE single script: the entry point. The imports bring in the rest. -->
  <script type="module" src="js/app.js"></script>
</body>
</html>

A single <script> for the whole application: the imports take care of the rest, and the browser discovers and downloads the complete graph. These are the differences from a classic script:

Aspect Classic <script> <script type="module">
Scope of declarations Shared global The module's own
Strict mode Only with 'use strict' Always on
Execution timing Blocks the HTML while downloading and running Implicit defer: waits for the HTML to be ready
Executions if included twice Two One
import/export SyntaxError Allowed
this at top level window undefined
File origin Anything, including file:// Requires HTTP and respects CORS

The implicit defer is good practical news: when you get to Module 6 and start looking for elements on the page, the HTML will already be fully built, with no need to wait for any event. And if you need the opposite —running as soon as it downloads, without waiting— there is <script type="module" async src="…">.

The last row is the one that causes the most headaches. Opening index.html by double-clicking does not work with modules:

Access to script at 'file:///…/js/app.js' from origin 'null' has been blocked
by CORS policy: Cross origin requests are only supported for protocol schemes:
http, https, …

The reason is security: modules are downloaded under the same rules as any network request, and the file:// protocol has no valid origin. The solution is the one you already met in 01-02: start a local server.

# With the VS Code Live Server extension: right-click → "Open with Live Server"

# With Python, if you have it installed
python3 -m http.server 8000

# With Node
npx serve

And then open http://localhost:8000, not the file path. From now on, all development on the project is done with a local server running.

  1. Dynamic import() and module-level await

Static imports go at the top of the file and are resolved before anything runs. When you need to load something conditionally or at a specific moment, there is the function form:

// import() looks like a function call and returns a promise
const mod = await import('./reports/pdf-exporter.js');
mod.exportBoard(board);

We focus here only on the syntax, because its main use —splitting the application into chunks that download on demand so it starts faster— belongs to 09-05, and because what it returns is a promise, an object you will study in depth in 05-06. Keep three differences in mind:

Static import Dynamic import()
Where it can be written Only at the top level Anywhere: inside an if, inside a function…
When it is resolved Before the module runs At the moment the line executes
The path can be a variable No, it must be a literal Yes: import(\./locales/${code}.js`)`
What it returns Nothing (it declares bindings) A promise with the namespace object

That await in the example is also unusual outside a function: in ES modules you are allowed to use await directly at the top level, something called top-level await.

// data/config.js
const response = await loadSimulatedConfig();     // ✓ only valid in an ES module
export const config = response;

When a module uses await at the top level, every module that imports it waits for it to finish before running. It is a convenient tool, but it has a real cost at application startup, so it is worth using with care. You will come back to it in 05-06, when await stops being a word that appears in passing.

  1. CommonJS: require and module.exports

Before ES modules existed, Node.js invented its own system, called CommonJS, and it is still everywhere. You are going to run into it constantly, so you need to recognize it.

// util/dates.js — CommonJS version
const TODAY = '2026-09-20';

function daysBetween(from, to) {
  return Math.round((new Date(to) - new Date(from)) / 86400000);
}

module.exports = { TODAY, daysBetween };
// or: exports.TODAY = TODAY;  exports.daysBetween = daysBetween;
// app.js — CommonJS version
const { TODAY, daysBetween } = require('./util/dates.js');
const dates = require('./util/dates.js');         // the whole object

console.log(daysBetween(TODAY, '2026-09-30'));    // 10

Here the braces really are destructuring (04-07), because require returns an ordinary object. That is the underlying difference between the two systems:

ESM (import/export) CommonJS (require/module.exports)
Standard The language's, since ES2015 Node.js's
When it is resolved Statically, before running At run time, when the line is reached
Loading Asynchronous Synchronous (blocking)
Where it can be written Only at the top level Anywhere, including inside an if
Variable path No Yes
What is imported Live bindings A copy of the object at that instant
Works in the browser Yes Not without tooling
await at the top level Yes No
.js extension mandatory Yes No

The difference between "live binding" and "copy" has visible consequences:

// counter.cjs
let calls = 0;
function increment() { calls += 1; }
module.exports = { calls, increment };
const { calls, increment } = require('./counter.cjs');
increment();
console.log(calls);         // 0  ← a copy of the value at import time!

With ESM, that same code would print 1. It is a classic source of confusion when migrating between systems.

Which to use today: ESM for everything new. It is the language standard, it works in the browser and in Node, and it allows the static analysis the tools in 09-05 depend on. You will see CommonJS in projects a few years old, in many configuration files and in old dependencies.

  1. Node and "type": "module"

In the browser, type="module" on the tag decides the system. In Node it is decided by the folder's package.json:

{
  "name": "nomada-tasks",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node js/app.js"
  }
}

With "type": "module", Node reads every .js in that project as an ES module. Without that line (or with "type": "commonjs"), it reads them as CommonJS. And if you need to mix, extensions win over the package.json:

Extension Read as
.js Whatever "type" says in the nearest package.json
.mjs Always an ES module
.cjs Always CommonJS

With that in place you can already run the project from the terminal, which is very handy for testing the logic without opening a browser:

cd nomada-tasks
npm run start          # or directly: node js/app.js

A frequent gotcha: in an ES module there is no require, __dirname or __filename. If you need them there are modern equivalents, but normally you will not miss them.

  1. Circular dependencies

Two modules that import each other form a cycle. It is not a language error, but it usually ends in undefined at the worst possible moment.

// model/task.js
import { Board } from './board.js';
export class Task {
  moveTo(destination) { return new Board(destination); }
}

// model/board.js
import { Task } from './task.js';
export class Board {
  add(data) { return new Task(data); }
}

What happens underneath is that one module starts running, hits the import, goes to the other, and that one comes back to the first —which has not finished running yet. With class, which has a temporal dead zone (05-02), the typical result is an error like Cannot access 'Task' before initialization.

flowchart LR
    A["task.js"] -->|"import { Board }"| B["board.js"]
    B -->|"import { Task }"| A
    A -.->|"⚠ cycle"| A

A cycle is almost always a design signal: two modules that need each other are really one, or they are missing a third. The three usual ways out:

  1. Extract what is common into a third module. If both need the constants and the errors, pull them out into model/errors.js and model/rules.js, and have both depend on those.
  2. Invert the dependency. Let Board know about Task (it adds tasks), but let Task not know about Board. If a task needs something from the board, it should be passed in as a parameter.
  3. Merge the two modules if they really are inseparable.

In Nómada Tasks we apply number 2, which is the one that produces a clean graph: the lower layers do not know about the upper ones.

  1. Nómada Tasks, reorganized

Here is the whole project spread across its final modules. This is the structure every following module of the course will work on.

nomada-tasks/
├── index.html
├── package.json          ← { "type": "module" }
├── css/
│   └── styles.css
└── js/
    ├── app.js            ← entry point
    ├── model/
    │   ├── task.js       ← class Task, RecurringTask
    │   ├── board.js      ← class Board
    │   └── errors.js     ← ValidationError, DataError
    ├── data/
    │   └── backlog.js    ← the canonical backlog
    └── util/
        ├── dates.js      ← TODAY, daysBetween, isOverdue
        └── format.js     ← badges, plurals, text tables

And its dependency graph:

flowchart TD
    APP["js/app.js<br/>entry point"]
    BOA["model/board.js"]
    TAS["model/task.js"]
    BAC["data/backlog.js"]
    ERR["model/errors.js"]
    DAT["util/dates.js"]
    FOR["util/format.js"]

    APP --> BOA
    APP --> BAC
    APP --> FOR
    APP --> ERR
    BOA --> TAS
    BOA --> ERR
    TAS --> ERR
    TAS --> DAT
    TAS --> FOR
    BAC --> TAS

Notice that every arrow points downwards: util/ and model/errors.js depend on nobody, model/task.js depends on them, model/board.js depends on the task, and app.js sits right at the top. There are no cycles, and every file declares in its first lines exactly what it depends on.

Now the files. First the leaves of the graph:

// js/util/dates.js
export const TODAY = '2026-09-20';

/** Days of difference between two ISO dates (negative if 'to' has already passed). */
export function daysBetween(from, to) {
  return Math.round((new Date(to) - new Date(from)) / 86400000);
}

/** R10: overdue = due date in the past and the task unfinished. */
export function isOverdue(dueDate, status, today = TODAY) {
  return dueDate < today && status !== 'done';
}

/** '2026-09-05' → '5 September 2026' */
export function readableDate(iso) {
  const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
                  'July', 'August', 'September', 'October', 'November', 'December'];
  const [year, month, day] = iso.split('-');
  return `${Number(day)} ${MONTHS[Number(month) - 1]} ${year}`;
}
// js/util/format.js
export const BADGES = Object.freeze({ pending: '○', 'in-progress': '▸', done: '✓' });
export const WEIGHTS = Object.freeze({ high: 3, medium: 2, low: 1 });

export function statusBadge(status) {
  return BADGES[status] ?? '?';
}

export function plural(count, singular, pluralForm) {
  return `${count} ${count === 1 ? singular : pluralForm}`;
}

/** Right-aligns a piece of text with spaces, for console tables. */
export function alignRight(text, width) {
  return String(text).padStart(width, ' ');
}
// js/model/errors.js
export class ValidationError extends Error {
  constructor(message, field, receivedValue) {
    super(message);
    this.name = 'ValidationError';
    this.field = field;
    this.receivedValue = receivedValue;
  }
  describe() {
    return `[${this.name}] ${this.field}: ${this.message}`;
  }
}

export class DataError extends Error {
  constructor(message, cause) {
    super(message);
    this.name = 'DataError';
    this.cause = cause;
  }
}

Now the model, which contains only what belongs to it:

// js/model/task.js
import { TODAY, isOverdue as dateOverdue } from '../util/dates.js';
import { WEIGHTS, statusBadge } from '../util/format.js';
import { ValidationError } from './errors.js';

const TRANSITIONS = Object.freeze({                     // R6
  pending: ['in-progress'],
  'in-progress': ['pending', 'done'],
  done: []
});

export class Task {
  #status = 'pending';                                  // R5
  #hours;

  constructor(data) {
    if (typeof data.title !== 'string' || data.title.trim() === '') {
      throw new ValidationError('The title cannot be empty.', 'title', data.title);   // R2
    }
    this.id = data.id;
    this.title = data.title.trim();
    this.assignee = data.assignee || null;              // R8
    this.priority = data.priority ?? 'medium';
    this.tags = [...new Set((data.tags ?? []).map((tag) => tag.trim().toLowerCase()))];   // R9
    this.dueDate = data.dueDate;
    this.reviewer = data.reviewer ?? null;
    this.estimatedHours = data.estimatedHours;          // goes through the setter (R3)
    if (data.status !== undefined) {
      if (!Object.hasOwn(TRANSITIONS, data.status)) {
        throw new ValidationError(`Unknown status: "${data.status}".`, 'status', data.status);
      }
      this.#status = data.status;
    }
  }

  get status()         { return this.#status; }
  get estimatedHours() { return this.#hours; }
  get isOpen()         { return this.#status !== 'done'; }
  get effort()         { return (WEIGHTS[this.priority] ?? 0) * this.#hours; }

  set estimatedHours(value) {
    if (typeof value !== 'number' || !(value > 0 && value <= 40)) {
      throw new ValidationError('Hours must be between 1 and 40.', 'estimatedHours', value);   // R3
    }
    this.#hours = value;
  }

  isOverdue(today = TODAY) {
    return dateOverdue(this.dueDate, this.#status, today);
  }

  changeStatus(next) {
    if (!(TRANSITIONS[this.#status] ?? []).includes(next)) {
      throw new ValidationError(
        `Transition not allowed: "${this.#status}" → "${next}".`, 'status', next);   // R6
    }
    this.#status = next;
    return this;
  }

  describe(today = TODAY) {
    const warning = this.isOverdue(today) ? ' ⚠ OVERDUE' : '';
    return `${statusBadge(this.#status)} [${this.id}] ${this.title} · ${this.assignee ?? 'unassigned'} · ${this.#hours} h${warning}`;
  }

  toJSON() {
    return { id: this.id, title: this.title, assignee: this.assignee,
             priority: this.priority, status: this.#status, tags: [...this.tags],
             estimatedHours: this.#hours, dueDate: this.dueDate, reviewer: this.reviewer };
  }

  static fromJSON(data) {
    return new Task(typeof data === 'string' ? JSON.parse(data) : data);
  }
}

export class RecurringTask extends Task {
  constructor(data) {
    super(data);
    this.frequency = data.frequency ?? 'weekly';
  }
  describe(today) {
    return `${super.describe(today)} · repeats ${this.frequency}`;
  }
}

Notice the import { isOverdue as dateOverdue }: the utility function and the class method have the same name, and as resolves the clash without renaming either of them. That is exactly what it exists for.

// js/model/board.js
import { Task } from './task.js';
import { ValidationError } from './errors.js';

export class Board {
  #tasks = [];

  constructor(name, tasks = []) {
    this.name = name;
    for (const t of tasks) this.add(t);
  }

  get total()      { return this.#tasks.length; }
  get tasks()      { return [...this.#tasks]; }
  get open()       { return this.#tasks.filter((t) => t.isOpen); }
  get totalHours() { return this.#tasks.reduce((s, t) => s + t.estimatedHours, 0); }
  get openHours()  { return this.open.reduce((s, t) => s + t.estimatedHours, 0); }
  get effort()     { return this.#tasks.reduce((s, t) => s + t.effort, 0); }

  findById(id)      { return this.#tasks.find((t) => t.id === id) ?? null; }
  filter(predicate) { return this.#tasks.filter(predicate); }
  overdue(today)    { return this.open.filter((t) => t.isOverdue(today)); }

  hoursByAssignee() {
    return this.open.reduce((acc, t) => {
      const key = t.assignee ?? 'unassigned';
      acc[key] = (acc[key] ?? 0) + t.estimatedHours;
      return acc;
    }, {});
  }

  add(task) {
    if (!(task instanceof Task)) {
      throw new ValidationError('Only instances of Task are accepted.', 'task', task);
    }
    if (this.findById(task.id)) {
      throw new ValidationError(`Duplicate id: ${task.id}.`, 'id', task.id);          // R1
    }
    this.#tasks.push(task);
    return this;
  }

  changeStatus(id, next) {
    const task = this.findById(id);
    if (task === null) throw new ValidationError(`Task ${id} does not exist.`, 'id', id);
    task.changeStatus(next);
    return this;
  }

  summary(today) {
    return { total: this.total, open: this.open.length,
             totalHours: this.totalHours, openHours: this.openHours,
             overdue: this.overdue(today).length, effort: this.effort };
  }
}
// js/data/backlog.js
import { Task } from '../model/task.js';

/** Plain data for the canonical Taller Nómada backlog. */
export const backlogData = [
  { id: 1, title: 'Redesign the multipurpose room',          assignee: 'Iván',  priority: 'high',   status: 'in-progress', tags: ['space', 'design'],                  estimatedHours: 12, dueDate: '2026-09-30', reviewer: 'Marta' },
  { id: 2, title: 'Signage for the screen-printing workshop', assignee: 'Marta', priority: 'medium', status: 'pending',     tags: ['screen-printing', 'communication'], estimatedHours: 6,  dueDate: '2026-10-15', reviewer: null },
  { id: 3, title: 'Update the bookings website',             assignee: 'Lucía', priority: 'high',   status: 'pending',     tags: ['web', 'bookings'],                  estimatedHours: 14, dueDate: '2026-10-02', reviewer: 'Iván' },
  { id: 4, title: 'Screen-printing ink inventory',           assignee: 'Marta', priority: 'low',    status: 'done',        tags: ['screen-printing', 'storeroom'],     estimatedHours: 3,  dueDate: '2026-09-12', reviewer: null },
  { id: 5, title: 'Bookbinding guide for residents',         assignee: 'Iván',  priority: 'medium', status: 'in-progress', tags: ['bookbinding', 'documentation'],     estimatedHours: 8,  dueDate: '2026-11-05', reviewer: 'Lucía' },
  { id: 6, title: 'Carpentry workshop quote',                assignee: 'Iván',  priority: 'high',   status: 'pending',     tags: ['carpentry', 'purchasing'],          estimatedHours: 5,  dueDate: '2026-09-05', reviewer: 'Marta' }
];

/** Returns new instances every time: nobody shares state by accident. */
export function createBacklog() {
  return backlogData.map((data) => new Task(data));
}

That createBacklog() as a function, instead of an already-built exported array, is a deliberate decision. Since modules run only once (section 7), a directly exported array would be the same one for the whole application, and a test that changed a task's status would contaminate the next one. By returning new instances on every call, each consumer has its own.

And finally the entry point, which no longer contains any logic: it just orchestrates.

// js/app.js
import { Board } from './model/board.js';
import { createBacklog } from './data/backlog.js';
import { TODAY, readableDate } from './util/dates.js';
import { plural, alignRight } from './util/format.js';
import { ValidationError } from './model/errors.js';

const board = new Board('Taller Nómada', createBacklog());

console.log(`— ${board.name} · ${readableDate(TODAY)} —`);
for (const task of board.tasks) {
  console.log(task.describe(TODAY));
}

const s = board.summary(TODAY);
console.log(`\n${plural(s.total, 'task', 'tasks')}, ${s.open} open`);
console.log(`Hours: ${s.openHours} open out of ${s.totalHours} total`);
console.log(`Overdue: ${s.overdue} · Weighted effort: ${s.effort}`);

console.log('\nWorkload by assignee:');
for (const [person, hours] of Object.entries(board.hoursByAssignee())) {
  console.log(`  ${person.padEnd(8)} ${alignRight(hours, 3)} h`);
}

try {
  board.changeStatus(6, 'done');              // pending → done: forbidden (R6)
} catch (error) {
  if (error instanceof ValidationError) console.error(`\n⚠ ${error.describe()}`);
  else throw error;
}

Running it with node js/app.js (or opening index.html from the local server and looking at the console):

— Taller Nómada · 20 September 2026 —
▸ [1] Redesign the multipurpose room · Iván · 12 h
○ [2] Signage for the screen-printing workshop · Marta · 6 h
○ [3] Update the bookings website · Lucía · 14 h
✓ [4] Screen-printing ink inventory · Marta · 3 h
▸ [5] Bookbinding guide for residents · Iván · 8 h
○ [6] Carpentry workshop quote · Iván · 5 h ⚠ OVERDUE

6 tasks, 5 open
Hours: 45 open out of 48 total
Overdue: 1 · Weighted effort: 124

Workload by assignee:
  Iván      25 h
  Marta      6 h
  Lucía     14 h

⚠ [ValidationError] status: Transition not allowed: "pending" → "done".

The same canonical numbers as always, now produced by six files with clear boundaries. And most importantly: if tomorrow the effort calculation has to change, you know exactly which file to open.

Common Mistakes and Tips

  • Forgetting the .js extension in the specifier. from './util/dates' gives a 404 in the browser and an ERR_MODULE_NOT_FOUND in Node. The extension is mandatory.
  • Opening the HTML by double-clicking. Modules do not work over file:// because of CORS. Always a local server.
  • Confusing import { X } with destructuring. It takes no defaults, no renaming with :, no nested patterns; renaming is done with as.
  • A misspelled name in a named import. It gives SyntaxError: The requested module does not provide an export named 'Boar'. It is a good error: it shows up at load time, not halfway through execution.
  • Mixing export default and named exports without a criterion. Pick a convention per project and stick to it. In Nómada Tasks: always named.
  • Putting an import inside an if or a function. It is a SyntaxError; that is what dynamic import() is for.
  • Mutable state at module level. Since the module runs once, an export const cache = new Map() is global to the whole application. Sometimes that is exactly what you want; other times it is a headache in the Module 8 tests. Export factory functions when each consumer should have its own.
  • Circular dependencies. If you see Cannot access 'X' before initialization, look for the cycle. The fix is almost never a trick: it is redesigning so dependencies point in one direction only.
  • Barrels in large projects. An index.js that re-exports fifty files loads them all even if you use one. Convenient, but at a cost (09-05).
  • Tip: order the imports in each file by layer —external libraries first, then project modules, then the nearest ones. It is the convention the tools in 08-02 apply, and it makes the file read like a statement of intent.

Exercises

Exercise 1 — Spot the cycle. This project does not start. Draw the graph, identify the cycle and propose a reorganization with a new module.

// a/report.js
import { Board } from '../b/board.js';
export function generateReport(tasks) { return new Board('temp', tasks).summary(); }

// b/board.js
import { formatReport } from '../a/report.js';
export class Board {
  print() { return formatReport(this.summary()); }
}

Exercise 2 — A util/stats.js module. Create a module exporting mean(numbers), maxBy(objects, field) and groupBy(objects, field), plus a VERSION constant. None of the three may import anything from the model (they must work for any array of objects). Then write an app.js that imports it and calculates, over the canonical backlog: the mean number of hours of the open tasks, the task with the most hours, and the grouping by priority.

Exercise 3 — Shared counter. Create data/metrics.js exporting record(event) and report(), keeping the count in module state. Import it from model/task.js and from model/board.js, record an event on every status change, and show with a console trace that the two modules share the same counter. Then explain in two lines when that would be a problem.

Solutions

Exercise 1

The cycle is direct: report.jsboard.jsreport.js.

flowchart LR
    I["a/report.js"] -->|"import Board"| T["b/board.js"]
    T -->|"import formatReport"| I

The underlying cause is a layer inversion: Board is a model piece and should know nothing about how reports are formatted. The fix is not an import trick, but sorting out the responsibilities:

// util/report-format.js — bottom layer: imports nothing
export function formatReport(summary) {
  return `${summary.total} tasks · ${summary.openHours} h open · effort ${summary.effort}`;
}

// model/board.js — middle layer: depends only on utilities
export class Board {
  summary() { /* … */ }
  // print() disappears: the board calculates, it does not present
}

// reports/report.js — top layer: knows about the two below
import { Board } from '../model/board.js';
import { formatReport } from '../util/report-format.js';

export function generateReport(tasks) {
  return formatReport(new Board('temp', tasks).summary());
}

The resulting graph, cycle-free and with every arrow pointing the same way:

flowchart TD
    REP["reports/report.js"] --> BOA["model/board.js"]
    REP --> FMT["util/report-format.js"]
    BOA --> FMT

The general lesson: when a cycle appears, there is almost always a layer looking upwards. Here it was Board.print(), a presentation method in a model class.

Exercise 2

// js/util/stats.js
export const VERSION = '1.0.0';

/** Arithmetic mean of an array of numbers. Returns 0 if it is empty. */
export function mean(numbers) {
  if (numbers.length === 0) return 0;
  return numbers.reduce((s, n) => s + n, 0) / numbers.length;
}

/** The object with the highest value in the given field, or null if the array is empty. */
export function maxBy(objects, field) {
  return objects.reduce((best, current) =>
    (best === null || current[field] > best[field]) ? current : best, null);
}

/** Groups by the value of a field: { high: [...], medium: [...] } */
export function groupBy(objects, field) {
  return objects.reduce((acc, obj) => {
    const key = obj[field] ?? 'no value';
    (acc[key] ??= []).push(obj);
    return acc;
  }, {});
}
// js/app.js
import { createBacklog } from './data/backlog.js';
import { mean, maxBy, groupBy, VERSION } from './util/stats.js';

const backlog = createBacklog();
const open = backlog.filter((t) => t.isOpen);

console.log(`stats v${VERSION}`);
console.log(mean(open.map((t) => t.estimatedHours)).toFixed(1));   // '9.0'  (45 / 5)
console.log(maxBy(backlog, 'estimatedHours').title);               // 'Update the bookings website'

const byPriority = groupBy(backlog, 'priority');
for (const [priority, tasks] of Object.entries(byPriority)) {
  console.log(`${priority}: ${tasks.length}`);
}
// high: 3 · medium: 2 · low: 1

The constraint in the statement —that it must not import anything from the model— is the point of the exercise. mean, maxBy and groupBy work just as well with tasks, with residents or with room bookings, and that is why they live in util/ and depend on nobody: they are a leaf of the graph. A utility module that imports from the model stops being reusable and is the seed of the next cycle. Note as well that maxBy returns the first maximum in the event of a tie, because the comparison is strict (>).

Exercise 3

// js/data/metrics.js
const counts = new Map();          // module state: a single one for the whole application

export function record(event) {
  counts.set(event, (counts.get(event) ?? 0) + 1);
}

export function report() {
  return Object.fromEntries([...counts.entries()].sort((a, b) => b[1] - a[1]));
}

export function reset() {          // essential for the Module 8 tests
  counts.clear();
}
// js/model/task.js  (added)
import { record } from '../data/metrics.js';

changeStatus(next) {
  // …R6 validation…
  this.#status = next;
  record(`task:${next}`);
  return this;
}
// js/model/board.js  (added)
import { record } from '../data/metrics.js';

changeStatus(id, next) {
  // …
  task.changeStatus(next);
  record('board:statusChange');
  return this;
}
// js/app.js
import { report } from './data/metrics.js';

board.changeStatus(2, 'in-progress');
board.changeStatus(3, 'in-progress');
board.changeStatus(1, 'done');

console.log(report());
// { 'board:statusChange': 3, 'task:in-progress': 2, 'task:done': 1 }

That the report adds up the calls made from two different modules proves that both received the same instance of metrics.js: the module was loaded and run once, and that Map is unique.

When is it a problem? When the shared state should be independent. In the Module 8 tests, each test would inherit the previous one's counts and would fail intermittently —hence why we exported reset(). And if the application showed two boards at once, the metrics of both would blend together with no way to separate them. The alternative is the same one we applied in createBacklog(): export a factory (createMetrics()) so each consumer has its own instance, and reserve module state for what really is global, such as the application's configuration.

Conclusion

The project has stopped being a file and become an architecture. The three problems from the beginning are solved by construction: every module has its own scope, so two files can declare WEIGHTS without either noticing; load order is worked out by the engine from the imports, not by the HTML; and dependencies are explicit, written in the first lines of each file, to the point where you can draw the graph and check at a glance that every arrow points downwards.

You have the complete syntax down. export in front of a declaration or grouped in a list at the end; import { a, b } with names that must match —it is not destructuring—; as to rename at either end, as you did with isOverdue as dateOverdue to resolve a real clash; import * as ns to bring in the whole namespace; export default for the "this file is a single thing" case, with its pros and cons against named exports; and re-exporting through barrels, convenient but with a loading cost worth knowing about. You know that a module runs only once and works like a singleton, that imports are read-only live bindings, and that this is why createBacklog() is a function and not an exported array.

On the environment side you have the full picture: <script type="module"> with its own scope, its permanent strict mode, its implicit defer —which will come in very handy in Module 6— and its requirement of a local server because of CORS; dynamic import() and module-level await as syntax you will pick up again in 05-06 and 09-05; CommonJS with require/module.exports so you recognize it when it turns up, with its table of differences against ESM —static versus dynamic resolution, live bindings versus copies, browser support or none—; and the "type": "module" in package.json that lets you run the project with node js/app.js. And you know how to diagnose a circular dependency: not as an import problem to be fixed with a trick, but as the symptom of a layer looking upwards.

Nómada Tasks now lives in model/task.js, model/board.js, model/errors.js, data/backlog.js, util/dates.js, util/format.js and js/app.js, and it still gives the same numbers as always: 48 h in total, 45 open, 1 overdue, effort 124, Iván on 25 h, Lucía on 14 and Marta on 6. With one important difference: that data/backlog.js is currently a hand-written array, and everybody knows that in the real application the data will come from somewhere —a file, a server— and will take time to arrive. A single-threaded language cannot sit there waiting with its arms folded while that happens, because while it waits the entire interface freezes. How to program a wait without blocking anything is the course's other great leap, and it starts in Asynchronous JavaScript: Callbacks.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved