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:
@@ -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],
|
||||
|
||||
@@ -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 { 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>
|
||||
|
||||
Reference in New Issue
Block a user