Volver al curso

JavaScript Desde Cero: Tu Primer Lenguaje de Programación

leccion
20 / 22
beginner
8 horas
Proyectos Prácticos

Proyecto 2: Aplicación de Tareas (To-Do List) Completa

Lectura
50 min~7 min lectura

Proyecto 2: Aplicación de Tareas (To-Do List) Completa

Objetivos de aprendizaje

Al 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

  1. CRUD completo: Crear, leer, actualizar y eliminar tareas
  2. Categorías y prioridades: Organizar tareas por tipo y urgencia
  3. Filtros: Todas, pendientes, completadas, por categoría y por prioridad
  4. Búsqueda: Filtrar tareas en tiempo real mientras escribís
  5. Persistencia: Las tareas se guardan en localStorage
  6. Estadísticas: Contador de tareas, porcentaje completado
  7. Edición inline: Doble click para editar el texto de una tarea
  8. Fechas: Fecha de creación y fecha límite opcional
  9. 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
  1. Drag & Drop: Reordenar tareas arrastrándolas
  2. Subtareas: Agregar subtareas dentro de cada tarea
  3. Exportar/Importar: Exportar tareas como JSON y poder importarlas
  4. Notificaciones: Alertar cuando una tarea está por vencer
  5. Modo Pomodoro: Integrar un timer de 25 minutos para cada tarea

💡 Concepto Clave

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

🧠 Pon a prueba tu conocimiento
¿Cuál es el aspecto más importante que aprendiste en esta lección?
  • 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
✅ ¡Excelente! Continúa con la siguiente lección para profundizar más.