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 <liutian.6000@bytedance.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
TimLiu
2026-10-26 03:27:00 -07:00
committed by GitHub
co-authored by liutian Neil
parent bd3a6c875c
commit 39d4c73b07
3 changed files with 57 additions and 5 deletions
+9 -2
View File
@@ -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
}
@@ -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))
}
}
)
})
@@ -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
}