rrule icon indicating copy to clipboard operation
rrule copied to clipboard

Recurring hourly times DST mismatch

Open holmberd opened this issue 9 months ago • 3 comments

This is very much an edge-case. Rather than explaining I'll illustrate with two examples below.

Daily Frequence:

loc, _ := time.LoadLocation("America/Vancouver")
r := RRule{
	Frequency: Daily,
	ByHours:   []int{2},
	Count:     3,
	Dtstart:   time.Date(2025, 3, 8, 0, 0, 0, 0, loc), // Before DST change: 2025-03-08 00:00:00 -0800 PST
}
for _, t := range All(r.Iterator(), 0) {
	fmt.Println(t)
}

// Prints:
// 2025-03-08 02:00:00 -0800 PST
// 2025-03-09 03:00:00 -0700 PDT
// 2025-03-10 02:00:00 -0700 PDT

That looks good. DST is respected during 2025-03-09 02:00:00.

Hourly Frequence:

loc, _ := time.LoadLocation("America/Vancouver")
r := RRule{
	Frequency: Hourly,
	ByHours:   []int{2},
	Count:     3,
	Dtstart:   time.Date(2025, 3, 8, 0, 0, 0, 0, loc), // Before DST change: 2025-03-08 00:00:00 -0800 PST
}
for _, t := range All(r.Iterator(), 0) {
	fmt.Println(t)
}

// Prints:
// 2025-03-08 02:00:00 -0800 PST
// 2025-03-10 02:00:00 -0700 PDT
// 2025-03-11 02:00:00 -0700 PDT

Problem, since 2025-03-09 02:00:00 -0700 PDT technically doesn't exist. Instead of returning 2025-03-09 03:00:00 -0700 PDT, it skips the day and returns the next date 2025-03-10 02:00:00 -0700 PDT instead.

However, expected behaviour is for it to return the same as daily, i.e. 2025-03-09 03:00:00 -0700 PDT.

holmberd avatar Feb 24 '25 23:02 holmberd

I'm tempted to agree with you, however, the spec this library is trying to follow is actually pretty clear about the case:

Recurrence rules may generate recurrence instances with an invalid date (e.g., February 30) or nonexistent local time (e.g., 1:30 AM on a day where the local time is moved forward by an hour at 1:00 AM). Such recurrence instances MUST be ignored and MUST NOT be counted as part of the recurrence set.

I suspect the expectation is that an extra RDATE could be included to cover such missed instances, allowing implementers to decide what a fallback mechanism would do.

I imagine this library or another could grow a helper function to "validate and correct" generated recurrences, to apply some fallback mechanism for these cases. I don't personally use this library much, but if you have motivation for that, by all means. I'm going to preemptively close this issue, but feel free to continue on here if you'd like.

stephens2424 avatar Feb 25 '25 00:02 stephens2424

Oh I see, it's inconsistent. It should probably not emit that case on the 9th in the first example, going by the spec. Reopened the issue. But again, I still don't have the time to dig into this. I may someday, so I'll leave this here. But if you're motivated to do so, feel free.

stephens2424 avatar Feb 25 '25 00:02 stephens2424

Thank you for the response.

I agree that the spec is somewhat contradictory regarding these rules. My interpretation is as follows:

Rule 1: Invalid recurrence instances must be ignored

Recurrence rules may generate recurrence instances with an invalid date (e.g., February 30) or nonexistent local time (e.g., 1:30 AM on a day where the local time is moved forward by an hour at 1:00 AM). Such recurrence instances MUST be ignored and MUST NOT be counted as part of the recurrence set.

Rule 2: Nonexistent/Ambiguous local times are interpreted as if they were explicitly specified DATE-TIME values

If the computed local start time of a recurrence instance does not exist, or occurs more than once, for the specified time zone, the time of the recurrence instance is interpreted in the same manner as an explicit DATE-TIME value describing that date and time, as specified in Section 3.3.5.

Rule 3: DATE-TIME with timezone reference interpretation

If, based on the definition of the referenced time zone, the local time described occurs more than once (when changing from daylight to standard time), the DATE-TIME value refers to the first occurrence of the referenced time. Thus, TZID=America/ New_York:20071104T013000 indicates November 4, 2007 at 1:30 A.M. EDT (UTC-04:00). If the local time described does not occur (when changing from standard to daylight time), the DATE-TIME value is interpreted using the UTC offset before the gap in local times. Thus, TZID=America/New_York:20070311T023000 indicates March 11, 2007 at 3:30 A.M. EDT (UTC-04:00), one hour after 1:30 A.M. EST (UTC-05:00).

Since nonexistent local times with a timezone reference are interpreted as DATE-TIME values, we can infer:

  1. When a time occurs twice (DST fall back), we use the first occurrence (Rule 3).
  2. When a time does not exist (DST spring forward), it should be interpreted using the last valid UTC offset before the shift (Rule 3).

Applying this logic:

  • February 30 is ignored completely because it doesn't exist and is not a timezone issue.
  • In rule FREQ=DAILY;BYHOUR=2, 2025-03-09 02:00:00 -0800 PST is invalid, because 2 AM does not exist on that day in America/Vancouver.
    • Since it does not exist, it is interpreted as an explicit DATE-TIME value using the last valid UTC offset before the gap. In this case, 2025-03-09 02:00:00 -0800 (UTC-8) is adjusted to 2025-03-09 03:00:00 -0700 PDT (UTC-7).
    • As a result, 2025-03-09 02:00:00 -0800 PST is ignored in the set, but the adjusted time is not. This behavior aligns with what you would expect from a calendar (e.g. Google Calendar) when scheduling a daily event at 2 AM.

Edit: However, whether the rule FREQ=HOURLY;BYHOUR=2 should behave the same as FREQ=DAILY;BYHOUR2 or not, is not very clear. I don't see any specific rules in the spec that say they should behave differently, and based on that I have to assume they should behave the same.

I still don't have the time to dig into this. I may someday,

I completely understand that there’s only so much time in a day, and despite this edge cases (which might not be an edge-case at all), this package is still more consistent than the others. If I need to make adjustments later, I’ll push an update.

holmberd avatar Feb 25 '25 17:02 holmberd