Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { microsoftGraphRequest } from "@sync/providers/microsoft/microsoft-graph
import {
MICROSOFT_EVENT_SELECT,
MICROSOFT_GRAPH_BASE_URL,
MICROSOFT_GRAPH_OUTLOOK_TIMEZONE,
MICROSOFT_GRAPH_PREFER_EVENT_PAGE,
} from "@sync/providers/microsoft/microsoft-http.constants";
import { ProviderEventError } from "@sync/providers/provider-event.port";
Expand Down Expand Up @@ -100,17 +99,12 @@ export class MicrosoftEventReaderAdapter implements ProviderEventReader {
const seenProviderIds = new Set<string>();
let skipped = 0;
const colorLabels = input.colorLabels ?? new Map();
const normalizeOptions = {
graphPreferTimeZone: MICROSOFT_GRAPH_OUTLOOK_TIMEZONE,
};
const seriesMasterIds = new Set<string>();

const pushItem = (item: GraphEventDeltaItem) => {
if (!item.id || seenProviderIds.has(item.id)) return;
try {
events.push(
normalizeMicrosoftEvent(item, colorLabels, normalizeOptions),
);
events.push(normalizeMicrosoftEvent(item, colorLabels));
seenProviderIds.add(item.id);
} catch (error) {
if (error instanceof ProviderEventError) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { microsoftGraphRequest } from "@sync/providers/microsoft/microsoft-graph
import {
MICROSOFT_EVENT_SELECT,
MICROSOFT_GRAPH_BASE_URL,
MICROSOFT_GRAPH_OUTLOOK_TIMEZONE,
MICROSOFT_GRAPH_PREFER_OUTLOOK_TIMEZONE,
} from "@sync/providers/microsoft/microsoft-http.constants";
import {
Expand Down Expand Up @@ -204,9 +203,7 @@ export class MicrosoftEventWriter implements ProviderEventWriter {
const api = this.#makeApi(input.accessToken);
try {
const event = await api.get({ eventId: input.providerEventId });
return normalizeMicrosoftEvent(event, undefined, {
graphPreferTimeZone: MICROSOFT_GRAPH_OUTLOOK_TIMEZONE,
});
return normalizeMicrosoftEvent(event);
} catch (error) {
if (isNotFound(error)) return null;
throw classifyWriteError(error);
Expand All @@ -230,7 +227,6 @@ export class MicrosoftEventWriter implements ProviderEventWriter {
if (!match) return null;
return normalizeMicrosoftEvent(match, undefined, {
allowOccurrence: true,
graphPreferTimeZone: MICROSOFT_GRAPH_OUTLOOK_TIMEZONE,
});
} catch (error) {
if (isNotFound(error)) return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,7 @@ const toMasterRecord = (

describe("normalizeMicrosoftEvent Microsoft recurring Unwind (UTC daily)", () => {
it("keeps UTC when Graph declares UTC on the series and maps OID cancelledOccurrences to EXDATE", () => {
const read = normalizeMicrosoftEvent(unwindUtcDailyMaster(), undefined, {
graphPreferTimeZone: "UTC",
});
const read = normalizeMicrosoftEvent(unwindUtcDailyMaster());
const master = asSeriesMaster(read);

expect(read.schedule.kind).toBe("timed");
Expand All @@ -120,9 +118,7 @@ describe("normalizeMicrosoftEvent Microsoft recurring Unwind (UTC daily)", () =>
});

it("does not project occurrences on cancelled UTC dates", () => {
const read = normalizeMicrosoftEvent(unwindUtcDailyMaster(), undefined, {
graphPreferTimeZone: "UTC",
});
const read = normalizeMicrosoftEvent(unwindUtcDailyMaster());
const master = toMasterRecord(read);
const horizon = {
start: new Date("2025-10-19T00:00:00.000Z"),
Expand All @@ -139,20 +135,8 @@ describe("normalizeMicrosoftEvent Microsoft recurring Unwind (UTC daily)", () =>
});

it("renders edited Oct 17 occurrence at 8:15 PM Denver without duplicating the series slot", () => {
const masterRead = normalizeMicrosoftEvent(
unwindUtcDailyMaster(),
undefined,
{
graphPreferTimeZone: "UTC",
},
);
const exceptionRead = normalizeMicrosoftEvent(
oct17DenverException(),
undefined,
{
graphPreferTimeZone: "UTC",
},
);
const masterRead = normalizeMicrosoftEvent(unwindUtcDailyMaster());
const exceptionRead = normalizeMicrosoftEvent(oct17DenverException());
expect(exceptionRead.kind).toBe("event");
if (exceptionRead.kind !== "event") return;
expect(exceptionRead.schedule.kind).toBe("timed");
Expand Down Expand Up @@ -213,17 +197,13 @@ describe("normalizeMicrosoftEvent Microsoft recurring Unwind (UTC daily)", () =>
id: "series-a",
cancelledOccurrences: [OID("2025-10-26")],
}),
undefined,
{ graphPreferTimeZone: "UTC" },
);
const second = normalizeMicrosoftEvent(
unwindUtcDailyMaster({
id: "series-b",
iCalUId: "other-ical-uid",
cancelledOccurrences: [OID("2025-10-25")],
}),
undefined,
{ graphPreferTimeZone: "UTC" },
);
const eventA = toMasterRecord(first);
const eventB = toMasterRecord(second);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,7 @@ describe("normalizeMicrosoftEvent Microsoft time zones", () => {
it.each(zoneFixtures)(
"maps Unwind ($label) to Denver wall time and weekly BYDAY",
(fixture) => {
const read = normalizeMicrosoftEvent(
unwindGraphEvent(fixture),
undefined,
{
graphPreferTimeZone: "UTC",
},
);
const read = normalizeMicrosoftEvent(unwindGraphEvent(fixture));
const schedule = asTimedSchedule(read);
const recurrence = asSeriesMaster(read);

Expand All @@ -157,8 +151,6 @@ describe("normalizeMicrosoftEvent Microsoft time zones", () => {
startEndTimeZone: "UTC",
recurrenceTimeZone: "Mountain Standard Time",
}),
undefined,
{ graphPreferTimeZone: "UTC" },
);
const event = toEventRecord(read.schedule, asSeriesMaster(read).rules);
const slots = projectedLocalSlots(
Expand All @@ -181,8 +173,6 @@ describe("normalizeMicrosoftEvent Microsoft time zones", () => {
startEndTimeZone: "UTC",
originalStartTimeZone: "Mountain Standard Time",
}),
undefined,
{ graphPreferTimeZone: "UTC" },
);
const event = toEventRecord(read.schedule, asSeriesMaster(read).rules);
const slots = projectedLocalSlots(
Expand All @@ -207,8 +197,6 @@ describe("normalizeMicrosoftEvent Microsoft time zones", () => {
try {
const read = normalizeMicrosoftEvent(
unwindGraphEvent({ startEndTimeZone: "Mountain Standard Time" }),
undefined,
{ graphPreferTimeZone: "UTC" },
);
const event = toEventRecord(read.schedule, asSeriesMaster(read).rules);
const slots = projectedLocalSlots(
Expand Down
56 changes: 18 additions & 38 deletions packages/sync/src/providers/microsoft/microsoft-event.normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { SyncEventContentSchema } from "@core/types/sync/event.contracts";
import dayjs from "@core/util/date/dayjs";
import { cancelledOccurrencesToExdateRules } from "@sync/providers/microsoft/microsoft-cancelled-occurrences";
import { outlookCategoryNameToSlot } from "@sync/providers/microsoft/microsoft-event-category.map";
import { MICROSOFT_GRAPH_OUTLOOK_TIMEZONE } from "@sync/providers/microsoft/microsoft-http.constants";
import { toRRule } from "@sync/providers/microsoft/microsoft-recurrence";
import { UnsupportedRecurrenceError } from "@sync/providers/microsoft/microsoft-recurrence.error";
import { type GraphPatternedRecurrence } from "@sync/providers/microsoft/microsoft-recurrence.types";
Expand All @@ -35,8 +34,6 @@ const MEETING_PROVIDER_LABELS: Readonly<Record<string, string>> = {

export interface NormalizeMicrosoftEventOptions {
readonly allowOccurrence?: boolean;
/** Matches Prefer outlook.timezone on Graph reads (see event reader adapter). */
readonly graphPreferTimeZone?: string;
}

export interface GraphDateTimeTimeZone {
Expand Down Expand Up @@ -126,7 +123,7 @@ export function normalizeMicrosoftEvent(
}

const providerVersion = requireVersion(item);
const schedule = mapSchedule(item, options);
const schedule = mapSchedule(item);

return {
kind: "event",
Expand Down Expand Up @@ -270,10 +267,7 @@ export function mapConference(item: GraphEvent): Conference | null {
return parsed.success ? parsed.data : null;
}

function mapSchedule(
item: GraphEvent,
options: NormalizeMicrosoftEventOptions,
) {
function mapSchedule(item: GraphEvent) {
const { start, end } = item;
if (!start || !end) {
throw new ProviderEventError(
Expand All @@ -299,20 +293,14 @@ function mapSchedule(
}

if (start.dateTime && end.dateTime) {
const graphPreferTimeZone =
options.graphPreferTimeZone ?? MICROSOFT_GRAPH_OUTLOOK_TIMEZONE;
const startFieldZone = toIanaTimeZone(
start.timeZone ?? end.timeZone ?? "UTC",
);
const timeZone = resolveTimedEventTimeZone(
item,
startFieldZone,
graphPreferTimeZone,
);
const timeZone = resolveTimedEventTimeZone(item, startFieldZone);
return parseSchedule({
kind: "timed",
start: toOffsetIso(start, timeZone, graphPreferTimeZone),
end: toOffsetIso(end, timeZone, graphPreferTimeZone),
start: toOffsetIso(start, timeZone),
end: toOffsetIso(end, timeZone),
timeZone,
});
}
Expand All @@ -330,28 +318,27 @@ function extractDatePart(value: GraphDateTimeTimeZone): string | null {
return parsed.format(DATE_ONLY);
}

/**
* Reads come back under `Prefer: outlook.timezone="UTC"`, so a UTC start field
* carries no zone of its own: fall back to the series' recurrence zone, then to
* the original start zone. Any other start-field zone is the event's real one.
*/
function resolveTimedEventTimeZone(
item: GraphEvent,
startFieldZone: string,
graphPreferTimeZone: string,
): string {
if (startFieldZone !== "UTC") return startFieldZone;
const recurrenceZone = item.recurrence?.range?.recurrenceTimeZone
? toIanaTimeZone(item.recurrence.range.recurrenceTimeZone)
: null;
if (recurrenceZone && recurrenceZone !== "UTC") return recurrenceZone;
const originalStartZone = item.originalStartTimeZone
? toIanaTimeZone(item.originalStartTimeZone)
: null;
if (
graphPreferTimeZone === MICROSOFT_GRAPH_OUTLOOK_TIMEZONE &&
startFieldZone === "UTC"
) {
if (recurrenceZone && recurrenceZone !== "UTC") return recurrenceZone;
if (originalStartZone && originalStartZone !== "UTC") {
return originalStartZone;
}
if (originalStartZone && originalStartZone !== "UTC") {
return originalStartZone;
}
if (startFieldZone !== "UTC") return startFieldZone;
return recurrenceZone ?? originalStartZone ?? startFieldZone;
return "UTC";
}

function parseSchedule(candidate: unknown) {
Expand Down Expand Up @@ -410,21 +397,14 @@ function stripGraphDateTimeFraction(dateTime: string): string {
return dateTime.replace(/\.\d+$/, "");
}

function toOffsetIso(
value: GraphDateTimeTimeZone,
timeZone: string,
graphPreferTimeZone: string,
): string {
/** Graph wall clocks arrive in UTC (see the Prefer header), then shift to `timeZone`. */
function toOffsetIso(value: GraphDateTimeTimeZone, timeZone: string): string {
if (!value.dateTime) {
throw new ProviderEventError(
"unmappableSchedule",
"Event date-time had no dateTime",
);
}
const raw = stripGraphDateTimeFraction(value.dateTime);
const anchored =
graphPreferTimeZone === MICROSOFT_GRAPH_OUTLOOK_TIMEZONE
? dayjs.utc(raw).tz(timeZone)
: dayjs.tz(raw, timeZone);
return anchored.format(RFC3339_OFFSET);
return dayjs.utc(raw).tz(timeZone).format(RFC3339_OFFSET);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ export const MICROSOFT_CALENDAR_LIST_SELECT =
"id,name,color,hexColor,canEdit,canShare,isDefaultCalendar,owner,isRemovable";
const MICROSOFT_EVENT_PAGE_SIZE = 200;

// Graph Prefer header: wall clocks in UTC so readers and writers share one
// zone contract with the normalizer's graphPreferTimeZone default.
// Graph Prefer header: wall clocks in UTC, which is the zone contract the
// normalizer assumes when it resolves a timed event's real time zone.
export const MICROSOFT_GRAPH_OUTLOOK_TIMEZONE = "UTC";
export const MICROSOFT_GRAPH_PREFER_OUTLOOK_TIMEZONE = `outlook.timezone="${MICROSOFT_GRAPH_OUTLOOK_TIMEZONE}"`;
export const MICROSOFT_GRAPH_PREFER_EVENT_PAGE = `odata.maxpagesize=${MICROSOFT_EVENT_PAGE_SIZE}, ${MICROSOFT_GRAPH_PREFER_OUTLOOK_TIMEZONE}`;
Expand Down
Loading
Loading