WIP(initial-implementation): parallel batch P2/P3/P5 — code complete, unreviewed

Snapshot of the parallel batch (Phases 2 + 3 + 5) at session pause. Solution does
NOT build cleanly yet — known cross-phase compile issues remain to be resolved
before review. See plans/initial-implementation/PLAN.md "Resume Notes" section
for the exact tomorrow-morning action list.

Phase 2 (Storage):
- Repository interfaces in Marathon.Application/Abstractions
- DateRange, ExportKind, StorageOptions in Marathon.Application/Storage
- EF Core 8 + SQLite (WAL) persistence: 7 entities + configurations + 4 repos
- Hand-written InitialCreate migration (dotnet ef blocked by parallel work)
- ClosedXML ExcelExporter with exact customer-spec wide columns
- PersistenceModule.AddMarathonPersistence DI extension
- Round-trip + export tests (cannot run yet — see cross-phase issues)

Phase 3 (Scraping):
- IOddsScraper, IBetPlacer in Marathon.Application/Abstractions
- ScrapingOptions in Marathon.Infrastructure/Configuration
- MarathonbetScraper with 4 parsers (Upcoming, Live, EventOdds, Results)
- Helpers: ServerTimeProvider, PeriodScopeMapper, OutcomeCodeMapper, MoscowDateParser
- UserAgentRotatorHandler + Polly v8 resilience pipeline
- ScrapingModule.AddMarathonScraping DI extension
- GlobalUsings.cs aliases for EventId / Configuration disambiguation
- Parser tests with trimmed HTML fixtures
- ScrapeResultsAsync interim no-op (Phase 8 will replace via watch-list polling)

Phase 5 (UI shell — killed mid-final-verify, assumed ~95%):
- Marathon.UI populated: MainLayout, App.razor, Pages (Home, Settings),
  Components, Theme (MarathonTheme.cs + Tokens.cs + app.css), Resources
  (SharedResource.{cs,ru.resx,en.resx}), Services (ISettingsWriter), wwwroot
- WPF host: App.xaml(.cs), MainWindow.xaml(.cs), Marathon.Hosts.WpfBlazor.csproj
  with Microsoft.AspNetCore.Components.WebView.Wpf + MudBlazor + Serilog
- appsettings.json + appsettings.Development.json with all sections wired
- bUnit tests: MainLayoutTests, LocaleSwitcherTests, ThemeToggleTests,
  JsonSettingsWriterTests + Support helpers

Cross-phase issues to resolve at next session:
1. Phase 2 repository classes are 'internal' — Phase 3's tests can't reference
   them. Fix: add InternalsVisibleTo to Marathon.Infrastructure.csproj.
2. Phase 5: LocalizationOptions namespace ambiguity (AspNetCore vs Extensions).
3. Phase 5: WpfBlazor Serilog API mismatch.

Reviewer has NOT run on this batch. Move to Phase 4 only after build is green
and a combined parallel-batch reviewer passes.
This commit is contained in:
2026-05-05 01:56:53 +03:00
parent 144c936e90
commit e4d8476782
129 changed files with 8524 additions and 121 deletions
+19
View File
@@ -0,0 +1,19 @@
@*
Top-level Blazor router. Mounted at #app inside index.html via the host's
BlazorWebView RootComponents collection.
*@
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<div class="m-shell">
<p class="m-kicker">404</p>
<h1 class="m-display" style="font-size: 2.5rem;">Страница не найдена</h1>
<p>Запрошенный маршрут не существует.</p>
</div>
</LayoutView>
</NotFound>
</Router>
+10
View File
@@ -0,0 +1,10 @@
@inject IStringLocalizer<SharedResource> L
<a href="/" class="m-brand @Class" aria-label="@L["App.Title"]">
<span class="m-brand__mark">@L["App.BrandMark"]</span>
<span class="m-brand__dateline">@L["App.Dateline"]</span>
</a>
@code {
[Parameter] public string? Class { get; set; }
}
+18
View File
@@ -0,0 +1,18 @@
<div class="m-field-row">
<div>
<label style="font-weight: 500; font-size: 0.9375rem;">@Label</label>
@if (!string.IsNullOrEmpty(Hint))
{
<div class="m-field-row__hint">@Hint</div>
}
</div>
<div>
@ChildContent
</div>
</div>
@code {
[Parameter, EditorRequired] public string Label { get; set; } = string.Empty;
[Parameter] public string? Hint { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
}
@@ -0,0 +1,45 @@
@using LocalizationOptions = Marathon.UI.Services.LocalizationOptions
@inject LocaleState LocaleState
@inject ISettingsWriter SettingsWriter
@inject IStringLocalizer<SharedResource> L
@inject ILogger<LocaleSwitcher> Logger
<div class="m-segmented" role="group" aria-label="@L["Locale.Tooltip.Switch"]">
<button type="button"
class="m-segmented__btn @(IsActive(LocaleState.Russian) ? "is-active" : null)"
aria-pressed="@IsActive(LocaleState.Russian).ToString().ToLowerInvariant()"
@onclick="@(() => SwitchAsync(LocaleState.Russian))">
@L["Locale.Russian"]
</button>
<button type="button"
class="m-segmented__btn @(IsActive(LocaleState.English) ? "is-active" : null)"
aria-pressed="@IsActive(LocaleState.English).ToString().ToLowerInvariant()"
@onclick="@(() => SwitchAsync(LocaleState.English))">
@L["Locale.English"]
</button>
</div>
@code {
private bool IsActive(string culture) =>
string.Equals(LocaleState.Culture.Name, culture, StringComparison.OrdinalIgnoreCase);
private async Task SwitchAsync(string culture)
{
if (IsActive(culture))
{
return;
}
try
{
LocaleState.Set(culture);
await SettingsWriter.SaveSectionAsync(
LocalizationOptions.SectionName,
new LocalizationOptions { DefaultCulture = culture });
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to persist locale {Culture}", culture);
}
}
}
+40
View File
@@ -0,0 +1,40 @@
@inject IStringLocalizer<SharedResource> L
<nav class="m-nav" aria-label="primary">
<div style="padding: var(--m-space-5) var(--m-space-4) var(--m-space-3); border-bottom: 1px solid rgba(231,229,228,0.10);">
<div style="font-family: var(--m-font-display); font-size: 1.25rem; color: #fafaf7;">
<span style="color: var(--m-c-accent);">M</span>arathon
</div>
<div style="font-family: var(--m-font-mono); font-size: 0.6875rem; letter-spacing: 0.18em; text-transform: uppercase; color: rgba(231,229,228,0.55); margin-top: 4px;">
Odds Lab · v0.1
</div>
</div>
<div class="m-nav__group">@L["Nav.Section.Analysis"]</div>
<NavLink class="m-nav__link" href="" Match="NavLinkMatch.All">
<MudIcon Icon="@Icons.Material.Outlined.GridView" Size="Size.Small" />
<span>@L["Nav.Dashboard"]</span>
</NavLink>
<NavLink class="m-nav__link" href="prematch">
<MudIcon Icon="@Icons.Material.Outlined.Schedule" Size="Size.Small" />
<span>@L["Nav.PreMatch"]</span>
</NavLink>
<NavLink class="m-nav__link" href="live">
<MudIcon Icon="@Icons.Material.Outlined.Bolt" Size="Size.Small" />
<span>@L["Nav.Live"]</span>
</NavLink>
<NavLink class="m-nav__link" href="anomalies">
<MudIcon Icon="@Icons.Material.Outlined.Warning" Size="Size.Small" />
<span>@L["Nav.Anomalies"]</span>
</NavLink>
<NavLink class="m-nav__link" href="results">
<MudIcon Icon="@Icons.Material.Outlined.Done" Size="Size.Small" />
<span>@L["Nav.Results"]</span>
</NavLink>
<div class="m-nav__group" style="margin-top: var(--m-space-5);">@L["Nav.Section.System"]</div>
<NavLink class="m-nav__link" href="settings">
<MudIcon Icon="@Icons.Material.Outlined.Tune" Size="Size.Small" />
<span>@L["Nav.Settings"]</span>
</NavLink>
</nav>
@@ -0,0 +1,30 @@
<li style="display: grid; grid-template-columns: 36px 1fr auto; gap: var(--m-space-3); align-items: center; padding: var(--m-space-2) 0;">
<span class="m-mono" style="font-size: 0.75rem; color: var(--m-c-ink-soft); letter-spacing: 0.12em;">@Index</span>
<span style="font-size: 0.9375rem;">@Label</span>
<span style="display: inline-flex; align-items: center; gap: 6px; font-family: var(--m-font-mono); font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.14em; color: @StatusColor;">
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: @StatusColor;"></span>
@StatusLabel
</span>
</li>
@code {
[Parameter, EditorRequired] public string Index { get; set; } = string.Empty;
[Parameter, EditorRequired] public string Label { get; set; } = string.Empty;
[Parameter] public string Status { get; set; } = "idle";
private string StatusColor => Status switch
{
"ok" => "var(--m-c-positive)",
"warn" => "var(--m-c-accent)",
"error" => "var(--m-c-anomaly)",
_ => "var(--m-c-ink-soft)",
};
private string StatusLabel => Status switch
{
"ok" => "OK",
"warn" => "WAIT",
"error" => "FAIL",
_ => "IDLE",
};
}
@@ -0,0 +1,11 @@
@inject IStringLocalizer<SharedResource> L
<div style="display: flex; justify-content: flex-end; gap: var(--m-space-3); padding-top: var(--m-space-3); border-top: 1px solid var(--m-c-rule); margin-top: var(--m-space-2);">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OnSave">
@L["Settings.Action.Save"]
</MudButton>
</div>
@code {
[Parameter, EditorRequired] public EventCallback OnSave { get; set; }
}
+17
View File
@@ -0,0 +1,17 @@
<div class="m-card @(Anomaly ? "m-card--anomaly" : null)" style="display: flex; flex-direction: column; gap: var(--m-space-2);">
<span class="m-stat__label">@Label</span>
<span class="m-stat__value">@Value</span>
@if (!string.IsNullOrEmpty(Delta))
{
<span class="m-stat__delta @(Anomaly ? "m-stat__delta--down" : null)" style="color: @(Anomaly ? "var(--m-c-anomaly)" : "var(--m-c-ink-soft)");">
@Delta
</span>
}
</div>
@code {
[Parameter, EditorRequired] public string Label { get; set; } = string.Empty;
[Parameter, EditorRequired] public string Value { get; set; } = string.Empty;
[Parameter] public string? Delta { get; set; }
[Parameter] public bool Anomaly { get; set; }
}
@@ -0,0 +1,14 @@
@inject ThemeState ThemeState
@inject IStringLocalizer<SharedResource> L
<MudTooltip Text="@(ThemeState.IsDark ? L["Theme.Toggle.Light"] : L["Theme.Toggle.Dark"])">
<MudIconButton
Icon="@(ThemeState.IsDark ? Icons.Material.Outlined.LightMode : Icons.Material.Outlined.DarkMode)"
Color="Color.Inherit"
OnClick="OnToggle"
aria-label="@(ThemeState.IsDark ? L["Theme.Toggle.Light"] : L["Theme.Toggle.Dark"])" />
</MudTooltip>
@code {
private void OnToggle() => ThemeState.Toggle();
}
+119
View File
@@ -0,0 +1,119 @@
@inherits LayoutComponentBase
@inject ThemeState ThemeState
@inject LocaleState LocaleState
@inject IStringLocalizer<SharedResource> L
<MudThemeProvider IsDarkMode="@ThemeState.IsDark" Theme="@_theme" />
<MudPopoverProvider />
<MudDialogProvider FullWidth="true" MaxWidth="MaxWidth.Small" CloseOnEscapeKey="true" />
<MudSnackbarProvider />
<div class="m-app-frame @(_drawerOpen ? "is-drawer-open" : null)" data-theme="@(ThemeState.IsDark ? "dark" : "light")">
<header class="m-appbar">
<MudIconButton
Icon="@Icons.Material.Outlined.Menu"
Color="Color.Inherit"
Edge="Edge.Start"
OnClick="ToggleDrawer"
aria-label="@L["Nav.Section.Analysis"]" />
<AppBrand Class="m-rise m-rise-1" />
<div class="m-appbar__spacer"></div>
<div class="m-appbar__tools m-rise m-rise-2">
<LocaleSwitcher />
<ThemeToggle />
</div>
</header>
<MudDrawer
@bind-Open="_drawerOpen"
Anchor="Anchor.Left"
Variant="DrawerVariant.Responsive"
ClipMode="DrawerClipMode.Always"
Elevation="0"
Width="248px"
Color="Color.Dark">
<NavBody />
</MudDrawer>
<main class="m-main">
<CascadingValue Value="ThemeState">
<CascadingValue Value="LocaleState">
@Body
</CascadingValue>
</CascadingValue>
</main>
<footer class="m-footer">
<span class="m-kicker">Marathon Odds Lab</span>
<span style="font-family: var(--m-font-mono); font-size: 0.6875rem; color: var(--m-c-ink-soft); letter-spacing: 0.16em; text-transform: uppercase;">
Phase 5 · Editorial-Quant · v0.1
</span>
</footer>
</div>
<style>
.m-app-frame {
display: grid;
grid-template-rows: 60px 1fr 36px;
min-height: 100vh;
}
.m-appbar {
display: flex;
align-items: center;
gap: var(--m-space-3);
padding: 0 clamp(var(--m-space-3), 2vw, var(--m-space-5));
border-bottom: 1px solid var(--m-c-rule);
background: var(--m-c-paper);
position: sticky;
top: 0;
z-index: 10;
}
.m-appbar__spacer { flex: 1; }
.m-appbar__tools { display: inline-flex; gap: var(--m-space-3); align-items: center; }
.m-main {
position: relative;
z-index: 1;
min-height: 0;
}
.m-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 clamp(var(--m-space-3), 2vw, var(--m-space-5));
border-top: 1px solid var(--m-c-rule);
background: var(--m-c-paper);
}
[data-theme="dark"] .m-appbar,
[data-theme="dark"] .m-footer {
background: var(--m-c-paper-2);
border-color: var(--m-c-rule);
}
</style>
@code {
private bool _drawerOpen = true;
private MudBlazor.MudTheme _theme = Theme.MarathonTheme.Build();
protected override void OnInitialized()
{
ThemeState.OnChange += StateHasChanged;
LocaleState.OnChange += StateHasChanged;
}
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
public void Dispose()
{
ThemeState.OnChange -= StateHasChanged;
LocaleState.OnChange -= StateHasChanged;
}
}
+19
View File
@@ -2,6 +2,8 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Marathon.UI</RootNamespace>
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
</PropertyGroup>
<ItemGroup>
@@ -10,6 +12,14 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" />
<PackageReference Include="MudBlazor" />
<PackageReference Include="Microsoft.Extensions.Localization" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
@@ -17,4 +27,13 @@
<ProjectReference Include="..\Marathon.Application\Marathon.Application.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Resources\SharedResource.ru.resx">
<Generator>ResXFileCodeGenerator</Generator>
</EmbeddedResource>
<EmbeddedResource Update="Resources\SharedResource.en.resx">
<Generator>ResXFileCodeGenerator</Generator>
</EmbeddedResource>
</ItemGroup>
</Project>
+5
View File
@@ -0,0 +1,5 @@
@page "/anomalies"
@inject IStringLocalizer<SharedResource> L
<PageTitle>@L["App.Title"] · @L["Nav.Anomalies"]</PageTitle>
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.Anomalies"]" />
+78
View File
@@ -0,0 +1,78 @@
@page "/"
@inject IStringLocalizer<SharedResource> L
<PageTitle>@L["App.Title"] · @L["Nav.Dashboard"]</PageTitle>
<section class="m-shell">
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
<span class="m-kicker">@L["Home.Kicker"]</span>
<h1 class="m-display" style="font-size: clamp(2.5rem, 5vw, 4rem);">@L["Home.Title"]</h1>
<p style="font-size: 1.0625rem; line-height: 1.5; color: var(--m-c-ink-soft); max-width: 60ch;">
@L["Home.Lede"]
</p>
</header>
<hr class="m-rule--double" />
<div class="m-grid--three m-rise m-rise-2">
<StatCard Label="@L["Home.Stat.Events"]" Value="@_eventsTracked.ToString("N0")" Delta="+12%" />
<StatCard Label="@L["Home.Stat.Snapshots"]" Value="@_snapshotsToday.ToString("N0")" Delta="+318" />
<StatCard Label="@L["Home.Stat.Anomalies"]" Value="@_anomalies.ToString()" Delta="3 NEW" Anomaly="true" />
<StatCard Label="@L["Home.Stat.Coverage"]" Value="4" Delta="BSK · FBL · TNS · HKY" />
</div>
<div class="m-grid--asym m-rise m-rise-3" style="margin-top: var(--m-space-6);">
<div class="m-card">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
@L["Home.Section.Latest"]
</span>
<h2 style="font-family: var(--m-font-display); font-weight: 400; font-size: 1.625rem; margin: var(--m-space-3) 0 var(--m-space-5);">
@L["Anomaly.Kind.SuspensionFlip"]
</h2>
<div style="display: grid; gap: var(--m-space-4);">
@foreach (var item in _placeholderFeed)
{
<article style="display: grid; grid-template-columns: 80px 1fr auto; gap: var(--m-space-4); padding: var(--m-space-3) 0; border-top: 1px solid var(--m-c-rule);">
<div class="m-mono" style="font-size: 0.75rem; color: var(--m-c-ink-soft); text-transform: uppercase; letter-spacing: 0.1em;">
@item.Time
</div>
<div>
<div style="font-weight: 500;">@item.Match</div>
<div style="color: var(--m-c-ink-soft); font-size: 0.8125rem;">@item.Detail</div>
</div>
<span class="m-anomaly">
<span class="m-anomaly__pulse"></span>
@($"{item.Score:0.00}")
</span>
</article>
}
</div>
<div style="margin-top: var(--m-space-5); padding-top: var(--m-space-3); border-top: 1px solid var(--m-c-rule); color: var(--m-c-ink-soft); font-size: 0.8125rem;">
@L["Home.Empty"]
</div>
</div>
<aside class="m-card m-card--accented">
<span class="m-kicker">@L["Home.Section.Pipeline"]</span>
<ol style="list-style: none; padding: 0; margin: var(--m-space-4) 0 0; display: grid; gap: var(--m-space-3); counter-reset: m-step;">
<PipelineStep Index="01" Label="@L["Home.Pipeline.Step1"]" Status="ok" />
<PipelineStep Index="02" Label="@L["Home.Pipeline.Step2"]" Status="ok" />
<PipelineStep Index="03" Label="@L["Home.Pipeline.Step3"]" Status="warn" />
<PipelineStep Index="04" Label="@L["Home.Pipeline.Step4"]" Status="idle" />
</ol>
</aside>
</div>
</section>
@code {
// Mock data — Phase 6+ will replace with live queries.
private readonly int _eventsTracked = 0;
private readonly int _snapshotsToday = 0;
private readonly int _anomalies = 0;
private record FeedItem(string Time, string Match, string Detail, decimal Score);
private readonly List<FeedItem> _placeholderFeed = new();
}
+5
View File
@@ -0,0 +1,5 @@
@page "/live"
@inject IStringLocalizer<SharedResource> L
<PageTitle>@L["App.Title"] · @L["Nav.Live"]</PageTitle>
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.Live"]" />
+19
View File
@@ -0,0 +1,19 @@
@*
Lightweight placeholders for routes that Phase 6/7/8 will replace. Keeping
them here means the navigation drawer is fully wired today; later phases
just convert each @page block into a real component file.
*@
@inject IStringLocalizer<SharedResource> L
<section class="m-shell">
<span class="m-kicker">@Surface</span>
<h1 class="m-display" style="font-size: clamp(1.75rem, 3vw, 2.5rem);">@Title</h1>
<p style="color: var(--m-c-ink-soft); max-width: 60ch;">
Coming in a later phase. The visual language defined in Phase 5 will carry through unchanged.
</p>
</section>
@code {
[Parameter] public string Surface { get; set; } = string.Empty;
[Parameter] public string Title { get; set; } = string.Empty;
}
+5
View File
@@ -0,0 +1,5 @@
@page "/prematch"
@inject IStringLocalizer<SharedResource> L
<PageTitle>@L["App.Title"] · @L["Nav.PreMatch"]</PageTitle>
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.PreMatch"]" />
+5
View File
@@ -0,0 +1,5 @@
@page "/results"
@inject IStringLocalizer<SharedResource> L
<PageTitle>@L["App.Title"] · @L["Nav.Results"]</PageTitle>
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.Results"]" />
+272
View File
@@ -0,0 +1,272 @@
@page "/settings"
@using Marathon.Application.Storage
@using LocalizationOptions = Marathon.UI.Services.LocalizationOptions
@inject IStringLocalizer<SharedResource> L
@inject IOptionsMonitor<ScrapingSettingsForm> ScrapingOpts
@inject IOptionsMonitor<WorkerOptions> WorkerOpts
@inject IOptionsMonitor<StorageOptions> StorageOpts
@inject IOptionsMonitor<AnomalyOptions> AnomalyOpts
@inject IOptionsMonitor<Marathon.UI.Services.LocalizationOptions> LocaleOpts
@inject ISettingsWriter Writer
@inject IDialogService Dialogs
@inject ISnackbar Snackbar
@inject ILogger<Settings> Logger
<PageTitle>@L["App.Title"] · @L["Settings.Title"]</PageTitle>
<section class="m-shell">
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
<span class="m-kicker">@L["Settings.Kicker"]</span>
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Settings.Title"]</h1>
<p style="color: var(--m-c-ink-soft); max-width: 70ch;">@L["Settings.Lede"]</p>
</header>
<hr class="m-rule--double" />
@* SCRAPING *@
<article class="m-section m-rise m-rise-2">
<header class="m-section__head">
<h2>@L["Settings.Section.Scraping"]</h2>
<MudButton Variant="Variant.Text"
Size="Size.Small"
OnClick="@(() => ResetSectionAsync(ScrapingSettingsForm.SectionName))">
@L["Settings.Action.Reset"]
</MudButton>
</header>
<div class="m-section__body">
<Field Label="@L["Settings.Scraping.PollingIntervalSeconds"]" Hint="@L["Settings.Scraping.PollingIntervalSeconds.Hint"]">
<MudNumericField T="int" @bind-Value="_scraping.PollingIntervalSeconds" Min="5" Max="3600" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.MaxConcurrentRequests"]" Hint="@L["Settings.Scraping.MaxConcurrentRequests.Hint"]">
<MudNumericField T="int" @bind-Value="_scraping.MaxConcurrentRequests" Min="1" Max="16" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.RateLimitRps"]" Hint="@L["Settings.Scraping.RateLimitRps.Hint"]">
<MudNumericField T="int" @bind-Value="_scraping.RateLimit.RequestsPerSecond" Min="1" Max="20" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.RetryMaxAttempts"]">
<MudNumericField T="int" @bind-Value="_scraping.RetryPolicy.MaxAttempts" Min="0" Max="10" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.RetryBaseDelayMs"]">
<MudNumericField T="int" @bind-Value="_scraping.RetryPolicy.BaseDelayMs" Min="100" Max="60000" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.BaseUrl"]">
<MudTextField T="string" @bind-Value="_scraping.BaseUrl" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.RequestTimeoutSeconds"]">
<MudNumericField T="int" @bind-Value="_scraping.RequestTimeoutSeconds" Min="5" Max="600" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.UserAgents"]" Hint="@L["Settings.Scraping.UserAgents.Hint"]">
<MudTextField T="string"
Value="@_userAgentsRaw"
ValueChanged="@OnUserAgentsChanged"
Lines="4"
Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.UsePlaywright"]">
<MudSwitch T="bool" @bind-Value="_scraping.UsePlaywright" Color="Color.Primary" />
</Field>
<SectionFooter OnSave="@(() => SaveSectionAsync(ScrapingSettingsForm.SectionName, _scraping))" />
</div>
</article>
@* WORKERS *@
<article class="m-section m-rise m-rise-3">
<header class="m-section__head">
<h2>@L["Settings.Section.Workers"]</h2>
<MudButton Variant="Variant.Text" Size="Size.Small"
OnClick="@(() => ResetSectionAsync(WorkerOptions.SectionName))">
@L["Settings.Action.Reset"]
</MudButton>
</header>
<div class="m-section__body">
<Field Label="@L["Settings.Workers.UpcomingScheduleCron"]" Hint="@L["Settings.Workers.UpcomingScheduleCron.Hint"]">
<MudTextField T="string" @bind-Value="_workers.UpcomingScheduleCron" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Workers.UpcomingPollerEnabled"]">
<MudSwitch T="bool" @bind-Value="_workers.UpcomingPollerEnabled" Color="Color.Primary" />
</Field>
<Field Label="@L["Settings.Workers.LivePollerEnabled"]">
<MudSwitch T="bool" @bind-Value="_workers.LivePollerEnabled" Color="Color.Primary" />
</Field>
<SectionFooter OnSave="@(() => SaveSectionAsync(WorkerOptions.SectionName, _workers))" />
</div>
</article>
@* STORAGE *@
<article class="m-section m-rise m-rise-4">
<header class="m-section__head">
<h2>@L["Settings.Section.Storage"]</h2>
<MudButton Variant="Variant.Text" Size="Size.Small"
OnClick="@(() => ResetSectionAsync(StorageOptions.SectionName))">
@L["Settings.Action.Reset"]
</MudButton>
</header>
<div class="m-section__body">
<Field Label="@L["Settings.Storage.DatabasePath"]">
<MudTextField T="string" @bind-Value="_storage.DatabasePath" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Storage.ExportDirectory"]">
<MudTextField T="string" @bind-Value="_storage.ExportDirectory" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Storage.SnapshotRetentionDays"]">
<MudNumericField T="int" @bind-Value="_storage.SnapshotRetentionDays" Min="1" Max="3650" Variant="Variant.Outlined" />
</Field>
<SectionFooter OnSave="@(() => SaveSectionAsync(StorageOptions.SectionName, _storage))" />
</div>
</article>
@* ANOMALY *@
<article class="m-section m-rise m-rise-5">
<header class="m-section__head">
<h2>@L["Settings.Section.Anomaly"]</h2>
<MudButton Variant="Variant.Text" Size="Size.Small"
OnClick="@(() => ResetSectionAsync(AnomalyOptions.SectionName))">
@L["Settings.Action.Reset"]
</MudButton>
</header>
<div class="m-section__body">
<Field Label="@L["Settings.Anomaly.SuspensionGapSeconds"]">
<MudNumericField T="int" @bind-Value="_anomaly.SuspensionGapSeconds" Min="5" Max="3600" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Anomaly.OddsFlipThreshold"]">
<MudNumericField T="decimal" @bind-Value="_anomaly.OddsFlipThreshold" Min="0.01m" Max="1m" Step="0.01m" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Anomaly.MinSnapshotCount"]">
<MudNumericField T="int" @bind-Value="_anomaly.MinSnapshotCount" Min="2" Max="100" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Anomaly.DetectionIntervalSeconds"]">
<MudNumericField T="int" @bind-Value="_anomaly.DetectionIntervalSeconds" Min="5" Max="3600" Variant="Variant.Outlined" />
</Field>
<SectionFooter OnSave="@(() => SaveSectionAsync(AnomalyOptions.SectionName, _anomaly))" />
</div>
</article>
@* LOCALIZATION *@
<article class="m-section m-rise m-rise-5">
<header class="m-section__head">
<h2>@L["Settings.Section.Localization"]</h2>
<MudButton Variant="Variant.Text" Size="Size.Small"
OnClick="@(() => ResetSectionAsync(LocalizationOptions.SectionName))">
@L["Settings.Action.Reset"]
</MudButton>
</header>
<div class="m-section__body">
<Field Label="@L["Settings.Localization.DefaultCulture"]">
<MudSelect T="string" @bind-Value="_locale.DefaultCulture" Variant="Variant.Outlined">
<MudSelectItem T="string" Value="@LocaleState.Russian">@L["Locale.Russian"] · ru-RU</MudSelectItem>
<MudSelectItem T="string" Value="@LocaleState.English">@L["Locale.English"] · en-US</MudSelectItem>
</MudSelect>
</Field>
<SectionFooter OnSave="@(() => SaveSectionAsync(LocalizationOptions.SectionName, _locale))" />
</div>
</article>
</section>
@code {
private ScrapingSettingsForm _scraping = new();
private WorkerOptions _workers = new();
private StorageOptions _storage = new();
private AnomalyOptions _anomaly = new();
private LocalizationOptions _locale = new();
private string _userAgentsRaw = string.Empty;
protected override void OnInitialized()
{
_scraping = ScrapingOpts.CurrentValue.Clone();
_userAgentsRaw = string.Join('\n', _scraping.UserAgents ?? Array.Empty<string>());
_workers = new WorkerOptions
{
UpcomingScheduleCron = WorkerOpts.CurrentValue.UpcomingScheduleCron,
LivePollerEnabled = WorkerOpts.CurrentValue.LivePollerEnabled,
UpcomingPollerEnabled = WorkerOpts.CurrentValue.UpcomingPollerEnabled,
};
_storage = new StorageOptions
{
DatabasePath = StorageOpts.CurrentValue.DatabasePath,
ExportDirectory = StorageOpts.CurrentValue.ExportDirectory,
SnapshotRetentionDays = StorageOpts.CurrentValue.SnapshotRetentionDays,
};
_anomaly = new AnomalyOptions
{
SuspensionGapSeconds = AnomalyOpts.CurrentValue.SuspensionGapSeconds,
OddsFlipThreshold = AnomalyOpts.CurrentValue.OddsFlipThreshold,
MinSnapshotCount = AnomalyOpts.CurrentValue.MinSnapshotCount,
DetectionIntervalSeconds = AnomalyOpts.CurrentValue.DetectionIntervalSeconds,
};
_locale = new LocalizationOptions { DefaultCulture = LocaleOpts.CurrentValue.DefaultCulture };
}
private void OnUserAgentsChanged(string raw)
{
_userAgentsRaw = raw;
_scraping.UserAgents = (raw ?? string.Empty)
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private async Task SaveSectionAsync<T>(string section, T payload) where T : class
{
var confirmed = await ConfirmAsync();
if (!confirmed)
{
return;
}
try
{
await Writer.SaveSectionAsync(section, payload);
Snackbar.Add(L["Settings.Saved"], Severity.Success);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to save section {Section}", section);
Snackbar.Add(L["Settings.SaveFailed"], Severity.Error);
}
}
private async Task ResetSectionAsync(string section)
{
var confirmed = await ConfirmAsync();
if (!confirmed)
{
return;
}
try
{
await Writer.ResetSectionAsync(section);
Snackbar.Add(L["Settings.Saved"], Severity.Success);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to reset section {Section}", section);
Snackbar.Add(L["Settings.SaveFailed"], Severity.Error);
}
}
private async Task<bool> ConfirmAsync()
{
var parameters = new DialogParameters
{
["ContentText"] = L["Settings.Confirm.Body"].Value,
["ButtonText"] = L["Settings.Action.Save"].Value,
["CancelText"] = L["Common.Cancel"].Value,
};
var result = await Dialogs.ShowMessageBox(
title: L["Settings.Confirm.Title"],
message: L["Settings.Confirm.Body"],
yesText: L["Settings.Action.Save"],
cancelText: L["Common.Cancel"]);
return result == true;
}
}
@@ -0,0 +1,25 @@
namespace Marathon.UI.Resources;
/// <summary>
/// Marker class for <see cref="Microsoft.Extensions.Localization.IStringLocalizer{T}"/>.
/// Routes all <c>IStringLocalizer&lt;SharedResource&gt;</c> lookups to the
/// <c>SharedResource.{culture}.resx</c> files in this folder.
/// </summary>
/// <remarks>
/// <para><b>Key naming convention</b>: dot-segmented <c>&lt;Surface&gt;.&lt;Element&gt;</c>.</para>
/// <para>Surfaces:</para>
/// <list type="bullet">
/// <item><c>App.*</c> — application chrome (title, brand, tagline)</item>
/// <item><c>Nav.*</c> — main navigation labels</item>
/// <item><c>Home.*</c> — dashboard page</item>
/// <item><c>Settings.*</c> — settings page (further nested by section: <c>Settings.Scraping.*</c>)</item>
/// <item><c>Locale.*</c> — locale switcher labels</item>
/// <item><c>Theme.*</c> — theme toggle labels</item>
/// <item><c>Common.*</c> — shared verbs/nouns (Save, Cancel, Reset)</item>
/// <item><c>Anomaly.*</c> — anomaly feed (Phase 7 placeholder)</item>
/// </list>
/// <para>Add new keys to BOTH <c>SharedResource.ru.resx</c> AND <c>SharedResource.en.resx</c>.</para>
/// </remarks>
public sealed class SharedResource
{
}
@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype"><value>text/microsoft-resx</value></resheader>
<resheader name="version"><value>2.0</value></resheader>
<resheader name="reader"><value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
<resheader name="writer"><value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
<data name="App.Title"><value>Marathon Odds Lab</value></data>
<data name="App.Tagline"><value>Odds analytics for marathonbet.by</value></data>
<data name="App.BrandMark"><value>Marathon</value></data>
<data name="App.Dateline"><value>Odds Laboratory</value></data>
<data name="Nav.Section.Analysis"><value>Analysis</value></data>
<data name="Nav.Section.System"><value>System</value></data>
<data name="Nav.Dashboard"><value>Dashboard</value></data>
<data name="Nav.PreMatch"><value>Pre-match</value></data>
<data name="Nav.Live"><value>Live</value></data>
<data name="Nav.Anomalies"><value>Anomalies</value></data>
<data name="Nav.Results"><value>Results</value></data>
<data name="Nav.Settings"><value>Settings</value></data>
<data name="Home.Kicker"><value>Briefing</value></data>
<data name="Home.Title"><value>Hunting odds-flip anomalies</value></data>
<data name="Home.Lede"><value>We snapshot marathonbet.by lines on a schedule, watch for favorite-underdog reversals, and keep evidence for every anomaly.</value></data>
<data name="Home.Stat.Events"><value>Events tracked</value></data>
<data name="Home.Stat.Snapshots"><value>Snapshots today</value></data>
<data name="Home.Stat.Anomalies"><value>Anomalies flagged</value></data>
<data name="Home.Stat.Coverage"><value>Sports covered</value></data>
<data name="Home.Section.Latest"><value>Latest signals</value></data>
<data name="Home.Section.Pipeline"><value>Capture pipeline</value></data>
<data name="Home.Pipeline.Step1"><value>Schedule capture (`/su`)</value></data>
<data name="Home.Pipeline.Step2"><value>Odds snapshot</value></data>
<data name="Home.Pipeline.Step3"><value>Flip detector</value></data>
<data name="Home.Pipeline.Step4"><value>XLSX export</value></data>
<data name="Home.Empty"><value>No data yet. Enable the background pollers in Settings to start the feed.</value></data>
<data name="Settings.Kicker"><value>Configuration</value></data>
<data name="Settings.Title"><value>Settings</value></data>
<data name="Settings.Lede"><value>Every scraper, storage, detector, and locale parameter. Changes are written to appsettings.Local.json and applied live.</value></data>
<data name="Settings.Section.Scraping"><value>Scraping</value></data>
<data name="Settings.Section.Workers"><value>Background workers</value></data>
<data name="Settings.Section.Storage"><value>Storage</value></data>
<data name="Settings.Section.Anomaly"><value>Anomaly detector</value></data>
<data name="Settings.Section.Localization"><value>Localization</value></data>
<data name="Settings.Action.Reset"><value>Reset section</value></data>
<data name="Settings.Action.Save"><value>Save</value></data>
<data name="Settings.Action.SaveAll"><value>Save all</value></data>
<data name="Settings.Confirm.Title"><value>Confirm changes</value></data>
<data name="Settings.Confirm.Body"><value>Settings will be written to appsettings.Local.json and re-read by services. Continue?</value></data>
<data name="Settings.Saved"><value>Settings saved.</value></data>
<data name="Settings.SaveFailed"><value>Failed to save settings.</value></data>
<data name="Settings.Scraping.PollingIntervalSeconds"><value>Polling interval (sec)</value></data>
<data name="Settings.Scraping.PollingIntervalSeconds.Hint"><value>How often to refresh the schedule. Minimum 5 seconds.</value></data>
<data name="Settings.Scraping.MaxConcurrentRequests"><value>Concurrent requests</value></data>
<data name="Settings.Scraping.MaxConcurrentRequests.Hint"><value>Cap at 8 to avoid throttling.</value></data>
<data name="Settings.Scraping.UserAgents"><value>User-Agent pool</value></data>
<data name="Settings.Scraping.UserAgents.Hint"><value>One UA per line. Rotated per request.</value></data>
<data name="Settings.Scraping.RetryMaxAttempts"><value>Retry attempts</value></data>
<data name="Settings.Scraping.RetryBaseDelayMs"><value>Base delay (ms)</value></data>
<data name="Settings.Scraping.RateLimitRps"><value>Rate limit (RPS)</value></data>
<data name="Settings.Scraping.RateLimitRps.Hint"><value>Requests per second. 1 is recommended.</value></data>
<data name="Settings.Scraping.BaseUrl"><value>Base URL</value></data>
<data name="Settings.Scraping.RequestTimeoutSeconds"><value>Request timeout (sec)</value></data>
<data name="Settings.Scraping.UsePlaywright"><value>Use Playwright</value></data>
<data name="Settings.Workers.UpcomingScheduleCron"><value>Schedule cron (UPCOMING)</value></data>
<data name="Settings.Workers.UpcomingScheduleCron.Hint"><value>Standard cron. Defaults to every 5 minutes.</value></data>
<data name="Settings.Workers.LivePollerEnabled"><value>Live poller enabled</value></data>
<data name="Settings.Workers.UpcomingPollerEnabled"><value>Schedule poller enabled</value></data>
<data name="Settings.Storage.DatabasePath"><value>SQLite path</value></data>
<data name="Settings.Storage.ExportDirectory"><value>Export directory</value></data>
<data name="Settings.Storage.SnapshotRetentionDays"><value>Snapshot retention (days)</value></data>
<data name="Settings.Anomaly.SuspensionGapSeconds"><value>Suspension window (sec)</value></data>
<data name="Settings.Anomaly.OddsFlipThreshold"><value>Flip threshold (Δ probability)</value></data>
<data name="Settings.Anomaly.MinSnapshotCount"><value>Min snapshot count</value></data>
<data name="Settings.Anomaly.DetectionIntervalSeconds"><value>Detection interval (sec)</value></data>
<data name="Settings.Localization.DefaultCulture"><value>Default UI language</value></data>
<data name="Locale.Russian"><value>RU</value></data>
<data name="Locale.English"><value>EN</value></data>
<data name="Locale.Tooltip.Switch"><value>Switch language</value></data>
<data name="Theme.Toggle.Light"><value>Light theme</value></data>
<data name="Theme.Toggle.Dark"><value>Dark theme</value></data>
<data name="Common.Save"><value>Save</value></data>
<data name="Common.Cancel"><value>Cancel</value></data>
<data name="Common.Reset"><value>Reset</value></data>
<data name="Common.Loading"><value>Loading…</value></data>
<data name="Common.Empty"><value>No data</value></data>
<data name="Common.Yes"><value>Yes</value></data>
<data name="Common.No"><value>No</value></data>
<data name="Anomaly.Live"><value>Anomaly</value></data>
<data name="Anomaly.Kind.SuspensionFlip"><value>Suspension flip</value></data>
<data name="Anomaly.Score"><value>Confidence</value></data>
</root>
@@ -0,0 +1,160 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype"><value>text/microsoft-resx</value></resheader>
<resheader name="version"><value>2.0</value></resheader>
<resheader name="reader"><value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
<resheader name="writer"><value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
<!-- App chrome -->
<data name="App.Title"><value>Marathon Odds Lab</value></data>
<data name="App.Tagline"><value>Аналитика коэффициентов marathonbet.by</value></data>
<data name="App.BrandMark"><value>Marathon</value></data>
<data name="App.Dateline"><value>Лаборатория коэффициентов</value></data>
<!-- Navigation -->
<data name="Nav.Section.Analysis"><value>Анализ</value></data>
<data name="Nav.Section.System"><value>Система</value></data>
<data name="Nav.Dashboard"><value>Сводка</value></data>
<data name="Nav.PreMatch"><value>До матча</value></data>
<data name="Nav.Live"><value>Лайв</value></data>
<data name="Nav.Anomalies"><value>Аномалии</value></data>
<data name="Nav.Results"><value>Результаты</value></data>
<data name="Nav.Settings"><value>Настройки</value></data>
<!-- Home / Dashboard -->
<data name="Home.Kicker"><value>Сводка</value></data>
<data name="Home.Title"><value>Поиск аномалий в коэффициентах</value></data>
<data name="Home.Lede"><value>Снимаем линии marathonbet.by по расписанию, ищем разворот фаворита и удерживаем доказательства каждой аномалии.</value></data>
<data name="Home.Stat.Events"><value>Событий в работе</value></data>
<data name="Home.Stat.Snapshots"><value>Снимков сегодня</value></data>
<data name="Home.Stat.Anomalies"><value>Аномалий найдено</value></data>
<data name="Home.Stat.Coverage"><value>Видов спорта</value></data>
<data name="Home.Section.Latest"><value>Свежий поток</value></data>
<data name="Home.Section.Pipeline"><value>Конвейер сбора</value></data>
<data name="Home.Pipeline.Step1"><value>Сбор расписания (`/su`)</value></data>
<data name="Home.Pipeline.Step2"><value>Снимок коэффициентов</value></data>
<data name="Home.Pipeline.Step3"><value>Детектор разворота</value></data>
<data name="Home.Pipeline.Step4"><value>Экспорт XLSX</value></data>
<data name="Home.Empty"><value>Пока пусто. Запустите фоновые сборщики на странице «Настройки», чтобы пошёл поток данных.</value></data>
<!-- Settings — sections -->
<data name="Settings.Kicker"><value>Конфигурация</value></data>
<data name="Settings.Title"><value>Настройки</value></data>
<data name="Settings.Lede"><value>Каждый параметр сборщика, хранилища, детектора и локализации. Изменения сохраняются в appsettings.Local.json и применяются на лету.</value></data>
<data name="Settings.Section.Scraping"><value>Сбор</value></data>
<data name="Settings.Section.Workers"><value>Фоновые задачи</value></data>
<data name="Settings.Section.Storage"><value>Хранилище</value></data>
<data name="Settings.Section.Anomaly"><value>Детектор аномалий</value></data>
<data name="Settings.Section.Localization"><value>Локализация</value></data>
<data name="Settings.Action.Reset"><value>Сбросить раздел</value></data>
<data name="Settings.Action.Save"><value>Сохранить</value></data>
<data name="Settings.Action.SaveAll"><value>Сохранить все</value></data>
<data name="Settings.Confirm.Title"><value>Подтвердите изменения</value></data>
<data name="Settings.Confirm.Body"><value>Параметры будут записаны в appsettings.Local.json и перечитаны службами. Продолжить?</value></data>
<data name="Settings.Saved"><value>Настройки сохранены.</value></data>
<data name="Settings.SaveFailed"><value>Не удалось сохранить настройки.</value></data>
<!-- Settings — Scraping -->
<data name="Settings.Scraping.PollingIntervalSeconds"><value>Интервал опроса (сек)</value></data>
<data name="Settings.Scraping.PollingIntervalSeconds.Hint"><value>Как часто перечитывать список матчей. Минимум 5 секунд.</value></data>
<data name="Settings.Scraping.MaxConcurrentRequests"><value>Параллельных запросов</value></data>
<data name="Settings.Scraping.MaxConcurrentRequests.Hint"><value>Не более 8 — иначе увидим 429.</value></data>
<data name="Settings.Scraping.UserAgents"><value>Пул User-Agent</value></data>
<data name="Settings.Scraping.UserAgents.Hint"><value>По одному значению на строку. Ротируется на запрос.</value></data>
<data name="Settings.Scraping.RetryMaxAttempts"><value>Повторы при сбое</value></data>
<data name="Settings.Scraping.RetryBaseDelayMs"><value>Базовая задержка (мс)</value></data>
<data name="Settings.Scraping.RateLimitRps"><value>Лимит RPS</value></data>
<data name="Settings.Scraping.RateLimitRps.Hint"><value>Запросов в секунду. Рекомендовано 1.</value></data>
<data name="Settings.Scraping.BaseUrl"><value>Базовый URL</value></data>
<data name="Settings.Scraping.RequestTimeoutSeconds"><value>Тайм-аут запроса (сек)</value></data>
<data name="Settings.Scraping.UsePlaywright"><value>Использовать Playwright</value></data>
<!-- Settings — Workers -->
<data name="Settings.Workers.UpcomingScheduleCron"><value>Cron расписания (UPCOMING)</value></data>
<data name="Settings.Workers.UpcomingScheduleCron.Hint"><value>Стандартный cron. По умолчанию каждые 5 минут.</value></data>
<data name="Settings.Workers.LivePollerEnabled"><value>Лайв-сборщик включён</value></data>
<data name="Settings.Workers.UpcomingPollerEnabled"><value>Сборщик расписания включён</value></data>
<!-- Settings — Storage -->
<data name="Settings.Storage.DatabasePath"><value>Путь к SQLite</value></data>
<data name="Settings.Storage.ExportDirectory"><value>Каталог экспорта</value></data>
<data name="Settings.Storage.SnapshotRetentionDays"><value>Хранить снимки (дней)</value></data>
<!-- Settings — Anomaly -->
<data name="Settings.Anomaly.SuspensionGapSeconds"><value>Окно «заморозки» (сек)</value></data>
<data name="Settings.Anomaly.OddsFlipThreshold"><value>Порог флипа (Δ вероятности)</value></data>
<data name="Settings.Anomaly.MinSnapshotCount"><value>Мин. число снимков</value></data>
<data name="Settings.Anomaly.DetectionIntervalSeconds"><value>Интервал детектора (сек)</value></data>
<!-- Settings — Localization -->
<data name="Settings.Localization.DefaultCulture"><value>Язык интерфейса по умолчанию</value></data>
<!-- Locale switcher -->
<data name="Locale.Russian"><value>RU</value></data>
<data name="Locale.English"><value>EN</value></data>
<data name="Locale.Tooltip.Switch"><value>Сменить язык</value></data>
<!-- Theme toggle -->
<data name="Theme.Toggle.Light"><value>Светлая тема</value></data>
<data name="Theme.Toggle.Dark"><value>Тёмная тема</value></data>
<!-- Common -->
<data name="Common.Save"><value>Сохранить</value></data>
<data name="Common.Cancel"><value>Отмена</value></data>
<data name="Common.Reset"><value>Сбросить</value></data>
<data name="Common.Loading"><value>Загрузка…</value></data>
<data name="Common.Empty"><value>Нет данных</value></data>
<data name="Common.Yes"><value>Да</value></data>
<data name="Common.No"><value>Нет</value></data>
<!-- Anomaly (Phase 7 placeholders) -->
<data name="Anomaly.Live"><value>Аномалия</value></data>
<data name="Anomaly.Kind.SuspensionFlip"><value>Разворот после заморозки</value></data>
<data name="Anomaly.Score"><value>Уверенность</value></data>
</root>
@@ -0,0 +1,21 @@
namespace Marathon.UI.Services;
/// <summary>
/// Options bound to the <c>Anomaly</c> section of <c>appsettings.json</c>.
/// </summary>
public sealed class AnomalyOptions
{
public const string SectionName = "Anomaly";
/// <summary>Suspension window after which a flip is treated as suspicious.</summary>
public int SuspensionGapSeconds { get; set; } = 60;
/// <summary>Implied-probability delta that qualifies as a flip.</summary>
public decimal OddsFlipThreshold { get; set; } = 0.30m;
/// <summary>Minimum snapshot count before the detector runs.</summary>
public int MinSnapshotCount { get; set; } = 3;
/// <summary>How often the detector executes, in seconds.</summary>
public int DetectionIntervalSeconds { get; set; } = 60;
}
@@ -0,0 +1,21 @@
namespace Marathon.UI.Services;
/// <summary>
/// Persists user-edited settings to <c>appsettings.Local.json</c> (gitignored).
/// </summary>
public interface ISettingsWriter
{
/// <summary>
/// Persists a single configuration section under its canonical name
/// (e.g. <c>"Scraping"</c>, <c>"Storage"</c>) to <c>appsettings.Local.json</c>.
/// Other sections in that file are preserved.
/// </summary>
Task SaveSectionAsync<T>(string sectionName, T values, CancellationToken cancellationToken = default)
where T : class;
/// <summary>
/// Removes the specified section from <c>appsettings.Local.json</c>, restoring the
/// value defined in <c>appsettings.json</c> on next configuration reload.
/// </summary>
Task ResetSectionAsync(string sectionName, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,142 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Marathon.UI.Services;
/// <summary>
/// File-backed implementation of <see cref="ISettingsWriter"/> that maintains
/// <c>appsettings.Local.json</c> next to the host's <c>appsettings.json</c>.
/// </summary>
/// <remarks>
/// The host registers this with a known file path (resolved from the host's
/// <c>ContentRootPath</c>). The file is created on first write and is gitignored
/// by the repository's <c>.gitignore</c>.
/// </remarks>
public sealed class JsonSettingsWriter : ISettingsWriter
{
private static readonly JsonSerializerOptions ReadOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
private static readonly JsonSerializerOptions WriteOptions = new()
{
WriteIndented = true,
};
private readonly string _filePath;
private readonly SemaphoreSlim _gate = new(1, 1);
public JsonSettingsWriter(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
{
throw new ArgumentException("Settings file path is required.", nameof(filePath));
}
_filePath = filePath;
}
public async Task SaveSectionAsync<T>(string sectionName, T values, CancellationToken cancellationToken = default)
where T : class
{
if (string.IsNullOrWhiteSpace(sectionName))
{
throw new ArgumentException("Section name is required.", nameof(sectionName));
}
ArgumentNullException.ThrowIfNull(values);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var root = await ReadRootAsync(cancellationToken).ConfigureAwait(false);
var json = JsonSerializer.SerializeToNode(values, WriteOptions);
root[sectionName] = json;
await WriteRootAsync(root, cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
public async Task ResetSectionAsync(string sectionName, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(sectionName))
{
throw new ArgumentException("Section name is required.", nameof(sectionName));
}
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (!File.Exists(_filePath))
{
return;
}
var root = await ReadRootAsync(cancellationToken).ConfigureAwait(false);
if (root.ContainsKey(sectionName))
{
root.Remove(sectionName);
await WriteRootAsync(root, cancellationToken).ConfigureAwait(false);
}
}
finally
{
_gate.Release();
}
}
private async Task<JsonObject> ReadRootAsync(CancellationToken cancellationToken)
{
if (!File.Exists(_filePath))
{
return new JsonObject();
}
await using var stream = File.OpenRead(_filePath);
var node = await JsonNode.ParseAsync(stream, documentOptions: new JsonDocumentOptions
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
}, cancellationToken: cancellationToken).ConfigureAwait(false);
return node as JsonObject ?? new JsonObject();
}
private async Task WriteRootAsync(JsonObject root, CancellationToken cancellationToken)
{
var directory = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
var tempPath = _filePath + ".tmp";
await using (var stream = File.Create(tempPath))
{
await JsonSerializer.SerializeAsync(stream, root, WriteOptions, cancellationToken).ConfigureAwait(false);
}
// Atomic rename — survives crashes mid-write.
File.Move(tempPath, _filePath, overwrite: true);
}
/// <summary>For tests: reads the persisted JSON object back.</summary>
public async Task<JsonObject> ReadAllAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await ReadRootAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
}
+50
View File
@@ -0,0 +1,50 @@
using System.Globalization;
namespace Marathon.UI.Services;
/// <summary>
/// Observable culture state. Components subscribe to <see cref="OnChange"/> to
/// re-render when the user toggles the locale. Setting the value also flips
/// <see cref="CultureInfo.DefaultThreadCurrentUICulture"/> so newly created
/// localizers pick up the right resource.
/// </summary>
public sealed class LocaleState
{
public const string Russian = "ru-RU";
public const string English = "en-US";
public static readonly IReadOnlyList<string> Supported = new[] { Russian, English };
private CultureInfo _culture = CultureInfo.GetCultureInfo(Russian);
public CultureInfo Culture
{
get => _culture;
private set
{
if (string.Equals(_culture.Name, value.Name, StringComparison.OrdinalIgnoreCase))
{
return;
}
_culture = value;
CultureInfo.DefaultThreadCurrentCulture = value;
CultureInfo.DefaultThreadCurrentUICulture = value;
CultureInfo.CurrentCulture = value;
CultureInfo.CurrentUICulture = value;
OnChange?.Invoke();
}
}
public event Action? OnChange;
public void Set(string cultureName)
{
if (string.IsNullOrWhiteSpace(cultureName))
{
throw new ArgumentException("Culture name is required.", nameof(cultureName));
}
Culture = CultureInfo.GetCultureInfo(cultureName);
}
}
@@ -0,0 +1,12 @@
namespace Marathon.UI.Services;
/// <summary>
/// Options bound to the <c>Localization</c> section of <c>appsettings.json</c>.
/// </summary>
public sealed class LocalizationOptions
{
public const string SectionName = "Localization";
/// <summary>The default UI culture; either <c>ru-RU</c> or <c>en-US</c>.</summary>
public string DefaultCulture { get; set; } = "ru-RU";
}
@@ -0,0 +1,51 @@
namespace Marathon.UI.Services;
/// <summary>
/// UI-side form model that mirrors <c>Marathon.Infrastructure.Configuration.ScrapingOptions</c>.
/// Kept here (not in Infrastructure) so the Razor Class Library remains
/// host-agnostic — i.e. usable from a future ASP.NET Core host that doesn't
/// reference the Infrastructure project directly.
/// </summary>
/// <remarks>
/// Property names match the JSON shape exactly so binding works either way.
/// </remarks>
public sealed class ScrapingSettingsForm
{
public const string SectionName = "Scraping";
public int PollingIntervalSeconds { get; set; } = 30;
public int MaxConcurrentRequests { get; set; } = 4;
public string[] UserAgents { get; set; } = Array.Empty<string>();
public RetryPolicyForm RetryPolicy { get; set; } = new();
public RateLimitForm RateLimit { get; set; } = new();
public bool UsePlaywright { get; set; }
public string BaseUrl { get; set; } = "https://www.marathonbet.by";
public int RequestTimeoutSeconds { get; set; } = 30;
public ScrapingSettingsForm Clone() => new()
{
PollingIntervalSeconds = PollingIntervalSeconds,
MaxConcurrentRequests = MaxConcurrentRequests,
UserAgents = (string[])UserAgents.Clone(),
RetryPolicy = new RetryPolicyForm
{
MaxAttempts = RetryPolicy.MaxAttempts,
BaseDelayMs = RetryPolicy.BaseDelayMs,
},
RateLimit = new RateLimitForm { RequestsPerSecond = RateLimit.RequestsPerSecond },
UsePlaywright = UsePlaywright,
BaseUrl = BaseUrl,
RequestTimeoutSeconds = RequestTimeoutSeconds,
};
}
public sealed class RetryPolicyForm
{
public int MaxAttempts { get; set; } = 3;
public int BaseDelayMs { get; set; } = 500;
}
public sealed class RateLimitForm
{
public int RequestsPerSecond { get; set; } = 1;
}
+31
View File
@@ -0,0 +1,31 @@
namespace Marathon.UI.Services;
/// <summary>
/// In-memory observable holding the current light/dark preference. Persisted
/// (best-effort) by the host via <see cref="ISettingsWriter"/> on change.
/// </summary>
public sealed class ThemeState
{
private bool _isDark;
public bool IsDark
{
get => _isDark;
private set
{
if (_isDark == value)
{
return;
}
_isDark = value;
OnChange?.Invoke();
}
}
public event Action? OnChange;
public void Set(bool isDark) => IsDark = isDark;
public void Toggle() => IsDark = !IsDark;
}
@@ -0,0 +1,54 @@
using Marathon.Application.Storage;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
namespace Marathon.UI.Services;
/// <summary>
/// DI registration helpers for the Marathon.UI Razor Class Library.
/// Hosts call <see cref="AddMarathonUi(IServiceCollection, IConfiguration, string)"/>
/// during startup.
/// </summary>
public static class UiServicesExtensions
{
/// <summary>
/// Registers MudBlazor services, localization, the theme/locale observable
/// state objects, the file-backed settings writer, and binds all
/// configuration sections that the Settings page surfaces.
/// </summary>
/// <param name="services">DI container.</param>
/// <param name="configuration">Host configuration root.</param>
/// <param name="settingsLocalPath">
/// Absolute path to <c>appsettings.Local.json</c>, used by the writer.
/// </param>
public static IServiceCollection AddMarathonUi(
this IServiceCollection services,
IConfiguration configuration,
string settingsLocalPath)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
ArgumentException.ThrowIfNullOrEmpty(settingsLocalPath);
services.AddMudServices();
services.AddLocalization(options => options.ResourcesPath = "Resources");
// Strongly typed options bound to appsettings.json sections.
services.Configure<LocalizationOptions>(configuration.GetSection(LocalizationOptions.SectionName));
services.Configure<WorkerOptions>(configuration.GetSection(WorkerOptions.SectionName));
services.Configure<AnomalyOptions>(configuration.GetSection(AnomalyOptions.SectionName));
services.Configure<StorageOptions>(configuration.GetSection(StorageOptions.SectionName));
services.Configure<ScrapingSettingsForm>(configuration.GetSection(ScrapingSettingsForm.SectionName));
// Singletons that drive UI chrome state.
services.AddSingleton<ThemeState>();
services.AddSingleton<LocaleState>();
// Settings writer — file path is host-resolved.
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));
return services;
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace Marathon.UI.Services;
/// <summary>
/// Options bound to the <c>Workers</c> section of <c>appsettings.json</c>.
/// Phase 4 will read these to configure the background pollers.
/// </summary>
public sealed class WorkerOptions
{
public const string SectionName = "Workers";
/// <summary>Cron expression that drives the upcoming-schedule poller.</summary>
public string UpcomingScheduleCron { get; set; } = "0 */5 * * * *";
/// <summary>Whether the live odds poller should run at startup.</summary>
public bool LivePollerEnabled { get; set; } = true;
/// <summary>Whether the upcoming/pre-match poller should run at startup.</summary>
public bool UpcomingPollerEnabled { get; set; } = true;
}
+294
View File
@@ -0,0 +1,294 @@
using MudBlazor;
using MudBlazor.Utilities;
namespace Marathon.UI.Theme;
/// <summary>
/// The Marathon design system, expressed as a MudBlazor theme.
///
/// Aesthetic direction: editorial-quant. Inspired by Bloomberg terminals,
/// FT.com long-reads, and Quartz dashboards. Confident, information-dense,
/// reveals patterns. Pairs IBM Plex Sans (Cyrillic-capable display + body)
/// with JetBrains Mono for tabular numerals. Anomaly accent is a load-bearing
/// signal red so Phase 7 can hang the entire anomaly visual language off
/// <c>palette.Error</c> without coupling to a hard-coded hex.
/// </summary>
public static class MarathonTheme
{
/// <summary>The full theme — both light and dark palettes plus typography.</summary>
public static MudTheme Build() => new()
{
PaletteLight = LightPalette,
PaletteDark = DarkPalette,
Typography = MarathonTypography,
LayoutProperties = LayoutProps,
Shadows = MarathonShadows,
ZIndex = new ZIndex(),
};
// ------------------------------------------------------------------
// Palettes — single accent (amber #d97706), single signal (red #ef4444)
// on a deep navy/parchment chassis. No purple gradients, no cliche.
// ------------------------------------------------------------------
private static readonly PaletteLight LightPalette = new()
{
Primary = "#0f172a", // deep navy / ink
PrimaryContrastText = "#fafaf7",
Secondary = "#334155", // slate
SecondaryContrastText = "#fafaf7",
Tertiary = "#d97706", // amber accent
TertiaryContrastText = "#1c1917",
Info = "#0369a1",
Success = "#15803d",
Warning = "#b45309",
Error = "#dc2626", // anomaly signal
ErrorContrastText = "#fff7ed",
Black = "#1c1917",
White = "#fafaf7",
Surface = "#fafaf7", // warm parchment
Background = "#f5f4ef", // a half-step warmer than surface
BackgroundGray = "#ebe9e1",
DrawerBackground = "#0f172a", // dark drawer on light app — editorial contrast
DrawerText = "#e7e5e4",
DrawerIcon = "#d6d3d1",
AppbarBackground = "#fafaf7",
AppbarText = "#0f172a",
TextPrimary = "#0f172a",
TextSecondary = "#475569",
TextDisabled = "#94a3b8",
ActionDefault = "#334155",
ActionDisabled = "#cbd5e1",
ActionDisabledBackground = "#e2e8f0",
LinesDefault = "#e7e5e4",
LinesInputs = "#cbd5e1",
TableLines = "#e7e5e4",
TableStriped = "#f5f4ef",
TableHover = "#ebe9e1",
Divider = "#e7e5e4",
DividerLight = "#f1f5f9",
OverlayDark = new MudColor("#0f172a99").Value,
OverlayLight = new MudColor("#fafaf7cc").Value,
};
private static readonly PaletteDark DarkPalette = new()
{
Primary = "#fbbf24", // amber, promoted in dark mode
PrimaryContrastText = "#0c0a09",
Secondary = "#94a3b8",
SecondaryContrastText = "#0c0a09",
Tertiary = "#fbbf24",
TertiaryContrastText = "#0c0a09",
Info = "#38bdf8",
Success = "#4ade80",
Warning = "#fbbf24",
Error = "#f87171", // anomaly signal — softened for dark
ErrorContrastText = "#0c0a09",
Black = "#0c0a09",
White = "#fafaf7",
Surface = "#1c1917", // ink-stained paper
Background = "#0c0a09", // near-black
BackgroundGray = "#1c1917",
DrawerBackground = "#0c0a09",
DrawerText = "#e7e5e4",
DrawerIcon = "#a8a29e",
AppbarBackground = "#0c0a09",
AppbarText = "#fafaf7",
TextPrimary = "#f5f5f4",
TextSecondary = "#a8a29e",
TextDisabled = "#57534e",
ActionDefault = "#a8a29e",
ActionDisabled = "#44403c",
ActionDisabledBackground = "#1c1917",
LinesDefault = "#292524",
LinesInputs = "#44403c",
TableLines = "#292524",
TableStriped = "#1c1917",
TableHover = "#292524",
Divider = "#292524",
DividerLight = "#1c1917",
OverlayDark = new MudColor("#0c0a09cc").Value,
OverlayLight = new MudColor("#fafaf722").Value,
};
// ------------------------------------------------------------------
// Typography — IBM Plex Sans / JetBrains Mono / IBM Plex Serif (display)
// All have full Cyrillic coverage. Numerals are tabular.
// ------------------------------------------------------------------
private static readonly string[] DisplayStack = { "IBM Plex Serif", "PT Serif", "Georgia", "serif" };
private static readonly string[] BodyStack = { "IBM Plex Sans", "PT Sans", "system-ui", "sans-serif" };
private static readonly string[] MonoStack = { "JetBrains Mono", "IBM Plex Mono", "Fira Code", "Consolas", "monospace" };
private static readonly Typography MarathonTypography = new()
{
Default = new Default
{
FontFamily = BodyStack,
FontWeight = 400,
FontSize = "0.9375rem", // 15px — denser than MUD default 16
LineHeight = 1.55,
LetterSpacing = "0",
},
H1 = new H1
{
FontFamily = DisplayStack,
FontWeight = 300,
FontSize = "clamp(2.25rem, 4vw, 3.5rem)",
LineHeight = 1.05,
LetterSpacing = "-0.022em",
},
H2 = new H2
{
FontFamily = DisplayStack,
FontWeight = 400,
FontSize = "clamp(1.75rem, 2.5vw, 2.25rem)",
LineHeight = 1.15,
LetterSpacing = "-0.018em",
},
H3 = new H3
{
FontFamily = DisplayStack,
FontWeight = 500,
FontSize = "1.5rem",
LineHeight = 1.25,
LetterSpacing = "-0.012em",
},
H4 = new H4
{
FontFamily = BodyStack,
FontWeight = 600,
FontSize = "1.25rem",
LineHeight = 1.3,
LetterSpacing = "-0.005em",
},
H5 = new H5
{
FontFamily = BodyStack,
FontWeight = 600,
FontSize = "1.0625rem",
LineHeight = 1.35,
},
H6 = new H6
{
FontFamily = BodyStack,
FontWeight = 600,
FontSize = "0.9375rem",
LineHeight = 1.4,
LetterSpacing = "0.02em",
},
Subtitle1 = new Subtitle1
{
FontFamily = BodyStack,
FontWeight = 500,
FontSize = "0.9375rem",
LineHeight = 1.5,
},
Subtitle2 = new Subtitle2
{
FontFamily = BodyStack,
FontWeight = 500,
FontSize = "0.8125rem",
LineHeight = 1.5,
LetterSpacing = "0.01em",
},
Body1 = new Body1
{
FontFamily = BodyStack,
FontWeight = 400,
FontSize = "0.9375rem",
LineHeight = 1.55,
},
Body2 = new Body2
{
FontFamily = BodyStack,
FontWeight = 400,
FontSize = "0.8125rem",
LineHeight = 1.5,
},
Button = new Button
{
FontFamily = BodyStack,
FontWeight = 500,
FontSize = "0.8125rem",
LineHeight = 1.4,
LetterSpacing = "0.06em",
TextTransform = "uppercase",
},
Caption = new Caption
{
FontFamily = MonoStack,
FontWeight = 400,
FontSize = "0.75rem",
LineHeight = 1.4,
LetterSpacing = "0.04em",
TextTransform = "uppercase",
},
Overline = new Overline
{
FontFamily = MonoStack,
FontWeight = 500,
FontSize = "0.6875rem",
LineHeight = 1.4,
LetterSpacing = "0.18em",
TextTransform = "uppercase",
},
};
// ------------------------------------------------------------------
// Layout — sharp corners, narrow drawer. The aesthetic earns its
// authority through restraint.
// ------------------------------------------------------------------
private static readonly LayoutProperties LayoutProps = new()
{
DefaultBorderRadius = "2px",
AppbarHeight = "60px",
DrawerWidthLeft = "248px",
DrawerWidthRight = "248px",
DrawerMiniWidthLeft = "60px",
DrawerMiniWidthRight = "60px",
};
// ------------------------------------------------------------------
// Shadows — flat by default, one accent shadow for floating panels.
// Override only the slots Mud actually uses; keep first/last as-is.
// ------------------------------------------------------------------
private static readonly Shadow MarathonShadows = new()
{
Elevation = new[]
{
"none",
"0 1px 0 0 rgba(15,23,42,0.06)",
"0 1px 2px 0 rgba(15,23,42,0.08)",
"0 2px 4px -1px rgba(15,23,42,0.10)",
"0 4px 8px -2px rgba(15,23,42,0.12)",
"0 6px 14px -4px rgba(15,23,42,0.14)",
"0 8px 18px -6px rgba(15,23,42,0.16)",
"0 10px 22px -8px rgba(15,23,42,0.18)",
"0 12px 28px -10px rgba(15,23,42,0.20)",
"0 14px 32px -12px rgba(15,23,42,0.22)",
"0 16px 36px -14px rgba(15,23,42,0.24)",
"0 18px 40px -16px rgba(15,23,42,0.26)",
"0 20px 44px -18px rgba(15,23,42,0.28)",
"0 22px 48px -20px rgba(15,23,42,0.30)",
"0 24px 52px -22px rgba(15,23,42,0.32)",
"0 26px 56px -24px rgba(15,23,42,0.34)",
"0 28px 60px -26px rgba(15,23,42,0.36)",
"0 30px 64px -28px rgba(15,23,42,0.38)",
"0 32px 68px -30px rgba(15,23,42,0.40)",
"0 34px 72px -32px rgba(15,23,42,0.42)",
"0 36px 76px -34px rgba(15,23,42,0.44)",
"0 38px 80px -36px rgba(15,23,42,0.46)",
"0 40px 84px -38px rgba(15,23,42,0.48)",
"0 42px 88px -40px rgba(15,23,42,0.50)",
"0 44px 92px -42px rgba(15,23,42,0.52)",
"0 46px 96px -44px rgba(15,23,42,0.54)",
},
};
}
+38
View File
@@ -0,0 +1,38 @@
namespace Marathon.UI.Theme;
/// <summary>
/// Design tokens exposed to C# code (e.g. for chart colors, custom shapes,
/// Razor components that need to reach beyond the MudTheme palette).
/// Mirrors the values declared as CSS variables in <c>wwwroot/app.css</c>.
/// </summary>
public static class Tokens
{
public static class Colors
{
public const string AnomalySignal = "#dc2626";
public const string AnomalySignalDark = "#f87171";
public const string Accent = "#d97706";
public const string AccentDark = "#fbbf24";
public const string InkPrimary = "#0f172a";
public const string Parchment = "#fafaf7";
public const string ParchmentDeep = "#f5f4ef";
public const string InkDeep = "#0c0a09";
}
public static class Spacing
{
public const string Xs = "4px";
public const string Sm = "8px";
public const string Md = "16px";
public const string Lg = "24px";
public const string Xl = "40px";
public const string Xxl = "64px";
}
public static class Typography
{
public const string DisplayStack = "\"IBM Plex Serif\", \"PT Serif\", Georgia, serif";
public const string BodyStack = "\"IBM Plex Sans\", \"PT Sans\", system-ui, sans-serif";
public const string MonoStack = "\"JetBrains Mono\", \"IBM Plex Mono\", \"Fira Code\", Consolas, monospace";
}
}
+23 -1
View File
@@ -1 +1,23 @@
@using Microsoft.AspNetCore.Components.Web
@using System
@using System.Collections.Generic
@using System.Globalization
@using System.Linq
@using System.Threading
@using System.Threading.Tasks
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.Extensions.Localization
@using Microsoft.Extensions.Options
@using Microsoft.Extensions.Logging
@using MudBlazor
@using Marathon.Domain.Entities
@using Marathon.Domain.Enums
@using Marathon.Domain.ValueObjects
@using Marathon.UI
@using Marathon.UI.Components
@using Marathon.UI.Pages
@using Marathon.UI.Resources
@using Marathon.UI.Services
@using Marathon.UI.Theme
+479
View File
@@ -0,0 +1,479 @@
/* ===================================================================
Marathon — Editorial-Quant design system
------------------------------------------------------------------
Inspiration: long-form data journalism (FT, Quartz), terminal
instruments (Bloomberg), and Belarusian / Soviet print typography.
The aesthetic is confident, dense, and serif-led on display surfaces.
=================================================================== */
:root {
/* ----- Spacing scale (4-pt base, doubled at 16+) ----- */
--m-space-1: 4px;
--m-space-2: 8px;
--m-space-3: 12px;
--m-space-4: 16px;
--m-space-5: 24px;
--m-space-6: 32px;
--m-space-7: 48px;
--m-space-8: 64px;
--m-space-9: 96px;
/* ----- Radius — sharp by default, soft variants for inputs ----- */
--m-radius-sharp: 0;
--m-radius-xs: 2px;
--m-radius-sm: 4px;
--m-radius-md: 6px;
--m-radius-lg: 10px;
/* ----- Typography ----- */
--m-font-display: "IBM Plex Serif", "PT Serif", Georgia, serif;
--m-font-body: "IBM Plex Sans", "PT Sans", system-ui, sans-serif;
--m-font-mono: "JetBrains Mono", "IBM Plex Mono", "Fira Code", Consolas, monospace;
/* ----- Colors — light (parchment) chassis ----- */
--m-c-ink: #0f172a;
--m-c-ink-2: #1e293b;
--m-c-ink-soft: #475569;
--m-c-paper: #fafaf7;
--m-c-paper-2: #f5f4ef;
--m-c-paper-3: #ebe9e1;
--m-c-rule: #e7e5e4;
--m-c-accent: #d97706;
--m-c-accent-soft: #f59e0b;
--m-c-anomaly: #dc2626;
--m-c-positive: #15803d;
--m-c-info: #0369a1;
/* Tabular numerals for everywhere odds/scores appear */
--m-num-feature: "tnum" 1, "lnum" 1, "ss01" 1;
}
/* Dark theme overrides (applied via class on <html> or via MudThemeProvider) */
.mud-theme-dark, [data-theme="dark"] {
--m-c-ink: #f5f5f4;
--m-c-ink-2: #e7e5e4;
--m-c-ink-soft: #a8a29e;
--m-c-paper: #1c1917;
--m-c-paper-2: #0c0a09;
--m-c-paper-3: #292524;
--m-c-rule: #292524;
--m-c-accent: #fbbf24;
--m-c-accent-soft: #fcd34d;
--m-c-anomaly: #f87171;
--m-c-positive: #4ade80;
--m-c-info: #38bdf8;
}
/* ===================================================================
Base
=================================================================== */
html, body {
margin: 0;
padding: 0;
background: var(--m-c-paper-2);
color: var(--m-c-ink);
font-family: var(--m-font-body);
font-feature-settings: var(--m-num-feature);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
/* Subtle paper grain — 1px mottled noise, rendered cheaply via SVG. */
background-image:
radial-gradient(circle at 25% 12%, rgba(217, 119, 6, 0.035), transparent 45%),
radial-gradient(circle at 88% 78%, rgba(15, 23, 42, 0.040), transparent 50%),
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.045 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
background-attachment: fixed;
}
.mud-theme-dark body, [data-theme="dark"] body {
background-image:
radial-gradient(circle at 25% 12%, rgba(251, 191, 36, 0.045), transparent 45%),
radial-gradient(circle at 88% 78%, rgba(56, 189, 248, 0.030), transparent 50%),
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.025 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
}
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ===================================================================
Numerals — always tabular for odds tables and score readouts
=================================================================== */
.m-num,
.mud-table tbody td,
.mud-data-grid tbody td,
[data-numeric] {
font-feature-settings: var(--m-num-feature);
font-variant-numeric: tabular-nums lining-nums;
}
.m-mono {
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
letter-spacing: 0;
}
/* ===================================================================
Editorial markers — kicker label + serif display lockup
=================================================================== */
.m-kicker {
font-family: var(--m-font-mono);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.6875rem;
color: var(--m-c-accent);
font-weight: 500;
display: inline-block;
padding-bottom: var(--m-space-1);
border-bottom: 1px solid var(--m-c-accent);
}
.m-display {
font-family: var(--m-font-display);
font-weight: 300;
letter-spacing: -0.022em;
line-height: 1.05;
color: var(--m-c-ink);
}
.m-rule {
border: 0;
border-top: 1px solid var(--m-c-rule);
margin: var(--m-space-5) 0;
}
.m-rule--double {
border: 0;
border-top: 3px double var(--m-c-rule);
margin: var(--m-space-5) 0;
}
/* ===================================================================
Cards — paper-like, borders not shadows
=================================================================== */
.m-card {
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
border-radius: var(--m-radius-xs);
padding: var(--m-space-5);
position: relative;
}
.m-card--accented {
border-left: 3px solid var(--m-c-accent);
}
.m-card--anomaly {
border-left: 3px solid var(--m-c-anomaly);
}
/* ===================================================================
Stat block — large number, mono, kicker on top
=================================================================== */
.m-stat {
display: flex;
flex-direction: column;
gap: var(--m-space-2);
}
.m-stat__value {
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 500;
line-height: 1;
color: var(--m-c-ink);
letter-spacing: -0.02em;
}
.m-stat__label {
font-family: var(--m-font-body);
font-size: 0.8125rem;
color: var(--m-c-ink-soft);
text-transform: none;
letter-spacing: 0;
}
.m-stat__delta {
font-family: var(--m-font-mono);
font-size: 0.75rem;
color: var(--m-c-positive);
}
.m-stat__delta--down { color: var(--m-c-anomaly); }
/* ===================================================================
Page-load reveal — one orchestrated entrance, respects motion prefs
=================================================================== */
@keyframes m-rise {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.m-rise {
animation: m-rise 480ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
.m-rise-1 { animation-delay: 40ms; }
.m-rise-2 { animation-delay: 100ms; }
.m-rise-3 { animation-delay: 180ms; }
.m-rise-4 { animation-delay: 260ms; }
.m-rise-5 { animation-delay: 340ms; }
@media (prefers-reduced-motion: reduce) {
.m-rise, .m-rise-1, .m-rise-2, .m-rise-3, .m-rise-4, .m-rise-5 {
animation: none !important;
}
}
/* ===================================================================
Focus rings — deliberate, accent, never invisible
=================================================================== */
:focus-visible {
outline: 2px solid var(--m-c-accent);
outline-offset: 2px;
}
.mud-button:focus-visible,
.mud-icon-button:focus-visible {
outline: 2px solid var(--m-c-accent);
outline-offset: 2px;
}
/* ===================================================================
Layout primitives — asymmetric content grid
=================================================================== */
.m-shell {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--m-space-5);
padding: var(--m-space-5) clamp(var(--m-space-4), 4vw, var(--m-space-7));
max-width: 1480px;
width: 100%;
margin: 0 auto;
}
.m-grid--asym {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
gap: var(--m-space-5);
}
@media (max-width: 960px) {
.m-grid--asym { grid-template-columns: 1fr; }
}
.m-grid--three {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--m-space-4);
}
/* ===================================================================
AppBar wordmark + dateline
=================================================================== */
.m-brand {
display: flex;
align-items: baseline;
gap: var(--m-space-3);
}
.m-brand__mark {
font-family: var(--m-font-display);
font-weight: 500;
font-size: 1.375rem;
letter-spacing: -0.02em;
line-height: 1;
}
.m-brand__mark::first-letter {
color: var(--m-c-accent);
}
.m-brand__dateline {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--m-c-ink-soft);
border-left: 1px solid var(--m-c-rule);
padding-left: var(--m-space-3);
}
/* ===================================================================
Drawer — narrow, dark, mono labels
=================================================================== */
.m-nav__group {
padding: var(--m-space-3) var(--m-space-4);
font-family: var(--m-font-mono);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: rgba(231, 229, 228, 0.55);
}
.m-nav__link {
display: flex;
align-items: center;
gap: var(--m-space-3);
padding: var(--m-space-3) var(--m-space-4);
color: rgba(231, 229, 228, 0.85);
text-decoration: none;
font-size: 0.9375rem;
border-left: 2px solid transparent;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
}
.m-nav__link:hover {
background: rgba(217, 119, 6, 0.10);
color: #ffffff;
}
.m-nav__link.active {
color: #ffffff;
background: rgba(217, 119, 6, 0.14);
border-left-color: var(--m-c-accent);
}
.m-nav__link .mud-icon-root { font-size: 1.1rem; }
/* ===================================================================
Locale switcher — segmented control
=================================================================== */
.m-segmented {
display: inline-flex;
border: 1px solid var(--m-c-rule);
border-radius: var(--m-radius-xs);
overflow: hidden;
background: var(--m-c-paper);
}
.m-segmented__btn {
appearance: none;
border: 0;
background: transparent;
padding: 6px 12px;
font-family: var(--m-font-mono);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--m-c-ink-soft);
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.m-segmented__btn + .m-segmented__btn {
border-left: 1px solid var(--m-c-rule);
}
.m-segmented__btn:hover {
color: var(--m-c-ink);
}
.m-segmented__btn.is-active {
background: var(--m-c-ink);
color: var(--m-c-paper);
}
.mud-theme-dark .m-segmented__btn.is-active,
[data-theme="dark"] .m-segmented__btn.is-active {
background: var(--m-c-accent);
color: var(--m-c-paper-2);
}
/* ===================================================================
Settings page — section ledger
=================================================================== */
.m-section {
border: 1px solid var(--m-c-rule);
background: var(--m-c-paper);
margin-bottom: var(--m-space-5);
}
.m-section__head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: var(--m-space-4) var(--m-space-5);
border-bottom: 1px solid var(--m-c-rule);
background: var(--m-c-paper-2);
}
.m-section__head h2 {
margin: 0;
font-family: var(--m-font-display);
font-weight: 400;
font-size: 1.25rem;
letter-spacing: -0.012em;
}
.m-section__body {
padding: var(--m-space-5);
display: grid;
gap: var(--m-space-4);
}
.m-field-row {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
align-items: start;
gap: var(--m-space-4);
}
@media (max-width: 720px) {
.m-field-row { grid-template-columns: 1fr; }
}
.m-field-row__hint {
font-family: var(--m-font-mono);
font-size: 0.75rem;
color: var(--m-c-ink-soft);
line-height: 1.4;
}
/* ===================================================================
Anomaly badge — load-bearing for Phase 7
=================================================================== */
.m-anomaly {
display: inline-flex;
align-items: center;
gap: var(--m-space-2);
padding: 2px 8px;
background: rgba(220, 38, 38, 0.10);
color: var(--m-c-anomaly);
border: 1px solid currentColor;
border-radius: var(--m-radius-xs);
font-family: var(--m-font-mono);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.m-anomaly__pulse {
width: 6px;
height: 6px;
background: currentColor;
border-radius: 50%;
animation: m-pulse 1.6s ease-in-out infinite;
}
@keyframes m-pulse {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 1; transform: scale(1.2); }
}
@media (prefers-reduced-motion: reduce) {
.m-anomaly__pulse { animation: none; opacity: 1; }
}
+44
View File
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Marathon — Odds Lab</title>
<base href="/" />
<!-- Prevent flash of unthemed content -->
<style>
html, body { background: #f5f4ef; margin: 0; }
@media (prefers-color-scheme: dark) {
html, body { background: #0c0a09; }
}
</style>
<!-- Fonts: IBM Plex Sans / Serif / Mono + JetBrains Mono. Full Cyrillic coverage. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=IBM+Plex+Serif:wght@300;400;500;600&family=IBM+Plex+Mono:wght@400;500&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<link href="_content/Marathon.UI/app.css" rel="stylesheet" />
</head>
<body>
<div id="app">
<div style="padding: 64px; font-family: 'IBM Plex Serif', Georgia, serif; color: #475569;">
<span style="font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: #d97706;">Booting</span>
<div style="font-size: 32px; font-weight: 300; margin-top: 8px;">Marathon Odds Lab</div>
</div>
</div>
<div id="blazor-error-ui" style="display:none; position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000; padding: 12px 24px; background: #dc2626; color: #fafaf7; font-family: 'IBM Plex Sans', sans-serif;">
<span>An unhandled error has occurred.</span>
<a href="" class="reload" style="color: #fff; text-decoration: underline; margin-left: 12px;">Reload</a>
<a class="dismiss" style="float: right; cursor: pointer; padding: 0 8px;">×</a>
</div>
<script src="_framework/blazor.webview.js" autostart="false"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
</body>
</html>