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