You're Doing Interfaces Wrong
Interfaces are everywhere, but still we have tight coupling, and inflexibility. Dependency inversion is the answer.
.NET provides structs to help you manage time and timezones. They represent the bare-basics for what you need to handle time accurately.
.NET has classes built in to help you manage time. They represent the bare basics for what you need to get Time-zones working correctly. Here I'll discuss what you get out of the box to signpost you in the right direction, and traps to avoid.
A TimeSpan represents a duration of time. Here are some useful methods:
new Timespan(days, hours, minutes, seconds);
var ts = Timespan.FromHours(1.5) + Timespan.FromMinutes(6);
ts.TotalMinutes; // outputs 96
ts.Minutes; // outputs 36
ts.TotalHours; // outputs 1.6
ts.Hours; // outputs 1
TimeSpans always represent exact lengths of time, so they don't support concepts of like months or years. (If you want to find difference between dates by these amounts, Check out System.Data.Linq.SqlClient.SqlMethods.DateDiffMonth
and DateDiffYear
functions)
Leap seconds and daylight savings are ignored, so minutes and days are considered exact time lengths.
A DateTime represents a date and a time. It has constructors that accept the units of a DateTime you would expect, and which I won't go into. I will briefly point out that DateTime.Parse can accept a string to create a new DateTime, and that passing an ISO8601 DateTime (extended format) like "2019-06-24T13:01:24.400" is ideal because it avoids any formatting ambiguity between cultures. Even so, you can use other formats safely if you supply the CultureInfo argument.
Here are some useful methods:
new DateTime(2017, 7, 30);
var dt = DateTime.Parse("2019-06-24T13:01:24.4");
DateTime dt2;
dt2 = dt.AddSeconds(2);
dt2 = dt.AddMonths(4); // see note below
dt2 = dt.Add(new TimeSpan(0, 1, 15, 0));
dt2 = dt.Subtract(new TimeSpan(0, 1, 15, 0)); //returns Date
var ts = dt.Subtract(DateTime.Now); //returns TimeSpan difference
var xDay = dt2.DayOfWeek;
Note all the methods create new Dates - none of the methods modify the original date object. In fact, this is not possible at all, because DateTime is a struct.
The behaviour of the AddMonths function is a little peculiar by necessity. It adds 1 month to the new date, and then if the new date is invalid, it will subtract days until it is valid.
This means March 31 + 1 month = April 30.
And March 31 + 2 months = May 31
But, March 31 + 1 month + 1 month = May 30.
DateTimes support non-Gregorian calendars such as Persian, Chinese and Hijri, but this is beyond the scope of this article - see DateTime(Int32, Int32, Int32, Calendar).
Did you know that a DateTime has a concept of a timezone offset in it? There is a property of a DateTime called "Kind". Which has three values:
If you create a DateTimeOffset from a DateTime it's important to make sure that the "Kind" is correct.
You can freely convert times between Local and UTC via .ToUniversalTime()
and .ToLocalTime()
.
If you maintain the Kind, then you're fine, but you may find weird behaviour happens when you start comparing DateTimes of different DateTimeKinds together.
For web, keep in mind "Local" means the server's timezone - not the user's timezone. If this is all you need to support, you can get away with using DateTime with careful management.
Specifying a default serialisation DateTimeKind is useful for web development. If your kind is Utc, you will send Dates back to client with a UTC timezone signified. If local, you will send the EQUIVALENT Utc back to the client with a UTC timezone signified. Both of these are fine. However, if your kind is Unspecified, no timezone conversion occurs and no timezone is indicated in the message to the client. If your date makes it to JavaScript in this form, the client will assume the date is in the timezone OF THE USER. What behavior you actually want will depend on your application.
Specifying the default Kind is different depending on the server setup. In MVC Core, the kind can be set as follows:
services.AddMvc().AddJsonOptions(options => {
options.SerializerSettings.DateTimeZoneHandling = DateTimeZoneHandling.Utc;
});
A DateTimeOffset is effectively a DateTime and a TimeSpan. The TimeSpan is the offset from UTC. I.e. subtracting the timespan from the date and time will give you the equivalent UTC date and time.
Here are some useful methods:
var dto = DateTimeOffset.UtcNow;
dto = dt.AddSeconds(4);
dto = dto.ToOffset(TimeSpan.FromHours(1)); //time in UTC+1:00
var dt = DateTime.Parse("2019-06-24T13:01:24.4-06:20");
//the current date and local system offset
dto = DateTimeOffset.Now;
//the current date and local system offset (long version)
dto = new DateTimeOffset(DateTime.Now,
TimeZoneInfo.Local.GetUtcOffset(DateTime.UtcNow));
An offset from UTC is not the same thing as a timezone, as time-zones can have multiple UTC offsets that take place at different times of the year due to daylight savings or historical changes. This can be a common source of errors which users in daylight saving areas have learned to put up with.
DateTimeOffset.Parse can accept a string to create a new DateTimeOffset. Use ISO8601 where possible to avoid ambiguity.
TimeZoneInfo contains information about a particular timezone. You have the ability to create Timezones yourself, but using the system provided timezones is also possible:
System.TimeZoneInfo.GetSystemTimeZones();
When you use system time zones in a client executable or similar, keep in mind that if you save the TimeZone ID, you're not guaranteed another system will recognise the TimeZone by that ID - especially for different operating systems.
Despite TimeZoneInfo simplifying the process of working out exact dates across the globe, it's also another source of disagreement. Windows has a TimeZone list which is different to other operating systems, and different to the browser. Also, different versions of Windows have different TimeZones, which could make migration a challenge if the timezone is saved in the database by ID. Linux uses IANA timezones, which matches what modern web browsers tend to use. (By the way, NodaTime has a handy conversion feature for different timezone systems)
As a further note, as the System TimeZoneInfo definition list is stored in the registry settings, it's possible for the user to editing their timezone definitions directly or indirectly through some other program installation, though unlikely. This might be important for stand-alone executables.
With a DateTimeOffset and a target TimeZoneInfo, you can easily shift the offset to match the TimeZone.
Here is the wrong way to make your DateTimeOffset's timezone match:
TimeZoneInfo tzi = ...
dto.toOffset(tzi.BaseUtcOffset);
While in majority of situations this may work, many TimeZones implement daylight savings, and the above doesn't take this into account.
There are about 70 countries in the world that use Daylight Savings - including Australia, United States, Egypt, UK, Mexico and Brazil.
This means, at certain times of the year, the UtcOffset shifts backwards and forwards. The dates when the times shift themselves can sometimes change.
Thankfully, the operating system has these rules defined and updated regularly, and has a way to retrieve the offset that applies based on a date - another good reason to use GetSystemTimeZones().
dto.toOffset(tzi.getUtcOffset(dto));
Because a DateTimeOffset doesn't take timezones into account, simply adding days will not always be the best thing to do. If you are dealing with some kind of concept where time of day is preserved before/after daylight savings, (e.g. setting an alarm for 5PM every day), then the below is appropriate:
dto.toOffset(tzi.getUtcOffset(dto)).AddDays(1);
However, if you are dealing with a concept where you want to specifically add a duration of time, (i.e. 24 hours), then daylight savings could mean that the above code actually added 23 or 25 hours. Ironically, to ignore the effect of daylight savings, we have to CONSIDER daylight savings in our code.
dto.toOffset(tzi.BaseUtcOffset).AddDays(1);
GetBaseUtcOffset pretends daylight savings isn't possible for the timezone. This means it is almost the same as the above code, except it has the added mistake of "undoing" daylight saving that might already be in effect.
dto = dto.AddDays(1);
dto = dto.toOffset(tzi.getUtcOffest(dto)); //change Timezoneoffset for DST?
I used to think this was an incorrect method for adding a day to a time. After all, if adding 1 day makes the new time falls within the hour of a daylight saving change, then an invalid or ambiguous time has been specified. However in both situations, no exception is thrown. This is because whilst the time may be ambiguous or invalid for the timezone, it is never so for the specified offset.
When you try to find the UtcOffset for a time that is invalid in the timezone, you will get the offset for the next offset to be applied:
This is because even though the time you are looking for doesn't exist in the Timezone, A DateTimeOffset is just a UTC time and an offset, so it will always refer to some real instant/moment in time.
When you try to find the UtcOffset for a time that is ambiguous in the timezone, you will also get an offset, not an exception:
This is because even though the time you stated occurred twice in the timezone, it only occurred once for that UTC offset - it is still an instant, and you can work out which offset was enforced at that instant for the timezone being observed.
dto = dto.toOffset(TimeSpan.Zero).AddDays(1);
dto = dto.toOffset(tzi.getUtcOffset(dto));
Converting dto to a UTC offset (Timespan of zero) means we very clearly will avoid any daylight savings conversions when adding the day. Then we convert this new date to the timezone we want with timezones taken into account. It is basically the same logic as the other correct method, but I favour it because logically it makes it very clear that you are ignoring the effects of daylight savings.
dto = dto.AddHours(24);
The above is very straightforward - we just use hours to add a duration of time equaling a day, rather than increasing the DATE by a day, and most of the time this will work exactly as you want. HOWEVER Daylight Savings strikes again, and if you're unlucky enough to be adding time over the period where DST begins or ends, this logic will not compensate - meaning you may be 1 hour short or 1 hour long. The best way to facilitate this logic properly is to use Method 2 - BUT keep in mind when you convert BACK to local time from UTC you may still get into an unfortunate situation where your time is 2:10 AM local , and there are two 2:10 AMs that day...
For more advanced time concepts, NodaTime is a handy library that expresses more time-related concepts, and with less ambiguity than .NET.
Cover photo credit:Â Â Jon Tyson