Skip to content

Recurring CalendarEvent occurrences are computed in UTC and shift local time across DST #3496

Open

Description

@richturner

Describe the bug
CalendarEvent.getNextOrActiveFromTo works out recurrences in UTC (model/src/main/java/org/openremote/model/calendar/CalendarEvent.java, the recurrence.getDates(...) call):

recurrence.getDates(
    LocalDateTime.ofInstant(getStart().toInstant(), ZoneOffset.UTC),
    LocalDateTime.ofInstant(whenMillis, ZoneOffset.UTC), ...);
List<Instant> instants = matches.stream().map(d -> d.toInstant(ZoneOffset.UTC)).toList();

CalendarEvent stores only start/end as instants plus a Recur<LocalDateTime>, with no time zone. Every occurrence therefore lands at the same UTC time of day. A user outside UTC sees the local time of a recurring event shift by an hour when DST starts or ends.

Example: a ruleset validity of Mondays 06:00–08:00 Europe/Brussels, recurring weekly, created in summer (CEST, UTC+2), starts at 04:00 UTC. After DST ends, the next occurrences are still at 04:00 UTC, which is 05:00–07:00 local time, an hour earlier than configured.

This affects anything that uses CalendarEvent recurrence, mainly ruleset validity (RulesetDeployment.getNextOrActiveFromTo) and probably CalendarEventPredicate in asset queries (to check).

How it surfaced
TimerService.Clock.PSEUDO.init() used to call TimeZone.setDefault(UTC) and never reset it (added in 4d39cca). Tests therefore ran in UTC, which hid the problem. #3447 removes that global change. ResidenceNotifyAlarmTriggerTest.getOccurrenceFromTo then failed whenever the test's two-week recurrence crossed a DST change in the developer's local zone. The test built the expected windows in ZoneId.systemDefault(), which is what a user would expect, but the implementation returns UTC-based windows.

#3447 changed the test to expect UTC windows so it passes, with the comment:

// CalendarEvent evaluates recurrences in UTC, so expected windows must also stay in UTC across DST.

So the test now asserts the current (arguably wrong) behaviour. CI runners use UTC, so CI can't catch this either way.

To Reproduce

  1. Run with a JVM default zone that observes DST, e.g. -Duser.timezone=Europe/Brussels.
  2. Create a CalendarEvent with a start before a DST change (e.g. 2026-10-19 06:00 local) and new Recur<>(Frequency.WEEKLY, 2).
  3. Call getNextOrActiveFromTo with a time after the DST change (e.g. 2026-10-26).
  4. The returned window starts at 05:00 local, not 06:00.

Expected behavior
Recurring events keep their configured local time of day across DST changes. That needs a reference time zone for the recurrence: stored on the CalendarEvent, or taken from the realm or the ruleset's context. Once that's fixed, ResidenceNotifyAlarmTriggerTest.getOccurrenceFromTo should go back to expecting local-time windows. A unit test for CalendarEvent that runs across a DST change in a non-UTC zone should be added, so CI no longer relies on the runner's zone.

Additional context

  • Changing this alters when existing recurring validity periods fire for non-UTC users, so it may need a migration or compatibility note.
  • Things to decide: where the zone comes from (event, realm or user), and what to do with existing stored events that have no zone (treat them as UTC for backward compatibility?).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    BugError or issue in the system

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions