From bb52402a173e42bc9cb77649d3dc6d5efad9340d Mon Sep 17 00:00:00 2001 From: David Montesinos Date: Wed, 9 Jul 2025 12:09:00 +0200 Subject: [PATCH] feat: handle timezones and conversions with NodaTime --- .../OqtaneServiceCollectionExtensions.cs | 1 - .../Modules/Admin/Register/Index.razor | 3 +- Oqtane.Client/Modules/Admin/Site/Index.razor | 3 +- .../Modules/Admin/UserProfile/Index.razor | 3 +- Oqtane.Client/Modules/Admin/Users/Add.razor | 3 +- Oqtane.Client/Modules/Admin/Users/Edit.razor | 3 +- Oqtane.Client/Modules/ModuleBase.cs | 40 ++---- .../Services/Interfaces/ITimeZoneService.cs | 18 --- Oqtane.Client/Services/TimeZoneService.cs | 22 ---- .../Controllers/TimeZoneController.cs | 29 ----- .../OqtaneServiceCollectionExtensions.cs | 1 - Oqtane.Shared/Oqtane.Shared.csproj | 1 + Oqtane.Shared/Shared/Utilities.cs | 123 +++++++++++++++++- 13 files changed, 142 insertions(+), 108 deletions(-) delete mode 100644 Oqtane.Client/Services/Interfaces/ITimeZoneService.cs delete mode 100644 Oqtane.Client/Services/TimeZoneService.cs delete mode 100644 Oqtane.Server/Controllers/TimeZoneController.cs diff --git a/Oqtane.Client/Extensions/OqtaneServiceCollectionExtensions.cs b/Oqtane.Client/Extensions/OqtaneServiceCollectionExtensions.cs index 9a89bce8..c5590d51 100644 --- a/Oqtane.Client/Extensions/OqtaneServiceCollectionExtensions.cs +++ b/Oqtane.Client/Extensions/OqtaneServiceCollectionExtensions.cs @@ -54,7 +54,6 @@ namespace Microsoft.Extensions.DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); // providers services.AddScoped(); diff --git a/Oqtane.Client/Modules/Admin/Register/Index.razor b/Oqtane.Client/Modules/Admin/Register/Index.razor index 712ba186..025c86c0 100644 --- a/Oqtane.Client/Modules/Admin/Register/Index.razor +++ b/Oqtane.Client/Modules/Admin/Register/Index.razor @@ -3,7 +3,6 @@ @inherits ModuleBase @inject NavigationManager NavigationManager @inject IUserService UserService -@inject ITimeZoneService TimeZoneService @inject IStringLocalizer Localizer @inject IStringLocalizer SharedLocalizer @inject ISettingService SettingService @@ -115,7 +114,7 @@ { _passwordrequirements = await UserService.GetPasswordRequirementsAsync(PageState.Site.SiteId); _allowsitelogin = bool.Parse(SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:AllowSiteLogin", "true")); - _timezones = await TimeZoneService.GetTimeZonesAsync(); + _timezones = Utilities.GetTimeZones(); _timezoneid = PageState.Site.TimeZoneId; _initialized = true; } diff --git a/Oqtane.Client/Modules/Admin/Site/Index.razor b/Oqtane.Client/Modules/Admin/Site/Index.razor index 172c4225..fcc9557f 100644 --- a/Oqtane.Client/Modules/Admin/Site/Index.razor +++ b/Oqtane.Client/Modules/Admin/Site/Index.razor @@ -10,7 +10,6 @@ @inject IAliasService AliasService @inject IThemeService ThemeService @inject ISettingService SettingService -@inject ITimeZoneService TimeZoneService @inject IServiceProvider ServiceProvider @inject IStringLocalizer Localizer @inject INotificationService NotificationService @@ -508,7 +507,7 @@ Site site = await SiteService.GetSiteAsync(PageState.Site.SiteId); if (site != null) { - _timezones = await TimeZoneService.GetTimeZonesAsync(); + _timezones = Utilities.GetTimeZones(); var settings = await SettingService.GetSiteSettingsAsync(site.SiteId); _pages = await PageService.GetPagesAsync(PageState.Site.SiteId); diff --git a/Oqtane.Client/Modules/Admin/UserProfile/Index.razor b/Oqtane.Client/Modules/Admin/UserProfile/Index.razor index 377b27d8..f993a7c1 100644 --- a/Oqtane.Client/Modules/Admin/UserProfile/Index.razor +++ b/Oqtane.Client/Modules/Admin/UserProfile/Index.razor @@ -9,7 +9,6 @@ @inject INotificationService NotificationService @inject IFileService FileService @inject IFolderService FolderService -@inject ITimeZoneService TimeZoneService @inject IJSRuntime jsRuntime @inject IServiceProvider ServiceProvider @inject IStringLocalizer Localizer @@ -404,7 +403,7 @@ _togglepassword = SharedLocalizer["ShowPassword"]; _allowtwofactor = (SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:TwoFactor", "false") == "true"); _profiles = await ProfileService.GetProfilesAsync(ModuleState.SiteId); - _timezones = await TimeZoneService.GetTimeZonesAsync(); + _timezones = Utilities.GetTimeZones(); if (PageState.User != null) { diff --git a/Oqtane.Client/Modules/Admin/Users/Add.razor b/Oqtane.Client/Modules/Admin/Users/Add.razor index 510c87e7..2387e88e 100644 --- a/Oqtane.Client/Modules/Admin/Users/Add.razor +++ b/Oqtane.Client/Modules/Admin/Users/Add.razor @@ -5,7 +5,6 @@ @inject IUserService UserService @inject IProfileService ProfileService @inject ISettingService SettingService -@inject ITimeZoneService TimeZoneService @inject IStringLocalizer Localizer @inject IStringLocalizer SharedLocalizer @@ -133,7 +132,7 @@ { try { - _timezones = await TimeZoneService.GetTimeZonesAsync(); + _timezones = Utilities.GetTimeZones(); _profiles = await ProfileService.GetProfilesAsync(ModuleState.SiteId); _settings = new Dictionary(); _timezoneid = PageState.Site.TimeZoneId; diff --git a/Oqtane.Client/Modules/Admin/Users/Edit.razor b/Oqtane.Client/Modules/Admin/Users/Edit.razor index c6401a47..002e64df 100644 --- a/Oqtane.Client/Modules/Admin/Users/Edit.razor +++ b/Oqtane.Client/Modules/Admin/Users/Edit.razor @@ -6,7 +6,6 @@ @inject IProfileService ProfileService @inject ISettingService SettingService @inject IFileService FileService -@inject ITimeZoneService TimeZoneService @inject IServiceProvider ServiceProvider @inject IStringLocalizer Localizer @inject IStringLocalizer SharedLocalizer @@ -204,7 +203,7 @@ _passwordrequirements = await UserService.GetPasswordRequirementsAsync(PageState.Site.SiteId); _togglepassword = SharedLocalizer["ShowPassword"]; _profiles = await ProfileService.GetProfilesAsync(PageState.Site.SiteId); - _timezones = await TimeZoneService.GetTimeZonesAsync(); + _timezones = Utilities.GetTimeZones(); if (PageState.QueryString.ContainsKey("id") && int.TryParse(PageState.QueryString["id"], out int UserId)) { diff --git a/Oqtane.Client/Modules/ModuleBase.cs b/Oqtane.Client/Modules/ModuleBase.cs index a066d159..59721817 100644 --- a/Oqtane.Client/Modules/ModuleBase.cs +++ b/Oqtane.Client/Modules/ModuleBase.cs @@ -507,24 +507,18 @@ namespace Oqtane.Modules if (datetime == null) return null; - TimeZoneInfo timezone = null; - try + string timezoneId = null; + + if (PageState.User != null && !string.IsNullOrEmpty(PageState.User.TimeZoneId)) { - if (PageState.User != null && !string.IsNullOrEmpty(PageState.User.TimeZoneId)) - { - timezone = TimeZoneInfo.FindSystemTimeZoneById(PageState.User.TimeZoneId); - } - else if (!string.IsNullOrEmpty(PageState.Site.TimeZoneId)) - { - timezone = TimeZoneInfo.FindSystemTimeZoneById(PageState.Site.TimeZoneId); - } + timezoneId = PageState.User.TimeZoneId; } - catch + else if (!string.IsNullOrEmpty(PageState.Site.TimeZoneId)) { - // The time zone ID was not found on the local computer + timezoneId = PageState.Site.TimeZoneId; } - return Utilities.UtcAsLocalDateTime(datetime, timezone); + return Utilities.UtcAsLocalDateTime(datetime, timezoneId); } public DateTime? LocalToUtc(DateTime? datetime) @@ -533,24 +527,18 @@ namespace Oqtane.Modules if (datetime == null) return null; - TimeZoneInfo timezone = null; - try + string timezoneId = null; + + if (PageState.User != null && !string.IsNullOrEmpty(PageState.User.TimeZoneId)) { - if (PageState.User != null && !string.IsNullOrEmpty(PageState.User.TimeZoneId)) - { - timezone = TimeZoneInfo.FindSystemTimeZoneById(PageState.User.TimeZoneId); - } - else if (!string.IsNullOrEmpty(PageState.Site.TimeZoneId)) - { - timezone = TimeZoneInfo.FindSystemTimeZoneById(PageState.Site.TimeZoneId); - } + timezoneId = PageState.User.TimeZoneId; } - catch + else if (!string.IsNullOrEmpty(PageState.Site.TimeZoneId)) { - // The time zone ID was not found on the local computer + timezoneId = PageState.Site.TimeZoneId; } - return Utilities.LocalDateAndTimeAsUtc(datetime, timezone); + return Utilities.LocalDateAndTimeAsUtc(datetime, timezoneId); } // logging methods diff --git a/Oqtane.Client/Services/Interfaces/ITimeZoneService.cs b/Oqtane.Client/Services/Interfaces/ITimeZoneService.cs deleted file mode 100644 index c31f90b6..00000000 --- a/Oqtane.Client/Services/Interfaces/ITimeZoneService.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Oqtane.Models; - -namespace Oqtane.Services -{ - /// - /// Service to store and retrieve entries - /// - public interface ITimeZoneService - { - /// - /// Get the list of time zones - /// - /// - Task> GetTimeZonesAsync(); - } -} diff --git a/Oqtane.Client/Services/TimeZoneService.cs b/Oqtane.Client/Services/TimeZoneService.cs deleted file mode 100644 index f7983b14..00000000 --- a/Oqtane.Client/Services/TimeZoneService.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Collections.Generic; -using System.Net.Http; -using System.Threading.Tasks; -using Oqtane.Documentation; -using Oqtane.Models; -using Oqtane.Shared; - -namespace Oqtane.Services -{ - [PrivateApi("Don't show in the documentation, as everything should use the Interface")] - public class TimeZoneService : ServiceBase, ITimeZoneService - { - public TimeZoneService(HttpClient http, SiteState siteState) : base(http, siteState) { } - - private string Apiurl => CreateApiUrl("TimeZone"); - - public async Task> GetTimeZonesAsync() - { - return await GetJsonAsync>($"{Apiurl}"); - } - } -} diff --git a/Oqtane.Server/Controllers/TimeZoneController.cs b/Oqtane.Server/Controllers/TimeZoneController.cs deleted file mode 100644 index 158d9f72..00000000 --- a/Oqtane.Server/Controllers/TimeZoneController.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Oqtane.Models; -using Oqtane.Shared; - -namespace Oqtane.Controllers -{ - [Route(ControllerRoutes.ApiRoute)] - public class TimeZoneController : Controller - { - public TimeZoneController() {} - - // GET: api/ - [HttpGet] - public IEnumerable Get() - { - return TimeZoneInfo.GetSystemTimeZones() - .Select(item => new Models.TimeZone - { - Id = item.Id, - DisplayName = item.DisplayName - }) - .OrderBy(item => item.DisplayName); - } - } -} diff --git a/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs b/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs index 98a392cc..9b4b09e9 100644 --- a/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs +++ b/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs @@ -104,7 +104,6 @@ namespace Microsoft.Extensions.DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); // providers services.AddScoped(); diff --git a/Oqtane.Shared/Oqtane.Shared.csproj b/Oqtane.Shared/Oqtane.Shared.csproj index 77dc05d9..3f699bd6 100644 --- a/Oqtane.Shared/Oqtane.Shared.csproj +++ b/Oqtane.Shared/Oqtane.Shared.csproj @@ -22,6 +22,7 @@ + diff --git a/Oqtane.Shared/Shared/Utilities.cs b/Oqtane.Shared/Shared/Utilities.cs index 95ae2cd1..bf2efe3e 100644 --- a/Oqtane.Shared/Shared/Utilities.cs +++ b/Oqtane.Shared/Shared/Utilities.cs @@ -1,4 +1,3 @@ -using Oqtane.Models; using System; using System.Collections.Generic; using System.Globalization; @@ -7,7 +6,14 @@ using System.Linq; using System.Net; using System.Text; using System.Text.RegularExpressions; + +using NodaTime; +using NodaTime.Extensions; + +using Oqtane.Models; + using File = Oqtane.Models.File; +using TimeZone = Oqtane.Models.TimeZone; namespace Oqtane.Shared { @@ -505,6 +511,7 @@ namespace Oqtane.Shared return $"[{@class.GetType()}] {message}"; } + //Time conversions with TimeZoneInfo public static DateTime? LocalDateAndTimeAsUtc(DateTime? date, string time, TimeZoneInfo localTimeZone = null) { if (date != null && !string.IsNullOrEmpty(time) && TimeSpan.TryParse(time, out TimeSpan timespan)) @@ -581,6 +588,120 @@ namespace Oqtane.Shared return (localDateTime?.Date, localTime); } + + //Time conversions with NodaTime (IANA) timezoneId + public static DateTime? LocalDateAndTimeAsUtc(DateTime? date, string time, string localTimeZoneId) + { + if (date != null && !string.IsNullOrEmpty(time) && TimeSpan.TryParse(time, out TimeSpan timespan)) + { + return LocalDateAndTimeAsUtc(date.Value.Date.Add(timespan), localTimeZoneId); + } + return null; + } + + public static DateTime? LocalDateAndTimeAsUtc(DateTime? date, DateTime? time, string localTimeZoneId) + { + if (date != null) + { + if (time != null) + { + return LocalDateAndTimeAsUtc(date.Value.Date.Add(time.Value.TimeOfDay), localTimeZoneId); + } + return LocalDateAndTimeAsUtc(date.Value.Date, localTimeZoneId); + } + return null; + } + + public static DateTime? LocalDateAndTimeAsUtc(DateTime? date, string localTimeZoneId) + { + if (date != null) + { + DateTimeZone localTimeZone; + + if (!string.IsNullOrEmpty(localTimeZoneId)) + { + localTimeZone = DateTimeZoneProviders.Tzdb.GetZoneOrNull(localTimeZoneId) ?? DateTimeZoneProviders.Tzdb.GetSystemDefault(); + } + else + { + localTimeZone = DateTimeZoneProviders.Tzdb.GetSystemDefault(); + } + + var localDateTime = LocalDateTime.FromDateTime(date.Value); + return localTimeZone.AtLeniently(localDateTime).ToDateTimeUtc(); + } + return null; + } + + public static DateTime? UtcAsLocalDate(DateTime? dateTime, string timeZoneId) + { + return UtcAsLocalDateAndTime(dateTime, timeZoneId).date; + } + + public static DateTime? UtcAsLocalDateTime(DateTime? dateTime, string timeZoneId) + { + var result = UtcAsLocalDateAndTime(dateTime, timeZoneId); + if (result.date != null && !string.IsNullOrEmpty(result.time) && TimeSpan.TryParse(result.time, out TimeSpan timespan)) + { + result.date = result.date.Value.Add(timespan); + } + return result.date; + } + + public static (DateTime? date, string time) UtcAsLocalDateAndTime(DateTime? dateTime, string timeZoneId) + { + DateTimeZone localTimeZone; + + if (!string.IsNullOrEmpty(timeZoneId)) + { + localTimeZone = DateTimeZoneProviders.Tzdb.GetZoneOrNull(timeZoneId) ?? DateTimeZoneProviders.Tzdb.GetSystemDefault(); + } + else + { + localTimeZone = DateTimeZoneProviders.Tzdb.GetSystemDefault(); + } + + DateTime? localDateTime = null; + string localTime = string.Empty; + + if (dateTime.HasValue && dateTime?.Kind != DateTimeKind.Local) + { + Instant instant; + + if (dateTime?.Kind == DateTimeKind.Unspecified) + { + // Treat Unspecified as Utc not Local. This is due to EF Core, on some databases, after retrieval will have DateTimeKind as Unspecified. + // All values in database should be UTC. + // Normal .net conversion treats Unspecified as local. + // https://docs.microsoft.com/en-us/dotnet/api/system.timezoneinfo.converttime?view=net-6.0 + instant = Instant.FromDateTimeUtc(new DateTime(dateTime.Value.Ticks, DateTimeKind.Utc)); + } + else + { + instant = Instant.FromDateTimeUtc(dateTime.Value); + } + + localDateTime = instant.InZone(localTimeZone).ToDateTimeOffset().DateTime; + } + + if (localDateTime != null && localDateTime.Value.TimeOfDay.TotalSeconds != 0) + { + localTime = localDateTime.Value.ToString("HH:mm"); + } + + return (localDateTime?.Date, localTime); + } + + public static List GetTimeZones() + { + return [.. DateTimeZoneProviders.Tzdb.GetAllZones() + .Select(tz => new TimeZone() + { + Id = tz.Id, + DisplayName = tz.ToString() + })]; + } + public static bool IsEffectiveAndNotExpired(DateTime? effectiveDate, DateTime? expiryDate) { DateTime currentUtcTime = DateTime.UtcNow;