Proyecto 2: Aplicación de Tareas (To-Do List) Completa
Objetivos de aprendizajeAl finalizar este proyecto serás capaz de:
- Construir una aplicación CRUD completa sin frameworks
- Implementar persistencia con localStorage
- Crear un sistema de filtrado y búsqueda en tiempo real
- Aplicar drag & drop nativo para reordenar tareas
- Estructurar código JavaScript de forma modular y mantenible
Descripción del proyecto
Esta no es la típica to-do list de 20 líneas. Vamos a construir una aplicación de gestión de tareas completa con las siguientes funcionalidades:
Funcionalidades
- CRUD completo: Crear, leer, actualizar y eliminar tareas
- Categorías y prioridades: Organizar tareas por tipo y urgencia
- Filtros: Todas, pendientes, completadas, por categoría y por prioridad
- Búsqueda: Filtrar tareas en tiempo real mientras escribís
- Persistencia: Las tareas se guardan en localStorage
- Estadísticas: Contador de tareas, porcentaje completado
- Edición inline: Doble click para editar el texto de una tarea
- Fechas: Fecha de creación y fecha límite opcional
- Modo oscuro/claro: Toggle de tema guardado en localStorage
Arquitectura del código
Vamos a usar un patrón MVC simplificado:
// ============================================
// TO-DO APP - Arquitectura modular
// ============================================
// ===== MODELO (datos y lógica) =====
const Model = {
tareas: [],
filtroActual: "todas",
busqueda: "",
init() {
const guardadas = localStorage.getItem("tareas");
this.tareas = guardadas ? JSON.parse(guardadas) : [];
},
guardar() {
localStorage.setItem("tareas", JSON.stringify(this.tareas));
},
agregar(texto, categoria = "general", prioridad = "media", fechaLimite = null) {
const tarea = {
id: Date.now().toString(36) + Math.random().toString(36).slice(2),
texto: texto.trim(),
completada: false,
categoria,
prioridad,
fechaCreacion: new Date().toISOString(),
fechaLimite,
fechaCompletada: null
};
this.tareas.unshift(tarea);
this.guardar();
return tarea;
},
eliminar(id) {
this.tareas = this.tareas.filter(t => t.id !== id);
this.guardar();
},
toggleCompletada(id) {
const tarea = this.tareas.find(t => t.id === id);
if (tarea) {
tarea.completada = !tarea.completada;
tarea.fechaCompletada = tarea.completada ? new Date().toISOString() : null;
this.guardar();
}
return tarea;
},
actualizar(id, cambios) {
const tarea = this.tareas.find(t => t.id === id);
if (tarea) {
Object.assign(tarea, cambios);
this.guardar();
}
return tarea;
},
obtenerFiltradas() {
let resultado = [...this.tareas];
// Filtro por estado
if (this.filtroActual === "pendientes") {
resultado = resultado.filter(t => !t.completada);
} else if (this.filtroActual === "completadas") {
resultado = resultado.filter(t => t.completada);
} else if (this.filtroActual !== "todas") {
resultado = resultado.filter(t => t.categoria === this.filtroActual);
}
// Filtro por búsqueda
if (this.busqueda) {
const termino = this.busqueda.toLowerCase();
resultado = resultado.filter(t =>
t.texto.toLowerCase().includes(termino)
);
}
return resultado;
},
estadisticas() {
const total = this.tareas.length;
const completadas = this.tareas.filter(t => t.completada).length;
const pendientes = total - completadas;
const porcentaje = total > 0 ? Math.round((completadas / total) * 100) : 0;
const porCategoria = this.tareas.reduce((acc, t) => {
acc[t.categoria] = (acc[t.categoria] || 0) + 1;
return acc;
}, {});
return { total, completadas, pendientes, porcentaje, porCategoria };
},
limpiarCompletadas() {
this.tareas = this.tareas.filter(t => !t.completada);
this.guardar();
}
};
// ===== VISTA (renderizado) =====
const Vista = {
elementos: {},
init() {
this.elementos = {
formTarea: document.getElementById("formTarea"),
inputTarea: document.getElementById("inputTarea"),
selectCategoria: document.getElementById("selectCategoria"),
selectPrioridad: document.getElementById("selectPrioridad"),
inputFechaLimite: document.getElementById("inputFechaLimite"),
listaTareas: document.getElementById("listaTareas"),
filtros: document.getElementById("filtros"),
inputBusqueda: document.getElementById("inputBusqueda"),
statsTotal: document.getElementById("statsTotal"),
statsCompletadas: document.getElementById("statsCompletadas"),
statsPendientes: document.getElementById("statsPendientes"),
statsPorcentaje: document.getElementById("statsPorcentaje"),
barraProgreso: document.getElementById("barraProgreso"),
btnLimpiarCompletadas: document.getElementById("btnLimpiarCompletadas"),
contadorResultados: document.getElementById("contadorResultados"),
};
},
renderizarTareas(tareas) {
const { listaTareas, contadorResultados } = this.elementos;
if (tareas.length === 0) {
listaTareas.innerHTML = `
<div class="empty-state">
<p>No hay tareas ${Model.filtroActual !== "todas" ? "con este filtro" : "todavía"}</p>
<p class="hint">${Model.filtroActual === "todas" ? "Agregá una nueva tarea arriba" : "Probá otro filtro"}</p>
</div>
`;
contadorResultados.textContent = "";
return;
}
contadorResultados.textContent = `${tareas.length} tarea${tareas.length !== 1 ? "s" : ""}`;
const prioridadClase = { alta: "priority-high", media: "priority-medium", baja: "priority-low" };
const prioridadEmoji = { alta: "!", media: "~", baja: "-" };
listaTareas.innerHTML = tareas.map(t => `
<li class="tarea ${t.completada ? 'completada' : ''} ${prioridadClase[t.prioridad]}"
data-id="${t.id}" draggable="true">
<div class="tarea-check">
<input type="checkbox" ${t.completada ? 'checked' : ''} class="checkbox">
</div>
<div class="tarea-content">
<span class="tarea-texto" title="Doble click para editar">${this.escaparHtml(t.texto)}</span>
<div class="tarea-meta">
<span class="tag tag-${t.categoria}">${t.categoria}</span>
<span class="tag tag-prioridad">${prioridadEmoji[t.prioridad]} ${t.prioridad}</span>
<span class="fecha">${this.formatearFecha(t.fechaCreacion)}</span>
${t.fechaLimite ? `<span class="fecha-limite ${this.estaVencida(t) ? 'vencida' : ''}">Limite: ${this.formatearFecha(t.fechaLimite)}</span>` : ''}
</div>
</div>
<button class="btn-eliminar" title="Eliminar">x</button>
</li>
`).join("");
},
renderizarEstadisticas(stats) {
const { statsTotal, statsCompletadas, statsPendientes, statsPorcentaje, barraProgreso } = this.elementos;
statsTotal.textContent = stats.total;
statsCompletadas.textContent = stats.completadas;
statsPendientes.textContent = stats.pendientes;
statsPorcentaje.textContent = `${stats.porcentaje}%`;
barraProgreso.style.width = `${stats.porcentaje}%`;
},
resaltarFiltroActivo(filtro) {
const botones = this.elementos.filtros.querySelectorAll(".filtro-btn");
botones.forEach(btn => {
btn.classList.toggle("activo", btn.dataset.filtro === filtro);
});
},
escaparHtml(texto) {
const div = document.createElement("div");
div.textContent = texto;
return div.innerHTML;
},
formatearFecha(fechaISO) {
if (!fechaISO) return "";
const fecha = new Date(fechaISO);
const hoy = new Date();
const diffDias = Math.floor((hoy - fecha) / (1000 * 60 * 60 * 24));
if (diffDias === 0) return "Hoy";
if (diffDias === 1) return "Ayer";
if (diffDias < 7) return `Hace ${diffDias} días`;
return fecha.toLocaleDateString("es-AR", { day: "numeric", month: "short" });
},
estaVencida(tarea) {
if (!tarea.fechaLimite || tarea.completada) return false;
return new Date(tarea.fechaLimite) < new Date();
}
};
// ===== CONTROLADOR (lógica de interacción) =====
const Controlador = {
init() {
Model.init();
Vista.init();
this.bindEventos();
this.actualizar();
},
bindEventos() {
const { formTarea, listaTareas, filtros, inputBusqueda, btnLimpiarCompletadas } = Vista.elementos;
// Agregar tarea
formTarea.addEventListener("submit", (e) => {
e.preventDefault();
const texto = Vista.elementos.inputTarea.value.trim();
if (!texto) return;
const categoria = Vista.elementos.selectCategoria.value;
const prioridad = Vista.elementos.selectPrioridad.value;
const fechaLimite = Vista.elementos.inputFechaLimite.value || null;
Model.agregar(texto, categoria, prioridad, fechaLimite);
formTarea.reset();
this.actualizar();
});
// Event delegation para la lista
listaTareas.addEventListener("click", (e) => {
const li = e.target.closest(".tarea");
if (!li) return;
const id = li.dataset.id;
if (e.target.classList.contains("checkbox")) {
Model.toggleCompletada(id);
this.actualizar();
}
if (e.target.classList.contains("btn-eliminar")) {
li.style.animation = "slideOut 0.3s ease forwards";
setTimeout(() => {
Model.eliminar(id);
this.actualizar();
}, 300);
}
});
// Doble click para editar
listaTareas.addEventListener("dblclick", (e) => {
const textoSpan = e.target.closest(".tarea-texto");
if (!textoSpan) return;
const li = textoSpan.closest(".tarea");
const id = li.dataset.id;
const input = document.createElement("input");
input.type = "text";
input.value = textoSpan.textContent;
input.className = "edit-input";
textoSpan.replaceWith(input);
input.focus();
input.select();
const guardar = () => {
const nuevoTexto = input.value.trim();
if (nuevoTexto) {
Model.actualizar(id, { texto: nuevoTexto });
}
this.actualizar();
};
input.addEventListener("blur", guardar);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") guardar();
if (e.key === "Escape") this.actualizar();
});
});
// Filtros
filtros.addEventListener("click", (e) => {
const btn = e.target.closest(".filtro-btn");
if (!btn) return;
Model.filtroActual = btn.dataset.filtro;
this.actualizar();
});
// Búsqueda con debounce
let timerBusqueda;
inputBusqueda.addEventListener("input", (e) => {
clearTimeout(timerBusqueda);
timerBusqueda = setTimeout(() => {
Model.busqueda = e.target.value;
this.actualizar();
}, 200);
});
// Limpiar completadas
btnLimpiarCompletadas.addEventListener("click", () => {
if (confirm("¿Eliminar todas las tareas completadas?")) {
Model.limpiarCompletadas();
this.actualizar();
}
});
// Atajos de teclado
document.addEventListener("keydown", (e) => {
if (e.key === "/" && document.activeElement.tagName !== "INPUT") {
e.preventDefault();
Vista.elementos.inputBusqueda.focus();
}
});
},
actualizar() {
const tareasFiltradas = Model.obtenerFiltradas();
Vista.renderizarTareas(tareasFiltradas);
Vista.renderizarEstadisticas(Model.estadisticas());
Vista.resaltarFiltroActivo(Model.filtroActual);
}
};
// Inicializar la aplicación
Controlador.init();
Conceptos aplicados
| Concepto | Dónde se aplica |
|---|---|
| MVC pattern | Model, Vista, Controlador separados |
| localStorage | Persistencia de datos entre sesiones |
| Event delegation | Un listener para toda la lista de tareas |
| Debounce | Búsqueda en tiempo real |
| CRUD | Crear, leer, actualizar, eliminar tareas |
| Arrays (filter, map, reduce) | Filtrado, renderizado, estadísticas |
| Objetos | Modelo de datos, configuración |
| Template literals | Renderizado HTML dinámico |
| Closures | Timer de debounce, event handlers |
| Destructuring | Acceso a elementos del DOM |
| Spread operator | Copia de arrays para filtrar |
| Ternary operator | Renderizado condicional |
Desafíos extra
- Drag & Drop: Reordenar tareas arrastrándolas
- Subtareas: Agregar subtareas dentro de cada tarea
- Exportar/Importar: Exportar tareas como JSON y poder importarlas
- Notificaciones: Alertar cuando una tarea está por vencer
- Modo Pomodoro: Integrar un timer de 25 minutos para cada tarea
Revisemos los puntos más importantes de esta lección antes de continuar.
Quiz de autoevaluación
1. ¿Por qué usamos localStorage en vez de una variable global?
a) Porque es más rápido
b) Para que los datos persistan entre sesiones del navegador
c) Porque las variables globales no existen
d) Por seguridad
2. ¿Qué ventaja tiene el patrón MVC en una aplicación?
a) Es más rápido
b) Separa datos, presentación y lógica, haciendo el código más mantenible
c) Usa menos memoria
d) Es obligatorio en JavaScript
3. ¿Por qué usamos debounce en la búsqueda?
a) Para que sea más lento
b) Para no filtrar en cada tecla presionada, mejorando rendimiento
c) Porque filter no funciona sin debounce
d) Es un requisito de localStorage
Respuestas: 1-b, 2-b, 3-b
- Comprendo el concepto principal y puedo explicarlo con mis palabras
- Entiendo cómo aplicarlo en mi situación específica
- Necesito repasar algunas partes antes de continuar
- Quiero ver más ejemplos prácticos del tema