A side-by-side potato dish ranking app running at totornot.com. Two dishes enter, one dish leaves. Vote for your favorite and watch the global Elo rankings shift — or dial in your personal texture and flavor preferences to find your perfect spud match.
- No accounts — just vote
- Elo ranking — zero-sum, globally consistent scores
- Spud Match — TOPSIS-based preference matching across 8 flavor/texture criteria
- Anti-spam — hashed IP rate limiting (lazy token bucket)
- Free to run — Cloudflare free tier (Workers + D1)
| Layer | Technology |
|---|---|
| Framework | SvelteKit 5 with Svelte runes |
| Styling | Tailwind CSS v4 |
| Runtime | Cloudflare Workers |
| Database | Cloudflare D1 (SQLite) via Drizzle ORM |
| Analytics | PostHog |
| Ranking | Elo rating system (K=32) |
| Matching | TOPSIS multi-criteria decision analysis |
| i18n | Paraglide JS |
| Components | Storybook |
- Node.js 22+ (see
.node-version) - pnpm —
npm install -g pnpm - Wrangler CLI —
pnpm add -g wrangler - A Cloudflare account (free) — dash.cloudflare.com
pnpm installcp .env.example .envFill in your Cloudflare credentials in .env (used by drizzle-kit for migrations):
CLOUDFLARE_ACCOUNT_ID="your_account_id"
CLOUDFLARE_DATABASE_ID="your_database_id"
CLOUDFLARE_D1_TOKEN="your_token"To get these: log into dash.cloudflare.com, create a D1 database, and generate an API token with D1 write permissions.
pnpm genThis generates types from your Wrangler config and compiles Paraglide message files.
pnpm dlx wrangler d1 migrations apply totornot --localThis sets up all tables and seeds the initial dish data. Migrations live in drizzle/migrations/ and are tracked in the d1_migrations table.
pnpm devOpen http://localhost:5173. Cloudflare bindings (D1, etc.) are emulated via getPlatformProxy. The local D1 state is stored in .wrangler/state/.
For testing the production build:
pnpm preview— this runs the full Cloudflare Workers runtime locally.
# Unit tests
pnpm test:unit
# E2E tests (requires local D1 to be set up)
pnpm test:e2e
# Storybook component tests
pnpm test:storybook
# All tests
pnpm testsrc/
lib/
criteria.ts # 8 TOPSIS criteria definitions (crispiness, richness, etc.)
elo.ts # Elo calculation engine
components/ # Shared Svelte components
server/
db/
schema.ts # Drizzle schema (dishes, votes, rate_limits, etc.)
index.ts # DB connection helper
rateLimiter.ts # Lazy token bucket rate limiter
topsis.ts # TOPSIS matching algorithm
csrf.ts # CSRF protection
posthog.ts # Server-side PostHog client
routes/
+page.svelte # Voting matchup page (home)
leaderboard/ # Elo leaderboard
match/ # "My Spud Match" — TOPSIS preference finder
about/ # About the project
tip/ # Dish suggestion tip line
api/
vote/ # POST /api/vote
dishes/random/ # GET /api/dishes/random
spud-match/ # POST /api/spud-match (TOPSIS)
criteria-votes/ # POST /api/criteria-votes
tip/ # POST /api/tip
(internal)/
rate-dishes/ # Internal criteria rating tool
drizzle/
migrations/ # Wrangler-managed SQL migrations
wrangler.jsonc # Cloudflare Workers config
Use the helper script to generate migration files:
# Create a new migration with one dish
bin/add-dish "Dish Name" "Description here"
# Append to the most recent migration instead of creating a new file
bin/add-dish "Another Dish" "Description" --append
# Apply the migration locally
pnpm dlx wrangler d1 migrations apply totornot --localFlags: --append / -a, --help / -h
- Edit
src/lib/server/db/schema.ts - Generate migration:
pnpm dlx wrangler d1 migrations create totornot "describe_the_change" - Write the SQL in the generated file under
drizzle/migrations/ - Apply locally:
pnpm dlx wrangler d1 migrations apply totornot --local - Test, then apply remotely after deploy
# Log in
wrangler login
# Create the D1 database
wrangler d1 create totornot
# Paste the returned database_id into wrangler.jsonc, then run migrations
wrangler d1 migrations apply totornot --remote
# Set the IP hashing secret (generate with: openssl rand -base64 32)
wrangler secret put IP_HASH_SECRETpnpm build && wrangler deploy| Variable | Where | Description |
|---|---|---|
IP_HASH_SECRET |
Wrangler secret | HMAC key for hashing IPs — rate limiting disabled if unset |
PUBLIC_POSTHOG_PROJECT_TOKEN |
wrangler.jsonc vars |
PostHog project token — analytics disabled if unset |
PUBLIC_POSTHOG_HOST |
wrangler.jsonc vars |
PostHog ingest host |
VOTE_RATE_LIMIT_PER_HOUR |
wrangler.jsonc vars |
Vote rate limit (default: 1000) |
TIP_RATE_LIMIT_PER_HOUR |
wrangler.jsonc vars |
Tip submission rate limit (default: 1000) |
Pull requests are welcome. For significant changes, open an issue first.
- Code style is enforced via Prettier and ESLint — run
pnpm lintbefore pushing - Commits follow Conventional Commits
- Releases are managed by Release Please