Nómada Tasks already remembers, talks to a server, stands up to network failures, syncs live and works offline. What is left are the medium-sized pieces: the ones that separate a correct application from one that is a pleasure to use. Loading cards only when they appear on screen, making the filters linkable and able to survive a reload, copying the board summary with one click, respecting Marta working in dark mode or Lucía having asked for less animation, animating without stutter and —at last— formatting dates and numbers properly, retiring that hand-written array of months in util/dates.js. All of this already exists in the browser: no libraries needed. In this lesson you will go through the APIs that solve real needs in the project, each with its example, its note on permissions and its way of checking availability, because the rule that governs the whole lesson is progressive enhancement: if the API is there, all the better; if not, the application carries on working.

Contents

  1. IntersectionObserver: reacting to what is visible
  2. MutationObserver and ResizeObserver
  3. Permissions: the golden rule
  4. Notification: alerting without pestering
  5. Geolocation and the weight of personal data
  6. The Clipboard API: copying the summary
  7. The History API: linkable filters
  8. matchMedia: theme and motion
  9. navigator.share: sharing properly
  10. requestAnimationFrame versus setTimeout
  11. Intl: real internationalization
  12. Rewriting util/format.js with Intl
  13. Summary table and progressive enhancement
  14. Common Mistakes and Tips
  15. Exercises
  16. Conclusion

  1. IntersectionObserver: reacting to what is visible

The problem: if the board has two hundred cards, painting them all at once is wasted work. The old way of knowing what was visible was to listen for scroll and call getBoundingClientRect() on every pixel of movement: expensive, and it also forces the browser to recalculate the page geometry constantly.

IntersectionObserver turns the approach around: the browser tells you when an element enters or leaves the visible area, asynchronously and without blocking the scroll.

// js/view/lazy-load.js
const observer = new IntersectionObserver((entries, obs) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;           // we only care about the ones COMING IN

    const card = entry.target;
    loadTaskDetail(card.dataset.id);               // request the expensive stuff only now
    obs.unobserve(card);                           // ← no need to watch it any more
  }
}, {
  root: null,          // null = the viewport; it can also be a scrolling container
  rootMargin: '200px', // starts loading 200 px BEFORE it is visible: it feels instant
  threshold: 0.1       // 10 % of the element being visible is enough
});

document.querySelectorAll('.task[data-id]').forEach((t) => observer.observe(t));
Option What it controls Useful values
root What it is measured against null (viewport) or a scrolling element
rootMargin Margin that grows or shrinks the area '200px' to preload; '-50px' to demand deeper entry
threshold How much of the element must be visible 0 (one pixel), 0.5 (half), [0, 0.5, 1] (several notifications)

And the data each entry carries:

entry.isIntersecting     // true if it is visible according to the threshold
entry.intersectionRatio  // 0 to 1: what proportion is visible
entry.target             // the observed element
entry.boundingClientRect // its geometry, without forcing a recalculation
entry.time               // timestamp

Three frequent uses beyond lazy loading:

// A · Infinite scroll: load the next page when the sentinel is reached
const sentinel = document.querySelector('#end-of-list');
new IntersectionObserver(([e]) => { if (e.isIntersecting) loadNextPage(); })
  .observe(sentinel);

// B · Animate on appearance (respecting the user's preference, section 8)
new IntersectionObserver((entries) => {
  for (const e of entries) e.target.classList.toggle('visible', e.isIntersecting);
}, { threshold: 0.25 }).observe(section);

// C · A header that sticks on scroll, without listening for 'scroll'
new IntersectionObserver(([e]) => header.classList.toggle('stuck', !e.isIntersecting))
  .observe(document.querySelector('#top-sentinel'));

Permissions: none. Privacy: no implications.

For images, the browser already ships native loading="lazy" and you do not need an observer. And lazy loading of code —splitting the JavaScript so it is not all downloaded at once— is a different topic covered in 09-05.

  1. MutationObserver and ResizeObserver

Two siblings of the previous one, with the same pattern: new Observer(callback), observe(element, options), disconnect().

MutationObserver tells you when the DOM changes: nodes are added or removed, an attribute changes, the text changes.

const watcher = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    if (mutation.type === 'childList') {
      console.log(`+${mutation.addedNodes.length} −${mutation.removedNodes.length} cards`);
    }
    if (mutation.type === 'attributes' && mutation.attributeName === 'data-status') {
      console.log('The card changed column:', mutation.target.dataset.id);
    }
  }
});

watcher.observe(document.querySelector('#task-list'), {
  childList: true, subtree: true, attributes: true, attributeFilter: ['data-status']
});

An architectural warning: in your own application you almost never need it. If you control the render, you already know when the DOM changes: that is why you emit CustomEvents (06-04). MutationObserver is for what you do not control —third-party content, a rich text editor, an extension— or for writing tools. Using it to react to your own changes is a sign that the data flow has been lost.

ResizeObserver tells you when an element changes size, something window.onresize does not cover: a panel can change width without the window moving.

const sizeObserver = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const width = entry.contentRect.width;
    // Compact columns when the board is narrow, without depending on the window width
    entry.target.classList.toggle('column--compact', width < 320);
  }
});

document.querySelectorAll('.column').forEach((c) => sizeObserver.observe(c));

Watch out for the loop: if inside the callback you change the size of the observed element itself, you cause an infinite cascade. The browser cuts it off with the warning ResizeObserver loop limit exceeded, which in the console looks like a mysterious error and means exactly that.

Permissions: none in either case.

  1. Permissions: the golden rule

Several of the following APIs require the user's consent, and how you ask for it determines whether you get it.

// Query without asking for anything (not every API supports it)
const status = await navigator.permissions.query({ name: 'geolocation' });
console.log(status.state);          // 'granted' | 'denied' | 'prompt'

status.addEventListener('change', () => console.log('Changed to', status.state));
State Means What to do
'granted' Granted Use the API
'denied' Denied Do not ask again: the browser will not even show the dialog
'prompt' Undecided You may ask, at the right moment

The four rules to follow, always:

  • Never ask for a permission when the page loads. The user does not yet know who you are or what you want it for. The overwhelmingly common answer is "Block".
  • Ask in response to an action. The user presses "Notify me when a task is due" → then you ask for notifications. The context explains the reason.
  • Explain before you ask. One sentence of your own before the browser's dialog multiplies acceptance, because the native dialog cannot explain anything.
  • A denied is final. You cannot ask again from code; only the user can reverse it from the browser settings. Design the application to work without the permission.
/** The right pattern: explain, ask inside a gesture, and accept the no. */
async function enableAlerts() {
  if (Notification.permission === 'denied') {
    showHelp('You have blocked notifications. You can re-enable them from the padlock in the address bar.');
    return false;
  }
  if (Notification.permission === 'granted') return true;

  const accepts = await showOwnDialog({
    title: 'Due-date alerts',
    text: 'We will let you know when a workshop task is about to fall due. Nothing else.'
  });
  if (!accepts) return false;                      // we do not even get as far as asking the browser

  return (await Notification.requestPermission()) === 'granted';
}

That preliminary dialog of your own is sometimes called a "pre-permission", and its value is twofold: it explains the reason, and if the user says no, you do not spend your one chance with the browser.

  1. Notification: alerting without pestering

// 1 · Current state, without asking for anything
console.log(Notification.permission);      // 'default' | 'granted' | 'denied'

// 2 · Ask for permission (only inside a user gesture)
const result = await Notification.requestPermission();

// 3 · Show it
if (result === 'granted') {
  const notice = new Notification('Nómada Tasks', {
    body: '"Carpentry workshop quote" is due today.',
    icon: '/icons/icon-192.png',
    badge: '/icons/badge.png',
    tag: 'due-6',                          // ← same tag = replaces, does not pile up
    requireInteraction: false,
    silent: false
  });

  notice.addEventListener('click', () => {
    window.focus();
    document.querySelector('[data-id="6"]')?.scrollIntoView({ behavior: 'smooth' });
    notice.close();
  });
}
Option What for
body The text
icon Large icon
badge Small monochrome icon (mobile)
tag Groups: a notification with the same tag replaces the previous one
requireInteraction Stays until the user closes it
silent No sound
data Your own data, retrieved in the click

The tag is the difference between a well-mannered application and a plague: without it, five due-date checks produce five identical stacked notifications.

In the project, applied to rule R10 (overdue task):

// js/view/alerts.js
export function notifyOverdue(board, today) {
  if (Notification.permission !== 'granted') return;      // no permission, absolute silence

  const critical = board.filter((t) => t.isOpen && t.daysLeft <= 0);
  if (critical.length === 0) return;

  const notice = new Notification('Nómada Tasks', {
    body: critical.length === 1
      ? `"${critical[0].title}" is overdue.`
      : `${critical.length} tasks are overdue.`,
    icon: '/icons/icon-192.png',
    tag: 'overdue'                                        // always the same: they never pile up
  });
  notice.addEventListener('click', () => { window.focus(); notice.close(); });
}

Permissions: yes, explicit. Privacy: the text is shown on the device's lock screen; do not put sensitive data in the body. "You have a new message" is better than reproducing its content on a screen anyone can see.

On mobile, notifications usually require a service worker (registration.showNotification), and with the application closed you need push (07-05).

  1. Geolocation and the weight of personal data

if ('geolocation' in navigator) {
  navigator.geolocation.getCurrentPosition(
    (position) => {
      const { latitude, longitude, accuracy } = position.coords;
      console.log(`You are within ${accuracy} m of ${latitude}, ${longitude}`);
    },
    (error) => {
      const reasons = {
        1: 'Permission denied',
        2: 'Position unavailable',
        3: 'Timed out'
      };
      console.warn(reasons[error.code] ?? error.message);
    },
    { enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 }
  );
}

Notice that it is a callback API, in the style of 05-05, predating promises. It is easy to wrap:

const currentPosition = (options) => new Promise((resolve, reject) =>
  navigator.geolocation.getCurrentPosition(resolve, reject, options)
);

try {
  const { coords } = await currentPosition({ timeout: 8000 });
} catch (error) {
  if (error.code === 1) show('We need your location to clock in at the workshop.');
}
Option Effect
enableHighAccuracy Uses GPS: more accurate, more battery and slower
timeout Maximum time to wait (ms)
maximumAge Accepts a cached position up to N ms old

watchPosition(success, error, options) tracks the position and returns an id that is canceled with clearWatch(id). Always cancel it when leaving the screen: a forgotten watch drains battery non-stop.

Permissions: yes, explicit and very visible. Privacy: the most delicate in the whole lesson. Location is personal data in the full sense of data protection law: it reveals where a person lives, where they work and where they move. Before storing a single coordinate in a real product you need a legal basis, a declared purpose, a retention period and, almost always, legal and compliance review. Collect the minimum accuracy that serves you, not the maximum available; do not store it if using it in the moment is enough; and do not send it to third parties. In Nómada Tasks we do not use geolocation: Marta, Iván and Lucía are fictional people, but the habit of not collecting what you do not need is trained on fictional examples.

  1. The Clipboard API: copying the summary

Marta wants to paste the board summary into the weekly email.

// js/view/clipboard.js
export async function copySummary(board, today) {
  const r = board.summary(today);
  const text = [
    `Taller Nómada — summary for ${today}`,
    `Tasks: ${r.total} (${r.open} open)`,
    `Hours: ${r.totalHours} total, ${r.openHours} open`,
    `Overdue: ${r.overdue}`,
    `Weighted effort: ${r.effort}`
  ].join('\n');

  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch (error) {
    console.warn('[nomada] Could not copy:', error);
    return false;
  }
}
// It must be called from a user GESTURE, not from a timer
$('#copy-summary').addEventListener('click', async () => {
  const copied = await copySummary(board, TODAY);
  $('#copy-notice').textContent = copied
    ? 'Summary copied to the clipboard.'
    : 'Could not copy. Select the text and use Ctrl+C.';
  $('#copy-notice').hidden = false;              // with role="status" so it is announced
});
Method What it does Requirements
clipboard.writeText(text) Copies text User gesture, HTTPS
clipboard.readText() Reads the clipboard Explicit permission; heavily restricted
clipboard.write([items]) Copies rich formats (HTML, images) Gesture, HTTPS

Why it requires a gesture: without that restriction, any page could overwrite your clipboard in the background —imagine you are copying a bank account number— or, worse still, read whatever you have copied. Reading is even more restricted for that reason: it asks for explicit permission and many browsers show a warning.

Permissions: writing, no (the gesture is enough); reading, yes. Privacy: the clipboard can contain passwords and banking details; do not read it unless it is essential and obvious to the user.

One accessibility detail that gets forgotten: confirm the copy. Without confirmation, the user does not know whether it worked, and a role="status" means someone who cannot see the screen knows it too.

  1. The History API: linkable filters

This is the one that improves Nómada Tasks the most. Right now, when Marta filters by Iván and sorts by date, that view cannot be shared and does not survive F5: the state lives only in memory. The History API lets you reflect it in the URL without reloading the page.

// js/view/router.js
import { EVENTS, emit } from './events.js';

/** Reads the view state from the current URL. */
export function readStateFromUrl() {
  const p = new URLSearchParams(location.search);
  return {
    assignee: p.get('assignee'),                          // null if not present
    text: p.get('q') ?? '',
    sort: p.get('sort') ?? 'priority',
    status: p.get('status')
  };
}

/** Writes the state into the URL. `replace` avoids filling the history. */
export function writeStateToUrl(state, { replace = false } = {}) {
  const p = new URLSearchParams();
  if (state.assignee) p.set('assignee', state.assignee);
  if (state.text) p.set('q', state.text);
  if (state.sort && state.sort !== 'priority') p.set('sort', state.sort);
  if (state.status) p.set('status', state.status);

  const url = p.toString() ? `${location.pathname}?${p}` : location.pathname;
  const method = replace ? 'replaceState' : 'pushState';

  history[method](state, '', url);                        // ← the first argument comes back in popstate
}

/** The user pressed back or forward. */
window.addEventListener('popstate', (event) => {
  const state = event.state ?? readStateFromUrl();        // ← careful: state can be null
  emit(document, EVENTS.FILTER_APPLIED, state);
});
// js/app.js
// 1 · At startup, the URL rules
view.update({ filters: readStateFromUrl() });

// 2 · Every filter change is reflected in the URL
document.addEventListener(EVENTS.FILTER_APPLIED, (event) => {
  view.update({ filters: event.detail });
  writeStateToUrl(event.detail, { replace: event.detail.source === 'keyboard' });
});
Method What it does When to use it
history.pushState(state, '', url) Adds an entry to the history Changes the user would want to undo with "back": choosing an assignee
history.replaceState(state, '', url) Replaces the current entry Continuous changes: every key in the search box
history.back() / forward() / go(n) Navigate the history Your own buttons
popstate event The user pressed back/forward Restore the view

Four details that prevent errors:

  • pushState on every keystroke is torture. The user would have to press "back" fifteen times to get out of the search box. For continuous typing, replaceState.
  • popstate does not fire on pushState/replaceState. Only when the user navigates. You already know what you have just done, exactly as the storage event in 07-01 did not fire in the tab that wrote.
  • event.state can be null, for example when you land on the initial entry. Always have the fallback of reading the URL.
  • The URL must be same-origin. You cannot fake the domain; the browser throws a SecurityError.

The benefit is huge and cheap: Marta can paste https://nomada.taller/?assignee=Iván&status=pending into the team chat and everybody sees exactly her view. And F5 no longer loses the filter.

Permissions: none. Privacy: be careful what you put in the URL, because it ends up in the history, in the server logs and in the address bar that anyone walking past can see. Never put personal identifiers or tokens in it.

  1. matchMedia: theme and motion

Media queries are not just for CSS: JavaScript can query them and react to their changes.

const dark = window.matchMedia('(prefers-color-scheme: dark)');

console.log(dark.matches);                    // true if the system is in dark mode

dark.addEventListener('change', (event) => {  // ← reacts if the user changes it on the fly
  applyTheme(event.matches ? 'dark' : 'light');
});

The three queries that matter most in an application:

// js/view/system-preferences.js
export const PREFERENCES = {
  darkTheme:     window.matchMedia('(prefers-color-scheme: dark)'),
  reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)'),
  wideScreen:    window.matchMedia('(min-width: 900px)')
};

/** Applies the theme: the stored preference (07-01) wins over the system one. */
export function applyTheme() {
  const stored = readPreferences().theme;                      // 'light' | 'dark' | 'system'
  const effective = stored === 'system'
    ? (PREFERENCES.darkTheme.matches ? 'dark' : 'light')
    : stored;

  document.documentElement.dataset.theme = effective;          // the CSS does the rest
  document.querySelector('meta[name="theme-color"]')
    ?.setAttribute('content', effective === 'dark' ? '#1b1f1e' : '#2b6b5b');
}

prefers-reduced-motion is not an aesthetic detail. There are people for whom a sliding animation causes dizziness or migraine; the operating system lets them ask for motion to be reduced, and respecting it is accessibility, not courtesy.

/** Moving a card to another column: with animation, or without it if the user avoids motion. */
export function moveCard(card, target) {
  if (PREFERENCES.reducedMotion.matches) {
    target.append(card);                                       // instant, no transition
    return;
  }
  card.animate(
    [{ opacity: 0, transform: 'translateY(-8px)' }, { opacity: 1, transform: 'none' }],
    { duration: 180, easing: 'ease-out' }
  );
  target.append(card);
}

Other useful queries: (prefers-contrast: more), (pointer: coarse) to detect a finger rather than a mouse, (display-mode: standalone) to know whether the PWA from 07-05 is running installed.

Permissions: none. Privacy: they are signals that contribute to browser fingerprinting, but querying them to adapt the interface is a legitimate and expected use.

  1. navigator.share: sharing properly

// js/view/share.js
export async function shareBoard(board, today) {
  const data = {
    title: 'Nómada Tasks',
    text: `Workshop board: ${board.summary(today).open} open tasks.`,
    url: location.href                          // ← includes the filters, thanks to section 7
  };

  // 1 · Native sharing, if it exists (mostly on mobile)
  if (navigator.canShare?.(data)) {
    try {
      await navigator.share(data);
      return 'shared';
    } catch (error) {
      if (error.name === 'AbortError') return 'canceled';      // the user closed the dialog
      console.warn('[nomada] Failed to share:', error);
    }
  }

  // 2 · Fallback: copy the link to the clipboard (section 6)
  await navigator.clipboard.writeText(location.href);
  return 'copied';
}

That is progressive enhancement in its purest form: if the native API exists, it is used; if not, there is an alternative route that meets the same need. The user never runs into a button that does nothing.

Three notes: navigator.share requires a user gesture and HTTPS; its support is far better on mobile than on desktop; and navigator.canShare(data) checks whether that specific data can be shared, which matters when sharing files (files), not just text.

Permissions: none explicit, but a gesture is compulsory. Privacy: you share whatever you put in; check that the URL does not carry information the recipient should not see.

  1. requestAnimationFrame versus setTimeout

Picking up the event loop from 05-07: for animation, setTimeout is the wrong tool.

// ✗ With setTimeout: "roughly" 60 fps, out of sync with painting
function animateBadly(element) {
  let x = 0;
  const id = setInterval(() => {
    x += 2;
    element.style.transform = `translateX(${x}px)`;
    if (x >= 200) clearInterval(id);
  }, 16);
}

// ✓ With requestAnimationFrame: the browser decides the exact moment before painting
function animateWell(element) {
  let start = null;

  function step(timestamp) {                    // timestamp = high-precision ms, given by the browser
    start ??= timestamp;
    const elapsed = timestamp - start;
    const progress = Math.min(elapsed / 300, 1);            // 300 ms duration

    element.style.transform = `translateX(${progress * 200}px)`;

    if (progress < 1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}
setTimeout / setInterval requestAnimationFrame
Moment of execution When its turn comes in the macrotask queue Just before the next paint
Frequency Whatever you ask for, with accumulated drift The monitor's (60, 120, 144 Hz…)
In a hidden tab Keeps running Pauses: saves battery
Sync with painting No: it causes torn frames Yes
Callback argument None A precise timestamp

Two keys to animating well:

  • Base the animation on elapsed time, not on the number of frames. If you add 2 pixels per frame, the animation runs twice as fast on a 120 Hz monitor. By computing the progress from the timestamp, it lasts the same on any screen.
  • Animate transform and opacity. They are the two properties the browser can animate without recalculating the page layout. Animating width, top or margin forces recalculations on every frame. This is covered in depth in 09-04.

And a third key: for simple animations, the best option is usually CSS or the Web Animations API (element.animate(...), which you already used in section 8). requestAnimationFrame is for what needs per-frame computation. It is canceled with cancelAnimationFrame(id), and you have to do it when unmounting the view.

Permissions: none.

  1. Intl: real internationalization

Here comes the outstanding repair. In 05-04 you wrote this:

// ✗ The hand-written array of months: English only, no alternative formats, and yours to maintain
export function readableDate(iso) {
  const MONTHS = ['January', 'February', 'March', /* … */];
  const [year, month, day] = iso.split('-');
  return `${Number(day)} ${MONTHS[Number(month) - 1]} ${year}`;
}

The Intl object has been in the browser for years and does that —and much more— correctly, in any language.

Intl.DateTimeFormat

const date = new Date('2026-09-05T00:00:00');

new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(date);
// '5 September 2026'

new Intl.DateTimeFormat('en-GB', { dateStyle: 'short' }).format(date);
// '05/09/2026'

new Intl.DateTimeFormat('en-GB', { weekday: 'long', day: 'numeric', month: 'long' }).format(date);
// 'Saturday 5 September'

new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date());
// '20 Sept 2026, 10:14'
Option Values
dateStyle 'full', 'long', 'medium', 'short'
timeStyle the same
weekday / month 'long', 'short', 'narrow'
day / year / hour / minute 'numeric', '2-digit'
timeZone 'Europe/London', 'UTC'

Intl.NumberFormat

new Intl.NumberFormat('en-GB').format(1234.5);                             // '1,234.5'
new Intl.NumberFormat('en-GB', { minimumFractionDigits: 1 }).format(48);   // '48.0'

new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'EUR' }).format(1250);
// '€1,250.00'

new Intl.NumberFormat('en-GB', { style: 'percent', maximumFractionDigits: 0 }).format(0.9375);
// '94%'

new Intl.NumberFormat('en-GB', { style: 'unit', unit: 'hour', unitDisplay: 'long' }).format(12);
// '12 hours'

Notice what it solves for free: the thousands comma and the decimal point, the euro symbol in front of the number, the percent sign with no space, and hours in the plural. These are British English conventions that differ in other languages and that, written by hand, always end up wrong somewhere.

Intl.RelativeTimeFormat

Perfect for rule R10 and the model's daysLeft:

const relative = new Intl.RelativeTimeFormat('en-GB', { numeric: 'auto' });

relative.format(-15, 'day');   // '15 days ago'   ← task 6, overdue
relative.format(10, 'day');    // 'in 10 days'
relative.format(-1, 'day');    // 'yesterday'     ← thanks to numeric: 'auto'
relative.format(0, 'day');     // 'today'
relative.format(1, 'day');     // 'tomorrow'
relative.format(2, 'week');    // 'in 2 weeks'

That numeric: 'auto' is the detail that makes it sound natural: with 'always' it would say "in 1 day" instead of "tomorrow".

Intl.Collator

Sorting text with a bare sort() is incorrect, because it compares by character code:

const labels = ['Ávila', 'Zurbarán', 'archive', 'Barcelona', 'ñu', 'note'];

console.log([...labels].sort());
// ['Barcelona', 'Zurbarán', 'archive', 'note', 'Ávila', 'ñu']   ← a disaster

const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true });
console.log([...labels].sort(collator.compare));
// ['archive', 'Ávila', 'Barcelona', 'note', 'ñu', 'Zurbarán']   ← correct

Uppercase was sorted before lowercase and the accented letters went to the end. With Collator, everything is in its place.

Option Effect
sensitivity: 'base' Ignores accents and case: 'resume' === 'résumé'
sensitivity: 'accent' Distinguishes accents, ignores case
numeric: true 'Task 2' before 'Task 10' (natural order!)
caseFirst 'upper' or 'lower'

That numeric: true deserves attention: without it, 'Task 10' comes before 'Task 2', which is the classic flaw of alphabetically sorted lists.

Intl.ListFormat and Intl.PluralRules

new Intl.ListFormat('en-GB', { style: 'long', type: 'conjunction' })
  .format(['Marta', 'Iván', 'Lucía']);
// 'Marta, Iván and Lucía'   ← the final 'and', free

const plural = new Intl.PluralRules('en-GB');
plural.select(1);    // 'one'
plural.select(0);    // 'other'
plural.select(5);    // 'other'

An important performance warning: creating an Intl formatter is expensive. Never do it inside a loop or inside a render function that is called for every card.

// ✗ Creates a formatter per card, on every render
tasks.map((t) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(t.dueDate));

// ✓ A single one, reused
const LONG_DATE = new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' });
tasks.map((t) => LONG_DATE.format(new Date(t.dueDate)));

Permissions: none. Privacy: Intl.DateTimeFormat().resolvedOptions().timeZone reveals the user's time zone, a piece of data that contributes to fingerprinting. Use it to format, not to profile.

  1. Rewriting util/format.js with Intl

Now the complete repair. The formatters are created once when the module loads, and the functions just use them:

// js/util/format.js — rewritten with Intl
const LOCALE = 'en-GB';

// Formatters created ONCE: creating them is expensive
const LONG_DATE  = new Intl.DateTimeFormat(LOCALE, { dateStyle: 'long' });
const SHORT_DATE = new Intl.DateTimeFormat(LOCALE, { day: 'numeric', month: 'short' });
const DATE_TIME  = new Intl.DateTimeFormat(LOCALE, { dateStyle: 'medium', timeStyle: 'short' });
const RELATIVE   = new Intl.RelativeTimeFormat(LOCALE, { numeric: 'auto' });
const NUMBER     = new Intl.NumberFormat(LOCALE, { maximumFractionDigits: 1 });
const HOURS      = new Intl.NumberFormat(LOCALE, { style: 'unit', unit: 'hour', unitDisplay: 'long' });
const PERCENT    = new Intl.NumberFormat(LOCALE, { style: 'percent', maximumFractionDigits: 0 });
const LIST       = new Intl.ListFormat(LOCALE, { style: 'long', type: 'conjunction' });
const COLLATOR   = new Intl.Collator(LOCALE, { sensitivity: 'base', numeric: true });

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] ?? '?';
}

/** '2026-09-05' → '5 September 2026' */
export function readableDate(iso) {
  return LONG_DATE.format(new Date(`${iso}T00:00:00`));
}

/** '2026-09-05' → '5 Sept' — for the cards, where space is tight */
export function shortDate(iso) {
  return SHORT_DATE.format(new Date(`${iso}T00:00:00`));
}

/** -15 → '15 days ago' · 0 → 'today' · 1 → 'tomorrow' */
export function readableDays(days) {
  if (Math.abs(days) >= 14) return RELATIVE.format(Math.round(days / 7), 'week');
  return RELATIVE.format(days, 'day');
}

/** 12 → '12 hours' · 1 → '1 hour' — without the hand-written plural() */
export function readableHours(hours) {
  return HOURS.format(hours);
}

export function number(value) {
  return NUMBER.format(value);
}

/** 0.9375 → '94%' */
export function percentage(fraction) {
  return PERCENT.format(fraction);
}

/** ['Marta','Iván','Lucía'] → 'Marta, Iván and Lucía' */
export function formatList(items) {
  return LIST.format(items);
}

/** Comparator for sort(): respects accents, ñ and numbers inside the text */
export const compareText = COLLATOR.compare;

And its effect on the view, which now reads by itself:

// js/view/card.js (excerpt)
import { shortDate, readableDays, readableHours } from '../util/format.js';

const footer = buildElement('p', { class: 'task__footer' });
footer.textContent = `${shortDate(task.dueDate)} · ${readableDays(task.daysLeft)} · ${readableHours(task.estimatedHours)}`;
// '5 Sept · 15 days ago · 5 hours'   ← task 6, overdue (R10)
// js/view/board-view.js — sorting by title, correctly
import { compareText } from '../util/format.js';

const SORTS = {
  priority: (a, b) => WEIGHTS[b.priority] - WEIGHTS[a.priority],
  date:     (a, b) => a.dueDate.localeCompare(b.dueDate),
  title:    (a, b) => compareText(a.title, b.title)            // ← Intl.Collator
};

The array of months, the hand-written plural() function and the sort() that pushed accents to the end have all been removed. And as a bonus, changing LOCALE to 'es-ES' or 'ca-ES' translates every format without touching another line.

Two warnings about dates that save hours of debugging:

  • new Date('2026-09-05') is interpreted as UTC, and west of Greenwich it can show as the 4th at 19:00. That is why the code adds T00:00:00: that way it is interpreted as local time. It is JavaScript's most frequent "one day out" bug.
  • Intl formats, it does not calculate. Adding months or days is still your job (or that of the future Temporal API). The daysBetween in util/dates.js is still needed.

  1. Summary table and progressive enhancement

API What it is for Does it ask for permission? Notes
IntersectionObserver Knowing what is visible No Replaces listening for scroll
MutationObserver Detecting DOM changes No For DOM you do not control
ResizeObserver Detecting size changes No Watch out for the loop
Notification System alerts Yes, explicit Use tag; nothing sensitive in the body
Geolocation Location Yes, explicit Personal data: legal review
Clipboard Copy / paste Writing no; reading yes Requires a gesture and HTTPS
History URL without reloading No pushState vs replaceState
matchMedia Querying media queries No Theme and prefers-reduced-motion
navigator.share Native sharing No, but a gesture Mostly on mobile
requestAnimationFrame Animating in sync No Pauses in a hidden tab
Intl Formats and language No Create the formatters once

How to check availability. The pattern is called feature detection, and it consists of asking about the capability, never about the browser:

// ✓ Check the feature
if ('share' in navigator)              { /* … */ }
if ('IntersectionObserver' in window)  { /* … */ }
if ('clipboard' in navigator)          { /* … */ }
if ('serviceWorker' in navigator)      { /* … */ }
if (typeof Intl?.RelativeTimeFormat === 'function') { /* … */ }

// Optional chaining for specific methods
navigator.canShare?.(data);
registration.sync?.register('send');

// ✗ Never detect the browser by its identification string
if (navigator.userAgent.includes('Chrome')) { /* … */ }   // fragile, dishonest and obsolete

And the complete progressive enhancement pattern, with its three layers:

export function copyLink(url) {
  // Layer 1 · The best one: the modern API
  if (navigator.clipboard?.writeText) {
    return navigator.clipboard.writeText(url).then(() => 'copied');
  }
  // Layer 2 · The old fallback
  const field = Object.assign(document.createElement('textarea'), { value: url });
  document.body.append(field);
  field.select();
  const ok = document.execCommand?.('copy');            // deprecated, but it still works
  field.remove();
  if (ok) return Promise.resolve('copied');

  // Layer 3 · Let the user do it by hand
  return Promise.resolve('manual');
}

The application never breaks: in the worst case, it does less.

Common Mistakes and Tips

  • Asking for permissions when the page loads. The most effective way of having them denied forever.
  • Insisting after a denied. You cannot: the browser does not even show the dialog. Offer instructions for re-enabling it.
  • Notifications without a tag. They stack up and the user turns the alerts off.
  • Sensitive data in a notification's body. It appears on the lock screen.
  • watchPosition without clearWatch. Continuous battery drain.
  • Copying to the clipboard outside a gesture. It fails silently or throws.
  • Copying without confirming. The user does not know whether it worked; use role="status".
  • pushState on every keystroke. The "back" button becomes useless. Use replaceState.
  • Expecting popstate to fire on pushState. It does not, just like the storage event in 07-01.
  • Assuming event.state is never null. Always have the fallback of reading the URL.
  • Ignoring prefers-reduced-motion. It is a real accessibility problem, not an aesthetic preference.
  • Animating with setInterval adding pixels per frame. The speed depends on the monitor. Use the timestamp.
  • Animating width, top or margin. It forces a layout recalculation on every frame. Use transform and opacity.
  • Creating an Intl.DateTimeFormat inside a loop. It is expensive; create it once at module level.
  • new Date('2026-09-05') with no time. It is interpreted as UTC and can show the previous day.
  • Sorting text with a bare sort(). Accents end up at the end. Use Intl.Collator.
  • Detecting the browser by userAgent. Detect the feature, not the browser.
  • Tip: group the system preferences into one module. Having matchMedia scattered across ten files is unmanageable.
  • Tip: store 'system' as a theme value, not only 'light'/'dark'. It is what most people prefer.
  • Tip: switch LOCALE to 'es-ES' for a moment and check that the whole interface still makes sense. It is the best proof that no hand-written formats are left.
  • Tip: disconnect the observers when unmounting. disconnect() or unobserve(), or you will keep elements alive in memory (leaks you will see in 09-03).

Exercises

Exercise 1 — Fully linkable filters. Write js/view/router.js with readStateFromUrl(), writeStateToUrl(state, options) and connectRouter(view, { signal }). It must: read the initial state from the URL at startup; use replaceState for the search box (continuous typing) and pushState for the discrete filters (assignee, status, sort); respond to popstate by restoring the view; and leave out of the URL any values that are the default, so that an unfiltered board has a clean URL. Add a "Copy link to this view" button that uses the clipboard and confirms accessibly.

Exercise 2 — Three-state theme selector. Write js/view/theme.js that manages a selector with three options: light, dark and system. It must apply the theme to the documentElement through data-theme, store the choice in localStorage (07-01), react to prefers-color-scheme changes only when the option is system, update the <meta name="theme-color"> so the installed PWA (07-05) has the right bar color, and avoid the flash of the wrong theme on load. Cancelable with { signal }.

Exercise 3 — Summary panel with Intl and copying. Write readableSummary(board, today) that returns multi-line text with: today's date in long format, the total tasks and hours with Intl.NumberFormat, the percentage completed, the breakdown per assignee sorted with Intl.Collator, the team names joined with Intl.ListFormat, and the overdue tasks with how long ago they were due in relative format. With the canonical backlog it must produce a summary consistent with the 48 h total, 45 h open and task 6 overdue. Add copySummary() with accessible confirmation.

Solutions

Solution 1

// js/view/router.js
import { EVENTS, emit } from './events.js';

const DEFAULTS = Object.freeze({ assignee: null, text: '', sort: 'priority', status: null });

export function readStateFromUrl(url = location.href) {
  const p = new URL(url).searchParams;
  return {
    assignee: p.get('assignee'),
    text: p.get('q') ?? '',
    sort: p.get('sort') ?? DEFAULTS.sort,
    status: p.get('status')
  };
}

export function writeStateToUrl(state, { replace = false } = {}) {
  const p = new URLSearchParams();
  // Only what differs from the default value: clean URLs
  if (state.assignee) p.set('assignee', state.assignee);
  if (state.text) p.set('q', state.text);
  if (state.sort !== DEFAULTS.sort) p.set('sort', state.sort);
  if (state.status) p.set('status', state.status);

  const query = p.toString();
  const url = query ? `${location.pathname}?${query}` : location.pathname;

  if (url === location.pathname + location.search) return;      // nothing to change
  history[replace ? 'replaceState' : 'pushState']({ ...state }, '', url);
}

export function connectRouter(view, { signal } = {}) {
  view.update({ filters: readStateFromUrl() });                 // 1 · the URL rules at startup

  document.addEventListener(EVENTS.FILTER_APPLIED, (event) => {
    const { source, ...filters } = event.detail;
    view.update({ filters });
    writeStateToUrl(filters, { replace: source === 'text' });    // 2 · reflect it
  }, { signal });

  window.addEventListener('popstate', (event) => {              // 3 · back / forward
    const state = event.state ?? readStateFromUrl();
    view.update({ filters: state });
  }, { signal });
}
document.querySelector('#copy-link').addEventListener('click', async () => {
  const notice = document.querySelector('#copy-notice');        // role="status" aria-live="polite"
  try {
    await navigator.clipboard.writeText(location.href);
    notice.textContent = 'Link to this view copied.';
  } catch {
    notice.textContent = 'Could not copy. The link is in the address bar.';
  }
  notice.hidden = false;
}, { signal });

The detail that gives it quality is leaving out the default values: without it, an unfiltered board would have the URL ?q=&sort=priority, ugly to share and noisy in the history. And source === 'text' is what tells the search box (which uses replaceState) apart from the discrete filters.

Solution 2

// js/view/theme.js
const KEY = 'nomada:theme';
const QUERY = window.matchMedia('(prefers-color-scheme: dark)');

const readPreference  = () => localStorage.getItem(KEY) ?? 'system';
const effectiveTheme = (pref) => (pref === 'system' ? (QUERY.matches ? 'dark' : 'light') : pref);

const COLORS = Object.freeze({ light: '#2b6b5b', dark: '#1b1f1e' });

export function applyTheme(preference = readPreference()) {
  const effective = effectiveTheme(preference);
  document.documentElement.dataset.theme = effective;
  document.querySelector('meta[name="theme-color"]')?.setAttribute('content', COLORS[effective]);
  return effective;
}

export function connectThemeSelector(selector, { signal } = {}) {
  selector.value = readPreference();

  selector.addEventListener('change', () => {
    const chosen = selector.value;
    try { localStorage.setItem(KEY, chosen); } catch { /* private mode: it does not persist */ }
    applyTheme(chosen);
  }, { signal });

  // Only when the preference is 'system' does what the system does matter
  QUERY.addEventListener('change', () => {
    if (readPreference() === 'system') applyTheme('system');
  }, { signal });

  applyTheme();
}
<!-- In <head>, BEFORE the CSS: avoids the flash of the wrong theme -->
<script>
  (function () {
    var pref = localStorage.getItem('nomada:theme') || 'system';
    var dark = pref === 'dark' ||
      (pref === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
    document.documentElement.dataset.theme = dark ? 'dark' : 'light';
  })();
</script>

That inline script is one of the very few legitimate exceptions to the rule of not putting JavaScript in the HTML: if the theme were applied from a module, the page would paint for an instant in light mode before switching to dark, and that white flash is very unpleasant at night. It has to run before the first paint, and that is why it is synchronous and sits in the <head>.

Solution 3

// js/view/summary.js
import { readableDate, readableDays, number, percentage, formatList, compareText, readableHours }
  from '../util/format.js';

export function readableSummary(board, today) {
  const r = board.summary(today);
  const completed = r.total === 0 ? 0 : (r.total - r.open) / r.total;

  // Open hours per assignee, sorted by name with the Collator
  const byAssignee = new Map();
  for (const task of board.filter((t) => t.isOpen)) {
    const who = task.assignee ?? 'Unassigned';
    byAssignee.set(who, (byAssignee.get(who) ?? 0) + task.estimatedHours);
  }
  const breakdown = [...byAssignee.entries()]
    .sort((a, b) => compareText(a[0], b[0]))
    .map(([who, hours]) => `  · ${who}: ${readableHours(hours)}`);

  const team = formatList([...byAssignee.keys()].sort(compareText));

  const overdue = board
    .filter((t) => t.isOpen && t.daysLeft < 0)
    .map((t) => `  ! ${t.title} — was due ${readableDays(t.daysLeft)}`);

  return [
    `Taller Nómada — ${readableDate(today)}`,
    '',
    `Tasks: ${number(r.total)} (${number(r.open)} open, ${percentage(completed)} completed)`,
    `Hours: ${readableHours(r.totalHours)} total · ${readableHours(r.openHours)} open`,
    `Weighted effort: ${number(r.effort)}`,
    '',
    `Team: ${team}`,
    ...breakdown,
    ...(overdue.length ? ['', `Overdue (${overdue.length}):`, ...overdue] : [])
  ].join('\n');
}
export async function copySummary(board, today, notice) {
  try {
    await navigator.clipboard.writeText(readableSummary(board, today));
    notice.textContent = 'Summary copied to the clipboard.';
  } catch {
    notice.textContent = 'Could not copy. Select the text and use Ctrl+C.';
  }
  notice.hidden = false;
}

With the canonical backlog and TODAY = '2026-09-20', the output is:

Taller Nómada — 20 September 2026

Tasks: 6 (5 open, 17% completed)
Hours: 48 hours total · 45 hours open
Weighted effort: 124

Team: Iván, Lucía and Marta
  · Iván: 25 hours
  · Lucía: 14 hours
  · Marta: 6 hours

Overdue (1):
  ! Carpentry workshop quote — was due 15 days ago

Every number matches the canonical backlog, and everything you read —the long date, the percentage with no space, hours in the plural, the comma and the and in the list, 15 days ago— comes out of Intl. There is not a single hand-written format string left.

Conclusion

Nómada Tasks has gone from working to being comfortable. You know how to use the three observers with the same pattern —observe, unobserve, disconnect—: IntersectionObserver to know what is visible without listening for scroll, with its rootMargin that preloads before it is needed; ResizeObserver to adapt to an element's size and not just the window's, watching out for the infinite loop; and MutationObserver for the DOM you do not control, with the warning that needing it in your own application usually means the data flow has been lost.

You have internalized the golden rule of permissions: never on load, always in response to a gesture, always explaining first, and accepting that a denied is final. You applied it to Notification —with a tag so alerts do not stack up and no sensitive data in the body, because it is read on the lock screen— and to Geolocation, the API with the most legal weight of them all, where the lesson is not technical but a matter of judgment: collect the minimum accuracy that serves you, do not store it if you do not need it, and count on legal and compliance review before touching real personal data.

You know how to copy to the clipboard with navigator.clipboard.writeText, understanding why it requires a user gesture and always confirming accessibly. You know how to make the board's filters linkable with pushState for discrete changes and replaceState for continuous typing, restoring the view in popstate with the fallback of reading the URL when state is null, and remembering that popstate does not fire on your own changes —the same echo that does not fire in storage. You know how to query the user's preferences with matchMedia, respecting prefers-color-scheme without overriding an explicit choice and treating prefers-reduced-motion as what it is: accessibility. You know how to share with navigator.share and fall back to the clipboard when it is not there. And you know how to animate with requestAnimationFrame, computing the progress from the timestamp so the duration does not depend on the monitor, on transform and opacity, with the automatic pause in hidden tabs that setInterval does not offer.

And you have settled a debt you had been carrying since 05-04: util/format.js no longer has a hand-written array of months, nor a homemade plural() function, nor a sort() that sent accents to the end. Intl does that job better and in any language: DateTimeFormat with its dateStyle, NumberFormat with the thousands comma and the currency symbol in its place, RelativeTimeFormat with its numeric: 'auto' that says "yesterday" instead of "1 day ago", Collator that sorts "archive" before "Ávila" and "Task 2" before "Task 10", and ListFormat that puts the final "and" in on its own. With two precautions engraved: create the formatters once because they are expensive, and add T00:00:00 to ISO dates so you do not lose a day to the UTC interpretation. And above all, the pattern that runs through the whole lesson: detect the feature, not the browser, and build in layers so the application never breaks, it just does less.

There is one lesson left in this module, and it is the most different of them all. Up to here, everything you have seen was JavaScript talking to the browser. The next one is about something that is not JavaScript: a binary format that runs in the same engine, alongside your code, and that makes it possible to bring programs written in C, C++ or Rust to the browser at near-native speed. You will see what it is, why it exists, how it is loaded and instantiated from JavaScript, and —most important of all for a professional— when it is not worth using, because most applications, Nómada Tasks included, do not need it. This is Introduction to WebAssembly.

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