Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DST ignored on creation of RRule date using between() #260

Open
Skjall opened this issue Mar 26, 2023 · 8 comments
Open

DST ignored on creation of RRule date using between() #260

Skjall opened this issue Mar 26, 2023 · 8 comments

Comments

@Skjall
Copy link

Skjall commented Mar 26, 2023

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
}
@satadhi4alumnux
Copy link

satadhi4alumnux commented Apr 4, 2023

Hello,

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

@sdetweil
Copy link
Contributor

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.

@hietkamp
Copy link

hietkamp commented Jan 3, 2024

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

@Skjall
Copy link
Author

Skjall commented Jan 3, 2024

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

@hietkamp
Copy link

hietkamp commented Jan 3, 2024

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'
},

@Skjall
Copy link
Author

Skjall commented Jan 3, 2024

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

@sdetweil
Copy link
Contributor

sdetweil commented Jan 3, 2024

node-ical does not process the vtimezone records

@sdetweil
Copy link
Contributor

i see it parses the vtimezone, BUT it does not UTILIZE the info. node-ical is iana tz only.

RRULE is LOCAL time ONLY. you WILL get trash if you pass in tz based start/end to rrule.between()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants