JavaScript de Cero a Experto / APIs del navegador
Reto: Mi Bolsillo que no olvida
La promesa más vieja del curso se cobra hoy: la app interactiva del módulo 8 pierde su amnesia. Gastos que sobreviven a la recarga, con la arquitectura correcta — el array como única fuente de verdad, el DOM como espejo, y localStorage como memoria. Cuando termines, tendrás una app completa Y persistente, construida por ti desde el primer píxel.
Reto
Mi Bolsillo que no olvida
Qué debes construir
La app del módulo 8 (formulario con validación, borrar por delegación, total reactivo)… que ahora RECUERDA. Esta vista previa muestra la app funcionando (sin persistencia — el sandbox de la vista previa no permite localStorage; en TU playground, agrega gastos y usa el botón de refresh del preview para verlos sobrevivir):
<h1>Mi Bolsillo</h1>
<form id="registro">
<input id="nombre" placeholder="Gasto..." />
<input id="valor" type="number" placeholder="Valor" />
<button>Agregar</button>
</form>
<p id="error"></p>
<ul id="lista"></ul>
<p class="resumen">Total: <strong id="total"></strong></p>
<button id="vaciar">Vaciar bolsillo</button>body { font-family: system-ui; padding: 24px; background: #0b1120; color: #f1f5f9; }
h1 { font-size: 24px; margin: 0 0 14px; }
form { display: flex; gap: 8px; }
input { padding: 10px 14px; border: 1px solid #2a3752; border-radius: 8px; background: #151e31; color: #f1f5f9; width: 130px; }
input:focus { outline: 2px solid #7c3aed; }
form button { padding: 10px 18px; border: 0; border-radius: 8px; background: #6d28d9; color: white; font-weight: 600; cursor: pointer; }
#error { color: #ef4444; font-size: 14px; min-height: 20px; margin: 8px 0; }
li { display: flex; align-items: center; gap: 10px; margin: 6px 0; color: #cbd5e1; }
.borrar { background: #151e31; color: #ef4444; border: 1px solid #2a3752; border-radius: 8px; padding: 2px 10px; cursor: pointer; font-weight: 600; }
.resumen { color: #94a3b8; }
.resumen strong { color: #f1f5f9; }
#vaciar { background: #151e31; color: #94a3b8; border: 1px solid #2a3752; border-radius: 8px; padding: 8px 14px; cursor: pointer; }const SEMILLA = [
{ nombre: "Almuerzo", valor: 18000 },
{ nombre: "Bus", valor: 5800 },
{ nombre: "Café", valor: 4500 },
];
const cop = new Intl.NumberFormat("es-CO", {
style: "currency", currency: "COP", maximumFractionDigits: 0,
});
function cargar() {
try {
const texto = localStorage.getItem("bolsillo:gastos");
if (texto !== null) return JSON.parse(texto);
} catch (e) {}
return SEMILLA;
}
function guardar(gastos) {
try {
localStorage.setItem("bolsillo:gastos", JSON.stringify(gastos));
} catch (e) {}
}
let gastos = cargar();
function crearItem(gasto, indice) {
const item = document.createElement("li");
item.dataset.indice = indice;
item.textContent = `${gasto.nombre} — ${cop.format(gasto.valor)} `;
const boton = document.createElement("button");
boton.className = "borrar";
boton.textContent = "✕";
item.append(boton);
return item;
}
function aplicar(nuevos) {
gastos = nuevos;
guardar(gastos);
const lista = document.querySelector("#lista");
lista.textContent = "";
lista.append(...gastos.map(crearItem));
const total = gastos.reduce((acum, g) => acum + g.valor, 0);
document.querySelector("#total").textContent = cop.format(total);
}
aplicar(gastos);
document.querySelector("#registro").addEventListener("submit", (event) => {
event.preventDefault();
const error = document.querySelector("#error");
const nombre = document.querySelector("#nombre").value.trim();
const valor = Number(document.querySelector("#valor").value);
if (nombre === "" || Number.isNaN(valor) || valor <= 0) {
error.textContent = "Escribe un nombre y un valor mayor que cero.";
return;
}
error.textContent = "";
aplicar([...gastos, { nombre, valor }]);
event.target.reset();
document.querySelector("#nombre").focus();
});
document.querySelector("#lista").addEventListener("click", (event) => {
const boton = event.target.closest(".borrar");
if (boton === null) return;
const indice = Number(boton.closest("li").dataset.indice);
aplicar(gastos.filter((g, i) => i !== indice));
});
document.querySelector("#vaciar").addEventListener("click", () => {
aplicar([]);
});Requisitos:
- El par sagrado:
cargar()(lee la llave"bolsillo:gastos", haceJSON.parse, y si el cajón está vacío devuelve la SEMILLA de 3 gastos: Almuerzo 18000, Bus 5800, Café 4500) yguardar(gastos)(JSON.stringify+setItem). - La fuente de verdad es EL ARRAY — cambio de arquitectura respecto
al módulo 8: el estado vive en
gastos, y el DOM es un ESPEJO que se repinta desde él. Nada de leerdatasetpara calcular totales. - UNA función
aplicar(nuevosGastos)concentra todo cambio de estado: reasigna el array,guardar, repinta la lista completa (textContent = ""+map(crearItem)+ un soloappend), y recalcula el total conreduce+Intl. - Agregar:
submit+preventDefault+ validación (nombre contrimno vacío, valor > 0, mensaje en#error) →aplicarcon spread INMUTABLE ([...gastos, nuevo]),reset()yfocus(). - Borrar por delegación (un listener en la
ul,closest, chequeo denull): cadaliguarda su índice endataset.indice, y borrar esaplicar(gastos.filter(...))— inmutable, módulo 12. - Botón "Vaciar bolsillo":
aplicar([]). - La PRUEBA: agrega dos gastos, borra uno, recarga el preview (botón
refresh del playground) — la lista debe volver EXACTA. Vacía, recarga:
vacía se queda (ojo: vacío guardado NO es lo mismo que primera vez —
tu
cargardebe sembrar solo cuando la llave no existe).
Reglas
- Cero
innerHTML; cero mutaciones del array (push/spliceprohibidos: spread yfilter). - TODO cambio de estado pasa por
aplicar— si hay unguardaro un repintado suelto por fuera, está mal. - El formateador
Intlse crea una vez. Patróniniciarpara el DOM.
40:00
Cuando estés listo, arranca el reloj.
Qué sigue
Catorce módulos: lenguaje, DOM, asincronía, patrones y las APIs del
navegador. Tu código ya HACE de todo — el módulo 15 te enseña qué pasa
cuando lo que hace está MAL: manejo de errores en serio (tus propias
clases de Error), y el arte más subestimado del oficio: DEPURAR con
DevTools — breakpoints, watch, y la consola más allá del .log.