Show team PTO on a shared calendar
Sync time-off from the PTO sheet to a shared Northwind calendar — visibility for planning.
Published Aug 5, 2025
At Northwind, time-off lives in a spreadsheet — that is where HR books it and where the team checks who is out. The problem is that nobody opens the sheet before scheduling a meeting, so half-attended workshops and missed deadlines keep happening. People need to see PTO where they already look: the calendar.
This script reads each row of the PTO sheet and adds an all-day event to a
shared Northwind Team PTO calendar that everyone subscribes to. It writes the
new event’s ID back to the row so the second run does not create duplicates —
the standard “ledger” pattern for sheet-driven sync.
What you’ll need
- A
PTOGoogle Sheet with the columnsname,startDate,endDate, andeventId. The first three are filled in by whoever books the time off;eventIdis left blank for the script to populate. - A shared
Northwind Team PTOcalendar that the team subscribes to. Grab its ID from Calendar settings and paste it intoPTO_CALbelow. - Nothing else — no API keys, no add-ons.
The script
// The shared calendar that holds team PTO.
const PTO_CAL = '1abcPtoCalId';
// The sheet HR uses to record time off.
const PTO_SHEET = '1abcPtoId';
/**
* Reads the PTO sheet, creates an all-day event for any row without an
* eventId, and writes the new event ID back so the next run skips it.
*/
function syncPto() {
const sheet = SpreadsheetApp.openById(PTO_SHEET).getSheets()[0];
const values = sheet.getDataRange().getValues();
const [h, ...rows] = values;
const col = Object.fromEntries(h.map((k, i) => [k, i]));
const cal = CalendarApp.getCalendarById(PTO_CAL);
if (!cal) {
Logger.log('Calendar not found — check PTO_CAL.');
return;
}
// 1. Walk every row. Skip rows that already carry an eventId.
rows.forEach((r, i) => {
if (r[col.eventId]) return;
if (!(r[col.startDate] instanceof Date) || !(r[col.endDate] instanceof Date)) {
return;
}
// 2. Calendar's all-day series treats the end date as exclusive,
// so add a day to include the last day of leave.
const endExclusive = new Date(r[col.endDate].getTime() + 86400000);
// 3. Create a daily recurrence that ends on the last day of leave.
// A series (rather than one long block) reads better in week view.
const event = cal.createAllDayEventSeries(
`${r[col.name]} — PTO`,
r[col.startDate],
endExclusive,
CalendarApp.newRecurrence().addDailyRule().until(r[col.endDate]));
// 4. Stamp the event ID back into the row so we never duplicate it.
values[i + 1][col.eventId] = event.getId();
});
// 5. One bulk write at the end — cheaper than setValue per row.
sheet.getDataRange().setValues(values);
}
How it works
syncPtoopens the PTO sheet and reads every row at once, then builds a smallcollookup so the rest of the code readsr[col.name]instead ofr[0]— easier to follow and order-independent.- It opens the shared calendar by ID and bails out with a log line if the ID is wrong, rather than failing later with a less obvious error.
- For each row, it skips anything that already has an
eventId— that is the row’s record of having been synced. - It also skips rows whose dates are not real dates, which catches rows that are still being filled in.
- It creates an all-day event series from
startDatetoendDate, adding a day to the end because Calendar treats the series end as exclusive. - It writes the new event’s ID back into the in-memory
valuesarray, and pushes the whole thing back to the sheet in a singlesetValuescall.
Example run
Say the PTO sheet looks like this before a run:
| name | startDate | endDate | eventId |
|---|---|---|---|
| Priya | 2025-08-11 | 2025-08-15 | |
| Sam | 2025-08-18 | 2025-08-18 | |
| Jordan | 2025-08-25 | 2025-08-29 | (already filled) |
After syncPto runs, the shared calendar shows “Priya — PTO” across that week
and “Sam — PTO” on the Monday. Jordan’s row is untouched because it already had
an event ID. The sheet now reads:
| name | startDate | endDate | eventId |
|---|---|---|---|
| Priya | 2025-08-11 | 2025-08-15 | [email protected] |
| Sam | 2025-08-18 | 2025-08-18 | [email protected] |
| Jordan | 2025-08-25 | 2025-08-29 | (already filled) |
Trigger it
Set a time-driven trigger so new PTO rows pick up overnight:
- In the Apps Script editor, open Triggers (clock icon).
- Add a trigger for
syncPto, event source Time-driven, type Day timer, time 2am to 3am. - Approve the authorisation prompt the first time.
A daily run is plenty — PTO is not booked minute-by-minute, and an overnight job keeps the calendar quiet during the day.
Watch out for
- The
eventIdcolumn is the ledger. If you clear it, the next run will create a second event for the same row. To re-sync a row cleanly, delete the event in Calendar first, then clear the cell. - Editing the PTO sheet does not edit the calendar. This script only creates;
it does not update or delete. If someone shortens their leave, delete the
event manually (or extend the script to call
event.deleteEventSeries()when the row changes). - Calendar’s “end” is exclusive. The
+ 86400000is not a bug — it makes the last day of leave appear on the calendar instead of finishing the night before. - All-day events show as “free” by default, which is what most teams want.
If you would rather show PTO as “busy”, set
event.setVisibility()andevent.setTransparency()after creation.
Related
Schedule personal habits and routines
Block recurring habits on Awadesh's calendar — gym, walks, deep-work mornings.
Updated Nov 5, 2025
Sync birthdays and anniversaries to Calendar
Populate recurring personal dates from a Sheet — the Northwind team rituals calendar.
Updated Nov 1, 2025
Generate recurring events with custom exceptions
Handle complex recurrence rules in code — every Tuesday except UK bank holidays.
Updated Oct 28, 2025
Auto-reschedule low-priority conflicts
Move flexible Northwind events around fixed ones — focus blocks bend, client calls don't.
Updated Oct 16, 2025
Build a contract-renewal calendar
Track Northwind's recurring-revenue renewal dates as calendar events for proactive sales.
Updated Oct 8, 2025