Five Common Daylight Saving Time Antipatterns of .NET Developers
It's 2015, and Daylight Saving Time is just around the corner. For most of North America, the clocks will "spring-forward" on Sunday, March 8th, stealing an hour of precious time from our daily routine. A few weeks later, the same thing will occur in much of Europe on Sunday, March 29th. Shortly after that, our friends in Australia will "fall-back" on Sunday, April 5th, gaining an hour back (lucky bastards!).
There are actually many changes throughout the year, and not all occur at the same time of day. Refer to this page for precise details of the upcoming DST transitions.
In this article, I'll highlight some of the most common mistakes .NET developers make in their code that might blow up when daylight saving time hits. These are the sort of things that could set off alerts in the middle of the night, or have other more drastic consequences. Wouldn't you rather sleep soundly, knowing that you've accounted for DST properly? Then read on!
#1 - Measuring Human Activity
Many applications track the actions of human beings. These include productivity trackers, health and fitness monitors, workforce management applications, security applications, and many others. If you're timing when something occurred and using that time in some sort of business logic, then you need to pay close attention.
Antipattern
public void RecordStartTime()
{
foo.StartDateTime = DateTime.Now; // Wheee! I got a time!
db.Save(foo);
}
public void RecordStopTime()
{
foo.StopDateTime = DateTime.Now; // Wheee! I got another time!
db.Save(foo);
}
public TimeSpan GetTotalTime()
{
return foo.StopDateTime - foo.StartDateTime; // Math all the things!
}
You may see this manifest in a few other ways, but the key smells are:
- Usage of
DateTime.Now
to record an activity time. Really, any usage ofDateTime.Now
is a smell, especially in a web app. - Calculation of
StopDateTime - StartDateTime
using local time values Or any place math is done withDateTime
values that have their.Kind
property set to eitherDateTimeKind.Local
orDateTimeKind.Unspecified
.
Consequences
- In the spring, when the clock shifts forward, an hour is lost on the clock. If you don't account for this, you will compute an extra hour when the start and stop times encompass the transition. (Bonus!)
- In the fall, when the clock shifts backward, an hour is repeated on the clock. If you don't account for this, you could compute up to an hour less than the actual elapsed time. (Bummer!)
As an example, consider an timekeeping application for hourly employees. A worker who takes the night shift might clock in at 10:00 PM and clock out at 6:00 AM. On most days that is 8 hours, but if the clocks spring forward during that time then only 7 hours have actually passed. When the clocks fall back, then 9 hours have actually passed. If you don't account for DST, then you will either overpay the the employee or rob them of an hour's work. This has some serious ramifications when you consider overtime laws.
Remedy
Choose one of the following approaches:
- If the local time is unimportant in your application, then record the activity with respect to UTC instead of the local time. Daylight saving time does not occur in UTC.
foo.StartDateTimeUtc = DateTime.UtcNow;
...
foo.StopDateTimeUtc = DateTime.UtcNow;
- If the local time is important, then record values using a
DateTimeOffset
instead of aDateTime
. The offset will keep track of how the time is related to UTC, which will change depending on whether DST is in effect or not.
foo.StartDateTimeOffset = DateTimeOffset.Now;
...
foo.StopDateTimeOffset = DateTimeOffset.Now;
- If the user could be in some other time zone, you should take that into account when you determine the current time.
private DateTimeOffset GetCurrentTime(string timeZoneId)
{
TimeZoneInfo tzi = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
DateTimeOffset now = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, tzi);
return now;
}
```csharp
foo.StartDateTimeOffset = GetCurrentTime(theUsersTimeZoneId);
...
foo.StopDateTimeOffset = GetCurrentTime(theUsersTimeZoneId);
```
#2 - Measuring Computational Activity
If you haven't noticed, computers are a bit different than people. In particular, they tend to be more precise. Sometimes we want to know how long they take to do things, so we might write some code like this:
Antipattern
DateTime start = DateTime.Now;
// ...do some work...
DateTime stop = DateTime.Now;
TimeSpan elapsedTime = foo.StopDateTime - foo.StartDateTime;
Consequences
- If this code happens to be "doing the work" when the clock shifts forward or backward, what kind of results do you think we might get? (hint: not good ones!)
- Even without DST there are problems. You see, the system clock just isn't all that precise! Just because
DateTime
has 7 decimal places worth of "ticks" doesn't mean your computer can actually keep time with that level of precision. In most cases, it's closer to about 10 milliseconds worth of accuracy. If you want more precision than that, you'll need a hardware solution. - Let's not forget about clock drift. Your computer's clock is always ever-so-slightly falling out of alignment. Periodically, the operating system will sync with a network time server to correct itself. These corrections are usually on the order of a few milliseconds to a few seconds, and they happen quite frequently. If you're timing things with
DateTime
, who knows what kind of results you will get!
Remedy
Within a single unit of work, it's quite simple to use the System.Diagnostics.Stopwatch
class.
using System.Diagnostics;
...
Stopwatch stopwatch = Stowatch.StartNew();
// ...do some work...
stopwatch.Stop();
TimeSpan elapsedTime = stopwatch.Elapsed;
A lesser known fact about the Stopwatch
class is that it can also be used even when you're not in a single unit of work. In fact, you don't even need to be in the same thread or in the same process! As long as you are comparing values on the same computer, this will work:
using System.Diagnostics;
...
long start = Stopwatch.GetTimestamp();
// ...do some work, even on another thread or process...
long stop = Stopwatch.GetTimestamp();
long elapsed = stop - start; // Maths are ok here.
TimeSpan elapsedTime = TimeSpan.FromSeconds(elapsed * (1.0 / Stopwatch.Frequency));
The Stopwatch
class uses your computer's CPU for timing, through something called a QueryPerformanceCounter
. You can read about QPCs in all their glory, here (A must read for time geeks.)
The only gotcha is that on some classes of older processors while running pre-Vista operating systems (read "WinXP"), there can be inconsistencies when comparing QPCs from different threads. Hopefully that's not you, but you can read more in the geeky fine print.
#3 - Using the TimeZone Class
Antipattern
Any use of the TimeZone
class. For any reason, whatsoever. Period.
No, really, I'm serious. Just don't.
Consequences
The System.TimeZone
class has two major flaws:
- It only can be used for the local time zone of the computer it is running on. If your users might be in some other time zone, it's useless.
- It is only aware of the current set of daylight saving time rules for that time zone. If you store data, then later retrieve it and pass it through
TimeZone
, you might be using the wrong set of DST rules. Ok, perhaps this isn't such a big deal for 2015 in the USA, since last time we changed DST rules was in 2007. But did you know that time zones and DST rules often change for other time zones all over the world? Multiple updates are made every year. Think globally, people!
Remedy
Anything TimeZone
can do, TimeZoneInfo
can do better. And anything TimeZoneInfo
can do, Noda Time can do better than that!
The only excuse for having System.TimeZone
in your project is if you're stuck in .NET 2.0. Even then (God help you) there are better alternatives, such as TZ4NET.
Really, I don't want to hear any complaining when you try to move to .NET Core and find TimeZone
has been removed. Just switch.
#4 - Field Validation
Many applications perform little or no validation when it comes to taking date and time values from their user interfaces.
Antipattern
DateTime dt = DateTime.Parse(someUserInputString);
SaveItOrCallSomeBusinessLogicWithoutAnySortOfValidation(dt);
(or ParseExact
, TryParse
, or TryParseExact
variants - and I'm not even going into globalization issues...)
Consequences
Just because the input fits in a DateTime
doesn't mean it's a valid date time.
- The value might be out of range (duh!) Especially if you are fitting it into a SQL
datetime
column, since it doesn't support years before 1753. (hint: use adatetime2
) - But more on point, the value might not exist within the target time zone, due to the DST spring-forward transition.
- And don't forget that in the fall-back DST transition, the value might exist twice. Do you know which one to use?
Consider the following graph of US Pacific Time on March 8th, 2015. If your user supplies 2:30 AM - that time doesn't exist on this day!
Now consider the following graph of US Pacific Time on November 1st, 2015. If your user supplies 1:30 AM - that time exists twice on this day. Once at 8:30 UTC, and again at 9:30 UTC.
Remedy
Validate your inputs!
- Always check for a reasonable range.
- If your user enters an invalid date, alert them. Ask them to enter a valid one.
- If your user enters an ambiguous date, prompt them to choose which value they meant.
public bool ValidateDateTime(DateTime dt, TimeZoneInfo tzi)
{
if (dt < YourMinDateTime || dt > YourMaxDateTime)
throw new ArgumentOutOfRangeException(); // catch and present an error dialog
if (tzi.IsInvalidTime(dt))
throw new ArgumentException(); // catch and present an error dialog
if (tzi.IsAmbiguousTime(dt))
return false; // present the user with a choice of daylight time or standard time
return true; // all is good!
}
But what if the user isn't present?
That's unfortunate, but understandable. Perhaps you're calculating the time for a daily job to run. It needs to run at the same time every day, but what do you do when that time is invalid or ambiguous?
- Have a plan. If you don't check, who knows what will happen.
- Here's a good plan if you don't know what else to do:
public DateTime ComputeNextUtcTimeToRun(int localHour, int localMinute, TimeZoneInfo tzi)
{
// Get the current time in the target time zone
DateTime now = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tzi);
// Compute the next local time
DateTime next = new DateTime(now.Year, now.Month, now.Day, localHour, localMinute, 0);
if (next < now) next = next.AddDays(1);
// If it's invalid, advance. The clocks shifted forward, so might you!
if (tzi.IsInvalidTime(next))
return TimeZoneInfo.ConvertTimeToUtc(next.AddHours(1), tzi);
// If it's ambiguous, pick the first instance. Why not - You have to pick something!
if (tzi.IsAmbiguousTime(next))
{
TimeSpan offset = tzi.GetAmbiguousTimeOffsets(next).Max();
DateTimeOffset dto = new DateTimeOffset(next, offset);
return dto.UtcDateTime;
}
// It's safe to use as-is.
return TimeZoneInfo.ConvertTimeToUtc(next, tzi);
}
BTW - if you're using Noda Time, this is what we call a ZoneLocalMappingResolver
- because we're fancy like that.
#5 - Reporting
Like many systems, yours probably runs some kind of daily or weekly reports. Are you considering daylight saving time when you evaluate the results?
Antipattern
Assuming that all days have 24 hours.
Consequences
- On the day of the spring-forward transition, the day will have only 23 hours.
- On the day of the fall-back transition, the day will have 25 hours!
- Depending on your business, the totals might have significant discrepancies when compared to other days. Or perhaps not. It depends what you are doing.
Remedy
There's no cheating, a local day really does have less or more time on these DST transition days. However, you might consider some of the following mitigations:
- If aligning your "business day" to UTC is possible, then go for it. Problem solved. (But that's not always practical.)
- You might just make the business aware. Just like not all months have the same number of days, these couple of days don't have the same number of hours. Put an asterisk in the report footer.
- Ok, so you want a technical solution? Consider adjusting that day's totals as follows: Take the total, and divide by the number of hours in the day (23 in spring, 25 in the fall). That computes the average value per hour. In the spring, increase the total by that amount. In the fall, reduce the total by that amount. This will level out your results, and is good for general day-by-day or week-by-week comparison reports and graphs. Do not use this technique for critical financial reports! Your accountant won't appreciate having the totals not matching up!