From 39d4c73b07657d40b185840c44ebb4d7bae4e113 Mon Sep 17 00:00:00 2001 From: TimLiu <91778998+ImcLiuQian@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:27:00 +0800 Subject: [PATCH] fix(automations): step schedule scans by calendar day so DST changes don't skip dates (#19086) The RRULE day scan stepped local midnight by a fixed 24h. Walking back over a 23-hour spring-forward day landed at 23:00 two dates earlier, so the latest-occurrence lookup used for catch-up runs skipped a date. It returned a run a day too old for daily schedules, and a week too old for weekly ones. Step with setDate via a shared addLocalDays helper, in both the RRULE scan and the cron feasibility scan. Add a regression test for America/New_York and Australia/Lord_Howe. The test is skipped on Windows, where unstubbing TZ doesn't restore the OS zone. Co-authored-by: liutian Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- src/shared/automation-cron-occurrence.ts | 11 ++++- src/shared/automation-schedule-dst.test.ts | 41 +++++++++++++++++++ src/shared/automation-schedule-occurrences.ts | 10 +++-- 3 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 src/shared/automation-schedule-dst.test.ts diff --git a/src/shared/automation-cron-occurrence.ts b/src/shared/automation-cron-occurrence.ts index 3cb37a83034..391d50ae9ff 100644 --- a/src/shared/automation-cron-occurrence.ts +++ b/src/shared/automation-cron-occurrence.ts @@ -1,6 +1,5 @@ import type { ParsedCron } from './automation-schedule-parsing' -const DAY_MS = 24 * 60 * 60 * 1000 const CRON_SCAN_DAYS = 9 * 366 export function startOfLocalDay(timestamp: number): number { @@ -9,6 +8,14 @@ export function startOfLocalDay(timestamp: number): number { return date.getTime() } +// Why: calendar steps, not 24h ones; DST days are not 24 hours long. +export function addLocalDays(day: number, days: number): number { + const date = new Date(day) + date.setDate(date.getDate() + days) + date.setHours(0, 0, 0, 0) + return date.getTime() +} + export function floorToMinute(timestamp: number): number { const date = new Date(timestamp) date.setSeconds(0, 0) @@ -43,7 +50,7 @@ export function cronHasPossibleOccurrence(rule: ParsedCron, anchor: number): boo if (cronDateMatches(rule, day)) { return true } - day += DAY_MS + day = addLocalDays(day, 1) } return false } diff --git a/src/shared/automation-schedule-dst.test.ts b/src/shared/automation-schedule-dst.test.ts new file mode 100644 index 00000000000..27f9ce1611b --- /dev/null +++ b/src/shared/automation-schedule-dst.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { latestAutomationOccurrenceAtOrBefore } from './automation-schedule-occurrences' + +afterEach(() => vi.unstubAllEnvs()) + +// Why: on Windows, unsetting a stubbed TZ doesn't restore the OS zone, so it would leak into later tests. +describe.skipIf(process.platform === 'win32')('automation calendar days across DST', () => { + it.each([ + { + timezone: 'America/New_York', + now: '2026-03-09T00:30:00-04:00', + expected: '2026-03-08T23:30:00-04:00' + }, + { + timezone: 'Australia/Lord_Howe', + now: '2026-10-05T00:15:00+11:00', + expected: '2026-10-04T23:30:00+11:00' + } + ])( + 'finds the previous calendar day after spring-forward in $timezone', + ({ timezone, now, expected }) => { + vi.stubEnv('TZ', timezone) + // Precondition: the stub took and the offset changed in the preceding two days. + expect(new Date(now).getTimezoneOffset()).not.toBe( + new Date(Date.parse(now) - 2 * 24 * 60 * 60 * 1000).getTimezoneOffset() + ) + for (const rrule of [ + 'FREQ=DAILY;BYHOUR=23;BYMINUTE=30', + 'FREQ=WEEKLY;BYDAY=SU;BYHOUR=23;BYMINUTE=30' + ]) { + expect( + latestAutomationOccurrenceAtOrBefore( + rrule, + Date.parse('2026-01-01T00:00:00Z'), + Date.parse(now) + ) + ).toBe(Date.parse(expected)) + } + } + ) +}) diff --git a/src/shared/automation-schedule-occurrences.ts b/src/shared/automation-schedule-occurrences.ts index 57e67743d42..f58327c1156 100644 --- a/src/shared/automation-schedule-occurrences.ts +++ b/src/shared/automation-schedule-occurrences.ts @@ -1,8 +1,12 @@ import type { AutomationSchedulePreset } from './automations-types' import { parseSchedule, type ParsedRrule } from './automation-schedule-parsing' -import { cronMatches, floorToMinute, startOfLocalDay } from './automation-cron-occurrence' +import { + addLocalDays, + cronMatches, + floorToMinute, + startOfLocalDay +} from './automation-cron-occurrence' -const DAY_MS = 24 * 60 * 60 * 1000 const HOUR_MS = 60 * 60 * 1000 const MINUTE_MS = 60 * 1000 // Why: valid cron expressions like Feb 29 can have an 8-year gap across non-leap centuries. @@ -35,7 +39,7 @@ function scanDayCandidates(rule: ParsedRrule, anchor: number, direction: 1 | -1) return candidate } } - day += direction * DAY_MS + day = addLocalDays(day, direction) } return null }