MatchTracker
activeMobile-first PWA for tracking Lorcana TCG match history, deck stats, and local events.
A mobile-first PWA for Lorcana TCG. Logs match history, tracks deck stats and win rates, scrapes local events, and syncs automatically from duels.ink. Single user — Discord OAuth login, data in Supabase.
The v2 milestone wrapped in May 2026 across five phases: security hardening, duels.ink API key infrastructure, sync engine, design foundation, and visual redesign. Here’s what actually happened.
Phase 1 — Security and types
The app started as a rapid prototype. That meant a few shortcuts that needed to be closed before building anything on top.
The biggest one: Supabase’s getSession() trusts the client-supplied JWT without re-validating it server-side. Swapping every server-side call to getUser() is a one-line change per file but matters — unauthenticated session data was reaching RSC output. Along the same lines, route protection used an allowlist pattern (explicitly marking routes as protected), which meant every new route was unprotected by default. Flipped it to a denylist: the middleware guards everything, and you opt routes out if they should be public.
The CSRF fix for the sign-out endpoint was the most interesting one. Next.js App Router doesn’t give you request origin headers directly — behind Vercel’s proxy, you have to read x-forwarded-host and x-forwarded-proto to reconstruct the origin. Hardcoding the domain would have broken staging and preview URLs. The check runs before signOut() so unauthenticated cross-origin requests never touch the database.
TypeScript types were hand-written when the project started because the Supabase CLI wasn’t set up. Running supabase gen types against the live project replaced about 400 lines of manually maintained types with generated ones, and let the query files drop their SupabaseClient<any, any, any> workarounds.
One migration also landed here: the played_at column was date, which meant time-of-day analytics couldn’t work. Migrating to timestamptz unlocked a day-of-week and time-of-day breakdown in the analytics view.
Phase 2 — API key infrastructure
duels.ink requires a personal API key to pull match data. The key needed to live somewhere more secure than a user-editable column.
Supabase Vault stores it — vault.create_secret() on write, vault.decrypted_secrets view on read. The user-facing table (user_api_keys) only stores metadata: has_duels_key, last_sync_at, last_sync_status. The actual key value never appears in any client response or Supabase dashboard column.
The Route Handlers for POST /api/user/duels-key and DELETE /api/user/duels-key are straightforward. The more useful one was POST /api/user/duels-key/test — it retrieves the key from Vault, makes a request to duels.ink, and returns a success/error message. Lets the user confirm the key works without ever seeing it again.
Phase 3 — Sync engine
The sync pipeline runs in syncUserMatches(): retrieve key from Vault, fetch matches from duels.ink, resolve deck by ink color, insert new records, skip duplicates.
Deduplication required a migration: a UNIQUE constraint on (user_id, duels_game_id). The ADD CONSTRAINT IF NOT EXISTS syntax isn’t supported in the Supabase Postgres 17 deployment, so it went through a DO $$ ... IF NOT EXISTS ... END $$ block instead. Small gotcha.
Duplicate detection also had a subtle case: matches previously imported via CSV had duels_game_id set but played_at stored as YYYY-MM-DD (the old date type), while the API returns ISO timestamps. The dedup constraint handles this correctly now that played_at is timestamptz, but it required normalizing both sides during the import path.
The Route Handler at POST /api/sync/duels uses dual auth: it accepts either a valid user session (manual sync from the UI) or a SYNC_SECRET header (for the GitHub Actions cron). When SYNC_SECRET is unset on the server, the endpoint returns 503 instead of 401 — distinguishes “server not configured” from “wrong secret,” which matters for cron job debugging.
The cron runs hourly via GitHub Actions. Supabase free tier pauses projects after a week of inactivity, so there’s also a keepalive workflow that pings the project every three days.
Phase 4 — Design foundation
The app was using Tailwind’s class="dark" toggle for dark mode. The HoschDB design system uses data-theme="dark" as the attribute. These need to be the same mechanism — otherwise dark mode tokens don’t fire.
Migrating: one attribute swap in the ThemePicker, one selector change in tailwind.config.ts (darkMode: ['selector', '[data-theme="dark"]']), and making sure the data-theme attribute is written to <html> on first load from localStorage. After that, every [data-theme="dark"] CSS override works as expected.
The HoschDB token layer — --hdb-ink, --hdb-chalk, --hdb-moss, and about 20 others — was appended to globals.css outside any @layer block. That detail matters: unlayered CSS beats @layer base rules. If the tokens went inside @layer base, the * { @apply border-border } reset could override them.
Font replacement swapped Inter for DM Mono and DM Sans. Both served via next/font/local pointing to WOFF2 files in public/fonts/ — no Google Fonts CDN, no layout shift from external font loading.
Phase 5 — Visual redesign
42 source files migrated from hardcoded Tailwind colors (text-indigo-400, bg-zinc-950, text-green-400, text-red-400) to HoschDB CSS custom properties.
The foundation plan added five utility classes to globals.css — .hdb-label, .hdb-stat, .hdb-stat-hero, .hdb-data, .hdb-card — all placed outside any @layer so they win specificity over Tailwind’s base reset. .hdb-card sets a 0.5px ash border and 12px radius; its :hover rule shifts the border color toward fern green. Subsequent plans applied these classes component by component.
The chart components (MmrChart, RollingWinRateChart) needed a different approach. SVG presentation attributes don’t inherit CSS custom properties, and inline style={{ fill: 'var(--hdb-moss)' }} doesn’t work on SVG elements in all browsers. The solution: getComputedStyle(document.documentElement) in a useEffect to read the resolved CSS var values into React state, then pass those strings as SVG attribute values. A MutationObserver watching the data-theme attribute re-reads the values when the theme changes, so the chart colors stay current after ThemePicker mutates CSS vars in place.
The final audit used three grep commands — text-indigo|border-indigo|bg-indigo, text-green-4|text-red-4, and bg-zinc-9 — to confirm zero violations outside the known-exempt files (constants.ts for Lorcana brand colors, ResultBadge for game result display). Twenty-two stragglers (form validation errors, destructive button hover states, error boxes) were caught in the audit pass and migrated to var(--hdb-error) and var(--hdb-success).
One thing the grep missed: accent-indigo-500 on a checkbox. The accent-* utility doesn’t match any of the three patterns. Caught in the code review pass, fixed with accent-[color:var(--hdb-moss)].
What’s next
v3 scope is open. Some candidates: full Tailwind removal in favor of pure CSS custom properties, new stat views (time-of-day win rates, format breakdown), and a replay viewer for duels.ink replay files. Nothing committed yet.