Skip to content

Outlook sync: a Graph series mirrors as a series (structured recurrence → RRULE, exceptions, occurrence skip) - #919

Closed
NewWorldVenture wants to merge 8 commits into
claude/google-sync-exception-dates-20261003from
claude/outlook-series-mirror-20261003
Closed

NewWorldVenture wants to merge 8 commits into
claude/google-sync-exception-dates-20261003from
claude/outlook-series-mirror-20261003

Conversation

@NewWorldVenture

Copy link
Copy Markdown
Owner

Stacked on #918 (→ #913 → #908) for NormalizedEvent's exception fields and the generic engine's fold pass. No migration: it writes the column 0483 adds.

What was wrong

msEventToRow in lib/sync/providers/microsoft.ts wrote recurrence_rule: null for every event, with a comment calling RRULE mapping a follow-up, and MsEvent had no type, seriesMasterId or originalStart. So a recurring Outlook event mirrored into sync_calendar_events, and was published by the feed to every subscribed phone, as the one occasion it started on. Had an occurrence reached the delta pull it would have been mirrored beside its master as a second copy.

Change

  • lib/sync/providers/graph-recurrence.ts (new): graphRecurrenceToRrule writes Graph's patternedRecurrence as an RRULE: daily, weekly (BYDAY, WKST), absoluteMonthly (BYMONTHDAY), relativeMonthly (BYDAY + BYSETPOS, last → -1), absoluteYearly, relativeYearly; INTERVAL when above one; COUNT for a numbered range; UNTIL for an end date. UNTIL on a timed series is the last second of the end date in the series' own zone. Graph names zones the Windows way ("Eastern Standard Time"), so a curated Windows→IANA table (CLDR windowsZones, the names Graph emits) resolves them, falling back to the event's own start zone, then UTC. An all-day series ends on a DATE, as RFC 5545 asks when DTSTART is a date. An unknown pattern yields null rather than an invented rule.
  • microsoft.ts: MsEvent gains type, seriesMasterId, originalStart and a typed recurrence. A seriesMaster carries its RRULE; an exception names its master and the slot it left (recurring_event_id, original_starts_at), which lib/sync/engine/exceptions.ts folds into the master's exception_dates; the delta pull skips occurrence items (isSeriesOccurrence), and only there: calendarView still expands them for the onboarding import window.

Tests

tests/an-outlook-series-mirrors-as-a-series.test.ts (17):

  • the RRULE table (six patterns), UNTIL in New York (20261001T035959Z for an end date of 30 September), Tokyo, an unknown zone with and without the event's own zone as a hint, an all-day UNTIL=20260930, unknown pattern → null, the zone table;
  • master / moved exception / cancelled exception / plain event mapping; only an occurrence is skipped;
  • the real adapter's delta pull over a faked Graph leaves occurrences out and keeps the delta token;
  • the real generic engine with the real Microsoft adapter over a faked Graph, every write through the in-memory client: the series mirrors once with FREQ=WEEKLY;BYDAY=WE;WKST=SU;UNTIL=20261001T035959Z and the two slots its exceptions left, the moved occurrence is its own row with recurrence_id, the occurrence and the never-mirrored cancelled instance have no rows, the dentist is untouched (imported 3, skipped 1). This is the first end-to-end exercise of generic.ts's exception wiring from Google sync: a mirrored series remembers the occurrences its source gave up (migration 0483) #918.

Mutation controls (each reverted): no RRULE → 2 fail; occurrences kept → 2 fail; exceptions forget their master → 2 fail; UNTIL in UTC → 4 fail. tsc --noEmit and ESLint clean. 32 sync-related suites 565/565.

Not done

  • The push side (rowToMsEvent) still sends no recurrence; a series created in Bubaly reaches Outlook as a single event, as before.
  • The Windows→IANA table covers the zones Graph emits for the regions Bubaly serves; a name outside it ends the series on the right date within a few hours, and the fallback is stated in the module header.
  • Retarget to claude/calendar-recurrence-exceptions-20261003 when Google sync: a mirrored series remembers the occurrences its source gave up (migration 0483) #918 merges, and onward with the stack.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K


Generated by Claude Code

msEventToRow wrote recurrence_rule: null for every event and MsEvent had
no type, seriesMasterId or originalStart, so a recurring Outlook event
mirrored, and was published to every subscribed phone, as the one
occasion it started on; an occurrence reaching the pull would have been
mirrored beside its master as a second copy.

- lib/sync/providers/graph-recurrence.ts: graphRecurrenceToRrule writes
  Graph's patternedRecurrence (daily, weekly, absoluteMonthly,
  relativeMonthly, absoluteYearly, relativeYearly; noEnd, endDate,
  numbered) as an RRULE. UNTIL is the last second of the end date in the
  series' own zone; Graph names zones the Windows way, so a curated
  Windows-to-IANA table (CLDR windowsZones) resolves them, falling back
  to the event's start zone, then UTC. An all-day series ends on a DATE.
  An unknown pattern yields null rather than an invented rule.
- microsoft.ts: MsEvent gains type, seriesMasterId, originalStart and a
  typed recurrence. A seriesMaster carries its RRULE; an exception names
  its master and the slot it left (recurring_event_id,
  original_starts_at), which lib/sync/engine/exceptions folds into the
  master; the delta pull skips occurrences (isSeriesOccurrence), and
  only there: calendarView still expands them for the onboarding window.

Stacked on the Google mirrored-series branch for NormalizedEvent's
exception fields and the generic engine's fold pass.

tests/an-outlook-series-mirrors-as-a-series.test.ts (17): the RRULE
table, UNTIL in New York / Tokyo / unknown zone with and without a hint,
all-day DATE UNTIL, unknown pattern, zone table; master/exception/plain
mapping; the delta pull leaves occurrences out; the real generic engine
with the real Microsoft adapter over a faked Graph mirrors the series
once with the slots its exceptions left and the moved occurrence as an
override (imported 3, skipped 1), which also exercises generic.ts's
exception wiring. Controls: no RRULE fails 2; occurrences kept fails 2;
exceptions forget their master fails 2; UNTIL in UTC fails 4. tsc and
eslint clean; 32 sync suites 565/565.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
@vercel

vercel Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
bubaly Ignored Ignored Oct 4, 2026 3:49pm UTC

Request Review

claude added 5 commits October 3, 2026 18:47
…e/outlook-series-mirror-20261003

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…e/outlook-series-mirror-20261003

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…e/outlook-series-mirror-20261003

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…e/outlook-series-mirror-20261003

Carries #908's fenced claim (and current main) down the stack. No conflicts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…e/outlook-series-mirror-20261003

Carries #908's fenced chunk write (0490) down the stack into this unit.
No conflicts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
@NewWorldVenture
NewWorldVenture marked this pull request as ready for review October 4, 2026 14:59
…ok-series-mirror-20261003

Carries the parent's refreshed head: the feed sync binds every row to the
feed's own family, a CANCEL without DTSTART removes the occurrence it
names, and the ICS parser keeps the EXDATE helpers beside its new options.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants