From 5613a5efb785938393a82bdbb248f1972dee61b0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 17 Jun 2026 20:06:31 -0400 Subject: [PATCH] =?UTF-8?q?feat(kpi):=20K12=20=E2=80=94=20reusable=20KpiTr?= =?UTF-8?q?endChart=20SVG=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/Shared/KpiTrendChart.razor | 64 ++++++ .../Components/Shared/KpiTrendChart.razor.cs | 209 ++++++++++++++++++ .../Components/Shared/KpiTrendChartTests.cs | 139 ++++++++++++ 3 files changed, 412 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/KpiTrendChart.razor create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/KpiTrendChart.razor.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Shared/KpiTrendChartTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/KpiTrendChart.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/KpiTrendChart.razor new file mode 100644 index 00000000..cda351cb --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/KpiTrendChart.razor @@ -0,0 +1,64 @@ +@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared + +@* M6 (K12) reusable KPI trend chart. Dependency-free inline SVG inside a + Bootstrap card, styled to sit beside the AuditKpiTiles / SiteCallKpiTiles + KPI cards (card + card-body, small text-muted labels, muted single stroke). + Three states: a ≥2-point polyline chart, a single-sample note, and an + em-dash placeholder for the unavailable / empty case. The coordinate math + and number formatting live in the code-behind (InvariantCulture throughout). *@ + +
+
+
@Title
+ + @if (HasChart) + { + + @Title trend + + @* Baseline (x-axis) at value 0. *@ + + + @* The series itself — a single muted stroke, no fill. *@ + + + + @* Value (left) + time (full width) labels beneath the chart. *@ +
+ max @MaxValueLabel + min @BaselineLabel +
+
+ @StartTimeLabel + @EndTimeLabel +
+ } + else if (HasSingleSample) + { +
+
@MaxValueLabel
+
Only one sample in range.
+
+ } + else + { + @* Unavailable OR null/empty — em-dash placeholder, never an SVG. *@ +
+
+ @if (!string.IsNullOrEmpty(ErrorMessage)) + { +
@ErrorMessage
+ } +
+ } +
+
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/KpiTrendChart.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/KpiTrendChart.razor.cs new file mode 100644 index 00000000..27f2a2cb --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/KpiTrendChart.razor.cs @@ -0,0 +1,209 @@ +using System.Globalization; +using System.Text; +using Microsoft.AspNetCore.Components; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; + +/// +/// Reusable dependency-free SVG trend chart for M6 "KPI History & Trends" +/// (Task K12). Renders a series as a single muted +/// polyline inside a Bootstrap card, with a baseline (x-axis) and value/time +/// labels. No third-party charting library — the project rule is custom Blazor +/// components on Bootstrap CSS only. +/// +/// +/// +/// Purely presentational. The component never fetches data; the parent +/// page supplies a fresh list and an +/// flag, mirroring how AuditKpiTiles and +/// SiteCallKpiTiles consume a snapshot. The same card chrome and +/// small text-muted label styling keep the chart visually consistent +/// with the KPI tiles it sits beside. +/// +/// +/// Coordinate math. X is normalised across the time range +/// [min(BucketStartUtc), max(BucketStartUtc)]; Y is normalised across +/// [0, max(Value)] with the baseline pinned at 0. Every guard that could +/// otherwise divide by zero is explicit: an empty / single-point series never +/// reaches the polyline math, an all-equal-timestamp series falls back to even +/// horizontal spacing, and an all-zero (or single-distinct-value) series draws a +/// flat line at the baseline. All coordinate doubles are formatted with +/// so locales with comma decimals do +/// not corrupt the SVG points attribute. +/// +/// +public partial class KpiTrendChart +{ + // The fixed user-space coordinate system the polyline is computed in. The + // SVG is rendered with width="100%" + preserveAspectRatio="none" so it + // scales responsively to its container while the math stays integer-clean. + internal const int ViewBoxWidth = 600; + internal const int ViewBoxHeight = 140; + + // Inner plot inset so the polyline + baseline never touch the card edge and + // the top sample is not clipped by the 1px stroke. + private const double PadX = 4; + private const double PadTop = 6; + private const double PadBottom = 6; + + /// + /// The series to plot, assumed ordered ascending by + /// . null or empty renders + /// the unavailable placeholder rather than an empty chart. + /// + [Parameter] public IReadOnlyList? Points { get; set; } + + /// Card title — also used for the SVG <title> and the + /// data-test slug. Required-ish; defaults to "Trend" if blank. + [Parameter] public string Title { get; set; } = "Trend"; + + /// Optional unit suffix appended to value labels (e.g. "s"). + [Parameter] public string? Unit { get; set; } + + /// + /// True when the supplied series is a successful query result. False renders + /// the em-dash placeholder (no SVG), mirroring the KPI tiles' unavailable + /// state. + /// + [Parameter] public bool IsAvailable { get; set; } = true; + + /// Optional message rendered under the placeholder when + /// is false. + [Parameter] public string? ErrorMessage { get; set; } + + // ── Derived state ──────────────────────────────────────────────────────── + + private IReadOnlyList Series => Points ?? Array.Empty(); + + /// A drawable chart needs to be available and have ≥2 points. + private bool HasChart => IsAvailable && Series.Count >= 2; + + /// Exactly one sample — show the single-sample note, not a polyline. + private bool HasSingleSample => IsAvailable && Series.Count == 1; + + /// Available + ≥1 point but not a full chart, or unavailable / empty. + private bool ShowPlaceholder => !IsAvailable || Series.Count == 0; + + /// + /// Stable Playwright hook: kpi-trend-<slug> where the slug is the + /// title lowercased with each run of non-alphanumerics collapsed to a single + /// dash and trimmed. Empty / symbol-only titles fall back to "chart". + /// + private string DataTest => $"kpi-trend-{Slugify(Title)}"; + + internal static string Slugify(string? title) + { + if (string.IsNullOrWhiteSpace(title)) + { + return "chart"; + } + + var sb = new StringBuilder(title.Length); + var lastWasDash = false; + foreach (var ch in title) + { + if (char.IsLetterOrDigit(ch)) + { + sb.Append(char.ToLowerInvariant(ch)); + lastWasDash = false; + } + else if (!lastWasDash) + { + sb.Append('-'); + lastWasDash = true; + } + } + + var slug = sb.ToString().Trim('-'); + return slug.Length == 0 ? "chart" : slug; + } + + // ── Value/time range ───────────────────────────────────────────────────── + + private double MaxValue => Series.Count == 0 ? 0 : Series.Max(p => p.Value); + + private DateTime FirstUtc => Series[0].BucketStartUtc; + private DateTime LastUtc => Series[^1].BucketStartUtc; + + // ── Label formatting ───────────────────────────────────────────────────── + + private string MaxValueLabel => FormatValue(MaxValue); + private string BaselineLabel => FormatValue(0); + + private string FormatValue(double v) + { + var num = v.ToString("0.###", CultureInfo.InvariantCulture); + return string.IsNullOrEmpty(Unit) ? num : $"{num} {Unit}"; + } + + // Time labels: include the date prefix when the range spans more than a day, + // otherwise just HH:mm. Always UTC (all timestamps in the system are UTC). + private string StartTimeLabel => FormatTime(FirstUtc); + private string EndTimeLabel => FormatTime(LastUtc); + + private string FormatTime(DateTime utc) + { + var spansDays = Series.Count >= 2 && (LastUtc.Date != FirstUtc.Date); + return utc.ToString(spansDays ? "MM-dd HH:mm" : "HH:mm", CultureInfo.InvariantCulture); + } + + // ── Polyline geometry ──────────────────────────────────────────────────── + + /// + /// Builds the SVG polyline points string. Only called when + /// is true (≥2 points). X spreads each point across + /// the time range; if every timestamp is identical the range is zero and we + /// fall back to even index-based spacing. Y is inverted (SVG origin is + /// top-left) and normalised against ; a zero max draws + /// every point on the baseline. + /// + private string BuildPoints() + { + var n = Series.Count; + var plotW = ViewBoxWidth - (2 * PadX); + var plotH = ViewBoxHeight - PadTop - PadBottom; + var baselineY = ViewBoxHeight - PadBottom; + + var firstTicks = FirstUtc.Ticks; + double timeSpanTicks = LastUtc.Ticks - firstTicks; + var max = MaxValue; + + var sb = new StringBuilder(n * 12); + for (var i = 0; i < n; i++) + { + var p = Series[i]; + + // X — by time fraction, or even index spacing when all timestamps equal. + double xFrac = timeSpanTicks > 0 + ? (p.BucketStartUtc.Ticks - firstTicks) / timeSpanTicks + : (n == 1 ? 0.0 : (double)i / (n - 1)); + var x = PadX + (xFrac * plotW); + + // Y — baseline at 0, top at max. Flat at baseline when max == 0. + double yFrac = max > 0 ? p.Value / max : 0.0; + var y = baselineY - (yFrac * plotH); + + if (i > 0) + { + sb.Append(' '); + } + sb.Append(x.ToString("0.##", CultureInfo.InvariantCulture)); + sb.Append(','); + sb.Append(y.ToString("0.##", CultureInfo.InvariantCulture)); + } + + return sb.ToString(); + } + + private string BaselineYString => + (ViewBoxHeight - PadBottom).ToString("0.##", CultureInfo.InvariantCulture); + + private string PlotRightXString => + (ViewBoxWidth - PadX).ToString("0.##", CultureInfo.InvariantCulture); + + private string PlotLeftXString => + PadX.ToString("0.##", CultureInfo.InvariantCulture); + + private string ViewBox => $"0 0 {ViewBoxWidth} {ViewBoxHeight}"; +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Shared/KpiTrendChartTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Shared/KpiTrendChartTests.cs new file mode 100644 index 00000000..ca5d14d2 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Shared/KpiTrendChartTests.cs @@ -0,0 +1,139 @@ +using Bunit; +using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Shared; + +/// +/// bUnit tests for (M6 K12) — the reusable +/// dependency-free SVG trend chart. Coverage pins the three render states +/// (multi-point chart, unavailable placeholder, single-sample note), the stable +/// data-test slug, and the coordinate-math edge guards (all-equal +/// timestamps, all-zero values) that must not divide by zero or throw. +/// +public class KpiTrendChartTests : BunitContext +{ + private static readonly DateTime Base = new(2026, 6, 15, 10, 0, 0, DateTimeKind.Utc); + + private static IReadOnlyList ThreePoints() => new[] + { + new KpiSeriesPoint(Base, 1.0), + new KpiSeriesPoint(Base.AddMinutes(5), 4.0), + new KpiSeriesPoint(Base.AddMinutes(10), 2.0), + }; + + [Fact] + public void Available_WithThreePoints_RendersPolylineTitleAndDataTest() + { + var cut = Render(p => p + .Add(c => c.Points, ThreePoints()) + .Add(c => c.Title, "Queue Depth") + .Add(c => c.IsAvailable, true)); + + var markup = cut.Markup; + Assert.Contains("", markup); + } + + [Fact] + public void Available_WithUnit_AppendsUnitToValueLabels() + { + var cut = Render(p => p + .Add(c => c.Points, ThreePoints()) + .Add(c => c.Title, "Oldest Pending") + .Add(c => c.Unit, "s") + .Add(c => c.IsAvailable, true)); + + // Max value label is "4 s" with the unit suffix applied. + Assert.Contains("max 4 s", cut.Markup); + } + + [Fact] + public void Unavailable_RendersEmDashPlaceholderAndError_NoPolyline() + { + var cut = Render(p => p + .Add(c => c.Points, ThreePoints()) + .Add(c => c.Title, "Queue Depth") + .Add(c => c.IsAvailable, false) + .Add(c => c.ErrorMessage, "KPI query failed")); + + var markup = cut.Markup; + Assert.Contains("—", markup); // em dash + Assert.Contains("KPI query failed", markup); + Assert.DoesNotContain("(p => p + .Add(c => c.Points, (IReadOnlyList?)null) + .Add(c => c.Title, "Stuck Count") + .Add(c => c.IsAvailable, true)); + + var markup = cut.Markup; + Assert.Contains("—", markup); + Assert.DoesNotContain("(p => p + .Add(c => c.Points, new[] { new KpiSeriesPoint(Base, 7.0) }) + .Add(c => c.Title, "Backlog") + .Add(c => c.IsAvailable, true)); + + var markup = cut.Markup; + Assert.Contains("Only one sample in range.", markup); + Assert.DoesNotContain("(p => p + .Add(c => c.Points, new[] + { + new KpiSeriesPoint(Base, 1.0), + new KpiSeriesPoint(Base, 2.0), + new KpiSeriesPoint(Base, 3.0), + }) + .Add(c => c.Title, "Flat Time") + .Add(c => c.IsAvailable, true)); + + Assert.Contains("(p => p + .Add(c => c.Points, new[] + { + new KpiSeriesPoint(Base, 0.0), + new KpiSeriesPoint(Base.AddMinutes(5), 0.0), + new KpiSeriesPoint(Base.AddMinutes(10), 0.0), + }) + .Add(c => c.Title, "Quiet") + .Add(c => c.IsAvailable, true)); + + Assert.Contains("