# 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.