Proyecto final JavaScript: aplicación de tareas con DOM, eventos y localStorage

Proyecto final de JavaScript: construí una aplicación de tareas con DOM, eventos, arrays, objetos, filtros y localStorage.


En este proyecto vamos a integrar los conceptos principales de la ruta de JavaScript construyendo una aplicación de tareas.

La aplicación permitirá agregar, completar, eliminar y filtrar tareas. Además guardará los datos en localStorage para conservarlos al recargar la página.

Qué vamos a practicar

  • variables y constantes;
  • funciones;
  • arrays y objetos;
  • métodos de array;
  • DOM;
  • eventos;
  • formularios;
  • delegación de eventos;
  • JSON;
  • localStorage.

Estructura del proyecto

tareas-js/
|-- index.html
|-- styles.css
|-- app.js

HTML

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Mis tareas</title>
    <link rel="stylesheet" href="styles.css">
    <script src="app.js" defer></script>
</head>
<body>

    <main class="app">

        <h1>Mis tareas</h1>

        <form id="form-tarea">
            <label for="titulo">Nueva tarea</label>
            <input
                id="titulo"
                name="titulo"
                type="text"
                maxlength="80"
                required
            >
            <button type="submit">Agregar</button>
        </form>

        <div class="filtros">
            <button data-filtro="todas">Todas</button>
            <button data-filtro="pendientes">Pendientes</button>
            <button data-filtro="completadas">Completadas</button>
        </div>

        <p id="resumen"></p>

        <ul id="lista-tareas"></ul>

    </main>

</body>
</html>

CSS mínimo

body {
    font-family: Arial, sans-serif;
    margin: 0;
    background: #f4f6f8;
}

.app {
    width: 90%;
    max-width: 700px;
    margin: 3rem auto;
}

form,
.filtros {
    display: flex;
    gap: 0.75rem;
    margin-bottom: 1rem;
    flex-wrap: wrap;
}

li {
    display: flex;
    justify-content: space-between;
    gap: 1rem;
    padding: 1rem;
    margin-bottom: 0.75rem;
    background: white;
    border-radius: 8px;
}

.completada {
    text-decoration: line-through;
    opacity: 0.6;
}

Estado inicial

Primero guardamos referencias a los elementos del DOM y definimos el estado de la aplicación: el array de tareas y el filtro activo.

const formulario = document.querySelector("#form-tarea");
const inputTitulo = document.querySelector("#titulo");
const lista = document.querySelector("#lista-tareas");
const resumen = document.querySelector("#resumen");
const filtros = document.querySelector(".filtros");

let tareas = cargarTareas();
let filtroActual = "todas";

Cargar desde localStorage

Al iniciar la aplicación intentamos recuperar las tareas guardadas. Si no existen o el contenido no es válido, comenzamos con un array vacío.

function cargarTareas() {
    const guardadas = localStorage.getItem("tareas");

    if (!guardadas) {
        return [];
    }

    try {
        return JSON.parse(guardadas);
    } catch (error) {
        console.error("Datos inválidos en localStorage", error);
        return [];
    }
}

Guardar tareas

Cada vez que cambia el array de tareas lo convertimos a JSON y lo guardamos. De esta manera los datos sobreviven a una recarga de la página.

function guardarTareas() {
    localStorage.setItem("tareas", JSON.stringify(tareas));
}

localStorage guarda strings. Por eso convertimos el array a JSON antes de almacenarlo y lo reconstruimos con JSON.parse().

Crear una tarea

Centralizamos la creación en una función para que todas las tareas tengan siempre la misma estructura: identificador, título y estado de completada.

function crearTarea(titulo) {
    return {
        id: Date.now(),
        titulo,
        completada: false
    };
}

Filtrar tareas

La función no modifica el array original: usa filter() para devolver únicamente las tareas que corresponden al filtro seleccionado.

function obtenerTareasVisibles() {
    if (filtroActual === "pendientes") {
        return tareas.filter(tarea => !tarea.completada);
    }

    if (filtroActual === "completadas") {
        return tareas.filter(tarea => tarea.completada);
    }

    return tareas;
}

Renderizar

Renderizar significa tomar el estado actual y reflejarlo en la interfaz. Primero limpiamos la lista y después volvemos a crear los elementos visibles.

function renderizar() {
    lista.innerHTML = "";

    const visibles = obtenerTareasVisibles();

    visibles.forEach(tarea => {
        const item = document.createElement("li");

        if (tarea.completada) {
            item.classList.add("completada");
        }

        item.dataset.id = tarea.id;

        const texto = document.createElement("span");
        texto.textContent = tarea.titulo;

        const acciones = document.createElement("div");

        const botonCompletar = document.createElement("button");
        botonCompletar.type = "button";
        botonCompletar.dataset.accion = "completar";
        botonCompletar.textContent = tarea.completada
            ? "Desmarcar"
            : "Completar";

        const botonEliminar = document.createElement("button");
        botonEliminar.type = "button";
        botonEliminar.dataset.accion = "eliminar";
        botonEliminar.textContent = "Eliminar";

        acciones.append(botonCompletar, botonEliminar);
        item.append(texto, acciones);
        lista.append(item);
    });

    const pendientes = tareas.filter(
        tarea => !tarea.completada
    ).length;

    resumen.textContent =
        `${pendientes} tarea(s) pendiente(s)`;
}

Agregar desde el formulario

Interceptamos el envío, validamos el texto, agregamos una nueva tarea al array, guardamos y volvemos a renderizar.

formulario.addEventListener("submit", event => {
    event.preventDefault();

    const titulo = inputTitulo.value.trim();

    if (titulo === "") {
        return;
    }

    tareas.push(crearTarea(titulo));

    guardarTareas();
    renderizar();

    formulario.reset();
    inputTitulo.focus();
});

Completar y eliminar con delegación

En lugar de crear un evento para cada botón, escuchamos un solo click en la lista. Según el atributo data-accion decidimos qué operación ejecutar.

lista.addEventListener("click", event => {
    const boton = event.target.closest("button");

    if (!boton) {
        return;
    }

    const item = boton.closest("li");
    const id = Number(item.dataset.id);
    const accion = boton.dataset.accion;

    if (accion === "completar") {
        tareas = tareas.map(tarea => {
            if (tarea.id === id) {
                return {
                    ...tarea,
                    completada: !tarea.completada
                };
            }

            return tarea;
        });
    }

    if (accion === "eliminar") {
        tareas = tareas.filter(tarea => tarea.id !== id);
    }

    guardarTareas();
    renderizar();
});

Cambiar filtros

Los botones sólo cambian filtroActual. Después llamamos nuevamente a renderizar() para que la interfaz refleje ese nuevo estado.

filtros.addEventListener("click", event => {
    const boton = event.target.closest("[data-filtro]");

    if (!boton) {
        return;
    }

    filtroActual = boton.dataset.filtro;

    renderizar();
});

Inicializar

La primera llamada a renderizar() muestra inmediatamente las tareas recuperadas de localStorage.

renderizar();

app.js completo

const formulario = document.querySelector("#form-tarea");
const inputTitulo = document.querySelector("#titulo");
const lista = document.querySelector("#lista-tareas");
const resumen = document.querySelector("#resumen");
const filtros = document.querySelector(".filtros");

let tareas = cargarTareas();
let filtroActual = "todas";

function cargarTareas() {
    const guardadas = localStorage.getItem("tareas");

    if (!guardadas) {
        return [];
    }

    try {
        return JSON.parse(guardadas);
    } catch (error) {
        console.error("Datos inválidos en localStorage", error);
        return [];
    }
}

function guardarTareas() {
    localStorage.setItem("tareas", JSON.stringify(tareas));
}

function crearTarea(titulo) {
    return {
        id: Date.now(),
        titulo,
        completada: false
    };
}

function obtenerTareasVisibles() {
    if (filtroActual === "pendientes") {
        return tareas.filter(tarea => !tarea.completada);
    }

    if (filtroActual === "completadas") {
        return tareas.filter(tarea => tarea.completada);
    }

    return tareas;
}

function renderizar() {
    lista.innerHTML = "";

    const visibles = obtenerTareasVisibles();

    visibles.forEach(tarea => {
        const item = document.createElement("li");

        if (tarea.completada) {
            item.classList.add("completada");
        }

        item.dataset.id = tarea.id;

        const texto = document.createElement("span");
        texto.textContent = tarea.titulo;

        const acciones = document.createElement("div");

        const completar = document.createElement("button");
        completar.type = "button";
        completar.dataset.accion = "completar";
        completar.textContent = tarea.completada
            ? "Desmarcar"
            : "Completar";

        const eliminar = document.createElement("button");
        eliminar.type = "button";
        eliminar.dataset.accion = "eliminar";
        eliminar.textContent = "Eliminar";

        acciones.append(completar, eliminar);
        item.append(texto, acciones);
        lista.append(item);
    });

    const pendientes = tareas.filter(
        tarea => !tarea.completada
    ).length;

    resumen.textContent =
        `${pendientes} tarea(s) pendiente(s)`;
}

formulario.addEventListener("submit", event => {
    event.preventDefault();

    const titulo = inputTitulo.value.trim();

    if (titulo === "") {
        return;
    }

    tareas.push(crearTarea(titulo));

    guardarTareas();
    renderizar();

    formulario.reset();
    inputTitulo.focus();
});

lista.addEventListener("click", event => {
    const boton = event.target.closest("button");

    if (!boton) {
        return;
    }

    const item = boton.closest("li");
    const id = Number(item.dataset.id);

    if (boton.dataset.accion === "completar") {
        tareas = tareas.map(tarea =>
            tarea.id === id
                ? { ...tarea, completada: !tarea.completada }
                : tarea
        );
    }

    if (boton.dataset.accion === "eliminar") {
        tareas = tareas.filter(tarea => tarea.id !== id);
    }

    guardarTareas();
    renderizar();
});

filtros.addEventListener("click", event => {
    const boton = event.target.closest("[data-filtro]");

    if (!boton) {
        return;
    }

    filtroActual = boton.dataset.filtro;

    renderizar();
});

renderizar();

Qué aprendimos con el proyecto

  • mantener un estado en un array;
  • modelar cada tarea con un objeto;
  • renderizar el estado en el DOM;
  • procesar formularios;
  • usar delegación de eventos;
  • transformar arrays con map y filter;
  • guardar y recuperar JSON con localStorage.

Mejoras para seguir practicando

  • editar tareas;
  • agregar prioridades;
  • incorporar fechas límite;
  • ordenar pendientes y completadas;
  • agregar contador por filtro;
  • confirmar antes de eliminar;
  • reemplazar localStorage por una API.

Después de esta ruta

Con estos fundamentos ya podés profundizar en módulos, clases, tooling, testing, frameworks y desarrollo frontend moderno. Lo importante es que DOM, eventos, arrays, objetos, asincronía y HTTP estén claros antes de depender de una librería.

Seguí aprendiendo JavaScript

← JavaScript asíncrono: Promises, async/await, fetch y APIs

Con este proyecto completás la ruta inicial de JavaScript.


Descubre más desde Club Programador

Suscríbete y recibe las últimas entradas en tu correo electrónico.

Deja un comentario

Descubre más desde Club Programador

Suscríbete ahora para seguir leyendo y obtener acceso al archivo completo.

Seguir leyendo