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();
+ }),
+ };
+})();