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
).