SamProf / MatBlazor

Material Design components for Blazor and Razor Components
http://www.matblazor.com
MIT License
2.84k stars 386 forks source link

MatDatePicker TimeZone issue - inconsistency between input and picker #663

Open craigbrown opened 4 years ago

craigbrown commented 4 years ago

Describe the bug I've seen issues with timezones discussed in e.g. #164, but I think this is something different. I'm using a MatDatePicker on a server-size Blazor app hosted in Azure. When running in this environment, Blazor thinks the user's local time is UTC, so I have some JavaScript interop which gets the browser's actual timezone and converts dates to their local time.

I'm binding a DateTime value to MatDatePicker using @bind-Value. Let's say my timezone is UTC+5, and I set an initial time value of now (say, 10:00) with DateTimeKind set to Local. When I click the icon to open the calendar view, a value of 15:00 is shown in the time section. Now if I change the time in this calendar view back to 10:00, the input is updated to 20:00.

It looks like it's trying to "correct" the time to be in my timezone, but failing very badly.

To Reproduce Steps to reproduce the behavior:

  1. Run this demo solution in Azure. Alternatively run it locally, set your system time to whatever UTC is right now, set your timezone to UTC+0, and use a Chrome extension (like this one) to change your browser's timezone to something else.
  2. Click the calendar icon to bring up the date/time picker.
  3. Change the hour.
  4. Click elsewhere on the page to hide the date/time picker box.

Expected behavior I would expect the time displayed in the input and the time displayed in the date/time picker box to always be the same.

Screenshots Here is a gif of me running my demo solution locally with my system time changed to 10:09, my system timezone changed to UTC+0, and running a browser extension which presents my timezone as being UTC+5. MatBlazorDateIssue

achilleaskar commented 3 years ago

Did you figure out to fix this one?

craigbrown commented 3 years ago

I worked around this by removing the EnableTime=true argument. It means you can't change the time in the dropdown, but you can still change it by typing into the input box.

This does have the effect that every time you select a date from the dropdown, the time will default back to 0:00 and the date's DateTimeKind will change to DateTimeKind.Utc.

To counter this, I wrapped the MatDatePicker in my own component and added a ValueChanged callback which checks whether the date was changed via the dropdown (in which case the date's DateTimeKind will have changed to Utc), and if it was I replace 0:00 with the previously entered time.

Here's some code if you need it. You'll also need a TimeZoneService which you can find an example of here.

DateTimePicker.razor

@using MatBlazor
@inject TimeZoneService TimeZoneService

<MatDatePicker Value="@MatDatePickerValue" ValueExpression=@(() => MatDatePickerValue) ValueChanged=@(async (DateTime value) => await MatDatePickerValueChanged(value)) Enable24hours="true" Format="dd MMM yyyy HH:mm"></MatDatePicker>

@code {
    private DateTime _value;

    /// <summary>
    /// The selected DateTime.
    /// </summary>
    [Parameter]
    public DateTime Value
    {
        get => _value;
        set
        {
            _value = value;
            MatDatePickerValue = value;
        }
    }

    [Parameter]
    public EventCallback<DateTime> ValueChanged { get; set; }

    /// <summary>
    /// The DateTime bound to the MatDatePicker.
    /// </summary>
    private DateTime MatDatePickerValue { get; set; }

    private async Task MatDatePickerValueChanged(DateTime value)
    {
        // If the Kind is UTC, the Date was selected via the calendar picker. So adjust the time to be the same as it was before, rather than 00:00.
        if (value.Kind == DateTimeKind.Utc)
        {
            var offset = await TimeZoneService.GetTimezoneOffset();
            var newDateTime = value.Add(offset);
            var oldDateTime = MatDatePickerValue;
            MatDatePickerValue = new DateTime(newDateTime.Year, newDateTime.Month, newDateTime.Day, oldDateTime.Hour, oldDateTime.Minute, 0, DateTimeKind.Local);
        }
        // If the Kind is Local, the Date was changed via the user manually typing into the input box, so don't change anything.
        else
        {
            MatDatePickerValue = value;
        }
        // Update the user bound value
        _value = MatDatePickerValue;
        await ValueChanged.InvokeAsync(_value);
    }

}
jimthurston commented 3 years ago

Hi,

We've encountered the same problem with our application, which is used by various offices around the world so needs to support different time zones. The application is a .NET website hosted in Azure as an app service.

Using TimeZoneService.GetTimeZoneOffset appeared to work, and does most of the time, until a user in the US picks a date that falls in the week when the UK and US' daylight saving times are out of synch. Our example was 31st Oct 2020 - in the UK, the clocks have reset to GMT/UTC, but in the US they're still in summer time.

So...it's January 2021. Our US user (in EST) picks 31st October 2020 in the date picker. The TimeZoneService returns an offset value of -5 hours as currently (Jan 2021) the US is five hours behind UTC. But when this is added to the original value, the result is 30th Oct 2020 23:00 - because on that day the US was four hours behind UTC.

Typing this here I realise our app setting of GMT (used for something else) might be the culprit....but I figure it'd be useful to add in case others have had similar issues. More resolutions / workarounds welcome!

jimthurston commented 3 years ago

I believe I've resolved our issue by changing the method in the TimeZoneService to use the date selected in the DatePicker, rather than using the current date. In site.js:

function blazorGetTimezoneOffsetForDate(date) {
    return new Date(date).getTimezoneOffset();
}

Then in TimeZoneService.cs:

public async ValueTask<DateTimeOffset> GetTimezoneOffset(DateTimeOffset dateTime)
{
    int offsetInMinutes = await _jsRuntime.InvokeAsync<int>("blazorGetTimezoneOffsetForDate", dateTime);
    _userOffset = TimeSpan.FromMinutes(-offsetInMinutes);
    return dateTime.ToOffset(_userOffset.Value);
}

So then Craig's callback method would look like this:

private async Task MatDatePickerValueChanged(DateTime value)
    {
        // If the Kind is UTC, the Date was selected via the calendar picker. So adjust the time to be the same as it was before, rather than 00:00.
        if (value.Kind == DateTimeKind.Utc)
        {
            var offset = await TimeZoneService.GetTimezoneOffset(value);  // include the selected DateTime value
            var newDateTime = value.Add(offset);
            var oldDateTime = MatDatePickerValue;
            MatDatePickerValue = new DateTime(newDateTime.Year, newDateTime.Month, newDateTime.Day, oldDateTime.Hour, oldDateTime.Minute, 0, DateTimeKind.Local);
        }
        // If the Kind is Local, the Date was changed via the user manually typing into the input box, so don't change anything.
        else
        {
            MatDatePickerValue = value;
        }
        // Update the user bound value
        _value = MatDatePickerValue;
        await ValueChanged.InvokeAsync(_value);
    }

Hope this helps anyone else having similar issues.