diff --git a/Core/Resgrid.Framework/DateTimeHelpers.cs b/Core/Resgrid.Framework/DateTimeHelpers.cs index 9b518825..f6303c64 100644 --- a/Core/Resgrid.Framework/DateTimeHelpers.cs +++ b/Core/Resgrid.Framework/DateTimeHelpers.cs @@ -96,7 +96,12 @@ public static int FindDay(int year, int month, DayOfWeek day, int occurance) return resultedDay; } - public static DateTime ConvertToUtc(DateTime dateTime, string timeZone) + /// + /// Converts a local (department) time into UTC. When lenient is set, local times that are + /// ambiguous or skipped by a DST transition are resolved instead of throwing, which matters + /// for user typed timestamps (i.e. a 0130 log entry on the fall back day). + /// + public static DateTime ConvertToUtc(DateTime dateTime, string timeZone, bool lenient = false) { //var tzdbSource = NodaTime.TimeZones.TzdbDateTimeZoneSource.Default; //var tzi = TimeZoneInfo.FindSystemTimeZoneById(IanaToWindows(timeZone)); @@ -112,7 +117,9 @@ public static DateTime ConvertToUtc(DateTime dateTime, string timeZone) var ianaTz = TZConvert.WindowsToIana(timeZone); var localTime = LocalDateTime.FromDateTime(dateTime); - var zonedDateTime = localTime.InZoneStrictly(DateTimeZoneProviders.Tzdb[ianaTz]); + var zonedDateTime = lenient + ? localTime.InZoneLeniently(DateTimeZoneProviders.Tzdb[ianaTz]) + : localTime.InZoneStrictly(DateTimeZoneProviders.Tzdb[ianaTz]); return zonedDateTime.ToDateTimeUtc(); } diff --git a/Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs b/Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs index 68f2a5b2..f002d256 100644 --- a/Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs @@ -261,6 +261,35 @@ public async Task should_leave_the_run_pending_when_the_broker_publish_throws() run.Status.Should().Be((int)CommunicationTestRunStatus.Pending); } + [Test] + public async Task should_write_a_snapshot_value_for_an_untargeted_run_rather_than_leaving_the_column_null() + { + var testId = Guid.NewGuid(); + _communicationTestRepoMock.Setup(x => x.GetByIdAsync(testId)).ReturnsAsync(new CommunicationTest + { + CommunicationTestId = testId, + DepartmentId = 1, + TestEmail = true, + ResponseWindowMinutes = 60, + Active = true + }); + + // No targets, so the resolved audience is null -- the whole department. + _communicationTestTargetRepoMock.Setup(x => x.GetTargetsByTestIdAsync(testId)).ReturnsAsync(new List()); + + SetupRunAndResultPersistence(); + + var run = await _communicationTestService.StartTestRunAsync(testId, 1, TestData.Users.TestUser1Id); + + // An empty/NULL column is what marks a run as predating snapshots, which sends the + // builder back to the test's current targeting. An untargeted run therefore has to + // store a real value -- the JSON literal "null" -- so its snapshot still reads as + // "the whole department" after the test is re-targeted. Serializing to a null string + // here would silently reintroduce the drift this snapshot exists to prevent. + run.TargetedUserIds.Should().NotBeNullOrWhiteSpace(); + run.TargetedUserIds.Should().Be("null"); + } + [Test] public async Task should_not_rebuild_results_for_a_run_that_already_has_them() { diff --git a/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs index 85a729bd..4475a207 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs @@ -163,6 +163,9 @@ public async Task NewLog(NewLogView model, IFormCollection form, if (model.LogType == LogTypes.Run) { + // Times come off the form in the department's local time, everything is stored in UTC. + model.Call.LoggedOn = ConvertLogTimeToUtc(model.Call.LoggedOn, model.Department); + if (model.CallId == 0) { model.Call.DepartmentId = DepartmentId; @@ -201,10 +204,10 @@ public async Task NewLog(NewLogView model, IFormCollection form, var endedOn = form["Log.EndedOn"]; if (!String.IsNullOrWhiteSpace(startedOn)) - model.Log.StartedOn = DateTime.Parse(startedOn); + model.Log.StartedOn = ParseLogTimeToUtc(startedOn, model.Department); if (!String.IsNullOrWhiteSpace(endedOn)) - model.Log.EndedOn = DateTime.Parse(endedOn); + model.Log.EndedOn = ParseLogTimeToUtc(endedOn, model.Department); } if (model.LogType == LogTypes.Meeting) @@ -213,10 +216,10 @@ public async Task NewLog(NewLogView model, IFormCollection form, var endedOn = form["Log.EndedOn"]; if (!String.IsNullOrWhiteSpace(startedOn)) - model.Log.StartedOn = DateTime.Parse(startedOn); + model.Log.StartedOn = ParseLogTimeToUtc(startedOn, model.Department); if (!String.IsNullOrWhiteSpace(endedOn)) - model.Log.EndedOn = DateTime.Parse(endedOn); + model.Log.EndedOn = ParseLogTimeToUtc(endedOn, model.Department); } if (model.LogType == LogTypes.Coroner) @@ -228,7 +231,7 @@ public async Task NewLog(NewLogView model, IFormCollection form, var coronerOthers = form["coronerOthers"]; if (!String.IsNullOrWhiteSpace(startedOn)) - model.Log.StartedOn = DateTime.Parse(startedOn); + model.Log.StartedOn = ParseLogTimeToUtc(startedOn, model.Department); if (!String.IsNullOrWhiteSpace(caseNumber)) model.Log.ExternalId = caseNumber; @@ -254,19 +257,19 @@ public async Task NewLog(NewLogView model, IFormCollection form, unit.UnitId = i; if (!string.IsNullOrWhiteSpace(form["unit_dispatchtime_" + i])) - unit.Dispatched = DateTime.Parse(form["unit_dispatchtime_" + i]); + unit.Dispatched = ParseLogTimeToUtc(form["unit_dispatchtime_" + i], model.Department); if (!string.IsNullOrWhiteSpace(form["unit_enroutetime_" + i])) - unit.Enroute = DateTime.Parse(form["unit_enroutetime_" + i]); + unit.Enroute = ParseLogTimeToUtc(form["unit_enroutetime_" + i], model.Department); if (!string.IsNullOrWhiteSpace(form["unit_onscenetime_" + i])) - unit.OnScene = DateTime.Parse(form["unit_onscenetime_" + i]); + unit.OnScene = ParseLogTimeToUtc(form["unit_onscenetime_" + i], model.Department); if (!string.IsNullOrWhiteSpace(form["unit_releasedtime_" + i])) - unit.Released = DateTime.Parse(form["unit_releasedtime_" + i]); + unit.Released = ParseLogTimeToUtc(form["unit_releasedtime_" + i], model.Department); if (!string.IsNullOrWhiteSpace(form["unit_inquarterstime_" + i])) - unit.InQuarters = DateTime.Parse(form["unit_inquarterstime_" + i]); + unit.InQuarters = ParseLogTimeToUtc(form["unit_inquarterstime_" + i], model.Department); model.Log.Units.Add(unit); @@ -655,6 +658,24 @@ public PartialViewResult CreateUnitHtmlBlock(int unitId, string unitName) return PartialView("_UnitLogBlockPartial", model); } + /// + /// Log timestamps are typed into the form in the department's local time, but every date on + /// the Log (and the Call) is persisted as UTC and rendered back through TimeConverter. Without + /// this the entered time was stored verbatim and then shifted again on display. + /// + private static DateTime ParseLogTimeToUtc(string value, Department department) + { + return ConvertLogTimeToUtc(DateTime.Parse(value), department); + } + + private static DateTime ConvertLogTimeToUtc(DateTime localTime, Department department) + { + if (department == null || String.IsNullOrWhiteSpace(department.TimeZone) || localTime == DateTime.MinValue) + return localTime; + + return DateTimeHelpers.ConvertToUtc(localTime, department.TimeZone, true); + } + private async Task PopulateLogViewModel(NewLogView model) { model.Department = await _departmentsService.GetDepartmentByUserIdAsync(UserId);