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
+6
View File
@@ -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],
+52
View File
@@ -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',
},
];
});
}
+14 -1
View File
@@ -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: `
<mat-toolbar color="primary" class="toolbar">
<a class="brand" routerLink="/games">
@@ -22,6 +30,11 @@ import { AuthService } from '../core/auth.service';
<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">
<mat-icon>add</mat-icon>
<span class="label-md">New game</span>