I had a problem recently where a scheduled task was being scheduled to start at the same time as daylight savings time adjustments are made, i.e when the clocks move forward one hour later in the US and UK. This happens at different times for different time zones. For example, in Eastern, Pacific, and Atlantic standard time zones, this happens at 2 am on 10 March 2024. In the UK this happens on the 31 March at 1 am. What this means is that 02:00-2:59 am and 01:00 -01:59 respectively do not exist because at 2 am, the time changes to 3 am for the US timezones, and 1 am changes to 2 am in the UK.
The implication is that as those times don't exist in the timezone, the scheduled task would never run and if you try to convert those times (in those affected timezones) to say UTC like this:
TimeZoneInfo.ConvertTimeToUtc(scheduledtime, timezone);
You get an error:
The supplied DateTime represents an invalid time.
So I went about figuring out how to automatically adjust the time to 3 am in the US when 2 am comes around and adjusting to 2 am when 1 am is in the UK...
The upshot of this is that I created two solutions, one initially which theoretically was sound but was complex, and the second one which was after the realization that the first was too complex and that a simpler solution was possible.
First solution:
if (timezone.IsInvalidTime(scheduledTime) && timezone.SupportsDaylightSavingTime)
{
var adjustedTime = ToDaylightSavingTime(timezone, scheduledTime);
return adjustedTime;
}
Now in ToDaylightsavingTime (shown below) it tried to figure out, for any timezone, what the necessary adjustments needed to be made (and when) by inspecting the AdjustmentRules for the supplied timezone like this:
public static DateTime ToDaylightSavingTime(TimeZoneInfo timeZone, DateTime targetScheduledDateTime)
{
var currentOffset = timeZone.GetUtcOffset(targetScheduledDateTime);
var futureOffset = timeZone.GetUtcOffset(targetScheduledDateTime) +
GetAdjustment(timeZone.GetAdjustmentRules(), targetScheduledDateTime).DaylightDelta;
var newOffset = currentOffset - futureOffset;
var newDateTimeWithOffset = new DateTimeOffset(targetScheduledDateTime, newOffset);
// Apply adjustment
return DateTime.SpecifyKind(newDateTimeWithOffset.UtcDateTime, DateTimeKind.Unspecified);
}
Btw, here is how you'd determine which of the available adjustments apply to the scheduledTime::
private TimeZoneInfo.AdjustmentRule GetAdjustment(IEnumerable<TimeZoneInfo.AdjustmentRule> adjustments,
DateTime targetDateTime)
{
// Iterate adjustment rules for time zone
foreach (var adjustment in adjustments)
{
var start = CalculateTransitionDateTime(adjustment.DaylightTransitionStart, targetDateTime.Year);
var end = CalculateTransitionDateTime(adjustment.DaylightTransitionEnd, targetDateTime.Year);
var withinTransitionDate = targetDateTime >= start && targetDateTime <= end;
if (adjustment.DateStart.Year <= targetDateTime.Year &&
adjustment.DateEnd.Year >= targetDateTime.Year && withinTransitionDate)
return adjustment;
}
return null;
}
Anyway, with all this code being written and tested, I woke up the next morning and realized I didn't need any of it!
A far simpler solution was just to forget about determining which adjustments needed to apply for any timezone, and just always adjust by 1 hour forward regardless one of these invalid times was detected using timezone.IsInvalidTime()
:
if (timezone.IsInvalidTime(scheduledTime) && timezone.SupportsDaylightSavingTime)
{
// Move forward past invalid time by an hour
return scheduledTime.AddHours(1);
}
And some research shows that most timezones that observe Daylight Saving Time adjust by 1 hour: https://en.wikipedia.org/wiki/Daylight_saving_time_by_country
Anyway for posterity, here is my complex solution if anyone needs to figure out how to work with AdjustmentRules in the timezone.
One of the difficulties in my complex solution was determining if the scheduled time falls within one of the AdjustmentRules which indicates that adjustment must be made. It's difficult because sometimes the adjustment might be needed on a non-fixed date like the 2nd Sunday of March, so you need to figure out what the date is for the 2nd Sunday of March, and if the scheduled time falls on that 2nd Sunday or not (See GetFloatingTransitionDate()
) :
public sealed class DaylightSavingTimeHelper
{
public static DateTime CalculateTransitionDateTime(TimeZoneInfo.TransitionTime transition, int year)
{
DateTime transitionDatetime;
if (transition.IsFixedDateRule)
{
// Fixed date such as 2:00 A.M. on November 3 eg:
// startTransition.TimeOfDay
// startTransition.Month
// startTransition.Day,
transitionDatetime = new DateTime(year,
transition.Month, transition.Day,
transition.TimeOfDay.Hour,
transition.TimeOfDay.Minute,
transition.TimeOfDay.Second);
}
else
{
// Non-fixed date such as 2:00 A.M. on first Sunday November
transitionDatetime = GetFloatingTransitionDateTime(transition, year);
}
return transitionDatetime;
}
private static DateTime GetFloatingTransitionDateTime(TimeZoneInfo.TransitionTime transition, int year)
{
// Code borrowed from Microsoft's examples here:
// see https://learn.microsoft.com/en-us/dotnet/api/system.timezoneinfo.transitiontime.week?view=net-8.0
// https://learn.microsoft.com/en-us/dotnet/api/system.timezoneinfo.transitiontime.isfixeddaterule?view=net-8.0
// For non-fixed date rules, get local calendar
var cal = CultureInfo.CurrentCulture.Calendar;
// Get first day of week for transition
// For example, the 3rd week starts no earlier than the 15th of the month
var startOfWeek = transition.Week * 7 - 6;
// What day of the week does the month start on?
var firstDayOfWeek = (int) cal.GetDayOfWeek(new DateTime(year, transition.Month, 1));
// Determine how much start date has to be adjusted
int transitionDay;
var changeDayOfWeek = (int) transition.DayOfWeek;
if (firstDayOfWeek <= changeDayOfWeek)
transitionDay = startOfWeek + (changeDayOfWeek - firstDayOfWeek);
else
transitionDay = startOfWeek + (7 - firstDayOfWeek + changeDayOfWeek);
// Adjust for months with no fifth week
if (transitionDay > cal.GetDaysInMonth(year, transition.Month))
transitionDay -= 7;
return new DateTime(year, transition.Month, transitionDay,
transition.TimeOfDay.Hour,
transition.TimeOfDay.Minute,
transition.TimeOfDay.Second);
}
private TimeZoneInfo.AdjustmentRule GetAdjustment(IEnumerable<TimeZoneInfo.AdjustmentRule> adjustments,
DateTime targetDateTime)
{
// Iterate adjustment rules for time zone
foreach (var adjustment in adjustments)
{
var start = CalculateTransitionDateTime(adjustment.DaylightTransitionStart, targetDateTime.Year);
var end = CalculateTransitionDateTime(adjustment.DaylightTransitionEnd, targetDateTime.Year);
var withinTransitionDate = targetDateTime >= start && targetDateTime <= end;
if (adjustment.DateStart.Year <= targetDateTime.Year &&
adjustment.DateEnd.Year >= targetDateTime.Year && withinTransitionDate)
return adjustment;
}
return null;
}
public static DateTime ToDaylightSavingTime(TimeZoneInfo timeZone, DateTime targetScheduledDateTime)
{
var currentOffset = timeZone.GetUtcOffset(targetScheduledDateTime);
var futureOffset = timeZone.GetUtcOffset(targetScheduledDateTime) +
GetAdjustment(connectorTimeZone.GetAdjustmentRules(), targetScheduledDateTime).DaylightDelta;
var newOffset = currentOffset - futureOffset;
var newDateTimeWithOffset = new DateTimeOffset(targetScheduledDateTime, newOffset);
// Apply adjustment
return DateTime.SpecifyKind(newDateTimeWithOffset.UtcDateTime, DateTimeKind.Unspecified);
}
}
So it was so much simpler than I thought it needed to be. I think this is one of the valuable things about trying to actually understand what you're trying to do so that you can realize what is simpler.