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