ALM Vote Manager
MVP voting app for basketball matches
ALM is my hometown basketball club in Évreux. Between quarters, spectators pull out their phones and vote for the MVP. The club was collecting votes by show of hands or paper ballots: slow, hard to count, and not engaging. This app replaced that.
Match night flow
The home page displays a QR code in landscape mode, designed to be shown on a screen at the venue. Spectators scan it, land on a registration form (name, email, phone, T&Cs), and get redirected to the voting page. The whole flow from scan to first vote takes under a minute.
The voting interface shows the roster (injured players filtered out) with a single tap to vote. Each spectator gets one vote per match, enforced by an HttpOnly cookie scoped to the match ID. Once the admin closes voting, the result screen shows the podium with the MVP highlighted.
Architecture
The app is a full-stack Next.js project using the App Router. The backend is a MySQL database accessed via Drizzle ORM. The schema is simple: players, matches, votes, and viewer (spectator registrations). Votes are immutable rows: each vote is an insert, and tallies are computed with COUNT GROUP BY player_id at read time. This keeps write latency low and avoids lock contention during peak voting.
Dual authentication. Admins authenticate via Lucia, a session-based auth library, with passwords hashed using Argon2 (memory-hard, GPU-resistant). Spectators get a stateless JWT signed with HS256, valid for 5 hours, long enough to cover a full match and the post-game ceremony. The two flows are completely isolated: a spectator token cannot reach any admin route.
Vote updates. The admin dashboard fetches vote counts on demand rather than maintaining a persistent connection. For an event where the admin manually closes the vote at the right moment, this is the right trade-off: no WebSocket infrastructure to manage, no reconnection logic, no scaling concerns. Just a button and an HTTP request.
Admin dashboard
Four sections: match management (create, edit, delete), player management (add players with photos, mark as injured), live voting view (podium display with manual refresh), and analytics (decrypted spectator registrations).
Opening a match for voting flips isOpen to true on the match record, which the spectator side checks before accepting a vote. Closing voting writes the MVP name and vote count directly onto the match row, a deliberate denormalization for fast historical reads.
Data protection
French clubs operate under RGPD. Spectator data (name, email, phone) is encrypted at rest with AES-256-CBC before being stored. The app is hosted on CleverCloud, a French PaaS provider, keeping all data within EU jurisdiction rather than routing it through American infrastructure.
Why we built it this way
Polling over WebSockets. A live sports event has one admin and a few hundred spectators voting over 5–10 minutes. There is no need for sub-second update propagation. A button that fetches fresh vote counts is simpler to build, simpler to debug, and has zero infrastructure overhead. WebSockets would have been over-engineering.
Lucia for admins, JWT for spectators. Admin sessions are stored server-side (Lucia + MySQL), which means they can be revoked immediately if needed. Spectator sessions don’t need revocation: a 5-hour JWT that expires naturally is sufficient. Using the same auth system for both would have added unnecessary complexity to the spectator flow, which needs to be frictionless.
CleverCloud over Vercel. Vercel routes data through US infrastructure by default. For a project collecting personal data from French citizens under RGPD, keeping everything in France wasn’t optional, it was the baseline.