appscript.dev
Automation Advanced Calendar

Generate recurring events with custom exceptions

Handle complex recurrence rules in code — every Tuesday except UK bank holidays.

Published Oct 28, 2025

Google Calendar’s built-in recurrence covers “every Tuesday” comfortably, but gets awkward the moment Northwind wants “every Tuesday except UK bank holidays, and never the week of the summer shutdown”. The UI forces you to create the series, then delete the offending instances by hand — fiddly, easy to forget, and impossible to audit.

Generating the series in code flips the model. The script knows about the weekly cadence and a list of dates to skip, and creates one independent event per occurrence. There is no underlying recurrence rule to fight, and the list of holidays lives next to the code so anyone can read what is excluded and why.

What you’ll need

  • Edit access to the calendar the events should land on. The script uses your default calendar — swap to getCalendarById for a shared team calendar.
  • A small list of YYYY-MM-DD dates to skip. The example uses two UK bank holidays, but the list can be as long as you like.
  • Nothing else.

The script

// Default hour the recurring event starts on each occurrence (24h clock).
const EVENT_START_HOUR = 10;
// How long the event runs, in minutes.
const EVENT_DURATION_MINUTES = 60;
// The target day of the week — 0 = Sunday, 1 = Monday, ... 2 = Tuesday.
const TARGET_DAY_OF_WEEK = 2;

/**
 * Creates `weeks` occurrences of a recurring event on the target day
 * of the week, skipping any date that appears in `holidays`.
 *
 * @param {string} title Event title to use for every occurrence.
 * @param {number} weeks How many occurrences to attempt.
 * @param {string[]} [holidays] YYYY-MM-DD dates to skip.
 */
function createTuesdaySeries(title, weeks, holidays = []) {
  const cal = CalendarApp.getDefaultCalendar();
  const today = new Date();
  // Normalise to midnight so the day-of-week maths is not affected by
  // the trigger's time of day.
  today.setHours(0, 0, 0, 0);

  // Pre-compute how many days to step from today to land on the next
  // occurrence of the target day. The `% 7` keeps the value in [0, 6].
  const daysUntilFirst = (TARGET_DAY_OF_WEEK + 7 - today.getDay()) % 7;

  let created = 0;
  let skipped = 0;
  for (let i = 0; i < weeks; i++) {
    // 1. Walk forward in seven-day jumps from the first matching day.
    const d = new Date(today);
    d.setDate(today.getDate() + i * 7 + daysUntilFirst);

    // 2. Compare the date against the holiday list in ISO form. Slicing
    //    `toISOString()` works because we are using UTC-anchored midnights.
    const iso = Utilities.formatDate(d, 'GMT', 'yyyy-MM-dd');
    if (holidays.includes(iso)) {
      skipped++;
      continue;
    }

    // 3. Set the start and end times for the occurrence. Mutating
    //    derived `Date` objects keeps the original `d` untouched.
    const start = new Date(d);
    start.setHours(EVENT_START_HOUR, 0, 0, 0);
    const end = new Date(start.getTime() + EVENT_DURATION_MINUTES * 60_000);

    cal.createEvent(title, start, end);
    created++;
  }

  Logger.log('Created ' + created + ' events, skipped ' + skipped + ' holidays.');
}

/**
 * Convenience wrapper — generates 12 weeks of the Northwind weekly sync,
 * skipping the August and December bank holidays that fall on a Tuesday.
 */
function generateNorthwindWeekly() {
  createTuesdaySeries('Northwind weekly sync', 12, [
    '2026-08-25', // Summer bank holiday (UK)
    '2026-12-29', // Holiday week shutdown
  ]);
}

How it works

  1. createTuesdaySeries normalises today to midnight so the day-of-week arithmetic does not drift if the script runs in the afternoon.
  2. It computes daysUntilFirst — the offset from today to the first matching day of the week. Stepping i * 7 + daysUntilFirst days lands on each subsequent occurrence.
  3. For each candidate occurrence it formats the date as yyyy-MM-dd in UTC and compares against the holiday list. A match is skipped; a miss continues to event creation.
  4. The start time is derived by setting hours on a fresh copy of the date, and the end time by adding the configured duration in milliseconds.
  5. cal.createEvent creates an independent event per occurrence — no recurrence rule, so removing one instance never cascades into the rest of the series.
  6. generateNorthwindWeekly is the convenience wrapper that supplies the studio’s actual title, length, and exception list.

Example run

Run generateNorthwindWeekly on Monday 18 May 2026 and the calendar receives twelve Tuesday slots between 19 May and 4 August, except where the holiday list intervenes:

TuesdayOutcome
19 May 2026Event created, 10:00 - 11:00
26 May 2026Event created
2 Jun 2026Event created
25 Aug 2026Skipped (summer bank holiday)
29 Dec 2026Skipped (shutdown)

The log line reads Created 10 events, skipped 2 holidays. — exactly what you’d expect for a 12-week run with two excluded dates.

Run it

This is a generate-once kind of script — you do not want it triggered weekly or it will pile up duplicate events. Run it by hand at the start of each quarter:

  1. In the Apps Script editor, select generateNorthwindWeekly and click Run.
  2. Approve the calendar authorisation prompt the first time.
  3. Open Google Calendar to confirm the new events.

If you do want a schedule, set a trigger that fires on the first of each quarter and bumps the start date forward by twelve weeks each time. Just keep in mind that the script as written does not check for existing events, so re-runs will create duplicates.

Watch out for

  • The script creates one independent event per occurrence rather than a true recurring series. That is the point — exceptions are easy — but it also means moving the time later requires editing each instance. If the time rarely changes, this trade is worth it.
  • The holiday list is plain strings in script. If your team needs richer rules (“the Tuesday after every UK bank Monday”), feed the list from a Sheet or a public iCal feed and parse it once at the top of the function.
  • Utilities.formatDate(d, 'GMT', ...) is reliable here because every event starts at midnight in the script’s perspective. If you change the script to use local times, format with the script time zone instead, or the comparison can slip by a day around midnight.
  • Re-running the script duplicates every event. Add a getEvents check on the start date if you want it idempotent, or delete the old series in the UI before running again.

Related