@@ -86,7 +86,6 @@ const CalendarFetcherUtils = {
8686 const rule = event . rrule ;
8787 const isFullDay = CalendarFetcherUtils . isFullDayEvent ( event ) ;
8888 const localTimezone = CalendarFetcherUtils . getLocalTimezone ( ) ;
89- const eventTimezone = event . start && event . start . tz ? event . start . tz : localTimezone ;
9089
9190 // rrule.js interprets years < 1900 as offsets from 1900 which breaks parsing for
9291 // some imported calendars (notably Google birthday calendars). Normalise those
@@ -130,18 +129,39 @@ const CalendarFetcherUtils = {
130129
131130 const validDates = rawDates . filter ( Boolean ) ;
132131 return validDates . map ( ( date ) => {
133- const baseUtcMoment = moment . tz ( date , "UTC" ) ;
132+ let occurrenceMoment ;
133+ let floatingStartDate = null ;
134134 if ( isFullDay ) {
135- // Convert the UTC timestamp into the configured event timezone and clamp to the
136- // start of that day so the calendar date stays consistent across viewer timezones.
137- return baseUtcMoment . clone ( ) . tz ( eventTimezone ) . startOf ( "day" ) ;
138- }
139- if ( event . start && event . start . tz ) {
140- // Preserve the original start timezone when the ICS explicitly defines one.
141- return baseUtcMoment . clone ( ) . tz ( event . start . tz , true ) ;
135+ // Treat DATE-based recurrences as floating dates in their original timezone so they
136+ // stay anchored to the same calendar day regardless of where the viewer is located.
137+ const floatingZone = event . start ?. tz || rule . origOptions ?. tzid ;
138+ if ( floatingZone ) {
139+ const canonicalDate = moment ( date ) . format ( "YYYY-MM-DD" ) ;
140+ occurrenceMoment = moment . tz ( canonicalDate , "YYYY-MM-DD" , floatingZone ) ;
141+ } else {
142+ occurrenceMoment = moment ( date ) . startOf ( "day" ) ;
143+ }
144+ floatingStartDate = occurrenceMoment . clone ( ) . format ( "YYYY-MM-DD" ) ;
145+ if ( ! event . _debugLogged ) {
146+ event . _debugLogged = true ;
147+ Log . debug ( "[Calendar] Floating recurrence" , {
148+ title : CalendarFetcherUtils . getTitleFromEvent ( event ) ,
149+ rawDate : date ,
150+ floatingZone : floatingZone ,
151+ floatingStartDate
152+ } ) ;
153+ }
154+ } else {
155+ const baseUtcMoment = moment . tz ( date , "UTC" ) ;
156+ if ( event . start && event . start . tz ) {
157+ // Preserve the original start timezone when the ICS explicitly defines one.
158+ occurrenceMoment = baseUtcMoment . clone ( ) . tz ( event . start . tz , true ) ;
159+ } else {
160+ // Fallback: render in the viewer's local timezone while keeping the absolute instant.
161+ occurrenceMoment = baseUtcMoment . clone ( ) . tz ( localTimezone , true ) ;
162+ }
142163 }
143- // Fallback: render in the viewer's local timezone while keeping the absolute instant.
144- return baseUtcMoment . clone ( ) . tz ( localTimezone , true ) ;
164+ return { occurrence : occurrenceMoment , floatingStartDate : floatingStartDate } ;
145165 } ) ;
146166 } ,
147167
@@ -224,17 +244,29 @@ const CalendarFetcherUtils = {
224244 // TODO This should be a seperate function.
225245 if ( event . rrule && typeof event . rrule !== "undefined" && ! isFacebookBirthday ) {
226246 // Recurring event.
227- let moments = CalendarFetcherUtils . getMomentsFromRecurringEvent ( event , pastLocalMoment , futureLocalMoment , durationMs ) ;
247+ const occurrences = CalendarFetcherUtils . getMomentsFromRecurringEvent ( event , pastLocalMoment , futureLocalMoment , durationMs ) ;
228248
229249 // Loop through the set of moment entries to see which recurrences should be added to our event list.
230250 // TODO This should create an event per moment so we can change anything we want.
231- for ( let m in moments ) {
251+ for ( const occurrenceData of occurrences ) {
232252 let curEvent = event ;
233253 let showRecurrence = true ;
234- let recurringEventStartMoment = moments [ m ] . tz ( CalendarFetcherUtils . getLocalTimezone ( ) ) . clone ( ) ;
254+ let recurringEventStartMoment = occurrenceData . occurrence
255+ . clone ( )
256+ . tz ( CalendarFetcherUtils . getLocalTimezone ( ) , CalendarFetcherUtils . isFullDayEvent ( event ) ) ;
235257 let recurringEventEndMoment = recurringEventStartMoment . clone ( ) . add ( durationMs , "ms" ) ;
236258
237- let dateKey = recurringEventStartMoment . tz ( "UTC" ) . format ( "YYYY-MM-DD" ) ;
259+ let floatingStartDate = occurrenceData . floatingStartDate ;
260+ let floatingEndDate = null ;
261+ if ( floatingStartDate ) {
262+ let floatingEndMoment = occurrenceData . occurrence . clone ( ) . add ( durationMs , "ms" ) ;
263+ if ( durationMs === 0 ) {
264+ floatingEndMoment = occurrenceData . occurrence . clone ( ) . endOf ( "day" ) ;
265+ }
266+ floatingEndDate = floatingEndMoment . format ( "YYYY-MM-DD" ) ;
267+ }
268+
269+ let dateKey = recurringEventStartMoment . clone ( ) . tz ( "UTC" ) . format ( "YYYY-MM-DD" ) ;
238270
239271 Log . debug ( "event date dateKey=" , dateKey ) ;
240272 // For each date that we're checking, it's possible that there is a recurrence override for that one day.
@@ -244,16 +276,30 @@ const CalendarFetcherUtils = {
244276 Log . debug ( "have a recurrence match for dateKey=" , dateKey ) ;
245277 // We found an override, so for this recurrence, use a potentially different title, start date, and duration.
246278 curEvent = curEvent . recurrences [ dateKey ] ;
279+ const recurrenceIsFullDay = CalendarFetcherUtils . isFullDayEvent ( curEvent ) ;
247280 // Some event start/end dates don't have timezones
248281 if ( curEvent . start . tz ) {
249- recurringEventStartMoment = moment ( curEvent . start ) . tz ( curEvent . start . tz ) . tz ( CalendarFetcherUtils . getLocalTimezone ( ) ) ;
282+ recurringEventStartMoment = moment ( curEvent . start ) . tz ( curEvent . start . tz ) . tz ( CalendarFetcherUtils . getLocalTimezone ( ) , recurrenceIsFullDay ) ;
250283 } else {
251- recurringEventStartMoment = moment ( curEvent . start ) . tz ( CalendarFetcherUtils . getLocalTimezone ( ) ) ;
284+ recurringEventStartMoment = moment ( curEvent . start ) . tz ( CalendarFetcherUtils . getLocalTimezone ( ) , recurrenceIsFullDay ) ;
252285 }
253286 if ( curEvent . end . tz ) {
254- recurringEventEndMoment = moment ( curEvent . end ) . tz ( curEvent . end . tz ) . tz ( CalendarFetcherUtils . getLocalTimezone ( ) ) ;
287+ recurringEventEndMoment = moment ( curEvent . end ) . tz ( curEvent . end . tz ) . tz ( CalendarFetcherUtils . getLocalTimezone ( ) , recurrenceIsFullDay ) ;
255288 } else {
256- recurringEventEndMoment = moment ( curEvent . end ) . tz ( CalendarFetcherUtils . getLocalTimezone ( ) ) ;
289+ recurringEventEndMoment = moment ( curEvent . end ) . tz ( CalendarFetcherUtils . getLocalTimezone ( ) , recurrenceIsFullDay ) ;
290+ }
291+
292+ if ( recurrenceIsFullDay ) {
293+ const overrideStart = curEvent . start . tz ? moment ( curEvent . start ) . tz ( curEvent . start . tz , true ) . startOf ( "day" ) : moment ( curEvent . start ) . startOf ( "day" ) ;
294+ floatingStartDate = overrideStart . format ( "YYYY-MM-DD" ) ;
295+ let overrideEnd = curEvent . end ? ( curEvent . end . tz ? moment ( curEvent . end ) . tz ( curEvent . end . tz , true ) : moment ( curEvent . end ) ) : overrideStart . clone ( ) ;
296+ if ( overrideStart . valueOf ( ) === overrideEnd . valueOf ( ) ) {
297+ overrideEnd = overrideEnd . endOf ( "day" ) ;
298+ }
299+ floatingEndDate = overrideEnd . format ( "YYYY-MM-DD" ) ;
300+ } else {
301+ floatingStartDate = null ;
302+ floatingEndDate = null ;
257303 }
258304 } else {
259305 Log . debug ( "recurrence key " , dateKey , " doesn't match" ) ;
@@ -270,11 +316,26 @@ const CalendarFetcherUtils = {
270316
271317 if ( recurringEventStartMoment . valueOf ( ) === recurringEventEndMoment . valueOf ( ) ) {
272318 recurringEventEndMoment = recurringEventEndMoment . endOf ( "day" ) ;
319+ if ( floatingStartDate && ! floatingEndDate ) {
320+ floatingEndDate = floatingStartDate ;
321+ }
273322 }
274323
275324 const recurrenceTitle = CalendarFetcherUtils . getTitleFromEvent ( curEvent ) ;
325+ const fullDayRecurringEvent = CalendarFetcherUtils . isFullDayEvent ( curEvent ) ;
326+ if ( fullDayRecurringEvent ) {
327+ if ( ! floatingStartDate ) {
328+ floatingStartDate = recurringEventStartMoment . clone ( ) . format ( "YYYY-MM-DD" ) ;
329+ }
330+ if ( ! floatingEndDate ) {
331+ floatingEndDate = recurringEventEndMoment . clone ( ) . format ( "YYYY-MM-DD" ) ;
332+ }
333+ } else {
334+ floatingStartDate = null ;
335+ floatingEndDate = null ;
336+ }
276337
277- // If this recurrence ends before the start of the date range, or starts after the end of the date range, don" t add
338+ // If this recurrence ends before the start of the date range, or starts after the end of the date range, don' t add
278339 // it to the event list.
279340 if ( recurringEventEndMoment . isBefore ( pastLocalMoment ) || recurringEventStartMoment . isAfter ( futureLocalMoment ) ) {
280341 showRecurrence = false ;
@@ -290,13 +351,15 @@ const CalendarFetcherUtils = {
290351 title : recurrenceTitle ,
291352 startDate : recurringEventStartMoment . format ( "x" ) ,
292353 endDate : recurringEventEndMoment . format ( "x" ) ,
293- fullDayEvent : CalendarFetcherUtils . isFullDayEvent ( event ) ,
354+ fullDayEvent : fullDayRecurringEvent ,
294355 recurringEvent : true ,
295356 class : event . class ,
296357 firstYear : event . start . getFullYear ( ) ,
297358 location : location ,
298359 geo : geo ,
299- description : description
360+ description : description ,
361+ floatingStartDate : floatingStartDate ,
362+ floatingEndDate : floatingEndDate
300363 } ) ;
301364 } else {
302365 Log . debug ( "not saving event " , recurrenceTitle , eventStartMoment ) ;
@@ -341,6 +404,8 @@ const CalendarFetcherUtils = {
341404 }
342405
343406 // Every thing is good. Add it to the list.
407+ const floatingStartDate = fullDayEvent ? eventStartMoment . clone ( ) . format ( "YYYY-MM-DD" ) : null ;
408+ const floatingEndDate = fullDayEvent ? eventEndMoment . clone ( ) . format ( "YYYY-MM-DD" ) : null ;
344409 newEvents . push ( {
345410 title : title ,
346411 startDate : eventStartMoment . format ( "x" ) ,
@@ -351,7 +416,9 @@ const CalendarFetcherUtils = {
351416 firstYear : event . start . getFullYear ( ) ,
352417 location : location ,
353418 geo : geo ,
354- description : description
419+ description : description ,
420+ floatingStartDate : floatingStartDate ,
421+ floatingEndDate : floatingEndDate
355422 } ) ;
356423 }
357424 }
0 commit comments