10

We have an app in ASP.NET that stores all user timezone data in Windows format (by TimeZoneInfo.Id).

We also use moment.js and moment.js TimeZone libraries to convert UTC data to user data on client side. It is a complex AngularJs application that needs to be do timezone conversion on client side.

So far we used NodaTime .NET library to convert Windows timezone ID to Moment.js timezone ID. It worked well for most common time zones. But we need to make this conversion 100% compatible.

Currently it appears, that there's no reliable way to map Windows timezone ID to IANA Time Zone data. There are a lot of discrepancies.

I believe modern JS apps deal with time zones often. And sometimes need to convert TZ exactly on server-side (C#) and client-side (JS).

Is there a way to strictly map/convert .NET TimeZoneInfo into Moment.js timezone object?

Rodion
  • 233
  • 1
  • 3
  • 7
  • 1
    moment-timezone uses IANA standard time zones. If you found a specific time zone that doesn't map correctly using the `WindowsToIana` function in the linked answer, please let me know which one. All `TimeZoneInfo` id's should be mappable. – Matt Johnson-Pint Jan 22 '15 at 17:07
  • Matt, this is not completely true. While moment.js uses IANA format, Windows timezone cannot be 100% mapped to IANA timezone. There will be many discrepancies. To solve the problem completely, we can transform TimeZoneInfo rules into moment.js zone data object. So there will be 100% Windows->Moment.js mapping. – Evgenyt Jan 22 '15 at 18:24
  • @Evgenyt - It depends on the level of granularity and history that are desired. From the standpoint of a user selecting their current time zone, and working with data within modern times, then [the CLDR mappings](http://unicode.org/repos/cldr/trunk/common/supplemental/windowsZones.xml) that Unicode maintains are reasonably accurate, and all zones are mappable in the windows->iana direction. You can even get finer accuracy if you use a country code, rather than selecting the "primary" (001) territory. Its only in the iana->windows direction that there are unmappable zones. – Matt Johnson-Pint Jan 22 '15 at 20:38
  • However, it doesn't necessarily mean that the exact rule definition for its entire history will match the details of the Windows `TimeZoneInfo` data. That usually doesn't matter though, because the IANA data is much more accurate and richer in history. So in that regard, I would agree with you that there would be discrepancies. But I would disagree that it would be a good move to map the Windows rules into the moment.js structure, given they have lower fidelity. – Matt Johnson-Pint Jan 22 '15 at 20:40
  • @matt-johnson I believe it depends on task. We have a web app, that needs exact map of TimeZoneInfo to moment.js. It uses both server-side and client-side time conversion. So we use Windows timezone data instead of IANA in momenbt.js. I agree that IANA timezones are more accurate, and this is one of the reasons, why sometimes we need exact conversion. Both IANA and Windows TZ data update over time, and not synchronously. I would just post code here to solve this particular issue. This is not about IANA. This is about Windows -> moment.js. – Evgenyt Jan 23 '15 at 07:16
  • 1
    I see your use case now, so I have re-opened the question. It makes sense that you would want the data to be the same on both sides. However, I believe it would be better to use IANA data on the server (via NodaTime), rather than to use TimeZoneInfo data on the client. – Matt Johnson-Pint Jan 27 '15 at 16:41

2 Answers2

9

TL;DR:

  • Keep using Noda Time on the server side
  • Choose whether to use BCL data or IANA data; I would personally recommend IANA, but it's your call. (Aside from anything else, IANA data is more clearly versioned.)
  • Use Noda Time to generate moment.js data so you know exactly what the client will use, and that it'll be consistent with what you do on the server
  • Work out a strategy for what happens when the data changes

Details:

And sometimes need to convert TZ exactly on server-side (C#) and client-side (JS).

You need to get exactly the same time zone data on both sides and equivalent implementations on both sides. This has problems because:

  • IANA time zone data is updated regularly (so you'd need to be able to say "use data 2015a" for example)
  • Windows time zone data is updated regularly
  • I wouldn't like to bet that every implementation of IANA rules is exactly the same, even though they should be
  • I know that the TimeZoneInfo implementation has changed over time, partly to remove some odd bugs and partly to include more data. (.NET 4.6 understands the concept of a time zone changing its standard offset over history; earlier versions don't)

With Noda Time you could pretty easily convert either BCL or IANA time zone data to moment.js format - and do so more reliably than Evgenyt's code, because TimeZoneInfo doesn't allow you to request transitions. (Due to bugs in TimeZoneInfo itself, there are small pockets where offsets can change just for a few hours - they shouldn't, but if you want to match TimeZoneInfo behaviour exactly, you'd need to be able to find all of those - Evgenyt's code won't always spot those.) Even if Noda Time doesn't mirror TimeZoneInfo exactly, it should be consistent with itself.

The moment.js format looks pretty simple, so as long as you don't mind shipping the data to the client, that's definitely an option. You need to think about what to do when the data changes though:

  • How do you pick it up on the server?
  • How do you cope with the client temporarily using old data?

If exact consistency is really important to you, you may well want to ship the time zone data to the client with a time zone data version... which the client can then present back to the server when it posts data. (I'm assuming it's doing so, of course.) The server could then either use that version, or reject the client's request and say there's more recent data.

Here's some sample code to convert Noda Time zone data into moment.js - it looks okay to me, but I haven't done much with it. It matches the documentation in momentjs.com... note that the offset has to be reversed because moment.js decides to use positive offsets for time zones which are behind UTC, for some reason.

using System;
using System.Linq;

using NodaTime;
using Newtonsoft.Json;

class Test
{
    static void Main(string[] args)
    {
        Console.WriteLine(GenerateMomentJsZoneData("Europe/London", 2010, 2020));
    }

    static string GenerateMomentJsZoneData(string tzdbId, int fromYear, int toYear)
    {
        var intervals = DateTimeZoneProviders
            .Tzdb[tzdbId]
            .GetZoneIntervals(Instant.FromUtc(fromYear, 1, 1, 0, 0),
                              Instant.FromUtc(toYear + 1, 1, 1, 0, 0))
            .ToList();

        var abbrs = intervals.Select(interval => interval.Name);
        var untils = intervals.Select(interval => interval.End.Ticks / NodaConstants.TicksPerMillisecond);
        var offsets = intervals.Select(interval => -interval.WallOffset.Ticks / NodaConstants.TicksPerMinute);
        var result = new { name = tzdbId, abbrs, untils, offsets };
        return JsonConvert.SerializeObject(result);
    }
}
Jon Skeet
  • 1,421,763
  • 867
  • 9,128
  • 9,194
  • Thanks Jon, I'm now using NodaTime and your code to enumerate untils/offsets. However your code fails so far, when running .NET/JavaScript tests. https://github.com/ScreenshotMonitor/WindowsTimeZoneToMomentJs/blob/noda/src/Pranas.WindowsTimeZoneToMomentJs/TimeZoneToMomentConverter.cs – Evgenyt Mar 12 '15 at 10:01
  • @Evgenyt: What are the tests testing, exactly, and what is the failure? (And which version of Noda Time are you using, and which version of .NET? Lots has been changing...) – Jon Skeet Mar 12 '15 at 10:04
  • NodaTime 1.3.1 and .NET 4.5, tests simply loop through days, and compares results of convertion using momentjs and TimeZoneInfo. https://github.com/ScreenshotMonitor/WindowsTimeZoneToMomentJs/blob/noda/src/Pranas.WindowsTimeZoneToMomentJs.Test/TimeZoneToMomentConverterTest.cs – Evgenyt Mar 12 '15 at 10:11
  • @Evgenyt: Right, you'll see differences there because of [bugs in TimeZoneInfo](http://codeblog.jonskeet.uk/2014/09/30/the-mysteries-of-bcl-time-zone-data/). I'm suggesting you use Noda Time's BclDateTimeZone to interpret the TimeZoneInfo *data* on the server too. At that point, you should get a consistent (and more accurate, IMO :) set of results. Basically that will get you the Windows time zone data, but with a cleaner implementation. Of course, I'm very biased on that front... – Jon Skeet Mar 12 '15 at 10:16
5

UPDATE

Jon suggested that you have to use NodaTime BCL or IANA data in both momentjs and .NET. Otherwise you'll get discrepancies. I should agree with this.

You cannot 100% reliably convert time in .NET 4.5 using TimeZoneInfo. Even if you convert it using NodaTime as suggested, or TimeZoneToMomentConverter as below.


ORIGINAL ANSWER

IANA and Windows timezone data update over time and have different granularity.

So if you want exactly the same conversion in .NET and moment.js - you have either to

  • use IANA everywhere (with NodaTime as suggested Matt),
  • use Windows timezone everywhere (convert TimeZoneInfo rules to moment.js format).

We went the second way, and implemented the converter.

It adds thread-safe cache to be more efficient, as it basically loops through dates (instead of trying to convert TimeZoneInfo rules themselves). In our tests it converts current Windows timezones with 100% accuracy (see tests on GitHub).

This is the code of the tool:

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Web.Script.Serialization;

namespace Pranas.WindowsTimeZoneToMomentJs
{
    /// <summary>
    /// Tool to generates JavaScript that adds MomentJs timezone into moment.tz store.
    /// As per http://momentjs.com/timezone/docs/
    /// </summary>
    public static class TimeZoneToMomentConverter
    {
        private static readonly DateTimeOffset UnixEpoch = new DateTimeOffset(1970, 1, 1, 0, 0, 0, 0, TimeSpan.Zero);
        private static readonly JavaScriptSerializer Serializer = new JavaScriptSerializer();
        private static readonly ConcurrentDictionary<Tuple<string, int, int, string>, string> Cache = new ConcurrentDictionary<Tuple<string, int, int, string>, string>();

        /// <summary>
        /// Generates JavaScript that adds MomentJs timezone into moment.tz store.
        /// It caches the result by TimeZoneInfo.Id
        /// </summary>
        /// <param name="tz">TimeZone</param>
        /// <param name="yearFrom">Minimum year</param>
        /// <param name="yearTo">Maximum year (inclusive)</param>
        /// <param name="overrideName">Name of the generated MomentJs Zone; TimeZoneInfo.Id by default</param>
        /// <returns>JavaScript</returns>
        public static string GenerateAddMomentZoneScript(TimeZoneInfo tz, int yearFrom, int yearTo, string overrideName = null)
        {
            var key = new Tuple<string, int, int, string>(tz.Id, yearFrom, yearTo, overrideName);

            return Cache.GetOrAdd(key, x =>
            {
                var untils = EnumerateUntils(tz, yearFrom, yearTo).ToArray();

                return string.Format(
@"(function(){{
    var z = new moment.tz.Zone(); 
    z.name = {0}; 
    z.abbrs = {1}; 
    z.untils = {2}; 
    z.offsets = {3};
    moment.tz._zones[z.name.toLowerCase().replace(/\//g, '_')] = z;
}})();",
                    Serializer.Serialize(overrideName ?? tz.Id),
                    Serializer.Serialize(untils.Select(u => "-")),
                    Serializer.Serialize(untils.Select(u => u.Item1)),
                    Serializer.Serialize(untils.Select(u => u.Item2)));
            });
        }

        private static IEnumerable<Tuple<long, int>> EnumerateUntils(TimeZoneInfo timeZone, int yearFrom, int yearTo)
        {
            // return until-offset pairs
            int maxStep = (int)TimeSpan.FromDays(7).TotalMinutes;
            Func<DateTimeOffset, int> offset = t => (int)TimeZoneInfo.ConvertTime(t, timeZone).Offset.TotalMinutes;

            var t1 = new DateTimeOffset(yearFrom, 1, 1, 0, 0, 0, TimeSpan.Zero);

            while (t1.Year <= yearTo)
            {
                int step = maxStep;

                var t2 = t1.AddMinutes(step);
                while (offset(t1) != offset(t2) && step > 1)
                {
                    step = step / 2;
                    t2 = t1.AddMinutes(step);
                }

                if (step == 1 && offset(t1) != offset(t2))
                {
                    yield return new Tuple<long, int>((long)(t2 - UnixEpoch).TotalMilliseconds, -offset(t1));
                }
                t1 = t2;
            }

            yield return new Tuple<long, int>((long)(t1 - UnixEpoch).TotalMilliseconds, -offset(t1));
        }
    }
}

You can also get it via NuGet:

PM> Install-Package Pranas.WindowsTimeZoneToMomentJs

And browser sources for code and tests on GitHub.

Evgenyt
  • 10,201
  • 12
  • 40
  • 44
  • I believe this will fail in some tiny corner cases where `TimeZoneInfo` decides to switch offsets for just a few hours - it can easily miss those quick transitions back and forth. See http://codeblog.jonskeet.uk/2014/09/30/the-mysteries-of-bcl-time-zone-data/ for more details and a little tool which will help you find those cases. – Jon Skeet Mar 11 '15 at 08:39