Merge branch 'fix/sentry-issue' into 'main'
[ProtonMail-WebClient.git] / packages / shared / lib / calendar / recurrence / recurring.ts
blobc6b656f6154cbfd79d7e509318e3518f16d36bd2
1 /* eslint-disable no-param-reassign */
2 import { addDays, addMilliseconds, differenceInCalendarDays, max } from '../../date-fns-utc';
3 import { convertUTCDateTimeToZone, convertZonedDateTimeToUTC, fromUTCDate, toUTCDate } from '../../date/timezone';
4 import type {
5     VcalDateOrDateTimeProperty,
6     VcalDateOrDateTimeValue,
7     VcalRruleProperty,
8     VcalVeventComponent,
9 } from '../../interfaces/calendar/VcalModel';
10 import { createExdateMap } from '../exdate';
11 import { getInternalDateTimeValue, internalValueToIcalValue } from '../vcal';
12 import { getDtendProperty, propertyToUTCDate } from '../vcalConverter';
13 import { getPropertyTzid } from '../vcalHelper';
14 import { getIsAllDay } from '../veventHelper';
16 interface CacheInner {
17     dtstart: VcalDateOrDateTimeValue;
18     utcStart: Date;
19     isAllDay: boolean;
20     eventDuration: number;
21     modifiedRrule: VcalRruleProperty;
22     exdateMap: { [key: number]: boolean };
25 export interface RecurringResult {
26     localStart: Date;
27     localEnd: Date;
28     utcStart: Date;
29     utcEnd: Date;
30     occurrenceNumber: number;
33 export interface OccurrenceIterationCache {
34     start: CacheInner;
35     iteration: {
36         iterator: any;
37         result: RecurringResult[];
38         interval: number[];
39     };
42 type RequiredVcalVeventComponent = Pick<VcalVeventComponent, 'dtstart' | 'rrule' | 'exdate'>;
44 const YEAR_IN_MS = Date.UTC(1971, 0, 1);
46 const isInInterval = (a1: number, a2: number, b1: number, b2: number) => a1 <= b2 && a2 >= b1;
48 // Special case for when attempting to use occurrences when an rrule does not exist.
49 // Fake an rrule so that the iteration goes through at least once
50 const DEFAULT_RRULE = {
51     value: {
52         freq: 'DAILY',
53         count: 1,
54     },
57 interface FillOccurrencesBetween {
58     interval: number[];
59     iterator: any;
60     eventDuration: number;
61     originalDtstart: VcalDateOrDateTimeProperty;
62     originalDtend: VcalDateOrDateTimeProperty;
63     isAllDay: boolean;
64     exdateMap: { [key: number]: boolean };
66 const fillOccurrencesBetween = ({
67     interval: [start, end],
68     iterator,
69     eventDuration,
70     originalDtstart,
71     originalDtend,
72     isAllDay,
73     exdateMap,
74 }: FillOccurrencesBetween) => {
75     const result = [];
76     let next;
78     const startTzid = getPropertyTzid(originalDtstart);
79     const endTzid = getPropertyTzid(originalDtend);
81     // eslint-disable-next-line no-cond-assign
82     while ((next = iterator.next())) {
83         const localStart = toUTCDate(getInternalDateTimeValue(next));
84         if (exdateMap[+localStart]) {
85             continue;
86         }
88         let localEnd;
89         let utcStart;
90         let utcEnd;
92         if (isAllDay) {
93             localEnd = addDays(localStart, eventDuration);
94             utcStart = localStart;
95             utcEnd = localEnd;
96         } else if (!startTzid || !endTzid) {
97             const endInStartTimezone = addMilliseconds(localStart, eventDuration);
98             localEnd = endInStartTimezone;
99             utcStart = localStart;
100             utcEnd = endInStartTimezone;
101         } else {
102             const endInStartTimezone = addMilliseconds(localStart, eventDuration);
104             const endInUTC = convertZonedDateTimeToUTC(fromUTCDate(endInStartTimezone), startTzid);
105             localEnd = toUTCDate(convertUTCDateTimeToZone(endInUTC, endTzid));
107             utcStart = toUTCDate(convertZonedDateTimeToUTC(fromUTCDate(localStart), startTzid));
108             utcEnd = toUTCDate(endInUTC);
109         }
111         if (+utcStart > end) {
112             break;
113         }
115         if (isInInterval(+utcStart, +utcEnd, start, end)) {
116             result.push({
117                 localStart,
118                 localEnd,
119                 utcStart,
120                 utcEnd,
121                 occurrenceNumber: iterator.occurrence_number as number,
122             });
123         }
124     }
125     return result;
129  * Convert the until property of an rrule to be in the timezone of the start date
130  */
131 const getModifiedUntilRrule = (internalRrule: VcalRruleProperty, startTzid: string | undefined): VcalRruleProperty => {
132     if (!internalRrule || !internalRrule.value || !internalRrule.value.until || !startTzid) {
133         return internalRrule;
134     }
135     const utcUntil = toUTCDate(internalRrule.value.until);
136     const localUntil = convertUTCDateTimeToZone(fromUTCDate(utcUntil), startTzid);
137     return {
138         ...internalRrule,
139         value: {
140             ...internalRrule.value,
141             until: {
142                 ...localUntil,
143                 isUTC: true,
144             },
145         },
146     };
149 const getOccurrenceSetup = (component: RequiredVcalVeventComponent) => {
150     const { dtstart: internalDtstart, rrule: internalRrule, exdate: internalExdate } = component;
151     const internalDtEnd = getDtendProperty(component);
153     const isAllDay = getIsAllDay(component);
154     const dtstartType = isAllDay ? 'date' : 'date-time';
156     // Pretend the (local) date is in UTC time to keep the absolute times.
157     const dtstart = internalValueToIcalValue(dtstartType, {
158         ...internalDtstart.value,
159         isUTC: true,
160     }) as VcalDateOrDateTimeValue;
161     // Since the local date is pretended in UTC time, the until has to be converted into a fake local UTC time too
162     const safeRrule = getModifiedUntilRrule(internalRrule || DEFAULT_RRULE, getPropertyTzid(internalDtstart));
164     const utcStart = propertyToUTCDate(internalDtstart);
165     let eventDuration: number;
167     if (isAllDay) {
168         const rawEnd = propertyToUTCDate(internalDtEnd);
169         // Non-inclusive end...
170         const modifiedEnd = addDays(rawEnd, -1);
171         const utcEnd = max(utcStart, modifiedEnd);
173         eventDuration = differenceInCalendarDays(utcEnd, utcStart);
174     } else {
175         const utcStart = propertyToUTCDate(internalDtstart);
176         const utcEnd = propertyToUTCDate(internalDtEnd);
178         eventDuration = Math.max(+utcEnd - +utcStart, 0);
179     }
181     return {
182         dtstart,
183         utcStart,
184         isAllDay,
185         eventDuration,
186         modifiedRrule: safeRrule,
187         exdateMap: createExdateMap(internalExdate),
188     };
191 interface GetOccurrences {
192     component: RequiredVcalVeventComponent;
193     maxStart?: Date;
194     maxCount?: number;
195     cache?: Partial<OccurrenceIterationCache>;
197 export const getOccurrences = ({
198     component,
199     maxStart = new Date(9999, 0, 1),
200     maxCount = 1,
201     cache = {},
202 }: GetOccurrences): Pick<RecurringResult, 'localStart' | 'localEnd' | 'occurrenceNumber'>[] => {
203     // ICAL.js ignores COUNT=0, so we have to deal with it by hand
204     if (maxCount <= 0 || component?.rrule?.value.count === 0) {
205         return [];
206     }
208     if (!cache.start) {
209         cache.start = getOccurrenceSetup(component);
210     }
212     const { eventDuration, isAllDay, dtstart, modifiedRrule, exdateMap } = cache.start;
214     let iterator;
215     try {
216         const rrule = internalValueToIcalValue('recur', modifiedRrule.value);
217         iterator = rrule.iterator(dtstart);
218     } catch (e: any) {
219         console.error(e);
220         // Pretend it was ok
221         return [];
222     }
223     const result = [];
225     let next;
226     // eslint-disable-next-line no-cond-assign
227     while ((next = iterator.next())) {
228         const localStart = toUTCDate(getInternalDateTimeValue(next));
229         if (exdateMap[+localStart]) {
230             continue;
231         }
232         if (result.length >= maxCount || localStart >= maxStart) {
233             break;
234         }
235         const localEnd = isAllDay ? addDays(localStart, eventDuration) : addMilliseconds(localStart, eventDuration);
236         result.push({
237             localStart,
238             localEnd,
239             occurrenceNumber: iterator.occurrence_number,
240         });
241     }
242     return result;
245 export const getOccurrencesBetween = (
246     component: Pick<VcalVeventComponent, 'dtstart' | 'rrule' | 'exdate'>,
247     start: number,
248     end: number,
249     cache: Partial<OccurrenceIterationCache> = {}
250 ): RecurringResult[] => {
251     // ICAL.js ignores COUNT=0, so we have to deal with it by hand
252     if (component?.rrule?.value.count === 0) {
253         return [];
254     }
256     if (!cache.start) {
257         cache.start = getOccurrenceSetup(component);
258     }
260     const originalDtstart = component.dtstart;
261     const originalDtend = getDtendProperty(component);
263     const { eventDuration, isAllDay, utcStart, dtstart, modifiedRrule, exdateMap } = cache.start;
265     // If it starts after the current end, ignore it
266     if (+utcStart > end) {
267         return [];
268     }
270     if (!cache.iteration || start < cache.iteration.interval[0] || end > cache.iteration.interval[1]) {
271         try {
272             const rrule = internalValueToIcalValue('recur', modifiedRrule.value);
273             const iterator = rrule.iterator(dtstart);
275             const interval = [start - YEAR_IN_MS, end + YEAR_IN_MS];
276             const result = fillOccurrencesBetween({
277                 interval,
278                 iterator,
279                 eventDuration,
280                 originalDtstart,
281                 originalDtend,
282                 isAllDay,
283                 exdateMap,
284             });
286             cache.iteration = {
287                 iterator,
288                 result,
289                 interval,
290             };
291         } catch (e: any) {
292             console.error(e);
293             // Pretend it was ok
294             return [];
295         }
296     }
298     return cache.iteration.result.filter(({ utcStart, utcEnd }) => isInInterval(+utcStart, +utcEnd, start, end));