@@ -76,39 +76,74 @@ public Interval(DayOfWeek dayOfWeek, int frequency, int hour, int minute, string
7676
7777 public TimeSpan CalculateTimeToNext ( DateTime fromDateTime )
7878 {
79- DateTime ? next = null ;
79+ // For relative intervals (Second/Minute/Hour), do arithmetic in UTC to completely
80+ // avoid DST issues. These intervals don't care about wall-clock time.
81+ if ( TimeType == TimeType . Second || TimeType == TimeType . Minute || TimeType == TimeType . Hour )
82+ {
83+ DateTime fromUtc = fromDateTime . Kind == DateTimeKind . Utc
84+ ? fromDateTime
85+ : fromDateTime . ToUniversalTime ( ) ;
86+
87+ DateTime nextUtc = TimeType switch
88+ {
89+ TimeType . Second => fromUtc . AddSeconds ( Frequency ) ,
90+ TimeType . Minute => fromUtc . AddMinutes ( Frequency ) ,
91+ TimeType . Hour => fromUtc . AddHours ( Frequency ) ,
92+ _ => throw new Exception ( "Unexpected TimeType" ) ,
93+ } ;
94+
95+ return nextUtc - fromUtc ;
96+ }
97+
98+ // For absolute intervals (Day/Week/Year), we need to calculate in local time
99+ // (or the specified timezone) since the user wants "run at HH:MM local time".
100+ TimeZoneInfo tz ;
101+ if ( ! string . IsNullOrEmpty ( Timezone ) )
102+ {
103+ try
104+ {
105+ tz = TimeZoneInfo . FindSystemTimeZoneById ( Timezone ) ; //I could use https://github.com/mattjohnsonpint/TimeZoneConverter to support IANA timezones
106+ }
107+ catch ( TimeZoneNotFoundException )
108+ {
109+ throw new Exception ( $ "Unrecognized timezone '{ Timezone } '") ;
110+ }
111+ }
112+ else
113+ {
114+ tz = TimeZoneInfo . Local ;
115+ }
116+
117+ // Convert fromDateTime to the target timezone for accurate wall-clock calculations
118+ DateTime fromUtcAbs = fromDateTime . Kind == DateTimeKind . Utc
119+ ? fromDateTime
120+ : fromDateTime . ToUniversalTime ( ) ;
121+ DateTime localNow = TimeZoneInfo . ConvertTimeFromUtc ( fromUtcAbs , tz ) ;
122+
123+ DateTime ? nextLocal = null ;
80124 switch ( TimeType )
81125 {
82- case TimeType . Second :
83- next = fromDateTime . AddSeconds ( Frequency ) ;
84- break ;
85- case TimeType . Minute :
86- next = fromDateTime . AddMinutes ( Frequency ) ;
87- break ;
88- case TimeType . Hour :
89- next = fromDateTime . AddHours ( Frequency ) ;
90- break ;
91126 case TimeType . Day :
92- next = fromDateTime ;
93- next = new DateTime ( next . Value . Year , next . Value . Month , next . Value . Day , Hour , Minute , 0 , 0 ) ;
127+ nextLocal = new DateTime ( localNow . Year , localNow . Month , localNow . Day , Hour , Minute , 0 , 0 ) ;
94128
95- if ( Frequency != 1 || next < fromDateTime ) //Only shift forward if the task repeats less frequently than everyday or the time has already passed
129+ if ( Frequency != 1 || nextLocal <= localNow ) //Only shift forward if the task repeats less frequently than everyday or the time has already passed
96130 {
97- next = next . Value . AddDays ( Frequency ) ;
131+ nextLocal = nextLocal . Value . AddDays ( Frequency ) ;
98132 }
99133
100134 break ;
101135 case TimeType . Week :
102- next = fromDateTime ;
103- next = new DateTime ( next . Value . Year , next . Value . Month , next . Value . Day , Hour , Minute , 0 , 0 ) ;
136+ nextLocal = new DateTime ( localNow . Year , localNow . Month , localNow . Day , Hour , Minute , 0 , 0 ) ;
104137
105- if ( fromDateTime . DayOfWeek != DayOfWeek || next < fromDateTime ) //Only shift forward if the DayOfWeek occurrence is not today or the time has already passed
138+ if ( localNow . DayOfWeek != DayOfWeek || nextLocal <= localNow ) //Only shift forward if the DayOfWeek occurrence is not today or the time has already passed
106139 {
107- next = fromDateTime . AddDays ( ( ( int ) DayOfWeek - ( int ) fromDateTime . DayOfWeek + 7 ) % 7 ) ;
140+ int daysUntilTarget = ( ( int ) DayOfWeek - ( int ) localNow . DayOfWeek + 7 ) % 7 ;
141+ if ( daysUntilTarget == 0 ) daysUntilTarget = 7 ; // If same day but time passed, go to next week
142+ nextLocal = new DateTime ( localNow . Year , localNow . Month , localNow . Day , Hour , Minute , 0 , 0 ) . AddDays ( daysUntilTarget ) ;
108143
109144 if ( Frequency > 1 )
110145 {
111- next = next . Value . AddDays ( 7 * Frequency ) ;
146+ nextLocal = nextLocal . Value . AddDays ( 7 * ( Frequency - 1 ) ) ;
112147 }
113148 }
114149
@@ -117,44 +152,48 @@ public TimeSpan CalculateTimeToNext(DateTime fromDateTime)
117152 //Todo: not currently supported
118153 break ;
119154 case TimeType . Year :
120- next = fromDateTime ;
121- next = new DateTime ( next . Value . Year , next . Value . Month , next . Value . Day , Hour , Minute , 0 , 0 ) ;
155+ nextLocal = new DateTime ( localNow . Year , localNow . Month , localNow . Day , Hour , Minute , 0 , 0 ) ;
122156
123- if ( next < fromDateTime ) //Only shift forward if the time has already passed
157+ if ( nextLocal <= localNow ) //Only shift forward if the time has already passed
124158 {
125- next = fromDateTime . AddYears ( Frequency ) ;
159+ nextLocal = nextLocal . Value . AddYears ( Frequency ) ;
126160 }
127161
128162 break ;
129163 }
130164
131- if ( ! next . HasValue )
165+ if ( ! nextLocal . HasValue )
132166 {
133167 throw new Exception ( "Unable to calculate next occurence" ) ;
134168 }
135169
136- if ( ! string . IsNullOrEmpty ( Timezone ) )
170+ // Handle DST edge cases before converting to UTC:
171+ // - "Invalid" times fall in the spring-forward gap (e.g. 2:30 AM when clocks skip 2:00->3:00)
172+ // - "Ambiguous" times occur during fall-back (e.g. 1:30 AM happens twice)
173+ if ( tz . IsInvalidTime ( nextLocal . Value ) )
137174 {
138- try
139- {
140- //This is a rudimentary first version. Some potential problems that might arise,
141- //causing this to provide inaccurate results:
142- // - At the bottom, we attempt to account for DST changes, but by converting timezones,
143- // I wouldn't be surprised if there were multiple DST or similar changes in between
144- // - Etc. (Timezones can be VERY weird. This could fail in a variety of ways)
175+ // The target time doesn't exist — shift forward by the DST adjustment amount
176+ // so we land just after the gap (e.g. 2:30 AM becomes 3:30 AM)
177+ TimeSpan dstDelta = tz . GetAdjustmentRules ( )
178+ . Where ( r => r . DateStart <= nextLocal . Value && r . DateEnd >= nextLocal . Value )
179+ . Select ( r => r . DaylightDelta )
180+ . FirstOrDefault ( ) ;
145181
146- TimeZoneInfo timeZoneInfo = TimeZoneInfo . FindSystemTimeZoneById ( Timezone ) ; //I could use https://github.com/mattjohnsonpint/TimeZoneConverter to support IANA timezones
182+ if ( dstDelta == TimeSpan . Zero )
183+ dstDelta = TimeSpan . FromHours ( 1 ) ; // Fallback: most DST transitions are 1 hour
147184
148- next = TimeZoneInfo . ConvertTime ( next . Value , timeZoneInfo ) ;
149- }
150- catch ( TimeZoneNotFoundException )
151- {
152- throw new Exception ( $ "Unrecognized timezone '{ Timezone } '") ;
153- }
185+ nextLocal = nextLocal . Value . Add ( dstDelta ) ;
154186 }
155187
156- //Using UTC converts here gives us the correct TimeSpan even if there is a DST change in between
157- return TimeZoneInfo . ConvertTimeToUtc ( next . Value ) - TimeZoneInfo . ConvertTimeToUtc ( fromDateTime ) ;
188+ // For ambiguous times (fall-back), ConvertTimeToUtc defaults to standard time offset.
189+ // This is acceptable — the task will run during the second occurrence of that wall-clock
190+ // time, which is a reasonable behavior for a scheduler.
191+
192+ // Convert both times to UTC for an accurate TimeSpan regardless of DST transitions
193+ DateTime nextUtcAbs = TimeZoneInfo . ConvertTimeToUtc (
194+ DateTime . SpecifyKind ( nextLocal . Value , DateTimeKind . Unspecified ) , tz ) ;
195+
196+ return nextUtcAbs - fromUtcAbs ;
158197 }
159198 }
160199}
0 commit comments