← Return to Portfolio

CardHerder

Inventory, Sales & P&L for Sports and Trading Card Show Vendors

Type: Personal project — built for a working card-show vendor business
Designed & Developed by: Dan Whittaker
Stack: TypeScript · React · Vite · Supabase (Postgres) · Cloudflare Workers

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.

The Problem

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.

The expensive mistakes in this business are quiet ones: a rounding error in a bundle, a traded card booked at its talked-up value, a stale comp. None of them look wrong on the day. They show up months later as a loss nobody can explain.
The Solution

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.

TypeScript
End to end, in an npm-workspaces monorepo. Strict type-checking and ESLint run with the test suite as a single npm run check.
React 18 + Vite
The web app. Hand-built component set and design system, no UI framework. Content-hashed build output makes aggressive caching safe.
Supabase / Postgres
The database, auth, and a private object-storage bucket for card photos. Row-Level Security scopes every table to the shops you belong to.
PL/pgSQL Functions
The writes that must be all-or-nothing (checkout, void, trade, purchase) run as single database transactions.
Cloudflare Workers
Serves the static app and hosts the one server-side route: a proxy that keeps the PSA API token off the client.
Workers AI
Llama 3.1 8B refines pasted card listings into structured fields. It's optional; the app falls back to a rules parser if the model is unavailable.
PSA Cert API
Look up a graded slab by certification number, the only 100%-accurate way to identify a card.
Vitest
317 tests over the domain math, P&L, parsers, and the exact shape of every database request the repositories build.

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.

The monorepo layout. The UI stays thin; the logic that matters lives once, in core.

Two Decisions That Shape Everything

Card identity is separate from the copy you own

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.

Money is an append-only ledger

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.

DealActive show
Your cards
2023 Prizm Silver RCPaid $60.00 · comp $310.00
$400.00
You pay$50.00→
Their cards
2020 Optic Holo PSA 10Their ask
$450.00
Offer
$450.00
What they see: your card at the value you agreed on, plus cash.
Intake · cost you carry
$110.00
What it actually cost: your card at what you paid, plus the same cash.

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.

Rip · $100.00 box4 hits
HitCompEven splitRe-weightedMargin
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.

Where the math lives, and why

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.

Show · SaturdayLive
Sales$1,840.00
Cost of cards sold−$1,120.00
Gross profit$720.00
Table fee−$150.00
ExpensesGas, toploaders, lunch−$85.00
Other incomeSupplies sold+$40.00
Show profit$525.00
Cards you bought and still have−$300.00
Net so far$225.00

Illustrative mockup of the Dashboard's live show panel.

Profit vs. cash in hand

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.

Realized vs. unrealized

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.

Late-logged buys

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.

Add a cardAI refined
2025 Panini Select Jalen Hurts Color Wheel Gold /10 Philadelphia Eagles
Year
2025
Brand
Panini
Set
Select
Player
Jalen Hurts
Parallel
Color Wheel Gold
Print run
/10
Team
Philadelphia Eagles
Sport
Football

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.

Row-Level Security on every table

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.

Roles and invite codes

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.

No secrets in the bundle

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.

Private storage, signed URLs

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

Question the architecture once it meets the real world.

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.

Ordering can replace a state machine.

"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.

Keep money in integers and keep it in one place.

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.

Design for the physical context.

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.