Search code examples
c#entity-frameworkblazor-server-sidetimezone-offset

What is a good way to handle TimeZone conversion in Blazor/ASP.NET when using Entity Framework?


Tl;dr

Blazor app which stores all DateTimes in the database in UTC format - but those DateTimes should be displayed to the user, or even made editable in the user's timezone.

Component (Radzen) DateTimePicker (problem/need surely arises also for other implementations) binds currently to the (UTC) property of an entity:

<RadzenDatePicker TValue="DateTime" @bind-Value=@entry.DateCreated ...

So, what is a good way to make the binding show the values in the user's local timezone but store the data as UTC?


Long Story

I've got a (server side) Blazor app and store my data in MSSQL using EF Core.

I know how to get the user's timezone (offset) using JS interop, for example:

function blazorGetTimezoneOffset() {
    return new Date().getTimezoneOffset();
}

For accssing this function, I have implemented a scoped TimeZoneService that gets registered in Startup.cs.

Hadn't tought about timezones and storage of the dates much until I implemented a statistics page, and noticed during a calculation with DateTime.Now that I haven't converted DateTime.Now to the users's local time, so the result was off a bit (the other dates, e.g. when entries where created, or e.g. the manually set 'publishing date' I did convert to the user's local time.

This got me thinking, that I should best store all dates in the database as UTC and not in the user's timezone. For example, how would I otherwise selected the latest X entries by date from a table, or entries for a specific date/time if I am not sure which timezone was used? If everything was stored in the 'neutral' time and later converted only for displaying/editing it would solve such issues.

So, my idea was, that my Entities get an additional NotMapped (attributed) property, let's call it DateLocal. When setting the property, the local timezone date is converted to UTC and stored in the existing (mapped) property. When reading the property, the value would be converted from the UTC format in the database to the current timezone the user has - dynamically read out using the TimeZone service.

This property than could be used, for example in a RadzenGrid component (notice the binding):

<RadzenGridColumn TItem="Entry" Property="Date" Title="Date">
    <Template Context="entry">
        <div>@entry.DateLocal.ToString("g")</div>
    </Template>
    <EditTemplate Context="entry">
        <RadzenDatePicker TValue="DateTime" @bind-Value=@entry.DateLocal ShowTime="true" ShowSeconds="false" HoursStep="1" MinutesStep="1" SecondsStep="10"  DateFormat="g" />
    </EditTemplate>
</RadzenGridColumn>

So I thought about ways to inject the TimeZoneService into my entities, but it looks like this does not work, as the database context (which needs apparently to be modified to be able to inject a service into my entities) cannot access a scoped service. Also I am not so sure this is a clean way to do things.

Then I thought, that maybe I shouldn't use the TimeZoneService in my entities, for stated reasons but rather add the user's timezone (offset) to his ApplicationUser entity (letting the user manage the value on his profile page). For some entities this would work, as there is a direct relation between the entity and the application user, but some entities can be shared between users, so there is no distinct relation ship available which I could use on the entity to make the conversion. So this also doesn't look as a suitable way to solve my issue.

Unfortunately I haven't found any other good solutions. I see that the storage of dates in UTC is recommended by many but I found no good information about the necessary timezone conversion.

Maybe I am missing something and I should do this completely different? Any ideas how to this should be done nicely?


Solution

  • ;TLDR: There is no free lunch. Blazor Server provides no support for converting DateTimeOffset values to a timezone which is different from UTC. Any such conversion must be done by user code.

    The Blazor Server Date Issue: Blazor Server, of course, runs on a Server. A server, such as an Azure AppService or VM runs in a Time Zone of UTC. All dates will then be in the UTC time zone, and displayed as such when using ToString() of all flavors. Azure DB also stores all dates as UTC.

    There are no plans by the Team to change this.

    On the server, the "local" time is UTC time. Thus, .ToLocalTime() actually means .ToUTCTime(). This can be confusing, because you typically think of the local time of the user. But it's the local time of the server.

    If you are using a DateTimeOffset and the offset is -5 Hours, you would think that ToLocalTime would be useful, but it will ADD +5 hours (because that is how many hours needs to be added to get UTC time.) Thus, in a DateTimeOffset field, you should not have any offsets. If you do, it means the value was not converted to a UTC date which has no offset. ToLocalTime means add or subtract the number of offset hours to get the 'local' time, based on the provided offset. It does not mean that the actual local offset is retrieved from the browser and used.

    Your Options:

    1. Translate from UTC to the correct user Timezone at the database level.

    With this option, you must get the Timezone from the User and store it in the Database. Then when querying a table, include "AT TIME ZONE 'PACIFIC STANDARD TIME' in the query. All this really does is add the correct number of offset hours to the DateTimeOffset, and modifies the Time portion accordingly. This will display the correct date PROVIDED you do not use ToLocalTime(). If you do, it will unconvert it back to the original UTC date which is not what your user wants to see. Just use ToString("f"), or whichever format string pleases you.

    2. Translate from UTC to the correct user Timezone at the Display level.

    With this option, you get the timezone info from the browser, and store it and use it during the session. See this blog post for an sample of how to implement this. (Too long to repeat here)

    Getting a user selected date INTO the database When a user selects a datetime with a DateTimePicker, he expects it to be in his local time zone. But in Blazor Server, it is not. When the user selects 12PM, he thinks he is selecting Eastern Standard Time. But he is in fact selecting 12PM UTC which is 5 hours later then EST. Step one, then, is converting the UTC DateTime, to his local time. This can be done like this:

    TimeZoneInfo.ConvertTimeBySystemTimeZoneId(value, "Eastern Standard Time");

    Now we have it in the correct, local timezone. On the server, however, the time needs to be in UTC, not EST. Yet another conversion is required to UTC:

    public DateTimeOffset ConvertFromLocalToUTC(DateTimeOffset value)
    {
        return  TimeZoneInfo.ConvertTimeBySystemTimeZoneId(value, "Eastern Standard Time").ToUniversalTime();
    }
    

    This DateTime can then be stored in the database and will round trip successfully. So many dates, so little time.

    To make these concept more clear, drop the following onto a Blazor Server Page and inspect the output when run on the server as well as you local dev machine.

    @code {
        //2023-12-04 11:00:00 -05:00
        public DateTimeOffset DateTimeOffset1 = new DateTimeOffset(2023, 12, 5, 18, 0, 0, TimeSpan.FromHours(-5));
    
        public DateTimeOffset DateTimeOffset2 = new DateTimeOffset(2023, 12, 5, 18, 0, 0, TimeSpan.FromHours(+5));
    }
    
    <p>@DateTimeOffset.Now</p>
    
    <p>@DateTimeOffset.UtcNow </p>
    
    
    <p>
    
        @DateTimeOffset1.ToString()
    </p>
    <p>
    
        @DateTimeOffset1.ToLocalTime().ToString()
    </p>
    <p>
        @DateTimeOffset2.ToString()
    
    </p>
    <p>
    
        @DateTimeOffset2.ToLocalTime().ToString()
    </p>