refactor: hoist Moscow offset + sport labels into shared helpers (HIGH)

* New Marathon.Domain.ValueObjects.MoscowTime with Offset, Now, and
  EndOfMoscowDay(DateOnly) — replaces ~15 inline TimeSpan.FromHours(3)
  literals across Domain/Application/Infrastructure/UI.
* New Marathon.UI.Services.SportLabels.Resolve(IStringLocalizer, int) —
  replaces 6 near-identical SportLabel switch bodies in EventListShell,
  Events/Detail, Anomalies/AnomalyFeed, Results/ResultsList,
  Results/ResultsLoader, and AnomalyCard. Single source of truth for the
  6/11/22723/43658 sport-code mapping. Pages keep a one-liner wrapper so
  the call sites stay terse.
This commit is contained in:
2026-05-09 15:40:35 +03:00
parent d1e6ce7ce2
commit fed3a09695
18 changed files with 92 additions and 96 deletions
@@ -2,6 +2,7 @@ using Marathon.Application.Abstractions;
using Marathon.Application.Configuration;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -24,7 +25,6 @@ namespace Marathon.Application.UseCases;
/// </remarks>
public sealed class DetectAnomaliesUseCase
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private static readonly TimeSpan SnapshotLookback = TimeSpan.FromHours(24);
// Dedup window: two anomalies for the same event within this window are considered duplicates.
@@ -67,7 +67,7 @@ public sealed class DetectAnomaliesUseCase
var events = await _eventRepo.ListAsync(ct);
int newAnomalyCount = 0;
var now = DateTimeOffset.UtcNow.ToOffset(MoscowOffset);
var now = MoscowTime.Now;
var from = now - SnapshotLookback;
// Hoisted outside the per-event loop: load existing anomalies ONCE per cycle
@@ -27,8 +27,6 @@ namespace Marathon.Domain.AnomalyDetection;
/// </summary>
public sealed class AnomalyDetector
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private readonly int _suspensionGapSeconds;
private readonly decimal _oddsFlipThreshold;
private readonly int _minSnapshotCount;
@@ -156,7 +154,7 @@ public sealed class AnomalyDetector
return new Anomaly(
Id: Guid.NewGuid(),
EventId: eventId,
DetectedAt: DateTimeOffset.UtcNow.ToOffset(MoscowOffset),
DetectedAt: MoscowTime.Now,
Kind: AnomalyKind.SuspensionFlip,
Score: clampedScore,
EvidenceJson: evidenceJson);
+1 -3
View File
@@ -21,8 +21,6 @@ public sealed record Event(
string Side1Name,
string Side2Name)
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
public EventId Id { get; } = Id ?? throw new ArgumentNullException(nameof(Id));
public SportCode Sport { get; } = Sport ?? throw new ArgumentNullException(nameof(Sport));
@@ -37,7 +35,7 @@ public sealed record Event(
public string Category { get; } = Category ?? string.Empty;
public DateTimeOffset ScheduledAt { get; } = ScheduledAt.Offset == MoscowOffset
public DateTimeOffset ScheduledAt { get; } = ScheduledAt.Offset == MoscowTime.Offset
? ScheduledAt
: throw new ArgumentException(
$"ScheduledAt must be in Europe/Moscow time (UTC+03:00). " +
@@ -0,0 +1,32 @@
namespace Marathon.Domain.ValueObjects;
/// <summary>
/// Single source of truth for the Moscow timezone offset.
/// </summary>
/// <remarks>
/// <para>
/// marathonbet.by serves all timestamps in Moscow time (UTC+3) and the domain
/// invariant on <see cref="Marathon.Domain.Entities.Event.ScheduledAt"/>
/// rejects any other offset. Code that constructs <see cref="DateTimeOffset"/>
/// values for events, results, snapshots, or test fixtures MUST use this
/// constant rather than re-deriving <c>TimeSpan.FromHours(3)</c>.
/// </para>
/// </remarks>
public static class MoscowTime
{
/// <summary>The Moscow time offset (UTC+3).</summary>
public static readonly TimeSpan Offset = TimeSpan.FromHours(3);
/// <summary>Current Moscow time.</summary>
public static DateTimeOffset Now => DateTimeOffset.UtcNow.ToOffset(Offset);
/// <summary>
/// Returns the inclusive end-of-day for the given Moscow date — i.e.,
/// the moment one second before the next day starts. Used by date-range
/// filters where the user picks "to: 2026-05-09" meaning "through the
/// rest of that day."
/// </summary>
public static DateTimeOffset EndOfMoscowDay(DateOnly date) =>
new DateTimeOffset(date.ToDateTime(TimeOnly.MinValue), Offset)
.AddDays(1).AddSeconds(-1);
}
@@ -17,8 +17,6 @@ namespace Marathon.Infrastructure.Scraping.Parsers;
/// </summary>
public abstract class EventListingParserBase
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
protected readonly IServerTimeProvider ServerTimeProvider;
protected readonly ILogger Logger;
@@ -36,7 +34,7 @@ public abstract class EventListingParserBase
CancellationToken ct)
{
var serverTime = ServerTimeProvider.ExtractServerTime(html)
?? DateTimeOffset.UtcNow.ToOffset(MoscowOffset);
?? MoscowTime.Now;
var config = AngleSharpConfig.Default;
using var context = BrowsingContext.New(config);
@@ -18,8 +18,6 @@ namespace Marathon.Infrastructure.Scraping.Parsers;
/// </summary>
public sealed partial class EventOddsParser : IEventOddsParser
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private readonly IServerTimeProvider _serverTime;
private readonly PeriodScopeMapper _periodMapper;
private readonly ILogger<EventOddsParser> _logger;
@@ -69,7 +67,7 @@ public sealed partial class EventOddsParser : IEventOddsParser
ArgumentNullException.ThrowIfNull(html);
var capturedAt = _serverTime.ExtractServerTime(html)
?? DateTimeOffset.UtcNow.ToOffset(MoscowOffset);
?? MoscowTime.Now;
var config = AngleSharpConfig.Default;
using var context = BrowsingContext.New(config);
@@ -1,5 +1,6 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Marathon.Domain.ValueObjects;
namespace Marathon.Infrastructure.Scraping.Parsers;
@@ -13,8 +14,6 @@ namespace Marathon.Infrastructure.Scraping.Parsers;
/// </summary>
public static partial class MoscowDateParser
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
// Matches "HH:MM"
[GeneratedRegex(@"^\s*(\d{1,2}):(\d{2})\s*$", RegexOptions.CultureInvariant)]
private static partial Regex TimeOnlyRegex();
@@ -70,7 +69,7 @@ public static partial class MoscowDateParser
var scheduled = new DateTimeOffset(
today.Year, today.Month, today.Day,
hour, minute, 0,
MoscowOffset);
MoscowTime.Offset);
// If the computed time is already in the past (same day but earlier),
// that's fine — the event may have already started (live) or the listing
@@ -98,7 +97,7 @@ public static partial class MoscowDateParser
if (candidate < DateOnly.FromDateTime(anchorDate))
year++; // e.g., anchor is Dec 2026 and event is in Jan 2027
return new DateTimeOffset(year, month, day, hour, minute, 0, MoscowOffset);
return new DateTimeOffset(year, month, day, hour, minute, 0, MoscowTime.Offset);
}
return null;
@@ -21,8 +21,6 @@ namespace Marathon.Infrastructure.Scraping.Parsers;
/// </remarks>
public sealed partial class ResultsParser : IResultsParser
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private readonly ILogger<ResultsParser> _logger;
// Matches score patterns like "2:1", "2:1 (1:1)", "2:1 (0:0) (2:1)"
@@ -101,7 +99,7 @@ public sealed partial class ResultsParser : IResultsParser
if (string.IsNullOrWhiteSpace(eventIdRaw))
return null;
var completedAt = DateTimeOffset.UtcNow.ToOffset(MoscowOffset);
var completedAt = MoscowTime.Now;
return new EventResult(
new DomainEventId(eventIdRaw),
@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
namespace Marathon.Infrastructure.Scraping.Parsers;
@@ -17,8 +18,6 @@ public sealed partial class ServerTimeProvider : IServerTimeProvider
RegexOptions.CultureInvariant)]
private static partial Regex ServerTimeRegex();
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private readonly ILogger<ServerTimeProvider> _logger;
public ServerTimeProvider(ILogger<ServerTimeProvider> logger)
@@ -47,6 +46,6 @@ public sealed partial class ServerTimeProvider : IServerTimeProvider
var minute = int.Parse(match.Groups[5].Value);
var second = int.Parse(match.Groups[6].Value);
return new DateTimeOffset(year, month, day, hour, minute, second, MoscowOffset);
return new DateTimeOffset(year, month, day, hour, minute, second, MoscowTime.Offset);
}
}
+1 -8
View File
@@ -215,14 +215,7 @@
_ => kind.ToString(),
};
private string SportLabel(int code) => code switch
{
6 => L["Sport.Basketball"],
11 => L["Sport.Football"],
22723 => L["Sport.Tennis"],
43658 => L["Sport.Hockey"],
_ => string.Format(System.Globalization.CultureInfo.InvariantCulture, "Sport {0}", code),
};
private string SportLabel(int code) => SportLabels.Resolve(L, code);
private static string FormatRate(decimal? r) => r is { } v
? v.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture)
@@ -113,7 +113,7 @@
protected override void OnInitialized()
{
var moscow = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(3));
var moscow = MoscowTime.Now;
_from = InitialFrom ?? moscow.AddDays(-1).Date;
_to = InitialTo ?? moscow.AddDays(1).Date;
_kind = InitialKind;
@@ -140,10 +140,9 @@
try
{
// Use Moscow offset to match domain ScheduledAt invariant.
var moscow = TimeSpan.FromHours(3);
var range = new AppDateRange(
new DateTimeOffset(_from.Value.Date, moscow),
new DateTimeOffset(_to.Value.Date.AddDays(1).AddSeconds(-1), moscow));
new DateTimeOffset(_from.Value.Date, MoscowTime.Offset),
MoscowTime.EndOfMoscowDay(DateOnly.FromDateTime(_to.Value.Date)));
var path = await ExportUseCase.ExecuteAsync(range, _kind, CancellationToken.None);
Dialog.Close(DialogResult.Ok(path));
@@ -252,8 +252,7 @@
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
var moscow = TimeSpan.FromHours(3);
await UpdateFilter(_filter with { From = new DateTimeOffset(v.Date, moscow) });
await UpdateFilter(_filter with { From = new DateTimeOffset(v.Date, MoscowTime.Offset) });
}
}
@@ -261,8 +260,7 @@
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
var moscow = TimeSpan.FromHours(3);
await UpdateFilter(_filter with { To = new DateTimeOffset(v.Date, moscow).AddDays(1).AddSeconds(-1) });
await UpdateFilter(_filter with { To = MoscowTime.EndOfMoscowDay(DateOnly.FromDateTime(v.Date)) });
}
}
@@ -283,14 +281,7 @@
_ => L["Anomaly.Severity.Low"],
};
private string SportLabel(int code) => code switch
{
6 => L["Sport.Basketball"],
11 => L["Sport.Football"],
22723 => L["Sport.Tennis"],
43658 => L["Sport.Hockey"],
_ => string.Format(System.Globalization.CultureInfo.InvariantCulture, "Sport {0}", code),
};
private string SportLabel(int code) => SportLabels.Resolve(L, code);
private static string FormatDate(DateTimeOffset? value)
=> value?.ToString("yyyy-MM-dd") ?? string.Empty;
+1 -8
View File
@@ -306,14 +306,7 @@
_ => "—",
};
private string SportLabel(int code) => code switch
{
6 => L["Sport.Basketball"],
11 => L["Sport.Football"],
22723 => L["Sport.Tennis"],
43658 => L["Sport.Hockey"],
_ => $"Sport {code}",
};
private string SportLabel(int code) => SportLabels.Resolve(L, code);
private string BetTypeLabel(BetType t) => t switch
{
@@ -164,8 +164,6 @@
</style>
@code {
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private DateTimeOffset _from;
private DateTimeOffset _to;
private string _searchInput = string.Empty;
@@ -181,7 +179,7 @@
protected override async Task OnInitializedAsync()
{
var todayMoscow = new DateTimeOffset(DateTime.UtcNow.Date, TimeSpan.Zero).ToOffset(MoscowOffset);
var todayMoscow = new DateTimeOffset(DateTime.UtcNow.Date, TimeSpan.Zero).ToOffset(MoscowTime.Offset);
_from = todayMoscow.AddDays(-30);
_to = todayMoscow.AddDays(1).AddSeconds(-1);
@@ -246,7 +244,7 @@
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
_from = new DateTimeOffset(v.Date, MoscowOffset);
_from = new DateTimeOffset(v.Date, MoscowTime.Offset);
await LoadAsync();
}
}
@@ -255,7 +253,7 @@
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
_to = new DateTimeOffset(v.Date, MoscowOffset).AddDays(1).AddSeconds(-1);
_to = new DateTimeOffset(v.Date, MoscowTime.Offset).AddDays(1).AddSeconds(-1);
await LoadAsync();
}
}
@@ -311,14 +309,7 @@
_ => "draw",
};
private string SportLabel(int code) => code switch
{
6 => L["Sport.Basketball"],
11 => L["Sport.Football"],
22723 => L["Sport.Tennis"],
43658 => L["Sport.Hockey"],
_ => $"Sport {code}",
};
private string SportLabel(int code) => SportLabels.Resolve(L, code);
public void Dispose()
{
@@ -204,8 +204,6 @@
</style>
@code {
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private DateTimeOffset _from;
private DateTimeOffset _to;
private LoaderMode _mode = LoaderMode.AllInRange;
@@ -234,7 +232,7 @@
protected override async Task OnInitializedAsync()
{
var todayMoscow = new DateTimeOffset(DateTime.UtcNow.Date, TimeSpan.Zero).ToOffset(MoscowOffset);
var todayMoscow = new DateTimeOffset(DateTime.UtcNow.Date, TimeSpan.Zero).ToOffset(MoscowTime.Offset);
_from = todayMoscow.AddDays(-7);
_to = todayMoscow.AddDays(1).AddSeconds(-1);
await ReloadCandidatesAsync();
@@ -347,7 +345,7 @@
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
_from = new DateTimeOffset(v.Date, MoscowOffset);
_from = new DateTimeOffset(v.Date, MoscowTime.Offset);
await ReloadCandidatesAsync();
}
}
@@ -356,7 +354,7 @@
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
_to = new DateTimeOffset(v.Date, MoscowOffset).AddDays(1).AddSeconds(-1);
_to = new DateTimeOffset(v.Date, MoscowTime.Offset).AddDays(1).AddSeconds(-1);
await ReloadCandidatesAsync();
}
}
@@ -400,14 +398,7 @@
_ => o.ToString(),
};
private string SportLabel(int code) => code switch
{
6 => L["Sport.Basketball"],
11 => L["Sport.Football"],
22723 => L["Sport.Tennis"],
43658 => L["Sport.Hockey"],
_ => $"Sport {code}",
};
private string SportLabel(int code) => SportLabels.Resolve(L, code);
private static string FormatDate(DateTimeOffset value) => value.ToString("yyyy-MM-dd");
@@ -437,8 +437,7 @@
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
var moscow = TimeSpan.FromHours(3);
await UpdateFilter(_filter with { From = new DateTimeOffset(v.Date, moscow) });
await UpdateFilter(_filter with { From = new DateTimeOffset(v.Date, MoscowTime.Offset) });
}
}
@@ -446,8 +445,7 @@
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
var moscow = TimeSpan.FromHours(3);
await UpdateFilter(_filter with { To = new DateTimeOffset(v.Date, moscow).AddDays(1).AddSeconds(-1) });
await UpdateFilter(_filter with { To = MoscowTime.EndOfMoscowDay(DateOnly.FromDateTime(v.Date)) });
}
}
@@ -519,14 +517,7 @@
private static string FormatDate(DateTimeOffset value)
=> value.ToString("yyyy-MM-dd");
private string SportLabel(int code) => code switch
{
6 => L["Sport.Basketball"],
11 => L["Sport.Football"],
22723 => L["Sport.Tennis"],
43658 => L["Sport.Hockey"],
_ => $"Sport {code}",
};
private string SportLabel(int code) => SportLabels.Resolve(L, code);
private readonly record struct RatesSnap(decimal? Win1, decimal? Draw, decimal? Win2);
@@ -1,3 +1,5 @@
using Marathon.Domain.ValueObjects;
namespace Marathon.UI.Services;
/// <summary>
@@ -54,8 +56,7 @@ public sealed class EventBrowsingState
public static PageFilter Default(DateTime nowUtc)
{
// Default window: -1d .. +7d in Moscow time, the same TZ events use.
var moscow = TimeSpan.FromHours(3);
var midnight = new DateTimeOffset(nowUtc.Date, TimeSpan.Zero).ToOffset(moscow);
var midnight = new DateTimeOffset(nowUtc.Date, TimeSpan.Zero).ToOffset(MoscowTime.Offset);
return new PageFilter(
From: midnight.AddDays(-1),
To: midnight.AddDays(7),
+26
View File
@@ -0,0 +1,26 @@
using System.Globalization;
using Marathon.UI.Resources;
using Microsoft.Extensions.Localization;
namespace Marathon.UI.Services;
/// <summary>
/// Single source of truth for the sport-code → display-label mapping that
/// every list/detail page in the RCL needs. Resolves the four canonical
/// data-sport-treeId values from the marathonbet.by spike (6 = basketball,
/// 11 = football, 22723 = tennis, 43658 = hockey) against shared
/// localization resources, with a stable invariant fallback for unknown
/// codes so the UI degrades gracefully if the bookmaker adds a new sport.
/// </summary>
public static class SportLabels
{
public static string Resolve(IStringLocalizer<SharedResource> localizer, int code) =>
code switch
{
6 => localizer["Sport.Basketball"],
11 => localizer["Sport.Football"],
22723 => localizer["Sport.Tennis"],
43658 => localizer["Sport.Hockey"],
_ => string.Format(CultureInfo.InvariantCulture, "Sport {0}", code),
};
}