fix(ui): auto-refresh the unread anomaly badge

The nav unread counter was only recomputed when the Anomalies feed page
loaded, so newly-detected anomalies didn't update it until the user clicked
the tab. NavBody now polls the (cheap, indexed) server-side unread count
every 15s — and once immediately on startup — using a fresh DI scope per
tick, updating the singleton AnomalyBrowsingState, which re-renders the
badge only when the count actually changes.

Build clean, all 568 tests green.
This commit is contained in:
2026-05-29 15:20:19 +03:00
parent 6f0d74b56e
commit def878f773
+52
View File
@@ -1,6 +1,9 @@
@implements IDisposable
@using Microsoft.Extensions.DependencyInjection
@inject IStringLocalizer<SharedResource> L
@inject AnomalyBrowsingState AnomalyState
@inject IServiceScopeFactory ScopeFactory
@inject ILogger<NavBody> Logger
<nav class="m-nav" aria-label="primary">
<div style="padding: var(--m-space-5) var(--m-space-4) var(--m-space-4); border-bottom: 2px solid var(--m-c-ink);">
@@ -100,9 +103,56 @@
</style>
@code {
// Keep the unread badge live without a visit to the feed. A short poll is fine:
// the count is a cheap server-side COUNT(*) over an indexed timestamp.
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(15);
private readonly CancellationTokenSource _cts = new();
protected override void OnInitialized()
{
AnomalyState.OnChange += OnAnomalyStateChanged;
_ = PollUnreadAsync(_cts.Token);
}
private async Task PollUnreadAsync(CancellationToken ct)
{
try
{
// Refresh once immediately so the badge is correct at startup too,
// then keep it fresh on the timer.
await RefreshUnreadAsync(ct).ConfigureAwait(false);
using var timer = new PeriodicTimer(PollInterval);
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
{
await RefreshUnreadAsync(ct).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Component disposed — stop polling.
}
}
private async Task RefreshUnreadAsync(CancellationToken ct)
{
try
{
// Fresh DI scope per tick so we never touch a page's scoped DbContext
// concurrently from this background loop.
await using var scope = ScopeFactory.CreateAsyncScope();
var anomalies = scope.ServiceProvider.GetRequiredService<IAnomalyBrowsingService>();
var count = await anomalies.GetUnreadCountAsync(AnomalyState.LastSeenUtc, ct).ConfigureAwait(false);
AnomalyState.SetUnreadCount(count); // no-op (and no re-render) when unchanged
}
catch (OperationCanceledException)
{
throw; // bubble cancellation so the poll loop exits
}
catch (Exception ex)
{
Logger.LogDebug(ex, "NavBody: failed to refresh unread anomaly count; retrying next tick.");
}
}
private void OnAnomalyStateChanged() => InvokeAsync(StateHasChanged);
@@ -110,5 +160,7 @@
public void Dispose()
{
AnomalyState.OnChange -= OnAnomalyStateChanged;
_cts.Cancel();
_cts.Dispose();
}
}