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>
Rating, notes, condition, region, purchase price and date, plus a market
value carrying the timestamp and source that make it interpretable.
Condition is load-bearing rather than cosmetic: price feeds quote per
condition, so it selects which quoted price applies to a copy. Market value
records when it was captured and where it came from — a collection total is
only as good as its staleness — and an edit to an unrelated field leaves
that timestamp alone, so a stale price cannot start looking freshly checked.
Two storage decisions worth naming:
* Money is stored as integer minor units. SQLite has no decimal type and
EF Core maps decimal to TEXT, which compares lexically: "9.00" sorts
above "10.00" and SUM is unavailable. A value converter keeps decimals
in C# while ordering and totalling work. A test pins the ordering.
* Enums serialise as names. The default is ordinals, which meant the API
rejected the browser's {"condition":"Cib"} with a 400 while the C# tests
passed, because they round-tripped ints and never spoke the client's
dialect. The tests now share the API's serializer options.
Also fixes a data-loss bug in the Python tools. Both built their PUT body
from a hardcoded list of field names, so any column added to the model was
omitted and therefore nulled. Adding collector fields meant the next art or
enrichment run would have erased every rating, note, condition, price and
valuation in the library. Payloads are now built by excluding the handful of
server-owned fields, so new columns carry through by default.
The migration was rehearsed against a copy of the live database before being
applied: 105 rows, descriptions and developers intact.
67 backend tests, 8 frontend.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The only backup was the Docker volume. Export writes the caller's whole
library as JSON or CSV; import reads either back, into the same account or
a different one.
Rows are matched on title + system rather than id, so a file is portable
between accounts and instances, and the same game on three consoles stays
three entries. Merge adds and updates but deletes nothing. Replace wipes
first, and is gated behind an explicit confirm dialog in the UI. dryRun
reports what would happen and writes nothing.
CSV is hand-rolled rather than pulling a dependency, but handles the parts
that actually bite: quoted fields containing commas, escaped quotes,
embedded newlines and CRLF endings. That is not hypothetical here — 101 of
the 105 descriptions contain newlines, and two titles contain accents, so a
naive split-on-comma would corrupt most of the library. Exports carry a BOM
so Excel reads them as UTF-8.
Verified against the real library, not just fixtures: 105 games exported to
CSV, imported into a scratch account and re-exported compare identical
field for field.
15 new tests cover round-trip fidelity, merge vs replace, dry run,
per-user isolation on the destructive path, malformed input, and the
awkward-quoting case. 51 backend tests total.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tools/cover-art/fetch_art.py matches each game against libretro-thumbnails
by title + system and attaches the result through the app's own
POST /api/images, so fetched art goes through the same validation and WebP
re-encoding as a manual upload. Standard library only.
Matching bridges a personal catalogue and a ROM-naming one:
* accents stripped, so "Pokemon Yellow" reaches "Pokémon"
* roman numerals folded to digits, so the SNES "Final Fantasy 2" lands on
"Final Fantasy II" and the PS1 "Final Fantasy V" on its own entry
* trailing articles unwound ("Sims 2, The" -> "The Sims 2")
* subtitle containment in both directions, since our rows sometimes omit
what the catalogue carries ("Wave Race 64" vs "... - Kawasaki Jet Ski")
and sometimes carry what it omits ("Donkey Kong Country 2: Diddy's Kong
Quest" vs the GBA set's "Donkey Kong Country 2")
* a sequel guard, so containment cannot collapse "Donkey Kong Country 2"
onto "Donkey Kong Country"
* fuzzy enough to absorb typos: "Brett Hull Hocky 95" finds "Hockey 95"
93 of 105 games now have art. The remainder: 10 Xbox 360 titles, which
libretro has no thumbnail set for, and two rows whose platform looks wrong
in the source data (a Game Boy "Donkey Kong Country 2", which was never
released on that system, and a DS "Donkey Kong Country Returns", which was
Wii and later 3DS).
Real art also invalidated a layout assumption: the grid used object-fit:
cover, which was fine for uniform placeholders but crops actual boxes, whose
aspect ratios run from near-square SNES to tall N64. Switched the grid and
the editor preview to object-fit: contain so the whole cover is visible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Angular's production build defers the main stylesheet with
`media="print" onload="this.media='all'"` and inlines a critical subset
ahead of it. The nginx CSP sets `script-src 'self'`, which blocks that
inline event handler — so the swap never ran and the stylesheet stayed
print-only. The app rendered from the ~23kB critical subset alone.
Most of the page still looked right, which is what made it easy to miss.
Material icons did not: `.material-icons` was not in the critical subset,
so every icon fell back to the body font and rendered its ligature name
("videogame_asset") clipped to the icon box.
`inlineCritical: false` emits a plain <link rel="stylesheet">. The
stylesheet is 24kB and same-origin, so the optimisation bought little and
cost correctness under a strict CSP.
Verified in headless Chromium: icons render as glyphs across the toolbar,
grid, editor and mobile layouts, and the console is now clean where it
previously logged four CSP violations per page load.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 2018 stack (Angular 5.2 / CLI 1.7, PHP, MySQL) had not been touched since
July 2018. Rebuilt rather than upgraded in place: the frontend was 17 major
versions behind, and of ~16,700 lines of PHP only ~150 were application logic —
the rest was four near-identical vendored copies of php-crud-api plus
class.upload.php.
Backend — ASP.NET Core 10, EF Core, SQLite
* ASP.NET Core Identity (PBKDF2) + JWT bearer auth
* Clean REST API replacing php-crud-api's filter[]/transform query syntax
* Box art uploads re-encoded to WebP via SkiaSharp
* Imports the 105 games recovered from the 2018 dump on first run
Frontend — Angular 22, zoneless, signals, Material 22
* Standalone components, lazy routes, functional guards and interceptor
* Vitest replaces Karma/Jasmine; fonts and icons bundled, no CDN calls
* No provideAnimations: @angular/animations is deprecated in v22 and
Material no longer depends on it (pinned by a test)
Docker
* Multi-stage builds for both services, non-root at runtime
* nginx serves the SPA and reverse-proxies the API, so everything is
same-origin; one volume holds the database, uploads and DP keys
Security issues in the old code, not carried across:
* Two endpoints exposed unauthenticated CRUD over every table
* The client chose whose rows to read (filter[]=userId,eq,N); ownership now
comes from the JWT subject server-side
* Login was hardcoded to a single username
* crypt() with one global salt, silently truncating passwords to 8 chars
* JWT secret was the literal string "testing", tokens never expired
* Token travelled in the query string rather than a header
* Uploads were anonymous with the path built from the client filename
* Access-Control-Allow-Origin: *
The live MySQL password committed in 2018 remains in git history and must be
rotated independently of this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>