Registra tiempos y actividades en el Calendario de Google y Hojas de cálculo de Google

Nivel de programación: Principiante Duración: 15 minutos Tipo de proyecto: Automatización con un menú personalizado

Objetivos

  • Comprende qué hace la solución.
  • Comprende qué hacen los servicios de Apps Script dentro de la solución.
  • Configura el entorno.
  • Configura la secuencia de comandos.
  • Ejecuta la secuencia de comandos.

Acerca de esta solución

Haz un seguimiento del tiempo dedicado a los proyectos de los clientes. Puedes registrar el tiempo relacionado con el proyecto en Calendario de Google y, luego, sincronizarlo con Hojas de Google para crear una hoja de horas o importar tu actividad a otro sistema de administración de hojas de horas. Puedes categorizar tu tiempo por cliente, proyecto y tarea.

Eventos en Calendario y Hojas de cálculo

Cómo funciona

La secuencia de comandos proporciona una barra lateral que te permite seleccionar los calendarios que quieres sincronizar, el período de sincronización y definir si quieres reemplazar los títulos y las descripciones de los eventos por la información ingresada en la hoja de cálculo. Una vez que configures esas opciones, puedes sincronizar eventos y ver tus actividades en un panel.

La secuencia de comandos incorpora eventos de los calendarios y el período que especificas de Calendario a la hoja de cálculo. Puedes agregar clientes, proyectos y tareas a la hoja de categorías y, luego, etiquetar los eventos según corresponda en la hoja de horas. De esta manera, cuando veas la hoja del panel , podrás ver el tiempo total por cliente, proyecto y tarea.

Servicios de Apps Script

Esta solución usa los siguientes servicios:

  • Servicio HTML: Compila la barra lateral que se usa para configurar los parámetros de sincronización.
  • Servicio de propiedades: Almacena la configuración que selecciona el usuario en la barra lateral.
  • Servicio de Calendario: Envía la información del evento a la hoja de cálculo.
  • Servicio de Hojas de cálculo: Escribe los eventos en la hoja de cálculo y, si está configurado, envía la información actualizada del título y la descripción a Calendario.

Requisitos previos

Para usar esta muestra, debes cumplir con los siguientes requisitos previos:

  • Una Cuenta de Google (es posible que las cuentas de Google Workspace requieran la aprobación del administrador)
  • Un navegador web con acceso a Internet

Configura tu entorno

Si tienes pensado usar un calendario existente, puedes omitir este paso.

  1. Ve a calendar.google.com.
  2. Junto a Otros calendarios, haz clic en Agregar otros calendarios > Crear calendario.
  3. Asigna un nombre a tu calendario y haz clic en Crear calendario.
  4. Agrega algunos eventos al calendario.

Configura la secuencia de comandos

Haz clic en el siguiente botón para crear una copia de la hoja de cálculo de Hojas de cálculo de ejemplo Registrar tiempo y actividades:

Crear una copia

Ejecuta la secuencia de comandos

En las siguientes secciones, se explica cómo ejecutar la secuencia de comandos.

Sincroniza eventos del calendario

  1. Haz clic en myTime > Configuración. Es posible que debas actualizar la página para que aparezca este menú personalizado.
  2. Cuando se te solicite, autoriza la secuencia de comandos. <<../_snippets/oauth.md>>
  3. Vuelve a hacer clic en myTime > Configuración.
  4. En la lista de calendarios disponibles, selecciona el calendario que creaste y cualquier otro que quieras sincronizar.
  5. Configura el resto de los parámetros y haz clic en Guardar.
  6. Haz clic en myTime > Sincronizar eventos del calendario.

Configura el panel

  1. Ve a la hoja de categorías.
  2. Agrega clientes, proyectos y tareas.
  3. Ve a la hoja de horas.
  4. Para cada evento sincronizado, selecciona el cliente, el proyecto y la tarea.
  5. Ve a la hoja del panel.
    • En la primera sección, se proporcionan los totales diarios. Para actualizar la lista de fechas de los totales diarios, cambia la fecha en la celda A1.
    • En la siguiente sección, se proporcionan los totales semanales y corresponden a la fecha seleccionada en A1.
    • En las últimas tres secciones, se proporcionan los totales generales por tarea, proyecto y cliente.

Revisa el código

Para revisar el código de Apps Script de esta solución, haz clic en Ver el código fuente:

Ver el código fuente

Code.gs

solutions/automations/calendar-timesheet/Code.js
// To learn how to use this script, refer to the documentation:
// https://developers.google.com/apps-script/samples/automations/calendar-timesheet

/*
Copyright 2022 Jasper Duizendstra

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

/**
 * Runs when the spreadsheet is opened and adds the menu options
 * to the spreadsheet menu
 */
const onOpen = () => {
  SpreadsheetApp.getUi()
    .createMenu("myTime")
    .addItem("Sync calendar events", "run")
    .addItem("Settings", "settings")
    .addToUi();
};

/**
 * Opens the sidebar
 */
const settings = () => {
  const html =
    HtmlService.createHtmlOutputFromFile("Page").setTitle("Settings");

  SpreadsheetApp.getUi().showSidebar(html);
};

/**
 * returns the settings from the script properties
 */
const getSettings = () => {
  const settings = {};

  // get the current settings
  const savedCalendarSettings = JSON.parse(
    PropertiesService.getScriptProperties().getProperty("calendar") || "[]",
  );

  // get the primary calendar
  const primaryCalendar = CalendarApp.getAllCalendars()
    .filter((cal) => cal.isMyPrimaryCalendar())
    .map((cal) => ({
      name: "Primary calendar",
      id: cal.getId(),
    }));

  // get the secondary calendars
  const secundaryCalendars = CalendarApp.getAllCalendars()
    .filter((cal) => cal.isOwnedByMe() && !cal.isMyPrimaryCalendar())
    .map((cal) => ({
      name: cal.getName(),
      id: cal.getId(),
    }));

  // the current available calendars
  const availableCalendars = primaryCalendar.concat(secundaryCalendars);

  // find any calendars that were removed
  const unavailebleCalendars = [];
  for (const savedCalendarSetting of savedCalendarSettings) {
    if (
      !availableCalendars.find(
        (availableCalendar) => availableCalendar.id === savedCalendarSetting.id,
      )
    ) {
      unavailebleCalendars.push(savedCalendarSetting);
    }
  }

  // map the current settings to the available calendars
  const calendarSettings = availableCalendars.map((availableCalendar) => {
    if (
      savedCalendarSettings.find(
        (savedCalendar) => savedCalendar.id === availableCalendar.id,
      )
    ) {
      availableCalendar.sync = true;
    }
    return availableCalendar;
  });

  // add the calendar settings to the settings
  settings.calendarSettings = calendarSettings;

  const savedFrom =
    PropertiesService.getScriptProperties().getProperty("syncFrom");
  settings.syncFrom = savedFrom;

  const savedTo = PropertiesService.getScriptProperties().getProperty("syncTo");
  settings.syncTo = savedTo;

  const savedIsUpdateTitle =
    PropertiesService.getScriptProperties().getProperty("isUpdateTitle") ===
    "true";
  settings.isUpdateCalendarItemTitle = savedIsUpdateTitle;

  const savedIsUseCategoriesAsCalendarItemTitle =
    PropertiesService.getScriptProperties().getProperty(
      "isUseCategoriesAsCalendarItemTitle",
    ) === "true";
  settings.isUseCategoriesAsCalendarItemTitle =
    savedIsUseCategoriesAsCalendarItemTitle;

  const savedIsUpdateDescription =
    PropertiesService.getScriptProperties().getProperty(
      "isUpdateDescription",
    ) === "true";
  settings.isUpdateCalendarItemDescription = savedIsUpdateDescription;

  return settings;
};

/**
 * Saves the settings from the sidebar
 */
const saveSettings = (settings) => {
  PropertiesService.getScriptProperties().setProperty(
    "calendar",
    JSON.stringify(settings.calendarSettings),
  );
  PropertiesService.getScriptProperties().setProperty(
    "syncFrom",
    settings.syncFrom,
  );
  PropertiesService.getScriptProperties().setProperty(
    "syncTo",
    settings.syncTo,
  );
  PropertiesService.getScriptProperties().setProperty(
    "isUpdateTitle",
    settings.isUpdateCalendarItemTitle,
  );
  PropertiesService.getScriptProperties().setProperty(
    "isUseCategoriesAsCalendarItemTitle",
    settings.isUseCategoriesAsCalendarItemTitle,
  );
  PropertiesService.getScriptProperties().setProperty(
    "isUpdateDescription",
    settings.isUpdateCalendarItemDescription,
  );
  return "Settings saved";
};

/**
 * Builds the myTime object and runs the synchronisation
 */
const run = () => {
  myTime({
    mainSpreadsheetId: SpreadsheetApp.getActiveSpreadsheet().getId(),
  }).run();
};

/**
 * The main function used for the synchronisation
 * @param {Object} par The main parameter object.
 * @return {Object} The myTime Object.
 */
const myTime = (par) => {
  /**
   * Format the sheet
   */
  const formatSheet = () => {
    // sort decending on start date
    hourSheet.sort(3, false);

    // hide the technical columns
    hourSheet.hideColumns(1, 2);

    // remove any extra rows
    if (
      hourSheet.getLastRow() > 1 &&
      hourSheet.getLastRow() < hourSheet.getMaxRows()
    ) {
      hourSheet.deleteRows(
        hourSheet.getLastRow() + 1,
        hourSheet.getMaxRows() - hourSheet.getLastRow(),
      );
    }

    // set the validation for the customers
    let rule = SpreadsheetApp.newDataValidation()
      .requireValueInRange(categoriesSheet.getRange("A2:A"), true)
      .setAllowInvalid(true)
      .build();
    hourSheet.getRange("I2:I").setDataValidation(rule);

    // set the validation for the projects
    rule = SpreadsheetApp.newDataValidation()
      .requireValueInRange(categoriesSheet.getRange("B2:B"), true)
      .setAllowInvalid(true)
      .build();
    hourSheet.getRange("J2:J").setDataValidation(rule);

    // set the validation for the tsaks
    rule = SpreadsheetApp.newDataValidation()
      .requireValueInRange(categoriesSheet.getRange("C2:C"), true)
      .setAllowInvalid(true)
      .build();
    hourSheet.getRange("K2:K").setDataValidation(rule);

    if (isUseCategoriesAsCalendarItemTitle) {
      hourSheet
        .getRange("L2:L")
        .setFormulaR1C1(
          'IF(OR(R[0]C[-3]="tbd";R[0]C[-2]="tbd";R[0]C[-1]="tbd");""; CONCATENATE(R[0]C[-3];"|";R[0]C[-2];"|";R[0]C[-1];"|"))',
        );
    }
    // set the hours, month, week and number collumns
    hourSheet
      .getRange("P2:P")
      .setFormulaR1C1('=IF(R[0]C[-12]="";"";R[0]C[-12]-R[0]C[-13])');
    hourSheet
      .getRange("Q2:Q")
      .setFormulaR1C1('=IF(R[0]C[-13]="";"";month(R[0]C[-13]))');
    hourSheet
      .getRange("R2:R")
      .setFormulaR1C1('=IF(R[0]C[-14]="";"";WEEKNUM(R[0]C[-14];2))');
    hourSheet.getRange("S2:S").setFormulaR1C1("=R[0]C[-3]");
  };

  /**
   * Activate the synchronisation
   */
  function run() {
    console.log("Started processing hours.");

    const processCalendar = (setting) => {
      SpreadsheetApp.flush();

      // current calendar info
      const calendarName = setting.name;
      const calendarId = setting.id;

      console.log(
        `processing ${calendarName} with the id ${calendarId} from ${syncStartDate} to ${syncEndDate}`,
      );

      // get the calendar
      const calendar = CalendarApp.getCalendarById(calendarId);

      // get the calendar events and create lookups
      const events = calendar.getEvents(syncStartDate, syncEndDate);
      const eventsLookup = events.reduce((jsn, event) => {
        jsn[event.getId()] = event;
        return jsn;
      }, {});

      // get the sheet events and create lookups
      const existingEvents = hourSheet.getDataRange().getValues().slice(1);
      const existingEventsLookUp = existingEvents.reduce((jsn, row, index) => {
        if (row[0] !== calendarId) {
          return jsn;
        }
        jsn[row[1]] = {
          event: row,
          row: index + 2,
        };
        return jsn;
      }, {});

      // handle a calendar event
      const handleEvent = (event) => {
        const eventId = event.getId();

        // new event
        if (!existingEventsLookUp[eventId]) {
          hourSheet.appendRow([
            calendarId,
            eventId,
            event.getStartTime(),
            event.getEndTime(),
            calendarName,
            event.getCreators().join(","),
            event.getTitle(),
            event.getDescription(),
            event.getTag("Client") || "tbd",
            event.getTag("Project") || "tbd",
            event.getTag("Task") || "tbd",
            isUpdateCalendarItemTitle ? "" : event.getTitle(),
            isUpdateCalendarItemDescription ? "" : event.getDescription(),
            event
              .getGuestList()
              .map((guest) => guest.getEmail())
              .join(","),
            event.getLocation(),
            undefined,
            undefined,
            undefined,
            undefined,
          ]);
          return true;
        }

        // existing event
        const exisitingEvent = existingEventsLookUp[eventId].event;
        const exisitingEventRow = existingEventsLookUp[eventId].row;

        if (event.getStartTime() - exisitingEvent[startTimeColumn - 1] !== 0) {
          hourSheet
            .getRange(exisitingEventRow, startTimeColumn)
            .setValue(event.getStartTime());
        }

        if (event.getEndTime() - exisitingEvent[endTimeColumn - 1] !== 0) {
          hourSheet
            .getRange(exisitingEventRow, endTimeColumn)
            .setValue(event.getEndTime());
        }

        if (
          event.getCreators().join(",") !== exisitingEvent[creatorsColumn - 1]
        ) {
          hourSheet
            .getRange(exisitingEventRow, creatorsColumn)
            .setValue(event.getCreators()[0]);
        }

        if (
          event
            .getGuestList()
            .map((guest) => guest.getEmail())
            .join(",") !== exisitingEvent[guestListColumn - 1]
        ) {
          hourSheet.getRange(exisitingEventRow, guestListColumn).setValue(
            event
              .getGuestList()
              .map((guest) => guest.getEmail())
              .join(","),
          );
        }

        if (event.getLocation() !== exisitingEvent[locationColumn - 1]) {
          hourSheet
            .getRange(exisitingEventRow, locationColumn)
            .setValue(event.getLocation());
        }

        if (event.getTitle() !== exisitingEvent[titleColumn - 1]) {
          if (!isUpdateCalendarItemTitle) {
            hourSheet
              .getRange(exisitingEventRow, titleColumn)
              .setValue(event.getTitle());
          }
          if (isUpdateCalendarItemTitle) {
            event.setTitle(exisitingEvent[titleColumn - 1]);
          }
        }

        if (event.getDescription() !== exisitingEvent[descriptionColumn - 1]) {
          if (!isUpdateCalendarItemDescription) {
            hourSheet
              .getRange(exisitingEventRow, descriptionColumn)
              .setValue(event.getDescription());
          }
          if (isUpdateCalendarItemDescription) {
            event.setDescription(exisitingEvent[descriptionColumn - 1]);
          }
        }

        return true;
      };

      // process each event for the calendar
      events.every(handleEvent);

      // remove any events in the sheet that are not in de calendar
      existingEvents.every((event, index) => {
        if (event[0] !== calendarId) {
          return true;
        }

        if (eventsLookup[event[1]]) {
          return true;
        }

        if (event[3] < syncStartDate) {
          return true;
        }

        hourSheet.getRange(index + 2, 1, 1, 20).clear();
        return true;
      });

      return true;
    };

    // process the calendars
    settings.calendarSettings
      .filter((calenderSetting) => calenderSetting.sync === true)
      .every(processCalendar);

    formatSheet();
    SpreadsheetApp.setActiveSheet(hourSheet);

    console.log("Finished processing hours.");
  }

  const mainSpreadSheetId = par.mainSpreadsheetId;
  const mainSpreadsheet = SpreadsheetApp.openById(mainSpreadSheetId);
  const hourSheet = mainSpreadsheet.getSheetByName("Hours");
  const categoriesSheet = mainSpreadsheet.getSheetByName("Categories");
  const settings = getSettings();

  const syncStartDate = new Date();
  syncStartDate.setDate(syncStartDate.getDate() - Number(settings.syncFrom));

  const syncEndDate = new Date();
  syncEndDate.setDate(syncEndDate.getDate() + Number(settings.syncTo));

  const isUpdateCalendarItemTitle = settings.isUpdateCalendarItemTitle;
  const isUseCategoriesAsCalendarItemTitle =
    settings.isUseCategoriesAsCalendarItemTitle;
  const isUpdateCalendarItemDescription =
    settings.isUpdateCalendarItemDescription;

  const startTimeColumn = 3;
  const endTimeColumn = 4;
  const creatorsColumn = 6;
  const originalTitleColumn = 7;
  const originalDescriptionColumn = 8;
  const clientColumn = 9;
  const projectColumn = 10;
  const taskColumn = 11;
  const titleColumn = 12;
  const descriptionColumn = 13;
  const guestListColumn = 14;
  const locationColumn = 15;

  return Object.freeze({
    run: run,
  });
};
</section>
<section>
  <h3>Page.html</h3>
solutions/automations/calendar-timesheet/Page.html
<!DOCTYPE html>
<!--
 Copyright 2022 Google LLC

 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at

      http://www.apache.org/licenses/LICENSE-2.0

 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
-->

<html>

<head>
    <link rel="stylesheet" href="https://ssl.gstatic.com/docs/script/css/add-ons1.css">
    <style>
        #main {
            display: none
        }

        #categories-as-item-title {
            display: none
        }

        #show_title_warning {
            display: none
        }

        #show_description_warning {
            display: none
        }

        .red {
            color: red;
        }

        .branding-below {
            bottom: 56px;
            top: 0;
        }

        input[type=number] {
            width: 50px;
            height: 15px;
        }
    </style>
</head>

<body>
    <div class="sidebar branding-below" id="wait">
        Please wait...
    </div>
    <div class="sidebar branding-below" id="main">
        <div class="block" id="checks">
            <b>Synchronise calendars</b>
            <div>
                <span class="error" id="calendar-message"></span>
            </div>
        </div>

        <div class="block">
            <b>Synchronisation period</b>
            <br>Synchronise from the last <input type="number" name="sync-from" id="sync-from"> days
            <br>Synchronise up to the coming <input type="number" name="sync-to" id="sync-to"> days
        </div>

        <div class="block">
            <b>Update the calendar items</b><br>
            <input type="checkbox" id="is-update-calendar-item-title">
            <label for="is-update-calendar-item-title">Overwrite the calendar item title</label>
            <span class="secondary" id="show_title_warning">The calendar title will be overwritten with the values in
                title
                column of the sheet</span>
        </div>
        <div id="categories-as-item-title">
            <input type="checkbox" id="is-use-categories-as-item-title">
            <label for="is-use-categories-as-item-title">Use categories as the calendar item title</label>
        </div>
        <div class="block">
            <input type="checkbox" id="is-update-calendar-item-description">
            <label for="is-update-calendar-item-description">Overwrite the calendar item description</label>
            <span class="secondary" id="show_description_warning">The calendar description will be overwritten with the
                values in description column of the sheet</span>
        </div>
        <div class="block">
            <button class="blue" onClick="saveSettings()">Save</button>
        </div>
        <div class="block">
            <span class="error" id="generic-error"></span>
            <span class="gray" id="generic-message"></span>
        </div>

    </div>
    <div class="sidebar bottom">
        <span class="gray">
            myTime v1.2.0</span>
    </div>
</body>
<script>
    // event handler for categrories
    document.getElementById('is-update-calendar-item-title').addEventListener('change', (event) => {
        if (event.target.checked) {
            document.getElementById('categories-as-item-title').style.display = "block";
            document.getElementById('show_title_warning').style.display = "block";
        } else {
            document.getElementById('categories-as-item-title').style.display = "none";
            document.getElementById('is-use-categories-as-item-title').checked = false;
            document.getElementById('show_title_warning').style.display = "none";
        }
    })

    document.getElementById('is-update-calendar-item-description').addEventListener('change', (event) => {
        if (event.target.checked) {
            document.getElementById('show_description_warning').style.display = "block";
        } else {
            document.getElementById('show_description_warning').style.display = "none";
        }
    })

    // generic error handler
    const onFailure = (error) => {
        console.debug(error);
        document.getElementById('generic-error').innerHTML = error.message;
    }

    // receiving the settings
    const onSuccessGetSettings = (settings) => {
        console.debug(settings);

        settings.calendarSettings.forEach((calendar, index) => {
            const div = document.createElement('div');

            const check = document.createElement('input');
            check.className = 'calendar-check';
            check.className = 'calendar-check red';
            check.type = 'checkbox';
            check.id = 'calendar' + index;
            check.value = (calendar.id);
            check.name = (calendar.name);
            check.checked = (calendar.sync);

            const label = document.createElement('label')
            label.htmlFor = "calendar" + index;
            label.appendChild(document.createTextNode(calendar.name));
            if (index == 0) {
                label.className = 'red';
            }

            div.appendChild(check);
            div.appendChild(label);

            document.getElementById('checks').appendChild(div);
        });

        document.getElementById('sync-from').value = settings.syncFrom || 31;
        document.getElementById('sync-to').value = settings.syncTo || 31;
        document.getElementById('is-update-calendar-item-title').checked = settings.isUpdateCalendarItemTitle;

        if (settings.isUpdateCalendarItemTitle) {
            document.getElementById('categories-as-item-title').style.display = "block";
            document.getElementById('is-use-categories-as-item-title').checked = settings.isUseCategoriesAsCalendarItemTitle;
            document.getElementById('show_title_warning').style.display = "block";
        }

        if (settings.isUpdateCalendarItemDescription) {
            document.getElementById('is-update-calendar-item-description').checked = settings.isUpdateCalendarItemDescription;
            document.getElementById('show_description_warning').style.display = "block";
        }
        document.getElementById('wait').style.display = "none";
        document.getElementById('main').style.display = "block";


    }

    // receiving the settings saved confirmation
    const onSuccessSaveSettings = (msg) => {
        console.debug(msg);
        document.getElementById('generic-message').innerHTML = msg;
    }

    // save the settings
    const saveSettings = () => {
        document.getElementById('generic-message').innerHTML = '';
        const checks = document.getElementsByClassName('calendar-check');
        const calendarSettings = [];
        for (let check of checks) {
            if (!check.checked) {
                continue;
            }
            calendarSettings.push({
                name: check.name,
                id: check.value,
                sync: check.checked
            });
        }

        const settings = {};
        settings.calendarSettings = calendarSettings;
        settings.syncFrom = document.getElementById('sync-from').value;
        settings.syncTo = document.getElementById('sync-to').value;
        settings.isUpdateCalendarItemTitle = document.getElementById('is-update-calendar-item-title').checked;
        if (settings.isUpdateCalendarItemTitle) {
            settings.isUseCategoriesAsCalendarItemTitle = document.getElementById('is-use-categories-as-item-title').checked;
        }
        if (!settings.isUpdateCalendarItemTitle) {
            settings.isUseCategoriesAsCalendarItemTitle = false;
        }

        settings.isUpdateCalendarItemDescription = document.getElementById('is-update-calendar-item-description').checked;
        console.debug(settings)

        google.script.run
            .withFailureHandler(onFailure)
            .withSuccessHandler(onSuccessSaveSettings)
            .saveSettings(settings);
    }

    // get the initial settings
    google.script.run
        .withFailureHandler(onFailure)
        .withSuccessHandler(onSuccessGetSettings)
        .getSettings();
</script>

</html>
</section>

Colaboradores

Jasper Duizendstra, arquitecto de Google Cloud y experto en desarrolladores de Google, creó esta muestra. Encuentra a Jasper en Twitter @Duizendstra.

Google mantiene esta muestra con la ayuda de expertos en desarrolladores de Google.

Próximos pasos