Last active
February 24, 2026 15:30
-
-
Save nikitaeverywhere/ed6b20dfcb09dace0f61b486c26a98a8 to your computer and use it in GitHub Desktop.
The end result of this script is a shared Google Calendar that automatically syncs vacation and out-of-office events from every team member's personal calendar. The script runs every hour and keeps the shared calendar up to date — no manual work needed. More info: https://nikitaeverywhere.com/posts/2026-02-24-google-calendar-vacations-sync/
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // @see https://nikitaeverywhere.com/posts/2026-02-24-google-calendar-vacations-sync/ | |
| // This is the original Google Vacations Calendar script to sync company/team vacations, | |
| // updated by Nikita Savchenko to: | |
| // a. Sync >23h events as full-day events, taking into account time shifts | |
| // b. Sync events from "Out of office" calendar type regardless of their title | |
| // c. Update already-synced events when their source changes (recurrence, time, etc.) | |
| // d. Remove synced events when the source event is cancelled/deleted | |
| // e. Handle single-instance modifications of recurring events | |
| // To learn how to use this script, refer to the documentation: | |
| // https://developers.google.com/apps-script/samples/automations/vacation-calendar | |
| /* | |
| 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 | |
| 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. | |
| */ | |
| // Set the ID of the team calendar to add events to. You can find the calendar's | |
| // ID on the settings page. | |
| let TEAM_CALENDAR_ID = | |
| "c_c30af3621835a0d4279167a2bfcb7bca3f2cee491528b2ba7e103d98bfba391a@group.calendar.google.com"; | |
| // Set the email address of the Google Group that contains everyone in the team. | |
| // Ensure the group has less than 500 members to avoid timeouts. | |
| // Change to an array in order to add indirect members from multiple groups, for example: | |
| // let GROUP_EMAIL = ['ENTER_GOOGLE_GROUP_EMAIL_HERE', 'ENTER_ANOTHER_GOOGLE_GROUP_EMAIL_HERE']; | |
| let GROUP_EMAIL = "company@arkis.xyz"; | |
| let ONLY_DIRECT_MEMBERS = false; | |
| let KEYWORDS = ["vacation", "ooo", "out of office", "offline"]; | |
| let MONTHS_IN_ADVANCE = 3; | |
| // Thresholds for deciding when to convert a timed event to an all-day event. | |
| // We use a 2-hour fuzz to safely handle DST transitions (23h or 25h days). | |
| const DAY_MS = 24 * 60 * 60 * 1000; | |
| const DST_FUZZ_MS = 2 * 60 * 60 * 1000; | |
| /** | |
| * Sets up the script to run automatically every hour. | |
| */ | |
| function setup() { | |
| let triggers = ScriptApp.getProjectTriggers(); | |
| if (triggers.length > 0) { | |
| throw new Error("Triggers are already setup."); | |
| } | |
| ScriptApp.newTrigger("sync").timeBased().everyHours(1).create(); | |
| // Runs the first sync immediately. | |
| sync(); | |
| } | |
| /** | |
| * One-time helper: clears lastRun so the next sync re-processes all events | |
| * in the MONTHS_IN_ADVANCE window. Useful after deploying code changes to | |
| * pick up previously-missed updates. Delete after use. | |
| */ | |
| function resetLastRun() { | |
| PropertiesService.getScriptProperties().deleteProperty("lastRun"); | |
| console.log("lastRun cleared — next sync will do a full scan"); | |
| } | |
| /** | |
| * Looks through the group members' public calendars and adds any | |
| * 'vacation' or 'out of office' events to the team calendar. | |
| */ | |
| function sync() { | |
| // Defines the calendar event date range to search. | |
| let today = new Date(); | |
| let maxDate = new Date(); | |
| maxDate.setMonth(maxDate.getMonth() + MONTHS_IN_ADVANCE); | |
| // Determines the time the script was last run. | |
| let lastRun = PropertiesService.getScriptProperties().getProperty("lastRun"); | |
| lastRun = lastRun ? new Date(lastRun) : null; | |
| // Gets the list of users in the Google Group. | |
| let users = getAllMembers(GROUP_EMAIL); | |
| if (ONLY_DIRECT_MEMBERS) { | |
| users = GroupsApp.getGroupByEmail(GROUP_EMAIL).getUsers(); | |
| } else if (Array.isArray(GROUP_EMAIL)) { | |
| users = getUsersFromGroups(GROUP_EMAIL); | |
| } | |
| // For each user, find events with the keywords and import them. | |
| let counts = { imported: 0, updated: 0, removed: 0, errors: 0 }; | |
| users.forEach(function (user) { | |
| let username = user.getEmail().split("@")[0]; | |
| let processedIds = new Set(); | |
| KEYWORDS.forEach(function (keyword) { | |
| let events = findEvents(user, keyword, today, maxDate, lastRun); | |
| events.forEach(function (event) { | |
| let uid = eventUID(event); | |
| if (processedIds.has(uid)) return; | |
| processedIds.add(uid); | |
| let result = importOrUpdateEvent(username, event); | |
| if (result) counts[result]++; | |
| }); | |
| }); | |
| // Also import native Out of office events if their titles don't match keywords. | |
| let oooEvents = findEvents(user, null, today, maxDate, lastRun); | |
| oooEvents.forEach(function (event) { | |
| let uid = eventUID(event); | |
| if (event.eventType === "outOfOffice" && !processedIds.has(uid)) { | |
| processedIds.add(uid); | |
| let result = importOrUpdateEvent(username, event); | |
| if (result) counts[result]++; | |
| } | |
| }); | |
| }); | |
| PropertiesService.getScriptProperties().setProperty("lastRun", today); | |
| console.log( | |
| "Done — imported: %s, updated: %s, removed: %s, errors: %s", | |
| counts.imported, | |
| counts.updated, | |
| counts.removed, | |
| counts.errors | |
| ); | |
| } | |
| /** | |
| * Returns a deduplication key for an event. For recurring-event instances | |
| * this includes the instance identifier so we don't confuse them with | |
| * the master event. | |
| * @param {Calendar.Event} event | |
| * @return {string} | |
| */ | |
| function eventUID(event) { | |
| let base = event.iCalUID || event.id; | |
| // For recurring instances, append the original start time to make the key unique. | |
| if (event.recurringEventId && event.originalStartTime) { | |
| let orig = | |
| event.originalStartTime.dateTime || event.originalStartTime.date || ""; | |
| return base + "::" + orig; | |
| } | |
| return base; | |
| } | |
| /** | |
| * Imports or updates the given event from the user's calendar into the shared | |
| * team calendar. If the event was cancelled/deleted, removes it from the team | |
| * calendar instead. | |
| * @param {string} username The team member that is attending the event. | |
| * @param {Calendar.Event} event The event to import. | |
| * @return {string|null} 'imported', 'updated', 'removed', 'errors', or null. | |
| */ | |
| function importOrUpdateEvent(username, event) { | |
| // Handle cancelled / deleted events. | |
| if (event.status === "cancelled") { | |
| if (event.recurringEventId) { | |
| return removeRecurringInstance(event); | |
| } | |
| return removeTeamEvent(event); | |
| } | |
| event.summary = "[" + username + "] " + event.summary; | |
| event.organizer = { id: TEAM_CALENDAR_ID }; | |
| event.attendees = []; | |
| // If the event is not of type 'default', it cannot be imported as-is. | |
| if (event.eventType != "default") { | |
| event.eventType = "default"; | |
| delete event.outOfOfficeProperties; | |
| delete event.focusTimeProperties; | |
| } | |
| // If this timed event lasts ≥ ~24h, convert it to an all-day event | |
| // to avoid cross-timezone bar shifts on the team calendar. | |
| maybeMakeAllDay(event); | |
| // Single-instance modification of a recurring event — patch that instance. | |
| if (event.recurringEventId) { | |
| return patchRecurringInstance(event); | |
| } | |
| // Try import first (fast path for new events). | |
| try { | |
| Calendar.Events.import(event, TEAM_CALENDAR_ID); | |
| console.log("Imported: %s", event.summary); | |
| return "imported"; | |
| } catch (e) { | |
| // Import fails when an event with this iCalUID already exists. | |
| // Fall through to update logic. | |
| } | |
| // Event already exists — find it by iCalUID and update. | |
| try { | |
| let existing = findTeamEventByUID(event.iCalUID); | |
| if (existing) { | |
| let teamEventId = existing.id; | |
| event.id = teamEventId; | |
| Calendar.Events.update(event, TEAM_CALENDAR_ID, teamEventId); | |
| console.log("Updated: %s", event.summary); | |
| return "updated"; | |
| } else { | |
| console.error( | |
| "Import failed and no existing event found for iCalUID: %s. Skipping.", | |
| event.iCalUID | |
| ); | |
| return "errors"; | |
| } | |
| } catch (e) { | |
| console.error( | |
| 'Error updating event "%s": %s. Skipping.', | |
| event.summary, | |
| e.toString() | |
| ); | |
| return "errors"; | |
| } | |
| } | |
| /** | |
| * Finds the team-calendar instance that corresponds to a source recurring-event | |
| * instance, by matching iCalUID + originalStartTime. | |
| * @param {Calendar.Event} sourceInstance The instance from the source calendar. | |
| * @return {Calendar.Event|null} The matching team-calendar instance, or null. | |
| */ | |
| function findTeamInstance(sourceInstance) { | |
| if (!sourceInstance.iCalUID || !sourceInstance.originalStartTime) return null; | |
| // Find the master recurring event on the team calendar. | |
| let master = findTeamEventByUID(sourceInstance.iCalUID); | |
| if (!master) return null; | |
| // Determine the original start of this particular instance. | |
| let origDT = | |
| sourceInstance.originalStartTime.dateTime || | |
| sourceInstance.originalStartTime.date; | |
| if (!origDT) return null; | |
| let origMs = new Date(origDT).getTime(); | |
| // List instances of the master event in a narrow window around the original start. | |
| let windowStart = new Date(origMs - DAY_MS); | |
| let windowEnd = new Date(origMs + DAY_MS); | |
| let response = Calendar.Events.instances(TEAM_CALENDAR_ID, master.id, { | |
| timeMin: formatDateAsRFC3339(windowStart), | |
| timeMax: formatDateAsRFC3339(windowEnd), | |
| }); | |
| if (!response.items) return null; | |
| for (let i = 0; i < response.items.length; i++) { | |
| let inst = response.items[i]; | |
| if (!inst.originalStartTime) continue; | |
| let instDT = inst.originalStartTime.dateTime || inst.originalStartTime.date; | |
| if (instDT && new Date(instDT).getTime() === origMs) { | |
| return inst; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * Patches a single instance of a recurring event on the team calendar. | |
| * Uses patch() to update only the changed fields without breaking the | |
| * recurring event structure. | |
| * @param {Calendar.Event} event The modified instance from the source calendar. | |
| * @return {string|null} 'updated', 'errors', or null. | |
| */ | |
| function patchRecurringInstance(event) { | |
| try { | |
| let instance = findTeamInstance(event); | |
| if (!instance) { | |
| console.error( | |
| "No matching team-calendar instance found for: %s (originalStartTime: %s). Skipping.", | |
| event.summary, | |
| JSON.stringify(event.originalStartTime) | |
| ); | |
| return "errors"; | |
| } | |
| // Patch only the fields that matter for display. | |
| let patch = { | |
| summary: event.summary, | |
| start: event.start, | |
| end: event.end, | |
| }; | |
| if (event.description !== undefined) patch.description = event.description; | |
| if (event.location !== undefined) patch.location = event.location; | |
| if (event.status) patch.status = event.status; | |
| Calendar.Events.patch(patch, TEAM_CALENDAR_ID, instance.id); | |
| console.log("Updated instance: %s", event.summary); | |
| return "updated"; | |
| } catch (e) { | |
| console.error( | |
| 'Error patching recurring instance "%s": %s. Skipping.', | |
| event.summary, | |
| e.toString() | |
| ); | |
| return "errors"; | |
| } | |
| } | |
| /** | |
| * Removes a cancelled instance of a recurring event from the team calendar. | |
| * @param {Calendar.Event} event The cancelled instance from the source calendar. | |
| * @return {string|null} 'removed' or 'errors'. | |
| */ | |
| function removeRecurringInstance(event) { | |
| try { | |
| let instance = findTeamInstance(event); | |
| if (instance) { | |
| Calendar.Events.remove(TEAM_CALENDAR_ID, instance.id); | |
| console.log( | |
| "Removed recurring instance (iCalUID: %s, originalStartTime: %s)", | |
| event.iCalUID, | |
| JSON.stringify(event.originalStartTime) | |
| ); | |
| return "removed"; | |
| } | |
| } catch (e) { | |
| console.error( | |
| "Error removing recurring instance (iCalUID: %s): %s. Skipping.", | |
| event.iCalUID, | |
| e.toString() | |
| ); | |
| return "errors"; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Removes a cancelled/deleted event from the team calendar by iCalUID. | |
| * @param {Calendar.Event} event The cancelled source event. | |
| * @return {string|null} 'removed' or 'errors'. | |
| */ | |
| function removeTeamEvent(event) { | |
| if (!event.iCalUID) return null; | |
| try { | |
| let existing = findTeamEventByUID(event.iCalUID); | |
| if (existing) { | |
| Calendar.Events.remove(TEAM_CALENDAR_ID, existing.id); | |
| console.log( | |
| "Removed cancelled event: %s (iCalUID: %s)", | |
| existing.summary, | |
| event.iCalUID | |
| ); | |
| return "removed"; | |
| } | |
| } catch (e) { | |
| console.error( | |
| "Error removing event (iCalUID: %s): %s. Skipping.", | |
| event.iCalUID, | |
| e.toString() | |
| ); | |
| return "errors"; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Finds an event on the team calendar by its iCalUID. | |
| * @param {string} iCalUID The iCalUID to search for. | |
| * @return {Calendar.Event|null} The matching event, or null. | |
| */ | |
| function findTeamEventByUID(iCalUID) { | |
| if (!iCalUID) return null; | |
| let response = Calendar.Events.list(TEAM_CALENDAR_ID, { iCalUID: iCalUID }); | |
| if (response.items && response.items.length > 0) { | |
| return response.items[0]; | |
| } | |
| return null; | |
| } | |
| /** | |
| * In a given user's calendar, looks for occurrences of the given keyword | |
| * in events within the specified date range and returns any such events found. | |
| * @param {Session.User} user The user to retrieve events for. | |
| * @param {string} keyword The keyword to look for. | |
| * @param {Date} start The starting date of the range to examine. | |
| * @param {Date} end The ending date of the range to examine. | |
| * @param {Date} optSince A date indicating the last time this script was run. | |
| * @return {Calendar.Event[]} An array of calendar events. | |
| */ | |
| function findEvents(user, keyword, start, end, optSince) { | |
| let params = { | |
| timeMin: formatDateAsRFC3339(start), | |
| timeMax: formatDateAsRFC3339(end), | |
| showDeleted: true, | |
| }; | |
| if (keyword) { | |
| params.q = keyword; | |
| } | |
| if (optSince) { | |
| params.updatedMin = formatDateAsRFC3339(optSince); | |
| } | |
| let pageToken = null; | |
| let events = []; | |
| do { | |
| params.pageToken = pageToken; | |
| let response; | |
| try { | |
| response = Calendar.Events.list(user.getEmail(), params); | |
| } catch (e) { | |
| console.error( | |
| "Error retrieving events for %s, %s: %s; skipping", | |
| user, | |
| keyword, | |
| e.toString() | |
| ); | |
| continue; | |
| } | |
| events = events.concat( | |
| response.items.filter(function (item) { | |
| return shouldImportEvent(user, keyword, item); | |
| }) | |
| ); | |
| pageToken = response.nextPageToken; | |
| } while (pageToken); | |
| return events; | |
| } | |
| /** | |
| * Determines if the given event should be imported into the shared team calendar. | |
| * @param {Session.User} user The user that is attending the event. | |
| * @param {string} keyword The keyword being searched for. | |
| * @param {Calendar.Event} event The event being considered. | |
| * @return {boolean} True if the event should be imported. | |
| */ | |
| function shouldImportEvent(user, keyword, event) { | |
| // Always let cancelled events through so they can be removed from the team calendar. | |
| if (event.status === "cancelled") { | |
| return true; | |
| } | |
| // Always accept native Out of office entries even if the title was changed. | |
| if (event.eventType === "outOfOffice") { | |
| return true; | |
| } | |
| // Ensure summary exists and contains the keyword. | |
| if ( | |
| !keyword || | |
| !event.summary || | |
| event.summary.toLowerCase().indexOf(keyword) < 0 | |
| ) { | |
| return false; | |
| } | |
| // If the user is the creator of the event, always import it. | |
| if (!event.organizer || event.organizer.email == user.getEmail()) { | |
| return true; | |
| } | |
| // Only import events the user has accepted. | |
| if (!event.attendees) return false; | |
| let matching = event.attendees.filter(function (attendee) { | |
| return attendee.self; | |
| }); | |
| return matching.length > 0 && matching[0].responseStatus == "accepted"; | |
| } | |
| /** | |
| * Returns an RFC3339 formatted date String corresponding to the given Date object. | |
| * @param {Date} date a Date. | |
| * @return {string} a formatted date string. | |
| */ | |
| function formatDateAsRFC3339(date) { | |
| return Utilities.formatDate(date, "UTC", "yyyy-MM-dd'T'HH:mm:ssZ"); | |
| } | |
| /** | |
| * If the source event is a timed event of length ≥ ~24h (DST tolerant), | |
| * convert it to an all-day event by setting start.date and end.date. | |
| * end.date is exclusive. If the event ends exactly at 00:00 local time, | |
| * we keep end.date equal to that end date. Otherwise we add +1 day. | |
| * No-op if the event is already all-day or lacks dateTime boundaries. | |
| * @param {Calendar.Event} event | |
| */ | |
| function maybeMakeAllDay(event) { | |
| if (!event || !event.start || !event.end) return; | |
| // Already all-day? Nothing to do. | |
| if (event.start.date || event.end.date) return; | |
| if (!event.start.dateTime || !event.end.dateTime) return; | |
| // Compute duration in ms, tolerant to DST (±2h). | |
| var startMs = new Date(event.start.dateTime).getTime(); | |
| var endMs = new Date(event.end.dateTime).getTime(); | |
| if (isNaN(startMs) || isNaN(endMs)) return; | |
| var dur = endMs - startMs; | |
| if (dur < DAY_MS - DST_FUZZ_MS) return; // < ~22h → keep as timed | |
| // Extract YYYY-MM-DD from the original RFC3339 strings. | |
| var startDateStr = datePartYYYYMMDD_(event.start.dateTime); | |
| var endDateStr = datePartYYYYMMDD_(event.end.dateTime); | |
| if (!startDateStr || !endDateStr) return; | |
| // For all-day events, end.date is EXCLUSIVE. | |
| // If the event ends exactly at 00:00 local time, use the same end date. | |
| // Otherwise, include that last day by adding +1. | |
| var exclusiveEnd = isMidnightRFC3339_(event.end.dateTime) | |
| ? endDateStr | |
| : addDaysYMD_(endDateStr, 1); | |
| // Rewrite as all-day and remove time-specific fields/timezones. | |
| event.start = { date: startDateStr }; | |
| event.end = { date: exclusiveEnd }; | |
| } | |
| /** | |
| * Return 'YYYY-MM-DD' from an RFC3339 dateTime string. | |
| * @param {string} dt | |
| * @return {string|null} | |
| */ | |
| function datePartYYYYMMDD_(dt) { | |
| if (!dt || typeof dt !== "string") return null; | |
| // RFC3339 begins with YYYY-MM-DD; safe to slice first 10 chars. | |
| return dt.length >= 10 ? dt.substring(0, 10) : null; | |
| } | |
| /** | |
| * Add days to a 'YYYY-MM-DD' string and return a new 'YYYY-MM-DD'. | |
| * Uses UTC math to avoid timezone surprises. | |
| * @param {string} ymd | |
| * @param {number} days | |
| * @return {string} | |
| */ | |
| function addDaysYMD_(ymd, days) { | |
| var parts = ymd.split("-").map(function (p) { | |
| return parseInt(p, 10); | |
| }); | |
| var y = parts[0], | |
| m = parts[1], | |
| d = parts[2]; | |
| var date = new Date(Date.UTC(y, m - 1, d)); | |
| date.setUTCDate(date.getUTCDate() + days); | |
| return Utilities.formatDate(date, "UTC", "yyyy-MM-dd"); | |
| } | |
| /** | |
| * Returns true if an RFC3339 dateTime string has a local time of exactly 00:00[:00[.sss]]. | |
| * Works purely on the string (doesn't convert timezones), so it is safe across offsets. | |
| * @param {string} dt | |
| * @return {boolean} | |
| */ | |
| function isMidnightRFC3339_(dt) { | |
| if (!dt || typeof dt !== "string") return false; | |
| // Extract HH:MM[:SS[.sss]] part between 'T' and 'Z'/'+'/'-'. | |
| var m = dt.match(/T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?/); | |
| if (!m) return false; | |
| var hh = parseInt(m[1], 10); | |
| var mm = parseInt(m[2], 10); | |
| var ss = m[3] ? parseInt(m[3], 10) : 0; | |
| return hh === 0 && mm === 0 && ss === 0; | |
| } | |
| /** | |
| * Get both direct and indirect members (and delete duplicates). | |
| * @param {string|Array<string>} groupEmail the e-mail address of the group, or array of addresses. | |
| * @return {object[]} direct and indirect members. | |
| */ | |
| function getAllMembers(groupEmail) { | |
| if (Array.isArray(groupEmail)) { | |
| return getUsersFromGroups(groupEmail); | |
| } | |
| var group = GroupsApp.getGroupByEmail(groupEmail); | |
| var users = group.getUsers(); | |
| var childGroups = group.getGroups(); | |
| for (var i = 0; i < childGroups.length; i++) { | |
| var childGroup = childGroups[i]; | |
| users = users.concat(getAllMembers(childGroup.getEmail())); | |
| } | |
| // Remove duplicate members | |
| var uniqueUsers = []; | |
| var userEmails = {}; | |
| for (var j = 0; j < users.length; j++) { | |
| var user = users[j]; | |
| if (!userEmails[user.getEmail()]) { | |
| uniqueUsers.push(user); | |
| userEmails[user.getEmail()] = true; | |
| } | |
| } | |
| return uniqueUsers; | |
| } | |
| /** | |
| * Get indirect members from multiple groups (and delete duplicates). | |
| * @param {Array<string>} groupEmails the e-mail addresses of multiple groups. | |
| * @return {object[]} indirect members of multiple groups. | |
| */ | |
| function getUsersFromGroups(groupEmails) { | |
| let users = []; | |
| for (let groupEmail of groupEmails) { | |
| let groupUsers = GroupsApp.getGroupByEmail(groupEmail).getUsers(); | |
| for (let user of groupUsers) { | |
| if (!users.some((u) => u.getEmail() === user.getEmail())) { | |
| users.push(user); | |
| } | |
| } | |
| } | |
| return users; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment