0

I am in the process of building a temporal expression library that must be properly globalized and therefore work in all available Time Zones.

Now I seem to be stuck as I'm not sure how to retrieve adjusted dates from DateTimeOffset objects in the correct Daylight Savings Time (DST) when the transition boundary is crossed using any variety of .Add to move days, hours, etc.

Interestingly, I figured out a work around for the local system's Time Zone but haven't found any way to apply the same strategy to any arbitrary Time Zone.

I was able to find a snippet (didn't keep source, sorry!) which tries to reverse lookup the Time Zone Info by offset but as there are multiple potential results, each of which likely have different DST rules that will not work. (There may be some optimizations available but the base premis is flawed I think)

public TimeZoneInfo GetTimeZoneInfo(DateTimeOffset Value)
{
    // Search available sytem time zones for a matching one
    foreach (var tzi in TimeZoneInfo.GetSystemTimeZones())
    {
        // Compare value offset with time zone offset
        if (tzi.GetUtcOffset(Value).Equals(Value.Offset))
        {
            return tzi;
        }
    }
}

This stuff can be a bit tedious to prove out so I've extracted the core issue into a couple methods and unit tests which will hopefully demonstrate the issue I'm facing.

public DateTimeOffset GetNextDay_Wrong(DateTimeOffset FromDateTimeOffset)
{
    // Cannot create a new DateTimeOffset using simply the supplied value's UtcOffset
    // because in PST, for example, it could be -7 or -8 depending on DST
    return new DateTimeOffset(FromDateTimeOffset.Date.AddDays(1), FromDateTimeOffset.Offset);
}

[TestMethod]
public void GetNextDay_WrongTest()
{
    var tz = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time");

    var workingDate = new DateTime(2009, 11, 2, 0, 0, 0);
    var failingDate = new DateTime(2009, 11, 1, 0, 0, 0);

    var workingDate_tz = new DateTimeOffset(workingDate, tz.GetUtcOffset(workingDate));
    var failingDate_tz = new DateTimeOffset(failingDate, tz.GetUtcOffset(failingDate));

    var actual_workingDate_tz = GetNextDay_Wrong(workingDate_tz);
    var actual_failingDate_tz = GetNextDay_Wrong(failingDate_tz);

    var expected_workingDate = new DateTime(2009, 11, 3, 0, 0, 0);
    var expected_failingDate = new DateTime(2009, 11, 2, 0, 0, 0);

    var expected_workingDate_tz = new DateTimeOffset(expected_workingDate, tz.GetUtcOffset(expected_workingDate));
    var expected_failingDate_tz = new DateTimeOffset(expected_failingDate, tz.GetUtcOffset(expected_failingDate));

    Assert.AreEqual(expected_workingDate_tz, actual_workingDate_tz, "Should have found the following day's midnight");
    Assert.AreEqual(expected_failingDate_tz, actual_failingDate_tz, "Failing date does not have the correct offset for it's DST");
}

public DateTimeOffset GetNextDay_LooksRight(DateTimeOffset FromDateTimeOffset)
{
    // Because we cannot create a new DateTimeOffset we simply adjust the one provided!
    var temp = FromDateTimeOffset;
    // Move back to midnight of the current day
    temp = temp.Subtract(new TimeSpan(temp.Hour, temp.Minute, temp.Second));
    // Now move to the next day
    temp = temp.AddDays(1);
    // Let the DateTimeOffset class do it's magic
    temp = temp.ToLocalTime();
    // Check if the time zone has changed
    if (FromDateTimeOffset.Offset != temp.Offset)
    {
        // Calculate the change amount (could be 30 mins or even stranger)
        var delta = FromDateTimeOffset.Offset - temp.Offset;
        // Adjust the temp value by the delta
        temp = temp.Add(delta);
    }
    return temp.ToLocalTime();
}

[TestMethod]
public void GetNextDay_LooksRightTest()
{
    // Everything is looking good and the test passes now, so we're home free yeah?

    // { To work this needs to match your system's configured Local Time Zone, I'm in PST }
    var tz = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time");

    var workingDate = new DateTime(2009, 11, 2, 0, 0, 0);
    var failingDate = new DateTime(2009, 11, 1, 0, 0, 0);

    var workingDate_tz = new DateTimeOffset(workingDate, tz.GetUtcOffset(workingDate));
    var failingDate_tz = new DateTimeOffset(failingDate, tz.GetUtcOffset(failingDate));

    var actual_workingDate_tz = GetNextDay_LooksRight(workingDate_tz);
    var actual_failingDate_tz = GetNextDay_LooksRight(failingDate_tz);

    var expected_workingDate = new DateTime(2009, 11, 3, 0, 0, 0);
    var expected_failingDate = new DateTime(2009, 11, 2, 0, 0, 0);

    var expected_workingDate_tz = new DateTimeOffset(expected_workingDate, tz.GetUtcOffset(expected_workingDate));
    var expected_failingDate_tz = new DateTimeOffset(expected_failingDate, tz.GetUtcOffset(expected_failingDate));

    Assert.AreEqual(expected_workingDate_tz, actual_workingDate_tz, "Should have found the following day's midnight");
    Assert.AreEqual(expected_failingDate_tz, actual_failingDate_tz, "Failing date does not have the correct offset for it's DST");
}

[TestMethod]
public void GetNextDay_LooksRight_FAILTest()
{
    // Here is where the frustrating part is... aparantly the "magic" that DateTimeOffset provides only works for your systems Local Time Zone...

    // { To properly fail this cannot match your system's configured Local Time Zone, I'm in PST so I use EST }
    var tz = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");

    var workingDate = new DateTime(2009, 11, 2, 0, 0, 0);
    var failingDate = new DateTime(2009, 11, 1, 0, 0, 0);

    var workingDate_tz = new DateTimeOffset(workingDate, tz.GetUtcOffset(workingDate));
    var failingDate_tz = new DateTimeOffset(failingDate, tz.GetUtcOffset(failingDate));

    var actual_workingDate_tz = GetNextDay_LooksRight(workingDate_tz);
    var actual_failingDate_tz = GetNextDay_LooksRight(failingDate_tz);

    var expected_workingDate = new DateTime(2009, 11, 3, 0, 0, 0);
    var expected_failingDate = new DateTime(2009, 11, 2, 0, 0, 0);

    var expected_workingDate_tz = new DateTimeOffset(expected_workingDate, tz.GetUtcOffset(expected_workingDate));
    var expected_failingDate_tz = new DateTimeOffset(expected_failingDate, tz.GetUtcOffset(expected_failingDate));

    Assert.AreEqual(expected_workingDate_tz, actual_workingDate_tz, "Should have found the following day's midnight");
    Assert.AreEqual(expected_failingDate_tz, actual_failingDate_tz, "Failing date does not have the correct offset for it's DST");
}
Perry
  • 528
  • 2
  • 12
  • You can't make this work. Use UTC. – Hans Passant Jan 06 '10 at 15:58
  • I can't use UTC because, for example, when representing a recurrance pattern such as Days Of the Week (which allows selection of 1-N days) I need to be able to resolve the next time where that recurrance happens and it has to be valid from the perspective of the user issuing the query. Given the pattern MTWTh, it could be Friday in UTC while still Thurstay PST and if calculated purely with UTC would return {date} 00:00:00.000 -00:00 when it needs to be {date} 00:00:00.000 -08:00 which is 8 hours off. – Perry Jan 06 '10 at 20:05
  • BTW, I am hoping there is a more elegant solution but in the mean time I am working on a wrapper class which tracks both the DateTimeOffset and the TimeZoneInfo.Id it was created with so that I'll have the info needed to properly generate a new DateTimeOffset but it a) seems to duplicate the value of the DateTimeOffset class and b) makes the API much less friendly (maybe I can add some helpers/extension methods to clean it back up a bit...) – Perry Jan 06 '10 at 20:47
  • Related question - http://stackoverflow.com/questions/2532729/daylight-saving-time-and-timezone-best-practices – Oded Jul 23 '10 at 20:24

1 Answers1

0

The underlying issue with this case is that there is not enough information to perform the appropriate Time Zone conversions. Simple offset is not specific enough to infer the Time Zone because for a given offset at a specific moment there can be multiple potential Time Zones. Because the DateTimeOffset object does not capture and maintain the information about the Time Zone it was created with it must be the responsibility of something external to that class to maintain this relationship.

The thing that threw me off the scent was the call ToLocalTime() which I later realized was in fact introducing an implied TimeZoneInfo into the calculation, that of the configured local time for the machine and I believe that internally the DateTimeOffset must be converting to UTC by simply removing the configured offset and then creating a new DateTimeOffset class using the constructor taking DateTime [in UTC] and TimeZoneInfo [from local system] to produce the correct dst aware resulting date.

Given this limitation I no longer see any value in the DateTimeOffset class over the equally accurate and more valuable combination of DateTime and TimeZoneInfo.

Perry
  • 528
  • 2
  • 12
  • There is value in `DateTimeOffset`. See [here](http://stackoverflow.com/a/14269924/634824). What you are talking about in the last paragraph has no .Net BCL implementation, but NodaTime calls it a [ZonedDateTime](http://noda-time.googlecode.com/hg/docs/api/html/T_NodaTime_ZonedDateTime.htm) which also has value - just a different one. – Matt Johnson-Pint Feb 13 '13 at 01:20