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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user