@@ -84,47 +84,65 @@ const CalendarFetcherUtils = {
8484 */
8585 getMomentsFromRecurringEvent ( event , pastLocalMoment , futureLocalMoment , durationInMs ) {
8686 const rule = event . rrule ;
87-
88- // can cause problems with e.g. birthdays before 1900
89- if ( ( rule . options && rule . origOptions && rule . origOptions . dtstart && rule . origOptions . dtstart . getFullYear ( ) < 1900 ) || ( rule . options && rule . options . dtstart && rule . options . dtstart . getFullYear ( ) < 1900 ) ) {
87+ const isFullDay = CalendarFetcherUtils . isFullDayEvent ( event ) ;
88+ const localTimezone = CalendarFetcherUtils . getLocalTimezone ( ) ;
89+ const eventTimezone = event . start && event . start . tz ? event . start . tz : localTimezone ;
90+
91+ // rrule.js interprets years < 1900 as offsets from 1900 which breaks parsing for
92+ // some imported calendars (notably Google birthday calendars). Normalise those
93+ // values before we expand the recurrence window.
94+ if ( rule . origOptions ?. dtstart instanceof Date && rule . origOptions . dtstart . getFullYear ( ) < 1900 ) {
9095 rule . origOptions . dtstart . setYear ( 1900 ) ;
96+ }
97+ if ( rule . options ?. dtstart instanceof Date && rule . options . dtstart . getFullYear ( ) < 1900 ) {
9198 rule . options . dtstart . setYear ( 1900 ) ;
9299 }
93100
94- // subtract the max of the duration of this event or 1 day to find events in the past that are currently still running and should therefor be displayed.
95- const oneDayInMs = 24 * 60 * 60000 ;
96- let searchFromDate = pastLocalMoment . clone ( ) . subtract ( Math . max ( durationInMs , oneDayInMs ) , "milliseconds" ) . toDate ( ) ;
97- let searchToDate = futureLocalMoment . clone ( ) . add ( 1 , "days" ) . toDate ( ) ;
101+ // Expand the search window by the event duration (or a full day) so ongoing recurrences are included.
102+ // Without this buffer a long-running recurrence that already started before "pastLocalMoment"
103+ // would be skipped even though it is still active.
104+ const oneDayInMs = 24 * 60 * 60 * 1000 ;
105+ const searchWindowMs = Math . max ( durationInMs , oneDayInMs ) ;
106+ const searchFromDate = pastLocalMoment . clone ( ) . subtract ( searchWindowMs , "milliseconds" ) . toDate ( ) ;
107+ const searchToDate = futureLocalMoment . clone ( ) . add ( 1 , "days" ) . toDate ( ) ;
98108 Log . debug ( `Search for recurring events between: ${ searchFromDate } and ${ searchToDate } ` ) ;
99109
100- // if until is set, and its a full day event, force the time to midnight. rrule gets confused with non-00 offset
101- // looks like MS Outlook sets the until time incorrectly for fullday events
102- if ( ( rule . options . until !== undefined ) && CalendarFetcherUtils . isFullDayEvent ( event ) ) {
110+ if ( isFullDay && rule . options && rule . options . until ) {
111+ // node-ical supplies "until" in UTC for all-day events; push the date to the end of
112+ // that day so the last occurrence is part of the set we request from rrule.js.
103113 Log . debug ( "fixup rrule until" ) ;
104114 rule . options . until = moment ( rule . options . until ) . clone ( ) . startOf ( "day" ) . add ( 1 , "day" )
105115 . toDate ( ) ;
106116 }
107117
108- Log . debug ( "fix rrule start=" , rule . options . dtstart ) ;
118+ Log . debug ( "fix rrule start=" , rule . options ? .dtstart ) ;
109119 Log . debug ( "event before rrule.between=" , JSON . stringify ( event , null , 2 ) , "exdates=" , event . exdate ) ;
110-
111120 Log . debug ( `RRule: ${ rule . toString ( ) } ` ) ;
112- rule . options . tzid = null ; // RRule gets *very* confused with timezones
113121
114- let dates = rule . between ( searchFromDate , searchToDate , true , ( ) => {
115- return true ;
116- } ) ;
122+ if ( rule . options ) {
123+ // Let moment.js handle the timezone conversion afterwards. Keeping tzid here lets
124+ // rrule.js double adjust the times which causes one-day drifts.
125+ rule . options . tzid = null ;
126+ }
117127
118- Log . debug ( `Title: ${ event . summary } , with dates: \n\n${ JSON . stringify ( dates ) } \n` ) ;
128+ const rawDates = rule . between ( searchFromDate , searchToDate , true , ( ) => true ) || [ ] ;
129+ Log . debug ( `Title: ${ event . summary } , with dates: \n\n${ JSON . stringify ( rawDates ) } \n` ) ;
119130
120- // shouldn't need this anymore, as RRULE not passed junk
121- dates = dates . filter ( ( d ) => {
122- return JSON . stringify ( d ) !== "null" ;
131+ const validDates = rawDates . filter ( Boolean ) ;
132+ return validDates . map ( ( date ) => {
133+ const baseUtcMoment = moment . tz ( date , "UTC" ) ;
134+ 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 ) ;
142+ }
143+ // Fallback: render in the viewer's local timezone while keeping the absolute instant.
144+ return baseUtcMoment . clone ( ) . tz ( localTimezone , true ) ;
123145 } ) ;
124-
125- // Dates are returned in UTC timezone but with localdatetime because tzid is null.
126- // So we map the date to a moment using the original timezone of the event.
127- return dates . map ( ( d ) => ( event . start . tz ? moment . tz ( d , "UTC" ) . tz ( event . start . tz , true ) : moment . tz ( d , "UTC" ) . tz ( CalendarFetcherUtils . getLocalTimezone ( ) , true ) ) ) ;
128146 } ,
129147
130148 /**
0 commit comments