From a48244cadd22edb8cedfb50928e74c3e2b117af6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:07:33 +0000 Subject: [PATCH 1/2] simplify(sync): drop the always-utc graphPreferTimeZone normalizer option `NormalizeMicrosoftEventOptions.graphPreferTimeZone` was configurable in name only. Every production caller (reader delta pages, writer fetchEvent, writer fetchInstanceAt) passed `MICROSOFT_GRAPH_OUTLOOK_TIMEZONE`, every test passed the same `"UTC"`, and the option defaulted to that constant when omitted. Both guards that read it compared it against that very constant, so `graphPreferTimeZone === MICROSOFT_GRAPH_OUTLOOK_TIMEZONE` was a tautology and the non-UTC branch in `toOffsetIso` was unreachable. Remove the option and collapse the two branches it fed. `mapSchedule` no longer needs the options bag at all, and `resolveTimedEventTimeZone` now reads as the rule it always implemented: a non-UTC start field is the event's real zone, and a UTC one falls back to the recurrence zone and then the original start zone. Zone resolution and the Prefer header are unchanged, so there is no behavior change. VERDICT: PASS Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018gVNGW5WLSrqPjZaT1ReM4 --- .../microsoft-event-reader.adapter.ts | 8 +-- .../microsoft-event-writer.adapter.ts | 6 +- ...rosoft-event.normalizer.recurrence.test.ts | 28 ++-------- ...icrosoft-event.normalizer.timezone.test.ts | 14 +---- .../microsoft/microsoft-event.normalizer.ts | 56 ++++++------------- .../microsoft/microsoft-http.constants.ts | 4 +- 6 files changed, 27 insertions(+), 89 deletions(-) diff --git a/packages/sync/src/providers/microsoft/microsoft-event-reader.adapter.ts b/packages/sync/src/providers/microsoft/microsoft-event-reader.adapter.ts index de134c2411..9c5cbc40dc 100644 --- a/packages/sync/src/providers/microsoft/microsoft-event-reader.adapter.ts +++ b/packages/sync/src/providers/microsoft/microsoft-event-reader.adapter.ts @@ -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"; @@ -100,17 +99,12 @@ export class MicrosoftEventReaderAdapter implements ProviderEventReader { const seenProviderIds = new Set(); let skipped = 0; const colorLabels = input.colorLabels ?? new Map(); - const normalizeOptions = { - graphPreferTimeZone: MICROSOFT_GRAPH_OUTLOOK_TIMEZONE, - }; const seriesMasterIds = new Set(); 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) { diff --git a/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.ts b/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.ts index 819d74af00..79d4ae8551 100644 --- a/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.ts +++ b/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.ts @@ -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 { @@ -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); @@ -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; diff --git a/packages/sync/src/providers/microsoft/microsoft-event.normalizer.recurrence.test.ts b/packages/sync/src/providers/microsoft/microsoft-event.normalizer.recurrence.test.ts index 89ddabaab8..e84279d763 100644 --- a/packages/sync/src/providers/microsoft/microsoft-event.normalizer.recurrence.test.ts +++ b/packages/sync/src/providers/microsoft/microsoft-event.normalizer.recurrence.test.ts @@ -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"); @@ -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"), @@ -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"); @@ -213,8 +197,6 @@ describe("normalizeMicrosoftEvent Microsoft recurring Unwind (UTC daily)", () => id: "series-a", cancelledOccurrences: [OID("2025-10-26")], }), - undefined, - { graphPreferTimeZone: "UTC" }, ); const second = normalizeMicrosoftEvent( unwindUtcDailyMaster({ @@ -222,8 +204,6 @@ describe("normalizeMicrosoftEvent Microsoft recurring Unwind (UTC daily)", () => iCalUId: "other-ical-uid", cancelledOccurrences: [OID("2025-10-25")], }), - undefined, - { graphPreferTimeZone: "UTC" }, ); const eventA = toMasterRecord(first); const eventB = toMasterRecord(second); diff --git a/packages/sync/src/providers/microsoft/microsoft-event.normalizer.timezone.test.ts b/packages/sync/src/providers/microsoft/microsoft-event.normalizer.timezone.test.ts index ecda536c62..8c26375490 100644 --- a/packages/sync/src/providers/microsoft/microsoft-event.normalizer.timezone.test.ts +++ b/packages/sync/src/providers/microsoft/microsoft-event.normalizer.timezone.test.ts @@ -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); @@ -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( @@ -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( @@ -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( diff --git a/packages/sync/src/providers/microsoft/microsoft-event.normalizer.ts b/packages/sync/src/providers/microsoft/microsoft-event.normalizer.ts index 955b5d6056..9223d91680 100644 --- a/packages/sync/src/providers/microsoft/microsoft-event.normalizer.ts +++ b/packages/sync/src/providers/microsoft/microsoft-event.normalizer.ts @@ -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"; @@ -35,8 +34,6 @@ const MEETING_PROVIDER_LABELS: Readonly> = { export interface NormalizeMicrosoftEventOptions { readonly allowOccurrence?: boolean; - /** Matches Prefer outlook.timezone on Graph reads (see event reader adapter). */ - readonly graphPreferTimeZone?: string; } export interface GraphDateTimeTimeZone { @@ -126,7 +123,7 @@ export function normalizeMicrosoftEvent( } const providerVersion = requireVersion(item); - const schedule = mapSchedule(item, options); + const schedule = mapSchedule(item); return { kind: "event", @@ -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( @@ -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, }); } @@ -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) { @@ -410,11 +397,8 @@ 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", @@ -422,9 +406,5 @@ function toOffsetIso( ); } 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); } diff --git a/packages/sync/src/providers/microsoft/microsoft-http.constants.ts b/packages/sync/src/providers/microsoft/microsoft-http.constants.ts index 6bb1510c21..d4f53d5dd3 100644 --- a/packages/sync/src/providers/microsoft/microsoft-http.constants.ts +++ b/packages/sync/src/providers/microsoft/microsoft-http.constants.ts @@ -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}`; From d14f0662cb76b1c2f006e8b8288124fcc49f44b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:08:51 +0000 Subject: [PATCH 2/2] refactor(sync): dedupe microsoft recurrence weekday tables and field guards Three pieces of copy-paste had built up in the Graph recurrence mapper: - `RRULE_TO_GRAPH_DAY` restated `GRAPH_TO_RRULE_DAY` backwards, so adding or correcting a weekday meant editing two tables that nothing checked against each other. Invert the forward table instead. - `requireDayOfMonth`, `requireMonth`, `requireCount`, and `requireEndDate` were the same eight lines four times over, differing only in the field name they interpolated. One `requireField` covers all four, and the numbered-range message now names the Graph field (`numberOfOccurrences`) rather than the local variable. - The "Multiple BYDAY ordinals" rule was thrown verbatim from both `splitRelativeByDay` and `byweekdayToTokens`. Both now call `assertSingleOrdinal`. `GRAPH_DAY_ORDER` and `RRULE_WEEKDAY_TOKENS` stay as they are: the first carries Graph's Sunday-first ordering and the second rrule's Monday-based weekday indexes, which are separate facts rather than restatements of the token map. VERDICT: PASS Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018gVNGW5WLSrqPjZaT1ReM4 --- .../microsoft/microsoft-recurrence.ts | 108 +++++++----------- 1 file changed, 43 insertions(+), 65 deletions(-) diff --git a/packages/sync/src/providers/microsoft/microsoft-recurrence.ts b/packages/sync/src/providers/microsoft/microsoft-recurrence.ts index 06b07a9144..95f0f75189 100644 --- a/packages/sync/src/providers/microsoft/microsoft-recurrence.ts +++ b/packages/sync/src/providers/microsoft/microsoft-recurrence.ts @@ -44,6 +44,15 @@ const GRAPH_TO_RRULE_DAY: Record = { saturday: "SA", }; +/** Inverted rather than restated, so the two directions cannot drift apart. */ +const RRULE_TO_GRAPH_DAY: Record = Object.fromEntries( + Object.entries(GRAPH_TO_RRULE_DAY).map(([graphDay, token]) => [ + token, + graphDay as GraphDayOfWeek, + ]), +); + +/** rrule's own weekday indexes are Monday-based, unlike Graph's Sunday-first order. */ const RRULE_WEEKDAY_TOKENS = [ "MO", "TU", @@ -54,16 +63,6 @@ const RRULE_WEEKDAY_TOKENS = [ "SU", ] as const; -const RRULE_TO_GRAPH_DAY: Record = { - SU: "sunday", - MO: "monday", - TU: "tuesday", - WE: "wednesday", - TH: "thursday", - FR: "friday", - SA: "saturday", -}; - const INDEX_TO_ORDINAL: Record = { first: 1, second: 2, @@ -178,7 +177,9 @@ function patternToParts( freq: "MONTHLY", interval, byday: [], - bymonthday: [requireDayOfMonth(pattern.dayOfMonth, pattern.type)], + bymonthday: [ + requireField(pattern.dayOfMonth, pattern.type, "dayOfMonth"), + ], bymonth: [], }; case "relativeMonthly": @@ -194,8 +195,10 @@ function patternToParts( freq: "YEARLY", interval, byday: [], - bymonthday: [requireDayOfMonth(pattern.dayOfMonth, pattern.type)], - bymonth: [requireMonth(pattern.month, pattern.type)], + bymonthday: [ + requireField(pattern.dayOfMonth, pattern.type, "dayOfMonth"), + ], + bymonth: [requireField(pattern.month, pattern.type, "month")], }; case "relativeYearly": return { @@ -203,7 +206,7 @@ function patternToParts( interval, byday: relativeByDay(pattern), bymonthday: [], - bymonth: [requireMonth(pattern.month, pattern.type)], + bymonth: [requireField(pattern.month, pattern.type, "month")], }; default: { const _exhaustive: never = pattern.type; @@ -222,10 +225,14 @@ function rangeToParts( return {}; case "numbered": return { - count: requireCount(range.numberOfOccurrences, range.type), + count: requireField( + range.numberOfOccurrences, + range.type, + "numberOfOccurrences", + ), }; case "endDate": { - const endDate = requireEndDate(range.endDate, range.type); + const endDate = requireField(range.endDate, range.type, "endDate"); const zone = range.recurrenceTimeZone ?? "UTC"; const iana = resolveIanaZone(zone); return { @@ -442,6 +449,15 @@ function relativeByDay(pattern: GraphRecurrencePattern): string[] { ); } +/** Graph carries one `index` per relative pattern, so mixed BYDAY ordinals cannot map. */ +function assertSingleOrdinal(ordinals: readonly number[]): void { + if (new Set(ordinals).size > 1) { + throw new UnsupportedRecurrenceError( + "Multiple BYDAY ordinals are not supported by Microsoft Graph", + ); + } +} + /** `1MO` is just `MO` in RRULE; every other ordinal, last included, prefixes. */ function ordinalDayToken(ordinal: number, code: string): string { return ordinal === 1 ? code : `${ordinal}${code}`; @@ -456,12 +472,7 @@ function splitRelativeByDay(byday: readonly string[]): { } const parsed = byday.map(parseOrdinalDay); - const ordinals = new Set(parsed.map((entry) => entry.ordinal)); - if (ordinals.size > 1) { - throw new UnsupportedRecurrenceError( - "Multiple BYDAY ordinals are not supported by Microsoft Graph", - ); - } + assertSingleOrdinal(parsed.map((entry) => entry.ordinal)); const ordinal = ORDINAL_TO_INDEX[parsed[0]!.ordinal]; if (!ordinal) { @@ -500,12 +511,7 @@ function byweekdayToTokens( return weekdays.map((day) => weekdayToken(day)); } - const ordinals = new Set(weekdays.map(weekdayOrdinal)); - if (ordinals.size > 1) { - throw new UnsupportedRecurrenceError( - "Multiple BYDAY ordinals are not supported by Microsoft Graph", - ); - } + assertSingleOrdinal(weekdays.map(weekdayOrdinal)); return weekdays.map((day) => ordinalDayToken(weekdayOrdinal(day), weekdayToken(day)), @@ -600,46 +606,18 @@ function requireDays( return sortGraphDays(days, weekStartsOn); } -function requireDayOfMonth( - dayOfMonth: number | undefined, - type: GraphRecurrencePatternType, -): number { - if (dayOfMonth === undefined) { +/** Graph marks these fields optional on the shared shape; each pattern needs its own. */ +function requireField( + value: T | undefined, + type: GraphRecurrencePatternType | GraphRecurrenceRange["type"], + field: string, +): T { + if (value === undefined || value === "") { throw new UnsupportedRecurrenceError( - `${type} recurrence requires dayOfMonth`, + `${type} recurrence requires ${field}`, ); } - return dayOfMonth; -} - -function requireMonth( - month: number | undefined, - type: GraphRecurrencePatternType, -): number { - if (month === undefined) { - throw new UnsupportedRecurrenceError(`${type} recurrence requires month`); - } - return month; -} - -function requireCount( - count: number | undefined, - type: GraphRecurrenceRange["type"], -): number { - if (count === undefined) { - throw new UnsupportedRecurrenceError(`${type} recurrence requires count`); - } - return count; -} - -function requireEndDate( - endDate: string | undefined, - type: GraphRecurrenceRange["type"], -): string { - if (!endDate) { - throw new UnsupportedRecurrenceError(`${type} recurrence requires endDate`); - } - return endDate; + return value; } function resolveIanaZone(zone: string): string {