PokéVault: what my Pokémon card collection is actually worth (and why it's public again today)
Project Tech Sheet
Month
Month 3
Project
PokéVault
Tech Stack
AI Tools
I've had Pokémon cards since I was a kid, and for the past couple of years I've been buying packs and singles again, for real this time. The problem is the usual one: a spreadsheet I stop updating after two weeks, and no fast way to know what I'm missing from a given set without going through the binder card by card.
For project #3 of the challenge I wanted to solve exactly that: sync the set I care about, mark what I already have in bulk, and always know what the collection is actually worth — not a rough guess, but added up card by card with a real reference price.
PokéVault: don't search card by card when you already have the whole set in front of you.
What does PokéVault do?
It's a personal app with four screens — My Collection, Plan, Wishlist and Summary — and inside My Collection, three tabs: Sets, Cards and Sealed. Same hexagonal architecture as its siblings in the challenge, a real Postgres database, card catalog via TCGdex.
- Set checklist: search for a set (in English or Spanish), pick it from a real list of candidates — never guessed — and it downloads with a Cardmarket reference price card by card.
- Custom binders: every card of a specific Pokémon, from any expansion it has ever appeared in, without syncing each full set.
- Mark several cards as owned in bulk straight from the checklist, with photo and price already filled in.
- Summary: total value, amount invested, estimated gain or loss — and a separate note for anything without a known value yet (never silently counted as €0).

One shared catalog, not one per user
After moving to multi-user, each account synced its own set independently — if two people added the same real set, each one downloaded its own full copy of the cards. That wasted storage and, more importantly, real calls to TCGdex that should only happen once per set, not once per user.
The fix splits the catalog from the pointer: `set_catalogs` stores a real set's cards, prices and images ONCE for the whole instance; each user only has a lightweight row (`set_checklists`) pointing at that catalog, with their own alias. When syncing, if the catalog already exists (another user brought it in), only the pointer gets added — no call to the provider. I verified it for real with two users syncing the same 99-card set: the second one took ~20ms versus several seconds for the first, and both checklist rows ended up pointing at the same catalog, with 147 card rows stored — not 294.

Real bug: the same card marked owned in two places at once
A binder brings together one Pokémon across every expansion it has appeared in; if that expansion is also synced as a separate official set, the same real card shows up in both lists. And since "what do I have from this set" was computed by matching card number against the ENTIRE collection, regardless of which checklist it had been marked from, marking a card as owned in one place marked it owned in the other, too — real case: Slowbro and Slowpoke from "Absol Peaks" (Oscuridad Absoluta), present in both the official set and a binder dedicated to those two Pokémon.
Fixed by adding a `checklist_id` column to each item: when it's set, ownership requires an exact match with the checklist currently being viewed. Older items, without that field, still fall back to the same text-matching as before — there's no way to know retroactively which checklist they belonged to. Covered with a domain test and an E2E test reproducing the exact case: two cards sharing the same number and variant, one in an official set and one in a binder.
AI photo recognition: tried, and deliberately removed
There was a phase with AI card recognition from a photo (Ollama running locally, with vision) for manual entries. It worked, but I removed it entirely on the day I prepared the app to deploy to a real server: Ollama runs on my Mac, not on a normal server, so the app would have behaved differently locally than in production. Rather than keep a feature that only worked on my own machine, I cut it completely. What's left is the photo as an attached image (never analyzed) and a "Search catalog" button using whatever you type by hand — the same search as always, now triggered manually instead of automatically.
Real multi-user accounts (and the "remember me" it was missing)
Login went from optional to mandatory with real accounts: every row in every table now carries a `user_id`, and every repository is built already scoped to that user (`findById`/`update`/`remove` included) so no one can read or touch another account's data by guessing an id. Migrated in two steps so existing data wouldn't break: first the column allows nulls, a one-off script assigns every orphan row to the original account, and only then is `NOT NULL` enforced.
This same week I added the last thing the day-to-day login was missing: a "Remember me on this device" checkbox. Checked (the default), the session token lives in `localStorage` and survives closing the browser, same as always. Unchecked, it goes to `sessionStorage` and is gone once you close it — meant for a device that isn't only yours.

What went wrong getting it ready to show again
The repo had been private since July, so making it public again wasn't just flipping a switch:
- Auditing the ENTIRE commit history, not just the current state, looking for any `.env`, key or real password that might have slipped in at some point — there was none, but it had to be checked, not assumed.
- 9 dependency vulnerabilities (2 high) fixed with `npm audit fix`, with no behavior changes.
- The auto-deploy I'd just set up was actually broken: the server's git remote pointed at a stale local copy instead of GitHub, left over from an earlier deploy method — and being a private repo, it needed its own read-only access key, separate from the challenge's public projects.
- Migrations were failing in production because `DATABASE_URL` couldn't be found: the project deliberately loads `.env` with Node's native API (no `dotenv` package), but the migration tool didn't know that and had to be told explicitly.
- A test failed the second time the full suite ran: a fixed test user that never got cleaned up between full runs, only between individual tests within the same run.
The code is public. My collection isn't.
That's why the "View Project" button on this card links to the GitHub repo, not a live demo: a real demo would expose exactly what I paid for each card and what the whole collection is worth, and that's not a number I want posted online next to my name. The repo had already been public before, as a portfolio piece; it went private for a while while I explored turning it into something more; and today it's public again — with the same care as always: screenshots with demo data, never my real collection.
View the PokéVault repo on GitHub
Building in public doesn't mean showing everything — it means showing the code and the decisions, not necessarily how much you've spent on cardboard.
Next month: whatever ends up feeling more urgent by then. Shall we?