When you work across multiple organizations, your calendars get fragmented. One organization uses Office 365, another uses Google Workspace. Appointments end up in both calendars, and every time you schedule a meeting, you have to check both to find available times.

It was tedious.

At first, I thought "well, I'll just check both." But then I double-booked twice without noticing, and I realized this wasn't sustainable.

Researching Existing Tools

First, I googled. "Google Calendar Outlook sync" and similar queries.

What came up were international SaaS products: OneCal, CalendarBridge, SyncGene, and others. They cost around $5-10/month and claim to offer "Google ⇄ Office 365 bidirectional sync." Reviews were decent.

I tried to use OneCal.

But I couldn't. The Office 365 OAuth screen appeared with "Admin approval required" and stopped there.

Ah, I see. The organization restricts external app integrations. Should I submit a request to the admin saying "Please approve this calendar sync tool"? No, that's not realistic. The approval process costs more than the problem.

CalendarBridge was the same. SyncGene was the same.

With OAuth restrictions as a barrier, external tools weren't an option.

Build It Myself? → Put on Hold

Next, I considered "should I build it myself?" Maybe with Lovable I could make something in a month or so.

But when I asked ChatGPT, it said "bidirectional sync is a minefield."

Yeah, fair point. If I tried to do this properly, I'd basically be recreating OneCal.

I gave up on building it myself.

I reorganized what I actually wanted to achieve.

"I want to schedule appointments while viewing both calendars without double-booking."

I don't need perfect bidirectional sync. I just need to prevent booking conflicts.

Google → Outlook (This Was Easy)

I decided to try one direction first. Make Google events visible in Outlook.

Outlook's web app has an "Add calendar" → "From Internet" feature. You get the ICS URL (subscription URL) from Google Calendar and paste it in.

Google Calendar's ICS URL can be found in Calendar settings → "Integrate calendar" → "Secret address in iCal format."

I added this to Outlook, and it just appeared.

Done in 5 minutes.

On the Outlook side, free/busy calculations include Google-originated events. This means when you try to add a new appointment in Outlook, the "this time is busy" indicator works.

One direction solved.

Outlook → Google (This Is Where It Got Messy)

Let's do the reverse. View Outlook events in Google.

Should be the same approach. "Publish" the Outlook calendar, get the ICS URL, add it to Google Calendar via URL.

Done. I can see Outlook events in Google.

But this doesn't prevent double-booking.

When I tried to add a new event in Google, time slots with Outlook-originated events were shown as "available." Google's event creation UI doesn't tell you "this time is busy."

I looked into it, and apparently Google Calendar's URL subscription (ICS display) is "view-only" and isn't used for free/busy calculations. You can see it, but you can't control it.

Problem.

The "Block Event" Approach with GAS

At this point, I thought "what if I just create actual events on the Google side?"

Read event information from Outlook's ICS and sync it to a dedicated Google calendar as "blocks." No need to sync titles or details. Just fill time slots as "Busy."

This would:

I could write this in Google Apps Script (GAS).

[📦 商品リンク: moshimo-book-PhRut]

First Implementation: Parsing ICS

ICS specification is RFC5545. Event information is between BEGIN:VEVENT and END:VEVENT. I only needed UID, DTSTART, DTEND.

Initially, I parsed with simple regex.

const m = val.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/);

Assuming YYYYMMDDTHHMMSS format. With seconds.

But when I looked at the actual ICS from Outlook, I found lines like this:

DTSTART;TZID=Tokyo Standard Time:20260109T143000

No seconds. HHMM.

Parse failed, and those events were skipped.

Fixed the regex. Made it handle both with and without seconds.

// YYYYMMDDTHHMM(Z)?
let m = val.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(Z)?$/);
if (m) { ... }
// YYYYMMDDTHHMMSS(Z)?
m = val.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/);
if (m) { ... }

This got it working.

But the next problem was waiting.

Recurring Events Disappearing

For recurring meetings like weekly standups, only "some" were being synced.

Logs showed created=27 or so, but the actual calendar only had about 10 items.

Why?

Investigation revealed that for recurring events, Outlook's ICS reuses the same UID across multiple events.

For example:

BEGIN:VEVENT
UID:040000008200E00074C5B7101A82E008...
DTSTART:20251223T130000
DTEND:20251223T140000
RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=TU
END:VEVENT

This is the master event. A rule saying "biweekly Tuesday 13:00-14:00."

And if a specific week has a time change, you get something like this:

BEGIN:VEVENT
UID:040000008200E00074C5B7101A82E008... (same UID)
RECURRENCE-ID;TZID=Tokyo Standard Time:20260106T130000
DTSTART;TZID=Tokyo Standard Time:20260109T143000
DTEND;TZID=Tokyo Standard Time:20260109T153000
END:VEVENT

It has RECURRENCE-ID. This means "the 1/6 occurrence is rescheduled to 1/9 at 14:30."

But the initial implementation used only UID as the key, so events with the same UID kept overwriting each other, leaving only the last one.

Redesigned the key. Combine UID + start time.

const instanceId = e.recurrenceIdRaw || formatIcsLocalKey_(start);
const key = `${e.uid}|${instanceId}`;

Now each occurrence is recognized as a separate event.

Duplicate Events Problem

Still, some events appeared "side by side" as duplicates.

Same time, same title "Block" appearing twice.

Found the cause: Outlook was outputting the same instance twice.

Both point to "1/23 13:00-14:00" but exist as separate VEVENT entries in the ICS.

Why is it designed this way?

Added normalization. When multiple events have the same start time, prioritize the one with RECURRENCE-ID (exception instance) and ignore the master.

function normalizeEvents_(events) {
  const map = new Map();
  for (const e of events) {
    const k = `${e.uid}|${formatIcsLocalKey_(e.dtstart)}`;
    if (!map.has(k)) {
      map.set(k, e);
      continue;
    }
    const existing = map.get(k);
    // Prioritize the one with RECURRENCE-ID
    if (!existing.recurrenceIdRaw && e.recurrenceIdRaw) {
      map.set(k, e);
    }
  }
  return Array.from(map.values());
}

Finally, duplicates were eliminated.

But by this point, I was getting tired.

Accepting Trade-offs

I stopped to reconsider.

What did I actually want? "To prevent double-booking."

I don't need to perfectly replicate Outlook events in Google. No titles, no details, no attendees needed. I just need to know time slots are "occupied."

Then even if there are multiple events in the same time slot, one block is enough.

I accepted the trade-offs.

This simplified the implementation.

Keys can be just start time + end time.

const eventKey = `${startTime.getTime()}_${endTime.getTime()}`;

Events at the same time get the same key. They get overwritten. But that's fine.

Final Architecture

The final setup:

Source: Office 365 calendar imported via URL subscription to Google Calendar
Target: Dedicated "Block" Google Calendar
Sync frequency: Every 4 hours (calendars don't update that frequently)
Sync method: Differential sync

I chose differential sync for efficiency. Deleting everything and recreating would balloon API calls as event counts grow. Currently around 100 events, but thinking ahead, differential made sense.

Differential sync requires maintaining a mapping (source event key → target event ID). Stored as JSON in Script Properties.

const map = JSON.parse(props.getProperty(PROP_MAP) || '{}');

Sync logic:

  1. Get events from source calendar
  2. Calculate each event's key (start+end)
  3. If in map, update; otherwise, create new
  4. Keys not found this time get deleted

About 200 lines of code.

Using It in Practice

Started using it today.

Only been a few hours, but no problems so far. When I try to add an event in Google, time slots with Outlook events show as "busy."

Syncs within 4 hours. Not real-time, but acceptable.

The block calendar has a dark gray color in the left calendar list. It's immediately clear "don't touch this one."

Trigger frequency of 4 hours feels right for now. Might change later, but this works for now.

Reflection: Asymmetry and Trade-offs

Google → Outlook took 5 minutes. Just add an ICS URL.

Outlook → Google took days. ICS parsing, recurring events, deduplication, key design, differential sync.

Reasons for the technical asymmetry:

Initially, I wanted "perfect sync." But partway through, it became "I just need to know what's blocked."

There are trade-offs:

A perfect solution probably doesn't exist. Accept constraints, identify what's necessary and sufficient, and shape it into something that works in practice.

"I just need to know what's blocked" — that acceptance was the key decision.

Related Resources

[📦 商品リンク: moshimo-book-R44mY]

[📦 商品リンク: moshimo-book-tJG6Z]