Cómo Crear una Aplicación To-Do List Profesional con Google Sheets y Apps Script

Cómo crear una Aplicación To-Do List con Google Sheets

Cómo crear una aplicación web de gestión de tareas con Google Sheets, Apps Script y Tailwind CSS. Desarrollar soluciones de software robustas, escalables y económicas es más sencillo de lo que parece. Google Workspace ofrece herramientas gratuitas que permiten crear aplicaciones web funcionales sin depender de costosas plataformas de gestión de proyectos ni suscripciones mensuales (SaaS).

Imagina una aplicación web moderna con un diseño profesional en Tailwind CSS, estadísticas en tiempo real, filtros avanzados, búsqueda instantánea, enlaces de Google Drive y almacenamiento persistente, todo conectado a una hoja de cálculo de Google mediante Google Apps Script y HTML5.

En este tutorial paso a paso aprenderás a desarrollar una aplicación de lista de tareas (To-Do List), desde el backend hasta el frontend, y a desplegarla en minutos sin servidores externos ni bases de datos complejas.

Para agilizar tus proyectos, explora estas aplicaciones web disponibles para descargar y descubre soluciones prácticas para automatizar tareas, mejorar la gestión de proyectos y optimizar tus flujos de trabajo.

Arquitectura del proyecto y requisitos previos

Antes de escribir la primera línea de código, es fundamental comprender cómo interactúan los componentes de nuestra solución. Nuestra arquitectura se divide en dos grandes bloques:

  1. El Backend (Google Apps Script): Actúa como el puente lógico y la API de nuestra aplicación. Lee, escribe, actualiza y elimina registros directamente en una hoja de cálculo activa de Google Sheets utilizando JavaScript del lado del servidor.
  2. El Frontend (HTML5, Tailwind CSS y JavaScript del cliente): Es la interfaz gráfica que ve el usuario final. Está diseñada con una estética minimalista en escala de grises (estilo app moderna oscura/clara), utilizando Tailwind CSS mediante CDN para garantizar una adaptabilidad total (responsive design) tanto en computadoras de escritorio como en dispositivos móviles.

Requisitos previos:

  • Una cuenta de Google activa.
  • Una hoja de cálculo de Google Sheets con una pestaña llamada exactamente Actividades.
  • Conocimientos básicos de JavaScript y HTML.

Paso 1: Configuración del Backend con Google Apps Script

El primer paso consiste en configurar la lógica que se ejecutará en los servidores de Google. Esta lógica se encargará de servir la interfaz HTML, consultar las actividades existentes, guardar nuevos registros, actualizar estatus de forma rápida y eliminar tareas obsoletas.

Creación de la función doGet y lectura de datos

Para desplegar nuestra aplicación web, Google Apps Script utiliza la función especial doGet(e). Esta función intercepta las peticiones HTTP y devuelve el archivo HTML principal. Asimismo, necesitamos una función obtenerActividades() que lea las celdas de nuestra hoja de cálculo (desde la fila 2 hasta la columna 11, correspondiente a la columna K) y las convierta en objetos JSON legibles para el frontend.

A continuación, te presento el primer fragmento del código del servidor (Código.gs):

Código backend: Google Apps Script

Este código permite conectar la aplicación web con Google Sheets y recuperar las actividades registradas en la hoja Actividades, incluyendo su estatus, responsable, fecha límite y enlace de soporte.

function doGet(e) {
  return HtmlService.createHtmlOutputFromFile('Index')
    .setTitle('Control de Actividades')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
    .addMetaTag('viewport', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no');
}

function obtenerActividades() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName('Actividades');

  if (!sheet) {
    return {
      status: 'error',
      message: "No se encontró la hoja 'Actividades'."
    };
  }

  var lastRow = sheet.getLastRow();

  if (lastRow < 2) {
    return { status: 'success', data: [] };
  }

  // Leemos hasta la columna 11 (K)
  // para incluir el enlace de soporte.
  var rows = sheet.getRange(2, 1, lastRow - 1, 11).getValues();
  var actividades = [];

  for (var i = 0; i < rows.length; i++) {
    var r = rows[i];

    actividades.push({
      rowIndex: i + 2,
      id: r[0] || ('ACT-' + (i + 1)),
      title: r[1] || '',
      folio: r[2] || '',
      status: String(r[3] || 'pendiente').toLowerCase().trim(),
      provider: r[4] || '',
      amount: r[5] || '',
      deadline: r[6] || '',
      situation: r[7] || '',
      action: r[8] || '',
      responsible: r[9] || '',
      supportLink: r[10] || '' // Columna K
    });
  }

  return {
    status: 'success',
    data: actividades
  };
}

Nota: Este código debe guardarse en el editor de Google Apps Script, dentro del archivo de servidor. La función doGet() carga el archivo HTML llamado Index, mientras que obtenerActividades() recupera los registros de Google Sheets.

Funciones de escritura, actualización y eliminación

Una vez que el usuario interactúa con la interfaz, necesitamos métodos para persistir los cambios en la hoja de cálculo. La función guardarActividad(data) evalúa si el registro ya cuenta con un índice de fila (rowIndex). Si existe, actualiza las celdas correspondientes; si no existe, genera un identificador único basado en la marca de tiempo actual (ACT-XXXXXX) y añade una nueva fila al final de la hoja. Complementariamente, incluimos funciones optimizadas para cambios de estatus rápidos (actualizarEstatusRapido) y eliminación de registros (eliminarActividad):

Código backend: guardar, actualizar y eliminar actividades

Estas funciones de Google Apps Script permiten gestionar las actividades desde la aplicación web: crear nuevos registros, editar información existente, cambiar rápidamente el estatus y eliminar actividades de la hoja de cálculo de Google Sheets.

function guardarActividad(data) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName('Actividades');

  if (!sheet) {
    return {
      status: 'error',
      message: "No se encontró la hoja 'Actividades'."
    };
  }

  if (data.rowIndex && data.rowIndex !== "" && !isNaN(data.rowIndex)) {
    var row = parseInt(data.rowIndex);

    sheet.getRange(row, 2).setValue(data.title);
    sheet.getRange(row, 3).setValue(data.folio);
    sheet.getRange(row, 4).setValue(data.status);
    sheet.getRange(row, 5).setValue(data.provider);
    sheet.getRange(row, 6).setValue(data.amount);
    sheet.getRange(row, 7).setValue(data.deadline);
    sheet.getRange(row, 8).setValue(data.situation);
    sheet.getRange(row, 9).setValue(data.action);
    sheet.getRange(row, 10).setValue(data.responsible);
    sheet.getRange(row, 11).setValue(data.supportLink); // Columna K

    return {
      status: 'success',
      message: 'Actividad actualizada correctamente.'
    };

  } else {
    var newId = 'ACT-' + new Date().getTime().toString().slice(-6);

    sheet.appendRow([
      newId,
      data.title,
      data.folio || 'S/F',
      data.status || 'pendiente',
      data.provider || '',
      data.amount || '0',
      data.deadline || '',
      data.situation || '',
      data.action || '',
      data.responsible || '',
      data.supportLink || '' // Columna K
    ]);

    return {
      status: 'success',
      message: 'Actividad creada exitosamente.'
    };
  }
}

function actualizarEstatusRapido(rowIndex, nuevoEstatus) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName('Actividades');

  if (!sheet) {
    return {
      status: 'error',
      message: "No se encontró la hoja 'Actividades'."
    };
  }

  sheet.getRange(parseInt(rowIndex), 4).setValue(nuevoEstatus);

  return { status: 'success' };
}

function eliminarActividad(rowIndex) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName('Actividades');

  if (!sheet) {
    return {
      status: 'error',
      message: "No se encontró la hoja 'Actividades'."
    };
  }

  sheet.deleteRow(parseInt(rowIndex));

  return {
    status: 'success',
    message: 'Registro eliminado.'
  };
}

Nota: Este código debe incorporarse al archivo de servidor de Google Apps Script junto con las funciones de consulta de actividades. Las operaciones de actualización y eliminación utilizan el número de fila del registro, por lo que es importante validar ese dato antes de ejecutar cambios.


Paso 2: Desarrollo de la interfaz gratuita (Frontend HTML)

El segundo componente fundamental es el archivo Index.html. Este archivo contiene la estructura visual de la aplicación, utilizando clases utilitarias de Tailwind CSS para lograr una interfaz moderna, limpia y altamente receptiva.

Estructura HTML y cabecera de la aplicación

La cabecera incluye el título de la aplicación, un indicador de sincronización con Google Sheets, un botón flotante para agregar nuevas actividades y un botón de recarga manual. Asimismo, integramos la hoja de estilos de FontAwesome para dotar a la interfaz de iconos intuitivos.

Código frontend: estructura HTML de la aplicación To-Do List

Este fragmento contiene la estructura inicial de la interfaz de usuario de la aplicación web. Incluye la configuración del documento HTML5, las bibliotecas Tailwind CSS y Font Awesome, la tipografía Inter y una cabecera moderna con botones para crear actividades y sincronizar los datos con Google Sheets.

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <title>Control de Actividades</title>

    <!-- Tailwind CSS CDN -->
    <script src="https://cdn.tailwindcss.com"></script>

    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">

    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">

    <style>
        body {
            font-family: 'Inter', sans-serif;
            -webkit-tap-highlight-color: transparent;
        }
    </style>
</head>

<body class="bg-zinc-100 text-zinc-900 min-h-screen flex flex-col antialiased">

    <!-- Header Estilo App Moderna (Negro Puro) -->
    <header class="bg-zinc-950 text-white sticky top-0 z-30 shadow-md">
        <div class="max-w-5xl mx-auto px-4 h-16 sm:h-20 flex items-center justify-between">

            <div class="flex items-center space-x-3">
                <div class="bg-zinc-900 text-white p-2 rounded-xl border border-zinc-800 flex items-center justify-center">
                    <i class="fa-solid fa-list-check text-base sm:text-lg"></i>
                </div>

                <div>
                    <h1 class="text-sm sm:text-base font-bold tracking-tight text-white leading-tight">To-Do List</h1>
                    <p class="text-[11px] text-zinc-400">Sincronizado con Sheets</p>
                </div>
            </div>

            <div class="flex items-center space-x-2">
                <button onclick="abrirModalNueva()" class="inline-flex items-center px-3.5 py-2 shadow-sm text-xs font-semibold rounded-xl text-zinc-950 bg-white hover:bg-zinc-200 transition active:scale-95">
                    <i class="fa-solid fa-plus sm:mr-1.5"></i>
                    <span class="hidden sm:inline">Nueva</span>
                </button>

                <button onclick="cargarActividades()" class="inline-flex items-center p-2 sm:px-3 sm:py-2 border border-zinc-800 text-xs font-semibold rounded-xl text-zinc-300 bg-zinc-900 hover:bg-zinc-800 transition active:scale-95" title="Sincronizar">
                    <i class="fa-solid fa-rotate-right"></i>
                </button>
            </div>

        </div>
    </header>

Nota: Este es el fragmento inicial del archivo Index.html de Google Apps Script. Los botones llaman a las funciones JavaScript abrirModalNueva() y cargarActividades(), que deben estar definidas en el resto de la aplicación.

Panel de métricas (Dashboard / Semáforo) y filtros

Una de las ventajas competitivas de esta aplicación es su panel superior de control, que calcula automáticamente el total de tareas, pendientes, elementos en proceso y completados, además de mostrar una barra de progreso global dinámica.

Código frontend: dashboard, indicadores y filtros de actividades

Este fragmento define el contenido principal de la aplicación To-Do List. Incluye notificaciones flotantes, un indicador de carga, tarjetas con estadísticas de actividades, una barra de progreso global, filtros por estatus, un buscador y un contenedor dinámico donde JavaScript mostrará las tareas registradas en Google Sheets.

<!-- Contenedor Principal Ajustado tipo App móvil -->
<main class="max-w-5xl mx-auto px-3 sm:px-6 py-4 sm:py-6 flex-1 w-full space-y-4">

    <!-- Toast Notificación Flotante -->
    <div id="toast" class="hidden fixed bottom-6 left-4 right-4 sm:left-auto sm:right-6 sm:max-w-sm z-50 bg-zinc-950 text-white px-4 py-3 rounded-2xl shadow-2xl flex items-center space-x-3 border border-zinc-800 transition-all">
        <i id="toastIcon" class="fa-solid fa-circle-check text-emerald-400 text-base"></i>
        <div>
            <p id="toastTitle" class="text-xs font-semibold">Notificación</p>
            <p id="toastMsg" class="text-[11px] text-zinc-400">Operación exitosa.</p>
        </div>
    </div>

    <!-- Indicador de Carga -->
    <div id="globalLoader" class="flex items-center justify-center py-10">
        <div class="inline-flex items-center gap-2 px-4 py-2 bg-zinc-900 text-white rounded-full text-xs font-medium shadow-md">
            <i class="fa-solid fa-spinner animate-spin"></i> Conectando...
        </div>
    </div>

    <!-- Dashboard / Semáforo Compacto App View -->
    <section class="grid grid-cols-2 sm:grid-cols-4 gap-2.5 sm:gap-4">

        <div class="bg-white p-3.5 sm:p-4 rounded-2xl border border-zinc-200/80 shadow-xs flex items-center justify-between">
            <div>
                <p class="text-[10px] sm:text-xs font-semibold uppercase tracking-wider text-zinc-400">Total</p>
                <h3 id="count-total" class="text-xl sm:text-2xl font-bold text-zinc-900 mt-0.5">0</h3>
            </div>
            <div class="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-zinc-100 flex items-center justify-center text-zinc-800">
                <i class="fa-solid fa-layer-group text-sm"></i>
            </div>
        </div>

        <div class="bg-white p-3.5 sm:p-4 rounded-2xl border border-zinc-200/80 shadow-xs flex items-center justify-between border-l-4 border-l-rose-500">
            <div>
                <p class="text-[10px] sm:text-xs font-semibold uppercase tracking-wider text-rose-600">Pendientes</p>
                <h3 id="count-pendiente" class="text-xl sm:text-2xl font-bold text-rose-600 mt-0.5">0</h3>
            </div>
            <div class="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-rose-50 flex items-center justify-center text-rose-600">
                <i class="fa-solid fa-circle-exclamation text-sm"></i>
            </div>
        </div>

        <div class="bg-white p-3.5 sm:p-4 rounded-2xl border border-zinc-200/80 shadow-xs flex items-center justify-between border-l-4 border-l-amber-500">
            <div>
                <p class="text-[10px] sm:text-xs font-semibold uppercase tracking-wider text-amber-600">En Proceso</p>
                <h3 id="count-proceso" class="text-xl sm:text-2xl font-bold text-amber-600 mt-0.5">0</h3>
            </div>
            <div class="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-amber-50 flex items-center justify-center text-amber-600">
                <i class="fa-solid fa-clock-rotate-left text-sm"></i>
            </div>
        </div>

        <div class="bg-white p-3.5 sm:p-4 rounded-2xl border border-zinc-200/80 shadow-xs flex items-center justify-between border-l-4 border-l-emerald-500">
            <div>
                <p class="text-[10px] sm:text-xs font-semibold uppercase tracking-wider text-emerald-600">Completadas</p>
                <h3 id="count-completado" class="text-xl sm:text-2xl font-bold text-emerald-600 mt-0.5">0</h3>
            </div>
            <div class="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600">
                <i class="fa-solid fa-circle-check text-sm"></i>
            </div>
        </div>

    </section>

    <!-- Barra de Progreso Minimalista -->
    <section class="bg-white p-3.5 sm:p-4 rounded-2xl border border-zinc-200/80 shadow-xs space-y-2">
        <div class="flex justify-between items-center text-xs">
            <span class="font-semibold text-zinc-500 uppercase tracking-wider text-[10px] sm:text-xs">Avance Global</span>
            <span id="progress-text" class="font-bold text-zinc-900">0%</span>
        </div>

        <div class="w-full bg-zinc-100 rounded-full h-2 overflow-hidden">
            <div id="progress-bar" class="bg-zinc-950 h-2 rounded-full transition-all duration-500" style="width: 0%"></div>
        </div>
    </section>

    <!-- Filtros y Buscador App-style -->
    <section class="space-y-3">
        <div class="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">

            <div class="flex items-center gap-1 overflow-x-auto pb-1 sm:pb-0 scrollbar-none">
                <button onclick="filtrarPorEstado('todos')" id="tab-todos" class="px-3 py-1.5 rounded-xl text-xs font-semibold bg-zinc-950 text-white transition shrink-0">Todas</button>

                <button onclick="filtrarPorEstado('pendiente')" id="tab-pendiente" class="px-3 py-1.5 rounded-xl text-xs font-semibold bg-white border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition shrink-0">Pendientes</button>

                <button onclick="filtrarPorEstado('proceso')" id="tab-proceso" class="px-3 py-1.5 rounded-xl text-xs font-semibold bg-white border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition shrink-0">En Proceso</button>

                <button onclick="filtrarPorEstado('completado')" id="tab-completado" class="px-3 py-1.5 rounded-xl text-xs font-semibold bg-white border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition shrink-0">Completadas</button>
            </div>

            <div class="relative w-full sm:w-64">
                <span class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-zinc-400">
                    <i class="fas fa-search text-xs"></i>
                </span>

                <input type="text" id="searchInput" placeholder="Buscar actividad..." class="w-full pl-9 pr-3 py-2 bg-white border border-zinc-200 rounded-xl text-xs text-zinc-900 focus:outline-none focus:border-zinc-950">
            </div>

        </div>

        <!-- Contenedor Dinámico de Tarjetas Estilo App -->
        <div id="activities-container" class="space-y-3">
            <!-- Inyectado por JS -->
        </div>

    </section>

</main>

Nota: Este fragmento corresponde al contenido principal del archivo Index.html. Los contadores, la barra de progreso, los filtros y las tarjetas dinámicas dependen del código JavaScript que actualiza sus elementos mediante los identificadores definidos en este HTML.

Modal de edición y creación de tareas

El formulario flotante permite gestionar tanto la creación de nuevas actividades como la edición de registros ya existentes. Incluye campos optimizados para títulos, folios, proveedores, importes, fechas límite, responsables, enlaces de soporte y notas de situación actual.

Código frontend: modal para crear y editar actividades

Este fragmento incorpora un formulario modal adaptable a dispositivos móviles para registrar y editar actividades. Incluye campos para el título, folio, estatus, proveedor, importe, fecha límite, responsable, enlace de Google Drive, situación actual y siguiente acción, además de botones para cancelar o guardar los cambios.

<!-- Modal Formulario App Responsive -->
<div id="activityModal" class="hidden fixed inset-0 z-50 overflow-y-auto bg-zinc-950/60 backdrop-blur-xs flex items-end sm:items-center justify-center p-0 sm:p-4">

    <div class="bg-white border border-zinc-200 rounded-t-3xl sm:rounded-3xl max-w-lg w-full p-5 sm:p-6 shadow-2xl relative max-h-[92vh] overflow-y-auto">

        <div class="flex items-center justify-between pb-3 border-b border-zinc-100 mb-4">
            <div class="flex items-center space-x-2.5">
                <div class="bg-zinc-100 p-2 rounded-xl text-zinc-900">
                    <i class="fa-solid fa-file-circle-plus text-sm"></i>
                </div>
                <h3 id="modalTitle" class="text-sm font-bold text-zinc-900">Gestión de Actividad</h3>
            </div>

            <button onclick="closeModal()" class="text-zinc-400 hover:text-zinc-900 p-2 rounded-lg transition">
                <i class="fa-solid fa-xmark text-base"></i>
            </button>
        </div>

        <form id="newActivityForm" onsubmit="saveActivity(event)" class="space-y-3">
            <input type="hidden" id="formRowIndex">

            <div>
                <label class="block text-[10px] font-bold uppercase tracking-wider text-zinc-500 mb-1">Título *</label>
                <input type="text" id="formTitle" required placeholder="Nombre de la actividad..." class="w-full bg-zinc-50 border border-zinc-200 rounded-xl px-3 py-2 text-xs text-zinc-900 focus:outline-none focus:border-zinc-950">
            </div>

            <div class="grid grid-cols-2 gap-3">
                <div>
                    <label class="block text-[10px] font-bold uppercase tracking-wider text-zinc-500 mb-1">Folio / Ref</label>
                    <input type="text" id="formFolio" placeholder="Ej. J1621" class="w-full bg-zinc-50 border border-zinc-200 rounded-xl px-3 py-2 text-xs text-zinc-900 focus:outline-none focus:border-zinc-950">
                </div>

                <div>
                    <label class="block text-[10px] font-bold uppercase tracking-wider text-zinc-500 mb-1">Estatus</label>
                    <select id="formStatus" class="w-full bg-zinc-50 border border-zinc-200 rounded-xl px-3 py-2 text-xs text-zinc-900 focus:outline-none focus:border-zinc-950">
                        <option value="pendiente">🔴 Pendiente</option>
                        <option value="proceso">🟡 En Proceso</option>
                        <option value="completado">🟢 Completado</option>
                    </select>
                </div>
            </div>

            <div class="grid grid-cols-2 gap-3">
                <div>
                    <label class="block text-[10px] font-bold uppercase tracking-wider text-zinc-500 mb-1">Proveedor</label>
                    <input type="text" id="formProvider" placeholder="Nombre..." class="w-full bg-zinc-50 border border-zinc-200 rounded-xl px-3 py-2 text-xs text-zinc-900 focus:outline-none focus:border-zinc-950">
                </div>

                <div>
                    <label class="block text-[10px] font-bold uppercase tracking-wider text-zinc-500 mb-1">Importe</label>
                    <input type="text" id="formAmount" placeholder="$0.00" class="w-full bg-zinc-50 border border-zinc-200 rounded-xl px-3 py-2 text-xs text-zinc-900 focus:outline-none focus:border-zinc-950">
                </div>
            </div>

            <div class="grid grid-cols-2 gap-3">
                <div>
                    <label class="block text-[10px] font-bold uppercase tracking-wider text-zinc-500 mb-1">Fecha Límite</label>
                    <input type="text" id="formDeadline" placeholder="Fecha..." class="w-full bg-zinc-50 border border-zinc-200 rounded-xl px-3 py-2 text-xs text-zinc-900 focus:outline-none focus:border-zinc-950">
                </div>

                <div>
                    <label class="block text-[10px] font-bold uppercase tracking-wider text-zinc-500 mb-1">Responsable</label>
                    <input type="text" id="formResponsible" placeholder="Responsable..." class="w-full bg-zinc-50 border border-zinc-200 rounded-xl px-3 py-2 text-xs text-zinc-900 focus:outline-none focus:border-zinc-950">
                </div>
            </div>

            <div>
                <label class="block text-[10px] font-bold uppercase tracking-wider text-zinc-500 mb-1">Enlace de Soporte (Drive, etc.)</label>
                <input type="url" id="formSupportLink" placeholder="https://..." class="w-full bg-zinc-50 border border-zinc-200 rounded-xl px-3 py-2 text-xs text-zinc-900 focus:outline-none focus:border-zinc-950">
            </div>

            <div>
                <label class="block text-[10px] font-bold uppercase tracking-wider text-zinc-500 mb-1">Situación Actual</label>
                <textarea id="formSituation" rows="2" placeholder="Contexto..." class="w-full bg-zinc-50 border border-zinc-200 rounded-xl px-3 py-2 text-xs text-zinc-900 focus:outline-none focus:border-zinc-950"></textarea>
            </div>

            <div>
                <label class="block text-[10px] font-bold uppercase tracking-wider text-zinc-500 mb-1">Siguiente Acción</label>
                <textarea id="formAction" rows="2" placeholder="Acciones..." class="w-full bg-zinc-50 border border-zinc-200 rounded-xl px-3 py-2 text-xs text-zinc-900 focus:outline-none focus:border-zinc-950"></textarea>
            </div>

            <div class="flex items-center justify-end space-x-2 pt-3 border-t border-zinc-100">
                <button type="button" onclick="closeModal()" class="px-3.5 py-2 text-xs font-semibold rounded-xl text-zinc-600 bg-zinc-100 hover:bg-zinc-200 transition">Cancelar</button>

                <button type="submit" id="btnSubmitModal" class="px-4 py-2 text-xs font-semibold rounded-xl text-white bg-zinc-950 hover:bg-zinc-800 transition">Guardar</button>
            </div>

        </form>
    </div>
</div>

Nota: Este fragmento corresponde al modal del archivo Index.html. Para que funcione en la aplicación original, deben estar implementadas las funciones JavaScript closeModal() y saveActivity(event), así como la lógica para abrir el formulario en modo de creación o edición.

Script de control del lado del cliente (google.script.run)

El motor que comunica la interfaz visual con Google Apps Script es google.script.run. Este bloque de JavaScript gestiona la carga asíncrona de datos, el filtrado dinámico en tiempo real, el renderizado de tarjetas con etiquetas de colores por proveedor y la gestión de notificaciones flotantes (Toast).

1. Carga de actividades y filtros

Este fragmento inicializa la aplicación, obtiene los registros de Google Sheets y permite filtrar las actividades por estatus y texto de búsqueda.

<script>
let globalActivities = [];
let estadoFiltroActual = 'todos';

document.addEventListener('DOMContentLoaded', () => {
    cargarActividades();
    document.getElementById('searchInput')
        .addEventListener('input', aplicarFiltros);
});

function cargarActividades() {
    document.getElementById('globalLoader')
        .classList.remove('hidden');

    google.script.run
        .withSuccessHandler(res => {
            document.getElementById('globalLoader')
                .classList.add('hidden');

            if (res.status === 'success') {
                globalActivities = res.data;
                aplicarFiltros();
                updateDashboardCounters(globalActivities);
            } else {
                showToast("Aviso", res.message, "error");
            }
        })
        .withFailureHandler(err => {
            document.getElementById('globalLoader')
                .classList.add('hidden');
            showToast("Error", err.message, "error");
        })
        .obtenerActividades();
}

function filtrarPorEstado(estado) {
    estadoFiltroActual = estado;

    ['todos', 'pendiente', 'proceso', 'completado'].forEach(e => {
        const btn = document.getElementById(`tab-${e}`);

        if (e === estado) {
            btn.className = "px-3 py-1.5 rounded-xl text-xs font-semibold bg-zinc-950 text-white transition shrink-0";
        } else {
            btn.className = "px-3 py-1.5 rounded-xl text-xs font-semibold bg-white border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition shrink-0";
        }
    });

    aplicarFiltros();
}

function aplicarFiltros() {
    const q = document.getElementById('searchInput')
        .value.toLowerCase();

    const filtered = globalActivities.filter(a => {
        let st = (a.status || '').toLowerCase();
        let cumpleEstado = false;

        if (estadoFiltroActual === 'todos') cumpleEstado = true;
        else if (estadoFiltroActual === 'pendiente' && st.includes('pendiente')) cumpleEstado = true;
        else if (estadoFiltroActual === 'proceso' && st.includes('proceso')) cumpleEstado = true;
        else if (estadoFiltroActual === 'completado' && st.includes('completado')) cumpleEstado = true;

        let cumpleTexto =
            (a.title && a.title.toLowerCase().includes(q)) ||
            (a.provider && a.provider.toLowerCase().includes(q)) ||
            (a.folio && a.folio.toLowerCase().includes(q)) ||
            (a.responsible && a.responsible.toLowerCase().includes(q));

        return cumpleEstado && cumpleTexto;
    });

    renderActivities(filtered);
}
</script>

2. Renderizado de actividades

Las tarjetas muestran el folio, título, proveedor, importe, fecha límite, enlace de soporte, situación actual, responsable y opciones de gestión.

<script>
function renderActivities(activities) {
    const container = document.getElementById('activities-container');
    container.innerHTML = "";

    if (activities.length === 0) {
        container.innerHTML = `
            <div class="bg-white border border-zinc-200 rounded-2xl p-8 text-center text-zinc-400 shadow-xs">
                <i class="fa-solid fa-folder-open text-2xl mb-2 text-zinc-300"></i>
                <p class="text-xs">No se encontraron actividades.</p>
            </div>
        `;
        return;
    }

    const providerPalette = [
        "bg-purple-100 text-purple-800 border-purple-200",
        "bg-blue-100 text-blue-800 border-blue-200",
        "bg-emerald-100 text-emerald-800 border-emerald-200",
        "bg-amber-100 text-amber-800 border-amber-200",
        "bg-indigo-100 text-indigo-800 border-indigo-200",
        "bg-cyan-100 text-cyan-800 border-cyan-200",
        "bg-pink-100 text-pink-800 border-pink-200",
        "bg-orange-100 text-orange-800 border-orange-200"
    ];

    let providerColorMap = {};
    let paletteIndex = 0;

    activities.forEach(act => {
        if (act.provider && !providerColorMap[act.provider]) {
            providerColorMap[act.provider] =
                providerPalette[paletteIndex % providerPalette.length];
            paletteIndex++;
        }
    });

    activities.forEach(act => {
        let badgeClass = "";
        let badgeText = "";
        let borderStyle = "";
        let st = (act.status || 'pendiente').toLowerCase();

        if (st.includes('pendiente')) {
            badgeClass = "bg-rose-50 text-rose-700 border border-rose-200";
            badgeText = "Pendiente";
            borderStyle = "border-l-4 border-l-rose-500";
        } else if (st.includes('proceso')) {
            badgeClass = "bg-amber-50 text-amber-700 border border-amber-200";
            badgeText = "En Proceso";
            borderStyle = "border-l-4 border-l-amber-500";
        } else if (st.includes('completado')) {
            badgeClass = "bg-emerald-50 text-emerald-700 border border-emerald-200";
            badgeText = "Completado";
            borderStyle = "border-l-4 border-l-emerald-500";
        }

        let providerBadgeStyle =
            act.provider && providerColorMap[act.provider]
                ? providerColorMap[act.provider]
                : "bg-zinc-100 text-zinc-800 border-zinc-200";

        const cardHTML = `
            <div class="bg-white rounded-2xl border border-zinc-200/80 shadow-xs p-4 sm:p-5 ${borderStyle}">
                <div class="flex flex-col gap-3">
                    <div class="flex items-start justify-between gap-2">
                        <div class="space-y-1">
                            <div class="flex flex-wrap items-center gap-1.5">
                                ${act.folio ? `<span class="px-2 py-0.5 rounded text-[10px] bg-zinc-100 text-zinc-700 border border-zinc-200">${act.folio}</span>` : ''}
                                <span class="px-2 py-0.5 rounded text-[10px] font-semibold ${badgeClass}">${badgeText}</span>
                            </div>
                            <h3 class="text-xs sm:text-sm font-bold text-zinc-900">${act.title}</h3>
                        </div>

                        <div class="flex items-center gap-1 shrink-0">
                            <button onclick="editarActividad(${act.rowIndex})" title="Editar">
                                <i class="fa-solid fa-pen-to-square"></i>
                            </button>
                            <button onclick="eliminarActividad(${act.rowIndex})" title="Eliminar">
                                <i class="fa-solid fa-trash-can"></i>
                            </button>
                        </div>
                    </div>

                    ${act.provider ? `<div><strong>Proveedor: </strong><span class="${providerBadgeStyle}">${act.provider}</span></div>` : ''}

                    <div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs text-zinc-600 bg-zinc-50 p-3 rounded-xl">
                        ${act.amount ? `<div>Importe: <strong>${act.amount}</strong></div>` : ''}
                        ${act.deadline ? `<div>Límite: <strong>${act.deadline}</strong></div>` : ''}
                        ${act.supportLink ? `<div class="sm:col-span-2">Soporte: <a href="${act.supportLink}" target="_blank" rel="noopener noreferrer">${act.supportLink}</a></div>` : ''}
                        ${act.situation ? `<div class="sm:col-span-2">Situación: ${act.situation}</div>` : ''}
                        ${act.action ? `<div class="sm:col-span-2">Acción: ${act.action}</div>` : ''}
                        ${act.responsible ? `<div class="sm:col-span-2">Responsable: ${act.responsible}</div>` : ''}
                    </div>

                    <div class="flex items-center justify-between pt-2 border-t">
                        <span class="text-xs font-bold">Cambiar estatus:</span>
                        <select onchange="cambiarEstatusRapido(${act.rowIndex}, this.value)">
                            <option value="pendiente" ${st.includes('pendiente') ? 'selected' : ''}>🔴 Pendiente</option>
                            <option value="proceso" ${st.includes('proceso') ? 'selected' : ''}>🟡 En Proceso</option>
                            <option value="completado" ${st.includes('completado') ? 'selected' : ''}>🟢 Completado</option>
                        </select>
                    </div>
                </div>
            </div>
        `;

        container.innerHTML += cardHTML;
    });
}
</script>

3. Gestión de actividades

Incluye el cambio rápido de estatus, apertura del formulario para nuevas actividades, guardado de datos, edición de registros y eliminación con confirmación.

<script>
function cambiarEstatusRapido(rowIndex, nuevoEstatus) {
    showToast("Actualizando...", "Guardando...");

    google.script.run
        .withSuccessHandler(() => {
            cargarActividades();
            showToast("¡Listo!", "Actualizado correctamente.");
        })
        .withFailureHandler(err => showToast("Error", err.message))
        .actualizarEstatusRapido(rowIndex, nuevoEstatus);
}

function abrirModalNueva() {
    document.getElementById('modalTitle').innerText = "Nueva Actividad";
    document.getElementById('formRowIndex').value = "";
    document.getElementById('newActivityForm').reset();
    document.getElementById('activityModal').classList.remove('hidden');
}

function saveActivity(e) {
    e.preventDefault();

    const btn = document.getElementById('btnSubmitModal');
    btn.innerHTML = 'Guardando...';
    btn.disabled = true;

    const payload = {
        rowIndex: document.getElementById('formRowIndex').value,
        title: document.getElementById('formTitle').value,
        folio: document.getElementById('formFolio').value,
        status: document.getElementById('formStatus').value,
        provider: document.getElementById('formProvider').value,
        amount: document.getElementById('formAmount').value,
        deadline: document.getElementById('formDeadline').value,
        situation: document.getElementById('formSituation').value,
        action: document.getElementById('formAction').value,
        responsible: document.getElementById('formResponsible').value,
        supportLink: document.getElementById('formSupportLink').value
    };

    google.script.run
        .withSuccessHandler(res => {
            btn.innerHTML = 'Guardar';
            btn.disabled = false;
            showToast("Éxito", res.message);
            closeModal();
            cargarActividades();
        })
        .withFailureHandler(err => {
            btn.innerHTML = 'Guardar';
            btn.disabled = false;
            showToast("Error", err.message);
        })
        .guardarActividad(payload);
}

function editarActividad(rowIndex) {
    const act = globalActivities.find(a => a.rowIndex === rowIndex);

    if (!act) return;

    document.getElementById('modalTitle').innerText = "Editar Actividad";
    document.getElementById('formRowIndex').value = act.rowIndex;
    document.getElementById('formTitle').value = act.title;
    document.getElementById('formFolio').value = act.folio;
    document.getElementById('formStatus').value =
        act.status.includes('proceso') ? 'proceso' :
        act.status.includes('completado') ? 'completado' : 'pendiente';

    document.getElementById('formProvider').value = act.provider;
    document.getElementById('formAmount').value = act.amount;
    document.getElementById('formDeadline').value = act.deadline;
    document.getElementById('formSituation').value = act.situation;
    document.getElementById('formAction').value = act.action;
    document.getElementById('formResponsible').value = act.responsible;
    document.getElementById('formSupportLink').value = act.supportLink || '';

    document.getElementById('activityModal').classList.remove('hidden');
}

function eliminarActividad(rowIndex) {
    if (confirm("¿Eliminar este registro?")) {
        showToast("Procesando...", "Eliminando...");

        google.script.run
            .withSuccessHandler(res => {
                showToast("Eliminado", res.message);
                cargarActividades();
            })
            .withFailureHandler(err => showToast("Error", err.message))
            .eliminarActividad(rowIndex);
    }
}
</script>

4. Dashboard y notificaciones

Calcula el total de actividades, los pendientes, las que están en proceso y las completadas. También actualiza la barra de progreso y permite cerrar el modal y mostrar mensajes temporales.

<script>
function updateDashboardCounters(activities) {
    let total = activities.length;
    let pendientes = 0;
    let procesos = 0;
    let completados = 0;

    activities.forEach(a => {
        let st = (a.status || '').toLowerCase();

        if (st.includes('pendiente')) pendientes++;
        else if (st.includes('proceso')) procesos++;
        else if (st.includes('completado')) completados++;
    });

    document.getElementById('count-total').innerText = total;
    document.getElementById('count-pendiente').innerText = pendientes;
    document.getElementById('count-proceso').innerText = procesos;
    document.getElementById('count-completado').innerText = completados;

    let percentage = total > 0
        ? Math.round((completados / total) * 100)
        : 0;

    document.getElementById('progress-text').innerText = `${percentage}%`;
    document.getElementById('progress-bar').style.width = `${percentage}%`;
}

function closeModal() {
    document.getElementById('activityModal')
        .classList.add('hidden');
}

function showToast(title, message) {
    const toast = document.getElementById('toast');

    document.getElementById('toastTitle').innerText = title;
    document.getElementById('toastMsg').innerText = message;

    toast.classList.remove('hidden');

    setTimeout(() => {
        toast.classList.add('hidden');
    }, 3000);
}
</script>

</body>
</html>

Paso 3: Despliegue y publicación como aplicación Web

Una vez que hayas copiado el código en el editor de Google Apps Script (creando el archivo de servidor Código.gs y un archivo HTML llamado Index.html), el siguiente paso es desplegar la aplicación:

🚀 Paso final: Implementar tu aplicación web

Una vez que hayas completado el código, sigue estos pasos para publicar tu aplicación To-Do List y compartirla con tu equipo.

  1. En la esquina superior derecha del editor de Google Apps Script, haz clic en Implementar (Deploy) y selecciona Nueva implementación (New deployment).
  2. Haz clic en el icono de rueda dentada, selecciona el tipo de implementación y elige Aplicación web (Web app).
  3. Configura los siguientes parámetros:
    • Descripción: To-Do List Sincronizado v1.
    • Ejecutar como: Yo (tu cuenta de Google).
    • Quién tiene acceso: Cualquier usuario, o usuarios de tu organización de Google Workspace, según tus necesidades.
  4. Haz clic en Implementar. Google te solicitará autorización para ejecutar la aplicación. Revisa los permisos y autoriza el acceso si estás de acuerdo.
  5. Finalmente, copia la URL de la aplicación web generada por Google Apps Script.

✅ ¡Listo! Tu aplicación ya está implementada y puedes compartir su URL con tu equipo para gestionar actividades de forma centralizada mediante Google Sheets.

Nota de seguridad: si habilitas el acceso para cualquier usuario, revisa cuidadosamente los permisos y los datos que podrá consultar o modificar la aplicación. Utiliza el acceso más restrictivo que se ajuste a tus necesidades.

Desarrollar herramientas de productividad a medida utilizando Google Sheets y Apps Script demuestra que no siempre es necesario depender de costosas plataformas de terceros para digitalizar y optimizar los flujos de trabajo de un equipo. Combinando la potencia analítica y de almacenamiento de las hojas de cálculo con la versatilidad de un diseño moderno en Tailwind CSS, hemos construido una aplicación To-Do List rápida, segura, escalable y sincronizada en tiempo real. Este enfoque abre un abanico infinito de posibilidades para automatizar operaciones empresariales sin realizar inversión en infraestructura de servidores.

SOLÍS ENTERPRISES · APPS WEB

Herramientas para trabajar mejor.

Descubre aplicaciones web para organizar tareas, gestionar procesos y ahorrar tiempo.

Ver aplicaciones disponibles →

Preguntas Frecuentes (FAQ) App Web Google Sheets

¿Es necesario pagar algún servicio de hosting para mantener la app funcionando?

No, todo se aloja de forma gratuita en los servidores seguros de Google Cloud mediante Apps Script.

¿Puedo acceder y usar la aplicación desde mi teléfono celular?

Sí, gracias al diseño responsive con Tailwind CSS, se adapta perfectamente a pantallas móviles.

¿Qué sucede si cierro la hoja de cálculo mientras edito tareas?

Los datos se guardan de inmediato en Sheets gracias a las llamadas asíncronas de google.script.run.