node-ical icon indicating copy to clipboard operation
node-ical copied to clipboard

DST ignored on creation of RRule date using between()

Open Skjall opened this issue 1 year ago • 7 comments

I noticed that node-ical is not correctly handling timezones in recurring events. When using

Assume an event is created in the calendar in the "winter" time:

  "origOptions": {
    "tzid": "Europe/Berlin",
    "dtstart": "2023-03-02T07:00:00.000Z"
  }

I used the between method to get the dates based on the rule from now to an end date.

            // If the event has a recurrence rule, it uses the 'between' function of the RRULE to get the start dates between the current time (now) and the event's end time (end).
            startDates = event.rrule
              .between(now, end, true)
              .map((isoString) => new Date(isoString))

the (stringified) result was

startDates = ["2023-03-24T07:00:00.000Z","2023-03-27T07:00:00.000Z","2023-03-28T07:00:00.000Z","2023-03-29T07:00:00.000Z"]

As you can see, the UTC date on the 24th was good as it was at 08:00 Local time. However the dates for the 27th is not good as it moves the local event to 07:00. The anchor point should be the timezone of the original creation.

As a workaround, I wrote a function to correct the dates.

            // If the event has a recurrence rule, it uses the 'between' function of the RRULE to get the start dates between the current time (now) and the event's end time (end).
            startDates = correctStartDates(
              event.rrule,
              event.rrule
                .between(now, end, true) // The third argument 'true' specifies that the date should be included if it is equal to 'now'.
                .map((isoString) => new Date(isoString)),
            )
function correctStartDates(rrule, startDates) {
  /**
   * Corrects an array of start dates for a recurring event based on the difference between the original timezone
   * and the current timezone. Each date in the array is corrected by the offset difference to ensure that the event
   * occurs at the correct time in the user's local timezone.
   * @param {Object} rrule - The rrule object containing information about the recurring event.
   * @param {Array} startDates - An array of start dates for the recurring event.
   * @returns {Array} - An array of corrected start dates for the recurring event.
   */

  // Get the timezone identifier from the rrule object
  const tzid = rrule.origOptions.tzid

  // Get the original start date and offset from the rrule object
  const originalDate = new Date(rrule.origOptions.dtstart)
  const originalTzDate = luxon.DateTime.fromJSDate(originalDate, { zone: tzid })
  const originalOffset = originalTzDate.offset

  // Create an array to store the corrected start dates
  const correctedStartDates = []

  // Loop through each date in the array
  for (const currentDate of currentDates) {
    const currentTzDate = luxon.DateTime.fromJSDate(currentDate, { zone: tzid })
    const currentOffset = currentTzDate.offset

    // Calculate the difference between the current offset and the original offset
    const offsetDiff = currentOffset - originalOffset

    // Adjust the start date by the offset difference to get the corrected start date
    currentDate.setHours(currentDate.getHours() + offsetDiff / 60)

    // Add the corrected start date to the array
    correctedStartDates.push(currentDate)
  }

  // Return the array of corrected start dates
  return correctedStartDates
}

Skjall avatar Mar 26 '23 12:03 Skjall

Hello,

Can you please tell how I can incorporate this code in this example ?

satadhi4alumnux avatar Apr 04 '23 10:04 satadhi4alumnux

node-ical is not correctly handling timezones

the problem is not node-ical. it is rrule. node-ical uses tz, rrule does not. https://github.com/jakubroztocil/rrule#important-use-utc-dates

THE BOTTOM LINE: Returned "UTC" dates are always meant to be interpreted as dates in your local timezone. This may mean you have to do additional conversion to get the "correct" local time with offset applied.

sdetweil avatar Apr 19 '23 20:04 sdetweil

I don't agree that it's an issue with the RRULE. The calendar events are being utilized in GMT time rather than UTC time. Consequently, if an event is created during daylight saving time, it will be considered in the example underneath as GMT+02:00 rather than UTC+01:00.

Example: BEGIN:VCALENDAR PRODID:-//Google Inc//Google Calendar 70.9054//EN VERSION:2.0 CALSCALE:GREGORIAN METHOD:PUBLISH X-WR-CALNAME:Calendar with events X-WR-TIMEZONE:Europe/Amsterdam BEGIN:VTIMEZONE TZID:Europe/Amsterdam X-LIC-LOCATION:Europe/Amsterdam BEGIN:DAYLIGHT TZOFFSETFROM:+0100 TZOFFSETTO:+0200 TZNAME:CEST DTSTART:19700329T020000 RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU END:DAYLIGHT BEGIN:STANDARD TZOFFSETFROM:+0200 TZOFFSETTO:+0100 TZNAME:CET DTSTART:19701025T030000 RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU END:STANDARD END:VTIMEZONE BEGIN:VTIMEZONE TZID:Europe/Brussels X-LIC-LOCATION:Europe/Brussels BEGIN:DAYLIGHT TZOFFSETFROM:+0100 TZOFFSETTO:+0200 TZNAME:CEST DTSTART:19700329T020000 RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU END:DAYLIGHT BEGIN:STANDARD TZOFFSETFROM:+0200 TZOFFSETTO:+0100 TZNAME:CET DTSTART:19701025T030000 RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU END:STANDARD END:VTIMEZONE BEGIN:VEVENT DTSTART;TZID=Europe/Brussels:20220915T123000 DTEND;TZID=Europe/Brussels:20220915T133000 RRULE:FREQ=WEEKLY;BYDAY=TH DTSTAMP:20240102T194105Z UID:6676f61a-cc1a-46a5-bd11-f8bb7e1b4f6c CREATED:20220913T164045Z DESCRIPTION:Some description LAST-MODIFIED:20231222T114543Z LOCATION:Somewhere SEQUENCE:2 STATUS:CONFIRMED SUMMARY:Title of the event TRANSP:OPAQUE END:VEVENT END:VCALENDAR

hietkamp avatar Jan 03 '24 06:01 hietkamp

This is an event from my iCloud Calender. I Removed the X-APPLE stuff and anonymized it. The Event was planned (Created) on 20230616T112420Z --> 16.06.2023 11:24:20 UTC (so DST apply in Germany) and the events are taking place at 08:00:00 UTC+1 or UTC+2 (DST) on every Tuesday, Wednesday and Friday.

BEGIN:VEVENT
CREATED:20230616T112420Z
DTEND;TZID=Europe/Berlin:20230704T081500
DTSTAMP:20240102T000556Z
DTSTART;TZID=Europe/Berlin:20230704T080000
EXDATE;TZID=Europe/Berlin:20231206T080000
EXDATE;TZID=Europe/Berlin:20231213T080000
EXDATE;TZID=Europe/Berlin:20231229T080000
LAST-MODIFIED:20240102T000556Z
LOCATION:Andere von Name\nStreet\nZIP CITY\nDeutschland
RRULE:FREQ=WEEKLY;BYDAY=TU,WE,FR
SEQUENCE:0
SUMMARY:Kita
UID:F12DD26C-4019-410D-B74A-B46217040E02
URL;VALUE=URI:
END:VEVENT

This is the object await ical.async.fromURL(calendarUrlHttp) creates:

{
  "type": "VEVENT",
  "params": [],
  "created": "2023-06-16T11:24:20.000Z",
  "end": "2023-07-04T06:15:00.000Z",
  "dtstamp": "2024-01-02T00:05:56.000Z",
  "start": "2023-07-04T06:00:00.000Z",
  "datetype": "date-time",
  "exdate": [
    "2023-12-06T07:00:00.000Z",
    "2023-12-13T07:00:00.000Z",
    "2023-12-29T07:00:00.000Z"
  ],
  "lastmodified": "2024-01-02T00:05:56.000Z",
  "location": "NAME\nSTREET\nZIP TOWN\nDeutschland",
  "rrule": {
    "_cache": {
      "all": false,
      "before": [],
      "after": [],
      "between": []
    },
    "origOptions": {
      "tzid": "Europe/Berlin",
      "dtstart": "2023-07-04T06:00:00.000Z",
      "freq": 2,
      "byweekday": [
        {
          "weekday": 1
        },
        {
          "weekday": 2
        },
        {
          "weekday": 4
        }
      ]
    },
    "options": {
      "freq": 2,
      "dtstart": "2023-07-04T06:00:00.000Z",
      "interval": 1,
      "wkst": 0,
      "count": null,
      "until": null,
      "tzid": "Europe/Berlin",
      "bysetpos": null,
      "bymonth": null,
      "bymonthday": [],
      "bynmonthday": [],
      "byyearday": null,
      "byweekno": null,
      "byweekday": [
        1,
        2,
        4
      ],
      "bynweekday": null,
      "byhour": [
        6
      ],
      "byminute": [
        0
      ],
      "bysecond": [
        0
      ],
      "byeaster": null
    }
  },
  "sequence": "0",
  "summary": "Kita",
  "uid": "F12DD26C-4019-410D-B74A-B46217040E02",
  "url": {
    "params": {
      "VALUE": "URI"
    },
    "val": ""
  }
}

It gets the Date created correct: 20230616T112420Z -> 2023-06-16T11:24:20.000Z

And it gets the start of the event correct, as it normalizes it to UTC and remembers the original Timezone: Europe/Berlin:20230704T080000 -> "origOptions": {"tzid": "Europe/Berlin","dtstart": "2023-07-04T06:00:00.000Z"}

Now I run

// Get the current date and time
const now = new Date()

// Create a new date object for the end date
const end = new Date(now)

// Add 1 week to the end date
end.setDate(end.getDate() + 7)

// Calculate the dates of the RRULE
const startDates = event.rrule
  .between(now, end, true)
  .map((isoString) => new Date(isoString))

The Result is ["2024-01-05T06:00:00.000Z","2024-01-09T06:00:00.000Z","2024-01-10T06:00:00.000Z"]

And this is not correct. These dates would be correct for DST but not on the 5th of January. The correct Z dates would be ["2024-01-05T07:00:00.000Z","2024-01-09T07:00:00.000Z","2024-01-10T07:00:00.000Z"] to get the local time of 08:00.

I now fix this by

startDates = correctStartDates(event.rrule, startDatesToBeFixed) With this function: (The function above didn't wored correclty)

function correctStartDates(rrule, currentDates) {
  /**
   * Corrects an array of start dates for a recurring event based on the difference between the original timezone
   * and the current timezone. Each date in the array is corrected by the offset difference to ensure that the event
   * occurs at the correct time in the user's local timezone.
   * @param {Object} rrule - The rrule object containing information about the recurring event.
   * @param {Array} startDates - An array of start dates for the recurring event.
   * @returns {Array} - An array of corrected start dates for the recurring event.
   */

  generalLog.debug(
    __filename,
    'correctStartDates',
    'Current Dates: ' + currentDates,
  )

  // Get the timezone identifier from the rrule object
  const tzid = rrule.origOptions.tzid

  // Get the original start date and offset from the rrule object
  const originalDate = new Date(rrule.origOptions.dtstart)
  const originalTzDate = luxon.DateTime.fromJSDate(originalDate, { zone: tzid })
  const originalOffset = originalTzDate.offset

  // Create an array to store the corrected start dates
  const correctedStartDates = []

  // Loop through each date in the array
  for (const currentDate of currentDates) {
    generalLog.debug(
      __filename,
      'correctStartDates',
      'Current Date (before correction): ' + currentDate,
    )

    // Add the original offset back to the date. This is a bug in node-ical.
    currentDate.setHours(currentDate.getHours() + originalOffset / 60)

    // Add the corrected start date to the array
    correctedStartDates.push(currentDate)
  }

  // Return the array of corrected start dates
  return correctedStartDates
}

Now I have the correct date.

Soo... either I have a bug in the implementation or the module has one.

BR Jan

Skjall avatar Jan 03 '24 10:01 Skjall

Jan, your problem might be a bit different from mine, even though they both relate to daylight saving time. If you perform a parseFile using my example, you will obtain the underneath object. As you can observe, the time has shifted from 12:30 (Europe/Brussels) to 10:30 UTC, rather than 11:30 UTC. Probably caused by "TZOFFSETTO:+0200" in the VTIMEZONE part. My conclusion is that it's a bug in the module -- René

From the example: DTSTART;TZID=Europe/Brussels:20220915T123000 DTEND;TZID=Europe/Brussels:20220915T133000

Object created: '6676f61a-cc1a-46a5-bd11-f8bb7e1b4f6c': { type: 'VEVENT', params: [], start: 2022-09-15T10:30:00.000Z { tz: 'Europe/Brussels' }, datetype: 'date-time', end: 2022-09-15T11:30:00.000Z { tz: 'Europe/Brussels' }, rrule: RRule { _cache: [Cache], origOptions: [Object], options: [Object] }, dtstamp: 2024-01-02T19:41:05.000Z { tz: 'Etc/UTC' }, uid: '6676f61a-cc1a-46a5-bd11-f8bb7e1b4f6c', created: 2022-09-13T16:40:45.000Z { tz: 'Etc/UTC' }, description: 'Some description', lastmodified: 2023-12-22T11:45:43.000Z { tz: 'Etc/UTC' }, location: 'Somewhere', sequence: '2', status: 'CONFIRMED', summary: 'Title of the event', transparency: 'OPAQUE', method: 'PUBLISH' },

hietkamp avatar Jan 03 '24 11:01 hietkamp

Below is the VTIMEZONE from my calendar... What a P.I.T.A. file format...

What I noticed is that there are several entries for DAYLIGHT and STANDARD

It seems that Apple is storing the complete history of all definitions of all timezones used in the calendar.

MAYBE this causes an issue.

For Germany, from Wikipedia:

1916–1918

April 30, 1916, 23:00 CET (Central European Time) – October 1, 1916, 01:00 CEST (Central European Summer Time)
April 16, 1917, 02:00 CET – September 17, 1917, 03:00 CEST
April 15, 1918, 02:00 CET – September 16, 1918, 03:00 CEST

1940–1944

April 1, 1940, 02:00 CET – December 31, 1940, 24:00 CEST
January 1, 1941, 00:00 CEST – December 31, 1941, 24:00 CEST
January 1, 1942, 00:00 CEST – November 2, 1942, 03:00 CEST
March 29, 1943, 02:00 CET – October 4, 1943, 03:00 CEST
April 3, 1944, 02:00 CET – October 2, 1944, 03:00 CEST

1945–1949

1945 – Berlin and the Soviet-occupied zone

May 24, 1945, 02:00 CET – September 24, 1945, 03:00 CEMT (Central European Midsummer Time)
September 24, 1945, 03:00 CEMT – November 18, 1945, 03:00 CEST

1945 – Rest of Germany

April 2, 1945, 02:00 CET – September 16, 1945, 02:00 CEST

1946–1947 – Entire German territory

April 14, 1946, 02:00 CET – October 7, 1946, 03:00 CEST
April 6, 1947, 03:00 CET – May 11, 1947, 03:00 CEST
May 11, 1947, 03:00 CEST – June 29, 1947, 03:00 CEMT
June 29, 1947, 03:00 CEMT – October 5, 1947, 03:00 CEST

1948–1949 – Soviet-occupied zone

April 18, 1948, 03:00 CET – October 3, 1948, 03:00 CEST
April 10, 1949, 03:00 CET – October 2, 1949, 03:00 CEST

1948–1949 – Rest of Germany

April 18, 1948, 02:00 CET – October 3, 1948, 03:00 CEST
April 10, 1949, 02:00 CET – October 2, 1949, 03:00 CEST

Federal Republic and GDR with the exception of Büsingen am Hochrhein, since 1981 also Büsingen, since 1991 only Federal Republic: common European summer time.
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
BEGIN:STANDARD
DTSTART:18930401T000000
RDATE:18930401T000000
TZNAME:CEST
TZOFFSETFROM:+005328
TZOFFSETTO:+0100
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19160430T230000
RDATE:19160430T230000
RDATE:19400401T020000
RDATE:19430329T020000
RDATE:19460414T020000
RDATE:19470406T030000
RDATE:19480418T020000
RDATE:19490410T020000
RDATE:19800406T020000
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:19161001T010000
RDATE:19161001T010000
RDATE:19421102T030000
RDATE:19431004T030000
RDATE:19441002T030000
RDATE:19451118T030000
RDATE:19461007T030000
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19170416T020000
RRULE:FREQ=YEARLY;UNTIL=19180415T010000Z;BYMONTH=4;BYDAY=3MO
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:19170917T030000
RRULE:FREQ=YEARLY;UNTIL=19180916T010000Z;BYMONTH=9;BYDAY=3MO
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19440403T020000
RRULE:FREQ=YEARLY;UNTIL=19450402T010000Z;BYMONTH=4;BYDAY=1MO
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19450524T020000
RDATE:19450524T020000
RDATE:19470511T030000
TZNAME:CEMT
TZOFFSETFROM:+0200
TZOFFSETTO:+0300
END:DAYLIGHT
BEGIN:DAYLIGHT
DTSTART:19450924T030000
RDATE:19450924T030000
RDATE:19470629T030000
TZNAME:CEST
TZOFFSETFROM:+0300
TZOFFSETTO:+0200
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:19460101T000000
RDATE:19460101T000000
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0100
END:STANDARD
BEGIN:STANDARD
DTSTART:19471005T030000
RRULE:FREQ=YEARLY;UNTIL=19491002T010000Z;BYMONTH=10;BYDAY=1SU
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
END:STANDARD
BEGIN:STANDARD
DTSTART:19800101T000000
RDATE:19800101T000000
TZNAME:CET
TZOFFSETFROM:+0100
TZOFFSETTO:+0100
END:STANDARD
BEGIN:STANDARD
DTSTART:19800928T030000
RRULE:FREQ=YEARLY;UNTIL=19950924T010000Z;BYMONTH=9;BYDAY=-1SU
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19810329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:19961027T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
END:STANDARD
END:VTIMEZONE

Skjall avatar Jan 03 '24 12:01 Skjall

node-ical does not process the vtimezone records

sdetweil avatar Jan 03 '24 12:01 sdetweil