Overview
CardHerder is a cloud-backed web app that runs a sports and trading card vendor's business: inventory, pricing, deals at the table, pack openings, and whether each show actually made money. It runs on a phone, a laptop, or a Chromebook at the same table at the same time, with every device looking at the same live data.
Most card-show vendors run their business out of a spreadsheet, and the spreadsheet breaks down exactly where the money gets interesting. It can't take a "four cards for $60" bundle discount and spread it honestly back across four different cost bases. It can't tell you whether a show cleared its table fee once you count the gas and the cards you bought while you were there. It won't warn you that the price you're about to write on a tag is based on a comp from March. And it can't be open on a phone and a laptop at the same table without one of them losing edits.
CardHerder models the business the way a vendor actually thinks about it: shows, deals, rips and comps, not rows and columns. All the money math lives in a tested domain layer, so bundle allocation, trade valuation and pack-cost splitting come out the same every time and always add up to the exact cent. The UI is built for a convention hall: dark by default for bad lighting, big touch targets for one-handed use, and a capture flow that takes a photo and a price and gets out of the way so you can keep talking to the customer.
Technology Stack
Chosen to run for close to nothing on a small business's budget, deploy from a git push,
and keep every secret off the client.
npm run check.Architecture
The rule that shapes the codebase: the arithmetic that decides what a card earned has exactly one implementation, and it's the tested one. The domain layer has no DOM, no Node, and no React, so the money math is tested in isolation, without a browser or a database.
main.The monorepo layout. The UI stays thin; the logic that matters lives once, in core.
Two Decisions That Shape Everything
A card identity is (year, set, player, number, parallel). An item is your physical copy, with its own grade, cost basis, location and status. Comps attach to (identity, grade), so every copy you've ever owned feeds one shared price history, and that history survives after every copy has sold.
Acquisitions, sales and trades are immutable events; a card's current value is derived from
them. Two people ringing up sales at the same table can't collide, because there is no
mutable "sold price" field to fight over. Deletion is always soft, and there is deliberately
no DELETE policy in the database at all.
It Used to Be Offline-First, and Isn't Anymore
The first design gave every device its own SQLite database in the browser's private file system (OPFS) and reconciled them with a sync engine, so the app would keep working at a table with no signal. It was the right instinct and the wrong tradeoff, and the reason only became clear once it was running in the field.
Before: local database + sync engine
- The app still needed the network to start. A cold load (closed tab, restarted phone, or the OS reclaiming a backgrounded tab when you switch to the camera) always fetched the app itself.
- Offline-first protected one narrow case at the cost of a whole reconciliation layer under everything.
- That layer was where the bugs lived: a retry loop hammering storage for photos that were never coming, a column that existed on one side of the sync and not the other, photos stuck between "taken" and "uploaded."
After: cloud-first, honest about it
- Postgres is the database, queried directly. "Sync" is no longer a concept the app has.
- A photo is in the bucket before its row is written. There is no state in which a record points at an image that isn't there. One ordering rule replaced an entire state machine.
- With no connection the app says so, rather than quietly queueing. A service worker now caches the content-hashed bundle for speed only, and makes no attempt to pretend to be a second database.
Core Features
The navigation is organized around the vendor's day: an overview, what happens at the table, and the upkeep that happens the night before a show.
Dashboard
Realized vs. unrealized profit kept strictly apart, 30-day figures, underwater and sitting-too-long cards, the grading shelf, rip outcomes, and a live panel for the active show.
Inventory Shelves
Cards live on Stock (for sale), Long-Term, Personal Collection, or Grading. Every shelf counts toward cost and value; only Stock is sold. Search, filters, and sorts like "oldest comp."
Deals
One screen for a whole table negotiation: sell, buy, or trade. Your pile, their pile, and cash bridging the gap, with the direction of the cash worked out rather than chosen.
Rips
Opening product. Record each hit with just a photo; the box's cost splits evenly now and is re-weighted by comp as those cards get detailed later.
Comp Check Queue
Never-comped first, then oldest. The day before a show it narrows to Stock and badges the nav, so every card on the table has a fresh price before it gets tagged.
Shows
Per-show P&L tying together sales, the table fee, travel and supply expenses, non-card income, and what you bought while you were there.
Showcase Planner
Lay out a physical display case as a grid of slots, measured in thousandths of an inch, and drag cards into place with their prices shown.
Grading Lifecycle
Track submissions and grading costs, then bring a slab home in one step: it rejoins Stock and records a comp at the grade it actually got.
Multi-Shop Teams
Owner, Manager and Staff roles. Invite teammates with single-use, expiring codes, and switch between shops if you belong to more than one.
The Money Math
This is the part a spreadsheet can't do without a lot of manual fiddling, and where per-card P&L either stays honest or quietly becomes fiction. Every figure is an integer number of cents, and every function that splits money returns parts that sum to exactly the input total.
A Deal Has Two Totals
The central idea behind the Deals screen: the value the other person perceives and the cost you actually carry are never the same number. Trading away a card you paid $60 for, after talking it up to $400, doesn't mean the card you got back cost you $400. It cost you $60 plus whatever cash changed hands. Booking it at the talked-up figure invents profit today and produces a baffling loss when it eventually sells.
Illustrative mockup of the Deals screen. The card coming in is booked at $110, not $450.
Splitting a Rip Honestly
When you open a box you don't buy a card, you buy a chance, so there's no per-card price anywhere to read. CardHerder splits the cost twice: evenly when the hits are captured at the table, then re-weighted by comp as each card is detailed. A flat split is right in total but wrong per card. It makes the throw-ins look like losses when they were really byproducts of the card that paid for the box.
| Hit | Comp | Even split | Re-weighted | Margin |
|---|---|---|---|---|
| Rookie Auto /25 | $300.00 | $25.00 | $90.91 | +$209.09 |
| Base Refractor | $10.00 | $25.00 | $3.03 | +$6.97 |
| Insert | $10.00 | $25.00 | $3.03 | +$6.97 |
| Veteran Parallel | $10.00 | $25.00 | $3.03 | +$6.97 |
| Total | $330.00 | $100.00 | $100.00 |
Under the even split, three of these cards would each show a $15 loss. They were never losses. The re-weighted shares still sum to exactly $100.00.
The Postgres functions that commit a sale, buy or trade take rows that are already computed.
Allocation, bundle discounts and trade cost basis are all done in TypeScript in
packages/core/src/domain, where they're unit-tested. Reimplementing the allocation in
PL/pgSQL would mean two copies of the arithmetic that decides what a card earned, and the one in the
database would be the untested one. Once a card sells, its cost basis is snapshotted onto the sale
line, so re-weighting a rip later never rewrites a closed sale's profit.
Shows & P&L
A show is the unit a vendor actually thinks in: you drive somewhere, pay for a table, and want to know on Sunday night whether it was worth it. Almost nobody works this out, because a spreadsheet won't tie sales, buys and the table fee together on its own.
Illustrative mockup of the Dashboard's live show panel.
Show profit answers "did this show earn money?" Net so far answers "how much more cash do I have than when I arrived?" Those differ whenever you've bought inventory at the show, and a vendor needs both: one to decide whether to book the show again, the other to count the cash drawer against.
Reports keep profit on cards that actually sold separate from comp-minus-cost on cards still in hand. The second is a guess that moves every time a player has a good week, and blending the two is how a vendor talks themselves into believing a bad year was a good one.
A buy the app didn't catch at the time can be logged against the show afterward, picked from inventory rather than retyped. The purchase is grouped as it happened: a $300 deal for four cards is one line, not four invented prices.
Built for the Table
The app is used for eight hours under convention-hall lighting, often one-handed on a phone while the other hand holds a card. Those constraints drove most of the UX decisions.
Paste a Listing, Get the Fields
Retyping a card name into eight boxes is the slowest part of adding a card, and nothing can fetch it automatically: the main pricing sites block automated requests and publish no API. A paste is the one channel that works, so CardHerder makes the paste count. A rules-based parser fills the review form instantly, and a Workers AI model refines it a second later. Every AI failure path (binding absent, model error, bad JSON, timeout) leaves the rules result standing, because a slightly-wrong guess you can edit beats a blocked screen.
The parse is shown as editable fields, not a verdict. It's easier to correct "Set" with the pasted text directly above it.
Capture Now, Detail Later
Grab a card, snap front and back, note what you paid, and keep talking. The camera sides alternate automatically. Naming and pricing wait in a queue for a quieter moment.
Undo Instead of "Are You Sure?"
Destructive taps hide the row instantly and start a short countdown. A confirmation dialog on every tap trains people to click through without reading; a few seconds to undo catches the same mistake for free.
Dark-First Design System
Plum grounds for harsh hall lighting, with the light theme following the OS. Color carries meaning: gold is money, green is the active show, teal is a card pulled from a pack.
44px Touch Targets
Throughout. Mobile tab labels are kept under seven characters so eight tabs fit at 375px without truncating mid-word.
Stays Signed In Mid-Show
The sign-in gate opens on "has signed in on this device," not on a currently valid token, so an hourly background token refresh never bounces you to a login screen mid-sale.
Lightweight Photos
Images are downscaled to about 250 KB in the browser before upload. The photo's row ID is its storage path, so the inventory list turns a cover photo into a signed URL without a second lookup.
Security & Multi-Tenancy
CardHerder is multi-tenant from the schema up. A person can belong to several shops, and nothing one shop does is ever visible to another.
Every business table carries an org_id, and its select, insert and update policies
all check it against the caller's memberships. Membership lookups run through a
SECURITY DEFINER helper to avoid the infinite-recursion trap of a policy that has to
query a table protected by that same policy.
Owner, Manager and Staff roles are enforced in database functions, not just hidden in the UI. Teammates join by redeeming a single-use, time-limited code that grants a specific role in a specific shop, so there's no coordinating who signs up first.
The PSA API token and the Workers AI binding live only in the Cloudflare Worker. A token shipped in client JavaScript is visible to anyone who opens dev tools; the Worker route lets the browser ask "what's on this cert?" without ever touching the real credential.
Card photos live in a private bucket governed by storage policies and are only reachable through
short-lived signed URLs. There is no DELETE policy on business data, so every removal is
a soft, recoverable state change.
Testing
The domain layer has 317 Vitest tests covering the math that decides what a card earned, plus the exact shape of every database request the repositories build.
Covered
Bundle and cart allocation, acquisition cost basis, trade valuation, rip splits and re-weighting, comp staleness, per-sale / per-show / per-period P&L, aging and underwater detection, card-name parsing, search normalization, showcase layout, grading scales, and the PSA cert lookup.
Honestly not covered yet
Behavior inside the Postgres functions (a sale marking its cards sold, a void putting them back) and proof that the RLS policies actually hold. Those need a real database, and running them against the local Supabase stack is the next step.
Roadmap
Pricing Integrations
Comps are stored with a date and a source today, so switching on an automated feed later backfills into the same price history.
QR Price Labels
Printable tags that open the card's record when scanned at the table.
Read-Through Cache
If a dead convention hall turns out to be a real problem, a cache that's honest about being a cache, rather than a second database.
Engineering Takeaways
Offline-first sounded essential for a convention hall. In practice the app needed the network to cold-start anyway, and the sync layer was the source of nearly every bug. Tearing it out and stating the new tradeoff plainly made the app both simpler and more reliable.
"Upload the photo, then write the row" eliminated an entire category of stuck and orphaned records. The same idea shows up when a graded card comes home: the comp is recorded last, after the card's grade changes, so it lands on the slab instead of the raw card.
Integer cents and a single tested implementation of every split mean the P&L reconciles against an actual cash drawer. A penny of float drift compounds across thousands of allocations.
Bad lighting, one free hand, a customer waiting, and a phone the OS keeps suspending shaped more of this app than any feature list did. Capture-now-detail-later, undo over confirmations, and a sign-in gate that survives token refreshes all came from watching how the tool is actually used.