Skip to content

Instantly share code, notes, and snippets.

@patforna
Last active March 7, 2026 21:02
Show Gist options
  • Select an option

  • Save patforna/94f03ed05c2d570a0eb2bcaf4d06598e to your computer and use it in GitHub Desktop.

Select an option

Save patforna/94f03ed05c2d570a0eb2bcaf4d06598e to your computer and use it in GitHub Desktop.
Google Calendar Auto-Colorizer (Jane's version)
// === Google Calendar Auto-Colorizer (Jane's version) ===
//
// Automatically colors calendar events based on organizer and attendee roles.
//
// Color rules:
// Organizer | Condition | Color
// ------------|------------------------------------|-----------------
// Patric | Jane not accepted OR optional | Blue (Peacock)
// Patric | Jane accepted AND not optional | Yellow (Banana)
// Jane | Patric included + required | Yellow (Banana)
// Jane | Patric not included, or optional | Do nothing (Tomato default)
// Someone else| — | Do nothing
//
// Manual override:
// To override the script's color for any event, simply set the color manually
// in Google Calendar. The script tracks which colors it set via a hidden
// extended property (autoColored). If an event has a color but no autoColored
// property, the script assumes it was set manually and will never touch it.
//
// How it works:
// The onEventUpdated trigger fires whenever any event on the calendar changes,
// but it only provides the calendar ID — not the changed event. To find what
// changed, we use a sync token with Calendar.Events.list(), which returns only
// events modified since the last call. The 90-day scan only happens on the very
// first run to establish the initial sync token. After that, each trigger
// typically processes just 1-2 changed events.
//
// Setup:
// 1. Go to https://script.google.com → New project
// 2. Name it "Calendar Colorizer"
// 3. Paste this entire file into the editor (replace any existing code)
// 4. In the left sidebar, click Services (+) → select "Google Calendar API" → Add
// 5. In the function dropdown (top bar), select "setup" → click Run
// 6. When prompted, click Advanced → "Go to Calendar Colorizer (unsafe)" → Allow
// 7. Done! The script is now live and will auto-color new/updated events
//
// Utilities:
// colorizeDryRun() — logs what would change without modifying anything (next 365 days)
// colorizeAll() — bulk colorize existing events (next 365 days)
// resetSyncToken() — clears the sync token to force a full re-scan on next run
//
// === Configuration ===
var PATRIC_EMAIL = 'patric.fornasier@gmail.com';
var CALENDAR_ID = 'primary';
var COLOR_PEACOCK = '7'; // Blue
var COLOR_BANANA = '5'; // Yellow
var AUTO_COLOR_KEY = 'autoColored'; // extended property key to track script-set colors
// === Main handler — called by onEventUpdated trigger ===
function onCalendarChange(e) {
try {
processCalendarChanges(e);
} catch (err) {
MailApp.sendEmail(
Session.getActiveUser().getEmail(),
'Calendar Colorizer failed',
'Error: ' + err.message + '\n\nStack: ' + err.stack
);
throw err; // re-throw so it also shows in Apps Script logs
}
}
function processCalendarChanges(e) {
var calendarId = (e && e.calendarId) || CALENDAR_ID;
var props = PropertiesService.getUserProperties();
var syncToken = props.getProperty('syncToken');
var options = { maxResults: 250 };
if (syncToken) {
options.syncToken = syncToken;
} else {
// First run: scan upcoming 90 days
options.timeMin = new Date().toISOString();
options.timeMax = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
}
var pageToken;
do {
if (pageToken) options.pageToken = pageToken;
var events;
try {
events = Calendar.Events.list(calendarId, options);
} catch (err) {
if (err.message && err.message.indexOf('Sync token') !== -1) {
// Token invalidated — full re-sync
props.deleteProperty('syncToken');
delete options.syncToken;
options.timeMin = new Date().toISOString();
options.timeMax = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
events = Calendar.Events.list(calendarId, options);
} else {
throw err;
}
}
if (events.items) {
events.items.forEach(function(event) {
colorizeEvent(calendarId, event);
});
}
pageToken = events.nextPageToken;
if (!pageToken && events.nextSyncToken) {
props.setProperty('syncToken', events.nextSyncToken);
}
} while (pageToken);
}
// === Color logic ===
var COLOR_NAMES = { '7': 'BLUE', '5': 'YELLOW' };
function colorizeEvent(calendarId, event, dryRun) {
if (event.status === 'cancelled') return null;
var organizer = event.organizer;
if (!organizer) return null;
var attendees = event.attendees || [];
var targetColor = null;
if (isPatric(organizer.email)) {
// Organizer is Patric
var me = findSelf(attendees);
if (!me) return null; // not an attendee (e.g. shared calendar artifact) → ignore
if (me.responseStatus !== 'accepted' || me.optional) {
targetColor = COLOR_PEACOCK; // Blue — not accepted or FYI from Patric
} else {
targetColor = COLOR_BANANA; // Yellow — accepted family event
}
} else if (organizer.self) {
// Organizer is me (Jane)
var patric = findPatric(attendees);
if (patric && !patric.optional) {
targetColor = COLOR_BANANA; // Yellow — family event I organised
}
// else: Patric not included or optional → do nothing
}
// else: someone else is organizer → ignore
// Skip if no action needed
if (!targetColor || event.colorId === targetColor) return null;
// Skip if the event has a color that was manually set (not by this script)
var autoColored = event.extendedProperties
&& event.extendedProperties.private
&& event.extendedProperties.private[AUTO_COLOR_KEY];
if (event.colorId && !autoColored) {
if (dryRun) {
var date = event.start && (event.start.date || event.start.dateTime || '');
Logger.log('[DRY RUN] Skipped "%s" (%s) — has manually set color', event.summary, date);
}
return null;
}
var colorName = COLOR_NAMES[targetColor];
var date = event.start && (event.start.date || event.start.dateTime || '');
if (dryRun) {
Logger.log('[DRY RUN] Would set %s on "%s" (%s)', colorName, event.summary, date);
} else {
patchWithRetry(calendarId, event.id, {
colorId: targetColor,
extendedProperties: { private: { autoColored: 'true' } }
});
Logger.log('Set %s on "%s" (%s)', colorName, event.summary, date);
}
return { summary: event.summary, date: date, color: colorName };
}
// === Patch with rate-limit retry ===
function patchWithRetry(calendarId, eventId, payload) {
for (var attempt = 0; attempt < 3; attempt++) {
try {
Calendar.Events.patch(payload, calendarId, eventId);
Utilities.sleep(200); // throttle to stay under rate limit
return;
} catch (err) {
if (err.message && (err.message.indexOf('Rate Limit') !== -1 || err.message.indexOf('Quota exceeded') !== -1) && attempt < 2) {
Utilities.sleep(2000 * (attempt + 1)); // back off: 2s, 4s
} else {
throw err;
}
}
}
}
// === Helpers ===
function isPatric(email) {
return email && email.toLowerCase() === PATRIC_EMAIL.toLowerCase();
}
function findPatric(attendees) {
for (var i = 0; i < attendees.length; i++) {
if (isPatric(attendees[i].email)) return attendees[i];
}
return null;
}
function findSelf(attendees) {
for (var i = 0; i < attendees.length; i++) {
if (attendees[i].self) return attendees[i];
}
return null;
}
// === Setup — run this once manually ===
function setup() {
// Remove any existing triggers for this function
ScriptApp.getProjectTriggers().forEach(function(t) {
if (t.getHandlerFunction() === 'onCalendarChange') {
ScriptApp.deleteTrigger(t);
}
});
// Install the onEventUpdated trigger for your primary calendar
ScriptApp.newTrigger('onCalendarChange')
.forUserCalendar(Session.getActiveUser().getEmail())
.onEventUpdated()
.create();
// Establish sync token without colorizing existing events.
// This does a full list to get the token, then discards the results.
// Only events created/updated after this point will be colorized.
initSyncToken(CALENDAR_ID);
Logger.log('Setup complete. Trigger installed. Only new/updated events will be colorized.');
}
// === Utility: establish sync token without processing events ===
function initSyncToken(calendarId) {
var props = PropertiesService.getUserProperties();
var options = {
maxResults: 250,
timeMin: new Date().toISOString(),
timeMax: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString()
};
var pageToken;
do {
if (pageToken) options.pageToken = pageToken;
var events = Calendar.Events.list(calendarId, options);
pageToken = events.nextPageToken;
if (!pageToken && events.nextSyncToken) {
props.setProperty('syncToken', events.nextSyncToken);
}
} while (pageToken);
}
// === Utility: bulk colorize all events in the next 365 days ===
function colorizeAll() {
var count = processAllEvents(false);
Logger.log('Colorize complete. %s events updated.', count);
}
// === Utility: dry run — logs what would change without modifying anything ===
function colorizeDryRun() {
var count = processAllEvents(true);
Logger.log('Dry run complete. %s events would be updated.', count);
}
function processAllEvents(dryRun) {
var options = {
maxResults: 250,
timeMin: new Date().toISOString(),
timeMax: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString()
};
var count = 0;
var pageToken;
do {
if (pageToken) options.pageToken = pageToken;
var events = Calendar.Events.list(CALENDAR_ID, options);
if (events.items) {
events.items.forEach(function(event) {
if (colorizeEvent(CALENDAR_ID, event, dryRun)) count++;
});
}
pageToken = events.nextPageToken;
} while (pageToken);
return count;
}
// === Utility: reset sync token (if you need a fresh full scan) ===
function resetSyncToken() {
PropertiesService.getUserProperties().deleteProperty('syncToken');
Logger.log('Sync token cleared. Next run will do a full scan.');
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment