Skip to content

Commit e423572

Browse files
fix(calendar): keep floating recurrences on the correct day
All-day recurring events were serialized as UTC timestamps sourced from the rrule result. Viewers west of UTC therefore saw the start date drift to the preceding day. Parse the rrule output as a floating YYYY-MM-DD first and only then apply viewer offsets so every client sees the same calendar date. Clean up temporary debugging scaffolding in the process.
1 parent a3bf39e commit e423572

File tree

2 files changed

+113
-36
lines changed

2 files changed

+113
-36
lines changed

modules/default/calendar/calendar.js

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,8 @@ Module.register("calendar", {
248248
let lastSeenDate = "";
249249

250250
events.forEach((event, index) => {
251-
const eventStartDateMoment = this.timestampToMoment(event.startDate);
252-
const eventEndDateMoment = this.timestampToMoment(event.endDate);
251+
const eventStartDateMoment = this.timestampToMoment(event.startDate, event.fullDayEvent, event.floatingStartDate);
252+
const eventEndDateMoment = this.timestampToMoment(event.endDate, event.fullDayEvent, event.floatingEndDate);
253253
const dateAsString = eventStartDateMoment.format(this.config.dateFormat);
254254
if (this.config.timeFormat === "dateheaders") {
255255
if (lastSeenDate !== dateAsString) {
@@ -584,10 +584,17 @@ Module.register("calendar", {
584584
/**
585585
* converts the given timestamp to a moment with a timezone
586586
* @param {number} timestamp timestamp from an event
587+
* @param {boolean} isFullDayEvent flag indicating whether the timestamp represents an all-day event
588+
* @param {?string} floatingDate canonical YYYY-MM-DD date for floating events
587589
* @returns {moment.Moment} moment with a timezone
588590
*/
589-
timestampToMoment (timestamp) {
590-
return moment(timestamp, "x").tz(moment.tz.guess());
591+
timestampToMoment (timestamp, isFullDayEvent = false, floatingDate = null) {
592+
const viewerOffsetMinutes = -new Date().getTimezoneOffset();
593+
if (isFullDayEvent && floatingDate) {
594+
return moment(floatingDate, "YYYY-MM-DD").utcOffset(viewerOffsetMinutes, true).startOf("day");
595+
}
596+
const baseMoment = moment(timestamp, "x").utc();
597+
return baseMoment.clone().utcOffset(viewerOffsetMinutes, isFullDayEvent);
591598
},
592599

593600
/**
@@ -608,8 +615,8 @@ Module.register("calendar", {
608615
let by_url_calevents = [];
609616
for (const e in calendar) {
610617
const event = JSON.parse(JSON.stringify(calendar[e])); // clone object
611-
const eventStartDateMoment = this.timestampToMoment(event.startDate);
612-
const eventEndDateMoment = this.timestampToMoment(event.endDate);
618+
const eventStartDateMoment = this.timestampToMoment(event.startDate, event.fullDayEvent, event.floatingStartDate);
619+
const eventEndDateMoment = this.timestampToMoment(event.endDate, event.fullDayEvent, event.floatingEndDate);
613620

614621
if (this.config.hidePrivate && event.class === "PRIVATE") {
615622
// do not add the current event, skip it
@@ -650,24 +657,26 @@ Module.register("calendar", {
650657
let count = 1;
651658
while (eventEndDateMoment.isAfter(midnight)) {
652659
const thisEvent = JSON.parse(JSON.stringify(event)); // clone object
653-
thisEvent.today = this.timestampToMoment(thisEvent.startDate).isSame(now, "d");
654-
thisEvent.tomorrow = this.timestampToMoment(thisEvent.startDate).isSame(now.clone().add(1, "days"), "d");
660+
thisEvent.today = this.timestampToMoment(thisEvent.startDate, thisEvent.fullDayEvent, thisEvent.floatingStartDate).isSame(now, "d");
661+
thisEvent.tomorrow = this.timestampToMoment(thisEvent.startDate, thisEvent.fullDayEvent, thisEvent.floatingStartDate).isSame(now.clone().add(1, "days"), "d");
655662
thisEvent.endDate = midnight.clone().subtract(1, "day").format("x");
663+
thisEvent.floatingEndDate = midnight.clone().subtract(1, "day").format("YYYY-MM-DD");
656664
thisEvent.title += ` (${count}/${maxCount})`;
657665
splitEvents.push(thisEvent);
658666

659667
event.startDate = midnight.format("x");
668+
event.floatingStartDate = midnight.clone().format("YYYY-MM-DD");
660669
count += 1;
661670
midnight = midnight.clone().add(1, "day").endOf("day"); // next day
662671
}
663672
// Last day
664673
event.title += ` (${count}/${maxCount})`;
665-
event.today += this.timestampToMoment(event.startDate).isSame(now, "d");
666-
event.tomorrow = this.timestampToMoment(event.startDate).isSame(now.clone().add(1, "days"), "d");
674+
event.today += this.timestampToMoment(event.startDate, event.fullDayEvent, event.floatingStartDate).isSame(now, "d");
675+
event.tomorrow = this.timestampToMoment(event.startDate, event.fullDayEvent, event.floatingStartDate).isSame(now.clone().add(1, "days"), "d");
667676
splitEvents.push(event);
668677

669678
for (let splitEvent of splitEvents) {
670-
if (this.timestampToMoment(splitEvent.endDate).isAfter(now) && this.timestampToMoment(splitEvent.endDate).isSameOrBefore(future)) {
679+
if (this.timestampToMoment(splitEvent.endDate, splitEvent.fullDayEvent, splitEvent.floatingEndDate).isAfter(now) && this.timestampToMoment(splitEvent.endDate, splitEvent.fullDayEvent, splitEvent.floatingEndDate).isSameOrBefore(future)) {
671680
by_url_calevents.push(splitEvent);
672681
}
673682
}
@@ -702,7 +711,8 @@ Module.register("calendar", {
702711
*/
703712
if (this.config.limitDays > 0 && events.length > 0) { // watch out for initial display before events arrive from helper
704713
// Group all events by date, events on the same date will be in a list with the key being the date.
705-
const eventsByDate = Object.groupBy(events, (ev) => this.timestampToMoment(ev.startDate).format("YYYY-MM-DD"));
714+
const eventsByDate
715+
= Object.groupBy(events, (ev) => this.timestampToMoment(ev.startDate, ev.fullDayEvent, ev.floatingStartDate).format("YYYY-MM-DD"));
706716
const newEvents = [];
707717
let currentDate = moment();
708718
let daysCollected = 0;
@@ -712,7 +722,7 @@ Module.register("calendar", {
712722
// Check if there are events on the currentDate
713723
if (eventsByDate[dateStr] && eventsByDate[dateStr].length > 0) {
714724
// If there are any events today then get all those events and select the currently active events and the events that are starting later in the day.
715-
newEvents.push(...eventsByDate[dateStr].filter((ev) => this.timestampToMoment(ev.endDate).isAfter(moment())));
725+
newEvents.push(...eventsByDate[dateStr].filter((ev) => this.timestampToMoment(ev.endDate, ev.fullDayEvent, ev.floatingEndDate).isAfter(moment())));
716726
// Since we found a day with events, increase the daysCollected by 1
717727
daysCollected++;
718728
}

modules/default/calendar/calendarfetcherutils.js

Lines changed: 90 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)