Add the collection dashboard
One GET /api/stats call, aggregated in a single pass over the library.
Completion funnel, breakdowns by system, genre, decade, condition and
rating, a backlog that links into the filtered library, and a value card.
Form was picked before colour, and most of the page is not a chart: single
numbers are stat tiles, the breakdowns are bar lists with the value printed
per row, which is also the table view.
The colour work, in order:
* one hue for the breakdown bars — identity is on the axis labels, so
colour has nothing to encode, and a darker-where-bigger ramp would just
double-encode bar length
* an ordinal ramp for the funnel, since owned/played/finished are ordered
stages rather than peers
* validated with the dataviz validator against this app's real card
surfaces rather than a reference one, which caught that the documented
ordinal light-end measures 1.91:1 here and fails the 2:1 floor; the ramp
starts a step darker
* status colour used once, on the stale-valuation notice, with an icon and
text so it never carries meaning alone
Two bugs found by rendering it and looking, which the validator cannot see:
* the ratings card was showing condition data under a ratings heading — a
chart whose title did not describe its contents. Fixed by adding a real
rating distribution rather than relabelling the card.
* dark mode rendered light cards on a dark page. Copying the reference
pattern's `color-scheme` onto the container overrode how every
descendant resolved light-dark(), and the `:root`-prefixed media
override never matched at all, because Angular's emulated encapsulation
scopes selectors in component styles. Both replaced by light-dark()
values that inherit the app's own scheme.
The value card reports coverage, age and source beside the total, and flags
valuations older than 90 days, because a bare figure mixes fresh with stale
and silently omits everything unpriced.
148 backend tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
namespace LudosData.Api.Contracts;
|
||||
|
||||
public record CountByLabel(string Label, int Count);
|
||||
|
||||
/// <summary>
|
||||
/// The owned → played → finished progression. Each stage is a subset of the one
|
||||
/// before it, so the numbers only make sense read in order.
|
||||
/// </summary>
|
||||
public record CompletionFunnel(int Owned, int Played, int Finished);
|
||||
|
||||
/// <summary>
|
||||
/// Collection value, reported with everything needed to judge it.
|
||||
///
|
||||
/// A bare total invites a false reading: it silently mixes games priced today
|
||||
/// with games priced months ago, and quietly excludes everything unpriced. So the
|
||||
/// coverage, the age range and the sources all travel with the figure.
|
||||
/// </summary>
|
||||
public record ValueSummary(
|
||||
decimal Total,
|
||||
int PricedCount,
|
||||
int UnpricedCount,
|
||||
decimal TotalPaid,
|
||||
int PaidCount,
|
||||
DateTimeOffset? OldestValuedAt,
|
||||
DateTimeOffset? NewestValuedAt,
|
||||
IReadOnlyList<string> Sources,
|
||||
/// <summary>What the collection would be worth if every copy were complete in box.</summary>
|
||||
decimal? TotalIfCib);
|
||||
|
||||
public record StatsResponse(
|
||||
int TotalGames,
|
||||
CompletionFunnel Funnel,
|
||||
int Backlog,
|
||||
int InProgress,
|
||||
int Dumped,
|
||||
int RatedCount,
|
||||
double? AverageRating,
|
||||
IReadOnlyList<CountByLabel> BySystem,
|
||||
IReadOnlyList<CountByLabel> ByGenre,
|
||||
IReadOnlyList<CountByLabel> ByDecade,
|
||||
IReadOnlyList<CountByLabel> ByCondition,
|
||||
/// <summary>Rating distribution, 1-10. Only scores actually used appear.</summary>
|
||||
IReadOnlyList<CountByLabel> ByRating,
|
||||
ValueSummary Value);
|
||||
@@ -0,0 +1,138 @@
|
||||
using LudosData.Api.Auth;
|
||||
using LudosData.Api.Contracts;
|
||||
using LudosData.Api.Data;
|
||||
using LudosData.Api.Domain;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LudosData.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates for the dashboard, in one round trip.
|
||||
///
|
||||
/// The whole library is loaded and reduced in memory rather than issued as a
|
||||
/// dozen grouped queries: a personal collection is hundreds of rows, not
|
||||
/// millions, and one pass is both faster and far easier to keep consistent than
|
||||
/// twelve queries that could disagree with each other.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/stats")]
|
||||
[Authorize]
|
||||
public class StatsController(LudosDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<StatsResponse>> Get(CancellationToken ct)
|
||||
{
|
||||
var ownerId = User.GetUserId();
|
||||
var games = await db.Games.AsNoTracking()
|
||||
.Where(g => g.OwnerId == ownerId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var owned = games.Count(g => g.Own);
|
||||
var played = games.Count(g => g.Own && g.Played);
|
||||
var finished = games.Count(g => g.Own && g.Finished);
|
||||
|
||||
var rated = games.Where(g => g.Rating is not null).ToList();
|
||||
|
||||
var priced = games.Where(g => g.MarketValue is not null).ToList();
|
||||
var valuedAt = priced
|
||||
.Where(g => g.MarketValueUpdatedAt is not null)
|
||||
.Select(g => g.MarketValueUpdatedAt!.Value)
|
||||
.ToList();
|
||||
|
||||
var value = new ValueSummary(
|
||||
Total: priced.Sum(g => g.MarketValue ?? 0m),
|
||||
PricedCount: priced.Count,
|
||||
UnpricedCount: games.Count - priced.Count,
|
||||
TotalPaid: games.Sum(g => g.PurchasePrice ?? 0m),
|
||||
PaidCount: games.Count(g => g.PurchasePrice is not null),
|
||||
OldestValuedAt: valuedAt.Count > 0 ? valuedAt.Min() : null,
|
||||
NewestValuedAt: valuedAt.Count > 0 ? valuedAt.Max() : null,
|
||||
Sources: priced
|
||||
.Select(g => g.MarketValueSource)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(s => s!)
|
||||
.Distinct()
|
||||
.OrderBy(s => s)
|
||||
.ToList(),
|
||||
// Only meaningful once some CIB prices exist; null keeps the card
|
||||
// from showing a total that is really just the games that happen to
|
||||
// have that tier filled in.
|
||||
TotalIfCib: games.Any(g => g.ValueCib is not null)
|
||||
? games.Sum(g => g.ValueCib ?? g.MarketValue ?? 0m)
|
||||
: null);
|
||||
|
||||
return Ok(new StatsResponse(
|
||||
TotalGames: games.Count,
|
||||
Funnel: new CompletionFunnel(owned, played, finished),
|
||||
Backlog: games.Count(g => g.Own && !g.Played),
|
||||
InProgress: games.Count(g => g.Played && !g.Finished),
|
||||
Dumped: games.Count(g => g.Dumped),
|
||||
RatedCount: rated.Count,
|
||||
AverageRating: rated.Count > 0 ? Math.Round(rated.Average(g => g.Rating!.Value), 1) : null,
|
||||
BySystem: Rank(games, g => g.System),
|
||||
ByGenre: Rank(games, g => g.Genre),
|
||||
ByDecade: ByDecade(games),
|
||||
ByCondition: games
|
||||
.GroupBy(g => g.Condition)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Select(g => new CountByLabel(Describe(g.Key), g.Count()))
|
||||
.ToList(),
|
||||
// Highest score first, so the best-regarded games lead. Only scores
|
||||
// in use appear — empty rows for unused ratings would be noise.
|
||||
ByRating: rated
|
||||
.GroupBy(g => g.Rating!.Value)
|
||||
.OrderByDescending(g => g.Key)
|
||||
.Select(g => new CountByLabel($"{g.Key} / 10", g.Count()))
|
||||
.ToList(),
|
||||
Value: value));
|
||||
}
|
||||
|
||||
private static List<CountByLabel> Rank(List<Game> games, Func<Game, string?> select) =>
|
||||
games
|
||||
.Select(select)
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.GroupBy(v => v!)
|
||||
// Count first, then alphabetically, so equal counts have a stable
|
||||
// order rather than shuffling between requests.
|
||||
.OrderByDescending(g => g.Count())
|
||||
.ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => new CountByLabel(g.Key, g.Count()))
|
||||
.ToList();
|
||||
|
||||
private static List<CountByLabel> ByDecade(List<Game> games)
|
||||
{
|
||||
var decades = new Dictionary<int, int>();
|
||||
|
||||
foreach (var game in games)
|
||||
{
|
||||
// Year is a free-text column, so pull the first plausible year out
|
||||
// of whatever is there rather than trusting it to parse.
|
||||
var match = System.Text.RegularExpressions.Regex.Match(
|
||||
game.Year ?? string.Empty, @"(19|20)\d{2}");
|
||||
|
||||
if (!match.Success || !int.TryParse(match.Value, out var year))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var decade = year - (year % 10);
|
||||
decades[decade] = decades.GetValueOrDefault(decade) + 1;
|
||||
}
|
||||
|
||||
return decades
|
||||
.OrderBy(d => d.Key)
|
||||
.Select(d => new CountByLabel($"{d.Key}s", d.Value))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string Describe(GameCondition condition) => condition switch
|
||||
{
|
||||
GameCondition.Loose => "Loose",
|
||||
GameCondition.Cib => "Complete in box",
|
||||
GameCondition.Sealed => "Sealed",
|
||||
GameCondition.Digital => "Digital",
|
||||
_ => "Unspecified",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Net;
|
||||
using LudosData.Api.Contracts;
|
||||
|
||||
namespace LudosData.Api.Tests;
|
||||
|
||||
public class StatsTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
|
||||
{
|
||||
private static object Game(
|
||||
string title, string system = "SNES", string genre = "rpg", string? year = "1995",
|
||||
bool own = true, bool dumped = false, bool played = false, bool finished = false,
|
||||
int? rating = null, decimal? marketValue = null, decimal? purchasePrice = null,
|
||||
string condition = "Unspecified") => new
|
||||
{
|
||||
title, system, genre, year, own, dumped, played, finished,
|
||||
rating, marketValue, purchasePrice, condition,
|
||||
};
|
||||
|
||||
private static async Task SeedAsync(HttpClient client)
|
||||
{
|
||||
// 4 owned, 3 played, 1 finished — so every funnel stage differs.
|
||||
await client.PostJsonAsync("/api/games", Game("Alpha", "SNES", "rpg", "1995",
|
||||
played: true, finished: true, rating: 9, marketValue: 100m, purchasePrice: 40m));
|
||||
await client.PostJsonAsync("/api/games", Game("Beta", "SNES", "rpg", "1996",
|
||||
played: true, rating: 7, marketValue: 50m));
|
||||
await client.PostJsonAsync("/api/games", Game("Gamma", "N64", "fps", "2001",
|
||||
played: true, dumped: true));
|
||||
await client.PostJsonAsync("/api/games", Game("Delta", "N64", "racing", "2011"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_funnel_reports_each_stage_as_a_subset_of_the_last()
|
||||
{
|
||||
var client = await factory.CreateUserClientAsync("stats-funnel");
|
||||
await SeedAsync(client);
|
||||
|
||||
var stats = await client.GetJsonAsync<StatsResponse>("/api/stats");
|
||||
|
||||
Assert.Equal(4, stats!.TotalGames);
|
||||
Assert.Equal(4, stats.Funnel.Owned);
|
||||
Assert.Equal(3, stats.Funnel.Played);
|
||||
Assert.Equal(1, stats.Funnel.Finished);
|
||||
|
||||
// The two numbers a backlog view exists to surface.
|
||||
Assert.Equal(1, stats.Backlog); // owned, never played
|
||||
Assert.Equal(2, stats.InProgress); // played, not finished
|
||||
Assert.Equal(1, stats.Dumped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Breakdowns_are_ordered_by_count_then_alphabetically()
|
||||
{
|
||||
var client = await factory.CreateUserClientAsync("stats-breakdown");
|
||||
await SeedAsync(client);
|
||||
|
||||
var stats = await client.GetJsonAsync<StatsResponse>("/api/stats");
|
||||
|
||||
Assert.Equal(["N64", "SNES"], stats!.BySystem.Select(s => s.Label));
|
||||
Assert.Equal(2, stats.BySystem[0].Count);
|
||||
|
||||
// A tie must not shuffle between requests.
|
||||
var again = await client.GetJsonAsync<StatsResponse>("/api/stats");
|
||||
Assert.Equal(stats.BySystem.Select(s => s.Label), again!.BySystem.Select(s => s.Label));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Decades_are_derived_from_a_free_text_year_column()
|
||||
{
|
||||
var client = await factory.CreateUserClientAsync("stats-decades");
|
||||
await SeedAsync(client);
|
||||
// Year is free text, so these are the shapes that actually turn up.
|
||||
await client.PostJsonAsync("/api/games", Game("Vague", year: "circa 1998"));
|
||||
await client.PostJsonAsync("/api/games", Game("Empty", year: ""));
|
||||
await client.PostJsonAsync("/api/games", Game("Junk", year: "unknown"));
|
||||
|
||||
var stats = await client.GetJsonAsync<StatsResponse>("/api/stats");
|
||||
|
||||
var decades = stats!.ByDecade.ToDictionary(d => d.Label, d => d.Count);
|
||||
Assert.Equal(3, decades["1990s"]); // 1995, 1996, "circa 1998"
|
||||
Assert.Equal(1, decades["2000s"]); // 2001
|
||||
Assert.Equal(1, decades["2010s"]); // 2011
|
||||
// "" and "unknown" are omitted rather than bucketed as a zero decade,
|
||||
// so the totals fall short of the library count by design.
|
||||
Assert.Equal(5, decades.Values.Sum());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Decades_are_in_chronological_order()
|
||||
{
|
||||
var client = await factory.CreateUserClientAsync("stats-decade-order");
|
||||
await SeedAsync(client);
|
||||
|
||||
var stats = await client.GetJsonAsync<StatsResponse>("/api/stats");
|
||||
|
||||
// Time reads left to right, regardless of which decade is largest.
|
||||
var labels = stats!.ByDecade.Select(d => d.Label).ToList();
|
||||
Assert.Equal(labels.OrderBy(l => l, StringComparer.Ordinal), labels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Value_carries_coverage_age_and_source_alongside_the_total()
|
||||
{
|
||||
var client = await factory.CreateUserClientAsync("stats-value");
|
||||
await SeedAsync(client);
|
||||
|
||||
var stats = await client.GetJsonAsync<StatsResponse>("/api/stats");
|
||||
|
||||
Assert.Equal(150m, stats!.Value.Total);
|
||||
Assert.Equal(2, stats.Value.PricedCount);
|
||||
// The two unpriced games are reported, so a total is never mistaken for
|
||||
// covering the whole collection.
|
||||
Assert.Equal(2, stats.Value.UnpricedCount);
|
||||
Assert.NotNull(stats.Value.OldestValuedAt);
|
||||
Assert.Contains("manual", stats.Value.Sources);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_empty_library_reports_zeroes_rather_than_failing()
|
||||
{
|
||||
var client = await factory.CreateUserClientAsync("stats-empty");
|
||||
|
||||
var stats = await client.GetJsonAsync<StatsResponse>("/api/stats");
|
||||
|
||||
Assert.Equal(0, stats!.TotalGames);
|
||||
Assert.Equal(0, stats.Funnel.Owned);
|
||||
Assert.Empty(stats.BySystem);
|
||||
Assert.Equal(0m, stats.Value.Total);
|
||||
// No games rated means no average, which is not the same as zero.
|
||||
Assert.Null(stats.AverageRating);
|
||||
Assert.Null(stats.Value.OldestValuedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ratings_average_only_over_rated_games()
|
||||
{
|
||||
var client = await factory.CreateUserClientAsync("stats-rating");
|
||||
await SeedAsync(client);
|
||||
|
||||
var stats = await client.GetJsonAsync<StatsResponse>("/api/stats");
|
||||
|
||||
Assert.Equal(2, stats!.RatedCount);
|
||||
// (9 + 7) / 2 — the two unrated games do not count as zero.
|
||||
Assert.Equal(8.0, stats.AverageRating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stats_cover_only_the_signed_in_users_library()
|
||||
{
|
||||
var alice = await factory.CreateUserClientAsync("stats-alice");
|
||||
var bob = await factory.CreateUserClientAsync("stats-bob");
|
||||
await SeedAsync(alice);
|
||||
|
||||
var bobStats = await bob.GetJsonAsync<StatsResponse>("/api/stats");
|
||||
|
||||
Assert.Equal(0, bobStats!.TotalGames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stats_require_a_token()
|
||||
{
|
||||
var response = await factory.CreateClient().GetAsync("/api/stats");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user