This article walks through the process of capturing and detecting a user's time zone at the point of registration. This information can then be used to display time-related information in the local zone of the user. Note that, in most cases, you should still store datetime information in UTC; the time zone is only used for local display.
This setup assumes a site running Razor Pages in ASP.NET Core 2.2, although there's nothing particularly framework-specific in what follows. The same ideas should carry over quite easily to, for example, a regular ASP.NET MVC site.
We start with a basic registration page:
Register.cshtml
@page
@model RegisterModel
<h1>Create an account</h1>
<form method="post">
<div asp-validation-summary="ModelOnly"></div>
<div class="form-group">
<label asp-for="FirstName"></label>
<input asp-for="FirstName" class="form-control" />
<span asp-validation-for="FirstName" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="LastName"></label>
<input asp-for="LastName" class="form-control" />
<span asp-validation-for="LastName" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Email"></label>
<input asp-for="Email" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
Register.cshtml.cs
namespace WebApplication2.Pages
{
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
public class RegisterModel : PageModel
{
[Required]
[EmailAddress]
[BindProperty]
public string Email { get; set; }
[Required]
[Display(Name = "First name")]
[BindProperty]
public string FirstName { get; set; }
[Required]
[Display(Name = "Last name")]
[BindProperty]
public string LastName { get; set; }
public void OnGet()
{
}
public void OnPost()
{
if (ModelState.IsValid)
{
Debug.WriteLine($"Registration: FirstName = {FirstName}, LastName = {LastName}, Email = {Email}");
}
}
}
}
The first step is just to add a <select>
input with the time zones that the user can select. Since this application is running on Windows servers in Azure, we are going to store the id of the time zone as defined by TimeZoneInfo.GetSystemTimeZones. We populate a list of time zones and present this on the page:
Register.cshtml.cs
namespace WebApplication2.Pages
{
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using System;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Linq;
public class RegisterModel : PageModel
{
private readonly Lazy<SelectListItem[]> timezones = new Lazy<SelectListItem[]>(() => TimeZoneInfo.GetSystemTimeZones().Select(tz => new SelectListItem(tz.DisplayName, tz.Id, tz.Id == "Central Standard Time")).ToArray());
[Required]
[EmailAddress]
[BindProperty]
public string Email { get; set; }
[Required]
[Display(Name = "First name")]
[BindProperty]
public string FirstName { get; set; }
[Required]
[Display(Name = "Last name")]
[BindProperty]
public string LastName { get; set; }
[Required]
[Display(Name = "Time zone")]
[BindProperty]
public string TimeZone { get; set; }
public SelectListItem[] Timezones
{
get { return this.timezones.Value; }
}
public void OnGet()
{
}
public void OnPost()
{
if (ModelState.IsValid)
{
Debug.WriteLine($"Registration: FirstName = {FirstName}, LastName = {LastName}, Email = {Email}, TimeZone = {TimeZone}");
}
}
}
}
Register.cshtml
@page
@model RegisterModel
<h1>Create an account</h1>
<form method="post">
<div asp-validation-summary="ModelOnly"></div>
<div class="form-group">
<label asp-for="FirstName"></label>
<input asp-for="FirstName" class="form-control" />
<span asp-validation-for="FirstName" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="LastName"></label>
<input asp-for="LastName" class="form-control" />
<span asp-validation-for="LastName" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Email"></label>
<input asp-for="Email" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="TimeZone"></label>
<select asp-for="TimeZone" asp-items="Model.Timezones" class="form-control"></select>
<span asp-validation-for="TimeZone" class="text-danger"></span>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
This works, but it is not a great experience for the user. We can pick a default time zone, perhaps where we expect most of our users to reside (Central Standard Time in the example above), but that guess is going to be wrong for everyone else. For example, I happen to live in Eastern Standard Time. Ideally, we'd like to automatically detect and preselect the appropriate time zone.
The standard way to collect this information would be to use the resolvedOptions()
method on the Intl.DateTimeFormat
object. There are two challenges with this.
The first challenge is that browser support is still a little spotty. We can get around that by using the Moment Timezone library. This library provides a convenient guess()
method that uses the Intl/DateTimeFormat
object if it is available, but then falls back to some clever date offset trickery when that fails.
The second challenge is that the client-side detection will return a time zone based on the identifiers in the IANA Time Zone Database, not the Windows identifier. We could handle this one of two ways. First, of course, we could simply change our backend logic to use the IANA time zone ids. I decided not to go that route because I'd rather deal with the mapping at the point of registration and not during normal execution of the web site. But that's a call you will have to make based on what makes the most sense for your application. There are rumors that .Net Core / .Net Framework might move to using the IANA ids at some point, which would make this step obsolete. In any case, the code below does the mapping for now.
First, the server-side code is going to build a map from IANA time zone ids to Windows time zone ids. We can use the TimeZoneConverter
library to assist us with this mapping. Run the following from the Package Manager Console:
Install-Package TimeZoneConverter
Add the server-side code to create the map:
namespace WebApplication2.Pages
{
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Linq;
using TimeZoneConverter;
public class RegisterModel : PageModel
{
private readonly Lazy<SelectListItem[]> timezones = new Lazy<SelectListItem[]>(() => TimeZoneInfo.GetSystemTimeZones().Select(tz => new SelectListItem(tz.DisplayName, tz.Id, tz.Id == "Central Standard Time")).ToArray());
[Required]
[EmailAddress]
[BindProperty]
public string Email { get; set; }
[Required]
[Display(Name = "First name")]
[BindProperty]
public string FirstName { get; set; }
[Required]
[Display(Name = "Last name")]
[BindProperty]
public string LastName { get; set; }
[Required]
[Display(Name = "Time zone")]
[BindProperty]
public string TimeZone { get; set; }
public Dictionary<string, string> TimeZoneMap { get; private set; }
public SelectListItem[] Timezones
{
get { return this.timezones.Value; }
}
public void OnGet()
{
this.CreateTimeZoneMap();
}
public void OnPost()
{
if (ModelState.IsValid)
{
Debug.WriteLine($"Registration: FirstName = {FirstName}, LastName = {LastName}, Email = {Email}, TimeZone = {TimeZone}");
}
}
private void CreateTimeZoneMap()
{
this.TimeZoneMap = new Dictionary<string, string>();
foreach (string ianaName in TZConvert.KnownIanaTimeZoneNames)
{
if (TZConvert.TryIanaToWindows(ianaName, out string windowsId))
{
this.TimeZoneMap.Add(ianaName, windowsId);
}
}
}
}
}
And then the client-side code to select the detected time zone when the form is loaded:
@page
@model RegisterModel
<h1>Create an account</h1>
<form method="post">
<div asp-validation-summary="ModelOnly"></div>
<div class="form-group">
<label asp-for="FirstName"></label>
<input asp-for="FirstName" class="form-control" />
<span asp-validation-for="FirstName" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="LastName"></label>
<input asp-for="LastName" class="form-control" />
<span asp-validation-for="LastName" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Email"></label>
<input asp-for="Email" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="TimeZone"></label>
<select asp-for="TimeZone" asp-items="Model.Timezones" class="form-control"></select>
<span asp-validation-for="TimeZone" class="text-danger"></span>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
@section Scripts {
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js" integrity="sha256-4iQZ6BVL4qNKlQ27TExEhBN1HFPvAvAMbFavKKosSWQ=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.23/moment-timezone-with-data.min.js" integrity="sha256-15jnh2lee6Li94j6XCbw8PRzNZe29O/W9i97yXVyRmA=" crossorigin="anonymous"></script>
<script type="text/javascript">
var map = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(Model.TimeZoneMap));
var ianaTz = moment.tz.guess();
if (ianaTz) {
var inputTz = map[ianaTz];
if (inputTz) {
document.querySelector("#TimeZone").value = inputTz;
}
}
</script>
}
The client-side code emits a (large) object that maps all the IANA time zones to the appropriate Windows time zone. The object will look something like this:
var map = {
"Australia/Darwin":"AUS Central Standard Time",
"Australia/Sydney":"AUS Eastern Standard Time",
...,
"Pacific/Midway":"UTC-11",
"Pacific/Ponape":"Central Pacific Standard Time"
};
(There is obviously some optimization that can be done with this, both server-side and client-side, but that's outside the scope of this article.)
The moment.js
library detects the user's time zone, and if that succeeds, it looks for a matching key in the map
object. If one if found, then the matching select
option is selected. The end result is that the user's detected time zone is preselected when the form is loaded:
At this point, everything is working but there is one small thing to fix. When a user submits an invalid form (missing email, for example), we are still emitting the time zone detection code. However, at that point the user has already selected a time zone, so we don't want to "re-guess", possibly overwriting the user's previous selection. The code above kind of works by accident: we are only creating the map on a GET request, so the client-side map will always be null
and fail to match. However, we can optimize this by only emitting the code for the client-side map when there is no value for our page model's TimeZone
property:
@page
@model RegisterModel
<h1>Create an account</h1>
<form method="post">
<div asp-validation-summary="ModelOnly"></div>
<div class="form-group">
<label asp-for="FirstName"></label>
<input asp-for="FirstName" class="form-control" />
<span asp-validation-for="FirstName" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="LastName"></label>
<input asp-for="LastName" class="form-control" />
<span asp-validation-for="LastName" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Email"></label>
<input asp-for="Email" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="TimeZone"></label>
<select asp-for="TimeZone" asp-items="Model.Timezones" class="form-control"></select>
<span asp-validation-for="TimeZone" class="text-danger"></span>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
@section Scripts {
@if (Model.TimeZoneMap != null)
{
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js" integrity="sha256-4iQZ6BVL4qNKlQ27TExEhBN1HFPvAvAMbFavKKosSWQ=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.23/moment-timezone-with-data.min.js" integrity="sha256-15jnh2lee6Li94j6XCbw8PRzNZe29O/W9i97yXVyRmA=" crossorigin="anonymous"></script>
<script type="text/javascript">
var map = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(Model.TimeZoneMap));
var ianaTz = moment.tz.guess();
if (ianaTz) {
var inputTz = map[ianaTz];
if (inputTz) {
document.querySelector("#TimeZone").value = inputTz;
}
}
</script>
}
}
Really great post. Curious - how come you store the user's time zone (I assume you're storing it since you have the dropdown) rather than just capturing the user's current time zone at each login? Or maybe asked a different way, is the only reason for the drop down (and for storing the time zone) in case moment guesses wrong?
ReplyDeleteThanks for the feedback! In the solution that was being designed when I wrote this, we needed the user's time zone for more than client-side interaction. For example, nightly jobs that would send out emails and the schedule information on those emails needed to be localized to the user's time zone. But yes, if you only needed to adjust some datetimes on the fly on the client, you wouldn't necessary need anything like this. As with everything else in this business, "It depends!"
Delete