Ya sabes fabricar el <li> de una tarea. Lo que todavía no tienes es una forma de trabajar: ahora mismo pintas una vez al arrancar y después vas parcheando cada elemento a mano dentro del manejador de clic. Eso funciona con tres acciones y se vuelve inmanejable con quince, porque cada acción tiene que acordarse de actualizar el <li>, el resumen, el contador de horas, el panel de carga y lo que venga después. La alternativa es un cambio de mentalidad: en lugar de describir qué hay que modificar, se escribe una función que describe cómo debe verse la pantalla dado un estado, y se vuelve a llamar cada vez que el estado cambia. En esta lección construirás ese ciclo, aprenderás a agrupar el tablero por columnas, a escapar datos si usas plantillas de texto, y a resolver el problema que aparece en cuanto redibujas todo: que el foco, el desplazamiento y lo que el usuario estaba escribiendo se pierden por el camino.

Contenido

  1. Del nodo suelto a la lista completa: render(estado)
  2. El ciclo estado → render → evento → nuevo estado → render
  3. Plantillas con template literals y escaparHtml
  4. Tres formas de producir HTML, comparadas
  5. <template> para las tarjetas
  6. Agrupar el tablero por estado con Object.groupBy
  7. Estados vacíos, contadores y resumen
  8. El problema de redibujar todo
  9. Actualización parcial: claves y reutilización de nodos
  10. reconciliar(lista, datos)
  11. Ordenar y filtrar en la vista sin tocar el modelo
  12. Nómada Tareas: vista/tablero-vista.js
  13. Errores Comunes y Consejos
  14. Ejercicios
  15. Conclusión

  1. Del nodo suelto a la lista completa: render(estado)

Compara las dos formas de pensar:

// ✗ Imperativa: describir los cambios, uno a uno
boton.addEventListener('click', () => {
  tablero.cambiarEstado(id, 'hecha');
  li.classList.add('tarea--hecha');
  li.querySelector('.tarea__meta').textContent = '…';
  boton.disabled = true;
  resumen.textContent = '…';
  contadorHoras.textContent = '…';
  // … y todo lo que alguien añada mañana
});

// ✓ Declarativa: describir el resultado, y volver a dibujarlo
boton.addEventListener('click', () => {
  tablero.cambiarEstado(id, 'hecha');
  render();                     // una sola línea, siempre la misma
});

La función render toma el estado y produce la pantalla. Nada más. No sabe qué ha cambiado ni le importa: dibuja lo que hay ahora.

/** Estado de la vista: el modelo + las preferencias de visualización. */
const estado = {
  tablero,                                  // el Tablero del modelo
  filtros: { responsable: null, estado: null },
  orden: 'prioridad',
  hoy: HOY
};

function render() {
  const visibles = aplicarFiltros(estado);
  lista.replaceChildren(...visibles.map((t) => crearTarjeta(t, estado.hoy)));
  pintarResumen(resumen, estado.tablero, estado.hoy);
}

La ventaja no es escribir menos código: es que la pantalla no puede desincronizarse del estado. En la versión imperativa siempre acaba habiendo un caso en que alguien olvida actualizar una de las cinco cosas y la interfaz muestra una mentira. En la declarativa, eso es imposible por construcción.

  1. El ciclo estado → render → evento → nuevo estado → render

Esta es la arquitectura de casi cualquier aplicación de interfaz moderna, y ahora la vas a escribir a mano:

flowchart LR
    E["ESTADO<br/>Tablero + filtros + orden"] --> R["render(estado)<br/>dibuja la pantalla"]
    R --> P["PANTALLA<br/>lista, resumen, contadores"]
    P --> V["El usuario actúa<br/>clic, tecla, envío"]
    V --> M["Manejador delegado<br/>llama al MODELO"]
    M --> N["NUEVO ESTADO<br/>tablero.cambiarEstado(...)"]
    N --> R

Las reglas del ciclo, que conviene respetar con disciplina:

  • Un único sentido. El estado alimenta a la pantalla; la pantalla nunca es la fuente de verdad. Si necesitas saber qué tareas están hechas, preguntas al Tablero, no cuentas elementos con clase tarea--hecha.
  • Los manejadores no tocan el DOM. Modifican el estado (a través del modelo) y llaman a render(). Uno solo.
  • render es (casi) pura. Dado el mismo estado, produce la misma pantalla. Su único efecto secundario es escribir en el DOM.
  • El modelo sigue sin saber que existe una pantalla. Tarea y Tablero no cambian ni una línea en esta lección.

  1. Plantillas con template literals y escaparHtml

Hay una forma de construir HTML que resulta muy legible: los template literals de 01-05, con la estructura escrita tal cual y los datos interpolados.

function plantillaTarea(tarea) {
  return `
    <li class="tarea tarea--${tarea.prioridad}" data-id="${tarea.id}">
      <span class="tarea__titulo">${tarea.titulo}</span>
      <span class="tarea__meta">${tarea.responsable} · ${tarea.horasEstimadas} h</span>
      <button type="button" data-accion="avanzar">Empezar</button>
    </li>`;
}

lista.innerHTML = tablero.tareas.map(plantillaTarea).join('');

Se lee de maravilla. Y tal como está escrito, es una vulnerabilidad. En 06-07 Marta podrá escribir el título de una tarea; si escribe <img src=x onerror="…">, ese código se ejecutará en tu página.

La única forma de usar este enfoque con seguridad es escapar todos los datos interpolados, sin excepción:

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

/** Convierte los caracteres con significado en HTML en sus entidades. */
export function escaparHtml(valor) {
  return String(valor).replace(/[&<>"']/g, (caracter) => ESCAPES[caracter]);
}
console.log(escaparHtml('<img src=x onerror="robar()">'));
// '&lt;img src=x onerror=&quot;robar()&quot;&gt;'   ← se verá como texto, no se ejecutará

console.log(escaparHtml('Tintas & pantallas'));
// 'Tintas &amp; pantallas'

Detalles que hacen que esta función sea correcta y no una falsa sensación de seguridad:

  • & se escapa el primero en el orden de la expresión regular, porque si se escapara después convertiría los &lt; ya generados en &amp;lt;. Usar replace con una función de reemplazo evita el problema de raíz, porque cada carácter se procesa una sola vez.
  • Se escapan también las comillas. Sin ellas, un dato interpolado dentro de un atributo (data-id="${valor}") podría cerrar el atributo e inyectar otros. Es un vector real.
  • String(valor) protege de null, undefined y números.

Y una advertencia que no hay que olvidar: escapar sirve para contenido de texto y atributos entrecomillados. No es suficiente si interpolas dentro de un <script>, dentro de un style, o en un atributo href/src (donde un javascript: sigue siendo peligroso). Para esos casos, no interpoles: usa nodos.

Con la función, la plantilla queda así:

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

function plantillaTarea(tarea) {
  return `
    <li class="tarea tarea--${esc(tarea.prioridad)}" data-id="${esc(tarea.id)}">
      <span class="tarea__titulo">${esc(tarea.titulo)}</span>
      <span class="tarea__meta">${esc(tarea.responsable ?? 'sin asignar')} · ${esc(tarea.horasEstimadas)} h</span>
      <button type="button" data-accion="avanzar">Empezar</button>
    </li>`;
}

Funciona, es seguro… y depende de que nadie olvide nunca un esc(). Un solo despiste reabre el agujero. Esa fragilidad es el argumento decisivo contra este enfoque, y la razón de que las alternativas del apartado siguiente sean preferibles.

  1. Tres formas de producir HTML, comparadas

Criterio Template literal + innerHTML createElement / crearElemento <template> + cloneNode
Legibilidad de la estructura Muy alta Media Muy alta (está en el HTML)
Seguridad Depende de escapar siempre Segura por construcción Segura (se rellena con textContent)
Referencias a los nodos creados Hay que volver a buscarlos Ya las tienes Se buscan dentro del clon
Conserva nodos, foco y estado No: destruye y recrea Sí, si actualizas en lugar de recrear Sí, igual
Coste Analizar HTML en cada render Llamadas a métodos Clonar (muy barato)
Estructuras condicionales Fácil con ternarios Fácil con filter(Boolean) Incómodo: hay que ocultar partes
Riesgo de error humano Alto (olvidar un esc) Bajo Bajo

Conclusión operativa para Nómada Tareas: <template> para la estructura fija de la tarjeta, crearElemento para lo dinámico, y los template literals solo para marcado constante sin datos. Es el equilibrio entre legibilidad y seguridad, y elimina la clase entera de errores de escapado.

  1. <template> para las tarjetas

Retomamos la plantilla de 06-05 y le damos su forma definitiva, con las columnas incluidas:

<template id="plantilla-tarea">
  <li class="tarea" tabindex="-1">
    <span class="tarea__titulo"></span>
    <span class="tarea__meta"></span>
    <ul class="tarea__etiquetas"></ul>
    <button type="button" class="tarea__accion" data-accion="avanzar"></button>
    <button type="button" class="tarea__accion" data-accion="reabrir">Reabrir</button>
  </li>
</template>

<template id="plantilla-columna">
  <section class="columna" aria-labelledby="">
    <h3 class="columna__titulo"></h3>
    <p class="columna__contador"></p>
    <ul class="lista-tareas"></ul>
    <p class="columna__vacia" hidden>No hay tareas en esta columna.</p>
  </section>
</template>

Y la función que la rellena. Fíjate en que ahora actualiza un <li> existente si se lo pasas, y solo clona si no hay ninguno: esa doble capacidad es la que hará posible la reconciliación del apartado 10.

// js/vista/tarjeta.js
import { $ } from './dom.js';
import { HOY } from '../util/fechas.js';
import { marcaDeEstado } from '../util/formato.js';

const CLASES_PRIORIDAD = ['tarea--alta', 'tarea--media', 'tarea--baja'];
const SIGUIENTE = Object.freeze({ pendiente: 'en-curso', 'en-curso': 'hecha', hecha: null });
const ETIQUETA  = Object.freeze({
  pendiente: 'Empezar', 'en-curso': 'Marcar hecha', hecha: 'Completada'
});

/**
 * Crea o ACTUALIZA el <li> de una tarea.
 * @param {Tarea} tarea
 * @param {HTMLElement|null} existente  si se pasa, se reutiliza en lugar de clonar
 */
export function pintarTarjeta(tarea, existente = null, hoy = HOY) {
  const li = existente ?? $('#plantilla-tarea').content.firstElementChild.cloneNode(true);
  const vencida = tarea.estaVencida(hoy);

  li.dataset.id = tarea.id;
  li.dataset.estado = tarea.estado;
  li.dataset.responsable = tarea.responsable ?? '';
  li.dataset.prioridad = tarea.prioridad;

  li.classList.remove(...CLASES_PRIORIDAD);
  li.classList.add(`tarea--${tarea.prioridad}`);
  li.classList.toggle('tarea--hecha', tarea.estado === 'hecha');
  li.classList.toggle('tarea--vencida', vencida);

  $('.tarea__titulo', li).textContent = tarea.titulo;
  $('.tarea__meta', li).textContent =
    `${marcaDeEstado(tarea.estado)} ${tarea.responsable ?? 'sin asignar'} · ` +
    `${tarea.horasEstimadas} h` + (vencida ? ` · vencida hace ${Math.abs(tarea.diasRestantes)} d` : '');

  const etiquetas = $('.tarea__etiquetas', li);
  etiquetas.replaceChildren(...tarea.etiquetas.map((texto) => {
    const item = document.createElement('li');
    item.className = 'etiqueta';
    item.textContent = texto;
    return item;
  }));

  const avanzar = $('[data-accion="avanzar"]', li);
  avanzar.textContent = ETIQUETA[tarea.estado];
  avanzar.disabled = SIGUIENTE[tarea.estado] === null;
  avanzar.setAttribute('aria-label', `${ETIQUETA[tarea.estado]}: ${tarea.titulo}`);

  const reabrir = $('[data-accion="reabrir"]', li);
  reabrir.disabled = tarea.estado !== 'en-curso';
  reabrir.setAttribute('aria-label', `Devolver a pendiente: ${tarea.titulo}`);

  return li;
}

Una sola función sirve para crear y para actualizar. Eso es exactamente lo que necesita un render que se ejecuta muchas veces.

  1. Agrupar el tablero por estado con Object.groupBy

El tablero de Taller Nómada tiene tres columnas: pendiente, en curso y hecha. Agrupar es una línea, con lo que aprendiste en 04-05:

const COLUMNAS = [
  { estado: 'pendiente', titulo: 'Pendientes' },
  { estado: 'en-curso',  titulo: 'En curso' },
  { estado: 'hecha',     titulo: 'Hechas' }
];

const porEstado = Object.groupBy(tablero.tareas, (t) => t.estado);
console.log(Object.keys(porEstado));                // ['en-curso', 'pendiente', 'hecha']
console.log(porEstado.pendiente.length);            // 3  ← ids 2, 3 y 6
console.log(porEstado['en-curso'].map((t) => t.id)); // [1, 5]
console.log(porEstado.hecha.map((t) => t.id));      // [4]

Dos avisos sobre Object.groupBy:

  • Solo crea las claves que aparecen. Si ninguna tarea está hecha, porEstado.hecha es undefined, no un array vacío. Usa ?? [] siempre.
  • El objeto que devuelve tiene el prototipo null, así que no tiene hasOwnProperty ni toString. Para consultar claves, Object.hasOwn(porEstado, 'hecha') o directamente el ??.
const pendientes = porEstado.pendiente ?? [];       // ✓ nunca undefined

Con eso, dibujar las tres columnas es directo:

function renderColumnas(contenedor, tareas, hoy) {
  const porEstado = Object.groupBy(tareas, (t) => t.estado);

  contenedor.replaceChildren(...COLUMNAS.map(({ estado, titulo }) => {
    const columna = $('#plantilla-columna').content.firstElementChild.cloneNode(true);
    const delEstado = porEstado[estado] ?? [];

    columna.dataset.estado = estado;
    $('.columna__titulo', columna).textContent = titulo;
    $('.columna__titulo', columna).id = `columna-${estado}`;
    columna.setAttribute('aria-labelledby', `columna-${estado}`);

    const horas = delEstado.reduce((suma, t) => suma + t.horasEstimadas, 0);
    $('.columna__contador', columna).textContent =
      `${delEstado.length} tarea${delEstado.length === 1 ? '' : 's'} · ${horas} h`;

    $('.lista-tareas', columna).replaceChildren(...delEstado.map((t) => pintarTarjeta(t, null, hoy)));
    $('.columna__vacia', columna).hidden = delEstado.length > 0;

    return columna;
  }));
}

Con el backlog canónico, el resultado es:

Pendientes · 3 tareas · 25 h     (Cartelería 6, Web de reservas 14, Carpintería 5)
En curso   · 2 tareas · 20 h     (Sala polivalente 12, Guía de encuadernación 8)
Hechas     · 1 tarea  · 3 h      (Inventario de tintas)

Y 25 + 20 = 45 h abiertas, con 48 h totales. Los números canónicos, ahora repartidos en columnas.

El CSS mínimo para que se vean como tal:

.tablero { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.columna { border: 1px solid var(--borde); border-radius: 0.5rem; padding: 0.75rem; }
.columna__titulo { margin: 0 0 0.25rem; font-size: 1rem; }
.columna__contador { margin: 0 0 0.75rem; color: var(--gris); font-size: 0.85rem; }
.columna__vacia { color: var(--gris); font-style: italic; }
.tarea__etiquetas { 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. Estados vacíos, contadores y resumen

Un detalle que separa una interfaz acabada de un prototipo: qué se ve cuando no hay nada. Una columna vacía sin mensaje parece un error de carga.

Ya lo has resuelto arriba con $('.columna__vacia', columna).hidden = delEstado.length > 0, pero conviene distinguir tres situaciones distintas, porque el mensaje adecuado no es el mismo:

Situación Mensaje adecuado
No hay tareas en esta columna «No hay tareas en esta columna.»
Hay tareas, pero el filtro las oculta todas «Ninguna tarea coincide con el filtro. Quitar filtros»
El tablero está completamente vacío «Aún no hay tareas. Crea la primera con el formulario.»
function mensajeVacio(estado, hayTareasEnTotal, hayFiltroActivo) {
  if (!hayTareasEnTotal) return 'Aún no hay tareas. Crea la primera con el formulario.';
  if (hayFiltroActivo) return 'Ninguna tarea coincide con el filtro actual.';
  return 'No hay tareas en esta columna.';
}

Y el resumen global, que reutiliza el método del modelo:

function renderResumen(elemento, tablero, hoy) {
  const { total, abiertas, horasTotales, horasAbiertas, vencidas, esfuerzo } = tablero.resumen(hoy);
  elemento.textContent =
    `${total} tareas · ${abiertas} abiertas · ${horasAbiertas} de ${horasTotales} h · ` +
    `${vencidas} vencida${vencidas === 1 ? '' : 's'} · esfuerzo ${esfuerzo}`;
}
// 6 tareas · 5 abiertas · 45 de 48 h · 1 vencida · esfuerzo 124

Ese elemento lleva role="status" en el HTML (06-01), de modo que un lector de pantalla anuncia el nuevo resumen cada vez que cambia, sin robar el foco.

  1. El problema de redibujar todo

Y ahora la mala noticia. replaceChildren(...) destruye todos los nodos y crea otros. Eso tiene cuatro consecuencias visibles:

  1. Se pierde el foco. Si Marta había llegado con el tabulador al botón «Marcar hecha» de la tercera tarea y pulsa Enter, el render la deja con el foco en document.body. Su siguiente Tab la lleva al principio de la página.
  2. Se pierde el desplazamiento dentro de contenedores con barra propia.
  3. Se pierde el estado del navegador: texto seleccionado, <details> abiertos, la posición del cursor dentro de un <input> que estuviera en la lista.
  4. Se reinician las transiciones CSS, porque los elementos son nuevos.
render();
console.log(document.activeElement.tagName);   // 'BODY'  ✗ el foco se ha ido

Hay dos estrategias para resolverlo.

Estrategia A: salvar y restaurar. Antes de redibujar, anota qué estaba enfocado; después, vuelve a buscarlo por su clave estable y devuélvele el foco.

function conFocoConservado(render) {
  const activo = document.activeElement;
  const idTarea = activo?.closest?.('[data-id]')?.dataset.id ?? null;
  const accion  = activo?.dataset?.accion ?? null;
  const scroll  = contenedor.scrollTop;

  render();

  contenedor.scrollTop = scroll;
  if (idTarea !== null) {
    const destino = $(`[data-id="${idTarea}"] [data-accion="${accion}"]`) ??
                    $(`[data-id="${idTarea}"]`);
    if (destino !== null && !destino.disabled) destino.focus();
  }
}

Funciona y es sencilla, pero es un parche: cuantas más cosas haya que conservar, más frágil se vuelve.

Estrategia B: no destruir lo que no ha cambiado. Es la buena, y es lo que hacen los frameworks. En lugar de vaciar y recrear, se compara la lista de nodos actuales con los datos nuevos y se reutiliza cada nodo que siga siendo válido, actualizando solo su contenido.

  1. Actualización parcial: claves y reutilización de nodos

Para reutilizar un nodo hay que poder identificarlo. La posición no vale: si la primera tarea desaparece, todas las demás se desplazan y el nodo de la segunda pasaría a representar a la tercera, con el foco y las animaciones en el sitio equivocado.

Lo que vale es una clave estable: un identificador que pertenezca al dato, no a su posición. En Nómada Tareas ya la tienes desde 06-02: data-id.

flowchart TD
    D["Datos nuevos<br/>ids: 3, 6, 1"] --> C{"¿Existe ya un nodo<br/>con ese data-id?"}
    C -- "Sí" --> A["Actualizar su contenido<br/>(conserva foco y estado)"]
    C -- "No" --> N["Crear la tarjeta nueva"]
    A --> O["Colocar en el orden correcto"]
    N --> O
    O --> S["Eliminar los nodos sobrantes<br/>(ids que ya no están)"]

  1. reconciliar(lista, datos)

Aquí está la implementación, y es más corta de lo que parece:

// js/vista/dom.js

/**
 * Sincroniza los hijos de un contenedor con una lista de datos, reutilizando
 * los nodos que ya existen. Cada dato debe tener una clave estable.
 *
 * @param {HTMLElement} contenedor
 * @param {Array} datos
 * @param {(dato) => string|number} clave   identificador estable del dato
 * @param {(dato, nodoExistente|null) => HTMLElement} pintar  crea o actualiza el nodo
 */
export function reconciliar(contenedor, datos, clave, pintar) {
  // 1 · Indexamos los nodos actuales por su clave (el reduce de 04-05)
  const existentes = new Map(
    [...contenedor.children].map((nodo) => [nodo.dataset.clave ?? nodo.dataset.id, nodo])
  );

  // 2 · Recorremos los datos en su orden final
  let referencia = contenedor.firstElementChild;

  for (const dato of datos) {
    const k = String(clave(dato));
    const previo = existentes.get(k) ?? null;
    const nodo = pintar(dato, previo);         // reutiliza si previo !== null
    existentes.delete(k);                       // marcado como "sigue vivo"

    if (nodo !== referencia) {
      contenedor.insertBefore(nodo, referencia); // mueve o inserta en el sitio correcto
    } else {
      referencia = referencia.nextElementSibling; // ya estaba bien colocado
    }
  }

  // 3 · Lo que quede en el mapa ya no está en los datos: fuera
  for (const sobrante of existentes.values()) sobrante.remove();
}

Explicación línea a línea de lo que hace y por qué:

  • Paso 1: construye un Map de clave → nodo con lo que hay en pantalla. Es exactamente el patrón de indexar por id de 04-05, aplicado a nodos en lugar de a objetos.
  • Paso 2: recorre los datos en el orden en que deben quedar. Para cada uno, si ya existía un nodo con esa clave lo reutiliza (pintar(dato, previo) lo actualiza en lugar de clonar), y si no, crea uno nuevo. Después lo coloca en su posición con insertBefore, que —recuerda 06-05— mueve el nodo si ya estaba en el árbol.
  • Paso 3: lo que queda en el Map son nodos cuya clave ya no aparece en los datos. Se eliminan.

El resultado: los nodos de las tareas que siguen ahí nunca se destruyen. Conservan su foco, sus transiciones y cualquier estado del navegador.

// Uso, con la misma pintarTarjeta que sirve para crear y actualizar
reconciliar(
  lista,
  visibles,
  (tarea) => tarea.id,
  (tarea, nodo) => pintarTarjeta(tarea, nodo, estado.hoy)
);

Compruébalo: pon el foco en el botón «Empezar» de la tercera tarea, pulsa Enter y observa que el foco sigue ahí después del render, con el botón ya cambiado a «Marcar hecha». Con replaceChildren habría desaparecido.

Los límites de esta implementación. Es deliberadamente sencilla y no es un algoritmo de reconciliación completo: no detecta movimientos de forma óptima (puede mover más nodos de los estrictamente necesarios) y solo funciona con hijos directos que tengan clave. Para una lista de tareas es más que suficiente. Para una interfaz grande, con árboles anidados y componentes, hacer esto a mano se vuelve inviable, y ahí es donde entran los frameworks: React, Vue y Angular resuelven este problema exacto —y por eso todos ellos te piden una key en las listas, que es literalmente este data-id—. Lo verás en Por Qué Existen los Frameworks, y llegarás a esa lección sabiendo qué problema resuelven, que es la única forma de entenderlos de verdad.

  1. Ordenar y filtrar en la vista sin tocar el modelo

Filtrar y ordenar son decisiones de presentación. El Tablero no debe enterarse: si Lucía filtra por su nombre, el tablero sigue teniendo seis tareas y 45 h abiertas; lo único que cambia es lo que se muestra.

// js/vista/tablero-vista.js
const ORDENES = Object.freeze({
  prioridad: (a, b) => PESOS[b.prioridad] - PESOS[a.prioridad] || a.id - b.id,
  fecha:     (a, b) => a.fechaLimite.localeCompare(b.fechaLimite),
  horas:     (a, b) => b.horasEstimadas - a.horasEstimadas,
  titulo:    (a, b) => a.titulo.localeCompare(b.titulo, 'es')
});

/** Aplica filtros y orden SIN modificar el tablero. Devuelve un array nuevo. */
function tareasVisibles(estado) {
  const { responsable, texto } = estado.filtros;

  return estado.tablero.tareas                       // ← copia defensiva del modelo (05-03)
    .filter((t) => responsable === null || t.responsable === responsable)
    .filter((t) => texto === '' || t.titulo.toLowerCase().includes(texto.toLowerCase()))
    .sort(ORDENES[estado.orden] ?? ORDENES.prioridad);
}

Tres puntos importantes:

  • tablero.tareas ya devuelve una copia ([...this.#tareas], encapsulación de 05-03), así que el .sort(), que muta, no toca el array interno del modelo. Si el getter devolviera el array real, este sort reordenaría el modelo: un fallo sutil y desagradable.
  • La comparadora prioridad usa || como desempate: si dos tareas tienen la misma prioridad, se ordenan por id. Sin desempate, el orden entre iguales dependería del algoritmo y podría cambiar entre renders, produciendo saltos visuales.
  • localeCompare(texto, 'es') ordena correctamente los acentos y la ñ, cosa que el < de las cadenas no hace.

  1. Nómada Tareas: vista/tablero-vista.js

El módulo completo, que junta todo lo anterior:

// js/vista/tablero-vista.js
import { $, $$, reconciliar } from './dom.js';
import { pintarTarjeta } from './tarjeta.js';
import { PESOS } from '../util/formato.js';
import { HOY } from '../util/fechas.js';
import { EVENTOS, emitir } from './eventos.js';

const COLUMNAS = [
  { estado: 'pendiente', titulo: 'Pendientes' },
  { estado: 'en-curso',  titulo: 'En curso' },
  { estado: 'hecha',     titulo: 'Hechas' }
];

const ORDENES = Object.freeze({
  prioridad: (a, b) => PESOS[b.prioridad] - PESOS[a.prioridad] || a.id - b.id,
  fecha:     (a, b) => a.fechaLimite.localeCompare(b.fechaLimite),
  horas:     (a, b) => b.horasEstimadas - a.horasEstimadas
});

export class TableroVista {
  #contenedor;
  #resumen;
  #estado;

  constructor({ contenedor, resumen, tablero, hoy = HOY }) {
    this.#contenedor = contenedor;
    this.#resumen = resumen;
    this.#estado = {
      tablero,
      hoy,
      filtros: { responsable: null, texto: '' },
      orden: 'prioridad'
    };
    this.#prepararColumnas();
  }

  /** Las columnas se crean UNA vez; después solo se reconcilian sus listas. */
  #prepararColumnas() {
    this.#contenedor.replaceChildren(...COLUMNAS.map(({ estado, titulo }) => {
      const columna = $('#plantilla-columna').content.firstElementChild.cloneNode(true);
      columna.dataset.estado = estado;
      const encabezado = $('.columna__titulo', columna);
      encabezado.textContent = titulo;
      encabezado.id = `columna-${estado}`;
      columna.setAttribute('aria-labelledby', `columna-${estado}`);
      $('.lista-tareas', columna).setAttribute('aria-live', 'polite');
      return columna;
    }));
  }

  /** Cambia una parte del estado de la vista y redibuja. */
  actualizar(cambios) {
    Object.assign(this.#estado, cambios);
    if (cambios.filtros) Object.assign(this.#estado.filtros, cambios.filtros);
    this.render();
  }

  #visibles() {
    const { responsable, texto } = this.#estado.filtros;
    return this.#estado.tablero.tareas
      .filter((t) => responsable === null || t.responsable === responsable)
      .filter((t) => texto === '' || t.titulo.toLowerCase().includes(texto.toLowerCase()))
      .sort(ORDENES[this.#estado.orden] ?? ORDENES.prioridad);
  }

  render() {
    const visibles = this.#visibles();
    const porEstado = Object.groupBy(visibles, (t) => t.estado);
    const hayFiltro = this.#estado.filtros.responsable !== null || this.#estado.filtros.texto !== '';

    for (const { estado } of COLUMNAS) {
      const columna = $(`.columna[data-estado="${estado}"]`, this.#contenedor);
      const delEstado = porEstado[estado] ?? [];
      const horas = delEstado.reduce((suma, t) => suma + t.horasEstimadas, 0);

      $('.columna__contador', columna).textContent =
        `${delEstado.length} tarea${delEstado.length === 1 ? '' : 's'} · ${horas} h`;

      // Reconciliación: los nodos que siguen vivos NO se destruyen
      reconciliar(
        $('.lista-tareas', columna),
        delEstado,
        (tarea) => tarea.id,
        (tarea, nodo) => pintarTarjeta(tarea, nodo, this.#estado.hoy)
      );

      const vacia = $('.columna__vacia', columna);
      vacia.hidden = delEstado.length > 0;
      vacia.textContent = hayFiltro
        ? 'Ninguna tarea coincide con el filtro actual.'
        : 'No hay tareas en esta columna.';
    }

    const r = this.#estado.tablero.resumen(this.#estado.hoy);
    this.#resumen.textContent =
      `${r.total} tareas · ${r.abiertas} abiertas · ${r.horasAbiertas} de ${r.horasTotales} h · ` +
      `${r.vencidas} vencida${r.vencidas === 1 ? '' : 's'} · esfuerzo ${r.esfuerzo}` +
      (hayFiltro ? ` · mostrando ${visibles.length}` : '');

    emitir(this.#contenedor, EVENTOS.TABLERO_ACTUALIZADO, { ...r, mostradas: visibles.length });
  }
}

Y el punto de entrada, que ya es puro cableado:

// js/app.js
import { Tablero } from './modelo/tablero.js';
import { crearBacklog } from './datos/backlog.js';
import { HOY } from './util/fechas.js';
import { TableroVista } from './vista/tablero-vista.js';
import { $ } from './vista/dom.js';

const tablero = new Tablero('Taller Nómada', crearBacklog());
const vista = new TableroVista({
  contenedor: $('.tablero'),
  resumen: $('#resumen'),
  tablero,
  hoy: HOY
});
vista.render();

// Un único manejador delegado para TODO el tablero (06-04)
$('.tablero').addEventListener('click', (evento) => {
  const boton = evento.target.closest('button[data-accion]');
  if (boton === null) return;

  const id = Number(boton.closest('[data-id]').dataset.id);
  const tarea = tablero.buscarPorId(id);
  const destino = boton.dataset.accion === 'avanzar'
    ? { pendiente: 'en-curso', 'en-curso': 'hecha', hecha: null }[tarea.estado]
    : (tarea.estado === 'en-curso' ? 'pendiente' : null);
  if (destino === null) return;

  tablero.cambiarEstado(id, destino);   // 1 · cambia el ESTADO
  vista.render();                       // 2 · redibuja
});

// Los filtros solo cambian el estado de la vista; el modelo ni se entera
$('.filtros').addEventListener('click', (evento) => {
  const boton = evento.target.closest('.filtro');
  if (boton === null) return;
  $$('.filtro').forEach((b) => b.setAttribute('aria-pressed', String(b === boton)));
  vista.actualizar({ filtros: { responsable: boton.dataset.responsable || null } });
});

Todo el ciclo del apartado 2, en veinte líneas: el manejador delegado toca el modelo y llama a render(); render() dibuja lo que hay. Al marcar la carpintería como hecha, la tarjeta se mueve de la columna «Pendientes» a «Hechas» sin destruirse, los contadores pasan de 3 tareas · 25 h a 2 tareas · 20 h y de 1 tarea · 3 h a 2 tareas · 8 h, el resumen baja de 45 a 40 h abiertas y de 1 vencida a 0, y el foco sigue exactamente donde estaba.

Errores Comunes y Consejos

  • Interpolar datos sin escapar en una plantilla de texto. Basta un ${} olvidado para abrir un XSS. Si eliges este enfoque, escapa siempre; si puedes, usa <template> o nodos y elimina la clase entera de errores.
  • Usar el índice del array como clave. data-id="${indice}" parece funcionar hasta que se elimina un elemento del medio: entonces todos los nodos representan al dato equivocado. La clave debe pertenecer al dato.
  • Olvidar ?? [] con Object.groupBy. Solo crea las claves que aparecen; una columna sin tareas da undefined y el .map siguiente lanza TypeError.
  • Ordenar el array del modelo. sort muta. Funciona aquí porque tablero.tareas devuelve una copia; si algún getter devolviera el array interno, estarías reordenando el modelo desde la vista. Ante la duda, [...lista].sort(...).
  • Redibujar dentro de un bucle de eventos rápido. Un render() completo por cada pulsación de tecla en un campo de búsqueda es trabajo desperdiciado. La solución es el debounce que verás en 06-07.
  • Poner manejadores dentro de render. Si render registra manejadores, cada llamada añade otro y acabas con la acción ejecutándose cinco veces. Los manejadores se registran una vez, por delegación, fuera del render.
  • Redibujar y perder el foco sin darse cuenta. Es un fallo de accesibilidad serio y silencioso: quien usa ratón no lo nota nunca. Pruébalo siempre con el tabulador.
  • Consejo: mantén render() sin efectos ocultos. Que solo lea el estado y escriba en el DOM. Si hace peticiones, cambia el modelo o dispara acciones, deja de ser predecible y depurar se vuelve muy difícil.
  • Consejo: guarda el estado de la vista en un solo objeto. Tener filtros, orden y hoy en un único #estado hace trivial responder a «¿por qué se ve esto?»: imprimes el objeto y lo sabes.

Ejercicios

Ejercicio 1 · Buscador por título

Añade al HTML un <input type="search" id="buscar"> con su <label> y conéctalo para que al escribir se filtren las tareas por título. Debe usar estado.filtros.texto, no tocar el modelo, y mostrar en el resumen cuántas se están viendo. Comprueba con el tabulador que el foco no se sale del campo de búsqueda al escribir, y explica por qué la reconciliación es imprescindible aquí.

Ejercicio 2 · Ordenación con <select>

Añade un <select id="orden"> con las opciones prioridad, fecha límite y horas, y haz que al cambiarlo se reordenen las tarjetas. Después comprueba, con las DevTools, que al reordenar los nodos se mueven en lugar de recrearse: pon un data-marca en un <li> desde la consola, reordena, y verifica que la marca sigue ahí.

Ejercicio 3 · Reconciliación con eliminación

Añade un botón data-accion="eliminar" a la tarjeta y un método eliminar(id) al Tablero. Comprueba que tras eliminar una tarea del medio, las tarjetas restantes conservan sus nodos (no se recrean) y que el foco pasa al botón equivalente de la tarea siguiente. Explica qué habría ocurrido si reconciliar usara la posición en lugar de data-id.

Soluciones

Ejercicio 1

<label for="buscar">Buscar por título</label>
<input type="search" id="buscar" placeholder="serigrafía, web, carpintería…">
$('#buscar').addEventListener('input', (evento) => {
  vista.actualizar({ filtros: { texto: evento.target.value.trim() } });
});

La reconciliación es imprescindible aquí por un motivo muy concreto: el evento input se dispara en cada pulsación, así que cada letra provoca un render(). Si ese render hiciera replaceChildren, destruiría y recrearía todas las tarjetas veinte veces mientras Marta escribe «serigrafía». No perdería el foco del <input> —está fuera del contenedor reconciliado—, pero sí desperdiciaría muchísimo trabajo, reiniciaría las transiciones CSS en cada tecla y produciría un parpadeo perfectamente visible. Con reconciliar, cada pulsación solo elimina las tarjetas que dejan de coincidir y devuelve las que vuelven a coincidir.

Escribiendo «carpint», el resumen muestra 6 tareas · 5 abiertas · 45 de 48 h · 1 vencida · esfuerzo 124 · mostrando 1: los números del modelo no cambian, solo la cuenta de lo mostrado. Es exactamente la separación que buscábamos.

Ejercicio 2

<label for="orden">Ordenar por</label>
<select id="orden">
  <option value="prioridad">Prioridad</option>
  <option value="fecha">Fecha límite</option>
  <option value="horas">Horas estimadas</option>
</select>
$('#orden').addEventListener('change', (evento) => {
  vista.actualizar({ orden: evento.target.value });
});

Comprobación de que los nodos se mueven y no se recrean:

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

// Cambia el <select> a "Fecha límite" y comprueba:
document.querySelector('[data-id="6"]').dataset.marca;   // 'testigo' ✓ es el MISMO nodo

Si la marca sobrevive, es que reconciliar reutilizó el nodo y solo lo recolocó con insertBefore. Con replaceChildren la marca habría desaparecido, porque el <li> sería otro objeto distinto. Este truco del atributo testigo es, por cierto, una técnica de depuración excelente para verificar cualquier estrategia de reutilización de nodos.

Nota: change es el evento adecuado para un <select>; input también funciona, pero change expresa mejor la intención de «ha elegido una opción».

Ejercicio 3

// modelo/tablero.js — el método nuevo, en el MODELO
eliminar(id) {
  const indice = this.#tareas.findIndex((t) => t.id === id);
  if (indice === -1) throw new ErrorDeValidacion(`No existe la tarea ${id}.`, 'id', id);
  return this.#tareas.splice(indice, 1)[0];
}
// app.js — dentro del manejador delegado
if (boton.dataset.accion === 'eliminar') {
  const li = boton.closest('li[data-id]');
  const siguiente = li.nextElementSibling ?? li.previousElementSibling;
  const accion = boton.dataset.accion;

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

  // El nodo 'siguiente' sobrevive al render gracias a la reconciliación
  (siguiente?.querySelector(`[data-accion="${accion}"]`) ?? $('#buscar')).focus();
  return;
}

Si reconciliar identificara los nodos por posición, al eliminar la tarea del medio ocurriría lo siguiente: el nodo que ocupaba la posición 2 se rellenaría con los datos de la tarea que antes estaba en la 3, el de la 3 con los de la 4, y así hasta el final; el último nodo se eliminaría. Visualmente el resultado sería correcto, pero todos los nodos habrían cambiado de dato: el foco, que estaba en la tarjeta de la posición 3, acabaría en una tarjeta que ahora muestra otra tarea; cualquier transición CSS en curso se aplicaría al elemento equivocado; y cualquier estado del navegador ligado a un nodo (un <details> desplegado, por ejemplo) se atribuiría a la tarea que no era.

Con data-id como clave, el nodo de cada tarea sigue siendo suyo pase lo que pase: solo se elimina el nodo cuya clave desaparece de los datos. Es exactamente por esto que React, Vue y Angular insisten tanto en la prop key de las listas, y verlo desde dentro te va a ahorrar mucho tiempo cuando llegues al Módulo 10.

Conclusión

Has pasado de manipular nodos sueltos a tener una arquitectura. El eje es la función render(estado), que describe cómo debe verse la pantalla dado un estado, y el ciclo estado → render → evento → nuevo estado → render, con sus reglas: un solo sentido, los manejadores no tocan el DOM (cambian el estado y llaman a render), y el modelo sigue sin saber que existe una pantalla. La ventaja no es escribir menos, sino que la interfaz no puede desincronizarse de los datos, que es la clase de error más difícil de perseguir en una aplicación imperativa.

Conoces las tres maneras de producir HTML y el criterio para elegir. Los template literals son los más legibles y los más peligrosos: exigen escapar cada dato con una función escaparHtml que traduzca & < > " ', y basta un olvido para abrir un XSS. La construcción con nodos es segura por definición. Y <template> con content.cloneNode(true) combina la legibilidad de tener la estructura en el HTML con la seguridad de rellenar mediante textContent, que es la opción adoptada en el proyecto. Sobre ella has escrito pintarTarjeta(tarea, existente), una función que crea o actualiza según le pases o no un nodo previo: la propiedad que hace posible todo lo demás.

Sabes agrupar el tablero por columnas con Object.groupBy, con el recordatorio de que solo crea las claves presentes y de que hace falta ?? []; dibujar contadores por columna (3 tareas y 25 h pendientes, 2 y 20 h en curso, 1 y 3 h hechas, que suman las 45 h abiertas de siempre); y cuidar los estados vacíos con mensajes distintos según sea una columna vacía, un filtro sin resultados o un tablero recién estrenado. Y, sobre todo, sabes qué pasa cuando se redibuja todo con replaceChildren: se pierden el foco, el desplazamiento, la selección y el estado del navegador, un fallo de accesibilidad que quien usa ratón no percibe nunca. La respuesta está en las claves estables: reconciliar(contenedor, datos, clave, pintar) indexa los nodos existentes en un Map por su data-id, reutiliza los que siguen vivos actualizando su contenido, los recoloca con insertBefore y elimina los sobrantes. Los nodos supervivientes conservan su identidad, y con ella el foco y las transiciones. Filtrar y ordenar, por último, son decisiones de la vista: viven en su estado, operan sobre la copia que devuelve tablero.tareas y jamás alteran el modelo.

Ese reconciliar de veinte líneas es también una lección sobre el oficio: hacerlo bien para una lista plana con clave es asequible; hacerlo para árboles anidados de componentes, con orden arbitrario y actualizaciones parciales, es el trabajo que hacen React, Vue y Angular. Llegarás al Módulo 10 sabiendo exactamente qué problema resuelven y por qué todos te piden una key.

Queda una última pieza, y es la que convierte el tablero en una herramienta de verdad. Marta puede mover tareas y filtrarlas, pero no puede crear ninguna: el backlog sigue siendo el de datos/backlog.js. Dar de alta una tarea significa un formulario, y un formulario trae consigo un mundo propio: campos accesibles con sus etiquetas, valores que llegan siempre como texto, validación nativa del navegador frente a validación en JavaScript, reglas de negocio que deben seguir viviendo en el modelo, mensajes de error que un lector de pantalla pueda anunciar, y el foco puesto donde corresponde cuando algo falla. Todo eso es Manejo y Validación de Formularios, la última lección del módulo.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados