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);