diff --git a/Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs b/Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs
index 7006f40158..a2ecd7e659 100644
--- a/Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs
+++ b/Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs
@@ -812,6 +812,11 @@ private static Regex RenderCall(string renderer, string column) =>
/// is not optional: this scan keys on the renderer's NAME, so an unlisted one is invisible and its
/// sites would leave the census as the price of being fixed.
///
+ /// FormatForDisplay is the text form of ForDisplay (#4766): the same naive-UTC
+ /// contract, plus the UTC offset in the repeated autumn hour. The grid and caption sites moved from
+ /// ForDisplay(x).ToString(format) to it, and \bForDisplay does not match inside its name,
+ /// so it is registered here or those sites would leave the census.
+ ///
/// This list's completeness is the guard's own soft spot, and two checks cover it.
/// derives the ALIASES — a static
/// formatter over a DateTime reaching a renderer under a different name — and pins them at set
@@ -822,6 +827,7 @@ private static Regex RenderCall(string renderer, string column) =>
private static readonly (string Renderer, ClockFrame Expects)[] Renderers =
[
("ForDisplay", ClockFrame.Utc),
+ ("FormatForDisplay", ClockFrame.Utc),
("FormatServerTime", ClockFrame.Utc),
("FormatStoredUtc", ClockFrame.Utc),
("FormatServerClock", ClockFrame.ServerLocal),
@@ -832,7 +838,7 @@ private static readonly (string Renderer, ClockFrame Expects)[] Renderers =
/// instead of being one. A wrapper hides the renderer's NAME from the
/// render scan, which keys on it - so a column rendered through one is invisible to a census that
/// calls itself closed. That is not hypothetical: the four plan_correction stamps reached
- /// ForDisplay through ViewerDataService.PlanCorrection's Local(), and no scan
+ /// ForDisplay (now FormatForDisplay) through ViewerDataService.PlanCorrection's Local(), and no scan
/// here could see which renderer they were getting.
///
/// Declared with the renderer each one reaches and pinned at SET EQUALITY against the
@@ -848,10 +854,13 @@ private static readonly (string Renderer, ClockFrame Expects)[] Renderers =
///
private static readonly (string File, string Method, string Renderer)[] RenderWrappers =
[
- ("Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.PlanCorrection.cs", "Local", "ForDisplay"),
- ("Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs", "Local", "ForDisplay"),
- ("Darling/PerformanceMonitor.Darling.Viewer/ViewerHistoryRows.cs", "CollectionLocal", "ForDisplay"),
- ("Darling/PerformanceMonitor.Darling.Viewer/ViewerPostgresDisplay.cs", "Timestamp", "ForDisplay"),
+ ("Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.PlanCorrection.cs", "Local", "FormatForDisplay"),
+ ("Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs", "Local", "FormatForDisplay"),
+ /* #4766: the Default Trace row's bare renderer. A time read from the stored server wall clock cannot say which
+ pass of the repeated autumn hour it was in, so it goes through ForDisplay and never takes an offset. */
+ ("Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs", "StoredWallClock", "ForDisplay"),
+ ("Darling/PerformanceMonitor.Darling.Viewer/ViewerHistoryRows.cs", "CollectionLocal", "FormatForDisplay"),
+ ("Darling/PerformanceMonitor.Darling.Viewer/ViewerPostgresDisplay.cs", "Timestamp", "FormatForDisplay"),
("Lite/Services/LocalDataService.ConfigChanges.cs", "Local", "FormatServerTime"),
("Lite/Services/LocalDataService.SystemEvents.cs", "Local", "FormatServerTime"),
];
@@ -1585,6 +1594,20 @@ private static readonly (string Name, string Why)[] NonRenderingCallsOnACensusCo
+ "and does not word one"),
];
+ ///
+ /// Registered renderers that no code hands a census timestamp column. Declared with why, so the typo check
+ /// below (a registered renderer that reaches no census column is a mis-typed name) keeps its force for every
+ /// other renderer, and pinned both ways: a declared name that reaches a census column again is stale.
+ ///
+ private static readonly (string Renderer, string Why)[] RenderersWithNoCensusCall =
+ [
+ ("ForDisplay", "#4766: every grid and caption site that worded a census column moved to FormatForDisplay, "
+ + "which adds the repeated-hour UTC offset. What still calls ForDisplay hands it a DateTime that stays a "
+ + "DateTime (a FinOps or Query Store row property a XAML StringFormat words, the time-range slicer's plain "
+ + "labels, the heatmap's ticks, a discontinuity marker): none of those is a census column. It stays "
+ + "registered, so a new ForDisplay(row.SomeColumn) is judged for its clock frame like any renderer's"),
+ ];
+
///
/// The residual, with no shape assumption. Whatever its signature, no member may be handed a
/// census timestamp column unless it is a registered renderer, a declared alias, or declared as not
@@ -1654,12 +1677,27 @@ CollectorColumn declares. Their staleness is caught by
is left unguarded by relaxing it here. The other two directions ARE asserted: */
/* a registered renderer that reaches no census column is a typo in the map, and it would take
- its sites out of the judged population silently; */
+ its sites out of the judged population silently - unless it is declared in RenderersWithNoCensusCall
+ with why; */
Assert.Equal(
Array.Empty(),
- Renderers.Select(r => r.Renderer).Where(name => !applied.ContainsKey(name))
+ Renderers.Select(r => r.Renderer)
+ .Where(name => !applied.ContainsKey(name) && RenderersWithNoCensusCall.All(d => d.Renderer != name))
.OrderBy(x => x, StringComparer.Ordinal).ToArray());
+ /* a declared one that reaches a census column again is an exemption asserting nothing, and a declared
+ name that is not a registered renderer is a mis-typed exemption; each carries its reasoning; */
+ Assert.Equal(
+ Array.Empty(),
+ RenderersWithNoCensusCall.Select(d => d.Renderer).Where(name => applied.ContainsKey(name))
+ .OrderBy(x => x, StringComparer.Ordinal).ToArray());
+ Assert.All(
+ RenderersWithNoCensusCall,
+ d => Assert.Contains(Renderers, r => r.Renderer == d.Renderer));
+ Assert.All(
+ RenderersWithNoCensusCall,
+ d => Assert.False(string.IsNullOrWhiteSpace(d.Why), $"{d.Renderer}: an exemption with no reasoning"));
+
/* and a declared non-renderer that no longer appears is an exemption asserting nothing, which is
the direction an exemption list rots in. */
Assert.Equal(
@@ -1978,6 +2016,9 @@ colliding collector exists asserts nothing until then. */
"cpuTask.Result.Select(d => ViewerTimeHelper.ForDisplay(d.SampleTime).ToOADate())");
Assert.DoesNotMatch(RenderCall("ForDisplay", "sample_time"), "ViewerTimeHelper.ForDisplay(s.SampleTimeUtc).ToOADate()");
Assert.DoesNotMatch(RenderCall("FormatServerClock", "sample_time"), "ViewerTimeHelper.ForDisplay(d.SampleTime)");
+ /* The text renderer is its own name: it is found where it is called, and ForDisplay is not found inside it. */
+ Assert.Matches(RenderCall("FormatForDisplay", "sample_time"), "ViewerTimeHelper.FormatForDisplay(d.SampleTime, \"s\")");
+ Assert.DoesNotMatch(RenderCall("ForDisplay", "sample_time"), "ViewerTimeHelper.FormatForDisplay(d.SampleTime, \"s\")");
/* ProjectionAlias takes an alias that is the LAST column before FROM, with no comma after it. */
Assert.Equal(
@@ -2011,15 +2052,16 @@ the strip is load-bearing rather than tidy. */
Assert.Matches(WrapperSignature, " /// public static string Local(DateTime? utc)");
Assert.DoesNotMatch(WrapperSignature, WithoutComments(" /// public static string Local(DateTime? utc)"));
- /* And the two renderer families are distinguished, not merged: four names, two expectations. Three
+ /* And the two renderer families are distinguished, not merged: five names, two expectations. Four
take naive UTC and exactly ONE takes the server's own clock — asserted as a count rather than
left as a comment, because the defect class IS a value reaching the renderer for the other
frame, and a second server-local renderer appearing unnoticed would split that side. */
- Assert.Equal(4, Renderers.Length);
+ Assert.Equal(5, Renderers.Length);
Assert.Equal(2, Renderers.Select(r => r.Expects).Distinct().Count());
Assert.Equal(ClockFrame.ServerLocal, Renderers.Single(r => r.Renderer == "FormatServerClock").Expects);
Assert.Equal(ClockFrame.Utc, Renderers.Single(r => r.Renderer == "FormatServerTime").Expects);
Assert.Equal(ClockFrame.Utc, Renderers.Single(r => r.Renderer == "FormatStoredUtc").Expects);
+ Assert.Equal(ClockFrame.Utc, Renderers.Single(r => r.Renderer == "FormatForDisplay").Expects);
Assert.Single(Renderers, r => r.Expects == ClockFrame.ServerLocal);
}
diff --git a/Darling/Darling.Tests/QueryStoreClutterViewerSurfacesTests.cs b/Darling/Darling.Tests/QueryStoreClutterViewerSurfacesTests.cs
index e556ebfc25..668fbd0de1 100644
--- a/Darling/Darling.Tests/QueryStoreClutterViewerSurfacesTests.cs
+++ b/Darling/Darling.Tests/QueryStoreClutterViewerSurfacesTests.cs
@@ -319,7 +319,8 @@ public void EveryMeasuredColumn_BindsANumber_WithTheAbsenceOnTheBinding()
"RunsSlowestPct", "SlowestSharePct", "SlowestItemMsP50", "DominanceRatio",
"PlansPerQueryP95", "PlansPerQueryMax", "NewPlansPerDay", "OneShotFraction",
"DistinctPlans", "MaxPlansPerQuery", "StaleQueryThresholdDays", "PctOfCap",
- "OptionsCaptured",
+ /* OptionsCaptured is not here since #4766: the column binds OptionsCapturedText (a XAML StringFormat cannot
+ carry the offset of the repeated autumn hour) and sorts by the DateTime; ViewerGridTimeTextZoneTests pins it. */
})
{
var binding = Regex.Match(grid, @"\{Binding " + column + @"(?[^}]*)\}");
diff --git a/Darling/Darling.Tests/StoredServerClockRowTextTests.cs b/Darling/Darling.Tests/StoredServerClockRowTextTests.cs
new file mode 100644
index 0000000000..4d0bef1b4b
--- /dev/null
+++ b/Darling/Darling.Tests/StoredServerClockRowTextTests.cs
@@ -0,0 +1,258 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Data;
+using System.Linq;
+using System.Threading.Tasks;
+using PerformanceMonitor.Analysis.Baselines;
+using PerformanceMonitor.Common;
+using PerformanceMonitor.Darling.Viewer;
+using PerformanceMonitor.Ui;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4766: a time read from a STORED server wall-clock column prints as the plain wall time in the repeated autumn
+/// hour, never with a UTC offset.
+///
+/// Text that shows one instant appends the instant's offset there (ViewerTimeHelper.FormatForDisplay), and
+/// that is only true for a real UTC instant. A job step, a Default Trace event or a next scheduled run comes in as the
+/// server's own wall clock and goes to UTC through , which maps BOTH passes of a
+/// repeated local time to the first ("A stored server-local time cannot say which one it was"). Formatted with the
+/// offset, the second pass would print the first pass's: a step that ran at 01:30 the second time (06:30Z, -05:00) on a
+/// US Eastern server printed "-04:00". US Eastern falls back on 2026-11-01 at 06:00Z.
+///
+/* Serialized with the classes that flip the process-wide ViewerTimeHelper statics: every row renders through them. */
+[Collection("viewer-time-statics")]
+public sealed class StoredServerClockRowTextTests
+{
+ private const string EasternWindowsId = "Eastern Standard Time";
+ private const string Format = "yyyy-MM-dd HH:mm:ss";
+
+ private static DateTime Naive(int year, int month, int day, int hour, int minute = 0) =>
+ new(year, month, day, hour, minute, 0, DateTimeKind.Unspecified);
+
+ private static ServerClock Eastern => ServerClock.Resolve(EasternWindowsId, -300);
+
+ /// Server display mode on and the invariant culture for its lifetime, for a test
+ /// that reads rows (a row renders its time text when it is built) and then asserts on them. Restores all three.
+ private sealed class ServerModeScope : IDisposable
+ {
+ private readonly TimeDisplayMode _savedMode = ViewerTimeHelper.CurrentDisplayMode;
+ private readonly ServerClock _savedClock = ViewerTimeHelper.ActiveServerClock;
+ private readonly System.Globalization.CultureInfo _savedCulture = System.Globalization.CultureInfo.CurrentCulture;
+
+ public ServerModeScope(ServerClock clock)
+ {
+ ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+ ViewerTimeHelper.ActiveServerClock = clock;
+ System.Globalization.CultureInfo.CurrentCulture = System.Globalization.CultureInfo.InvariantCulture;
+ }
+
+ public void Dispose()
+ {
+ ViewerTimeHelper.CurrentDisplayMode = _savedMode;
+ ViewerTimeHelper.ActiveServerClock = _savedClock;
+ System.Globalization.CultureInfo.CurrentCulture = _savedCulture;
+ }
+ }
+
+ /// A system_health row: its event time is the XE @timestamp, a real UTC instant.
+ private static string TrueUtcRowText(DateTime naiveUtc) =>
+ new SchedulerIssueRow(new SchedulerIssueRecord { EventTime = naiveUtc }).EventTimeLocal;
+
+ // ── Job History ──
+
+ private static DataTable NewJobTable()
+ {
+ var table = new DataTable();
+ table.Columns.Add("server_id", typeof(int));
+ table.Columns.Add("server_name", typeof(string));
+ table.Columns.Add("instance_id", typeof(long));
+ table.Columns.Add("job_id", typeof(string));
+ table.Columns.Add("job_name", typeof(string));
+ table.Columns.Add("job_enabled", typeof(bool));
+ table.Columns.Add("category_name", typeof(string));
+ table.Columns.Add("step_id", typeof(int));
+ table.Columns.Add("step_name", typeof(string));
+ table.Columns.Add("run_status", typeof(int));
+ table.Columns.Add("run_status_desc", typeof(string));
+ table.Columns.Add("run_datetime_local", typeof(DateTime));
+ table.Columns.Add("run_duration_seconds", typeof(long));
+ table.Columns.Add("retries_attempted", typeof(int));
+ table.Columns.Add("message", typeof(string));
+ table.Columns.Add("last_success_run_local", typeof(DateTime));
+ table.Columns.Add("is_long_running", typeof(bool));
+ return table;
+ }
+
+ private static ViewerJobHistoryRow ReadJobRow(DateTime runLocal, DateTime? lastSuccessLocal)
+ {
+ var table = NewJobTable();
+ table.Rows.Add(1, "srv", 10L, "job", "Job", true, "cat", 0, "step", 1, "Succeeded", runLocal, 5L, 0, "ok",
+ lastSuccessLocal.HasValue ? lastSuccessLocal.Value : DBNull.Value, false);
+
+ using var reader = table.CreateDataReader();
+ Assert.True(reader.Read());
+ return ViewerDataService.ReadJobHistoryRow(reader, new Dictionary { [1] = Eastern });
+ }
+
+ [Fact]
+ public void AJobStepReadFromTheStoredWallClock_PrintsNoOffset_InBothPassesOfTheRepeatedHour_WhileARealInstantStillDoes()
+ {
+ using var scope = new ServerModeScope(Eastern);
+
+ /* The Agent history stores 01:30 for a step that ran in either pass; ToUtc reads both as 05:30Z. */
+ var firstPass = ReadJobRow(Naive(2026, 11, 1, 1, 30), Naive(2026, 11, 1, 1, 45));
+ var secondPass = ReadJobRow(Naive(2026, 11, 1, 1, 30), Naive(2026, 11, 1, 1, 45));
+ Assert.Equal(Naive(2026, 11, 1, 5, 30), firstPass.RunDateTimeUtc);
+ Assert.Equal(Naive(2026, 11, 1, 5, 30), secondPass.RunDateTimeUtc);
+
+ Assert.Equal("2026-11-01 01:30:00", firstPass.RunTimeLocal);
+ Assert.Equal("2026-11-01 01:30:00", secondPass.RunTimeLocal);
+ Assert.Equal("2026-11-01 01:45:00", firstPass.LastSuccessfulRunLocal);
+ Assert.Equal("2026-11-01 01:45:00", secondPass.LastSuccessfulRunLocal);
+
+ /* A real instant in the same test keeps the offset: the two passes of 01:30 are 05:30Z and 06:30Z. */
+ Assert.Equal("2026-11-01 01:30:00 -04:00", TrueUtcRowText(Naive(2026, 11, 1, 5, 30)));
+ Assert.Equal("2026-11-01 01:30:00 -05:00", TrueUtcRowText(Naive(2026, 11, 1, 6, 30)));
+ }
+
+ [Fact]
+ public void AJobStepOutsideTheRepeatedHour_ReadsAsTheDisplayTextItAlwaysDid()
+ {
+ using var scope = new ServerModeScope(Eastern);
+
+ var beforeTheHour = ReadJobRow(Naive(2026, 11, 1, 0, 59), null);
+ var afterTheHour = ReadJobRow(Naive(2026, 11, 1, 2, 0), null);
+ var summer = ReadJobRow(Naive(2026, 7, 6, 12, 0), Naive(2026, 7, 6, 11, 0));
+
+ Assert.Equal("2026-11-01 00:59:00", beforeTheHour.RunTimeLocal);
+ Assert.Equal("2026-11-01 02:00:00", afterTheHour.RunTimeLocal);
+ Assert.Equal("2026-07-06 12:00:00", summer.RunTimeLocal);
+ Assert.Equal("2026-07-06 11:00:00", summer.LastSuccessfulRunLocal);
+ Assert.Equal("Never", beforeTheHour.LastSuccessfulRunLocal);
+ Assert.Equal("", new ViewerJobHistoryRow().RunTimeLocal);
+ }
+
+ // ── Agent status ──
+
+ private static async Task ReadAgentRowAsync(DateTime? nextRunLocal)
+ {
+ var table = new DataTable();
+ table.Columns.Add("server_id", typeof(int));
+ table.Columns.Add("server_name", typeof(string));
+ table.Columns.Add("agent_running", typeof(bool));
+ table.Columns.Add("agent_status_desc", typeof(string));
+ table.Columns.Add("agent_startup_desc", typeof(string));
+ table.Columns.Add("next_scheduled_run_local", typeof(DateTime));
+ table.Rows.Add(1, "srv", true, "Running", "Auto", nextRunLocal.HasValue ? nextRunLocal.Value : DBNull.Value);
+
+ using var reader = table.CreateDataReader();
+ var rows = await ViewerDataService.ReadAgentStatusRowsAsync(
+ reader, new Dictionary { [1] = Eastern }, TestContext.Current.CancellationToken);
+ return Assert.Single(rows);
+ }
+
+ [Fact]
+ public async Task ANextScheduledRunReadFromTheStoredWallClock_PrintsNoOffset_InTheRepeatedHour_WhileARealInstantStillDoes()
+ {
+ using var scope = new ServerModeScope(Eastern);
+
+ var repeated = await ReadAgentRowAsync(Naive(2026, 11, 1, 1, 30));
+ var ordinary = await ReadAgentRowAsync(Naive(2026, 11, 2, 8, 0));
+ var none = await ReadAgentRowAsync(null);
+
+ Assert.Equal("2026-11-01 01:30:00", repeated.NextScheduledRunLocal);
+ Assert.Equal("2026-11-02 08:00:00", ordinary.NextScheduledRunLocal);
+ Assert.Equal("None scheduled", none.NextScheduledRunLocal);
+
+ Assert.Equal("2026-11-01 01:30:00 -05:00", TrueUtcRowText(Naive(2026, 11, 1, 6, 30)));
+ }
+
+ // ── Default Trace ──
+
+ private static DataTable NewDefaultTraceTable()
+ {
+ var table = new DataTable();
+ table.Columns.Add("event_time_local", typeof(DateTime));
+ table.Columns.Add("event_name", typeof(string));
+ table.Columns.Add("database_name", typeof(string));
+ table.Columns.Add("object_name", typeof(string));
+ table.Columns.Add("login_name", typeof(string));
+ table.Columns.Add("host_name", typeof(string));
+ table.Columns.Add("application_name", typeof(string));
+ table.Columns.Add("spid", typeof(int));
+ table.Columns.Add("duration_us", typeof(long));
+ table.Columns.Add("integer_data", typeof(long));
+ table.Columns.Add("severity", typeof(int));
+ table.Columns.Add("error_number", typeof(int));
+ table.Columns.Add("text_data", typeof(string));
+ return table;
+ }
+
+ [Fact]
+ public async Task ADefaultTraceEventReadFromTheStoredWallClock_PrintsNoOffset_InBothPassesOfTheRepeatedHour_WhileARealInstantStillDoes()
+ {
+ using var scope = new ServerModeScope(Eastern);
+
+ /* Two auto-grows, one in each pass of the hour: the trace file stores 01:30 for both (the label rides in
+ database_name). */
+ var table = NewDefaultTraceTable();
+ foreach (var label in new[] { "first-pass", "second-pass" })
+ {
+ table.Rows.Add(Naive(2026, 11, 1, 1, 30), "Data File Auto Grow", label, DBNull.Value, DBNull.Value, DBNull.Value,
+ DBNull.Value, 55, 1_500_000L, 256L, DBNull.Value, DBNull.Value, DBNull.Value);
+ }
+
+ table.Rows.Add(Naive(2026, 11, 1, 2, 30), "Data File Auto Grow", "after", DBNull.Value, DBNull.Value, DBNull.Value,
+ DBNull.Value, 55, 1_500_000L, 256L, DBNull.Value, DBNull.Value, DBNull.Value);
+
+ List rows;
+ using (var reader = table.CreateDataReader())
+ {
+ rows = await ViewerDataService.ReadDefaultTraceEventsAsync(
+ reader, Eastern, Naive(2026, 11, 1, 0, 0), Naive(2026, 11, 1, 12, 0), TestContext.Current.CancellationToken);
+ }
+
+ var byLabel = rows.ToDictionary(r => r.DatabaseName!);
+ Assert.Equal(3, byLabel.Count);
+ Assert.Equal(Naive(2026, 11, 1, 5, 30), byLabel["first-pass"].EventTimeUtc);
+ Assert.Equal(Naive(2026, 11, 1, 5, 30), byLabel["second-pass"].EventTimeUtc);
+
+ Assert.Equal("2026-11-01 01:30:00", byLabel["first-pass"].EventTimeLocal);
+ Assert.Equal("2026-11-01 01:30:00", byLabel["second-pass"].EventTimeLocal);
+ Assert.Equal("2026-11-01 02:30:00", byLabel["after"].EventTimeLocal);
+
+ /* A real instant in the same test keeps the offset. */
+ Assert.Equal("2026-11-01 01:30:00 -04:00", TrueUtcRowText(Naive(2026, 11, 1, 5, 30)));
+ Assert.Equal("2026-11-01 01:30:00 -05:00", TrueUtcRowText(Naive(2026, 11, 1, 6, 30)));
+ Assert.Equal("", new DefaultTraceEventRow(
+ null, DefaultTraceEventCategory.ErrorLog, "ErrorLog", null, null, null, null, null, null, null, null, 20, 823, "x").EventTimeLocal);
+ }
+
+ [Fact]
+ public void TheStoredWallClockRenderer_IsTheForDisplayText_AndTheRealInstantRendererDiffersFromItOnlyInTheRepeatedHour()
+ {
+ using var scope = new ServerModeScope(Eastern);
+
+ foreach (var instant in new[] { Naive(2026, 11, 1, 4, 59), Naive(2026, 11, 1, 7, 0), Naive(2026, 7, 6, 16, 0) })
+ {
+ Assert.Equal(ViewerTimeHelper.ForDisplay(instant).ToString(Format), SystemEventRowFormat.StoredWallClock(instant));
+ Assert.Equal(SystemEventRowFormat.Local(instant), SystemEventRowFormat.StoredWallClock(instant));
+ }
+
+ var secondPass = Naive(2026, 11, 1, 6, 30);
+ Assert.Equal("2026-11-01 01:30:00", SystemEventRowFormat.StoredWallClock(secondPass));
+ Assert.Equal("2026-11-01 01:30:00 -05:00", SystemEventRowFormat.Local(secondPass));
+ Assert.Equal("", SystemEventRowFormat.StoredWallClock(null));
+ }
+}
diff --git a/Darling/Darling.Tests/ViewerAlertRowClockTests.cs b/Darling/Darling.Tests/ViewerAlertRowClockTests.cs
new file mode 100644
index 0000000000..4d0fb7502e
--- /dev/null
+++ b/Darling/Darling.Tests/ViewerAlertRowClockTests.cs
@@ -0,0 +1,186 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using System.Text.RegularExpressions;
+using PerformanceMonitor.Analysis.Baselines;
+using PerformanceMonitor.Darling.Viewer;
+using PerformanceMonitor.Ui;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4766: the viewer's Alerts History time and Manage Servers' "Last Collected" show one UTC instant, so in the repeated
+/// autumn hour they carry the instant's UTC offset, as Lite's alert row does. Both used to build
+/// ConvertToDisplay(...).ToString(format) themselves, so the alert at 05:30Z and the one at 06:30Z on 2026-11-01
+/// both read "01:30" on a US Eastern server. They now word the text through
+/// , the twin of Lite's
+/// ServerTimeHelper.FormatInstant, which the current-zone overload calls too, so there is one rule.
+///
+/// US Eastern is the server: the autumn change is 2026-11-01 at 06:00 UTC, so 05:30Z is the first 01:30 (-04:00)
+/// and 06:30Z the second (-05:00).
+///
+/* Flips the process-wide display mode and active server clock (each restored in Dispose); one shared collection
+ serializes it with every other class that does. */
+[Collection("viewer-time-statics")]
+public sealed class ViewerAlertRowClockTests : IDisposable
+{
+ private static readonly ServerClock Eastern = ServerClock.Resolve("Eastern Standard Time", -300);
+
+ private static readonly ServerClock India = ServerClock.Resolve("India Standard Time", 330);
+
+ private readonly TimeDisplayMode _savedMode = ViewerTimeHelper.CurrentDisplayMode;
+ private readonly ServerClock _savedClock = ViewerTimeHelper.ActiveServerClock;
+ private readonly CultureInfo _savedCulture = CultureInfo.CurrentCulture;
+
+ public ViewerAlertRowClockTests()
+ {
+ /* The text runs on the current culture, as every grid's does; the invariant one fixes its separators. */
+ CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
+ }
+
+ public void Dispose()
+ {
+ ViewerTimeHelper.CurrentDisplayMode = _savedMode;
+ ViewerTimeHelper.ActiveServerClock = _savedClock;
+ CultureInfo.CurrentCulture = _savedCulture;
+ }
+
+ private static DateTime Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, DateTimeKind.Unspecified);
+
+ private static ViewerAlertRow Alert(DateTime alertTimeUtc, ServerClock? clock) => new()
+ {
+ AlertTime = alertTimeUtc,
+ ServerId = 1,
+ MetricName = "High CPU",
+ CurrentValue = 95,
+ ThresholdValue = 90,
+ AlertSent = false,
+ NotificationType = "tray",
+ Muted = false,
+ Clock = clock,
+ };
+
+ private static ManagedServerListItem Server(DateTime collectedUtc, ServerClock? clock) =>
+ new(new MonitoredServerRow { ServerId = 1, Host = "SQL1", Name = "SQL1" }, isFavorite: false)
+ {
+ LastCollectedUtc = collectedUtc,
+ Clock = clock,
+ };
+
+ ///
+ /// The alert at 05:30Z reads 01:30 -04:00 and the one at 06:30Z reads 01:30 -05:00 in Server mode, the hour after
+ /// is the bare 02:30, and UTC mode is the stored instant with no offset.
+ ///
+ [Fact]
+ public void AlertRow_InTheRepeatedHour_ReadsItsOffsetInServerMode_AndTheStoredInstantInUtcMode()
+ {
+ ViewerTimeHelper.ActiveServerClock = India;
+ var first = Alert(Utc(2026, 11, 1, 5, 30), Eastern);
+ var second = Alert(Utc(2026, 11, 1, 6, 30), Eastern);
+ var after = Alert(Utc(2026, 11, 1, 7, 30), Eastern);
+
+ ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+ Assert.Equal("2026-11-01 01:30:00 -04:00", first.TimeLocal);
+ Assert.Equal("2026-11-01 01:30:00 -05:00", second.TimeLocal);
+ Assert.Equal("2026-11-01 02:30:00", after.TimeLocal);
+
+ ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC;
+ Assert.Equal("2026-11-01 05:30:00", first.TimeLocal);
+ Assert.Equal("2026-11-01 06:30:00", second.TimeLocal);
+ }
+
+ /// A row built without a clock reads on the active server's, the fallback it had before.
+ [Fact]
+ public void AlertRow_WithNoClockStamped_TakesTheActiveServersClock()
+ {
+ ViewerTimeHelper.ActiveServerClock = Eastern;
+ ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+
+ Assert.Equal("2026-11-01 01:30:00 -05:00", Alert(Utc(2026, 11, 1, 6, 30), clock: null).TimeLocal);
+ Assert.Equal("2026-07-01 08:00:00", Alert(Utc(2026, 7, 1, 12, 0), clock: null).TimeLocal);
+ }
+
+ /// Manage Servers' Last Collected takes the same pair, in its own "yyyy-MM-dd HH:mm" shape.
+ [Fact]
+ public void ManageServersLastCollected_InTheRepeatedHour_ReadsItsOffsetInServerMode_AndTheStoredInstantInUtcMode()
+ {
+ ViewerTimeHelper.ActiveServerClock = India;
+ var first = Server(Utc(2026, 11, 1, 5, 30), Eastern);
+ var second = Server(Utc(2026, 11, 1, 6, 30), Eastern);
+ var after = Server(Utc(2026, 11, 1, 7, 30), Eastern);
+
+ ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+ Assert.Equal("2026-11-01 01:30 -04:00", first.LastCollectedDisplay);
+ Assert.Equal("2026-11-01 01:30 -05:00", second.LastCollectedDisplay);
+ Assert.Equal("2026-11-01 02:30", after.LastCollectedDisplay);
+
+ ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC;
+ Assert.Equal("2026-11-01 05:30", first.LastCollectedDisplay);
+ Assert.Equal("2026-11-01 06:30", second.LastCollectedDisplay);
+ }
+
+ ///
+ /// The zone overload is the one rule: an explicit zone words the instant the same way the current-zone overload does
+ /// for that zone, UTC never takes an offset, and a wall time that happens once stays bare.
+ ///
+ [Fact]
+ public void FormatForDisplay_WithAZone_IsTheRuleTheCurrentZoneOverloadUses()
+ {
+ var eastern = ViewerTimeHelper.DisplayZoneFor(TimeDisplayMode.ServerTime, Eastern);
+
+ Assert.Equal("2026-11-01 01:30 -04:00", ViewerTimeHelper.FormatForDisplay(Utc(2026, 11, 1, 5, 30), eastern, "yyyy-MM-dd HH:mm"));
+ Assert.Equal("2026-11-01 01:30 -05:00", ViewerTimeHelper.FormatForDisplay(Utc(2026, 11, 1, 6, 30), eastern, "yyyy-MM-dd HH:mm"));
+ Assert.Equal("2026-11-01 05:30", ViewerTimeHelper.FormatForDisplay(Utc(2026, 11, 1, 5, 30), TimeZoneInfo.Utc, "yyyy-MM-dd HH:mm"));
+ Assert.Equal("2026-07-01 10:30", ViewerTimeHelper.FormatForDisplay(Utc(2026, 7, 1, 14, 30), eastern, "yyyy-MM-dd HH:mm"));
+
+ ViewerTimeHelper.ActiveServerClock = Eastern;
+ ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+ foreach (var instant in new[] { Utc(2026, 11, 1, 5, 30), Utc(2026, 11, 1, 6, 30), Utc(2026, 7, 1, 14, 30) })
+ {
+ Assert.Equal(
+ ViewerTimeHelper.FormatForDisplay(instant, ViewerTimeHelper.CurrentDisplayZone(), "yyyy-MM-dd HH:mm:ss"),
+ ViewerTimeHelper.FormatForDisplay(instant, "yyyy-MM-dd HH:mm:ss"));
+ }
+ }
+
+ ///
+ /// The current-zone overload hands its work to the zone overload rather than wording the suffix itself, and the two
+ /// list rows call the zone overload with the zone they compute, so the rule stays in one place.
+ ///
+ [Fact]
+ public void TheOneRule_LivesInTheZoneOverload_AndTheRowsCallIt()
+ {
+ var helper = ViewerTypedRangeTests.StripComments(ViewerTypedRangeTests.ViewerSource("ViewerTimeHelper.cs", ThisFile()));
+ var current = ViewerTypedRangeTests.StripComments(FormatForDisplayMembers(helper, "string format) =>"));
+ Assert.Contains("FormatForDisplay(naiveUtc, CurrentDisplayZone(), format)", current);
+ Assert.Single(Regex.Matches(helper, @"AmbiguousOffsetSuffix\("));
+
+ var alert = ViewerTypedRangeTests.StripComments(
+ ViewerTypedRangeTests.MemberText(ViewerTypedRangeTests.ViewerSource("ViewerDataService.AlertHistory.cs", ThisFile()), "TimeLocal"));
+ var manage = ViewerTypedRangeTests.StripComments(
+ ViewerTypedRangeTests.MemberText(ViewerTypedRangeTests.ViewerSource("ManageServersWindow.xaml.cs", ThisFile()), "LastCollectedDisplay"));
+ Assert.Matches(@"ViewerTimeHelper\.FormatForDisplay\(\s*AlertTime,\s*ViewerTimeHelper\.DisplayZoneFor\(", alert);
+ Assert.Matches(@"ViewerTimeHelper\.FormatForDisplay\(\s*utc,\s*ViewerTimeHelper\.DisplayZoneFor\(", manage);
+ Assert.DoesNotContain(".ToString(", alert);
+ Assert.DoesNotContain(".ToString(", manage);
+ }
+
+ /// The text of the two-argument (DateTime, string) overload, from its declaration to its semicolon.
+ private static string FormatForDisplayMembers(string helper, string signatureTail)
+ {
+ var at = helper.IndexOf("public static string FormatForDisplay(DateTime naiveUtc, " + signatureTail, StringComparison.Ordinal);
+ Assert.True(at >= 0, "the (DateTime, string) overload of FormatForDisplay moved");
+ return helper.Substring(at, helper.IndexOf(';', at) - at + 1);
+ }
+
+ private static string ThisFile([CallerFilePath] string thisFile = "") => thisFile;
+}
diff --git a/Darling/Darling.Tests/ViewerChartUtcFrameTests.cs b/Darling/Darling.Tests/ViewerChartUtcFrameTests.cs
index d75d4f0015..43b4bc6d43 100644
--- a/Darling/Darling.Tests/ViewerChartUtcFrameTests.cs
+++ b/Darling/Darling.Tests/ViewerChartUtcFrameTests.cs
@@ -89,7 +89,9 @@ public void NoDisplayTimeProjectionFeedsAChartX()
foreach (var (file, code) in ViewerSources())
{
- foreach (var call in Regex.Matches(code, @"\bForDisplay\s*\(").Cast())
+ /* FormatForDisplay is the text form of ForDisplay (#4766): a statement that words a display time and also
+ plots a number or sets an axis limit is the same mistake, and the calls the sites moved to still count. */
+ foreach (var call in Regex.Matches(code, @"\b(?:Format)?ForDisplay\s*\(").Cast())
{
forDisplayCalls++;
if (FeedsAChart.IsMatch(StatementAround(code, call.Index)))
@@ -209,9 +211,12 @@ public void TheCsvExportWritesTheTimesTheChartLabelsShow()
/* The export writes every point through the pure ChartCsvDataLine (ViewerChartContextMenuTests pins what it
writes for a repeated hour), handing it the chart's X as plotted and the display zone; the seam applies
- the zone to the instant. Neither the raw X nor a display-frame projection reaches FormatChartCsvLine. */
+ the zone to the instant, and the UTC offset column is that same instant's offset. Neither the raw X nor a
+ display-frame projection reaches FormatChartCsvLine. */
Assert.Contains("ChartCsvDataLine(point.X, seriesName, point.Y, sep, zone)", code, StringComparison.Ordinal);
- Assert.Contains("DisplayZone.ToDisplay(DateTime.FromOADate(plottedX), zone)", code, StringComparison.Ordinal);
+ Assert.Contains("var instant = DateTime.FromOADate(plottedX);", code, StringComparison.Ordinal);
+ Assert.Contains("DisplayZone.ToDisplay(instant, zone)", code, StringComparison.Ordinal);
+ Assert.Contains("DisplayZone.UtcOffsetText(instant, zone)", code, StringComparison.Ordinal);
Assert.DoesNotContain("FormatChartCsvLine(DateTime.FromOADate(", code, StringComparison.Ordinal);
}
diff --git a/Darling/Darling.Tests/ViewerContextMenuTests.cs b/Darling/Darling.Tests/ViewerContextMenuTests.cs
index 516e7d07e7..a9a198d4a4 100644
--- a/Darling/Darling.Tests/ViewerContextMenuTests.cs
+++ b/Darling/Darling.Tests/ViewerContextMenuTests.cs
@@ -67,10 +67,10 @@ public void ChartMenuHeaderOrder_WithDataSource_AppendsShowDataSource()
}
[Theory]
- [InlineData(",", "DateTime (UTC),Series,Value")]
- [InlineData(";", "DateTime (UTC);Series;Value")]
- [InlineData("\t", "DateTime (UTC)\tSeries\tValue")]
- public void ChartCsvHeaderLine_IsDateTimeSeriesValue_InTheChosenSeparator(string separator, string expected)
+ [InlineData(",", "DateTime (UTC),Series,Value,UTC offset")]
+ [InlineData(";", "DateTime (UTC);Series;Value;UTC offset")]
+ [InlineData("\t", "DateTime (UTC)\tSeries\tValue\tUTC offset")]
+ public void ChartCsvHeaderLine_IsDateTimeSeriesValueUtcOffset_InTheChosenSeparator(string separator, string expected)
{
Assert.Equal(expected, ViewerServerTab.ChartCsvHeaderLine(separator, TimeZoneInfo.Utc));
}
@@ -79,28 +79,28 @@ public void ChartCsvHeaderLine_IsDateTimeSeriesValue_InTheChosenSeparator(string
public void ChartCsvHeaderLine_NamesTheZoneTheDataLinesAreWrittenIn()
{
/* The time column names the zone ChartCsvDataLine writes each time in (#4766), so a file opened later still
- says which clock it is on. The same header text Lite's chart export writes. */
+ says which clock it is on; the last column holds the UTC offset each row was written at. */
var eastern = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
var x = new DateTime(2026, 11, 1, 6, 30, 0).ToOADate();
- Assert.Equal("DateTime (Eastern Standard Time),Series,Value", ViewerServerTab.ChartCsvHeaderLine(",", eastern));
- Assert.Equal("DateTime (Eastern Standard Time)\tSeries\tValue", ViewerServerTab.ChartCsvHeaderLine("\t", eastern));
- Assert.Equal("2026-11-01 01:30:00,CPU %,42.5", ViewerServerTab.ChartCsvDataLine(x, "CPU %", 42.5, ",", eastern));
+ Assert.Equal("DateTime (Eastern Standard Time),Series,Value,UTC offset", ViewerServerTab.ChartCsvHeaderLine(",", eastern));
+ Assert.Equal("DateTime (Eastern Standard Time)\tSeries\tValue\tUTC offset", ViewerServerTab.ChartCsvHeaderLine("\t", eastern));
+ Assert.Equal("2026-11-01 01:30:00,CPU %,42.5,-05:00", ViewerServerTab.ChartCsvDataLine(x, "CPU %", 42.5, ",", eastern));
/* A fixed-offset server clock is a zone too (Id "UTC+05:30"): the header names the offset its times are in. */
var offset = ServerClock.FixedOffset(330).AsTimeZone();
- Assert.Equal("DateTime (UTC+05:30);Series;Value", ViewerServerTab.ChartCsvHeaderLine(";", offset));
- Assert.Equal("2026-11-01 12:00:00;CPU %;42.5", ViewerServerTab.ChartCsvDataLine(x, "CPU %", 42.5, ";", offset));
+ Assert.Equal("DateTime (UTC+05:30);Series;Value;UTC offset", ViewerServerTab.ChartCsvHeaderLine(";", offset));
+ Assert.Equal("2026-11-01 12:00:00;CPU %;42.5;+05:30", ViewerServerTab.ChartCsvDataLine(x, "CPU %", 42.5, ";", offset));
}
[Fact]
public void ChartCsvHeaderLine_QuotesAZoneNameContainingTheSeparator()
{
- /* A zone Id with the separator in it is quoted as any other cell would be, so the column count stays three. */
+ /* A zone Id with the separator in it is quoted as any other cell would be, so the column count stays four. */
var zone = TimeZoneInfo.CreateCustomTimeZone("Custom, Zone", TimeSpan.Zero, "Custom", "Custom");
- Assert.Equal("\"DateTime (Custom, Zone)\",Series,Value", ViewerServerTab.ChartCsvHeaderLine(",", zone));
- Assert.Equal("DateTime (Custom, Zone);Series;Value", ViewerServerTab.ChartCsvHeaderLine(";", zone));
+ Assert.Equal("\"DateTime (Custom, Zone)\",Series,Value,UTC offset", ViewerServerTab.ChartCsvHeaderLine(",", zone));
+ Assert.Equal("DateTime (Custom, Zone);Series;Value;UTC offset", ViewerServerTab.ChartCsvHeaderLine(";", zone));
}
[Fact]
@@ -119,8 +119,65 @@ public void ChartCsvDataLine_ShowsTheChartInstantInTheDisplayZone()
var eastern = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
var x = new DateTime(2026, 11, 1, 6, 30, 0).ToOADate();
- Assert.Equal("2026-11-01 01:30:00,CPU %,42.5", ViewerServerTab.ChartCsvDataLine(x, "CPU %", 42.5, ",", eastern));
- Assert.Equal("2026-11-01 06:30:00,CPU %,42.5", ViewerServerTab.ChartCsvDataLine(x, "CPU %", 42.5, ",", TimeZoneInfo.Utc));
+ Assert.Equal("2026-11-01 01:30:00,CPU %,42.5,-05:00", ViewerServerTab.ChartCsvDataLine(x, "CPU %", 42.5, ",", eastern));
+ Assert.Equal("2026-11-01 06:30:00,CPU %,42.5,+00:00", ViewerServerTab.ChartCsvDataLine(x, "CPU %", 42.5, ",", TimeZoneInfo.Utc));
+ }
+
+ [Fact]
+ public void ChartCsvDataLine_TheTwoInstantsOfTheRepeatedHour_DifferOnlyInTheOffsetCell()
+ {
+ /* US Eastern fell back at 06:00Z on 2026-11-01, so 05:30Z is the FIRST 01:30 (-04:00) and 06:30Z the SECOND
+ (-05:00). The time cell reads 01:30 on both rows, as the chart's tick labels do (and stays one plain date
+ and time format), so the fourth cell is what names which instant a row is (#4766). */
+ var eastern = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
+ var first = ViewerServerTab.ChartCsvDataLine(new DateTime(2026, 11, 1, 5, 30, 0).ToOADate(), "CPU %", 42.5, ",", eastern);
+ var second = ViewerServerTab.ChartCsvDataLine(new DateTime(2026, 11, 1, 6, 30, 0).ToOADate(), "CPU %", 42.5, ",", eastern);
+
+ Assert.Equal("2026-11-01 01:30:00,CPU %,42.5,-04:00", first);
+ Assert.Equal("2026-11-01 01:30:00,CPU %,42.5,-05:00", second);
+ Assert.Equal(first.Split(',')[..3], second.Split(',')[..3]);
+ }
+
+ [Fact]
+ public void ChartCsvDataLine_WritesTheOffsetOnEveryRow_NotOnlyInTheRepeatedHour()
+ {
+ /* Each row carries the offset in force at its own instant: a summer and a winter instant on the Eastern
+ clock, and a half-hour zone. The time cell is the plain wall time in all of them. */
+ var eastern = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
+ var summer = new DateTime(2026, 7, 6, 16, 0, 0).ToOADate();
+ var winter = new DateTime(2026, 1, 15, 17, 0, 0).ToOADate();
+
+ Assert.Equal("2026-07-06 12:00:00,CPU %,42.5,-04:00", ViewerServerTab.ChartCsvDataLine(summer, "CPU %", 42.5, ",", eastern));
+ Assert.Equal("2026-01-15 12:00:00,CPU %,42.5,-05:00", ViewerServerTab.ChartCsvDataLine(winter, "CPU %", 42.5, ",", eastern));
+
+ var kolkata = ServerClock.FixedOffset(330).AsTimeZone();
+ Assert.Equal("2026-07-06 21:30:00,CPU %,42.5,+05:30", ViewerServerTab.ChartCsvDataLine(summer, "CPU %", 42.5, ",", kolkata));
+ }
+
+ [Fact]
+ public void ChartCsvDataLine_InUtc_IsPlusZeroOnEveryRow_EvenForTheInstantsEasternRepeats()
+ {
+ /* UTC has no repeated hour: 05:30Z and 06:30Z on the Eastern change day are two different times, and both
+ rows read +00:00. */
+ var atFirst = new DateTime(2026, 11, 1, 5, 30, 0).ToOADate();
+ var atSecond = new DateTime(2026, 11, 1, 6, 30, 0).ToOADate();
+
+ Assert.Equal("2026-11-01 05:30:00,CPU %,42.5,+00:00", ViewerServerTab.ChartCsvDataLine(atFirst, "CPU %", 42.5, ",", TimeZoneInfo.Utc));
+ Assert.Equal("2026-11-01 06:30:00,CPU %,42.5,+00:00", ViewerServerTab.ChartCsvDataLine(atSecond, "CPU %", 42.5, ",", TimeZoneInfo.Utc));
+ }
+
+ [Theory]
+ [InlineData(",")]
+ [InlineData(";")]
+ [InlineData("\t")]
+ public void ChartCsvDataLine_JoinsTheOffsetCellWithTheChosenSeparator(string separator)
+ {
+ var eastern = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
+ var x = new DateTime(2026, 11, 1, 5, 30, 0).ToOADate();
+
+ Assert.Equal(
+ string.Join(separator, "2026-11-01 01:30:00", "CPU %", "42.5", "-04:00"),
+ ViewerServerTab.ChartCsvDataLine(x, "CPU %", 42.5, separator, eastern));
}
[Fact]
diff --git a/Darling/Darling.Tests/ViewerFinOpsRowClockTests.cs b/Darling/Darling.Tests/ViewerFinOpsRowClockTests.cs
new file mode 100644
index 0000000000..b79ee53a09
--- /dev/null
+++ b/Darling/Darling.Tests/ViewerFinOpsRowClockTests.cs
@@ -0,0 +1,178 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using PerformanceMonitor.Analysis.Baselines;
+using PerformanceMonitor.Darling.Viewer;
+using PerformanceMonitor.Ui;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4766: the FinOps Server Inventory rows (one per server) and Application Connections rows read their times on THEIR
+/// server's clock. They went through the active server tab's clock, but Server Inventory lists every server at once and
+/// the FinOps tab lists the server it was opened for, so in Server mode a row could read another server's hour. Each row
+/// now carries the clock its loader stamped (the server's collected clock, else the viewer machine's offset, the rule every
+/// list row uses) and words its text on it, with the UTC offset in the repeated autumn hour.
+///
+/// US Eastern and India (+05:30 all year) are the servers, so a clock that differs from this machine's is always
+/// among them; the autumn change is 2026-11-01 at 06:00 UTC, so 05:30Z is the first 01:30 (-04:00) and 06:30Z the second
+/// (-05:00) on the Eastern one.
+///
+/* Flips the process-wide display mode and active server clock (each restored in Dispose); one shared collection
+ serializes it with every other class that does. */
+[Collection("viewer-time-statics")]
+public sealed class ViewerFinOpsRowClockTests : IDisposable
+{
+ private static readonly ServerClock Eastern = ServerClock.Resolve("Eastern Standard Time", -300);
+
+ private static readonly ServerClock India = ServerClock.Resolve("India Standard Time", 330);
+
+ private readonly TimeDisplayMode _savedMode = ViewerTimeHelper.CurrentDisplayMode;
+ private readonly ServerClock _savedClock = ViewerTimeHelper.ActiveServerClock;
+ private readonly CultureInfo _savedCulture = CultureInfo.CurrentCulture;
+
+ public ViewerFinOpsRowClockTests()
+ {
+ CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
+ }
+
+ public void Dispose()
+ {
+ ViewerTimeHelper.CurrentDisplayMode = _savedMode;
+ ViewerTimeHelper.ActiveServerClock = _savedClock;
+ CultureInfo.CurrentCulture = _savedCulture;
+ }
+
+ private static DateTime Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, DateTimeKind.Unspecified);
+
+ private static ServerPropertyRow Inventory(DateTime? asOfUtc, DateTime? collectedUtc, ServerClock? clock) => new()
+ {
+ ServerId = 1,
+ ServerName = "SQL1",
+ InventoryAsOfUtc = asOfUtc,
+ LastCollectedUtc = collectedUtc,
+ Clock = clock,
+ };
+
+ private static ApplicationConnectionRow Connections(DateTime firstUtc, DateTime lastUtc, ServerClock? clock) => new()
+ {
+ ApplicationName = "app",
+ FirstSeenUtc = firstUtc,
+ LastSeenUtc = lastUtc,
+ Clock = clock,
+ };
+
+ [Fact]
+ public void Inventory_ARowReadsItsOwnServersWallTime_NotTheActiveTabs()
+ {
+ ViewerTimeHelper.ActiveServerClock = India;
+ ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+
+ /* 14:30Z is 10:30 in US Eastern (the row's server) and 20:00 in India (the active tab's). */
+ var row = Inventory(Utc(2026, 7, 1, 14, 30), Utc(2026, 7, 1, 14, 30), Eastern);
+ Assert.Equal("2026-07-01 10:30", row.InventoryAsOfText);
+ Assert.Equal("2026-07-01 10:30", row.LastCollectedText);
+
+ ViewerTimeHelper.ActiveServerClock = Eastern;
+ var indian = Inventory(Utc(2026, 7, 1, 14, 30), Utc(2026, 7, 1, 14, 30), India);
+ Assert.Equal("2026-07-01 20:00", indian.InventoryAsOfText);
+ Assert.Equal("2026-07-01 20:00", indian.LastCollectedText);
+ }
+
+ [Fact]
+ public void Inventory_InTheRepeatedHour_ReadsItsOffsetInServerMode_AndTheStoredInstantInUtcMode()
+ {
+ ViewerTimeHelper.ActiveServerClock = India;
+ var first = Inventory(Utc(2026, 11, 1, 5, 30), Utc(2026, 11, 1, 5, 30), Eastern);
+ var second = Inventory(Utc(2026, 11, 1, 6, 30), Utc(2026, 11, 1, 6, 30), Eastern);
+
+ ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+ Assert.Equal("2026-11-01 01:30 -04:00", first.InventoryAsOfText);
+ Assert.Equal("2026-11-01 01:30 -05:00", second.InventoryAsOfText);
+ Assert.Equal("2026-11-01 01:30 -04:00", first.LastCollectedText);
+ Assert.Equal("2026-11-01 01:30 -05:00", second.LastCollectedText);
+
+ ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC;
+ Assert.Equal("2026-11-01 05:30", first.InventoryAsOfText);
+ Assert.Equal("2026-11-01 06:30", second.LastCollectedText);
+ }
+
+ [Fact]
+ public void Inventory_AServerWithNoSnapshotOrCollection_ShowsNothing_AndARowWithoutAClockTakesTheActiveOne()
+ {
+ ViewerTimeHelper.ActiveServerClock = Eastern;
+ ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+
+ var none = Inventory(null, null, Eastern);
+ Assert.Equal("", none.InventoryAsOfText);
+ Assert.Equal("", none.LastCollectedText);
+
+ var unstamped = Inventory(Utc(2026, 11, 1, 6, 30), Utc(2026, 11, 1, 6, 30), clock: null);
+ Assert.Equal("2026-11-01 01:30 -05:00", unstamped.InventoryAsOfText);
+ Assert.Equal("2026-11-01 01:30 -05:00", unstamped.LastCollectedText);
+ }
+
+ [Fact]
+ public void AppConnections_ARowReadsItsOwnServersWallTime_NotTheActiveTabs()
+ {
+ ViewerTimeHelper.ActiveServerClock = India;
+ ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+
+ var row = Connections(Utc(2026, 7, 1, 14, 30), Utc(2026, 7, 1, 15, 30), Eastern);
+ Assert.Equal("2026-07-01 10:30", row.FirstSeenText);
+ Assert.Equal("2026-07-01 11:30", row.LastSeenText);
+ }
+
+ [Fact]
+ public void AppConnections_InTheRepeatedHour_ReadsItsOffsetInServerMode_AndTheStoredInstantInUtcMode()
+ {
+ ViewerTimeHelper.ActiveServerClock = India;
+ var row = Connections(Utc(2026, 11, 1, 5, 30), Utc(2026, 11, 1, 6, 30), Eastern);
+
+ ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+ Assert.Equal("2026-11-01 01:30 -04:00", row.FirstSeenText);
+ Assert.Equal("2026-11-01 01:30 -05:00", row.LastSeenText);
+
+ ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC;
+ Assert.Equal("2026-11-01 05:30", row.FirstSeenText);
+ Assert.Equal("2026-11-01 06:30", row.LastSeenText);
+ }
+
+ ///
+ /// Both loaders read the clocks and stamp each row with its server's, and the sort values (the "Local" DateTimes) are
+ /// converted on that same clock, so a column sorts in the order its text reads. Neither goes through the active
+ /// server's clock any more (ForDisplay).
+ ///
+ [Fact]
+ public void TheLoaders_StampEachRowsClock_AndConvertTheSortValuesOnIt()
+ {
+ var workload = ViewerTypedRangeTests.StripComments(ViewerTypedRangeTests.ViewerSource("ViewerDataService.FinOps.Workload.cs", ThisFile()));
+ var connections = ViewerTypedRangeTests.MemberText(workload, "GetApplicationConnectionsAsync");
+ Assert.Contains("GetServerClocksAsync(serverId, cancellationToken)", connections);
+ Assert.Contains("ClockForServerOrMachine(", connections);
+ Assert.Contains("Clock = clock", connections);
+ Assert.Contains("ConvertToDisplay(reader.GetDateTime(18), ViewerTimeHelper.CurrentDisplayMode, clock)", connections);
+ Assert.Contains("ConvertToDisplay(reader.GetDateTime(19), ViewerTimeHelper.CurrentDisplayMode, clock)", connections);
+ Assert.DoesNotContain("ForDisplay(", connections);
+
+ var inventorySource = ViewerTypedRangeTests.StripComments(ViewerTypedRangeTests.ViewerSource("ViewerDataService.FinOps.Inventory.cs", ThisFile()));
+ var inventory = ViewerTypedRangeTests.MemberText(inventorySource, "GetServerInventoryAsync");
+ Assert.Contains("GetServerClocksAsync(null, cancellationToken)", inventory);
+ Assert.Contains("ClockForServerOrMachine(clocks, serverId, TimeZoneInfo.Local, nowUtc)", inventory);
+ Assert.Contains("Clock = clock", inventory);
+ Assert.Contains("ConvertToDisplay(reader.GetDateTime(13), ViewerTimeHelper.CurrentDisplayMode, clock)", inventory);
+ Assert.Contains("ConvertToDisplay(reader.GetDateTime(19), ViewerTimeHelper.CurrentDisplayMode, clock)", inventory);
+ Assert.DoesNotContain("ForDisplay(", inventory);
+ }
+
+ private static string ThisFile([CallerFilePath] string thisFile = "") => thisFile;
+}
diff --git a/Darling/Darling.Tests/ViewerGridTimeColumnSortMemberTests.cs b/Darling/Darling.Tests/ViewerGridTimeColumnSortMemberTests.cs
new file mode 100644
index 0000000000..9c51a6306c
--- /dev/null
+++ b/Darling/Darling.Tests/ViewerGridTimeColumnSortMemberTests.cs
@@ -0,0 +1,190 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.ComponentModel;
+using System.Globalization;
+using System.Linq;
+using System.Reflection;
+using System.Text.RegularExpressions;
+using System.Windows.Data;
+using PerformanceMonitor.Analysis.Baselines;
+using PerformanceMonitor.Darling.Viewer;
+using PerformanceMonitor.Ui;
+using Xunit;
+using static Darling.Tests.RepoFile;
+
+namespace Darling.Tests;
+
+///
+/// #4766: a viewer grid time column that shows the UTC offset in the repeated autumn hour must sort by the row's true UTC
+/// DateTime, not by its text and not by a DateTime already converted to the display zone.
+///
+/// The text sorts wrong east of UTC: "02:30 +01:00" sorts before "02:30 +02:00", so the second pass of the hour
+/// comes out before the first. A display-zone DateTime (a ...Local value made by ForDisplay) is no better:
+/// the second pass at 01:15 (06:15Z) sorts before the first pass at 01:45 (05:45Z). Only the UTC instant puts both passes
+/// in order, so each column carries the row's UTC member in SortMemberPath. The filter button's Tag is a
+/// separate contract and is left alone.
+///
+/// A row read from a stored server wall clock (job history, default trace, the query and procedure stats creation
+/// and execution times) keeps its text sort, and a row with no UTC member of its own (the System Events rows) is not in
+/// the table.
+///
+/* Serialized with the other classes that flip the process-wide ViewerTimeHelper statics. */
+[Collection("viewer-time-statics")]
+public sealed class ViewerGridTimeColumnSortMemberTests
+{
+ private const string ViewerFolder = "PerformanceMonitor.Darling.Viewer";
+
+ /// The row type is carried as its assembly-qualified name, so each case stays a serializable, separately reported row.
+ private static string Name(Type type) => type.AssemblyQualifiedName!;
+
+ /// (xaml in the viewer project, the DataGrid's x:Name, the bound text property, the row type, the SortMemberPath).
+ public static TheoryData Columns => new()
+ {
+ { "FinOpsTab.xaml", "FinOpsApplicationConnectionsDataGrid", "FirstSeenText", Name(typeof(ApplicationConnectionRow)), "FirstSeenUtc" },
+ { "FinOpsTab.xaml", "FinOpsApplicationConnectionsDataGrid", "LastSeenText", Name(typeof(ApplicationConnectionRow)), "LastSeenUtc" },
+ { "FinOpsTab.xaml", "FinOpsServerInventoryDataGrid", "InventoryAsOfText", Name(typeof(ServerPropertyRow)), "InventoryAsOfUtc" },
+ { "FinOpsTab.xaml", "FinOpsServerInventoryDataGrid", "LastCollectedText", Name(typeof(ServerPropertyRow)), "LastCollectedUtc" },
+ { "ViewerServerTab.xaml", "QueryStoreClutterGrid", "OptionsCapturedText", Name(typeof(ViewerDataService.QueryStoreClutterRow)), "OptionsCapturedUtc" },
+ { "ViewerServerTab.xaml", "QueryStoreOverheadGrid", "LastObservedText", Name(typeof(ViewerDataService.QueryStoreOverheadWaitRow)), "LastObservedUtc" },
+ { "ViewerServerTab.xaml", "QuerySnapshotsGrid", "CollectionTimeLocal", Name(typeof(ViewerQuerySnapshotRow)), "CollectionTime" },
+ { "ViewerServerTab.xaml", "CurrentActiveQueriesGrid", "CollectionTimeLocal", Name(typeof(ViewerQuerySnapshotRow)), "CollectionTime" },
+ { "ViewerServerTab.xaml", "QueryStoreGrid", "LastExecutionTimeLocal", Name(typeof(ViewerQueryStoreRow)), "LastExecutionTime" },
+ { "ViewerServerTab.xaml", "QueryStoreGrid", "FirstExecutionTimeLocal", Name(typeof(ViewerQueryStoreRow)), "FirstExecutionTime" },
+ { "ViewerServerTab.xaml", "QueryStoreRegressionsGrid", "LastExecutionTimeLocal", Name(typeof(ViewerQueryStoreRegressionRow)), "LastExecutionTime" },
+ { "ViewerServerTab.xaml", "PlanCorrectionGrid", "CollectionTimeLocal", Name(typeof(PlanCorrectionRow)), "CollectionTime" },
+ { "ViewerServerTab.xaml", "PlanCorrectionGrid", "ValidSinceLocal", Name(typeof(PlanCorrectionRow)), "ValidSince" },
+ { "ViewerServerTab.xaml", "PlanCorrectionGrid", "LastRefreshLocal", Name(typeof(PlanCorrectionRow)), "LastRefresh" },
+ { "ViewerServerTab.xaml", "PlanCorrectionGrid", "ExecuteActionInitiatedTimeLocal", Name(typeof(PlanCorrectionRow)), "ExecuteActionInitiatedTime" },
+ { "ViewerServerTab.xaml", "PlanCorrectionGrid", "RevertActionInitiatedTimeLocal", Name(typeof(PlanCorrectionRow)), "RevertActionInitiatedTime" },
+ { "ViewerServerTab.xaml", "BlockedProcessReportGrid", "EventTimeLocal", Name(typeof(ViewerBlockedProcessRow)), "EventTime" },
+ { "ViewerServerTab.xaml", "DeadlockGrid", "DeadlockTimeLocal", Name(typeof(DeadlockProcessDetail)), "DeadlockTime" },
+ { "ViewerServerTab.xaml", "AutomaticTuningGrid", "CollectionTimeLocal", Name(typeof(AutomaticTuningRow)), "CollectionTime" },
+ { "ViewerServerTab.xaml", "ServerConfigChangesGrid", "ChangeTimeDisplay", Name(typeof(ServerConfigChangeRow)), "ChangeTime" },
+ { "ViewerServerTab.xaml", "DatabaseConfigChangesGrid", "ChangeTimeDisplay", Name(typeof(DatabaseConfigChangeRow)), "ChangeTime" },
+ { "ViewerServerTab.xaml", "TraceFlagChangesGrid", "ChangeTimeDisplay", Name(typeof(TraceFlagChangeRow)), "ChangeTime" },
+ { "ViewerServerTab.xaml", "CollectionCaveatsGrid", "FirstSeenFormatted", Name(typeof(CollectionCaveatRow)), "FirstSeenUtc" },
+ { "ViewerServerTab.xaml", "CollectionCaveatsGrid", "LastSeenFormatted", Name(typeof(CollectionCaveatRow)), "LastSeenUtc" },
+ { "ViewerServerTab.xaml", "PgCollectionCaveatsGrid", "FirstSeenFormatted", Name(typeof(CollectionCaveatRow)), "FirstSeenUtc" },
+ { "ViewerServerTab.xaml", "PgCollectionCaveatsGrid", "LastSeenFormatted", Name(typeof(CollectionCaveatRow)), "LastSeenUtc" },
+ { "ViewerServerTab.xaml", "CollectionHealthGrid", "LastSuccessFormatted", Name(typeof(CollectorHealthRow)), "LastSuccessTime" },
+ { "ViewerServerTab.xaml", "CollectionHealthGrid", "LastRunFormatted", Name(typeof(CollectorHealthRow)), "LastRunTime" },
+ { "ViewerServerTab.xaml", "CollectionHealthGrid", "LastErrorFormatted", Name(typeof(CollectorHealthRow)), "LastErrorTime" },
+ { "ViewerServerTab.xaml", "CollectionLogGrid", "CollectionTimeFormatted", Name(typeof(CollectionLogRow)), "CollectionTime" },
+ { "CollectionLogWindow.xaml", "LogDataGrid", "CollectionTimeFormatted", Name(typeof(CollectionLogRow)), "CollectionTime" },
+ { "WaitDrillDownWindow.xaml", "ResultsDataGrid", "CollectionTimeLocal", Name(typeof(ViewerQuerySnapshotRow)), "CollectionTime" },
+ { "QueryStoreHistoryWindow.xaml", "HistoryDataGrid", "FirstExecutionTimeLocal", Name(typeof(ViewerQueryStoreHistoryRow)), "FirstExecutionTime" },
+ { "QueryStoreHistoryWindow.xaml", "HistoryDataGrid", "LastExecutionTimeLocal", Name(typeof(ViewerQueryStoreHistoryRow)), "LastExecutionTime" },
+ { "ManageServersWindow.xaml", "ServersGrid", "LastCollectedDisplay", Name(typeof(ManagedServerListItem)), "LastCollectedUtc" },
+ { "NotificationRoutesWindow.xaml", "RoutesGrid", "ModifiedDisplay", Name(typeof(NotificationRouteRow)), "ModifiedAtUtc" },
+ };
+
+ ///
+ /// The column bound to the text property carries the row's UTC member in SortMemberPath, and that member is a
+ /// public or DateTime? on the row type. A wrong name sorts nothing and raises nothing,
+ /// so the name is checked against the row type the grid is bound to. A name ending in Local or Display
+ /// would be a display-zone value and is refused.
+ ///
+ [Theory]
+ [MemberData(nameof(Columns))]
+ public void ATimeColumn_SortsByItsRowsUtcDateTime(string xamlFile, string grid, string textProperty, string rowTypeName, string sortMember)
+ {
+ var xaml = ReadRepoFile("Darling", ViewerFolder, xamlFile);
+
+ var column = FindColumn(xaml, grid, textProperty);
+ var declared = Regex.Match(column, @"\sSortMemberPath=""(?[^""]*)""");
+ Assert.True(declared.Success, $"{xamlFile}: the {grid} column bound to {textProperty} has no SortMemberPath, so it sorts by its text.");
+ Assert.Equal(sortMember, declared.Groups["path"].Value);
+
+ var rowType = Type.GetType(rowTypeName, throwOnError: true)!;
+ var property = rowType.GetProperty(sortMember, BindingFlags.Public | BindingFlags.Instance);
+ Assert.True(property != null, $"{rowType.Name} has no public property {sortMember}.");
+ Assert.True(
+ (Nullable.GetUnderlyingType(property!.PropertyType) ?? property.PropertyType) == typeof(DateTime),
+ $"{rowType.Name}.{sortMember} is a {property.PropertyType.Name}, not a DateTime.");
+
+ Assert.False(sortMember.EndsWith("Local", StringComparison.Ordinal) || sortMember.EndsWith("Display", StringComparison.Ordinal),
+ $"{sortMember} reads as a display-zone member; the column must sort by the UTC one.");
+ }
+
+ ///
+ /// The two passes of the repeated hour, sorted the way a header click sorts them (a
+ /// on the column's SortMemberPath). US Eastern falls back at 2026-11-01 06:00Z, so 05:45Z is 01:45 -04:00 (the
+ /// first pass) and 06:15Z is 01:15 -05:00 (the second). The UTC member puts the first pass first; the display-zone
+ /// DateTime and the text both put the second pass first, which is what the columns did before.
+ ///
+ [Fact]
+ public void TheRepeatedHour_SortsFirstPassBeforeSecond_OnTheUtcMember_ButNotOnTheDisplayDateTimeOrTheText()
+ {
+ var savedMode = ViewerTimeHelper.CurrentDisplayMode;
+ var savedClock = ViewerTimeHelper.ActiveServerClock;
+ var savedCulture = CultureInfo.CurrentCulture;
+ try
+ {
+ ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+ ViewerTimeHelper.ActiveServerClock = ServerClock.Resolve("Eastern Standard Time", -300);
+ CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
+
+ /* The reader sets the UTC instant and the display-zone DateTime side by side. */
+ ApplicationConnectionRow Row(string name, DateTime utc) => new()
+ {
+ ApplicationName = name,
+ FirstSeenUtc = utc,
+ FirstSeenLocal = ViewerTimeHelper.ForDisplay(utc),
+ };
+ var firstPass = Row("first", Utc(2026, 11, 1, 5, 45));
+ var secondPass = Row("second", Utc(2026, 11, 1, 6, 15));
+ var rows = new[] { secondPass, firstPass };
+
+ /* The member the column is declared to sort by, read from the XAML rather than restated here. */
+ var declared = Regex.Match(
+ FindColumn(ReadRepoFile("Darling", ViewerFolder, "FinOpsTab.xaml"), "FinOpsApplicationConnectionsDataGrid", "FirstSeenText"),
+ @"\sSortMemberPath=""(?[^""]*)""").Groups["path"].Value;
+
+ Assert.Equal(new[] { "first", "second" }, Sorted(rows, declared));
+
+ /* Why the display-zone value and the text are refused as sort keys. */
+ Assert.Equal(new DateTime(2026, 11, 1, 1, 45, 0), firstPass.FirstSeenLocal);
+ Assert.Equal(new DateTime(2026, 11, 1, 1, 15, 0), secondPass.FirstSeenLocal);
+ Assert.Equal(new[] { "second", "first" }, Sorted(rows, nameof(ApplicationConnectionRow.FirstSeenLocal)));
+ Assert.Equal(new[] { "second", "first" }, Sorted(rows, nameof(ApplicationConnectionRow.FirstSeenText)));
+ }
+ finally
+ {
+ ViewerTimeHelper.CurrentDisplayMode = savedMode;
+ ViewerTimeHelper.ActiveServerClock = savedClock;
+ CultureInfo.CurrentCulture = savedCulture;
+ }
+ }
+
+ private static DateTime Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, DateTimeKind.Unspecified);
+
+ /// The application names in the order a header click on that sort member ascending gives.
+ private static string[] Sorted(ApplicationConnectionRow[] rows, string sortMemberPath)
+ {
+ var view = new ListCollectionView(rows);
+ view.SortDescriptions.Add(new SortDescription(sortMemberPath, ListSortDirection.Ascending));
+ return view.Cast().Select(r => r.ApplicationName).ToArray();
+ }
+
+ /// The start tag of the one DataGridTextColumn inside the named grid whose binding is the text property.
+ private static string FindColumn(string xaml, string grid, string textProperty)
+ {
+ var gridStart = Regex.Match(xaml, @"]*?x:Name=""" + Regex.Escape(grid) + @"""");
+ Assert.True(gridStart.Success, $"the grid {grid} is not in the file.");
+ var gridEnd = xaml.IndexOf("", gridStart.Index, StringComparison.Ordinal);
+ Assert.True(gridEnd > gridStart.Index, $"the end of the grid {grid} was not found.");
+
+ var columns = Regex.Matches(xaml[gridStart.Index..gridEnd], @"""])*>")
+ .Where(m => Regex.IsMatch(m.Value, @"\sBinding=""\{Binding " + Regex.Escape(textProperty) + @"(?=[,}\s])"))
+ .ToList();
+ Assert.True(columns.Count == 1, $"{grid} has {columns.Count} columns bound to {textProperty}; the pin expects exactly one.");
+ return columns[0].Value;
+ }
+}
diff --git a/Darling/Darling.Tests/ViewerGridTimeTextZoneTests.cs b/Darling/Darling.Tests/ViewerGridTimeTextZoneTests.cs
new file mode 100644
index 0000000000..f75a39cf35
--- /dev/null
+++ b/Darling/Darling.Tests/ViewerGridTimeTextZoneTests.cs
@@ -0,0 +1,183 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Globalization;
+using System.Text.RegularExpressions;
+using PerformanceMonitor.Analysis.Baselines;
+using PerformanceMonitor.Darling.Viewer;
+using PerformanceMonitor.Ui;
+using Xunit;
+using static Darling.Tests.RepoFile;
+
+namespace Darling.Tests;
+
+///
+/// #4766: the viewer grid columns that bound a DateTime with a XAML StringFormat (FinOps First Seen, Last Seen,
+/// Inventory As Of and Last Collected; the Query Store clutter grid's Options Captured; the overhead grid's Last Seen).
+/// A DateTime already converted to the display clock cannot say which of the two 01:30s of the repeated autumn hour it
+/// was, and a XAML format cannot add an offset. Each row now keeps the UTC instant beside the converted DateTime and words
+/// a text property from it through ; the column binds the
+/// text and sorts by the DateTime, so the order stays chronological.
+///
+/// US Eastern falls back at 2026-11-01 06:00Z, so 05:30Z is the first 01:30 (-04:00) and 06:30Z the second (-05:00).
+///
+/* Serialized with the other classes that flip the process-wide ViewerTimeHelper statics. */
+[Collection("viewer-time-statics")]
+public sealed class ViewerGridTimeTextZoneTests
+{
+ private const string ViewerFolder = "PerformanceMonitor.Darling.Viewer";
+
+ private static DateTime Naive(int year, int month, int day, int hour, int minute = 0) =>
+ new(year, month, day, hour, minute, 0, DateTimeKind.Unspecified);
+
+ private static ServerClock Eastern => ServerClock.Resolve("Eastern Standard Time", -300);
+
+ /* Runs the body in the given display mode and server clock, on the invariant culture; all three are restored. */
+ private static void WithDisplay(TimeDisplayMode mode, ServerClock clock, Action body)
+ {
+ var savedMode = ViewerTimeHelper.CurrentDisplayMode;
+ var savedClock = ViewerTimeHelper.ActiveServerClock;
+ var savedCulture = CultureInfo.CurrentCulture;
+ try
+ {
+ ViewerTimeHelper.CurrentDisplayMode = mode;
+ ViewerTimeHelper.ActiveServerClock = clock;
+ CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
+
+ body();
+ }
+ finally
+ {
+ ViewerTimeHelper.CurrentDisplayMode = savedMode;
+ ViewerTimeHelper.ActiveServerClock = savedClock;
+ CultureInfo.CurrentCulture = savedCulture;
+ }
+ }
+
+ ///
+ /// A FinOps Application Connections row: First Seen at 05:30Z and Last Seen at 06:30Z read 01:30 -04:00 and 01:30 -05:00
+ /// in Server mode on a US Eastern clock, the two DateTimes the columns sort by are told apart only by the text, and the
+ /// hour after is the bare wall time. UTC mode is the stored instant.
+ ///
+ [Fact]
+ public void AFinOpsApplicationConnectionRow_InTheRepeatedHour_ReadsItsOffset()
+ {
+ var row = new ApplicationConnectionRow
+ {
+ FirstSeenUtc = Naive(2026, 11, 1, 5, 30),
+ LastSeenUtc = Naive(2026, 11, 1, 6, 30),
+ };
+ var after = new ApplicationConnectionRow { FirstSeenUtc = Naive(2026, 11, 1, 7, 30), LastSeenUtc = Naive(2026, 11, 1, 7, 30) };
+
+ WithDisplay(TimeDisplayMode.ServerTime, Eastern, () =>
+ {
+ Assert.Equal("2026-11-01 01:30 -04:00", row.FirstSeenText);
+ Assert.Equal("2026-11-01 01:30 -05:00", row.LastSeenText);
+ Assert.Equal("2026-11-01 02:30", after.FirstSeenText);
+ });
+
+ WithDisplay(TimeDisplayMode.UTC, Eastern, () =>
+ {
+ Assert.Equal("2026-11-01 05:30", row.FirstSeenText);
+ Assert.Equal("2026-11-01 06:30", row.LastSeenText);
+ });
+ }
+
+ ///
+ /// A FinOps inventory row: Inventory As Of and Last Collected read the offset in the repeated hour, and a server with no
+ /// snapshot or no collection shows an empty cell, as the XAML-formatted column did.
+ ///
+ [Fact]
+ public void AFinOpsInventoryRow_InTheRepeatedHour_ReadsItsOffset_AndAMissingTimeIsEmpty()
+ {
+ var row = new ServerPropertyRow
+ {
+ InventoryAsOfUtc = Naive(2026, 11, 1, 5, 30),
+ LastCollectedUtc = Naive(2026, 11, 1, 6, 30),
+ };
+ var missing = new ServerPropertyRow();
+
+ WithDisplay(TimeDisplayMode.ServerTime, Eastern, () =>
+ {
+ Assert.Equal("2026-11-01 01:30 -04:00", row.InventoryAsOfText);
+ Assert.Equal("2026-11-01 01:30 -05:00", row.LastCollectedText);
+ Assert.Equal("", missing.InventoryAsOfText);
+ Assert.Equal("", missing.LastCollectedText);
+ });
+ }
+
+ ///
+ /// The Query Store clutter grid: a database row's Options Captured and an overhead row's Last Seen read the offset in the
+ /// repeated hour, and a database with no options row has no text at all, so the binding's TargetNullValue still
+ /// draws the em-dash.
+ ///
+ [Fact]
+ public void AClutterRow_InTheRepeatedHour_ReadsItsOffset_AndNoOptionsRowHasNoText()
+ {
+ var database = new ViewerDataService.QueryStoreClutterRow { OptionsCapturedUtc = Naive(2026, 11, 1, 6, 30) };
+ var noOptions = new ViewerDataService.QueryStoreClutterRow();
+ var wait = new ViewerDataService.QueryStoreOverheadWaitRow { LastObservedUtc = Naive(2026, 11, 1, 5, 30) };
+
+ WithDisplay(TimeDisplayMode.ServerTime, Eastern, () =>
+ {
+ Assert.Equal("2026-11-01 01:30 -05:00", database.OptionsCapturedText);
+ Assert.Null(noOptions.OptionsCapturedText);
+ Assert.Equal("2026-11-01 01:30 -04:00", wait.LastObservedText);
+ });
+ }
+
+ ///
+ /// Each column is a WPF column this suite does not instantiate, so the wiring is a source pin: the column binds its text
+ /// property (no StringFormat, which cannot carry the offset), sorts by the row's UTC DateTime (the display-zone one
+ /// puts the second pass of the repeated hour before the first, see ),
+ /// and, where its header carries a filter button, keeps that button's tag on the display-zone DateTime property.
+ ///
+ [Theory]
+ [InlineData("FinOpsTab.xaml", "FirstSeenText", "FirstSeenUtc", "FirstSeenLocal", true)]
+ [InlineData("FinOpsTab.xaml", "LastSeenText", "LastSeenUtc", "LastSeenLocal", true)]
+ [InlineData("FinOpsTab.xaml", "InventoryAsOfText", "InventoryAsOfUtc", "InventoryAsOf", true)]
+ [InlineData("FinOpsTab.xaml", "LastCollectedText", "LastCollectedUtc", "LastCollected", true)]
+ [InlineData("ViewerServerTab.xaml", "OptionsCapturedText", "OptionsCapturedUtc", "OptionsCaptured", false)]
+ [InlineData("ViewerServerTab.xaml", "LastObservedText", "LastObservedUtc", "LastObserved", false)]
+ public void EachColumn_BindsItsText_SortsByItsUtcDateTime_AndFiltersOnTheDateTime(
+ string file, string textProperty, string sortMember, string dateProperty, bool hasFilterButton)
+ {
+ var xaml = ReadRepoFile("Darling", ViewerFolder, file);
+
+ /* The element's start tag, whatever the order of its attributes; the binding may carry only a TargetNullValue. */
+ var start = Regex.Match(xaml,
+ @"]*\sBinding=""\{Binding " + textProperty + @"(?:, TargetNullValue=[^,}]+)?\}"")"
+ + @"(?=[^>]*\sSortMemberPath=""" + sortMember + @""")[^>]*>");
+ Assert.True(start.Success, $"{file}: the column bound to {textProperty} and sorted by {sortMember} is not where this pin looks.");
+
+ Assert.DoesNotContain("StringFormat", start.Value, StringComparison.Ordinal);
+
+ if (hasFilterButton)
+ {
+ var end = xaml.IndexOf("", start.Index, StringComparison.Ordinal);
+ Assert.True(end > start.Index, $"{file}: the end of the {textProperty} column was not found.");
+ Assert.Contains($"Tag=\"{dateProperty}\"", xaml[start.Index..end], StringComparison.Ordinal);
+ }
+ }
+
+ ///
+ /// The readers set the UTC instant beside each converted DateTime, so a row can never show the default 0001-01-01.
+ ///
+ [Theory]
+ [InlineData("ViewerDataService.FinOps.Workload.cs", "FirstSeenUtc = reader.GetDateTime(18)")]
+ [InlineData("ViewerDataService.FinOps.Workload.cs", "LastSeenUtc = reader.GetDateTime(19)")]
+ [InlineData("ViewerDataService.FinOps.Inventory.cs", "InventoryAsOfUtc = reader.IsDBNull(13) ? null : reader.GetDateTime(13)")]
+ [InlineData("ViewerDataService.FinOps.Inventory.cs", "LastCollectedUtc = reader.IsDBNull(19) ? null : reader.GetDateTime(19)")]
+ [InlineData("ViewerDataService.QueryStoreClutter.cs", "OptionsCapturedUtc = c?.CapturedAt")]
+ [InlineData("ViewerDataService.QueryStoreClutter.cs", "LastObservedUtc = w.LastObserved")]
+ public void EachReader_SetsTheUtcInstant_BesideTheConvertedDateTime(string file, string assignment)
+ {
+ Assert.Contains(assignment, ReadRepoFile("Darling", ViewerFolder, file), StringComparison.Ordinal);
+ }
+}
diff --git a/Darling/Darling.Tests/ViewerNotificationRouteDisplayTests.cs b/Darling/Darling.Tests/ViewerNotificationRouteDisplayTests.cs
new file mode 100644
index 0000000000..377b644d0d
--- /dev/null
+++ b/Darling/Darling.Tests/ViewerNotificationRouteDisplayTests.cs
@@ -0,0 +1,103 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using PerformanceMonitor.Analysis.Baselines;
+using PerformanceMonitor.Darling.Viewer;
+using PerformanceMonitor.Ui;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4766: the Notification Routes grid's Modified column. modified_at is written as
+/// now() AT TIME ZONE 'UTC', so it is one UTC instant (the row reads it as ), and it
+/// used to be ToLocalTime().ToString("g"): this machine's clock in every display mode, and bare in the repeated
+/// autumn hour. It now goes through like the grids
+/// beside it, so it follows the mode and names the UTC offset of each 01:30 on a US Eastern server (autumn change
+/// 2026-11-01 at 06:00 UTC).
+///
+/* Flips the process-wide display mode and active server clock (each restored in Dispose); one shared collection
+ serializes it with every other class that does. */
+[Collection("viewer-time-statics")]
+public sealed class ViewerNotificationRouteDisplayTests : IDisposable
+{
+ private static readonly ServerClock Eastern = ServerClock.Resolve("Eastern Standard Time", -300);
+
+ private readonly TimeDisplayMode _savedMode = ViewerTimeHelper.CurrentDisplayMode;
+ private readonly ServerClock _savedClock = ViewerTimeHelper.ActiveServerClock;
+ private readonly CultureInfo _savedCulture = CultureInfo.CurrentCulture;
+
+ public ViewerNotificationRouteDisplayTests()
+ {
+ /* "g" runs on the current culture, as every grid's text does; the invariant one fixes its pattern (MM/dd/yyyy HH:mm). */
+ CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
+ }
+
+ public void Dispose()
+ {
+ ViewerTimeHelper.CurrentDisplayMode = _savedMode;
+ ViewerTimeHelper.ActiveServerClock = _savedClock;
+ CultureInfo.CurrentCulture = _savedCulture;
+ }
+
+ private static NotificationRouteRow Route(int y, int mo, int d, int h, int mi) => new()
+ {
+ RouteId = 1,
+ MetricMatch = "High CPU",
+ ModifiedAtUtc = DateTime.SpecifyKind(new DateTime(y, mo, d, h, mi, 0), DateTimeKind.Utc),
+ };
+
+ [Fact]
+ public void Modified_InTheRepeatedHour_ReadsItsOffsetInServerMode_AndTheStoredInstantInUtcMode()
+ {
+ ViewerTimeHelper.ActiveServerClock = Eastern;
+
+ ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+ Assert.Equal("11/01/2026 01:30 -04:00", Route(2026, 11, 1, 5, 30).ModifiedDisplay);
+ Assert.Equal("11/01/2026 01:30 -05:00", Route(2026, 11, 1, 6, 30).ModifiedDisplay);
+ Assert.Equal("11/01/2026 02:30", Route(2026, 11, 1, 7, 30).ModifiedDisplay);
+
+ ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC;
+ Assert.Equal("11/01/2026 05:30", Route(2026, 11, 1, 5, 30).ModifiedDisplay);
+ Assert.Equal("11/01/2026 06:30", Route(2026, 11, 1, 6, 30).ModifiedDisplay);
+ }
+
+ /// The column follows the display mode: a summer instant on the Eastern server reads its wall time, not this machine's.
+ [Fact]
+ public void Modified_InServerMode_ReadsTheServersWallTime_WhateverThisMachinesZoneIs()
+ {
+ ViewerTimeHelper.ActiveServerClock = Eastern;
+ ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+
+ Assert.Equal("07/01/2026 10:30", Route(2026, 7, 1, 14, 30).ModifiedDisplay);
+ }
+
+ /// A route that has not been stored yet has no time to show.
+ [Fact]
+ public void Modified_OfARouteNotStoredYet_IsEmpty()
+ {
+ Assert.Equal("", new NotificationRouteRow().ModifiedDisplay);
+ }
+
+ /// The column is worded by the shared renderer and not by a ToLocalTime() of its own.
+ [Fact]
+ public void Modified_IsWordedByTheSharedRenderer()
+ {
+ var source = ViewerTypedRangeTests.StripComments(
+ ViewerTypedRangeTests.MemberText(
+ ViewerTypedRangeTests.ViewerSource("ViewerDataService.NotificationRoutes.cs", ThisFile()), "ModifiedDisplay"));
+
+ Assert.Contains("ViewerTimeHelper.FormatForDisplay(ModifiedAtUtc, \"g\")", source);
+ Assert.DoesNotContain("ToLocalTime", source);
+ }
+
+ private static string ThisFile([CallerFilePath] string thisFile = "") => thisFile;
+}
diff --git a/Darling/Darling.Tests/ViewerServerClockTests.cs b/Darling/Darling.Tests/ViewerServerClockTests.cs
index e958839082..afeb78af8b 100644
--- a/Darling/Darling.Tests/ViewerServerClockTests.cs
+++ b/Darling/Darling.Tests/ViewerServerClockTests.cs
@@ -139,6 +139,137 @@ public void TheProcessWideClock_DrivesForDisplayAndTheDisplayZone()
}
}
+ [Fact]
+ public void FormatForDisplay_InServerMode_GivesEachInstantOfTheRepeatedHourItsOwnOffset()
+ {
+ /* US Eastern fell back at 06:00Z on 2026-11-01: 05:30Z is the FIRST 01:30 (daylight time, -04:00) and 06:30Z the
+ SECOND (standard time, -05:00). Both used to print as 01:30, two grid rows nobody could tell apart (#4766). */
+ WithDisplay(TimeDisplayMode.ServerTime, Eastern, () =>
+ {
+ Assert.Equal("2026-11-01 01:30:00 -04:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 5, 30), "yyyy-MM-dd HH:mm:ss"));
+ Assert.Equal("2026-11-01 01:30:00 -05:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 6, 30), "yyyy-MM-dd HH:mm:ss"));
+
+ /* The whole repeated hour is covered, edge to edge: 05:00Z is the first 01:00 and 06:59Z the last minute of
+ the second 01:xx. The format string is the site's own, so a time-only column carries the offset too. */
+ Assert.Equal("01:00 -04:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 5, 0), "HH:mm"));
+ Assert.Equal("01:59 -04:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 5, 59), "HH:mm"));
+ Assert.Equal("01:00 -05:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 6, 0), "HH:mm"));
+ Assert.Equal("01:59 -05:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 6, 59), "HH:mm"));
+
+ /* A value that may be missing is the same text, and nothing at all when it is. */
+ Assert.Equal("2026-11-01 01:30:00 -05:00", ViewerTimeHelper.FormatForDisplay((DateTime?)Naive(2026, 11, 1, 6, 30), "yyyy-MM-dd HH:mm:ss"));
+ Assert.Equal("", ViewerTimeHelper.FormatForDisplay((DateTime?)null, "yyyy-MM-dd HH:mm:ss"));
+ });
+ }
+
+ [Fact]
+ public void FormatForDisplay_OutsideTheRepeatedHour_IsTheForDisplayTextWithNothingAppended()
+ {
+ /* The minute before the repeated hour (00:59 daylight time), the first minute after it (02:00 standard time),
+ and an ordinary summer and winter instant read exactly as ForDisplay(x).ToString(format) did. */
+ WithDisplay(TimeDisplayMode.ServerTime, Eastern, () =>
+ {
+ Assert.Equal("2026-11-01 00:59:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 4, 59), "yyyy-MM-dd HH:mm:ss"));
+ Assert.Equal("2026-11-01 02:00:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 7, 0), "yyyy-MM-dd HH:mm:ss"));
+ Assert.Equal("2026-07-06 12:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 7, 6, 16, 0), "yyyy-MM-dd HH:mm"));
+ Assert.Equal("2026-01-15 12:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 1, 15, 17, 0), "yyyy-MM-dd HH:mm"));
+
+ foreach (var instant in new[] { Naive(2026, 11, 1, 4, 59), Naive(2026, 11, 1, 7, 0), Naive(2026, 7, 6, 16, 0), Naive(2026, 1, 15, 17, 0) })
+ {
+ Assert.Equal(ViewerTimeHelper.ForDisplay(instant).ToString("yyyy-MM-dd HH:mm:ss"), ViewerTimeHelper.FormatForDisplay(instant, "yyyy-MM-dd HH:mm:ss"));
+ }
+ });
+ }
+
+ [Fact]
+ public void FormatForDisplay_InUtcMode_NeverAddsAnOffset()
+ {
+ /* UTC has no repeated hour: 05:30Z and 06:30Z are two different times, whatever the server's own zone is. */
+ WithDisplay(TimeDisplayMode.UTC, Eastern, () =>
+ {
+ Assert.Equal("2026-11-01 05:30:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 5, 30), "yyyy-MM-dd HH:mm:ss"));
+ Assert.Equal("2026-11-01 06:30:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 6, 30), "yyyy-MM-dd HH:mm:ss"));
+ });
+ }
+
+ [Fact]
+ public void FormatForDisplay_OnAFixedOffsetClock_NeverAddsAnOffset_AtTheInstantsWhereAUsZoneRepeatsAnHour()
+ {
+ /* A fixed-offset clock (a server that reported no time zone id) has no repeated hour: at 05:30Z and 06:30Z on the
+ day a US zone falls back, a -05:00 clock reads 00:30 and 01:30, each once. */
+ WithDisplay(TimeDisplayMode.ServerTime, ServerClock.FixedOffset(-300), () =>
+ {
+ Assert.Equal("2026-11-01 00:30:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 5, 30), "yyyy-MM-dd HH:mm:ss"));
+ Assert.Equal("2026-11-01 01:30:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 6, 30), "yyyy-MM-dd HH:mm:ss"));
+ Assert.Equal("01:30", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 6, 30), "HH:mm"));
+ });
+
+ /* The same two instants on a zone clock read 01:30 twice. */
+ WithDisplay(TimeDisplayMode.ServerTime, Eastern, () =>
+ {
+ Assert.Equal("2026-11-01 01:30:00 -04:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 5, 30), "yyyy-MM-dd HH:mm:ss"));
+ Assert.Equal("2026-11-01 01:30:00 -05:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 11, 1, 6, 30), "yyyy-MM-dd HH:mm:ss"));
+ });
+ }
+
+ [Fact]
+ public void FormatForDisplay_OnAZoneEastOfUtc_GivesTheTwoPassesOfTheRepeatedHourTheirPositiveOffsets()
+ {
+ /* Berlin falls back at 01:00Z on 2026-10-25 (03:00 CEST to 02:00 CET): 02:30 happens at 00:30Z (+02:00) and
+ again at 01:30Z (+01:00). */
+ WithDisplay(TimeDisplayMode.ServerTime, ServerClock.Resolve("W. Europe Standard Time", 60), () =>
+ {
+ Assert.Equal("2026-10-25 02:30:00 +02:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 10, 25, 0, 30), "yyyy-MM-dd HH:mm:ss"));
+ Assert.Equal("2026-10-25 02:30:00 +01:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 10, 25, 1, 30), "yyyy-MM-dd HH:mm:ss"));
+
+ /* The hour before and the hour after are bare. */
+ Assert.Equal("2026-10-25 01:59:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 10, 24, 23, 59), "yyyy-MM-dd HH:mm:ss"));
+ Assert.Equal("2026-10-25 03:00:00", ViewerTimeHelper.FormatForDisplay(Naive(2026, 10, 25, 2, 0), "yyyy-MM-dd HH:mm:ss"));
+ });
+ }
+
+ [Fact]
+ public void FormatForDisplay_FormatsOnTheCurrentCulture_AsTheSitesItReplacedDid()
+ {
+ /* "g" is the culture's own general date and short time. The renderer keeps the current culture, so the grids
+ that print it read as they did before; only the offset suffix is new. */
+ var german = new System.Globalization.CultureInfo("de-DE");
+
+ WithDisplay(TimeDisplayMode.ServerTime, Eastern, () =>
+ {
+ var instant = Naive(2026, 11, 1, 6, 30);
+
+ Assert.Equal(ViewerTimeHelper.ForDisplay(instant).ToString("g") + " -05:00", ViewerTimeHelper.FormatForDisplay(instant, "g"));
+ Assert.NotEqual(
+ ViewerTimeHelper.ForDisplay(instant).ToString("g", System.Globalization.CultureInfo.InvariantCulture) + " -05:00",
+ ViewerTimeHelper.FormatForDisplay(instant, "g"));
+ }, german);
+ }
+
+ /// Runs with the process-wide display mode and server clock set, on the invariant
+ /// culture unless one is given, so a literal expected string does not depend on the machine's separators. All
+ /// three are restored.
+ private static void WithDisplay(TimeDisplayMode mode, ServerClock clock, Action body, System.Globalization.CultureInfo? culture = null)
+ {
+ var savedMode = ViewerTimeHelper.CurrentDisplayMode;
+ var savedClock = ViewerTimeHelper.ActiveServerClock;
+ var savedCulture = System.Globalization.CultureInfo.CurrentCulture;
+ try
+ {
+ ViewerTimeHelper.CurrentDisplayMode = mode;
+ ViewerTimeHelper.ActiveServerClock = clock;
+ System.Globalization.CultureInfo.CurrentCulture = culture ?? System.Globalization.CultureInfo.InvariantCulture;
+
+ body();
+ }
+ finally
+ {
+ ViewerTimeHelper.CurrentDisplayMode = savedMode;
+ ViewerTimeHelper.ActiveServerClock = savedClock;
+ System.Globalization.CultureInfo.CurrentCulture = savedCulture;
+ }
+ }
+
[Fact]
public void ServerClockSql_ReadsTheZoneAndTheOffsetFromTheSameNewestRow()
{
diff --git a/Darling/Darling.Tests/ViewerSlicerRangeLabelTests.cs b/Darling/Darling.Tests/ViewerSlicerRangeLabelTests.cs
new file mode 100644
index 0000000000..9e9af1dca0
--- /dev/null
+++ b/Darling/Darling.Tests/ViewerSlicerRangeLabelTests.cs
@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text.RegularExpressions;
+using PerformanceMonitor.Analysis.Baselines;
+using PerformanceMonitor.Darling.Viewer;
+using PerformanceMonitor.Ui;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4766: the two time-range slicers word the range they hold the same way. Lite's caption (the start and the end of the
+/// selection) goes through , so the two instants of a repeated autumn hour read "01:30
+/// -04:00" and "01:30 -05:00". The viewer's slicer is a copy of Lite's and used to build the caption with
+/// ForDisplay(...).ToString(...), which read both as "01:30". The tick labels along the axis stay the plain wall time
+/// in both.
+///
+/// The caption is built in a private method of a WPF control, so the tests read the two sources: the viewer's call
+/// must be Lite's call (same method, same instants, same format, so the same culture), and the text that call gives for
+/// the pair is checked on US Eastern (autumn change 2026-11-01 at 06:00 UTC).
+///
+public sealed class ViewerSlicerRangeLabelTests
+{
+ private static readonly ServerClock Eastern = ServerClock.Resolve("Eastern Standard Time", -300);
+
+ private static DateTime Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, DateTimeKind.Unspecified);
+
+ /// The caption's two Format calls, as written, from a slicer's UpdateRangeLabel.
+ private static string[] FormatCalls(string source)
+ {
+ var body = ViewerTypedRangeTests.StripComments(ViewerTypedRangeTests.MemberText(source, "UpdateRangeLabel"))
+ .Replace("UiDisplayZone.", "DisplayZone.", StringComparison.Ordinal);
+ return Regex.Matches(body, @"DisplayZone\.Format\(UtcAtNorm\(_range(?:Start|End)\),[^;]*;")
+ .Select(m => Regex.Replace(m.Value, @"\s+", " "))
+ .ToArray();
+ }
+
+ ///
+ /// The viewer's range caption is Lite's, call for call: two DisplayZone.Format calls, one per end of the range,
+ /// in a zone value, with the same "yyyy-MM-dd HH:mm" format. That method words the text on the invariant culture in
+ /// both products.
+ ///
+ [Fact]
+ public void TheViewersRangeCaption_IsLitesCall_ForCall()
+ {
+ var viewer = FormatCalls(ViewerTypedRangeTests.ViewerSource("TimeRangeSlicerControl.xaml.cs", ThisFile()));
+ var lite = FormatCalls(File.ReadAllText(LitePath("Controls", "TimeRangeSlicerControl.xaml.cs")));
+
+ Assert.Equal(2, lite.Length);
+ Assert.Equal(lite, viewer);
+ Assert.Equal("DisplayZone.Format(UtcAtNorm(_rangeStart), zone, \"yyyy-MM-dd HH:mm\");", viewer[0]);
+ Assert.Equal("DisplayZone.Format(UtcAtNorm(_rangeEnd), zone, \"yyyy-MM-dd HH:mm\");", viewer[1]);
+ }
+
+ ///
+ /// The caption's zone is the display mode's zone on the active server's clock, and the viewer's caption no longer
+ /// words the range from ForDisplay. The tick labels are the one place the slicer still does, so they stay
+ /// the plain wall time.
+ ///
+ [Fact]
+ public void TheViewersRangeCaption_ReadsTheCurrentDisplayZone_AndTheTickLabelsStayPlain()
+ {
+ var source = ViewerTypedRangeTests.ViewerSource("TimeRangeSlicerControl.xaml.cs", ThisFile());
+ var caption = ViewerTypedRangeTests.StripComments(ViewerTypedRangeTests.MemberText(source, "UpdateRangeLabel"));
+
+ Assert.Contains("var zone = ViewerTimeHelper.CurrentDisplayZone();", caption);
+ Assert.DoesNotMatch(@"\bForDisplay\b", caption);
+
+ var code = ViewerTypedRangeTests.StripComments(source);
+ Assert.Single(Regex.Matches(code, @"ViewerTimeHelper\.ForDisplay\(tickTime\)\.ToString\(""MM/dd HH:mm""\)"));
+ Assert.Single(Regex.Matches(code, @"\bForDisplay\("));
+ }
+
+ ///
+ /// The text that shared call gives for the pair, on the culture Lite's caption uses (the invariant one, whatever the
+ /// machine's): 05:30Z is the first 01:30 (-04:00), 06:30Z the second (-05:00), and the hour after is bare.
+ ///
+ [Fact]
+ public void TheRangeCaptionsCall_NamesTheOffsetOfEachOccurrenceOfTheRepeatedHour()
+ {
+ var saved = CultureInfo.CurrentCulture;
+ try
+ {
+ /* A culture with its own time separator would change ToString(format) but not the invariant Format call. */
+ CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fi-FI");
+ var zone = ViewerTimeHelper.DisplayZoneFor(TimeDisplayMode.ServerTime, Eastern);
+
+ Assert.Equal("2026-11-01 01:30 -04:00", DisplayZone.Format(Utc(2026, 11, 1, 5, 30), zone, "yyyy-MM-dd HH:mm"));
+ Assert.Equal("2026-11-01 01:30 -05:00", DisplayZone.Format(Utc(2026, 11, 1, 6, 30), zone, "yyyy-MM-dd HH:mm"));
+ Assert.Equal("2026-11-01 02:30", DisplayZone.Format(Utc(2026, 11, 1, 7, 30), zone, "yyyy-MM-dd HH:mm"));
+ Assert.Equal("2026-11-01 05:30", DisplayZone.Format(Utc(2026, 11, 1, 5, 30), TimeZoneInfo.Utc, "yyyy-MM-dd HH:mm"));
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = saved;
+ }
+ }
+
+ /// A Lite source file, found by walking up from this test file to the repo root.
+ private static string LitePath(string folder, string file, [CallerFilePath] string thisFile = "")
+ {
+ var dir = Path.GetDirectoryName(thisFile)!;
+ var relative = Path.Combine("Lite", folder, file);
+ while (dir is not null && !File.Exists(Path.Combine(dir, relative)))
+ {
+ dir = Path.GetDirectoryName(dir);
+ }
+
+ Assert.NotNull(dir);
+ return Path.Combine(dir!, relative);
+ }
+
+ private static string ThisFile([CallerFilePath] string thisFile = "") => thisFile;
+}
diff --git a/Darling/Darling.Tests/ViewerTypedRangeZoneTests.cs b/Darling/Darling.Tests/ViewerTypedRangeZoneTests.cs
index 141e7e815a..83290771ad 100644
--- a/Darling/Darling.Tests/ViewerTypedRangeZoneTests.cs
+++ b/Darling/Darling.Tests/ViewerTypedRangeZoneTests.cs
@@ -452,8 +452,10 @@ public void ManageServers_ReadsTheClocksOncePerLoad_AndStampsEachRow()
var display = ViewerTypedRangeTests.StripComments(ViewerTypedRangeTests.MemberText(source, "LastCollectedDisplay"));
var load = ViewerTypedRangeTests.StripComments(source);
- Assert.DoesNotContain("ForDisplay", display);
- Assert.Contains("ConvertToDisplay(utc, ViewerTimeHelper.CurrentDisplayMode, Clock", display);
+ /* The active-clock ForDisplay by its exact name: FormatForDisplay is the renderer that takes the row's zone (#4766). */
+ Assert.DoesNotMatch(@"\bForDisplay\b", display);
+ Assert.Matches(
+ @"FormatForDisplay\(\s*utc,\s*ViewerTimeHelper\.DisplayZoneFor\(ViewerTimeHelper\.CurrentDisplayMode,\s*Clock", display);
Assert.Single(Regex.Matches(load, @"GetServerClocksAsync\("));
Assert.Contains("Clock = ViewerTimeHelper.ClockForServerOrMachine(clocks, row.ServerId, TimeZoneInfo.Local, nowUtc)", load);
}
@@ -465,8 +467,10 @@ public void AlertHistory_ReadsTheClocksOncePerLoad_AndStampsEachRow()
var display = ViewerTypedRangeTests.StripComments(ViewerTypedRangeTests.MemberText(source, "TimeLocal"));
var load = ViewerTypedRangeTests.StripComments(ViewerTypedRangeTests.MemberText(source, "GetAlertHistoryAsync"));
- Assert.DoesNotContain("ForDisplay", display);
- Assert.Contains("ConvertToDisplay(AlertTime, ViewerTimeHelper.CurrentDisplayMode, Clock", display);
+ /* The active-clock ForDisplay by its exact name: FormatForDisplay is the renderer that takes the row's zone (#4766). */
+ Assert.DoesNotMatch(@"\bForDisplay\b", display);
+ Assert.Matches(
+ @"FormatForDisplay\(\s*AlertTime,\s*ViewerTimeHelper\.DisplayZoneFor\(ViewerTimeHelper\.CurrentDisplayMode,\s*Clock", display);
/* One ask per load, not per row - and the ask goes to the fleet's clocks held for AlertClockLifetime (#4766),
not to the store on every poll (ViewerAlertClockCacheTests pins the cache itself). */
Assert.Single(Regex.Matches(load, @"_alertClocks\.GetAsync\("));
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/CollectionLogWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/CollectionLogWindow.xaml
index 8a9f0ba24f..8c8641e18c 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/CollectionLogWindow.xaml
+++ b/Darling/PerformanceMonitor.Darling.Viewer/CollectionLogWindow.xaml
@@ -63,7 +63,7 @@
Margin="10"
ContextMenu="{StaticResource DataGridContextMenu}">
-
+
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml
index 68b22c3028..3d03be261d 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml
+++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml
@@ -917,8 +917,8 @@
-
-
+
+
@@ -977,8 +977,8 @@
-
-
+
+
-
+
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ManageServersWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/ManageServersWindow.xaml.cs
index 20787e44af..fefba22aea 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ManageServersWindow.xaml.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ManageServersWindow.xaml.cs
@@ -369,15 +369,17 @@ public ManagedServerListItem(MonitoredServerRow row, bool isFavorite)
public bool HistoryAgedOut { get; set; }
/// The "Last Collected" cell: the newest collection time rendered in the viewer's timestamp-display
- /// mode (Server/Local/UTC via
- /// on this row's own ), "None retained" when retention
+ /// mode (Server/Local/UTC via
+ /// on this row's own , which adds the UTC offset to a time in the repeated autumn hour, #4766),
+ /// "None retained" when retention
/// has dropped all of it, or "Never" when the service has not collected this server yet. Labeled "Last
/// Collected" — the SERVICE connects and collects, the viewer never does — so this reflects service
/// activity, not a viewer connection.
public string LastCollectedDisplay =>
LastCollectedUtc is { } utc
- ? ViewerTimeHelper.ConvertToDisplay(utc, ViewerTimeHelper.CurrentDisplayMode, Clock ?? ViewerTimeHelper.ActiveServerClock)
- .ToString("yyyy-MM-dd HH:mm")
+ ? ViewerTimeHelper.FormatForDisplay(
+ utc, ViewerTimeHelper.DisplayZoneFor(ViewerTimeHelper.CurrentDisplayMode, Clock ?? ViewerTimeHelper.ActiveServerClock),
+ "yyyy-MM-dd HH:mm")
: HistoryAgedOut ? "None retained"
: "Never";
}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/NotificationRoutesWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/NotificationRoutesWindow.xaml
index 55b0536788..fc96d90403 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/NotificationRoutesWindow.xaml
+++ b/Darling/PerformanceMonitor.Darling.Viewer/NotificationRoutesWindow.xaml
@@ -53,7 +53,7 @@
-
+ r.DeltaExecutions);
var totalCpu = _historyData.Sum(r => r.DeltaCpuMs);
- var first = ViewerTimeHelper.ForDisplay(_historyData.First().CollectionTime);
- var last = ViewerTimeHelper.ForDisplay(_historyData.Last().CollectionTime);
- SummaryText.Text = $"{_historyData.Count} samples from {first:MM/dd HH:mm} to {last:MM/dd HH:mm} | " +
+ var first = ViewerTimeHelper.FormatForDisplay(_historyData.First().CollectionTime, "MM/dd HH:mm");
+ var last = ViewerTimeHelper.FormatForDisplay(_historyData.Last().CollectionTime, "MM/dd HH:mm");
+ SummaryText.Text = $"{_historyData.Count} samples from {first} to {last} | " +
$"Total Executions: {totalExec:N0} | Total CPU: {totalCpu:N1} ms";
}
else
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml.cs
index b711f6aade..6978a5ca35 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml.cs
@@ -81,9 +81,9 @@ private async Task LoadHistoryAsync()
{
var totalExec = _historyData.Sum(r => r.DeltaExecutions);
var totalCpu = _historyData.Sum(r => r.DeltaCpuMs);
- var first = ViewerTimeHelper.ForDisplay(_historyData.First().CollectionTime);
- var last = ViewerTimeHelper.ForDisplay(_historyData.Last().CollectionTime);
- SummaryText.Text = $"{_historyData.Count} samples from {first:MM/dd HH:mm} to {last:MM/dd HH:mm} | " +
+ var first = ViewerTimeHelper.FormatForDisplay(_historyData.First().CollectionTime, "MM/dd HH:mm");
+ var last = ViewerTimeHelper.FormatForDisplay(_historyData.Last().CollectionTime, "MM/dd HH:mm");
+ SummaryText.Text = $"{_historyData.Count} samples from {first} to {last} | " +
$"Total Executions: {totalExec:N0} | Total CPU: {totalCpu:N1} ms";
}
else
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml
index eedbdecae7..921df1f9bc 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml
+++ b/Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml
@@ -95,8 +95,8 @@
-
-
+
+
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml.cs
index e85136f1b3..539f1ed1a7 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml.cs
@@ -86,9 +86,9 @@ private async Task LoadHistoryAsync()
{
var totalExec = _historyData.Sum(r => r.ExecutionCount);
var planCount = _historyData.Select(r => r.PlanId).Distinct().Count();
- var first = ViewerTimeHelper.ForDisplay(_historyData.First().CollectionTime);
- var last = ViewerTimeHelper.ForDisplay(_historyData.Last().CollectionTime);
- SummaryText.Text = $"{_historyData.Count} samples from {first:MM/dd HH:mm} to {last:MM/dd HH:mm} | " +
+ var first = ViewerTimeHelper.FormatForDisplay(_historyData.First().CollectionTime, "MM/dd HH:mm");
+ var last = ViewerTimeHelper.FormatForDisplay(_historyData.Last().CollectionTime, "MM/dd HH:mm");
+ SummaryText.Text = $"{_historyData.Count} samples from {first} to {last} | " +
$"Total Executions: {totalExec:N0} | " +
(planCount > 1 ? $"{planCount} different plans" : "Single plan");
}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/TimeRangeSlicerControl.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/TimeRangeSlicerControl.xaml.cs
index 299b7a1f5a..324bd1ce27 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/TimeRangeSlicerControl.xaml.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/TimeRangeSlicerControl.xaml.cs
@@ -15,6 +15,7 @@
using System.Windows.Media;
using System.Windows.Shapes;
using PerformanceMonitor.Common;
+using PerformanceMonitor.Ui;
namespace PerformanceMonitor.Darling.Viewer;
@@ -24,9 +25,10 @@ namespace PerformanceMonitor.Darling.Viewer;
/// untouched). Its bucket timestamps are UTC (matching the Darling store's naive-UTC collection_time),
/// so / and the
/// payload are UTC and flow straight into the viewer's naive-UTC reads with no clock conversion. The
-/// only deviation from Lite is the two display strings (axis labels + range label): Lite's per-server
-/// ServerTimeHelper.FormatServerTime becomes (the
-/// viewer's mode-aware Server/Local/UTC text conversion).
+/// only deviation from Lite is the axis labels: Lite's per-server tick words become
+/// (the viewer's mode-aware Server/Local/UTC text conversion). The range
+/// caption is worded exactly as Lite's is (#4766): in the current display zone, so
+/// the two instants of a repeated autumn hour differ by their UTC offset.
///
public partial class TimeRangeSlicerControl : UserControl
{
@@ -373,8 +375,12 @@ private Brush FindBrush(string key, string fallbackHex)
private void UpdateRangeLabel()
{
if (_data.Count == 0) { RangeLabel.Text = ""; return; }
- var startDisplay = ViewerTimeHelper.ForDisplay(UtcAtNorm(_rangeStart)).ToString("yyyy-MM-dd HH:mm");
- var endDisplay = ViewerTimeHelper.ForDisplay(UtcAtNorm(_rangeEnd)).ToString("yyyy-MM-dd HH:mm");
+ /* #4766: worded as Lite's slicer words it (DisplayZone.Format, invariant culture), so the two instants of the
+ repeated autumn hour read "01:30 -04:00" and "01:30 -05:00" instead of both "01:30". The tick labels stay
+ the plain wall time. */
+ var zone = ViewerTimeHelper.CurrentDisplayZone();
+ var startDisplay = DisplayZone.Format(UtcAtNorm(_rangeStart), zone, "yyyy-MM-dd HH:mm");
+ var endDisplay = DisplayZone.Format(UtcAtNorm(_rangeEnd), zone, "yyyy-MM-dd HH:mm");
var spanHours = (UtcAtNorm(_rangeEnd) - UtcAtNorm(_rangeStart)).TotalHours;
var spanLabel = spanHours >= 1 ? $"{spanHours:F0}h" : $"{spanHours * 60:F0}m";
RangeLabel.Text = $"{startDisplay} → {endDisplay} ({spanLabel})";
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertHistory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertHistory.cs
index ce3e11ffb5..e1451dbf0f 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertHistory.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertHistory.cs
@@ -68,10 +68,13 @@ public sealed class ViewerAlertRow
public ServerClock? Clock { get; init; }
/// Stored naive-UTC; shown in the viewer's time display mode (Server/Local/UTC), Server on this row's
- /// own server's clock ().
- public string TimeLocal =>
- ViewerTimeHelper.ConvertToDisplay(AlertTime, ViewerTimeHelper.CurrentDisplayMode, Clock ?? ViewerTimeHelper.ActiveServerClock)
- .ToString("yyyy-MM-dd HH:mm:ss");
+ /// own server's clock (). An alert in the repeated hour after a fall-back reads the same wall
+ /// time in both of its occurrences, so it takes a space and its UTC offset ("2026-11-01 01:30:00 -05:00"), as
+ /// Lite's alert row and do for the other
+ /// grids (#4766).
+ public string TimeLocal => ViewerTimeHelper.FormatForDisplay(
+ AlertTime, ViewerTimeHelper.DisplayZoneFor(ViewerTimeHelper.CurrentDisplayMode, Clock ?? ViewerTimeHelper.ActiveServerClock),
+ "yyyy-MM-dd HH:mm:ss");
public string CurrentValueDisplay => AlertMetricClassifier.FormatHistoryValue(MetricName, CurrentValue);
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Blocking.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Blocking.cs
index 7ccffd52f4..06c77fd4a9 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Blocking.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Blocking.cs
@@ -63,7 +63,7 @@ public sealed class ViewerBlockedProcessRow : BlockedProcessAlertRow
/// The stored naive-UTC event time in the viewer machine's local time (Lite's grid format).
public string EventTimeLocal
- => EventTime is { } eventTime ? ViewerTimeHelper.ForDisplay(eventTime).ToString("yyyy-MM-dd HH:mm:ss") : "";
+ => EventTime is { } eventTime ? ViewerTimeHelper.FormatForDisplay(eventTime, "yyyy-MM-dd HH:mm:ss") : "";
/// Lite's wait-time rendering: sub-second in ms, else one-decimal seconds.
public string WaitTimeFormatted => ViewerDataService.FormatWaitTime(WaitTimeMs);
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs
index 385d8b3196..01b69c639c 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs
@@ -867,7 +867,7 @@ public class CollectionLogRow
public string Status { get; set; } = "";
public string? ErrorMessage { get; set; }
- public string CollectionTimeFormatted => ViewerTimeHelper.ForDisplay(CollectionTime).ToString("g");
+ public string CollectionTimeFormatted => ViewerTimeHelper.FormatForDisplay(CollectionTime, "g");
public string DurationFormatted => DurationMs.HasValue
? (DurationMs.Value < 1000 ? $"{DurationMs.Value} ms" : $"{DurationMs.Value / 1000.0:F1} s")
@@ -1027,15 +1027,15 @@ the two surfaces that have a rows_stored to count and a field to render it in. *
: $"{AvgDurationMs / 1000:F1} s";
public string LastSuccessFormatted => LastSuccessTime.HasValue
- ? ViewerTimeHelper.ForDisplay(LastSuccessTime.Value).ToString("g")
+ ? ViewerTimeHelper.FormatForDisplay(LastSuccessTime.Value, "g")
: "Never";
public string LastRunFormatted => LastRunTime.HasValue
- ? ViewerTimeHelper.ForDisplay(LastRunTime.Value).ToString("g")
+ ? ViewerTimeHelper.FormatForDisplay(LastRunTime.Value, "g")
: "Never";
public string LastErrorFormatted => LastErrorTime.HasValue
- ? ViewerTimeHelper.ForDisplay(LastErrorTime.Value).ToString("g")
+ ? ViewerTimeHelper.FormatForDisplay(LastErrorTime.Value, "g")
: "";
///
@@ -1061,6 +1061,6 @@ public class CollectionCaveatRow
public DateTime FirstSeenUtc { get; set; }
public DateTime LastSeenUtc { get; set; }
- public string FirstSeenFormatted => ViewerTimeHelper.ForDisplay(FirstSeenUtc).ToString("g");
- public string LastSeenFormatted => ViewerTimeHelper.ForDisplay(LastSeenUtc).ToString("g");
+ public string FirstSeenFormatted => ViewerTimeHelper.FormatForDisplay(FirstSeenUtc, "g");
+ public string LastSeenFormatted => ViewerTimeHelper.FormatForDisplay(LastSeenUtc, "g");
}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ConfigChanges.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ConfigChanges.cs
index d8ca456910..80778337e7 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ConfigChanges.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ConfigChanges.cs
@@ -262,7 +262,7 @@ public sealed class ServerConfigChangeRow(ConfigChangeDiff.ServerConfigChange ch
public bool? IsDynamic => change.IsDynamic;
public bool? IsAdvanced => change.IsAdvanced;
- public string ChangeTimeDisplay => ViewerTimeHelper.ForDisplay(change.ChangeTime).ToString("yyyy-MM-dd HH:mm:ss");
+ public string ChangeTimeDisplay => ViewerTimeHelper.FormatForDisplay(change.ChangeTime, "yyyy-MM-dd HH:mm:ss");
public string DynamicDisplay => change.DynamicDisplay;
public string AdvancedDisplay => change.AdvancedDisplay;
public bool RequiresRestart => change.RequiresRestart;
@@ -280,7 +280,7 @@ public sealed class DatabaseConfigChangeRow(ConfigChangeDiff.DatabaseConfigChang
public string? OldValue => change.OldValue;
public string? NewValue => change.NewValue;
- public string ChangeTimeDisplay => ViewerTimeHelper.ForDisplay(change.ChangeTime).ToString("yyyy-MM-dd HH:mm:ss");
+ public string ChangeTimeDisplay => ViewerTimeHelper.FormatForDisplay(change.ChangeTime, "yyyy-MM-dd HH:mm:ss");
public string ChangeDescription => change.ChangeDescription;
}
@@ -298,7 +298,7 @@ public sealed class TraceFlagChangeRow(ConfigChangeDiff.TraceFlagChange change)
/// enabled / disabled / modified (the set-diff outcome).
public string ChangeType => change.ChangeType;
- public string ChangeTimeDisplay => ViewerTimeHelper.ForDisplay(change.ChangeTime).ToString("yyyy-MM-dd HH:mm:ss");
+ public string ChangeTimeDisplay => ViewerTimeHelper.FormatForDisplay(change.ChangeTime, "yyyy-MM-dd HH:mm:ss");
public string PreviousStatusDisplay => change.PreviousStatusDisplay;
public string NewStatusDisplay => change.NewStatusDisplay;
public string GlobalDisplay => change.GlobalDisplay;
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Deadlock.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Deadlock.cs
index eb2c0ca899..548d14a711 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Deadlock.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Deadlock.cs
@@ -62,7 +62,7 @@ public sealed class DeadlockProcessDetail : DeadlockProcessInfo
/// deadlocks.deadlock_time is the XE @timestamp, so it is naive UTC and
/// converts through .
public string DeadlockTimeLocal
- => DeadlockTime is { } t ? ViewerTimeHelper.ForDisplay(t).ToString("yyyy-MM-dd HH:mm:ss") : "";
+ => DeadlockTime is { } t ? ViewerTimeHelper.FormatForDisplay(t, "yyyy-MM-dd HH:mm:ss") : "";
public string VictimDisplay => IsVictim ? "Victim" : "";
public string WaitTimeFormatted => WaitTime > 0 ? $"{WaitTime:N0} ms" : "";
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs
index 34ff40a6e4..01ef445e37 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs
@@ -363,6 +363,12 @@ FROM server_properties
public async Task> GetServerInventoryAsync(CancellationToken cancellationToken = default)
{
+ /* #4766: Server Inventory is one row per server, so each row reads its times on ITS server's clock (the
+ collected one, else the viewer machine's offset, the rule every list row uses) and not on the active server
+ tab's. The fleet's clocks are read once per load. */
+ var clocks = await GetServerClocksAsync(null, cancellationToken);
+ var nowUtc = DateTime.UtcNow;
+
await using var command = _dataSource.CreateCommand(ServerInventorySql);
command.CommandTimeout = ViewerCommandDeadlines.CurrentInteractiveReadSeconds;
@@ -377,9 +383,13 @@ public async Task> GetServerInventoryAsync(CancellationT
? $"{version} - {updateLevel}"
: $"{version} - {level}";
+ var serverId = reader.GetInt32(0);
+ var clock = ViewerTimeHelper.ClockForServerOrMachine(clocks, serverId, TimeZoneInfo.Local, nowUtc);
+
items.Add(new ServerPropertyRow
{
- ServerId = reader.GetInt32(0),
+ ServerId = serverId,
+ Clock = clock,
ServerName = reader.IsDBNull(1) ? "" : reader.GetString(1),
Edition = reader.IsDBNull(2) ? "" : reader.GetString(2),
ProductVersion = versionDisplay,
@@ -393,7 +403,10 @@ public async Task> GetServerInventoryAsync(CancellationT
IsClustered = reader.IsDBNull(12) ? null : reader.GetBoolean(12),
/* #2359: this is the CONFIG SNAPSHOT time, not a freshness heartbeat. Named for what it
is so nobody reads a days-old value as a stale metric again. */
- InventoryAsOf = reader.IsDBNull(13) ? null : ViewerTimeHelper.ForDisplay(reader.GetDateTime(13)),
+ InventoryAsOf = reader.IsDBNull(13) ? null : ViewerTimeHelper.ConvertToDisplay(reader.GetDateTime(13), ViewerTimeHelper.CurrentDisplayMode, clock),
+ /* #4766: the UTC instants too (this one and LastCollected below), so the columns' text can name the
+ offset in the repeated autumn hour. */
+ InventoryAsOfUtc = reader.IsDBNull(13) ? null : reader.GetDateTime(13),
/* sqlserver_start_time is the server's LOCAL clock — read verbatim, shown as-is like Lite
(UptimeDisplay = Now - start). host OS + AG role are the collected guarded values. */
SqlServerStartTime = reader.IsDBNull(14) ? null : reader.GetDateTime(14),
@@ -404,7 +417,8 @@ is so nobody reads a days-old value as a stale metric again. */
as a stale metric rather than as the date monitoring stopped. */
IsEnabled = reader.IsDBNull(17) || reader.GetBoolean(17),
MonthlyCost = reader.IsDBNull(18) ? 0m : Convert.ToDecimal(reader.GetValue(18)),
- LastCollected = reader.IsDBNull(19) ? null : ViewerTimeHelper.ForDisplay(reader.GetDateTime(19))
+ LastCollected = reader.IsDBNull(19) ? null : ViewerTimeHelper.ConvertToDisplay(reader.GetDateTime(19), ViewerTimeHelper.CurrentDisplayMode, clock),
+ LastCollectedUtc = reader.IsDBNull(19) ? null : reader.GetDateTime(19)
});
}
return items;
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs
index 45c2f2f881..590d7343a3 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs
@@ -299,6 +299,12 @@ public async Task> GetApplicationConnectionsAsync
{
var cutoff = DateTime.UtcNow.AddHours(-24);
+ /* #4766: the rows read their times on THIS server's clock (its collected one, else the viewer machine's offset,
+ the rule every list row uses), read once per load, and not on the active server tab's: the FinOps tab lists the
+ server it was opened for. */
+ var clock = ViewerTimeHelper.ClockForServerOrMachine(
+ await GetServerClocksAsync(serverId, cancellationToken), serverId, TimeZoneInfo.Local, DateTime.UtcNow);
+
await using var command = _dataSource.CreateCommand(ApplicationConnectionsSql);
command.CommandTimeout = ViewerCommandDeadlines.CurrentInteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
@@ -328,8 +334,12 @@ public async Task> GetApplicationConnectionsAsync
AvgLogicalReads = reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15)),
MaxLogicalReads = reader.IsDBNull(16) ? 0L : Convert.ToInt64(reader.GetValue(16)),
SampleCount = reader.IsDBNull(17) ? 0 : Convert.ToInt64(reader.GetValue(17)),
- FirstSeenLocal = ViewerTimeHelper.ForDisplay(reader.GetDateTime(18)),
- LastSeenLocal = ViewerTimeHelper.ForDisplay(reader.GetDateTime(19))
+ FirstSeenLocal = ViewerTimeHelper.ConvertToDisplay(reader.GetDateTime(18), ViewerTimeHelper.CurrentDisplayMode, clock),
+ LastSeenLocal = ViewerTimeHelper.ConvertToDisplay(reader.GetDateTime(19), ViewerTimeHelper.CurrentDisplayMode, clock),
+ /* #4766: the UTC instants too, so the columns' text can name the offset in the repeated autumn hour. */
+ FirstSeenUtc = reader.GetDateTime(18),
+ LastSeenUtc = reader.GetDateTime(19),
+ Clock = clock
});
}
return items;
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs
index a6bd560d5f..4159712f2f 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs
@@ -9,6 +9,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using PerformanceMonitor.Analysis.Baselines;
namespace PerformanceMonitor.Darling.Viewer;
@@ -164,6 +165,34 @@ public sealed class ApplicationConnectionRow
public long SampleCount { get; set; }
public DateTime FirstSeenLocal { get; set; }
public DateTime LastSeenLocal { get; set; }
+
+ ///
+ /// The UTC instants behind and (#4766). A converted wall
+ /// clock cannot say which of the two 01:30s of the repeated autumn hour it was, so the column text is worded from these.
+ ///
+ public DateTime FirstSeenUtc { get; set; }
+ public DateTime LastSeenUtc { get; set; }
+
+ ///
+ /// The clock of the server these rows are for, stamped by the loader (#4766): its collected clock, else the viewer
+ /// machine's offset (), the rule every list row uses. The
+ /// FinOps tab loads the server it is opened for, which need not be the server tab that is active, so the times read
+ /// that server's wall time in Server mode. Null (a row built without one) falls back to the active server's.
+ ///
+ public ServerClock? Clock { get; set; }
+
+ ///
+ /// What the First Seen and Last Seen columns show (#4766): the instant in the display mode on the row's server's clock,
+ /// with its UTC offset added in the repeated autumn hour
+ /// (). The columns bind these
+ /// and sort by and , so the order stays chronological.
+ ///
+ public string FirstSeenText => ViewerTimeHelper.FormatForDisplay(FirstSeenUtc, DisplayZoneNow(Clock), "yyyy-MM-dd HH:mm");
+ public string LastSeenText => ViewerTimeHelper.FormatForDisplay(LastSeenUtc, DisplayZoneNow(Clock), "yyyy-MM-dd HH:mm");
+
+ /// The zone the current display mode names for 's server, else the active server's.
+ internal static TimeZoneInfo DisplayZoneNow(ServerClock? rowClock) =>
+ ViewerTimeHelper.DisplayZoneFor(ViewerTimeHelper.CurrentDisplayMode, rowClock ?? ViewerTimeHelper.ActiveServerClock);
}
/// Per-file database size + growth config (Database Sizes sub-tab).
@@ -242,12 +271,43 @@ public sealed class ServerPropertyRow
///
public DateTime? InventoryAsOf { get; set; }
+ ///
+ /// The UTC instants behind and (#4766). A converted wall clock
+ /// cannot say which of the two 01:30s of the repeated autumn hour it was, so the column text is worded from these.
+ ///
+ public DateTime? InventoryAsOfUtc { get; set; }
+
+ ///
+ /// What the Inventory As Of column shows (#4766): the instant in the display mode, with its UTC offset added in the
+ /// repeated autumn hour, and nothing for a server with no snapshot. The column binds this and sorts by
+ /// .
+ ///
+ public string InventoryAsOfText => InventoryAsOfUtc.HasValue
+ ? ViewerTimeHelper.FormatForDisplay(InventoryAsOfUtc.Value, ApplicationConnectionRow.DisplayZoneNow(Clock), "yyyy-MM-dd HH:mm")
+ : "";
+
+ ///
+ /// The clock of THIS row's server, stamped by the loader (#4766): its collected clock, else the viewer machine's
+ /// offset (). Server Inventory is one row per server, so each
+ /// row's times read its own server's wall time in Server mode and not the active server tab's. Null (a row built
+ /// without one) falls back to the active server's.
+ ///
+ public ServerClock? Clock { get; set; }
+
///
/// The newest collection of ANY kind for this server — MAX(collection_time) across
/// v_collection_log, the same signal list_servers and the Overview cards use (#2359). This is
/// the real freshness heartbeat, and it moves every sweep.
///
public DateTime? LastCollected { get; set; }
+
+ /// The UTC instant behind ; see .
+ public DateTime? LastCollectedUtc { get; set; }
+
+ /// What the Last Collected column shows (#4766); see . The column sorts by .
+ public string LastCollectedText => LastCollectedUtc.HasValue
+ ? ViewerTimeHelper.FormatForDisplay(LastCollectedUtc.Value, ApplicationConnectionRow.DisplayZoneNow(Clock), "yyyy-MM-dd HH:mm")
+ : "";
public bool? IsHadrEnabled { get; set; }
public bool? IsClustered { get; set; }
public string AgReplicaRole { get; set; } = "Standalone";
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs
index 08410d7990..ab47e357e4 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs
@@ -25,8 +25,9 @@ namespace PerformanceMonitor.Darling.Viewer;
/// long-runtime / retry flags for the grid's color-coding. run_datetime is the monitored server's LOCAL
/// wall clock, so converts it to naive-UTC in C# with the
/// server's before it reaches this row — exactly like the Default Trace reader
-/// (#4766) — so renders through the same
-/// as every other viewer grid and sorts consistently with them.
+/// (#4766) — so sorts consistently with every other viewer grid and renders through
+/// , as the plain wall time: a stored server-local time cannot say which pass
+/// of the repeated autumn hour it was, so it never takes the offset suffix a real instant does.
///
public sealed class ViewerJobHistoryRow
{
@@ -57,11 +58,19 @@ public sealed class ViewerJobHistoryRow
public DateTime? LastSuccessfulRunUtc { get; init; }
public bool IsLongRunning { get; init; }
- /// Stored naive-UTC; shown in the viewer machine's local time (the viewer convention).
+ ///
+ /// in the current display mode, as the plain wall time and never with the repeated-hour
+ /// UTC offset that adds (#4766). That offset is only
+ /// true for a real instant, and this one was converted from a STORED server-local time: a wall time that happens
+ /// twice on the autumn change day reads as its first occurrence (), so a step that
+ /// ran at 01:30 the second time (-05:00) would print "-04:00", the other pass's offset. The bare text is what
+ /// the row's own wall clock said.
+ ///
public string RunTimeLocal => RunDateTimeUtc is { } t
? ViewerTimeHelper.ForDisplay(t).ToString("yyyy-MM-dd HH:mm:ss")
: "";
+ /// as the plain wall time, for the reason gives.
public string LastSuccessfulRunLocal => LastSuccessfulRunUtc is { } t
? ViewerTimeHelper.ForDisplay(t).ToString("yyyy-MM-dd HH:mm:ss")
: "Never";
@@ -557,6 +566,11 @@ public sealed class ViewerAgentStatusRow
public string StatusDisplay => AgentRunning ? "Running" : (AgentStatusDesc ?? "Stopped");
+ ///
+ /// as the plain wall time (#4766). It was converted from the server's stored wall
+ /// clock, which cannot say which pass of a repeated autumn hour it was, so it never takes the UTC offset that
+ /// adds for a real instant.
+ ///
public string NextScheduledRunLocal => NextScheduledRunUtc is { } t
? ViewerTimeHelper.ForDisplay(t).ToString("yyyy-MM-dd HH:mm:ss")
: "None scheduled";
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LongQueries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LongQueries.cs
index ee70f788ef..abe47529f0 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LongQueries.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LongQueries.cs
@@ -45,7 +45,7 @@ public sealed class ViewerLongQueryRow
public string ObjectName { get; set; } = "";
public string EventTimeLocal
- => EventTime is { } eventTime ? ViewerTimeHelper.ForDisplay(eventTime).ToString("yyyy-MM-dd HH:mm:ss") : "";
+ => EventTime is { } eventTime ? ViewerTimeHelper.FormatForDisplay(eventTime, "yyyy-MM-dd HH:mm:ss") : "";
public string DurationFormatted => FormatMicroseconds(DurationMicroseconds);
public string CpuFormatted => FormatMicroseconds(CpuTimeMicroseconds);
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.NotificationRoutes.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.NotificationRoutes.cs
index 224daa122f..62071868c4 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.NotificationRoutes.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.NotificationRoutes.cs
@@ -238,7 +238,15 @@ public sealed class NotificationRouteRow
/* Grid display members. */
public string MatchKindDisplay => Family is null ? "Exact metric" : "Family";
public string ChannelsDisplay => ConfiguredChannels.Count == 0 ? "—" : string.Join(", ", ConfiguredChannels);
- public string ModifiedDisplay => ModifiedAtUtc == default ? "" : ModifiedAtUtc.ToLocalTime().ToString("g", System.Globalization.CultureInfo.CurrentCulture);
+
+ ///
+ /// When the route was last saved, in the viewer's display mode (#4766). is the store's
+ /// modified_at, written as now() AT TIME ZONE 'UTC', so it is one UTC instant, and it reads as the
+ /// other grids' times do: Server, Local or UTC per the mode, with the UTC offset added in the repeated autumn
+ /// hour (). This used to be the machine's local
+ /// time in every mode. Empty for a route that has not been stored yet.
+ ///
+ public string ModifiedDisplay => ModifiedAtUtc == default ? "" : ViewerTimeHelper.FormatForDisplay(ModifiedAtUtc, "g");
public NotificationRouteRow Clone() => new()
{
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs
index d6cb99d8c0..103cd5bfa0 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs
@@ -1026,7 +1026,7 @@ public string ThreadsDisplay
/// was collected, and none of it is retained, which is what the row says.
///
public string LastCollectionDisplay => LastCollectionTime.HasValue
- ? ViewerTimeHelper.ForDisplay(LastCollectionTime.Value).ToString("HH:mm:ss")
+ ? ViewerTimeHelper.FormatForDisplay(LastCollectionTime.Value, "HH:mm:ss")
: IsOnline == false ? "None retained" : "Never";
/* Collection status. The (IsOnline, CollectionStale, AwaitingFirstCollection) triple is resolved by
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.PlanCorrection.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.PlanCorrection.cs
index 21bcb6058f..151676d9a5 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.PlanCorrection.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.PlanCorrection.cs
@@ -226,7 +226,7 @@ against the collector-written UTC collection_time rather than inferred from Plan
/// A naive-UTC stamp in the display mode. NOT for a server-clock column — every one of
/// those in this row uses instead.
internal static string Local(DateTime? naiveUtc)
- => naiveUtc is { } utc ? ViewerTimeHelper.ForDisplay(utc).ToString("yyyy-MM-dd HH:mm:ss") : "";
+ => naiveUtc is { } utc ? ViewerTimeHelper.FormatForDisplay(utc, "yyyy-MM-dd HH:mm:ss") : "";
private static string YesNo(bool? value) => value is bool flag ? (flag ? "Yes" : "No") : "";
}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Postgres.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Postgres.cs
index 03842a7c2b..eaeac7ebf0 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Postgres.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Postgres.cs
@@ -213,7 +213,7 @@ public static List BuildPostgresCollectorHealth(
StoreTable = definition.TargetTable,
Status = status,
LastRun = facts?.LastRunAt is { } at
- ? ViewerTimeHelper.ForDisplay(at).ToString("yyyy-MM-dd HH:mm", CultureInfo.CurrentCulture)
+ ? ViewerTimeHelper.FormatForDisplay(at, "yyyy-MM-dd HH:mm")
: "",
Runs = facts?.Runs ?? 0,
FailedRuns = facts?.FailedRuns ?? 0,
@@ -357,7 +357,7 @@ public async Task> GetPgCpuUtilizationHistoryAsync(
.OrderByDescending(s => s.SampleTimeUtc)
.Select(s => new PgCpuUtilizationRow
{
- Time = ViewerTimeHelper.ForDisplay(s.SampleTimeUtc).ToString("yyyy-MM-dd HH:mm", CultureInfo.CurrentCulture),
+ Time = ViewerTimeHelper.FormatForDisplay(s.SampleTimeUtc, "yyyy-MM-dd HH:mm"),
CpuPercent = s.CpuPercent,
})
.ToList();
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QuerySnapshots.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QuerySnapshots.cs
index 5b7a332fec..c820bd39a6 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QuerySnapshots.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QuerySnapshots.cs
@@ -81,7 +81,7 @@ set only when WaitDrillDownWindow walks blocking chains — the transitive block
public bool HasQueryPlan { get; set; }
public bool HasLiveQueryPlan { get; set; }
public string CollectionTimeLocal =>
- CollectionTime == DateTime.MinValue ? "" : ViewerTimeHelper.ForDisplay(CollectionTime).ToString("yyyy-MM-dd HH:mm:ss");
+ CollectionTime == DateTime.MinValue ? "" : ViewerTimeHelper.FormatForDisplay(CollectionTime, "yyyy-MM-dd HH:mm:ss");
/// The transaction begin time, empty when the request has no open transaction.
/// transaction_begin_time from sys.dm_tran_active_transactions is a SQL-server-local wall-clock time (NOT
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs
index cd1abd78f4..dcda1a674e 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs
@@ -769,12 +769,13 @@ public static string FormatServerClock(DateTime? serverLocal)
/// for instead — query_store_stats' first- and last-execution
/// times, which Query Store returns as datetimeoffset and QueryStoreCollector normalises
/// through DateTimeOffset.UtcDateTime. Inlining
- /// ViewerTimeHelper.ForDisplay(x).ToString(...) would work identically; a named method beside its
- /// opposite is what makes the choice reviewable.
+ /// ViewerTimeHelper.FormatForDisplay(x, ...) would work identically; a named method beside its
+ /// opposite is what makes the choice reviewable. A time in the repeated autumn hour carries its UTC offset
+ /// ().
///
public static string FormatStoredUtc(DateTime? naiveUtc)
=> naiveUtc.HasValue
- ? ViewerTimeHelper.ForDisplay(naiveUtc.Value).ToString("yyyy-MM-dd HH:mm:ss")
+ ? ViewerTimeHelper.FormatForDisplay(naiveUtc.Value, "yyyy-MM-dd HH:mm:ss")
: "";
///
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreClutter.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreClutter.cs
index cf3a44305b..19961a10a0 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreClutter.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreClutter.cs
@@ -94,6 +94,17 @@ public sealed class QueryStoreClutterRow
/// column sorts chronologically rather than by the text of a formatted stamp.
public DateTime? OptionsCaptured { get; init; }
+ /// The UTC instant behind (#4766). A converted wall clock cannot say which
+ /// of the two 01:30s of the repeated autumn hour it was, so the column text is worded from this.
+ public DateTime? OptionsCapturedUtc { get; init; }
+
+ /// What the Options Captured column shows (#4766): the instant in the display mode, with its UTC offset
+ /// added in the repeated autumn hour. Null when the verdict read no options row, so the binding's
+ /// TargetNullValue still draws the em-dash. The column binds this and sorts by .
+ public string? OptionsCapturedText => OptionsCapturedUtc.HasValue
+ ? ViewerTimeHelper.FormatForDisplay(OptionsCapturedUtc.Value, "yyyy-MM-dd HH:mm")
+ : null;
+
/// The composition's prose for this row's reasons, one sentence per reason — the same
/// sentences the MCP payload and the web panel carry. Shown as the row's tooltip and in the
/// detail pane under the grid.
@@ -132,6 +143,13 @@ public sealed class QueryStoreOverheadWaitRow
public long WaitingTasks { get; init; }
public long MeasuredSeconds { get; init; }
public DateTime LastObserved { get; init; }
+
+ /// The UTC instant behind (#4766); the column text is worded from it.
+ public DateTime LastObservedUtc { get; init; }
+
+ /// What the Last Seen column shows (#4766): the instant in the display mode, with its UTC offset added in the
+ /// repeated autumn hour. The column binds this and sorts by .
+ public string LastObservedText => ViewerTimeHelper.FormatForDisplay(LastObservedUtc, "yyyy-MM-dd HH:mm");
}
/// Both halves of one read: the per-database rows and the one per-server overhead block.
@@ -206,6 +224,7 @@ private static QueryStoreClutterRow Project(QueryStoreClutter.DatabaseClutter ro
StaleQueryThresholdDays = c?.StaleQueryThresholdDays,
PctOfCap = c is null ? null : Round(QueryStoreClutter.PctOfCap(c), 1),
OptionsCaptured = c is null ? null : ViewerTimeHelper.ForDisplay(c.CapturedAt),
+ OptionsCapturedUtc = c?.CapturedAt,
Recommendation = string.Join(" ", QueryStoreClutter.Recommendations(row)),
};
}
@@ -225,6 +244,7 @@ private static QueryStoreOverheadSummary ProjectOverhead(
WaitingTasks = w.WaitingTasksTotal,
MeasuredSeconds = w.MeasuredSeconds,
LastObserved = ViewerTimeHelper.ForDisplay(w.LastObserved),
+ LastObservedUtc = w.LastObserved,
})
.ToList();
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs
index 32adedec92..21bd9695c2 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs
@@ -32,15 +32,31 @@ namespace PerformanceMonitor.Darling.Viewer;
* The row view-models below are flat projections of the Common records (all shredded columns preserved,
* so the grids faithfully match sp_HealthParser's per-category table shape) plus an EventTimeLocal
* display string — the machine-local render of the event's naive-UTC XE @timestamp, via the same
- * ViewerTimeHelper.ForDisplay the deadlock / blocked-process grids use. Only SevereError adds a
- * resolved DatabaseName (see ResolveDatabaseName).
+ * ViewerTimeHelper.FormatForDisplay the deadlock / blocked-process grids use (a Default Trace row is a
+ * stored server wall clock instead, so it renders the bare wall time through
+ * SystemEventRowFormat.StoredWallClock and never takes the repeated-hour offset, #4766). Only SevereError
+ * adds a resolved DatabaseName (see ResolveDatabaseName).
*/
/// Shared machine-local render of a naive-UTC event timestamp for the System Events grids.
internal static class SystemEventRowFormat
{
+ ///
+ /// A time converted from a STORED server wall clock (the Default Trace StartTime), as the plain wall time
+ /// (#4766). maps both passes of a repeated local hour to the first, so the
+ /// instant cannot say which pass the event was in; appending the offset would print the first pass's for an
+ /// event that ran in the second. Every other time reads as does. Declared before
+ /// on purpose: the wrapper census reads each wrapper's body as the text that follows its
+ /// signature, and a renderer named in the next member would be read as this one's.
+ ///
+ public static string StoredWallClock(DateTime? utc) =>
+ utc.HasValue ? ViewerTimeHelper.ForDisplay(utc.Value).ToString("yyyy-MM-dd HH:mm:ss") : "";
+
+ /// A REAL instant (the system_health XE @timestamp is UTC): the text
+ /// words, so the two passes of the repeated autumn
+ /// hour differ by their UTC offsets.
public static string Local(DateTime? utc) =>
- utc is { } t ? ViewerTimeHelper.ForDisplay(t).ToString("yyyy-MM-dd HH:mm:ss") : "";
+ utc is { } t ? ViewerTimeHelper.FormatForDisplay(t, "yyyy-MM-dd HH:mm:ss") : "";
}
/// One scheduler-monitor utilization sample (Scheduler Issues sub-tab), flagged the way sp_HealthParser flags this section: SQL CPU pinned, other-process CPU high, or memory utilization low.
@@ -208,9 +224,10 @@ public sealed class IoIssuesRow(IoIssuesRecord record)
/// it to naive-UTC in C# with the server's (its time zone where SQL Server reports
/// one, else the collected server_properties.utc_offset_minutes, else UTC) BEFORE it reaches this row,
/// so is the event's real UTC time on either side of a daylight-saving change and
-/// renders through the same as every
-/// other System Events grid and sorts consistently with them (the inverse of the MCP reader's bounds
-/// conversion; mirrors CorrelatedTimelineLanesControl's CPU-lane de-skew) (#4766).
+/// sorts consistently with every other System Events grid (the inverse of the MCP
+/// reader's bounds conversion; mirrors CorrelatedTimelineLanesControl's CPU-lane de-skew) (#4766). It renders through
+/// , not : the wall time is
+/// stored, so an event in the repeated autumn hour cannot say which pass it was in and never takes an offset.
///
public sealed class DefaultTraceEventRow
{
@@ -231,7 +248,7 @@ public DefaultTraceEventRow(
string? textData)
{
EventTimeUtc = eventTimeUtc;
- EventTimeLocal = SystemEventRowFormat.Local(eventTimeUtc);
+ EventTimeLocal = SystemEventRowFormat.StoredWallClock(eventTimeUtc);
Category = category.ToString();
EventName = eventName;
DatabaseName = databaseName;
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerHistoryRows.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerHistoryRows.cs
index 27b70c801b..17dcabb4ec 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerHistoryRows.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerHistoryRows.cs
@@ -32,7 +32,7 @@ internal static class HistoryTime
{
/// Formats a naive-UTC collection timestamp in the current display mode (Server / Local / UTC).
public static string CollectionLocal(DateTime collectionTimeUtc)
- => ViewerTimeHelper.ForDisplay(collectionTimeUtc).ToString("yyyy-MM-dd HH:mm:ss");
+ => ViewerTimeHelper.FormatForDisplay(collectionTimeUtc, "yyyy-MM-dd HH:mm:ss");
}
/// One collected query_stats snapshot for a single (database, query_hash) — Lite's
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerPostgresDisplay.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerPostgresDisplay.cs
index e510d74085..1a29354d43 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerPostgresDisplay.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerPostgresDisplay.cs
@@ -121,7 +121,7 @@ internal static string Milliseconds(long ms)
internal static string Timestamp(DateTime? utc) =>
utc is { } value
- ? ViewerTimeHelper.ForDisplay(value).ToString("yyyy-MM-dd HH:mm", CultureInfo.CurrentCulture)
+ ? ViewerTimeHelper.FormatForDisplay(value, "yyyy-MM-dd HH:mm")
: string.Empty;
/// A percentage of a total, or the dash when the total is zero — which is "nothing happened",
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActiveQueries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActiveQueries.cs
index afbff2e0a5..31e1fc8a10 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActiveQueries.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActiveQueries.cs
@@ -185,7 +185,7 @@ private async void LatestSnapshot_Click(object sender, RoutedEventArgs e)
var (batchTime, rows) = await _dataService.GetLatestQuerySnapshotBatchAsync(_server.ServerId, databaseNames: SelectedDatabaseFilter);
_querySnapshotsFilterMgr!.UpdateData(rows);
LatestSnapshotIndicator.Text = batchTime.HasValue
- ? $"Latest snapshot: {ViewerTimeHelper.ForDisplay(batchTime.Value):yyyy-MM-dd HH:mm:ss}"
+ ? $"Latest snapshot: {ViewerTimeHelper.FormatForDisplay(batchTime.Value, "yyyy-MM-dd HH:mm:ss")}"
: "No snapshots stored";
}
catch (Exception ex)
@@ -318,7 +318,7 @@ internal async Task DeepLinkToActiveQueriesAsync(DateTime fromUtc, DateTime toUt
await RefreshServerClockAsync();
ApplyServerClockToHelper();
- var indicator = $"Finding window: {ViewerTimeHelper.ForDisplay(fromUtc):yyyy-MM-dd HH:mm} → {ViewerTimeHelper.ForDisplay(toUtc):HH:mm}";
+ var indicator = $"Finding window: {ViewerTimeHelper.FormatForDisplay(fromUtc, "yyyy-MM-dd HH:mm")} → {ViewerTimeHelper.FormatForDisplay(toUtc, "HH:mm")}";
_pendingActiveQueriesWindow = (fromUtc, toUtc, indicator);
await NavigateToActiveQueriesForWindowAsync(fromUtc, toUtc, indicator);
}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ChartContextMenu.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ChartContextMenu.cs
index b8a3fef132..b9223df4b0 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ChartContextMenu.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ChartContextMenu.cs
@@ -81,20 +81,23 @@ internal static IReadOnlyList ChartMenuHeaderOrder(string? drillDownLabe
return items;
}
- /* The chart-data CSV shape: DateTime (),Series,Value (Lite's ContextMenuHelper CSV export verbatim).
+ /* The chart-data CSV shape: DateTime (),Series,Value,UTC offset. The first three cells are Lite's
+ ContextMenuHelper CSV export verbatim; the fourth names each row's instant exactly (#4766).
Pure so the tests pin the header + row format without a WpfPlot. */
///
- /// The chart-data CSV header line ("DateTime (<zone Id>){sep}Series{sep}Value", #4766). The time column names
- /// , the zone writes each time in ("DateTime (UTC)",
- /// "DateTime (Eastern Standard Time)"), so a file opened later still says which clock its times are on. The name is
- /// quoted when it holds the separator, as any other cell would be.
+ /// The chart-data CSV header line ("DateTime (<zone Id>){sep}Series{sep}Value{sep}UTC offset", #4766). The
+ /// time column names , the zone writes each time in
+ /// ("DateTime (UTC)", "DateTime (Eastern Standard Time)"), so a file opened later still says which clock its times
+ /// are on. The name is quoted when it holds the separator, as any other cell would be. The last column holds the
+ /// UTC offset each row's time was written at.
///
internal static string ChartCsvHeaderLine(string separator, TimeZoneInfo zone) =>
- string.Join(separator, new[] { CsvEscape($"DateTime ({zone.Id})", separator), "Series", "Value" });
+ string.Join(separator, new[] { CsvEscape($"DateTime ({zone.Id})", separator), "Series", "Value", "UTC offset" });
- /// One chart-data CSV row: an invariant yyyy-MM-dd HH:mm:ss timestamp, the escaped series
- /// name, and the invariant value (Lite's exact row shape).
+ /// The first three cells of a chart-data CSV row: an invariant yyyy-MM-dd HH:mm:ss timestamp,
+ /// the escaped series name, and the invariant value (Lite's exact row shape).
+ /// adds the UTC offset cell.
internal static string FormatChartCsvLine(DateTime dateTime, string series, double value, string separator) =>
string.Join(separator, new[]
{
@@ -108,11 +111,16 @@ internal static string FormatChartCsvLine(DateTime dateTime, string series, doub
/// date, and the file shows it in , the zone the chart's own tick, hover and crosshair
/// labels use, so the CSV reads as the chart does. An instant in a repeated hour reads as the same wall time
/// each time it comes round (06:30Z on a US Eastern autumn change day is the second 01:30), as the chart's tick
- /// labels do; the file has no offset column to tell the two apart. Pure, so a test names the zone instead of
- /// reading the display mode.
+ /// labels do; the fourth cell, , tells the two apart (-04:00 for the first
+ /// 01:30, -05:00 for the second). It is written on every row, so the time column keeps one plain date-and-time
+ /// format from the first row to the last. Pure, so a test names the zone instead of reading the display mode.
///
- internal static string ChartCsvDataLine(double plottedX, string series, double value, string separator, TimeZoneInfo zone) =>
- FormatChartCsvLine(DisplayZone.ToDisplay(DateTime.FromOADate(plottedX), zone), series, value, separator);
+ internal static string ChartCsvDataLine(double plottedX, string series, double value, string separator, TimeZoneInfo zone)
+ {
+ var instant = DateTime.FromOADate(plottedX);
+ return FormatChartCsvLine(DisplayZone.ToDisplay(instant, zone), series, value, separator)
+ + separator + DisplayZone.UtcOffsetText(instant, zone);
+ }
/// RFC-4180 CSV quoting (Lite's ContextMenuHelper.CsvEscape).
internal static string CsvEscape(string value, string separator)
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CurrentActiveQueries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CurrentActiveQueries.cs
index f503c5e6eb..83ba14b234 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CurrentActiveQueries.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CurrentActiveQueries.cs
@@ -67,7 +67,7 @@ rather than attempting an enqueue that would throw. */
{
case ActiveQueriesLiveStatus.Fetched:
CurrentActiveQueriesGrid.ItemsSource = result.Rows;
- var stamp = ViewerTimeHelper.ForDisplay(DateTime.UtcNow).ToString("yyyy-MM-dd HH:mm:ss");
+ var stamp = ViewerTimeHelper.FormatForDisplay(DateTime.UtcNow, "yyyy-MM-dd HH:mm:ss");
CurrentActiveQueriesStatus.Text = result.Rows.Count == 0
? $"No user requests running as of {stamp}."
: $"{result.Rows.Count} running request(s) as of {stamp}.";
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs
index a0bc9ff1e8..100ecd2841 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs
@@ -223,8 +223,8 @@ ordinary read (M1) — null tells the gate this end is open. */
/// sits past 's slack after
/// , collapsed otherwise. The chart-title idiom
/// () states the same fact on Performance Trends; this is its grid-header
- /// form, in 's own yyyy-MM-dd HH:mm, the format every trend
- /// chart title already uses for a head timestamp.
+ /// form, in 's yyyy-MM-dd HH:mm, the format
+ /// every trend chart title already uses for a head timestamp.
///
internal static void UpdateTruncationBanner(TextBlock banner, DateTime? floor, DateTime requestedStartUtc, string? tierSuffix = null,
QueryStoreIntervalWide.WideReadPlan? widePlan = null)
@@ -241,13 +241,13 @@ named whether or not the grid itself was cut. */
if (wideTruncated || slicerTruncated || !string.IsNullOrEmpty(tierSuffix))
{
var slicerSince = slicerTruncated
- ? ViewerTimeHelper.ForDisplay(RawWindowFloor.EffectiveStart(floor, requestedStartUtc)).ToString("yyyy-MM-dd HH:mm")
+ ? ViewerTimeHelper.FormatForDisplay(RawWindowFloor.EffectiveStart(floor, requestedStartUtc), "yyyy-MM-dd HH:mm")
: null;
var text = wideTruncated
- ? $"Showing since {ViewerTimeHelper.ForDisplay(wideStart):yyyy-MM-dd HH:mm}{QueryStoreIntervalWide.BannerReason(widePlan.Value.StartBound)}"
+ ? $"Showing since {ViewerTimeHelper.FormatForDisplay(wideStart, "yyyy-MM-dd HH:mm")}{QueryStoreIntervalWide.BannerReason(widePlan.Value.StartBound)}"
+ (slicerSince is null ? string.Empty : $" · slicer since {slicerSince}")
: slicerSince is null
- ? $"Showing {ViewerTimeHelper.ForDisplay(requestedStartUtc):yyyy-MM-dd HH:mm}"
+ ? $"Showing {ViewerTimeHelper.FormatForDisplay(requestedStartUtc, "yyyy-MM-dd HH:mm")}"
: $"Slicer since {slicerSince} (the grid shows the full window)";
banner.Text = text + tierSuffix;
@@ -272,8 +272,8 @@ named whether or not the grid itself was cut. */
}
banner.Text = truncated
- ? $"Showing since {ViewerTimeHelper.ForDisplay(RawWindowFloor.EffectiveStart(floor, requestedStartUtc)):yyyy-MM-dd HH:mm}{tierSuffix}"
- : $"Showing {ViewerTimeHelper.ForDisplay(requestedStartUtc):yyyy-MM-dd HH:mm}{tierSuffix}";
+ ? $"Showing since {ViewerTimeHelper.FormatForDisplay(RawWindowFloor.EffectiveStart(floor, requestedStartUtc), "yyyy-MM-dd HH:mm")}{tierSuffix}"
+ : $"Showing {ViewerTimeHelper.FormatForDisplay(requestedStartUtc, "yyyy-MM-dd HH:mm")}{tierSuffix}";
banner.Visibility = Visibility.Visible;
}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueriesComparison.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueriesComparison.cs
index 3fb41bb91a..ee0e9c68fc 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueriesComparison.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueriesComparison.cs
@@ -93,8 +93,8 @@ private void SetComparisonMode(
if (active && baselineRange.HasValue)
{
- var from = ViewerTimeHelper.ForDisplay(baselineRange.Value.From).ToString("yyyy-MM-dd HH:mm");
- var to = ViewerTimeHelper.ForDisplay(baselineRange.Value.To).ToString("yyyy-MM-dd HH:mm");
+ var from = ViewerTimeHelper.FormatForDisplay(baselineRange.Value.From, "yyyy-MM-dd HH:mm");
+ var to = ViewerTimeHelper.FormatForDisplay(baselineRange.Value.To, "yyyy-MM-dd HH:mm");
banner.Text = $"Comparing against baseline: {from} → {to}";
}
}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs
index 249e8a2e4a..c5d1c573e3 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs
@@ -114,17 +114,21 @@ rather than drawing nothing. */
///
/// Draws the window's baseline discontinuities on one trend chart (#3653 A5): a dashed vertical line per
- /// marker at its UTC instant, legend-named with the shared sentence. Called after the
+ /// marker at its UTC instant, legend-named with the shared sentence, which adds the instant's UTC offset when
+ /// its wall time happens twice in the display zone (the repeated autumn hour, #4766). Called after the
/// series is added and before the chart's axis limits and legend are set, as
/// asks.
///
private static void MarkDiscontinuities(ScottPlot.WPF.WpfPlot chart, IReadOnlyList discontinuities)
{
+ var zone = ViewerTimeHelper.CurrentDisplayZone();
foreach (var discontinuity in discontinuities)
{
- /* The marker sits at the UTC instant like every other X on the chart; the sentence names it in the display zone. */
+ /* The marker sits at the UTC instant like every other X on the chart; the sentence names it in the display zone,
+ with that instant's UTC offset when the wall time repeats. */
var shown = ViewerTimeHelper.ForDisplay(discontinuity.At);
- ChartStyle.AddDiscontinuityMarker(chart, discontinuity.At.ToOADate(), BaselineDiscontinuities.Sentence(shown, discontinuity.Reason));
+ ChartStyle.AddDiscontinuityMarker(chart, discontinuity.At.ToOADate(),
+ BaselineDiscontinuities.Sentence(shown, discontinuity.Reason, DisplayZone.AmbiguousOffsetSuffix(discontinuity.At, zone)));
}
}
@@ -177,7 +181,7 @@ private static string DescribeBucketWidth(int minutes)
internal static string DescribeQueryStoreTrendCoverage(QueryStoreTrendSeries series)
=> ComposeTrendCoverage(
series.Route.UseRollup
- ? $"{series.Source} (one point per hour before {ViewerTimeHelper.ForDisplay(series.Route.RawStartUtc):yyyy-MM-dd HH:mm}, one per Query Store interval from it)"
+ ? $"{series.Source} (one point per hour before {ViewerTimeHelper.FormatForDisplay(series.Route.RawStartUtc, "yyyy-MM-dd HH:mm")}, one per Query Store interval from it)"
: $"{series.Source} (one point per Query Store interval)",
series.HeadUnserved ? series.EffectiveStartUtc : null,
"the corrected Query Store rollup has not materialized the rest of this window (--backfill-rollups reaches it)");
@@ -196,8 +200,8 @@ private static string ComposeTrendCoverage(string served, DateTime? dataBeginsUt
return $"Source: {served}";
}
- var from = ViewerTimeHelper.ForDisplay(head);
- return $"Source: {served} — data begins {from:yyyy-MM-dd HH:mm}; {whyUnserved}";
+ var from = ViewerTimeHelper.FormatForDisplay(head, "yyyy-MM-dd HH:mm");
+ return $"Source: {served} — data begins {from}; {whyUnserved}";
}
/// Puts on a chart as its title, coloured like its tick
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
index 4e944b11b8..a69e7965f3 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
@@ -709,7 +709,7 @@
-
+
@@ -832,7 +832,7 @@
HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
-
+
@@ -1442,10 +1442,10 @@
-
+
-
+
@@ -1721,7 +1721,7 @@
RowStyle="{StaticResource QueryStorePlanGridRowStyle}"
HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
-
+
@@ -1861,7 +1861,7 @@
-
+
-
+
@@ -3845,8 +3845,8 @@
-
-
+
+
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml.cs
index 918fd00497..25b387383a 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml.cs
@@ -359,7 +359,7 @@ private async Task UpdateServerFreshnessLabelAsync()
{
var freshness = await _dataService.GetServerFreshnessAsync();
ServerFreshnessText.Text = freshness.TryGetValue(_server.ServerId, out var lastUtc)
- ? $"collected {ViewerTimeHelper.ForDisplay(lastUtc):yyyy-MM-dd HH:mm}"
+ ? $"collected {ViewerTimeHelper.FormatForDisplay(lastUtc, "yyyy-MM-dd HH:mm")}"
: ServerSummaryItem.ClassifyFreshness(null, _server.RegisteredAt, DateTime.UtcNow) == ServerFreshness.Offline
? "no collection retained"
: "no data collected yet";
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerTimeHelper.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerTimeHelper.cs
index bc2ae3017c..dc676e9a6c 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerTimeHelper.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerTimeHelper.cs
@@ -42,8 +42,10 @@ namespace PerformanceMonitor.Darling.Viewer;
/// clock before that tab renders — only the visible tab renders (the viewer's visible-only rule), so the
/// visible tab's offset always wins. Charts plot the naive-UTC instant as X (#4766) and draw their tick, hover
/// and crosshair labels in , and the CSV export writes each point's X in that
-/// same zone (DisplayZone.ToDisplay); is for TEXT only (grid columns, captions,
-/// tooltips), never for a chart's X. is deliberately left at its
+/// same zone (DisplayZone.ToDisplay), with the point's UTC offset in a column of its own;
+/// is the TEXT renderer (grid columns, captions, tooltips) and
+/// the display time under it for a value that stays a ; neither is
+/// for a chart's X. is deliberately left at its
/// identity default in the viewer: every chart label takes the zone directly and never consults it, so wiring it
/// would only put a second conversion on any label that did.
///
@@ -82,6 +84,42 @@ public static int UtcOffsetMinutes
///
public static DateTime ForDisplay(DateTime naiveUtc) => ConvertToDisplay(naiveUtc, CurrentDisplayMode, _serverClock);
+ ///
+ /// as TEXT in the current display mode (#4766): formatted with
+ /// on the current culture, then a space and the instant's UTC offset when the wall time
+ /// happens twice in that zone (), so the two rows of a grid that
+ /// both read 01:30 in the repeated autumn hour differ by "-04:00" and "-05:00". Every other time is the text
+ /// ForDisplay(x).ToString(format) gave, and UTC never gets a suffix. The twin of Lite's
+ /// ServerTimeHelper.FormatServerTime: the renderer for text that shows one instant. A label that is parsed
+ /// back or used as a key or file name stays on , as do axis and tick labels and the
+ /// time-range slicer's, which want the plain wall time.
+ ///
+ public static string FormatForDisplay(DateTime naiveUtc, string format) =>
+ FormatForDisplay(naiveUtc, CurrentDisplayZone(), format);
+
+ ///
+ /// as TEXT in (#4766): applied to
+ /// the instant's wall clock there on the current culture, then a space and the instant's UTC offset when that
+ /// wall time happens twice in (). The one
+ /// place the viewer's text takes the suffix, and the twin of Lite's ServerTimeHelper.FormatInstant:
+ /// asks it for the current display zone, and a row that
+ /// converts on its own server's clock (an alert row, a Manage Servers row) asks it for that zone
+ /// ( on the row's ), so no list words the rule again.
+ ///
+ public static string FormatForDisplay(DateTime naiveUtc, TimeZoneInfo zone, string format)
+ {
+ var text = DisplayZone.ToDisplay(naiveUtc, zone).ToString(format);
+ var suffix = DisplayZone.AmbiguousOffsetSuffix(naiveUtc, zone);
+ return suffix is null ? text : text + " " + suffix;
+ }
+
+ ///
+ /// for a value that may be missing: an empty string for
+ /// null, so a grid cell with no time shows nothing.
+ ///
+ public static string FormatForDisplay(DateTime? naiveUtc, string format) =>
+ naiveUtc.HasValue ? FormatForDisplay(naiveUtc.Value, format) : "";
+
///
/// Pure (static-free) naive-UTC → display conversion for an explicit mode + fixed offset: the same as
/// the overload with a clock that never changes offset.
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml
index c153f22f04..9217f1c4e5 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml
+++ b/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml
@@ -84,7 +84,7 @@
-
+
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml.cs
index e178873ba7..df1f2e3482 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml.cs
@@ -288,9 +288,9 @@ private void ApplyInitialSort(string property)
private string GetTimeRangeDescription(List data)
{
if (data.Count == 0) return "";
- var first = ViewerTimeHelper.ForDisplay(data.Min(r => r.CollectionTime));
- var last = ViewerTimeHelper.ForDisplay(data.Max(r => r.CollectionTime));
- return $"{first:yyyy-MM-dd HH:mm:ss} to {last:yyyy-MM-dd HH:mm:ss}";
+ var first = ViewerTimeHelper.FormatForDisplay(data.Min(r => r.CollectionTime), "yyyy-MM-dd HH:mm:ss");
+ var last = ViewerTimeHelper.FormatForDisplay(data.Max(r => r.CollectionTime), "yyyy-MM-dd HH:mm:ss");
+ return $"{first} to {last}";
}
private void OnThemeChanged(string _) => _filterManager.UpdateFilterButtonStyles();
diff --git a/Lite.Tests/AlertHistoryRowClockTests.cs b/Lite.Tests/AlertHistoryRowClockTests.cs
index a78ed0deed..4421716bc3 100644
--- a/Lite.Tests/AlertHistoryRowClockTests.cs
+++ b/Lite.Tests/AlertHistoryRowClockTests.cs
@@ -68,19 +68,33 @@ public void Dispose()
// ── the row's own conversion ─────────────────────────────────────────────────
+ ///
+ /// The two alerts of the repeated hour read 01:30 in Server mode, so each takes its UTC offset: -04:00 for the
+ /// alert at 05:30Z and -05:00 for the one at 06:30Z. UTC mode is the instant with no offset, and the alert an hour
+ /// later (02:30) is the bare wall time.
+ ///
[Fact]
- public void TimeLocal_AnEasternRowInTheRepeatedHour_Shows0130InServerMode_And0630InUtcMode()
+ public void TimeLocal_AnEasternRowInTheRepeatedHour_Shows0130WithItsOffsetInServerMode_And0630InUtcMode()
{
- var row = Row(1, Utc(2026, 11, 1, 6, 30), Eastern());
+ var first = Row(1, Utc(2026, 11, 1, 5, 30), Eastern());
+ var second = Row(1, Utc(2026, 11, 1, 6, 30), Eastern());
+ var after = Row(1, Utc(2026, 11, 1, 7, 30), Eastern());
ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
- Assert.Equal("2026-11-01 01:30:00", row.TimeLocal);
+ Assert.Equal("2026-11-01 01:30:00 -04:00", first.TimeLocal);
+ Assert.Equal("2026-11-01 01:30:00 -05:00", second.TimeLocal);
+ Assert.Equal("2026-11-01 02:30:00", after.TimeLocal);
ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC;
- Assert.Equal("2026-11-01 06:30:00", row.TimeLocal);
+ Assert.Equal("2026-11-01 05:30:00", first.TimeLocal);
+ Assert.Equal("2026-11-01 06:30:00", second.TimeLocal);
}
- /// Local mode is this machine's zone, whatever clock the row carries; it is what the column always showed.
+ ///
+ /// Local mode is this machine's zone, whatever clock the row carries; it is what the column always showed. On a
+ /// machine whose own clock repeats an hour at these instants (a US Eastern desktop) the text carries that offset
+ /// too, so the expected text is worked out the same way from the framework's zone functions.
+ ///
[Fact]
public void TimeLocal_InLocalMode_IsThisMachinesZone_OnAnyClock()
{
@@ -90,8 +104,7 @@ public void TimeLocal_InLocalMode_IsThisMachinesZone_OnAnyClock()
{
foreach (var utc in new[] { Utc(2026, 11, 1, 5, 30), Utc(2026, 11, 1, 6, 30), Utc(2026, 7, 1, 12, 0) })
{
- var expected = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(utc, DateTimeKind.Utc), TimeZoneInfo.Local);
- Assert.Equal(expected.ToString("yyyy-MM-dd HH:mm:ss"), Row(1, utc, clock).TimeLocal);
+ Assert.Equal(ServerTimeHelperClockTests.MachineWallText(utc), Row(1, utc, clock).TimeLocal);
}
}
}
@@ -102,7 +115,7 @@ public void TimeLocal_WithNoClockStamped_TakesTheActiveServersClock()
ServerTimeHelper.ActiveServerClock = Eastern();
ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
- Assert.Equal("2026-11-01 01:30:00", Row(1, Utc(2026, 11, 1, 6, 30)).TimeLocal);
+ Assert.Equal("2026-11-01 01:30:00 -05:00", Row(1, Utc(2026, 11, 1, 6, 30)).TimeLocal);
Assert.Equal("2026-07-01 08:00:00", Row(1, Utc(2026, 7, 1, 12, 0)).TimeLocal);
}
@@ -125,7 +138,7 @@ public void StampClocks_TwoServersWithDifferentClocks_EachConvertOnTheirOwn()
AlertsHistoryTab.StampClocks(new[] { eastern, india, easternSummer }, collected, openTabClock: null);
- Assert.Equal("2026-11-01 01:30:00", eastern.TimeLocal);
+ Assert.Equal("2026-11-01 01:30:00 -05:00", eastern.TimeLocal);
Assert.Equal("2026-11-01 12:00:00", india.TimeLocal);
Assert.Equal("2026-07-01 08:00:00", easternSummer.TimeLocal);
}
@@ -223,8 +236,8 @@ public async Task ReadCollectedClocksAsync_ReadsEachDistinctServersOwnClock_AndN
ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
AlertsHistoryTab.StampClocks(rows, clocks, openTabClock: null);
- Assert.Equal("2026-11-01 01:30:00", rows[0].TimeLocal);
+ Assert.Equal("2026-11-01 01:30:00 -05:00", rows[0].TimeLocal);
Assert.Equal("2026-11-01 12:00:00", rows[1].TimeLocal);
- Assert.Equal("2026-11-01 01:31:00", rows[2].TimeLocal);
+ Assert.Equal("2026-11-01 01:31:00 -05:00", rows[2].TimeLocal);
}
}
diff --git a/Lite.Tests/BaselineDiscontinuityRenderCensusTests.cs b/Lite.Tests/BaselineDiscontinuityRenderCensusTests.cs
index 4714dfd36f..f0d19ce0e5 100644
--- a/Lite.Tests/BaselineDiscontinuityRenderCensusTests.cs
+++ b/Lite.Tests/BaselineDiscontinuityRenderCensusTests.cs
@@ -162,6 +162,9 @@ public void BothViewers_MarkAllFourTrendCharts_ThroughTheSharedHelper(string cha
Assert.Contains("ChartStyle.AddDiscontinuityMarker(", charts, StringComparison.Ordinal);
Assert.Contains("BaselineDiscontinuities.Sentence(", charts, StringComparison.Ordinal);
+ /* #4766: the sentence carries the instant's UTC offset in the repeated autumn hour, from a zone read once. */
+ Assert.Contains("DisplayZone.AmbiguousOffsetSuffix(discontinuity.At, zone)", charts, StringComparison.Ordinal);
+
foreach (var chart in new[] { "QueryDurationTrendChart", "ProcDurationTrendChart", "QueryStoreDurationTrendChart", "ExecutionCountTrendChart" })
{
Assert.Contains("MarkDiscontinuities(" + chart + ", discontinuities);", charts, StringComparison.Ordinal);
diff --git a/Lite.Tests/BaselineDiscontinuityTests.cs b/Lite.Tests/BaselineDiscontinuityTests.cs
index c984c39410..c9be69622d 100644
--- a/Lite.Tests/BaselineDiscontinuityTests.cs
+++ b/Lite.Tests/BaselineDiscontinuityTests.cs
@@ -11,6 +11,7 @@
using System.Linq;
using System.Text.Json;
using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Ui;
using Xunit;
namespace PerformanceMonitorLite.Tests;
@@ -290,6 +291,41 @@ public void Sentence_IsTheSharedRenderText()
Assert.Equal("baseline discontinuity at 2026-09-20 03:12 (restart)", BaselineDiscontinuities.Sentence(T0, BaselineDiscontinuities.RestartReason));
}
+ ///
+ /// #4766: a surface passes the instant's UTC offset when its wall time happens twice in the display zone, and the
+ /// sentence writes it after the time with one space. Without one (the default, and what every other time passes)
+ /// the text is exactly what it was.
+ ///
+ [Fact]
+ public void Sentence_WithAnOffsetSuffix_WritesItAfterTheTime_AndWithoutOneIsUnchanged()
+ {
+ var wall = new DateTime(2026, 11, 1, 1, 30, 0, DateTimeKind.Unspecified);
+
+ Assert.Equal("baseline discontinuity at 2026-11-01 01:30 -04:00 (restart)", BaselineDiscontinuities.Sentence(wall, BaselineDiscontinuities.RestartReason, "-04:00"));
+ Assert.Equal("baseline discontinuity at 2026-11-01 01:30 -05:00 (restart)", BaselineDiscontinuities.Sentence(wall, BaselineDiscontinuities.RestartReason, "-05:00"));
+ Assert.Equal("baseline discontinuity at 2026-11-01 01:30 (restart)", BaselineDiscontinuities.Sentence(wall, BaselineDiscontinuities.RestartReason));
+ Assert.Equal("baseline discontinuity at 2026-11-01 01:30 (restart)", BaselineDiscontinuities.Sentence(wall, BaselineDiscontinuities.RestartReason, null));
+ }
+
+ ///
+ /// The way both desktop charts call it, on a US Eastern display zone (change day 2026-11-01, 06:00Z): the two markers
+ /// of the repeated hour read 01:30 -04:00 and 01:30 -05:00, and the hour before and after carry no offset.
+ ///
+ [Fact]
+ public void Sentence_AsTheChartsCallIt_ReadsTheTwoMarkersOfTheRepeatedHourApart()
+ {
+ var zone = DisplayZoneFixtures.Eastern;
+
+ static string Worded(DateTime atUtc, TimeZoneInfo zone) => BaselineDiscontinuities.Sentence(
+ DisplayZone.ToDisplay(atUtc, zone), BaselineDiscontinuities.RestartReason, DisplayZone.AmbiguousOffsetSuffix(atUtc, zone));
+
+ Assert.Equal("baseline discontinuity at 2026-11-01 01:30 -04:00 (restart)", Worded(DisplayZoneFixtures.At(2026, 11, 1, 5, 30), zone));
+ Assert.Equal("baseline discontinuity at 2026-11-01 01:30 -05:00 (restart)", Worded(DisplayZoneFixtures.At(2026, 11, 1, 6, 30), zone));
+ Assert.Equal("baseline discontinuity at 2026-11-01 00:30 (restart)", Worded(DisplayZoneFixtures.At(2026, 11, 1, 4, 30), zone));
+ Assert.Equal("baseline discontinuity at 2026-11-01 02:30 (restart)", Worded(DisplayZoneFixtures.At(2026, 11, 1, 7, 30), zone));
+ Assert.Equal("baseline discontinuity at 2026-11-01 05:30 (restart)", Worded(DisplayZoneFixtures.At(2026, 11, 1, 5, 30), TimeZoneInfo.Utc));
+ }
+
[Fact]
public void TheVocabularyIsClosed_AndTheKeyAndSentenceNameIt()
{
diff --git a/Lite.Tests/ChartCsvExportDisplayZoneTests.cs b/Lite.Tests/ChartCsvExportDisplayZoneTests.cs
index 3175ceabc3..7fb6ecc7e6 100644
--- a/Lite.Tests/ChartCsvExportDisplayZoneTests.cs
+++ b/Lite.Tests/ChartCsvExportDisplayZoneTests.cs
@@ -22,8 +22,10 @@ namespace PerformanceMonitorLite.Tests;
///
/// #4766: a chart on the server tab plots the naive-UTC instant as X and draws its text in the tab's display zone,
/// so the two other places that print a time from it must agree with the axis. The CSV export writes each point's
-/// time in the display zone and names the zone in the header, and the "Last refresh" line shows the refresh
-/// instant in the display zone rather than the machine's clock under a label for some other zone.
+/// time in the display zone and names the zone in the header, and its last column gives each point's UTC offset, so
+/// the two points that read the same wall time in the repeated autumn hour stay apart while the time column stays
+/// a plain date and time. The "Last refresh" line shows the refresh instant in the display zone rather than the
+/// machine's clock under a label for some other zone.
///
/// The export's time and header text come from two pure functions, called here without the WPF handler. The
/// context menus and the status line live on a WPF control this suite does not instantiate, so what wires them is
@@ -39,56 +41,90 @@ private static double XOf(string utcInstant) =>
///
/// The autumn change in US Eastern is 2026-11-01 06:00Z: 01:30 happens at 05:30Z (EDT) and again at 06:30Z (EST).
- /// The point at 06:30Z is the second 01:30, and the export writes the wall time the axis shows for it; the same
- /// point in the UTC zone is written as 06:30.
+ /// The point at 06:30Z is the second 01:30, and the export writes the wall time the axis shows for it, then its
+ /// offset; the same point in the UTC zone is written as 06:30 with a zero offset.
///
[Fact]
public void ThePointAt0630Z_OnTheAutumnChangeDay_IsWritten0130InEastern_And0630InUtc()
{
var x = XOf("2026-11-01 06:30:00");
- Assert.Equal("2026-11-01 01:30:00,CPU,12.5", ContextMenuHelper.ChartCsvLine(x, "CPU", 12.5, ",", Eastern));
- Assert.Equal("2026-11-01 06:30:00,CPU,12.5", ContextMenuHelper.ChartCsvLine(x, "CPU", 12.5, ",", TimeZoneInfo.Utc));
+ Assert.Equal("2026-11-01 01:30:00,CPU,12.5,-05:00", ContextMenuHelper.ChartCsvLine(x, "CPU", 12.5, ",", Eastern));
+ Assert.Equal("2026-11-01 06:30:00,CPU,12.5,+00:00", ContextMenuHelper.ChartCsvLine(x, "CPU", 12.5, ",", TimeZoneInfo.Utc));
+ }
+
+ ///
+ /// The two points that read 01:30 in the repeated hour write the same first three cells, and the last cell tells
+ /// them apart: -04:00 for the first (05:30Z, EDT) and -05:00 for the second (06:30Z, EST). The time column is
+ /// the same plain date and time on both, never a text with an offset stuck to some of its rows.
+ ///
+ [Fact]
+ public void TheTwoPointsOfTheRepeatedHour_WriteTheSameTimeCell_AndAreToldApartByTheirOffsets()
+ {
+ var first = ContextMenuHelper.ChartCsvLine(XOf("2026-11-01 05:30:00"), "CPU", 12.5, ",", Eastern);
+ var second = ContextMenuHelper.ChartCsvLine(XOf("2026-11-01 06:30:00"), "CPU", 12.5, ",", Eastern);
+
+ Assert.Equal("2026-11-01 01:30:00,CPU,12.5,-04:00", first);
+ Assert.Equal("2026-11-01 01:30:00,CPU,12.5,-05:00", second);
+ Assert.Equal(first[..^"-04:00".Length], second[..^"-05:00".Length]);
+ }
+
+ ///
+ /// Every row carries its offset, not only the repeated hour's: a summer instant is on EDT (-04:00) and a winter
+ /// one on EST (-05:00), and the time cell is the plain wall time in both.
+ ///
+ [Theory]
+ [InlineData("2026-07-15 16:00:00", "2026-07-15 12:00:00", "-04:00")]
+ [InlineData("2026-01-15 17:00:00", "2026-01-15 12:00:00", "-05:00")]
+ public void ASummerAndAWinterInstant_CarryTheirOwnOffsets(string utcInstant, string expectedWall, string expectedOffset)
+ {
+ var line = ContextMenuHelper.ChartCsvLine(XOf(utcInstant), "CPU", 3, ",", Eastern);
+
+ Assert.Equal(expectedWall + ",CPU,3," + expectedOffset, line);
}
///
/// Each instant across both changes is written as its own wall clock: the offset switches at the change and is
- /// not one fixed number, and the repeated hour reads 01:30 twice, in time order.
+ /// not one fixed number, the repeated hour reads 01:30 twice, in time order, and each row's last cell is the
+ /// offset that instant carries.
///
[Theory]
- [InlineData("2026-11-01 04:30:00", "2026-11-01 00:30:00")]
- [InlineData("2026-11-01 05:30:00", "2026-11-01 01:30:00")]
- [InlineData("2026-11-01 06:30:00", "2026-11-01 01:30:00")]
- [InlineData("2026-11-01 07:30:00", "2026-11-01 02:30:00")]
- [InlineData("2026-03-08 06:30:00", "2026-03-08 01:30:00")]
- [InlineData("2026-03-08 07:30:00", "2026-03-08 03:30:00")]
- public void EachInstantAcrossTheChanges_IsWrittenAsItsOwnEasternWallClock(string utcInstant, string expectedWall)
+ [InlineData("2026-11-01 04:30:00", "2026-11-01 00:30:00", "-04:00")]
+ [InlineData("2026-11-01 05:30:00", "2026-11-01 01:30:00", "-04:00")]
+ [InlineData("2026-11-01 06:30:00", "2026-11-01 01:30:00", "-05:00")]
+ [InlineData("2026-11-01 07:30:00", "2026-11-01 02:30:00", "-05:00")]
+ [InlineData("2026-03-08 06:30:00", "2026-03-08 01:30:00", "-05:00")]
+ [InlineData("2026-03-08 07:30:00", "2026-03-08 03:30:00", "-04:00")]
+ public void EachInstantAcrossTheChanges_IsWrittenAsItsOwnEasternWallClockAndOffset(string utcInstant, string expectedWall, string expectedOffset)
{
var line = ContextMenuHelper.ChartCsvLine(XOf(utcInstant), "Series", 1.5, ",", Eastern);
- Assert.Equal(expectedWall + ",Series,1.5", line);
+ Assert.Equal(expectedWall + ",Series,1.5," + expectedOffset, line);
}
///
/// The server's own clock is a zone too: a fixed-offset server (no zone name collected) writes its offset time,
- /// and the header names that offset.
+ /// the header names that offset, and the last cell repeats it.
///
[Fact]
public void AFixedOffsetServerClock_WritesItsOffsetTime_AndTheHeaderNamesTheOffset()
{
var zone = ServerClock.FixedOffset(330).AsTimeZone();
- Assert.Equal("2026-11-01 12:00:00;CPU;3", ContextMenuHelper.ChartCsvLine(XOf("2026-11-01 06:30:00"), "CPU", 3, ";", zone));
- Assert.Equal("DateTime (UTC+05:30);Series;Value", ContextMenuHelper.ChartCsvHeader(";", zone));
+ Assert.Equal("2026-11-01 12:00:00;CPU;3;+05:30", ContextMenuHelper.ChartCsvLine(XOf("2026-11-01 06:30:00"), "CPU", 3, ";", zone));
+ Assert.Equal("DateTime (UTC+05:30);Series;Value;UTC offset", ContextMenuHelper.ChartCsvHeader(";", zone));
}
- /// The time column's header names the zone the times are written in; the other two columns are unchanged.
+ ///
+ /// The time column's header names the zone the times are written in; the last column, "UTC offset", is added
+ /// after the other two, which are unchanged.
+ ///
[Fact]
- public void TheHeader_NamesTheZoneItsTimesAreWrittenIn()
+ public void TheHeader_NamesTheZoneItsTimesAreWrittenIn_AndEndsInTheUtcOffsetColumn()
{
- Assert.Equal("DateTime (UTC),Series,Value", ContextMenuHelper.ChartCsvHeader(",", TimeZoneInfo.Utc));
- Assert.Equal("DateTime (Eastern Standard Time),Series,Value", ContextMenuHelper.ChartCsvHeader(",", Eastern));
- Assert.Equal("DateTime (Eastern Standard Time)\tSeries\tValue", ContextMenuHelper.ChartCsvHeader("\t", Eastern));
+ Assert.Equal("DateTime (UTC),Series,Value,UTC offset", ContextMenuHelper.ChartCsvHeader(",", TimeZoneInfo.Utc));
+ Assert.Equal("DateTime (Eastern Standard Time),Series,Value,UTC offset", ContextMenuHelper.ChartCsvHeader(",", Eastern));
+ Assert.Equal("DateTime (Eastern Standard Time)\tSeries\tValue\tUTC offset", ContextMenuHelper.ChartCsvHeader("\t", Eastern));
}
/// The series name is escaped for the separator exactly as before, in both zones.
@@ -97,22 +133,24 @@ public void ASeriesNameWithTheSeparator_IsQuotedAsBefore()
{
var x = XOf("2026-11-01 06:30:00");
- Assert.Equal("2026-11-01 01:30:00,\"Reads, MB\",1.5", ContextMenuHelper.ChartCsvLine(x, "Reads, MB", 1.5, ",", Eastern));
- Assert.Equal("2026-11-01 06:30:00,\"Reads, MB\",1.5", ContextMenuHelper.ChartCsvLine(x, "Reads, MB", 1.5, ",", TimeZoneInfo.Utc));
+ Assert.Equal("2026-11-01 01:30:00,\"Reads, MB\",1.5,-05:00", ContextMenuHelper.ChartCsvLine(x, "Reads, MB", 1.5, ",", Eastern));
+ Assert.Equal("2026-11-01 06:30:00,\"Reads, MB\",1.5,+00:00", ContextMenuHelper.ChartCsvLine(x, "Reads, MB", 1.5, ",", TimeZoneInfo.Utc));
}
///
- /// The UTC zone writes the plotted instant as it is, under a header that says so, for every separator.
+ /// The UTC zone writes the plotted instant as it is, under a header that says so, for every separator, with a
+ /// "+00:00" offset on every row, the repeated hour's two instants included.
///
[Fact]
public void InUtc_TheLineIsTheInstantItself_AndTheHeaderNamesUtc()
{
var x = XOf("2026-11-01 06:30:00");
- Assert.Equal("DateTime (UTC),Series,Value", ContextMenuHelper.ChartCsvHeader(",", TimeZoneInfo.Utc));
- Assert.Equal("DateTime (UTC);Series;Value", ContextMenuHelper.ChartCsvHeader(";", TimeZoneInfo.Utc));
- Assert.Equal("2026-11-01 06:30:00,Series,1.5", ContextMenuHelper.ChartCsvLine(x, "Series", 1.5, ",", TimeZoneInfo.Utc));
- Assert.Equal("2026-11-01 06:30:00\tSeries\t1.5", ContextMenuHelper.ChartCsvLine(x, "Series", 1.5, "\t", TimeZoneInfo.Utc));
+ Assert.Equal("DateTime (UTC),Series,Value,UTC offset", ContextMenuHelper.ChartCsvHeader(",", TimeZoneInfo.Utc));
+ Assert.Equal("DateTime (UTC);Series;Value;UTC offset", ContextMenuHelper.ChartCsvHeader(";", TimeZoneInfo.Utc));
+ Assert.Equal("2026-11-01 06:30:00,Series,1.5,+00:00", ContextMenuHelper.ChartCsvLine(x, "Series", 1.5, ",", TimeZoneInfo.Utc));
+ Assert.Equal("2026-11-01 06:30:00\tSeries\t1.5\t+00:00", ContextMenuHelper.ChartCsvLine(x, "Series", 1.5, "\t", TimeZoneInfo.Utc));
+ Assert.Equal("2026-11-01 05:30:00,Series,1.5,+00:00", ContextMenuHelper.ChartCsvLine(XOf("2026-11-01 05:30:00"), "Series", 1.5, ",", TimeZoneInfo.Utc));
}
private static IEnumerable<(string Name, string Code)> ServerTabCode()
diff --git a/Lite.Tests/ChartTimeConversionClockTests.cs b/Lite.Tests/ChartTimeConversionClockTests.cs
index f973798224..d238bed2f9 100644
--- a/Lite.Tests/ChartTimeConversionClockTests.cs
+++ b/Lite.Tests/ChartTimeConversionClockTests.cs
@@ -145,8 +145,12 @@ public void HistoryWindows_PlotTheCollectionTimeAsTheInstant_AndWordItInTheDispl
Assert.Contains("Axes.DateTimeTicksBottomUtc(_displayZone)", source, StringComparison.Ordinal);
Assert.DoesNotContain("DateTimeTicksBottomDateChange", source, StringComparison.Ordinal);
Assert.Contains("new ChartHoverHelper(HistoryChart, unit, displayZone: _displayZone)", source, StringComparison.Ordinal);
- Assert.Contains("DisplayZone.ToDisplay(_historyData.First().CollectionTime, _displayZone())", source, StringComparison.Ordinal);
- Assert.Contains("DisplayZone.ToDisplay(_historyData.Last().CollectionTime, _displayZone())", source, StringComparison.Ordinal);
+
+ /* The summary range is worded in that same zone, read once, through FormatInstant, which adds the instant's UTC
+ offset in the repeated autumn hour (#4766); HistoryGridZoneTests pins it further. */
+ Assert.Contains("var zone = _displayZone();", source, StringComparison.Ordinal);
+ Assert.Contains("ServerTimeHelper.FormatInstant(_historyData.First().CollectionTime, zone, \"MM/dd HH:mm\")", source, StringComparison.Ordinal);
+ Assert.Contains("ServerTimeHelper.FormatInstant(_historyData.Last().CollectionTime, zone, \"MM/dd HH:mm\")", source, StringComparison.Ordinal);
}
/* The text of the method that starts at the signature, up to its closing brace: these files indent members by four
diff --git a/Lite.Tests/CollectionHealthDisplayZoneTests.cs b/Lite.Tests/CollectionHealthDisplayZoneTests.cs
new file mode 100644
index 0000000000..cfaaae09aa
--- /dev/null
+++ b/Lite.Tests/CollectionHealthDisplayZoneTests.cs
@@ -0,0 +1,203 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Globalization;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Text.RegularExpressions;
+using PerformanceMonitor.Analysis.Baselines;
+using PerformanceMonitor.Ui;
+using PerformanceMonitorLite.Services;
+using Xunit;
+
+namespace PerformanceMonitorLite.Tests;
+
+///
+/// #4766: the Collection Health grids' times (Last Success, Last Run and Last Error on the health rows, and the run
+/// history's time). They were ToLocalTime().ToString("g"): this machine's clock in every display mode, bare in the
+/// repeated autumn hour. A row now carries the clock of the server it belongs to and words its collector-written UTC instant
+/// on it (ServerTimeHelper.FormatInstant), so it follows the display mode, reads its own server's wall time whichever
+/// tab is active when it renders, and names the UTC offset of each 01:30 of the repeated hour.
+///
+/// The surfaces are the two grids of a server's own tab (which stamp that tab's clock on every row they load) and the
+/// run-history window the tab opens for one collector (which takes the same clock). The fleet-wide log the MCP tool reads
+/// never uses these columns.
+///
+/// US Eastern is the row's server and India (+05:30 all year) the active tab's, so a clock that differs from this
+/// machine's is always among them; the autumn change is 2026-11-01 at 06:00 UTC, so 05:30Z is the first 01:30 (-04:00)
+/// and 06:30Z the second (-05:00).
+///
+/* Installs ServerTimeHelper.ActiveServerClock and CurrentDisplayMode, process-wide mutable statics; joins the
+ collection every other class that writes them uses. */
+[Collection("server-time-helper")]
+public sealed class CollectionHealthDisplayZoneTests : IDisposable
+{
+ private readonly ServerClock _savedClock = ServerTimeHelper.ActiveServerClock;
+ private readonly TimeDisplayMode _savedMode = ServerTimeHelper.CurrentDisplayMode;
+ private readonly CultureInfo _savedCulture = CultureInfo.CurrentCulture;
+
+ public CollectionHealthDisplayZoneTests()
+ {
+ /* "g" runs on the current culture, as every grid's time does; the invariant one fixes its pattern (MM/dd/yyyy HH:mm). */
+ CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
+ }
+
+ public void Dispose()
+ {
+ ServerTimeHelper.ActiveServerClock = _savedClock;
+ ServerTimeHelper.CurrentDisplayMode = _savedMode;
+ CultureInfo.CurrentCulture = _savedCulture;
+ }
+
+ private static ServerClock Eastern() => ServerClock.Resolve("Eastern Standard Time", -300);
+
+ private static ServerClock India() => ServerClock.Resolve("India Standard Time", 330);
+
+ private static DateTime Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, DateTimeKind.Unspecified);
+
+ private static CollectorHealthRow Health(DateTime utc, ServerClock? clock) => new()
+ {
+ CollectorName = "wait_stats",
+ LastSuccessTime = utc,
+ LastRunTime = utc,
+ LastErrorTime = utc,
+ Clock = clock,
+ };
+
+ private static CollectionLogRow Log(DateTime utc, ServerClock? clock) => new()
+ {
+ CollectorName = "wait_stats",
+ CollectionTime = utc,
+ Status = "SUCCESS",
+ Clock = clock,
+ };
+
+ ///
+ /// A row reads its own server's wall time in Server mode, whichever clock is active: 14:30Z is 10:30 in US Eastern
+ /// (the row's server) and 20:00 in India (the active tab's), and neither is this machine's.
+ ///
+ [Fact]
+ public void ServerMode_ARowReadsItsOwnServersWallTime_NotTheActiveTabsOrThisMachines()
+ {
+ ServerTimeHelper.ActiveServerClock = India();
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+ var instant = Utc(2026, 7, 1, 14, 30);
+
+ var health = Health(instant, Eastern());
+ Assert.Equal("07/01/2026 10:30", health.LastSuccessFormatted);
+ Assert.Equal("07/01/2026 10:30", health.LastRunFormatted);
+ Assert.Equal("07/01/2026 10:30", health.LastErrorFormatted);
+ Assert.Equal("07/01/2026 10:30", Log(instant, Eastern()).CollectionTimeFormatted);
+
+ ServerTimeHelper.ActiveServerClock = Eastern();
+ Assert.Equal("07/01/2026 20:00", Health(instant, India()).LastSuccessFormatted);
+ Assert.Equal("07/01/2026 20:00", Log(instant, India()).CollectionTimeFormatted);
+ }
+
+ ///
+ /// The two occurrences of the repeated hour take their UTC offsets (-04:00 for 05:30Z, -05:00 for 06:30Z), the hour
+ /// after is bare, and UTC mode is the stored instant with no offset.
+ ///
+ [Fact]
+ public void InTheRepeatedHour_EachOccurrenceCarriesItsOffsetInServerMode_AndUtcModeIsTheStoredInstant()
+ {
+ ServerTimeHelper.ActiveServerClock = India();
+ var first = Health(Utc(2026, 11, 1, 5, 30), Eastern());
+ var second = Health(Utc(2026, 11, 1, 6, 30), Eastern());
+ var after = Health(Utc(2026, 11, 1, 7, 30), Eastern());
+
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+ Assert.Equal("11/01/2026 01:30 -04:00", first.LastSuccessFormatted);
+ Assert.Equal("11/01/2026 01:30 -05:00", second.LastSuccessFormatted);
+ Assert.Equal("11/01/2026 01:30 -05:00", second.LastRunFormatted);
+ Assert.Equal("11/01/2026 01:30 -05:00", second.LastErrorFormatted);
+ Assert.Equal("11/01/2026 02:30", after.LastSuccessFormatted);
+ Assert.Equal("11/01/2026 01:30 -04:00", Log(Utc(2026, 11, 1, 5, 30), Eastern()).CollectionTimeFormatted);
+ Assert.Equal("11/01/2026 01:30 -05:00", Log(Utc(2026, 11, 1, 6, 30), Eastern()).CollectionTimeFormatted);
+
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC;
+ Assert.Equal("11/01/2026 05:30", first.LastSuccessFormatted);
+ Assert.Equal("11/01/2026 06:30", second.LastSuccessFormatted);
+ Assert.Equal("11/01/2026 06:30", Log(Utc(2026, 11, 1, 6, 30), Eastern()).CollectionTimeFormatted);
+ }
+
+ /// Local mode is this machine's zone, whatever clock the row carries (a summer instant, so no offset).
+ [Fact]
+ public void LocalMode_IsThisMachinesZone_OnAnyClock()
+ {
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.LocalTime;
+ var instant = Utc(2026, 7, 1, 14, 30);
+ var expected = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(instant, DateTimeKind.Utc), TimeZoneInfo.Local)
+ .ToString("g", CultureInfo.InvariantCulture);
+
+ foreach (var clock in new[] { Eastern(), India() })
+ {
+ Assert.Equal(expected, Health(instant, clock).LastRunFormatted);
+ Assert.Equal(expected, Log(instant, clock).CollectionTimeFormatted);
+ }
+ }
+
+ /// A row built without a clock reads on the active tab's, and a missing time keeps its placeholder.
+ [Fact]
+ public void ARowWithNoClockStamped_TakesTheActiveTabsClock_AndAMissingTimeKeepsItsPlaceholder()
+ {
+ ServerTimeHelper.ActiveServerClock = Eastern();
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+
+ Assert.Equal("11/01/2026 01:30 -05:00", Health(Utc(2026, 11, 1, 6, 30), clock: null).LastSuccessFormatted);
+ Assert.Equal("11/01/2026 01:30 -05:00", Log(Utc(2026, 11, 1, 6, 30), clock: null).CollectionTimeFormatted);
+
+ var never = new CollectorHealthRow { CollectorName = "wait_stats", Clock = Eastern() };
+ Assert.Equal("Never", never.LastSuccessFormatted);
+ Assert.Equal("Never", never.LastRunFormatted);
+ Assert.Equal("", never.LastErrorFormatted);
+ }
+
+ ///
+ /// The rows word their time through the one instant renderer and not through ToLocalTime(), and every place that
+ /// loads them stamps the clock of the tab they belong to: the tab's two grids, and the run-history window the tab opens.
+ ///
+ [Fact]
+ public void TheRowsAreWordedByTheInstantRenderer_AndEveryLoaderStampsItsServersClock()
+ {
+ var data = Code(LiteSource("Services", "LocalDataService.CollectionHealth.cs"));
+ Assert.DoesNotContain("ToLocalTime", data);
+ Assert.DoesNotContain("FormatServerTime(", data);
+ Assert.Single(Regex.Matches(data, @"ServerTimeHelper\.FormatInstant\("));
+ Assert.Equal(4, Regex.Matches(data, @"CollectionHealthTime\.Format\(").Count);
+
+ var refresh = Code(LiteSource("Controls", "ServerTab.Refresh.cs"));
+ Assert.Contains("var tabClock = _serverClock;", refresh);
+ Assert.Contains("foreach (var row in collectionHealthTask.Result) row.Clock = tabClock;", refresh);
+ Assert.Contains("foreach (var row in collectionLogTask.Result) row.Clock = tabClock;", refresh);
+
+ var grids = Code(LiteSource("Controls", "ServerTab.Grids.cs"));
+ Assert.Contains("new Windows.CollectionLogWindow(_dataService, _serverId, item.CollectorName, _serverClock)", grids);
+
+ var window = Code(LiteSource("Windows", "CollectionLogWindow.xaml.cs"));
+ Assert.Contains("foreach (var log in logs) log.Clock = _serverClock;", window);
+ }
+
+ private static string Code(string source) =>
+ Regex.Replace(source, @"/\*.*?\*/|//[^\r\n]*", "", RegexOptions.Singleline);
+
+ /// A Lite source file, found by walking up from this test file to the repo root.
+ private static string LiteSource(string folder, string file, [CallerFilePath] string thisFile = "")
+ {
+ var dir = Path.GetDirectoryName(thisFile)!;
+ var relative = Path.Combine("Lite", folder, file);
+ while (dir is not null && !File.Exists(Path.Combine(dir, relative)))
+ {
+ dir = Path.GetDirectoryName(dir);
+ }
+
+ Assert.NotNull(dir);
+ return File.ReadAllText(Path.Combine(dir!, relative));
+ }
+}
diff --git a/Lite.Tests/FinOpsRowDisplayZoneTests.cs b/Lite.Tests/FinOpsRowDisplayZoneTests.cs
new file mode 100644
index 0000000000..56bebf59b5
--- /dev/null
+++ b/Lite.Tests/FinOpsRowDisplayZoneTests.cs
@@ -0,0 +1,180 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Globalization;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Text.RegularExpressions;
+using PerformanceMonitor.Analysis.Baselines;
+using PerformanceMonitor.Ui;
+using PerformanceMonitorLite.Services;
+using Xunit;
+
+namespace PerformanceMonitorLite.Tests;
+
+///
+/// #4766: the FinOps Application Connections rows read their First Seen and Last Seen on the SELECTED server's clock. The
+/// FinOps tab lists whichever server its picker names, not the server of the tab that is active, and the rows went through
+/// the active tab's clock for both the column text and the DateTime the column sorts by, so in Server mode a row could read
+/// another server's hour. The tab now stamps the selected server's clock on each row when it loads them, by the rule the
+/// PVS trend beside the grid uses (the server's own collected clock, else its open tab's, else the machine's), and the text
+/// and the sort values both read it.
+///
+/// US Eastern is the selected server and India (+05:30 all year) the active tab's, so a clock that differs from this
+/// machine's is always among them; the autumn change is 2026-11-01 at 06:00 UTC, so 05:30Z is the first 01:30 (-04:00) and
+/// 06:30Z the second (-05:00).
+///
+/* Installs ServerTimeHelper.ActiveServerClock and CurrentDisplayMode, process-wide mutable statics; joins the
+ collection every other class that writes them uses. */
+[Collection("server-time-helper")]
+public sealed class FinOpsRowDisplayZoneTests : IDisposable
+{
+ private readonly ServerClock _savedClock = ServerTimeHelper.ActiveServerClock;
+ private readonly TimeDisplayMode _savedMode = ServerTimeHelper.CurrentDisplayMode;
+ private readonly CultureInfo _savedCulture = CultureInfo.CurrentCulture;
+
+ public FinOpsRowDisplayZoneTests()
+ {
+ CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
+ }
+
+ public void Dispose()
+ {
+ ServerTimeHelper.ActiveServerClock = _savedClock;
+ ServerTimeHelper.CurrentDisplayMode = _savedMode;
+ CultureInfo.CurrentCulture = _savedCulture;
+ }
+
+ private static ServerClock Eastern() => ServerClock.Resolve("Eastern Standard Time", -300);
+
+ private static ServerClock India() => ServerClock.Resolve("India Standard Time", 330);
+
+ private static DateTime Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, DateTimeKind.Unspecified);
+
+ private static ApplicationConnectionRow Row(DateTime firstUtc, DateTime lastUtc, ServerClock? clock) => new()
+ {
+ ApplicationName = "app",
+ FirstSeen = firstUtc,
+ LastSeen = lastUtc,
+ Clock = clock,
+ };
+
+ ///
+ /// The selected server's wall time in Server mode, not the active tab's: 14:30Z is 10:30 in US Eastern (the selected
+ /// server) and 20:00 in India (the active tab's). The sort values read the same clock as the text.
+ ///
+ [Fact]
+ public void ServerMode_ARowReadsTheSelectedServersWallTime_NotTheActiveTabs_InTextAndSortValue()
+ {
+ ServerTimeHelper.ActiveServerClock = India();
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+
+ var row = Row(Utc(2026, 7, 1, 14, 30), Utc(2026, 7, 1, 15, 30), Eastern());
+ Assert.Equal("2026-07-01 10:30", row.FirstSeenText);
+ Assert.Equal("2026-07-01 11:30", row.LastSeenText);
+ Assert.Equal(new DateTime(2026, 7, 1, 10, 30, 0), row.FirstSeenLocal);
+ Assert.Equal(new DateTime(2026, 7, 1, 11, 30, 0), row.LastSeenLocal);
+
+ ServerTimeHelper.ActiveServerClock = Eastern();
+ var indian = Row(Utc(2026, 7, 1, 14, 30), Utc(2026, 7, 1, 15, 30), India());
+ Assert.Equal("2026-07-01 20:00", indian.FirstSeenText);
+ Assert.Equal(new DateTime(2026, 7, 1, 20, 0, 0), indian.FirstSeenLocal);
+ }
+
+ [Fact]
+ public void InTheRepeatedHour_EachOccurrenceCarriesItsOffsetInServerMode_AndUtcModeIsTheStoredInstant()
+ {
+ ServerTimeHelper.ActiveServerClock = India();
+ var row = Row(Utc(2026, 11, 1, 5, 30), Utc(2026, 11, 1, 6, 30), Eastern());
+
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+ Assert.Equal("2026-11-01 01:30 -04:00", row.FirstSeenText);
+ Assert.Equal("2026-11-01 01:30 -05:00", row.LastSeenText);
+ Assert.Equal(new DateTime(2026, 11, 1, 1, 30, 0), row.FirstSeenLocal);
+
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC;
+ Assert.Equal("2026-11-01 05:30", row.FirstSeenText);
+ Assert.Equal("2026-11-01 06:30", row.LastSeenText);
+ Assert.Equal(new DateTime(2026, 11, 1, 6, 30, 0), row.LastSeenLocal);
+ }
+
+ /// A row built without a clock reads on the active tab's, the fallback it had before.
+ [Fact]
+ public void ARowWithNoClockStamped_TakesTheActiveTabsClock()
+ {
+ ServerTimeHelper.ActiveServerClock = Eastern();
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+
+ var row = Row(Utc(2026, 11, 1, 6, 30), Utc(2026, 11, 1, 6, 30), clock: null);
+ Assert.Equal("2026-11-01 01:30 -05:00", row.FirstSeenText);
+ Assert.Equal(new DateTime(2026, 11, 1, 1, 30, 0), row.LastSeenLocal);
+ }
+
+ ///
+ /// The tab stamps the selected server's clock the way the PVS trend picks it, before the rows reach the grid, and the row
+ /// words its text through the instant renderer on its own zone and not through the active tab's shared setting.
+ ///
+ [Fact]
+ public void TheTab_StampsTheSelectedServersClock_AndTheRowWordsItOnItsOwnZone()
+ {
+ var tab = Code(LiteSource("Controls", "FinOpsTab.xaml.cs"));
+ var load = Member(tab, "LoadApplicationConnectionsAsync");
+ Assert.Contains("_openTabClock.Invoke(serverId)", load);
+ Assert.Contains("dataService.GetServerClockAsync(serverId)", load);
+ Assert.Contains("ServerTimeHelper.ClockForServer(collected, openTab)", load);
+ Assert.Contains("foreach (var row in data) row.Clock = clock;", load);
+
+ var data = Code(LiteSource("Services", "LocalDataService.FinOps.cs"));
+ var rowSource = data.Substring(data.IndexOf("class ApplicationConnectionRow", StringComparison.Ordinal));
+ rowSource = rowSource.Substring(0, rowSource.IndexOf("class DatabaseSizeRow", StringComparison.Ordinal));
+ Assert.DoesNotContain("FormatServerTime(", rowSource);
+ Assert.Contains("ServerTimeHelper.FormatInstant(FirstSeen, DisplayZoneNow,", rowSource);
+ Assert.Contains("ServerTimeHelper.FormatInstant(LastSeen, DisplayZoneNow,", rowSource);
+ Assert.Contains("Clock ?? ServerTimeHelper.ActiveServerClock", rowSource);
+ }
+
+ /// The declaration and body of the method called , to its matching close brace.
+ private static string Member(string source, string name)
+ {
+ var at = Regex.Match(source, @"Task\s+" + Regex.Escape(name) + @"\(");
+ Assert.True(at.Success, $"member {name} not found");
+ var open = source.IndexOf('{', at.Index);
+ var depth = 0;
+ for (var i = open; i < source.Length; i++)
+ {
+ if (source[i] == '{')
+ {
+ depth++;
+ }
+ else if (source[i] == '}' && --depth == 0)
+ {
+ return source.Substring(at.Index, i - at.Index + 1);
+ }
+ }
+
+ throw new InvalidOperationException($"member {name} is not closed");
+ }
+
+ private static string Code(string source) =>
+ Regex.Replace(source, @"/\*.*?\*/|//[^\r\n]*", "", RegexOptions.Singleline);
+
+ /// A Lite source file, found by walking up from this test file to the repo root.
+ private static string LiteSource(string folder, string file, [CallerFilePath] string thisFile = "")
+ {
+ var dir = Path.GetDirectoryName(thisFile)!;
+ var relative = Path.Combine("Lite", folder, file);
+ while (dir is not null && !File.Exists(Path.Combine(dir, relative)))
+ {
+ dir = Path.GetDirectoryName(dir);
+ }
+
+ Assert.NotNull(dir);
+ return File.ReadAllText(Path.Combine(dir!, relative));
+ }
+}
diff --git a/Lite.Tests/FinOpsSeenColumnsZoneTests.cs b/Lite.Tests/FinOpsSeenColumnsZoneTests.cs
new file mode 100644
index 0000000000..ff97e7a039
--- /dev/null
+++ b/Lite.Tests/FinOpsSeenColumnsZoneTests.cs
@@ -0,0 +1,179 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Globalization;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Text.RegularExpressions;
+using PerformanceMonitor.Analysis.Baselines;
+using PerformanceMonitor.Ui;
+using PerformanceMonitorLite.Services;
+using Xunit;
+
+namespace PerformanceMonitorLite.Tests;
+
+///
+/// #4766: the FinOps Application Connections grid's First Seen and Last Seen columns.
+///
+/// They bound a DateTime with a XAML StringFormat, so they could not carry the UTC offset in the repeated
+/// autumn hour, and the DateTime was ToLocalTime() of the stored instant: this machine's clock in every display mode,
+/// where every other Lite grid follows the mode (UTC, this machine, or the active server's own clock). The row now words
+/// the instant through (FirstSeenText), and its
+/// DateTime (FirstSeenLocal) reads the same display zone; the column binds the text and sorts by the DateTime.
+///
+/// The tests use US Eastern (autumn change 2026-11-01 06:00Z, so 05:30Z is the first 01:30 and 06:30Z the second) and
+/// India (+05:30 all year) as the server, so a clock that differs from this machine's is always among them.
+///
+/* Installs ServerTimeHelper.ActiveServerClock and CurrentDisplayMode, process-wide mutable statics; joins the
+ collection every other class that writes them uses. */
+[Collection("server-time-helper")]
+public sealed class FinOpsSeenColumnsZoneTests : IDisposable
+{
+ private readonly ServerClock _savedClock = ServerTimeHelper.ActiveServerClock;
+ private readonly TimeDisplayMode _savedMode = ServerTimeHelper.CurrentDisplayMode;
+ private readonly CultureInfo _savedCulture = CultureInfo.CurrentCulture;
+
+ public FinOpsSeenColumnsZoneTests()
+ {
+ /* The text format runs on the current culture (as every grid's does); the invariant one fixes its separators. */
+ CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
+ }
+
+ public void Dispose()
+ {
+ ServerTimeHelper.ActiveServerClock = _savedClock;
+ ServerTimeHelper.CurrentDisplayMode = _savedMode;
+ CultureInfo.CurrentCulture = _savedCulture;
+ }
+
+ private static ServerClock Eastern() => ServerClock.Resolve("Eastern Standard Time", -300);
+
+ private static ServerClock India() => ServerClock.Resolve("India Standard Time", 330);
+
+ private static DateTime Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, DateTimeKind.Unspecified);
+
+ private static ApplicationConnectionRow Row(DateTime firstSeen, DateTime lastSeen) => new()
+ {
+ ApplicationName = "app",
+ FirstSeen = firstSeen,
+ LastSeen = lastSeen,
+ };
+
+ ///
+ /// In Server mode the row reads the active server's wall clock, not this machine's: 12:00Z on 2026-07-01 is 08:00 on a US
+ /// Eastern server (UTC-4) and 17:30 on an India server, and a machine can share the zone of at most one of them.
+ ///
+ [Fact]
+ public void ServerMode_ReadsTheActiveServersWallClock_NotThisMachines()
+ {
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+ var row = Row(Utc(2026, 7, 1, 12, 0), Utc(2026, 7, 1, 13, 0));
+
+ ServerTimeHelper.ActiveServerClock = Eastern();
+ Assert.Equal(new DateTime(2026, 7, 1, 8, 0, 0), row.FirstSeenLocal);
+ Assert.Equal(new DateTime(2026, 7, 1, 9, 0, 0), row.LastSeenLocal);
+ Assert.Equal("2026-07-01 08:00", row.FirstSeenText);
+ Assert.Equal("2026-07-01 09:00", row.LastSeenText);
+
+ ServerTimeHelper.ActiveServerClock = India();
+ Assert.Equal(new DateTime(2026, 7, 1, 17, 30, 0), row.FirstSeenLocal);
+ Assert.Equal(new DateTime(2026, 7, 1, 18, 30, 0), row.LastSeenLocal);
+ Assert.Equal("2026-07-01 17:30", row.FirstSeenText);
+ Assert.Equal("2026-07-01 18:30", row.LastSeenText);
+ }
+
+ /// UTC mode is the stored instant itself, on any server's clock.
+ [Fact]
+ public void UtcMode_IsTheStoredInstant_OnAnyClock()
+ {
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC;
+ var row = Row(Utc(2026, 7, 1, 12, 0), Utc(2026, 11, 1, 6, 30));
+
+ foreach (var clock in new[] { Eastern(), India() })
+ {
+ ServerTimeHelper.ActiveServerClock = clock;
+ Assert.Equal(new DateTime(2026, 7, 1, 12, 0, 0), row.FirstSeenLocal);
+ Assert.Equal("2026-07-01 12:00", row.FirstSeenText);
+ Assert.Equal("2026-11-01 06:30", row.LastSeenText);
+ }
+ }
+
+ /// Local mode is this machine's zone at the instant, whatever server is active.
+ [Fact]
+ public void LocalMode_IsThisMachinesZone_OnAnyClock()
+ {
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.LocalTime;
+ var utc = Utc(2026, 7, 1, 12, 0);
+ var row = Row(utc, utc);
+ var machine = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(utc, DateTimeKind.Utc), TimeZoneInfo.Local);
+
+ foreach (var clock in new[] { Eastern(), India() })
+ {
+ ServerTimeHelper.ActiveServerClock = clock;
+ Assert.Equal(machine, row.FirstSeenLocal);
+ }
+ }
+
+ ///
+ /// The repeated autumn hour on a US Eastern server clock: 05:30Z reads 01:30 -04:00 and 06:30Z 01:30 -05:00, and the two
+ /// DateTimes the columns sort by are the same wall time, so the text is what tells the rows apart. The hour after
+ /// carries no offset.
+ ///
+ [Fact]
+ public void AnEasternServersRepeatedHour_ReadsItsOffset_AndTheHourAfterDoesNot()
+ {
+ ServerTimeHelper.ActiveServerClock = Eastern();
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+
+ var first = Row(Utc(2026, 11, 1, 5, 30), Utc(2026, 11, 1, 6, 30));
+ Assert.Equal("2026-11-01 01:30 -04:00", first.FirstSeenText);
+ Assert.Equal("2026-11-01 01:30 -05:00", first.LastSeenText);
+ Assert.Equal(first.FirstSeenLocal, first.LastSeenLocal);
+
+ var after = Row(Utc(2026, 11, 1, 7, 30), Utc(2026, 11, 1, 7, 30));
+ Assert.Equal("2026-11-01 02:30", after.FirstSeenText);
+ }
+
+ ///
+ /// The grid is a WPF grid this suite does not instantiate, so the columns are a source pin: each binds its text
+ /// property (no StringFormat, which cannot carry the offset), sorts by the row's UTC DateTime (the display-zone
+ /// one puts the second pass of the repeated hour before the first, see ),
+ /// and keeps its filter button's tag on the display-zone DateTime property the filter manager reads.
+ ///
+ [Theory]
+ [InlineData("FirstSeenText", "FirstSeen", "FirstSeenLocal")]
+ [InlineData("LastSeenText", "LastSeen", "LastSeenLocal")]
+ public void EachSeenColumn_BindsItsText_SortsByItsUtcDateTime_AndFiltersOnTheDateTime(string textProperty, string sortMember, string dateProperty)
+ {
+ var xaml = ReadLite("Controls", "FinOpsTab.xaml");
+
+ var start = Regex.Match(xaml,
+ @"]*\sBinding=""\{Binding " + textProperty + @"\}"")(?=[^>]*\sSortMemberPath=""" + sortMember + @""")[^>]*>");
+ Assert.True(start.Success, $"the column bound to {textProperty} and sorted by {sortMember} is not in FinOpsTab.xaml.");
+
+ var end = xaml.IndexOf("", start.Index, StringComparison.Ordinal);
+ Assert.True(end > start.Index, "the end of the column was not found.");
+ var column = xaml[start.Index..end];
+
+ Assert.DoesNotContain("StringFormat", column, StringComparison.Ordinal);
+ Assert.Contains($"Tag=\"{dateProperty}\"", column, StringComparison.Ordinal);
+ }
+
+ /// The DateTime-bound spellings this replaced must not come back.
+ [Fact]
+ public void NoSeenColumn_BindsADateTime_WithAStringFormat()
+ {
+ var xaml = ReadLite("Controls", "FinOpsTab.xaml");
+
+ Assert.DoesNotMatch(@"\{Binding (FirstSeenLocal|LastSeenLocal)\b", xaml);
+ }
+
+ private static string ReadLite(string folder, string file, [CallerFilePath] string thisFile = "") =>
+ File.ReadAllText(Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "Lite", folder, file)));
+}
diff --git a/Lite.Tests/GridTimeColumnSortMemberTests.cs b/Lite.Tests/GridTimeColumnSortMemberTests.cs
new file mode 100644
index 0000000000..643a2ef662
--- /dev/null
+++ b/Lite.Tests/GridTimeColumnSortMemberTests.cs
@@ -0,0 +1,179 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.ComponentModel;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Text.RegularExpressions;
+using System.Windows.Data;
+using PerformanceMonitor.Analysis.Baselines;
+using PerformanceMonitor.Ui;
+using PerformanceMonitorLite.Services;
+using Xunit;
+
+namespace PerformanceMonitorLite.Tests;
+
+///
+/// #4766: a grid time column that shows the UTC offset in the repeated autumn hour must sort by the row's true UTC
+/// DateTime, not by its text and not by a DateTime already converted to the display zone.
+///
+/// The text sorts wrong east of UTC: "02:30 +01:00" sorts before "02:30 +02:00", so the second pass of the hour
+/// comes out before the first. A display-zone DateTime is no better: the second pass at 01:15 (06:15Z) sorts before the
+/// first pass at 01:45 (05:45Z). Only the naive-UTC instant puts both passes in order, so each column carries the row's
+/// UTC member in SortMemberPath. The filter button's Tag is a separate contract (the filter manager reads
+/// it) and is left alone.
+///
+/// A row read from a stored server wall clock (job history, default trace, the query and procedure stats
+/// creation and execution times) keeps its text sort, and a row with no UTC member of its own is not in the table.
+///
+/* The behaviour check installs ServerTimeHelper.ActiveServerClock and CurrentDisplayMode, process-wide mutable statics;
+ the class joins the collection every other class that writes them uses. */
+[Collection("server-time-helper")]
+public sealed class GridTimeColumnSortMemberTests : IDisposable
+{
+ private readonly ServerClock _savedClock = ServerTimeHelper.ActiveServerClock;
+ private readonly TimeDisplayMode _savedMode = ServerTimeHelper.CurrentDisplayMode;
+
+ public void Dispose()
+ {
+ ServerTimeHelper.ActiveServerClock = _savedClock;
+ ServerTimeHelper.CurrentDisplayMode = _savedMode;
+ }
+
+ /// The row type is carried as its assembly-qualified name, so each case stays a serializable, separately reported row.
+ private static string Name(Type type) => type.AssemblyQualifiedName!;
+
+ /// (xaml under Lite/, the DataGrid's x:Name, the bound text property, the row type, the SortMemberPath).
+ public static TheoryData Columns => new()
+ {
+ { "Controls/FinOpsTab.xaml", "ApplicationConnectionsDataGrid", "FirstSeenText", Name(typeof(ApplicationConnectionRow)), "FirstSeen" },
+ { "Controls/FinOpsTab.xaml", "ApplicationConnectionsDataGrid", "LastSeenText", Name(typeof(ApplicationConnectionRow)), "LastSeen" },
+ { "Controls/AlertsHistoryTab.xaml", "AlertsDataGrid", "TimeLocal", Name(typeof(AlertHistoryRow)), "AlertTime" },
+ { "Controls/ServerTab.xaml", "QuerySnapshotsGrid", "CollectionTimeLocal", Name(typeof(QuerySnapshotRow)), "CollectionTime" },
+ { "Controls/ServerTab.xaml", "QueryStoreGrid", "LastExecutionTimeLocal", Name(typeof(QueryStoreRow)), "LastExecutionTime" },
+ { "Controls/ServerTab.xaml", "QueryStoreGrid", "FirstExecutionTimeLocal", Name(typeof(QueryStoreRow)), "FirstExecutionTime" },
+ { "Controls/ServerTab.xaml", "PlanCorrectionGrid", "CollectionTimeLocal", Name(typeof(PlanCorrectionRow)), "CollectionTime" },
+ { "Controls/ServerTab.xaml", "PlanCorrectionGrid", "ValidSinceLocal", Name(typeof(PlanCorrectionRow)), "ValidSince" },
+ { "Controls/ServerTab.xaml", "PlanCorrectionGrid", "LastRefreshLocal", Name(typeof(PlanCorrectionRow)), "LastRefresh" },
+ { "Controls/ServerTab.xaml", "PlanCorrectionGrid", "ExecuteActionInitiatedTimeLocal", Name(typeof(PlanCorrectionRow)), "ExecuteActionInitiatedTime" },
+ { "Controls/ServerTab.xaml", "PlanCorrectionGrid", "RevertActionInitiatedTimeLocal", Name(typeof(PlanCorrectionRow)), "RevertActionInitiatedTime" },
+ { "Controls/ServerTab.xaml", "BlockedProcessReportGrid", "EventTimeLocal", Name(typeof(BlockedProcessReportRow)), "EventTime" },
+ { "Controls/ServerTab.xaml", "DeadlockGrid", "DeadlockTimeLocal", Name(typeof(DeadlockProcessDetail)), "DeadlockTime" },
+ { "Controls/ServerTab.xaml", "AutomaticTuningGrid", "CollectionTimeLocal", Name(typeof(AutomaticTuningRow)), "CollectionTime" },
+ { "Controls/ServerTab.xaml", "SchedulerIssuesGrid", "EventTimeLocal", Name(typeof(SchedulerIssueRow)), "EventTime" },
+ { "Controls/ServerTab.xaml", "SevereErrorsGrid", "EventTimeLocal", Name(typeof(SevereErrorRow)), "EventTime" },
+ { "Controls/ServerTab.xaml", "MemoryConditionsGrid", "EventTimeLocal", Name(typeof(MemoryConditionsRow)), "EventTime" },
+ { "Controls/ServerTab.xaml", "MemoryBrokerGrid", "EventTimeLocal", Name(typeof(MemoryBrokerRow)), "EventTime" },
+ { "Controls/ServerTab.xaml", "MemoryNodeOomGrid", "EventTimeLocal", Name(typeof(MemoryNodeOomRow)), "EventTime" },
+ { "Controls/ServerTab.xaml", "SignificantWaitsGrid", "EventTimeLocal", Name(typeof(SignificantWaitRow)), "EventTime" },
+ { "Controls/ServerTab.xaml", "CpuTasksGrid", "EventTimeLocal", Name(typeof(CpuTasksRow)), "EventTime" },
+ { "Controls/ServerTab.xaml", "IoIssuesGrid", "EventTimeLocal", Name(typeof(IoIssuesRow)), "EventTime" },
+ { "Controls/ServerTab.xaml", "ServerConfigChangesGrid", "ChangeTimeLocal", Name(typeof(ServerConfigChangeRow)), "ChangeTime" },
+ { "Controls/ServerTab.xaml", "DatabaseConfigChangesGrid", "ChangeTimeLocal", Name(typeof(DatabaseConfigChangeRow)), "ChangeTime" },
+ { "Controls/ServerTab.xaml", "TraceFlagChangesGrid", "ChangeTimeLocal", Name(typeof(TraceFlagChangeRow)), "ChangeTime" },
+ { "Controls/ServerTab.xaml", "CollectionHealthGrid", "LastSuccessFormatted", Name(typeof(CollectorHealthRow)), "LastSuccessTime" },
+ { "Controls/ServerTab.xaml", "CollectionHealthGrid", "LastRunFormatted", Name(typeof(CollectorHealthRow)), "LastRunTime" },
+ { "Controls/ServerTab.xaml", "CollectionHealthGrid", "LastErrorFormatted", Name(typeof(CollectorHealthRow)), "LastErrorTime" },
+ { "Controls/ServerTab.xaml", "CollectionLogGrid", "CollectionTimeFormatted", Name(typeof(CollectionLogRow)), "CollectionTime" },
+ { "Windows/CollectionLogWindow.xaml", "LogDataGrid", "CollectionTimeFormatted", Name(typeof(CollectionLogRow)), "CollectionTime" },
+ { "Windows/ProcedureHistoryWindow.xaml", "HistoryDataGrid", "CollectionTimeLocal", Name(typeof(ProcedureStatsHistoryRow)), "CollectionTime" },
+ { "Windows/QueryStatsHistoryWindow.xaml", "HistoryDataGrid", "CollectionTimeLocal", Name(typeof(QueryStatsHistoryRow)), "CollectionTime" },
+ { "Windows/QueryStoreHistoryWindow.xaml", "HistoryDataGrid", "CollectionTimeLocal", Name(typeof(QueryStoreHistoryRow)), "CollectionTime" },
+ { "Windows/QueryStoreHistoryWindow.xaml", "HistoryDataGrid", "FirstExecutionTimeLocal", Name(typeof(QueryStoreHistoryRow)), "FirstExecutionTime" },
+ { "Windows/QueryStoreHistoryWindow.xaml", "HistoryDataGrid", "LastExecutionTimeLocal", Name(typeof(QueryStoreHistoryRow)), "LastExecutionTime" },
+ { "Windows/WaitDrillDownWindow.xaml", "ResultsDataGrid", "CollectionTimeLocal", Name(typeof(QuerySnapshotRow)), "CollectionTime" },
+ };
+
+ ///
+ /// The column bound to the text property carries the row's UTC member in SortMemberPath, and that member is a
+ /// public or DateTime? on the row type. A wrong name sorts nothing and raises nothing,
+ /// so the name is checked against the row type the grid is bound to. A name ending in Local or Display
+ /// would be a display-zone value and is refused.
+ ///
+ [Theory]
+ [MemberData(nameof(Columns))]
+ public void ATimeColumn_SortsByItsRowsUtcDateTime(string xamlFile, string grid, string textProperty, string rowTypeName, string sortMember)
+ {
+ var xaml = ReadLite(xamlFile);
+
+ var column = FindColumn(xaml, grid, textProperty);
+ var declared = Regex.Match(column, @"\sSortMemberPath=""(?[^""]*)""");
+ Assert.True(declared.Success, $"{xamlFile}: the {grid} column bound to {textProperty} has no SortMemberPath, so it sorts by its text.");
+ Assert.Equal(sortMember, declared.Groups["path"].Value);
+
+ var rowType = Type.GetType(rowTypeName, throwOnError: true)!;
+ var property = rowType.GetProperty(sortMember, BindingFlags.Public | BindingFlags.Instance);
+ Assert.True(property != null, $"{rowType.Name} has no public property {sortMember}.");
+ Assert.True(
+ (Nullable.GetUnderlyingType(property!.PropertyType) ?? property.PropertyType) == typeof(DateTime),
+ $"{rowType.Name}.{sortMember} is a {property.PropertyType.Name}, not a DateTime.");
+
+ Assert.False(sortMember.EndsWith("Local", StringComparison.Ordinal) || sortMember.EndsWith("Display", StringComparison.Ordinal),
+ $"{sortMember} reads as a display-zone member; the column must sort by the UTC one.");
+ }
+
+ ///
+ /// The two passes of the repeated hour, sorted the way a header click sorts them (a
+ /// on the column's SortMemberPath). US Eastern falls back at 2026-11-01 06:00Z, so 05:45Z is 01:45 -04:00 (the
+ /// first pass) and 06:15Z is 01:15 -05:00 (the second). The UTC member puts the first pass first; the display-zone
+ /// DateTime and the text both put the second pass first, which is what the columns did before.
+ ///
+ [Fact]
+ public void TheRepeatedHour_SortsFirstPassBeforeSecond_OnTheUtcMember_ButNotOnTheDisplayDateTimeOrTheText()
+ {
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+ ServerTimeHelper.ActiveServerClock = ServerClock.Resolve("Eastern Standard Time", -300);
+
+ var firstPass = new ApplicationConnectionRow { ApplicationName = "first", FirstSeen = Utc(2026, 11, 1, 5, 45), LastSeen = Utc(2026, 11, 1, 5, 45) };
+ var secondPass = new ApplicationConnectionRow { ApplicationName = "second", FirstSeen = Utc(2026, 11, 1, 6, 15), LastSeen = Utc(2026, 11, 1, 6, 15) };
+ var rows = new[] { secondPass, firstPass };
+
+ /* The member the column is declared to sort by, read from the XAML rather than restated here. */
+ var declared = Regex.Match(FindColumn(ReadLite("Controls/FinOpsTab.xaml"), "ApplicationConnectionsDataGrid", "FirstSeenText"),
+ @"\sSortMemberPath=""(?[^""]*)""").Groups["path"].Value;
+
+ Assert.Equal(new[] { "first", "second" }, Sorted(rows, declared));
+
+ /* Why the display-zone value and the text are refused as sort keys. */
+ Assert.Equal(new DateTime(2026, 11, 1, 1, 45, 0), firstPass.FirstSeenLocal);
+ Assert.Equal(new DateTime(2026, 11, 1, 1, 15, 0), secondPass.FirstSeenLocal);
+ Assert.Equal(new[] { "second", "first" }, Sorted(rows, nameof(ApplicationConnectionRow.FirstSeenLocal)));
+ Assert.Equal(new[] { "second", "first" }, Sorted(rows, nameof(ApplicationConnectionRow.FirstSeenText)));
+ }
+
+ private static DateTime Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, DateTimeKind.Unspecified);
+
+ /// The application names in the order a header click on that sort member ascending gives.
+ private static string[] Sorted(ApplicationConnectionRow[] rows, string sortMemberPath)
+ {
+ var view = new ListCollectionView(rows);
+ view.SortDescriptions.Add(new SortDescription(sortMemberPath, ListSortDirection.Ascending));
+ return view.Cast().Select(r => r.ApplicationName).ToArray();
+ }
+
+ /// The start tag of the one DataGridTextColumn inside the named grid whose binding is the text property.
+ private static string FindColumn(string xaml, string grid, string textProperty)
+ {
+ var gridStart = Regex.Match(xaml, @"]*?x:Name=""" + Regex.Escape(grid) + @"""");
+ Assert.True(gridStart.Success, $"the grid {grid} is not in the file.");
+ var gridEnd = xaml.IndexOf("", gridStart.Index, StringComparison.Ordinal);
+ Assert.True(gridEnd > gridStart.Index, $"the end of the grid {grid} was not found.");
+
+ var columns = Regex.Matches(xaml[gridStart.Index..gridEnd], @"""])*>")
+ .Where(m => Regex.IsMatch(m.Value, @"\sBinding=""\{Binding " + Regex.Escape(textProperty) + @"(?=[,}\s])"))
+ .ToList();
+ Assert.True(columns.Count == 1, $"{grid} has {columns.Count} columns bound to {textProperty}; the pin expects exactly one.");
+ return columns[0].Value;
+ }
+
+ private static string ReadLite(string relative, [CallerFilePath] string thisFile = "") =>
+ File.ReadAllText(Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "Lite", relative)));
+}
diff --git a/Lite.Tests/HistoryGridZoneTests.cs b/Lite.Tests/HistoryGridZoneTests.cs
index 3ce277478f..9ca7ff6f22 100644
--- a/Lite.Tests/HistoryGridZoneTests.cs
+++ b/Lite.Tests/HistoryGridZoneTests.cs
@@ -7,6 +7,7 @@
*/
using System;
+using System.Globalization;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
@@ -161,6 +162,33 @@ public void WithoutAZone_TheTextIsFormatServerTimes(string rowType)
}
}
+ ///
+ /// The two paths word the repeated hour alike (#4766): a row with a zone reads the second 01:30 as
+ /// does, with its -05:00 offset, and a row with none reads it through
+ /// , which in Server mode on a US Eastern clock
+ /// adds the same offset. In UTC mode the second path is the bare 06:30, as the zone path is in the UTC zone.
+ ///
+ [Theory]
+ [InlineData(nameof(QueryStatsHistoryRow))]
+ [InlineData(nameof(ProcedureStatsHistoryRow))]
+ [InlineData(nameof(QueryStoreHistoryRow))]
+ public void WithoutAZone_InServerMode_TheRepeatedHourCarriesTheSameOffsetAsWithAZone(string rowType)
+ {
+ ServerTimeHelper.ActiveServerClock = ServerClock.Resolve("Eastern Standard Time", -300);
+
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+ foreach (var text in InstantTexts(rowType, null, SecondOneThirty))
+ {
+ Assert.Equal("2026-11-01 01:30:00 -05:00", text);
+ }
+
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC;
+ foreach (var text in InstantTexts(rowType, null, SecondOneThirty))
+ {
+ Assert.Equal("2026-11-01 06:30:00", text);
+ }
+ }
+
///
/// Query Store's first and last execution are nullable and a sentinel can reach
/// the grid: a null is blank and the sentinel stays the sentinel, with a zone as without one.
@@ -414,6 +442,63 @@ public void EachHistoryWindow_SetsItsZoneOnEveryLoadedRow_BeforeTheRowsAreBound(
Assert.True(bind > assign, $"{file}: the rows are bound before they are given the window's zone.");
}
+ ///
+ /// A summary range end reads as words it (#4766): the wall time of the
+ /// instant in the window's zone, and in the repeated autumn hour a space and that instant's UTC offset, so the first
+ /// and the second 01:30 of a US Eastern change day differ. The pattern still runs on the current culture, as the
+ /// interpolation it replaces did, so the test names the invariant one for its separators.
+ ///
+ [Fact]
+ public void FormatInstant_WordsTheSummaryRange_WithTheOffsetOnlyInTheRepeatedHour()
+ {
+ var saved = CultureInfo.CurrentCulture;
+
+ try
+ {
+ CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
+ const string RangeFormat = "MM/dd HH:mm";
+
+ Assert.Equal("11/01 01:30 -04:00", ServerTimeHelper.FormatInstant(DisplayZoneFixtures.At(2026, 11, 1, 5, 30), DisplayZoneFixtures.Eastern, RangeFormat));
+ Assert.Equal("11/01 01:30 -05:00", ServerTimeHelper.FormatInstant(DisplayZoneFixtures.At(2026, 11, 1, 6, 30), DisplayZoneFixtures.Eastern, RangeFormat));
+
+ /* The hour before, the hour after and UTC, whose two instants read 05:30 and 06:30, are the bare text. */
+ Assert.Equal("11/01 00:30", ServerTimeHelper.FormatInstant(DisplayZoneFixtures.At(2026, 11, 1, 4, 30), DisplayZoneFixtures.Eastern, RangeFormat));
+ Assert.Equal("11/01 02:30", ServerTimeHelper.FormatInstant(DisplayZoneFixtures.At(2026, 11, 1, 7, 30), DisplayZoneFixtures.Eastern, RangeFormat));
+ Assert.Equal("11/01 05:30", ServerTimeHelper.FormatInstant(DisplayZoneFixtures.At(2026, 11, 1, 5, 30), TimeZoneInfo.Utc, RangeFormat));
+ Assert.Equal("11/01 06:30", ServerTimeHelper.FormatInstant(DisplayZoneFixtures.At(2026, 11, 1, 6, 30), TimeZoneInfo.Utc, RangeFormat));
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = saved;
+ }
+ }
+
+ ///
+ /// Each window is a WPF window this suite does not instantiate, so the wiring is a source pin: the summary line words
+ /// both ends of its range through ServerTimeHelper.FormatInstant on a zone the window reads once, and no
+ /// longer interpolates a converted with the "MM/dd HH:mm" specifier, which cannot tell the two
+ /// 01:30s of the repeated hour apart.
+ ///
+ [Theory]
+ [InlineData("ProcedureHistoryWindow.xaml.cs")]
+ [InlineData("QueryStatsHistoryWindow.xaml.cs")]
+ [InlineData("QueryStoreHistoryWindow.xaml.cs")]
+ public void EachHistoryWindow_WordsItsSummaryRange_ThroughFormatInstant(string file)
+ {
+ var source = CodeOnly(ReadLite("Windows", file));
+
+ var start = source.IndexOf("if (_historyData.Count > 0)", StringComparison.Ordinal);
+ Assert.True(start >= 0, $"{file}: the summary block is no longer where this pin looks.");
+ var end = source.IndexOf("SummaryText.Text = \"No history data found", start, StringComparison.Ordinal);
+ Assert.True(end > start, $"{file}: the end of the summary block was not found.");
+ var summary = source[start..end];
+
+ Assert.Contains("ServerTimeHelper.FormatInstant(_historyData.First().CollectionTime, zone, \"MM/dd HH:mm\")", summary, StringComparison.Ordinal);
+ Assert.Contains("ServerTimeHelper.FormatInstant(_historyData.Last().CollectionTime, zone, \"MM/dd HH:mm\")", summary, StringComparison.Ordinal);
+ Assert.Single(Regex.Matches(summary, Regex.Escape("_displayZone()")));
+ Assert.DoesNotContain(":MM/dd HH:mm", source, StringComparison.Ordinal);
+ }
+
/* Line and block comments removed, and line endings normalised, so a pin reads code only. */
private static string CodeOnly(string source)
{
diff --git a/Lite.Tests/ServerTimeHelperClockTests.cs b/Lite.Tests/ServerTimeHelperClockTests.cs
index c24e6c8231..ba4afb9ce9 100644
--- a/Lite.Tests/ServerTimeHelperClockTests.cs
+++ b/Lite.Tests/ServerTimeHelperClockTests.cs
@@ -8,6 +8,7 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
@@ -83,11 +84,12 @@ public void ServerMode_ShowsTheServersWallClock_OnEachSideOfFallBack()
Assert.Equal(Utc(2026, 11, 1, 2, 30), clock.ToServerLocal(Utc(2026, 11, 1, 7, 30)));
/* And the rendered string the grids use. Both instants of the repeated hour read "01:30" on the server's clock,
- so this text cannot tell 05:30 UTC from 06:30 UTC; the naive-UTC value is what keeps them apart (#4766). */
+ so the wall time cannot tell 05:30 UTC from 06:30 UTC; the text adds the instant's UTC offset for those two
+ and only those (#4766). */
ServerTimeHelper.ActiveServerClock = clock;
ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
- Assert.Equal("2026-11-01 01:30:00", ServerTimeHelper.FormatServerTime(Utc(2026, 11, 1, 5, 30)));
- Assert.Equal("2026-11-01 01:30:00", ServerTimeHelper.FormatServerTime(Utc(2026, 11, 1, 6, 30)));
+ Assert.Equal("2026-11-01 01:30:00 -04:00", ServerTimeHelper.FormatServerTime(Utc(2026, 11, 1, 5, 30)));
+ Assert.Equal("2026-11-01 01:30:00 -05:00", ServerTimeHelper.FormatServerTime(Utc(2026, 11, 1, 6, 30)));
Assert.Equal("2026-11-01 02:30:00", ServerTimeHelper.FormatServerTime(Utc(2026, 11, 1, 7, 30)));
}
@@ -209,10 +211,12 @@ public void DisplayZoneFor_IsUtc_ThisMachinesZone_OrTheServersOwnClock()
/// 05:30Z and 06:30Z on the autumn change day are the two occurrences of 01:30 on a US Eastern wall clock. The
/// renderer used to go UTC to the server's wall clock and back out of it for the UTC and Local modes, and a wall
/// clock cannot say which occurrence it was: 06:30Z read 05:30 in UTC mode. It now converts the instant into the
- /// zone of the mode in one step.
+ /// zone of the mode in one step. The two occurrences of 01:30 in Server mode read the same wall time, so the text
+ /// adds each one's UTC offset: "-04:00" for 05:30Z (EDT) and "-05:00" for 06:30Z (EST). UTC mode and every time
+ /// outside the repeated hour stay the bare text.
///
[Fact]
- public void FormatServerTime_InTheRepeatedHour_ShowsTheInstantInUtcMode_AndTheWallClockInServerMode()
+ public void FormatServerTime_InTheRepeatedHour_ShowsTheInstantInUtcMode_AndTheWallClockWithItsOffsetInServerMode()
{
ServerTimeHelper.ActiveServerClock = Eastern();
var first = Utc(2026, 11, 1, 5, 30);
@@ -224,14 +228,49 @@ public void FormatServerTime_InTheRepeatedHour_ShowsTheInstantInUtcMode_AndTheWa
Assert.Equal("2026-11-01 06:30:00", ServerTimeHelper.FormatServerTime((DateTime?)second));
ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
- Assert.Equal("2026-11-01 01:30:00", ServerTimeHelper.FormatServerTime(first));
- Assert.Equal("2026-11-01 01:30:00", ServerTimeHelper.FormatServerTime(second));
- Assert.Equal("2026-11-01 01:30:00", ServerTimeHelper.FormatServerTime((DateTime?)second));
- Assert.Equal("01:30", ServerTimeHelper.FormatServerTime(second, "HH:mm"));
+ Assert.Equal("2026-11-01 01:30:00 -04:00", ServerTimeHelper.FormatServerTime(first));
+ Assert.Equal("2026-11-01 01:30:00 -05:00", ServerTimeHelper.FormatServerTime(second));
+ Assert.Equal("2026-11-01 01:30:00 -05:00", ServerTimeHelper.FormatServerTime((DateTime?)second));
+ Assert.Equal("01:30 -05:00", ServerTimeHelper.FormatServerTime(second, "HH:mm"));
Assert.Equal("", ServerTimeHelper.FormatServerTime((DateTime?)null));
+
+ /* The hour before, the hour after, and an ordinary summer time carry no offset. */
+ Assert.Equal("2026-11-01 00:30:00", ServerTimeHelper.FormatServerTime(Utc(2026, 11, 1, 4, 30)));
+ Assert.Equal("2026-11-01 02:30:00", ServerTimeHelper.FormatServerTime(Utc(2026, 11, 1, 7, 30)));
+ Assert.Equal("2026-07-01 08:00:00", ServerTimeHelper.FormatServerTime(Utc(2026, 7, 1, 12, 0)));
+ }
+
+ ///
+ /// The format string still runs on the current culture, as it did before the offset was added; only the offset
+ /// is culture-free. A culture with a "." time separator words "HH:mm" as "01.30".
+ ///
+ [Fact]
+ public void FormatServerTime_InTheRepeatedHour_AppliesTheFormatOnTheCurrentCulture_AndTheOffsetIsCultureFree()
+ {
+ ServerTimeHelper.ActiveServerClock = Eastern();
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+ var dotted = (CultureInfo)CultureInfo.InvariantCulture.Clone();
+ dotted.DateTimeFormat.TimeSeparator = ".";
+ var saved = CultureInfo.CurrentCulture;
+
+ try
+ {
+ CultureInfo.CurrentCulture = dotted;
+
+ Assert.Equal("01.30 -05:00", ServerTimeHelper.FormatServerTime(Utc(2026, 11, 1, 6, 30), "HH:mm"));
+ Assert.Equal("02.30", ServerTimeHelper.FormatServerTime(Utc(2026, 11, 1, 7, 30), "HH:mm"));
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = saved;
+ }
}
- /// Local mode is the machine's zone at each instant, so the two occurrences stay two instants.
+ ///
+ /// Local mode is the machine's zone at each instant, so the two occurrences stay two instants. On a machine whose
+ /// own clock repeats an hour at these instants (a US Eastern desktop) the text carries that offset too, so the
+ /// expected text names it the same way.
+ ///
[Fact]
public void FormatServerTime_InLocalMode_IsThisMachinesZoneAtEachInstant()
{
@@ -240,11 +279,67 @@ public void FormatServerTime_InLocalMode_IsThisMachinesZoneAtEachInstant()
foreach (var utc in new[] { Utc(2026, 11, 1, 5, 30), Utc(2026, 11, 1, 6, 30), Utc(2026, 7, 1, 12, 0) })
{
- var expected = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(utc, DateTimeKind.Utc), TimeZoneInfo.Local);
- Assert.Equal(expected.ToString("yyyy-MM-dd HH:mm:ss"), ServerTimeHelper.FormatServerTime(utc));
+ Assert.Equal(MachineWallText(utc), ServerTimeHelper.FormatServerTime(utc));
}
}
+ ///
+ /// A fixed-offset server clock (a server that reported no time zone id) has no repeated hour, so it never takes the
+ /// offset: at 05:30Z and 06:30Z on the day a US zone falls back, a -05:00 clock reads 00:30 and 01:30, each once. The
+ /// same instants on a zone clock read 01:30 twice.
+ ///
+ [Fact]
+ public void FormatServerTime_OnAFixedOffsetClock_NeverAddsAnOffset_AtTheInstantsWhereAUsZoneRepeatsAnHour()
+ {
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+
+ ServerTimeHelper.ActiveServerClock = ServerClock.FixedOffset(-300);
+ Assert.Equal("2026-11-01 00:30:00", ServerTimeHelper.FormatServerTime(Utc(2026, 11, 1, 5, 30)));
+ Assert.Equal("2026-11-01 01:30:00", ServerTimeHelper.FormatServerTime(Utc(2026, 11, 1, 6, 30)));
+ Assert.Equal("01:30", ServerTimeHelper.FormatServerTime(Utc(2026, 11, 1, 6, 30), "HH:mm"));
+
+ ServerTimeHelper.ActiveServerClock = Eastern();
+ Assert.Equal("2026-11-01 01:30:00 -04:00", ServerTimeHelper.FormatServerTime(Utc(2026, 11, 1, 5, 30)));
+ Assert.Equal("2026-11-01 01:30:00 -05:00", ServerTimeHelper.FormatServerTime(Utc(2026, 11, 1, 6, 30)));
+ }
+
+ ///
+ /// A zone east of UTC repeats its hour at a different UTC instant and with positive offsets: Berlin falls back at
+ /// 01:00Z on 2026-10-25 (03:00 CEST to 02:00 CET), so 02:30 happens at 00:30Z (+02:00) and again at 01:30Z (+01:00).
+ ///
+ [Fact]
+ public void FormatServerTime_OnAZoneEastOfUtc_GivesTheTwoPassesOfTheRepeatedHourTheirPositiveOffsets()
+ {
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+ ServerTimeHelper.ActiveServerClock = ServerClock.Resolve("W. Europe Standard Time", 60);
+
+ Assert.Equal("2026-10-25 02:30:00 +02:00", ServerTimeHelper.FormatServerTime(Utc(2026, 10, 25, 0, 30)));
+ Assert.Equal("2026-10-25 02:30:00 +01:00", ServerTimeHelper.FormatServerTime(Utc(2026, 10, 25, 1, 30)));
+
+ /* The hour before and the hour after are bare. */
+ Assert.Equal("2026-10-25 01:59:00", ServerTimeHelper.FormatServerTime(Utc(2026, 10, 24, 23, 59)));
+ Assert.Equal("2026-10-25 03:00:00", ServerTimeHelper.FormatServerTime(Utc(2026, 10, 25, 2, 0)));
+ }
+
+ ///
+ /// The text this machine's own zone gives the naive-UTC instant : its wall clock, then a
+ /// space and its UTC offset when that wall time happens twice on this machine. Worked out from the framework's
+ /// own zone functions, not from the renderer it checks.
+ ///
+ internal static string MachineWallText(DateTime utc)
+ {
+ var instant = DateTime.SpecifyKind(utc, DateTimeKind.Utc);
+ var wall = TimeZoneInfo.ConvertTimeFromUtc(instant, TimeZoneInfo.Local);
+ var text = wall.ToString("yyyy-MM-dd HH:mm:ss");
+ if (!TimeZoneInfo.Local.IsAmbiguousTime(wall))
+ {
+ return text;
+ }
+
+ var offset = TimeZoneInfo.Local.GetUtcOffset(instant);
+ return text + " " + (offset < TimeSpan.Zero ? "-" : "+") + offset.Duration().ToString(@"hh\:mm");
+ }
+
///
/// The label names the zone AT THE INSTANT it labels: a US Eastern clock reads "UTC-4:00" in July and "UTC-5:00"
/// in December, not the zone's standard name ("Eastern Standard Time" reads wrong on a July time) and not the
diff --git a/Lite.Tests/StoredServerClockRowTextTests.cs b/Lite.Tests/StoredServerClockRowTextTests.cs
new file mode 100644
index 0000000000..dfa47cb2ab
--- /dev/null
+++ b/Lite.Tests/StoredServerClockRowTextTests.cs
@@ -0,0 +1,173 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using DuckDB.NET.Data;
+using PerformanceMonitor.Analysis.Baselines;
+using PerformanceMonitor.Common;
+using PerformanceMonitor.Ui;
+using PerformanceMonitorLite.Database;
+using PerformanceMonitorLite.Services;
+using Xunit;
+
+namespace PerformanceMonitorLite.Tests;
+
+///
+/// #4766: a time read from a STORED server wall-clock column prints as the plain wall time in the repeated autumn
+/// hour, never with a UTC offset.
+///
+/// Text that shows one instant appends the instant's offset there (ServerTimeHelper.FormatServerTime), and
+/// that is only true for a real UTC instant. A Default Trace event comes in as the server's own wall clock and goes to
+/// UTC through , which maps BOTH passes of a repeated local time to the first ("A stored
+/// server-local time cannot say which one it was"). Formatted with the offset, the second pass would print the first
+/// pass's: an event at 01:30 the second time (06:30Z, -05:00) on a US Eastern server printed "-04:00". US Eastern falls
+/// back on 2026-11-01 at 06:00Z. The Darling viewer's twin is StoredServerClockRowTextTests.
+///
+/* Installs ServerTimeHelper.ActiveServerClock and CurrentDisplayMode, process-wide mutable statics; joins the
+ collection every other class that writes them uses. */
+[Collection("server-time-helper")]
+public sealed class StoredServerClockRowTextTests : IClassFixture, IDisposable
+{
+ private const int ServerId = 8812;
+ private const string EasternZone = "Eastern Standard Time";
+
+ private readonly DuckDbInitializer _duckDb;
+ private readonly ServerClock _savedClock = ServerTimeHelper.ActiveServerClock;
+ private readonly TimeDisplayMode _savedMode = ServerTimeHelper.CurrentDisplayMode;
+ private DuckDBConnection? _seedConn;
+ private long _nextId = 1;
+
+ public StoredServerClockRowTextTests(SharedDuckDbFixture fixture)
+ {
+ fixture.ResetData();
+ _duckDb = fixture.DuckDb;
+ ServerTimeHelper.ActiveServerClock = Eastern();
+ ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime;
+ }
+
+ public void Dispose()
+ {
+ ServerTimeHelper.ActiveServerClock = _savedClock;
+ ServerTimeHelper.CurrentDisplayMode = _savedMode;
+ _seedConn?.Dispose();
+ }
+
+ private static DateTime At(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, DateTimeKind.Unspecified);
+
+ private static ServerClock Eastern() => ServerClock.Resolve(EasternZone, -300);
+
+ /// A system_health row: its event time is the XE @timestamp, a real UTC instant.
+ private static string TrueUtcRowText(DateTime naiveUtc) =>
+ new SchedulerIssueRow(new SchedulerIssueRecord { EventTime = naiveUtc }).EventTimeLocal;
+
+ private async Task SeedConnectionAsync()
+ {
+ if (_seedConn is null)
+ {
+ _seedConn = _duckDb.CreateConnection();
+ await _seedConn.OpenAsync();
+ }
+
+ return _seedConn;
+ }
+
+ private async Task SeedDefaultTraceAsync(DateTime eventTimeServerLocal, string text)
+ {
+ using var readLock = _duckDb.AcquireReadLock();
+ var conn = await SeedConnectionAsync();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = @"
+INSERT INTO default_trace_events
+ (default_trace_event_id, collection_time, server_id, server_name, event_time, event_name,
+ database_name, duration_us, integer_data, severity, error_number, text_data)
+VALUES ($1, $2, $3, 'TestSrv', $4, 'Server Memory Change', NULL, NULL, NULL, NULL, NULL, $5)";
+ cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ });
+ cmd.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow });
+ cmd.Parameters.Add(new DuckDBParameter { Value = ServerId });
+ cmd.Parameters.Add(new DuckDBParameter { Value = eventTimeServerLocal });
+ cmd.Parameters.Add(new DuckDBParameter { Value = text });
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ [Fact]
+ public async Task ADefaultTraceEventReadFromTheStoredWallClock_PrintsNoOffset_InBothPassesOfTheRepeatedHour_WhileARealInstantStillDoes()
+ {
+ var service = new LocalDataService(_duckDb);
+
+ /* Two events, one in each pass of the hour: the trace file stores 01:30 for both. */
+ await SeedDefaultTraceAsync(At(2026, 11, 1, 1, 30), "first-pass");
+ await SeedDefaultTraceAsync(At(2026, 11, 1, 1, 30), "second-pass");
+ await SeedDefaultTraceAsync(At(2026, 11, 1, 2, 30), "after");
+
+ var rows = await service.GetDefaultTraceEventsAsync(
+ ServerId, fromDate: At(2026, 11, 1, 0, 0), toDate: At(2026, 11, 1, 12, 0), serverClock: Eastern());
+
+ Assert.Equal(3, rows.Count);
+ Assert.Equal(At(2026, 11, 1, 5, 30), rows.Single(r => r.TextData == "first-pass").EventTimeUtc);
+ Assert.Equal(At(2026, 11, 1, 5, 30), rows.Single(r => r.TextData == "second-pass").EventTimeUtc);
+
+ Assert.Equal("2026-11-01 01:30:00", rows.Single(r => r.TextData == "first-pass").EventTimeLocal);
+ Assert.Equal("2026-11-01 01:30:00", rows.Single(r => r.TextData == "second-pass").EventTimeLocal);
+ Assert.Equal("2026-11-01 02:30:00", rows.Single(r => r.TextData == "after").EventTimeLocal);
+
+ /* A real instant in the same test keeps the offset: the two passes of 01:30 are 05:30Z and 06:30Z. */
+ Assert.Equal("2026-11-01 01:30:00 -04:00", TrueUtcRowText(At(2026, 11, 1, 5, 30)));
+ Assert.Equal("2026-11-01 01:30:00 -05:00", TrueUtcRowText(At(2026, 11, 1, 6, 30)));
+ Assert.Equal("", new DefaultTraceEventRow(
+ null, DefaultTraceEventCategory.ErrorLog, "ErrorLog", null, null, null, null, null, null, null, null, 20, 823, "x").EventTimeLocal);
+ }
+
+ [Fact]
+ public void TheStoredWallClockRenderer_IsTheFormatServerTimeText_AndTheRealInstantRendererDiffersFromItOnlyInTheRepeatedHour()
+ {
+ foreach (var instant in new[] { At(2026, 11, 1, 4, 59), At(2026, 11, 1, 7, 0), At(2026, 7, 6, 16, 0) })
+ {
+ Assert.Equal(SystemEventRowFormat.Local(instant), SystemEventRowFormat.StoredWallClock(instant));
+ }
+
+ var secondPass = At(2026, 11, 1, 6, 30);
+ Assert.Equal("2026-11-01 01:30:00", SystemEventRowFormat.StoredWallClock(secondPass));
+ Assert.Equal("2026-11-01 01:30:00 -05:00", SystemEventRowFormat.Local(secondPass));
+ Assert.Equal("", SystemEventRowFormat.StoredWallClock(null));
+ }
+
+ [Theory]
+ [InlineData(TimeDisplayMode.UTC)]
+ [InlineData(TimeDisplayMode.LocalTime)]
+ public void TheStoredWallClockRenderer_FollowsTheDisplayMode_LikeTheRealInstantRenderer(TimeDisplayMode mode)
+ {
+ ServerTimeHelper.CurrentDisplayMode = mode;
+
+ /* Outside the repeated hour on any machine zone the two renderers word the instant the same way; UTC mode
+ has no repeated hour at all, so it never differs. */
+ Assert.Equal(SystemEventRowFormat.Local(At(2026, 7, 6, 16, 0)), SystemEventRowFormat.StoredWallClock(At(2026, 7, 6, 16, 0)));
+
+ if (mode == TimeDisplayMode.UTC)
+ {
+ Assert.Equal("2026-11-01 05:30:00", SystemEventRowFormat.StoredWallClock(At(2026, 11, 1, 5, 30)));
+ Assert.Equal("2026-11-01 06:30:00", SystemEventRowFormat.StoredWallClock(At(2026, 11, 1, 6, 30)));
+ }
+ }
+
+ /* The Job History and Agent status grids were never converted: Lite shows the stored wall clock as it is. The
+ pins keep them bare if a later change routes them through the instant renderer. */
+ [Fact]
+ public void AJobStepAndANextRun_StoredAsTheWallClock_AreShownAsItIs_InTheRepeatedHour()
+ {
+ var step = new JobHistoryRow { RunDateTime = At(2026, 11, 1, 1, 30), LastSuccessfulRun = At(2026, 11, 1, 1, 45) };
+ var agent = new AgentStatusRow { NextScheduledRun = At(2026, 11, 1, 1, 30) };
+
+ Assert.Equal("2026-11-01 01:30:00", step.RunTimeLocal);
+ Assert.Equal("2026-11-01 01:45:00", step.LastSuccessfulRunLocal);
+ Assert.Equal("2026-11-01 01:30:00", agent.NextScheduledRunLocal);
+
+ Assert.Equal("2026-11-01 01:30:00 -05:00", TrueUtcRowText(At(2026, 11, 1, 6, 30)));
+ }
+}
diff --git a/Lite/Controls/AlertsHistoryTab.xaml b/Lite/Controls/AlertsHistoryTab.xaml
index b932ea4914..ef5dc6d29a 100644
--- a/Lite/Controls/AlertsHistoryTab.xaml
+++ b/Lite/Controls/AlertsHistoryTab.xaml
@@ -118,7 +118,7 @@
MouseDoubleClick="AlertsDataGrid_MouseDoubleClick"
RowStyle="{StaticResource AlertRowStyle}">
-
+
diff --git a/Lite/Controls/FinOpsTab.xaml b/Lite/Controls/FinOpsTab.xaml
index 9487efdec9..f46c146e70 100644
--- a/Lite/Controls/FinOpsTab.xaml
+++ b/Lite/Controls/FinOpsTab.xaml
@@ -2867,7 +2867,7 @@
-
+
@@ -2875,7 +2875,7 @@
-
+
diff --git a/Lite/Controls/FinOpsTab.xaml.cs b/Lite/Controls/FinOpsTab.xaml.cs
index 6a25059113..98eb60b19e 100644
--- a/Lite/Controls/FinOpsTab.xaml.cs
+++ b/Lite/Controls/FinOpsTab.xaml.cs
@@ -523,8 +523,18 @@ private async System.Threading.Tasks.Task LoadApplicationConnectionsAsync(int se
try
{
- var data = await Task.Run(() => _dataService.GetApplicationConnectionsAsync(serverId));
+ /* #4766: the rows read their times on the SELECTED server's clock (that server's own collected clock, else
+ its open tab's, else the machine's), as the PVS trend does, never the active tab's: this tab lists any
+ server, whichever tab is open. The open tab is asked here, on the UI thread, because the tabs are UI
+ objects. Both the column text and the sort values read the stamped clock. */
+ var openTab = _openTabClock.Invoke(serverId);
+ var dataService = _dataService;
+ var (data, collected) = await Task.Run(async () =>
+ (await dataService.GetApplicationConnectionsAsync(serverId),
+ await dataService.GetServerClockAsync(serverId)));
if (_loads.Superseded(nameof(LoadApplicationConnectionsAsync), gen)) return;
+ var clock = ServerTimeHelper.ClockForServer(collected, openTab);
+ foreach (var row in data) row.Clock = clock;
_appConnectionsFilterMgr!.UpdateData(data);
NoAppConnectionsMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
AppConnectionsCountIndicator.Text = data.Count > 0 ? $"{data.Count} application(s)" : "";
diff --git a/Lite/Controls/ServerTab.Charts.cs b/Lite/Controls/ServerTab.Charts.cs
index 20a0891a72..83911d0763 100644
--- a/Lite/Controls/ServerTab.Charts.cs
+++ b/Lite/Controls/ServerTab.Charts.cs
@@ -1095,7 +1095,8 @@ private void UpdateCurrentWaitsBlockedChart(List data,
/// Draws the window's baseline discontinuities on one Performance Trends chart (#3653 A5): a dashed
/// vertical line per marker at its instant, as every point here is placed (X is the UTC instant, #4766),
/// legend-named with the shared , which words the instant in
- /// the display zone the axis ticks use.
+ /// the display zone the axis ticks use and, in the repeated autumn hour, adds that instant's UTC offset so
+ /// the two markers a repeated hour holds read differently (#4766; the axis tick labels stay plain).
/// The identity-epoch carriers (#3694, #3705) forget this server's delta baselines when the target
/// restarts, fails over, is renamed or has its statistics reset; the four series here are all
/// delta-family rates, so across such an instant they show a step that is the instrument re-baselining,
@@ -1106,10 +1107,12 @@ private void UpdateCurrentWaitsBlockedChart(List data,
///
private void MarkDiscontinuities(ScottPlot.WPF.WpfPlot chart, IReadOnlyList discontinuities)
{
+ var zone = GetPickerZone();
foreach (var discontinuity in discontinuities)
{
- var shown = DisplayZone.ToDisplay(discontinuity.At, GetPickerZone());
- ChartStyle.AddDiscontinuityMarker(chart, discontinuity.At.ToOADate(), BaselineDiscontinuities.Sentence(shown, discontinuity.Reason));
+ var shown = DisplayZone.ToDisplay(discontinuity.At, zone);
+ ChartStyle.AddDiscontinuityMarker(chart, discontinuity.At.ToOADate(),
+ BaselineDiscontinuities.Sentence(shown, discontinuity.Reason, DisplayZone.AmbiguousOffsetSuffix(discontinuity.At, zone)));
}
}
diff --git a/Lite/Controls/ServerTab.Grids.cs b/Lite/Controls/ServerTab.Grids.cs
index 4b3f2b1fe9..935ebf2f8f 100644
--- a/Lite/Controls/ServerTab.Grids.cs
+++ b/Lite/Controls/ServerTab.Grids.cs
@@ -253,7 +253,7 @@ private void CollectionHealthGrid_MouseDoubleClick(object sender, System.Windows
{
if (CollectionHealthGrid.SelectedItem is not CollectorHealthRow item) return;
- var window = new Windows.CollectionLogWindow(_dataService, _serverId, item.CollectorName);
+ var window = new Windows.CollectionLogWindow(_dataService, _serverId, item.CollectorName, _serverClock);
window.Owner = Window.GetWindow(this);
window.ShowDialog();
}
diff --git a/Lite/Controls/ServerTab.Refresh.cs b/Lite/Controls/ServerTab.Refresh.cs
index 523c00a6c3..4ac2865046 100644
--- a/Lite/Controls/ServerTab.Refresh.cs
+++ b/Lite/Controls/ServerTab.Refresh.cs
@@ -821,6 +821,13 @@ private async System.Threading.Tasks.Task RefreshCollectionHealthAsync(int hours
await System.Threading.Tasks.Task.WhenAll(collectionHealthTask, collectionLogTask);
+ /* #4766: every row reads its time on THIS tab's server clock, not on whichever clock is active when the
+ grid renders. Both grids sit in this server's own tab, so the two are the same while the tab is showing,
+ and the stamped one also holds when the display mode flips or another tab is selected first. */
+ var tabClock = _serverClock;
+ foreach (var row in collectionHealthTask.Result) row.Clock = tabClock;
+ foreach (var row in collectionLogTask.Result) row.Clock = tabClock;
+
_collectionHealthFilterMgr!.UpdateData(collectionHealthTask.Result);
_collectionLogFilterMgr!.UpdateData(collectionLogTask.Result);
UpdateCollectorDurationChart(collectionLogTask.Result, hoursBack, fromDate, toDate);
diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml
index 568adb00f2..abdec9f46f 100644
--- a/Lite/Controls/ServerTab.xaml
+++ b/Lite/Controls/ServerTab.xaml
@@ -382,7 +382,7 @@
-
+
@@ -1031,10 +1031,10 @@
-
+
-
+
@@ -1309,7 +1309,7 @@
HeadersVisibility="Column" GridLinesVisibility="Horizontal"
CanUserSortColumns="True">
-
+
@@ -1369,10 +1369,10 @@
-
+
-
+
@@ -1384,13 +1384,13 @@
-
+
-
+
@@ -1731,7 +1731,7 @@
MouseDoubleClick="BlockedProcessReportGrid_MouseDoubleClick"
Sorting="BlockedProcessReportGrid_Sorting">
-
+
@@ -1841,7 +1841,7 @@
HeadersVisibility="Column" GridLinesVisibility="Horizontal"
HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
-
+
@@ -2283,7 +2283,7 @@
-
+
@@ -2557,13 +2557,13 @@
-
+
-
+
-
+
@@ -2590,7 +2590,7 @@
RowStyle="{StaticResource GridRowStyle}"
HeadersVisibility="Column" GridLinesVisibility="Horizontal">
-
+
@@ -2685,7 +2685,7 @@
-
+
@@ -2711,7 +2711,7 @@
-
+
@@ -2740,7 +2740,7 @@
-
+
@@ -2791,7 +2791,7 @@
-
+
@@ -2823,7 +2823,7 @@
-
+
@@ -2870,7 +2870,7 @@
-
+
@@ -2899,7 +2899,7 @@
-
+
@@ -2929,7 +2929,7 @@
-
+
@@ -3003,7 +3003,7 @@
-
+
@@ -3032,7 +3032,7 @@
-
+
@@ -3057,7 +3057,7 @@
-
+
diff --git a/Lite/Helpers/ContextMenuHelper.cs b/Lite/Helpers/ContextMenuHelper.cs
index 1573205292..08191388a5 100644
--- a/Lite/Helpers/ContextMenuHelper.cs
+++ b/Lite/Helpers/ContextMenuHelper.cs
@@ -170,22 +170,30 @@ private static string CsvEscape(string value, string separator)
/// The time text of a chart CSV export line, the format the export has always used.
private const string ChartCsvTimeFormat = "yyyy-MM-dd HH:mm:ss";
+ /// The name of the last column of a chart CSV export, the UTC offset of each row's instant.
+ private const string ChartCsvOffsetColumn = "UTC offset";
+
///
/// The header line of a chart's CSV export (#4766). The time column names the zone its times are written in
/// ("DateTime (UTC)", "DateTime (Eastern Standard Time)"), so a file opened later still says which clock they are
- /// on. Pure, so a test calls it without the WPF handler.
+ /// on. The last column, "UTC offset", holds the offset each row's instant carries in that zone, so the two rows
+ /// that read the same wall time in the repeated hour of a fall-back day can still be told apart. Pure, so a test
+ /// calls it without the WPF handler.
///
internal static string ChartCsvHeader(string separator, TimeZoneInfo displayZone)
{
var time = $"DateTime ({displayZone.Id})";
- return string.Join(separator, new[] { CsvEscape(time, separator), "Series", "Value" });
+ return string.Join(separator, new[] { CsvEscape(time, separator), "Series", "Value", ChartCsvOffsetColumn });
}
///
/// One data line of a chart's CSV export (#4766). A ServerTab chart plots the naive-UTC instant as X, so the
- /// time column is that instant read in the zone (): the same text the chart's
- /// axis shows for it, and a point in the repeated hour of a fall-back day is written as the wall time it reads
- /// there. Pure, so a test calls it without the WPF handler.
+ /// time column is that instant read in the zone (): the same wall time the
+ /// chart's axis shows for it, always a plain date and time so a spreadsheet reads the whole column one way. A
+ /// point in the repeated hour of a fall-back day is written as the wall time it reads there, and the last cell,
+ /// on every row, is that instant's UTC offset (), "-04:00" for the first
+ /// 01:30 and "-05:00" for the second on a US Eastern change day ("+00:00" in UTC). Pure, so a test calls it
+ /// without the WPF handler.
///
internal static string ChartCsvLine(double x, string seriesName, double y, string separator, TimeZoneInfo displayZone)
{
@@ -195,7 +203,8 @@ internal static string ChartCsvLine(double x, string seriesName, double y, strin
{
shown.ToString(ChartCsvTimeFormat, CultureInfo.InvariantCulture),
CsvEscape(seriesName, separator),
- y.ToString(CultureInfo.InvariantCulture)
+ y.ToString(CultureInfo.InvariantCulture),
+ DisplayZone.UtcOffsetText(plotted, displayZone)
});
}
diff --git a/Lite/Services/LocalDataService.AlertHistory.cs b/Lite/Services/LocalDataService.AlertHistory.cs
index bb2e98cc78..fc7b5e75e2 100644
--- a/Lite/Services/LocalDataService.AlertHistory.cs
+++ b/Lite/Services/LocalDataService.AlertHistory.cs
@@ -14,7 +14,6 @@
using PerformanceMonitor.Analysis.Baselines;
using PerformanceMonitor.Common;
using PerformanceMonitor.Notifications;
-using PerformanceMonitor.Ui;
using PerformanceMonitorLite.Database;
namespace PerformanceMonitorLite.Services;
@@ -523,11 +522,14 @@ public class AlertHistoryRow
///
/// (naive UTC) in the selected display mode (#4766): UTC as stored, this machine's zone, or
/// the row's server's own clock. This used to be the machine's local time in every mode, so a grid in UTC or Server
- /// mode showed local times under a header the rest of the app read in the chosen mode.
+ /// mode showed local times under a header the rest of the app read in the chosen mode. An alert in the repeated
+ /// hour after a fall-back reads the same wall time in both of its occurrences, so it takes a space and its UTC
+ /// offset ("2026-11-01 01:30:00 -05:00"), as does
+ /// for the other grids.
///
- public string TimeLocal => DisplayZone.ToDisplay(
- AlertTime, ServerTimeHelper.DisplayZoneFor(ServerTimeHelper.CurrentDisplayMode, Clock ?? ServerTimeHelper.ActiveServerClock))
- .ToString("yyyy-MM-dd HH:mm:ss");
+ public string TimeLocal => ServerTimeHelper.FormatInstant(
+ AlertTime, ServerTimeHelper.DisplayZoneFor(ServerTimeHelper.CurrentDisplayMode, Clock ?? ServerTimeHelper.ActiveServerClock),
+ "yyyy-MM-dd HH:mm:ss");
public string CurrentValueDisplay => AlertMetricClassifier.FormatHistoryValue(MetricName, CurrentValue);
public string ThresholdValueDisplay => AlertMetricClassifier.FormatHistoryValue(MetricName, ThresholdValue);
diff --git a/Lite/Services/LocalDataService.CollectionHealth.cs b/Lite/Services/LocalDataService.CollectionHealth.cs
index 5778fdd17a..67bd05c16e 100644
--- a/Lite/Services/LocalDataService.CollectionHealth.cs
+++ b/Lite/Services/LocalDataService.CollectionHealth.cs
@@ -10,6 +10,7 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using DuckDB.NET.Data;
+using PerformanceMonitor.Analysis.Baselines;
using PerformanceMonitor.Collectors;
using PerformanceMonitor.Common;
@@ -669,6 +670,19 @@ FROM v_collection_log
}
}
+///
+/// The one place the Collection Health rows word a collector-written UTC instant (#4766): "g" in the zone the display
+/// mode names for the row's own server (, else the active tab's), then a space and the UTC
+/// offset when that wall time is one of the two of a repeated autumn hour
+/// ().
+///
+internal static class CollectionHealthTime
+{
+ internal static string Format(DateTime utc, ServerClock? rowClock) =>
+ ServerTimeHelper.FormatInstant(
+ utc, ServerTimeHelper.DisplayZoneFor(ServerTimeHelper.CurrentDisplayMode, rowClock ?? ServerTimeHelper.ActiveServerClock), "g");
+}
+
public class CollectionLogRow
{
public string CollectorName { get; set; } = "";
@@ -681,7 +695,21 @@ public class CollectionLogRow
public string Status { get; set; } = "";
public string? ErrorMessage { get; set; }
- public string CollectionTimeFormatted => CollectionTime.ToLocalTime().ToString("g");
+ ///
+ /// The clock of the server this run belongs to (#4766), stamped by the tab or window that lists it; null (a row
+ /// built without one, such as the fleet-wide log the MCP tool serializes on its own) falls back to the active
+ /// tab's. The Collection Health grids sit in a server's own tab, and the run-history window is opened from it, but
+ /// a row that carries its clock reads its own server's wall time whichever tab is active when it renders.
+ ///
+ public ServerClock? Clock { get; set; }
+
+ ///
+ /// (a collector-written UTC instant) in the selected display mode (#4766): UTC as stored,
+ /// this machine's zone, or the row's server's own clock, with a space and its UTC offset when the wall time is one
+ /// of the two of a repeated autumn hour ("11/1/2026 1:30 AM -05:00" in a US locale; the "g" pattern is the
+ /// current culture's). This used to be the machine's local time in every mode.
+ ///
+ public string CollectionTimeFormatted => CollectionHealthTime.Format(CollectionTime, Clock);
public string DurationFormatted => DurationMs.HasValue
? (DurationMs.Value < 1000 ? $"{DurationMs.Value} ms" : $"{DurationMs.Value / 1000.0:F1} s")
@@ -1033,16 +1061,28 @@ hazard exactly. */
? $"{AvgDurationMs:F0} ms"
: $"{AvgDurationMs / 1000:F1} s";
+ ///
+ /// The clock of the server this row belongs to (#4766), stamped by the tab that lists it; null (a row built without
+ /// one) falls back to the active tab's. The three time columns below read on it, so a row shows its own server's
+ /// wall time in Server mode whichever tab is active when it renders.
+ ///
+ public ServerClock? Clock { get; set; }
+
+ ///
+ /// The last success, run and error (collector-written UTC instants) in the selected display mode (#4766): UTC as
+ /// stored, this machine's zone, or the row's server's own clock, with a space and the UTC offset when the wall
+ /// time is one of the two of a repeated autumn hour. They used to be the machine's local time in every mode.
+ ///
public string LastSuccessFormatted => LastSuccessTime.HasValue
- ? LastSuccessTime.Value.ToLocalTime().ToString("g")
+ ? CollectionHealthTime.Format(LastSuccessTime.Value, Clock)
: "Never";
public string LastRunFormatted => LastRunTime.HasValue
- ? LastRunTime.Value.ToLocalTime().ToString("g")
+ ? CollectionHealthTime.Format(LastRunTime.Value, Clock)
: "Never";
public string LastErrorFormatted => LastErrorTime.HasValue
- ? LastErrorTime.Value.ToLocalTime().ToString("g")
+ ? CollectionHealthTime.Format(LastErrorTime.Value, Clock)
: "";
///
diff --git a/Lite/Services/LocalDataService.FinOps.cs b/Lite/Services/LocalDataService.FinOps.cs
index 3eccc8904e..0ad4a66313 100644
--- a/Lite/Services/LocalDataService.FinOps.cs
+++ b/Lite/Services/LocalDataService.FinOps.cs
@@ -9,6 +9,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using PerformanceMonitor.Analysis.Baselines;
+using PerformanceMonitor.Ui;
namespace PerformanceMonitorLite.Services;
@@ -144,8 +146,41 @@ public class ApplicationConnectionRow
public long SampleCount { get; set; }
public DateTime FirstSeen { get; set; }
public DateTime LastSeen { get; set; }
- public DateTime FirstSeenLocal => FirstSeen.ToLocalTime();
- public DateTime LastSeenLocal => LastSeen.ToLocalTime();
+
+ ///
+ /// The clock of the SELECTED server, stamped when the FinOps tab loads the rows (#4766): its own collected clock, else
+ /// its open tab's, else the machine's (), the
+ /// same rule the PVS trend beside it uses. The FinOps tab lists whichever server its picker names, not the server of
+ /// the tab that is active, so these times read that server's wall time in Server mode. Null (a row built without
+ /// one) falls back to the active tab's clock.
+ ///
+ public ServerClock? Clock { get; set; }
+
+ ///
+ /// (naive UTC) on the clock of the selected display mode (#4766): UTC as stored, this machine's
+ /// zone, or the selected server's own clock (), the zone words it in.
+ /// This used to be ToLocalTime() in every mode, and then the active tab's clock, so Server and UTC mode showed
+ /// another zone's time under a header every other grid read in the chosen mode. The First Seen column shows
+ /// and sorts by this.
+ ///
+ public DateTime FirstSeenLocal => DisplayZone.ToDisplay(FirstSeen, DisplayZoneNow);
+
+ /// on the same clock as ; the Last Seen column sorts by it.
+ public DateTime LastSeenLocal => DisplayZone.ToDisplay(LastSeen, DisplayZoneNow);
+
+ ///
+ /// What the First Seen column shows (#4766): as
+ /// words it on the selected server's clock, so in the repeated autumn hour it carries its UTC offset
+ /// ("2026-11-01 01:30 -04:00"). A DateTime bound with a XAML StringFormat could not, because it cannot say
+ /// which of the two 01:30s it was.
+ ///
+ public string FirstSeenText => ServerTimeHelper.FormatInstant(FirstSeen, DisplayZoneNow, "yyyy-MM-dd HH:mm");
+
+ /// What the Last Seen column shows (#4766); see .
+ public string LastSeenText => ServerTimeHelper.FormatInstant(LastSeen, DisplayZoneNow, "yyyy-MM-dd HH:mm");
+
+ private TimeZoneInfo DisplayZoneNow =>
+ ServerTimeHelper.DisplayZoneFor(ServerTimeHelper.CurrentDisplayMode, Clock ?? ServerTimeHelper.ActiveServerClock);
}
public class DatabaseSizeRow
diff --git a/Lite/Services/LocalDataService.SystemEvents.cs b/Lite/Services/LocalDataService.SystemEvents.cs
index f75e8b8eb5..88eebc7ac0 100644
--- a/Lite/Services/LocalDataService.SystemEvents.cs
+++ b/Lite/Services/LocalDataService.SystemEvents.cs
@@ -12,6 +12,7 @@
using DuckDB.NET.Data;
using PerformanceMonitor.Analysis.Baselines;
using PerformanceMonitor.Common;
+using PerformanceMonitor.Ui;
namespace PerformanceMonitorLite.Services;
@@ -40,14 +41,30 @@ namespace PerformanceMonitorLite.Services;
* pre-filter. It converts each row's event_time to naive-UTC with the clock at the row's own date and keeps
* only the rows inside the exact UTC window, all BEFORE the row VM, so a Default Trace row and a
* system_health row from the same instant render the same wall-clock time and a merged System Events
- * timeline sorts consistently.
+ * timeline sorts consistently. The one difference is the repeated autumn hour (#4766): a system_health row
+ * is a real instant and carries its UTC offset there, but a Default Trace row is a stored wall clock that
+ * cannot say which pass it was in, so it renders the bare wall time (SystemEventRowFormat.StoredWallClock).
*/
/// Shared render of a naive-UTC event timestamp for the System Events grids — the same
/// ServerTimeHelper.FormatServerTime the deadlock / blocked-process grids use (empty for a null time).
internal static class SystemEventRowFormat
{
+ /// A REAL instant (the system_health XE @timestamp is UTC): the text
+ /// words, so the two passes of the repeated autumn
+ /// hour differ by their UTC offsets.
public static string Local(DateTime? utc) => ServerTimeHelper.FormatServerTime(utc, "yyyy-MM-dd HH:mm:ss");
+
+ ///
+ /// A time converted from a STORED server wall clock (the Default Trace StartTime), as the plain wall time in
+ /// the same zone uses (#4766). maps both passes of a repeated
+ /// local hour to the first, so the instant cannot say which pass the event was in; appending the offset would print
+ /// the first pass's for an event that ran in the second. Every other time reads as does.
+ ///
+ public static string StoredWallClock(DateTime? utc) => utc.HasValue
+ ? DisplayZone.ToDisplay(utc.Value, ServerTimeHelper.DisplayZoneFor(ServerTimeHelper.CurrentDisplayMode, ServerTimeHelper.ActiveServerClock))
+ .ToString("yyyy-MM-dd HH:mm:ss")
+ : "";
}
/// One scheduler-monitor utilization sample (Scheduler Issues sub-tab), flagged the way sp_HealthParser flags this section: SQL CPU pinned, other-process CPU high, or memory utilization low.
@@ -228,8 +245,10 @@ public sealed class IoIssuesRow(IoIssuesRecord record)
/// Memory Change), classified via the shared . Unlike
/// the system_health rows (whose event_time is the UTC XE @timestamp), the Default Trace StartTime is the
/// monitored server's LOCAL wall clock, so de-skews
-/// it to naive-UTC BEFORE this row, so renders through the same
-/// as every other System Events grid and sorts consistently.
+/// it to naive-UTC BEFORE this row, so sorts consistently with every other System Events
+/// grid. It renders through , not :
+/// the wall time is stored, so an event in the repeated autumn hour cannot say which pass it was in and never takes an
+/// offset (#4766).
///
public sealed class DefaultTraceEventRow
{
@@ -250,7 +269,7 @@ public DefaultTraceEventRow(
string? textData)
{
EventTimeUtc = eventTimeUtc;
- EventTimeLocal = SystemEventRowFormat.Local(eventTimeUtc);
+ EventTimeLocal = SystemEventRowFormat.StoredWallClock(eventTimeUtc);
Category = category.ToString();
EventName = eventName;
DatabaseName = databaseName;
diff --git a/Lite/Services/ServerTimeHelper.cs b/Lite/Services/ServerTimeHelper.cs
index e68b5f50a0..eea988508c 100644
--- a/Lite/Services/ServerTimeHelper.cs
+++ b/Lite/Services/ServerTimeHelper.cs
@@ -160,17 +160,33 @@ private static string OffsetLabel(int utcOffsetMinutes)
/// clock, so UTC mode shows the stored instant itself and a time in the hour that repeats after a fall-back reads
/// the wall time of its own offset in every mode. The path used to go to the server's wall clock first and back
/// out of it, and a wall clock cannot say which occurrence of the repeated hour it was: 06:30Z on 2026-11-01 on a
- /// US Eastern server read 05:30 in UTC mode. Use instead for a value that is
- /// already the server's clock.
+ /// US Eastern server read 05:30 in UTC mode. A time in the repeated hour itself reads the same in both of its
+ /// occurrences, so it takes a space and its UTC offset (): "2026-11-01 01:30:00 -04:00"
+ /// for 05:30Z and "2026-11-01 01:30:00 -05:00" for 06:30Z on a US Eastern clock. Every other time, and every
+ /// time in UTC mode, is the bare text. Use instead for a value that is already
+ /// the server's clock.
///
public static string FormatServerTime(DateTime utcTime, string format = "yyyy-MM-dd HH:mm:ss")
- => DisplayZone.ToDisplay(utcTime, DisplayZoneFor(CurrentDisplayMode, _serverClock)).ToString(format);
+ => FormatInstant(utcTime, DisplayZoneFor(CurrentDisplayMode, _serverClock), format);
///
public static string FormatServerTime(DateTime? utcTime, string format = "yyyy-MM-dd HH:mm:ss")
- => utcTime.HasValue
- ? DisplayZone.ToDisplay(utcTime.Value, DisplayZoneFor(CurrentDisplayMode, _serverClock)).ToString(format)
- : "";
+ => utcTime.HasValue ? FormatServerTime(utcTime.Value, format) : "";
+
+ ///
+ /// The instant (naive UTC) as applied to its wall clock in
+ /// (#4766), then a space and when that
+ /// wall time happens twice there, so the two occurrences of a repeated hour read differently in a grid. The
+ /// format is applied with the current culture, as the grids have always worded their times; the invariant-culture
+ /// is for the text a chart or a window draws. The one place a grid's time text
+ /// takes the suffix, so a row class that words its own time (an alert row) calls this instead of repeating the rule.
+ ///
+ internal static string FormatInstant(DateTime utcTime, TimeZoneInfo zone, string format)
+ {
+ var text = DisplayZone.ToDisplay(utcTime, zone).ToString(format);
+ var suffix = DisplayZone.AmbiguousOffsetSuffix(utcTime, zone);
+ return suffix is null ? text : text + " " + suffix;
+ }
///
/// Formats a timestamp that is ALREADY the monitored server's own wall clock: the
diff --git a/Lite/Windows/CollectionLogWindow.xaml b/Lite/Windows/CollectionLogWindow.xaml
index 78d63be91d..db8d905569 100644
--- a/Lite/Windows/CollectionLogWindow.xaml
+++ b/Lite/Windows/CollectionLogWindow.xaml
@@ -50,7 +50,7 @@
Margin="10"
ContextMenu="{StaticResource DataGridContextMenu}">
-
+
diff --git a/Lite/Windows/CollectionLogWindow.xaml.cs b/Lite/Windows/CollectionLogWindow.xaml.cs
index 56dc819f90..c13a182897 100644
--- a/Lite/Windows/CollectionLogWindow.xaml.cs
+++ b/Lite/Windows/CollectionLogWindow.xaml.cs
@@ -10,6 +10,7 @@
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
+using PerformanceMonitor.Analysis.Baselines;
using PerformanceMonitorLite.Services;
using PerformanceMonitor.Ui;
@@ -20,14 +21,18 @@ public partial class CollectionLogWindow : Window
private readonly string _collectorName;
private readonly LocalDataService _dataService;
private readonly int _serverId;
+ private readonly ServerClock? _serverClock;
- public CollectionLogWindow(LocalDataService dataService, int serverId, string collectorName)
+ /// The clock of the server whose history this is (the tab it was opened from), so its
+ /// run times read that server's wall time in Server mode (#4766). Null falls back to the active tab's.
+ public CollectionLogWindow(LocalDataService dataService, int serverId, string collectorName, ServerClock? serverClock = null)
{
InitializeComponent();
_dataService = dataService;
_serverId = serverId;
_collectorName = collectorName;
+ _serverClock = serverClock;
CollectorNameText.Text = $"Collection History: {collectorName}";
@@ -44,6 +49,7 @@ private async Task LoadCollectionLogAsync()
try
{
var logs = await _dataService.GetCollectionLogByCollectorAsync(_serverId, _collectorName);
+ foreach (var log in logs) log.Clock = _serverClock;
LogDataGrid.ItemsSource = logs;
if (logs.Count > 0)
diff --git a/Lite/Windows/ProcedureHistoryWindow.xaml b/Lite/Windows/ProcedureHistoryWindow.xaml
index eefb28ae77..40d4724288 100644
--- a/Lite/Windows/ProcedureHistoryWindow.xaml
+++ b/Lite/Windows/ProcedureHistoryWindow.xaml
@@ -75,7 +75,7 @@
ContextMenu="{StaticResource DataGridContextMenu}">
-
+
diff --git a/Lite/Windows/ProcedureHistoryWindow.xaml.cs b/Lite/Windows/ProcedureHistoryWindow.xaml.cs
index 6eb2f94d8e..42d0dbaff1 100644
--- a/Lite/Windows/ProcedureHistoryWindow.xaml.cs
+++ b/Lite/Windows/ProcedureHistoryWindow.xaml.cs
@@ -99,9 +99,10 @@ private async System.Threading.Tasks.Task LoadHistoryAsync()
{
var totalExec = _historyData.Sum(r => r.DeltaExecutions);
var totalCpu = _historyData.Sum(r => r.DeltaCpuMs);
- var first = DisplayZone.ToDisplay(_historyData.First().CollectionTime, _displayZone());
- var last = DisplayZone.ToDisplay(_historyData.Last().CollectionTime, _displayZone());
- SummaryText.Text = $"{_historyData.Count} samples from {first:MM/dd HH:mm} to {last:MM/dd HH:mm} | " +
+ var zone = _displayZone();
+ var first = ServerTimeHelper.FormatInstant(_historyData.First().CollectionTime, zone, "MM/dd HH:mm");
+ var last = ServerTimeHelper.FormatInstant(_historyData.Last().CollectionTime, zone, "MM/dd HH:mm");
+ SummaryText.Text = $"{_historyData.Count} samples from {first} to {last} | " +
$"Total Executions: {totalExec:N0} | Total CPU: {totalCpu:N1} ms";
}
else
diff --git a/Lite/Windows/QueryStatsHistoryWindow.xaml b/Lite/Windows/QueryStatsHistoryWindow.xaml
index 1bb354841e..9ffb03a754 100644
--- a/Lite/Windows/QueryStatsHistoryWindow.xaml
+++ b/Lite/Windows/QueryStatsHistoryWindow.xaml
@@ -78,7 +78,7 @@
ContextMenu="{StaticResource DataGridContextMenu}">
-
+
diff --git a/Lite/Windows/QueryStatsHistoryWindow.xaml.cs b/Lite/Windows/QueryStatsHistoryWindow.xaml.cs
index 500e8b9edd..74f6d5ee39 100644
--- a/Lite/Windows/QueryStatsHistoryWindow.xaml.cs
+++ b/Lite/Windows/QueryStatsHistoryWindow.xaml.cs
@@ -98,9 +98,10 @@ private async System.Threading.Tasks.Task LoadHistoryAsync()
{
var totalExec = _historyData.Sum(r => r.DeltaExecutions);
var totalCpu = _historyData.Sum(r => r.DeltaCpuMs);
- var first = DisplayZone.ToDisplay(_historyData.First().CollectionTime, _displayZone());
- var last = DisplayZone.ToDisplay(_historyData.Last().CollectionTime, _displayZone());
- SummaryText.Text = $"{_historyData.Count} samples from {first:MM/dd HH:mm} to {last:MM/dd HH:mm} | " +
+ var zone = _displayZone();
+ var first = ServerTimeHelper.FormatInstant(_historyData.First().CollectionTime, zone, "MM/dd HH:mm");
+ var last = ServerTimeHelper.FormatInstant(_historyData.Last().CollectionTime, zone, "MM/dd HH:mm");
+ SummaryText.Text = $"{_historyData.Count} samples from {first} to {last} | " +
$"Total Executions: {totalExec:N0} | Total CPU: {totalCpu:N1} ms";
}
else
diff --git a/Lite/Windows/QueryStoreHistoryWindow.xaml b/Lite/Windows/QueryStoreHistoryWindow.xaml
index ab37b2beae..0e782a1283 100644
--- a/Lite/Windows/QueryStoreHistoryWindow.xaml
+++ b/Lite/Windows/QueryStoreHistoryWindow.xaml
@@ -78,11 +78,11 @@
ContextMenu="{StaticResource DataGridContextMenu}">
-
+
-
-
+
+
diff --git a/Lite/Windows/QueryStoreHistoryWindow.xaml.cs b/Lite/Windows/QueryStoreHistoryWindow.xaml.cs
index 413c679c03..945476a2df 100644
--- a/Lite/Windows/QueryStoreHistoryWindow.xaml.cs
+++ b/Lite/Windows/QueryStoreHistoryWindow.xaml.cs
@@ -97,9 +97,10 @@ private async System.Threading.Tasks.Task LoadHistoryAsync()
{
var totalExec = _historyData.Sum(r => r.ExecutionCount);
var planCount = _historyData.Select(r => r.PlanId).Distinct().Count();
- var first = DisplayZone.ToDisplay(_historyData.First().CollectionTime, _displayZone());
- var last = DisplayZone.ToDisplay(_historyData.Last().CollectionTime, _displayZone());
- SummaryText.Text = $"{_historyData.Count} samples from {first:MM/dd HH:mm} to {last:MM/dd HH:mm} | " +
+ var zone = _displayZone();
+ var first = ServerTimeHelper.FormatInstant(_historyData.First().CollectionTime, zone, "MM/dd HH:mm");
+ var last = ServerTimeHelper.FormatInstant(_historyData.Last().CollectionTime, zone, "MM/dd HH:mm");
+ SummaryText.Text = $"{_historyData.Count} samples from {first} to {last} | " +
$"Total Executions: {totalExec:N0} | " +
(planCount > 1 ? $"{planCount} different plans" : "Single plan");
}
diff --git a/Lite/Windows/WaitDrillDownWindow.xaml b/Lite/Windows/WaitDrillDownWindow.xaml
index 8f563fdc3f..9aba89c1fc 100644
--- a/Lite/Windows/WaitDrillDownWindow.xaml
+++ b/Lite/Windows/WaitDrillDownWindow.xaml
@@ -69,7 +69,7 @@
-
+
diff --git a/PerformanceMonitor.Collectors/BaselineDiscontinuity.cs b/PerformanceMonitor.Collectors/BaselineDiscontinuity.cs
index 931e371d3c..2e87bfb9a8 100644
--- a/PerformanceMonitor.Collectors/BaselineDiscontinuity.cs
+++ b/PerformanceMonitor.Collectors/BaselineDiscontinuity.cs
@@ -186,10 +186,15 @@ public static class BaselineDiscontinuities
/// The rendered sentence, one spelling for the two desktop viewers and the web page:
/// baseline discontinuity at 2026-09-20 03:12 (restart). is the instant
/// ALREADY converted to the surface's display clock — this method does not know which clock a surface shows
- /// and must not guess.
+ /// and must not guess. is the caller's UTC offset for that instant when its
+ /// wall time happens twice in the display zone (the repeated autumn hour; DisplayZone.AmbiguousOffsetSuffix
+ /// answers it, #4766): it follows the time after one space, baseline discontinuity at 2026-11-01 01:30 -04:00
+ /// (restart), so the two markers a repeated hour holds read differently. null (the default) leaves
+ /// the sentence exactly as it was; this method takes the text and does not decide when to add it.
///
- public static string Sentence(DateTime displayAt, string reason)
- => "baseline discontinuity at " + displayAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) + " (" + reason + ")";
+ public static string Sentence(DateTime displayAt, string reason, string? offsetSuffix = null)
+ => "baseline discontinuity at " + displayAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)
+ + (offsetSuffix is null ? "" : " " + offsetSuffix) + " (" + reason + ")";
/// The payload projection — per item, oldest first, the store's frame with no zone suffix.
public static IReadOnlyList ToPayload(IReadOnlyList items)
diff --git a/PerformanceMonitor.Ui/DataGridFilterManager.cs b/PerformanceMonitor.Ui/DataGridFilterManager.cs
index f690cb03ec..43df110c72 100644
--- a/PerformanceMonitor.Ui/DataGridFilterManager.cs
+++ b/PerformanceMonitor.Ui/DataGridFilterManager.cs
@@ -146,7 +146,11 @@ private void SetItemsSourcePreservingSort(System.Collections.IEnumerable? newSou
if (column is DataGridBoundColumn bc &&
bc.Binding is Binding b)
{
- var match = savedSorts.FirstOrDefault(s => s.PropertyName == b.Path.Path);
+ /* A column that sorts by another member than the one it shows (a time column bound to its text and
+ sorted by its DateTime, #4766) carries that member in SortMemberPath, which is also what a header click
+ put in the saved sort. The binding path is only the column's sort member when none is set. */
+ var sortPath = string.IsNullOrEmpty(column.SortMemberPath) ? b.Path.Path : column.SortMemberPath;
+ var match = savedSorts.FirstOrDefault(s => s.PropertyName == sortPath);
column.SortDirection = match.PropertyName != null ? match.Direction : null;
}
}
diff --git a/PerformanceMonitor.Ui/DisplayZone.cs b/PerformanceMonitor.Ui/DisplayZone.cs
index 36a7b77dd2..5a8e351e36 100644
--- a/PerformanceMonitor.Ui/DisplayZone.cs
+++ b/PerformanceMonitor.Ui/DisplayZone.cs
@@ -78,6 +78,14 @@ public static DateTime ToDisplay(DateTime naiveUtc, TimeZoneInfo zone)
return zone.IsAmbiguousTime(wall) ? FormatOffset(OffsetAt(naiveUtc, zone)) : null;
}
+ ///
+ /// The UTC offset of the instant in as "+hh:mm" or "-hh:mm",
+ /// for every instant, ambiguous or not ("+00:00" in UTC). A chart CSV export writes it in its own column, so each
+ /// row names its instant exactly while the time column stays a plain date and time.
+ ///
+ public static string UtcOffsetText(DateTime naiveUtc, TimeZoneInfo zone)
+ => FormatOffset(OffsetAt(naiveUtc, zone));
+
///
/// The instant as text in : applied
/// to with the invariant culture, then a space and