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:
@@ -99,6 +99,23 @@
|
||||
}
|
||||
</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__group">
|
||||
<label class="m-list-toolbar__label">@L["Anomaly.Filter.From"]</label>
|
||||
@@ -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<AnomalyListItem> _items = new();
|
||||
private IReadOnlyList<int> _availableSports = Array.Empty<int>();
|
||||
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))
|
||||
|
||||
@@ -193,6 +193,10 @@
|
||||
<data name="Anomaly.Filter.Severity"><value>Min severity</value></data>
|
||||
<data name="Anomaly.Filter.Sport"><value>Sport</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.To"><value>Detected to</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.Sport"><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.To"><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));
|
||||
}
|
||||
|
||||
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<AnomalyDetailVm?> GetByIdAsync(Guid id, CancellationToken ct)
|
||||
|
||||
@@ -16,16 +16,30 @@ public enum AnomalySeverity
|
||||
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>
|
||||
/// 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>
|
||||
public sealed record AnomalyFilter(
|
||||
AnomalySeverity? MinSeverity = null,
|
||||
IReadOnlyCollection<int>? SportCodes = null,
|
||||
DateTimeOffset? From = null,
|
||||
DateTimeOffset? To = null,
|
||||
IReadOnlyCollection<AnomalyKind>? Kinds = null);
|
||||
IReadOnlyCollection<AnomalyKind>? Kinds = null,
|
||||
AnomalySort Sort = AnomalySort.Newest);
|
||||
|
||||
/// <summary>
|
||||
/// Compact anomaly row used by the feed page. Designed to render without any
|
||||
|
||||
Reference in New Issue
Block a user