diff --git a/docs/plans/2026-06-17-m6-kpi-history.md b/docs/plans/2026-06-17-m6-kpi-history.md new file mode 100644 index 00000000..78e539dc --- /dev/null +++ b/docs/plans/2026-06-17-m6-kpi-history.md @@ -0,0 +1,442 @@ +# M6 — KPI History & Trends Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Build a reusable central KPI-history backbone (tall/EAV store + cluster-singleton recorder + bucketed query + custom SVG trend chart) and ship trend charts for Notification Outbox, Site Call Audit, Audit Log, and Site Health. + +**Architecture:** New `#26 KpiHistory` central component. A tall `KpiSample` table in central MS SQL holds `(Source, Metric, Scope, ScopeKey, Value, CapturedAtUtc)` rows. A `KpiHistoryRecorderActor` cluster singleton samples every `SampleIntervalSeconds` by enumerating DI-registered `IKpiSampleSource`s (each owned by its component, reusing existing `Compute…KpisAsync`/aggregator reads) and bulk-writing samples; a daily purge enforces retention. Central UI reads via a `KpiHistoryQueryService` (scoped-repo, dual-ctor test seam like `AuditLogQueryService`) that fetches raw points and reduces them with a pure `KpiSeriesBucketer` (last-value per bucket); a reusable `KpiTrendChart.razor` (inline SVG, no third-party lib) renders the series on four surfaces. + +**Tech Stack:** C#/.NET 10, Akka.NET 1.5 (ReceiveActor + Timers + ClusterSingleton), EF Core 10 (MS SQL central / SQLite tests), Blazor Server + Bootstrap 5, xUnit + bUnit + NSubstitute, Playwright. + +**Design doc:** `docs/plans/2026-06-17-m6-kpi-history-design.md` (committed `dda9398`). + +--- + +## Conventions for every task + +- **Worktree:** all work happens in `m6-kpi-history` (branched off local `main` 639e331). Implementers do **NOT** create worktrees. +- **Commits:** pathspec form — `git commit -m "…" -- `; retry on `.git/index.lock` (wait ~3s). New files: `git add ` first, then pathspec commit. +- **Concurrency:** the controller keeps **≤3 concurrent committers** per wave and runs a post-wave `git log --oneline ..HEAD` presence check (per the concurrent-commit ref-race lesson). +- **Targeted tests only:** `dotnet test --filter ` + per-project `dotnet build `. **Full-solution build (`dotnet build ZB.MOM.WW.ScadaBridge.slnx`) + docker rebuild only in K17.** +- **Namespaces:** `ZB.MOM.WW.ScadaBridge.` mirrors folder. UTC everywhere. +- **The `Files:` block is the contract** — Read/Edit exactly those paths. If you need another file, that's a plan defect — surface it. + +## Shared naming (used across tasks) + +- Sources (string constants in `KpiSources`): `NotificationOutbox`, `SiteCallAudit`, `AuditLog`, `SiteHealth`. +- Scopes (string constants in `KpiScopes`): `Global`, `Site`, `Node`. +- Metric-name catalog (lowerCamel, defined as constants on each source impl): + - **NotificationOutbox:** `queueDepth, stuckCount, parkedCount, deliveredLastInterval, oldestPendingAgeSeconds` + - **SiteCallAudit:** `buffered, parked, failedLastInterval, deliveredLastInterval, oldestPendingAgeSeconds, stuck` + - **AuditLog:** `totalEventsLastHour, errorEventsLastHour, backlogTotal` + - **SiteHealth:** `connectionsUp, connectionsDown, scriptErrors, alarmEvalErrors, sfBufferDepth, deadLetters, parkedMessages, deployedInstances, enabledInstances, disabledInstances, auditBacklogPending, eventLogWriteFailures` + +--- + +### Task K1: Foundation contracts (Commons) + +**Classification:** high-risk +**Estimated implement time:** ~4 min +**Parallelizable with:** none (everything depends on it) + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Kpi/KpiSample.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiSeriesPoint.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiSources.cs` (string constants `NotificationOutbox/SiteCallAudit/AuditLog/SiteHealth`) +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiScopes.cs` (string constants `Global/Site/Node`) +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Kpi/IKpiSampleSource.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IKpiHistoryRepository.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSampleTests.cs` (if a Commons.Tests project exists; else fold the construction assertion into K2's test and note it) + +**Shapes:** + +```csharp +// KpiSample.cs — persistence-ignorant POCO +public sealed class KpiSample +{ + public long Id { get; set; } + public required string Source { get; set; } // KpiSources.* + public required string Metric { get; set; } // per-source catalog + public required string Scope { get; set; } // KpiScopes.* + public string? ScopeKey { get; set; } // site id / node name; null for Global + public double Value { get; set; } // counts exact; ages as seconds + public DateTime CapturedAtUtc { get; set; } // UTC +} + +// KpiSeriesPoint.cs +public readonly record struct KpiSeriesPoint(DateTime BucketStartUtc, double Value); + +// IKpiSampleSource.cs — implemented by each owning component, enumerated by the recorder +public interface IKpiSampleSource +{ + string Source { get; } // KpiSources.* + /// Compute this source's KPIs as-of-now, stamped with capturedAtUtc. + Task> CollectAsync(DateTime capturedAtUtc, CancellationToken ct = default); +} + +// IKpiHistoryRepository.cs +public interface IKpiHistoryRepository +{ + Task RecordSamplesAsync(IReadOnlyCollection samples, CancellationToken ct = default); + /// Raw points for one series in [fromUtc, toUtc], ordered by CapturedAtUtc ascending. + Task> GetRawSeriesAsync( + string source, string metric, string scope, string? scopeKey, + DateTime fromUtc, DateTime toUtc, CancellationToken ct = default); + /// Bulk-delete rows older than `before`. Returns rows deleted. + Task PurgeOlderThanAsync(DateTime before, CancellationToken ct = default); +} +``` + +**Steps:** write the types → `dotnet build src/ZB.MOM.WW.ScadaBridge.Commons` (Expected: success) → if a `Commons.Tests` project exists, add a one-line construction/round-trip-able test and run it → commit `feat(kpi): K1 — KpiSample + IKpiSampleSource + IKpiHistoryRepository contracts (Commons)` with pathspec of the created files. + +--- + +### Task K2: Persistence — entity config, repository, migration (ConfigurationDatabase) + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** K3, K6, K7, K8, K9, K10, K12 (disjoint files) + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/KpiSampleEntityTypeConfiguration.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs` (add `DbSet KpiSamples => Set();`; `ApplyConfigurationsFromAssembly` already picks up the config) +- Create: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/KpiHistoryRepository.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs` (add `services.AddScoped();`) +- Create (generated): `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/*_AddKpiSampleTable.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/KpiHistoryRepositoryTests.cs` + +**EF config** (mirror `SiteCallEntityTypeConfiguration`): + +```csharp +builder.ToTable("KpiSample"); +builder.HasKey(s => s.Id); +builder.Property(s => s.Source).HasMaxLength(64).IsUnicode(false).IsRequired(); +builder.Property(s => s.Metric).HasMaxLength(64).IsUnicode(false).IsRequired(); +builder.Property(s => s.Scope).HasMaxLength(16).IsUnicode(false).IsRequired(); +builder.Property(s => s.ScopeKey).HasMaxLength(64).IsUnicode(false); // nullable +builder.Property(s => s.Value); // float +builder.Property(s => s.CapturedAtUtc).IsRequired(); +builder.HasIndex(s => new { s.Source, s.Metric, s.Scope, s.ScopeKey, s.CapturedAtUtc }) + .HasDatabaseName("IX_KpiSample_Series"); +builder.HasIndex(s => s.CapturedAtUtc).HasDatabaseName("IX_KpiSample_Captured"); +``` + +**Repository:** `RecordSamplesAsync` → `context.KpiSamples.AddRange(samples); await SaveChangesAsync(ct);`. `GetRawSeriesAsync` → filtered `Where` (note `ScopeKey == scopeKey` handles null correctly in EF) ordered by `CapturedAtUtc`, projected to `KpiSeriesPoint`. `PurgeOlderThanAsync` → `await context.KpiSamples.Where(s => s.CapturedAtUtc < before).ExecuteDeleteAsync(ct);`. + +**Migration:** +```bash +dotnet ef migrations add AddKpiSampleTable \ + --project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase \ + --startup-project src/ZB.MOM.WW.ScadaBridge.Host +``` +After generating, `dotnet build src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` and confirm **no `PendingModelChangesWarning`** (M2-pre regression guard). + +**Test:** in-memory SQLite (the existing ConfigurationDatabase.Tests harness) — insert samples across two metrics/timestamps, assert `GetRawSeriesAsync` returns only the matching series in time order; assert `PurgeOlderThanAsync(cutoff)` deletes only aged rows. +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests --filter KpiHistoryRepository` +Commit: `feat(kpi): K2 — KpiSample EF mapping + KpiHistoryRepository + AddKpiSampleTable migration`. + +--- + +### Task K3: KpiHistory project scaffold + options (new component) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** K2, K6, K7, K8, K9, K10, K12 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj` (mirror `NotificationOutbox.csproj`: TargetFramework, project ref to Commons, package refs Akka + Microsoft.Extensions.{Options,Logging.Abstractions,DependencyInjection.Abstractions}) +- Modify: `ZB.MOM.WW.ScadaBridge.slnx` (add project) +- Create: `src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptions.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptionsValidator.cs` (mirror `AuditLogOptionsValidator` / `OptionsValidatorBase`) +- Create: `src/ZB.MOM.WW.ScadaBridge.KpiHistory/ServiceCollectionExtensions.cs` (`AddKpiHistory(this IServiceCollection, IConfiguration)` — `AddValidatedOptions(config, "ScadaBridge:KpiHistory")`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryOptionsValidatorTests.cs` (create test project, add to slnx) + +**Options:** +```csharp +public sealed class KpiHistoryOptions +{ + public TimeSpan SampleInterval { get; set; } = TimeSpan.FromSeconds(60); + public int RetentionDays { get; set; } = 90; + public TimeSpan PurgeInterval { get; set; } = TimeSpan.FromDays(1); + public int DefaultMaxSeriesPoints { get; set; } = 200; +} +``` +Validator: `SampleInterval > 0`, `RetentionDays` in `[1, 3650]`, `PurgeInterval > 0`, `DefaultMaxSeriesPoints` in `[2, 5000]`. + +**Steps:** scaffold → `dotnet build src/ZB.MOM.WW.ScadaBridge.KpiHistory` → validator test (`dotnet test tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests --filter Validator`) → commit `feat(kpi): K3 — KpiHistory project + options/validator + AddKpiHistory`. + +--- + +### Task K4: KpiHistoryRecorderActor (KpiHistory project) + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** none (depends K3 project + K1 contracts) + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryRecorderActor.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.KpiHistory/ServiceCollectionExtensions.cs` (no DI for the actor itself — created via Props in Host — but expose any logger/helpers if needed) +- Test: `tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs` + +**Behavior** (mirror `NotificationOutboxActor` timer + scope-per-tick and `AuditLogPurgeActor` daily purge): +- ctor: `(IServiceProvider sp, KpiHistoryOptions opts, ILogger log)`. `ReceiveActor` with `Timers` (ITimerScheduler) or `Context.System.Scheduler` cancelable. +- `PreStart`: start periodic `SampleTick` at `opts.SampleInterval`; start periodic `PurgeTick` at `opts.PurgeInterval`; create `_shutdownCts`. +- `SampleTick`: `capturedAt = DateTime.UtcNow`; open `await using var scope = sp.CreateAsyncScope()`; resolve `IEnumerable` + `IKpiHistoryRepository`; for each source, **try** `CollectAsync(capturedAt)` (catch+log, skip on throw), accumulate; if any samples, `RecordSamplesAsync` (try/catch+log). **Best-effort: no exception escapes the tick.** +- `PurgeTick`: `before = DateTime.UtcNow - TimeSpan.FromDays(opts.RetentionDays)`; scope → repo → `PurgeOlderThanAsync(before)` (try/catch+log). +- `PostStop`: cancel `_shutdownCts`, cancel timers. +- Use `PipeTo(Self)` or async-receive pattern consistent with `NotificationOutboxActor` (do not block the actor thread). + +**Test (Akka.TestKit):** register two fake `IKpiSampleSource`s in a test `IServiceProvider` (one returns 2 samples, one **throws**); a fake `IKpiHistoryRepository` capturing `RecordSamplesAsync`. Drive a `SampleTick`; assert the healthy source's 2 samples were recorded and the throwing source did **not** abort the tick. Drive a `PurgeTick`; assert `PurgeOlderThanAsync` called with a cutoff ≈ now−RetentionDays. +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests --filter Recorder` +Commit: `feat(kpi): K4 — KpiHistoryRecorderActor (best-effort sampling + daily purge)`. + +--- + +### Task K5: Host wiring + appsettings + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** none (depends K3, K4; touches Host) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` (call `services.AddKpiHistory(configuration)` on the central composition path — alongside `AddNotificationOutbox`/`AddAuditLog`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs` (register `KpiHistoryRecorderActor` as a non-role-scoped `ClusterSingletonManager` + proxy + `CoordinatedShutdown` drain — copy the `audit-log-purge` block; singleton name `kpi-history-recorder`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json` (add `ScadaBridge:KpiHistory` section with defaults) +- Modify: `docker/appsettings.Central.json` and `docker-env2/appsettings.Central.json` (add the same section) +- Modify: `deploy/wonder-app-vd03/appsettings.Central.json` (add the section) + +**Note (readiness):** do **NOT** add `kpi-history-recorder` to `RequiredSingletonsHealthCheck` — KPI history is observability/best-effort, not a readiness gate. State this in the commit message. + +**Steps:** wire → `dotnet build src/ZB.MOM.WW.ScadaBridge.Host` (Expected: success) → commit `feat(kpi): K5 — Host central wiring + KpiHistoryRecorder cluster singleton + appsettings (not readiness-gated)`. + +--- + +### Task K6: NotificationOutboxKpiSampleSource + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** K2, K3, K7, K8, K9, K10, K12 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Kpi/NotificationOutboxKpiSampleSource.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ServiceCollectionExtensions.cs` (`services.AddScoped();`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Kpi/NotificationOutboxKpiSampleSourceTests.cs` + +**Behavior:** `Source => KpiSources.NotificationOutbox`. ctor injects `INotificationOutboxRepository` + `IOptions`. In `CollectAsync(capturedAt)`: compute `stuckCutoff = capturedAt - opts.StuckAgeThreshold`, `deliveredSince = capturedAt - opts.DeliveredKpiWindow` (so sampled values match the live tiles). Call `ComputeKpisAsync` → emit Global metrics; `ComputePerSiteKpisAsync` → emit Site-scoped (ScopeKey = site id); `ComputePerNodeKpisAsync` → emit Node-scoped. Map `OldestPendingAge` (`TimeSpan?`) to `oldestPendingAgeSeconds` (`.TotalSeconds`, omit the metric when null). All `CapturedAtUtc = capturedAt`. + +**Test:** mock `INotificationOutboxRepository` returning a known global snapshot + one per-site + one per-node; assert the emitted `KpiSample` list has the expected (Metric, Scope, ScopeKey, Value) tuples. +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests --filter NotificationOutboxKpiSampleSource` +Commit: `feat(kpi): K6 — NotificationOutbox sample source (global/site/node)`. + +--- + +### Task K7: SiteCallAuditKpiSampleSource + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** K2, K3, K6, K8, K9, K10, K12 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/Kpi/SiteCallAuditKpiSampleSource.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/ServiceCollectionExtensions.cs` (register `IKpiSampleSource`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/Kpi/SiteCallAuditKpiSampleSourceTests.cs` + +**Behavior:** `Source => KpiSources.SiteCallAudit`. ctor injects `ISiteCallAuditRepository` + the SiteCallAudit options (for stuck/interval windows — match the live KPI computation; reuse whatever options type SiteCallAuditActor uses, `DateTime` cutoffs). Emit `buffered/parked/failedLastInterval/deliveredLastInterval/oldestPendingAgeSeconds/stuck` Global + per-Site + per-Node from `ComputeKpisAsync`/`ComputePerSiteKpisAsync`/`ComputePerNodeKpisAsync`. + +**Test:** mock repo; assert emitted tuples. Run filter `SiteCallAuditKpiSampleSource`. Commit `feat(kpi): K7 — SiteCallAudit sample source`. + +--- + +### Task K8: AuditLogKpiSampleSource + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** K2, K3, K6, K7, K9, K10, K12 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.AuditLog/Kpi/AuditLogKpiSampleSource.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs` (register `IKpiSampleSource`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Kpi/AuditLogKpiSampleSourceTests.cs` + +**Behavior:** `Source => KpiSources.AuditLog`. ctor injects `IAuditLogRepository`. In `CollectAsync`: `var k = await repo.GetKpiSnapshotAsync(...)`; emit Global `totalEventsLastHour, errorEventsLastHour, backlogTotal` (all from `AuditLogKpiSnapshot`). Check `GetKpiSnapshotAsync`'s exact signature/params before calling. + +**Test:** mock repo; assert 3 Global metrics. Run filter `AuditLogKpiSampleSource`. Commit `feat(kpi): K8 — AuditLog sample source`. + +--- + +### Task K9: SiteHealthKpiSampleSource + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** K2, K3, K6, K7, K8, K10, K12 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/Kpi/SiteHealthKpiSampleSource.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ServiceCollectionExtensions.cs` (register `IKpiSampleSource`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/Kpi/SiteHealthKpiSampleSourceTests.cs` + +**Behavior:** `Source => KpiSources.SiteHealth`. ctor injects `ICentralHealthAggregator`. In `CollectAsync`: `foreach (var (siteId, state) in aggregator.GetAllSiteStates())` — when `state.LatestReport is { } r`, emit Site-scoped (ScopeKey = siteId) metrics derived from `r` (see `SiteHealthReport`): `connectionsUp/Down` from `DataConnectionStatuses` counts, `scriptErrors = r.ScriptErrorCount`, `alarmEvalErrors = r.AlarmEvaluationErrorCount`, `sfBufferDepth = sum(r.StoreAndForwardBufferDepths.Values)`, `deadLetters = r.DeadLetterCount`, `parkedMessages = r.ParkedMessageCount`, `deployedInstances/enabledInstances/disabledInstances`, `auditBacklogPending = r.SiteAuditBacklog?.PendingCount ?? 0`, `eventLogWriteFailures = r.SiteEventLogWriteFailures`. Sites with `LatestReport == null` contribute nothing. Read `SiteHealthReport`/`ConnectionHealth` field names before mapping. + +**Test:** fake `ICentralHealthAggregator` returning one site with a populated `LatestReport`; assert the expected per-site metric tuples; a site with null report yields no samples. +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests --filter SiteHealthKpiSampleSource` +Commit: `feat(kpi): K9 — SiteHealth sample source (per-site, from aggregator)`. + +--- + +### Task K10: KpiSeriesBucketer (pure helper) + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** K2, K3, K6, K7, K8, K9, K12 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiSeriesBucketer.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSeriesBucketerTests.cs` + +**Behavior:** `static IReadOnlyList Bucket(IReadOnlyList raw, DateTime fromUtc, DateTime toUtc, int maxPoints)`. If `raw.Count <= maxPoints` return raw unchanged. Else divide `[from,to]` into `maxPoints` equal time buckets; for each non-empty bucket emit one point: `BucketStartUtc` = bucket start, `Value` = **last** raw value in the bucket (gauge semantics). Empty buckets omitted. Guard `maxPoints >= 2`, `toUtc > fromUtc`, empty input → empty output. + +**Test:** raw under/over `maxPoints`; last-per-bucket selection; empty input; single bucket. Run filter `KpiSeriesBucketer`. Commit `feat(kpi): K10 — KpiSeriesBucketer last-per-bucket downsampler`. + +--- + +### Task K11: KpiHistoryQueryService (CentralUI) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** K12 (after K2 + K10 land) + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IKpiHistoryQueryService.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/KpiHistoryQueryService.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs` (explicit-factory registration choosing the `IServiceScopeFactory` ctor — mirror `AuditLogQueryService` lines 46–48) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/KpiHistoryQueryServiceTests.cs` + +**Behavior:** dual-ctor (production `IServiceScopeFactory` scope-per-query + test-seam injected `IKpiHistoryRepository` + `IOptions` for default `maxPoints`). Method: +```csharp +Task> GetSeriesAsync( + string source, string metric, string scope, string? scopeKey, + DateTime fromUtc, DateTime toUtc, int? maxPoints = null, CancellationToken ct = default); +``` +→ resolve repo (scope or injected) → `raw = await repo.GetRawSeriesAsync(...)` → `return KpiSeriesBucketer.Bucket(raw, fromUtc, toUtc, maxPoints ?? opts.DefaultMaxSeriesPoints)`. + +**Test:** NSubstitute repo returns raw points; assert forwarding args + bucketed result. Run filter `KpiHistoryQueryService`. Commit `feat(kpi): K11 — KpiHistoryQueryService (scoped read + bucketing)`. + +--- + +### Task K12: KpiTrendChart.razor reusable SVG component (CentralUI) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** K2, K3, K6–K11 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/KpiTrendChart.razor` +- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/KpiTrendChart.razor.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Shared/KpiTrendChartTests.cs` (bUnit) + +**Behavior:** parameters `[Parameter] IReadOnlyList? Points`, `string Title`, `string? Unit`, `bool IsAvailable`, `string? ErrorMessage`. Render an inline `` with a `` over the points (normalize X by time across [min,max], Y by value across [0,max]); a baseline; min/max value labels + start/end time labels (Bootstrap `small text-muted`). Empty/`!IsAvailable` → a placeholder card with an em-dash + `ErrorMessage`. Add `data-test="kpi-trend-"` for Playwright. Pattern after the existing inline SVG in `AlarmTriggerEditor.razor` + the `AuditKpiTiles` card styling. No third-party lib; component-scoped `.razor.css` only if needed. + +**Test (bUnit):** render with 3 points + `IsAvailable=true` → markup contains `` — seed a few samples via a small seeder or rely on the recorder having run) + +**Steps:** +1. Write docs. +2. **Full-solution build:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — Expected: 0 errors, 0 warnings. +3. **Targeted test sweep:** run the filtered suites for ConfigurationDatabase, KpiHistory, NotificationService, SiteCallAudit, AuditLog, HealthMonitoring, CentralUI — all green. +4. **Docker rebuild:** `bash docker/deploy.sh`; confirm cluster healthy + migration applied (`KpiSample` table exists). +5. Run the Playwright trend spec (skips if cluster down). +6. Commit docs+spec together: `feat(kpi): K17 — #26 KpiHistory docs + completion-design update + Playwright trend spec + integration verification`. + +--- + +## Execution waves (controller guidance — ≤3 concurrent committers/wave) + +- **Wave 1:** K1 (solo). +- **Wave 2:** K2, K3 (2 committers). +- **Wave 3:** K6, K7, K8 (sources, 3 committers). +- **Wave 4:** K9, K10, K12 (3 committers). +- **Wave 5:** K4 (needs K3) → then K5 (needs K4) — serial (Host/actor). +- **Wave 6:** K11 (needs K2+K10). +- **Wave 7:** K13, K14, K15 (3 committers; need K11+K12). +- **Wave 8:** K16. +- **Wave 9:** K17 (final integration). + +Reorder freely as long as `blockedBy` holds; run the post-wave HEAD-presence check after every multi-committer wave.