Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/FantasyFootball.Core/Models/CompetitionSummary.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace FantasyFootball.Models;

/// <summary>
/// Lightweight projection of a <see cref="Competition"/> 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.
/// </summary>
public sealed record CompetitionSummary(
int Id,
CompetitionType Type,
string Title,
bool IsNationalTeam,
DateTime? SimulationStart,
bool IsFinished,
string? WinnerId,
int PlayedGames,
int TotalGames)
{
/// <summary>Projects the list-relevant fields off a fully-materialized competition.</summary>
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);
}
22 changes: 16 additions & 6 deletions src/FantasyFootball.Core/Repositories/ICompetitionRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,9 @@ namespace FantasyFootball.Repositories;
/// <list type="bullet">
/// <item><c>InMemoryCompetitionRepository</c> — tests + the working
/// set inside <c>BulkSimRunner</c> before user-visible persistence.</item>
/// <item><c>LocalStorageCompetitionRepository</c> — browser persistence
/// for the Blazor WASM host.</item>
/// <item><c>IndexedDbCompetitionRepository</c> (follow-up) —
/// higher-capacity browser storage for bulk sims that exceed
/// LocalStorage's 5–10 MB quota.</item>
/// <item><c>IndexedDbCompetitionRepository</c> — browser persistence
/// for the Blazor WASM host. Each competition is one record in an
/// IndexedDB object store, escaping LocalStorage's ~5 MB cap.</item>
/// </list>
/// </summary>
public interface ICompetitionRepository
Expand All @@ -30,9 +28,21 @@ public interface ICompetitionRepository
/// <summary>Loads a competition by repository ID, or null if no such ID.</summary>
Task<Competition?> GetAsync(int id);

/// <summary>Lists all persisted competitions. Order is implementation-defined.</summary>
/// <summary>
/// Lists all persisted competitions, fully materialized. Heavy — deserializes
/// every game of every competition. Use <see cref="GetAllSummariesAsync"/> for
/// the list view; reserve this for the overall-standings aggregate, which needs
/// per-game results. Order is implementation-defined.
/// </summary>
Task<IReadOnlyList<Competition>> GetAllAsync();

/// <summary>
/// Lists a lightweight <see cref="CompetitionSummary"/> for every persisted
/// competition — enough to render the list without deserializing the games.
/// Order is implementation-defined.
/// </summary>
Task<IReadOnlyList<CompetitionSummary>> GetAllSummariesAsync();

/// <summary>Removes a competition by ID. No-op if the ID is unknown.</summary>
Task DeleteAsync(int id);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ public Task<IReadOnlyList<Competition>> GetAllAsync()
return Task.FromResult<IReadOnlyList<Competition>>(list);
}

public Task<IReadOnlyList<CompetitionSummary>> GetAllSummariesAsync()
{
var list = new List<CompetitionSummary>(_storage.Count);
foreach (var (id, json) in _storage)
{
var c = CompetitionDefinitionLoader.Load(json);
c.Id = id;
list.Add(CompetitionSummary.Of(c));
}
return Task.FromResult<IReadOnlyList<CompetitionSummary>>(list);
}

public Task DeleteAsync(int id)
{
_storage.Remove(id);
Expand Down
20 changes: 20 additions & 0 deletions src/FantasyFootball.Tests/InMemoryCompetitionRepositoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
109 changes: 71 additions & 38 deletions src/FantasyFootball.UI/Pages/Competitions.razor
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,12 @@
<MudChip T="string" Size="Size.Small"
Variant="@(isSel ? Variant.Filled : Variant.Outlined)"
Color="@(isSel ? Color.Primary : Color.Default)"
OnClick="@(() => ViewModel.SelectedType = t)">
OnClick="@(() => SetType(t))">
@label
</MudChip>
}
<MudSpacer />
@if (ViewModel.FilteredCompetitions.Count > 0)
@if (ViewModel.FilteredSummaries.Count > 0)
{
@if (_confirmingDeleteAll)
{
Expand All @@ -65,7 +65,7 @@
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
}
else if (ViewModel.FilteredCompetitions.Count == 0)
else if (ViewModel.FilteredSummaries.Count == 0)
{
<MudAlert Severity="Severity.Info">
No competitions @(ViewModel.SelectedType is { } t ? $"of type {t.Name().Long} " : "")yet.
Expand All @@ -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. *@
<div class="competitions-list">
<MudTable Items="ViewModel.FilteredCompetitions" Dense="true" Hover="true" Breakpoint="Breakpoint.None"
OnRowClick="@(EventCallback.Factory.Create<TableRowClickEventArgs<Competition>>(this, args => Nav.NavigateTo($"competitions/{args.Item!.Id}")))"
T="Competition">
<MudTable Items="ViewModel.FilteredSummaries" Dense="true" Hover="true" Breakpoint="Breakpoint.None"
OnRowClick="@(EventCallback.Factory.Create<TableRowClickEventArgs<CompetitionSummary>>(this, args => Nav.NavigateTo($"competitions/{args.Item!.Id}")))"
T="CompetitionSummary">
<HeaderContent>
<MudTh style="width:1%;"><MudTableSortLabel SortBy="new Func<Competition, object>(c => c.Id)" T="Competition">#</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<Competition, object>(c => c.Title)" T="Competition">Title</MudTableSortLabel></MudTh>
<MudTh Class="comp-started"><MudTableSortLabel SortBy="new Func<Competition, object>(c => c.SimulationStart ?? DateTime.MinValue)" T="Competition" InitialDirection="SortDirection.Descending">Started</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<Competition, object>(c => c.WinnerTeamId() ?? string.Empty)" T="Competition">Winner</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<Competition, object>(c => StateOrder(c))" T="Competition">State</MudTableSortLabel></MudTh>
<MudTh style="width:1%;"><MudTableSortLabel SortBy="new Func<CompetitionSummary, object>(s => s.Id)" T="CompetitionSummary">#</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<CompetitionSummary, object>(s => s.Title)" T="CompetitionSummary">Title</MudTableSortLabel></MudTh>
<MudTh Class="comp-started"><MudTableSortLabel SortBy="new Func<CompetitionSummary, object>(s => s.SimulationStart ?? DateTime.MinValue)" T="CompetitionSummary" InitialDirection="SortDirection.Descending">Started</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<CompetitionSummary, object>(s => s.WinnerId ?? string.Empty)" T="CompetitionSummary">Winner</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<CompetitionSummary, object>(s => StateOrder(s))" T="CompetitionSummary">State</MudTableSortLabel></MudTh>
<MudTh style="width:1%;"></MudTh>
</HeaderContent>
<RowTemplate>
@{
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;
}
<MudTd DataLabel="#">@context.Id</MudTd>
<MudTd DataLabel="Title">
Expand All @@ -113,9 +113,9 @@ else
}
</MudTd>
<MudTd DataLabel="Winner">
@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);
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<FlagIcon Team="winner" Code="@winnerId" Size="20" />
<span class="@(winner?.CompactName is null ? "" : "team-name-full")">@DisplayHyphenation.Soften(winner?.Name ?? winnerId)</span>
Expand Down Expand Up @@ -169,53 +169,73 @@ else
</MudTable>
</div>

@* 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)
{
<MudExpansionPanels Class="mt-4">
<MudExpansionPanel Text="@($"Overall standings ({overallRecords.Count} teams)")">
<MudExpansionPanel Text="@(ViewModel.OverallRecords.Count > 0 ? $"Overall standings ({ViewModel.OverallRecords.Count} teams)" : "Overall standings")"
Expanded="_overallExpanded" ExpandedChanged="OnOverallExpandedChanged">
@if (ViewModel.IsLoadingOverall)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="my-2" />
}
else if (ViewModel.OverallRecords.Count > 0)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="mb-2">
<MudChip T="string" Size="Size.Small"
Variant="@(_overallPerGame ? Variant.Outlined : Variant.Filled)"
Color="@(_overallPerGame ? Color.Default : Color.Primary)"
OnClick="@(() => _overallPerGame = false)">Total</MudChip>
<MudChip T="string" Size="Size.Small"
Variant="@(_overallPerGame ? Variant.Filled : Variant.Outlined)"
Color="@(_overallPerGame ? Color.Primary : Color.Default)"
OnClick="@(() => _overallPerGame = true)">Per game</MudChip>
</MudStack>
@* Breakpoint.None: keep the real table on phones — the stacked label/value fallback can't be scanned as standings. *@
<div class="overall-standings">
<MudTable Items="overallRecords" Dense="true" Hover="true" Breakpoint="Breakpoint.None" T="TeamRecord">
<MudTable Items="ViewModel.OverallRecords" Dense="true" Hover="true" Breakpoint="Breakpoint.None" T="TeamRecord">
<HeaderContent>
<MudTh><MudTableSortLabel SortBy="new Func<TeamRecord, object>(r => r.Team.Name)" T="TeamRecord">Team</MudTableSortLabel></MudTh>
<MudTh Class="standings-num"><MudTableSortLabel SortBy="new Func<TeamRecord, object>(r => r.CompetitionWins)" T="TeamRecord">Titles</MudTableSortLabel></MudTh>
<MudTh Class="standings-num"><MudTableSortLabel SortBy="new Func<TeamRecord, object>(r => r.MatchesPlayed)" T="TeamRecord">P</MudTableSortLabel></MudTh>
<MudTh Class="standings-num"><MudTableSortLabel SortBy="new Func<TeamRecord, object>(r => r.Wins)" T="TeamRecord">W</MudTableSortLabel></MudTh>
<MudTh Class="standings-num"><MudTableSortLabel SortBy="new Func<TeamRecord, object>(r => r.Draws)" T="TeamRecord">D</MudTableSortLabel></MudTh>
<MudTh Class="standings-num"><MudTableSortLabel SortBy="new Func<TeamRecord, object>(r => r.Losses)" T="TeamRecord">L</MudTableSortLabel></MudTh>
<MudTh Class="standings-num"><MudTableSortLabel SortBy="new Func<TeamRecord, object>(r => r.GoalsFor)" T="TeamRecord">GF</MudTableSortLabel></MudTh>
<MudTh Class="standings-num"><MudTableSortLabel SortBy="new Func<TeamRecord, object>(r => r.GoalsAgainst)" T="TeamRecord">GA</MudTableSortLabel></MudTh>
<MudTh Class="standings-num"><MudTableSortLabel SortBy="new Func<TeamRecord, object>(r => r.GoalDifference)" T="TeamRecord">GD</MudTableSortLabel></MudTh>
<MudTh Class="standings-num"><MudTableSortLabel SortBy="new Func<TeamRecord, object>(r => r.Points)" T="TeamRecord" InitialDirection="SortDirection.Descending">Pts</MudTableSortLabel></MudTh>
<MudTh Class="standings-num"><MudTableSortLabel SortBy="new Func<TeamRecord, object>(r => _overallPerGame ? r.WinRatio : r.Wins)" T="TeamRecord">@(_overallPerGame ? "W%" : "W")</MudTableSortLabel></MudTh>
<MudTh Class="standings-num"><MudTableSortLabel SortBy="new Func<TeamRecord, object>(r => _overallPerGame ? r.DrawRatio : r.Draws)" T="TeamRecord">@(_overallPerGame ? "D%" : "D")</MudTableSortLabel></MudTh>
<MudTh Class="standings-num"><MudTableSortLabel SortBy="new Func<TeamRecord, object>(r => _overallPerGame ? r.LossRatio : r.Losses)" T="TeamRecord">@(_overallPerGame ? "L%" : "L")</MudTableSortLabel></MudTh>
<MudTh Class="standings-num"><MudTableSortLabel SortBy="new Func<TeamRecord, object>(r => _overallPerGame ? r.GoalsForPerGame : r.GoalsFor)" T="TeamRecord">@(_overallPerGame ? "GF/g" : "GF")</MudTableSortLabel></MudTh>
<MudTh Class="standings-num"><MudTableSortLabel SortBy="new Func<TeamRecord, object>(r => _overallPerGame ? r.GoalsAgainstPerGame : r.GoalsAgainst)" T="TeamRecord">@(_overallPerGame ? "GA/g" : "GA")</MudTableSortLabel></MudTh>
<MudTh Class="standings-num"><MudTableSortLabel SortBy="new Func<TeamRecord, object>(r => _overallPerGame ? r.GoalDifferencePerGame : r.GoalDifference)" T="TeamRecord">@(_overallPerGame ? "GD/g" : "GD")</MudTableSortLabel></MudTh>
<MudTh Class="standings-num"><MudTableSortLabel SortBy="new Func<TeamRecord, object>(r => _overallPerGame ? r.PointsPerGame : r.Points)" T="TeamRecord" InitialDirection="SortDirection.Descending">@(_overallPerGame ? "PPG" : "Pts")</MudTableSortLabel></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Team">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<FlagIcon Team="context.Team" Size="20" />
<FlagIcon Team="context.Team" Code="@context.Team.ShortName" Size="20" />
<span class="@(context.Team.CompactName is null ? "" : "team-name-full")">@DisplayHyphenation.Soften(context.Team.Name)</span>
@if (context.Team.CompactName is { } compact) { <span class="team-name-compact">@DisplayHyphenation.Soften(compact)</span> }
</MudStack>
</MudTd>
<MudTd DataLabel="Titles" Class="standings-num">@context.CompetitionWins</MudTd>
<MudTd DataLabel="P" Class="standings-num">@context.MatchesPlayed</MudTd>
<MudTd DataLabel="W" Class="standings-num">@context.Wins</MudTd>
<MudTd DataLabel="D" Class="standings-num">@context.Draws</MudTd>
<MudTd DataLabel="L" Class="standings-num">@context.Losses</MudTd>
<MudTd DataLabel="GF" Class="standings-num">@context.GoalsFor</MudTd>
<MudTd DataLabel="GA" Class="standings-num">@context.GoalsAgainst</MudTd>
<MudTd DataLabel="GD" Class="standings-num">@context.GoalDifference</MudTd>
<MudTd DataLabel="Pts" Class="standings-num"><strong>@context.Points</strong></MudTd>
<MudTd DataLabel="W" Class="standings-num">@(_overallPerGame ? context.WinRatio.ToString("P0") : context.Wins.ToString())</MudTd>
<MudTd DataLabel="D" Class="standings-num">@(_overallPerGame ? context.DrawRatio.ToString("P0") : context.Draws.ToString())</MudTd>
<MudTd DataLabel="L" Class="standings-num">@(_overallPerGame ? context.LossRatio.ToString("P0") : context.Losses.ToString())</MudTd>
<MudTd DataLabel="GF" Class="standings-num">@(_overallPerGame ? context.GoalsForPerGame.ToString("0.00") : context.GoalsFor.ToString())</MudTd>
<MudTd DataLabel="GA" Class="standings-num">@(_overallPerGame ? context.GoalsAgainstPerGame.ToString("0.00") : context.GoalsAgainst.ToString())</MudTd>
<MudTd DataLabel="GD" Class="standings-num">@(_overallPerGame ? context.GoalDifferencePerGame.ToString("0.00") : context.GoalDifference.ToString())</MudTd>
<MudTd DataLabel="Pts" Class="standings-num"><strong>@(_overallPerGame ? context.PointsPerGame.ToString("0.00") : context.Points.ToString())</strong></MudTd>
</RowTemplate>
</MudTable>
</div>
}
</MudExpansionPanel>
</MudExpansionPanels>
}
}

@code {
bool _confirmingDeleteAll;
bool _overallExpanded;
bool _overallPerGame;

protected override async Task OnInitializedAsync()
{
Expand All @@ -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);

Expand All @@ -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;
}

Expand Down
Loading
Loading