# Notifications Nav Group Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

**Goal:** Consolidate all notification-related Central UI pages under a new **Notifications** left-menu section, split the combined Outbox page into a report page and a KPIs page, give Notification Lists its own page, and add a per-source-site KPI breakdown.

**Architecture:** Four pages move to a consistent `/notifications/*` route prefix and a new `Notifications` nav section. The existing `Monitoring/NotificationOutbox.razor` (KPI tiles + filterable table on one page) is split into `NotificationReport.razor` (table) and `NotificationKpis.razor` (tiles + per-site table). The KPI page's per-site breakdown is backed by a bounded, additive backend chain: a new repository method, message contract pair, actor handler, and `CommunicationService` method — each mirroring the existing global-KPI equivalent.

**Tech Stack:** .NET 10, Blazor Server + Bootstrap, Akka.NET (cluster singleton actor, TestKit), EF Core (MS SQL; SQLite in-memory for tests), xUnit + NSubstitute + bUnit.

**Design doc:** `docs/plans/2026-05-19-notifications-nav-group-design.md`

---

## Conventions for every task

- **TDD:** write the failing test first, watch it fail, implement, watch it pass.
- **Commit with explicit paths only.** NEVER `git add -A` or `git add .`. There are pre-existing uncommitted files under `infra/` that MUST NOT be committed — always `git add <explicit paths>`.
- Build the solution with `dotnet build ScadaLink.slnx`. `TreatWarningsAsErrors` is on — zero warnings.
- Run a single test project with `dotnet test tests/<Project>/<Project>.csproj`.
- Entity/domain POCOs live in `ScadaLink.Commons`; EF fluent configs in `ScadaLink.ConfigurationDatabase`.
- Message records follow additive-only evolution — only add new record types, never reorder/remove members of existing ones.

---

## Task 1: Per-site KPI domain type + repository interface method

**Files:**
- Create: `src/ScadaLink.Commons/Types/Notifications/SiteNotificationKpiSnapshot.cs`
- Modify: `src/ScadaLink.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs`
- Test: `tests/ScadaLink.Commons.Tests/Types/SiteNotificationKpiSnapshotTests.cs`

**Step 1: Write the failing test**

```csharp
using ScadaLink.Commons.Types.Notifications;

namespace ScadaLink.Commons.Tests.Types;

public class SiteNotificationKpiSnapshotTests
{
    [Fact]
    public void Constructor_AssignsAllMembers()
    {
        var snapshot = new SiteNotificationKpiSnapshot(
            SourceSiteId: "plant-a",
            QueueDepth: 5,
            StuckCount: 2,
            ParkedCount: 1,
            DeliveredLastInterval: 40,
            OldestPendingAge: TimeSpan.FromMinutes(12));

        Assert.Equal("plant-a", snapshot.SourceSiteId);
        Assert.Equal(5, snapshot.QueueDepth);
        Assert.Equal(2, snapshot.StuckCount);
        Assert.Equal(1, snapshot.ParkedCount);
        Assert.Equal(40, snapshot.DeliveredLastInterval);
        Assert.Equal(TimeSpan.FromMinutes(12), snapshot.OldestPendingAge);
    }

    [Fact]
    public void OldestPendingAge_IsNullableForSitesWithNoBacklog()
    {
        var snapshot = new SiteNotificationKpiSnapshot("plant-b", 0, 0, 0, 0, null);
        Assert.Null(snapshot.OldestPendingAge);
    }
}
```

**Step 2: Run test to verify it fails**

Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter SiteNotificationKpiSnapshotTests`
Expected: FAIL — `SiteNotificationKpiSnapshot` does not exist.

**Step 3: Write minimal implementation**

`SiteNotificationKpiSnapshot.cs`:

```csharp
namespace ScadaLink.Commons.Types.Notifications;

/// <summary>
/// Point-in-time notification-outbox metrics scoped to a single source site.
/// The per-site counterpart of <see cref="NotificationKpiSnapshot"/>; surfaced
/// in the per-site breakdown table on the Notification KPIs page.
/// </summary>
/// <param name="SourceSiteId">The site identifier these metrics are scoped to.</param>
/// <param name="QueueDepth">Count of this site's non-terminal rows (Pending + Retrying).</param>
/// <param name="StuckCount">
/// Count of this site's non-terminal rows whose <c>CreatedAt</c> is older than the stuck cutoff.
/// </param>
/// <param name="ParkedCount">Count of this site's rows in the Parked status.</param>
/// <param name="DeliveredLastInterval">
/// Count of this site's Delivered rows whose <c>DeliveredAt</c> is at or after the
/// "delivered since" timestamp.
/// </param>
/// <param name="OldestPendingAge">
/// Age of this site's oldest non-terminal row, or <c>null</c> when it has none.
/// </param>
public record SiteNotificationKpiSnapshot(
    string SourceSiteId,
    int QueueDepth,
    int StuckCount,
    int ParkedCount,
    int DeliveredLastInterval,
    TimeSpan? OldestPendingAge);
```

Add to `INotificationOutboxRepository.cs`, immediately after the `ComputeKpisAsync` method:

```csharp
    /// <summary>
    /// Computes a point-in-time <see cref="SiteNotificationKpiSnapshot"/> per source site.
    /// Sites with no notification rows at all are omitted. The stuck and delivered cutoffs
    /// are supplied by the caller; the current time used for <c>OldestPendingAge</c> is
    /// captured inside the method.
    /// </summary>
    Task<IReadOnlyList<SiteNotificationKpiSnapshot>> ComputePerSiteKpisAsync(
        DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default);
```

**Step 4: Run test to verify it passes**

Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter SiteNotificationKpiSnapshotTests`
Expected: PASS. (`ScadaLink.ConfigurationDatabase` will not yet compile — the interface method is unimplemented. That is fixed in Task 2; do not build the whole solution at this step.)

**Step 5: Commit**

```bash
git add src/ScadaLink.Commons/Types/Notifications/SiteNotificationKpiSnapshot.cs \
        src/ScadaLink.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs \
        tests/ScadaLink.Commons.Tests/Types/SiteNotificationKpiSnapshotTests.cs
git commit -m "feat(notification-outbox): per-site KPI snapshot type + repository contract"
```

---

## Task 2: ComputePerSiteKpisAsync repository implementation

**Files:**
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs`
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerSiteKpiTests.cs`

**Context:** `NotificationOutboxRepository` is an EF Core repository over `ScadaLinkDbContext.Notifications`. The existing global `ComputeKpisAsync` (in the same file) shows the metric definitions and the `DateTimeOffset`-min workaround. The `Notification` entity has a `SourceSiteId` string and a `Status` (`NotificationStatus` enum: `Pending`, `Retrying`, `Delivered`, `Parked`, `Discarded`). Look at an existing repository test in `tests/ScadaLink.ConfigurationDatabase.Tests/` to copy the SQLite-in-memory `ScadaLinkDbContext` setup pattern.

**Step 1: Write the failing test**

Mirror the existing repository-test setup (in-memory SQLite `ScadaLinkDbContext`). Seed `Notification` rows across two sites and assert the per-site aggregation:

```csharp
[Fact]
public async Task ComputePerSiteKpisAsync_AggregatesMetricsPerSite()
{
    await using var ctx = NewContext();   // copy the in-memory-SQLite helper used by sibling tests
    var now = DateTimeOffset.UtcNow;

    // plant-a: 1 pending (stuck, created 20m ago), 1 parked
    ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Pending,  createdAt: now.AddMinutes(-20)));
    ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Parked,   createdAt: now.AddMinutes(-5)));
    // plant-b: 1 delivered in-window, 1 pending (fresh)
    ctx.Notifications.Add(NewNotification("plant-b", NotificationStatus.Delivered, createdAt: now.AddHours(-2), deliveredAt: now.AddMinutes(-2)));
    ctx.Notifications.Add(NewNotification("plant-b", NotificationStatus.Pending,   createdAt: now.AddMinutes(-1)));
    await ctx.SaveChangesAsync();

    var repo = new NotificationOutboxRepository(ctx);
    var result = await repo.ComputePerSiteKpisAsync(
        stuckCutoff: now.AddMinutes(-10), deliveredSince: now.AddMinutes(-30));

    var a = result.Single(s => s.SourceSiteId == "plant-a");
    Assert.Equal(1, a.QueueDepth);
    Assert.Equal(1, a.StuckCount);
    Assert.Equal(1, a.ParkedCount);
    Assert.Equal(0, a.DeliveredLastInterval);
    Assert.NotNull(a.OldestPendingAge);

    var b = result.Single(s => s.SourceSiteId == "plant-b");
    Assert.Equal(1, b.QueueDepth);
    Assert.Equal(0, b.StuckCount);
    Assert.Equal(1, b.DeliveredLastInterval);
}

[Fact]
public async Task ComputePerSiteKpisAsync_ReturnsEmpty_WhenNoNotifications()
{
    await using var ctx = NewContext();
    var repo = new NotificationOutboxRepository(ctx);
    var result = await repo.ComputePerSiteKpisAsync(
        DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(-30));
    Assert.Empty(result);
}
```

Define the `NewContext()` and `NewNotification(...)` helpers by copying the conventions from the nearest existing `NotificationOutboxRepository` test (check `RepositoryCoverageTests.cs` for how `Notification` rows are constructed).

**Step 2: Run test to verify it fails**

Run: `dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj --filter NotificationOutboxRepositoryPerSiteKpiTests`
Expected: FAIL — `ComputePerSiteKpisAsync` not implemented.

**Step 3: Write minimal implementation**

Add to `NotificationOutboxRepository.cs`, after `ComputeKpisAsync`:

```csharp
    public async Task<IReadOnlyList<SiteNotificationKpiSnapshot>> ComputePerSiteKpisAsync(
        DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default)
    {
        var now = DateTimeOffset.UtcNow;

        var queueDepth = await CountBySiteAsync(
            n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying,
            cancellationToken);

        var stuck = await CountBySiteAsync(
            n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying)
                && n.CreatedAt < stuckCutoff,
            cancellationToken);

        var parked = await CountBySiteAsync(
            n => n.Status == NotificationStatus.Parked, cancellationToken);

        var delivered = await CountBySiteAsync(
            n => n.Status == NotificationStatus.Delivered
                && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince,
            cancellationToken);

        // Oldest non-terminal CreatedAt per site. A SQL Min over the DateTimeOffset
        // converter is awkward (see ComputeKpisAsync), so project the non-terminal
        // (site, created) pairs — the live queue, which stays bounded — and reduce
        // in memory.
        var oldest = (await _context.Notifications
                .Where(n => n.Status == NotificationStatus.Pending
                    || n.Status == NotificationStatus.Retrying)
                .Select(n => new { n.SourceSiteId, n.CreatedAt })
                .ToListAsync(cancellationToken))
            .GroupBy(x => x.SourceSiteId)
            .ToDictionary(g => g.Key, g => g.Min(x => x.CreatedAt));

        var siteIds = queueDepth.Keys
            .Concat(stuck.Keys).Concat(parked.Keys).Concat(delivered.Keys)
            .Distinct()
            .OrderBy(s => s, StringComparer.Ordinal);

        return siteIds.Select(site => new SiteNotificationKpiSnapshot(
            SourceSiteId: site,
            QueueDepth: queueDepth.GetValueOrDefault(site),
            StuckCount: stuck.GetValueOrDefault(site),
            ParkedCount: parked.GetValueOrDefault(site),
            DeliveredLastInterval: delivered.GetValueOrDefault(site),
            OldestPendingAge: oldest.TryGetValue(site, out var createdAt)
                ? now - createdAt
                : null)).ToList();
    }

    /// <summary>Counts notification rows matching <paramref name="predicate"/>, grouped by source site.</summary>
    private async Task<Dictionary<string, int>> CountBySiteAsync(
        System.Linq.Expressions.Expression<Func<Notification, bool>> predicate,
        CancellationToken cancellationToken)
    {
        return await _context.Notifications
            .Where(predicate)
            .GroupBy(n => n.SourceSiteId)
            .Select(g => new { Site = g.Key, Count = g.Count() })
            .ToDictionaryAsync(x => x.Site, x => x.Count, cancellationToken);
    }
```

**Step 4: Run test to verify it passes**

Run: `dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj --filter NotificationOutboxRepositoryPerSiteKpiTests`
Expected: PASS.

**Step 5: Commit**

```bash
git add src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs \
        tests/ScadaLink.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerSiteKpiTests.cs
git commit -m "feat(notification-outbox): per-site KPI aggregation in the repository"
```

---

## Task 3: Per-site KPI message contracts

**Files:**
- Modify: `src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs`
- Test: `tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs` (add cases)

**Context:** `NotificationOutboxQueries.cs` already holds `NotificationKpiRequest` / `NotificationKpiResponse`. The per-site response carries the `SiteNotificationKpiSnapshot` records directly (no flattening — the type is a clean serializable record in Commons). Additive-only: append new records, do not touch existing ones.

**Step 1: Write the failing test**

Add to `NotificationMessagesTests.cs`:

```csharp
[Fact]
public void PerSiteNotificationKpiRequest_CarriesCorrelationId()
{
    var request = new PerSiteNotificationKpiRequest("corr-1");
    Assert.Equal("corr-1", request.CorrelationId);
}

[Fact]
public void PerSiteNotificationKpiResponse_CarriesPerSiteSnapshots()
{
    var sites = new[]
    {
        new SiteNotificationKpiSnapshot("plant-a", 3, 1, 0, 10, TimeSpan.FromMinutes(4)),
    };
    var response = new PerSiteNotificationKpiResponse("corr-1", Success: true, ErrorMessage: null, sites);

    Assert.True(response.Success);
    Assert.Null(response.ErrorMessage);
    Assert.Single(response.Sites);
    Assert.Equal("plant-a", response.Sites[0].SourceSiteId);
}
```

Add `using ScadaLink.Commons.Types.Notifications;` to the test file if not present.

**Step 2: Run test to verify it fails**

Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter NotificationMessagesTests`
Expected: FAIL — the two records do not exist.

**Step 3: Write minimal implementation**

Append to `NotificationOutboxQueries.cs` (add `using ScadaLink.Commons.Types.Notifications;` at the top if absent):

```csharp
/// <summary>
/// Outbox UI -> Central: request for the per-source-site notification outbox KPI breakdown.
/// </summary>
public record PerSiteNotificationKpiRequest(
    string CorrelationId);

/// <summary>
/// Central -> Outbox UI: per-site KPI breakdown for the Notification KPIs page.
/// On a repository fault <see cref="Success"/> is <c>false</c>, <see cref="ErrorMessage"/>
/// carries the cause, and <see cref="Sites"/> is empty.
/// </summary>
public record PerSiteNotificationKpiResponse(
    string CorrelationId,
    bool Success,
    string? ErrorMessage,
    IReadOnlyList<SiteNotificationKpiSnapshot> Sites);
```

**Step 4: Run test to verify it passes**

Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter NotificationMessagesTests`
Expected: PASS.

**Step 5: Commit**

```bash
git add src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs \
        tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs
git commit -m "feat(notification-outbox): per-site KPI request/response message contracts"
```

---

## Task 4: NotificationOutboxActor per-site KPI handler

**Files:**
- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs`
- Test: `tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs` (add cases)

**Context:** The actor registers handlers in its constructor and resolves `INotificationOutboxRepository` per-request from a fresh DI scope (`_serviceProvider.CreateScope()`) to avoid a captive dependency. `HandleKpiRequest` / `ComputeKpisAsync` are the exact template to mirror. `StuckCutoff(now)` and `_options.DeliveredKpiWindow` already exist. `NotificationOutboxActorQueryTests.cs` shows how the actor is spun up with a substituted repository.

**Step 1: Write the failing test**

Add to `NotificationOutboxActorQueryTests.cs`, following the existing KPI-request test in that file:

```csharp
[Fact]
public void PerSiteKpiRequest_RepliesWithPerSiteSnapshots()
{
    var repo = Substitute.For<INotificationOutboxRepository>();
    repo.ComputePerSiteKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
        .Returns(new List<SiteNotificationKpiSnapshot>
        {
            new("plant-a", 4, 1, 0, 9, TimeSpan.FromMinutes(7)),
        });

    var actor = CreateActor(repo);   // reuse the helper this test class already uses
    actor.Tell(new PerSiteNotificationKpiRequest("corr-ps"), TestActor);

    var response = ExpectMsg<PerSiteNotificationKpiResponse>();
    Assert.True(response.Success);
    Assert.Equal("corr-ps", response.CorrelationId);
    Assert.Single(response.Sites);
    Assert.Equal("plant-a", response.Sites[0].SourceSiteId);
}

[Fact]
public void PerSiteKpiRequest_RepositoryFault_RepliesUnsuccessful()
{
    var repo = Substitute.For<INotificationOutboxRepository>();
    repo.ComputePerSiteKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
        .Returns<IReadOnlyList<SiteNotificationKpiSnapshot>>(_ => throw new InvalidOperationException("db down"));

    var actor = CreateActor(repo);
    actor.Tell(new PerSiteNotificationKpiRequest("corr-ps"), TestActor);

    var response = ExpectMsg<PerSiteNotificationKpiResponse>();
    Assert.False(response.Success);
    Assert.Contains("db down", response.ErrorMessage);
    Assert.Empty(response.Sites);
}
```

Match the seam the existing KPI test uses (the `CreateActor` helper name may differ — copy whatever the sibling KPI test does).

**Step 2: Run test to verify it fails**

Run: `dotnet test tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj --filter NotificationOutboxActorQueryTests`
Expected: FAIL — `PerSiteNotificationKpiRequest` is unhandled (TestKit reports an unexpected message / no reply).

**Step 3: Write minimal implementation**

In the constructor of `NotificationOutboxActor`, after the `Receive<NotificationKpiRequest>(HandleKpiRequest);` line:

```csharp
        Receive<PerSiteNotificationKpiRequest>(HandlePerSiteKpiRequest);
```

After the `ComputeKpisAsync` method, add:

```csharp
    /// <summary>
    /// Handles a per-site KPI request, computing the per-source-site outbox metrics with the
    /// same stuck cutoff and delivered window as <see cref="HandleKpiRequest"/>.
    /// </summary>
    private void HandlePerSiteKpiRequest(PerSiteNotificationKpiRequest request)
    {
        var sender = Sender;
        var now = DateTimeOffset.UtcNow;
        var stuckCutoff = StuckCutoff(now);
        var deliveredSince = now - _options.DeliveredKpiWindow;

        ComputePerSiteKpisAsync(request.CorrelationId, stuckCutoff, deliveredSince).PipeTo(
            sender,
            success: response => response,
            failure: ex => new PerSiteNotificationKpiResponse(
                request.CorrelationId,
                Success: false,
                ErrorMessage: ex.GetBaseException().Message,
                Sites: Array.Empty<SiteNotificationKpiSnapshot>()));
    }

    private async Task<PerSiteNotificationKpiResponse> ComputePerSiteKpisAsync(
        string correlationId, DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince)
    {
        using var scope = _serviceProvider.CreateScope();
        var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
        var sites = await repository.ComputePerSiteKpisAsync(stuckCutoff, deliveredSince);

        return new PerSiteNotificationKpiResponse(correlationId, Success: true, ErrorMessage: null, sites);
    }
```

**Step 4: Run test to verify it passes**

Run: `dotnet test tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj --filter NotificationOutboxActorQueryTests`
Expected: PASS.

**Step 5: Commit**

```bash
git add src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs \
        tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs
git commit -m "feat(notification-outbox): actor handler for per-site KPI requests"
```

---

## Task 5: CommunicationService.GetPerSiteNotificationKpisAsync

**Files:**
- Modify: `src/ScadaLink.Communication/CommunicationService.cs`
- Test: `tests/ScadaLink.Communication.Tests/CommunicationServiceTests.cs` (add a case)

**Context:** `CommunicationService` has a "Notification Outbox" region. `GetNotificationKpisAsync` is the exact template — it `Ask`s the notification-outbox actor proxy (`GetNotificationOutbox()`) with `_options.QueryTimeout`. The test class wires a real lightweight `ActorSystem` with a scripted actor (the same `SetNotificationOutbox` seam the outbox page tests use).

**Step 1: Write the failing test**

Add to `CommunicationServiceTests.cs`, mirroring the existing `GetNotificationKpisAsync` test:

```csharp
[Fact]
public async Task GetPerSiteNotificationKpisAsync_RoundTripsThroughTheOutboxActor()
{
    var expected = new PerSiteNotificationKpiResponse("corr-ps", true, null,
        new[] { new SiteNotificationKpiSnapshot("plant-a", 2, 0, 0, 5, null) });

    // Scripted actor replies with `expected` to any PerSiteNotificationKpiRequest —
    // copy the scripted-actor pattern used by the sibling KPI test.
    var service = CreateServiceWithScriptedOutbox<PerSiteNotificationKpiRequest>(_ => expected);

    var actual = await service.GetPerSiteNotificationKpisAsync(new PerSiteNotificationKpiRequest("corr-ps"));

    Assert.True(actual.Success);
    Assert.Single(actual.Sites);
    Assert.Equal("plant-a", actual.Sites[0].SourceSiteId);
}
```

If the test class uses an inline scripted `ReceiveActor` rather than a `CreateServiceWith...` helper, follow that existing shape instead — the point is a scripted reply to `PerSiteNotificationKpiRequest`.

**Step 2: Run test to verify it fails**

Run: `dotnet test tests/ScadaLink.Communication.Tests/ScadaLink.Communication.Tests.csproj --filter GetPerSiteNotificationKpisAsync`
Expected: FAIL — `GetPerSiteNotificationKpisAsync` does not exist.

**Step 3: Write minimal implementation**

In `CommunicationService.cs`, in the Notification Outbox region, after `GetNotificationKpisAsync`:

```csharp
    public async Task<PerSiteNotificationKpiResponse> GetPerSiteNotificationKpisAsync(
        PerSiteNotificationKpiRequest request, CancellationToken cancellationToken = default)
    {
        return await GetNotificationOutbox().Ask<PerSiteNotificationKpiResponse>(
            request, _options.QueryTimeout, cancellationToken);
    }
```

**Step 4: Run test to verify it passes**

Run: `dotnet test tests/ScadaLink.Communication.Tests/ScadaLink.Communication.Tests.csproj --filter GetPerSiteNotificationKpisAsync`
Expected: PASS.

**Step 5: Build the whole solution** to confirm the backend chain is clean.

Run: `dotnet build ScadaLink.slnx`
Expected: Build succeeded, 0 warnings.

**Step 6: Commit**

```bash
git add src/ScadaLink.Communication/CommunicationService.cs \
        tests/ScadaLink.Communication.Tests/CommunicationServiceTests.cs
git commit -m "feat(notification-outbox): CommunicationService per-site KPI accessor"
```

---

## Task 6: Move SMTP Configuration page to /notifications/smtp

**Files:**
- Move: `src/ScadaLink.CentralUI/Components/Pages/Admin/SmtpConfiguration.razor` → `src/ScadaLink.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor`
- Move (if present): `Admin/SmtpConfiguration.razor.cs` → `Notifications/SmtpConfiguration.razor.cs`
- Modify: `tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs:30`

**Context:** The page currently declares `@page "/admin/smtp"` and lives in the `Admin` Pages folder. Only the route and folder/namespace change — content and `RequireAdmin` policy stay. The Razor namespace follows the folder, so a moved `.razor` resolves to `ScadaLink.CentralUI.Components.Pages.Notifications`. There is no test that renders this page directly; the only reference is the Playwright `NavigationTests` inline data.

**Step 1:** Create the `Components/Pages/Notifications/` folder by writing the moved file there.

`git mv` the file:

```bash
git mv src/ScadaLink.CentralUI/Components/Pages/Admin/SmtpConfiguration.razor \
       src/ScadaLink.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor
```

If a code-behind `SmtpConfiguration.razor.cs` exists, `git mv` it too and update its `namespace` to `ScadaLink.CentralUI.Components.Pages.Notifications`.

**Step 2:** In the moved `Notifications/SmtpConfiguration.razor`, change line 1 from:

```razor
@page "/admin/smtp"
```

to:

```razor
@page "/notifications/smtp"
```

**Step 3:** Update `tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs` line 30 — change the inline data from:

```csharp
[InlineData("SMTP Configuration", "/admin/smtp")]
```

to:

```csharp
[InlineData("SMTP Configuration", "/notifications/smtp")]
```

**Step 4: Build to verify**

Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj`
Expected: Build succeeded, 0 warnings.

**Step 5: Commit**

```bash
git add src/ScadaLink.CentralUI/Components/Pages/Admin/SmtpConfiguration.razor \
        src/ScadaLink.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor \
        tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs
git commit -m "refactor(central-ui): move SMTP Configuration page to /notifications/smtp"
```

(Include the code-behind paths in `git add` if one was moved.)

---

## Task 7: New Notification Lists page

**Files:**
- Create: `src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationLists.razor`
- Test: `tests/ScadaLink.CentralUI.Tests/Pages/NotificationListsPageTests.cs`

**Context:** Notification lists are currently shown only inside the "Notification Lists" tab of `Components/Pages/Design/ExternalSystems.razor` (the `RenderNotificationLists` render fragment, lines ~301-380). This task creates a standalone page; Task 8 removes that tab. `INotificationRepository` exposes `GetAllNotificationListsAsync()`, `GetRecipientsByListIdAsync(int)`, `DeleteNotificationListAsync(int)`, `SaveChangesAsync()`. `NotificationList` has `Id` and `Name`; `NotificationRecipient` has `Name` and `EmailAddress`. The page is `RequireDesign`. Look at `NotificationOutboxPageTests.cs` for the bUnit `AuthorizeView` + DI wiring pattern (substituted repositories, a `TestAuthorizationContext`-style claims principal).

**Step 1: Write the failing test**

```csharp
using Bunit;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Interfaces.Repositories;
using NotificationListsPage = ScadaLink.CentralUI.Components.Pages.Notifications.NotificationLists;

namespace ScadaLink.CentralUI.Tests.Pages;

public class NotificationListsPageTests : BunitContext
{
    [Fact]
    public void RendersNotificationListRows()
    {
        var repo = Substitute.For<INotificationRepository>();
        repo.GetAllNotificationListsAsync()
            .Returns(new List<NotificationList> { new("Ops On-Call") { Id = 1 } });
        repo.GetRecipientsByListIdAsync(1)
            .Returns(new List<NotificationRecipient> { new("Jane", "jane@example.com") });
        Services.AddSingleton(repo);
        // wire IDialogService + an authorized Design-role principal — copy
        // NotificationOutboxPageTests' auth/DI setup.

        var cut = Render<NotificationListsPage>();

        Assert.Contains("Ops On-Call", cut.Markup);
    }

    [Fact]
    public void ShowsEmptyState_WhenNoLists()
    {
        var repo = Substitute.For<INotificationRepository>();
        repo.GetAllNotificationListsAsync().Returns(new List<NotificationList>());
        Services.AddSingleton(repo);
        // ...same auth/DI setup...

        var cut = Render<NotificationListsPage>();

        Assert.Contains("No notification lists", cut.Markup);
    }
}
```

**Step 2: Run test to verify it fails**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationListsPageTests`
Expected: FAIL — `NotificationLists` page does not exist.

**Step 3: Write minimal implementation**

`NotificationLists.razor`:

```razor
@page "/notifications/lists"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Notifications
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject INotificationRepository NotificationRepository
@inject NavigationManager NavigationManager
@inject IDialogService Dialog

<div class="container-fluid mt-3">
    <ToastNotification @ref="_toast" />

    <div class="d-flex justify-content-between align-items-center mb-3">
        <h4 class="mb-0">Notification Lists</h4>
        <button class="btn btn-primary btn-sm"
                @onclick='() => NavigationManager.NavigateTo("/notifications/lists/create")'>
            Add Notification List
        </button>
    </div>

    @if (_loading)
    {
        <LoadingSpinner IsLoading="true" />
    }
    else if (_errorMessage != null)
    {
        <div class="alert alert-danger">@_errorMessage</div>
    }
    else if (_lists.Count == 0)
    {
        <div class="card">
            <div class="card-body text-center text-muted py-5">
                <div class="fs-5 mb-2">No notification lists</div>
                <button class="btn btn-primary btn-sm"
                        @onclick='() => NavigationManager.NavigateTo("/notifications/lists/create")'>
                    Add your first notification list
                </button>
            </div>
        </div>
    }
    else
    {
        <div class="table-responsive">
            <table class="table table-sm table-hover align-middle">
                <thead class="table-light">
                    <tr>
                        <th>Name</th>
                        <th>Recipients</th>
                        <th class="text-end">Actions</th>
                    </tr>
                </thead>
                <tbody>
                    @foreach (var list in _lists)
                    {
                        var recipients = _recipients.GetValueOrDefault(list.Id) ?? new();
                        <tr @key="list.Id">
                            <td>@list.Name</td>
                            <td>
                                @if (recipients.Count == 0)
                                {
                                    <span class="text-muted small fst-italic">No recipients</span>
                                }
                                else
                                {
                                    @foreach (var r in recipients)
                                    {
                                        <span class="badge bg-light text-dark me-1 mb-1">@r.Name &lt;@r.EmailAddress&gt;</span>
                                    }
                                }
                            </td>
                            <td class="text-end">
                                <button class="btn btn-outline-primary btn-sm me-1"
                                        @onclick='() => NavigationManager.NavigateTo($"/notifications/lists/{list.Id}/edit")'>
                                    Edit
                                </button>
                                <button class="btn btn-outline-danger btn-sm"
                                        @onclick="() => DeleteList(list)">
                                    Delete
                                </button>
                            </td>
                        </tr>
                    }
                </tbody>
            </table>
        </div>
    }
</div>

@code {
    private bool _loading = true;
    private string? _errorMessage;
    private List<NotificationList> _lists = new();
    private readonly Dictionary<int, List<NotificationRecipient>> _recipients = new();
    private ToastNotification _toast = default!;

    protected override async Task OnInitializedAsync() => await LoadAsync();

    private async Task LoadAsync()
    {
        _loading = true;
        _errorMessage = null;
        try
        {
            _lists = (await NotificationRepository.GetAllNotificationListsAsync()).ToList();
            _recipients.Clear();
            foreach (var list in _lists)
            {
                var recips = await NotificationRepository.GetRecipientsByListIdAsync(list.Id);
                _recipients[list.Id] = recips.ToList();
            }
        }
        catch (Exception ex)
        {
            _errorMessage = ex.Message;
        }
        _loading = false;
    }

    private async Task DeleteList(NotificationList list)
    {
        if (!await Dialog.ConfirmAsync("Delete", $"Delete notification list '{list.Name}'?", danger: true))
        {
            return;
        }
        try
        {
            await NotificationRepository.DeleteNotificationListAsync(list.Id);
            await NotificationRepository.SaveChangesAsync();
            _toast.ShowSuccess("Deleted.");
            await LoadAsync();
        }
        catch (Exception ex)
        {
            _toast.ShowError(ex.Message);
        }
    }
}
```

**Step 4: Run test to verify it passes**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationListsPageTests`
Expected: PASS.

**Step 5: Commit**

```bash
git add src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationLists.razor \
        tests/ScadaLink.CentralUI.Tests/Pages/NotificationListsPageTests.cs
git commit -m "feat(central-ui): standalone Notification Lists page"
```

---

## Task 8: Move the Notification List form route; drop the External Systems tab

**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Design/NotificationListForm.razor`
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor`

**Context:** `NotificationListForm.razor` has two `@page` routes under `/design/notification-lists/...` and navigates back to `/design/external-systems` (`GoBack()` and the post-`Save()` redirect). `ExternalSystems.razor` is a 4-tab page ("Integration Definitions"): External Systems, Database Connections, **Notification Lists**, Inbound API Methods. Removing the Notification Lists tab leaves three. This task has no behavioral test of its own (Task 7 covers the new lists page); verify by build + a render-smoke check.

**Step 1: Re-point the form routes.** In `NotificationListForm.razor`, change lines 1-2:

```razor
@page "/design/notification-lists/create"
@page "/design/notification-lists/{Id:int}/edit"
```

to:

```razor
@page "/notifications/lists/create"
@page "/notifications/lists/{Id:int}/edit"
```

**Step 2: Re-point the form's back-navigation.** In `NotificationListForm.razor`:
- In `Save()`, change `NavigationManager.NavigateTo("/design/external-systems");` to `NavigationManager.NavigateTo("/notifications/lists");`
- In `GoBack()`, change `NavigationManager.NavigateTo("/design/external-systems");` to `NavigationManager.NavigateTo("/notifications/lists");`

**Step 3: Remove the Notification Lists tab from `ExternalSystems.razor`.** Delete, from that file:
- The tab `<li>` button (the `_tab == "notif"` nav-item, ~lines 50-58).
- The tab-panel branch `else if (_tab == "notif") { ... }` (~lines 78-81).
- The `RenderNotificationLists()` render fragment method (~lines 301-373).
- The `DeleteNotifList(...)` method (~lines 375-380).
- The Notification Lists `@code` fields: `_notificationLists`, `_recipients`, `_notifSearch`, `FilteredNotificationLists` (~lines 110-117).
- In `LoadAllAsync()`, the lines that populate `_notificationLists` and the `_recipients` loop (~lines 141-148).
- The now-unused `@inject INotificationRepository NotificationRepository` (line 9) and `@using ScadaLink.Commons.Entities.Notifications` (line 4) — remove only if nothing else in the file still uses them (verify by search).

**Step 4: Build to verify**

Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj`
Expected: Build succeeded, 0 warnings. (A warning here usually means a leftover unused `using`/`@inject` — remove it.)

**Step 5: Commit**

```bash
git add src/ScadaLink.CentralUI/Components/Pages/Design/NotificationListForm.razor \
        src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor
git commit -m "refactor(central-ui): move Notification List form to /notifications, drop External Systems tab"
```

---

## Task 9: New Notification Report page; retire the Outbox page

**Files:**
- Create: `src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor`
- Delete: `src/ScadaLink.CentralUI/Components/Pages/Monitoring/NotificationOutbox.razor`
- Rename: `tests/ScadaLink.CentralUI.Tests/Pages/NotificationOutboxPageTests.cs` → `NotificationReportPageTests.cs`

**Context:** `Monitoring/NotificationOutbox.razor` currently combines KPI tiles (markup lines 23-72) with a filterable, paginated notifications table + Retry/Discard (lines 74-253). The new `NotificationReport.razor` keeps everything **except** the KPI tiles. Its existing test, `NotificationOutboxPageTests.cs`, renders the page via a scripted Akka actor seam and asserts on both KPI tiles and table rows.

**Step 1: Create `NotificationReport.razor`** as a copy of `Monitoring/NotificationOutbox.razor` with these changes:
- Line 1 route: `@page "/monitoring/notification-outbox"` → `@page "/notifications/report"`.
- The `@inject ILogger<NotificationOutbox> Logger` type argument → `ILogger<NotificationReport>` (the class name follows the file).
- Page heading `<h4 ...>Notification Outbox</h4>` → `<h4 ...>Notification Report</h4>`.
- **Delete the KPI tiles markup block** — the `@* ── KPI tiles ── *@` comment through the closing of its `else { ... }` (lines 23-72).
- **Delete the KPI `@code` members:** `_kpi`, `_kpiError`, and the `LoadKpis()` method.
- **Simplify `RefreshAll()`** — it currently does `await Task.WhenAll(LoadKpis(), FetchPage());`. Replace its body with `await FetchPage();` (keep the method name so the Retry/Discard callers are untouched), or inline `FetchPage()` at the three call sites and delete `RefreshAll()`. Keep `FormatAge` only if still referenced; if the KPI removal orphans it, delete it (build will flag it under `TreatWarningsAsErrors`).

**Step 2: Delete the old page**

```bash
git rm src/ScadaLink.CentralUI/Components/Pages/Monitoring/NotificationOutbox.razor
```

**Step 3: Migrate the test.** Rename the test file and update it:

```bash
git mv tests/ScadaLink.CentralUI.Tests/Pages/NotificationOutboxPageTests.cs \
       tests/ScadaLink.CentralUI.Tests/Pages/NotificationReportPageTests.cs
```

In `NotificationReportPageTests.cs`:
- Rename the class to `NotificationReportPageTests`.
- Change the page alias: `using NotificationReportPage = ScadaLink.CentralUI.Components.Pages.Notifications.NotificationReport;` and update render calls.
- **Delete the KPI-tile assertions** (any test asserting on Queue Depth / Stuck / Parked tile values, and the `_kpiReply` field if now unused — but `_queryReply`, retry/discard scripting stay).
- Keep the table-rendering, filter, pagination, and Retry/Discard tests.

**Step 4: Run test to verify it passes**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationReportPageTests`
Expected: PASS.

Also build the UI project to confirm no dangling references to the deleted page:

Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj`
Expected: Build succeeded, 0 warnings.

**Step 5: Commit**

```bash
git add src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor \
        src/ScadaLink.CentralUI/Components/Pages/Monitoring/NotificationOutbox.razor \
        tests/ScadaLink.CentralUI.Tests/Pages/NotificationOutboxPageTests.cs \
        tests/ScadaLink.CentralUI.Tests/Pages/NotificationReportPageTests.cs
git commit -m "refactor(central-ui): split Notification Report out of the Outbox page"
```

---

## Task 10: New Notification KPIs page

**Files:**
- Create: `src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationKpis.razor`
- Test: `tests/ScadaLink.CentralUI.Tests/Pages/NotificationKpisPageTests.cs`

**Context:** This page shows the five global KPI tiles (lifted from the old Outbox page, markup lines 30-71) plus a per-source-site breakdown table backed by `CommunicationService.GetPerSiteNotificationKpisAsync` (Task 5). `CommunicationService` is a concrete class with non-virtual methods, so tests drive it through the scripted-actor seam — copy the `ActorSystem` + scripted `ReceiveActor` + `SetNotificationOutbox` setup verbatim from `NotificationReportPageTests.cs` (Task 9). The scripted actor must answer **both** `NotificationKpiRequest` and `PerSiteNotificationKpiRequest`. Site friendly-names come from `ISiteRepository.GetAllSitesAsync()` (`Site.SiteIdentifier` / `Site.Name`), exactly as the old Outbox page resolved the source-site filter.

**Step 1: Write the failing test**

```csharp
// Copy the ActorSystem / scripted-actor / CommunicationService / ISiteRepository / auth
// wiring from NotificationReportPageTests. Script the actor to reply:
//   NotificationKpiRequest        -> a NotificationKpiResponse with known tile values
//   PerSiteNotificationKpiRequest -> a PerSiteNotificationKpiResponse with one site row

[Fact]
public void RendersGlobalTilesAndPerSiteRows()
{
    // _kpiReply: QueueDepth 7, StuckCount 2, ParkedCount 1, DeliveredLastInterval 42
    // _perSiteReply: [ SiteNotificationKpiSnapshot("plant-a", 4, 1, 0, 9, 7m) ]
    var cut = Render<NotificationKpisPage>();

    Assert.Contains("Queue Depth", cut.Markup);
    Assert.Contains("7", cut.Markup);            // global tile value
    Assert.Contains("plant-a", cut.Markup);      // per-site row
}

[Fact]
public void ShowsKpiError_WhenGlobalKpiQueryFails()
{
    // script NotificationKpiRequest -> response with Success:false, ErrorMessage:"kpi down"
    var cut = Render<NotificationKpisPage>();
    Assert.Contains("kpi down", cut.Markup);
}

[Fact]
public void ShowsPerSiteEmptyState_WhenNoSites()
{
    // script PerSiteNotificationKpiRequest -> Success:true, empty Sites
    var cut = Render<NotificationKpisPage>();
    Assert.Contains("No per-site activity", cut.Markup);
}
```

Use `using NotificationKpisPage = ScadaLink.CentralUI.Components.Pages.Notifications.NotificationKpis;`.

**Step 2: Run test to verify it fails**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationKpisPageTests`
Expected: FAIL — `NotificationKpis` page does not exist.

**Step 3: Write minimal implementation**

`NotificationKpis.razor`:

```razor
@page "/notifications/kpis"
@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)]
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Messages.Notification
@using ScadaLink.Commons.Types.Notifications
@using ScadaLink.Communication
@inject CommunicationService CommunicationService
@inject ISiteRepository SiteRepository
@inject ILogger<NotificationKpis> Logger

<div class="container-fluid mt-3">
    <div class="d-flex justify-content-between align-items-center mb-3">
        <h4 class="mb-0">Notification KPIs</h4>
        <button class="btn btn-outline-secondary btn-sm" @onclick="RefreshAll" disabled="@_loading">
            @if (_loading) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
            Refresh
        </button>
    </div>

    @* ── Global KPI tiles ── *@
    @if (_kpiError != null)
    {
        <div class="alert alert-warning py-2">KPIs unavailable: @_kpiError</div>
    }
    else
    {
        <div class="row g-3 mb-4">
            <div class="col-lg col-md-4 col-6">
                <div class="card h-100">
                    <div class="card-body text-center py-3">
                        <h3 class="mb-0">@_kpi.QueueDepth</h3>
                        <small class="text-muted">Queue Depth</small>
                    </div>
                </div>
            </div>
            <div class="col-lg col-md-4 col-6">
                <div class="card h-100 @(_kpi.StuckCount > 0 ? "border-warning" : "")">
                    <div class="card-body text-center py-3">
                        <h3 class="mb-0 @(_kpi.StuckCount > 0 ? "text-warning" : "")">@_kpi.StuckCount</h3>
                        <small class="text-muted">Stuck</small>
                    </div>
                </div>
            </div>
            <div class="col-lg col-md-4 col-6">
                <div class="card h-100 @(_kpi.ParkedCount > 0 ? "border-danger" : "")">
                    <div class="card-body text-center py-3">
                        <h3 class="mb-0 @(_kpi.ParkedCount > 0 ? "text-danger" : "")">@_kpi.ParkedCount</h3>
                        <small class="text-muted">Parked</small>
                    </div>
                </div>
            </div>
            <div class="col-lg col-md-4 col-6">
                <div class="card h-100">
                    <div class="card-body text-center py-3">
                        <h3 class="mb-0 text-success">@_kpi.DeliveredLastInterval</h3>
                        <small class="text-muted">Delivered (last interval)</small>
                    </div>
                </div>
            </div>
            <div class="col-lg col-md-4 col-6">
                <div class="card h-100">
                    <div class="card-body text-center py-3">
                        <h3 class="mb-0">@FormatAge(_kpi.OldestPendingAge)</h3>
                        <small class="text-muted">Oldest Pending Age</small>
                    </div>
                </div>
            </div>
        </div>
    }

    @* ── Per-site breakdown ── *@
    <h5 class="mb-2">Per-site breakdown</h5>
    @if (_perSiteError != null)
    {
        <div class="alert alert-warning py-2">Per-site KPIs unavailable: @_perSiteError</div>
    }
    else if (_perSite.Count == 0)
    {
        <div class="card">
            <div class="card-body text-center text-muted py-4">
                <div class="small">No per-site activity.</div>
            </div>
        </div>
    }
    else
    {
        <div class="table-responsive">
            <table class="table table-sm table-hover align-middle">
                <thead class="table-light">
                    <tr>
                        <th>Site</th>
                        <th class="text-end">Queue Depth</th>
                        <th class="text-end">Stuck</th>
                        <th class="text-end">Parked</th>
                        <th class="text-end">Delivered (last interval)</th>
                        <th class="text-end">Oldest Pending Age</th>
                    </tr>
                </thead>
                <tbody>
                    @foreach (var s in _perSite)
                    {
                        <tr @key="s.SourceSiteId" class="@(s.StuckCount > 0 ? "table-warning" : "")">
                            <td>@SiteName(s.SourceSiteId)</td>
                            <td class="text-end font-monospace">@s.QueueDepth</td>
                            <td class="text-end font-monospace @(s.StuckCount > 0 ? "text-warning" : "")">@s.StuckCount</td>
                            <td class="text-end font-monospace @(s.ParkedCount > 0 ? "text-danger" : "")">@s.ParkedCount</td>
                            <td class="text-end font-monospace text-success">@s.DeliveredLastInterval</td>
                            <td class="text-end font-monospace">@FormatAge(s.OldestPendingAge)</td>
                        </tr>
                    }
                </tbody>
            </table>
        </div>
    }
</div>

@code {
    private List<Site> _sites = new();

    private NotificationKpiResponse _kpi = new(string.Empty, true, null, 0, 0, 0, 0, null);
    private string? _kpiError;

    private IReadOnlyList<SiteNotificationKpiSnapshot> _perSite = Array.Empty<SiteNotificationKpiSnapshot>();
    private string? _perSiteError;

    private bool _loading;

    protected override async Task OnInitializedAsync()
    {
        try
        {
            _sites = (await SiteRepository.GetAllSitesAsync()).ToList();
        }
        catch (Exception ex)
        {
            // Non-fatal — the per-site table falls back to raw site identifiers.
            Logger.LogWarning(ex, "Failed to load sites for the KPI per-site breakdown.");
        }

        await RefreshAll();
    }

    private async Task RefreshAll()
    {
        _loading = true;
        // Race-free despite both tasks mutating component fields: Blazor Server runs
        // every continuation on the circuit's single-threaded synchronization context.
        await Task.WhenAll(LoadGlobalKpis(), LoadPerSiteKpis());
        _loading = false;
    }

    private async Task LoadGlobalKpis()
    {
        try
        {
            var response = await CommunicationService.GetNotificationKpisAsync(
                new NotificationKpiRequest(Guid.NewGuid().ToString("N")));
            if (response.Success)
            {
                _kpi = response;
                _kpiError = null;
            }
            else
            {
                _kpiError = response.ErrorMessage ?? "KPI query failed.";
            }
        }
        catch (Exception ex)
        {
            _kpiError = $"KPI query failed: {ex.Message}";
        }
    }

    private async Task LoadPerSiteKpis()
    {
        try
        {
            var response = await CommunicationService.GetPerSiteNotificationKpisAsync(
                new PerSiteNotificationKpiRequest(Guid.NewGuid().ToString("N")));
            if (response.Success)
            {
                _perSite = response.Sites;
                _perSiteError = null;
            }
            else
            {
                _perSiteError = response.ErrorMessage ?? "Per-site KPI query failed.";
            }
        }
        catch (Exception ex)
        {
            _perSiteError = $"Per-site KPI query failed: {ex.Message}";
        }
    }

    private string SiteName(string siteId) =>
        _sites.FirstOrDefault(s => s.SiteIdentifier == siteId)?.Name ?? siteId;

    private static string FormatAge(TimeSpan? age)
    {
        if (age == null) return "—";
        var t = age.Value;
        if (t.TotalSeconds < 60) return $"{(int)t.TotalSeconds}s";
        if (t.TotalMinutes < 60) return $"{(int)t.TotalMinutes}m";
        if (t.TotalHours < 24) return $"{(int)t.TotalHours}h";
        return $"{(int)t.TotalDays}d";
    }
}
```

**Step 4: Run test to verify it passes**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationKpisPageTests`
Expected: PASS.

**Step 5: Commit**

```bash
git add src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationKpis.razor \
        tests/ScadaLink.CentralUI.Tests/Pages/NotificationKpisPageTests.cs
git commit -m "feat(central-ui): Notification KPIs page with per-site breakdown"
```

---

## Task 11: NavMenu — add the Notifications section

**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor`
- Test: `tests/ScadaLink.CentralUI.Tests/Layout/NavMenuTests.cs` (create)

**Context:** `NavMenu.razor` builds the sidebar. It has gated sections (Admin / Design / Deployment each wrapped in one `AuthorizeView Policy=...`) and a Monitoring section with per-item gating. Two existing items must move out: the **SMTP Configuration** `<li>` in the Admin section and the **Notification Outbox** `<li>` in the Monitoring section. The new Notifications section goes **between the Deployment section and the Monitoring section**. Each item is wrapped in its own per-item `AuthorizeView` (the Monitoring section already does this — copy that shape). The plain-`div` section header needs no gating: every authenticated user holds at least one of Admin/Design/Deployment.

**Step 1: Write the failing test**

bUnit-render `NavMenu` under different role principals. Copy the authorization wiring from an existing nav/auth test (e.g. `Monitoring/MonitoringAuthorizationTests.cs` or `NotificationReportPageTests`).

```csharp
using Bunit;
using ScadaLink.Security;
using NavMenu = ScadaLink.CentralUI.Components.Layout.NavMenu;

namespace ScadaLink.CentralUI.Tests.Layout;

public class NavMenuTests : BunitContext
{
    [Fact]
    public void NotificationsSection_ShowsAllItems_ForMultiRoleUser()
    {
        // authorize a principal holding Admin + Design + Deployment roles
        var cut = Render<NavMenu>();   // with the authorized principal wired in

        Assert.Contains("Notifications", cut.Markup);
        Assert.Contains("/notifications/smtp", cut.Markup);
        Assert.Contains("/notifications/lists", cut.Markup);
        Assert.Contains("/notifications/report", cut.Markup);
        Assert.Contains("/notifications/kpis", cut.Markup);
    }

    [Fact]
    public void NotificationsSection_AdminOnlyUser_SeesOnlySmtp()
    {
        // authorize a principal holding only the Admin role
        var cut = Render<NavMenu>();

        Assert.Contains("/notifications/smtp", cut.Markup);
        Assert.DoesNotContain("/notifications/report", cut.Markup);
        Assert.DoesNotContain("/notifications/lists", cut.Markup);
    }

    [Fact]
    public void OldRoutes_AreNoLongerLinked()
    {
        // authorize a multi-role principal
        var cut = Render<NavMenu>();

        Assert.DoesNotContain("/admin/smtp", cut.Markup);
        Assert.DoesNotContain("/monitoring/notification-outbox", cut.Markup);
    }
}
```

**Step 2: Run test to verify it fails**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NavMenuTests`
Expected: FAIL — old routes still present, new section absent.

**Step 3: Write minimal implementation**

In `NavMenu.razor`:

1. **Remove** the SMTP `<li>` from the Admin section:
   ```razor
   <li class="nav-item">
       <NavLink class="nav-link" href="/admin/smtp">SMTP Configuration</NavLink>
   </li>
   ```

2. **Remove** the Notification Outbox `<li>` from the Monitoring section:
   ```razor
   <li class="nav-item">
       <NavLink class="nav-link" href="/monitoring/notification-outbox">Notification Outbox</NavLink>
   </li>
   ```

3. **Insert the Notifications section** between the closing of the Deployment `AuthorizeView` and the Monitoring `<div role="presentation" class="nav-section-header">Monitoring</div>` line:

   ```razor
   @* Notifications — mixed-role section; each item gated by its own policy.
      The header is ungated: every authenticated user holds at least one of
      Admin/Design/Deployment, so it always has a visible child. *@
   <div role="presentation" class="nav-section-header">Notifications</div>
   <AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
       <Authorized Context="notifAdminContext">
           <li class="nav-item">
               <NavLink class="nav-link" href="/notifications/smtp">SMTP Configuration</NavLink>
           </li>
       </Authorized>
   </AuthorizeView>
   <AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
       <Authorized Context="notifDesignContext">
           <li class="nav-item">
               <NavLink class="nav-link" href="/notifications/lists">Notification Lists</NavLink>
           </li>
       </Authorized>
   </AuthorizeView>
   <AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
       <Authorized Context="notifDeploymentContext">
           <li class="nav-item">
               <NavLink class="nav-link" href="/notifications/report">Notification Report</NavLink>
           </li>
           <li class="nav-item">
               <NavLink class="nav-link" href="/notifications/kpis">Notification KPIs</NavLink>
           </li>
       </Authorized>
   </AuthorizeView>
   ```

   Each `<Authorized Context="...">` needs a unique context name (nested `AuthorizeView`s require it — the file already uses this pattern).

**Step 4: Run test to verify it passes**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NavMenuTests`
Expected: PASS.

**Step 5: Commit**

```bash
git add src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor \
        tests/ScadaLink.CentralUI.Tests/Layout/NavMenuTests.cs
git commit -m "feat(central-ui): add the Notifications nav section"
```

---

## Task 12: Health dashboard — link to the KPI page

**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor`
- Test: `tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs` (add a case)

**Context:** `Health.razor` keeps a "Notification Outbox" headline tile row (`<h6 ...>Notification Outbox</h6>` at line ~24, three tiles below). Add a "View details →" link to `/notifications/kpis` next to that heading. The tiles and their data path are unchanged.

**Step 1: Write the failing test**

Add to `HealthPageTests.cs`, following its existing render setup:

```csharp
[Fact]
public void RendersLinkToTheNotificationKpisPage()
{
    var cut = Render<Health>();   // match the existing HealthPageTests render call
    Assert.Contains("/notifications/kpis", cut.Markup);
}
```

**Step 2: Run test to verify it fails**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter RendersLinkToTheNotificationKpisPage`
Expected: FAIL — no such link.

**Step 3: Write minimal implementation**

In `Health.razor`, replace the bare heading line:

```razor
<h6 class="text-muted mb-2">Notification Outbox</h6>
```

with a heading row carrying the link:

```razor
<div class="d-flex justify-content-between align-items-center mb-2">
    <h6 class="text-muted mb-0">Notification Outbox</h6>
    <a class="small" href="/notifications/kpis">View details &rarr;</a>
</div>
```

**Step 4: Run test to verify it passes**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter HealthPageTests`
Expected: PASS.

**Step 5: Commit**

```bash
git add src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor \
        tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs
git commit -m "feat(central-ui): link Health outbox tiles to the Notification KPIs page"
```

---

## Task 13: Full build + suite verification

**Files:** none (verification only).

**Step 1: Clean build**

Run: `dotnet build ScadaLink.slnx`
Expected: Build succeeded, **0 warnings** (`TreatWarningsAsErrors` is on).

**Step 2: Full test suite**

Run: `dotnet test ScadaLink.slnx`
Expected: all test projects green. If `SandboxTests.Sandbox_LongRunningScript_TimesOut` or an `InstanceActorChildAttributeRace` test fails, that is a known pre-existing CPU-timing flake under parallel load — re-run `tests/ScadaLink.SiteRuntime.Tests` in isolation to confirm it is not a regression from this work.

**Step 3: Grep for stale references**

Run:
```bash
grep -rn "monitoring/notification-outbox\|admin/smtp\|design/notification-lists" \
     src tests --include="*.razor" --include="*.cs" | grep -v "/obj/" | grep -v "/bin/"
```
Expected: **no matches.** Any hit is a stale link to fix before finishing.

**Step 4: Commit (only if Step 3 required fixes)**

```bash
git add <explicit fixed paths>
git commit -m "fix(central-ui): clear stale notification route references"
```

If no fixes were needed, there is nothing to commit — the work is complete.

---

## Follow-ups (not in scope, track separately)

- Per-site KPI **trend** charts would need a time-series store — deliberately omitted (no historical persistence; KPIs are point-in-time).
- The `Notification Outbox` **component** name (#21 in CLAUDE.md) is unchanged; only UI page names changed. If the component is ever renamed, update CLAUDE.md and `README.md` together.
