18
votes

My local timezone is (UTC+10:00) Canberra, Melbourne, Sydney

Sat 31-Mar-2012 15:59 UTC = Sun 01-Apr-2012 02:59 +11:00
Sat 31-Mar-2012 16:00 UTC = Sun 01-Apr-2012 02:00 +10:00

Daylight savings finishes at 3 AM first Sunday in April and the clock wind back 1 hour.

Given the following code ....

DateTime dt1 = DateTime.Parse("31-Mar-2012 15:59", CultureInfo.CurrentCulture, DateTimeStyles.AssumeUniversal);

DateTime dt2 = DateTime.Parse("31-Mar-2012 15:59", CultureInfo.CurrentCulture, DateTimeStyles.AssumeUniversal).AddMinutes(1);
DateTime dt3 = DateTime.Parse("31-Mar-2012 16:00", CultureInfo.CurrentCulture, DateTimeStyles.AssumeUniversal);

Console.WriteLine("{0:yyyy-MMM-dd HH:mm:ss.ffff K}", dt1);
Console.WriteLine("{0:yyyy-MMM-dd HH:mm:ss.ffff K} ({1}) = {2:yyyy-MMM-dd HH:mm:ss.ffff K} ({3})", dt2, dt2.Kind, dt3, dt3.Kind);
Console.WriteLine("{0} : {1} : {2}", dt1.ToUniversalTime().Hour, dt2.ToUniversalTime().Hour, dt3.ToUniversalTime().Hour);

I get the following output

2012-Apr-01 02:59:00.0000 +11:00
2012-Apr-01 03:00:00.0000 +10:00 (Local) = 2012-Apr-01 02:00:00.0000 +10:00 (Local)
15 : 17 : 16

Adding 1 minute to the original datetime makes the local time 3AM but also set the offset to +10 hours. Adding 1 minute to the UTC date and parsing correctly sets the local time to 2 AM with a +10 UTC offset.

Repeating with

DateTime dt1 = new DateTime(2012, 03, 31, 15, 59, 0, DateTimeKind.Utc);

DateTime dt2 = new DateTime(2012, 03, 31, 15, 59, 0, DateTimeKind.Utc).AddMinutes(1);
DateTime dt3 = new DateTime(2012, 03, 31, 16, 0, 0, DateTimeKind.Utc);

or

DateTime dt1 = DateTime.Parse("31-Mar-2012 15:59", CultureInfo.CurrentCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);

DateTime dt2 = DateTime.Parse("31-Mar-2012 15:59", CultureInfo.CurrentCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal).AddMinutes(1);
DateTime dt3 = DateTime.Parse("31-Mar-2012 16:00", CultureInfo.CurrentCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal); 

gives

2012-Mar-31 15:59:00.0000 Z
2012-Mar-31 16:00:00.0000 Z (Utc) = 2012-Mar-31 16:00:00.0000 Z (Utc)
15 : 16 : 16

as expected

Repeating again with

DateTime dt1 = new DateTime(2012, 03, 31, 15, 59, 0, DateTimeKind.Utc).ToLocalTime();

DateTime dt2 = new DateTime(2012, 03, 31, 15, 59, 0, DateTimeKind.Utc).ToLocalTime().AddMinutes(1);
DateTime dt3 = new DateTime(2012, 03, 31, 16, 0, 0, DateTimeKind.Utc).ToLocalTime();

gives the original

2012-Apr-01 02:59:00.0000 +11:00
2012-Apr-01 03:00:00.0000 +10:00 (Local) = 2012-Apr-01 02:00:00.0000 +10:00 (Local)
15 : 17 : 16

Can anyone explain this ?

Indecently if I use the TimeZoneInfo to convert from UTC to AUS Eastern Standard Time I get the correct time, but I lose the offset information in the DateTime instance as the DateTime.Kind == DateTimeKind.Unspecified

== Additional scenario to highlight

This is just simple timespan adding, starting with an NON-ambiguous UTC date, 1 minute before Daylight savings finishes.

DateTime dt1 = new DateTime(2012, 03, 31, 15, 59, 0, DateTimeKind.Utc);  
DateTime dt2 = new DateTime(2012, 03, 31, 15, 59, 0, DateTimeKind.Utc).ToLocalTime();  

Console.WriteLine("Original in UTC     : {0:yyyy-MMM-dd HH:mm:ss.ffff K}", dt1);  
Console.WriteLine("Original in Local   : {0:yyyy-MMM-dd HH:mm:ss.ffff K}", dt1.ToLocalTime());  
Console.WriteLine("+ 1 Minute in Local : {0:yyyy-MMM-dd HH:mm:ss.ffff K}", dt1.AddMinutes(1).ToLocalTime());  
Console.WriteLine("+ 1 Minute in UTC   : {0:yyyy-MMM-dd HH:mm:ss.ffff K}", dt1.AddMinutes(1));  
Console.WriteLine("=====================================================");
Console.WriteLine("Original in UTC     : {0:yyyy-MMM-dd HH:mm:ss.ffff K}", dt2.ToUniversalTime());  
Console.WriteLine("Original in Local   : {0:yyyy-MMM-dd HH:mm:ss.ffff K}", dt2);  
Console.WriteLine("+ 1 Minute in Local : {0:yyyy-MMM-dd HH:mm:ss.ffff K}", dt2.AddMinutes(1));  
Console.WriteLine("+ 1 Minute in UTC   : {0:yyyy-MMM-dd HH:mm:ss.ffff K}", dt2.AddMinutes(1).ToUniversalTime());  

gives

Original in UTC : 2012-Mar-31 15:59:00.0000 Z
Original in Local : 2012-Apr-01 02:59:00.0000 +11:00
+ 1 Minute in Local : 2012-Apr-01 02:00:00.0000 +10:00
+ 1 Minute in UTC : 2012-Mar-31 16:00:00.0000 Z

=====================================================

Original in UTC : 2012-Mar-31 15:59:00.0000 Z
Original in Local : 2012-Apr-01 02:59:00.0000 +11:00
+ 1 Minute in Local : 2012-Apr-01 03:00:00.0000 +10:00
+ 1 Minute in UTC : 2012-Mar-31 17:00:00.0000 Z

2
DateTime does NOT "keep" the local offset. I merely shows the offset that would be in effect at that time. Since dt2 is always in local time, the view it has of the current hour IS "true". You should be using DateTimeOffset if you want to carry around the "as applied" offset msdn.microsoft.com/en-us/library/system.datetimeoffset.aspxIDisposable
... if that was true then I would have expected the third result from dt2 in the last scenario to be 03:00:00 +11:00, but it knows that DST has finished. It correctly switched to +10:00, but didn't take off the hour. DateTimeOffset shows the time as 03:00:00 +11:00, which is not valid for my local timezone.Robert Slaney
No, it knows that YOU SAID this was 3:00 as of 4/1/2012, so the offset AT THAT moment is +10:00IDisposable
I never said it was 03:00 +10, I added 1 minute to 2:59 +11. It should have resulted in 02:00 +10 The DateTime.Kind property of that date was LocalRobert Slaney
@RobertSlaney: That's the problem - that it was doing local arithmetic. When you've got a DateTimeKind of Local, it doesn't take any DST into account; you're not adding "elapsed" time, you're just adding to the local time.Jon Skeet

2 Answers

29
votes

I believe the problem is in terms of when the conversions are performed.

You're parsing assuming universal time, but then implicitly converting to a "local" kind - with a value of 2:59:59. When you ask that "local" value to add a minute, it's just adding a minute to the local value, with no consideration for time zone. When you then print the offset, the system is trying to work out the offset at the local time of 3am... which is +10.

So effectively you've got:

  • Parse step 1: treat string as universal (15:59 UTC)
  • Parse step 2: convert result to local (2:59 local)
  • Addition: in local time, no time zone values are applied (3:00 local)
  • Format step 1: offset is requested, so work out what that local time maps to (17:00 UTC)
  • Format step 2: compute offset as difference between local and universal (+10)

Yes, it's all a bit painful - DateTime is painful in general, which is the main reason I'm writing Noda Time, where there are separate types for "date/time in a zone" vs "local date/time" (or "local date" or "local time"), and it's obvious which you're using at any one point.

It's not clear to me what you're actually trying to achieve here - if you can be more specific, I can show you what you would do in Noda Time, although there may be some inherent ambiguities (conversions from local date/times to "zoned" date/times can have 0, 1 or 2 results).

EDIT: If the aim is merely to remember the time zone as well as the instant, in Noda Time you'd want ZonedDateTime, like this:

using System;
using NodaTime;

class Program
{
    static void Main(string[] args)
    {
        var zone = DateTimeZone.ForId("Australia/Melbourne");
        ZonedDateTime start = Instant.FromUtc(2012, 3, 31, 15, 59, 0)
                                     .InZone(zone);
        ZonedDateTime end = start + Duration.FromMinutes(1);

        Console.WriteLine("{0} ({1})", start.LocalDateTime, start.Offset);
        Console.WriteLine("{0} ({1})", end.LocalDateTime, end.Offset);
    }
}

See the notes on calendar arithmetic for some more information about this.

0
votes

My way of dealing with this is to treat DateTime's a bit like Floats - they require special handling for when you're manipulating them vs when you're showing them to the user. I use a little library I wrote to wrap them:

https://github.com/b9chris/TimeZoneInfoLib.Net

And always treat them as UTC + TimeZoneInfo. That way you can do all the typical math you'd normally do, operating solely UTC to UTC, and only deal with local DateTimes at the last step of showing them to the user in some nice format. Another bonus of this structure is you can more accurately show a clean timezone to the user in a format they're used to, rather than scraping around in the TimeZoneInfo class each time.