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:
2026-08-04 17:08:39 -04:00
co-authored by Claude Opus 5
parent d34e6b4ced
commit ddc618fc9c
12 changed files with 1228 additions and 1 deletions
@@ -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);
}
}