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
IntersectionObserver: reaccionar a lo que se veMutationObserveryResizeObserver- Permisos: la regla de oro
Notification: avisar sin molestar- Geolocation y el peso de los datos personales
- Clipboard API: copiar el resumen
- History API: filtros enlazables
matchMedia: tema y movimientonavigator.share: compartir de verdadrequestAnimationFramefrente asetTimeoutIntl: internacionalización de verdad- Reescribir
util/formato.jsconIntl - Tabla resumen y mejora progresiva
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
IntersectionObserver: reaccionar a lo que se ve
IntersectionObserver: reaccionar a lo que se veEl 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 tiempoTres 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.
MutationObserver y ResizeObserver
MutationObserver y ResizeObserverDos 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.
- 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
deniedes 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.
Notification: avisar sin molestar
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).
- 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.
- 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.
- 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:
pushStatecon cada tecla es una tortura. El usuario tendría que pulsar «atrás» quince veces para salir del buscador. Para escritura continua,replaceState.popstateno se dispara conpushState/replaceState. Solo cuando el usuario navega. Tú ya sabes lo que acabas de hacer, exactamente igual que el eventostoragede 07-01 no se disparaba en la pestaña que escribió.evento.statepuede sernull, 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.
matchMedia: tema y movimiento
matchMedia: tema y movimientoLas 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.
navigator.share: compartir de verdad
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.
requestAnimationFrame frente a setTimeout
requestAnimationFrame frente a setTimeoutRetomando 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 | Sí |
| 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
transformyopacity. Son las dos propiedades que el navegador puede animar sin recalcular el diseño de la página. Animarwidth,topomarginfuerza 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.
Intl: internacionalización de verdad
Intl: internacionalización de verdadAquí 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'] ← correctoLas 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.
- Reescribir
util/formato.js con Intl
util/formato.js con IntlAhora 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ñadeT00:00:00: así se interpreta como hora local. Es el error de "un día menos" más frecuente de JavaScript.Intlformatea, no calcula. Sumar meses o días sigue siendo trabajo tuyo (o de la futura APITemporal). EldiasEntredeutil/fechas.jssigue siendo necesario.
- 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 obsoletoY 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
bodyde una notificación. Aparecen en la pantalla de bloqueo. watchPositionsinclearWatch. 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". pushStatecon cada tecla. El botón «atrás» queda inservible.replaceState.- Esperar que
popstatese dispare conpushState. No lo hace, igual que el eventostoragede 07-01. - Suponer que
evento.statenunca esnull. 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
setIntervalsumando píxeles por fotograma. La velocidad depende del monitor. Usa la marca de tiempo. - Animar
width,topomargin. Fuerza recálculo de diseño en cada fotograma.transformyopacity. - Crear un
Intl.DateTimeFormatdentro 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
matchMediaesparcido 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
LOCALa'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()ounobserve(), 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
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
