Ja saps fabricar el <li> d'una tasca. El que encara no tens és una manera de treballar: ara mateix pintes una vegada en arrencar i després vas apedaçant cada element a mà dins del gestor de clic. Això funciona amb tres accions i es torna inmanejable amb quinze, perquè cada acció s'ha de recordar d'actualitzar el <li>, el resum, el comptador d'hores, el panell de càrrega i el que vingui després. L'alternativa és un canvi de mentalitat: en lloc de descriure què cal modificar, s'escriu una funció que descriu com ha de veure's la pantalla donat un estat, i es torna a cridar cada vegada que l'estat canvia. En aquesta lliçó construiràs aquell cicle, aprendràs a agrupar el tauler per columnes, a escapar dades si fas servir plantilles de text, i a resoldre el problema que apareix tan bon punt redibuixes tot: que el focus, el desplaçament i el que l'usuari estava escrivint es perden pel camí.

Contingut

  1. Del node solt a la llista completa: render(estat)
  2. El cicle estat → render → esdeveniment → estat nou → render
  3. Plantilles amb template literals i escaparHtml
  4. Tres maneres de produir HTML, comparades
  5. <template> per a les targetes
  6. Agrupar el tauler per estat amb Object.groupBy
  7. Estats buits, comptadors i resum
  8. El problema de redibuixar-ho tot
  9. Actualització parcial: claus i reutilització de nodes
  10. reconciliar(llista, dades)
  11. Ordenar i filtrar a la vista sense tocar el model
  12. Nómada Tasques: vista/tauler-vista.js
  13. Errors Habituals i Consells
  14. Exercicis
  15. Conclusió

  1. Del node solt a la llista completa: render(estat)

Compara les dues maneres de pensar:

// ✗ Imperativa: descriure els canvis, un a un
boto.addEventListener('click', () => {
  tauler.canviarEstat(id, 'feta');
  li.classList.add('tasca--feta');
  li.querySelector('.tasca__meta').textContent = '…';
  boto.disabled = true;
  resum.textContent = '…';
  comptadorHores.textContent = '…';
  // … i tot el que algú afegeixi demà
});

// ✓ Declarativa: descriure el resultat, i tornar a dibuixar-lo
boto.addEventListener('click', () => {
  tauler.canviarEstat(id, 'feta');
  render();                     // una sola línia, sempre la mateixa
});

La funció render pren l'estat i produeix la pantalla. Res més. No sap què ha canviat ni li importa: dibuixa el que hi ha ara.

/** Estat de la vista: el model + les preferències de visualització. */
const estat = {
  tauler,                                   // el Tauler del model
  filtres: { responsable: null, estat: null },
  ordre: 'prioritat',
  avui: AVUI
};

function render() {
  const visibles = aplicarFiltres(estat);
  llista.replaceChildren(...visibles.map((t) => crearTargeta(t, estat.avui)));
  pintarResum(resum, estat.tauler, estat.avui);
}

L'avantatge no és escriure menys codi: és que la pantalla no es pot desincronitzar de l'estat. A la versió imperativa sempre acaba havent-hi un cas en què algú s'oblida d'actualitzar una de les cinc coses i la interfície mostra una mentida. A la declarativa, això és impossible per construcció.

  1. El cicle estat → render → esdeveniment → estat nou → render

Aquesta és l'arquitectura de gairebé qualsevol aplicació d'interfície moderna, i ara l'escriuràs a mà:

flowchart LR
    E["ESTAT<br/>Tauler + filtres + ordre"] --> R["render(estat)<br/>dibuixa la pantalla"]
    R --> P["PANTALLA<br/>llista, resum, comptadors"]
    P --> V["L'usuari actua<br/>clic, tecla, enviament"]
    V --> M["Gestor delegat<br/>crida el MODEL"]
    M --> N["ESTAT NOU<br/>tauler.canviarEstat(...)"]
    N --> R

Les regles del cicle, que convé respectar amb disciplina:

  • Un únic sentit. L'estat alimenta la pantalla; la pantalla no és mai la font de veritat. Si necessites saber quines tasques estan fetes, ho preguntes al Tauler, no comptes elements amb classe tasca--feta.
  • Els gestors no toquen el DOM. Modifiquen l'estat (a través del model) i criden render(). Un de sol.
  • render és (gairebé) pura. Donat el mateix estat, produeix la mateixa pantalla. El seu únic efecte secundari és escriure al DOM.
  • El model continua sense saber que existeix una pantalla. Tasca i Tauler no canvien ni una línia en aquesta lliçó.

  1. Plantilles amb template literals i escaparHtml

Hi ha una manera de construir HTML que resulta molt llegible: els template literals de 01-05, amb l'estructura escrita tal qual i les dades interpolades.

function plantillaTasca(tasca) {
  return `
    <li class="tasca tasca--${tasca.prioritat}" data-id="${tasca.id}">
      <span class="tasca__titol">${tasca.titol}</span>
      <span class="tasca__meta">${tasca.responsable} · ${tasca.horesEstimades} h</span>
      <button type="button" data-accio="avancar">Començar</button>
    </li>`;
}

llista.innerHTML = tauler.tasques.map(plantillaTasca).join('');

Es llegeix de meravella. I tal com està escrit, és una vulnerabilitat. A 06-07 la Marta podrà escriure el títol d'una tasca; si escriu <img src=x onerror="…">, aquell codi s'executarà a la teva pàgina.

L'única manera de fer servir aquest enfocament amb seguretat és escapar totes les dades interpolades, sense excepció:

// js/vista/dom.js
const ESCAPAMENTS = Object.freeze({
  '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
});

/** Converteix els caràcters amb significat en HTML en les seves entitats. */
export function escaparHtml(valor) {
  return String(valor).replace(/[&<>"']/g, (caracter) => ESCAPAMENTS[caracter]);
}
console.log(escaparHtml('<img src=x onerror="robar()">'));
// '&lt;img src=x onerror=&quot;robar()&quot;&gt;'   ← es veurà com a text, no s'executarà

console.log(escaparHtml('Tintes & pantalles'));
// 'Tintes &amp; pantalles'

Detalls que fan que aquesta funció sigui correcta i no una falsa sensació de seguretat:

  • & s'escapa el primer en l'ordre de l'expressió regular, perquè si s'escapés després convertiria els &lt; ja generats en &amp;lt;. Fer servir replace amb una funció de reemplaçament evita el problema d'arrel, perquè cada caràcter es processa una sola vegada.
  • També s'escapen les cometes. Sense elles, una dada interpolada dins d'un atribut (data-id="${valor}") podria tancar l'atribut i injectar-ne d'altres. És un vector real.
  • String(valor) protegeix de null, undefined i números.

I una advertència que no s'ha d'oblidar: escapar serveix per a contingut de text i atributs entre cometes. No n'hi ha prou si interpoles dins d'un <script>, dins d'un style, o en un atribut href/src (on un javascript: continua sent perillós). Per a aquells casos, no interpolis: fes servir nodes.

Amb la funció, la plantilla queda així:

import { escaparHtml as esc } from './dom.js';

function plantillaTasca(tasca) {
  return `
    <li class="tasca tasca--${esc(tasca.prioritat)}" data-id="${esc(tasca.id)}">
      <span class="tasca__titol">${esc(tasca.titol)}</span>
      <span class="tasca__meta">${esc(tasca.responsable ?? 'sense assignar')} · ${esc(tasca.horesEstimades)} h</span>
      <button type="button" data-accio="avancar">Començar</button>
    </li>`;
}

Funciona, és segur… i depèn que ningú no s'oblidi mai d'un esc(). Una sola distracció reobre el forat. Aquella fragilitat és l'argument decisiu contra aquest enfocament, i la raó que les alternatives de l'apartat següent siguin preferibles.

  1. Tres maneres de produir HTML, comparades

Criteri Template literal + innerHTML createElement / crearElement <template> + cloneNode
Llegibilitat de l'estructura Molt alta Mitjana Molt alta (és a l'HTML)
Seguretat Depèn d'escapar sempre Segura per construcció Segura (s'omple amb textContent)
Referències als nodes creats Cal tornar-los a buscar Ja les tens Es busquen dins del clon
Conserva nodes, focus i estat No: destrueix i recrea Sí, si actualitzes en lloc de recrear Sí, igual
Cost Analitzar HTML a cada render Crides a mètodes Clonar (molt barat)
Estructures condicionals Fàcil amb ternaris Fàcil amb filter(Boolean) Incòmode: cal amagar parts
Risc d'error humà Alt (oblidar un esc) Baix Baix

Conclusió operativa per a Nómada Tasques: <template> per a l'estructura fixa de la targeta, crearElement per al que és dinàmic, i els template literals només per a marcatge constant sense dades. És l'equilibri entre llegibilitat i seguretat, i elimina la classe sencera d'errors d'escapament.

  1. <template> per a les targetes

Reprenem la plantilla de 06-05 i li donem la seva forma definitiva, amb les columnes incloses:

<template id="plantilla-tasca">
  <li class="tasca" tabindex="-1">
    <span class="tasca__titol"></span>
    <span class="tasca__meta"></span>
    <ul class="tasca__etiquetes"></ul>
    <button type="button" class="tasca__accio" data-accio="avancar"></button>
    <button type="button" class="tasca__accio" data-accio="reobrir">Reobrir</button>
  </li>
</template>

<template id="plantilla-columna">
  <section class="columna" aria-labelledby="">
    <h3 class="columna__titol"></h3>
    <p class="columna__comptador"></p>
    <ul class="llista-tasques"></ul>
    <p class="columna__buida" hidden>No hi ha tasques en aquesta columna.</p>
  </section>
</template>

I la funció que la omple. Fixa't que ara actualitza un <li> existent si li'n passes un, i només clona si no n'hi ha cap: aquella doble capacitat és la que farà possible la reconciliació de l'apartat 10.

// js/vista/targeta.js
import { $ } from './dom.js';
import { AVUI } from '../util/dates.js';
import { marcaEstat } from '../util/format.js';

const CLASSES_PRIORITAT = ['tasca--alta', 'tasca--mitjana', 'tasca--baixa'];
const SEGUENT = Object.freeze({ pendent: 'en-curs', 'en-curs': 'feta', feta: null });
const ETIQUETA = Object.freeze({
  pendent: 'Començar', 'en-curs': 'Marcar feta', feta: 'Completada'
});

/**
 * Crea o ACTUALITZA el <li> d'una tasca.
 * @param {Tasca} tasca
 * @param {HTMLElement|null} existent  si es passa, es reutilitza en lloc de clonar
 */
export function pintarTargeta(tasca, existent = null, avui = AVUI) {
  const li = existent ?? $('#plantilla-tasca').content.firstElementChild.cloneNode(true);
  const vencuda = tasca.estaVencuda(avui);

  li.dataset.id = tasca.id;
  li.dataset.estat = tasca.estat;
  li.dataset.responsable = tasca.responsable ?? '';
  li.dataset.prioritat = tasca.prioritat;

  li.classList.remove(...CLASSES_PRIORITAT);
  li.classList.add(`tasca--${tasca.prioritat}`);
  li.classList.toggle('tasca--feta', tasca.estat === 'feta');
  li.classList.toggle('tasca--vencuda', vencuda);

  $('.tasca__titol', li).textContent = tasca.titol;
  $('.tasca__meta', li).textContent =
    `${marcaEstat(tasca.estat)} ${tasca.responsable ?? 'sense assignar'} · ` +
    `${tasca.horesEstimades} h` + (vencuda ? ` · vençuda fa ${Math.abs(tasca.diesRestants)} d` : '');

  const etiquetes = $('.tasca__etiquetes', li);
  etiquetes.replaceChildren(...tasca.etiquetes.map((text) => {
    const item = document.createElement('li');
    item.className = 'etiqueta';
    item.textContent = text;
    return item;
  }));

  const avancar = $('[data-accio="avancar"]', li);
  avancar.textContent = ETIQUETA[tasca.estat];
  avancar.disabled = SEGUENT[tasca.estat] === null;
  avancar.setAttribute('aria-label', `${ETIQUETA[tasca.estat]}: ${tasca.titol}`);

  const reobrir = $('[data-accio="reobrir"]', li);
  reobrir.disabled = tasca.estat !== 'en-curs';
  reobrir.setAttribute('aria-label', `Tornar a pendent: ${tasca.titol}`);

  return li;
}

Una sola funció serveix per crear i per actualitzar. Això és exactament el que necessita un render que s'executa moltes vegades.

  1. Agrupar el tauler per estat amb Object.groupBy

El tauler de Taller Nómada té tres columnes: pendent, en curs i feta. Agrupar és una línia, amb el que vas aprendre a 04-05:

const COLUMNES = [
  { estat: 'pendent', titol: 'Pendents' },
  { estat: 'en-curs', titol: 'En curs' },
  { estat: 'feta',    titol: 'Fetes' }
];

const perEstat = Object.groupBy(tauler.tasques, (t) => t.estat);
console.log(Object.keys(perEstat));                // ['en-curs', 'pendent', 'feta']
console.log(perEstat.pendent.length);              // 3  ← ids 2, 3 i 6
console.log(perEstat['en-curs'].map((t) => t.id)); // [1, 5]
console.log(perEstat.feta.map((t) => t.id));       // [4]

Dos avisos sobre Object.groupBy:

  • Només crea les claus que apareixen. Si cap tasca no està feta, perEstat.feta és undefined, no un array buit. Fes servir ?? [] sempre.
  • L'objecte que retorna té el prototip null, així que no té hasOwnProperty ni toString. Per consultar claus, Object.hasOwn(perEstat, 'feta') o directament el ??.
const pendents = perEstat.pendent ?? [];           // ✓ mai undefined

Amb això, dibuixar les tres columnes és directe:

function renderColumnes(contenidor, tasques, avui) {
  const perEstat = Object.groupBy(tasques, (t) => t.estat);

  contenidor.replaceChildren(...COLUMNES.map(({ estat, titol }) => {
    const columna = $('#plantilla-columna').content.firstElementChild.cloneNode(true);
    const delEstat = perEstat[estat] ?? [];

    columna.dataset.estat = estat;
    $('.columna__titol', columna).textContent = titol;
    $('.columna__titol', columna).id = `columna-${estat}`;
    columna.setAttribute('aria-labelledby', `columna-${estat}`);

    const hores = delEstat.reduce((suma, t) => suma + t.horesEstimades, 0);
    $('.columna__comptador', columna).textContent =
      `${delEstat.length} ${delEstat.length === 1 ? 'tasca' : 'tasques'} · ${hores} h`;

    $('.llista-tasques', columna).replaceChildren(...delEstat.map((t) => pintarTargeta(t, null, avui)));
    $('.columna__buida', columna).hidden = delEstat.length > 0;

    return columna;
  }));
}

Amb el backlog canònic, el resultat és:

Pendents · 3 tasques · 25 h     (Cartelleria 6, Web de reserves 14, Fusteria 5)
En curs  · 2 tasques · 20 h     (Sala polivalent 12, Guia d'enquadernació 8)
Fetes    · 1 tasca   · 3 h      (Inventari de tintes)

I 25 + 20 = 45 h obertes, amb 48 h totals. Els números canònics, ara repartits en columnes.

El CSS mínim perquè es vegin com a tals:

.tauler { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.columna { border: 1px solid var(--vora); border-radius: 0.5rem; padding: 0.75rem; }
.columna__titol { margin: 0 0 0.25rem; font-size: 1rem; }
.columna__comptador { margin: 0 0 0.75rem; color: var(--gris); font-size: 0.85rem; }
.columna__buida { color: var(--gris); font-style: italic; }
.tasca__etiquetes { list-style: none; display: flex; gap: 0.3rem; padding: 0; margin: 0.3rem 0 0; }
.etiqueta { font-size: 0.75rem; background: #f3f4f6; border-radius: 999px; padding: 0.1rem 0.5rem; }

  1. Estats buits, comptadors i resum

Un detall que separa una interfície acabada d'un prototip: què es veu quan no hi ha res. Una columna buida sense missatge sembla un error de càrrega.

Ja ho has resolt més amunt amb $('.columna__buida', columna).hidden = delEstat.length > 0, però convé distingir tres situacions diferents, perquè el missatge adequat no és el mateix:

Situació Missatge adequat
No hi ha tasques en aquesta columna «No hi ha tasques en aquesta columna.»
Hi ha tasques, però el filtre les amaga totes «Cap tasca no coincideix amb el filtre. Treure filtres»
El tauler està completament buit «Encara no hi ha tasques. Crea la primera amb el formulari.»
function missatgeBuit(estat, hiHaTasquesEnTotal, hiHaFiltreActiu) {
  if (!hiHaTasquesEnTotal) return 'Encara no hi ha tasques. Crea la primera amb el formulari.';
  if (hiHaFiltreActiu) return 'Cap tasca no coincideix amb el filtre actual.';
  return 'No hi ha tasques en aquesta columna.';
}

I el resum global, que reutilitza el mètode del model:

function renderResum(element, tauler, avui) {
  const { total, obertes, horesTotals, horesObertes, vencudes, esforc } = tauler.resum(avui);
  element.textContent =
    `${total} tasques · ${obertes} obertes · ${horesObertes} de ${horesTotals} h · ` +
    `${vencudes} vençuda${vencudes === 1 ? '' : 'es'} · esforç ${esforc}`;
}
// 6 tasques · 5 obertes · 45 de 48 h · 1 vençuda · esforç 124

Aquell element porta role="status" a l'HTML (06-01), de manera que un lector de pantalla anuncia el resum nou cada vegada que canvia, sense robar el focus.

  1. El problema de redibuixar-ho tot

I ara la mala notícia. replaceChildren(...) destrueix tots els nodes i en crea d'altres. Això té quatre conseqüències visibles:

  1. Es perd el focus. Si la Marta havia arribat amb el tabulador al botó «Marcar feta» de la tercera tasca i prem Enter, el render la deixa amb el focus a document.body. El seu Tab següent la porta al principi de la pàgina.
  2. Es perd el desplaçament dins de contenidors amb barra pròpia.
  3. Es perd l'estat del navegador: text seleccionat, <details> oberts, la posició del cursor dins d'un <input> que hi hagués a la llista.
  4. Es reinicien les transicions CSS, perquè els elements són nous.
render();
console.log(document.activeElement.tagName);   // 'BODY'  ✗ el focus se n'ha anat

Hi ha dues estratègies per resoldre-ho.

Estratègia A: salvar i restaurar. Abans de redibuixar, apunta què estava enfocat; després, torna a buscar-ho per la seva clau estable i torna-li el focus.

function ambFocusConservat(render) {
  const actiu = document.activeElement;
  const idTasca = actiu?.closest?.('[data-id]')?.dataset.id ?? null;
  const accio   = actiu?.dataset?.accio ?? null;
  const desplacament = contenidor.scrollTop;

  render();

  contenidor.scrollTop = desplacament;
  if (idTasca !== null) {
    const desti = $(`[data-id="${idTasca}"] [data-accio="${accio}"]`) ??
                  $(`[data-id="${idTasca}"]`);
    if (desti !== null && !desti.disabled) desti.focus();
  }
}

Funciona i és senzilla, però és un apedaçament: com més coses hi hagi a conservar, més fràgil es torna.

Estratègia B: no destruir el que no ha canviat. És la bona, i és el que fan els frameworks. En lloc de buidar i recrear, es compara la llista de nodes actuals amb les dades noves i es reutilitza cada node que continuï sent vàlid, actualitzant només el seu contingut.

  1. Actualització parcial: claus i reutilització de nodes

Per reutilitzar un node cal poder identificar-lo. La posició no val: si la primera tasca desapareix, totes les altres es desplacen i el node de la segona passaria a representar la tercera, amb el focus i les animacions al lloc equivocat.

El que val és una clau estable: un identificador que pertanyi a la dada, no a la seva posició. A Nómada Tasques ja la tens des de 06-02: data-id.

flowchart TD
    D["Dades noves<br/>ids: 3, 6, 1"] --> C{"Ja existeix un node<br/>amb aquell data-id?"}
    C -- "Sí" --> A["Actualitzar-ne el contingut<br/>(conserva focus i estat)"]
    C -- "No" --> N["Crear la targeta nova"]
    A --> O["Col·locar en l'ordre correcte"]
    N --> O
    O --> S["Eliminar els nodes sobrants<br/>(ids que ja no hi són)"]

  1. reconciliar(llista, dades)

Aquí està la implementació, i és més curta del que sembla:

// js/vista/dom.js

/**
 * Sincronitza els fills d'un contenidor amb una llista de dades, reutilitzant
 * els nodes que ja existeixen. Cada dada ha de tenir una clau estable.
 *
 * @param {HTMLElement} contenidor
 * @param {Array} dades
 * @param {(dada) => string|number} clau   identificador estable de la dada
 * @param {(dada, nodeExistent|null) => HTMLElement} pintar  crea o actualitza el node
 */
export function reconciliar(contenidor, dades, clau, pintar) {
  // 1 · Indexem els nodes actuals per la seva clau (el reduce de 04-05)
  const existents = new Map(
    [...contenidor.children].map((node) => [node.dataset.clau ?? node.dataset.id, node])
  );

  // 2 · Recorrem les dades en el seu ordre final
  let referencia = contenidor.firstElementChild;

  for (const dada of dades) {
    const k = String(clau(dada));
    const previ = existents.get(k) ?? null;
    const node = pintar(dada, previ);           // reutilitza si previ !== null
    existents.delete(k);                        // marcat com a "continua viu"

    if (node !== referencia) {
      contenidor.insertBefore(node, referencia); // mou o insereix al lloc correcte
    } else {
      referencia = referencia.nextElementSibling; // ja estava ben col·locat
    }
  }

  // 3 · El que quedi al mapa ja no és a les dades: fora
  for (const sobrant of existents.values()) sobrant.remove();
}

Explicació línia a línia del que fa i per què:

  • Pas 1: construeix un Map de clau → node amb el que hi ha a la pantalla. És exactament el patró d'indexar per id de 04-05, aplicat a nodes en lloc d'objectes.
  • Pas 2: recorre les dades en l'ordre en què han de quedar. Per a cadascuna, si ja existia un node amb aquella clau el reutilitza (pintar(dada, previ) l'actualitza en lloc de clonar), i si no, en crea un de nou. Després el col·loca a la seva posició amb insertBefore, que —recorda 06-05— mou el node si ja era a l'arbre.
  • Pas 3: el que queda al Map són nodes la clau dels quals ja no apareix a les dades. S'eliminen.

El resultat: els nodes de les tasques que continuen allà no es destrueixen mai. Conserven el seu focus, les seves transicions i qualsevol estat del navegador.

// Ús, amb la mateixa pintarTargeta que serveix per crear i actualitzar
reconciliar(
  llista,
  visibles,
  (tasca) => tasca.id,
  (tasca, node) => pintarTargeta(tasca, node, estat.avui)
);

Comprova-ho: posa el focus al botó «Començar» de la tercera tasca, prem Enter i observa que el focus continua allà després del render, amb el botó ja canviat a «Marcar feta». Amb replaceChildren hauria desaparegut.

Els límits d'aquesta implementació. És deliberadament senzilla i no és un algorisme de reconciliació complet: no detecta moviments de manera òptima (pot moure més nodes dels estrictament necessaris) i només funciona amb fills directes que tinguin clau. Per a una llista de tasques és més que suficient. Per a una interfície gran, amb arbres imbricats i components, fer això a mà es torna inviable, i aquí és on entren els frameworks: React, Vue i Angular resolen aquest problema exacte —i per això tots ells et demanen una key a les llistes, que és literalment aquest data-id—. Ho veuràs a Per Què Existeixen els Frameworks, i arribaràs a aquella lliçó sabent quin problema resolen, que és l'única manera d'entendre'ls de debò.

  1. Ordenar i filtrar a la vista sense tocar el model

Filtrar i ordenar són decisions de presentació. El Tauler no se n'ha d'assabentar: si la Lucía filtra pel seu nom, el tauler continua tenint sis tasques i 45 h obertes; l'única cosa que canvia és el que es mostra.

// js/vista/tauler-vista.js
const ORDRES = Object.freeze({
  prioritat: (a, b) => PESOS[b.prioritat] - PESOS[a.prioritat] || a.id - b.id,
  data:      (a, b) => a.dataLimit.localeCompare(b.dataLimit),
  hores:     (a, b) => b.horesEstimades - a.horesEstimades,
  titol:     (a, b) => a.titol.localeCompare(b.titol, 'ca')
});

/** Aplica filtres i ordre SENSE modificar el tauler. Retorna un array nou. */
function tasquesVisibles(estat) {
  const { responsable, text } = estat.filtres;

  return estat.tauler.tasques                        // ← còpia defensiva del model (05-03)
    .filter((t) => responsable === null || t.responsable === responsable)
    .filter((t) => text === '' || t.titol.toLowerCase().includes(text.toLowerCase()))
    .sort(ORDRES[estat.ordre] ?? ORDRES.prioritat);
}

Tres punts importants:

  • tauler.tasques ja retorna una còpia ([...this.#tasques], encapsulació de 05-03), així que el .sort(), que muta, no toca l'array intern del model. Si el getter retornés l'array real, aquest sort reordenaria el model: una fallada subtil i desagradable.
  • La comparadora prioritat fa servir || com a desempat: si dues tasques tenen la mateixa prioritat, s'ordenen per id. Sense desempat, l'ordre entre iguals dependria de l'algorisme i podria canviar entre renders, produint salts visuals.
  • localeCompare(text, 'ca') ordena correctament els accents i la ç, cosa que el < de les cadenes no fa.

  1. Nómada Tasques: vista/tauler-vista.js

El mòdul complet, que ajunta tot l'anterior:

// js/vista/tauler-vista.js
import { $, $$, reconciliar } from './dom.js';
import { pintarTargeta } from './targeta.js';
import { PESOS } from '../util/format.js';
import { AVUI } from '../util/dates.js';
import { ESDEVENIMENTS, emetre } from './esdeveniments.js';

const COLUMNES = [
  { estat: 'pendent', titol: 'Pendents' },
  { estat: 'en-curs', titol: 'En curs' },
  { estat: 'feta',    titol: 'Fetes' }
];

const ORDRES = Object.freeze({
  prioritat: (a, b) => PESOS[b.prioritat] - PESOS[a.prioritat] || a.id - b.id,
  data:      (a, b) => a.dataLimit.localeCompare(b.dataLimit),
  hores:     (a, b) => b.horesEstimades - a.horesEstimades
});

export class TaulerVista {
  #contenidor;
  #resum;
  #estat;

  constructor({ contenidor, resum, tauler, avui = AVUI }) {
    this.#contenidor = contenidor;
    this.#resum = resum;
    this.#estat = {
      tauler,
      avui,
      filtres: { responsable: null, text: '' },
      ordre: 'prioritat'
    };
    this.#prepararColumnes();
  }

  /** Les columnes es creen UNA vegada; després només es reconcilien les seves llistes. */
  #prepararColumnes() {
    this.#contenidor.replaceChildren(...COLUMNES.map(({ estat, titol }) => {
      const columna = $('#plantilla-columna').content.firstElementChild.cloneNode(true);
      columna.dataset.estat = estat;
      const encapcalament = $('.columna__titol', columna);
      encapcalament.textContent = titol;
      encapcalament.id = `columna-${estat}`;
      columna.setAttribute('aria-labelledby', `columna-${estat}`);
      $('.llista-tasques', columna).setAttribute('aria-live', 'polite');
      return columna;
    }));
  }

  /** Canvia una part de l'estat de la vista i redibuixa. */
  actualitzar(canvis) {
    Object.assign(this.#estat, canvis);
    if (canvis.filtres) Object.assign(this.#estat.filtres, canvis.filtres);
    this.render();
  }

  #visibles() {
    const { responsable, text } = this.#estat.filtres;
    return this.#estat.tauler.tasques
      .filter((t) => responsable === null || t.responsable === responsable)
      .filter((t) => text === '' || t.titol.toLowerCase().includes(text.toLowerCase()))
      .sort(ORDRES[this.#estat.ordre] ?? ORDRES.prioritat);
  }

  render() {
    const visibles = this.#visibles();
    const perEstat = Object.groupBy(visibles, (t) => t.estat);
    const hiHaFiltre = this.#estat.filtres.responsable !== null || this.#estat.filtres.text !== '';

    for (const { estat } of COLUMNES) {
      const columna = $(`.columna[data-estat="${estat}"]`, this.#contenidor);
      const delEstat = perEstat[estat] ?? [];
      const hores = delEstat.reduce((suma, t) => suma + t.horesEstimades, 0);

      $('.columna__comptador', columna).textContent =
        `${delEstat.length} ${delEstat.length === 1 ? 'tasca' : 'tasques'} · ${hores} h`;

      // Reconciliació: els nodes que continuen vius NO es destrueixen
      reconciliar(
        $('.llista-tasques', columna),
        delEstat,
        (tasca) => tasca.id,
        (tasca, node) => pintarTargeta(tasca, node, this.#estat.avui)
      );

      const buida = $('.columna__buida', columna);
      buida.hidden = delEstat.length > 0;
      buida.textContent = hiHaFiltre
        ? 'Cap tasca no coincideix amb el filtre actual.'
        : 'No hi ha tasques en aquesta columna.';
    }

    const r = this.#estat.tauler.resum(this.#estat.avui);
    this.#resum.textContent =
      `${r.total} tasques · ${r.obertes} obertes · ${r.horesObertes} de ${r.horesTotals} h · ` +
      `${r.vencudes} vençuda${r.vencudes === 1 ? '' : 'es'} · esforç ${r.esforc}` +
      (hiHaFiltre ? ` · mostrant-ne ${visibles.length}` : '');

    emetre(this.#contenidor, ESDEVENIMENTS.TAULER_ACTUALITZAT, { ...r, mostrades: visibles.length });
  }
}

I el punt d'entrada, que ja és pur cablejat:

// js/app.js
import { Tauler } from './model/tauler.js';
import { crearBacklog } from './dades/backlog.js';
import { AVUI } from './util/dates.js';
import { TaulerVista } from './vista/tauler-vista.js';
import { $ } from './vista/dom.js';

const tauler = new Tauler('Taller Nómada', crearBacklog());
const vista = new TaulerVista({
  contenidor: $('.tauler'),
  resum: $('#resum'),
  tauler,
  avui: AVUI
});
vista.render();

// Un únic gestor delegat per a TOT el tauler (06-04)
$('.tauler').addEventListener('click', (esdeveniment) => {
  const boto = esdeveniment.target.closest('button[data-accio]');
  if (boto === null) return;

  const id = Number(boto.closest('[data-id]').dataset.id);
  const tasca = tauler.cercarPerId(id);
  const desti = boto.dataset.accio === 'avancar'
    ? { pendent: 'en-curs', 'en-curs': 'feta', feta: null }[tasca.estat]
    : (tasca.estat === 'en-curs' ? 'pendent' : null);
  if (desti === null) return;

  tauler.canviarEstat(id, desti);       // 1 · canvia l'ESTAT
  vista.render();                       // 2 · redibuixa
});

// Els filtres només canvien l'estat de la vista; el model ni se n'assabenta
$('.filtres').addEventListener('click', (esdeveniment) => {
  const boto = esdeveniment.target.closest('.filtre');
  if (boto === null) return;
  $$('.filtre').forEach((b) => b.setAttribute('aria-pressed', String(b === boto)));
  vista.actualitzar({ filtres: { responsable: boto.dataset.responsable || null } });
});

Tot el cicle de l'apartat 2, en vint línies: el gestor delegat toca el model i crida render(); render() dibuixa el que hi ha. En marcar la fusteria com a feta, la targeta es mou de la columna «Pendents» a «Fetes» sense destruir-se, els comptadors passen de 3 tasques · 25 h a 2 tasques · 20 h i d'1 tasca · 3 h a 2 tasques · 8 h, el resum baixa de 45 a 40 h obertes i d'1 vençuda a 0, i el focus continua exactament on era.

Errors Habituals i Consells

  • Interpolar dades sense escapar en una plantilla de text. N'hi ha prou amb un ${} oblidat per obrir un XSS. Si tries aquest enfocament, escapa sempre; si pots, fes servir <template> o nodes i elimina la classe sencera d'errors.
  • Fer servir l'índex de l'array com a clau. data-id="${index}" sembla que funciona fins que s'elimina un element del mig: llavors tots els nodes representen la dada equivocada. La clau ha de pertànyer a la dada.
  • Oblidar ?? [] amb Object.groupBy. Només crea les claus que apareixen; una columna sense tasques dona undefined i el .map següent llança TypeError.
  • Ordenar l'array del model. sort muta. Funciona aquí perquè tauler.tasques retorna una còpia; si algun getter retornés l'array intern, estaries reordenant el model des de la vista. Davant del dubte, [...llista].sort(...).
  • Redibuixar dins d'un bucle d'esdeveniments ràpid. Un render() complet per cada pulsació de tecla en un camp de cerca és feina malgastada. La solució és el debounce que veuràs a 06-07.
  • Posar gestors dins de render. Si render registra gestors, cada crida n'afegeix un altre i acabes amb l'acció executant-se cinc vegades. Els gestors es registren una vegada, per delegació, fora del render.
  • Redibuixar i perdre el focus sense adonar-se'n. És una fallada d'accessibilitat seriosa i silenciosa: qui fa servir ratolí no la nota mai. Prova-ho sempre amb el tabulador.
  • Consell: mantén render() sense efectes ocults. Que només llegeixi l'estat i escrigui al DOM. Si fa peticions, canvia el model o dispara accions, deixa de ser predictible i depurar es torna molt difícil.
  • Consell: desa l'estat de la vista en un sol objecte. Tenir filtres, ordre i avui en un únic #estat fa trivial respondre a «per què es veu això?»: imprimeixes l'objecte i ho saps.

Exercicis

Exercici 1 · Cercador per títol

Afegeix a l'HTML un <input type="search" id="cercar"> amb la seva <label> i connecta'l perquè en escriure es filtrin les tasques per títol. Ha de fer servir estat.filtres.text, no tocar el model, i mostrar al resum quantes se n'estan veient. Comprova amb el tabulador que el focus no surt del camp de cerca en escriure, i explica per què la reconciliació és imprescindible aquí.

Exercici 2 · Ordenació amb <select>

Afegeix un <select id="ordre"> amb les opcions prioritat, data límit i hores, i fes que en canviar-lo es reordenin les targetes. Després comprova, amb les DevTools, que en reordenar els nodes es mouen en lloc de recrear-se: posa un data-marca en un <li> des de la consola, reordena, i verifica que la marca continua allà.

Exercici 3 · Reconciliació amb eliminació

Afegeix un botó data-accio="eliminar" a la targeta i un mètode eliminar(id) al Tauler. Comprova que després d'eliminar una tasca del mig, les targetes restants conserven els seus nodes (no es recreen) i que el focus passa al botó equivalent de la tasca següent. Explica què hauria passat si reconciliar fes servir la posició en lloc de data-id.

Solucions

Exercici 1

<label for="cercar">Cercar per títol</label>
<input type="search" id="cercar" placeholder="serigrafia, web, fusteria…">
$('#cercar').addEventListener('input', (esdeveniment) => {
  vista.actualitzar({ filtres: { text: esdeveniment.target.value.trim() } });
});

La reconciliació és imprescindible aquí per un motiu molt concret: l'esdeveniment input es dispara a cada pulsació, així que cada lletra provoca un render(). Si aquell render fes replaceChildren, destruiria i recrearia totes les targetes vint vegades mentre la Marta escriu «serigrafia». No perdria el focus de l'<input> —és fora del contenidor reconciliat—, però sí que malgastaria moltíssima feina, reiniciaria les transicions CSS a cada tecla i produiria un parpelleig perfectament visible. Amb reconciliar, cada pulsació només elimina les targetes que deixen de coincidir i retorna les que hi tornen a coincidir.

Escrivint «fust», el resum mostra 6 tasques · 5 obertes · 45 de 48 h · 1 vençuda · esforç 124 · mostrant-ne 1: els números del model no canvien, només el compte del que es mostra. És exactament la separació que buscàvem.

Exercici 2

<label for="ordre">Ordenar per</label>
<select id="ordre">
  <option value="prioritat">Prioritat</option>
  <option value="data">Data límit</option>
  <option value="hores">Hores estimades</option>
</select>
$('#ordre').addEventListener('change', (esdeveniment) => {
  vista.actualitzar({ ordre: esdeveniment.target.value });
});

Comprovació que els nodes es mouen i no es recreen:

// A la consola, abans de reordenar:
document.querySelector('[data-id="6"]').dataset.marca = 'testimoni';

// Canvia el <select> a "Data límit" i comprova:
document.querySelector('[data-id="6"]').dataset.marca;   // 'testimoni' ✓ és el MATEIX node

Si la marca sobreviu, és que reconciliar va reutilitzar el node i només el va recol·locar amb insertBefore. Amb replaceChildren la marca hauria desaparegut, perquè el <li> seria un altre objecte diferent. Aquest truc de l'atribut testimoni és, per cert, una tècnica de depuració excel·lent per verificar qualsevol estratègia de reutilització de nodes.

Nota: change és l'esdeveniment adequat per a un <select>; input també funciona, però change expressa millor la intenció de «ha triat una opció».

Exercici 3

// model/tauler.js — el mètode nou, al MODEL
eliminar(id) {
  const index = this.#tasques.findIndex((t) => t.id === id);
  if (index === -1) throw new ErrorDeValidacio(`No existeix la tasca ${id}.`, 'id', id);
  return this.#tasques.splice(index, 1)[0];
}
// app.js — dins del gestor delegat
if (boto.dataset.accio === 'eliminar') {
  const li = boto.closest('li[data-id]');
  const seguent = li.nextElementSibling ?? li.previousElementSibling;
  const accio = boto.dataset.accio;

  tauler.eliminar(id);
  vista.render();

  // El node 'seguent' sobreviu al render gràcies a la reconciliació
  (seguent?.querySelector(`[data-accio="${accio}"]`) ?? $('#cercar')).focus();
  return;
}

Si reconciliar identifiqués els nodes per posició, en eliminar la tasca del mig passaria el següent: el node que ocupava la posició 2 s'ompliria amb les dades de la tasca que abans era a la 3, el de la 3 amb els de la 4, i així fins al final; l'últim node s'eliminaria. Visualment el resultat seria correcte, però tots els nodes haurien canviat de dada: el focus, que era a la targeta de la posició 3, acabaria en una targeta que ara mostra una altra tasca; qualsevol transició CSS en curs s'aplicaria a l'element equivocat; i qualsevol estat del navegador lligat a un node (un <details> desplegat, per exemple) s'atribuiria a la tasca que no era.

Amb data-id com a clau, el node de cada tasca continua sent seu passi el que passi: només s'elimina el node la clau del qual desapareix de les dades. És exactament per això que React, Vue i Angular insisteixen tant en la prop key de les llistes, i veure-ho des de dins t'estalviarà molt de temps quan arribis al Mòdul 10.

Conclusió

Has passat de manipular nodes solts a tenir una arquitectura. L'eix és la funció render(estat), que descriu com ha de veure's la pantalla donat un estat, i el cicle estat → render → esdeveniment → estat nou → render, amb les seves regles: un sol sentit, els gestors no toquen el DOM (canvien l'estat i criden render), i el model continua sense saber que existeix una pantalla. L'avantatge no és escriure menys, sinó que la interfície no es pot desincronitzar de les dades, que és la classe d'error més difícil de perseguir en una aplicació imperativa.

Coneixes les tres maneres de produir HTML i el criteri per triar. Els template literals són els més llegibles i els més perillosos: exigeixen escapar cada dada amb una funció escaparHtml que tradueixi & < > " ', i n'hi ha prou amb un oblit per obrir un XSS. La construcció amb nodes és segura per definició. I <template> amb content.cloneNode(true) combina la llegibilitat de tenir l'estructura a l'HTML amb la seguretat d'omplir mitjançant textContent, que és l'opció adoptada al projecte. Sobre ella has escrit pintarTargeta(tasca, existent), una funció que crea o actualitza segons li passis o no un node previ: la propietat que fa possible tota la resta.

Saps agrupar el tauler per columnes amb Object.groupBy, amb el recordatori que només crea les claus presents i que cal ?? []; dibuixar comptadors per columna (3 tasques i 25 h pendents, 2 i 20 h en curs, 1 i 3 h fetes, que sumen les 45 h obertes de sempre); i tenir cura dels estats buits amb missatges diferents segons sigui una columna buida, un filtre sense resultats o un tauler acabat d'estrenar. I, sobretot, saps què passa quan es redibuixa tot amb replaceChildren: es perden el focus, el desplaçament, la selecció i l'estat del navegador, una fallada d'accessibilitat que qui fa servir ratolí no percep mai. La resposta són les claus estables: reconciliar(contenidor, dades, clau, pintar) indexa els nodes existents en un Map pel seu data-id, reutilitza els que continuen vius actualitzant-ne el contingut, els recol·loca amb insertBefore i elimina els sobrants. Els nodes supervivents conserven la seva identitat, i amb ella el focus i les transicions. Filtrar i ordenar, finalment, són decisions de la vista: viuen al seu estat, operen sobre la còpia que retorna tauler.tasques i no alteren mai el model.

Aquell reconciliar de vint línies és també una lliçó sobre l'ofici: fer-ho bé per a una llista plana amb clau és assequible; fer-ho per a arbres imbricats de components, amb ordre arbitrari i actualitzacions parcials, és la feina que fan React, Vue i Angular. Arribaràs al Mòdul 10 sabent exactament quin problema resolen i per què tots et demanen una key.

Queda una última peça, i és la que converteix el tauler en una eina de debò. La Marta pot moure tasques i filtrar-les, però no en pot crear cap: el backlog continua sent el de dades/backlog.js. Donar d'alta una tasca significa un formulari, i un formulari porta un món propi: camps accessibles amb les seves etiquetes, valors que arriben sempre com a text, validació nativa del navegador davant de validació en JavaScript, regles de negoci que han de continuar vivint al model, missatges d'error que un lector de pantalla pugui anunciar, i el focus posat on toca quan alguna cosa falla. Tot això és Gestió i Validació de Formularis, l'última lliçó del mòdul.

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