Skip to content

Move competitions to IndexedDB; fast summary-backed list#75

Merged
StepKie merged 4 commits into
developfrom
feature/indexeddb-competitions
Jun 8, 2026
Merged

Move competitions to IndexedDB; fast summary-backed list#75
StepKie merged 4 commits into
developfrom
feature/indexeddb-competitions

Conversation

@StepKie

@StepKie StepKie commented Jun 8, 2026

Copy link
Copy Markdown
Owner

Why

Bulk-simulating enough competitions on the web app crashed the renderer with an unhandled QuotaExceededError. Each competition was stored under its own localStorage key, and localStorage is 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; localStorage can't reach it.)

What

  • Move the competition store to IndexedDB via a small window.ffIdb JS helper, so it draws from the browser's disk-based quota — thousands of sims now fit. A one-time startup purge clears the orphaned ff-flat:* localStorage keys the old store left behind.
  • Render the list from a lightweight CompetitionSummary projection (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.
  • Load the overall-standings aggregate lazily when its panel expands (it genuinely needs per-game results), behind a spinner.
  • Add a Total / Per-game toggle to the standings: W%, D%, L%, GF/g, GA/g, GD/g, PPG, with sorting following the displayed values.
  • Fix the CIS (Euro 1992) flag in the standings — a code absent from the registry fell to a club-typed placeholder and 404'd on a club crest instead of resolving the bundled flags/cis.svg.

Verification

  • 216 unit tests pass; clean build (0 errors).
  • Manually verified on the dev server: bulk-simmed 100 Bundesliga seasons (no crash, stored in IndexedDB), list loads fast, lazy standings + spinner + per-game toggle all work.
  • CI build/test runs on main only; verified locally per the develop-PR convention.

Notes

  • LocalStorageCompetitionRepository was removed (fully replaced by the IndexedDB store); recoverable from git history if ever needed.
  • Per-competition footprint is unchanged; this lifts the storage ceiling rather than shrinking payloads.

StepKie added 3 commits June 8, 2026 22:34
…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.
Comment thread src/FantasyFootball.Web/Services/IndexedDbCompetitionRepository.cs Outdated
Comment on lines +105 to +119
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);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic constant. The comment explains the why well — extract to a const so the number doesn't appear naked next to the reasoning:

Suggested change
// 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);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Done in 887a733: extracted to a named const SpinnerPaintDelayMs = 350.

@claude

claude Bot commented Jun 8, 2026

Copy link
Copy Markdown

Review: Move competitions to IndexedDB; fast summary-backed list

Overall: 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

  • CompetitionSummary record is a clean projection — primary constructor, static Of() factory, no behaviour leaking in. The interface doc update correctly signals when to use each query method.
  • IndexedDbCompetitionRepository mirrors the old LocalStorageCompetitionRepository structure faithfully (same id-seeding pattern, same FlatJson round-trip), making the replacement easy to audit.
  • idb.js helper is minimal and well-structured. Atomic two-store writes/removes via a single readwrite transaction prevent the FULL and SUMMARY stores from drifting on save or delete. onupgradeneeded store creation is idempotent.
  • EnsureOverallRecordsAsync — the _overallStale flag + IsLoadingOverall guard correctly prevents re-entrant loads. Filter-change handling in SetType + OnSelectedTypeChanged is consistent.
  • CIS flag fix — setting Type = TeamType.NATIONAL_MEN on the fallback Team object for unknown codes is the right fix; FlagIcon routing depends on it.
  • Legacy cleanup in Program.cs correctly uses ToList() before iterating-and-removing localStorage keys.
  • Test coverageGetAllSummaries_FaithfullyProjectsEveryPersistedCompetition covers all projected fields.

Inline comments (3)

File Line Severity
IndexedDbCompetitionRepository.cs 68–69 Minor — two-transaction gap in GetAllAsync; delete-while-loading race
IndexedDbCompetitionRepository.cs 105–119 Minor — BackfillMissingSummaries N sequential interop calls; batch suggestion provided (dev-only path, production no-op)
CompetitionsViewModel.cs 212 Nit — magic constant 350

One additional note

CountAsync counts summary keys (ffIdb.summaryKeys), not full-store keys. Before BackfillMissingSummaries runs on first load, the count could be lower than the actual number of stored competitions. In practice fine — CountAsync is only meaningful after the list has loaded (which triggers backfill), and in production no pre-summary entries exist. Worth knowing if CountAsync is ever called at startup before GetAllSummariesAsync.

StepKie added a commit that referenced this pull request Jun 8, 2026
- 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.
Comment on lines +1 to +5
// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// 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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.
@StepKie StepKie force-pushed the feature/indexeddb-competitions branch from 887a733 to fbc6fdb Compare June 8, 2026 21:09
@StepKie StepKie merged commit 3b89124 into develop Jun 8, 2026
@StepKie StepKie deleted the feature/indexeddb-competitions branch June 8, 2026 21:09
.OrderByDescending(s => s.Id)
.ToList();
_overallStale = true;
OverallRecords = [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
OverallRecords = [];
var wasLoaded = OverallRecords.Count > 0;
_overallStale = true;
OverallRecords = [];
if (wasLoaded) { await EnsureOverallRecordsAsync(); }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant