1

A couple of premises:

  1. By "prevailing time" I mean how it is handled locally (my industry uses this terminology). For example, Eastern Prevailing Time has a UTC offset of -05:00 except during DST when it is -04:00
  2. I find it much cleaner to handle range data by treating the end value as exclusive, rather than the hackish inclusive approach (where you have to subtract an epsilon from the first value beyond the end of your range).

For example, the range of values from 0 (inclusive) to 1 (exclusive), as per interval notation, is [0, 1), which is much more readable than [0, 0.99999999999...] (and is less prone to rounding issues and thus off-by-one errors, because the epsilon value depends on the data type being used).

With these two ideas in mind, how can I represent the final hour time range on the spring DST transition day, when the ending timestamp is invalid (i.e. there is no 2am, it instantly becomes 3am)?

[2019-03-10 01:00, 2019-03-10 02:00) in your time zone of choice that supports DST.

Putting the end time as 03:00 is quite misleading, as it looks like a 2-hour wide time range.

When I run it through this C# sample code, it blows up:

DateTime hourEnd_tz = new DateTime(2019, 3, 10, 0, 0, 0, DateTimeKind.Unspecified);//midnight on the spring DST transition day
hourEnd_tz = hourEnd_tz.AddHours(2);//other code variably computes this offset from business logic
TimeZoneInfo EPT = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");//includes DST rules
DateTime hourEnd_utc = TimeZoneInfo.ConvertTime(//interpret the value from the user's time zone
    hourEnd_tz,
    EPT,
    TimeZoneInfo.Utc);

System.ArgumentException: 'The supplied DateTime represents an invalid time. For example, when the clock is adjusted forward, any time in the period that is skipped is invalid. Parameter name: dateTime'

How might I handle this case (elsewhere I am already handling the autumn ambiguous times), without having to extensively refactor my time range class library?

Elaskanator
  • 1,135
  • 10
  • 28
  • 1
    Give up on `DateTime` itself, especially `DateTimeKind.Unspecified` - you're going to be better off with at least `DateTimeOffset` (if not switch to NodaTime). This also means the ending time is always valid, if in the wrong offset. Otherwise, what is it you're actually trying to accomplish with this? – Clockwork-Muse Sep 10 '19 at 22:07
  • 1
    Side note: There are non-hour-offset timezones. There are non-hour DST changes. In some timezones, DST changes at a time other than midnight (up to and including making "midnight" invalid). DST may happen in a different set of months (in the southern hemisphere, expect the jump to go the other way....). Timezones + DST are complicated, is why we're trying to figure out what you actually need to do. – Clockwork-Muse Sep 10 '19 at 22:25
  • After thinking about it for a couple days, I believe you're completely right that `DateTimeKind.Unspecified` needs to be avoided as much as possible (although I still have to continue interpreting SQL Server `DATETIME` instances because everybody else uses that type). I'll have to bite the bullet and refactor my code. P.S. I am not trying to implement my own DST rules, I'm letting the `TimeZoneInfo.ConvertTime` method handle it for me, and copying the DST rules to my custom time zone definitions from the standard identifiers. – Elaskanator Sep 12 '19 at 18:20

1 Answers1

1

Premise 1 is reasonable, though often the word "prevailing" is dropped and it's just called "Eastern Time" - either are fine.

Premise 2 is a best practice. Half-open ranges offer many benefits, such as not having to deal with date math involving an epsilon, or having to determine what precision the epsilon should have.

However, the range you're attempting to describe cannot be done with a date and time alone. It needs to also involve the offset from UTC. For US Eastern Time (using ISO 8601 format), it looks like this:

[2019-03-10T01:00:00-05:00, 2019-03-10T03:00:00-04:00)  (spring-forward)
[2019-11-03T02:00:00-04:00, 2019-11-03T02:00:00-05:00)  (fall-back)

You said:

Putting the end time as 03:00 is quite misleading, as it looks like a 2-hour wide time range.

Ah, but putting the spring end time as 02:00 would also be misleading, as that local time is not observed on that day. Only by combining the actual local date and time with the offset at that time can one be accurate.

You can use the DateTimeOffset structure in .NET to model these (or the OffsetDateTime structure in Noda Time).

How might I handle this case ... without having to extensively refactor my time range class library?

First, you'll need an extension method that lets you convert from DateTime to a DateTimeOffset for a specific time zone. You'll need this for two reasons:

  • The new DateTimeOffset(DateTime) constructor assumes that a DateTime with Kind of DateTimeKind.Unspecified should be treated as local time. There's no opportunity to specify a time zone.

  • The new DateTimeOffset(dt, TimeZoneInfo.GetUtcOffset(dt)) approach isn't good enough, because GetUtcOffset presumes you want the standard time offset in the case of ambiguity or invalidity. That is usually not the case, and thus you have to code the following yourself:

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));
}

Now that you have that defined (and put it in a static class somewhere in your project), you can call it where needed in your application.

For example:

TimeZoneInfo tz = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
DateTime dt = new DateTime(2019, 3, 10, 2, 0, 0, DateTimeKind.Unspecified);
DateTimeOffset dto = dt.ToDateTimeOffset(tz);  // 2019-03-10T03:00:00-04:00

or

TimeZoneInfo tz = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
DateTime dt = new DateTime(2019, 11, 3, 1, 0, 0, DateTimeKind.Unspecified);
DateTimeOffset dto = dt.ToDateTimeOffset(tz);  // 2019-11-03T01:00:00-04:00

or

TimeZoneInfo tz = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
DateTime dt = new DateTime(2019, 3, 10, 0, 0, 0, DateTimeKind.Unspecified);

DateTimeOffset midnight = dt.ToDateTimeOffset(tz);                     // 2019-03-10T00:00:00-05:00
DateTimeOffset oneOClock = midnight.AddHours(1);                       // 2019-03-10T01:00:00-05:00
DateTimeOffset twoOClock = oneOClock.AddHours(1);                      // 2019-03-10T02:00:00-05:00
DateTimeOffset threeOClock = TimeZoneInfo.ConvertTime(twoOClock, tz);  // 2019-03-10T03:00:00-04:00

TimeSpan diff = threeOClock - oneOClock;  // 1 hour

Note that subtracting two DateTimeOffset values correctly considers their offsets (whereas subtracting two DateTime values completely ignores their Kind).

Matt Johnson-Pint
  • 230,703
  • 74
  • 448
  • 575
  • P.S.: For more on "hidden 4th kind" see [More Fun with DateTime](https://codeblog.jonskeet.uk/2012/05/02/more-fun-with-datetime/), in the section titled "DateTime’s Deep Dark Secret". – Matt Johnson-Pint Sep 16 '19 at 17:04
  • I've been looking for more information about what best practices are for handling ranges, and I definitely agree from my experience that using open ranges is so much better. Do you have anything more on it? I am trying to convince my team to adopt the same, because I keep seeing them using nasty '23:59:59.997' values in SQL. Thanks. Also, something else I discovered is that parsing ISO timestamp strings [even with the Z identifier](https://stackoverflow.com/questions/10029099) does not return with kind of UTC. – Elaskanator Sep 16 '19 at 22:14
  • 1
    For parsing, use the overload that accepts a `DateTimeStyles` parameter, and pass `DateTimeStyles.RoundTripKind`. If you use `ParseExact`, match this with [the `K` specifier](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings#KSpecifier). – Matt Johnson-Pint Sep 16 '19 at 22:32
  • 1
    I have a section on ranges in [my Date and Time Fundamentals course on Pluralsight](https://www.pluralsight.com/courses/date-time-fundamentals) (in the final module "Common Mistakes and Best Practices" - "Working with Ranges"). I also talked about them in a conference talk "How to Have the Best Dates Ever", which is [here on YouTube](https://youtu.be/jMsqR_paQdA?t=2104). Ranges are about 35:04. – Matt Johnson-Pint Sep 16 '19 at 22:35