The Google Calendar Data Model, Explained Through the Bugs It Causes
- 1 Bug one: the weekly client call that appears only once
- 2 Bug two: one moved meeting, counted twice
- 3 Bug three: the company offsite that ends a day late
- 4 Bug four: a client lunch logged at the wrong hour
- 5 Bug five: a meeting with a customer who declined
- 6 Bug six: missing meetings and birthdays in the report
Calls to the Google Calendar API rarely fail. What fails is the interpretation of the data that comes back: a weekly meeting counted once instead of every week, a holiday that spills into the next day, a client lunch logged at the wrong hour, a meeting nobody actually attended. This article follows a fictional meeting-tracking feature through six bugs, and uses each one to explain a part of the Google Calendar data model that is easy to misread.
Imagine a small team adding a “meetings logged” panel to their CRM: connect a sales rep’s Google Calendar, pull events, and show which customer meetings happened this week. Every API call in the first prototype succeeds. Then the bug reports arrive, and almost none of them are about the API failing. They are about the shape of the data being misunderstood. If you need the setup side first (project, credentials, OAuth, basic reads), a complete reference is available at https://www.unipile.com/guide-to-google-calendar-api-integration/. Here we focus on what the returned objects actually mean.
Bug one: the weekly client call that appears only once
The prototype calls events.list and gets one object for a weekly call with a client. That is correct behavior: by default Google returns a recurring series as a single event carrying a recurrence array, typically an RRULE line such as a weekly rule on a given weekday, sometimes with EXDATE lines for skipped dates. The occurrences are not stored as separate rows.
There are two ways to get the individual meetings. Pass singleEvents=true to events.list and Google expands the series inside your time range, or call events.instances on the series id. For a “what happened this week” panel, expansion is almost always what you want. Parsing RRULE yourself is possible, but it is a deep specification, and reimplementing it is a reliable source of subtle errors.
Bug two: one moved meeting, counted twice
A rep pushes one occurrence of the weekly call from Tuesday to Wednesday. The panel now shows the meeting on both days. The cause: that moved occurrence has become an exception, an instance with its own id, a recurringEventId pointing to the series, and an originalStartTime recording the slot it used to occupy. If your code generates occurrences from the rule and also stores the exceptions, you count the meeting twice.
The fix is to pick one source of truth. Either rely entirely on expanded instances from the API, which already merge exceptions, or, if you expand locally, use originalStartTime to replace the generated occurrence. Cancelled single occurrences follow the same logic: they come back as instances with status “cancelled”, and they must remove the generated slot, not be ignored.
Bug three: the company offsite that ends a day late
All-day events do not use dateTime. Their start and end hold a plain date field instead, and the end date is exclusive. A one-day offsite on the 14th has start 14 and end 15; a three-day event from the 14th to the 16th has end 17. Teams that treat end as inclusive display every all-day event one day too long.
The second trap is converting these dates into timestamps. An all-day event has no time zone and no midnight; it is simply “that date” for whoever looks at it. Turning it into midnight UTC makes it drift onto the previous day for users west of Greenwich. Keep all-day events as a separate type in your model, with a date range rather than an instant range, and only map them onto hours when you render.
Bug four: a client lunch logged at the wrong hour
For timed events, start and end contain a dateTime with an offset and, often, a separate timeZone field naming an IANA zone such as America/New_York. That named zone matters more than the offset. A recurring 9:00 meeting anchored to New York stays at 9:00 local across daylight saving changes, while its UTC offset moves. Store only the offset and your recurring occurrences shift by an hour twice a year.
There are actually three zones in play: the event’s own timeZone, the calendar’s default zone (exposed on the calendar resource), and the zone of the person viewing your app. An event created from a phone while traveling may carry a different zone than the calendar default. The safe rule: persist the instant plus the event’s named zone, compute in UTC, render in the viewer’s zone, and never assume the calendar default applies to every event inside it.
Bug five: a meeting with a customer who declined
The panel counted every event with an external attendee as a customer meeting. But attendees is a list of people with a responseStatus each: needsAction, accepted, tentative, or declined. A meeting where the customer declined, or where the rep themselves declined, is not a meeting that happened.
A few fields help here. The attendee entry flagged with self tells you which one is the connected user, so you can read their own response. The organizer field tells you who owns the event, which also governs what your app is allowed to change. Meeting rooms often appear as attendees marked as resources, and they should not be counted as people. And the attendee list can be truncated or hidden on events where the user is only a guest and the organizer limited visibility, so design for incomplete data rather than treating the list as authoritative.
Bug six: missing meetings and birthdays in the report
The prototype read only the primary calendar. Many people keep meetings on secondary calendars: a shared team calendar, a calendar they own for a specific client, or one delegated by a manager. calendarList.list returns every calendar the user has in their list, each with an accessRole (owner, writer, reader, or freeBusyReader). A freeBusyReader calendar shows busy blocks without details, so your code must handle events with almost no fields.
The opposite problem shows up too. Subscribed calendars such as public holidays, and special event types like out of office, focus time, working location, or birthdays, appear in the data and inflate any meeting count. Filter on eventType and let users choose which calendars count, rather than guessing.
None of these six bugs is an API error, and none would appear in a test with one clean calendar. They surface with real users who travel, move meetings, decline invitations, and keep five calendars. Before shipping, build a fixture account that contains a recurring series with a moved and a cancelled occurrence, a multi-day all-day event, events in two time zones, a declined invitation, and a secondary calendar. If your feature reads that account correctly, it will read most real ones correctly too. And if the same feature must support Outlook, expect each of these concepts to exist there as well, under different names and structures, which is the main reason products that cover both providers often normalize the model once in a unified calendar layer such as Unipile.













