El Mòdul 7 va acabar amb un diagnòstic incòmode: Nómada Tasques té sis capes, catorze mòduls, dues fonts de dades i un canal en temps real, i l'única manera de saber si alguna cosa funciona és obrir-la i provar-la a mà. Aquesta lliçó ataca la primera meitat del problema —quan ja saps que alguna cosa falla, com esbrines per què?— i ho fa amb un canvi de mentalitat: depurar no és endevinar, és un procediment. Aprendràs el cicle reproduir → aïllar → formular hipòtesis → comprovar → corregir → prevenir, la tècnica de bisecció que redueix a la meitat el terreny sospitós a cada pas, la consola sencera (que és molt més que console.log), el depurador de les DevTools amb les seves sis classes de punts d'interrupció, la lectura d'una pila de crides asíncrona, els source maps, i els panells de xarxa i emmagatzematge. I acabaràs resolent tres errors reals de Nómada Tasques pas a pas, amb el mètode al davant.

Contingut

  1. Per què depurar per intuïció no escala
  2. El mètode: sis passos
  3. Bisecció: partir el problema per la meitat
  4. La consola més enllà de console.log
  5. El truc de les claus: console.log({variable})
  6. La sentència debugger i el primer punt d'interrupció
  7. Els sis tipus de punt d'interrupció
  8. Executar pas a pas: step over, into i out
  9. Els panells Scope, Watch i Call Stack
  10. Llegir un stack trace i «Pause on exceptions»
  11. Depurar codi asíncron
  12. Source maps: per què producció és il·legible sense ells
  13. Depurar la xarxa: el panell Network
  14. Depurar l'emmagatzematge: el panell Application
  15. Depurar en un mòbil real
  16. Cas 1: la tasca que no canvia d'estat
  17. Cas 2: el filtre que perd tasques en recarregar
  18. Cas 3: el comptador d'hores descompensat
  19. El bug que no es reprodueix
  20. Errors Habituals i Consells
  21. Exercicis
  22. Conclusió

  1. Per què depurar per intuïció no escala

La manera com gairebé tothom comença a depurar és aquesta: llegir el codi on creu que hi ha l'error, veure alguna cosa estranya, canviar-la, recarregar, mirar si continua fallant. Repetir. Això s'anomena depuració per canvi aleatori, i té tres problemes greus:

  • No convergeix. Cada canvi modifica el sistema, així que l'error pot desaparèixer per una raó diferent de la que creies, i reaparèixer una setmana després.
  • Introdueix errors nous. Un if afegit "per si de cas" que tapa el símptoma deixa el sistema amb dos problemes en lloc d'un.
  • No ensenya res. Quan l'error se'n va, no saps per què se n'ha anat, i per tant no pots evitar el següent de la mateixa família.

L'alternativa és tractar l'error com el que és: una diferència entre el que el programa fa i el que creus que fa. Depurar és localitzar el punt exacte on el teu model mental i la realitat se separen. I per a això existeix un procediment.

Una idea que convé interioritzar des d'ara: el 90 % del temps de depuració es gasta a localitzar l'error, no a arreglar-lo. La correcció sol ser una línia. Per això tot el mètode està orientat a localitzar de pressa, no a escriure de pressa.

  1. El mètode: sis passos

flowchart TD
    A["1 · Reproduir<br/>passos exactes i fiables"] --> B["2 · Aïllar<br/>reduir al cas mínim"]
    B --> C["3 · Hipòtesi<br/>una afirmació falsable"]
    C --> D["4 · Comprovar<br/>breakpoint, registre o prova"]
    D -->|hipòtesi falsa| C
    D -->|hipòtesi certa| E["5 · Corregir<br/>la causa, no el símptoma"]
    E --> F["6 · Prevenir<br/>una prova que falli sense l'arranjament"]

Els sis passos, amb el que significa cadascun a la pràctica:

Pas Què fas Com saps que has acabat
1 · Reproduir Escrius la seqüència exacta d'accions que provoca l'error Pots provocar-lo a voluntat, tres vegades seguides
2 · Aïllar Treus tot el que no sigui imprescindible: dades, mòduls, passos Un cas mínim que continua fallant
3 · Hipòtesi Formules una frase concreta i comprovable: «id arriba com a cadena» La frase es pot demostrar falsa amb una dada
4 · Comprovar Poses un punt d'interrupció o un registre que la confirmi o la negui Tens un valor observat, no una impressió
5 · Corregir Arregles la causa, a la capa correcta El cas mínim passa i entens per què
6 · Prevenir Escrius una prova automàtica que falla sense l'arranjament La prova es posa verda en aplicar la correcció

El pas 6 és el que gairebé tothom es salta, i és el que converteix una tarda perduda en un actiu permanent. Encara no saps escriure aquestes proves —això arriba a Proves Unitàries amb Jest—, però en aquesta lliçó ja deixaràs escrita la comprovació que hauria d'existir per a cada error. Quan arribis a 08-03 tindràs tres proves esperant-te.

Una regla d'or del pas 3: una hipòtesi que no es pot demostrar falsa no és una hipòtesi. «Crec que hi ha alguna cosa estranya al filtre» no serveix. «Crec que estat.filtres.responsable val 'Iván' quan hauria de valer null» sí que serveix, perquè una sola ullada la resol.

  1. Bisecció: partir el problema per la meitat

Quan l'error és en algun punt d'un recorregut llarg —el clic entra per controlador.js, passa per tauler.js, torna per tauler-vista.js i acaba a repositori-local.js—, buscar llegint és lent. La tècnica correcta és la bisecció: triar un punt intermedi del recorregut, comprovar si allà les dades ja estan malament, i descartar la meitat corresponent.

flowchart LR
    A["Clic<br/>controlador"] --> B["Tauler<br/>canviarEstat"]
    B --> C["Esdeveniment<br/>tasca:canviada"]
    C --> D["Vista<br/>reconciliar"]
    D --> E["Repositori<br/>desar"]
    style C fill:#fde68a,stroke:#b45309

Si la dada ja està malament al punt mitjà (C), l'error és entre A i C. Si està bé, és entre C i E. Cada comprovació divideix el terreny entre dos. Amb quatre comprovacions cobreixes un recorregut de setze passos.

La bisecció s'aplica a tres dimensions diferents, i convé conèixer-les totes tres:

  • Al flux de dades, com acabes de veure: comprovar en punts intermedis del camí.
  • Al temps, amb git bisect: si l'aplicació funcionava fa trenta commits i ara no, Git pot buscar en binari el commit culpable en cinc passos.
git bisect start
git bisect bad                 # el HEAD actual falla
git bisect good a455c72        # aquest commit funcionava
# Git et situa en un commit intermedi: proves i respons
git bisect good                # o: git bisect bad
# ... cinc iteracions ...
git bisect reset               # torna on eres
  • Al codi, comentant la meitat: si desactives connectarFormulari i l'error desapareix, ja saps de quin costat mirar. És la versió més tosca, però en una interfície amb molts oients és sorprenentment eficaç.

  1. La consola més enllà de console.log

console.log és una eina legítima —qui digui que un professional no la fa servir mai, menteix—, però l'objecte console té una dotzena de mètodes que resolen millor problemes concrets. Aquests són els que es fan servir de debò:

Mètode Per a què serveix Exemple a Nómada Tasques
console.table(dades) Mostra un array d'objectes com a taula ordenable console.table(tauler.tasques.map((t) => t.toJSON()))
console.dir(obj) Mostra l'objecte com a estructura, no com a text console.dir(document.querySelector('#llista-tasques'))
console.group() / groupEnd() Agrupa i plega registres relacionats Un grup per cada render
console.count(etiqueta) Compta quantes vegades es passa per un punt Detectar renders duplicats
console.time() / timeEnd() Mesura el temps entre dos punts console.time('render')console.timeEnd('render')
console.assert(cond, msg) Registra només si la condició és falsa console.assert(r.horesObertes === 45, r)
console.trace() Imprimeix la pila de crides sense aturar res Qui ha cridat desar()?
console.warn / error Nivell de gravetat: es poden filtrar per nivell Avisos del repositori

Quatre d'ells mereixen un exemple complet, perquè canvien de debò la manera de treballar.

console.table és la diferència entre entendre un array de sis objectes d'una ullada i no entendre'l. Accepta un segon argument amb les columnes que vols:

// A la consola, amb l'aplicació oberta
console.table(
  tauler.tasques.map((t) => t.toJSON()),
  ['id', 'titol', 'responsable', 'estat', 'horesEstimades']
);
┌─────────┬────┬───────────────────────────────┬─────────────┬─────────────┬────────────────┐
│ (index) │ id │ titol                         │ responsable │ estat       │ horesEstimades │
├─────────┼────┼───────────────────────────────┼─────────────┼─────────────┼────────────────┤
│ 0       │ 1  │ 'Redissenyar la sala…'        │ 'Iván'      │ 'en-curs'   │ 12             │
│ 1       │ 2  │ 'Cartelleria del taller…'     │ 'Marta'     │ 'pendent'   │ 6              │
│ 2       │ 3  │ 'Actualitzar el web…'         │ 'Lucía'     │ 'pendent'   │ 14             │
│ 3       │ 4  │ 'Inventari de tintes…'        │ 'Marta'     │ 'feta'      │ 3              │
│ 4       │ 5  │ 'Guia sobre enquadernació…'   │ 'Iván'      │ 'en-curs'   │ 8              │
│ 5       │ 6  │ 'Pressupost de la…'           │ 'Iván'      │ 'pendent'   │ 5              │
└─────────┴────┴───────────────────────────────┴─────────────┴─────────────┴────────────────┘

Les columnes de la taula de DevTools s'ordenen en prémer la capçalera. Ordenar per estat i comptar és més ràpid que qualsevol filter escrit a mà.

console.count respon a la pregunta més freqüent d'una interfície: per què això s'executa tres vegades?

// js/vista/tauler-vista.js, dins d'actualitzar()
console.count('render del tauler');
render del tauler: 1
render del tauler: 2
render del tauler: 3     ← un sol clic ha provocat tres renders

Aquest resultat ja és un diagnòstic: hi ha tres oients diferents reaccionant al mateix esdeveniment, o l'esdeveniment fa bombolla i es gestiona dues vegades. Sense count, aquest error es manifesta només com a "va una mica lent".

console.assert és una afirmació que només parla quan s'incompleix. És perfecta per vigilar invariants coneguts, com els números canònics del backlog:

const r = tauler.resum(AVUI);
console.assert(r.horesObertes === 45, 'Hores obertes descompensades', r);
console.assert(r.esforc === 124, 'Esforç ponderat descompensat', r);
// Si tot va bé no imprimeix res. Si falla:
// Assertion failed: Hores obertes descompensades {total: 6, obertes: 5, horesObertes: 48, …}

console.group converteix un abocador de línies en un arbre plegable:

export function desarTauler(tauler) {
  console.group(`[repositori] desar · ${tauler.total} tasques`);
  console.log('clau:', 'nomada:tauler:v1');
  console.table(tauler.resum(AVUI));
  const ok = repositori.desar(tauler);
  console.log('resultat:', ok ? 'desat' : 'sense espai');
  console.groupEnd();
}

Amb console.groupCollapsed() el grup apareix ja plegat, que és el que vols quan registres una cosa que passa moltes vegades.

  1. El truc de les claus: console.log({variable})

Aquest petit truc estalvia més temps del que sembla. Compara:

console.log(responsable, estat, visibles.length);
// Iván pendent 3          ← quin era quin?

console.log({ responsable, estat, visibles: visibles.length });
// {responsable: 'Iván', estat: 'pendent', visibles: 3}    ← amb noms

En envoltar les variables amb claus fas servir l'abreviatura de propietats d'ES6 (04-01): { responsable } és { responsable: responsable }. El resultat és un objecte on cada valor ve etiquetat amb el nom de la seva variable, i a més es mostra plegable i inspeccionable a la consola. Adopta'l com a norma: no costa res i elimina la classe sencera d'errors de "estic llegint el registre d'una altra variable".

Dos complements del mateix estil:

// Marcar el punt exacte, útil quan hi ha diversos registres iguals
console.log('[controlador:avancar]', { id, estatActual: tasca.estat });

// Copiar un valor gran al porta-retalls per enganxar-lo en un fitxer
copy(tauler.tasques.map((t) => t.toJSON()));   // copy() només existeix a la consola

copy() no és JavaScript estàndard: és una de les funcions d'utilitat de la consola de DevTools, juntament amb $0 (l'últim element seleccionat a l'inspector), $_ (l'últim resultat) i $$('selector') (un querySelectorAll que retorna un array de debò). Només funcionen escrites a mà a la consola, mai al teu codi.

  1. La sentència debugger i el primer punt d'interrupció

console.log et diu el valor del que se't va acudir imprimir. El depurador et deixa mirar-ho tot, en el moment exacte, i a més seguir avançant. La diferència de potència és enorme.

La manera més ràpida d'entrar al depurador és la paraula clau debugger:

canviarEstat(id, nou) {
  const tasca = this.cercarPerId(id);
  debugger;                                    // ← el navegador s'atura AQUÍ
  if (tasca === null) throw new ErrorDeValidacio(`No existeix la tasca ${id}.`, 'id', id);
  tasca.canviarEstat(nou);
  return this;
}

Amb les DevTools obertes, l'execució es congela en aquesta línia i pots inspeccionar id, nou, this i tota la resta. Amb les DevTools tancades, la sentència no fa res.

debugger té un avantatge (és una línia, funciona sempre, sobreviu a un canvi de fitxer) i un perill seriós: si s'escola a producció, congela l'aplicació a qualsevol que tingui les eines obertes. A la lliçó següent configuraràs una regla d'ESLint (no-debugger) que impedeix exactament això.

L'habitual, però, és no tocar el codi: al panell Sources (Chrome/Edge) o Depurador (Firefox), obres el fitxer i prems el número de línia. Apareix un marcador blau: aquest és un punt d'interrupció normal.

  1. Els sis tipus de punt d'interrupció

Aquí és on el depurador deixa de ser "un console.log més elegant" i es converteix en una altra cosa. Aquests són els sis tipus i quan fer servir cadascun:

Tipus Com es posa Quan és l'eina correcta
Normal Clic al número de línia Vols aturar-te sempre en aquest punt
Condicional Clic dret → Add conditional breakpoint La línia s'executa 200 vegades i només t'interessa un cas
De registre (logpoint) Clic dret → Add logpoint Vols el valor sense aturar i sense tocar el codi
Per esdeveniment Event Listener BreakpointsMouse → click No saps quin codi gestiona aquest clic
Per petició XHR/fetch Breakpoints → afegir /tasques Vols aturar-te just abans d'una petició concreta
Per canvi del DOM Clic dret al node → Break on… Un element canvia i no saps qui el canvia

El condicional és el que més temps estalvia en aquest projecte. La funció pintarTargeta s'executa sis vegades per render; aturar-se a les sis és inútil. Amb la condició tasca.id === 3 t'atures només a la que t'interessa:

// Condició escrita al diàleg del breakpoint (no al codi):
tasca.id === 3 && tasca.estat === 'pendent'

La condició és una expressió JavaScript normal avaluada a l'àmbit d'aquella línia. Truc poc conegut: si escrius console.count('pas') com a condició, no s'atura mai (perquè retorna undefined, que és fals) però sí que executa el comptador. Això és exactament el que fa un logpoint, que és la versió oficial de la idea.

El logpoint mereix un paràgraf propi perquè substitueix el 80 % dels console.log que la gent escriu:

// Al diàleg del logpoint, sobre la línia de canviarEstat:
'canviant', {id, tipus: typeof id, nou, estatActual: tasca?.estat}

Avantatges respecte a escriure el console.log al fitxer: no modifiques el codi, no cal recarregar, no hi ha risc d'oblidar-lo en un commit, i funciona igual sobre codi de tercers o minificat. Quan acabes, esborres el punt i no queda rastre.

Els punts per esdeveniment resolen la pregunta "quin codi s'executa quan premo aquest botó?" en una aplicació que no coneixes. Actives Mouse → click, prems el botó, i el depurador et deixa dins del gestor, amb la pila de crides completa. A Nómada Tasques això et portaria directament a l'oient delegat de js/vista/controlador.js, amb esdeveniment.target ja disponible.

Els punts per petició (XHR/fetch Breakpoints) s'aturen just abans que surti una petició la URL de la qual contingui el text que indiquis —per exemple tasques—. Serveixen per inspeccionar el body que estàs a punt d'enviar abans que el servidor el rebutgi, i per descobrir qui dispara una petició inesperada: la pila de crides t'ho diu.

Els punts per canvi del DOM són la solució al clàssic "aquesta classe es treu sola". A l'inspector, clic dret sobre el node → Break onattribute modifications, i el depurador s'atura a la línia de JavaScript que modifica aquell atribut. Hi ha tres variants:

Variant Es dispara quan
subtree modifications S'afegeix o s'elimina un descendent (útil per a reconciliar)
attribute modifications Canvia un atribut: class, data-estat, disabled, hidden
node removal El mateix node s'elimina de l'arbre

  1. Executar pas a pas: step over, into i out

Un cop aturat, controles l'execució amb quatre botons. Entendre'ls bé és el que separa fer servir el depurador de patir-lo:

Acció Drecera habitual Què fa
Resume (continuar) F8 Segueix fins al proper punt d'interrupció
Step over (saltar) F10 Executa la línia sencera, sense entrar a les funcions que crida
Step into (entrar) F11 Entra dins de la funció que es crida en aquesta línia
Step out (sortir) Maj + F11 Acaba la funció actual i torna a qui l'ha cridada

La regla pràctica és senzilla:

  • Fes servir step over per defecte. Vas llegint el flux de la funció actual sense perdre't a les crides.
  • Fes servir step into només quan sospitis de la funció que estàs cridant.
  • Fes servir step out tan bon punt t'adonis que has entrat on no volies (típicament, dins d'una funció de llibreria).

Amb reconciliar, pintarTargeta i map pel mig, és fàcil acabar vint fotogrames dins de codi aliè. Per evitar-ho existeix Ignore List (abans blackboxing): marques un fitxer o un patró com a ignorat i el depurador no hi entra mai ni el mostra a la pila. Marca-hi les teves dependències i la pila es torna llegible a l'instant.

  1. Els panells Scope, Watch i Call Stack

Quan l'execució està aturada, la meitat dreta del panell és on passa la depuració de debò.

Scope mostra les variables agrupades per àmbit, i és la comprovació empírica de tot el que vas estudiar a 03-04 i 03-05:

Scope
├─ Local          ← les variables de la funció actual
│    tasca: Tasca {id: 3, titol: 'Actualitzar el web…'}
│    nou: "en-curs"
├─ Closure (connectarTauler)   ← el closure, visible!
│    tauler: Tauler {nom: 'Taller Nómada'}
│    avui: "2026-09-20"
├─ Module         ← l'importat i el declarat al mòdul
└─ Global         ← window

Aquest bloc Closure és literalment l'entorn capturat del qual parlava 03-04, llistat amb nom i contingut. Si alguna vegada vas dubtar que els closures existeixen físicament, aquí els tens.

Watch és una llista d'expressions que es reavaluen a cada pas. No estàs limitat a variables: pots vigilar càlculs complets.

// Expressions útils a Watch mentre es depura Nómada Tasques
tauler.resum('2026-09-20').horesObertes
tauler.tasques.filter((t) => t.oberta).length
estat.filtres
document.querySelectorAll('#llista-tasques > li').length

Veure horesObertes canviar de 45 a 39 al pas exacte en què passa és una informació impossible d'obtenir amb registres solts.

Call Stack és la pila de crides: qui ha cridat qui fins a arribar aquí. Es llegeix de dalt a baix, del més recent al més antic:

pintarTargeta          targeta.js:24        ← ets aquí
reconciliar            dom.js:38
actualitzar            tauler-vista.js:96
(anonymous)            app.js:41            ← l'oient de l'esdeveniment

Prement qualsevol fotograma et trasllades a ell amb el seu Scope corresponent, sense perdre l'aturada. És la manera de respondre "amb quins arguments m'han cridat?" quan el problema no és aquí sinó a qui ha cridat.

I una funció poc coneguda i molt útil: Restart frame. Clic dret sobre un fotograma → Restart frame torna a executar aquella funció des del principi, amb els mateixos arguments. Si has passat de llarg la línia interessant amb un F10 de més, no cal recarregar la pàgina i repetir els deu clics que t'hi van portar: reinicies el fotograma i hi tornes a passar. (Amb un advertiment: els efectes secundaris que ja van passar —una petició enviada, un setItem fet— no es desfan.)

  1. Llegir un stack trace i «Pause on exceptions»

Un error sense capturar imprimeix una cosa així:

ErrorDeValidacio: Transició no permesa: "feta" → "en-curs".
    at Tasca.canviarEstat (tasca.js:71:13)
    at Tauler.canviarEstat (tauler.js:52:11)
    at HTMLUListElement.<anonymous> (controlador.js:38:15)

Es llegeix així:

  • Primera línia: tipus d'error i missatge. Aquí ja saps que és una regla R6 incomplerta.
  • Línia 1 de la pila: on es va llançar. tasca.js:71:13 = fitxer, línia 71, columna 13.
  • Línies següents: la cadena de crides cap enrere. L'última sol ser el punt d'entrada real.
  • HTMLUListElement.<anonymous>: una funció anònima adjuntada com a oient a un <ul>. És l'oient delegat de 06-04.

L'error més habitual en llegir una pila és fixar-se només en la primera línia. El lloc on es llança gairebé mai és el lloc on hi ha l'error. Aquí l'error es llança a Tasca, però la causa és a controlador.js:38: algú va intentar reobrir una tasca ja acabada perquè el botó no estava desactivat. La primera línia diu què ha passat; les de sota diuen per què.

«Pause on exceptions» (la icona ⏸ amb el rombe, al panell Sources) fa que el depurador s'aturi en l'instant en què es llança l'error, amb tot el context viu. Té dos nivells:

Opció S'atura a Quan activar-la
Pause on uncaught exceptions Només errors que ningú captura Sempre; soroll gairebé nul
Pause on caught exceptions També als que un catch captura Quan alguna cosa falla en silenci

La segona és la joia amagada. Nómada Tasques captura errors en diversos llocs (el try/catch del formulari, el catch del repositori que descarta dades corrompudes, l'ambReintents). Si un error s'està empassant en un d'aquests catch, activar «pause on caught exceptions» et porta directament al throw original. Això sí: activa-la només mentre investigues, perquè també s'aturarà en errors capturats que són perfectament normals.

  1. Depurar codi asíncron

Aquí és on el depurador clàssic solia rendir-se. Quan un setTimeout, una promesa o un await reprenen l'execució, la pila de crides original ja s'ha buidat —és exactament el mecanisme del bucle d'esdeveniments que vas estudiar a 05-07—. Sense ajuda, la pila mostraria només això:

(anonymous)            http.js:112

Inútil: no diu qui ha demanat aquella petició. Per això els navegadors moderns mantenen piles asíncrones, que cusen el fotograma actual amb el que va programar la tasca:

demanarJson                  http.js:112
  ── Async: await ──                          ← la costura
llistarTasques               api-tasques.js:47
  ── Async: await ──
carregarTauler               app.js:63
  ── Async: promise callback ──
(anonymous)                  controlador.js:52

Ara sí: la petició va néixer d'un clic al controlador. Quatre consells concrets per depurar asincronia en aquest projecte:

  • Posa el punt d'interrupció després de l'await, no abans. Abans només veus la promesa pendent; després veus el valor resolt.
  • Fes servir un punt condicional a ambReintents amb la condició intent > 1: t'atures només quan alguna cosa ja ha fallat una vegada.
  • Al panell Network activa la columna Initiator. Igual que la pila asíncrona, et diu quina línia ha disparat cada petició.
  • Compte amb les curses que el mateix depurador provoca. En aturar l'execució cinc segons, un timeout de 8 s pot vèncer. Si sospites d'una condició de cursa, prefereix logpoints a aturades.

Un patró molt útil per a l'asincronia és mesurar on se'n va el temps sense frenar res:

export async function llistarTasquesMesurat(filtres) {
  console.time('llistarTasques');
  try {
    return await llistarTasques(filtres);
  } finally {
    console.timeEnd('llistarTasques');    // s'executa també si llança
  }
}
// llistarTasques: 843.21 ms

El finally garanteix que el timeEnd s'executi encara que la petició falli; si no, un error deixaria el temporitzador obert i el següent console.time avisaria d'una etiqueta duplicada.

  1. Source maps: per què producció és il·legible sense ells

El codi que es desplega no és el que escrius. Un empaquetador l'uneix, el minifica i reanomena les variables, així que un error a producció es veu així:

TypeError: Cannot read properties of null (reading 'dataset')
    at t (app.4f3a1b.js:1:24817)

t, línia 1, columna 24817. Sense informació addicional, aquesta pista no val res.

Un source map és un fitxer (app.4f3a1b.js.map) que conté el diccionari de traducció entre el codi generat i l'original: quina posició del fitxer minificat correspon a quin fitxer, línia, columna i nom de variable del codi font. El navegador el carrega si troba el comentari final:

//# sourceMappingURL=app.4f3a1b.js.map

I aleshores el mateix error es mostra així:

TypeError: Cannot read properties of null (reading 'dataset')
    at gestionarClic (js/vista/controlador.js:34:22)

Tres decisions pràctiques sobre source maps:

Escenari Recomanació Motiu
Desenvolupament Sempre activats El cost de mida és indiferent en local
Producció, aplicació pública Generar-los, però no publicar-los a l'abast de qualsevol Es pugen al servei de monitoratge; el navegador anònim no els descarrega
Producció, eina interna Publicar-los sense problema El codi no és secret i depurar incidències és més fàcil

Tingues present que un source map reconstrueix el teu codi font. Publicar-lo equival a publicar el codi sense minificar. No és un error de seguretat per si mateix —la seguretat mai ha de dependre d'ofuscació—, però és una decisió conscient que cal prendre.

A DevTools, si un source map no carrega, la pestanya Sources mostra un avís a la consola (DevTools failed to load source map). Les tres causes habituals: el .map no es va desplegar, la ruta del comentari final és incorrecta, o el servidor el retorna amb un 404 després d'una configuració de memòria cau agressiva.

  1. Depurar la xarxa: el panell Network

El Mòdul 7 va omplir Nómada Tasques de peticions. Quan una falla, el panell Network respon en segons el que el codi no et pot explicar:

  • Filtra per Fetch/XHR per veure només les teves peticions, sense imatges ni CSS.
  • La columna Status distingeix el que fetch no distingeix: un 200 amb cos buit, un 404, un 500, o (failed) per a una fallada de transport.
  • La pestanya Headers mostra el que vas enviar i el que va arribar, inclosa la capçalera Content-Type que fa fallar el demanarJson quan arriba HTML.
  • La pestanya Payload ensenya el cos del POST tal com va sortir: aquí es descobreixen els undefined que JSON.stringify elimina en silenci.
  • La pestanya Timing desglossa el temps: Stalled, Waiting (TTFB), Content Download. Si el TTFB és de 4 s, no és culpa del teu JavaScript.

Tres funcions del panell que convé conèixer:

Copy as fetch. Clic dret sobre una petició → CopyCopy as fetch. Enganxa el resultat a la consola i reprodueixes la petició exacta, amb les seves capçaleres, tantes vegades com vulguis. És la millor manera d'aïllar si el problema és al servidor o al teu codi: si la petició copiada funciona a la consola i no a l'aplicació, l'error és teu.

// Enganxat des de "Copy as fetch" i modificat per provar una hipòtesi
await fetch('https://api.tallernomada.example/v1/tasques?responsable=Iv%C3%A1n', {
  headers: { accept: 'application/json' }
}).then((r) => ({ ok: r.ok, status: r.status, tipus: r.headers.get('content-type') }));

Throttling. El selector de xarxa (No throttling / Slow 4G / Offline) simula connexions lentes. És imprescindible per provar dues coses que vas escriure al Mòdul 7 i que en local no es veuen mai: els estats de "carregant" de 07-03 i el funcionament sense connexió del service worker de 07-05. En una xarxa local d'1 ms, l'estat de càrrega apareix i desapareix abans de renderitzar-se.

Preserve log. Marca aquesta casella si la petició que investigues provoca una navegació o una recàrrega; sense ella, el registre s'esborra i la petició culpable desapareix.

  1. Depurar l'emmagatzematge: el panell Application

Per a tot el de 07-01 i 07-05, el panell Application és la finestra directa a l'estat persistit:

Secció Què inspecciones a Nómada Tasques
Local Storage La clau nomada:tauler:v1, el seu JSON i la seva mida
Session Storage Estat efímer de la pestanya
IndexedDB (No fet servir aquí, però és on miraria una aplicació més gran)
Cache Storage Els recursos precarregats a la memòria cau pel service worker
Service Workers Estat del worker: instal·lat, actiu, en espera; Update on reload, Unregister
Manifest Com interpreta el navegador el teu manifest.json

El valor de localStorage es pot editar allà mateix: fas doble clic al valor, canvies el JSON i recarregues. És la manera més ràpida de comprovar com reacciona el teu codi a dades corrompudes, a un format de versió antiga, o a un tauler buit… sense escriure ni una línia. I Clear site data et torna a un arrencada neta, que és l'estat en què has de reproduir qualsevol error abans de donar-lo per bo.

  1. Depurar en un mòbil real

L'emulador de dispositiu de les DevTools canvia la mida i simula el tàctil, però no és Safari en un iPhone ni Chrome en un Android de gamma baixa. Els errors de debò —un 100vh que es menja la barra d'adreces, un esdeveniment tàctil que no fa bombolla, una API que no existeix en aquella versió— només es veuen a l'aparell.

La inspecció remota connecta les DevTools del teu ordinador a la pàgina que s'executa al telèfon:

  • Android + Chrome: activa les Opcions de desenvolupador i la Depuració per USB al telèfon, connecta'l per cable i obre chrome://inspect#devices a l'ordinador. La pestanya del telèfon apareix a la llista; prems inspect i tens les DevTools completes, inclosos el depurador i el panell Network.
  • iOS + Safari: activa Configuració → Safari → Avançat → Inspector web a l'iPhone i Safari → Configuració → Avançat → Mostra el menú Desenvolupament al Mac; el dispositiu apareix sota el menú Desenvolupament.

I per provar Nómada Tasques al mòbil cal que el telèfon arribi al teu servidor de desenvolupament. Dues vies:

# Opció A · mateixa xarxa wifi: serveix a totes les interfícies i fes servir la IP local
npx serve -l tcp://0.0.0.0:5000
# El mòbil obre http://192.168.1.42:5000

# Opció B · túnel públic amb HTTPS (necessari per a service workers reals)
npx localtunnel --port 5000

L'opció B importa per un detall de 07-05: els service workers exigeixen HTTPS excepte a localhost. I localhost al mòbil és el mateix mòbil, no el teu portàtil. Sense túnel HTTPS no podràs depurar la PWA en un dispositiu real.

  1. Cas 1: la tasca que no canvia d'estat

Apliquem el mètode complet a un error real.

El part d'incidència. La Marta escriu: «He creat la tasca Revisar els extintors amb el formulari i després he premut Començar i no fa res. Les altres sí que funcionen.»

Pas 1 · Reproduir. Primer, una arrencada neta (Clear site data). Després, la seqüència exacta:

  1. Obrir l'aplicació amb el backlog canònic (6 tasques).
  2. Prémer Començar a la tasca 2 → funciona.
  3. Crear una tasca nova amb el formulari.
  4. Prémer Començar a la tasca nova → no passa res.

Reproduït, i amb una pista d'or: falla només a les tasques creades durant la sessió. Les del backlog van bé.

Pas 2 · Aïllar. És del formulari o de la creació? Provem de crear una tasca des de la consola, sense tocar el formulari:

const t = await crearTasca({ titol: 'Prova', responsable: 'Iván', prioritat: 'baixa',
                             horesEstimades: 2, dataLimit: '2026-10-30', etiquetes: [] });
tauler.afegir(t);
vista.actualitzar();
// Prémer "Començar" en aquella targeta → tampoc funciona

El formulari queda descartat: el problema és a les tasques que vénen de crearTasca, és a dir, de l'API.

Pas 3 · Hipòtesi. El flux del clic és: controlador llegeix li.dataset.id → crida tauler.canviarEstat(id, ...)cercarPerId(id) compara amb t.id === id. I dataset sempre retorna cadenes. Per a les tasques del backlog, id és un número al model; per a les de l'API… la hipòtesi concreta és:

Tasca.desDeJSON està desant id com a cadena per a les tasques que arriben del servidor, i cercarPerId fa servir ===, que no converteix tipus (01-07). Per això cercarPerId('7') retorna null i no troba la tasca.

Espera: si retornés null, Tauler.canviarEstat llançaria ErrorDeValidacio. Per què no veiem res? Perquè el controlador captura aquest error per mostrar-lo, i el catch l'escriu en un contenidor que està ocult. Segona part de la hipòtesi: l'error es llança i s'empassa.

Pas 4 · Comprovar. Dues comprovacions, cap no modifica el codi:

  • Activar Pause on caught exceptions, prémer el botó. El depurador s'atura… al throw de Tauler.canviarEstat. Hipòtesi B confirmada.
  • Posar un logpoint a la línia de cercarPerId amb {id, tipusId: typeof id, ids: this.tasques.map((t) => [t.id, typeof t.id])}:
{id: '7', tipusId: 'string', ids: [[1,'number'],[2,'number'],…,[7,'string']]}

Confirmadíssim. L'id del model és una cadena '7', el dataset també retorna cadena, però el controlador feia Number(li.dataset.id) abans de cridar, així que compara 7 === '7'false.

Pas 5 · Corregir la causa. Tres arranjaments possibles, i només un és el correcte:

Arranjament On Veredicte
Canviar === per == a cercarPerId Model ❌ Tapa el símptoma i reintrodueix la coerció que 01-07 desaconsella
Convertir a número al controlador Vista ❌ El model continuaria amb tipus barrejats; l'error reapareixeria en un altre lloc
Normalitzar el tipus a la frontera de dades Tasca.desDeJSON ✅ Un sol punt, i l'invariant «id és number» es compleix sempre
// js/model/tasca.js — la frontera normalitza els tipus
static desDeJSON(dades) {
  const pla = typeof dades === 'string' ? JSON.parse(dades) : dades;
  return new Tasca({ ...pla, id: Number(pla.id) });   // ← l'id SEMPRE és número
}

I de passada, el catch que s'empassava l'error deixa de ser silenciós: si el contenidor d'errors està ocult, es mostra. Un error que ningú veu és un error que existeix dues vegades.

Pas 6 · Prevenir. La prova que hauria d'existir, i que escriuràs a 08-03:

// Pendent per a 08-03:
// "Tasca.desDeJSON converteix un id en cadena a número"
//   → Tasca.desDeJSON({ ...dades, id: '7' }).id === 7  (i typeof === 'number')
// "Tauler.cercarPerId troba una tasca importada del servidor"

  1. Cas 2: el filtre que perd tasques en recarregar

El part. L'Iván: «He filtrat pel meu nom per veure les meves tasques, he tancat el portàtil, i en tornar només quedaven tres tasques a tot el tauler. Les de la Marta i la Lucía han desaparegut.»

Pas 1 · Reproduir. Amb el backlog canònic: filtrar per Iván (queden 3 visibles), recarregar (F5) → el tauler té 3 tasques i el resum diu 25 h. Reproduïble al 100 %. I és un error de pèrdua de dades, la pitjor categoria: prioritat màxima.

Pas 2 · Aïllar. La pregunta clau: s'ha desat malament, o es llegeix malament? El panell Application la respon sense tocar el codi. Filtrem per Iván (sense recarregar) i mirem Local Storage → nomada:tauler:v1:

{ "nom": "Taller Nómada", "versio": 1, "tasques": [
  { "id": 1, "titol": "Redissenyar la sala polivalent", "responsable": "Iván", … },
  { "id": 5, "titol": "Guia sobre enquadernació per a residents", … },
  { "id": 6, "titol": "Pressupost de la fusteria", … } ] }

Només tres tasques ja escrites. L'error és a l'escriptura, no a la lectura. Acabem de descartar la meitat del recorregut amb una ullada.

Pas 3 · Hipòtesi. Qui crida desar i amb què? Aquí l'eina és console.trace() col·locat com a logpoint a RepositoriLocal.desar, amb l'expressió:

'desar', {total: tauler.total}, console.trace()
desar {total: 3}
console.trace
    at HTMLDocument.<anonymous>       (app.js:78)
    at TaulerVista.actualitzar        (tauler-vista.js:104)

La hipòtesi s'escriu sola:

app.js està desant el que la vista mostra en lloc del tauler complet. En filtrar, la vista té 3 tasques i això és el que es persisteix, esclafant les altres 3.

Pas 4 · Comprovar. Obrim app.js:78:

document.addEventListener(ESDEVENIMENTS.FILTRE_APLICAT, () => {
  vista.actualitzar({ filtres: llegirEstatDeUrl() });
  repositori.desar(new Tauler(tauler.nom, vista.visibles));   // ← aquí
});

Algú, en afegir l'encaminador de 07-06, va voler "desar l'estat en filtrar" i va construir un tauler nou amb les tasques visibles. Hipòtesi confirmada amb la línia al davant.

Pas 5 · Corregir. La causa real és conceptual, i mereix enunciar-se: s'ha persistit un valor derivat de la vista com si fos estat del model. El filtre és presentació (06-06) i el seu lloc és la URL (07-06); el tauler és el model i el seu lloc és el magatzem.

// js/app.js — corregit
document.addEventListener(ESDEVENIMENTS.FILTRE_APLICAT, (esdeveniment) => {
  vista.actualitzar({ filtres: esdeveniment.detail });
  escriureEstatAUrl(esdeveniment.detail);        // el filtre viu a la URL…
});

// …i el model es desa només quan canvia el MODEL
document.addEventListener(ESDEVENIMENTS.TASCA_CANVIADA, () => repositori.desar(tauler));
document.addEventListener(ESDEVENIMENTS.TASCA_CREADA,   () => repositori.desar(tauler));

Pas 6 · Prevenir. Dues comprovacions per a 08-05, on es proven model i dades junts:

// Pendent per a 08-05:
// "filtrar la vista no altera el que està persistit"
//   → filtrar per 'Iván', i repositori.carregar().total continua sent 6
// "desar després de canviar d'estat conserva les 6 tasques"

  1. Cas 3: el comptador d'hores descompensat

El part. La Lucía: «El resum diu 48 h obertes tot just obrir. Haurien de ser 45; la tasca de l'inventari està feta des de fa una setmana.»

Aquest cas és diferent dels anteriors: no cal reproduir res, falla sempre. I hi ha una cosa molt millor que un part d'incidència: hi ha un oracle. El backlog canònic té números coneguts —48 h totals, 45 h obertes, esforç 124— i el programa en contradiu un.

Pas 2 · Aïllar. Una sola ordre a la consola separa el model de la vista:

tauler.resum('2026-09-20');
// { total: 6, obertes: 5, horesTotals: 48, horesObertes: 48, vencudes: 1, esforc: 124 }

El model ja retorna 48. La vista és innocent: només pinta el que li donen. I hi ha un detall revelador: obertes: 5 és correcte (5 tasques obertes de 6), però horesObertes coincideix exactament amb horesTotals. Això no és un error de càlcul, és un error de selecció: s'estan sumant les sis.

Pas 3 · Hipòtesi.

horesObertes està reduint sobre totes les tasques en lloc de sobre les obertes.

Pas 4 · Comprovar. Posem el punt d'interrupció al getter i fem servir Watch amb dues expressions alhora:

// A Watch:
this.obertes.length                              // → 5
this.obertes.reduce((s, t) => s + t.horesEstimades, 0)   // → 45   ← el valor correcte

L'expressió correcta dóna 45. Mirem el codi:

get horesTotals()  { return this.#tasques.reduce((s, t) => s + t.horesEstimades, 0); }
get horesObertes() { return this.#tasques.reduce((s, t) => s + t.horesEstimades, 0); }
//                            ^^^^^^^^^^^^  hauria de ser this.obertes

Un copiar i enganxar. L'error clàssic: dues línies gairebé idèntiques, una d'elles sense adaptar. Sospita sempre de les línies bessones.

Comprovació addicional amb git, per saber quan va entrar i descartar que hi hagués més danys al mateix canvi:

git log -L :horesObertes:js/model/tauler.js
# mostra la història completa d'AQUESTA funció, amb el commit que la va trencar

Pas 5 · Corregir.

get horesObertes() { return this.obertes.reduce((s, t) => s + t.horesEstimades, 0); }

Pas 6 · Prevenir. Aquest cas és l'argument perfecte per al mòdul sencer. Un error així:

  • No llança cap error.
  • No trenca cap pantalla.
  • És invisible llevat que algú conegui el número correcte.
  • I surt d'una capa —el model— que es pot comprovar sense navegador, sense DOM i sense xarxa, amb una funció que rep dades i retorna dades.
// Pendent per a 08-03 (serà literalment la primera prova que escriuràs):
// "el resum del backlog canònic dóna 45 h obertes de 48 i esforç 124"

Mentrestant, una xarxa de seguretat de trenta segons que ja pots deixar posada, activada per una bandera:

// js/app.js — comprovació d'invariants, només en desenvolupament
if (localStorage.getItem('nomada:diag') === '1') {
  const r = tauler.resum(AVUI);
  console.assert(r.horesTotals === r.horesObertes + 3, 'horesObertes descompensat', r);
  console.assert(r.total === r.obertes + 1, 'recompte d obertes descompensat', r);
}

  1. El bug que no es reprodueix

Queda la categoria més difícil: «de vegades, en sortir de l'ascensor, se'm dupliquen les tasques». No hi ha passos, no hi ha pantalla, no hi ha pila. Quatre eines, en ordre d'esforç creixent:

1 · Registre estructurat. Substitueix els console.log solts per un registrador únic que emeti objectes amb context. Els objectes es poden filtrar, comptar i enviar; el text lliure no.

// js/util/registre.js
const NIVELLS = { debug: 10, info: 20, warn: 30, error: 40 };
let llindar = NIVELLS.warn;                                  // en producció, només warn i error

export function ajustarNivell(nom) { llindar = NIVELLS[nom] ?? NIVELLS.warn; }

export function registrar(nivell, esdeveniment, dades = {}) {
  if (NIVELLS[nivell] < llindar) return;
  const linia = {
    ts: new Date().toISOString(),
    nivell,
    esdeveniment,                             // 'tasca:canviada', 'api:error', 'sw:activat'
    sessio: sessioId(),                       // el mateix id durant tota la visita
    ...dades
  };
  console[nivell === 'debug' ? 'log' : nivell](linia);
  historial.push(linia);                      // buffer circular en memòria
  if (historial.length > 200) historial.shift();
}

const historial = [];
export const abocarHistorial = () => [...historial];         // per adjuntar a un informe

El buffer circular és la clau: quan l'error rar per fi passa, tens les 200 línies anteriors, no només el moment del desastre. Aquest és exactament el context que falta als incidents que no es reprodueixen.

2 · Banderes de diagnòstic. No pots demanar a la Marta que obri les DevTools. Però sí que li pots donar un interruptor:

// Activable amb ?diag=1 a la URL, i persistent fins que es desactivi
const params = new URLSearchParams(location.search);
if (params.has('diag')) localStorage.setItem('nomada:diag', params.get('diag'));
if (localStorage.getItem('nomada:diag') === '1') ajustarNivell('debug');

I un botó «Copiar informe de diagnòstic» que posi al porta-retalls l'historial, el resum del tauler, la versió de l'aplicació i el navigator.userAgent. Un informe així converteix «de vegades falla» en un cas reproduïble.

3 · Reproduir les condicions, no els passos. Els errors intermitents gairebé sempre vénen de quatre llocs: la xarxa (fes servir Slow 4G i Offline), el temps (una tasca que venç avui i no ahir), la concurrència (dues pestanyes obertes amb l'esdeveniment storage de 07-01) i l'estat previ (un localStorage d'una versió antiga). Provoca aquestes condicions deliberadament i molts "irreproduïbles" es tornen fiables.

4 · Monitoratge d'errors. Per al que passa a l'ordinador d'una altra persona, la solució de la indústria és un servei de monitoratge (Sentry, Rollbar, Bugsnag i similars). El mecanisme essencial cap en deu línies, i entendre'l importa més que el proveïdor concret:

// Captura global: errors síncrons i promeses rebutjades sense gestionar
window.addEventListener('error', (e) => {
  enviarIncidencia({ tipus: 'error', missatge: e.message, fitxer: e.filename,
                     linia: e.lineno, pila: e.error?.stack });
});

window.addEventListener('unhandledrejection', (e) => {          // ← el de 05-06
  enviarIncidencia({ tipus: 'promesa', missatge: String(e.reason), pila: e.reason?.stack });
});

El que un servei afegeix sobre això: agrupa incidències idèntiques, aplica els source maps de l'apartat 12 per mostrar el teu codi original, desa la traça d'accions prèvies de l'usuari, i avisa quan apareix un error nou després d'un desplegament. Dos advertiments imprescindibles: no enviïs dades personals al context (ni títols de tasca, si poden contenir informació sensible), i respecta la normativa de protecció de dades aplicable.

Errors Habituals i Consells

  • Canviar el codi abans d'entendre l'error. Si no pots explicar per què falla, el teu arranjament és una aposta. Primer la hipòtesi comprovada, després l'edició.
  • Depurar en un estat brut. Un localStorage amb dades de tres experiments anteriors produeix errors fantasma. Reprodueix sempre des de Clear site data, i en una finestra d'incògnit si sospites d'una extensió.
  • Confondre on es llança l'error amb on hi ha l'error. Llegeix la pila sencera, de baix a dalt. El culpable sol estar dos fotogrames més avall que el throw.
  • Oblidar que dataset retorna cadenes. És la font del cas 1 i de la meitat dels errors d'identitat en aplicacions amb DOM. Normalitza els tipus a la frontera, sempre.
  • console.log d'un objecte i creure que en veus el valor d'aleshores. La consola mostra objectes en viu: en desplegar-lo pots estar veient el seu estat actual, no el del moment del registre. Si necessites la foto, registra structuredClone(obj) (04-08) o JSON.stringify(obj).
  • Posar el punt d'interrupció abans de l'await. Posa'l després: abans només veuràs una promesa pendent.
  • Deixar debugger o registres de depuració en un commit. Es resol tot sol a la lliçó següent, amb no-debugger i no-console a ESLint més un hook de Git.
  • Tapar errors amb try { … } catch {} buit. Un catch buit converteix un error sorollós en un de silenciós, que és infinitament pitjor. Si de veritat vols ignorar alguna cosa, escriu per què en un comentari.
  • Consell: escriu la hipòtesi en una nota abans de comprovar-la. Obliga a concretar i evita la deriva d'anar mirant coses a l'atzar. En acabar tindràs a més el material del post mortem.
  • Consell: si portes més d'una hora sense avançar, explica-ho a algú. El rubber duck debugging funciona perquè verbalitzar obliga a fer explícites les suposicions. La meitat de les vegades la solució apareix a mitja explicació.
  • Consell: Ctrl/Cmd + P al panell Sources obre qualsevol fitxer pel nom, i Ctrl/Cmd + Maj + P és la paleta d'ordres de les DevTools. Amb aquestes dues dreceres deixaràs de buscar fitxers a l'arbre.

Exercicis

Exercici 1 — Diagnòstic amb el mètode. Un company reporta: «En prémer dues vegades ràpid sobre Començar a la tasca 1, la targeta es queda en en-curs però el resum diu que hi ha 4 tasques obertes en lloc de 5, i a la consola apareix un ErrorDeValidacio: Transició no permesa: "en-curs" → "en-curs"». Redacta el diagnòstic complet seguint els sis passos: passos de reproducció, cas mínim aïllat, hipòtesi falsable, quin instrument de DevTools faries servir per comprovar-la (indica el tipus exacte de punt d'interrupció i la seva condició), en quina capa corregiries, i quina prova escriuries per prevenir-ho.

Exercici 2 — Un mòdul de registre amb nivells i buffer. Escriu js/util/registre.js complet amb: quatre nivells (debug, info, warn, error), un llindar configurable per bandera (?diag=1 a la URL, persistit a localStorage), un identificador de sessió estable durant tota la visita, un buffer circular de les últimes 200 línies, i abocarInforme() que retorni un objecte amb l'historial, el resum del tauler, la versió de l'aplicació i el userAgent, llest per copiar al porta-retalls. Ha de sortir per console.debug/info/warn/error segons el nivell perquè el filtre de la consola funcioni.

Exercici 3 — Instrumentar el flux d'un clic. Sense modificar el codi de l'aplicació, descriu la configuració de DevTools que et permetria seguir el recorregut complet d'un clic a Començar de la tasca 3 —des de l'oient delegat fins a l'escriptura a localStorage— registrant a cada punt els valors rellevants i sense aturar mai l'execució. Indica el fitxer, la línia aproximada i l'expressió exacta de cada logpoint, i què esperaries veure a la consola si tot funciona bé.

Solucions

Solució 1

1 · REPRODUIR
   - Clear site data. Carregar amb el backlog canònic.
   - Doble clic ràpid (< 100 ms) sobre "Començar" de la tasca 1.
   - Es reprodueix 3 de cada 3 vegades si el segon clic arriba abans del render.

2 · AÏLLAR
   - Cas mínim: dues crides seguides a controlador.avancar(1) sense render intermedi.
     tauler.canviarEstat(1, 'en-curs'); tauler.canviarEstat(1, 'en-curs');
   - Sense DOM: el segon llança el mateix ErrorDeValidacio. L'error NO és del navegador
     ni del doble clic: és que l'estat següent es calcula des del DOM, no des del model.

3 · HIPÒTESI (falsable)
   El controlador calcula l'estat destí a partir de `li.dataset.estat`, que només
   s'actualitza al render. Entre el primer clic i el seu render, el dataset encara diu
   'pendent', així que el segon clic torna a demanar 'en-curs' → transició invàlida (R6).
   I el resum es recalcula al catch amb un comptador incrementat a mà, que ja s'havia
   sumat abans de llançar: d'aquí el 4 en lloc del 5.

4 · COMPROVAR
   - Punt d'interrupció CONDICIONAL a controlador.js, línia del càlcul del destí:
        condició:  id === 1
   - I un logpoint a la mateixa línia:
        'avancar', {id, datasetEstat: li.dataset.estat, modelEstat: tauler.cercarPerId(id).estat}
     Si la hipòtesi és certa, al segon clic es veurà:
        {id: 1, datasetEstat: 'pendent', modelEstat: 'en-curs'}   ← divergeixen
   - Activar "Pause on caught exceptions" per confirmar que l'error s'empassa al catch.

5 · CORREGIR (capa: vista/controlador)
   - L'estat destí es calcula SEMPRE des del model, mai des del DOM:
        const tasca = tauler.cercarPerId(id);
        const desti = SEGUENT[tasca.estat];
        if (desti === null) return;
   - El DOM és una projecció de l'estat, no la seva font (cicle de 06-06).
   - A més, el resum es recalcula amb tauler.resum(AVUI), sense comptadors manuals.

6 · PREVENIR (per a 08-03)
   - "dos canviarEstat consecutius al mateix destí llancen ErrorDeValidacio" (model).
   - "dos clics seguits a avançar deixen la tasca en 'en-curs' i el resum en 5 obertes"
     (integració amb jsdom, 08-05).

Solució 2

// js/util/registre.js
const NIVELLS = Object.freeze({ debug: 10, info: 20, warn: 30, error: 40 });
const SORTIDA = Object.freeze({ debug: 'debug', info: 'info', warn: 'warn', error: 'error' });
const MAXIM   = 200;

const historial = [];
let llindar = NIVELLS.warn;

/** Bandera de diagnòstic: ?diag=1 l'activa i queda persistida fins a ?diag=0. */
function llegirBandera() {
  const p = new URLSearchParams(location.search);
  if (p.has('diag')) localStorage.setItem('nomada:diag', p.get('diag'));
  return localStorage.getItem('nomada:diag') === '1';
}

/** Un id per visita: permet agrupar totes les línies d'una mateixa sessió. */
function sessioId() {
  let id = sessionStorage.getItem('nomada:sessio');
  if (id === null) {
    id = crypto.randomUUID();
    sessionStorage.setItem('nomada:sessio', id);
  }
  return id;
}

export function ajustarNivell(nom) {
  llindar = NIVELLS[nom] ?? NIVELLS.warn;
}

export function registrar(nivell, esdeveniment, dades = {}) {
  const linia = { ts: new Date().toISOString(), nivell, esdeveniment, sessio: sessioId(), ...dades };

  historial.push(linia);                       // el buffer desa TOT, encara que no s'imprimeixi
  if (historial.length > MAXIM) historial.shift();

  if (NIVELLS[nivell] >= llindar) console[SORTIDA[nivell]](`[nomada] ${esdeveniment}`, linia);
  return linia;
}

export const debug = (esdeveniment, dades) => registrar('debug', esdeveniment, dades);
export const info  = (esdeveniment, dades) => registrar('info',  esdeveniment, dades);
export const avis  = (esdeveniment, dades) => registrar('warn',  esdeveniment, dades);
export const fallada = (esdeveniment, dades) => registrar('error', esdeveniment, dades);

/** Informe complet, llest per copiar i adjuntar a una incidència. */
export function abocarInforme({ tauler, avui, versio = '1.0.0' } = {}) {
  return {
    versio,
    generat: new Date().toISOString(),
    sessio: sessioId(),
    userAgent: navigator.userAgent,
    diagnostic: llegirBandera(),
    resum: tauler ? tauler.resum(avui) : null,
    historial: structuredClone(historial)      // còpia profunda: la foto, no l'objecte viu
  };
}

export async function copiarInforme(context) {
  await navigator.clipboard.writeText(JSON.stringify(abocarInforme(context), null, 2));
}

// Arrencada
if (llegirBandera()) ajustarNivell('debug');

Solució 3

Configuració de DevTools (cap línia de codi modificada):

A · Ignore List
   Afegir qualsevol dependència externa perquè "step into" no es perdi.

B · Quatre logpoints (clic dret al número de línia → Add logpoint)

   1) js/vista/controlador.js — dins de l'oient delegat, després del closest():
      'A·clic', {accio: boto?.dataset.accio, id: boto?.closest('[data-id]')?.dataset.id}
      → esperat: {accio: 'avancar', id: '3'}          (cadena! compte amb el cas 1)

   2) js/model/tauler.js — primera línia de canviarEstat(id, nou):
      'B·model', {id, tipus: typeof id, nou, actual: this.cercarPerId(id)?.estat}
      → esperat: {id: 3, tipus: 'number', nou: 'en-curs', actual: 'pendent'}

   3) js/vista/tauler-vista.js — primera línia d'actualitzar():
      'C·render', {visibles: this.visibles?.length, resum: this.estat?.tauler.resum('2026-09-20')}
      → esperat: {visibles: 6, resum: {…, horesObertes: 45, …}}

   4) js/dades/repositori-local.js — primera línia de desar(tauler):
      'D·persistir', {total: tauler.total, bytes: JSON.stringify(tauler).length}
      → esperat: {total: 6, bytes: ~1200}

C · Comprovació creuada
   - console.count activat al logpoint C detectaria renders duplicats.
   - Si apareixen A i B però no C, l'esdeveniment 'tasca:canviada' no s'està emetent.
   - Si apareixen A, B i C però no D, falta l'oient de persistència (cas 2).
   - Si D diu total: 3 mentre C diu visibles: 3, s'està persistint la vista (cas 2).

D · Extra sense aturar res
   Un breakpoint per canvi del DOM (attribute modifications) sobre el <li data-id="3">
   assenyalaria exactament quina línia escriu data-estat, útil si C s'executa però la
   targeta no canvia.

Conclusió

Has canviat la manera d'enfrontar-te a un error. Ja no es tracta de mirar el codi fins que alguna cosa sembli sospitosa, sinó de recórrer sis passos: reproduir de manera fiable, aïllar fins al cas mínim, formular una hipòtesi falsable, comprovar-la amb un instrument, corregir la causa a la capa correcta i prevenir amb una prova. I saps que quan el terreny és gran, la bisecció —al flux de dades, a la història amb git bisect o al mateix codi— converteix una cerca lineal en una de logarítmica.

Coneixes la consola sencera i no només console.log: table per veure sis tasques d'una ullada, count per descobrir els renders duplicats, assert per vigilar invariants com les 45 h obertes, group per no ofegar-te en línies, trace per saber qui ha cridat, i el truc de les claus console.log({variable}) que etiqueta cada valor amb el seu nom. I domines el depurador de debò: la sentència debugger i els seus riscos, els sis tipus de punt d'interrupció —normal, condicional, de registre, per esdeveniment, per petició i per canvi del DOM—, la navegació amb step over, into i out, els panells Scope (on els closures de 03-04 són per fi visibles), Watch amb expressions calculades i Call Stack amb el seu Restart frame. Saps llegir un stack trace de baix a dalt, activar «Pause on caught exceptions» per caçar els errors que s'empassen els catch, seguir una pila asíncrona cosida a través dels await de 05-07, i per què sense source maps un error de producció no diu res. I tens el panell Network amb el seu Copy as fetch i el seu throttling, el panell Application per al localStorage i el service worker del Mòdul 7, i la inspecció remota per al mòbil de debò.

Sobretot, has resolt tres errors reals amb el mètode al davant: un id que arribava com a cadena des de l'API i feia fallar el === de 01-07 —corregit a la frontera de dades, no al model ni a la vista—; un filtre que persistia les tasques visibles en lloc del tauler complet, confonent un valor derivat de la vista amb l'estat del model; i un horesObertes que sumava sobre totes les tasques i retornava 48 en lloc de 45, un error silenciós que cap pantalla delatava. I has vist què fer quan l'error no es reprodueix: registre estructurat amb buffer circular, banderes de diagnòstic activables per URL, reproducció de condicions en lloc de passos, i una nota sobre els serveis de monitoratge que apliquen els teus source maps als errors d'altres persones.

Els tres casos comparteixen un final incòmode: tots tres s'han tancat amb una prova «pendent d'escriure». I tots tres, a més, tenien un avís previ que ningú va veure. L'id barrejant tipus, el catch buit que s'empassava l'error, les dues línies bessones on una no es va adaptar: són exactament el tipus de problema que una màquina pot assenyalar abans d'executar res, llegint el codi. Abans d'automatitzar les comprovacions de comportament convé automatitzar les de forma, perquè són més barates i atrapen una família sencera d'errors elles soles. Això és el següent: Qualitat de Codi: ESLint, Prettier i Convencions, on configuraràs un analitzador estàtic sobre Nómada Tasques, arreglaràs els avisos que apareguin —inclòs, amb tota seguretat, algun debugger oblidat d'aquesta lliçó— i posaràs un guardià a Git perquè res d'això no torni a arribar al repositori.

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