Nómada Tareas ya recuerda, habla con un servidor, aguanta los fallos de red, se sincroniza en vivo y funciona sin conexión. Lo que queda son las piezas medianas: las que separan una aplicación correcta de una que da gusto usar. Cargar tarjetas solo cuando aparecen en pantalla, hacer que los filtros sean enlazables y sobrevivan a la recarga, copiar el resumen del tablero de un clic, respetar que Marta trabaje en modo oscuro o que Lucía haya pedido menos animación, animar sin tirones y —por fin— formatear fechas y números en español de verdad, retirando aquel array de meses escrito a mano en util/fechas.js. Todo esto ya existe en el navegador: no hacen falta librerías. En esta lección recorrerás las APIs que resuelven necesidades reales del proyecto, cada una con su ejemplo, su nota de permisos y su forma de comprobar disponibilidad, porque la regla que gobierna toda la lección es la mejora progresiva: si la API está, mejor; si no, la aplicación sigue funcionando.

Contenido

  1. IntersectionObserver: reaccionar a lo que se ve
  2. MutationObserver y ResizeObserver
  3. Permisos: la regla de oro
  4. Notification: avisar sin molestar
  5. Geolocation y el peso de los datos personales
  6. Clipboard API: copiar el resumen
  7. History API: filtros enlazables
  8. matchMedia: tema y movimiento
  9. navigator.share: compartir de verdad
  10. requestAnimationFrame frente a setTimeout
  11. Intl: internacionalización de verdad
  12. Reescribir util/formato.js con Intl
  13. Tabla resumen y mejora progresiva
  14. Errores Comunes y Consejos
  15. Ejercicios
  16. Conclusión

  1. IntersectionObserver: reaccionar a lo que se ve

El problema: si el tablero tiene doscientas tarjetas, pintarlas todas de golpe es trabajo desperdiciado. La forma antigua de saber qué se ve era escuchar scroll y llamar a getBoundingClientRect() en cada píxel de desplazamiento: costoso, y además fuerza al navegador a recalcular la geometría de la página constantemente.

IntersectionObserver invierte el planteamiento: el navegador te avisa cuando un elemento entra o sale del área visible, de forma asíncrona y sin bloquear el desplazamiento.

// js/vista/carga-progresiva.js
const observador = new IntersectionObserver((entradas, obs) => {
  for (const entrada of entradas) {
    if (!entrada.isIntersecting) continue;         // solo nos interesan las que ENTRAN

    const tarjeta = entrada.target;
    cargarDetalleDeTarea(tarjeta.dataset.id);      // pedir lo caro solo ahora
    obs.unobserve(tarjeta);                        // ← ya no hace falta vigilarla
  }
}, {
  root: null,          // null = el viewport; también puede ser un contenedor con scroll
  rootMargin: '200px', // empieza a cargar 200 px ANTES de que se vea: se percibe instantáneo
  threshold: 0.1       // basta con que se vea el 10 % del elemento
});

document.querySelectorAll('.tarea[data-id]').forEach((t) => observador.observe(t));
Opción Qué controla Valores útiles
root Respecto a qué se mide null (viewport) o un elemento con scroll
rootMargin Margen que agranda o encoge el área '200px' para precargar; '-50px' para exigir más entrada
threshold Cuánto del elemento debe verse 0 (un píxel), 0.5 (mitad), [0, 0.5, 1] (varios avisos)

Y los datos que trae cada entrada:

entrada.isIntersecting     // true si está visible según el threshold
entrada.intersectionRatio  // 0 a 1: qué proporción se ve
entrada.target             // el elemento observado
entrada.boundingClientRect // su geometría, sin forzar recálculo
entrada.time               // marca de tiempo

Tres usos frecuentes más allá de la carga perezosa:

// A · Scroll infinito: cargar la página siguiente al llegar al centinela
const centinela = document.querySelector('#fin-de-lista');
new IntersectionObserver(([e]) => { if (e.isIntersecting) cargarSiguientePagina(); })
  .observe(centinela);

// B · Animar al aparecer (respetando la preferencia del usuario, apartado 8)
new IntersectionObserver((entradas) => {
  for (const e of entradas) e.target.classList.toggle('visible', e.isIntersecting);
}, { threshold: 0.25 }).observe(seccion);

// C · Cabecera que se pega al hacer scroll, sin escuchar 'scroll'
new IntersectionObserver(([e]) => cabecera.classList.toggle('fijada', !e.isIntersecting))
  .observe(document.querySelector('#sentinela-superior'));

Permisos: ninguno. Privacidad: ninguna implicación.

Para imágenes, el navegador ya trae loading="lazy" nativo y no necesitas observador. Y la carga perezosa de código —dividir el JavaScript para no descargarlo todo de golpe— es otro tema distinto que se trata en 09-05.

  1. MutationObserver y ResizeObserver

Dos hermanos del anterior, con el mismo patrón: new Observador(callback), observe(elemento, opciones), disconnect().

MutationObserver avisa cuando el DOM cambia: se añaden o quitan nodos, cambia un atributo, cambia el texto.

const vigilante = new MutationObserver((mutaciones) => {
  for (const mutacion of mutaciones) {
    if (mutacion.type === 'childList') {
      console.log(`+${mutacion.addedNodes.length} −${mutacion.removedNodes.length} tarjetas`);
    }
    if (mutacion.type === 'attributes' && mutacion.attributeName === 'data-estado') {
      console.log('La tarjeta cambió de columna:', mutacion.target.dataset.id);
    }
  }
});

vigilante.observe(document.querySelector('#lista-tareas'), {
  childList: true, subtree: true, attributes: true, attributeFilter: ['data-estado']
});

Una advertencia de arquitectura: en tu propia aplicación casi nunca lo necesitas. Si tú controlas el render, ya sabes cuándo cambia el DOM: por eso emites CustomEvent (06-04). MutationObserver es para lo que no controlas —contenido de terceros, un editor enriquecido, una extensión—, o para escribir herramientas. Usarlo para reaccionar a tus propios cambios es una señal de que el flujo de datos se ha perdido.

ResizeObserver avisa cuando un elemento cambia de tamaño, cosa que window.onresize no cubre: un panel puede cambiar de ancho sin que la ventana se mueva.

const observadorTamano = new ResizeObserver((entradas) => {
  for (const entrada of entradas) {
    const ancho = entrada.contentRect.width;
    // Columnas compactas cuando el tablero es estrecho, sin depender del ancho de la ventana
    entrada.target.classList.toggle('columna--compacta', ancho < 320);
  }
});

document.querySelectorAll('.columna').forEach((c) => observadorTamano.observe(c));

Cuidado con el bucle: si dentro del callback cambias el tamaño del propio elemento observado, provocas una cascada infinita. El navegador la corta con el aviso ResizeObserver loop limit exceeded, que en consola parece un error misterioso y significa exactamente eso.

Permisos: ninguno en ambos casos.

  1. Permisos: la regla de oro

Varias de las APIs siguientes exigen el consentimiento del usuario, y la forma de pedirlo determina si te lo dan.

// Consultar sin pedir nada (no todas las APIs lo soportan)
const estado = await navigator.permissions.query({ name: 'geolocation' });
console.log(estado.state);          // 'granted' | 'denied' | 'prompt'

estado.addEventListener('change', () => console.log('Cambió a', estado.state));
Estado Significa Qué hacer
'granted' Concedido Usar la API
'denied' Denegado No volver a pedirlo: el navegador ni siquiera mostrará el diálogo
'prompt' Sin decidir Puedes pedirlo, en el momento adecuado

Las cuatro reglas que hay que seguir siempre:

  • Nunca pidas un permiso al cargar la página. El usuario no sabe todavía quién eres ni para qué lo quieres. La respuesta abrumadoramente mayoritaria es «Bloquear».
  • Pide en respuesta a una acción. El usuario pulsa «Avisarme cuando venza una tarea» → entonces pides notificaciones. El contexto explica el porqué.
  • Explica antes de pedir. Una frase tuya antes del diálogo del navegador multiplica la aceptación, porque el diálogo nativo no puede explicar nada.
  • Un denied es definitivo. No se puede volver a preguntar por código; solo el usuario puede revertirlo desde la configuración del navegador. Diseña la aplicación para funcionar sin el permiso.
/** Patrón correcto: explicar, pedir en un gesto, y aceptar el no. */
async function activarAvisos() {
  if (Notification.permission === 'denied') {
    mostrarAyuda('Has bloqueado las notificaciones. Puedes reactivarlas desde el candado de la barra de direcciones.');
    return false;
  }
  if (Notification.permission === 'granted') return true;

  const acepta = await mostrarDialogoPropio({
    titulo: 'Avisos de vencimiento',
    texto: 'Te avisaremos cuando una tarea del taller esté a punto de vencer. Nada más.'
  });
  if (!acepta) return false;                       // ni siquiera llegamos a pedirlo al navegador

  return (await Notification.requestPermission()) === 'granted';
}

Ese diálogo propio previo se llama a veces «pre-permiso», y su valor es doble: explica el motivo, y si el usuario dice que no, no gastas tu única oportunidad con el navegador.

  1. Notification: avisar sin molestar

// 1 · Estado actual, sin pedir nada
console.log(Notification.permission);      // 'default' | 'granted' | 'denied'

// 2 · Pedir permiso (solo dentro de un gesto del usuario)
const resultado = await Notification.requestPermission();

// 3 · Mostrar
if (resultado === 'granted') {
  const aviso = new Notification('Nómada Tareas', {
    body: '«Presupuesto de la carpintería» vence hoy.',
    icon: '/iconos/icono-192.png',
    badge: '/iconos/badge.png',
    tag: 'vence-6',                        // ← mismo tag = reemplaza, no acumula
    requireInteraction: false,
    silent: false
  });

  aviso.addEventListener('click', () => {
    window.focus();
    document.querySelector('[data-id="6"]')?.scrollIntoView({ behavior: 'smooth' });
    aviso.close();
  });
}
Opción Para qué
body El texto
icon Icono grande
badge Icono monocromo pequeño (móvil)
tag Agrupa: una notificación con el mismo tag sustituye a la anterior
requireInteraction Permanece hasta que el usuario la cierre
silent Sin sonido
data Datos propios que recuperas en el click

El tag es la diferencia entre una aplicación educada y una plaga: sin él, cinco comprobaciones de vencimiento producen cinco notificaciones idénticas apiladas.

En el proyecto, aplicado a la regla R10 (tarea vencida):

// js/vista/avisos.js
export function avisarDeVencimientos(tablero, hoy) {
  if (Notification.permission !== 'granted') return;      // sin permiso, silencio absoluto

  const criticas = tablero.filtrar((t) => t.abierta && t.diasRestantes <= 0);
  if (criticas.length === 0) return;

  const aviso = new Notification('Nómada Tareas', {
    body: criticas.length === 1
      ? `«${criticas[0].titulo}» ha vencido.`
      : `${criticas.length} tareas han vencido.`,
    icon: '/iconos/icono-192.png',
    tag: 'vencimientos'                                   // siempre la misma: nunca se acumulan
  });
  aviso.addEventListener('click', () => { window.focus(); aviso.close(); });
}

Permisos: sí, explícito. Privacidad: el texto se muestra en la pantalla de bloqueo del dispositivo; no pongas datos sensibles en el body. «Tienes un mensaje nuevo» es mejor que reproducir su contenido en una pantalla que puede ver cualquiera.

En móvil, las notificaciones suelen requerir un service worker (registration.showNotification), y con la aplicación cerrada hace falta push (07-05).

  1. Geolocation y el peso de los datos personales

if ('geolocation' in navigator) {
  navigator.geolocation.getCurrentPosition(
    (posicion) => {
      const { latitude, longitude, accuracy } = posicion.coords;
      console.log(`Estás a ${accuracy} m de ${latitude}, ${longitude}`);
    },
    (error) => {
      const motivos = {
        1: 'Permiso denegado',
        2: 'Posición no disponible',
        3: 'Se agotó el tiempo de espera'
      };
      console.warn(motivos[error.code] ?? error.message);
    },
    { enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 }
  );
}

Fíjate en que es una API de callbacks, del estilo de 05-05, anterior a las promesas. Se envuelve fácilmente:

const posicionActual = (opciones) => new Promise((resolver, rechazar) =>
  navigator.geolocation.getCurrentPosition(resolver, rechazar, opciones)
);

try {
  const { coords } = await posicionActual({ timeout: 8000 });
} catch (error) {
  if (error.code === 1) mostrar('Necesitamos tu ubicación para fichar la entrada al taller.');
}
Opción Efecto
enableHighAccuracy Usa GPS: más preciso, más batería y más lento
timeout Máximo a esperar (ms)
maximumAge Acepta una posición cacheada de hasta N ms

watchPosition(exito, error, opciones) sigue la posición y devuelve un id que se cancela con clearWatch(id). Cancélalo siempre al salir de la pantalla: un seguimiento olvidado consume batería sin parar.

Permisos: sí, explícito y muy visible. Privacidad: la más delicada de toda la lección. La ubicación es un dato personal en el sentido pleno de la normativa de protección de datos: revela dónde vive, dónde trabaja y por dónde se mueve una persona. Antes de guardar una sola coordenada en un producto real hacen falta base legal, finalidad declarada, plazo de conservación y, casi siempre, revisión legal y de compliance. Recoge la mínima precisión que te sirva, no la máxima disponible; no la guardes si te basta con usarla en el momento; y no la envíes a terceros. En Nómada Tareas no usamos geolocalización: Marta, Iván y Lucía son personas ficticias, pero el hábito de no recoger lo que no necesitas se entrena con ejemplos ficticios.

  1. Clipboard API: copiar el resumen

Marta quiere pegar el resumen del tablero en el correo semanal.

// js/vista/portapapeles.js
export async function copiarResumen(tablero, hoy) {
  const r = tablero.resumen(hoy);
  const texto = [
    `Taller Nómada — resumen del ${hoy}`,
    `Tareas: ${r.total} (${r.abiertas} abiertas)`,
    `Horas: ${r.horasTotales} totales, ${r.horasAbiertas} abiertas`,
    `Vencidas: ${r.vencidas}`,
    `Esfuerzo ponderado: ${r.esfuerzo}`
  ].join('\n');

  try {
    await navigator.clipboard.writeText(texto);
    return true;
  } catch (error) {
    console.warn('[nomada] No se pudo copiar:', error);
    return false;
  }
}
// Debe llamarse desde un GESTO del usuario, no desde un temporizador
$('#copiar-resumen').addEventListener('click', async () => {
  const copiado = await copiarResumen(tablero, HOY);
  $('#aviso-copia').textContent = copiado
    ? 'Resumen copiado al portapapeles.'
    : 'No se pudo copiar. Selecciona el texto y usa Ctrl+C.';
  $('#aviso-copia').hidden = false;              // con role="status" para que se anuncie
});
Método Qué hace Requisitos
clipboard.writeText(texto) Copia texto Gesto del usuario, HTTPS
clipboard.readText() Lee el portapapeles Permiso explícito; muy restringido
clipboard.write([items]) Copia formatos ricos (HTML, imágenes) Gesto, HTTPS

Por qué exige un gesto: sin esa restricción, cualquier página podría sobrescribir tu portapapeles en segundo plano —imagina que estás copiando un IBAN— o, peor aún, leer lo que tengas copiado. La lectura está aún más restringida por ese motivo: pide permiso explícito y muchos navegadores muestran un aviso.

Permisos: escribir, no (basta el gesto); leer, sí. Privacidad: el portapapeles puede contener contraseñas y datos bancarios; no lo leas salvo que sea imprescindible y evidente para el usuario.

Un detalle de accesibilidad que se olvida: confirma la copia. Sin confirmación, el usuario no sabe si funcionó, y un role="status" hace que también lo sepa quien no ve la pantalla.

  1. History API: filtros enlazables

Este es el que más mejora Nómada Tareas. Ahora mismo, cuando Marta filtra por Iván y ordena por fecha, esa vista no se puede compartir ni sobrevive a F5: el estado vive solo en memoria. La History API permite reflejarlo en la URL sin recargar la página.

// js/vista/enrutador.js
import { EVENTOS, emitir } from './eventos.js';

/** Lee el estado de la vista desde la URL actual. */
export function leerEstadoDeUrl() {
  const p = new URLSearchParams(location.search);
  return {
    responsable: p.get('responsable'),                    // null si no está
    texto: p.get('q') ?? '',
    orden: p.get('orden') ?? 'prioridad',
    estado: p.get('estado')
  };
}

/** Escribe el estado en la URL. `reemplazar` evita llenar el historial. */
export function escribirEstadoEnUrl(estado, { reemplazar = false } = {}) {
  const p = new URLSearchParams();
  if (estado.responsable) p.set('responsable', estado.responsable);
  if (estado.texto) p.set('q', estado.texto);
  if (estado.orden && estado.orden !== 'prioridad') p.set('orden', estado.orden);
  if (estado.estado) p.set('estado', estado.estado);

  const url = p.toString() ? `${location.pathname}?${p}` : location.pathname;
  const metodo = reemplazar ? 'replaceState' : 'pushState';

  history[metodo](estado, '', url);                       // ← el primer argumento se recupera en popstate
}

/** El usuario pulsó atrás o adelante. */
window.addEventListener('popstate', (evento) => {
  const estado = evento.state ?? leerEstadoDeUrl();       // ← ojo: state puede ser null
  emitir(document, EVENTOS.FILTRO_APLICADO, estado);
});
// js/app.js
// 1 · Al arrancar, la URL manda
vista.actualizar({ filtros: leerEstadoDeUrl() });

// 2 · Cada cambio de filtro se refleja en la URL
document.addEventListener(EVENTOS.FILTRO_APLICADO, (evento) => {
  vista.actualizar({ filtros: evento.detail });
  escribirEstadoEnUrl(evento.detail, { reemplazar: evento.detail.origen === 'teclado' });
});
Método Qué hace Cuándo usarlo
history.pushState(estado, '', url) Añade una entrada al historial Cambios que el usuario querría deshacer con «atrás»: elegir un responsable
history.replaceState(estado, '', url) Sustituye la entrada actual Cambios continuos: cada tecla del buscador
history.back() / forward() / go(n) Navegar por el historial Botones propios
evento popstate El usuario pulsó atrás/adelante Restaurar la vista

Cuatro detalles que evitan errores:

  • pushState con cada tecla es una tortura. El usuario tendría que pulsar «atrás» quince veces para salir del buscador. Para escritura continua, replaceState.
  • popstate no se dispara con pushState/replaceState. Solo cuando el usuario navega. Tú ya sabes lo que acabas de hacer, exactamente igual que el evento storage de 07-01 no se disparaba en la pestaña que escribió.
  • evento.state puede ser null, por ejemplo si se llega a la entrada inicial. Ten siempre el respaldo de leer la URL.
  • La URL debe ser del mismo origen. No puedes falsear el dominio; el navegador lanza un SecurityError.

El beneficio es enorme y barato: Marta puede pegar en el chat del equipo https://nomada.taller/?responsable=Iván&estado=pendiente y todos ven exactamente su misma vista. Y F5 ya no pierde el filtro.

Permisos: ninguno. Privacidad: cuidado con qué metes en la URL, porque acaba en el historial, en los registros del servidor y en la barra de direcciones que puede ver quien pase por detrás. Nunca pongas identificadores personales ni tokens.

  1. matchMedia: tema y movimiento

Las media queries no son solo de CSS: JavaScript puede consultarlas y reaccionar a sus cambios.

const oscuro = window.matchMedia('(prefers-color-scheme: dark)');

console.log(oscuro.matches);                    // true si el sistema está en modo oscuro

oscuro.addEventListener('change', (evento) => { // ← reacciona si el usuario lo cambia sobre la marcha
  aplicarTema(evento.matches ? 'oscuro' : 'claro');
});

Las tres consultas que más importan en una aplicación:

// js/vista/preferencias-sistema.js
export const PREFERENCIAS = {
  temaOscuro:      window.matchMedia('(prefers-color-scheme: dark)'),
  menosMovimiento: window.matchMedia('(prefers-reduced-motion: reduce)'),
  pantallaAncha:   window.matchMedia('(min-width: 900px)')
};

/** Aplica el tema: la preferencia guardada (07-01) gana sobre la del sistema. */
export function aplicarTema() {
  const guardado = leerPreferencias().tema;                    // 'claro' | 'oscuro' | 'sistema'
  const efectivo = guardado === 'sistema'
    ? (PREFERENCIAS.temaOscuro.matches ? 'oscuro' : 'claro')
    : guardado;

  document.documentElement.dataset.tema = efectivo;            // el CSS hace el resto
  document.querySelector('meta[name="theme-color"]')
    ?.setAttribute('content', efectivo === 'oscuro' ? '#1b1f1e' : '#2b6b5b');
}

prefers-reduced-motion no es un detalle estético. Hay personas para quienes una animación de desplazamiento provoca mareo o migraña; el sistema operativo permite pedir que se reduzca el movimiento, y respetarlo es accesibilidad, no cortesía.

/** Mover una tarjeta de columna: con animación, o sin ella si el usuario la evita. */
export function moverTarjeta(tarjeta, destino) {
  if (PREFERENCIAS.menosMovimiento.matches) {
    destino.append(tarjeta);                                   // instantáneo, sin transición
    return;
  }
  tarjeta.animate(
    [{ opacity: 0, transform: 'translateY(-8px)' }, { opacity: 1, transform: 'none' }],
    { duration: 180, easing: 'ease-out' }
  );
  destino.append(tarjeta);
}

Otras consultas útiles: (prefers-contrast: more), (pointer: coarse) para detectar dedo en lugar de ratón, (display-mode: standalone) para saber si la PWA de 07-05 se está ejecutando instalada.

Permisos: ninguno. Privacidad: son señales que contribuyen al fingerprinting del navegador, pero consultarlas para adaptar la interfaz es un uso legítimo y esperado.

  1. navigator.share: compartir de verdad

// js/vista/compartir.js
export async function compartirTablero(tablero, hoy) {
  const datos = {
    title: 'Nómada Tareas',
    text: `Tablero del taller: ${tablero.resumen(hoy).abiertas} tareas abiertas.`,
    url: location.href                          // ← incluye los filtros, gracias al apartado 7
  };

  // 1 · Compartir nativo, si existe (móvil sobre todo)
  if (navigator.canShare?.(datos)) {
    try {
      await navigator.share(datos);
      return 'compartido';
    } catch (error) {
      if (error.name === 'AbortError') return 'cancelado';   // el usuario cerró el diálogo
      console.warn('[nomada] Fallo al compartir:', error);
    }
  }

  // 2 · Respaldo: copiar el enlace al portapapeles (apartado 6)
  await navigator.clipboard.writeText(location.href);
  return 'copiado';
}

Ese es el patrón de mejora progresiva en estado puro: si la API nativa existe, se usa; si no, hay un camino alternativo que resuelve la misma necesidad. El usuario nunca se topa con un botón que no hace nada.

Tres notas: navigator.share requiere un gesto del usuario y HTTPS; su soporte es mucho mejor en móvil que en escritorio; y navigator.canShare(datos) comprueba si esos datos concretos se pueden compartir, cosa que importa al compartir archivos (files), no solo texto.

Permisos: ninguno explícito, pero gesto obligatorio. Privacidad: compartes lo que tú pongas; revisa que la URL no lleve información que el destinatario no deba ver.

  1. requestAnimationFrame frente a setTimeout

Retomando el bucle de eventos de 05-07: para animar, setTimeout es la herramienta equivocada.

// ✗ Con setTimeout: 60 fps "aproximados", desincronizados con el pintado
function animarMal(elemento) {
  let x = 0;
  const id = setInterval(() => {
    x += 2;
    elemento.style.transform = `translateX(${x}px)`;
    if (x >= 200) clearInterval(id);
  }, 16);
}

// ✓ Con requestAnimationFrame: el navegador decide el momento exacto antes de pintar
function animarBien(elemento) {
  let inicio = null;

  function paso(marca) {                        // marca = ms de alta precisión, lo da el navegador
    inicio ??= marca;
    const transcurrido = marca - inicio;
    const progreso = Math.min(transcurrido / 300, 1);       // 300 ms de duración

    elemento.style.transform = `translateX(${progreso * 200}px)`;

    if (progreso < 1) requestAnimationFrame(paso);
  }
  requestAnimationFrame(paso);
}
setTimeout / setInterval requestAnimationFrame
Momento de ejecución Cuando le toca en la cola de macrotareas Justo antes del siguiente pintado
Frecuencia La que pidas, con deriva acumulada La del monitor (60, 120, 144 Hz…)
En pestaña oculta Sigue ejecutándose Se pausa: ahorra batería
Sincronización con el pintado No: provoca fotogramas partidos
Argumento del callback Ninguno Marca de tiempo precisa

Dos claves para animar bien:

  • Basa la animación en el tiempo transcurrido, no en el número de fotogramas. Si sumas 2 píxeles por fotograma, la animación va al doble de velocidad en un monitor de 120 Hz. Calculando el progreso a partir de la marca de tiempo, dura lo mismo en cualquier pantalla.
  • Anima transform y opacity. Son las dos propiedades que el navegador puede animar sin recalcular el diseño de la página. Animar width, top o margin fuerza recálculos en cada fotograma. Esto se estudia a fondo en 09-04.

Y una tercera clave: para animaciones sencillas, la mejor opción suele ser el CSS o la Web Animations API (elemento.animate(...), que ya has usado en el apartado 8). requestAnimationFrame es para lo que necesita cálculo por fotograma. Se cancela con cancelAnimationFrame(id), y hay que hacerlo al desmontar la vista.

Permisos: ninguno.

  1. Intl: internacionalización de verdad

Aquí llega la reparación pendiente. En 05-04 escribiste esto:

// ✗ El array de meses a mano: solo español, sin formatos alternativos, y a mantener
export function fechaLegible(iso) {
  const MESES = ['enero', 'febrero', 'marzo', /* … */];
  const [anio, mes, dia] = iso.split('-');
  return `${Number(dia)} de ${MESES[Number(mes) - 1]} de ${anio}`;
}

El objeto Intl está en el navegador desde hace años y hace eso —y mucho más— correctamente, en cualquier idioma.

Intl.DateTimeFormat

const fecha = new Date('2026-09-05T00:00:00');

new Intl.DateTimeFormat('es-ES', { dateStyle: 'long' }).format(fecha);
// '5 de septiembre de 2026'

new Intl.DateTimeFormat('es-ES', { dateStyle: 'short' }).format(fecha);
// '5/9/26'

new Intl.DateTimeFormat('es-ES', { weekday: 'long', day: 'numeric', month: 'long' }).format(fecha);
// 'sábado, 5 de septiembre'

new Intl.DateTimeFormat('es-ES', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date());
// '20 sept 2026, 10:14'
Opción Valores
dateStyle 'full', 'long', 'medium', 'short'
timeStyle igual
weekday / month 'long', 'short', 'narrow'
day / year / hour / minute 'numeric', '2-digit'
timeZone 'Europe/Madrid', 'UTC'

Intl.NumberFormat

new Intl.NumberFormat('es-ES').format(1234.5);                       // '1234,5'
new Intl.NumberFormat('es-ES', { minimumFractionDigits: 1 }).format(48);   // '48,0'

new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(1250);
// '1250,00 €'

new Intl.NumberFormat('es-ES', { style: 'percent', maximumFractionDigits: 0 }).format(0.9375);
// '94 %'

new Intl.NumberFormat('es-ES', { style: 'unit', unit: 'hour', unitDisplay: 'long' }).format(12);
// '12 horas'

Fíjate en lo que resuelve gratis: la coma decimal, el símbolo de euro detrás del número, el espacio antes del %. Son convenciones del español de España que en inglés son distintas y que, escritas a mano, siempre se acaban equivocando.

Intl.RelativeTimeFormat

Perfecto para la regla R10 y los diasRestantes del modelo:

const relativo = new Intl.RelativeTimeFormat('es-ES', { numeric: 'auto' });

relativo.format(-15, 'day');   // 'hace 15 días'   ← la tarea 6, vencida
relativo.format(10, 'day');    // 'dentro de 10 días'
relativo.format(-1, 'day');    // 'ayer'           ← gracias a numeric: 'auto'
relativo.format(0, 'day');     // 'hoy'
relativo.format(1, 'day');     // 'mañana'
relativo.format(2, 'week');    // 'dentro de 2 semanas'

Ese numeric: 'auto' es el detalle que da naturalidad: con 'always' diría «dentro de 1 día» en lugar de «mañana».

Intl.Collator

Ordenar textos en español con sort() a secas es incorrecto, porque compara por código de carácter:

const nombres = ['Ávila', 'Zurbarán', 'ámbito', 'Barcelona', 'ñu', 'nube'];

console.log([...nombres].sort());
// ['Barcelona', 'Zurbarán', 'nube', 'ámbito', 'ñu', 'Ávila']   ← desastre

const comparador = new Intl.Collator('es-ES', { sensitivity: 'base', numeric: true });
console.log([...nombres].sort(comparador.compare));
// ['ámbito', 'Ávila', 'Barcelona', 'nube', 'ñu', 'Zurbarán']   ← correcto

Las mayúsculas se ordenaban antes que las minúsculas y las tildes iban al final. Con Collator, todo en su sitio.

Opción Efecto
sensitivity: 'base' Ignora tildes y mayúsculas: 'serigrafia' === 'serigrafía'
sensitivity: 'accent' Distingue tildes, ignora mayúsculas
numeric: true 'Tarea 2' antes que 'Tarea 10' (¡el orden natural!)
caseFirst 'upper' o 'lower'

Ese numeric: true merece atención: sin él, 'Tarea 10' va antes que 'Tarea 2', que es el clásico defecto de las listas ordenadas alfabéticamente.

Intl.ListFormat y Intl.PluralRules

new Intl.ListFormat('es-ES', { style: 'long', type: 'conjunction' })
  .format(['Marta', 'Iván', 'Lucía']);
// 'Marta, Iván y Lucía'   ← la 'y' final, gratis

const plural = new Intl.PluralRules('es-ES');
plural.select(1);    // 'one'
plural.select(0);    // 'other'
plural.select(5);    // 'other'

Un aviso de rendimiento importante: crear un formateador de Intl es caro. Nunca lo hagas dentro de un bucle o de una función de render que se llama por cada tarjeta.

// ✗ Crea un formateador por tarjeta, en cada render
tareas.map((t) => new Intl.DateTimeFormat('es-ES', { dateStyle: 'long' }).format(t.fechaLimite));

// ✓ Uno solo, reutilizado
const FECHA_LARGA = new Intl.DateTimeFormat('es-ES', { dateStyle: 'long' });
tareas.map((t) => FECHA_LARGA.format(new Date(t.fechaLimite)));

Permisos: ninguno. Privacidad: Intl.DateTimeFormat().resolvedOptions().timeZone revela la zona horaria del usuario, un dato que contribuye al fingerprinting. Úsalo para formatear, no para perfilar.

  1. Reescribir util/formato.js con Intl

Ahora la reparación completa. Los formateadores se crean una vez al cargar el módulo, y las funciones solo los usan:

// js/util/formato.js — reescrito con Intl
const LOCAL = 'es-ES';

// Formateadores creados UNA vez: crearlos es caro
const FECHA_LARGA  = new Intl.DateTimeFormat(LOCAL, { dateStyle: 'long' });
const FECHA_CORTA  = new Intl.DateTimeFormat(LOCAL, { day: 'numeric', month: 'short' });
const FECHA_HORA   = new Intl.DateTimeFormat(LOCAL, { dateStyle: 'medium', timeStyle: 'short' });
const RELATIVO     = new Intl.RelativeTimeFormat(LOCAL, { numeric: 'auto' });
const NUMERO       = new Intl.NumberFormat(LOCAL, { maximumFractionDigits: 1 });
const HORAS        = new Intl.NumberFormat(LOCAL, { style: 'unit', unit: 'hour', unitDisplay: 'long' });
const PORCENTAJE   = new Intl.NumberFormat(LOCAL, { style: 'percent', maximumFractionDigits: 0 });
const LISTA        = new Intl.ListFormat(LOCAL, { style: 'long', type: 'conjunction' });
const COMPARADOR   = new Intl.Collator(LOCAL, { sensitivity: 'base', numeric: true });

export const MARCAS = Object.freeze({ pendiente: '○', 'en-curso': '▸', hecha: '✓' });
export const PESOS  = Object.freeze({ alta: 3, media: 2, baja: 1 });

export function marcaDeEstado(estado) {
  return MARCAS[estado] ?? '?';
}

/** '2026-09-05' → '5 de septiembre de 2026' */
export function fechaLegible(iso) {
  return FECHA_LARGA.format(new Date(`${iso}T00:00:00`));
}

/** '2026-09-05' → '5 sept' — para las tarjetas, donde el espacio es escaso */
export function fechaBreve(iso) {
  return FECHA_CORTA.format(new Date(`${iso}T00:00:00`));
}

/** -15 → 'hace 15 días' · 0 → 'hoy' · 1 → 'mañana' */
export function diasLegibles(dias) {
  if (Math.abs(dias) >= 14) return RELATIVO.format(Math.round(dias / 7), 'week');
  return RELATIVO.format(dias, 'day');
}

/** 12 → '12 horas' · 1 → '1 hora' — sin el plural() escrito a mano */
export function horasLegibles(horas) {
  return HORAS.format(horas);
}

export function numero(valor) {
  return NUMERO.format(valor);
}

/** 0.9375 → '94 %' */
export function porcentaje(fraccion) {
  return PORCENTAJE.format(fraccion);
}

/** ['Marta','Iván','Lucía'] → 'Marta, Iván y Lucía' */
export function listar(elementos) {
  return LISTA.format(elementos);
}

/** Comparador para sort(): respeta tildes, ñ y números dentro del texto */
export const compararTextos = COMPARADOR.compare;

Y su efecto en la vista, que ahora se lee sola:

// js/vista/tarjeta.js (fragmento)
import { fechaBreve, diasLegibles, horasLegibles } from '../util/formato.js';

const pie = crearElemento('p', { class: 'tarea__pie' });
pie.textContent = `${fechaBreve(tarea.fechaLimite)} · ${diasLegibles(tarea.diasRestantes)} · ${horasLegibles(tarea.horasEstimadas)}`;
// '5 sept · hace 15 días · 5 horas'   ← la tarea 6, vencida (R10)
// js/vista/tablero-vista.js — ordenar por título, correctamente
import { compararTextos } from '../util/formato.js';

const ORDENES = {
  prioridad: (a, b) => PESOS[b.prioridad] - PESOS[a.prioridad],
  fecha:     (a, b) => a.fechaLimite.localeCompare(b.fechaLimite),
  titulo:    (a, b) => compararTextos(a.titulo, b.titulo)      // ← Intl.Collator
};

Se han eliminado el array de meses, la función plural() escrita a mano y el sort() que ponía las tildes al final. Y como propina, cambiar LOCAL a 'ca-ES' o 'en-GB' traduce todos los formatos sin tocar una línea más.

Dos advertencias sobre fechas que ahorran horas de depuración:

  • new Date('2026-09-05') se interpreta como UTC, y en España puede mostrarse como el día 4 a las 22:00. Por eso el código añade T00:00:00: así se interpreta como hora local. Es el error de "un día menos" más frecuente de JavaScript.
  • Intl formatea, no calcula. Sumar meses o días sigue siendo trabajo tuyo (o de la futura API Temporal). El diasEntre de util/fechas.js sigue siendo necesario.

  1. Tabla resumen y mejora progresiva

API Para qué sirve ¿Pide permiso? Notas
IntersectionObserver Saber qué se ve No Sustituye a escuchar scroll
MutationObserver Detectar cambios en el DOM No Para DOM que no controlas
ResizeObserver Detectar cambios de tamaño No Cuidado con el bucle
Notification Avisos del sistema Sí, explícito Usa tag; nada sensible en el body
Geolocation Ubicación Sí, explícito Dato personal: revisión legal
Clipboard Copiar / pegar Escribir no; leer sí Requiere gesto y HTTPS
History URL sin recargar No pushState vs replaceState
matchMedia Consultar media queries No Tema y prefers-reduced-motion
navigator.share Compartir nativo No, pero gesto Sobre todo en móvil
requestAnimationFrame Animar sincronizado No Se pausa en pestaña oculta
Intl Formatos e idioma No Crea los formateadores una vez

Cómo comprobar disponibilidad. El patrón se llama feature detection, y consiste en preguntar por la capacidad, nunca por el navegador:

// ✓ Comprobar la característica
if ('share' in navigator)              { /* … */ }
if ('IntersectionObserver' in window)  { /* … */ }
if ('clipboard' in navigator)          { /* … */ }
if ('serviceWorker' in navigator)      { /* … */ }
if (typeof Intl?.RelativeTimeFormat === 'function') { /* … */ }

// Encadenamiento opcional para métodos concretos
navigator.canShare?.(datos);
registro.sync?.register('enviar');

// ✗ Nunca detectes el navegador por su cadena de identificación
if (navigator.userAgent.includes('Chrome')) { /* … */ }   // frágil, mentiroso y obsoleto

Y el patrón completo de mejora progresiva, con las tres capas:

export function copiarEnlace(url) {
  // Capa 1 · La mejor: la API moderna
  if (navigator.clipboard?.writeText) {
    return navigator.clipboard.writeText(url).then(() => 'copiado');
  }
  // Capa 2 · El respaldo antiguo
  const campo = Object.assign(document.createElement('textarea'), { value: url });
  document.body.append(campo);
  campo.select();
  const ok = document.execCommand?.('copy');            // obsoleto, pero aún funciona
  campo.remove();
  if (ok) return Promise.resolve('copiado');

  // Capa 3 · Que el usuario lo haga a mano
  return Promise.resolve('manual');
}

La aplicación nunca se rompe: en el peor caso, hace menos.

Errores Comunes y Consejos

  • Pedir permisos al cargar la página. La forma más eficaz de que te los denieguen para siempre.
  • Insistir tras un denied. No se puede: el navegador ni siquiera muestra el diálogo. Ofrece instrucciones para reactivarlo.
  • Notificaciones sin tag. Se apilan y el usuario desactiva los avisos.
  • Datos sensibles en el body de una notificación. Aparecen en la pantalla de bloqueo.
  • watchPosition sin clearWatch. Consumo de batería continuo.
  • Copiar al portapapeles fuera de un gesto. Falla silenciosamente o lanza.
  • Copiar sin confirmar. El usuario no sabe si funcionó; usa role="status".
  • pushState con cada tecla. El botón «atrás» queda inservible. replaceState.
  • Esperar que popstate se dispare con pushState. No lo hace, igual que el evento storage de 07-01.
  • Suponer que evento.state nunca es null. Ten siempre el respaldo de leer la URL.
  • Ignorar prefers-reduced-motion. Es un problema de accesibilidad real, no una preferencia estética.
  • Animar con setInterval sumando píxeles por fotograma. La velocidad depende del monitor. Usa la marca de tiempo.
  • Animar width, top o margin. Fuerza recálculo de diseño en cada fotograma. transform y opacity.
  • Crear un Intl.DateTimeFormat dentro de un bucle. Es caro; créalo una vez a nivel de módulo.
  • new Date('2026-09-05') sin hora. Se interpreta como UTC y puede mostrar el día anterior.
  • Ordenar textos en español con sort() a secas. Las tildes acaban al final. Intl.Collator.
  • Detectar el navegador por userAgent. Detecta la característica, no el navegador.
  • Consejo: agrupa las preferencias del sistema en un módulo. Tener matchMedia esparcido por diez ficheros es inmanejable.
  • Consejo: guarda 'sistema' como valor de tema, no solo 'claro'/'oscuro'. Es lo que la mayoría prefiere.
  • Consejo: cambia LOCAL a 'en-GB' un momento y comprueba que toda la interfaz sigue teniendo sentido. Es la mejor prueba de que no quedan formatos escritos a mano.
  • Consejo: desconecta los observadores al desmontar. disconnect() o unobserve(), o mantendrás elementos vivos en memoria (fugas que verás en 09-03).

Ejercicios

Ejercicio 1 — Filtros enlazables completos. Escribe js/vista/enrutador.js con leerEstadoDeUrl(), escribirEstadoEnUrl(estado, opciones) y conectarEnrutador(vista, { signal }). Debe: leer el estado inicial de la URL al arrancar; usar replaceState para el buscador (escritura continua) y pushState para los filtros discretos (responsable, estado, orden); responder a popstate restaurando la vista; y omitir de la URL los valores que sean el valor por defecto, para que un tablero sin filtros tenga una URL limpia. Añade un botón «Copiar enlace de esta vista» que use el portapapeles y confirme de forma accesible.

Ejercicio 2 — Selector de tema de tres estados. Escribe js/vista/tema.js que gestione un selector con tres opciones: claro, oscuro y sistema. Debe aplicar el tema al documentElement mediante data-tema, guardar la elección en localStorage (07-01), reaccionar a los cambios de prefers-color-scheme solo cuando la opción sea sistema, actualizar la <meta name="theme-color"> para que la PWA instalada (07-05) tenga la barra del color correcto, y evitar el destello de tema equivocado al cargar. Cancelable con { signal }.

Ejercicio 3 — Panel de resumen con Intl y copia. Escribe resumenLegible(tablero, hoy) que devuelva un texto multilínea con: la fecha de hoy en formato largo, el total de tareas y horas con Intl.NumberFormat, el porcentaje de completadas, el reparto por responsable ordenado con Intl.Collator, los nombres del equipo unidos con Intl.ListFormat, y las tareas vencidas con su antigüedad en formato relativo. Con el backlog canónico debe producir un resumen coherente con los 48 h totales, 45 h abiertas y la tarea 6 vencida. Añade copiarResumen() con confirmación accesible.

Soluciones

Solución 1

// js/vista/enrutador.js
import { EVENTOS, emitir } from './eventos.js';

const POR_DEFECTO = Object.freeze({ responsable: null, texto: '', orden: 'prioridad', estado: null });

export function leerEstadoDeUrl(url = location.href) {
  const p = new URL(url).searchParams;
  return {
    responsable: p.get('responsable'),
    texto: p.get('q') ?? '',
    orden: p.get('orden') ?? POR_DEFECTO.orden,
    estado: p.get('estado')
  };
}

export function escribirEstadoEnUrl(estado, { reemplazar = false } = {}) {
  const p = new URLSearchParams();
  // Solo lo que difiere del valor por defecto: URLs limpias
  if (estado.responsable) p.set('responsable', estado.responsable);
  if (estado.texto) p.set('q', estado.texto);
  if (estado.orden !== POR_DEFECTO.orden) p.set('orden', estado.orden);
  if (estado.estado) p.set('estado', estado.estado);

  const cadena = p.toString();
  const url = cadena ? `${location.pathname}?${cadena}` : location.pathname;

  if (url === location.pathname + location.search) return;      // nada que cambiar
  history[reemplazar ? 'replaceState' : 'pushState']({ ...estado }, '', url);
}

export function conectarEnrutador(vista, { signal } = {}) {
  vista.actualizar({ filtros: leerEstadoDeUrl() });              // 1 · la URL manda al arrancar

  document.addEventListener(EVENTOS.FILTRO_APLICADO, (evento) => {
    const { origen, ...filtros } = evento.detail;
    vista.actualizar({ filtros });
    escribirEstadoEnUrl(filtros, { reemplazar: origen === 'texto' });   // 2 · reflejar
  }, { signal });

  window.addEventListener('popstate', (evento) => {              // 3 · atrás / adelante
    const estado = evento.state ?? leerEstadoDeUrl();
    vista.actualizar({ filtros: estado });
  }, { signal });
}
document.querySelector('#copiar-enlace').addEventListener('click', async () => {
  const aviso = document.querySelector('#aviso-copia');         // role="status" aria-live="polite"
  try {
    await navigator.clipboard.writeText(location.href);
    aviso.textContent = 'Enlace de esta vista copiado.';
  } catch {
    aviso.textContent = 'No se pudo copiar. El enlace está en la barra de direcciones.';
  }
  aviso.hidden = false;
}, { signal });

El detalle que da calidad es omitir los valores por defecto: sin él, un tablero sin filtrar tendría la URL ?q=&orden=prioridad, fea de compartir y ruidosa en el historial. Y origen === 'texto' es lo que distingue el buscador (que usa replaceState) de los filtros discretos.

Solución 2

// js/vista/tema.js
const CLAVE = 'nomada:tema';
const CONSULTA = window.matchMedia('(prefers-color-scheme: dark)');

const leerPreferencia  = () => localStorage.getItem(CLAVE) ?? 'sistema';
const temaEfectivo = (pref) => (pref === 'sistema' ? (CONSULTA.matches ? 'oscuro' : 'claro') : pref);

const COLORES = Object.freeze({ claro: '#2b6b5b', oscuro: '#1b1f1e' });

export function aplicarTema(preferencia = leerPreferencia()) {
  const efectivo = temaEfectivo(preferencia);
  document.documentElement.dataset.tema = efectivo;
  document.querySelector('meta[name="theme-color"]')?.setAttribute('content', COLORES[efectivo]);
  return efectivo;
}

export function conectarSelectorDeTema(selector, { signal } = {}) {
  selector.value = leerPreferencia();

  selector.addEventListener('change', () => {
    const elegido = selector.value;
    try { localStorage.setItem(CLAVE, elegido); } catch { /* modo privado: no persiste */ }
    aplicarTema(elegido);
  }, { signal });

  // Solo cuando la preferencia es 'sistema' importa lo que haga el sistema
  CONSULTA.addEventListener('change', () => {
    if (leerPreferencia() === 'sistema') aplicarTema('sistema');
  }, { signal });

  aplicarTema();
}
<!-- En <head>, ANTES del CSS: evita el destello de tema equivocado -->
<script>
  (function () {
    var pref = localStorage.getItem('nomada:tema') || 'sistema';
    var oscuro = pref === 'oscuro' ||
      (pref === 'sistema' && matchMedia('(prefers-color-scheme: dark)').matches);
    document.documentElement.dataset.tema = oscuro ? 'oscuro' : 'claro';
  })();
</script>

Ese script en línea es una de las poquísimas excepciones legítimas a la regla de no poner JavaScript en el HTML: si el tema se aplicara desde un módulo, la página se pintaría un instante en claro antes de cambiar a oscuro, y ese destello blanco es muy molesto de noche. Tiene que ejecutarse antes del primer pintado, y por eso es síncrono y va en el <head>.

Solución 3

// js/vista/resumen.js
import { fechaLegible, diasLegibles, numero, porcentaje, listar, compararTextos, horasLegibles }
  from '../util/formato.js';

export function resumenLegible(tablero, hoy) {
  const r = tablero.resumen(hoy);
  const completadas = r.total === 0 ? 0 : (r.total - r.abiertas) / r.total;

  // Horas abiertas por responsable, ordenadas por nombre con el Collator
  const porResponsable = new Map();
  for (const tarea of tablero.filtrar((t) => t.abierta)) {
    const quien = tarea.responsable ?? 'Sin asignar';
    porResponsable.set(quien, (porResponsable.get(quien) ?? 0) + tarea.horasEstimadas);
  }
  const reparto = [...porResponsable.entries()]
    .sort((a, b) => compararTextos(a[0], b[0]))
    .map(([quien, horas]) => `  · ${quien}: ${horasLegibles(horas)}`);

  const equipo = listar([...porResponsable.keys()].sort(compararTextos));

  const vencidas = tablero
    .filtrar((t) => t.abierta && t.diasRestantes < 0)
    .map((t) => `  ! ${t.titulo} — venció ${diasLegibles(t.diasRestantes)}`);

  return [
    `Taller Nómada — ${fechaLegible(hoy)}`,
    '',
    `Tareas: ${numero(r.total)} (${numero(r.abiertas)} abiertas, ${porcentaje(completadas)} completado)`,
    `Horas: ${horasLegibles(r.horasTotales)} totales · ${horasLegibles(r.horasAbiertas)} abiertas`,
    `Esfuerzo ponderado: ${numero(r.esfuerzo)}`,
    '',
    `Equipo: ${equipo}`,
    ...reparto,
    ...(vencidas.length ? ['', `Vencidas (${vencidas.length}):`, ...vencidas] : [])
  ].join('\n');
}
export async function copiarResumen(tablero, hoy, aviso) {
  try {
    await navigator.clipboard.writeText(resumenLegible(tablero, hoy));
    aviso.textContent = 'Resumen copiado al portapapeles.';
  } catch {
    aviso.textContent = 'No se pudo copiar. Selecciona el texto y usa Ctrl+C.';
  }
  aviso.hidden = false;
}

Con el backlog canónico y HOY = '2026-09-20', la salida es:

Taller Nómada — 20 de septiembre de 2026

Tareas: 6 (5 abiertas, 17 % completado)
Horas: 48 horas totales · 45 horas abiertas
Esfuerzo ponderado: 124

Equipo: Iván, Lucía y Marta
  · Iván: 25 horas
  · Lucía: 14 horas
  · Marta: 6 horas

Vencidas (1):
  ! Presupuesto de la carpintería — venció hace 15 días

Todos los números coinciden con el backlog canónico, y todo lo que se lee —la fecha larga, el porcentaje con su espacio, «horas» declinado, la coma y la «y» de la lista, «hace 15 días»— sale de Intl. No queda una sola cadena de formato escrita a mano.

Conclusión

Nómada Tareas ha pasado de funcionar a resultar cómoda. Sabes usar los tres observadores con el mismo patrón —observe, unobserve, disconnect—: IntersectionObserver para saber qué se ve sin escuchar scroll, con su rootMargin que precarga antes de que haga falta; ResizeObserver para adaptarse al tamaño de un elemento y no solo al de la ventana, cuidando el bucle infinito; y MutationObserver para el DOM que no controlas, con la advertencia de que necesitarlo en tu propia aplicación suele indicar que el flujo de datos se ha perdido.

Tienes interiorizada la regla de oro de los permisos: nunca al cargar, siempre en respuesta a un gesto, siempre explicando antes, y aceptando que un denied es definitivo. La aplicaste a Notification —con tag para no apilar avisos y sin datos sensibles en el body, porque se leen en la pantalla de bloqueo— y a Geolocation, la API con más peso legal de todas, donde la lección no es técnica sino de criterio: recoge la mínima precisión que te sirva, no la guardes si no la necesitas, y cuenta con revisión legal y de compliance antes de tocar datos personales reales.

Sabes copiar al portapapeles con navigator.clipboard.writeText, entendiendo por qué exige un gesto del usuario y confirmando siempre de forma accesible. Sabes hacer que los filtros del tablero sean enlazables con pushState para los cambios discretos y replaceState para la escritura continua, restaurando la vista en popstate con el respaldo de leer la URL cuando state es null, y recordando que popstate no se dispara con tus propios cambios —el mismo eco que no se dispara en storage—. Sabes consultar las preferencias del usuario con matchMedia, respetando prefers-color-scheme sin pisar la elección explícita y tratando prefers-reduced-motion como lo que es: accesibilidad. Sabes compartir con navigator.share y caer al portapapeles cuando no está. Y sabes animar con requestAnimationFrame, calculando el progreso a partir de la marca de tiempo para que la duración no dependa del monitor, sobre transform y opacity, con la pausa automática en pestañas ocultas que setInterval no ofrece.

Y has saldado una deuda que arrastrabas desde 05-04: util/formato.js ya no tiene un array de meses escrito a mano, ni una función plural() artesanal, ni un sort() que mandaba las tildes al final. Intl hace ese trabajo mejor y en cualquier idioma: DateTimeFormat con sus dateStyle, NumberFormat con la coma decimal y el euro en su sitio, RelativeTimeFormat con su numeric: 'auto' que dice «ayer» en lugar de «hace 1 día», Collator que ordena «ámbito» antes que «Barcelona» y «Tarea 2» antes que «Tarea 10», y ListFormat que pone la «y» final sola. Con dos precauciones grabadas: crear los formateadores una vez porque son caros, y añadir T00:00:00 a las fechas ISO para no perder un día por la interpretación en UTC. Y sobre todo, el patrón que atraviesa la lección entera: detecta la característica, no el navegador, y construye por capas para que la aplicación nunca se rompa, solo haga menos.

Queda una última lección en este módulo, y es la más distinta de todas. Hasta aquí, todo lo que has visto era JavaScript hablando con el navegador. La siguiente trata de algo que no es JavaScript: un formato binario que se ejecuta en el mismo motor, junto a tu código, y que permite traer al navegador programas escritos en C, C++ o Rust a velocidad casi nativa. Verás qué es, por qué existe, cómo se carga e instancia desde JavaScript, y —lo más importante para un profesional— cuándo no compensa usarlo, porque la mayoría de las aplicaciones, incluida Nómada Tareas, no lo necesitan. Es Introducción a WebAssembly.

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