US clocks went back on Sunday, November 1. Germany’s went back a week earlier, on October 25. For one week, a 9:00 meeting in New York was 14:00 in Berlin instead of 15:00. An app that stored the wrong thing, or read the right thing in the wrong zone, showed some users the wrong hour.
Google Calendar, Microsoft Graph and CalDAV describe an event’s time differently. This post covers what each sends, where all-day and recurring events stop being plain instants, what to do with times that do not exist or happen twice, and how Workwae normalizes them.
Store the instant and the event’s zone.
A UTC instant answers “when does this happen?” It does not answer “what
does the organizer expect next week?” A weekly 9:00 meeting in New York is 13:00 UTC
before November 1 and 14:00 UTC after, so an app that kept only the first occurrence’s
instant would be an hour off every Monday after the change. Keep both: the instant, for
sorting, overlap checks and free/busy, and the IANA zone the event was scheduled in
(IANA Time Zone Database, e.g.
America/New_York). A UTC offset is no stand-in for a zone. It is one fact about
one day.
Graph speaks Windows zone names.
Microsoft Graph represents a time as a dateTimeTimeZone: a local
dateTime with no offset, and a timeZone such as “Pacific
Standard Time”. It can be any
time zone Windows supports
among others, and Microsoft notes that create and update might not accept every one
(dateTimeTimeZone).
On reads, start and end come back in UTC unless you send
Prefer: outlook.timezone with a zone name. The zone an event was created in is
in originalStartTimeZone and originalEndTimeZone
(Get event,
event resource). A
legacy custom Outlook zone shows up as tzone://Microsoft/Custom.
GET https://graph.microsoft.com/v1.0/me/events/{id}?$select=start,end,originalStartTimeZone,isAllDay
Prefer: outlook.timezone="Eastern Standard Time"
# start: { "dateTime": "2026-11-02T09:00:00.0000000", "timeZone": "Eastern Standard Time" }
# without the header: { "dateTime": "2026-11-02T14:00:00.0000000", "timeZone": "UTC" }
To map a Windows name to an IANA one, use the Unicode CLDR
windowsZones.xml.
One Windows name covers several IANA zones, and the entry for the world (territory
001) picks one.
Google sends an offset and a zone.
Google Calendar’s start and end hold either a
dateTime or a date. A dateTime is RFC 3339 and needs an
offset unless timeZone is given. The timeZone is an IANA name. For a
single event it is optional and marks a custom zone. For a recurring event it is required, and
it is the zone in which the recurrence is expanded
(Events resource).
By default, events.list returns a recurring series once, as its parent event;
singleEvents=true expands it into instances. Each instance carries
originalStartTime, the slot it has in the series even after it is moved
(Recurring events).
All-day events are dates, not instants.
Google marks an all-day event with start.date and end.date, in the
form yyyy-mm-dd, and the end is exclusive
(Events resource).
Graph uses isAllDay, and then start and end must be midnight in the same time
zone
(event resource).
A birthday on March 8 is March 8 for everyone. Converted to an instant in your server’s zone and back in the viewer’s, it can land on March 7 or 9. Keep the date as a date, and where you need an instant, use midnight in the calendar’s zone.
Floating time is the third kind. In iCalendar, a date-time with no
Z and no TZID is floating: “the same hour, minute, and second
value regardless of which time zone is currently being observed”
(RFC 5545 section 3.3.5).
For time-range queries, a CalDAV server reads floating times and dates in the
collection’s calendar-timezone
(RFC 4791 section 7.3).
A weekly 9:00 keeps its wall time, not its UTC time.
RFC 5545 says to give a recurring DTSTART a time zone, so that “all the
recurrence instances start at the same local time regardless of time zone changes”
(section 3.8.5.3). The
rule runs on wall-clock time in its zone. Each occurrence is then converted to an instant
with the offset in force that day.
Monday local (America/New_York) UTC Berlin
Oct 19 09:00 EDT (-04:00) 13:00Z 15:00 CEST
Oct 26 09:00 EDT (-04:00) 13:00Z 14:00 CET
Nov 02 09:00 EST (-05:00) 14:00Z 15:00 CET
The UTC column moves and the local column does not, which is the point of the rule’s
zone. The Berlin column is what a colleague in another zone sees: in the week between the two
changes, the meeting is an hour earlier for them. That is correct, not a bug. With
DTEND, every occurrence gets the same exact duration, so an event that spans a
clock change keeps its elapsed length.
Graph’s recurrence.range.recurrenceTimeZone is the zone of the rule’s
start and end dates, and defaults to the event’s own zone
(recurrenceRange).
Expand a series in that zone, not in UTC.
Times that do not exist, and times that happen twice.
Two wall times need a rule. When clocks go forward, a span of local time never happens: 2:00 to 3:00 in New York on March 8, 2026. When they go back, a span happens twice: 1:00 to 2:00 on November 1. RFC 5545 gives both answers (section 3.3.5):
- Repeated time: the value refers to the first occurrence.
TZID=America/New_York:20071104T013000is 1:30 EDT, UTC-04:00. - Nonexistent time: the value is read with the UTC offset before the gap.
TZID=America/New_York:20070311T023000is 3:30 EDT, one hour after 1:30 EST.
That is a rule about iCalendar, not a promise from the Google or Graph APIs. If your product lets people type a time, check it against the zone before you send it, and decide yourself what 02:30 on the spring-forward day means.
How Workwae handles it.
Workwae keeps one shape for events from every provider. Each end has an instant
(dateTime), the IANA zone the event was scheduled in (timeZone),
and, for all-day events, the date (an end date is exclusive). All-day instants
are midnight of that date in the calendar’s zone, and an all-day end has no zone.
- Microsoft 365. The sync reads events without
Prefer: outlook.timezone, so start and end arrive in UTC, and takes the event’s own zone fromoriginalStartTimeZoneandoriginalEndTimeZone. Windows names become IANA names through a table generated from the CLDR file above; writes send the Windows name, and pass an IANA name through when the table has none. The calendar’s zone is the mailbox zone frommailboxSettings. - Google. A
dateTimewith an offset is used as is; one without is read in the zone it names, else the calendar’s. A series’ zone is itsstart.timeZone, else the calendar’s. - CalDAV. A
TZIDnaming an IANA or Windows zone, or an IANA name with a vendor prefix, is read like the other providers’ zones. A customVTIMEZONEis converted with its own rules. Floating times and dates are read in the calendar’s zone: itscalendar-timezone, or UTC when the server names none. Writes carry aTZIDand a generatedVTIMEZONE. - Series. Workwae stores the rule and expands occurrences itself, in the series’ zone (the calendar’s zone for all-day). Each keeps the series’ wall time, so the 9:00 above is 13:00Z, then 14:00Z, and lasts as long as the first event.
- Gaps and repeats. One function turns a wall time into an instant. The first of two matches wins, and a wall time in the gap uses the offset before it, so 02:30 becomes 03:30, as RFC 5545 specifies.
A weekly series in New York, read back across the change, with the SDKs:
import { createClient, paginate } from "@workwae/sdk";
const workwae = createClient({ apiKey: process.env.WORKWAE_API_KEY });
await workwae.calendarEvents.create({
accountId,
calendarId,
subject: "Weekly sync",
// Wall time in a zone, no offset. The rule is evaluated in this zone.
start: { dateTime: "2026-10-19T09:00:00", timeZone: "America/New_York" },
end: { dateTime: "2026-10-19T09:30:00", timeZone: "America/New_York" },
recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO"],
});
for await (const event of paginate(workwae.calendarEvents.list, {
accountId,
calendarId,
start: new Date("2026-10-19T00:00:00Z"),
end: new Date("2026-11-10T00:00:00Z"),
})) {
// event.start.dateTime is the instant: 13:00Z on Oct 19 and Oct 26, 14:00Z on Nov 2 and Nov 9.
// event.start.timeZone stays "America/New_York".
console.log(event.start.dateTime, event.start.timeZone, event.kind);
}import os
from datetime import datetime, timezone
from workwae import Workwae, paginate
workwae = Workwae(api_key=os.environ["WORKWAE_API_KEY"])
workwae.calendar_events.create(
account_id=account_id,
calendar_id=calendar_id,
subject="Weekly sync",
# Wall time in a zone, no offset. The rule is evaluated in this zone.
start={"date_time": datetime(2026, 10, 19, 9, 0), "time_zone": "America/New_York"},
end={"date_time": datetime(2026, 10, 19, 9, 30), "time_zone": "America/New_York"},
recurrence=["RRULE:FREQ=WEEKLY;BYDAY=MO"],
)
for event in paginate(
workwae.calendar_events.list,
account_id=account_id,
calendar_id=calendar_id,
start=datetime(2026, 10, 19, tzinfo=timezone.utc),
end=datetime(2026, 11, 10, tzinfo=timezone.utc),
):
# event.start.date_time is the instant: 13:00Z on Oct 19 and Oct 26, 14:00Z on Nov 2 and Nov 9.
# event.start.time_zone stays "America/New_York".
print(event.start.date_time, event.start.time_zone, event.kind)Limits.
- A time in the spring-forward gap is not rejected. Workwae passes the wall time and zone to the provider as given. Its own checks, such as end after start, read it with the offset before the gap.
-
On Microsoft 365 the zone you read back can differ from the one you wrote. Graph reports a
Windows name, and Workwae maps it to the one IANA zone CLDR picks for the world, so an event
written as
America/Detroitreads back asAmerica/New_York. The rules are the same today. The name is not preserved. -
A legacy custom Outlook zone has no IANA name. The instants stay correct, since the sync
reads them in UTC, but the event’s
timeZonetime_zoneis null, and a series with such a zone is expanded in the calendar’s zone. -
Without
MailboxSettings.Readthe mailbox zone is unknown, so all-day events are anchored in the zone each was created in, and in UTC when that is unknown too. -
A floating CalDAV event becomes an event in the calendar’s zone. A custom
VTIMEZONEgives the right instant, but the recorded zone isUTC. -
A calendar’s time zone can be set on Google only. Microsoft 365 uses the mailbox zone
for every calendar, and CalDAV keeps it in a
VTIMEZONEWorkwae does not write; both answerprovider_unsupported. - The SDKs are alpha:
@workwae/sdkis 0.1.0-alpha andworkwaeis 0.1.0a2, so their surface can still change.
The calendar guide covers
creating events with a zone and a recurrence, and its
listing section explains
start and end. Calendar covers sync.