diff --git a/Core/Resgrid.Model/Helpers/TimeConverterHelper.cs b/Core/Resgrid.Model/Helpers/TimeConverterHelper.cs index 56bc3e232..bd47b5a70 100644 --- a/Core/Resgrid.Model/Helpers/TimeConverterHelper.cs +++ b/Core/Resgrid.Model/Helpers/TimeConverterHelper.cs @@ -12,28 +12,26 @@ public static class TimeConverterHelper { public static DateTime TimeConverter(this DateTime timestamp, Department department) { - DateTime newTime = timestamp; - TimeZoneInfo timeZoneInfo = null; - // If department is null we gotta just bail if (department == null) return timestamp; try { + string timeZone = "Pacific Standard Time"; // Default to Pacific as it's better then UTC + if (!String.IsNullOrEmpty(department.TimeZone)) - timeZoneInfo = - TZConvert.GetTimeZoneInfo( - DateTimeHelpers.ConvertTimeZoneString(department - .TimeZone)); // TimeZoneInfo.FindSystemTimeZoneById(DateTimeHelpers.ConvertTimeZoneString(department.TimeZone)); - else - timeZoneInfo = TZConvert.GetTimeZoneInfo("Pacific Standard Time");// TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time"); // Default to Pacific as it's better then UTC + timeZone = department.TimeZone; - if (timeZoneInfo != null) - { - newTime = TimeZoneInfo.ConvertTimeFromUtc(timestamp, timeZoneInfo); - } - return newTime; + // Resolve via NodaTime's embedded IANA database instead of TimeZoneInfo / + // TZConvert.GetTimeZoneInfo. The hardened (DHI) container ships without ICU and + // runs in globalization-invariant mode, where TimeZoneInfo cannot map a Windows + // zone id and throws TimeZoneNotFoundException. NodaTime carries its own tzdb and + // needs neither ICU nor the OS /usr/share/zoneinfo files. Mirrors TimeConverterToString. + var ianaTz = TZConvert.WindowsToIana(DateTimeHelpers.ConvertTimeZoneString(timeZone)); + + var instant = Instant.FromDateTimeUtc(DateTime.SpecifyKind(timestamp, DateTimeKind.Utc)); + return instant.InZone(DateTimeZoneProviders.Tzdb[ianaTz]).ToDateTimeUnspecified(); } catch (Exception ex) { @@ -101,21 +99,21 @@ public static string FormatForDepartment(this DateTime timestamp, Department dep public static TimeSpan GetOffsetForDepartment(Department department) { - TimeZoneInfo timeZoneInfo = null; TimeSpan timeSpan; try { + string timeZone = "Pacific Standard Time"; // Default to Pacific as it's better then UTC + if (!String.IsNullOrEmpty(department.TimeZone)) - timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(DateTimeHelpers.ConvertTimeZoneString(department.TimeZone)); - else - timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(DateTimeHelpers.WindowsToIana("Pacific Standard Time")); // Default to Pacific as it's better then UTC + timeZone = department.TimeZone; - timeSpan = timeZoneInfo.BaseUtcOffset; - var currentDateTime = DateTime.UtcNow.TimeConverter(department); + // NodaTime tzdb (no ICU / OS tzdata dependency, unlike TimeZoneInfo). GetUtcOffset + // already folds the active DST rule into the returned offset for the given instant. + var ianaTz = TZConvert.WindowsToIana(DateTimeHelpers.ConvertTimeZoneString(timeZone)); + var instant = Instant.FromDateTimeUtc(DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Utc)); - if (timeZoneInfo.GetAdjustmentRules() != null && timeZoneInfo.GetAdjustmentRules().Any()) - timeSpan = timeZoneInfo.GetAdjustmentRules().Where(timeZoneAdjustment => timeZoneAdjustment.DateStart <= currentDateTime && timeZoneAdjustment.DateEnd >= currentDateTime).Aggregate(timeSpan, (current, timeZoneAdjustment) => current + timeZoneAdjustment.DaylightDelta); + timeSpan = DateTimeZoneProviders.Tzdb[ianaTz].GetUtcOffset(instant).ToTimeSpan(); } catch (Exception ex) { diff --git a/Core/Resgrid.Services/CallEmailTemplates/ParklandCounty2Template.cs b/Core/Resgrid.Services/CallEmailTemplates/ParklandCounty2Template.cs index d0204a37a..8c3f35434 100644 --- a/Core/Resgrid.Services/CallEmailTemplates/ParklandCounty2Template.cs +++ b/Core/Resgrid.Services/CallEmailTemplates/ParklandCounty2Template.cs @@ -4,9 +4,11 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using NodaTime; using Resgrid.Model; using Resgrid.Model.Identity; using Resgrid.Model.Providers; +using TimeZoneConverter; namespace Resgrid.Services.CallEmailTemplates { @@ -51,8 +53,17 @@ public async Task GenerateCall(CallEmail email, string managingUser, List< var priorityString = c.Name.Substring(0, c.Name.IndexOf(char.Parse("-"))).Trim(); priorityChar = Regex.Replace(priorityString, @"\d", "").Trim(); - TimeZoneInfo timeZone = TimeZoneInfo.FindSystemTimeZoneById(department.TimeZone); - callTimeUtc = new DateTimeOffset(DateTime.Parse(data[0].Replace("Date:", "").Trim()), timeZone.BaseUtcOffset).UtcDateTime; + // NodaTime tzdb (no ICU / OS tzdata) — DHI runs in globalization-invariant mode where + // TimeZoneInfo.FindSystemTimeZoneById can't resolve a Windows zone id. InZoneLeniently is + // DST-aware (the prior BaseUtcOffset ignored DST, drifting an hour for ~8 months/year) and never throws. + // Default to Pacific (matches TimeConverterHelper) so WindowsToIana never receives a null/blank id + // and throws InvalidTimeZoneException, which would silently downgrade callTimeUtc to processing-time UTC. + var windowsTz = String.IsNullOrWhiteSpace(department.TimeZone) + ? "Pacific Standard Time" + : department.TimeZone; + var ianaTz = TZConvert.WindowsToIana(windowsTz); + var localCallTime = LocalDateTime.FromDateTime(DateTime.Parse(data[0].Replace("Date:", "").Trim())); + callTimeUtc = localCallTime.InZoneLeniently(DateTimeZoneProviders.Tzdb[ianaTz]).ToDateTimeUtc(); is2ndPage = c.Notes.Contains("WCT2ndPage"); diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index 0963ff8d3..ff554fad6 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; +using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Events; using Resgrid.Model.Providers; @@ -357,10 +358,12 @@ private static void AddCommonTimestampVariables(ScriptObject obj, string timeZon DateTime deptNow; try { - var tz = !string.IsNullOrWhiteSpace(timeZoneId) - ? TimeZoneInfo.FindSystemTimeZoneById(timeZoneId) - : TimeZoneInfo.Utc; - deptNow = TimeZoneInfo.ConvertTimeFromUtc(utcNow, tz); + // NodaTime tzdb (no ICU / OS tzdata). DHI runs in globalization-invariant mode where + // TimeZoneInfo.FindSystemTimeZoneById can't map a Windows zone id (TimeZoneNotFoundException). + // DateTimeHelpers.GetLocalDateTime resolves UTC -> department-local via the embedded tzdb. + deptNow = string.IsNullOrWhiteSpace(timeZoneId) + ? utcNow + : DateTimeHelpers.GetLocalDateTime(utcNow, timeZoneId); } catch {