Tienes el plan, el modelo de datos, las reglas R1–R15 y un repositorio verde con la página vacía. Ahora hay que llenarla, y la pregunta que decide si el proyecto avanza o se atasca no es «¿qué escribo?», sino «¿por dónde empiezo?». La respuesta intuitiva —abrir index.html y maquetar la pantalla, porque es lo único que se ve— es exactamente la equivocada, y es la razón por la que tantos proyectos personales tienen una interfaz preciosa encima de una lógica que no se puede probar ni cambiar. En esta lección aprenderás el orden que funciona: de dentro hacia fuera —dominio, datos, vista, aplicación— y vertical antes que horizontal, una funcionalidad completa de punta a punta antes que todas las capas a medias. Verás cómo construir el dominio con TDD sobre las reglas nuevas, cómo resolver el árbol de subtareas con la recursividad de 03-07, cómo montar una capa de datos en memoria que no te bloquee y que deje la frontera lista para la lección siguiente, cómo escribir componentes de vista sin framework con el ciclo estado → render → evento, cómo gobernar el estado con una única fuente de verdad y actualizaciones inmutables, cómo hacer accesible la aplicación desde el primer commit en lugar de al final, y cómo manejar y registrar los errores de todo el sistema. Y terminarás con dos cosas que no son código pero deciden el ritmo del proyecto: una lista de comprobación de calidad para cerrar cada incremento, y un método concreto para cuando te atascas — porque te vas a atascar.

Contenido

  1. De dentro hacia fuera: por qué ese orden
  2. Vertical antes que horizontal
  3. El orden de trabajo recomendado
  4. El dominio: entidades e invariantes
  5. TDD sobre las reglas R11 a R15
  6. El árbol de subtareas con recursividad
  7. La capa de datos: el repositorio en memoria primero
  8. El contrato del repositorio como frontera
  9. El estado de la aplicación: una única fuente de verdad
  10. Actualizaciones inmutables y eventos
  11. La vista sin framework: estado → render → evento
  12. Delegación de eventos y el controlador
  13. El formulario accesible
  14. Las tres pantallas nuevas
  15. Accesibilidad desde el principio
  16. Registro y manejo de errores de toda la aplicación
  17. Incrementos verificables y commits pequeños
  18. Cuándo refactorizar y cómo no romper nada
  19. La lista de comprobación antes de cerrar un incremento
  20. Qué hacer cuando te atascas
  21. Errores Comunes y Consejos
  22. Ejercicios
  23. Conclusión

  1. De dentro hacia fuera: por qué ese orden

Hay dos formas de construir una aplicación, y la elección determina casi todo lo demás.

De fuera hacia dentro es la intuitiva: se empieza por el HTML y el CSS, porque es lo que se ve y lo que da sensación de avance. Cuando la pantalla está bonita, se añade JavaScript para que los botones hagan algo. Cuando los botones hacen algo, se guarda en localStorage. Y la lógica de negocio acaba repartida entre los manejadores de eventos, porque es donde se necesitaba.

De dentro hacia fuera es la que vas a usar: se empieza por el dominio —las entidades y las reglas—, luego los datos, luego la vista, y por último la aplicación que lo une. Durante los primeros días no hay nada que enseñar en el navegador, y eso incomoda.

La comparación honesta:

Aspecto De fuera hacia dentro De dentro hacia fuera
Sensación de avance inicial Alta: se ve algo enseguida Baja: solo hay pruebas en verde
Dónde acaba la lógica de negocio Repartida entre manejadores de eventos Concentrada en dominio/
Se puede probar sin navegador No: todo necesita DOM Sí: el dominio se prueba en Node en milisegundos
Coste de cambiar la interfaz Alto: la lógica se va con ella Bajo: solo cambia vista/
Dónde aparecen los fallos Tarde, en la interfaz, difíciles de aislar Pronto, en pruebas unitarias, con causa evidente
Cuándo se descubre que el modelo estaba mal En la semana 5 En el día 2

La última fila es la decisiva. Los errores de modelo son los más caros que existen, porque contaminan todo lo que se apoya en ellos. Si descubres el día 2 que horasEstimadas no puede ser un campo editable en una tarea con hijas (R12), cambias tres funciones. Si lo descubres en la semana 5, cambias tres funciones, dos vistas, un formulario, el informe, la persistencia y los datos que ya guardaron tus usuarios de prueba.

Y hay un argumento adicional del propio curso: en 10-06 quedó demostrado que dominio/reglas.js fue idéntico en las cuatro versiones de la misma pantalla. El dominio es la parte del proyecto con más valor y más vida útil. Construirlo primero es construir primero lo que más dura.

flowchart LR
    A["1 · dominio/<br/>entidades + R1-R15<br/><i>se prueba en Node</i>"] --> B["2 · datos/<br/>repositorio en memoria<br/><i>frontera definida</i>"]
    B --> C["3 · vista/<br/>componentes + eventos<br/><i>recibe estado y funciones</i>"]
    C --> D["4 · aplicacion/<br/>casos de uso + estado<br/><i>lo une todo</i>"]
    D -.->|"y solo entonces"| E["Navegador<br/>funcionando"]

    style A fill:#dcfce7,stroke:#16a34a
    style B fill:#dbeafe,stroke:#2563eb
    style C fill:#fef3c7,stroke:#d97706
    style D fill:#f3e8ff,stroke:#9333ea

La objeción razonable, y su respuesta. «Si no veo nada durante tres días, pierdo la motivación». Es un problema real y tiene dos soluciones concretas:

  1. Las pruebas en verde son tu pantalla. Ver 47 passed con las reglas del dominio cubiertas es exactamente la misma señal de avance que ver una lista pintada. Cambia lo que cuenta como progreso.
  2. El apartado 2 acorta esos días drásticamente. No construyes todo el dominio antes de tocar la vista: construyes la rebanada del dominio que necesita la primera funcionalidad, y sales al navegador con ella.

  1. Vertical antes que horizontal

Este es el segundo principio, y corrige el peligro del primero.

  • Horizontal significa terminar una capa entera antes de pasar a la siguiente: todo el dominio, luego todos los datos, luego toda la vista.
  • Vertical significa terminar una funcionalidad completa atravesando las cuatro capas antes de empezar la siguiente.
flowchart TB
    subgraph H["❌ Horizontal: 3 semanas sin nada que enseñar"]
        direction LR
        H1["Todo el dominio"] --> H2["Todos los datos"] --> H3["Toda la vista"] --> H4["¿Funciona?<br/>Se descubre aquí"]
    end
    subgraph V["✅ Vertical: algo funciona el día 3"]
        direction LR
        V1["Ver el tablero<br/>dominio→datos→vista→app"] --> V2["Crear tarea<br/>dominio→datos→vista→app"] --> V3["Subtareas<br/>dominio→datos→vista→app"]
    end
    style H fill:#fee2e2,stroke:#b91c1c
    style V fill:#dcfce7,stroke:#16a34a

Por qué vertical gana, con tres razones que no son de estilo:

1 · Valida la arquitectura pronto. La primera rebanada vertical es la que revela si tus fronteras funcionan. Si al pintar la primera lista descubres que necesitas importar datos/ desde vista/, tienes un problema de diseño — y lo tienes el día 3, cuando cambiarlo cuesta una hora.

2 · Produce algo entregable en todo momento. Al final de cada rebanada, la aplicación funciona. Con menos funcionalidades, pero funciona. Si el proyecto se interrumpiera mañana, tendrías un producto pequeño en lugar de tres capas incompletas que no arrancan.

3 · Descubre los requisitos ocultos. Cada rebanada completa saca a la luz cosas que el plan no contemplaba: qué pasa si la lista está vacía, dónde va el foco tras crear una tarea, qué se muestra mientras carga. Descubrirlas de una en una es manejable; descubrir treinta a la vez en la semana 6 es abrumador.

La primera rebanada vertical de Órbita —la que hay que hacer literalmente primero— es esta:

Ver el tablero con las tareas de ejemplo. Sin crear, sin editar, sin filtrar. Solo: los datos de semilla llegan del repositorio en memoria, se construyen entidades del dominio, y se pintan en una lista accesible.

Parece poco. Atraviesa las cuatro capas, obliga a definir el contrato del repositorio, el formato del estado, la estructura de la vista y el punto de entrada. Cuando esa rebanada funciona, el esqueleto del proyecto entero está decidido y probado.

  1. El orden de trabajo recomendado

Combinando los dos principios, este es el orden concreto de los hitos H2 y H3, con las horas estimadas del plan de 11-01:

# Incremento Capas que toca Est. Cómo sabes que está hecho
1 Entidades base con sus invariantes (Tarea, Usuario) dominio 6 h Pruebas de R1–R5, R8, R9, R11 en verde
2 Transiciones de estado (R6) y vencimiento (R10) dominio 3 h Matriz de transiciones probada, válidas y no válidas
3 Repositorio en memoria + semilla ficticia datos 3 h Se puede listar, añadir y buscar desde una prueba
4 Rebanada vertical 1: ver el tablero las 4 8 h Se ve la lista en el navegador, accesible
5 Rebanada vertical 2: crear una tarea las 4 8 h Formulario con validación real del dominio
6 Rebanada vertical 3: cambiar de estado las 4 5 h R6 aplicada desde la interfaz, con error visible
7 Árbol de subtareas (R12) dominio 7 h Ciclos detectados, horas por hojas, profundidad limitada
8 Rebanada vertical 4: subtareas en la interfaz (R13) las 4 7 h Se crea, se ve anidada, no se puede cerrar la madre
9 Asignación de responsable (R11, R15) dominio + vista 6 h Usuario inactivo rechazado; invitado en solo lectura
10 Editar y borrar con historial (R14) las 4 6 h Cada cambio genera entrada inmutable

Fíjate en el patrón: tres incrementos de dominio puro al principio (los tres primeros, 12 horas) y a partir de ahí rebanadas verticales. Ese arranque de dominio no es horizontalismo: es la porción mínima sin la cual la primera rebanada no tiene qué pintar.

Y fíjate en el incremento 7, que es de dominio puro en mitad de las rebanadas. Es correcto: cuando una funcionalidad tiene lógica de negocio compleja —el árbol— conviene resolverla y probarla en el dominio antes de exponerla. Depurar un ciclo en un árbol a través de la interfaz es una pesadilla; depurarlo en una prueba unitaria es trivial.

  1. El dominio: entidades e invariantes

Un invariante es una afirmación sobre un objeto que es cierta siempre, desde que se crea hasta que se destruye. «Una tarea siempre tiene título no vacío» es un invariante. «Una tarea a veces tiene título» no lo es.

La idea que hace que el dominio valga la pena es esta:

Si un objeto no puede existir en estado inválido, la mitad de tus errores no pueden ocurrir.

Eso se consigue con dos técnicas que ya conoces de 05-02 y 05-03: validar en el constructor y encapsular el estado mutable.

4.1 La estructura de una entidad

Este es el esqueleto de src/dominio/tarea.js. No es el código completo: es el contrato que debes rellenar, con los puntos de decisión marcados.

// src/dominio/tarea.js
import { ErrorDeValidacion, ErrorDeRegla } from './errores.js';
import { TRANSICIONES, PRIORIDADES, MAX_HORAS } from './reglas.js';

export class Tarea {
  #estado;          // R5, R6: solo cambia por cambiarEstado()
  #horas;           // R12: derivado si hay subtareas

  constructor({ id, titulo, prioridad, horasEstimadas, /* … */ }) {
    // 1 · Validar TODO antes de asignar nada.
    //     Un objeto a medio construir es un objeto inválido.
    // 2 · Normalizar: trim del título, etiquetas a minúsculas y sin
    //     duplicados (R9), responsable '' -> null (R8).
    // 3 · Congelar lo que no debe cambiar: Object.freeze sobre
    //     etiquetas, id de solo lectura.
    // 4 · R5: el estado inicial es SIEMPRE 'pendiente'; no se acepta
    //     por parámetro salvo en desdeJSON().
  }

  get estado() { return this.#estado; }

  cambiarEstado(nuevo, { subtareas = [] } = {}) {
    // R6: ¿la transición está en TRANSICIONES[actual]?
    // R13: si nuevo === 'hecha' y alguna subtarea no está hecha -> ErrorDeRegla
    // Devuelve el cambio realizado para que la aplicación registre historial (R14)
  }

  estaVencida(hoy) {
    // R10. OJO: 'hoy' se PASA como parámetro, no se lee de Date.now().
    // Es lo que hace que la prueba no dependa del día en que se ejecute.
  }

  toJSON() { /* … */ }
  static desdeJSON(plano) { /* … reconstruye validando … */ }
}

Cinco decisiones incrustadas en ese esqueleto que conviene entender bien:

1 · Validar todo antes de asignar nada. Si validas y asignas alternando, un error a mitad deja el objeto medio construido. En JavaScript el constructor que lanza no devuelve objeto, así que da igual en la práctica… salvo si el constructor ya ha modificado algo externo (un contador de ids, por ejemplo). Valida primero, siempre.

2 · Normalizar en la frontera. El título llega con espacios, las etiquetas con mayúsculas, el responsable como ''. El dominio los normaliza una vez, al entrar, y a partir de ahí todo el código puede confiar en el formato. Es la misma idea que R8 y R9 llevaban defendiendo desde el Módulo 1.

3 · Congelar lo inmutable. Object.freeze(this.etiquetas) evita que alguien haga tarea.etiquetas.push('X') saltándose R9. Es barato y cierra una clase entera de errores.

4 · hoy como parámetro. Esta es la decisión que más agradecerás en 11-04. Si estaVencida() llama a Date.now(), la prueba «una tarea del 5 de septiembre está vencida» funciona hoy y falla el día que cambies el dato de ejemplo. Pasando hoy, la prueba es determinista para siempre. La misma técnica sirve para el generador de ids y para cualquier fuente de no determinismo: se inyecta, no se invoca.

5 · cambiarEstado devuelve el cambio. No devuelve void ni this: devuelve un objeto { campo, antes, despues } que la capa de aplicación usará para construir la entrada de historial (R14). Así el dominio no necesita saber que existe un historial, y el historial no necesita saber cómo se calcula un cambio.

4.2 reglas.js: las reglas en un solo sitio

// src/dominio/reglas.js — la única fuente de verdad de las reglas
export const ESTADOS = Object.freeze(['pendiente', 'en-curso', 'hecha']);
export const PRIORIDADES = Object.freeze(['alta', 'media', 'baja']);
export const ROLES = Object.freeze(['coordinacion', 'equipo', 'invitado']);

// R6: matriz de transiciones válidas
export const TRANSICIONES = Object.freeze({
  'pendiente': ['en-curso'],
  'en-curso': ['hecha', 'pendiente'],
  'hecha': ['en-curso']
});

export const MAX_HORAS = 40;            // R3
export const MAX_HORAS_SEMANA = 40;     // R7, R15
export const PROFUNDIDAD_MAXIMA = 3;    // R12

// R15: quién puede escribir
export const PUEDE_ESCRIBIR = Object.freeze({
  coordinacion: true, equipo: true, invitado: false
});

Tener las reglas en constantes con nombre, en un único fichero, produce tres beneficios inmediatos:

  • Se leen como documentación. Alguien que abra reglas.js entiende el negocio en dos minutos.
  • Cambiarlas es cambiar una línea. Si el máximo pasa de 40 a 45 horas, hay un solo sitio.
  • Las pruebas pueden importarlas. Y eso evita el error clásico de escribir 40 a mano en la prueba: si la regla cambia, la prueba cambia sola con ella… lo cual es bueno para los límites y malo para los valores de ejemplo. Sé consciente de cuál estás usando en cada caso.

  1. TDD sobre las reglas R11 a R15

TDD (Test-Driven Development, desarrollo guiado por pruebas) es el ciclo que 08-03 introdujo: rojo → verde → refactorizar. Escribes una prueba que falla, escribes el código mínimo que la hace pasar, y luego limpias.

No todo el proyecto se hace con TDD, y decirlo es más honesto que fingir lo contrario. Pero hay una parte donde compensa muchísimo:

Dónde ¿TDD? Por qué
Reglas de negocio del dominio Sí, siempre Los criterios ya están escritos como Dado/Cuando/Entonces: la prueba es una traducción, no una invención
Cálculos (carga, progreso, árbol) Entrada y salida claras, casos límite evidentes
Repositorios A veces La prueba de contrato (apartado 8) sí; los detalles no
Vista y maquetación No No sabes cómo va a ser hasta que la ves; se prueba después
Exploración de una API nueva No Primero entiendes, luego pruebas lo que has entendido

5.1 El ciclo aplicado a R13

Este es un ciclo completo, tal y como debes ejecutarlo. Rojo primero:

// test/dominio/r13-cierre.test.js
import { Tarea } from '../../src/dominio/tarea.js';
import { ErrorDeRegla } from '../../src/dominio/errores.js';

describe('R13 · no se cierra una tarea con subtareas abiertas', () => {
  test('rechaza pasar a hecha si una subtarea sigue pendiente', () => {
    const madre = crearTareaEnCurso();
    const subtareas = [tareaHecha(), tareaPendiente({ titulo: 'Medir el hueco' })];

    expect(() => madre.cambiarEstado('hecha', { subtareas }))
      .toThrow(ErrorDeRegla);
  });

  test('el mensaje del error nombra la subtarea que lo impide', () => {
    const madre = crearTareaEnCurso();
    const subtareas = [tareaPendiente({ titulo: 'Medir el hueco' })];

    expect(() => madre.cambiarEstado('hecha', { subtareas }))
      .toThrow(/Medir el hueco/);
  });

  test('permite cerrar cuando todas las subtareas están hechas', () => {
    const madre = crearTareaEnCurso();
    const cambio = madre.cambiarEstado('hecha', { subtareas: [tareaHecha(), tareaHecha()] });

    expect(madre.estado).toBe('hecha');
    expect(cambio).toEqual({ campo: 'estado', antes: 'en-curso', despues: 'hecha' });
  });

  test('permite cerrar una tarea sin subtareas', () => {
    const hoja = crearTareaEnCurso();
    hoja.cambiarEstado('hecha');
    expect(hoja.estado).toBe('hecha');
  });
});

Cuatro pruebas, y ninguna es redundante:

  • La primera comprueba que falla.
  • La segunda comprueba que el mensaje sirve al usuario. Un ErrorDeRegla con el texto «Operación no permitida» es inútil en la interfaz. Probar el mensaje obliga a escribirlo bien.
  • La tercera comprueba el camino feliz y, de paso, el objeto de cambio que necesita R14.
  • La cuarta comprueba el caso degenerado: sin subtareas, la regla no debe estorbar. Es el caso que más a menudo se rompe al implementar la regla con demasiado entusiasmo.

Las funciones auxiliares (crearTareaEnCurso, tareaHecha, tareaPendiente) son el secreto de una suite legible. Construir una Tarea válida requiere seis campos; repetirlos en cuarenta pruebas hace que no se lea ninguna. Escribe un fichero test/ayudas/fabricas.js con constructores que reciban solo lo que la prueba quiere destacar y rellenen el resto con valores por defecto válidos. Es media hora de trabajo que se amortiza en la tercera prueba.

5.2 Pruebas parametrizadas para las transiciones

R6 tiene 3 estados × 3 destinos = 9 combinaciones, de las cuales 4 son válidas. Escribir nueve pruebas casi idénticas es ruido; test.each de Jest lo resuelve:

describe.each([
  ['pendiente', 'en-curso',  true],
  ['pendiente', 'hecha',     false],
  ['pendiente', 'pendiente', false],
  ['en-curso',  'hecha',     true],
  ['en-curso',  'pendiente', true],
  ['en-curso',  'en-curso',  false],
  ['hecha',     'en-curso',  true],
  ['hecha',     'pendiente', false],
  ['hecha',     'hecha',     false]
])('R6 · de %s a %s', (desde, hasta, permitida) => {
  test(permitida ? 'se permite' : 'se rechaza', () => {
    const tarea = tareaEnEstado(desde);
    if (permitida) {
      tarea.cambiarEstado(hasta);
      expect(tarea.estado).toBe(hasta);
    } else {
      expect(() => tarea.cambiarEstado(hasta)).toThrow(ErrorDeRegla);
    }
  });
});

La tabla es la especificación. Si alguien pregunta qué transiciones son válidas, esas nueve líneas lo responden mejor que cualquier documento, y además están verificadas.

Consejo importante sobre la cobertura de reglas: para cada regla, escribe siempre al menos el caso que sí pasa, el caso que no pasa, y el caso límite exacto. Para R3 (horasEstimadas de 0 a 40): 0 rechazado, 0.5 aceptado, 40 aceptado, 40.1 rechazado. Los fallos viven en los bordes, no en el medio.

  1. El árbol de subtareas con recursividad

Esta es la parte del dominio con más sustancia técnica y la que retoma directamente la lección 03-07. Recuerda la decisión de 11-01: el árbol se guarda plano (cada tarea con tareaMadreId) y se construye en memoria cuando hace falta.

6.1 Construir el árbol desde la lista plana

// src/dominio/arbol.js
export function construirArbol(tareas) {
  // 1 · Un índice por id para no buscar en bucle: O(n) en vez de O(n²)
  const porId = new Map(tareas.map((t) => [t.id, { tarea: t, hijas: [] }]));
  const raices = [];

  // 2 · Enganchar cada nodo a su madre, o a las raíces
  for (const nodo of porId.values()) {
    const madreId = nodo.tarea.tareaMadreId;
    if (madreId === null) { raices.push(nodo); continue; }
    const madre = porId.get(madreId);
    if (!madre) throw new ErrorDeDatos(`Tarea ${nodo.tarea.id}: madre ${madreId} inexistente`);
    madre.hijas.push(nodo);
  }
  return raices;
}

Dos detalles que separan una implementación correcta de una que funciona por casualidad:

  • El Map por id convierte la construcción en una sola pasada. Con tareas.find(...) dentro del bucle, el coste sería cuadrático: irrelevante con 6 tareas, perceptible con 600 (que es exactamente el escenario de 09-01).
  • La madre inexistente lanza ErrorDeDatos, no se ignora. Si un dato guardado apunta a una madre borrada, quieres enterarte, no que la tarea desaparezca en silencio de la interfaz. Este caso ocurrirá de verdad en la lección 11-03, cuando migres datos.

6.2 Las cuatro funciones recursivas que necesitas

Función Qué devuelve Caso base Regla
profundidad(nodo) Niveles bajo el nodo Sin hijas → 1 R12 (máx. 3)
hojas(nodo) Todas las tareas sin hijas del subárbol Sin hijas → [tarea] R12 (horas por hojas)
horasTotales(nodo) Suma de horas de las hojas Sin hijas → tarea.horasEstimadas R12
esDescendiente(nodo, id) true si id está en el subárbol Sin hijas → false R12 (sin ciclos)

Y la detección de ciclos, que es la regla que más gente implementa mal:

// R12: vincular una subtarea sin crear ciclos
export function puedeVincular(nodoMadre, idHija, arbol) {
  if (nodoMadre.tarea.id === idHija) return false;         // no es su propia madre
  if (esDescendiente(buscarNodo(arbol, idHija), nodoMadre.tarea.id)) return false; // ciclo
  if (profundidadDesdeRaiz(nodoMadre, arbol) + profundidad(buscarNodo(arbol, idHija)) > PROFUNDIDAD_MAXIMA) return false;
  return true;
}

Las tres condiciones son distintas y hay que probarlas por separado:

  1. Autorreferencia: A no puede ser madre de A. Es el caso trivial y el único que todo el mundo comprueba.
  2. Ciclo indirecto: si A es madre de B y B de C, C no puede ser madre de A. Es el que se olvida, y produce un desbordamiento de pila en cuanto alguien pinta el árbol.
  3. Profundidad: vincular un subárbol de 2 niveles bajo un nodo que ya está en el nivel 2 daría 4. Este también se olvida, porque la comprobación ingenua solo mira el nodo, no el subárbol entero.

Cuidado con la recursividad y la pila. Con profundidad máxima 3 no hay riesgo alguno. Pero si tu dominio permite árboles arbitrariamente profundos (una categoría de inventario, por ejemplo), un ciclo no detectado produce recursión infinita y RangeError: Maximum call stack size exceeded. La lección 03-07 explicaba la alternativa iterativa con pila explícita; tenla presente si tu dominio no acota la profundidad.

  1. La capa de datos: el repositorio en memoria primero

Aquí hay una tentación fuerte que conviene desactivar ya: empezar por localStorage porque «así ya persiste». No lo hagas. Empieza con un repositorio en memoria, por cuatro razones muy concretas:

  1. No te bloquea. Serializar, migrar formatos y manejar cuotas son problemas reales que merecen una lección entera — la siguiente. Resolverlos ahora te aparta de lo que estás construyendo.
  2. Las pruebas son instantáneas y limpias. Un repositorio en memoria se crea nuevo en cada prueba. Con localStorage, cada prueba arrastra el estado de la anterior salvo que limpies, y ese beforeEach que se olvida es una fuente clásica de pruebas intermitentes.
  3. Obliga a definir la frontera. Si escribes primero la implementación fácil, el contrato queda claro; si escribes primero la difícil, el contrato acaba contaminado con detalles de localStorage (claves, JSON.stringify, cuotas) que no deberían salir de ahí.
  4. Es la implementación que usarán tus pruebas para siempre. No es código desechable: RepositorioMemoria seguirá vivo en la suite de 11-04 como doble de prueba.
// src/datos/repositorio-memoria.js
export class RepositorioMemoria {
  #tareas = new Map();
  #usuarios = new Map();
  #historial = [];
  #siguienteId = 1;

  async listarTareas() { return [...this.#tareas.values()]; }

  async guardarTarea(tarea) {
    // R1: si no tiene id, se le asigna aquí; nunca lo trae el usuario
    // Devuelve la tarea guardada (con su id definitivo)
  }

  async borrarTarea(id) { /* … */ }
  async listarUsuarios() { /* … */ }
  async añadirCambio(cambio) { /* R14: append-only, nunca modifica */ }
  async listarHistorial(tareaId) { /* … */ }
}

¿Por qué async si todo está en memoria? Porque el contrato debe ser el mismo que el del repositorio de API de la lección 11-03, donde la asincronía es inevitable. Si el repositorio en memoria fuera síncrono, toda la capa de aplicación estaría escrita en estilo síncrono y cambiarla luego significaría reescribirla entera. Diseña la frontera para la implementación más exigente, no para la más cómoda. Es una de las decisiones más rentables de todo el proyecto y cuesta cero.

La semilla. src/datos/semilla.js contiene los datos ficticios de arranque: tres o cuatro usuarios inventados y ocho o diez tareas que incluyan al menos un caso límite de cada regla nueva — una tarea vencida (R10), una con subtareas mixtas (R13), un usuario inactivo con tareas asignadas (R11), una persona por encima de 40 h en una semana (R7/R15). Así, cada vez que abras la aplicación, los casos interesantes están delante y no tienes que fabricarlos a mano.

  1. El contrato del repositorio como frontera

src/datos/repositorio.js no contiene implementación: contiene el contrato, documentado.

// src/datos/repositorio.js
/**
 * Contrato que TODAS las implementaciones de repositorio cumplen.
 * Implementaciones: memoria (11-02), local (11-03), api (11-03).
 *
 * Reglas del contrato:
 * - Todos los métodos son asíncronos y devuelven promesas.
 * - Devuelven ENTIDADES del dominio, nunca objetos planos ni HTML.
 * - En caso de fallo lanzan ErrorDeDatos (nunca devuelven null silencioso).
 * - listar*() devuelve siempre un array, vacío si no hay nada. Nunca null.
 * - guardar*() devuelve la entidad guardada, con su id definitivo.
 * - El repositorio NO valida reglas de negocio: eso es del dominio.
 *
 * @typedef {Object} Repositorio
 * @property {() => Promise<Tarea[]>}          listarTareas
 * @property {(t: Tarea) => Promise<Tarea>}    guardarTarea
 * @property {(id: number) => Promise<void>}   borrarTarea
 * @property {() => Promise<Usuario[]>}        listarUsuarios
 * @property {(c: Cambio) => Promise<void>}    añadirCambio
 * @property {(id: number) => Promise<Cambio[]>} listarHistorial
 */

Las seis reglas del contrato no son burocracia; cada una evita un problema concreto:

Regla Problema que evita
Todo asíncrono Reescribir la aplicación al cambiar de implementación
Devuelve entidades, no planos Que la validación se salte al cargar datos guardados
Lanza ErrorDeDatos, no devuelve null Los null silenciosos que estallan tres capas más arriba
listar* nunca devuelve null Los if (lista) repartidos por toda la vista
guardar* devuelve la entidad Tener que recargar todo para conocer el id nuevo
No valida reglas de negocio Reglas duplicadas en dos sitios que se desincronizan

La prueba de contrato es la técnica que hace que esto funcione de verdad: una única batería de pruebas que se ejecuta contra las tres implementaciones.

// test/datos/contrato-repositorio.js
export function pruebasDeContrato(nombre, crearRepositorio) {
  describe(`Contrato de repositorio · ${nombre}`, () => {
    let repo;
    beforeEach(async () => { repo = await crearRepositorio(); });

    test('listarTareas devuelve array vacío si no hay nada', async () => {
      expect(await repo.listarTareas()).toEqual([]);
    });

    test('guardarTarea asigna id y la tarea aparece al listar', async () => { /* … */ });
    test('devuelve instancias de Tarea, no objetos planos', async () => { /* … */ });
    test('borrar una tarea inexistente lanza ErrorDeDatos', async () => { /* … */ });
    test('el historial es append-only', async () => { /* … */ });
  });
}

En la lección 11-03 llamarás a esta misma función con RepositorioLocal y RepositorioApi, y si las tres pasan, son intercambiables. Es la garantía que hace posible que cambiar de almacenamiento sea cambiar una línea en main.js. Escríbela ahora, con la implementación en memoria, aunque parezca excesiva para un solo caso: en dos lecciones te ahorrará el día entero.

  1. El estado de la aplicación: una única fuente de verdad

Aquí está el error de diseño más común en aplicaciones sin framework, y conviene verlo con nombre y apellidos: el estado repartido. El filtro activo vive en una variable de la barra de filtros, las tareas visibles en un array de la vista de tablero, el usuario actual en un atributo data- de la cabecera, y el modo del formulario en una propiedad del propio formulario.

Funciona. Hasta el día en que dos de esos sitios dejan de coincidir, y entonces tienes una interfaz que muestra «12 tareas» sobre una lista de 9, y no hay forma de saber cuál de los dos miente.

La regla que lo evita:

Un solo objeto contiene todo lo que la interfaz necesita para pintarse. Nada más gobierna lo que se ve.

// src/aplicacion/estado.js
export function estadoInicial() {
  return Object.freeze({
    // Datos del dominio
    tareas: [],
    usuarios: [],
    // Sesión
    usuarioActual: null,
    // Interfaz
    pantalla: 'tablero',          // 'tablero' | 'informe' | 'historial'
    filtros: { responsableId: null, estados: [], etiquetas: [], modoEtiquetas: 'o' },
    tareaAbiertaId: null,
    // Ciclo de vida de la carga
    cargando: false,
    error: null,
    // Marca de tiempo del último cambio, útil para depurar
    version: 0
  });
}

Tres cosas que van en el estado y a menudo se olvidan:

  • cargando y error. Toda operación asíncrona tiene tres desenlaces y la interfaz tiene que poder pintar los tres. Si no están en el estado, acabarán como variables sueltas y como interfaces que se quedan colgadas.
  • pantalla. La navegación es estado, no una consecuencia lateral del enrutador. El enrutador traduce la URL a estado y viceversa; no es la fuente de verdad.
  • filtros completo, con su modo. Es lo que permite cumplir el criterio 3 de H-07: serializar el filtro a la URL y restaurarlo.

Y una que no va: los datos derivados. tareasVisibles, horasAbiertas o tareasPorPersona se calculan a partir del estado, no se guardan en él. Guardarlos crea dos fuentes de verdad, que es exactamente el problema que estamos evitando. Si el cálculo es caro, se memoriza (09-02), pero se sigue calculando.

// src/aplicacion/selectores.js — funciones puras, derivan del estado
export const tareasVisibles = (estado) => aplicarFiltros(estado.tareas, estado.filtros);
export const horasAbiertas  = (estado) => tareasVisibles(estado)
  .filter((t) => t.estado !== 'hecha')
  .reduce((suma, t) => suma + t.horasEstimadas, 0);

Estas funciones se llaman selectores y tienen tres ventajas: son puras (se prueban sin montar nada), se componen entre ellas, y son el sitio natural donde memorizar si el rendimiento lo pide. Es el mismo concepto que 10-03 presentaba en Redux, aquí sin ninguna librería: cuarenta líneas propias.

  1. Actualizaciones inmutables y eventos

El estado se sustituye, no se modifica. Cada acción produce un estado nuevo a partir del anterior, con la desestructuración y el spread de 04-07:

// src/aplicacion/almacen.js
export function crearAlmacen(estadoInicial) {
  let estado = estadoInicial;
  const suscriptores = new Set();

  return {
    obtener: () => estado,
    actualizar(cambio) {
      const anterior = estado;
      estado = Object.freeze({ ...estado, ...cambio, version: estado.version + 1 });
      for (const fn of suscriptores) fn(estado, anterior);
    },
    suscribir(fn) {
      suscriptores.add(fn);
      return () => suscriptores.delete(fn);   // función para darse de baja
    }
  };
}

Cinco decisiones en veinte líneas:

1 · Object.freeze en cada actualización. Convierte en error cualquier intento de mutar el estado directamente (en modo estricto, que ya usas). Es una barandilla barata que detecta el error donde ocurre.

2 · version se incrementa siempre. Sirve para depurar («¿cuántas actualizaciones llevo?») y para detectar renderizados innecesarios. Es la misma idea del contador de versión que invalidaba la caché del Tablero de Nómada Tareas.

3 · Los suscriptores reciben el estado anterior. Sin él, una vista no puede saber qué ha cambiado y tiene que repintarlo todo. Con él, puede comparar y actualizar solo lo necesario, que es la base del actualizar() de 09-04.

4 · suscribir devuelve la función de baja. Este detalle vale una fuga de memoria. Si suscribirse no devuelve cómo darse de baja, las vistas destruidas siguen en el Set para siempre reteniendo su DOM: exactamente la fuga que estudiaste en 09-03. Devolviendo la baja, destruir() es una línea.

5 · Actualización superficial. { ...estado, ...cambio } fusiona un nivel. Para cambiar un filtro hay que escribir filtros: { ...estado.filtros, etiquetas: [...] }, lo cual es algo verboso pero explícito. Resistir la tentación de una fusión profunda «mágica» es correcto: la fusión profunda esconde qué se está cambiando y produce sorpresas con arrays.

Cómo encaja con los eventos de 06-04. Hay dos mecanismos y hacen cosas distintas; conviene no mezclarlos:

Mecanismo Para qué Dirección
Suscripción al almacén Que las vistas se enteren de que el estado cambió Aplicación → vista
CustomEvent con delegación Que la interacción del usuario llegue a la aplicación Vista → aplicación

La vista emite una intención ({ accion: 'cambiar-estado', id: 4, a: 'hecha' }), la aplicación ejecuta el caso de uso, el caso de uso llama al dominio y al repositorio, y el almacén notifica el estado nuevo. Es un ciclo unidireccional, y esa unidireccionalidad es la razón por la que se puede razonar sobre él.

sequenceDiagram
    participant U as Usuario
    participant V as vista/
    participant A as aplicacion/
    participant D as dominio/
    participant R as datos/
    U->>V: clic en "Marcar hecha"
    V->>A: CustomEvent {accion, id, a}
    A->>D: tarea.cambiarEstado('hecha', {subtareas})
    D-->>A: {campo, antes, despues} o ErrorDeRegla
    A->>R: guardarTarea(tarea) + añadirCambio(cambio)
    A->>A: almacen.actualizar({tareas, error:null})
    A-->>V: suscriptor(estadoNuevo, estadoAnterior)
    V->>U: repinta solo lo que cambió

Ese diagrama es toda la arquitectura de tu aplicación. Si puedes dibujarlo de memoria, puedes explicarlo en una entrevista (lección 11-06).

  1. La vista sin framework: estado → render → evento

Un componente de vista sin framework tiene una forma muy concreta, y conviene que todos los de tu proyecto la compartan. Esta es la plantilla:

// src/vista/tablero-vista.js
export function crearTableroVista(contenedor, { alEmitir }) {
  let ultimoEstado = null;
  const bajas = [];

  function render(estado) {
    // Primera vez: construir la estructura estable (cabecera, <ul>, pie)
  }

  function actualizar(estado, anterior) {
    // Siguientes veces: comparar y tocar solo lo que cambió (09-04)
  }

  function destruir() {
    bajas.forEach((baja) => baja());     // quitar escuchas
    contenedor.replaceChildren();        // vaciar el DOM
    ultimoEstado = null;                 // soltar referencias
  }

  return { render, actualizar, destruir };
}

Cuatro exigencias de esta forma:

1 · Recibe su contenedor, no lo busca. Un componente que hace document.querySelector('#tablero') dentro solo puede existir una vez y solo funciona si ese id existe. Recibiéndolo, se puede montar dos veces, en un fragmento, o en jsdom durante una prueba (08-05).

2 · Recibe alEmitir, no importa la aplicación. Es la frontera del apartado 10 de 11-01 hecha código: la vista no sabe qué pasa cuando emite, solo que alguien escucha. Esto es lo que permite probarla con un jest.fn() en lugar de con la aplicación entera.

3 · Separa render de actualizar. render construye; actualizar reconcilia. Repintar todo en cada cambio es lo que llevaba render() a 310 ms con 600 tareas en 09-01; reconciliar por data-id es lo que lo bajó a 31 ms en 09-04. Si tu proyecto va a tener listas largas, la separación es obligatoria; si no, sigue siendo buena disciplina.

4 · Tiene destruir() y se usa. Cada escucha añadida, cada setInterval, cada suscripción y cada IntersectionObserver se deshace ahí. Sin esto, navegar entre pantallas filtra memoria — y en 11-04 te tocará depurar exactamente esa fuga.

Plantillas con <template>. Para la estructura repetida (la fila de tarea), usa <template> en el HTML y cloneNode(true), como en 06-06. Es más rápido que construir con createElement nodo a nodo y, sobre todo, mantiene la estructura visible en el HTML donde se puede revisar la semántica.

<template id="plantilla-fila-tarea">
  <li class="tarea" data-id="">
    <span class="tarea__estado" aria-hidden="true"></span>
    <h3 class="tarea__titulo"></h3>
    <p class="tarea__meta"></p>
    <button type="button" data-accion="cambiar-estado">Marcar en curso</button>
    <button type="button" data-accion="abrir-detalle">Ver detalle</button>
  </li>
</template>

Nunca uses innerHTML con datos del usuario. Un título de tarea que contenga <img src=x onerror=alert(1)> se ejecuta. Usa textContent para todo lo que venga de datos, y innerHTML solo con literales tuyos. Es la lección de 06-02, y en 11-03 volveremos sobre ella porque en cuanto los datos vienen de una API el riesgo se multiplica.

  1. Delegación de eventos y el controlador

Con 600 tareas y tres botones cada una, añadir escuchas individuales significa 1.800 escuchas. La delegación de 06-04 lo resuelve con una:

// src/vista/controlador.js
export function conectarControlador(raiz, alEmitir) {
  function onClic(evento) {
    const disparador = evento.target.closest('[data-accion]');
    if (!disparador || !raiz.contains(disparador)) return;

    const fila = disparador.closest('[data-id]');
    alEmitir({
      accion: disparador.dataset.accion,
      id: fila ? Number(fila.dataset.id) : null,
      valor: disparador.dataset.valor ?? null
    });
  }

  raiz.addEventListener('click', onClic);
  return () => raiz.removeEventListener('click', onClic);   // baja
}

Cuatro detalles del código que importan:

  • closest('[data-accion]') encuentra el botón aunque el clic caiga sobre un icono dentro de él. Sin esto, la mitad de los clics no hacen nada y el fallo es intermitente y desconcertante.
  • raiz.contains(disparador) evita actuar sobre elementos que ya no están en el árbol (puede ocurrir si el DOM cambió entre el clic y el manejador).
  • El identificador se lee de data-id, la única fuente de verdad sobre a qué fila corresponde el evento. Nada de índices de array: cambian al filtrar.
  • Devuelve la función de baja. Otra vez. Es un patrón, no una casualidad: todo lo que se conecta debe poder desconectarse.

La delegación y el teclado. Un click en un <button> se dispara también con Enter y con Espacio: el navegador lo hace por ti. Esa es la razón práctica —y no estética— por la que un <div> pulsable está mal: obliga a añadir tabindex, role="button" y un manejador de teclado propio para replicar lo que el elemento correcto ya hace. La accesibilidad casi siempre es menos trabajo, no más.

  1. El formulario accesible

El formulario es donde se concentran los errores de accesibilidad y donde el dominio demuestra que sirve. La regla que gobierna todo:

La validación del formulario y la del dominio son la misma. El formulario la anticipa; el dominio la decide.

En la práctica, esto significa que el formulario llama al dominio y traduce sus errores, en lugar de duplicar las reglas:

// src/vista/formulario-tarea.js (fragmento del envío)
function onEnviar(evento) {
  evento.preventDefault();
  limpiarErrores();

  const datos = Object.fromEntries(new FormData(formulario));

  try {
    // El DOMINIO valida. El formulario no repite las reglas.
    const tarea = construirTareaDesdeFormulario(datos);
    alEmitir({ accion: 'crear-tarea', tarea });
  } catch (error) {
    if (error instanceof ErrorDeValidacion) {
      mostrarErrorEnCampo(error.campo, error.message);
    } else {
      mostrarErrorGeneral(error);
    }
  }
}

Ese error.campo es la razón por la que ErrorDeValidacion lleva esa propiedad desde el Módulo 5: permite que la vista sepa qué campo marcar sin analizar el mensaje.

La lista de comprobación de un formulario accesible, que debes cumplir en todos:

# Requisito Cómo se hace Por qué
1 Toda entrada tiene etiqueta <label for="titulo"> Sin ella, el lector de pantalla dice «campo de texto»
2 El campo con error se marca aria-invalid="true" Se anuncia el estado, no solo el borde rojo
3 El mensaje se asocia al campo aria-describedby="error-titulo" Se lee al enfocar el campo
4 El foco va al primer error campo.focus() tras validar Sin esto, el usuario de teclado no sabe dónde está el problema
5 El resumen de errores se anuncia role="alert" o aria-live="assertive" Un error silencioso no existe
6 El botón dice qué hace «Crear tarea», no «Enviar» Se lee fuera de contexto en la lista de elementos
7 Los campos obligatorios se indican required + texto, no solo asterisco El asterisco solo es una convención visual
8 Se puede enviar con Enter Usar <form> y submit, no un click Comportamiento esperado por todo el mundo
9 Nada se pierde al fallar No vaciar el formulario en el error Rehacer un formulario largo es la peor experiencia posible
10 El éxito se comunica Mensaje en aria-live="polite" + foco Si no, no se sabe si funcionó

Los puntos 4, 5 y 10 son los que casi nunca se implementan en proyectos de portafolio y los que más se notan en una revisión de accesibilidad (11-04).

  1. Las tres pantallas nuevas

Las extensiones E4, E5 y E6 de 11-01 producen tres pantallas que no son listas de tarjetas, y ahí está su valor formativo: son las primeras que no puedes resolver copiando TableroVista.

14.1 Calendario (E4)

Aspecto Decisión y por qué
Estructura <table> real con <th scope="col"> para los días de la semana. Un calendario es una tabla de datos; simularlo con <div> es rehacer a mano toda la semántica
Celdas vacías Los días de otros meses van con aria-hidden="true" o directamente sin contenido interactivo
Muchas tareas en un día Mostrar dos y «+3 más» que abre el día. No apiles sin límite: rompe la rejilla y el CLS
Sin fechaLimite Sección aparte «Sin fecha», nunca repartidas arbitrariamente
Navegación Botones anterior/siguiente y teclado: flechas mueven de día, PageUp/PageDown de mes
Fechas Intl.DateTimeFormat para nombres de mes y día (07-06), nunca arrays de textos propios
Hoy Marcado con texto (aria-current="date"), no solo con color

El cálculo del primer día de la rejilla y del número de semanas es el clásico ejercicio de fechas: escríbelo en util/fechas.js como función pura y pruébalo con febrero de un año bisiesto, con un mes que empieza en domingo y con el cambio de año. Esos tres casos cubren casi todos los fallos posibles.

14.2 Informe de carga (E5)

Aspecto Decisión y por qué
Cálculo Un selector puro: cargaPorPersonaYSemana(estado). Sin DOM. Se prueba con tablas de entrada/salida
Semana ISO Función propia en util/fechas.js, con el caso del 1 de enero probado explícitamente (R15)
Doble contabilización Solo hojas del árbol (R12). Es el fallo más probable de toda la pantalla
Sobrecarga Fondo y texto («46 h · supera el límite»), nunca solo color
Tabla <table> con <caption>, <th scope="col"> para semanas y <th scope="row"> para personas
Exportación Fuera del MVP; cuando llegue, Blob + URL.createObjectURL (07-06). Cuidado al escapar comas y comillas en CSV
Rendimiento Si el cálculo pasa de 16 ms con datos realistas, memorízalo (09-02)

14.3 Historial (E6)

Aspecto Decisión y por qué
Estructura <ol> en orden cronológico inverso. Es una lista ordenada de verdad
Cada entrada «Marta cambió el estado de pendiente a hecha», con <time datetime="…">
Volumen Crece sin límite: pagina o carga por lotes desde el principio (09-05)
Inmutabilidad Sin botones de editar ni borrar. Ninguno. Es la R14 hecha interfaz
Usuario borrado Muestra el nombre guardado en la entrada, no el actual: el historial es una foto del pasado
Filtro Por tarea y por persona; reutiliza el mecanismo de filtros del estado

Ese detalle del usuario borrado es una decisión de modelado que quizá no anticipaste: si la entrada de historial guarda solo usuarioId y ese usuario se desactiva o desaparece, el historial pierde legibilidad. Guardar también el nombre en el momento del cambio es duplicación deliberada, y es lo correcto en datos históricos. Anótalo como ADR (11-06).

  1. Accesibilidad desde el principio

El argumento definitivo para no dejarla para el final es de coste:

Cuándo Coste típico Qué implica
Desde el primer commit ~5 % del tiempo Elegir el elemento correcto y añadir un atributo
Al final del proyecto ~30 % del tiempo Reescribir la mitad de la vista, rehacer el foco, reestructurar el HTML

Y buena parte de ese 5 % se reduce a usar el elemento HTML que ya existe:

Necesitas Elemento correcto Lo que te da gratis
Algo pulsable <button type="button"> Foco, Enter, Espacio, rol, estado deshabilitado
Navegar a otra vista <a href="…"> Foco, Enter, menú contextual, abrir en pestaña nueva
Una lista <ul> / <ol> + <li> «Lista de 12 elementos» anunciado
Datos tabulares <table> con <th scope> Navegación por celdas con encabezados leídos
Agrupar campos <fieldset> + <legend> El grupo se anuncia al entrar
Contenido plegable <details> / <summary> Estado expandido/plegado sin JavaScript
Diálogo modal <dialog> con showModal() Foco atrapado, Escape, fondo inerte

Ese último merece énfasis: <dialog> con showModal() resuelve solo el atrapado de foco, el cierre con Escape y la inertización del fondo. Reimplementar eso a mano son cien líneas frágiles.

Lista de comprobación de accesibilidad por incremento (va en la Definición de Hecho de 11-01):

# Comprobación Cómo se hace en 30 segundos
1 Todo alcanzable con Tab Guarda el ratón y recorre la pantalla entera
2 Foco siempre visible Mira dónde estás en cada parada; :focus-visible con contraste ≥ 3:1
3 Orden de tabulación lógico ¿Sigue el orden visual? Si no, el orden del DOM está mal
4 Sin trampas de foco ¿Puedes salir de todo lo que has entrado?
5 Foco gestionado al abrir y cerrar Al abrir un diálogo, entra; al cerrarlo, vuelve al disparador
6 Los cambios se anuncian Región aria-live="polite" para «12 tareas visibles de 47»
7 Los errores se anuncian role="alert" en el resumen de errores
8 Imágenes e iconos alt descriptivo, o aria-hidden="true" si son decorativos
9 Contraste DevTools sobre el texto; ≥ 4,5:1 normal, ≥ 3:1 grande
10 Nada solo por color Vencida (R10) y sobrecarga (R7) con texto o icono
11 Zoom al 200 % Ctrl + rueda; ¿se pierde contenido o función?
12 Idioma declarado <html lang="es">

Los doce puntos se comprueban en unos cinco minutos por pantalla. Hazlo al cerrar cada incremento, no al final del proyecto.

La región de anuncios es una sola, viva desde el arranque, y la usa toda la aplicación:

<div id="anuncios" role="status" aria-live="polite" class="solo-lectores"></div>
// src/vista/anunciar.js
let region = null;
export function anunciar(texto) {
  region ??= document.getElementById('anuncios');
  region.textContent = '';                       // fuerza el reanuncio
  requestAnimationFrame(() => { region.textContent = texto; });
}

El truco de vaciar y volver a poner en el siguiente fotograma es necesario porque muchos lectores de pantalla no anuncian un texto idéntico al anterior. Sin él, «Tarea creada» se anuncia la primera vez y ninguna más.

  1. Registro y manejo de errores de toda la aplicación

Tu proyecto tiene tres tipos de error, y cada uno se trata distinto:

Tipo Ejemplo Quién lo lanza Qué ve el usuario Se registra
De validación Título vacío (R2) Dominio Mensaje junto al campo No
De regla Cerrar con subtareas abiertas (R13) Dominio Aviso explicativo con la causa No
De datos / técnico Cuota llena, red caída, JSON corrupto Datos «Algo ha fallado» + acción de reintento

La distinción no es académica: los dos primeros son comportamiento esperado y no deben ensuciar el registro de errores. Si registras cada validación fallida, el registro se llena de ruido y los fallos reales se pierden entre él.

// src/aplicacion/casos-uso.js (patrón general de un caso de uso)
export async function crearTarea(almacen, repo, datos) {
  almacen.actualizar({ cargando: true, error: null });
  try {
    const tarea = new Tarea(datos);                    // puede lanzar ErrorDeValidacion
    const guardada = await repo.guardarTarea(tarea);   // puede lanzar ErrorDeDatos
    await repo.añadirCambio(cambioDeCreacion(guardada, almacen.obtener().usuarioActual));
    almacen.actualizar({ tareas: [...almacen.obtener().tareas, guardada], cargando: false });
    anunciar(`Tarea «${guardada.titulo}» creada`);
    return guardada;
  } catch (error) {
    almacen.actualizar({ cargando: false, error: aErrorDeInterfaz(error) });
    if (error instanceof ErrorDeDatos) registrar(error, { caso: 'crearTarea', datos });
    throw error;
  }
}

Cinco puntos del patrón, que debes repetir en todos los casos de uso:

  1. cargando: true y error: null al empezar. Limpiar el error anterior evita que quede colgado un mensaje de hace dos operaciones.
  2. try/catch alrededor de todo, con cargando: false en el catch. Un cargando que no se apaga es un contador girando para siempre.
  3. aErrorDeInterfaz(error) traduce un error técnico a algo que una persona entiende. Es responsabilidad de la aplicación, no del dominio ni de los datos.
  4. Solo se registran los técnicos.
  5. Se relanza el error. Quien llamó (el formulario) necesita saber que falló para no cerrarse ni limpiar los campos.

El registrador, minimalista y suficiente:

// src/util/registro.js
const MAXIMO = 50;
const entradas = [];

export function registrar(error, contexto = {}) {
  const entrada = {
    fecha: new Date().toISOString(),
    nombre: error.name,
    mensaje: error.message,
    contexto,                       // NUNCA datos personales aquí
    pila: error.stack?.split('\n').slice(0, 5).join('\n')
  };
  entradas.push(entrada);
  if (entradas.length > MAXIMO) entradas.shift();   // no crecer sin límite
  if (import.meta.env.DEV) console.error('[Órbita]', entrada);
}

export const historialDeErrores = () => [...entradas];

Y las dos redes de seguridad globales, en main.js:

window.addEventListener('error', (e) => registrar(e.error ?? new Error(e.message), { tipo: 'global' }));
window.addEventListener('unhandledrejection', (e) => registrar(e.reason, { tipo: 'promesa' }));

La segunda es la que más fallos silenciosos caza: una promesa rechazada sin catch no aparece en ningún sitio salvo en la consola, y en producción nadie mira la consola de sus usuarios.

Aviso de privacidad en el registro. El campo contexto es tentador para meter «los datos que causaron el fallo». No metas nunca ahí datos personales, tokens ni contenido escrito por el usuario. Registra identificadores y nombres de campo ({ caso: 'crearTarea', campos: ['titulo', 'horasEstimadas'] }), no valores. Cuando en 11-05 conectes un servicio de seguimiento de errores en producción, esa disciplina será una obligación legal, no una recomendación.

  1. Incrementos verificables y commits pequeños

Un incremento verificable es un trozo de trabajo que cumple tres condiciones:

  1. La aplicación sigue funcionando al terminarlo.
  2. Se puede demostrar con algo: una prueba nueva en verde o un comportamiento visible.
  3. Cabe en medio día o menos.

Comparación de dos formas de abordar la historia H-04 (subtareas), con la misma cantidad de trabajo:

❌ Un solo commit ✅ Seis incrementos
feat: subtareas (18 ficheros, 640 líneas) feat(dominio): añadir tareaMadreId con validación
feat(dominio): construir árbol desde lista plana
feat(dominio): detectar ciclos al vincular (R12)
feat(dominio): calcular horas por hojas (R12)
feat(dominio): bloquear cierre con hijas abiertas (R13)
feat(vista): mostrar subtareas anidadas en el tablero
Si falla, git bisect señala 640 líneas Si falla, señala 40
Imposible de revisar Cada uno se revisa en cinco minutos
Imposible de revertir en parte Se revierte solo el que sobra

El argumento de git bisect merece atención: es el comando que encuentra automáticamente qué commit introdujo un fallo, probando la mitad de la historia cada vez. Con commits de 40 líneas te da la causa exacta; con commits de 640 te da un rango en el que aún tienes que buscar. Esa herramienta solo funciona bien si tus commits son pequeños y cada uno deja el proyecto funcionando.

El ritmo que funciona, y que puedes cronometrar:

1 · Elige el incremento más pequeño que aporte algo            (2 min)
2 · Escribe la prueba que falla                                (10 min)
3 · Escríbelo hasta que pase                                   (30 min)
4 · Limpia con las pruebas en verde                            (10 min)
5 · npm run verificar                                          (1 min)
6 · Commit con mensaje que explique el porqué                  (2 min)
   → vuelve a 1

Unos 55 minutos por vuelta. Cuatro vueltas es una sesión de trabajo productiva, y termina con cuatro commits que cuentan una historia legible.

  1. Cuándo refactorizar y cómo no romper nada

Refactorizar es cambiar la estructura interna del código sin cambiar su comportamiento. Esa definición contiene la regla más importante:

Nunca refactorices y añadas funcionalidad en el mismo commit.

Si mezclas, y algo se rompe, no sabes si fue el cambio de estructura o el de comportamiento. Separados, el commit de refactorización tiene una propiedad valiosísima: todas las pruebas pasaban antes y pasan después, sin tocar ninguna prueba. Si tienes que modificar pruebas, no estabas refactorizando: estabas cambiando comportamiento.

Cuándo refactorizar, con señales objetivas:

Señal Qué indica Qué hacer
Copias y pegas por tercera vez Falta una abstracción Extrae la función (a la tercera, no a la segunda)
Una función no cabe en la pantalla Hace demasiado Extrae los pasos con nombres que expliquen
El nombre lleva «y» (validarYGuardar) Dos responsabilidades Pártela
Necesitas un comentario para explicar un bloque El código no se explica Extrae ese bloque a función con el nombre del comentario
Cuesta escribir la prueba Hay demasiadas dependencias Inyecta lo que necesita en vez de crearlo dentro
Cambiar algo obliga a tocar cinco ficheros Acoplamiento alto Revisa si estás cruzando una frontera de capa
Te da miedo tocarlo Falta cobertura Primero pruebas, luego refactorización

Esa última fila es la regla de oro: no se refactoriza código sin pruebas. Si hay que tocar algo sin cobertura, el orden es: escribir pruebas de lo que hace ahora (aunque lo que haga sea raro), verlas en verde, y solo entonces cambiar la estructura. Las pruebas son la red; sin red, no es refactorización, es reescritura a ciegas.

Y la regla que evita el otro extremo: no refactorices código que funciona, que no vas a tocar y que nadie ha reportado. La refactorización tiene un coste y un riesgo, y solo se justifica cuando la estructura actual te está estorbando para algo concreto que vas a hacer ahora. «Está feo» no es una razón suficiente cuando tienes un plan con hitos que cumplir.

  1. La lista de comprobación antes de cerrar un incremento

Cinco minutos por incremento. Es la versión operativa de la Definición de Hecho de 11-01.

# Comprobación Cómo
1 ¿Hace una cosa? Léelo en voz alta; si usas «y», pártelo
2 ¿Hay una prueba que falla sin este cambio? git stash el código, ejecuta las pruebas
3 ¿Los casos límite están cubiertos? Vacío, null, texto larguísimo, número fuera de rango
4 ¿Respeta las fronteras de capa? npm run lint
5 ¿Sin console.log, sin código comentado? git diff completo
6 ¿Los nombres dicen la verdad? ¿calcularCarga calcula o también guarda?
7 ¿Accesible con teclado? Recorre la pantalla sin ratón
8 ¿Se anuncia lo que cambia? ¿Hay anunciar() donde hace falta?
9 ¿Los errores tienen su camino? Provoca uno a propósito y mira qué se ve
10 ¿El estado sigue siendo única fuente de verdad? ¿Has creado alguna variable de estado en la vista?
11 ¿npm run verificar en verde? Ejecutalo
12 ¿El mensaje de commit explica el porqué? Léelo como si fuera de otra persona

El punto 2 tiene un procedimiento concreto que merece la pena aprender: guarda tu cambio de código con git stash dejando las pruebas nuevas, ejecútalas y comprueba que fallan; recupera con git stash pop y comprueba que pasan. Eso demuestra que la prueba prueba algo. Una prueba que pasa con y sin el cambio no está probando nada, y es más común de lo que parece.

  1. Qué hacer cuando te atascas

Te vas a atascar. No es una posibilidad: forma parte del trabajo, y saber salir es una habilidad tan técnica como saber escribir un reduce.

20.1 El método de las tres preguntas

Cuando algo no funciona y llevas más de quince minutos, para de teclear y responde por escrito:

1 · ¿Qué esperaba exactamente que pasara?

No «que funcione». Algo verificable: «esperaba que cargaPorPersona(estado) devolviera { 'u-1': 25 }». Si no puedes formularlo así, el problema no es el código: es que no sabes qué quieres. Y ese es el atasco de verdad.

2 · ¿Qué pasa en realidad?

Con el dato delante: «devuelve { 'u-1': 25, 'u-2': undefined }». No «da error», sino el mensaje exacto, la línea exacta, el valor exacto.

3 · ¿Cuál es la diferencia más pequeña entre lo que esperaba y lo que pasa?

«Hay una clave de más, con valor undefined». Esa frase suele contener la causa: si aparece una clave que no debería, algo está iterando sobre usuarios en lugar de sobre tareas asignadas.

Este método resuelve, por experiencia, más de la mitad de los atascos sin ejecutar nada. Funciona porque el atasco casi nunca es «no sé programar esto»: es «tengo tres suposiciones mezcladas y una es falsa». Escribirlas las separa.

20.2 La caja de tiempo

Pon un temporizador de 45 minutos. Si al sonar no has avanzado, cambia de estrategia obligatoriamente:

Intento Estrategia Tiempo
1 Las tres preguntas + depurador (08-01) 45 min
2 Reproducir en aislamiento (20.3) 45 min
3 Buscar en la documentación oficial —MDN, no un foro cualquiera 30 min
4 Explicárselo a alguien (o a un objeto inanimado, en voz alta) 15 min
5 Dejarlo y hacer otra cosa del proyecto Hasta mañana

El paso 5 no es rendirse: es la estrategia con mejor relación entre coste y resultado de las cinco. Una cantidad sorprendente de atascos se resuelven en la ducha del día siguiente, porque el problema era una suposición fija que solo se suelta al dejar de mirarla.

El paso 4 tiene nombre propio —rubber duck debugging, depuración con patito de goma— y funciona por una razón concreta: explicar en voz alta te obliga a hacer explícitas las suposiciones que estabas dando por buenas. Muchísimas veces la frase se corta a mitad con un «…ah, espera».

20.3 Reproducir el problema en aislamiento

Es la técnica más potente de todas y la que menos se usa. Consiste en construir el ejemplo más pequeño posible que reproduzca el fallo, fuera de la aplicación.

Procedimiento:

  1. Nuevo fichero de prueba, test/aislado.test.js, con solo lo imprescindible.
  2. Datos mínimos: dos tareas en lugar de cuarenta.
  3. Sin vista, sin repositorio, sin estado: solo la función sospechosa.
  4. Quita cosas hasta que deje de fallar. Lo último que quitaste está implicado.
  5. Cuando falle con diez líneas, la causa suele ser obvia.

Un ejemplo real de este proyecto:

// test/aislado.test.js — el informe daba 71 h donde debía dar 48
test('horas de un árbol de 3 tareas', () => {
  const madre = tarea({ id: 1, horas: 0, madre: null });
  const hija1 = tarea({ id: 2, horas: 10, madre: 1 });
  const hija2 = tarea({ id: 3, horas: 5,  madre: 1 });

  const arbol = construirArbol([madre, hija1, hija2]);
  expect(horasTotales(arbol[0])).toBe(15);   // ← falla: devuelve 30
});

Con tres tareas, «devuelve 30 en vez de 15» dice inmediatamente que algo se cuenta dos veces. Con cuarenta tareas y la interfaz montada, ese mismo fallo es «los números del informe están raros» y puede costarte una tarde.

Y un beneficio adicional que suele pasar desapercibido: esa prueba aislada se queda en la suite. Al arreglarlo, ya tienes la prueba de regresión escrita, gratis. Es exactamente el método que aplicarás a los tres fallos de la lección 11-04.

20.4 Cuándo el atasco significa otra cosa

A veces el atasco no es técnico. Estas señales indican que el problema está un nivel más arriba:

Señal Qué suele significar Qué hacer
Llevas días sin cerrar un incremento El incremento es demasiado grande Pártelo hasta que quepa en medio día
Cada cambio rompe tres cosas Falta cobertura o hay acoplamiento Para y escribe pruebas antes de seguir
No sabes por dónde empezar la historia No está bien definida Vuelve a los criterios de aceptación de 11-01
Cambias de tarea sin terminar ninguna Falta un orden explícito Cierra la actual antes de tocar otra
Llevas dos semanas sin ver progreso Estás trabajando horizontalmente Vuelve al apartado 2: haz una rebanada vertical

Errores Comunes y Consejos

Empezar por el HTML porque es lo que se ve. Es la trampa más natural del mundo y produce aplicaciones con la lógica de negocio repartida entre manejadores de clic. Si tu regla R13 vive dentro de un onclick, no se puede probar sin navegador, no se puede reutilizar desde el informe y desaparecerá el día que cambies la interfaz. Dominio primero, siempre.

Construir todo el dominio antes de tocar el navegador. El error opuesto, y también real. Tres incrementos de dominio y fuera: haz la primera rebanada vertical. La arquitectura solo se valida cuando la atraviesa algo de punta a punta.

Estado repartido por la interfaz. El filtro en la barra, la lista en la vista, el modo en el formulario. Funciona hasta el día que dejan de coincidir, y entonces no hay forma de saber cuál miente. Un objeto de estado, y las vistas leen de él.

Mutar el estado directamente. estado.tareas.push(nueva) no dispara ninguna suscripción, así que la interfaz no se entera y aparece «no se actualiza» sin causa aparente. Object.freeze en el almacén convierte ese error silencioso en una excepción inmediata: úsalo.

Duplicar las reglas en el formulario. Si validas «máximo 40 horas» en el formulario y en el dominio, tienes dos verdades que se desincronizarán. El formulario llama al dominio y traduce el error; el número 40 aparece una sola vez, en reglas.js.

Olvidar destruir(). Cada vista que se monta y no se desmonta bien deja escuchas y suscripciones vivas. Con dos pantallas no se nota; en la lección 11-04 aparecerá como una fuga de memoria al navegar y tendrás que buscarla. Escribe destruir() a la vez que render(), no después.

Leer Date.now() dentro del dominio. Hace que las pruebas dependan del día en que se ejecutan y produce fallos que aparecen solos un lunes. Pasa hoy como parámetro. La misma regla vale para Math.random() y para los ids.

Commits gigantes. «feat: subtareas» con 640 líneas es imposible de revisar, de revertir y de bisecar. Si el mensaje necesita una «y», son dos commits.

Refactorizar y añadir funcionalidad a la vez. Cuando algo se rompa, no sabrás cuál de los dos fue. Y si tienes que tocar pruebas mientras refactorizas, no estás refactorizando.

Consejo · Escribe primero la función auxiliar de pruebas. Media hora construyendo test/ayudas/fabricas.js hace que tus siguientes cien pruebas se lean en una línea cada una. Es la inversión con mejor retorno de todo el módulo.

Consejo · Ten siempre un incremento «en verde» al que volver. Antes de empezar algo arriesgado, asegúrate de que el último commit funciona. Así, si te pierdes, git restore . te devuelve a terreno firme en lugar de a un pantano de cambios a medias.

Consejo · Apunta las decisiones según las tomas. Un fichero docs/decisiones.md con tres líneas por decisión. En 11-06 lo convertirás en ADRs formales, y agradecerás no tener que reconstruir de memoria por qué el árbol es plano.

Consejo · Termina la sesión dejando una prueba escrita que falla. Es el mejor punto de retomada que existe: al día siguiente sabes exactamente qué estabas haciendo y qué toca hacer, sin releer nada.

Ejercicios

Estos ejercicios son los hitos H2 y H3 de tu proyecto: el dominio con sus pruebas en verde y la primera funcionalidad completa de punta a punta.

Ejercicio 1 — El dominio completo con TDD.

Implementa src/dominio/ entero con sus pruebas, siguiendo el ciclo rojo → verde → refactorizar:

  1. errores.js con ErrorDeValidacion (con .campo), ErrorDeRegla y ErrorDeDatos, todos heredando de Error y con name correcto.
  2. reglas.js con todas las constantes: estados, prioridades, roles, matriz de transiciones y límites.
  3. tarea.js y usuario.js con validación completa en el constructor, campos privados para lo mutable y toJSON/desdeJSON.
  4. arbol.js con construirArbol, hojas, horasTotales, profundidad, esDescendiente y puedeVincular.
  5. tablero.js con las operaciones de conjunto y los cálculos (resumen, horasAbiertas, cargaPorPersonaYSemana).
  6. Pruebas: cada regla R1–R15 con caso que pasa, caso que falla y caso límite. Las transiciones con test.each (9 combinaciones). Los ciclos del árbol con los tres tipos: autorreferencia, ciclo indirecto y exceso de profundidad.
  7. test/ayudas/fabricas.js con constructores de entidades válidas por defecto.
  8. Cobertura del dominio ≥ 90 % de ramas.

Requisito estricto: el dominio no puede importar nada de datos/, vista/ ni aplicacion/, ni usar document, localStorage, fetch, Date.now() ni Math.random(). npm run lint debe verificarlo (frontera configurada en 11-01).

Ejercicio 2 — La primera rebanada vertical.

Implementa «ver el tablero» de punta a punta:

  1. RepositorioMemoria con el contrato completo y la semilla ficticia (mínimo 8 tareas con un caso límite de cada regla nueva).
  2. test/datos/contrato-repositorio.js con al menos 8 pruebas de contrato, ejecutadas contra la implementación en memoria.
  3. crearAlmacen con obtener, actualizar, suscribir (que devuelve la baja) y congelación del estado.
  4. Selectores puros: tareasVisibles, horasAbiertas, resumenPorEstado.
  5. TableroVista con render, actualizar y destruir, usando <template> y semántica de lista.
  6. Controlador con delegación por [data-accion] y [data-id].
  7. main.js que lo monta todo, carga la semilla y pinta.
  8. Región aria-live funcionando y anuncio del número de tareas visibles.
  9. Prueba de integración con Testing Library: consultar por rol (getAllByRole('listitem')) y comprobar el contenido.

Ejercicio 3 — Las dos rebanadas siguientes y el árbol.

  1. Crear una tarea: formulario con FormData, validación delegada al dominio, aria-invalid y aria-describedby, foco al primer error, y anuncio del éxito. Cumple los 10 puntos de la lista del apartado 13.
  2. Cambiar de estado: R6 aplicada desde la interfaz, con el error de regla mostrado de forma comprensible y anunciado.
  3. Subtareas: crear una subtarea, verla anidada, y comprobar que R13 impide cerrar la madre con hijas abiertas, con el mensaje que nombra la subtarea.
  4. Cada una en su rama, con al menos cuatro commits convencionales, y cerrada con la lista de comprobación del apartado 19.
  5. Documenta en docs/decisiones.md al menos tres decisiones tomadas durante la implementación.

Soluciones

De nuevo, no hay código de solución: hay criterios de aceptación y rúbricas. Estas listas son las que debes recorrer antes de darte por satisfecho.

Criterios de aceptación del ejercicio 1 — Dominio

# Criterio Cómo se comprueba
1 Un objeto inválido no puede construirse new Tarea({ titulo: ' ' }) lanza ErrorDeValidacion con .campo === 'titulo'
2 El estado inicial es siempre 'pendiente' Pasar estado: 'hecha' al constructor no lo cambia (R5)
3 Las 9 transiciones están cubiertas test.each con las 9 filas, 4 válidas y 5 rechazadas
4 Las etiquetas están normalizadas y congeladas ['Taller','taller']['taller']; push lanza en modo estricto
5 estaVencida es determinista La misma prueba pasa hoy y dentro de un año
6 Los tres tipos de ciclo se detectan Autorreferencia, indirecto y profundidad, cada uno con su prueba
7 Las horas no se duplican Árbol de 1 madre + 2 hijas (10 h y 5 h) → 15 h, no 30
8 R11 rechaza usuarios inactivos Asignar a un usuario con activo: false lanza
9 R11 rechaza responsable = revisor Misma persona en ambos campos lanza
10 R13 nombra la subtarea que bloquea El mensaje contiene el título
11 La semana ISO es correcta El 1 de enero de un año que empieza en viernes cae en la semana 53 del anterior
12 El dominio se prueba sin jsdom testEnvironment: 'node' para test/dominio/ y todo pasa

El criterio 12 es la prueba definitiva de que tu arquitectura funciona. Configura Jest con proyectos separados: node para dominio, jsdom para el resto. Si el dominio necesita jsdom, has cruzado la frontera sin darte cuenta.

Rúbrica del ejercicio 1 (24 puntos)

Dimensión 0 1 2 3
Invariantes Objetos inválidos posibles Validación básica Validación completa Además normalización y congelación
Cobertura de reglas < 8 reglas probadas 10 reglas Las 15 Las 15 con caso límite exacto
Determinismo Usa Date.now() dentro Parcialmente inyectado Todo inyectado Además con reloj falso en pruebas
Recursividad Solo casos felices Ciclo directo Los 3 tipos de ciclo Además con árbol profundo probado
Legibilidad de las pruebas Repetitivas Con fábricas Con test.each donde toca Se leen como especificación
Errores Genéricos Tipados Tipados con .campo Con mensajes útiles para el usuario
Fronteras Cruzadas Respetadas de facto Verificadas por ESLint Además con Jest en entorno node
Cobertura < 70 % 70–85 % 85–90 % ≥ 90 % de ramas, sin huecos en reglas

Umbral: 17/24, con obligatoriamente 3 en «Fronteras» y ≥ 2 en «Cobertura de reglas».

Criterios de aceptación del ejercicio 2 — Rebanada vertical

# Criterio Cómo se comprueba
1 Se ven las tareas de la semilla al abrir Manual, npm run dev
2 La lista es una lista de verdad getAllByRole('listitem') devuelve tantas como tareas
3 Cada fila tiene su data-id Ningún índice de array en el DOM
4 Las vencidas se distinguen con texto No solo color (R10 + presupuesto de accesibilidad)
5 El estado está congelado estado.tareas.push(x) lanza
6 La suscripción se puede cancelar suscribir() devuelve función; tras llamarla no se notifica
7 El contrato pasa contra memoria Las 8 pruebas de contrato en verde
8 La vista no importa datos/ ESLint lo verifica
9 destruir() deja el contenedor vacío y sin escuchas Prueba que cuenta escuchas antes y después
10 El número de tareas se anuncia La región aria-live contiene el texto tras el render

Criterios de aceptación del ejercicio 3 — Rebanadas 2, 3 y 4

# Criterio Cómo se comprueba
1 Título vacío marca el campo, no un alert aria-invalid="true" + mensaje asociado
2 El foco va al primer campo con error document.activeElement es ese campo
3 El formulario no se vacía al fallar Los valores siguen ahí
4 Crear anuncia el éxito y devuelve el foco aria-live con texto + foco en la fila nueva o en el disparador
5 Una transición no válida no cambia el estado El dominio lanza y la interfaz sigue mostrando el estado anterior
6 El error de regla es comprensible Ninguna traza técnica visible para el usuario
7 La subtarea aparece anidada semánticamente <ul> dentro del <li> de la madre
8 Cerrar la madre con hijas abiertas se impide Mensaje que nombra la subtarea
9 Todo funciona sin ratón Recorrido completo con Tab, Enter y Escape
10 Cada rama tiene ≥ 4 commits convencionales git log --oneline

Autoevaluación del hito H3. Antes de pasar a la lección siguiente:

Pregunta Sí / No
¿Puedo ejecutar las pruebas del dominio sin jsdom y pasan todas?
¿Hay alguna regla de negocio fuera de dominio/?
¿Podría cambiar RepositorioMemoria por otro sin tocar vista/?
¿Puedo usar la aplicación entera sin ratón?
¿Cada vista que monto tiene su destruir() y lo llamo?
¿Mis últimos diez commits tienen mensajes que explican el porqué?
¿npm run verificar está en verde ahora mismo?

Un «sí» en la segunda pregunta (que es la única formulada al revés) o un «no» en cualquiera de las demás son deuda que la lección 11-03 va a multiplicar. Arréglalo ahora.

Conclusión

Has construido el esqueleto real de tu producto, y lo has hecho en el orden que aguanta.

Sabes por qué se construye de dentro hacia fuera: porque los errores de modelo son los más caros que existen y aparecen el día 2 en una prueba unitaria en lugar de en la semana 5 repartidos por seis ficheros; porque el dominio se prueba en Node en milisegundos; y porque es la parte con más vida útil, como demostró que dominio/reglas.js fuera idéntico en las cuatro versiones de 10-06. Y sabes por qué eso no significa horizontalismo: vertical antes que horizontal, una rebanada completa que atraviese las cuatro capas antes que todas las capas a medias, porque es lo que valida la arquitectura pronto, deja algo entregable en todo momento y saca a la luz los requisitos ocultos de uno en uno.

Tienes el dominio construido sobre invariantes: un objeto que no puede existir en estado inválido elimina la mitad de los errores posibles. Validar todo antes de asignar nada, normalizar en la frontera, congelar lo inmutable, inyectar hoy en lugar de invocar Date.now() —lo que hace las pruebas deterministas para siempre— y devolver el objeto de cambio desde cambiarEstado para que el historial de R14 no obligue al dominio a saber que existe un historial. Con las reglas en un único reglas.js que se lee como documentación.

Sabes aplicar TDD donde compensa —las reglas de negocio y los cálculos, no la maquetación ni la exploración— y sabes que los criterios Dado / Cuando / Entonces de 11-01 se traducen a pruebas casi palabra por palabra. Tienes las cuatro pruebas de R13 —que falla, que el mensaje sirve, que el camino feliz funciona, y que la regla no estorba sin subtareas—, las nueve transiciones parametrizadas con test.each como especificación verificada, y la disciplina de probar siempre el caso que pasa, el que no y el límite exacto, porque los fallos viven en los bordes.

Tienes el árbol resuelto con la recursividad de 03-07: construcción en una pasada con un Map por id, error explícito ante una madre inexistente, y las tres condiciones de ciclo que hay que comprobar por separado —autorreferencia, ciclo indirecto y exceso de profundidad—, de las cuales solo la primera es evidente y las otras dos son las que producen desbordamientos de pila.

Tienes la capa de datos empezada por donde no bloquea: un repositorio en memoria que no es código desechable —será tu doble de prueba para siempre—, con un contrato asíncrono diseñado para la implementación más exigente y no para la más cómoda, y una batería de pruebas de contrato que en la lección siguiente ejecutarás contra localStorage y contra la API para demostrar que son intercambiables.

Tienes el estado gobernado: una única fuente de verdad congelada, con cargando y error dentro porque toda operación asíncrona tiene tres desenlaces; datos derivados calculados por selectores puros y nunca guardados; actualizaciones inmutables con spread; suscripciones que devuelven su baja, que es la línea que separa una aplicación limpia de una con fugas; y el ciclo unidireccional vista → aplicación → dominio → datos → almacén → vista que puedes dibujar de memoria.

Tienes vistas sin framework con una forma común: reciben su contenedor y su alEmitir, separan render de actualizar —los 310 ms frente a los 31 ms de 09-04— y tienen destruir() escrito a la vez que el resto. Con delegación por [data-accion] y closest, plantillas <template>, textContent para todo dato de usuario, y formularios que llaman al dominio en lugar de duplicar sus reglas, con los diez puntos de accesibilidad que casi nadie implementa: foco al primer error, error anunciado, y el formulario que no se vacía cuando algo falla.

Sabes hacer las tres pantallas que no son listas —un calendario que es una <table> de verdad, un informe cuyo cálculo es un selector puro y cuyo mayor riesgo es contar dos veces las horas del árbol, y un historial sin un solo botón de editar porque la R14 es una promesa—, y sabes que la accesibilidad cuesta un 5 % desde el principio y un 30 % al final, y que la mayor parte de ese 5 % consiste simplemente en usar el elemento HTML que ya existe.

Tienes un manejo de errores con tres categorías que se tratan distinto, con las validaciones y las reglas fuera del registro para que el ruido no tape los fallos reales, un patrón de caso de uso con cargando, try/catch, traducción a lenguaje humano y relanzado, las dos redes de seguridad globales —incluida la de promesas rechazadas, que es la que caza los fallos silenciosos— y la disciplina de no meter jamás datos personales en el contexto del registro.

Y tienes el ritmo: incrementos de medio día que dejan la aplicación funcionando, commits pequeños que hacen útil git bisect, la regla de no refactorizar y añadir funcionalidad en el mismo commit, las siete señales objetivas de cuándo refactorizar —y la de cuándo no—, la lista de doce comprobaciones para cerrar un incremento, y un método concreto para los atascos: las tres preguntas por escrito, la caja de tiempo de 45 minutos con cambio obligatorio de estrategia, y reproducir en aislamiento, que convierte «los números del informe están raros» en «con tres tareas devuelve 30 en vez de 15» y, de paso, te regala la prueba de regresión.

El hito H3 está cerrado: el dominio en verde y la primera funcionalidad completa de punta a punta en el navegador. Pero hay algo que sigue siendo mentira: al recargar la página, todo desaparece. El repositorio en memoria hizo su trabajo —no bloquearte— y ahora toca sustituirlo sin tocar una sola línea de vista, que es exactamente para lo que definiste el contrato. Eso, más versionar el formato guardado y migrarlo sin perder datos, hablar con una API de verdad con sus estados y sus errores, y trabajar sin conexión con una cola de cambios pendientes, es Persistencia y Sincronización de Datos.

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