Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions Core/Resgrid.Framework/DateTimeHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,12 @@
return resultedDay;
}

public static DateTime ConvertToUtc(DateTime dateTime, string timeZone)
/// <summary>
/// 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).
/// </summary>
public static DateTime ConvertToUtc(DateTime dateTime, string timeZone, bool lenient = false)
{
//var tzdbSource = NodaTime.TimeZones.TzdbDateTimeZoneSource.Default;
//var tzi = TimeZoneInfo.FindSystemTimeZoneById(IanaToWindows(timeZone));
Expand All @@ -112,7 +117,9 @@
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();
}
Expand Down Expand Up @@ -367,7 +374,7 @@
}

//http://stackoverflow.com/questions/381401/how-do-you-compare-datetime-objects-using-a-specified-tolerance-in-c
public class DateTimeWithin

Check warning on line 377 in Core/Resgrid.Framework/DateTimeHelpers.cs

View workflow job for this annotation

GitHub Actions / build-and-test

'DateTimeWithin' defines operator == or operator != but does not override Object.GetHashCode()

Check warning on line 377 in Core/Resgrid.Framework/DateTimeHelpers.cs

View workflow job for this annotation

GitHub Actions / build-and-test

'DateTimeWithin' defines operator == or operator != but does not override Object.Equals(object o)
{
public DateTimeWithin(DateTime dateTime, TimeSpan tolerance)
{
Expand Down
29 changes: 29 additions & 0 deletions Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CommunicationTestTarget>());

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()
{
Expand Down
41 changes: 31 additions & 10 deletions Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ public async Task<IActionResult> 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;
Expand Down Expand Up @@ -201,10 +204,10 @@ public async Task<IActionResult> 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)
Expand All @@ -213,10 +216,10 @@ public async Task<IActionResult> 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)
Expand All @@ -228,7 +231,7 @@ public async Task<IActionResult> 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;
Expand All @@ -254,19 +257,19 @@ public async Task<IActionResult> 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);

Expand Down Expand Up @@ -655,6 +658,24 @@ public PartialViewResult CreateUnitHtmlBlock(int unitId, string unitName)
return PartialView("_UnitLogBlockPartial", model);
}

/// <summary>
/// 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.
/// </summary>
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<NewLogView> PopulateLogViewModel(NewLogView model)
{
model.Department = await _departmentsService.GetDepartmentByUserIdAsync(UserId);
Expand Down
Loading