From ddc618fc9ce097819122376ea3dc76a730b696fa Mon Sep 17 00:00:00 2001 From: Christopher Koch Date: Tue, 4 Aug 2026 17:08:39 -0400 Subject: [PATCH] Add the collection dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 34 +++ .../LudosData.Api/Contracts/StatsContracts.cs | 44 +++ .../Controllers/StatsController.cs | 138 ++++++++++ .../tests/LudosData.Api.Tests/StatsTests.cs | 164 +++++++++++ frontend/src/app/app.routes.ts | 6 + frontend/src/app/core/stats.service.ts | 52 ++++ .../src/app/features/dashboard/bar-list.ts | 112 ++++++++ .../src/app/features/dashboard/dashboard.html | 171 ++++++++++++ .../src/app/features/dashboard/dashboard.scss | 258 ++++++++++++++++++ .../src/app/features/dashboard/dashboard.ts | 85 ++++++ frontend/src/app/features/dashboard/funnel.ts | 150 ++++++++++ frontend/src/app/shared/toolbar.ts | 15 +- 12 files changed, 1228 insertions(+), 1 deletion(-) create mode 100644 backend/src/LudosData.Api/Contracts/StatsContracts.cs create mode 100644 backend/src/LudosData.Api/Controllers/StatsController.cs create mode 100644 backend/tests/LudosData.Api.Tests/StatsTests.cs create mode 100644 frontend/src/app/core/stats.service.ts create mode 100644 frontend/src/app/features/dashboard/bar-list.ts create mode 100644 frontend/src/app/features/dashboard/dashboard.html create mode 100644 frontend/src/app/features/dashboard/dashboard.scss create mode 100644 frontend/src/app/features/dashboard/dashboard.ts create mode 100644 frontend/src/app/features/dashboard/funnel.ts diff --git a/README.md b/README.md index 699d754..a4adf0d 100644 --- a/README.md +++ b/README.md @@ -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 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 ```bash diff --git a/backend/src/LudosData.Api/Contracts/StatsContracts.cs b/backend/src/LudosData.Api/Contracts/StatsContracts.cs new file mode 100644 index 0000000..6a12377 --- /dev/null +++ b/backend/src/LudosData.Api/Contracts/StatsContracts.cs @@ -0,0 +1,44 @@ +namespace LudosData.Api.Contracts; + +public record CountByLabel(string Label, int Count); + +/// +/// The owned → played → finished progression. Each stage is a subset of the one +/// before it, so the numbers only make sense read in order. +/// +public record CompletionFunnel(int Owned, int Played, int Finished); + +/// +/// 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. +/// +public record ValueSummary( + decimal Total, + int PricedCount, + int UnpricedCount, + decimal TotalPaid, + int PaidCount, + DateTimeOffset? OldestValuedAt, + DateTimeOffset? NewestValuedAt, + IReadOnlyList Sources, + /// What the collection would be worth if every copy were complete in box. + decimal? TotalIfCib); + +public record StatsResponse( + int TotalGames, + CompletionFunnel Funnel, + int Backlog, + int InProgress, + int Dumped, + int RatedCount, + double? AverageRating, + IReadOnlyList BySystem, + IReadOnlyList ByGenre, + IReadOnlyList ByDecade, + IReadOnlyList ByCondition, + /// Rating distribution, 1-10. Only scores actually used appear. + IReadOnlyList ByRating, + ValueSummary Value); diff --git a/backend/src/LudosData.Api/Controllers/StatsController.cs b/backend/src/LudosData.Api/Controllers/StatsController.cs new file mode 100644 index 0000000..c5d3447 --- /dev/null +++ b/backend/src/LudosData.Api/Controllers/StatsController.cs @@ -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; + +/// +/// 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. +/// +[ApiController] +[Route("api/stats")] +[Authorize] +public class StatsController(LudosDbContext db) : ControllerBase +{ + [HttpGet] + public async Task> 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 Rank(List games, Func 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 ByDecade(List games) + { + var decades = new Dictionary(); + + 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", + }; +} diff --git a/backend/tests/LudosData.Api.Tests/StatsTests.cs b/backend/tests/LudosData.Api.Tests/StatsTests.cs new file mode 100644 index 0000000..351487f --- /dev/null +++ b/backend/tests/LudosData.Api.Tests/StatsTests.cs @@ -0,0 +1,164 @@ +using System.Net; +using LudosData.Api.Contracts; + +namespace LudosData.Api.Tests; + +public class StatsTests(LudosApiFactory factory) : IClassFixture +{ + 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("/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("/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("/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("/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("/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("/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("/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("/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("/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); + } +} diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 70be428..feb5120 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -21,6 +21,12 @@ export const routes: Routes = [ title: 'Create account · LudosData', 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', canActivate: [authGuard], diff --git a/frontend/src/app/core/stats.service.ts b/frontend/src/app/core/stats.service.ts new file mode 100644 index 0000000..ed5bff6 --- /dev/null +++ b/frontend/src/app/core/stats.service.ts @@ -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 { + return this.http.get('/api/stats'); + } +} diff --git a/frontend/src/app/features/dashboard/bar-list.ts b/frontend/src/app/features/dashboard/bar-list.ts new file mode 100644 index 0000000..39b7db6 --- /dev/null +++ b/frontend/src/app/features/dashboard/bar-list.ts @@ -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: ` +
+ @for (row of rows(); track row.label) { +
+ {{ row.label }} + + + + {{ row.count }} +
+ } +
+ `, + 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(); + + /** Cap the list; the tail of a long-tailed breakdown is noise on a dashboard. */ + readonly limit = input(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, + })); + }); +} diff --git a/frontend/src/app/features/dashboard/dashboard.html b/frontend/src/app/features/dashboard/dashboard.html new file mode 100644 index 0000000..9157f3a --- /dev/null +++ b/frontend/src/app/features/dashboard/dashboard.html @@ -0,0 +1,171 @@ + + +@if (loading()) { + +} + +@if (failed()) { +
+ cloud_off +

Could not load your collection stats

+

The server did not respond. Check that the API is running, then reload.

+
+} @else if (data(); as stats) { + @if (stats.totalGames === 0) { +
+ insights +

Nothing to chart yet

+

Add a few games and this page will fill in.

+ Add a game +
+ } @else { +
+ +
+
+ Games + {{ stats.totalGames | number }} + {{ stats.bySystem.length }} systems +
+ +
+ Finished + {{ completionRate() }}% + {{ stats.funnel.finished }} of {{ stats.funnel.owned }} owned +
+ + + Backlog + {{ stats.backlog | number }} + owned, never played + + + + In progress + {{ stats.inProgress | number }} + played, not finished + +
+ +
+ + +

Completion

+

Each stage is a subset of the one above it.

+ +
+ + + +

Collection value

+ + @if (stats.value.pricedCount === 0) { +
+ sell +

No games are priced yet.

+

+ Import a price guide from the account page, or add a value to a game + by hand. +

+
+ } @else { +

{{ stats.value.total | currency: 'USD' : 'symbol' : '1.0-0' }}

+ +
+
+
Coverage
+
+ {{ stats.value.pricedCount }} of {{ stats.totalGames }} games + ({{ valueCoverage() }}%) +
+
+ @if (stats.value.paidCount > 0) { +
+
Paid
+
+ {{ stats.value.totalPaid | currency: 'USD' : 'symbol' : '1.0-0' }} + across {{ stats.value.paidCount }} games +
+
+ } + @if (stats.value.totalIfCib !== null) { +
+
If all complete
+
{{ stats.value.totalIfCib | currency: 'USD' : 'symbol' : '1.0-0' }}
+
+ } +
+
Source
+
{{ stats.value.sources.join(', ') || 'unknown' }}
+
+
+ + @if (stats.value.unpricedCount > 0) { +

+ info_outline + + {{ stats.value.unpricedCount }} + {{ stats.value.unpricedCount === 1 ? 'game is' : 'games are' }} unpriced, + so the real total is higher. + +

+ } + + @if (valueIsStale()) { + +

+ schedule + + Oldest valuation is from + {{ stats.value.oldestValuedAt | date: 'mediumDate' }}. Prices move; + refresh before trusting this figure. + +

+ } + } +
+ + + +

By system

+

{{ stats.bySystem.length }} systems

+ +
+ + +

By genre

+

{{ stats.byGenre.length }} genres

+ +
+ + @if (stats.byDecade.length) { + +

By decade

+

Release decade, where a year is recorded

+ +
+ } + + +

Condition

+

How your copies are kept

+ +
+ + @if (stats.ratedCount > 0) { + +

Ratings

+

+ {{ stats.ratedCount }} of {{ stats.totalGames }} rated · average + {{ stats.averageRating }} / 10 +

+ +
+ } +
+
+ } +} diff --git a/frontend/src/app/features/dashboard/dashboard.scss b/frontend/src/app/features/dashboard/dashboard.scss new file mode 100644 index 0000000..72b6f99 --- /dev/null +++ b/frontend/src/app/features/dashboard/dashboard.scss @@ -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 — 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; + } +} diff --git a/frontend/src/app/features/dashboard/dashboard.ts b/frontend/src/app/features/dashboard/dashboard.ts new file mode 100644 index 0000000..918d5d3 --- /dev/null +++ b/frontend/src/app/features/dashboard/dashboard.ts @@ -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(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); + }); +} diff --git a/frontend/src/app/features/dashboard/funnel.ts b/frontend/src/app/features/dashboard/funnel.ts new file mode 100644 index 0000000..132e57d --- /dev/null +++ b/frontend/src/app/features/dashboard/funnel.ts @@ -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: ` +
+ @for (stage of stages(); track stage.key) { +
+
+ {{ stage.name }} + + {{ stage.count }} + + +
+
+
+
+ @if (stage.drop !== null) { +

{{ stage.drop }} never {{ stage.dropVerb }}

+ } +
+ } +
+ `, + 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(); + + 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', + }, + ]; + }); +} diff --git a/frontend/src/app/shared/toolbar.ts b/frontend/src/app/shared/toolbar.ts index b71fa4a..522da81 100644 --- a/frontend/src/app/shared/toolbar.ts +++ b/frontend/src/app/shared/toolbar.ts @@ -3,6 +3,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { Router, RouterLink } from '@angular/router'; 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. */ @Component({ selector: 'app-toolbar', - imports: [MatToolbarModule, MatButtonModule, MatIconModule, MatMenuModule, RouterLink], + imports: [ + MatToolbarModule, + MatButtonModule, + MatIconModule, + MatMenuModule, + MatTooltipModule, + RouterLink, + ], template: ` @@ -22,6 +30,11 @@ import { AuthService } from '../core/auth.service'; + + insights + + add New game