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:
@@ -291,6 +291,40 @@ parser follows the widely-used convention — integer pennies under hyphenated
|
|||||||
keys — and is tolerant enough that a naming mismatch reads as "no price" rather
|
keys — and is tolerant enough that a naming mismatch reads as "no price" rather
|
||||||
than throwing. `PriceChartingProvider.Parse` is the one place to adjust.
|
than throwing. `PriceChartingProvider.Parse` is the one place to adjust.
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
`/dashboard` — one `GET /api/stats` call, aggregated server-side in a single pass
|
||||||
|
over the library rather than a dozen grouped queries that could disagree.
|
||||||
|
|
||||||
|
Form was chosen before colour, and most of the page is deliberately not a chart:
|
||||||
|
headline numbers are stat tiles, the backlog is a link into a filtered library
|
||||||
|
view, and the breakdowns are horizontal bar lists with the value printed on each
|
||||||
|
row — which doubles as the table view.
|
||||||
|
|
||||||
|
Colour decisions worth keeping:
|
||||||
|
|
||||||
|
- **One hue for the breakdown bars.** Identity is carried by the axis labels, so
|
||||||
|
colour has nothing to encode; twelve systems in twelve hues would be twelve
|
||||||
|
ways to be wrong, and a darker-where-bigger ramp would double-encode length.
|
||||||
|
- **An ordinal ramp for the funnel**, because owned → played → finished are
|
||||||
|
ordered stages, not peer categories.
|
||||||
|
- **The palette was validated with the dataviz validator against this app's own
|
||||||
|
card surfaces** (`#f8f2f6` light, `#1d1b1e` dark), not against a reference
|
||||||
|
surface. That mattered: the documented ordinal light-end measured 1.91:1 here
|
||||||
|
and failed the 2:1 floor, so the ramp starts a step darker.
|
||||||
|
- **Status colour appears once**, on the stale-valuation notice, always with an
|
||||||
|
icon and text so it never carries meaning alone.
|
||||||
|
|
||||||
|
The value card is deliberately wordy. A bare total silently mixes fresh and old
|
||||||
|
valuations and excludes everything unpriced, so coverage, age and source travel
|
||||||
|
with the figure, and a valuation older than 90 days is called out.
|
||||||
|
|
||||||
|
Theming note: the chart variables use `light-dark()` rather than a
|
||||||
|
`prefers-color-scheme` block. Angular's emulated encapsulation scopes selectors
|
||||||
|
in component styles, so a `:root`-prefixed media override never matches from
|
||||||
|
there — and setting `color-scheme` on the container overrides how every
|
||||||
|
descendant resolves `light-dark()`, which rendered light cards on a dark page.
|
||||||
|
|
||||||
### Database changes
|
### Database changes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,12 @@ export const routes: Routes = [
|
|||||||
title: 'Create account · LudosData',
|
title: 'Create account · LudosData',
|
||||||
loadComponent: () => import('./features/register/register').then((m) => m.Register),
|
loadComponent: () => import('./features/register/register').then((m) => m.Register),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
canActivate: [authGuard],
|
||||||
|
title: 'Collection · LudosData',
|
||||||
|
loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'games',
|
path: 'games',
|
||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
export interface CountByLabel {
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each stage is a subset of the one before, so these read in order. */
|
||||||
|
export interface CompletionFunnel {
|
||||||
|
owned: number;
|
||||||
|
played: number;
|
||||||
|
finished: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValueSummary {
|
||||||
|
total: number;
|
||||||
|
pricedCount: number;
|
||||||
|
unpricedCount: number;
|
||||||
|
totalPaid: number;
|
||||||
|
paidCount: number;
|
||||||
|
oldestValuedAt: string | null;
|
||||||
|
newestValuedAt: string | null;
|
||||||
|
sources: string[];
|
||||||
|
totalIfCib: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Stats {
|
||||||
|
totalGames: number;
|
||||||
|
funnel: CompletionFunnel;
|
||||||
|
backlog: number;
|
||||||
|
inProgress: number;
|
||||||
|
dumped: number;
|
||||||
|
ratedCount: number;
|
||||||
|
averageRating: number | null;
|
||||||
|
bySystem: CountByLabel[];
|
||||||
|
byGenre: CountByLabel[];
|
||||||
|
byDecade: CountByLabel[];
|
||||||
|
byCondition: CountByLabel[];
|
||||||
|
byRating: CountByLabel[];
|
||||||
|
value: ValueSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class StatsService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
get(): Observable<Stats> {
|
||||||
|
return this.http.get<Stats>('/api/stats');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Component, computed, input } from '@angular/core';
|
||||||
|
|
||||||
|
import { CountByLabel } from '../../core/stats.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A horizontal bar list: label, bar, value.
|
||||||
|
*
|
||||||
|
* Horizontal rather than vertical because the categories are named things
|
||||||
|
* ("Complete in box", "platformer") whose labels need room to sit unrotated.
|
||||||
|
* One hue for every bar — identity is carried by the axis labels, so colour has
|
||||||
|
* no work to do here, and twelve hues would be twelve ways to be wrong.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-bar-list',
|
||||||
|
template: `
|
||||||
|
<div class="rows" role="list">
|
||||||
|
@for (row of rows(); track row.label) {
|
||||||
|
<div
|
||||||
|
class="row"
|
||||||
|
role="listitem"
|
||||||
|
[attr.title]="row.label + ': ' + row.count + ' ' + (row.count === 1 ? 'game' : 'games')"
|
||||||
|
>
|
||||||
|
<span class="label">{{ row.label }}</span>
|
||||||
|
<span class="track">
|
||||||
|
<span class="fill" [style.width.%]="row.percent"></span>
|
||||||
|
</span>
|
||||||
|
<span class="value">{{ row.count }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
.rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* 2px surface gap between adjacent fills. */
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(4.5rem, 8rem) 1fr 2.25rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--viz-ink-secondary);
|
||||||
|
text-align: right;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
position: relative;
|
||||||
|
height: 0.75rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--viz-track);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--viz-series);
|
||||||
|
/* 4px rounded data-end, anchored square to the baseline. */
|
||||||
|
border-radius: 0 0.25rem 0.25rem 0;
|
||||||
|
min-width: 2px;
|
||||||
|
transition: width 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--viz-ink-primary);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover .fill {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fill {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class BarList {
|
||||||
|
readonly data = input.required<CountByLabel[]>();
|
||||||
|
|
||||||
|
/** Cap the list; the tail of a long-tailed breakdown is noise on a dashboard. */
|
||||||
|
readonly limit = input<number>(0);
|
||||||
|
|
||||||
|
protected readonly rows = computed(() => {
|
||||||
|
const all = this.data();
|
||||||
|
const shown = this.limit() > 0 ? all.slice(0, this.limit()) : all;
|
||||||
|
|
||||||
|
// Scale to the largest bar rather than the total: this compares magnitudes,
|
||||||
|
// it is not a part-to-whole.
|
||||||
|
const max = Math.max(...shown.map((d) => d.count), 1);
|
||||||
|
|
||||||
|
return shown.map((d) => ({
|
||||||
|
...d,
|
||||||
|
percent: (d.count / max) * 100,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<app-toolbar />
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<mat-progress-bar mode="indeterminate" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (failed()) {
|
||||||
|
<div class="state-panel">
|
||||||
|
<mat-icon>cloud_off</mat-icon>
|
||||||
|
<h2>Could not load your collection stats</h2>
|
||||||
|
<p>The server did not respond. Check that the API is running, then reload.</p>
|
||||||
|
</div>
|
||||||
|
} @else if (data(); as stats) {
|
||||||
|
@if (stats.totalGames === 0) {
|
||||||
|
<div class="state-panel">
|
||||||
|
<mat-icon>insights</mat-icon>
|
||||||
|
<h2>Nothing to chart yet</h2>
|
||||||
|
<p>Add a few games and this page will fill in.</p>
|
||||||
|
<a mat-flat-button color="primary" routerLink="/games/new">Add a game</a>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="viz-root dashboard">
|
||||||
|
<!-- Headline numbers. These are single values, so they are stat tiles
|
||||||
|
rather than one-bar charts. -->
|
||||||
|
<section class="kpis" aria-label="Collection at a glance">
|
||||||
|
<div class="kpi">
|
||||||
|
<span class="kpi-label">Games</span>
|
||||||
|
<span class="kpi-value">{{ stats.totalGames | number }}</span>
|
||||||
|
<span class="kpi-note">{{ stats.bySystem.length }} systems</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kpi">
|
||||||
|
<span class="kpi-label">Finished</span>
|
||||||
|
<span class="kpi-value">{{ completionRate() }}%</span>
|
||||||
|
<span class="kpi-note">{{ stats.funnel.finished }} of {{ stats.funnel.owned }} owned</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="kpi kpi--link" routerLink="/games" [queryParams]="{ played: false }">
|
||||||
|
<span class="kpi-label">Backlog</span>
|
||||||
|
<span class="kpi-value">{{ stats.backlog | number }}</span>
|
||||||
|
<span class="kpi-note">owned, never played</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="kpi kpi--link" routerLink="/games" [queryParams]="{ played: true, finished: false }">
|
||||||
|
<span class="kpi-label">In progress</span>
|
||||||
|
<span class="kpi-value">{{ stats.inProgress | number }}</span>
|
||||||
|
<span class="kpi-note">played, not finished</span>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<!-- Completion funnel -->
|
||||||
|
<mat-card class="panel panel--wide">
|
||||||
|
<h2 class="panel-title">Completion</h2>
|
||||||
|
<p class="panel-sub">Each stage is a subset of the one above it.</p>
|
||||||
|
<app-funnel [data]="stats.funnel" />
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Collection value. Deliberately verbose about what the total covers,
|
||||||
|
because a bare figure invites a reading the data cannot support. -->
|
||||||
|
<mat-card class="panel">
|
||||||
|
<h2 class="panel-title">Collection value</h2>
|
||||||
|
|
||||||
|
@if (stats.value.pricedCount === 0) {
|
||||||
|
<div class="empty">
|
||||||
|
<mat-icon>sell</mat-icon>
|
||||||
|
<p>No games are priced yet.</p>
|
||||||
|
<p class="empty-note">
|
||||||
|
Import a price guide from the account page, or add a value to a game
|
||||||
|
by hand.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="hero">{{ stats.value.total | currency: 'USD' : 'symbol' : '1.0-0' }}</p>
|
||||||
|
|
||||||
|
<dl class="facts">
|
||||||
|
<div>
|
||||||
|
<dt>Coverage</dt>
|
||||||
|
<dd>
|
||||||
|
{{ stats.value.pricedCount }} of {{ stats.totalGames }} games
|
||||||
|
({{ valueCoverage() }}%)
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@if (stats.value.paidCount > 0) {
|
||||||
|
<div>
|
||||||
|
<dt>Paid</dt>
|
||||||
|
<dd>
|
||||||
|
{{ stats.value.totalPaid | currency: 'USD' : 'symbol' : '1.0-0' }}
|
||||||
|
across {{ stats.value.paidCount }} games
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (stats.value.totalIfCib !== null) {
|
||||||
|
<div>
|
||||||
|
<dt>If all complete</dt>
|
||||||
|
<dd>{{ stats.value.totalIfCib | currency: 'USD' : 'symbol' : '1.0-0' }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<dt>Source</dt>
|
||||||
|
<dd>{{ stats.value.sources.join(', ') || 'unknown' }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
@if (stats.value.unpricedCount > 0) {
|
||||||
|
<p class="caveat">
|
||||||
|
<mat-icon>info_outline</mat-icon>
|
||||||
|
<span>
|
||||||
|
{{ stats.value.unpricedCount }}
|
||||||
|
{{ stats.value.unpricedCount === 1 ? 'game is' : 'games are' }} unpriced,
|
||||||
|
so the real total is higher.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (valueIsStale()) {
|
||||||
|
<!-- Status colour never carries meaning alone: icon and text too. -->
|
||||||
|
<p class="caveat caveat--warn" role="note">
|
||||||
|
<mat-icon>schedule</mat-icon>
|
||||||
|
<span>
|
||||||
|
Oldest valuation is from
|
||||||
|
{{ stats.value.oldestValuedAt | date: 'mediumDate' }}. Prices move;
|
||||||
|
refresh before trusting this figure.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Breakdowns. One hue each: the category names are on the axis, so
|
||||||
|
colour has no identity to carry. -->
|
||||||
|
<mat-card class="panel">
|
||||||
|
<h2 class="panel-title">By system</h2>
|
||||||
|
<p class="panel-sub">{{ stats.bySystem.length }} systems</p>
|
||||||
|
<app-bar-list [data]="stats.bySystem" />
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<mat-card class="panel">
|
||||||
|
<h2 class="panel-title">By genre</h2>
|
||||||
|
<p class="panel-sub">{{ stats.byGenre.length }} genres</p>
|
||||||
|
<app-bar-list [data]="stats.byGenre" />
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
@if (stats.byDecade.length) {
|
||||||
|
<mat-card class="panel">
|
||||||
|
<h2 class="panel-title">By decade</h2>
|
||||||
|
<p class="panel-sub">Release decade, where a year is recorded</p>
|
||||||
|
<app-bar-list [data]="stats.byDecade" />
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
<mat-card class="panel">
|
||||||
|
<h2 class="panel-title">Condition</h2>
|
||||||
|
<p class="panel-sub">How your copies are kept</p>
|
||||||
|
<app-bar-list [data]="stats.byCondition" />
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
@if (stats.ratedCount > 0) {
|
||||||
|
<mat-card class="panel">
|
||||||
|
<h2 class="panel-title">Ratings</h2>
|
||||||
|
<p class="panel-sub">
|
||||||
|
{{ stats.ratedCount }} of {{ stats.totalGames }} rated · average
|
||||||
|
{{ stats.averageRating }} / 10
|
||||||
|
</p>
|
||||||
|
<app-bar-list [data]="stats.byRating" />
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
/* Chart palette.
|
||||||
|
*
|
||||||
|
* Every value below was checked with the dataviz validator against this app's
|
||||||
|
* own card surfaces — light #f8f2f6, dark #1d1b1e — not against a reference
|
||||||
|
* surface. That mattered: the documented ordinal light-end (#86b6ef) measured
|
||||||
|
* 1.91:1 here and failed the 2:1 floor, so the ramp starts a step darker.
|
||||||
|
*
|
||||||
|
* Declared under both the media query and the data-theme scope so the app's
|
||||||
|
* theme toggle wins in either direction.
|
||||||
|
*/
|
||||||
|
/* Each value is light-dark(light, dark), resolved against the color-scheme this
|
||||||
|
* subtree inherits from <html> — the same mechanism Material's own tokens use.
|
||||||
|
*
|
||||||
|
* Two things deliberately NOT done here:
|
||||||
|
*
|
||||||
|
* - No `color-scheme` declaration. Setting it on a container overrides how
|
||||||
|
* every descendant resolves light-dark(), which rendered the whole dashboard
|
||||||
|
* in light card surfaces on a dark page.
|
||||||
|
* - No `:root`-prefixed media override. Angular's emulated encapsulation
|
||||||
|
* scopes selectors in component styles, so a `:root … .viz-root` rule never
|
||||||
|
* matches from here and the dark values silently never applied.
|
||||||
|
*/
|
||||||
|
.viz-root {
|
||||||
|
/* Ordinal ramp, light -> dark. Used for funnel stages, which are ordered. */
|
||||||
|
--viz-step-1: light-dark(#6da7ec, #9ec5f4);
|
||||||
|
--viz-step-2: light-dark(#2a78d6, #5598e7);
|
||||||
|
--viz-step-3: light-dark(#104281, #256abf);
|
||||||
|
|
||||||
|
/* Single hue for magnitude bars; identity lives on the axis labels. */
|
||||||
|
--viz-series: light-dark(#2a78d6, #3987e5);
|
||||||
|
--viz-track: light-dark(#e7e2ea, #2e2c31);
|
||||||
|
|
||||||
|
--viz-ink-primary: light-dark(#1d1b1e, #e6e1e6);
|
||||||
|
--viz-ink-secondary: light-dark(#52514e, #c3c2b7);
|
||||||
|
--viz-ink-muted: #898781;
|
||||||
|
|
||||||
|
/* Status. Reserved — never reused as a series colour. */
|
||||||
|
--viz-warn: #fab219;
|
||||||
|
--viz-warn-ink: light-dark(#6b4a00, #fbd88a);
|
||||||
|
--viz-warn-bg: light-dark(#fdf4e0, #2e2513);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- layout ------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.dashboard {
|
||||||
|
max-width: 72rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--mat-sys-surface-container-low);
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi--link {
|
||||||
|
transition: background 120ms ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
background: var(--mat-sys-surface-container-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--mat-sys-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--viz-ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-value {
|
||||||
|
/* Hero-scale figure, proportional numerals — these do not need to align. */
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.15;
|
||||||
|
color: var(--viz-ink-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-note {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--viz-ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel--wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--viz-ink-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-sub {
|
||||||
|
margin: 0.125rem 0 1rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--viz-ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- value card -------------------------------------------------------- */
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
margin: 0.25rem 0 1rem;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--viz-ink-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
color: var(--viz-ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--viz-ink-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.caveat {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--viz-ink-secondary);
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
flex: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
color: var(--viz-ink-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.caveat--warn {
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--viz-warn-bg);
|
||||||
|
color: var(--viz-warn-ink);
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: var(--viz-warn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 1.5rem 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
color: var(--viz-ink-muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--viz-ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-note {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--viz-ink-muted);
|
||||||
|
max-width: 20rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- shared states ----------------------------------------------------- */
|
||||||
|
|
||||||
|
.state-panel {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 4rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--mat-sys-on-surface-variant);
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
max-width: 28rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { CurrencyPipe, DatePipe, DecimalPipe } from '@angular/common';
|
||||||
|
import { Component, computed, inject, signal } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
|
import { Stats, StatsService } from '../../core/stats.service';
|
||||||
|
import { BarList } from './bar-list';
|
||||||
|
import { Funnel } from './funnel';
|
||||||
|
import { Toolbar } from '../../shared/toolbar';
|
||||||
|
|
||||||
|
/** A valuation older than this is called out rather than quietly totalled. */
|
||||||
|
const STALE_AFTER_DAYS = 90;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-dashboard',
|
||||||
|
imports: [
|
||||||
|
RouterLink,
|
||||||
|
Toolbar,
|
||||||
|
BarList,
|
||||||
|
Funnel,
|
||||||
|
MatCardModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
CurrencyPipe,
|
||||||
|
DecimalPipe,
|
||||||
|
DatePipe,
|
||||||
|
],
|
||||||
|
templateUrl: './dashboard.html',
|
||||||
|
styleUrl: './dashboard.scss',
|
||||||
|
})
|
||||||
|
export class Dashboard {
|
||||||
|
private readonly stats = inject(StatsService);
|
||||||
|
|
||||||
|
protected readonly data = signal<Stats | null>(null);
|
||||||
|
protected readonly loading = signal(true);
|
||||||
|
protected readonly failed = signal(false);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.stats
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe({
|
||||||
|
next: (stats) => {
|
||||||
|
this.data.set(stats);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.failed.set(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected readonly completionRate = computed(() => {
|
||||||
|
const stats = this.data();
|
||||||
|
if (!stats || stats.funnel.owned === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.round((stats.funnel.finished / stats.funnel.owned) * 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** True when the oldest valuation is old enough that the total misleads. */
|
||||||
|
protected readonly valueIsStale = computed(() => {
|
||||||
|
const oldest = this.data()?.value.oldestValuedAt;
|
||||||
|
if (!oldest) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const age = (Date.now() - new Date(oldest).getTime()) / 86_400_000;
|
||||||
|
return age > STALE_AFTER_DAYS;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Share of the library the value total actually covers. */
|
||||||
|
protected readonly valueCoverage = computed(() => {
|
||||||
|
const stats = this.data();
|
||||||
|
if (!stats || stats.totalGames === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.round((stats.value.pricedCount / stats.totalGames) * 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { Component, computed, input } from '@angular/core';
|
||||||
|
|
||||||
|
import { CompletionFunnel } from '../../core/stats.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owned → Played → Finished.
|
||||||
|
*
|
||||||
|
* Each stage is a subset of the one before it, so the stages are ordered rather
|
||||||
|
* than distinct categories: an ordinal ramp of one hue, light to dark, carries
|
||||||
|
* that. A categorical palette here would imply the three are unrelated peers.
|
||||||
|
*
|
||||||
|
* Every stage is direct-labelled with its count and its share of the library,
|
||||||
|
* so the reading never depends on comparing bar lengths by eye.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-funnel',
|
||||||
|
template: `
|
||||||
|
<div class="stages">
|
||||||
|
@for (stage of stages(); track stage.key) {
|
||||||
|
<div class="stage">
|
||||||
|
<div class="head">
|
||||||
|
<span class="name">{{ stage.name }}</span>
|
||||||
|
<span class="figures">
|
||||||
|
<strong>{{ stage.count }}</strong>
|
||||||
|
<span class="share">{{ stage.share }}%</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="track">
|
||||||
|
<div
|
||||||
|
class="fill"
|
||||||
|
[style.width.%]="stage.width"
|
||||||
|
[style.background]="stage.color"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
@if (stage.drop !== null) {
|
||||||
|
<p class="drop">{{ stage.drop }} never {{ stage.dropVerb }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
.stages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--viz-ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.figures {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figures strong {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--viz-ink-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--viz-ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--viz-track);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0 0.25rem 0.25rem 0;
|
||||||
|
min-width: 2px;
|
||||||
|
transition: width 240ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop {
|
||||||
|
margin: 0.375rem 0 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--viz-ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fill {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class Funnel {
|
||||||
|
readonly data = input.required<CompletionFunnel>();
|
||||||
|
|
||||||
|
protected readonly stages = computed(() => {
|
||||||
|
const { owned, played, finished } = this.data();
|
||||||
|
const base = Math.max(owned, 1);
|
||||||
|
const share = (n: number) => Math.round((n / base) * 100);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'owned',
|
||||||
|
name: 'Owned',
|
||||||
|
count: owned,
|
||||||
|
share: share(owned),
|
||||||
|
width: 100,
|
||||||
|
// Ordinal ramp, light to dark. Steps validated against this app's own
|
||||||
|
// card surfaces in both themes.
|
||||||
|
color: 'var(--viz-step-1)',
|
||||||
|
drop: null as number | null,
|
||||||
|
dropVerb: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'played',
|
||||||
|
name: 'Played',
|
||||||
|
count: played,
|
||||||
|
share: share(played),
|
||||||
|
width: (played / base) * 100,
|
||||||
|
color: 'var(--viz-step-2)',
|
||||||
|
drop: owned - played,
|
||||||
|
dropVerb: 'played',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'finished',
|
||||||
|
name: 'Finished',
|
||||||
|
count: finished,
|
||||||
|
share: share(finished),
|
||||||
|
width: (finished / base) * 100,
|
||||||
|
color: 'var(--viz-step-3)',
|
||||||
|
drop: played - finished,
|
||||||
|
dropVerb: 'finished',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { MatButtonModule } from '@angular/material/button';
|
|||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
import { AuthService } from '../core/auth.service';
|
import { AuthService } from '../core/auth.service';
|
||||||
@@ -10,7 +11,14 @@ import { AuthService } from '../core/auth.service';
|
|||||||
/** App bar shared by the library and editor screens. */
|
/** App bar shared by the library and editor screens. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-toolbar',
|
selector: 'app-toolbar',
|
||||||
imports: [MatToolbarModule, MatButtonModule, MatIconModule, MatMenuModule, RouterLink],
|
imports: [
|
||||||
|
MatToolbarModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatMenuModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
RouterLink,
|
||||||
|
],
|
||||||
template: `
|
template: `
|
||||||
<mat-toolbar color="primary" class="toolbar">
|
<mat-toolbar color="primary" class="toolbar">
|
||||||
<a class="brand" routerLink="/games">
|
<a class="brand" routerLink="/games">
|
||||||
@@ -22,6 +30,11 @@ import { AuthService } from '../core/auth.service';
|
|||||||
|
|
||||||
<ng-content />
|
<ng-content />
|
||||||
|
|
||||||
|
<a mat-icon-button routerLink="/dashboard" aria-label="Collection stats"
|
||||||
|
matTooltip="Collection stats">
|
||||||
|
<mat-icon>insights</mat-icon>
|
||||||
|
</a>
|
||||||
|
|
||||||
<a mat-button routerLink="/games/new">
|
<a mat-button routerLink="/games/new">
|
||||||
<mat-icon>add</mat-icon>
|
<mat-icon>add</mat-icon>
|
||||||
<span class="label-md">New game</span>
|
<span class="label-md">New game</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user