diff --git a/src/FantasyFootball.Core/Models/CompetitionSummary.cs b/src/FantasyFootball.Core/Models/CompetitionSummary.cs new file mode 100644 index 00000000..ec98cc87 --- /dev/null +++ b/src/FantasyFootball.Core/Models/CompetitionSummary.cs @@ -0,0 +1,32 @@ +namespace FantasyFootball.Models; + +/// +/// Lightweight projection of a for the competitions +/// list — every field the list renders (icon, title, date, winner, state) +/// without the games. Persisted alongside the full competition so the list +/// never has to deserialize thousands of full seasons; the full competition is +/// loaded only when one is opened or for the overall-standings aggregate. +/// +public sealed record CompetitionSummary( + int Id, + CompetitionType Type, + string Title, + bool IsNationalTeam, + DateTime? SimulationStart, + bool IsFinished, + string? WinnerId, + int PlayedGames, + int TotalGames) +{ + /// Projects the list-relevant fields off a fully-materialized competition. + public static CompetitionSummary Of(Competition c) => new( + c.Id, + c.Type, + c.Title, + c.IsNationalTeamCompetition(), + c.SimulationStart, + c.IsFinished(), + c.WinnerTeamId(), + c.Games.Count(g => g.Result is not null), + c.Games.Length); +} diff --git a/src/FantasyFootball.Core/Repositories/ICompetitionRepository.cs b/src/FantasyFootball.Core/Repositories/ICompetitionRepository.cs index 825a02a0..9985e4cf 100644 --- a/src/FantasyFootball.Core/Repositories/ICompetitionRepository.cs +++ b/src/FantasyFootball.Core/Repositories/ICompetitionRepository.cs @@ -11,11 +11,9 @@ namespace FantasyFootball.Repositories; /// /// InMemoryCompetitionRepository — tests + the working /// set inside BulkSimRunner before user-visible persistence. -/// LocalStorageCompetitionRepository — browser persistence -/// for the Blazor WASM host. -/// IndexedDbCompetitionRepository (follow-up) — -/// higher-capacity browser storage for bulk sims that exceed -/// LocalStorage's 5–10 MB quota. +/// IndexedDbCompetitionRepository — browser persistence +/// for the Blazor WASM host. Each competition is one record in an +/// IndexedDB object store, escaping LocalStorage's ~5 MB cap. /// /// public interface ICompetitionRepository @@ -30,9 +28,21 @@ public interface ICompetitionRepository /// Loads a competition by repository ID, or null if no such ID. Task GetAsync(int id); - /// Lists all persisted competitions. Order is implementation-defined. + /// + /// Lists all persisted competitions, fully materialized. Heavy — deserializes + /// every game of every competition. Use for + /// the list view; reserve this for the overall-standings aggregate, which needs + /// per-game results. Order is implementation-defined. + /// Task> GetAllAsync(); + /// + /// Lists a lightweight for every persisted + /// competition — enough to render the list without deserializing the games. + /// Order is implementation-defined. + /// + Task> GetAllSummariesAsync(); + /// Removes a competition by ID. No-op if the ID is unknown. Task DeleteAsync(int id); diff --git a/src/FantasyFootball.Core/Repositories/InMemoryCompetitionRepository.cs b/src/FantasyFootball.Core/Repositories/InMemoryCompetitionRepository.cs index f8c67a28..e504f775 100644 --- a/src/FantasyFootball.Core/Repositories/InMemoryCompetitionRepository.cs +++ b/src/FantasyFootball.Core/Repositories/InMemoryCompetitionRepository.cs @@ -60,6 +60,18 @@ public Task> GetAllAsync() return Task.FromResult>(list); } + public Task> GetAllSummariesAsync() + { + var list = new List(_storage.Count); + foreach (var (id, json) in _storage) + { + var c = CompetitionDefinitionLoader.Load(json); + c.Id = id; + list.Add(CompetitionSummary.Of(c)); + } + return Task.FromResult>(list); + } + public Task DeleteAsync(int id) { _storage.Remove(id); diff --git a/src/FantasyFootball.Tests/InMemoryCompetitionRepositoryTests.cs b/src/FantasyFootball.Tests/InMemoryCompetitionRepositoryTests.cs index afbaf550..24beae51 100644 --- a/src/FantasyFootball.Tests/InMemoryCompetitionRepositoryTests.cs +++ b/src/FantasyFootball.Tests/InMemoryCompetitionRepositoryTests.cs @@ -131,6 +131,26 @@ public async Task GetAll_ReturnsEveryPersistedCompetition() all.Select(c => c.DefinitionId).Should().BeEquivalentTo(["wm-2022", "wm-2026", "em-2024"]); } + [Fact] + public async Task GetAllSummaries_FaithfullyProjectsEveryPersistedCompetition() + { + var wm = _definitions.Load("wm-2022"); + await _repo.SaveAsync(wm); + await _repo.SaveAsync(_definitions.Load("em-2024")); + + var summaries = await _repo.GetAllSummariesAsync(); + + summaries.Should().HaveCount(2); + var wmSummary = summaries.Single(s => s.Id == wm.Id); + wmSummary.Type.Should().Be(wm.Type); + wmSummary.Title.Should().Be(wm.Title); + wmSummary.IsNationalTeam.Should().BeTrue(); + wmSummary.TotalGames.Should().Be(wm.Games.Length); + wmSummary.PlayedGames.Should().Be(wm.Games.Count(g => g.Result is not null)); + wmSummary.IsFinished.Should().Be(wm.IsFinished()); + wmSummary.WinnerId.Should().Be(wm.WinnerTeamId()); + } + [Fact] public async Task Delete_RemovesById() { diff --git a/src/FantasyFootball.UI/Pages/Competitions.razor b/src/FantasyFootball.UI/Pages/Competitions.razor index 9e59ad5b..1a447c19 100644 --- a/src/FantasyFootball.UI/Pages/Competitions.razor +++ b/src/FantasyFootball.UI/Pages/Competitions.razor @@ -33,12 +33,12 @@ + OnClick="@(() => SetType(t))"> @label } - @if (ViewModel.FilteredCompetitions.Count > 0) + @if (ViewModel.FilteredSummaries.Count > 0) { @if (_confirmingDeleteAll) { @@ -65,7 +65,7 @@ { } -else if (ViewModel.FilteredCompetitions.Count == 0) +else if (ViewModel.FilteredSummaries.Count == 0) { No competitions @(ViewModel.SelectedType is { } t ? $"of type {t.Name().Long} " : "")yet. @@ -77,23 +77,23 @@ else { @* Sortable competitions table. Breakpoint.None: keep one row per competition on phones — the stacked label/value fallback can't be scanned as a list. *@
- + - # - Title - Started - Winner - State + # + Title + Started + Winner + State @{ var id = context.Id; - var isFinished = context.IsFinished(); - var totalGames = context.Games.Length; - var playedGames = context.Games.Count(g => g.Result is not null); + var isFinished = context.IsFinished; + var totalGames = context.TotalGames; + var playedGames = context.PlayedGames; } @context.Id @@ -113,9 +113,9 @@ else } - @if (isFinished && context.WinnerTeamId() is { } winnerId) + @if (isFinished && context.WinnerId is { } winnerId) { - var winner = DataService.AllTeams.FirstOrDefault(t => t.ShortName == winnerId && t.IsNationalTeam == context.IsNationalTeamCompetition()); + var winner = DataService.AllTeams.FirstOrDefault(t => t.ShortName == winnerId && t.IsNationalTeam == context.IsNationalTeam); @DisplayHyphenation.Soften(winner?.Name ?? winnerId) @@ -169,46 +169,64 @@ else
- @* Overall standings — aggregate across every finished competition in the current filter; capture once so the OverallRecords getter only walks comps × games once per render. *@ - @if (ViewModel.OverallRecords is var overallRecords && overallRecords.Count > 0) + @* Overall standings need per-game results (absent from the list summaries), so the full competitions are loaded only when this panel is expanded. *@ + @if (ViewModel.HasFinished) { - + + @if (ViewModel.IsLoadingOverall) + { + + } + else if (ViewModel.OverallRecords.Count > 0) + { + + Total + Per game + @* Breakpoint.None: keep the real table on phones — the stacked label/value fallback can't be scanned as standings. *@
- + Team Titles P - W - D - L - GF - GA - GD - Pts + @(_overallPerGame ? "W%" : "W") + @(_overallPerGame ? "D%" : "D") + @(_overallPerGame ? "L%" : "L") + @(_overallPerGame ? "GF/g" : "GF") + @(_overallPerGame ? "GA/g" : "GA") + @(_overallPerGame ? "GD/g" : "GD") + @(_overallPerGame ? "PPG" : "Pts") - + @DisplayHyphenation.Soften(context.Team.Name) @if (context.Team.CompactName is { } compact) { @DisplayHyphenation.Soften(compact) } @context.CompetitionWins @context.MatchesPlayed - @context.Wins - @context.Draws - @context.Losses - @context.GoalsFor - @context.GoalsAgainst - @context.GoalDifference - @context.Points + @(_overallPerGame ? context.WinRatio.ToString("P0") : context.Wins.ToString()) + @(_overallPerGame ? context.DrawRatio.ToString("P0") : context.Draws.ToString()) + @(_overallPerGame ? context.LossRatio.ToString("P0") : context.Losses.ToString()) + @(_overallPerGame ? context.GoalsForPerGame.ToString("0.00") : context.GoalsFor.ToString()) + @(_overallPerGame ? context.GoalsAgainstPerGame.ToString("0.00") : context.GoalsAgainst.ToString()) + @(_overallPerGame ? context.GoalDifferencePerGame.ToString("0.00") : context.GoalDifference.ToString()) + @(_overallPerGame ? context.PointsPerGame.ToString("0.00") : context.Points.ToString())
+ }
} @@ -216,6 +234,8 @@ else @code { bool _confirmingDeleteAll; + bool _overallExpanded; + bool _overallPerGame; protected override async Task OnInitializedAsync() { @@ -224,6 +244,19 @@ else await ViewModel.InitializeAsync(); } + async Task SetType(CompetitionType? t) + { + ViewModel.SelectedType = t; + // Filter change re-scopes the standings; reload them if the panel is open. + if (_overallExpanded) { await ViewModel.EnsureOverallRecordsAsync(); } + } + + async Task OnOverallExpandedChanged(bool expanded) + { + _overallExpanded = expanded; + if (expanded) { await ViewModel.EnsureOverallRecordsAsync(); } + } + void OnVmChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => InvokeAsync(StateHasChanged); @@ -244,10 +277,10 @@ else } // Sort order for the State column: 0 = finished, 1 = in-progress, 2 = scheduled. - static int StateOrder(Competition c) + static int StateOrder(CompetitionSummary s) { - if (c.IsFinished()) { return 0; } - if (c.Games.Any(g => g.Result is not null)) { return 1; } + if (s.IsFinished) { return 0; } + if (s.PlayedGames > 0) { return 1; } return 2; } diff --git a/src/FantasyFootball.UI/ViewModels/CompetitionsViewModel.cs b/src/FantasyFootball.UI/ViewModels/CompetitionsViewModel.cs index 5609ac41..3352888f 100644 --- a/src/FantasyFootball.UI/ViewModels/CompetitionsViewModel.cs +++ b/src/FantasyFootball.UI/ViewModels/CompetitionsViewModel.cs @@ -45,6 +45,10 @@ partial void OnSelectedTypeChanged(CompetitionType? value) { // Persist non-null choices so other consumers inherit the pick; null = "All" filter, leave the prior pref alone. if (value is { } t) { _dataService.SelectedCompetitionType = t; } + + // Standings are filter-scoped — the loaded set no longer matches. + _overallStale = true; + OverallRecords = []; } /// @@ -68,75 +72,88 @@ public void SyncFromDataService() ]; [ObservableProperty] - [NotifyPropertyChangedFor(nameof(FilteredCompetitions))] + [NotifyPropertyChangedFor(nameof(FilteredSummaries))] + [NotifyPropertyChangedFor(nameof(HasFinished))] public partial CompetitionType? SelectedType { get; set; } [ObservableProperty] - [NotifyPropertyChangedFor(nameof(FilteredCompetitions))] - public partial IReadOnlyList AllCompetitions { get; set; } = []; + [NotifyPropertyChangedFor(nameof(FilteredSummaries))] + [NotifyPropertyChangedFor(nameof(HasFinished))] + public partial IReadOnlyList AllSummaries { get; set; } = []; [ObservableProperty] public partial bool IsBusy { get; set; } - public IReadOnlyList FilteredCompetitions => SelectedType is { } t - ? AllCompetitions.Where(c => c.Type == t).ToList() - : AllCompetitions; + /// Overall-standings rows, populated lazily by when the panel expands. + [ObservableProperty] + public partial IReadOnlyList OverallRecords { get; set; } = []; + + [ObservableProperty] + public partial bool IsLoadingOverall { get; set; } + + public IReadOnlyList FilteredSummaries => SelectedType is { } t + ? AllSummaries.Where(s => s.Type == t).ToList() + : AllSummaries; + + /// Any visible competition finished — cheap gate (off summaries) for showing the overall-standings panel. + public bool HasFinished => FilteredSummaries.Any(s => s.IsFinished); + + // Standings need per-game results (absent from the summary), so they load on demand; stale when the filter changes or data reloads. + bool _overallStale = true; /// - /// Team-level aggregate across every finished competition currently - /// visible (after the type filter). Played games are summed into a - /// single row per team. Sorted by points desc → GD desc → GF desc. + /// Team-level aggregate across every finished competition in . + /// Played games are summed into a single row per team. Sorted by points desc → + /// GD desc → GF desc. /// - public IReadOnlyList OverallRecords + IReadOnlyList ComputeOverallRecords(IEnumerable comps) { - get - { - // Codes collide across kinds (STP = São Tomé and Príncipe national + FC St. Pauli club). Aggregate per (isNational, code). - var clubsByCode = _dataService.AllTeams.Where(t => !t.IsNationalTeam).ToDictionary(t => t.ShortName, t => t); - var nationsByCode = _dataService.AllTeams.Where(t => t.IsNationalTeam).ToDictionary(t => t.ShortName, t => t); - var perTeam = new Dictionary<(bool IsNational, string Code), TeamRecord.Mutable>(); + // Codes collide across kinds (STP = São Tomé and Príncipe national + FC St. Pauli club). Aggregate per (isNational, code). + var clubsByCode = _dataService.AllTeams.Where(t => !t.IsNationalTeam).ToDictionary(t => t.ShortName, t => t); + var nationsByCode = _dataService.AllTeams.Where(t => t.IsNationalTeam).ToDictionary(t => t.ShortName, t => t); + var perTeam = new Dictionary<(bool IsNational, string Code), TeamRecord.Mutable>(); - foreach (var comp in FilteredCompetitions.Where(c => c.IsFinished())) + foreach (var comp in comps.Where(c => c.IsFinished())) + { + var isNational = IsNationalComp(comp.Type); + var champion = comp.WinnerTeamId(); + foreach (var game in comp.Games) { - var isNational = IsNationalComp(comp.Type); - var champion = comp.WinnerTeamId(); - foreach (var game in comp.Games) - { - if (game.Result is not { } r) { continue; } - - if (!game.IsFullyInitialized) { continue; } - var home = game.HomeTeamId; - var away = game.AwayTeamId; - - Accumulate(perTeam, (isNational, home), r, isHomeTeam: true); - Accumulate(perTeam, (isNational, away), r, isHomeTeam: false); - } - - if (champion is not null) - { - var key = (isNational, champion); - var entry = perTeam.GetValueOrDefault(key); - perTeam[key] = entry with { CompetitionWins = entry.CompetitionWins + 1 }; - } + if (game.Result is not { } r) { continue; } + + if (!game.IsFullyInitialized) { continue; } + var home = game.HomeTeamId; + var away = game.AwayTeamId; + + Accumulate(perTeam, (isNational, home), r, isHomeTeam: true); + Accumulate(perTeam, (isNational, away), r, isHomeTeam: false); } - return perTeam - .Select(kv => - { - var lookup = kv.Key.IsNational ? nationsByCode : clubsByCode; - var team = lookup.TryGetValue(kv.Key.Code, out var t) ? t : new Team { Name = kv.Key.Code, ShortName = kv.Key.Code }; - - return new TeamRecord(team, - kv.Value.Wins, kv.Value.Draws, kv.Value.Losses, - kv.Value.GoalsFor, kv.Value.GoalsAgainst, - kv.Value.CompetitionWins); - }) - .OrderByDescending(r => r.Points) - .ThenByDescending(r => r.GoalDifference) - .ThenByDescending(r => r.GoalsFor) - .ThenBy(r => r.Team.ShortName, StringComparer.Ordinal) - .ToList(); + if (champion is not null) + { + var key = (isNational, champion); + var entry = perTeam.GetValueOrDefault(key); + perTeam[key] = entry with { CompetitionWins = entry.CompetitionWins + 1 }; + } } + + return perTeam + .Select(kv => + { + var lookup = kv.Key.IsNational ? nationsByCode : clubsByCode; + // Type drives FlagIcon's club-crest-vs-flag routing; a code missing from the registry (e.g. CIS) must keep its national type or it 404s on a club crest. + var team = lookup.TryGetValue(kv.Key.Code, out var t) ? t : new Team { Name = kv.Key.Code, ShortName = kv.Key.Code, Type = kv.Key.IsNational ? TeamType.NATIONAL_MEN : TeamType.CLUB_MEN }; + + return new TeamRecord(team, + kv.Value.Wins, kv.Value.Draws, kv.Value.Losses, + kv.Value.GoalsFor, kv.Value.GoalsAgainst, + kv.Value.CompetitionWins); + }) + .OrderByDescending(r => r.Points) + .ThenByDescending(r => r.GoalDifference) + .ThenByDescending(r => r.GoalsFor) + .ThenBy(r => r.Team.ShortName, StringComparer.Ordinal) + .ToList(); } static bool IsNationalComp(CompetitionType t) => t is CompetitionType.WM or CompetitionType.EM; @@ -169,8 +186,11 @@ public async Task ReloadAsync() IsBusy = true; try { - var all = await _repo.GetAllAsync(); - AllCompetitions = all.OrderByDescending(c => c.Id).ToList(); + AllSummaries = (await _repo.GetAllSummariesAsync()) + .OrderByDescending(s => s.Id) + .ToList(); + _overallStale = true; + OverallRecords = []; } finally { @@ -178,6 +198,31 @@ public async Task ReloadAsync() } } + /// + /// Loads full competitions and builds the overall-standings aggregate for the + /// current filter. Driven by the standings panel expanding; a no-op until the + /// filter changes or the list reloads, so re-expanding is instant. + /// + public async Task EnsureOverallRecordsAsync() + { + if (!_overallStale || IsLoadingOverall) { return; } + IsLoadingOverall = true; + try + { + // Covers the panel's ~300ms expand animation so the (compositor-animated) spinner paints before the synchronous aggregation blocks the single WASM thread. + const int SpinnerPaintDelayMs = 350; + await Task.Delay(SpinnerPaintDelayMs); + var all = await _repo.GetAllAsync(); + var scoped = SelectedType is { } t ? all.Where(c => c.Type == t) : all; + OverallRecords = ComputeOverallRecords(scoped); + _overallStale = false; + } + finally + { + IsLoadingOverall = false; + } + } + public async Task DeleteAsync(int id) { await _repo.DeleteAsync(id); @@ -259,5 +304,14 @@ public sealed record TeamRecord( public int GoalDifference => GoalsFor - GoalsAgainst; public int MatchesPlayed => Wins + Draws + Losses; + // Per-game rates for the "Per game" view. MatchesPlayed is ≥ 1 for any aggregated row, but guard anyway so an empty row can't surface NaN/∞ in the table. + public double WinRatio => MatchesPlayed == 0 ? 0 : (double)Wins / MatchesPlayed; + public double DrawRatio => MatchesPlayed == 0 ? 0 : (double)Draws / MatchesPlayed; + public double LossRatio => MatchesPlayed == 0 ? 0 : (double)Losses / MatchesPlayed; + public double GoalsForPerGame => MatchesPlayed == 0 ? 0 : (double)GoalsFor / MatchesPlayed; + public double GoalsAgainstPerGame => MatchesPlayed == 0 ? 0 : (double)GoalsAgainst / MatchesPlayed; + public double GoalDifferencePerGame => MatchesPlayed == 0 ? 0 : (double)GoalDifference / MatchesPlayed; + public double PointsPerGame => MatchesPlayed == 0 ? 0 : (double)Points / MatchesPlayed; + internal record struct Mutable(int Wins, int Draws, int Losses, int GoalsFor, int GoalsAgainst, int CompetitionWins); } diff --git a/src/FantasyFootball.Web/Program.cs b/src/FantasyFootball.Web/Program.cs index 191e20d5..fdcfeb87 100644 --- a/src/FantasyFootball.Web/Program.cs +++ b/src/FantasyFootball.Web/Program.cs @@ -36,7 +36,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -50,6 +50,13 @@ var app = builder.Build(); +// One-time purge of pre-IndexedDB competitions orphaned in LocalStorage; they'd otherwise keep occupying the ~5 MB Web Storage cap the entity registry still uses. +var legacyStore = app.Services.GetRequiredService(); +foreach (var staleKey in legacyStore.Keys().Where(k => k.StartsWith("ff-flat:")).ToList()) +{ + legacyStore.RemoveItem(staleKey); +} + // Pre-warm IDataService so the embedded-JSON parse / LocalStorage polymorphic // deserialize (~200 teams + countries + confederations) runs during the WASM // boot splash rather than stalling the first navigation to Teams / Competitions. diff --git a/src/FantasyFootball.Web/Services/IndexedDbCompetitionRepository.cs b/src/FantasyFootball.Web/Services/IndexedDbCompetitionRepository.cs new file mode 100644 index 00000000..0de977bb --- /dev/null +++ b/src/FantasyFootball.Web/Services/IndexedDbCompetitionRepository.cs @@ -0,0 +1,125 @@ +using System.Text.Json; +using FantasyFootball.Models; +using FantasyFootball.Repositories; +using FantasyFootball.Services; +using Microsoft.JSInterop; + +namespace FantasyFootball.Web.Services; + +/// +/// IndexedDB-backed flat-competition store. Replaces the LocalStorage store to +/// escape the ~5 MB Web Storage per-origin cap — IndexedDB draws from the +/// browser's disk-based quota, so bulk sims of thousands of competitions fit. +/// Marshals through the window.ffIdb JS helper. +/// +/// Two object stores keyed by repo id: the full competition JSON and a tiny +/// projection, written together on every save. +/// The list reads only summaries () so it +/// never ships or parses every full season; the full competition is loaded only +/// when one is opened () or for the overall standings +/// (). JSON shape and round-trip match +/// . +/// +public sealed class IndexedDbCompetitionRepository : ICompetitionRepository +{ + readonly IJSRuntime _js; + + // Monotonic id counter; seeded once from existing keys, then bumped synchronously before each async write so a concurrent save (bulk sim + user click) can't collide on an id. + int _nextId; + + public IndexedDbCompetitionRepository(IJSRuntime js) + { + _js = js; + } + + public async Task SaveAsync(Competition competition) + { + if (_nextId == 0) + { + var keys = await _js.InvokeAsync("ffIdb.keys"); + if (keys.Length > 0) { _nextId = keys.Max(); } + } + if (competition.Id == 0) + { + competition.Id = ++_nextId; + } + + await _js.InvokeVoidAsync("ffIdb.save", + competition.Id, + FlatJson.Serialize(competition, compact: true), + SerializeSummary(CompetitionSummary.Of(competition))); + + return competition.Id; + } + + public async Task GetAsync(int id) + { + var json = await _js.InvokeAsync("ffIdb.get", id); + if (string.IsNullOrEmpty(json)) { return null; } + var c = CompetitionDefinitionLoader.Load(json); + c.Id = id; + + return c; + } + + public async Task> GetAllAsync() + { + // One transaction so keys and values are a consistent snapshot — a delete mid-read can't misalign id[i] with payload[i]. + var entries = await _js.InvokeAsync("ffIdb.entries"); + var ids = entries.GetProperty("keys").Deserialize()!; + var payloads = entries.GetProperty("values").Deserialize()!; + var result = new List(ids.Length); + for (int i = 0; i < ids.Length; i++) + { + if (string.IsNullOrEmpty(payloads[i])) { continue; } + var c = CompetitionDefinitionLoader.Load(payloads[i]); + c.Id = ids[i]; + result.Add(c); + } + + return result; + } + + public async Task> GetAllSummariesAsync() + { + var payloads = await _js.InvokeAsync("ffIdb.summaries"); + var summaries = payloads.Select(DeserializeSummary).ToList(); + + await BackfillMissingSummaries(summaries); + + return summaries; + } + + public async Task DeleteAsync(int id) => + await _js.InvokeVoidAsync("ffIdb.remove", id); + + public async Task CountAsync() => + (await _js.InvokeAsync("ffIdb.summaryKeys")).Length; + + public async Task ResetAsync() + { + await _js.InvokeVoidAsync("ffIdb.clear"); + _nextId = 0; + } + + // Competitions persisted before the summary store existed have a full payload but no summary. Derive the missing ones once from the full store; every later save keeps both in sync, so this no-ops thereafter. + async Task BackfillMissingSummaries(List summaries) + { + var fullKeys = await _js.InvokeAsync("ffIdb.keys"); + if (summaries.Count == fullKeys.Length) { return; } + + var known = summaries.Select(s => s.Id).ToHashSet(); + foreach (var id in fullKeys.Where(k => !known.Contains(k))) + { + var c = await GetAsync(id); + if (c is null) { continue; } + var summary = CompetitionSummary.Of(c); + await _js.InvokeVoidAsync("ffIdb.setSummary", id, SerializeSummary(summary)); + summaries.Add(summary); + } + } + + static string SerializeSummary(CompetitionSummary s) => JsonSerializer.Serialize(s, FlatJson.CompactOptions); + + static CompetitionSummary DeserializeSummary(string json) => JsonSerializer.Deserialize(json, FlatJson.CompactOptions)!; +} diff --git a/src/FantasyFootball.Web/Services/LocalStorageCompetitionRepository.cs b/src/FantasyFootball.Web/Services/LocalStorageCompetitionRepository.cs deleted file mode 100644 index 504d27ee..00000000 --- a/src/FantasyFootball.Web/Services/LocalStorageCompetitionRepository.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System.Text.Json; -using Blazored.LocalStorage; -using FantasyFootball.Models; -using FantasyFootball.Repositories; -using FantasyFootball.Services; - -namespace FantasyFootball.Web.Services; - -/// -/// LocalStorage-backed flat-competition store. Each competition lives in -/// its own key (ff-flat:c:{id}) so reads / writes / deletes touch -/// O(1) bytes; a small index key (ff-flat:index) lists the live -/// IDs so and don't -/// need to scan the entire LocalStorage namespace. -/// -/// JSON shape mirrors : -/// compact serialization via , parsed -/// back via . The -/// JSON round-trip is covered by the InMemory tests. -/// -public sealed class LocalStorageCompetitionRepository : ICompetitionRepository -{ - const string IndexKey = "ff-flat:index"; - const string CompetitionKeyPrefix = "ff-flat:c:"; - - readonly ILocalStorageService _storage; - - // Monotonic id counter incremented synchronously before any await so two concurrent SaveAsync calls (bulk sim + user click) can't allocate the same id. - int _nextId; - - public LocalStorageCompetitionRepository(ILocalStorageService storage) - { - _storage = storage; - } - - public async Task SaveAsync(Competition competition) - { - if (_nextId == 0) - { - var initIndex = await ReadIndex(); - if (initIndex.Count > 0) { _nextId = initIndex.Max(); } - } - if (competition.Id == 0) - { - competition.Id = ++_nextId; - } - - // Index-first, payload-second: re-read before WriteIndex to avoid clobbering a concurrent save; orphan index is recoverable, missing payload isn't. - var index = await ReadIndex(); - if (!index.Contains(competition.Id)) - { - index.Add(competition.Id); - await WriteIndex(index); - } - - await _storage.SetItemAsStringAsync(KeyFor(competition.Id), FlatJson.Serialize(competition, compact: true)); - - return competition.Id; - } - - public async Task GetAsync(int id) - { - var json = await _storage.GetItemAsStringAsync(KeyFor(id)); - if (string.IsNullOrEmpty(json)) { return null; } - var c = CompetitionDefinitionLoader.Load(json); - c.Id = id; - return c; - } - - public async Task> GetAllAsync() - { - var index = await ReadIndex(); - var result = new List(index.Count); - foreach (var id in index) - { - var c = await GetAsync(id); - if (c is not null) { result.Add(c); } - } - - return result; - } - - public async Task DeleteAsync(int id) - { - await _storage.RemoveItemAsync(KeyFor(id)); - var index = await ReadIndex(); - if (index.Remove(id)) - { - await WriteIndex(index); - } - } - - public async Task CountAsync() => (await ReadIndex()).Count; - - public async Task ResetAsync() - { - var index = await ReadIndex(); - foreach (var id in index) - { - await _storage.RemoveItemAsync(KeyFor(id)); - } - - await _storage.RemoveItemAsync(IndexKey); - _nextId = 0; - } - - async Task> ReadIndex() - { - var raw = await _storage.GetItemAsStringAsync(IndexKey); - if (string.IsNullOrEmpty(raw)) { return []; } - return JsonSerializer.Deserialize>(raw) ?? []; - } - - async Task WriteIndex(List ids) => - await _storage.SetItemAsStringAsync(IndexKey, JsonSerializer.Serialize(ids)); - - static string KeyFor(int id) => $"{CompetitionKeyPrefix}{id}"; -} diff --git a/src/FantasyFootball.Web/Services/LocalStorageRepository.cs b/src/FantasyFootball.Web/Services/LocalStorageRepository.cs index d4342eac..f213912b 100644 --- a/src/FantasyFootball.Web/Services/LocalStorageRepository.cs +++ b/src/FantasyFootball.Web/Services/LocalStorageRepository.cs @@ -11,7 +11,7 @@ namespace FantasyFootball.Web.Services; /// under its own browser LocalStorage key. Aggregate roots: Team, Confederation, /// EloSet — TeamDetailViewModel writes Elo edits into the active EloSet via this /// repo. The flat competition model has its own repository -/// (). +/// (). /// public sealed class LocalStorageRepository : IRepository { diff --git a/src/FantasyFootball.Web/wwwroot/index.html b/src/FantasyFootball.Web/wwwroot/index.html index 52a59900..bc296166 100644 --- a/src/FantasyFootball.Web/wwwroot/index.html +++ b/src/FantasyFootball.Web/wwwroot/index.html @@ -31,6 +31,7 @@ + diff --git a/src/FantasyFootball.Web/wwwroot/js/idb.js b/src/FantasyFootball.Web/wwwroot/js/idb.js new file mode 100644 index 00000000..0ea160ab --- /dev/null +++ b/src/FantasyFootball.Web/wwwroot/js/idb.js @@ -0,0 +1,67 @@ +// IndexedDB-backed competition store: two object stores ('competitions' full JSON, 'summaries' list projection) keyed by repo id, escaping localStorage's ~5 MB cap. +window.ffIdb = (() => { + const DB_NAME = 'fantasy-football'; + const FULL = 'competitions'; + const SUMMARY = 'summaries'; + let dbPromise; + + function open() { + return dbPromise ??= new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 2); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(FULL)) { db.createObjectStore(FULL); } + if (!db.objectStoreNames.contains(SUMMARY)) { db.createObjectStore(SUMMARY); } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + } + + function read(store, op) { + return open().then(db => new Promise((resolve, reject) => { + const req = op(db.transaction(store, 'readonly').objectStore(store)); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + })); + } + + function write(stores, body) { + return open().then(db => new Promise((resolve, reject) => { + const tx = db.transaction(stores, 'readwrite'); + body(tx); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + })); + } + + return { + get: (key) => read(FULL, s => s.get(key)), + keys: () => read(FULL, s => s.getAllKeys()), + // Keys and values from one transaction — a consistent snapshot, so a delete mid-read can't misalign them. + entries: () => open().then(db => new Promise((resolve, reject) => { + const store = db.transaction(FULL, 'readonly').objectStore(FULL); + const keysReq = store.getAllKeys(); + const valuesReq = store.getAll(); + store.transaction.oncomplete = () => resolve({ keys: keysReq.result, values: valuesReq.result }); + store.transaction.onerror = () => reject(store.transaction.error); + })), + summaries: () => read(SUMMARY, s => s.getAll()), + summaryKeys: () => read(SUMMARY, s => s.getAllKeys()), + // Full payload and its summary commit together so the list store never drifts from the full store. + save: (key, full, summary) => write([FULL, SUMMARY], tx => { + tx.objectStore(FULL).put(full, key); + tx.objectStore(SUMMARY).put(summary, key); + }), + setSummary: (key, summary) => write(SUMMARY, tx => { tx.objectStore(SUMMARY).put(summary, key); }), + remove: (key) => write([FULL, SUMMARY], tx => { + tx.objectStore(FULL).delete(key); + tx.objectStore(SUMMARY).delete(key); + }), + clear: () => write([FULL, SUMMARY], tx => { + tx.objectStore(FULL).clear(); + tx.objectStore(SUMMARY).clear(); + }), + }; +})();