2

I know this isn't the first time this topic has been brought up even in the past 24 hours, but I'm surprised that I have not come across one clear / best practices solution to this problem. The problem also seems to contradict what I thought was a no-brainer design decision to save all dates in UTC. I'll try to state the problem here:

Given two DateTime objects, find the duration between them while accounting for daylight savings.

Consider the following scenarios:

  1. UtcDate - LocalDate where LocalDate is 1 millisecond earlier than a DST switchover.

  2. LocalDateA - LocalDateB where LocalDateB is 1 millisecond earlier than a DST switchover.

UtcDate - LocalDate.ToUtc() provides a duration that did not consider the DST switch. LocalDateA.ToUtc() - LocalDateB.ToUtc() is correct, but LocalDateA - LocalDateB also disregards DST.

Now, there obviously are solutions to this problem. The solution that I'm using now is this extension method:

public static TimeSpan Subtract(this DateTime minuend, TimeZoneInfo minuendTimeZone, 
    DateTime subtrahend, TimeZoneInfo subtrahendTimeZone)
{
    return TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(minuend, 
        DateTimeKind.Unspecified), minuendTimeZone)
        .Subtract(TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(subtrahend, 
            DateTimeKind.Unspecified), subtrahendTimeZone));
}

It works, I guess. I have some problems with it though:

  1. If dates are all converted to UTC before being saved, then this method won't help. The timezone information (and any handling of DST) is lost. I've been conditioned to always save dates in UTC, is the issue of DST just not impactful enough to make that a bad decision?

  2. It's unlikely that someone will be aware of this method, or even thinking about this problem, when calculating the difference between dates. Is there a safer solution?

  3. If we all work together, maybe the tech industry can convince congress to abolish daylight savings.

Rufus L
  • 36,127
  • 5
  • 30
  • 43
Neat Machine
  • 681
  • 4
  • 11
  • 18
  • 2
    If you use [NodaTime](https://www.nuget.org/packages/NodaTime), it's difficult to get it wrong because mixing UTC and local is not possible without conversions that involve explicit intent. – madreflection Jan 21 '20 at 18:33
  • 2
    Some educational read by Jon Skeet - https://codeblog.jonskeet.uk/2019/03/27/storing-utc-is-not-a-silver-bullet/ … basically explaining that you can't really compute diffs between times particularly in the future with 100% correctness as rules can change. So you never know if "I drink coffee every day 9am, how long between my coffee breaks" means 23,24 or 25 hours or anything in between (and converting to UTC would make it even worse) :( – Alexei Levenkov Jan 21 '20 at 18:52
  • As you pointed out, this has been discussed *many* times. Please see the linked duplicates. It's also [in the remarks of these docs](https://learn.microsoft.com/dotnet/api/system.datetime.subtract#System_DateTime_Subtract_System_DateTime_). Basically, use `DateTimeOffset` instead. – Matt Johnson-Pint Jan 22 '20 at 17:01
  • Regarding your code, you're only going to get correct results if the input `.Kind` values are either both `Utc`, or both `Unspecified` and intending to represent the same time zone (and when no transition is crossed), or both `Local` and no transition is crossed. If you need a better approach, use my `ToDateTimeOffset` function [from this answer](https://stackoverflow.com/a/57961386/634824), which takes the time zone into account. (You can pass `TimeZoneInfo.Local` or `TimeZoneInfo.Utc`, or some other time zone.) – Matt Johnson-Pint Jan 22 '20 at 17:06
  • 1
    Yes, `DateTimeOffset` has a fixed offset, but that means subtraction works correctly even if the two offsets are different (it normalizes them to UTC before subtracting). Yes, I see your code is changing kind. That is a problem. Consider if `subtrahend` is `DateTimeKind.Utc`, and `minuend` is `DateTimeKind.Local` (and the local time zone is not UTC). You will not get the difference in actual elapsed time, but only the difference in local clock time with your code. (It might even be negative.) – Matt Johnson-Pint Jan 22 '20 at 19:04
  • 1
    I re-opened your question so I could add a more detailed answer below. Thanks. – Matt Johnson-Pint Jan 22 '20 at 19:15

1 Answers1

2

As you pointed out, this has been discussed before. Here and here are two good posts to review.

Also, the documentation on DateTime.Subtract has this to say:

The Subtract(DateTime) method does not consider the value of the Kind property of the two DateTime values when performing the subtraction. Before subtracting DateTime objects, ensure that the objects represent times in the same time zone. Otherwise, the result will include the difference between time zones.

Note

The DateTimeOffset.Subtract(DateTimeOffset) method does consider the difference between time zones when performing the subtraction.

Beyond just "represent times in the same time zone", keep in mind that even if the objects are in the same time zone, the subtraction of DateTime values will still not consider DST or other transitions between the two objects.

The key point is that to determine the time elapsed, you should be subtracting absolute points in time. These are best represented by a DateTimeOffset in .NET.

If you already have DateTimeOffset values, you can just subtract them. However, you can still work with DateTime values as long as you first convert them to a DateTimeOffset properly.

Alternatively, you could convert everything to UTC - but you'd have to go through DateTimeOffset or similar code to do that properly anyway.

In your case, you can change your code to the following:

public static TimeSpan Subtract(this DateTime minuend, TimeZoneInfo minuendTimeZone, 
    DateTime subtrahend, TimeZoneInfo subtrahendTimeZone)
{
    return minuend.ToDateTimeOffset(minuendTimeZone) -
        subtrahend.ToDateTimeOffset(subtrahendTimeZone);
}

You will also need the ToDateTimeOffset extension method (which I've also used on other answers).

public static DateTimeOffset ToDateTimeOffset(this DateTime dt, TimeZoneInfo tz)
{
    if (dt.Kind != DateTimeKind.Unspecified)
    {
        // Handle UTC or Local kinds (regular and hidden 4th kind)
        DateTimeOffset dto = new DateTimeOffset(dt.ToUniversalTime(), TimeSpan.Zero);
        return TimeZoneInfo.ConvertTime(dto, tz);
    }

    if (tz.IsAmbiguousTime(dt))
    {
        // Prefer the daylight offset, because it comes first sequentially (1:30 ET becomes 1:30 EDT)
        TimeSpan[] offsets = tz.GetAmbiguousTimeOffsets(dt);
        TimeSpan offset = offsets[0] > offsets[1] ? offsets[0] : offsets[1];
        return new DateTimeOffset(dt, offset);
    }

    if (tz.IsInvalidTime(dt))
    {
        // Advance by the gap, and return with the daylight offset  (2:30 ET becomes 3:30 EDT)
        TimeSpan[] offsets = { tz.GetUtcOffset(dt.AddDays(-1)), tz.GetUtcOffset(dt.AddDays(1)) };
        TimeSpan gap = offsets[1] - offsets[0];
        return new DateTimeOffset(dt.Add(gap), offsets[1]);
    }

    // Simple case
    return new DateTimeOffset(dt, tz.GetUtcOffset(dt));
}
Matt Johnson-Pint
  • 230,703
  • 74
  • 448
  • 575
  • 1
    *Because* you are removing the kind, then there must be a presumption ahead of time that time is already correct for that time zone. If, for example, `minuend` was in terms of UTC already, but `minuendTimeZone` was from `TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time")` or some other time zone, then the conversion performed by `ConvertTimeToUtc` would be incorrect. The `Kind` property is there to prevent that, at least for UTC and Local time zones. It doesn't do so well with other time zones, which is why `DateTimeOffset` works better. – Matt Johnson-Pint Jan 22 '20 at 21:34