feat(anomalies): sort the feed (newest / top score / longest gap)

Adds a sort chip row to the anomaly feed — newest (default), highest score, or
longest suspension gap — replacing the fixed newest-first order. DetectedAt is the
tiebreak so order stays stable.

- AnomalySort enum + AnomalyFilter.Sort (default Newest, so existing constructions
  are unaffected); AnomalyBrowsingService applies it; feed sort chips +
  SetSort/SortLabel; en/ru resx.
- 2 tests: default newest-first + highest-score ordering.
This commit is contained in:
2026-05-29 11:55:46 +03:00
parent 67f2ae130c
commit 36178e6d1b
6 changed files with 93 additions and 7 deletions
@@ -99,6 +99,23 @@
} }
</div> </div>
<div class="m-list-toolbar__row m-list-toolbar__chips">
<span class="m-list-toolbar__label">@L["Anomaly.Filter.Sort"]</span>
@foreach (var sort in _sortOptions)
{
var localSort = sort;
var active = _filter.Sort == localSort;
<button type="button"
class="m-chip @(active ? "is-active" : null)"
aria-pressed="@active"
data-test="sort-chip"
data-sort="@localSort"
@onclick="() => SetSort(localSort)">
@SortLabel(localSort)
</button>
}
</div>
<div class="m-list-toolbar__row"> <div class="m-list-toolbar__row">
<div class="m-list-toolbar__group"> <div class="m-list-toolbar__group">
<label class="m-list-toolbar__label">@L["Anomaly.Filter.From"]</label> <label class="m-list-toolbar__label">@L["Anomaly.Filter.From"]</label>
@@ -198,6 +215,9 @@
private static readonly AnomalyKind[] _kindOptions = private static readonly AnomalyKind[] _kindOptions =
{ AnomalyKind.SuspensionFlip, AnomalyKind.SteamMove, AnomalyKind.SuspensionFreeze, AnomalyKind.OverroundCompression }; { AnomalyKind.SuspensionFlip, AnomalyKind.SteamMove, AnomalyKind.SuspensionFreeze, AnomalyKind.OverroundCompression };
private static readonly AnomalySort[] _sortOptions =
{ AnomalySort.Newest, AnomalySort.HighestScore, AnomalySort.LongestGap };
private List<AnomalyListItem> _items = new(); private List<AnomalyListItem> _items = new();
private IReadOnlyList<int> _availableSports = Array.Empty<int>(); private IReadOnlyList<int> _availableSports = Array.Empty<int>();
private bool _loading = true; private bool _loading = true;
@@ -287,6 +307,16 @@
_ => kind.ToString(), _ => kind.ToString(),
}; };
private Task SetSort(AnomalySort sort) => UpdateFilter(_filter with { Sort = sort });
private string SortLabel(AnomalySort sort) => sort switch
{
AnomalySort.Newest => L["Anomaly.Sort.Newest"],
AnomalySort.HighestScore => L["Anomaly.Sort.Score"],
AnomalySort.LongestGap => L["Anomaly.Sort.Gap"],
_ => sort.ToString(),
};
private async Task OnFromChanged(ChangeEventArgs e) private async Task OnFromChanged(ChangeEventArgs e)
{ {
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v)) if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
@@ -193,6 +193,10 @@
<data name="Anomaly.Filter.Severity"><value>Min severity</value></data> <data name="Anomaly.Filter.Severity"><value>Min severity</value></data>
<data name="Anomaly.Filter.Sport"><value>Sport</value></data> <data name="Anomaly.Filter.Sport"><value>Sport</value></data>
<data name="Anomaly.Filter.Kind"><value>Kind</value></data> <data name="Anomaly.Filter.Kind"><value>Kind</value></data>
<data name="Anomaly.Filter.Sort"><value>Sort</value></data>
<data name="Anomaly.Sort.Newest"><value>Newest</value></data>
<data name="Anomaly.Sort.Score"><value>Top score</value></data>
<data name="Anomaly.Sort.Gap"><value>Longest gap</value></data>
<data name="Anomaly.Filter.From"><value>Detected from</value></data> <data name="Anomaly.Filter.From"><value>Detected from</value></data>
<data name="Anomaly.Filter.To"><value>Detected to</value></data> <data name="Anomaly.Filter.To"><value>Detected to</value></data>
<data name="Anomaly.Filter.DateRange"><value>Date range</value></data> <data name="Anomaly.Filter.DateRange"><value>Date range</value></data>
@@ -206,6 +206,10 @@
<data name="Anomaly.Filter.Severity"><value>Мин. важность</value></data> <data name="Anomaly.Filter.Severity"><value>Мин. важность</value></data>
<data name="Anomaly.Filter.Sport"><value>Вид спорта</value></data> <data name="Anomaly.Filter.Sport"><value>Вид спорта</value></data>
<data name="Anomaly.Filter.Kind"><value>Тип</value></data> <data name="Anomaly.Filter.Kind"><value>Тип</value></data>
<data name="Anomaly.Filter.Sort"><value>Сортировка</value></data>
<data name="Anomaly.Sort.Newest"><value>Новые</value></data>
<data name="Anomaly.Sort.Score"><value>По score</value></data>
<data name="Anomaly.Sort.Gap"><value>По паузе</value></data>
<data name="Anomaly.Filter.From"><value>Обнаружено с</value></data> <data name="Anomaly.Filter.From"><value>Обнаружено с</value></data>
<data name="Anomaly.Filter.To"><value>Обнаружено по</value></data> <data name="Anomaly.Filter.To"><value>Обнаружено по</value></data>
<data name="Anomaly.Filter.DateRange"><value>Диапазон дат</value></data> <data name="Anomaly.Filter.DateRange"><value>Диапазон дат</value></data>
@@ -64,9 +64,17 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
filtered = filtered.Where(i => kinds.Contains(i.Kind)); filtered = filtered.Where(i => kinds.Contains(i.Kind));
} }
return filtered // DetectedAt is the tiebreak for the score/gap sorts so order stays stable.
.OrderByDescending(static i => i.DetectedAt) var sorted = filter.Sort switch
.ToList(); {
AnomalySort.HighestScore =>
filtered.OrderByDescending(i => i.Score).ThenByDescending(i => i.DetectedAt),
AnomalySort.LongestGap =>
filtered.OrderByDescending(i => i.SuspensionGapSeconds).ThenByDescending(i => i.DetectedAt),
_ => filtered.OrderByDescending(i => i.DetectedAt),
};
return sorted.ToList();
} }
public async Task<AnomalyDetailVm?> GetByIdAsync(Guid id, CancellationToken ct) public async Task<AnomalyDetailVm?> GetByIdAsync(Guid id, CancellationToken ct)
+16 -2
View File
@@ -16,16 +16,30 @@ public enum AnomalySeverity
High, High,
} }
/// <summary>Sort order for the anomaly feed.</summary>
public enum AnomalySort
{
/// <summary>Most recently detected first (default).</summary>
Newest,
/// <summary>Highest confidence score first.</summary>
HighestScore,
/// <summary>Longest suspension gap first.</summary>
LongestGap,
}
/// <summary> /// <summary>
/// Filter state passed from a page to <see cref="IAnomalyBrowsingService"/>. /// Filter state passed from a page to <see cref="IAnomalyBrowsingService"/>.
/// All fields optional — empty filter returns the full feed. /// All fields optional — empty filter returns the full feed newest-first.
/// </summary> /// </summary>
public sealed record AnomalyFilter( public sealed record AnomalyFilter(
AnomalySeverity? MinSeverity = null, AnomalySeverity? MinSeverity = null,
IReadOnlyCollection<int>? SportCodes = null, IReadOnlyCollection<int>? SportCodes = null,
DateTimeOffset? From = null, DateTimeOffset? From = null,
DateTimeOffset? To = null, DateTimeOffset? To = null,
IReadOnlyCollection<AnomalyKind>? Kinds = null); IReadOnlyCollection<AnomalyKind>? Kinds = null,
AnomalySort Sort = AnomalySort.Newest);
/// <summary> /// <summary>
/// Compact anomaly row used by the feed page. Designed to render without any /// Compact anomaly row used by the feed page. Designed to render without any
@@ -29,8 +29,8 @@ public sealed class AnomalyBrowsingServiceTests
private AnomalyBrowsingService CreateSut() => new(_anomalies, _events); private AnomalyBrowsingService CreateSut() => new(_anomalies, _events);
private int _seq; private int _seq;
private Anomaly Make(AnomalyKind kind) => private Anomaly Make(AnomalyKind kind, decimal score = 0.70m, DateTimeOffset? detectedAt = null) =>
new(Guid.NewGuid(), new EventId($"evt-{_seq++}"), T0, kind, 0.70m, Evidence); new(Guid.NewGuid(), new EventId($"evt-{_seq++}"), detectedAt ?? T0, kind, score, Evidence);
/// <summary>Stubs the anomaly query AND a matching event per anomaly (events exist via FK).</summary> /// <summary>Stubs the anomaly query AND a matching event per anomaly (events exist via FK).</summary>
private void Setup(params Anomaly[] anomalies) private void Setup(params Anomaly[] anomalies)
@@ -74,4 +74,30 @@ public sealed class AnomalyBrowsingServiceTests
items.Should().HaveCount(3); items.Should().HaveCount(3);
} }
[Fact]
public async Task ListAsync_DefaultSort_IsNewestFirst()
{
Setup(
Make(AnomalyKind.SuspensionFlip, detectedAt: T0.AddHours(-2)),
Make(AnomalyKind.SteamMove, detectedAt: T0));
var items = await CreateSut().ListAsync(new AnomalyFilter(), CancellationToken.None);
items.First().DetectedAt.Should().Be(T0);
}
[Fact]
public async Task ListAsync_SortsByHighestScore()
{
Setup(
Make(AnomalyKind.SuspensionFlip, score: 0.40m),
Make(AnomalyKind.SteamMove, score: 0.80m),
Make(AnomalyKind.SuspensionFlip, score: 0.60m));
var items = await CreateSut().ListAsync(
new AnomalyFilter(Sort: AnomalySort.HighestScore), CancellationToken.None);
items.Select(i => i.Score).Should().ContainInOrder(0.80m, 0.60m, 0.40m);
}
} }