Skip to content

Commit 3cbf0be

Browse files
Output occurrences that fall in previous schedule using RDATE
A recurring meeting series is exported as one master event using DTSTART and RRULE. Individual meetings that differ from the schedule are exported as separate override events, each tagged with a RECURRENCE-ID pointing at the original slot it replaces. The problem: when someone changes the series schedule, we insert a new current_schedule_start date as DTSTART. But the older meetings that already happened before that new start are still exported as overrides, with a RECURRENCE-ID sitting before DTSTART. Strict clients like OX complain about this and fail the input This fix adds those older dates back as RDATE entries on the master event. RDATE means "this date is also part of the recurrence set, even though the RRULE wouldn't generate it." Now the override has a real instance to atccept it. What does not get an RDATE: - Occurrences at or after the new DTSTART: the RRULE already covers them, so they're valid instances on their own. - Cancelled occurrences: those use EXDATE (removal), the opposite
1 parent 6d973fc commit 3cbf0be

2 files changed

Lines changed: 38 additions & 10 deletions

File tree

modules/meeting/app/services/meetings/icalendar_builder.rb

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,11 @@ def add_series_event(recurring_meeting:, cancelled: false) # rubocop:disable Met
120120

121121
# Add exceptions for all cancelled recurrences
122122
set_excluded_recurrence_dates(event: e, recurring_meeting: recurring_meeting)
123+
124+
# Previous-schedule overrides are outside the current RRULE expansion.
125+
# Add them as RDATEs so strict clients can attach their RECURRENCE-ID
126+
# overrides to the master event.
127+
set_included_recurrence_dates(event: e, recurring_meeting: recurring_meeting)
123128
end
124129

125130
# Add single events for all occurrences
@@ -306,18 +311,33 @@ def url_helpers
306311

307312
# Methods for recurring meetings
308313
def add_instantiated_occurrences(recurring_meeting:)
309-
previous, upcoming = instantiated_schedules(recurring_meeting)
310-
.partition { |meeting| in_previous_schedule?(meeting, recurring_meeting) }
311-
312-
recent_previous = previous
313-
.sort_by(&:recurrence_start_time)
314-
.last(PAST_OCCURRENCES_LIMIT)
315-
316-
(recent_previous + upcoming).each do |meeting|
314+
instantiated_occurrences_for_export(recurring_meeting).each do |meeting|
317315
add_single_recurring_occurrence(meeting:)
318316
end
319317
end
320318

319+
def instantiated_occurrences_for_export(recurring_meeting)
320+
previous_schedule_occurrences(recurring_meeting) + upcoming_schedule_occurrences(recurring_meeting)
321+
end
322+
323+
def previous_schedule_occurrences(recurring_meeting)
324+
instantiated_schedules_partitioned(recurring_meeting)
325+
.first
326+
.sort_by(&:recurrence_start_time)
327+
.last(PAST_OCCURRENCES_LIMIT)
328+
end
329+
330+
def upcoming_schedule_occurrences(recurring_meeting)
331+
instantiated_schedules_partitioned(recurring_meeting).second
332+
end
333+
334+
def instantiated_schedules_partitioned(recurring_meeting)
335+
@instantiated_schedules_partition_cache ||= {}
336+
@instantiated_schedules_partition_cache[recurring_meeting.id] ||=
337+
instantiated_schedules(recurring_meeting)
338+
.partition { |meeting| in_previous_schedule?(meeting, recurring_meeting) }
339+
end
340+
321341
def in_previous_schedule?(meeting, recurring_meeting)
322342
meeting.recurrence_start_time < recurring_meeting.current_schedule_start
323343
end
@@ -367,6 +387,11 @@ def set_excluded_recurrence_dates(event:, recurring_meeting:)
367387
.map { ical_datetime(it, timezone: recurring_meeting.time_zone) }
368388
end
369389

390+
def set_included_recurrence_dates(event:, recurring_meeting:)
391+
event.rdate = previous_schedule_occurrences(recurring_meeting)
392+
.map { ical_datetime(it.recurrence_start_time, timezone: recurring_meeting.time_zone) }
393+
end
394+
370395
def cancelled_recurrence_dates(recurring_meeting)
371396
if series_cache_loaded?
372397
(@excluded_dates_cache[recurring_meeting.id] || [])

modules/meeting/spec/services/meetings/icalendar_builder_recurring_meeting_with_updated_schedule_spec.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,22 +241,25 @@
241241
recurring_meeting.update!(current_schedule_start: new_schedule_start)
242242
end
243243

244-
it "emits only the 10 most recent previous-schedule occurrences as overrides without polluting EXDATE" do
244+
it "emits only the 10 most recent previous-schedule occurrences as RDATE-backed overrides" do
245245
builder.add_series_event(recurring_meeting: recurring_meeting)
246246

247247
master_event = parsed_calendar.events.find { |event| event.recurrence_id.nil? }
248248
override_events = parsed_calendar.events.select { |event| event.recurrence_id.present? }
249+
previous_schedule_start_times = past_occurrence_start_times.last(10).map(&:to_time)
249250

250251
expect(master_event.dtstart).to eq(new_schedule_start)
251252
expect(master_event.dtend).to eq(new_schedule_start + recurring_meeting.template.duration.hours)
252253

253254
# EXDATE is reserved for cancelled meetings still in the RRULE expansion.
254255
# Previous-schedule occurrences are already outside it, so EXDATE'ing them would be a no-op.
255256
expect(Array(master_event.exdate)).to be_empty
257+
expect(Array(master_event.rdate).map { |date| date.value.to_time })
258+
.to match_array(previous_schedule_start_times)
256259

257260
expect(override_events.count).to eq(10)
258261
expect(override_events.map { |evt| evt.recurrence_id.to_time })
259-
.to match_array(past_occurrence_start_times.last(10).map(&:to_time))
262+
.to match_array(previous_schedule_start_times)
260263
end
261264
end
262265
end

0 commit comments

Comments
 (0)