Move competitions to IndexedDB; fast summary-backed list#75
Conversation
…quota The Blazor WASM host stored each simulated competition under its own localStorage key. localStorage is hard-capped at ~5 MB per origin — independent of the disk-based Storage-API quota that IndexedDB draws from — so bulk-simulating enough competitions blew the cap with an unhandled QuotaExceededError that crashed the renderer. Move the competition store to IndexedDB via a small window.ffIdb JS helper so it draws from the browser's disk-based quota instead; bulk sims of thousands of competitions now fit. Each competition is written to two object stores keyed by repo id: the full JSON plus a lightweight CompetitionSummary projection, so the list can render without deserializing every full season. The object store doubles as the index, retiring the separate index key the localStorage store needed. A one-time startup purge clears the orphaned ff-flat:* localStorage keys the old store left behind, freeing that ~5 MB cap for the entity registry (teams / Elo sets) that still lives in localStorage.
The list page deserialized every full competition on load to show a handful of per-row fields (winner, state, played/total). With the storage ceiling lifted, that meant parsing tens of thousands of game objects up front — seconds of frozen UI. Bind the list to the lightweight CompetitionSummary projection instead, so a load reads only the summaries. The overall-standings aggregate genuinely needs per-game results, so it now loads the full competitions lazily when its panel is expanded, behind a spinner. Add a Total / Per game toggle to the standings: Per game shows W%, D%, L%, GF/g, GA/g, GD/g and PPG, with sorting following the displayed values.
A team code absent from the registry — CIS, the one-tournament Euro 1992 side — fell back to a placeholder Team that defaulted to a club type, so FlagIcon requested a club crest (clubs/cis.png, a 404) instead of the bundled flags/cis.svg. Give the placeholder its real national type and pass the code through to the flag lookup so the bundled historic flag resolves.
| async Task BackfillMissingSummaries(List<CompetitionSummary> summaries) | ||
| { | ||
| var fullKeys = await _js.InvokeAsync<int[]>("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); | ||
| } | ||
| } |
There was a problem hiding this comment.
N sequential JS interop round-trips — one ffIdb.get(id) call per missing competition. For a developer who has many pre-summary IndexedDB entries this could stall the first list load noticeably.
ffIdb.values() is already available and fetches everything in one call. The batch approach:
| async Task BackfillMissingSummaries(List<CompetitionSummary> summaries) | |
| { | |
| var fullKeys = await _js.InvokeAsync<int[]>("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); | |
| } | |
| } | |
| async Task BackfillMissingSummaries(List<CompetitionSummary> summaries) | |
| { | |
| var fullKeys = await _js.InvokeAsync<int[]>("ffIdb.keys"); | |
| if (summaries.Count == fullKeys.Length) { return; } | |
| var known = summaries.Select(s => s.Id).ToHashSet(); | |
| var missingIds = fullKeys.Where(k => !known.Contains(k)).ToHashSet(); | |
| if (missingIds.Count == 0) { return; } | |
| var allPayloads = await _js.InvokeAsync<string[]>("ffIdb.values"); | |
| for (int i = 0; i < fullKeys.Length; i++) | |
| { | |
| if (!missingIds.Contains(fullKeys[i]) || string.IsNullOrEmpty(allPayloads[i])) { continue; } | |
| var c = CompetitionDefinitionLoader.Load(allPayloads[i]); | |
| c.Id = fullKeys[i]; | |
| var summary = CompetitionSummary.Of(c); | |
| await _js.InvokeVoidAsync("ffIdb.setSummary", fullKeys[i], SerializeSummary(summary)); | |
| summaries.Add(summary); | |
| } | |
| } |
(Same keys/values ordering assumption as GetAllAsync.) Low priority — this is a dev-only migration path that is a no-op in production since no user has pre-summary IndexedDB entries.
There was a problem hiding this comment.
❌ Skipped — as you note, this is a one-time, dev-only migration path that no-ops in production (no user has pre-summary IndexedDB entries). Optimizing a path that runs at most once locally isn't worth the extra branch.
| IsLoadingOverall = true; | ||
| try | ||
| { | ||
| // Delay covers the panel's ~300ms expand animation so the (compositor-animated) spinner is painted before the synchronous aggregation blocks the single WASM thread. |
There was a problem hiding this comment.
Magic constant. The comment explains the why well — extract to a const so the number doesn't appear naked next to the reasoning:
| // Delay covers the panel's ~300ms expand animation so the (compositor-animated) spinner is painted before the synchronous aggregation blocks the single WASM thread. | |
| // Yield for ~one render frame so the (compositor-animated) spinner paints before the synchronous aggregation | |
| // blocks the single WASM thread. Must exceed the panel's ~300ms expand animation. | |
| const int SpinnerPaintDelayMs = 350; | |
| await Task.Delay(SpinnerPaintDelayMs); |
There was a problem hiding this comment.
✅ Done in 887a733: extracted to a named const SpinnerPaintDelayMs = 350.
Review: Move competitions to IndexedDB; fast summary-backed listOverall: Approve with minor notes. The architectural decisions here are sound — IndexedDB is the right answer to the localStorage cap, the summary projection is well-designed, and the lazy standings load solves the genuine WASM single-thread problem cleanly. Code quality is high throughout. What's good
Inline comments (3)
One additional note
|
- GetAllAsync now reads keys and values from a single IndexedDB transaction (new ffIdb.entries) instead of two; a delete between the former two awaits could misalign id[i] with payload[i]. - Extract the standings spinner-paint delay to a named const. Skipped the backfill-batching suggestion: it's a one-time, dev-only migration path that no-ops in production.
| // IndexedDB-backed store for simulated competitions. Two object stores keyed by | ||
| // repo id: 'competitions' holds the full JSON, 'summaries' holds a tiny list | ||
| // projection so the competitions list never ships or parses every full season. | ||
| // Competitions outgrew localStorage's ~5 MB per-origin cap; IndexedDB draws from | ||
| // the browser's disk-based quota instead. |
There was a problem hiding this comment.
Multi-line // block — convention caps inline // comments at one line. Collapse to a single line or drop it (the why is in the PR description / commit message).
| // IndexedDB-backed store for simulated competitions. Two object stores keyed by | |
| // repo id: 'competitions' holds the full JSON, 'summaries' holds a tiny list | |
| // projection so the competitions list never ships or parses every full season. | |
| // Competitions outgrew localStorage's ~5 MB per-origin cap; IndexedDB draws from | |
| // the browser's disk-based quota instead. | |
| // IndexedDB-backed store; two object stores ('competitions' / 'summaries') keyed by repo id, escaping localStorage's ~5 MB cap. |
There was a problem hiding this comment.
✅ Done in fbc6fdb: collapsed the idb.js header to a single line.
- GetAllAsync reads keys+values from one IndexedDB transaction (ffIdb.entries) so a delete mid-read can't misalign id/payload. - Extract the standings spinner-paint delay to a named const. - Collapse the idb.js file header to one line.
887a733 to
fbc6fdb
Compare
| .OrderByDescending(s => s.Id) | ||
| .ToList(); | ||
| _overallStale = true; | ||
| OverallRecords = []; |
There was a problem hiding this comment.
Blank-panel regression: OverallRecords = [] empties the standings table, but EnsureOverallRecordsAsync is never re-triggered from here. Every caller of ReloadAsync (DeleteAsync, DeleteAllAsync, FastForwardAsync, ReplayAsync) can fire while the panel is already expanded, leaving the user staring at a blank panel until they manually collapse and re-expand.
Before, standings were a derived property that recomputed automatically whenever AllCompetitions changed. The lazy approach breaks that implicit refresh.
The least-scattered fix is to re-trigger inside ReloadAsync itself when records were previously populated:
| OverallRecords = []; | |
| var wasLoaded = OverallRecords.Count > 0; | |
| _overallStale = true; | |
| OverallRecords = []; | |
| if (wasLoaded) { await EnsureOverallRecordsAsync(); } |
Why
Bulk-simulating enough competitions on the web app crashed the renderer with an unhandled
QuotaExceededError. Each competition was stored under its ownlocalStoragekey, andlocalStorageis hard-capped at ~5 MB per origin — independent of the much larger disk-based Storage-API quota that IndexedDB draws from. (DevTools showing ~1.2 TB "available" is the Storage API quota for Cache / IndexedDB;localStoragecan't reach it.)What
window.ffIdbJS helper, so it draws from the browser's disk-based quota — thousands of sims now fit. A one-time startup purge clears the orphanedff-flat:*localStorage keys the old store left behind.CompetitionSummaryprojection (written alongside the full JSON in a second object store) instead of deserializing every full season — fixes multi-second list-load freezes. Competitions saved before summaries existed backfill once on first load.flags/cis.svg.Verification
mainonly; verified locally per the develop-PR convention.Notes
LocalStorageCompetitionRepositorywas removed (fully replaced by the IndexedDB store); recoverable from git history if ever needed.