Nómada Tasques ja recorda, parla amb un servidor, aguanta les fallades de xarxa, se sincronitza en directe i funciona sense connexió. El que queda són les peces mitjanes: les que separen una aplicació correcta d'una que dona gust fer servir. Carregar targetes només quan apareixen a la pantalla, fer que els filtres siguin enllaçables i sobrevisquin a la recàrrega, copiar el resum del tauler d'un clic, respectar que la Marta treballi en mode fosc o que la Lucía hagi demanat menys animació, animar sense estrebades i —per fi— formatar dates i números en català de debò, retirant aquell array de mesos escrit a mà a util/dates.js. Tot això ja existeix al navegador: no calen llibreries. En aquesta lliçó recorreràs les APIs que resolen necessitats reals del projecte, cadascuna amb el seu exemple, la seva nota de permisos i la seva manera de comprovar disponibilitat, perquè la regla que governa tota la lliçó és la millora progressiva: si l'API hi és, millor; si no, l'aplicació continua funcionant.

Contingut

  1. IntersectionObserver: reaccionar al que es veu
  2. MutationObserver i ResizeObserver
  3. Permisos: la regla d'or
  4. Notification: avisar sense molestar
  5. Geolocation i el pes de les dades personals
  6. Clipboard API: copiar el resum
  7. History API: filtres enllaçables
  8. matchMedia: tema i moviment
  9. navigator.share: compartir de debò
  10. requestAnimationFrame davant de setTimeout
  11. Intl: internacionalització de debò
  12. Reescriure util/format.js amb Intl
  13. Taula resum i millora progressiva
  14. Errors Habituals i Consells
  15. Exercicis
  16. Conclusió

  1. IntersectionObserver: reaccionar al que es veu

El problema: si el tauler té dues-centes targetes, pintar-les totes de cop és feina malgastada. La manera antiga de saber què es veu era escoltar scroll i cridar getBoundingClientRect() a cada píxel de desplaçament: costós, i a més obliga el navegador a recalcular la geometria de la pàgina constantment.

IntersectionObserver inverteix el plantejament: el navegador t'avisa quan un element entra o surt de l'àrea visible, de manera asíncrona i sense bloquejar el desplaçament.

// js/vista/carrega-progressiva.js
const observador = new IntersectionObserver((entrades, obs) => {
  for (const entrada of entrades) {
    if (!entrada.isIntersecting) continue;         // només ens interessen les que ENTREN

    const targeta = entrada.target;
    carregarDetallDeTasca(targeta.dataset.id);     // demanar el que és car només ara
    obs.unobserve(targeta);                        // ← ja no cal vigilar-la
  }
}, {
  root: null,          // null = el viewport; també pot ser un contenidor amb scroll
  rootMargin: '200px', // comença a carregar 200 px ABANS que es vegi: es percep instantani
  threshold: 0.1       // n'hi ha prou que se'n vegi el 10 %
});

document.querySelectorAll('.tasca[data-id]').forEach((t) => observador.observe(t));
Opció Què controla Valors útils
root Respecte a què es mesura null (viewport) o un element amb scroll
rootMargin Marge que engrandeix o encongeix l'àrea '200px' per precarregar; '-50px' per exigir més entrada
threshold Quant de l'element s'ha de veure 0 (un píxel), 0.5 (meitat), [0, 0.5, 1] (diversos avisos)

I les dades que porta cada entrada:

entrada.isIntersecting     // true si és visible segons el threshold
entrada.intersectionRatio  // 0 a 1: quina proporció es veu
entrada.target             // l'element observat
entrada.boundingClientRect // la seva geometria, sense forçar recàlcul
entrada.time               // marca de temps

Tres usos freqüents més enllà de la càrrega diferida:

// A · Scroll infinit: carregar la pàgina següent en arribar al sentinella
const sentinella = document.querySelector('#fi-de-llista');
new IntersectionObserver(([e]) => { if (e.isIntersecting) carregarPaginaSeguent(); })
  .observe(sentinella);

// B · Animar en aparèixer (respectant la preferència de l'usuari, apartat 8)
new IntersectionObserver((entrades) => {
  for (const e of entrades) e.target.classList.toggle('visible', e.isIntersecting);
}, { threshold: 0.25 }).observe(seccio);

// C · Capçalera que s'enganxa en fer scroll, sense escoltar 'scroll'
new IntersectionObserver(([e]) => capcalera.classList.toggle('fixada', !e.isIntersecting))
  .observe(document.querySelector('#sentinella-superior'));

Permisos: cap. Privacitat: cap implicació.

Per a imatges, el navegador ja porta loading="lazy" natiu i no necessites observador. I la càrrega diferida de codi —dividir el JavaScript per no descarregar-ho tot de cop— és un altre tema diferent que es tracta a 09-05.

  1. MutationObserver i ResizeObserver

Dos germans de l'anterior, amb el mateix patró: new Observador(callback), observe(element, opcions), disconnect().

MutationObserver avisa quan el DOM canvia: s'afegeixen o es treuen nodes, canvia un atribut, canvia el text.

const vigilant = new MutationObserver((mutacions) => {
  for (const mutacio of mutacions) {
    if (mutacio.type === 'childList') {
      console.log(`+${mutacio.addedNodes.length} −${mutacio.removedNodes.length} targetes`);
    }
    if (mutacio.type === 'attributes' && mutacio.attributeName === 'data-estat') {
      console.log('La targeta ha canviat de columna:', mutacio.target.dataset.id);
    }
  }
});

vigilant.observe(document.querySelector('#llista-tasques'), {
  childList: true, subtree: true, attributes: true, attributeFilter: ['data-estat']
});

Un advertiment d'arquitectura: a la teva pròpia aplicació gairebé mai el necessites. Si tu controles el render, ja saps quan canvia el DOM: per això emets CustomEvent (06-04). MutationObserver és per al que no controles —contingut de tercers, un editor enriquit, una extensió—, o per escriure eines. Fer-lo servir per reaccionar als teus propis canvis és un senyal que el flux de dades s'ha perdut.

ResizeObserver avisa quan un element canvia de mida, cosa que window.onresize no cobreix: un panell pot canviar d'amplada sense que la finestra es mogui.

const observadorMida = new ResizeObserver((entrades) => {
  for (const entrada of entrades) {
    const amplada = entrada.contentRect.width;
    // Columnes compactes quan el tauler és estret, sense dependre de l'amplada de la finestra
    entrada.target.classList.toggle('columna--compacta', amplada < 320);
  }
});

document.querySelectorAll('.columna').forEach((c) => observadorMida.observe(c));

Compte amb el bucle: si dins del callback canvies la mida del propi element observat, provoques una cascada infinita. El navegador la talla amb l'avís ResizeObserver loop limit exceeded, que a la consola sembla un error misteriós i significa exactament això.

Permisos: cap en tots dos casos.

  1. Permisos: la regla d'or

Diverses de les APIs següents exigeixen el consentiment de l'usuari, i la manera de demanar-lo determina si te'l donen.

// Consultar sense demanar res (no totes les APIs ho admeten)
const estat = await navigator.permissions.query({ name: 'geolocation' });
console.log(estat.state);          // 'granted' | 'denied' | 'prompt'

estat.addEventListener('change', () => console.log('Ha canviat a', estat.state));
Estat Significa Què fer
'granted' Concedit Fer servir l'API
'denied' Denegat No tornar-lo a demanar: el navegador ni tan sols mostrarà el diàleg
'prompt' Sense decidir El pots demanar, en el moment adequat

Les quatre regles que cal seguir sempre:

  • No demanis mai un permís en carregar la pàgina. L'usuari encara no sap qui ets ni per a què el vols. La resposta aclaparadorament majoritària és «Bloquejar».
  • Demana en resposta a una acció. L'usuari prem «Avisa'm quan venci una tasca» → llavors demanes notificacions. El context explica el perquè.
  • Explica abans de demanar. Una frase teva abans del diàleg del navegador multiplica l'acceptació, perquè el diàleg natiu no pot explicar res.
  • Un denied és definitiu. No es pot tornar a preguntar per codi; només l'usuari ho pot revertir des de la configuració del navegador. Dissenya l'aplicació per funcionar sense el permís.
/** Patró correcte: explicar, demanar dins d'un gest, i acceptar el no. */
async function activarAvisos() {
  if (Notification.permission === 'denied') {
    mostrarAjuda("Has bloquejat les notificacions. Les pots reactivar des del cadenat de la barra d'adreces.");
    return false;
  }
  if (Notification.permission === 'granted') return true;

  const accepta = await mostrarDialegPropi({
    titol: 'Avisos de venciment',
    text: "T'avisarem quan una tasca del taller estigui a punt de vèncer. Res més."
  });
  if (!accepta) return false;                      // ni tan sols arribem a demanar-ho al navegador

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

Aquell diàleg propi previ s'anomena de vegades «pre-permís», i el seu valor és doble: explica el motiu, i si l'usuari diu que no, no gastes la teva única oportunitat amb el navegador.

  1. Notification: avisar sense molestar

// 1 · Estat actual, sense demanar res
console.log(Notification.permission);      // 'default' | 'granted' | 'denied'

// 2 · Demanar permís (només dins d'un gest de l'usuari)
const resultat = await Notification.requestPermission();

// 3 · Mostrar
if (resultat === 'granted') {
  const avis = new Notification('Nómada Tasques', {
    body: '«Pressupost de la fusteria» venç avui.',
    icon: '/icones/icona-192.png',
    badge: '/icones/badge.png',
    tag: 'venc-6',                         // ← mateix tag = reemplaça, no acumula
    requireInteraction: false,
    silent: false
  });

  avis.addEventListener('click', () => {
    window.focus();
    document.querySelector('[data-id="6"]')?.scrollIntoView({ behavior: 'smooth' });
    avis.close();
  });
}
Opció Per a què
body El text
icon Icona gran
badge Icona monocroma petita (mòbil)
tag Agrupa: una notificació amb el mateix tag substitueix l'anterior
requireInteraction Roman fins que l'usuari la tanqui
silent Sense so
data Dades pròpies que recuperes al click

El tag és la diferència entre una aplicació educada i una plaga: sense ell, cinc comprovacions de venciment produeixen cinc notificacions idèntiques apilades.

Al projecte, aplicat a la regla R10 (tasca vençuda):

// js/vista/avisos.js
export function avisarDeVenciments(tauler, avui) {
  if (Notification.permission !== 'granted') return;      // sense permís, silenci absolut

  const critiques = tauler.filtrar((t) => t.oberta && t.diesRestants <= 0);
  if (critiques.length === 0) return;

  const avis = new Notification('Nómada Tasques', {
    body: critiques.length === 1
      ? `«${critiques[0].titol}» ha vençut.`
      : `${critiques.length} tasques han vençut.`,
    icon: '/icones/icona-192.png',
    tag: 'venciments'                                     // sempre el mateix: mai s'acumulen
  });
  avis.addEventListener('click', () => { window.focus(); avis.close(); });
}

Permisos: sí, explícit. Privacitat: el text es mostra a la pantalla de bloqueig del dispositiu; no posis dades sensibles al body. «Tens un missatge nou» és millor que reproduir-ne el contingut en una pantalla que pot veure qualsevol.

Al mòbil, les notificacions solen requerir un service worker (registration.showNotification), i amb l'aplicació tancada cal push (07-05).

  1. Geolocation i el pes de les dades personals

if ('geolocation' in navigator) {
  navigator.geolocation.getCurrentPosition(
    (posicio) => {
      const { latitude, longitude, accuracy } = posicio.coords;
      console.log(`Ets a ${accuracy} m de ${latitude}, ${longitude}`);
    },
    (error) => {
      const motius = {
        1: 'Permís denegat',
        2: 'Posició no disponible',
        3: "S'ha esgotat el temps d'espera"
      };
      console.warn(motius[error.code] ?? error.message);
    },
    { enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 }
  );
}

Fixa't que és una API de callbacks, de l'estil de 05-05, anterior a les promeses. S'embolcalla fàcilment:

const posicioActual = (opcions) => new Promise((resoldre, rebutjar) =>
  navigator.geolocation.getCurrentPosition(resoldre, rebutjar, opcions)
);

try {
  const { coords } = await posicioActual({ timeout: 8000 });
} catch (error) {
  if (error.code === 1) mostrar("Necessitem la teva ubicació per fitxar l'entrada al taller.");
}
Opció Efecte
enableHighAccuracy Fa servir GPS: més precís, més bateria i més lent
timeout Màxim a esperar (ms)
maximumAge Accepta una posició de la memòria cau de fins a N ms

watchPosition(exit, error, opcions) segueix la posició i retorna un id que es cancel·la amb clearWatch(id). Cancel·la'l sempre en sortir de la pantalla: un seguiment oblidat consumeix bateria sense parar.

Permisos: sí, explícit i molt visible. Privacitat: la més delicada de tota la lliçó. La ubicació és una dada personal en el sentit ple de la normativa de protecció de dades: revela on viu, on treballa i per on es mou una persona. Abans de desar una sola coordenada en un producte real calen base legal, finalitat declarada, termini de conservació i, gairebé sempre, revisió legal i de compliance. Recull la mínima precisió que et serveixi, no la màxima disponible; no la desis si en tens prou de fer-la servir en el moment; i no l'enviïs a tercers. A Nómada Tasques no fem servir geolocalització: la Marta, l'Iván i la Lucía són persones fictícies, però l'hàbit de no recollir el que no necessites s'entrena amb exemples ficticis.

  1. Clipboard API: copiar el resum

La Marta vol enganxar el resum del tauler al correu setmanal.

// js/vista/porta-retalls.js
export async function copiarResum(tauler, avui) {
  const r = tauler.resum(avui);
  const text = [
    `Taller Nómada — resum del ${avui}`,
    `Tasques: ${r.total} (${r.obertes} obertes)`,
    `Hores: ${r.horesTotals} totals, ${r.horesObertes} obertes`,
    `Vençudes: ${r.vencudes}`,
    `Esforç ponderat: ${r.esforc}`
  ].join('\n');

  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch (error) {
    console.warn("[nomada] No s'ha pogut copiar:", error);
    return false;
  }
}
// S'ha de cridar des d'un GEST de l'usuari, no des d'un temporitzador
$('#copiar-resum').addEventListener('click', async () => {
  const copiat = await copiarResum(tauler, AVUI);
  $('#avis-copia').textContent = copiat
    ? 'Resum copiat al porta-retalls.'
    : "No s'ha pogut copiar. Selecciona el text i fes servir Ctrl+C.";
  $('#avis-copia').hidden = false;               // amb role="status" perquè s'anunciï
});
Mètode Què fa Requisits
clipboard.writeText(text) Copia text Gest de l'usuari, HTTPS
clipboard.readText() Llegeix el porta-retalls Permís explícit; molt restringit
clipboard.write([items]) Copia formats rics (HTML, imatges) Gest, HTTPS

Per què exigeix un gest: sense aquella restricció, qualsevol pàgina podria sobreescriure el teu porta-retalls en segon pla —imagina que estàs copiant un IBAN— o, encara pitjor, llegir el que hi tinguis copiat. La lectura està encara més restringida per aquell motiu: demana permís explícit i molts navegadors mostren un avís.

Permisos: escriure, no (n'hi ha prou amb el gest); llegir, sí. Privacitat: el porta-retalls pot contenir contrasenyes i dades bancàries; no el llegeixis tret que sigui imprescindible i evident per a l'usuari.

Un detall d'accessibilitat que s'oblida: confirma la còpia. Sense confirmació, l'usuari no sap si ha funcionat, i un role="status" fa que també ho sàpiga qui no veu la pantalla.

  1. History API: filtres enllaçables

Aquest és el que més millora Nómada Tasques. Ara mateix, quan la Marta filtra per l'Iván i ordena per data, aquella vista no es pot compartir ni sobreviu a F5: l'estat viu només a la memòria. La History API permet reflectir-lo a la URL sense recarregar la pàgina.

// js/vista/encaminador.js
import { ESDEVENIMENTS, emetre } from './esdeveniments.js';

/** Llegeix l'estat de la vista des de la URL actual. */
export function llegirEstatDeUrl() {
  const p = new URLSearchParams(location.search);
  return {
    responsable: p.get('responsable'),                    // null si no hi és
    text: p.get('q') ?? '',
    ordre: p.get('ordre') ?? 'prioritat',
    estat: p.get('estat')
  };
}

/** Escriu l'estat a la URL. `substituir` evita omplir l'historial. */
export function escriureEstatAUrl(estat, { substituir = false } = {}) {
  const p = new URLSearchParams();
  if (estat.responsable) p.set('responsable', estat.responsable);
  if (estat.text) p.set('q', estat.text);
  if (estat.ordre && estat.ordre !== 'prioritat') p.set('ordre', estat.ordre);
  if (estat.estat) p.set('estat', estat.estat);

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

  history[metode](estat, '', url);                        // ← el primer argument es recupera a popstate
}

/** L'usuari ha premut enrere o endavant. */
window.addEventListener('popstate', (esdeveniment) => {
  const estat = esdeveniment.state ?? llegirEstatDeUrl(); // ← compte: state pot ser null
  emetre(document, ESDEVENIMENTS.FILTRE_APLICAT, estat);
});
// js/app.js
// 1 · En arrencar, la URL mana
vista.actualitzar({ filtres: llegirEstatDeUrl() });

// 2 · Cada canvi de filtre es reflecteix a la URL
document.addEventListener(ESDEVENIMENTS.FILTRE_APLICAT, (esdeveniment) => {
  vista.actualitzar({ filtres: esdeveniment.detail });
  escriureEstatAUrl(esdeveniment.detail, { substituir: esdeveniment.detail.origen === 'teclat' });
});
Mètode Què fa Quan fer-lo servir
history.pushState(estat, '', url) Afegeix una entrada a l'historial Canvis que l'usuari voldria desfer amb «enrere»: triar un responsable
history.replaceState(estat, '', url) Substitueix l'entrada actual Canvis continus: cada tecla del cercador
history.back() / forward() / go(n) Navegar per l'historial Botons propis
esdeveniment popstate L'usuari ha premut enrere/endavant Restaurar la vista

Quatre detalls que eviten errors:

  • pushState a cada tecla és una tortura. L'usuari hauria de prémer «enrere» quinze vegades per sortir del cercador. Per a escriptura contínua, replaceState.
  • popstate no es dispara amb pushState/replaceState. Només quan l'usuari navega. Tu ja saps el que acabes de fer, exactament igual que l'esdeveniment storage de 07-01 no es disparava a la pestanya que va escriure.
  • esdeveniment.state pot ser null, per exemple si s'arriba a l'entrada inicial. Tingues sempre la reserva de llegir la URL.
  • La URL ha de ser del mateix origen. No pots falsejar el domini; el navegador llança un SecurityError.

El benefici és enorme i barat: la Marta pot enganxar al xat de l'equip https://nomada.taller/?responsable=Iván&estat=pendent i tothom veu exactament la seva mateixa vista. I F5 ja no perd el filtre.

Permisos: cap. Privacitat: compte amb què hi fiques, a la URL, perquè acaba a l'historial, als registres del servidor i a la barra d'adreces que pot veure qui passi per darrere. No hi posis mai identificadors personals ni tokens.

  1. matchMedia: tema i moviment

Les media queries no són només de CSS: JavaScript les pot consultar i reaccionar als seus canvis.

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

console.log(fosc.matches);                    // true si el sistema està en mode fosc

fosc.addEventListener('change', (esdeveniment) => { // ← reacciona si l'usuari ho canvia sobre la marxa
  aplicarTema(esdeveniment.matches ? 'fosc' : 'clar');
});

Les tres consultes que més importen en una aplicació:

// js/vista/preferencies-sistema.js
export const PREFERENCIES = {
  temaFosc:      window.matchMedia('(prefers-color-scheme: dark)'),
  menysMoviment: window.matchMedia('(prefers-reduced-motion: reduce)'),
  pantallaAmpla: window.matchMedia('(min-width: 900px)')
};

/** Aplica el tema: la preferència desada (07-01) guanya sobre la del sistema. */
export function aplicarTema() {
  const desat = llegirPreferencies().tema;                     // 'clar' | 'fosc' | 'sistema'
  const efectiu = desat === 'sistema'
    ? (PREFERENCIES.temaFosc.matches ? 'fosc' : 'clar')
    : desat;

  document.documentElement.dataset.tema = efectiu;             // el CSS fa la resta
  document.querySelector('meta[name="theme-color"]')
    ?.setAttribute('content', efectiu === 'fosc' ? '#1b1f1e' : '#2b6b5b');
}

prefers-reduced-motion no és un detall estètic. Hi ha persones per a qui una animació de desplaçament provoca mareig o migranya; el sistema operatiu permet demanar que es redueixi el moviment, i respectar-ho és accessibilitat, no cortesia.

/** Moure una targeta de columna: amb animació, o sense si l'usuari l'evita. */
export function moureTargeta(targeta, desti) {
  if (PREFERENCIES.menysMoviment.matches) {
    desti.append(targeta);                                     // instantani, sense transició
    return;
  }
  targeta.animate(
    [{ opacity: 0, transform: 'translateY(-8px)' }, { opacity: 1, transform: 'none' }],
    { duration: 180, easing: 'ease-out' }
  );
  desti.append(targeta);
}

Altres consultes útils: (prefers-contrast: more), (pointer: coarse) per detectar dit en lloc de ratolí, (display-mode: standalone) per saber si la PWA de 07-05 s'està executant instal·lada.

Permisos: cap. Privacitat: són senyals que contribueixen al fingerprinting del navegador, però consultar-les per adaptar la interfície és un ús legítim i esperat.

  1. navigator.share: compartir de debò

// js/vista/compartir.js
export async function compartirTauler(tauler, avui) {
  const dades = {
    title: 'Nómada Tasques',
    text: `Tauler del taller: ${tauler.resum(avui).obertes} tasques obertes.`,
    url: location.href                          // ← inclou els filtres, gràcies a l'apartat 7
  };

  // 1 · Compartir natiu, si existeix (mòbil sobretot)
  if (navigator.canShare?.(dades)) {
    try {
      await navigator.share(dades);
      return 'compartit';
    } catch (error) {
      if (error.name === 'AbortError') return 'cancel·lat';   // l'usuari ha tancat el diàleg
      console.warn('[nomada] Fallada en compartir:', error);
    }
  }

  // 2 · Reserva: copiar l'enllaç al porta-retalls (apartat 6)
  await navigator.clipboard.writeText(location.href);
  return 'copiat';
}

Aquest és el patró de millora progressiva en estat pur: si l'API nativa existeix, es fa servir; si no, hi ha un camí alternatiu que resol la mateixa necessitat. L'usuari no topa mai amb un botó que no fa res.

Tres notes: navigator.share requereix un gest de l'usuari i HTTPS; el seu suport és molt millor al mòbil que a l'escriptori; i navigator.canShare(dades) comprova si aquelles dades concretes es poden compartir, cosa que importa en compartir fitxers (files), no només text.

Permisos: cap explícit, però gest obligatori. Privacitat: comparteixes el que hi posis; revisa que la URL no porti informació que el destinatari no hagi de veure.

  1. requestAnimationFrame davant de setTimeout

Reprenent el bucle d'esdeveniments de 05-07: per animar, setTimeout és l'eina equivocada.

// ✗ Amb setTimeout: 60 fps «aproximats», desincronitzats amb el pintat
function animarMalament(element) {
  let x = 0;
  const id = setInterval(() => {
    x += 2;
    element.style.transform = `translateX(${x}px)`;
    if (x >= 200) clearInterval(id);
  }, 16);
}

// ✓ Amb requestAnimationFrame: el navegador decideix el moment exacte abans de pintar
function animarBe(element) {
  let inici = null;

  function pas(marca) {                         // marca = ms d'alta precisió, la dona el navegador
    inici ??= marca;
    const transcorregut = marca - inici;
    const progres = Math.min(transcorregut / 300, 1);       // 300 ms de durada

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

    if (progres < 1) requestAnimationFrame(pas);
  }
  requestAnimationFrame(pas);
}
setTimeout / setInterval requestAnimationFrame
Moment d'execució Quan li toca a la cua de macrotasques Just abans del pintat següent
Freqüència La que demanis, amb deriva acumulada La del monitor (60, 120, 144 Hz…)
En pestanya amagada Continua executant-se Es posa en pausa: estalvia bateria
Sincronització amb el pintat No: provoca fotogrames partits
Argument del callback Cap Marca de temps precisa

Dues claus per animar bé:

  • Basa l'animació en el temps transcorregut, no en el nombre de fotogrames. Si sumes 2 píxels per fotograma, l'animació va al doble de velocitat en un monitor de 120 Hz. Calculant el progrés a partir de la marca de temps, dura el mateix en qualsevol pantalla.
  • Anima transform i opacity. Són les dues propietats que el navegador pot animar sense recalcular la disposició de la pàgina. Animar width, top o margin força recàlculs a cada fotograma. Això s'estudia a fons a 09-04.

I una tercera clau: per a animacions senzilles, la millor opció sol ser el CSS o la Web Animations API (element.animate(...), que ja has fet servir a l'apartat 8). requestAnimationFrame és per al que necessita càlcul per fotograma. Es cancel·la amb cancelAnimationFrame(id), i cal fer-ho en desmuntar la vista.

Permisos: cap.

  1. Intl: internacionalització de debò

Aquí arriba la reparació pendent. A 05-04 vas escriure això:

// ✗ L'array de mesos a mà: només català, sense formats alternatius, i a mantenir
export function dataLlegible(iso) {
  const MESOS = ['gener', 'febrer', 'març', /* … */];
  const [any, mes, dia] = iso.split('-');
  return `${Number(dia)} de ${MESOS[Number(mes) - 1]} de ${any}`;
}

L'objecte Intl és al navegador des de fa anys i fa això —i molt més— correctament, en qualsevol idioma.

Intl.DateTimeFormat

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

new Intl.DateTimeFormat('ca-ES', { dateStyle: 'long' }).format(data);
// '5 de setembre del 2026'

new Intl.DateTimeFormat('ca-ES', { dateStyle: 'short' }).format(data);
// '5/9/26'

new Intl.DateTimeFormat('ca-ES', { weekday: 'long', day: 'numeric', month: 'long' }).format(data);
// 'dissabte, 5 de setembre'

new Intl.DateTimeFormat('ca-ES', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date());
// '20 de set. 2026, 10:14'
Opció Valors
dateStyle 'full', 'long', 'medium', 'short'
timeStyle igual
weekday / month 'long', 'short', 'narrow'
day / year / hour / minute 'numeric', '2-digit'
timeZone 'Europe/Madrid', 'UTC'

Intl.NumberFormat

new Intl.NumberFormat('ca-ES').format(1234.5);                       // '1.234,5'
new Intl.NumberFormat('ca-ES', { minimumFractionDigits: 1 }).format(48);   // '48,0'

new Intl.NumberFormat('ca-ES', { style: 'currency', currency: 'EUR' }).format(1250);
// '1.250,00 €'

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

new Intl.NumberFormat('ca-ES', { style: 'unit', unit: 'hour', unitDisplay: 'long' }).format(12);
// '12 hores'

Fixa't en el que resol de franc: la coma decimal, el punt com a separador de milers, el símbol d'euro darrere del número, l'espai abans del %. Són convencions del català que en anglès són diferents i que, escrites a mà, sempre s'acaben equivocant.

Intl.RelativeTimeFormat

Perfecte per a la regla R10 i els diesRestants del model:

const relatiu = new Intl.RelativeTimeFormat('ca-ES', { numeric: 'auto' });

relatiu.format(-15, 'day');   // 'fa 15 dies'        ← la tasca 6, vençuda
relatiu.format(10, 'day');    // 'd’aquí a 10 dies'
relatiu.format(-1, 'day');    // 'ahir'              ← gràcies a numeric: 'auto'
relatiu.format(0, 'day');     // 'avui'
relatiu.format(1, 'day');     // 'demà'
relatiu.format(2, 'week');    // 'd’aquí a 2 setmanes'

Aquell numeric: 'auto' és el detall que dona naturalitat: amb 'always' diria «d’aquí a 1 dia» en lloc de «demà».

Intl.Collator

Ordenar textos en català amb sort() a seques és incorrecte, perquè compara per codi de caràcter:

const noms = ['Àvila', 'Zurbarán', 'àmbit', 'Barcelona', 'nyu', 'núvol'];

console.log([...noms].sort());
// ['Barcelona', 'Zurbarán', 'nyu', 'núvol', 'Àvila', 'àmbit']   ← desastre

const comparador = new Intl.Collator('ca-ES', { sensitivity: 'base', numeric: true });
console.log([...noms].sort(comparador.compare));
// ['àmbit', 'Àvila', 'Barcelona', 'núvol', 'nyu', 'Zurbarán']   ← correcte

Les majúscules s'ordenaven abans que les minúscules i els accents anaven al final. Amb Collator, tot al seu lloc.

Opció Efecte
sensitivity: 'base' Ignora accents i majúscules: 'ambit' === 'àmbit'
sensitivity: 'accent' Distingeix accents, ignora majúscules
numeric: true 'Tasca 2' abans que 'Tasca 10' (l'ordre natural!)
caseFirst 'upper' o 'lower'

Aquell numeric: true mereix atenció: sense ell, 'Tasca 10' va abans que 'Tasca 2', que és el defecte clàssic de les llistes ordenades alfabèticament.

Intl.ListFormat i Intl.PluralRules

new Intl.ListFormat('ca-ES', { style: 'long', type: 'conjunction' })
  .format(['Marta', 'Iván', 'Lucía']);
// 'Marta, Iván i Lucía'   ← la 'i' final, de franc

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

Un avís de rendiment important: crear un formatador d'Intl és car. No ho facis mai dins d'un bucle o d'una funció de render que es crida per cada targeta.

// ✗ Crea un formatador per targeta, a cada render
tasques.map((t) => new Intl.DateTimeFormat('ca-ES', { dateStyle: 'long' }).format(t.dataLimit));

// ✓ Un de sol, reutilitzat
const DATA_LLARGA = new Intl.DateTimeFormat('ca-ES', { dateStyle: 'long' });
tasques.map((t) => DATA_LLARGA.format(new Date(t.dataLimit)));

Permisos: cap. Privacitat: Intl.DateTimeFormat().resolvedOptions().timeZone revela la zona horària de l'usuari, una dada que contribueix al fingerprinting. Fes-lo servir per formatar, no per perfilar.

  1. Reescriure util/format.js amb Intl

Ara la reparació completa. Els formatadors es creen una vegada en carregar el mòdul, i les funcions només els fan servir:

// js/util/format.js — reescrit amb Intl
const LOCAL = 'ca-ES';

// Formatadors creats UNA vegada: crear-los és car
const DATA_LLARGA  = new Intl.DateTimeFormat(LOCAL, { dateStyle: 'long' });
const DATA_CURTA   = new Intl.DateTimeFormat(LOCAL, { day: 'numeric', month: 'short' });
const DATA_HORA    = new Intl.DateTimeFormat(LOCAL, { dateStyle: 'medium', timeStyle: 'short' });
const RELATIU      = new Intl.RelativeTimeFormat(LOCAL, { numeric: 'auto' });
const NUMERO       = new Intl.NumberFormat(LOCAL, { maximumFractionDigits: 1 });
const HORES        = new Intl.NumberFormat(LOCAL, { style: 'unit', unit: 'hour', unitDisplay: 'long' });
const PERCENTATGE  = new Intl.NumberFormat(LOCAL, { style: 'percent', maximumFractionDigits: 0 });
const LLISTA       = new Intl.ListFormat(LOCAL, { style: 'long', type: 'conjunction' });
const COMPARADOR   = new Intl.Collator(LOCAL, { sensitivity: 'base', numeric: true });

export const MARQUES = Object.freeze({ pendent: '○', 'en-curs': '▸', feta: '✓' });
export const PESOS   = Object.freeze({ alta: 3, mitjana: 2, baixa: 1 });

export function marcaEstat(estat) {
  return MARQUES[estat] ?? '?';
}

/** '2026-09-05' → '5 de setembre del 2026' */
export function dataLlegible(iso) {
  return DATA_LLARGA.format(new Date(`${iso}T00:00:00`));
}

/** '2026-09-05' → '5 de set.' — per a les targetes, on l'espai és escàs */
export function dataBreu(iso) {
  return DATA_CURTA.format(new Date(`${iso}T00:00:00`));
}

/** -15 → 'fa 15 dies' · 0 → 'avui' · 1 → 'demà' */
export function diesLlegibles(dies) {
  if (Math.abs(dies) >= 14) return RELATIU.format(Math.round(dies / 7), 'week');
  return RELATIU.format(dies, 'day');
}

/** 12 → '12 hores' · 1 → '1 hora' — sense el plural() escrit a mà */
export function horesLlegibles(hores) {
  return HORES.format(hores);
}

export function numero(valor) {
  return NUMERO.format(valor);
}

/** 0.9375 → '94 %' */
export function percentatge(fraccio) {
  return PERCENTATGE.format(fraccio);
}

/** ['Marta','Iván','Lucía'] → 'Marta, Iván i Lucía' */
export function llistar(elements) {
  return LLISTA.format(elements);
}

/** Comparador per a sort(): respecta accents, dígrafs i números dins del text */
export const compararTextos = COMPARADOR.compare;

I el seu efecte a la vista, que ara es llegeix sola:

// js/vista/targeta.js (fragment)
import { dataBreu, diesLlegibles, horesLlegibles } from '../util/format.js';

const peu = crearElement('p', { class: 'tasca__peu' });
peu.textContent = `${dataBreu(tasca.dataLimit)} · ${diesLlegibles(tasca.diesRestants)} · ${horesLlegibles(tasca.horesEstimades)}`;
// '5 de set. · fa 15 dies · 5 hores'   ← la tasca 6, vençuda (R10)
// js/vista/tauler-vista.js — ordenar per títol, correctament
import { compararTextos } from '../util/format.js';

const ORDRES = {
  prioritat: (a, b) => PESOS[b.prioritat] - PESOS[a.prioritat],
  data:      (a, b) => a.dataLimit.localeCompare(b.dataLimit),
  titol:     (a, b) => compararTextos(a.titol, b.titol)        // ← Intl.Collator
};

S'han eliminat l'array de mesos, la funció plural() escrita a mà i el sort() que posava els accents al final. I com a propina, canviar LOCAL a 'es-ES' o 'en-GB' tradueix tots els formats sense tocar cap línia més.

Dos advertiments sobre dates que estalvien hores de depuració:

  • new Date('2026-09-05') s'interpreta com a UTC, i a Catalunya es pot mostrar com el dia 4 a les 22:00. Per això el codi hi afegeix T00:00:00: així s'interpreta com a hora local. És l'error de «un dia menys» més freqüent de JavaScript.
  • Intl formata, no calcula. Sumar mesos o dies continua sent feina teva (o de la futura API Temporal). El diesEntre de util/dates.js continua sent necessari.

  1. Taula resum i millora progressiva

API Per a què serveix Demana permís? Notes
IntersectionObserver Saber què es veu No Substitueix escoltar scroll
MutationObserver Detectar canvis al DOM No Per a DOM que no controles
ResizeObserver Detectar canvis de mida No Compte amb el bucle
Notification Avisos del sistema Sí, explícit Fes servir tag; res sensible al body
Geolocation Ubicació Sí, explícit Dada personal: revisió legal
Clipboard Copiar / enganxar Escriure no; llegir sí Requereix gest i HTTPS
History URL sense recarregar No pushState vs replaceState
matchMedia Consultar media queries No Tema i prefers-reduced-motion
navigator.share Compartir natiu No, però gest Sobretot al mòbil
requestAnimationFrame Animar sincronitzat No Es posa en pausa en pestanya amagada
Intl Formats i idioma No Crea els formatadors una vegada

Com comprovar disponibilitat. El patró s'anomena feature detection, i consisteix a preguntar per la capacitat, mai pel navegador:

// ✓ Comprovar la característica
if ('share' in navigator)              { /* … */ }
if ('IntersectionObserver' in window)  { /* … */ }
if ('clipboard' in navigator)          { /* … */ }
if ('serviceWorker' in navigator)      { /* … */ }
if (typeof Intl?.RelativeTimeFormat === 'function') { /* … */ }

// Encadenament opcional per a mètodes concrets
navigator.canShare?.(dades);
registre.sync?.register('enviar');

// ✗ No detectis mai el navegador per la seva cadena d'identificació
if (navigator.userAgent.includes('Chrome')) { /* … */ }   // fràgil, mentider i obsolet

I el patró complet de millora progressiva, amb les tres capes:

export function copiarEnllac(url) {
  // Capa 1 · La millor: l'API moderna
  if (navigator.clipboard?.writeText) {
    return navigator.clipboard.writeText(url).then(() => 'copiat');
  }
  // Capa 2 · La reserva antiga
  const camp = Object.assign(document.createElement('textarea'), { value: url });
  document.body.append(camp);
  camp.select();
  const ok = document.execCommand?.('copy');            // obsolet, però encara funciona
  camp.remove();
  if (ok) return Promise.resolve('copiat');

  // Capa 3 · Que l'usuari ho faci a mà
  return Promise.resolve('manual');
}

L'aplicació mai es trenca: en el pitjor cas, fa menys.

Errors Habituals i Consells

  • Demanar permisos en carregar la pàgina. La manera més eficaç que te'ls deneguin per sempre.
  • Insistir després d'un denied. No es pot: el navegador ni tan sols mostra el diàleg. Ofereix instruccions per reactivar-lo.
  • Notificacions sense tag. S'apilen i l'usuari desactiva els avisos.
  • Dades sensibles al body d'una notificació. Apareixen a la pantalla de bloqueig.
  • watchPosition sense clearWatch. Consum de bateria continu.
  • Copiar al porta-retalls fora d'un gest. Falla silenciosament o llança.
  • Copiar sense confirmar. L'usuari no sap si ha funcionat; fes servir role="status".
  • pushState a cada tecla. El botó «enrere» queda inservible. replaceState.
  • Esperar que popstate es dispari amb pushState. No ho fa, igual que l'esdeveniment storage de 07-01.
  • Suposar que esdeveniment.state mai és null. Tingues sempre la reserva de llegir la URL.
  • Ignorar prefers-reduced-motion. És un problema d'accessibilitat real, no una preferència estètica.
  • Animar amb setInterval sumant píxels per fotograma. La velocitat depèn del monitor. Fes servir la marca de temps.
  • Animar width, top o margin. Força recàlcul de disposició a cada fotograma. transform i opacity.
  • Crear un Intl.DateTimeFormat dins d'un bucle. És car; crea'l una vegada a nivell de mòdul.
  • new Date('2026-09-05') sense hora. S'interpreta com a UTC i pot mostrar el dia anterior.
  • Ordenar textos en català amb sort() a seques. Els accents acaben al final. Intl.Collator.
  • Detectar el navegador per userAgent. Detecta la característica, no el navegador.
  • Consell: agrupa les preferències del sistema en un mòdul. Tenir matchMedia escampat per deu fitxers és impossible de mantenir.
  • Consell: desa 'sistema' com a valor de tema, no només 'clar'/'fosc'. És el que la majoria prefereix.
  • Consell: canvia LOCAL a 'en-GB' un moment i comprova que tota la interfície continua tenint sentit. És la millor prova que no queden formats escrits a mà.
  • Consell: desconnecta els observadors en desmuntar. disconnect() o unobserve(), o mantindràs elements vius en memòria (fuites que veuràs a 09-03).

Exercicis

Exercici 1 — Filtres enllaçables complets. Escriu js/vista/encaminador.js amb llegirEstatDeUrl(), escriureEstatAUrl(estat, opcions) i connectarEncaminador(vista, { signal }). Ha de: llegir l'estat inicial de la URL en arrencar; fer servir replaceState per al cercador (escriptura contínua) i pushState per als filtres discrets (responsable, estat, ordre); respondre a popstate restaurant la vista; i ometre de la URL els valors que siguin el valor per defecte, perquè un tauler sense filtres tingui una URL neta. Afegeix un botó «Copiar enllaç d'aquesta vista» que faci servir el porta-retalls i confirmi de manera accessible.

Exercici 2 — Selector de tema de tres estats. Escriu js/vista/tema.js que gestioni un selector amb tres opcions: clar, fosc i sistema. Ha d'aplicar el tema al documentElement mitjançant data-tema, desar l'elecció a localStorage (07-01), reaccionar als canvis de prefers-color-scheme només quan l'opció sigui sistema, actualitzar la <meta name="theme-color"> perquè la PWA instal·lada (07-05) tingui la barra del color correcte, i evitar l'espurneig de tema equivocat en carregar. Cancel·lable amb { signal }.

Exercici 3 — Panell de resum amb Intl i còpia. Escriu resumLlegible(tauler, avui) que retorni un text multilínia amb: la data d'avui en format llarg, el total de tasques i hores amb Intl.NumberFormat, el percentatge de completades, el repartiment per responsable ordenat amb Intl.Collator, els noms de l'equip units amb Intl.ListFormat, i les tasques vençudes amb la seva antiguitat en format relatiu. Amb el backlog canònic ha de produir un resum coherent amb les 48 h totals, 45 h obertes i la tasca 6 vençuda. Afegeix copiarResum() amb confirmació accessible.

Solucions

Solució 1

// js/vista/encaminador.js
import { ESDEVENIMENTS, emetre } from './esdeveniments.js';

const PER_DEFECTE = Object.freeze({ responsable: null, text: '', ordre: 'prioritat', estat: null });

export function llegirEstatDeUrl(url = location.href) {
  const p = new URL(url).searchParams;
  return {
    responsable: p.get('responsable'),
    text: p.get('q') ?? '',
    ordre: p.get('ordre') ?? PER_DEFECTE.ordre,
    estat: p.get('estat')
  };
}

export function escriureEstatAUrl(estat, { substituir = false } = {}) {
  const p = new URLSearchParams();
  // Només el que difereix del valor per defecte: URLs netes
  if (estat.responsable) p.set('responsable', estat.responsable);
  if (estat.text) p.set('q', estat.text);
  if (estat.ordre !== PER_DEFECTE.ordre) p.set('ordre', estat.ordre);
  if (estat.estat) p.set('estat', estat.estat);

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

  if (url === location.pathname + location.search) return;      // res per canviar
  history[substituir ? 'replaceState' : 'pushState']({ ...estat }, '', url);
}

export function connectarEncaminador(vista, { signal } = {}) {
  vista.actualitzar({ filtres: llegirEstatDeUrl() });            // 1 · la URL mana en arrencar

  document.addEventListener(ESDEVENIMENTS.FILTRE_APLICAT, (esdeveniment) => {
    const { origen, ...filtres } = esdeveniment.detail;
    vista.actualitzar({ filtres });
    escriureEstatAUrl(filtres, { substituir: origen === 'text' });   // 2 · reflectir
  }, { signal });

  window.addEventListener('popstate', (esdeveniment) => {        // 3 · enrere / endavant
    const estat = esdeveniment.state ?? llegirEstatDeUrl();
    vista.actualitzar({ filtres: estat });
  }, { signal });
}
document.querySelector('#copiar-enllac').addEventListener('click', async () => {
  const avis = document.querySelector('#avis-copia');            // role="status" aria-live="polite"
  try {
    await navigator.clipboard.writeText(location.href);
    avis.textContent = "Enllaç d'aquesta vista copiat.";
  } catch {
    avis.textContent = "No s'ha pogut copiar. L'enllaç és a la barra d'adreces.";
  }
  avis.hidden = false;
}, { signal });

El detall que dona qualitat és ometre els valors per defecte: sense ell, un tauler sense filtrar tindria la URL ?q=&ordre=prioritat, lletja de compartir i sorollosa a l'historial. I origen === 'text' és el que distingeix el cercador (que fa servir replaceState) dels filtres discrets.

Solució 2

// js/vista/tema.js
const CLAU = 'nomada:tema';
const CONSULTA = window.matchMedia('(prefers-color-scheme: dark)');

const llegirPreferencia = () => localStorage.getItem(CLAU) ?? 'sistema';
const temaEfectiu = (pref) => (pref === 'sistema' ? (CONSULTA.matches ? 'fosc' : 'clar') : pref);

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

export function aplicarTema(preferencia = llegirPreferencia()) {
  const efectiu = temaEfectiu(preferencia);
  document.documentElement.dataset.tema = efectiu;
  document.querySelector('meta[name="theme-color"]')?.setAttribute('content', COLORS[efectiu]);
  return efectiu;
}

export function connectarSelectorDeTema(selector, { signal } = {}) {
  selector.value = llegirPreferencia();

  selector.addEventListener('change', () => {
    const triat = selector.value;
    try { localStorage.setItem(CLAU, triat); } catch { /* mode privat: no persisteix */ }
    aplicarTema(triat);
  }, { signal });

  // Només quan la preferència és 'sistema' importa el que faci el sistema
  CONSULTA.addEventListener('change', () => {
    if (llegirPreferencia() === 'sistema') aplicarTema('sistema');
  }, { signal });

  aplicarTema();
}
<!-- A <head>, ABANS del CSS: evita l'espurneig de tema equivocat -->
<script>
  (function () {
    var pref = localStorage.getItem('nomada:tema') || 'sistema';
    var fosc = pref === 'fosc' ||
      (pref === 'sistema' && matchMedia('(prefers-color-scheme: dark)').matches);
    document.documentElement.dataset.tema = fosc ? 'fosc' : 'clar';
  })();
</script>

Aquell script en línia és una de les poquíssimes excepcions legítimes a la regla de no posar JavaScript a l'HTML: si el tema s'apliqués des d'un mòdul, la pàgina es pintaria un instant en clar abans de canviar a fosc, i aquell espurneig blanc és molt molest de nit. S'ha d'executar abans del primer pintat, i per això és síncron i va al <head>.

Solució 3

// js/vista/resum.js
import { dataLlegible, diesLlegibles, numero, percentatge, llistar, compararTextos, horesLlegibles }
  from '../util/format.js';

export function resumLlegible(tauler, avui) {
  const r = tauler.resum(avui);
  const completades = r.total === 0 ? 0 : (r.total - r.obertes) / r.total;

  // Hores obertes per responsable, ordenades per nom amb el Collator
  const perResponsable = new Map();
  for (const tasca of tauler.filtrar((t) => t.oberta)) {
    const qui = tasca.responsable ?? 'Sense assignar';
    perResponsable.set(qui, (perResponsable.get(qui) ?? 0) + tasca.horesEstimades);
  }
  const repartiment = [...perResponsable.entries()]
    .sort((a, b) => compararTextos(a[0], b[0]))
    .map(([qui, hores]) => `  · ${qui}: ${horesLlegibles(hores)}`);

  const equip = llistar([...perResponsable.keys()].sort(compararTextos));

  const vencudes = tauler
    .filtrar((t) => t.oberta && t.diesRestants < 0)
    .map((t) => `  ! ${t.titol} — va vèncer ${diesLlegibles(t.diesRestants)}`);

  return [
    `Taller Nómada — ${dataLlegible(avui)}`,
    '',
    `Tasques: ${numero(r.total)} (${numero(r.obertes)} obertes, ${percentatge(completades)} completat)`,
    `Hores: ${horesLlegibles(r.horesTotals)} totals · ${horesLlegibles(r.horesObertes)} obertes`,
    `Esforç ponderat: ${numero(r.esforc)}`,
    '',
    `Equip: ${equip}`,
    ...repartiment,
    ...(vencudes.length ? ['', `Vençudes (${vencudes.length}):`, ...vencudes] : [])
  ].join('\n');
}
export async function copiarResum(tauler, avui, avis) {
  try {
    await navigator.clipboard.writeText(resumLlegible(tauler, avui));
    avis.textContent = 'Resum copiat al porta-retalls.';
  } catch {
    avis.textContent = "No s'ha pogut copiar. Selecciona el text i fes servir Ctrl+C.";
  }
  avis.hidden = false;
}

Amb el backlog canònic i AVUI = '2026-09-20', la sortida és:

Taller Nómada — 20 de setembre del 2026

Tasques: 6 (5 obertes, 17 % completat)
Hores: 48 hores totals · 45 hores obertes
Esforç ponderat: 124

Equip: Iván, Lucía i Marta
  · Iván: 25 hores
  · Lucía: 14 hores
  · Marta: 6 hores

Vençudes (1):
  ! Pressupost de la fusteria — va vèncer fa 15 dies

Tots els números coincideixen amb el backlog canònic, i tot el que es llegeix —la data llarga, el percentatge amb el seu espai, «hores» declinat, la coma i la «i» de la llista, «fa 15 dies»— surt d'Intl. No queda ni una sola cadena de format escrita a mà.

Conclusió

Nómada Tasques ha passat de funcionar a resultar còmoda. Saps fer servir els tres observadors amb el mateix patró —observe, unobserve, disconnect—: IntersectionObserver per saber què es veu sense escoltar scroll, amb el seu rootMargin que precarrega abans que calgui; ResizeObserver per adaptar-se a la mida d'un element i no només a la de la finestra, tenint cura del bucle infinit; i MutationObserver per al DOM que no controles, amb l'advertiment que necessitar-lo a la teva pròpia aplicació sol indicar que el flux de dades s'ha perdut.

Tens interioritzada la regla d'or dels permisos: mai en carregar, sempre en resposta a un gest, sempre explicant abans, i acceptant que un denied és definitiu. La vas aplicar a Notification —amb tag per no apilar avisos i sense dades sensibles al body, perquè es llegeixen a la pantalla de bloqueig— i a Geolocation, l'API amb més pes legal de totes, on la lliçó no és tècnica sinó de criteri: recull la mínima precisió que et serveixi, no la desis si no la necessites, i compta amb revisió legal i de compliance abans de tocar dades personals reals.

Saps copiar al porta-retalls amb navigator.clipboard.writeText, entenent per què exigeix un gest de l'usuari i confirmant sempre de manera accessible. Saps fer que els filtres del tauler siguin enllaçables amb pushState per als canvis discrets i replaceState per a l'escriptura contínua, restaurant la vista a popstate amb la reserva de llegir la URL quan state és null, i recordant que popstate no es dispara amb els teus propis canvis —el mateix eco que no es dispara a storage—. Saps consultar les preferències de l'usuari amb matchMedia, respectant prefers-color-scheme sense trepitjar l'elecció explícita i tractant prefers-reduced-motion com el que és: accessibilitat. Saps compartir amb navigator.share i caure al porta-retalls quan no hi és. I saps animar amb requestAnimationFrame, calculant el progrés a partir de la marca de temps perquè la durada no depengui del monitor, sobre transform i opacity, amb la pausa automàtica en pestanyes amagades que setInterval no ofereix.

I has saldat un deute que arrossegaves des de 05-04: util/format.js ja no té un array de mesos escrit a mà, ni una funció plural() artesanal, ni un sort() que enviava els accents al final. Intl fa aquella feina millor i en qualsevol idioma: DateTimeFormat amb els seus dateStyle, NumberFormat amb la coma decimal i l'euro al seu lloc, RelativeTimeFormat amb el seu numeric: 'auto' que diu «ahir» en lloc de «fa 1 dia», Collator que ordena «àmbit» abans que «Barcelona» i «Tasca 2» abans que «Tasca 10», i ListFormat que posa la «i» final sola. Amb dues precaucions gravades: crear els formatadors una vegada perquè són cars, i afegir T00:00:00 a les dates ISO per no perdre un dia per la interpretació en UTC. I sobretot, el patró que travessa la lliçó sencera: detecta la característica, no el navegador, i construeix per capes perquè l'aplicació no es trenqui mai, només faci menys.

Queda una última lliçó en aquest mòdul, i és la més diferent de totes. Fins aquí, tot el que has vist era JavaScript parlant amb el navegador. La següent tracta d'una cosa que no és JavaScript: un format binari que s'executa al mateix motor, al costat del teu codi, i que permet portar al navegador programes escrits en C, C++ o Rust a velocitat gairebé nativa. Veuràs què és, per què existeix, com es carrega i s'instancia des de JavaScript, i —el més important per a un professional— quan no compensa fer-lo servir, perquè la majoria d'aplicacions, inclosa Nómada Tasques, no el necessiten. És Introducció a WebAssembly.

Curs de JavaScript: De Principiant a Avançat

Mòdul 1: Introducció a JavaScript

Mòdul 2: Estructures de Control

Mòdul 3: Funcions

Mòdul 4: Objectes i Arrays

Mòdul 5: Objectes i Funcions Avançades

Mòdul 6: El Model d'Objectes del Document (DOM)

Mòdul 7: APIs del Navegador i Temes Avançats

Mòdul 8: Proves i Depuració

Mòdul 9: Rendiment i Optimització

Mòdul 10: Frameworks i Llibreries de JavaScript

Mòdul 11: Projecte Final

© Copyright 2026. Tots els drets reservats