feat: handle timezones and conversions with NodaTime

This commit is contained in:
David Montesinos
2025-07-09 12:09:00 +02:00
parent 57a1257750
commit bb52402a17
13 changed files with 142 additions and 108 deletions

View File

@ -54,7 +54,6 @@ namespace Microsoft.Extensions.DependencyInjection
services.AddScoped<ILocalizationCookieService, LocalizationCookieService>();
services.AddScoped<ICookieConsentService, CookieConsentService>();
services.AddScoped<IOutputCacheService, OutputCacheService>();
services.AddScoped<ITimeZoneService, TimeZoneService>();
// providers
services.AddScoped<ITextEditor, Oqtane.Modules.Controls.QuillJSTextEditor>();

View File

@ -3,7 +3,6 @@
@inherits ModuleBase
@inject NavigationManager NavigationManager
@inject IUserService UserService
@inject ITimeZoneService TimeZoneService
@inject IStringLocalizer<Index> Localizer
@inject IStringLocalizer<SharedResources> 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;
}

View File

@ -10,7 +10,6 @@
@inject IAliasService AliasService
@inject IThemeService ThemeService
@inject ISettingService SettingService
@inject ITimeZoneService TimeZoneService
@inject IServiceProvider ServiceProvider
@inject IStringLocalizer<Index> 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);

View File

@ -9,7 +9,6 @@
@inject INotificationService NotificationService
@inject IFileService FileService
@inject IFolderService FolderService
@inject ITimeZoneService TimeZoneService
@inject IJSRuntime jsRuntime
@inject IServiceProvider ServiceProvider
@inject IStringLocalizer<Index> 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)
{

View File

@ -5,7 +5,6 @@
@inject IUserService UserService
@inject IProfileService ProfileService
@inject ISettingService SettingService
@inject ITimeZoneService TimeZoneService
@inject IStringLocalizer<Add> Localizer
@inject IStringLocalizer<SharedResources> SharedLocalizer
@ -133,7 +132,7 @@
{
try
{
_timezones = await TimeZoneService.GetTimeZonesAsync();
_timezones = Utilities.GetTimeZones();
_profiles = await ProfileService.GetProfilesAsync(ModuleState.SiteId);
_settings = new Dictionary<string, string>();
_timezoneid = PageState.Site.TimeZoneId;

View File

@ -6,7 +6,6 @@
@inject IProfileService ProfileService
@inject ISettingService SettingService
@inject IFileService FileService
@inject ITimeZoneService TimeZoneService
@inject IServiceProvider ServiceProvider
@inject IStringLocalizer<Edit> Localizer
@inject IStringLocalizer<SharedResources> 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))
{

View File

@ -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

View File

@ -1,18 +0,0 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Oqtane.Models;
namespace Oqtane.Services
{
/// <summary>
/// Service to store and retrieve <see cref="TimeZone"/> entries
/// </summary>
public interface ITimeZoneService
{
/// <summary>
/// Get the list of time zones
/// </summary>
/// <returns></returns>
Task<List<TimeZone>> GetTimeZonesAsync();
}
}

View File

@ -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<List<TimeZone>> GetTimeZonesAsync()
{
return await GetJsonAsync<List<TimeZone>>($"{Apiurl}");
}
}
}

View File

@ -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/<controller>
[HttpGet]
public IEnumerable<Models.TimeZone> Get()
{
return TimeZoneInfo.GetSystemTimeZones()
.Select(item => new Models.TimeZone
{
Id = item.Id,
DisplayName = item.DisplayName
})
.OrderBy(item => item.DisplayName);
}
}
}

View File

@ -104,7 +104,6 @@ namespace Microsoft.Extensions.DependencyInjection
services.AddScoped<ISearchProvider, DatabaseSearchProvider>();
services.AddScoped<IImageService, ImageService>();
services.AddScoped<ICookieConsentService, ServerCookieConsentService>();
services.AddScoped<ITimeZoneService, TimeZoneService>();
// providers
services.AddScoped<ITextEditor, Oqtane.Modules.Controls.QuillJSTextEditor>();

View File

@ -22,6 +22,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.6" />
<PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageReference Include="System.Text.Json" Version="9.0.6" />
</ItemGroup>

View File

@ -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<TimeZone> 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;