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.Application.Configuration;
using Marathon.Domain.AnomalyDetection; using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Entities; using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -24,7 +25,6 @@ namespace Marathon.Application.UseCases;
/// </remarks> /// </remarks>
public sealed class DetectAnomaliesUseCase public sealed class DetectAnomaliesUseCase
{ {
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private static readonly TimeSpan SnapshotLookback = TimeSpan.FromHours(24); private static readonly TimeSpan SnapshotLookback = TimeSpan.FromHours(24);
// Dedup window: two anomalies for the same event within this window are considered duplicates. // 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); var events = await _eventRepo.ListAsync(ct);
int newAnomalyCount = 0; int newAnomalyCount = 0;
var now = DateTimeOffset.UtcNow.ToOffset(MoscowOffset); var now = MoscowTime.Now;
var from = now - SnapshotLookback; var from = now - SnapshotLookback;
// Hoisted outside the per-event loop: load existing anomalies ONCE per cycle // Hoisted outside the per-event loop: load existing anomalies ONCE per cycle
@@ -27,8 +27,6 @@ namespace Marathon.Domain.AnomalyDetection;
/// </summary> /// </summary>
public sealed class AnomalyDetector public sealed class AnomalyDetector
{ {
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private readonly int _suspensionGapSeconds; private readonly int _suspensionGapSeconds;
private readonly decimal _oddsFlipThreshold; private readonly decimal _oddsFlipThreshold;
private readonly int _minSnapshotCount; private readonly int _minSnapshotCount;
@@ -156,7 +154,7 @@ public sealed class AnomalyDetector
return new Anomaly( return new Anomaly(
Id: Guid.NewGuid(), Id: Guid.NewGuid(),
EventId: eventId, EventId: eventId,
DetectedAt: DateTimeOffset.UtcNow.ToOffset(MoscowOffset), DetectedAt: MoscowTime.Now,
Kind: AnomalyKind.SuspensionFlip, Kind: AnomalyKind.SuspensionFlip,
Score: clampedScore, Score: clampedScore,
EvidenceJson: evidenceJson); EvidenceJson: evidenceJson);
+1 -3
View File
@@ -21,8 +21,6 @@ public sealed record Event(
string Side1Name, string Side1Name,
string Side2Name) string Side2Name)
{ {
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
public EventId Id { get; } = Id ?? throw new ArgumentNullException(nameof(Id)); public EventId Id { get; } = Id ?? throw new ArgumentNullException(nameof(Id));
public SportCode Sport { get; } = Sport ?? throw new ArgumentNullException(nameof(Sport)); 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 string Category { get; } = Category ?? string.Empty;
public DateTimeOffset ScheduledAt { get; } = ScheduledAt.Offset == MoscowOffset public DateTimeOffset ScheduledAt { get; } = ScheduledAt.Offset == MoscowTime.Offset
? ScheduledAt ? ScheduledAt
: throw new ArgumentException( : throw new ArgumentException(
$"ScheduledAt must be in Europe/Moscow time (UTC+03:00). " + $"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> /// </summary>
public abstract class EventListingParserBase public abstract class EventListingParserBase
{ {
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
protected readonly IServerTimeProvider ServerTimeProvider; protected readonly IServerTimeProvider ServerTimeProvider;
protected readonly ILogger Logger; protected readonly ILogger Logger;
@@ -36,7 +34,7 @@ public abstract class EventListingParserBase
CancellationToken ct) CancellationToken ct)
{ {
var serverTime = ServerTimeProvider.ExtractServerTime(html) var serverTime = ServerTimeProvider.ExtractServerTime(html)
?? DateTimeOffset.UtcNow.ToOffset(MoscowOffset); ?? MoscowTime.Now;
var config = AngleSharpConfig.Default; var config = AngleSharpConfig.Default;
using var context = BrowsingContext.New(config); using var context = BrowsingContext.New(config);
@@ -18,8 +18,6 @@ namespace Marathon.Infrastructure.Scraping.Parsers;
/// </summary> /// </summary>
public sealed partial class EventOddsParser : IEventOddsParser public sealed partial class EventOddsParser : IEventOddsParser
{ {
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private readonly IServerTimeProvider _serverTime; private readonly IServerTimeProvider _serverTime;
private readonly PeriodScopeMapper _periodMapper; private readonly PeriodScopeMapper _periodMapper;
private readonly ILogger<EventOddsParser> _logger; private readonly ILogger<EventOddsParser> _logger;
@@ -69,7 +67,7 @@ public sealed partial class EventOddsParser : IEventOddsParser
ArgumentNullException.ThrowIfNull(html); ArgumentNullException.ThrowIfNull(html);
var capturedAt = _serverTime.ExtractServerTime(html) var capturedAt = _serverTime.ExtractServerTime(html)
?? DateTimeOffset.UtcNow.ToOffset(MoscowOffset); ?? MoscowTime.Now;
var config = AngleSharpConfig.Default; var config = AngleSharpConfig.Default;
using var context = BrowsingContext.New(config); using var context = BrowsingContext.New(config);
@@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Marathon.Domain.ValueObjects;
namespace Marathon.Infrastructure.Scraping.Parsers; namespace Marathon.Infrastructure.Scraping.Parsers;
@@ -13,8 +14,6 @@ namespace Marathon.Infrastructure.Scraping.Parsers;
/// </summary> /// </summary>
public static partial class MoscowDateParser public static partial class MoscowDateParser
{ {
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
// Matches "HH:MM" // Matches "HH:MM"
[GeneratedRegex(@"^\s*(\d{1,2}):(\d{2})\s*$", RegexOptions.CultureInvariant)] [GeneratedRegex(@"^\s*(\d{1,2}):(\d{2})\s*$", RegexOptions.CultureInvariant)]
private static partial Regex TimeOnlyRegex(); private static partial Regex TimeOnlyRegex();
@@ -70,7 +69,7 @@ public static partial class MoscowDateParser
var scheduled = new DateTimeOffset( var scheduled = new DateTimeOffset(
today.Year, today.Month, today.Day, today.Year, today.Month, today.Day,
hour, minute, 0, hour, minute, 0,
MoscowOffset); MoscowTime.Offset);
// If the computed time is already in the past (same day but earlier), // 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 // 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)) if (candidate < DateOnly.FromDateTime(anchorDate))
year++; // e.g., anchor is Dec 2026 and event is in Jan 2027 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; return null;
@@ -21,8 +21,6 @@ namespace Marathon.Infrastructure.Scraping.Parsers;
/// </remarks> /// </remarks>
public sealed partial class ResultsParser : IResultsParser public sealed partial class ResultsParser : IResultsParser
{ {
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private readonly ILogger<ResultsParser> _logger; private readonly ILogger<ResultsParser> _logger;
// Matches score patterns like "2:1", "2:1 (1:1)", "2:1 (0:0) (2:1)" // 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)) if (string.IsNullOrWhiteSpace(eventIdRaw))
return null; return null;
var completedAt = DateTimeOffset.UtcNow.ToOffset(MoscowOffset); var completedAt = MoscowTime.Now;
return new EventResult( return new EventResult(
new DomainEventId(eventIdRaw), new DomainEventId(eventIdRaw),
@@ -1,4 +1,5 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Marathon.Infrastructure.Scraping.Parsers; namespace Marathon.Infrastructure.Scraping.Parsers;
@@ -17,8 +18,6 @@ public sealed partial class ServerTimeProvider : IServerTimeProvider
RegexOptions.CultureInvariant)] RegexOptions.CultureInvariant)]
private static partial Regex ServerTimeRegex(); private static partial Regex ServerTimeRegex();
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private readonly ILogger<ServerTimeProvider> _logger; private readonly ILogger<ServerTimeProvider> _logger;
public ServerTimeProvider(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 minute = int.Parse(match.Groups[5].Value);
var second = int.Parse(match.Groups[6].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(), _ => kind.ToString(),
}; };
private string SportLabel(int code) => code switch private string SportLabel(int code) => SportLabels.Resolve(L, code);
{
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 static string FormatRate(decimal? r) => r is { } v private static string FormatRate(decimal? r) => r is { } v
? v.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture) ? v.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture)
@@ -113,7 +113,7 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
var moscow = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(3)); var moscow = MoscowTime.Now;
_from = InitialFrom ?? moscow.AddDays(-1).Date; _from = InitialFrom ?? moscow.AddDays(-1).Date;
_to = InitialTo ?? moscow.AddDays(1).Date; _to = InitialTo ?? moscow.AddDays(1).Date;
_kind = InitialKind; _kind = InitialKind;
@@ -140,10 +140,9 @@
try try
{ {
// Use Moscow offset to match domain ScheduledAt invariant. // Use Moscow offset to match domain ScheduledAt invariant.
var moscow = TimeSpan.FromHours(3);
var range = new AppDateRange( var range = new AppDateRange(
new DateTimeOffset(_from.Value.Date, moscow), new DateTimeOffset(_from.Value.Date, MoscowTime.Offset),
new DateTimeOffset(_to.Value.Date.AddDays(1).AddSeconds(-1), moscow)); MoscowTime.EndOfMoscowDay(DateOnly.FromDateTime(_to.Value.Date)));
var path = await ExportUseCase.ExecuteAsync(range, _kind, CancellationToken.None); var path = await ExportUseCase.ExecuteAsync(range, _kind, CancellationToken.None);
Dialog.Close(DialogResult.Ok(path)); Dialog.Close(DialogResult.Ok(path));
@@ -252,8 +252,7 @@
{ {
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v)) if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{ {
var moscow = TimeSpan.FromHours(3); await UpdateFilter(_filter with { From = new DateTimeOffset(v.Date, MoscowTime.Offset) });
await UpdateFilter(_filter with { From = new DateTimeOffset(v.Date, moscow) });
} }
} }
@@ -261,8 +260,7 @@
{ {
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v)) if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{ {
var moscow = TimeSpan.FromHours(3); await UpdateFilter(_filter with { To = MoscowTime.EndOfMoscowDay(DateOnly.FromDateTime(v.Date)) });
await UpdateFilter(_filter with { To = new DateTimeOffset(v.Date, moscow).AddDays(1).AddSeconds(-1) });
} }
} }
@@ -283,14 +281,7 @@
_ => L["Anomaly.Severity.Low"], _ => L["Anomaly.Severity.Low"],
}; };
private string SportLabel(int code) => code switch private string SportLabel(int code) => SportLabels.Resolve(L, code);
{
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 static string FormatDate(DateTimeOffset? value) private static string FormatDate(DateTimeOffset? value)
=> value?.ToString("yyyy-MM-dd") ?? string.Empty; => value?.ToString("yyyy-MM-dd") ?? string.Empty;
+1 -8
View File
@@ -306,14 +306,7 @@
_ => "—", _ => "—",
}; };
private string SportLabel(int code) => code switch private string SportLabel(int code) => SportLabels.Resolve(L, code);
{
6 => L["Sport.Basketball"],
11 => L["Sport.Football"],
22723 => L["Sport.Tennis"],
43658 => L["Sport.Hockey"],
_ => $"Sport {code}",
};
private string BetTypeLabel(BetType t) => t switch private string BetTypeLabel(BetType t) => t switch
{ {
@@ -164,8 +164,6 @@
</style> </style>
@code { @code {
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private DateTimeOffset _from; private DateTimeOffset _from;
private DateTimeOffset _to; private DateTimeOffset _to;
private string _searchInput = string.Empty; private string _searchInput = string.Empty;
@@ -181,7 +179,7 @@
protected override async Task OnInitializedAsync() 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); _from = todayMoscow.AddDays(-30);
_to = todayMoscow.AddDays(1).AddSeconds(-1); _to = todayMoscow.AddDays(1).AddSeconds(-1);
@@ -246,7 +244,7 @@
{ {
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v)) if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{ {
_from = new DateTimeOffset(v.Date, MoscowOffset); _from = new DateTimeOffset(v.Date, MoscowTime.Offset);
await LoadAsync(); await LoadAsync();
} }
} }
@@ -255,7 +253,7 @@
{ {
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v)) 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(); await LoadAsync();
} }
} }
@@ -311,14 +309,7 @@
_ => "draw", _ => "draw",
}; };
private string SportLabel(int code) => code switch private string SportLabel(int code) => SportLabels.Resolve(L, code);
{
6 => L["Sport.Basketball"],
11 => L["Sport.Football"],
22723 => L["Sport.Tennis"],
43658 => L["Sport.Hockey"],
_ => $"Sport {code}",
};
public void Dispose() public void Dispose()
{ {
@@ -204,8 +204,6 @@
</style> </style>
@code { @code {
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private DateTimeOffset _from; private DateTimeOffset _from;
private DateTimeOffset _to; private DateTimeOffset _to;
private LoaderMode _mode = LoaderMode.AllInRange; private LoaderMode _mode = LoaderMode.AllInRange;
@@ -234,7 +232,7 @@
protected override async Task OnInitializedAsync() 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); _from = todayMoscow.AddDays(-7);
_to = todayMoscow.AddDays(1).AddSeconds(-1); _to = todayMoscow.AddDays(1).AddSeconds(-1);
await ReloadCandidatesAsync(); await ReloadCandidatesAsync();
@@ -347,7 +345,7 @@
{ {
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v)) if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{ {
_from = new DateTimeOffset(v.Date, MoscowOffset); _from = new DateTimeOffset(v.Date, MoscowTime.Offset);
await ReloadCandidatesAsync(); await ReloadCandidatesAsync();
} }
} }
@@ -356,7 +354,7 @@
{ {
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v)) 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(); await ReloadCandidatesAsync();
} }
} }
@@ -400,14 +398,7 @@
_ => o.ToString(), _ => o.ToString(),
}; };
private string SportLabel(int code) => code switch private string SportLabel(int code) => SportLabels.Resolve(L, code);
{
6 => L["Sport.Basketball"],
11 => L["Sport.Football"],
22723 => L["Sport.Tennis"],
43658 => L["Sport.Hockey"],
_ => $"Sport {code}",
};
private static string FormatDate(DateTimeOffset value) => value.ToString("yyyy-MM-dd"); private static string FormatDate(DateTimeOffset value) => value.ToString("yyyy-MM-dd");
@@ -437,8 +437,7 @@
{ {
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v)) if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{ {
var moscow = TimeSpan.FromHours(3); await UpdateFilter(_filter with { From = new DateTimeOffset(v.Date, MoscowTime.Offset) });
await UpdateFilter(_filter with { From = new DateTimeOffset(v.Date, moscow) });
} }
} }
@@ -446,8 +445,7 @@
{ {
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v)) if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{ {
var moscow = TimeSpan.FromHours(3); await UpdateFilter(_filter with { To = MoscowTime.EndOfMoscowDay(DateOnly.FromDateTime(v.Date)) });
await UpdateFilter(_filter with { To = new DateTimeOffset(v.Date, moscow).AddDays(1).AddSeconds(-1) });
} }
} }
@@ -519,14 +517,7 @@
private static string FormatDate(DateTimeOffset value) private static string FormatDate(DateTimeOffset value)
=> value.ToString("yyyy-MM-dd"); => value.ToString("yyyy-MM-dd");
private string SportLabel(int code) => code switch private string SportLabel(int code) => SportLabels.Resolve(L, code);
{
6 => L["Sport.Basketball"],
11 => L["Sport.Football"],
22723 => L["Sport.Tennis"],
43658 => L["Sport.Hockey"],
_ => $"Sport {code}",
};
private readonly record struct RatesSnap(decimal? Win1, decimal? Draw, decimal? Win2); private readonly record struct RatesSnap(decimal? Win1, decimal? Draw, decimal? Win2);
@@ -1,3 +1,5 @@
using Marathon.Domain.ValueObjects;
namespace Marathon.UI.Services; namespace Marathon.UI.Services;
/// <summary> /// <summary>
@@ -54,8 +56,7 @@ public sealed class EventBrowsingState
public static PageFilter Default(DateTime nowUtc) public static PageFilter Default(DateTime nowUtc)
{ {
// Default window: -1d .. +7d in Moscow time, the same TZ events use. // 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(MoscowTime.Offset);
var midnight = new DateTimeOffset(nowUtc.Date, TimeSpan.Zero).ToOffset(moscow);
return new PageFilter( return new PageFilter(
From: midnight.AddDays(-1), From: midnight.AddDays(-1),
To: midnight.AddDays(7), 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),
};
}