Cross Platform TimeZone Handling for ASP.NET Core

Joe Audette

Updated

When building cross-platform web applications on ASP.NET Core, one may think that since the framework itself is cross-platform then your application will just work on any supported platform without having to do anything special.

That is mostly true but there are still some things that one has to consider during development to avoid some problems that can happen due to platform differences. For example, file systems on Linux are case sensitive, and therefore so are URLs, so you generally want to standardize on lowercase URLs.

Cross Platform TimeZone Handling

Also if building strings with file system paths, you need to always use Path.Combine or concatenate with Path.DirectorySeparatorChar rather than a backslash since that is Windows specific and forward slash is used on Linux/Mac.

Additionally, Time Zone IDs are different on Windows than on Linux/mac which uses IANA time zone IDs, and if you try to use the TimeZoneInfo class and you pass in an invalid time zone ID for the current platform it results in exceptions.

If you are storing time zone IDs for users or sites in a database and you migrate the site to a different platform, all of the stored time zone IDs could be invalid for the new platform. I ran into this problem in developing my Cloudscribe project, because it supports the concept of a site-level time zone that is used by default, but an authenticated user may choose their own time zone.

The approach I use is to always store dates in the database as UTC and then they can be adjusted to the given time zone for display as needed.

To solve the problem, I turned to the NodaTime project, which provides a comprehensive datetime library that is arguably superior to the DateTime and TimeZoneInfo classes provided in the .NET framework.

For applications and features that are focused around dates, such as calendaring and scheduling, I think I would go all in on using the NodaTime classes instead of the built-in framework classes, but for more common scenarios it makes sense to me to stick with the standard DateTime class for entity properties, since that will typically map directly to a corresponding database datetime on various database platforms without any friction.

So for my purposes, what I wanted was to use NodaTime as a means to standardize on IANA time zone IDs, and as a tool to convert back and forth from UTC datetime to various time zones as needed in a way that will work the same in any platform.

NodaTime has a built-in list of the IANA time zones, and it has the needed functionality for doing the conversions to and from UTC. While I am continuing to use the standard DateTime class for all my date properties on entities, I am no longer using the standard TimeZoneInfo class that is built into the .NET framework for converting dates back and forth, but instead using a little TimeZoneHelper class that I implemented for encapsulating the conversions so that none of my other code needs to know about NodaTime.

My TimeZoneHelper also exposes the list of TimeZoneIds provided by NodaTime so I can use it to populate a dropdown list for time zone selection, and I simply store the IANA time zone ID in the database for my sites and users.

This TimeZoneHelper class is not very large or complex and I think it would be useful for anyone else who wants a consistent way to handle TimeZones in a platform-neutral way. Feel free to use this code in your own projects!

using Microsoft.Extensions.Logging;
using NodaTime;
using NodaTime.TimeZones;
using System;
using System.Collections.Generic;
namespace cloudscribe.Web.Common
{
    public class TimeZoneHelper : ITimeZoneHelper
    {
        public TimeZoneHelper(
            IDateTimeZoneProvider timeZoneProvider,
            ILogger<TimeZoneHelper> logger = null
            )
        {
            tzSource = timeZoneProvider;
            log = logger;
        }

        private IDateTimeZoneProvider tzSource;
        private ILogger log;

        public DateTime ConvertToLocalTime(DateTime utcDateTime, string timeZoneId)
        {
            DateTime dUtc;
            switch(utcDateTime.Kind)
            {
                case DateTimeKind.Utc:
                dUtc = utcDateTime;
                    break;
                case DateTimeKind.Local:
                    dUtc = utcDateTime.ToUniversalTime();
                    break;
                default: //DateTimeKind.Unspecified
                    dUtc = DateTime.SpecifyKind(utcDateTime, DateTimeKind.Utc);
                    break;
            }

            var timeZone = tzSource.GetZoneOrNull(timeZoneId);
            if (timeZone == null)
            {
                if(log != null)
                {
                    log.LogWarning("failed to find timezone for " + timeZoneId);
                }
               
                return utcDateTime;
            }

            var instant = Instant.FromDateTimeUtc(dUtc);
            var zoned = new ZonedDateTime(instant, timeZone);
            return new DateTime(
                zoned.Year,
                zoned.Month,
                zoned.Day,
                zoned.Hour,
                zoned.Minute,
                zoned.Second,
                zoned.Millisecond,
                DateTimeKind.Unspecified);
        }

        public DateTime ConvertToUtc(
            DateTime localDateTime,
            string timeZoneId,
            ZoneLocalMappingResolver resolver = null
            )
        {
            if (localDateTime.Kind == DateTimeKind.Utc) return localDateTime;

            if (resolver == null) resolver = Resolvers.LenientResolver;
            var timeZone = tzSource.GetZoneOrNull(timeZoneId);
            if (timeZone == null)
            {
                if (log != null)
                {
                    log.LogWarning("failed to find timezone for " + timeZoneId);
                }
                return localDateTime;
            }

            var local = LocalDateTime.FromDateTime(localDateTime);
            var zoned = timeZone.ResolveLocal(local, resolver);
            return zoned.ToDateTimeUtc();
        }

        public IReadOnlyCollection<string> GetTimeZoneList()
        {
            return tzSource.Ids;
        }
    }
}

You’ll notice that this helper has some constructor dependencies, you can wire those up to be injected in the ConfigureServices method of your Startup class like this:

services.TryAddSingleton<IDateTimeZoneProvider>(new DateTimeZoneCache(TzdbDateTimeZoneSource.Default));
services.TryAddScoped<ITimeZoneHelper, TimeZoneHelper>();

Note that I made an interface ITimeZoneHelper, that this class implements, which would allow me to plugin different logic if I ever need or want to but you could simply remove the interface declaration if you use this in your own project. I hope you find this code useful!