2

I have a DateTime instance with Kind = DateTimeKind.Utc and a timespan.

var dt = DateTime.UtcNow;
var ts = TimeSpan.FromDays(1);

When I localize dt and then add ts I get a different result than when I add ts and then localize, due to daylight savings.

var localizedFirst = dt.ToLocalTime() + ts; //Does account for daylight savings
var addedFirst = (dt + ts).ToLocalTime(); //Does not account for daylight savings

This seems very strange. Shouldn't adding an offset from localization and adding an offset from a timespan be commutative and associative?

I found a similar question: Why doesn't DateTime.ToLocalTime() take into account daylight savings? That question is dealing more with converting DateTime to and from String. I am working only with DateTime and TimeSpan arithmetic.

The best answer for that question suggested using DateTimeKind.Unspecified so that the runtime will assume the unspecified date is UTC and then it will convert it properly when localizing. I was very surprised that this actually worked. If I create a new DateTime like this:

var dt2 = new DateTime(dt.Ticks, DateTimeKind.Unspecified);

Then both orders of operations return the correct result with daylight savings.

(dt2 + ts).ToLocalTime() 
dt2.ToLocalTime() + ts

This all seems absurd to me. Why do I need to convert a Utc date to Unspecified just to convert it to Local properly? This seems like it should be considered a bug.

Other details:

  • Framework: .NET 4.6.1
  • My local timezone: Eastern Standard Time (USA)
  • An actual value being used by dt: 11/5/2017 2:36:13pm UTC
  • An actual value being used by ts: TimeSpan.FromDays(699)
  • Local equivalent of dt: 11/5/2017 9:36:13am
  • Value of (dt + ts).ToLocalTime(): 10/5/2019 10:36:13am
  • Value of dt.ToLocalTime() + ts: 10/5/2019 9:36:13am
Uwe Keim
  • 39,551
  • 56
  • 175
  • 291
JamesFaix
  • 8,050
  • 9
  • 37
  • 73
  • 1
    What version of .NET are you using? The 2.0/3.5 runtime handles Dates differently than the 4.x runtime. A few years ago I ran into a LOT of discrepancies. They may have been resolved since then, but either way, framework version would be helpful. – Steve Danner Nov 07 '17 at 14:47
  • 1
    maybe this article will help https://msdn.microsoft.com/en-us/library/ms973825.aspx – Allanckw Nov 07 '17 at 14:52
  • 1
    Can you provide specific times and timezone (what timezone, what time is dt, when daylight savings take effect in that zone)? Because it's important for this kind of questions. But in general - arithmetic operations on date time (like adding days - your case) do not take daylight savings into account, while conversion operations (like ToLocalTime - also your case) do. – Evk Nov 07 '17 at 14:55
  • 2
    It is the time of the year again. What happens is entirely normal, (dt + ts) is still utc so can be properly be adjusted for dst. But local time + ts stays local time. And cannot be adjusted because local time is ambiguous. – Hans Passant Nov 07 '17 at 15:00
  • 1
    Another important question here: do you happen to be in Arizona or Hawaii (states that do not adjust for DST)? – Steve Danner Nov 07 '17 at 15:03
  • @HansPassant But shouldn't adding an offset to a local time offset it by a fixed amount from the starting localized date? – JamesFaix Nov 07 '17 at 15:07
  • @SteveDanner I get a discrepancy (in UK local timezone) just by deducting 20 days rather than adding 1 day. This takes us back into daylight savings. I believe it's working as intended. How else could we change the date without changing the time? And how else could we find out the local time exactly 480 hours ago? – wardies Nov 07 '17 at 15:33
  • Yes, you are right. I agree...since UTC is NEVER DST and local times are. I'm finding that if the target is within DST, it's off. But if we're on Std time, its' not. Makes sense now. I'll delete previous comments. – Steve Danner Nov 07 '17 at 15:45

2 Answers2

5

A few points:

  • A TimeSpan represents an elapsed duration of time. Its "days" are standard days that are exactly 24 hours long.

  • On the day in question, in the local time zone, there were 25 hours because of the DST fall-back transition.

  • Addition on a DateTime object (either by + operator or Add... functions) is always done without regard for time zone. In other words, whatever the original .Kind property is, the output will have the same .Kind property, but the kind is not taken into consideration at all during addition/subtraction.

  • Thus, adding after converting to local time does not account for the 25 hour day. It is also problematic because it's possible to land on a local time value that does not exist, or exists twice.

So, when you say "does (or does not) account for daylight saving" in the code comments, technically you have it reversed. Since UTC has no transitions, the localizedFirst variable is the result of incorrectly assuming the local day is 24 hours long, while the addedFirst variable is the result of correctly applying the DST rules from the local time zone at the point that is 24 hours elapsed after the original point on the timeline.

Also, setting DateTimeKind.Unspecified will not change the effect for this case, because the DateTime.ToLocalTime() method will treat DateTimeKind.Unspecified as if it were DateTimeKind.Utc. See the table in the remarks of the documentation here. Indeed, I tried to replicate your results and could not get the value of dt2 to be any different just by changing the kind. If you can, please elaborate specifically on that point.

It's worth pointing out that eliminating this sort of confusion is exactly why the Noda Time library exists. In Noda Time, these are represented by two very different operations:

  • LocalDateTime + Period = LocalDateTime
  • Instant + Duration = Instant
Matt Johnson-Pint
  • 230,703
  • 74
  • 448
  • 575
  • If this was a greenfield project I would definitely be using NodaTime, but its a tough dependency to change after 10 years. – JamesFaix Nov 07 '17 at 18:12
  • @JamesFaix so you can provide reproducable example of how date kind unspecified helps or that was some mistake at your side? – Evk Nov 07 '17 at 18:27
  • @Evk I was using a DateTime of `11/5/2017 2:36:13pm UTC` and adding `TimeSpan.FromDays(699)`. If I created an unspecified DateTime from that one, by copying its Ticks value, the operations because commutative, but if I stuck with the UTC original they were not. – JamesFaix Nov 07 '17 at 23:23
4

This statement effectively asks for the day to be advanced but all other properties (hour, minute of day) to be left intact:

var localizedFirst = dt.ToLocalTime() + ts;

Whilst this statement asks what the local time will be after exactly 24 hours (elapsed time) have passed:

var addedFirst = (dt + ts).ToLocalTime();

It's a good argument for keeping everything in UTC until the last minute, then converting to Local Time for output.

Edit: Or conversely, if you don't want the local hours and minutes to change when you add or deduct days, convert to local time before adding the TimeSpan. However, as rightly pointed out by Matt Johnson, in this way you might end up with a local time that is either invalid (the clocks went forward over that time) or ambiguous (the clocks went back, so that time occurred twice). See his comment below for how to determine this.

wardies
  • 1,149
  • 10
  • 14
  • 1
    Yes, this is true, except that the result of `localizedFirst` may possibly be invalid or ambiguous. The `IsInvalidTime` and `IsAmbiguousTime` methods from `TimeZoneInfo` will tell you if they are or not, and then you can adjust accordingly if necessary. Ultimately, such "add calendar days with respect to a time zone" should probably be built in to the framework, but presently it is not. – Matt Johnson-Pint Nov 07 '17 at 17:33