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
IntersectionObserver: reaccionar al que es veuMutationObserveriResizeObserver- Permisos: la regla d'or
Notification: avisar sense molestar- Geolocation i el pes de les dades personals
- Clipboard API: copiar el resum
- History API: filtres enllaçables
matchMedia: tema i movimentnavigator.share: compartir de debòrequestAnimationFramedavant desetTimeoutIntl: internacionalització de debò- Reescriure
util/format.jsambIntl - Taula resum i millora progressiva
- Errors Habituals i Consells
- Exercicis
- Conclusió
IntersectionObserver: reaccionar al que es veu
IntersectionObserver: reaccionar al que es veuEl 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 tempsTres 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.
MutationObserver i ResizeObserver
MutationObserver i ResizeObserverDos 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.
- 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.
Notification: avisar sense molestar
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).
- 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.
- 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.
- 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:
pushStatea cada tecla és una tortura. L'usuari hauria de prémer «enrere» quinze vegades per sortir del cercador. Per a escriptura contínua,replaceState.popstateno es dispara ambpushState/replaceState. Només quan l'usuari navega. Tu ja saps el que acabes de fer, exactament igual que l'esdevenimentstoragede 07-01 no es disparava a la pestanya que va escriure.esdeveniment.statepot sernull, 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.
matchMedia: tema i moviment
matchMedia: tema i movimentLes 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.
navigator.share: compartir de debò
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.
requestAnimationFrame davant de setTimeout
requestAnimationFrame davant de setTimeoutReprenent 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 | Sí |
| 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
transformiopacity. Són les dues propietats que el navegador pot animar sense recalcular la disposició de la pàgina. Animarwidth,topomarginforç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.
Intl: internacionalització de debò
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'] ← correcteLes 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.
- Reescriure
util/format.js amb Intl
util/format.js amb IntlAra 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 afegeixT00:00:00: així s'interpreta com a hora local. És l'error de «un dia menys» més freqüent de JavaScript.Intlformata, no calcula. Sumar mesos o dies continua sent feina teva (o de la futura APITemporal). EldiesEntredeutil/dates.jscontinua sent necessari.
- 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 obsoletI 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
bodyd'una notificació. Apareixen a la pantalla de bloqueig. watchPositionsenseclearWatch. 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". pushStatea cada tecla. El botó «enrere» queda inservible.replaceState.- Esperar que
popstatees dispari ambpushState. No ho fa, igual que l'esdevenimentstoragede 07-01. - Suposar que
esdeveniment.statemai ésnull. 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
setIntervalsumant píxels per fotograma. La velocitat depèn del monitor. Fes servir la marca de temps. - Animar
width,topomargin. Força recàlcul de disposició a cada fotograma.transformiopacity. - Crear un
Intl.DateTimeFormatdins 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
matchMediaescampat 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
LOCALa'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()ounobserve(), 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
- Què és JavaScript?
- Configuració del teu Entorn de Desenvolupament
- El teu Primer Programa en JavaScript
- Sintaxi i Conceptes Bàsics de JavaScript
- Variables i Tipus de Dades
- Operadors Bàsics
- Conversió de Tipus i Comparacions
- El Projecte del Curs: Nómada Tasques
Mòdul 2: Estructures de Control
- Sentències Condicionals
- Bucles: for, while, do-while
- Sentències Switch
- Control del Flux: break, continue i Bucles Imbricats
- Gestió d'Errors amb try-catch
Mòdul 3: Funcions
- Definició i Crida de Funcions
- Expressions de Funció i Funcions Fletxa
- Paràmetres i Valors de Retorn
- Àmbit i Closures
- Hoisting i el Context d'Execució
- Funcions d'Ordre Superior
- Recursivitat
Mòdul 4: Objectes i Arrays
- Introducció als Objectes
- Mètodes d'Objecte i la Paraula Clau
this - Arrays: Conceptes Bàsics i Mètodes
- Iteració sobre Arrays
- Cercar, Ordenar i Agregar Dades: find, sort i reduce
- Desestructuració d'Arrays
- Desestructuració d'Objectes, Spread i Rest
- JSON i Còpies d'Objectes
Mòdul 5: Objectes i Funcions Avançades
- Prototips i Herència
- Classes i Programació Orientada a Objectes
- Encapsulació: Getters, Setters i Camps Privats
- Mòduls i Importació/Exportació
- JavaScript Asíncron: Callbacks
- Promeses i Async/Await
- El Bucle d'Esdeveniments i la Cua de Microtasques
- Iteradors i Generadors
Mòdul 6: El Model d'Objectes del Document (DOM)
- Introducció al DOM
- Selecció i Manipulació d'Elements del DOM
- Gestió d'Esdeveniments
- Propagació, Delegació i Esdeveniments Personalitzats
- Creació i Eliminació d'Elements del DOM
- Renderitzat de Llistes i Plantilles HTML
- Gestió i Validació de Formularis
Mòdul 7: APIs del Navegador i Temes Avançats
- Emmagatzematge Local i de Sessió
- Fetch API i AJAX
- Peticions Robustes: Errors, Timeouts i AbortController
- WebSockets
- Service Workers i Aplicacions Web Progressives (PWAs)
- APIs del Navegador Essencials
- Introducció a WebAssembly
Mòdul 8: Proves i Depuració
- Depuració de JavaScript
- Qualitat de Codi: ESLint, Prettier i Convencions
- Proves Unitàries amb Jest
- Dobles de Prova: Mocks, Stubs i Spies
- Proves d'Integració
- Proves d'Extrem a Extrem amb Cypress
Mòdul 9: Rendiment i Optimització
- Mesurar Abans d'Optimitzar: DevTools i Web Vitals
- Optimització del Rendiment de JavaScript
- Gestió de Memòria
- Manipulació Eficient del DOM
- Càrrega Diferida i Divisió de Codi
Mòdul 10: Frameworks i Llibreries de JavaScript
- Per Què Existeixen els Frameworks
- Introducció a React
- Gestió d'Estat amb Redux
- Conceptes Bàsics de Vue.js
- Conceptes Bàsics d'Angular
- Triar el Framework Adequat
