ckochandClaude Opus 5 ddc618fc9c 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>
2026-08-04 17:08:39 -04:00
2026-08-04 17:08:39 -04:00
2026-08-04 17:08:39 -04:00
2018-02-26 23:14:37 -05:00
2026-08-04 17:08:39 -04:00

LudosData

A personal video game library: catalogue what you own, what you've dumped, played and finished.

Originally built in 2018 on Angular 5 + PHP + MySQL. Rebuilt in 2026 on Angular 22 and ASP.NET Core 10 with SQLite, running in Docker.


Quick start

cp .env.example .env
# Generate a signing key and put it in .env as JWT_KEY:
openssl rand -base64 48
# Also set SEED_USERNAME / SEED_EMAIL / SEED_PASSWORD for the first account.

docker compose up --build

Then open http://localhost:8080 and sign in with the seed credentials.

On first run the API creates that account and imports the 105 games recovered from the 2018 database dump. Seeding only happens while the database has no users.

Password rules: 12+ characters, with an uppercase, a lowercase and a digit. The API refuses to start if JWT_KEY is missing or shorter than 32 characters — that is deliberate, so a misconfigured deployment fails loudly instead of signing tokens with a guessable key.


Layout

backend/          ASP.NET Core 10 Web API (C#)
  src/LudosData.Api/
    Domain/       Game, AppUser
    Data/         DbContext, migrations, seeder, games.json
    Auth/         JWT options, token service
    Controllers/  auth, games, images
    Services/     image storage
frontend/         Angular 22 SPA
  src/app/
    core/         models, services, guard, HTTP interceptor
    features/     login, register, game-grid, game-edit, account
    shared/       toolbar, confirm dialog
archive/          the original 2018 MySQL dump, for provenance

Everything stateful lives in one Docker volume (ludos-data): the SQLite file, uploaded box art, and the Data Protection keys. Back that volume up and you have backed up the whole application.


Development

Host tooling (Node 24, .NET 10) is installed via Homebrew. dotnet-ef needs ~/.dotnet/tools on PATH, which ~/.bashrc.d/dotnet.sh sets up.

# API on http://localhost:5099
cd backend/src/LudosData.Api
Jwt__Key="a-dev-key-of-at-least-32-characters!!" dotnet run

# SPA on http://localhost:4200, proxying /api and /uploads to :5099
cd frontend
npm start
cd frontend && npm test          # vitest
cd backend  && dotnet build      # 0 warnings expected

Cover art

tools/library/fetch_art.py fills in box art, pushing each image through the app's own POST /api/images so it gets the same validation and WebP re-encoding as a manual upload. Standard library only — no virtualenv, and neither source needs an account.

It tries two sources in order:

  1. libretro-thumbnails — scanned retail boxes, named to the No-Intro / Redump conventions. Best art where it has any, but its coverage is the retro consoles. Its Microsoft - Xbox 360 set exists but holds about a dozen entries.
  2. English Wikipedia — a cover on essentially every notable game article, which is what fills the Xbox 360 shelf. The exact filename is read from the article's infobox rather than guessed from file names, since filtering names for "box" also matches Xbox-360-Pro-wController.png. Calls are throttled to one per second and cached; the API returns 429 if pushed harder.
cd tools/library
python3 fetch_art.py --password '...' --dry-run      # report matches, change nothing
python3 fetch_art.py --password '...'                # download and attach
python3 fetch_art.py --password '...' --overwrite    # also replace existing art
python3 fetch_art.py --password '...' --no-wikipedia # libretro only

Always dry-run first; it prints every match with a similarity score, marks the source (W for Wikipedia), and flags anything below 0.95 for eyeballing.

Matching handles the gaps between a personal catalogue and a ROM-naming one: accents (Pokemon → Pokémon), roman numerals (our SNES Final Fantasy 2 is the catalogue's Final Fantasy II), trailing articles (Sims 2, The), missing subtitles in either direction, and outright typos — Brett Hull Hocky 95 finds Brett Hull Hockey 95. A sequel guard stops Donkey Kong Country 2 from silently taking Donkey Kong Country's box.

All 105 games currently have art: 93 from libretro, 12 from Wikipedia.

Two rows got art of the right game but the wrong platform, because the platform in the source data looks wrong — a Game Boy "Donkey Kong Country 2" (never released on that system; the handheld sequels were Donkey Kong Land) and a DS "Donkey Kong Country Returns" (a Wii game, later Returns 3D on 3DS). Fix the system field and re-run with --overwrite to correct them.

Art is publisher copyright. Fetching it for a private collection is ordinary practice for library software; redistributing it is a different question.

Metadata enrichment

tools/library/enrich_metadata.py fills developer, publisher, year and description from the same Wikipedia articles the cover fetcher locates. Only empty fields are touched unless --overwrite is passed — anything typed by hand outranks anything derived here.

cd tools/library
python3 enrich_metadata.py --password '...' --dry-run
python3 enrich_metadata.py --password '...'
python3 enrich_metadata.py --password '...' --fields developer,publisher

Coverage went from this to this:

Field Before After
year 79% 96%
developer 3.8% 95%
publisher 2.9% 96%
description 0% 96%

Developer, publisher and year are facts, written verbatim. Descriptions are article summaries, which are CC BY-SA, so each is stored with an attribution line naming the source article.

Parsing an infobox is messier than it looks, and the guards matter:

  • Series articles are rejected. A substring test for Infobox video game also matches Infobox video game series, which resolved Banjo-Kazooie to the series overview instead of the 1998 game.
  • Year is only filled when the article covers that platform. An article spans every release, and its date block leads with the original — so a DS port would otherwise be dated to the SNES original.
  • A search hit that neither covers the platform nor closely matches the title is discarded. Our "Dragon Ball Z Budokai" surfaces "Dragon Ball Z: Shin Budokai", a different game on a different console. A blank field beats a confidently wrong one.
  • Platform headings ('''PlayStation'''), region codes (JP, NA), trailing platform annotations (Rare (N64)) and named template parameters (title=) are all stripped, since each one otherwise reads as the value itself.

Four games have no usable article: a typo'd title (Brett Hull Hocky 95), Dragon Ball Z Budokai, and two niche releases.

Export and import

Until this existed, the only backup was the Docker volume.

  • GET /api/library/export?format=json|csv
  • POST /api/library/import?mode=Merge|Replace&dryRun=true (multipart file)

Both are in the UI on the account page. JSON round-trips exactly and is the right choice for a backup; CSV opens in a spreadsheet and is written with a BOM so Excel does not mangle Pokémon. Box art images are not bundled — they live in the upload volume, and a library imported into a fresh instance will reference art that is not there until the fetcher runs again.

Rows are matched on title + system, so the same game on three consoles stays three entries. Merge adds and updates but never deletes; Replace wipes the library first and is confirmed twice in the UI. dryRun reports exactly what would happen and writes nothing.

Collector fields

Beyond the four original flags, each game carries rating (1-10), notes, condition, region, what you paid and when, and a current market value.

condition is not cosmetic: price feeds quote per condition, and the gap between loose and sealed is routinely a multiple, so it selects which quoted price applies to a copy.

Market value is stored with when it was captured and where it came from. A figure with neither is not something you can reason about, and a collection total is only as good as its staleness. Editing an unrelated field leaves the timestamp alone; changing the figure moves it. A future price feed writes the same three columns.

Money is stored as integer minor units. SQLite has no decimal type, and EF Core's default maps decimal to TEXT, which compares lexically — "9.00" sorts above "10.00", and SUM does not work at all. A value converter keeps the C# side as decimal while ordering and totalling behave.

Enums travel as names, not ordinals. "Cib" is self-describing in a payload, an export and a log line; 2 is not, and renumbering the enum would silently reinterpret every stored export.

New query parameters: condition, region, minRating, hasValue. New sort keys: rating, value, price, purchased.

Market value

Three routes, deliberately independent, because each is blocked differently.

Route Blocked by Cost Basis
CSV price guide nothing free whatever you supply
PriceCharting nothing — immediate on subscribing paid sale-derived, per condition
eBay Browse production keyset needs account verification free asking prices, reads high
Manual entry nothing free your own judgement
GET  /api/prices/status                       which sources are usable
POST /api/prices/refresh {provider, dryRun}   price from a live source
POST /api/prices/import  (multipart CSV)      apply a price list

The CSV route needs no account and works today. Column names are matched by alias, so product-name / console-name / loose-price from a PriceCharting export and a hand-kept title,system,loose,cib,new sheet are both accepted, as are $, thousands separators and blank cells. Rows are matched on title + system, so the same game on two consoles is priced separately; rows for games you do not own are reported rather than added.

Free sources that do not work for this: eBay's completed-sales data sits behind the Marketplace Insights API, a limited release closed to new developers; NEXARDA and CheapShark price current retail and digital storefronts, not collectibles. Scraping PriceCharting violates their terms.

Deriving a price from eBay listings takes more than an average:

  • Listings are classified into loose / CIB / new from their titles, since a mixed feed has no single price. Accessories are discarded — a "box only" listing at $45 counted as a copy would halve the loose estimate for a $130 cartridge — as are reproductions and multi-game lots.
  • The discard qualifier is required. An early version matched a bare "box", which threw away "complete in box" and "with box and manual" — most of the CIB tier — while keeping exactly the listings the filter existed to remove. Caught by a test asserting on tiers rather than counts.
  • Median with an interquartile trim. One optimist asking 50x moves a mean and not a median.
  • Sample counts travel with the estimate, because a tier drawn from two listings deserves less confidence than one drawn from thirty.

Three prices are stored per game, and marketValue is whichever tier matches that copy's condition — so changing a condition re-prices it with no further lookup. Every value carries the source that wrote it and the moment it was captured.

Using PriceCharting. Subscribe, take the token from the Subscriptions page ("API/Download"), and set PRICECHARTING_TOKEN in .env. Their API access comes with a paid subscription; the bulk CSV download is limited to their top tier, so check which tier you need before subscribing — this integration does per-game lookups and only needs the API.

Run a dry run first:

curl -X POST localhost:8080/api/prices/refresh \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"provider":"pricecharting","dryRun":true,"limit":10}'

The response reports which product each game matched — name, console and id — alongside the prices. That matters more than it sounds: a lookup for the DS "Chrono Trigger" that quietly resolves to the SNES original returns entirely plausible numbers for the wrong game. Check the matches, then run without dryRun.

Once a game is priced, the matched product id is stored and later refreshes look it up directly, so they are cheaper and cannot drift to a different edition.

Their published API docs are not reachable without an account, so the response 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

cd backend/src/LudosData.Api
dotnet ef migrations add <Name> --output-dir Data/Migrations

Migrations are applied automatically at startup.


API

All /api/games and /api/images routes require Authorization: Bearer <token>.

Method Route Notes
POST /api/auth/register Returns a token; signs the new user straight in
POST /api/auth/login Returns { token, expiresAt, user }
GET /api/auth/me Current user
GET /api/auth/available?userName= / ?email= Returns only a boolean
GET /api/games search, system, genre, own, dumped, played, finished, page, pageSize, sort, dir
GET /api/games/{id}
GET /api/games/facets Distinct systems and genres, for filter dropdowns
POST /api/games
PUT /api/games/{id}
DELETE /api/games/{id}
POST /api/images multipart file; re-encodes to WebP
GET /health Anonymous

Ownership is always taken from the JWT subject, never from the request. A game belonging to another user returns 404, not 403, so the response does not confirm that the id exists.


Security notes

Rotate the old database password

The 2018 code committed live MySQL credentials to this repository (interfaceServices/dbConfig.php, and again in four other files). They are in git history. That password must be considered compromised and rotated, regardless of this rewrite. Removing the files does not remove them from history.

The new stack keeps secrets in .env, which is gitignored.

What was fixed in the rewrite

The old backend was ~16,700 lines of PHP, of which ~16,400 were vendored third-party code — four near-identical copies of php-crud-api plus class.upload.php. Only ~150 lines were application logic. These problems were not carried across:

Old behaviour Now
Two endpoints exposed unauthenticated CRUD over every table Every data route requires a valid token
Client chose whose rows to read (filter[]=userId,eq,N) Owner comes from the JWT subject, server-side
Login hardcoded to a single username Any registered user can sign in
crypt() with one global salt, silently truncating passwords to 8 chars ASP.NET Core Identity (PBKDF2, per-user salt)
JWT secret was the literal string "testing", tokens never expired Key required from config, 12-hour expiry
Token passed in the query string Authorization: Bearer header
Uploads anonymous, path built from the client filename Authenticated, server-generated name, per-user folder, must decode as an image
Access-Control-Allow-Origin: * Explicit origin allowlist

Passwords could not be migrated — the old hashes are unrecoverable by design.

Known accepted risk

npm audit reports a moderate advisory in @hono/node-server, reached transitively through @angular/cli's MCP server feature. It is:

  • dev-only — npm audit --omit=dev reports 0 vulnerabilities, and it is not in the browser bundle
  • a Windows-only path traversal, on a Linux-only toolchain here

npm audit fix --force would downgrade Angular CLI to 21.0.4, a breaking change. Overriding the dependency means forcing a major bump the MCP SDK does not accept (^1.19.9). Left as-is deliberately; revisit when Angular CLI updates the SDK.


Notable version facts (as of 2026-08)

  • Angular 22.1 is zoneless — there is no zone.js in the dependency tree. Component state must be signal-based for change detection to see it.
  • @angular/animations is deprecated in v22; Material 22 no longer depends on it. There is no provideAnimations() in app.config.ts, and a test pins that Material still renders without one.
  • Unit tests run on Vitest, not Karma/Jasmine.
  • Fonts and Material icons are bundled from node_modules, so the app makes no third-party requests at runtime.
  • The backend pins two transitive packages (Microsoft.OpenApi, SQLitePCLRaw.lib.e_sqlite3) to clear high-severity advisories. See the comment in LudosData.Api.csproj.
  • Image processing uses SkiaSharp, not ImageSharp: ImageSharp v4 requires a paid licence key at build time.
S
Description
Game Management Application
Readme
575 KiB
Languages
C# 61.5%
TypeScript 15.9%
Python 10%
HTML 7.9%
SCSS 4%
Other 0.7%