icalendar / icalendar

icalendar.rb main repository

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Timezone dtstart has offset applied twice

Mange opened this issue · comments

When building a timezone the Daylight dtstart field is set to the local time of the transition, but when parsed back then the time is assumed to be in UTC and so the offset is applied again.

Test case:

require "tzinfo"
require "icalendar"
require "icalendar/tzinfo"

timezone = TZInfo::Timezone.get("Europe/Stockholm")
period = timezone.current_period
dst = period.dst? ? period.start_transition : period.end_transition
dst_at = dst.local_start_at.to_time

puts "#{timezone}, DST is: #{dst.previous_offset.abbreviation} -> #{dst.offset.abbreviation}\n  at #{dst_at.xmlschema} (#{dst_at.utc.xmlschema})"
puts "(Should be transitioning on last Sunday of March, at 01:00 UTC, as defined by the EU)"

itz = timezone.ical_timezone(Time.now)
daylight = itz.daylights.first
puts "\n\nIn icalendar it gets serialized as:"
puts daylight.to_ical

puts "\n\nHowever, when reading dtstart it is converted to a Time using #to_time, which always assume UTC:"
puts "daylight.dtstart => #{daylight.dtstart.xmlschema}"

puts "Diff: #{(daylight.dtstart.to_i - dst_at.to_i).abs / 60.0 / 60.0} hours"

puts "\n\n\nThe dtstart can be parsed into the timezone using Date._parse and tzinfo's #local_time:"
parts = Date._parse(daylight.dtstart.value_ical)
correct_time = timezone.local_time(*parts.values_at(:year, :mon, :mday, :hour, :min, :sec))
puts "#{correct_time.xmlschema} -> #{correct_time.utc.xmlschema}"
Europe - Stockholm, DST is: CET -> CEST
  at 2021-03-28T03:00:00+02:00 (2021-03-28T01:00:00Z)
(Should be transitioning on last Sunday of March, at 01:00 UTC, as defined by the EU)


In icalendar it gets serialized as:
BEGIN:DAYLIGHT
DTSTART:20210328T030000
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
TZNAME:CEST
END:DAYLIGHT


However, when reading dtstart it is converted to a Time using #to_time, which always assume UTC:
daylight.dtstart => 2021-03-28T03:00:00+00:00
Diff: 2.0 hours



The dtstart can be parsed into the timezone using Date._parse and tzinfo's #local_time:
2021-03-28T03:00:00+02:00 -> 2021-03-28T01:00:00Z

Since this points to a fundamental problem in how datetimes are parsed (to_time is plagued by issues and should pretty much never be used in Ruby), I suspect this could cause other issues throughout the codebase.

To avoid confusion I would suggest always emitting timezone dtstart in UTC instead as UTC isn't affected by the very thing you are defining. If you use the timezone that is being defined to define it, I think it's likely that some parsers will choke and/or incorrectly read the times.

I am by no means an ical expect (I just read the Ruby code here with no real understanding of ical or the ecosystem around it), so I might be missing the reason for using local time here.

Internally, in a lot of cases, we treat UTC times as being "local time in an unknown timezone". This is definitely true for the internal workings of VTIMEZONE components, which lets us more easily compare them to other times in the same timezone where we don't yet know what the offset should be. It also helps us output the ICS format properly, which in this case is the transition time in the local timezone, not the UTC time.

I'm open to ideas for making this work more obviously, if you have them.

The bug here is just that String#to_time does not parse 20210328T030000 correctly. It always assumes localtime, which is wrong in most cases.

For example: A server in Amsterdam timezone (+01:00), parsing a timezone definition for Finland (+02:00) in DST (+03:00). Then the date, that should be in +03:00 gets parsed as +01:00, which leads to the wrong point in time.

But if we parse it using Date._parse, and then construct the Time ourselves and provide the offset that is intended, you get the correct result.

"20210328T030000".to_time # => 2021-03-28 03:00:00 +0200
Date._parse("20210328T030000") # => {:year=>2021, :mon=>3, :mday=>28, :hour=>3, :min=>0, :sec=>0}

The best way to think about timezones for me is to consider the UTC time just like a unix timestamp; it's just the number of seconds since some epoch. Then it's easy to understand how it works and how you are supposed to use it.

But when parsing dates into UTC, one must consider how the date is formatted. If it's already in UTC, that is fine. But if it is in a timezone then the date must be shifted back into UTC again, or else you get the wrong "second since epoch". Your strategy works and I don't see a problem with it, but then we must also make sure we read non-UTC timestamps correctly.
As it is right now, the dates are not parsed correctly so we get the wrong UTC timestamp internally to compare with.

I found this issue since I added an event between DST boundaries to make sure it was parsed correctly, but it was not. I found out that the timezone definition inside of the ICS file was parsed incorrectly to start at 04:00, after the event has passed.

I found this issue since I added an event between DST boundaries to make sure it was parsed correctly, but it was not

Could you share the exact code or ics file that demonstrates the issue?

I think I managed to encode it down to its bare essentials in OP. Is there some other format you'd prefer?

Yes, I would love a single VTIMEZONE, single VEVENT ICS file that when parsed has the event at the wrong time.

Alright. I generated a calendar with the Europe/Stockholm timezone and an event in that timezone that lies over the DST boundary. The event is 01:30:00 long, and it is correctly added and correctly parsed to be that long.

BEGIN:VCALENDAR
VERSION:2.0
PRODID:icalendar-ruby
CALSCALE:GREGORIAN
BEGIN:VTIMEZONE
TZID:Europe/Stockholm
BEGIN:DAYLIGHT
DTSTART:20210328T030000
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
TZNAME:CEST
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20201025T020000
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:CET
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTAMP:20210319T201614Z
UID:0a6b8294-cbc1-4fd3-b5e9-3aacd874b3a7
DTSTART;TZID=Europe/Stockholm:20210328T000000
DTEND;TZID=Europe/Stockholm:20210328T033000
END:VEVENT
END:VCALENDAR

…but the timezone definition is not correct:

BEGIN:DAYLIGHT
DTSTART:20210328T030000
TZOFFSETFROM:+0100

From the RFC:

The mandatory "DTSTART" property gives the effective onset date and local time for the time zone sub-component definition. "DTSTART" in this usage MUST be specified as a date with a local time value.

The mandatory "TZOFFSETFROM" property gives the UTC offset that is in use when the onset of this time zone observance begins. "TZOFFSETFROM" is combined with "DTSTART" to define the effective onset for the time zone sub-component definition.

So, the date as given (T030000) should be in offset +01:00 from UTC. DST changes in 01:00 UTC, 02:00 local time, but here it says 03:00 local time, meaning it is regarded as 02:00 UTC, which is an hour off.

If I try to parse this using Icalendar and look at the timezone it is parsed incorrectly on top of it:

cal = Icalendar.parse(data).first
tz = cal.find_timezone("Europe/Stockholm")
tz.daylights.first.dtstart # => Sun, 28 Mar 2021 03:00:00 +0000

Here it is in 03:00 UTC, now two hours off from 01:00 UTC. When I then try to use this object to calculate offsets it crashes:

t # => Sun, 28 Mar 2021 03:30:00.000000000 CEST +02:00
tz.offset_for_local(t) # => ArgumentError: comparison of IceCube::Occurrence with nil failed

This happens because it cannot find a valid period, but if I jump ahead a year and try it just get the wrong answer:

# Next year, 1 hour and 30 minutes after DST start
t2 # => Sun, 27 Mar 2022 03:30:00.000000000 CEST +02:00
tz.offset_for_local(t2) # => #<OpenStruct behind=false, hours=1, minutes=0, seconds=0>

Here it returns offset 01:00, which is not DST. Compare with the input date.


I generated the timezone like this:

timezone = TZInfo::Timezone.get("Europe/Stockholm")
cal.timezone(timezone.ical_timezone(Time.now))