diff --git a/src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor b/src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor index 0eb82f7..c87195e 100644 --- a/src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor +++ b/src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor @@ -99,6 +99,23 @@ } +
+ @L["Anomaly.Filter.Sort"] + @foreach (var sort in _sortOptions) + { + var localSort = sort; + var active = _filter.Sort == localSort; + + } +
+
@@ -198,6 +215,9 @@ private static readonly AnomalyKind[] _kindOptions = { AnomalyKind.SuspensionFlip, AnomalyKind.SteamMove, AnomalyKind.SuspensionFreeze, AnomalyKind.OverroundCompression }; + private static readonly AnomalySort[] _sortOptions = + { AnomalySort.Newest, AnomalySort.HighestScore, AnomalySort.LongestGap }; + private List _items = new(); private IReadOnlyList _availableSports = Array.Empty(); private bool _loading = true; @@ -287,6 +307,16 @@ _ => 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) { if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v)) diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index d7fd7ef..b971398 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -193,6 +193,10 @@ Min severity Sport Kind + Sort + Newest + Top score + Longest gap Detected from Detected to Date range diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index 8c98797..0cb8fd3 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -206,6 +206,10 @@ Мин. важность Вид спорта Тип + Сортировка + Новые + По score + По паузе Обнаружено с Обнаружено по Диапазон дат diff --git a/src/Marathon.UI/Services/AnomalyBrowsingService.cs b/src/Marathon.UI/Services/AnomalyBrowsingService.cs index dd08858..30f3b5d 100644 --- a/src/Marathon.UI/Services/AnomalyBrowsingService.cs +++ b/src/Marathon.UI/Services/AnomalyBrowsingService.cs @@ -64,9 +64,17 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService filtered = filtered.Where(i => kinds.Contains(i.Kind)); } - return filtered - .OrderByDescending(static i => i.DetectedAt) - .ToList(); + // DetectedAt is the tiebreak for the score/gap sorts so order stays stable. + var sorted = filter.Sort switch + { + 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 GetByIdAsync(Guid id, CancellationToken ct) diff --git a/src/Marathon.UI/Services/AnomalyViewModels.cs b/src/Marathon.UI/Services/AnomalyViewModels.cs index 7c60a94..6542385 100644 --- a/src/Marathon.UI/Services/AnomalyViewModels.cs +++ b/src/Marathon.UI/Services/AnomalyViewModels.cs @@ -16,16 +16,30 @@ public enum AnomalySeverity High, } +/// Sort order for the anomaly feed. +public enum AnomalySort +{ + /// Most recently detected first (default). + Newest, + + /// Highest confidence score first. + HighestScore, + + /// Longest suspension gap first. + LongestGap, +} + /// /// Filter state passed from a page to . -/// All fields optional — empty filter returns the full feed. +/// All fields optional — empty filter returns the full feed newest-first. /// public sealed record AnomalyFilter( AnomalySeverity? MinSeverity = null, IReadOnlyCollection? SportCodes = null, DateTimeOffset? From = null, DateTimeOffset? To = null, - IReadOnlyCollection? Kinds = null); + IReadOnlyCollection? Kinds = null, + AnomalySort Sort = AnomalySort.Newest); /// /// Compact anomaly row used by the feed page. Designed to render without any diff --git a/tests/Marathon.UI.Tests/Services/AnomalyBrowsingServiceTests.cs b/tests/Marathon.UI.Tests/Services/AnomalyBrowsingServiceTests.cs index 4b18a3d..f1c2f3c 100644 --- a/tests/Marathon.UI.Tests/Services/AnomalyBrowsingServiceTests.cs +++ b/tests/Marathon.UI.Tests/Services/AnomalyBrowsingServiceTests.cs @@ -29,8 +29,8 @@ public sealed class AnomalyBrowsingServiceTests private AnomalyBrowsingService CreateSut() => new(_anomalies, _events); private int _seq; - private Anomaly Make(AnomalyKind kind) => - new(Guid.NewGuid(), new EventId($"evt-{_seq++}"), T0, kind, 0.70m, Evidence); + private Anomaly Make(AnomalyKind kind, decimal score = 0.70m, DateTimeOffset? detectedAt = null) => + new(Guid.NewGuid(), new EventId($"evt-{_seq++}"), detectedAt ?? T0, kind, score, Evidence); /// Stubs the anomaly query AND a matching event per anomaly (events exist via FK). private void Setup(params Anomaly[] anomalies) @@ -74,4 +74,30 @@ public sealed class AnomalyBrowsingServiceTests 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); + } }