Skip to content

Timezones and DST

This guide explains how rrule-ts handles daylight saving time transitions: the wall-clock semantics it applies, the four dtstart types, and the documented spring-forward deviation from python-dateutil.

Daylight saving time transitions create two classes of problems for RRULE expansion:

  • Spring-forward gaps. Clocks skip an hour. A computed local time like 02:30 does not exist on the transition day.
  • Fall-back ambiguities. Clocks repeat an hour. A computed local time like 01:30 occurs twice on the transition day.

Date-based libraries handle these problems inconsistently because JavaScript’s Date object has no concept of a named timezone. rrule-ts uses Temporal throughout, which resolves both problems with explicit disambiguation semantics.

rrule-ts is Temporal-native and has zero runtime dependencies. It reads Temporal from two sources in order:

  1. globalThis.Temporal when present (Node.js 26+, modern browsers with native Temporal).
  2. An implementation injected via setTemporal() by the caller.

If neither is available, getTemporal() throws a descriptive error.

On Node.js below 26 or older browsers, install a polyfill and inject it once before any expansion:

import { setTemporal } from 'rrule-ts'
import { Temporal } from 'temporal-polyfill'
setTemporal(Temporal)

Do this at the top of your entry point, before any call to expand(), iterate(), or RRuleSet.

rrule-ts uses wall-clock semantics throughout.

For DAILY and coarser frequencies (WEEKLY, MONTHLY, YEARLY), the engine advances a PlainDate counter by the rule interval and pairs each resulting date with the same time components taken from dtstart. Each occurrence is then resolved to a ZonedDateTime in the rule’s timezone via Temporal.ZonedDateTime.from. As a result, a daily 09:00 meeting stays at 09:00 local time before and after a DST transition, even though the UTC offset changes.

For sub-daily frequencies (HOURLY, MINUTELY, SECONDLY), the engine advances using wall-clock arithmetic on the time fields. The UTC-elapsed distance between consecutive occurrences can vary across DST boundaries.

The type of dtstart controls how occurrences are returned:

TypeSemantics
Temporal.PlainDateAll-day events: no time component, no timezone
Temporal.PlainDateTimeFloating datetime: no timezone, wall clock only
Temporal.InstantUTC fixed moment: always the same absolute point in time
Temporal.ZonedDateTimeTimezone-aware: wall-clock time in a named IANA timezone, DST-correct

For calendar events that need to respect local time (meetings, appointments), use Temporal.ZonedDateTime.

When a computed local time falls in a fall-back ambiguity (clocks are set back and a local hour occurs twice), rrule-ts resolves the ambiguity with disambiguation: 'compatible'. The 'compatible' setting selects the earlier of the two possible instants, equivalent to fold=0 in Python’s datetime. This matches python-dateutil’s fall-back behavior exactly.

When a computed local time falls in a spring-forward gap (a local time that does not exist), rrule-ts again applies disambiguation: 'compatible'. For a skipped time, 'compatible' shifts the wall-clock time forward to the first valid instant after the gap.

Example. In America/Los_Angeles, clocks jump from 02:00 to 03:00 on the spring-forward Sunday. If the rule computes 02:30 as an occurrence:

  • rrule-ts emits 03:30-07:00 (the first valid instant after the gap, UTC epoch unchanged).
  • python-dateutil preserves the original wall-clock digits with the pre-gap offset: 02:30-08:00.

The resulting ISO strings differ, but the epoch milliseconds are identical. Downstream consumers that compare by timestamp are unaffected.

This is a deliberate deviation from python-dateutil. rrule-ts, via Temporal, always emits a valid instant. python-dateutil’s output describes an instant that does not exist in the timezone.

Documented spring-forward conformance cases

Section titled “Documented spring-forward conformance cases”

The conformance suite contains three hand-authored cases that cover this deviation explicitly:

Case IDTimezoneDescription
dst-la-spring-dailyAmerica/Los_Angeles02:30 in spring-forward gap; dateutil: 02:30-08:00; rrule-ts: 03:30-07:00
dst-berlin-spring-dailyEurope/Berlin02:00 in spring-forward gap; dateutil: 02:00+01:00; rrule-ts: 03:00+02:00
dst-lord-howe-spring-dailyAustralia/Lord_Howe02:00 in 30-min gap; dateutil: 02:00+10:30; rrule-ts: 02:30+11:00

These cases are intentionally excluded from the oracle-derived corpus and are maintained as hand-authored diff tests.

Example: weekly meeting in a named timezone

Section titled “Example: weekly meeting in a named timezone”
import { setTemporal } from 'rrule-ts'
import { Temporal } from 'temporal-polyfill'
setTemporal(Temporal)
import { expand } from 'rrule-ts'
const occurrences = expand({
freq: 'WEEKLY',
byDay: [{ weekday: 'MO', ordinal: undefined }],
count: 5,
dtstart: Temporal.ZonedDateTime.from('2025-03-03T09:00:00[America/New_York]'),
})
// Each occurrence is a ZonedDateTime in America/New_York.
// The spring-forward on 2025-03-09 does not affect the Monday 09:00 occurrences
// because 09:00 is outside the gap. The UTC offset changes from -05:00 to -04:00
// after the transition.
import { setTemporal, expand } from 'rrule-ts'
import { Temporal } from 'temporal-polyfill'
setTemporal(Temporal)
// 02:30 daily in Los Angeles. The spring-forward day (2025-03-09) has no 02:30,
// so rrule-ts emits 03:30-07:00 on that day.
expand({
freq: 'DAILY',
count: 5,
dtstart: Temporal.ZonedDateTime.from('2025-03-07T02:30:00[America/Los_Angeles]'),
})
// 2025-03-07T02:30:00-08:00[America/Los_Angeles]
// 2025-03-08T02:30:00-08:00[America/Los_Angeles]
// 2025-03-09T03:30:00-07:00[America/Los_Angeles] <- gap: 02:30 shifted to 03:30
// 2025-03-10T02:30:00-07:00[America/Los_Angeles]
// 2025-03-11T02:30:00-07:00[America/Los_Angeles]