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.
Temporal and polyfill setup
Section titled “Temporal and polyfill setup”rrule-ts is Temporal-native and has zero runtime dependencies. It reads Temporal from two sources in order:
globalThis.Temporalwhen present (Node.js 26+, modern browsers with native Temporal).- 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.
Wall-clock semantics
Section titled “Wall-clock semantics”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 four dtstart types
Section titled “The four dtstart types”The type of dtstart controls how occurrences are returned:
| Type | Semantics |
|---|---|
Temporal.PlainDate | All-day events: no time component, no timezone |
Temporal.PlainDateTime | Floating datetime: no timezone, wall clock only |
Temporal.Instant | UTC fixed moment: always the same absolute point in time |
Temporal.ZonedDateTime | Timezone-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.
Fall-back: the repeated hour
Section titled “Fall-back: the repeated hour”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.
Spring-forward: the skipped hour
Section titled “Spring-forward: the skipped hour”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 ID | Timezone | Description |
|---|---|---|
dst-la-spring-daily | America/Los_Angeles | 02:30 in spring-forward gap; dateutil: 02:30-08:00; rrule-ts: 03:30-07:00 |
dst-berlin-spring-daily | Europe/Berlin | 02:00 in spring-forward gap; dateutil: 02:00+01:00; rrule-ts: 03:00+02:00 |
dst-lord-howe-spring-daily | Australia/Lord_Howe | 02: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.Example: daily rule across spring-forward
Section titled “Example: daily rule across spring-forward”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]