Stop wondering if you remembered to add the ProVisors event you registered for to your calendar. Set this Google script up once and it will never forget to add your registrations to your calendar (and remove cancellations when those happen too).
Built right into your business's Google Workspace.
Important note: Make sure you are signed in and use the Google Workspace account or Google account that gets your ProVisors registration emails.
/**
* Auto Add ProVisors Hub Events (v2)
*
* Adds your ProVisors registrations and guest approvals to Google Calendar,
* and deletes events ProVisors cancels. Everything it does is listed in the
* Activity Log tab of Your ProVisors Groups Cheat Sheet.
*
* Run these from the dropdown at the top of the editor, in order:
* 1. setup – makes your cheat sheet and fills in what it can
* 2. testRun – shows what it WOULD do. Changes nothing.
* 3. turnOn – checks your email every 15 minutes from now on
* (turnOff stops it.)
*
* CHANGELOG
* v4 (2026-11-09) No Gmail label. The Activity Log is the memory (Email ID column).
* Never changes an event already on your calendar, except deleting cancellations.
* v2 (2026-11-09) Renamed. Cancelled events are deleted. Activity Log tab.
* Time zone worked out per event (city in the name, national = Pacific).
* Cheat sheet copies Zoom links and addresses already on your calendar.
* No settings to edit.
* v1 (2026-11-09) First version.
*/
const APP_NAME = 'Auto Add ProVisors Hub Events';
const SHEET_TITLE = 'Your ProVisors Groups Cheat Sheet';
const DEFAULT_LENGTH_MINUTES = 120;
const LOOKBACK_DAYS = 60;
const GROUP_HEADERS = ['Group', 'Time zone', 'Address (in-person)', 'Zoom link (virtual)', 'Notes (parking, floor, etc.)', 'Length (minutes)'];
const LOG_HEADERS = ['Date', 'What happened', 'Event', 'Event date & time', 'Time zone', 'Email', 'Email ID'];
const SEARCH_NEW = 'from:provisors@provisors.com (subject:"Registration Confirmation" OR subject:"Guest Request")';
const SEARCH_CANCEL = 'from:events@provisors.com (subject:cancelled OR subject:canceled)';
// Plain-English time zone names shown in the cheat sheet.
const ZONES = {
'Pacific': 'America/Los_Angeles',
'Mountain': 'America/Denver',
'Arizona': 'America/Phoenix',
'Central': 'America/Chicago',
'Eastern': 'America/New_York',
};
// First words of a ProVisors group name -> time zone.
const CITY_ZONES = [
['Central', ['austin', 'dallas', 'fort worth', 'houston', 'san antonio', 'north texas', 'dfw', 'chicago', 'minneapolis', 'nashville', 'kansas city', 'st. louis', 'st louis', 'new orleans', 'oklahoma', 'milwaukee']],
['Mountain', ['denver', 'boulder', 'colorado', 'salt lake', 'utah', 'albuquerque', 'boise']],
['Arizona', ['phoenix', 'scottsdale', 'tucson', 'arizona']],
['Eastern', ['new york', 'nyc', 'boston', 'atlanta', 'miami', 'tampa', 'orlando', 'florida', 'philadelphia', 'washington', 'dc ', 'charlotte', 'raleigh', 'new jersey', 'detroit', 'pittsburgh']],
['Pacific', ['los angeles', 'la ', 'orange county', 'san diego', 'san francisco', 'east bay', 'silicon valley', 'sacramento', 'seattle', 'portland', 'las vegas', 'inland empire', 'pasadena', 'conejo', 'ventura', 'santa barbara', 'south bay', 'san fernando', 'west la', 'long beach']],
];
// ---------------------------------------------------------------------
// Buttons you run
// ---------------------------------------------------------------------
function setup() {
const ss = getCheatSheet_();
const names = [];
GmailApp.search(SEARCH_NEW + ' newer_than:1y', 0, 200).forEach(t => t.getMessages().forEach(m => {
const p = parseNewEvent(m.getSubject(), m.getPlainBody(), '');
if (p && p.isRecurringGroup) names.push({ group: p.group, title: p.title });
}));
const added = addGroups_(ss, names);
const filled = fillFromCalendar_(ss);
Logger.log('Your cheat sheet: ' + ss.getUrl());
Logger.log(added + ' group(s) added. ' + filled + ' Zoom link(s) or address(es) copied from your calendar.');
}
function testRun() {
run_(true);
}
function turnOn() {
turnOff();
ScriptApp.newTrigger('checkEmail').timeBased().everyMinutes(15).create();
Logger.log('On. Checking your email every 15 minutes.');
run_(false);
}
function turnOff() {
ScriptApp.getProjectTriggers()
.filter(t => t.getHandlerFunction() === 'checkEmail')
.forEach(t => ScriptApp.deleteTrigger(t));
}
function checkEmail() {
run_(false);
}
// ---------------------------------------------------------------------
// Main work
// ---------------------------------------------------------------------
function run_(dryRun) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(5000)) return;
try {
const cal = CalendarApp.getDefaultCalendar();
const myZone = cal.getTimeZone();
const ss = getCheatSheet_();
const groups = readGroups_(ss);
const now = new Date();
const would = dryRun ? 'WOULD ' : '';
const recent = ' newer_than:' + LOOKBACK_DAYS + 'd';
const handled = handledEmailIds_(ss); // emails already in the Activity Log
const newGroups = [];
const log = [];
const row = (what, title, when, zone, thread, msg) =>
log.push([new Date(), what, title, when, zone, thread.getPermalink(), msg.getId()]);
// 1) Registrations and guest approvals
GmailApp.search(SEARCH_NEW + recent, 0, 50).forEach(thread => {
thread.getMessages().forEach(msg => {
if (handled[msg.getId()]) return;
const p = parseNewEvent(msg.getSubject(), msg.getPlainBody(), msg.getBody());
if (!p) return;
const g = groups[norm_(p.group)] || {};
const zoneName = ZONES[g.zone] ? g.zone : guessZone(p.title, myZone);
const zone = ZONES[zoneName] || myZone;
const start = Utilities.parseDate(p.dateText, zone, 'yyyy-MM-dd HH:mm');
const when = Utilities.formatDate(start, myZone, 'EEE MMM d, yyyy h:mm a z');
if (start < now) { Logger.log('Skip (already happened): ' + p.title); return; }
if (p.isRecurringGroup) newGroups.push({ group: p.group, title: p.title });
if (findExisting_(cal, start, p)) {
Logger.log('Skip (already on calendar): ' + p.title + ' | ' + when);
if (!dryRun) row('Skipped – already on your calendar', p.title, when, zoneName, thread, msg);
return;
}
const minutes = Number(g.length) || DEFAULT_LENGTH_MINUTES;
const location = p.format === 'Virtual'
? (g.zoom || 'Virtual – Zoom link on the ProVisors hub')
: (g.address || '');
Logger.log(would + 'ADD: ' + p.title + ' | ' + when + ' (' + zoneName + ' time in the email) | ' + minutes + ' min');
if (dryRun) return;
cal.createEvent(p.title, start, new Date(start.getTime() + minutes * 60000), {
location: location,
description: buildDescription(p, cleanLink_(p.link), g.notes, zoneName),
});
row('Added (' + p.kind + ')', p.title, when, zoneName, thread, msg);
});
});
// 2) Cancellations: the only time it touches an event already on your calendar is to delete it.
GmailApp.search(SEARCH_CANCEL + recent, 0, 50).forEach(thread => {
thread.getMessages().forEach(msg => {
if (handled[msg.getId()]) return;
const title = parseCancellation(msg.getSubject());
if (!title) return;
const matches = cal.getEvents(now, new Date(now.getTime() + 400 * 86400000), { search: title })
.filter(e => norm_(e.getTitle()) === norm_(title));
if (!matches.length) {
Logger.log('Cancellation, but it is not on your calendar: ' + title);
if (!dryRun) row('Cancellation received – nothing on calendar to delete', title, '', '', thread, msg);
return;
}
matches.forEach(e => {
const when = Utilities.formatDate(e.getStartTime(), myZone, 'EEE MMM d, yyyy h:mm a z');
Logger.log(would + 'DELETE (cancelled): ' + title + ' | ' + when);
if (dryRun) return;
e.deleteEvent();
row('Deleted (cancelled by ProVisors)', title, when, '', thread, msg);
});
});
});
if (!dryRun) {
if (newGroups.length && addGroups_(ss, newGroups)) fillFromCalendar_(ss);
if (log.length) {
const sh = getLogTab_(ss);
sh.getRange(sh.getLastRow() + 1, 1, log.length, LOG_HEADERS.length).setValues(log);
}
}
Logger.log(dryRun ? 'Test run finished. Nothing was changed.' : 'Done. ' + log.length + ' change(s) written to the Activity Log.');
} finally {
lock.releaseLock();
}
}
// ---------------------------------------------------------------------
// Reading the emails (no Google services here, so it can be tested anywhere)
// ---------------------------------------------------------------------
const MONTHS = { january: 1, february: 2, march: 3, april: 4, may: 5, june: 6, july: 7,
august: 8, september: 9, october: 10, november: 11, december: 12 };
function parseNewEvent(subject, plainBody, htmlBody) {
subject = decode_(subject || '').replace(/\s+/g, ' ').trim();
let title = null, kind = null, m;
if ((m = subject.match(/^Registration Confirmation for (.+)$/i))) { title = m[1]; kind = 'registration'; }
else if ((m = subject.match(/^Your Guest Request for (.+?) is approved$/i))) { title = m[1]; kind = 'guest approval'; }
if (!title) return null;
const body = decode_(plainBody || '').replace(/\s+/g, ' ');
const d = body.match(/ on (?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), ([A-Za-z]+) (\d{1,2}), (\d{4}), (\d{1,2}):(\d{2}) ?([AP]M)/i);
if (!d) return null;
const month = MONTHS[d[1].toLowerCase()];
if (!month) return null;
let hour = Number(d[4]) % 12;
if (d[6].toUpperCase() === 'PM') hour += 12;
const src = (htmlBody || '') + ' ' + (plainBody || '');
const l = src.match(/href="(https?:\/\/[^"]+)"/i) || src.match(/(https:\/\/hub\.provisors\.com\/[^\s>"]+)/i);
const format = /\bVirtual\b/i.test(title) ? 'Virtual' : (/\bIn-Person\b/i.test(title) ? 'In-Person' : '');
return {
title: title,
kind: kind,
dateText: d[3] + '-' + pad_(month) + '-' + pad_(d[2]) + ' ' + pad_(hour) + ':' + d[5],
link: l ? l[1].replace(/&/g, '&') : '',
format: format,
group: groupName(title),
isRecurringGroup: format !== '',
};
}
function parseCancellation(subject) {
const m = decode_(subject || '').replace(/\s+/g, ' ').trim().match(/^ProVisors\s*-\s*Cancell?ed\s*-\s*(.+)$/i);
return m ? m[1].trim() : null;
}
// "Austin 1, In-Person - December 2026" -> "Austin 1"
function groupName(title) {
return title
.replace(/,\s*(In-Person|Virtual)\b.*$/i, '')
.replace(/\s*-\s*(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{4}\s*$/i, '')
.trim();
}
// ProVisors emails show the meeting's local time but don't say which time zone.
// City in the name ("Austin 1", "Dallas 4") -> that city's time zone
// National meetings (virtual with no city, "Acceleration Meeting", "National") -> Pacific (ProVisors home office)
// Anything else (local socials like holiday lunches) -> your own calendar's time zone
function guessZone(title, myIanaZone) {
const t = decode_(title).toLowerCase().trim();
for (const [zone, cities] of CITY_ZONES) {
if (cities.some(c => { c = c.trim(); return t.indexOf(c) === 0 && !/[a-z]/.test(t.charAt(c.length)); })) return zone;
}
if (/\bvirtual\b|acceleration meeting|\bnational\b/.test(t)) return 'Pacific';
for (const name in ZONES) if (ZONES[name] === myIanaZone) return name;
return 'your calendar\'s time zone';
}
function buildDescription(p, link, notes, zoneName) {
const parts = [];
if (link) parts.push('ProVisors event details: <a href="' + link + '">' + p.title + '</a>');
if (notes) parts.push(String(notes).replace(/\n/g, '<br>'));
parts.push('<i>Added by ' + APP_NAME + ' from your ' + p.kind + ' email. Time read as ' + zoneName +
' time. If that looks wrong, fix this event on your calendar and change the Time zone for this group in Your ProVisors Groups Cheat Sheet so future events are right.</i>');
return parts.join('<br><br>');
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
// Skip if this meeting is already on the calendar at the same start time
// (your own recurring meeting, or one another tool added).
function findExisting_(cal, start, p) {
const title = norm_(p.title), group = norm_(p.group);
return cal.getEvents(start, new Date(start.getTime() + 60000)).some(e => {
if (e.getStartTime().getTime() !== start.getTime()) return false;
const t = norm_(e.getTitle());
return t === title || (group && titleHasGroup_(t, group));
});
}
function titleHasGroup_(title, group) {
const re = new RegExp('(^|[^a-z0-9])' + group.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '($|[^a-z0-9])');
return re.test(title);
}
// Registration emails wrap the hub link in an email-tracking link. Try to unwrap it.
function cleanLink_(url) {
if (!url || /hub\.provisors\.com/i.test(url)) return url;
try {
const h = UrlFetchApp.fetch(url, { followRedirects: false, muteHttpExceptions: true }).getHeaders();
const loc = h.Location || h.location;
if (loc && /provisors\.com/i.test(loc)) return loc;
} catch (e) { /* keep the original link */ }
return url;
}
// The Activity Log is the script's memory: any email listed there is already handled.
function handledEmailIds_(ss) {
const sh = getLogTab_(ss);
const last = sh.getLastRow();
const ids = {};
if (last > 1) sh.getRange(2, LOG_HEADERS.length, last - 1, 1).getValues().forEach(r => { if (r[0]) ids[r[0]] = true; });
return ids;
}
function getCheatSheet_() {
const props = PropertiesService.getUserProperties();
const id = props.getProperty('CHEAT_SHEET_ID');
if (id) {
try { return SpreadsheetApp.openById(id); } catch (e) { /* make a new one below */ }
}
const ss = SpreadsheetApp.create(SHEET_TITLE);
const groups = ss.getSheets()[0];
groups.setName('My Groups');
groups.getRange(1, 1, 1, GROUP_HEADERS.length).setValues([GROUP_HEADERS]).setFontWeight('bold');
groups.setFrozenRows(1);
groups.setColumnWidths(1, GROUP_HEADERS.length, 210);
groups.getRange('B2:B500').setDataValidation(
SpreadsheetApp.newDataValidation().requireValueInList(Object.keys(ZONES), true).build());
getLogTab_(ss);
props.setProperty('CHEAT_SHEET_ID', ss.getId());
return ss;
}
function getLogTab_(ss) {
let sh = ss.getSheetByName('Activity Log');
if (!sh) {
sh = ss.insertSheet('Activity Log');
sh.getRange(1, 1, 1, LOG_HEADERS.length).setValues([LOG_HEADERS]).setFontWeight('bold');
sh.setFrozenRows(1);
sh.setColumnWidths(1, LOG_HEADERS.length, 200);
}
return sh;
}
function addGroups_(ss, names) {
const sh = ss.getSheetByName('My Groups');
const myZone = CalendarApp.getDefaultCalendar().getTimeZone();
const have = {};
const last = sh.getLastRow();
if (last > 1) sh.getRange(2, 1, last - 1, 1).getValues().forEach(r => { have[norm_(r[0])] = true; });
const rows = [];
names.forEach(n => {
const k = norm_(n.group);
if (n.group && !have[k]) { have[k] = true; rows.push([n.group, guessZone(n.title, myZone), '', '', '', '']); }
});
rows.sort((a, b) => a[0].localeCompare(b[0]));
if (rows.length) sh.getRange(sh.getLastRow() + 1, 1, rows.length, GROUP_HEADERS.length).setValues(rows);
return rows.length;
}
// Look at the last year of your calendar for each group and copy the Zoom link
// or address you've used before. Only fills blank cells; never overwrites.
function fillFromCalendar_(ss) {
const sh = ss.getSheetByName('My Groups');
const last = sh.getLastRow();
if (last < 2) return 0;
const cal = CalendarApp.getDefaultCalendar();
const from = new Date(Date.now() - 365 * 86400000), to = new Date(Date.now() + 180 * 86400000);
const range = sh.getRange(2, 1, last - 1, GROUP_HEADERS.length);
const rows = range.getValues();
let filled = 0;
rows.forEach(r => {
if (!r[0] || (r[2] && r[3])) return;
const group = norm_(r[0]);
const zooms = {}, addresses = {};
cal.getEvents(from, to, { search: String(r[0]) }).forEach(e => {
if (!titleHasGroup_(norm_(e.getTitle()), group)) return;
const text = (e.getLocation() || '') + ' ' + (e.getDescription() || '');
const z = text.match(/https:\/\/[\w.-]*zoom\.us\/j\/[^\s"<>]+/i);
if (z) { const k = z[0].replace(/&/g, '&'); zooms[k] = (zooms[k] || 0) + 1; }
const loc = (e.getLocation() || '').trim();
if (loc && !/^https?:|zoom/i.test(loc)) addresses[loc] = (addresses[loc] || 0) + 1;
});
const top = o => Object.keys(o).sort((a, b) => o[b] - o[a])[0] || '';
if (!r[2] && top(addresses)) { r[2] = top(addresses); filled++; }
if (!r[3] && top(zooms)) { r[3] = top(zooms); filled++; }
});
range.setValues(rows);
return filled;
}
function readGroups_(ss) {
const out = {};
const sh = ss.getSheetByName('My Groups');
const last = sh.getLastRow();
if (last < 2) return out;
sh.getRange(2, 1, last - 1, GROUP_HEADERS.length).getValues().forEach(r => {
if (r[0]) out[norm_(r[0])] = { zone: String(r[1]).trim(), address: r[2], zoom: r[3], notes: r[4], length: r[5] };
});
return out;
}
function norm_(s) {
return decode_(String(s || '')).toLowerCase().replace(/\s+/g, ' ').trim();
}
function decode_(s) {
return String(s).replace(/'/g, "'").replace(/"/g, '"').replace(/&/g, '&');
}
function pad_(n) {
return ('0' + n).slice(-2);
}
If you are already using a different tool to add your events to your calendar, make sure you turn that off so you don't have duplicates.
ProVisors' automated emails usually don't include the address or Zoom link. To find them, you'd have to log in to the hub. Your Cheat Sheet is the shortcut, because these rarely change.
If you have a repeating ProVisors event (like your home group, or the Austin Women's Affinity Group with the same Zoom link each month), setup fills it in from your calendar. You or your assistant can edit it any time.
| Group | Time zone | Address (in-person) | Zoom link (virtual) | Notes | Length (minutes) |
|---|---|---|---|---|---|
| Austin 3 | Central | 100 Congress Ave, Suite 200 | — | Garage on 2nd St validates. | 120 |
| Austin Women's Affinity Group | Central | — | https://provisors.zoom.us/j/… | — | 90 |
| Women's Affinity Group | Pacific | — | https://provisors.zoom.us/j/… | — | — |
One limitation of ProVisors emails: they don't show the event's time zone. The Cheat Sheet fills it in for you. A city in the group name uses that city's time, and national meetings use Pacific, ProVisors' home office time. If a time looks off, fix that event on your calendar and change the Time zone here so future events come in right.
Every event it adds, skips or deletes is listed here, with the date and a link to the email. It's also how the script remembers which emails it already handled, so nothing gets added to your Gmail.
Example rows. Blank cells are fine: the event still gets the name, time and hub link, and runs 2 hours. New groups show up on their own.
It runs inside your own Google account. It reads emails from provisors@provisors.com and events@provisors.com, adds or deletes those events on your main Google Calendar, and keeps your Cheat Sheet up to date. Nothing is sent anywhere else.
Open the project at script.google.com, pick turnOff in the dropdown and click Run.
It checks the Activity Log in Your ProVisors Groups Cheat Sheet. If an email is listed there, it's done.
No. It adds new events and deletes the ones ProVisors cancels. It doesn't edit anything that's already there.
Unfortunately since ProVisors does not include time zones in their confirmation emails, events are normally added in your local time zone. If an event is outside your time zone, you will need to manually adjust it to a different time zone and then find the group in your cheat sheet and update its time zone for future events to come at the right time.
An Outlook version is on the way. This one is for Google.
Come to the next Unlocking AI for Business workshop with Lauren.
Or join the next cohort of The Residency: Freedom and Scaling Accelerator.
Over 90 days we build out your AI ecosystem, your Business Brain, and AI agents, so we can delegate more of your work to AI (and your team), shrink your to-do list, remove some of the hats you've been wearing, and trap more of your RATs so you have more time to work on the things you want to be working on.