# 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 "…" -- <paths>`; retry on `.git/index.lock` (wait ~3s). New files: `git add <path>` first, then pathspec commit.
- **Concurrency:** the controller keeps **≤3 concurrent committers** per wave and runs a post-wave `git log --oneline <base>..HEAD` presence check (per the concurrent-commit ref-race lesson).
- **Targeted tests only:** `dotnet test <testproj> --filter <name>` + per-project `dotnet build <proj>`. **Full-solution build (`dotnet build ZB.MOM.WW.ScadaBridge.slnx`) + docker rebuild only in K17.**
- **Namespaces:** `ZB.MOM.WW.ScadaBridge.<Component>` 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<IReadOnlyList<KpiSample>> CollectAsync(DateTime capturedAtUtc, CancellationToken ct = default);
}

// IKpiHistoryRepository.cs
public interface IKpiHistoryRepository
{
    Task RecordSamplesAsync(IReadOnlyCollection<KpiSample> samples, CancellationToken ct = default);
    /// Raw points for one series in [fromUtc, toUtc], ordered by CapturedAtUtc ascending.
    Task<IReadOnlyList<KpiSeriesPoint>> 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<int> 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<KpiSample> KpiSamples => Set<KpiSample>();`; `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<IKpiHistoryRepository, KpiHistoryRepository>();`)
- 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<KpiHistoryOptions, KpiHistoryOptionsValidator>(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<KpiHistoryRecorderActor> 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<IKpiSampleSource>` + `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<IKpiSampleSource, NotificationOutboxKpiSampleSource>();`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/Kpi/NotificationOutboxKpiSampleSourceTests.cs`

**Behavior:** `Source => KpiSources.NotificationOutbox`. ctor injects `INotificationOutboxRepository` + `IOptions<NotificationOutboxOptions>`. 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<KpiSeriesPoint> Bucket(IReadOnlyList<KpiSeriesPoint> 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<KpiHistoryOptions>` for default `maxPoints`). Method:
```csharp
Task<IReadOnlyList<KpiSeriesPoint>> 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<KpiSeriesPoint>? Points`, `string Title`, `string? Unit`, `bool IsAvailable`, `string? ErrorMessage`. Render an inline `<svg viewBox="0 0 <W> <H>" preserveAspectRatio="none">` with a `<polyline>` 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-<Title-slug>"` 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 `<polyline` and the title; render `IsAvailable=false` → contains the em-dash placeholder + error message.
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter KpiTrendChart`
Commit: `feat(kpi): K12 — reusable KpiTrendChart SVG component`.

---

### Task K13: Notification Outbox page trend section

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** K14, K15, K16

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationKpis.razor` (+ code-behind if present)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs` (extend)

**Behavior:** inject `IKpiHistoryQueryService`; on init load series for `KpiSources.NotificationOutbox` Global metrics `queueDepth`, `parkedCount`, `deliveredLastInterval` over a default 24h window (`from = UtcNow-24h`); render a `KpiTrendChart` per metric below the existing tiles. Add a simple window toggle (24h / 7d) that re-queries. Best-effort: a query failure renders the chart's unavailable placeholder, never breaks the page.

**Test:** bUnit/xUnit — page renders the trend charts when the query service returns points (substitute the service). Run filter `NotificationKpisPage`. Commit `feat(kpi): K13 — Notification Outbox trend charts (T11 first consumer)`.

---

### Task K14: Site Calls page trend section

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** K13, K15, K16

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor` (+ `.razor.cs`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs` (extend)

**Behavior:** inject `IKpiHistoryQueryService`; add a collapsible "Trends" section with `KpiTrendChart` for `KpiSources.SiteCallAudit` Global `buffered`, `parked`, `failedLastInterval` (24h/7d toggle). Same best-effort placeholder behavior.
Run filter `SiteCallsReportPage`. Commit `feat(kpi): K14 — Site Calls trend charts`.

---

### Task K15: Audit Log page trend section

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** K13, K14, K16

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/AuditLogPage.razor` (+ `.razor.cs`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageTrendTests.cs` (new)

**Behavior:** inject `IKpiHistoryQueryService`; add a "Trends" panel with `KpiTrendChart` for `KpiSources.AuditLog` Global `totalEventsLastHour`, `errorEventsLastHour`, `backlogTotal` (24h/7d). Best-effort placeholder.
Run filter `AuditLogPageTrend`. Commit `feat(kpi): K15 — Audit Log trend charts`.

---

### Task K16: Health dashboard per-site Site Health trend panel

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** K13, K14, K15

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor` (+ `.razor.cs`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs` (extend)

**Behavior:** inject `IKpiHistoryQueryService`; add a "Site Health Trends" panel with a site selector (populated from the already-loaded `_siteStates` keys) and `KpiTrendChart` for `KpiSources.SiteHealth` Site-scoped (ScopeKey = selected site) metrics `connectionsDown`, `deadLetters`, `scriptErrors`, `sfBufferDepth` (24h/7d). Best-effort placeholder; do not perturb the existing 10s tile refresh loop.
Run filter `HealthPage`. Commit `feat(kpi): K16 — Health dashboard per-site trend panel`.

---

### Task K17: Integration — docs, deploy, Playwright, full verification

**Classification:** high-risk
**Estimated implement time:** ~5 min (controller-driven; multiple sub-steps)
**Parallelizable with:** none (final)

**Files:**
- Create: `docs/requirements/Component-KpiHistory.md` (#26 — Purpose, Location, Responsibilities, schema, recorder, sources, query/UI, options, Dependencies, Interactions)
- Modify: `README.md` (component table: add #26 KpiHistory row)
- Modify: `CLAUDE.md` (25 → 26 components; add a "KPI History" entry + a Key-Design-Decision bullet; note T9/T10 deferred)
- Modify: `docs/requirements/Component-NotificationOutbox.md`, `-SiteCallAudit` (file: `Component-SiteCallAudit.md`), `Component-AuditLog.md`, `Component-HealthMonitoring.md` (if present, else the health doc), `Component-CentralUI.md` (Interactions: "emits `IKpiSampleSource` consumed by KpiHistory" / "renders KpiTrendChart")
- Modify: `docs/plans/2026-06-15-stillpending-completion-design.md` (M6: T9/T10 deferred to next major version; T11 → KPI-history backbone delivered)
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/KpiTrendChartTests.cs` (one `[SkippableFact]` gated on cluster availability: authenticate, navigate to `/monitoring/health` (or the notifications KPI page), assert a `data-test="kpi-trend-*"` SVG renders with a `<polyline>` — 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.
