From d0eb62a6cf6c4e86b68a25eb84a8b8c573f9c5aa Mon Sep 17 00:00:00 2001 From: LeandraH Date: Wed, 28 Aug 2024 17:15:58 +0200 Subject: [PATCH 1/6] 2901: Rewrite the rrule in local time to keep all params --- shared/api/models/DateModel.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/shared/api/models/DateModel.ts b/shared/api/models/DateModel.ts index 49bdecd6d5..28648b5f6a 100644 --- a/shared/api/models/DateModel.ts +++ b/shared/api/models/DateModel.ts @@ -1,5 +1,5 @@ import { DateTime, Duration } from 'luxon' -import { RRule as RRuleType } from 'rrule' +import { RRule as RRuleType, rrulestr } from 'rrule' const MAX_RECURRENCE_YEARS = 5 @@ -137,14 +137,12 @@ class DateModel { private getRecurrenceRuleInLocalTime(recurrenceRule: RRuleType): RRuleType { const startDate = recurrenceRule.options.dtstart - const offsetStartDate = DateTime.fromJSDate(startDate).minus({ minutes: startDate.getTimezoneOffset() }).toJSDate() - return new RRuleType({ - freq: recurrenceRule.options.freq, - byweekday: recurrenceRule.options.byweekday, - interval: recurrenceRule.options.interval, - until: recurrenceRule.options.until, - dtstart: offsetStartDate, - }) + const offsetStartDate = DateTime.fromJSDate(startDate) + .minus({ minutes: startDate.getTimezoneOffset() }) + .toUTC() + .toFormat("yyyyMMdd'T'HHmmss") + const regexForFindingDate = /\d{8}T\d{6}/ + return rrulestr(recurrenceRule.toString().replace(regexForFindingDate, offsetStartDate)) } isEqual(other: DateModel): boolean { From 5ecd6e1398d40697d58fb997b1b95d78152abafb Mon Sep 17 00:00:00 2001 From: LeandraH Date: Wed, 28 Aug 2024 17:16:17 +0200 Subject: [PATCH 2/6] 2901: Add more tests --- shared/api/models/__tests__/DateModel.spec.ts | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/shared/api/models/__tests__/DateModel.spec.ts b/shared/api/models/__tests__/DateModel.spec.ts index 75ac11db91..85b537c5a1 100644 --- a/shared/api/models/__tests__/DateModel.spec.ts +++ b/shared/api/models/__tests__/DateModel.spec.ts @@ -400,5 +400,174 @@ describe('DateModel', () => { }), ]) }) + + it('should correctly handle events recurring every second week of the month', () => { + jest.useFakeTimers({ now: new Date('2024-07-28T15:23:57.443+02:00') }) + const recurrenceRule = rrulestr('DTSTART:20240620T083000\nRRULE:FREQ=MONTHLY;BYDAY=+2MO') + const date = new DateModel({ + startDate: DateTime.fromISO('2024-06-20T10:30:00.000+02:00'), + endDate: DateTime.fromISO('2024-06-20T12:00:00.000+02:00'), + allDay: false, + recurrenceRule, + }) + + expect(date.recurrences(3)).toEqual([ + new DateModel({ + allDay: false, + recurrenceRule, + startDate: DateTime.fromISO('2024-08-12T10:30:00.000+02:00'), + endDate: DateTime.fromISO('2024-08-12T12:00:00.000+02:00'), + offset: 120, + }), + new DateModel({ + allDay: false, + recurrenceRule, + startDate: DateTime.fromISO('2024-09-09T10:30:00.000+02:00'), + endDate: DateTime.fromISO('2024-09-09T12:00:00.000+02:00'), + offset: 120, + }), + new DateModel({ + allDay: false, + recurrenceRule, + startDate: DateTime.fromISO('2024-10-14T10:30:00.000+02:00'), + endDate: DateTime.fromISO('2024-10-14T12:00:00.000+02:00'), + offset: 120, + }), + ]) + }) + + it('should correctly handle events recurring every last week of the month', () => { + jest.useFakeTimers({ now: new Date('2024-08-28T15:23:57.443+02:00') }) + const recurrenceRule = rrulestr('DTSTART:20240827T220000\nRRULE:FREQ=MONTHLY;BYDAY=-1WE') + const date = new DateModel({ + startDate: DateTime.fromISO('2024-08-28T00:00:00.000+02:00'), + endDate: DateTime.fromISO('2024-08-28T23:59:00.000+02:00'), + allDay: true, + recurrenceRule, + }) + + expect(date.recurrences(3)).toEqual([ + new DateModel({ + allDay: true, + recurrenceRule, + startDate: DateTime.fromISO('2024-08-28T00:00:00.000+02:00'), + endDate: DateTime.fromISO('2024-08-28T23:59:00.000+02:00'), + offset: 120, + }), + new DateModel({ + allDay: true, + recurrenceRule, + startDate: DateTime.fromISO('2024-09-25T00:00:00.000+02:00'), + endDate: DateTime.fromISO('2024-09-25T23:59:00.000+02:00'), + offset: 120, + }), + new DateModel({ + allDay: true, + recurrenceRule, + startDate: DateTime.fromISO('2024-10-30T00:00:00.000+01:00'), + endDate: DateTime.fromISO('2024-10-30T23:59:00.000+01:00'), + offset: 120, + }), + ]) + }) + + it('should correctly handle events recurring every third week of every second month', () => { + jest.useFakeTimers({ now: new Date('2024-08-28T15:23:57.443+02:00') }) + const recurrenceRule = rrulestr('DTSTART:20240620T083000\nRRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=+3TH') + const date = new DateModel({ + startDate: DateTime.fromISO('2024-06-20T10:30:00.000+02:00'), + endDate: DateTime.fromISO('2024-06-20T12:00:00.000+02:00'), + allDay: false, + recurrenceRule, + }) + + expect(date.recurrences(3)).toEqual([ + new DateModel({ + allDay: false, + recurrenceRule, + startDate: DateTime.fromISO('2024-10-17T10:30:00.000+02:00'), + endDate: DateTime.fromISO('2024-10-17T12:00:00.000+02:00'), + offset: 120, + }), + new DateModel({ + allDay: false, + recurrenceRule, + startDate: DateTime.fromISO('2024-12-19T10:30:00.000+01:00'), + endDate: DateTime.fromISO('2024-12-19T12:00:00.000+01:00'), + offset: 120, + }), + new DateModel({ + allDay: false, + recurrenceRule, + startDate: DateTime.fromISO('2025-02-20T10:30:00.000+01:00'), + endDate: DateTime.fromISO('2025-02-20T12:00:00.000+01:00'), + offset: 120, + }), + ]) + }) + + it('should correctly handle events repeating annually', () => { + jest.useFakeTimers({ now: new Date('2024-08-28T15:23:57.443+02:00') }) + const recurrenceRule = rrulestr('DTSTART:20241205T230000\nRRULE:FREQ=YEARLY') + const date = new DateModel({ + startDate: DateTime.fromISO('2024-12-06T00:00:00.000+01:00'), + endDate: DateTime.fromISO('2024-12-06T23:59:00.000+01:00'), + allDay: true, + recurrenceRule, + }) + + expect(date.recurrences(3)).toEqual([ + new DateModel({ + allDay: true, + startDate: DateTime.fromISO('2024-12-06T00:00:00.000+01:00'), + endDate: DateTime.fromISO('2024-12-06T23:59:00.000+01:00'), + recurrenceRule, + }), + new DateModel({ + allDay: true, + startDate: DateTime.fromISO('2025-12-06T00:00:00.000+01:00'), + endDate: DateTime.fromISO('2025-12-06T23:59:00.000+01:00'), + recurrenceRule, + }), + new DateModel({ + allDay: true, + startDate: DateTime.fromISO('2026-12-06T00:00:00.000+01:00'), + endDate: DateTime.fromISO('2026-12-06T23:59:00.000+01:00'), + recurrenceRule, + }), + ]) + }) + + it('should correctly handle events repeating every 2 years', () => { + jest.useFakeTimers({ now: new Date('2024-08-28T15:23:57.443+02:00') }) + const recurrenceRule = rrulestr('DTSTART:20241205T230000\nRRULE:FREQ=YEARLY;INTERVAL=2') + const date = new DateModel({ + startDate: DateTime.fromISO('2024-12-06T00:00:00.000+01:00'), + endDate: DateTime.fromISO('2024-12-06T23:59:00.000+01:00'), + allDay: true, + recurrenceRule, + }) + + expect(date.recurrences(3)).toEqual([ + new DateModel({ + allDay: true, + startDate: DateTime.fromISO('2024-12-06T00:00:00.000+01:00'), + endDate: DateTime.fromISO('2024-12-06T23:59:00.000+01:00'), + recurrenceRule, + }), + new DateModel({ + allDay: true, + startDate: DateTime.fromISO('2026-12-06T00:00:00.000+01:00'), + endDate: DateTime.fromISO('2026-12-06T23:59:00.000+01:00'), + recurrenceRule, + }), + new DateModel({ + allDay: true, + startDate: DateTime.fromISO('2028-12-06T00:00:00.000+01:00'), + endDate: DateTime.fromISO('2028-12-06T23:59:00.000+01:00'), + recurrenceRule, + }), + ]) + }) }) }) From f750f8ccb6766884739eac79f352070dbc030f9e Mon Sep 17 00:00:00 2001 From: LeandraH Date: Fri, 30 Aug 2024 17:15:05 +0200 Subject: [PATCH 3/6] 2901: Add an explaining comment --- shared/api/models/DateModel.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shared/api/models/DateModel.ts b/shared/api/models/DateModel.ts index 28648b5f6a..d4ca75b9f9 100644 --- a/shared/api/models/DateModel.ts +++ b/shared/api/models/DateModel.ts @@ -142,6 +142,8 @@ class DateModel { .toUTC() .toFormat("yyyyMMdd'T'HHmmss") const regexForFindingDate = /\d{8}T\d{6}/ + // Don't parse by the recurrenceRule options here, rrule doesn't properly parse the params for every nth day of the month + // https://github.com/jkbrzt/rrule/issues/326 return rrulestr(recurrenceRule.toString().replace(regexForFindingDate, offsetStartDate)) } From db46d87b7e87c09bbdf2a07a3a36f235f1dc5b63 Mon Sep 17 00:00:00 2001 From: LeandraH Date: Mon, 2 Sep 2024 10:17:54 +0200 Subject: [PATCH 4/6] 2901: Use pre-existing ical function --- shared/api/models/DateModel.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/shared/api/models/DateModel.ts b/shared/api/models/DateModel.ts index d4ca75b9f9..94b804642f 100644 --- a/shared/api/models/DateModel.ts +++ b/shared/api/models/DateModel.ts @@ -1,6 +1,8 @@ import { DateTime, Duration } from 'luxon' import { RRule as RRuleType, rrulestr } from 'rrule' +import { formatDateICal } from '../../utils' + const MAX_RECURRENCE_YEARS = 5 export type DateIcon = 'CalendarTodayRecurringIcon' | 'CalendarRecurringIcon' | 'CalendarTodayIcon' @@ -137,10 +139,9 @@ class DateModel { private getRecurrenceRuleInLocalTime(recurrenceRule: RRuleType): RRuleType { const startDate = recurrenceRule.options.dtstart - const offsetStartDate = DateTime.fromJSDate(startDate) - .minus({ minutes: startDate.getTimezoneOffset() }) - .toUTC() - .toFormat("yyyyMMdd'T'HHmmss") + const offsetStartDate = formatDateICal( + DateTime.fromJSDate(startDate).minus({ minutes: startDate.getTimezoneOffset() }).toUTC(), + ) const regexForFindingDate = /\d{8}T\d{6}/ // Don't parse by the recurrenceRule options here, rrule doesn't properly parse the params for every nth day of the month // https://github.com/jkbrzt/rrule/issues/326 From 759fead07739385fbccdc731e225df9f17e284ab Mon Sep 17 00:00:00 2001 From: LeandraH Date: Mon, 2 Sep 2024 15:23:12 +0200 Subject: [PATCH 5/6] 2901: Also fix the recurring date export --- shared/api/models/EventModel.ts | 3 +-- shared/api/models/__tests__/EventModel.spec.ts | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/shared/api/models/EventModel.ts b/shared/api/models/EventModel.ts index 0cd9ec2f50..4584c20bcb 100644 --- a/shared/api/models/EventModel.ts +++ b/shared/api/models/EventModel.ts @@ -72,8 +72,7 @@ class EventModel extends ExtendedPageModel { } if (recurring && date.recurrenceRule) { - const { freq, interval, until, byweekday } = date.recurrenceRule.options - const recurrence = RRule.optionsToString({ freq, interval, until, byweekday }) + const recurrence = date.recurrenceRule.toString() if (recurrence) { body.push(recurrence) } diff --git a/shared/api/models/__tests__/EventModel.spec.ts b/shared/api/models/__tests__/EventModel.spec.ts index 0535984015..bd63ad6d61 100644 --- a/shared/api/models/__tests__/EventModel.spec.ts +++ b/shared/api/models/__tests__/EventModel.spec.ts @@ -16,7 +16,7 @@ describe('EventModel', () => { startDate: DateTime.fromISO('2020-03-20T10:50:00+02:00'), endDate: DateTime.fromISO('2020-03-20T17:50:00+02:00'), allDay: false, - recurrenceRule: rrulestr('FREQ=WEEKLY;INTERVAL=3;UNTIL=20200703T235959Z;BYDAY=FR'), + recurrenceRule: rrulestr('FREQ=WEEKLY;INTERVAL=3;UNTIL=20200703T235959Z;BYDAY=-1FR'), }), location: new LocationModel({ id: 1, @@ -69,9 +69,9 @@ describe('EventModel', () => { expect(endDate).toBe(`DTEND;TZID=${timezone}:20200320T165000`) }) - it('should have a recurrence rule in iCal', () => { + it('should have the correct recurrence rule in iCal', () => { const recurrenceField = getICalField(event, 'RRULE', true) - expect(recurrenceField).toBe('RRULE:FREQ=WEEKLY;INTERVAL=3;UNTIL=20200703T235959Z;BYDAY=FR') + expect(recurrenceField).toBe('RRULE:FREQ=WEEKLY;INTERVAL=3;UNTIL=20200703T235959Z;BYDAY=-1FR') }) it('should correctly strip carriage returns and escape new lines in ical description', () => { From 7cb3a3e79ac61ae0089342ee1f404834e8ea08bb Mon Sep 17 00:00:00 2001 From: LeandraH Date: Mon, 2 Sep 2024 15:35:40 +0200 Subject: [PATCH 6/6] 2901: Fix lint --- shared/api/models/EventModel.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/shared/api/models/EventModel.ts b/shared/api/models/EventModel.ts index 4584c20bcb..0ac607a2e4 100644 --- a/shared/api/models/EventModel.ts +++ b/shared/api/models/EventModel.ts @@ -1,6 +1,5 @@ import { decodeHTML } from 'entities' import { DateTime } from 'luxon' -import { RRule } from 'rrule' import { v5 } from 'uuid' import { formatDateICal } from '../../utils'